# Sprint 15 — Character System v2 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Extend the Character node with AnimatedSprite2D (6 states), SnapReceiver (furniture attachment), HandLeft/HandRight slots (item holding), and three OutfitLayers (clothing overlay). **Architecture:** Four independent APIs are added to the existing `Character` class. `SnapPoint` and `SnapReceiver` are standalone nodes following the project's component pattern (`DragDropComponent`). All new nodes are added to `Character.tscn` so scene instantiation gives a complete character. ColorRect placeholder stays visible until real art replaces it. **Tech Stack:** Godot 4.6.2, GDScript (static typing), GUT v9.6.0 (TDD), headless runner. **GDD Reference:** `docs/game-design.md` — Kapitel 3 (Figurensystem) + Kapitel 5.1–5.2. **Headless runner:** ``` "F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1 ``` **Existing tests must stay green:** 42 tests passing before this sprint starts. --- ## File Map | Action | Path | Responsibility | |---|---|---| | Create | `scripts/objects/snap_point.gd` | Attachment node on furniture | | Create | `scripts/characters/snap_receiver.gd` | Snap detection on Character | | Modify | `scripts/characters/character_data.gd` | Add `outfit: Array[String]` field | | Modify | `scripts/characters/character.gd` | Anim state API, outfit API, hand slot API | | Modify | `scenes/characters/Character.tscn` | Add AnimatedSprite2D, OutfitLayers, HandSlots, SnapReceiver | | Create | `test/unit/test_snap_point.gd` | SnapPoint unit tests | | Create | `test/unit/test_character_v2.gd` | Character API tests | | Create | `test/unit/test_snap_receiver.gd` | SnapReceiver integration tests | --- ## Task 1: SnapPoint **Files:** - Create: `scripts/objects/snap_point.gd` - Create: `test/unit/test_snap_point.gd` - [ ] **Step 1: Write the failing tests** Create `test/unit/test_snap_point.gd`: ```gdscript ## Tests for SnapPoint — attachment node on furniture. extends GutTest const SnapPointScript: GDScript = preload("res://scripts/objects/snap_point.gd") var _snap: SnapPoint var _char: Character func before_each() -> void: _snap = SnapPointScript.new() _snap.pose = "sitting" add_child_autofree(_snap) _char = preload("res://scenes/characters/Character.tscn").instantiate() as Character add_child_autofree(_char) func test_is_free_when_no_occupant() -> void: assert_true(_snap.is_free()) func test_is_not_free_when_occupied() -> void: _snap.snap(_char) assert_false(_snap.is_free()) func test_snap_sets_occupant() -> void: _snap.snap(_char) assert_eq(_snap.occupant, _char) func test_unsnap_clears_occupant() -> void: _snap.snap(_char) _snap.unsnap() assert_null(_snap.occupant) func test_accepts_any_character_when_baby_only_false() -> void: assert_true(_snap.accepts(_char)) func test_does_not_accept_when_occupied() -> void: _snap.snap(_char) var other: Character = preload("res://scenes/characters/Character.tscn").instantiate() as Character add_child_autofree(other) assert_false(_snap.accepts(other)) func test_baby_only_rejects_healthy_character() -> void: _snap.baby_only = true var cd: CharacterData = CharacterData.new() cd.state = CharacterData.State.HEALTHY _char.data = cd assert_false(_snap.accepts(_char)) func test_baby_only_accepts_baby_state_character() -> void: _snap.baby_only = true var cd: CharacterData = CharacterData.new() cd.state = CharacterData.State.BABY _char.data = cd assert_true(_snap.accepts(_char)) func test_snap_emits_character_snapped() -> void: watch_signals(_snap) _snap.snap(_char) assert_signal_emitted(_snap, "character_snapped") func test_unsnap_emits_character_unsnapped() -> void: _snap.snap(_char) watch_signals(_snap) _snap.unsnap() assert_signal_emitted(_snap, "character_unsnapped") ``` - [ ] **Step 2: Run tests — verify they FAIL** ``` "F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1 ``` Expected: errors about `SnapPoint` class not found. Existing 42 tests still pass. - [ ] **Step 3: Implement `scripts/objects/snap_point.gd`** ```gdscript ## SnapPoint — attachment position on furniture where a Character can snap into a pose. ## Add to any furniture node. The node auto-registers in the "snap_points" group on _ready. class_name SnapPoint extends Node2D signal character_snapped(character: Character) signal character_unsnapped(character: Character) @export var pose: String = "sitting" @export var baby_only: bool = false var occupant: Character = null func _ready() -> void: add_to_group("snap_points") func is_free() -> bool: return occupant == null func accepts(character: Character) -> bool: if not is_free(): return false if baby_only: if character.data == null: return false return character.data.state == CharacterData.State.BABY return true func snap(character: Character) -> void: occupant = character character_snapped.emit(character) func unsnap() -> void: var prev: Character = occupant occupant = null if prev != null: character_unsnapped.emit(prev) ``` - [ ] **Step 4: Run tests — verify 10 new tests PASS** ``` "F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1 ``` Expected: 52/52 passed (42 existing + 10 SnapPoint). - [ ] **Step 5: Commit** ```bash git add scripts/objects/snap_point.gd test/unit/test_snap_point.gd git commit -m "feat(snap-point): add SnapPoint node with pose, baby_only filter, and occupant tracking" ``` --- ## Task 2: SnapReceiver stub **Files:** - Create: `scripts/characters/snap_receiver.gd` The SnapReceiver script must exist before `Character.tscn` can reference it. Full implementation comes in Task 7. - [ ] **Step 1: Create the stub** Create `scripts/characters/snap_receiver.gd`: ```gdscript ## SnapReceiver — scans for nearby SnapPoints when the parent Character is released. ## Attach as child of Character. Full implementation connects to DragDropComponent signals. class_name SnapReceiver extends Node const SCAN_RADIUS: float = 80.0 var _current_snap: SnapPoint = null var _character: Character func _ready() -> void: _character = get_parent() as Character func get_current_snap() -> SnapPoint: return _current_snap func force_unsnap() -> void: if _current_snap != null: _current_snap.unsnap() _current_snap = null ``` - [ ] **Step 2: Run tests — verify 52 still pass** ``` "F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1 ``` Expected: 52/52 passed. No new failures. - [ ] **Step 3: Commit** ```bash git add scripts/characters/snap_receiver.gd git commit -m "feat(snap-receiver): add SnapReceiver stub (full implementation in sprint-15 task 7)" ``` --- ## Task 3: Character.tscn — add new child nodes **Files:** - Modify: `scenes/characters/Character.tscn` - Create: `test/unit/test_character_v2.gd` (partial — node existence tests only for now) - [ ] **Step 1: Write node-existence tests** Create `test/unit/test_character_v2.gd` with only the node-existence tests for now: ```gdscript ## Tests for Character System v2 — animation state, outfit layers, hand slots. extends GutTest var _char: Character func before_each() -> void: _char = preload("res://scenes/characters/Character.tscn").instantiate() as Character add_child_autofree(_char) var cd: CharacterData = CharacterData.new() _char.data = cd func test_animated_sprite_node_exists() -> void: assert_not_null(_char.get_node_or_null("AnimatedSprite2D")) func test_outfit_layer_1_node_exists() -> void: assert_not_null(_char.get_node_or_null("OutfitLayer1")) func test_outfit_layer_2_node_exists() -> void: assert_not_null(_char.get_node_or_null("OutfitLayer2")) func test_outfit_layer_3_node_exists() -> void: assert_not_null(_char.get_node_or_null("OutfitLayer3")) func test_hand_left_node_exists() -> void: assert_not_null(_char.get_node_or_null("HandLeft")) func test_hand_right_node_exists() -> void: assert_not_null(_char.get_node_or_null("HandRight")) func test_snap_receiver_node_exists() -> void: assert_not_null(_char.get_node_or_null("SnapReceiver")) ``` - [ ] **Step 2: Run tests — verify they FAIL** ``` "F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1 ``` Expected: 7 new failures (nodes not in scene yet). 52 existing pass. - [ ] **Step 3: Update `scenes/characters/Character.tscn`** Read the file first (`F:/Development/_gameDev/Cozypaw-Hospital/scenes/characters/Character.tscn`), then make these changes: Change the header line: ``` [gd_scene load_steps=4 format=3 uid="uid://cozypaw_char"] ``` to: ``` [gd_scene load_steps=5 format=3 uid="uid://cozypaw_char"] ``` After the line `[ext_resource type="Script" path="res://scripts/systems/drag_drop_component.gd" id="2_drag"]`, add: ``` [ext_resource type="Script" path="res://scripts/characters/snap_receiver.gd" id="3_snap_recv"] ``` Append at the end of the file (after the last `[node ...]` block): ``` [node name="AnimatedSprite2D" type="AnimatedSprite2D" parent="."] visible = false [node name="OutfitLayer1" type="Sprite2D" parent="."] position = Vector2(0, -40) visible = false [node name="OutfitLayer2" type="Sprite2D" parent="."] position = Vector2(0, -40) visible = false [node name="OutfitLayer3" type="Sprite2D" parent="."] position = Vector2(0, -40) visible = false [node name="HandLeft" type="Node2D" parent="."] position = Vector2(-32, -30) [node name="HandRight" type="Node2D" parent="."] position = Vector2(32, -30) [node name="SnapReceiver" type="Node" parent="."] script = ExtResource("3_snap_recv") ``` - [ ] **Step 4: Run tests — verify 7 node-existence tests now PASS** ``` "F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1 ``` Expected: 59/59 passed (52 + 7 node-existence). - [ ] **Step 5: Commit** ```bash git add scenes/characters/Character.tscn test/unit/test_character_v2.gd git commit -m "feat(character): add AnimatedSprite2D, OutfitLayers, HandSlots, SnapReceiver to Character scene" ``` --- ## Task 4: CharacterData outfit field **Files:** - Modify: `scripts/characters/character_data.gd` - [ ] **Step 1: Add outfit test to `test/unit/test_character_v2.gd`** Append this function to the file (inside the class, before the final empty line): ```gdscript func test_character_data_outfit_has_three_empty_slots() -> void: assert_eq(_char.data.outfit.size(), 3) assert_eq(_char.data.outfit[0], "") assert_eq(_char.data.outfit[1], "") assert_eq(_char.data.outfit[2], "") ``` - [ ] **Step 2: Run test — verify it FAILS** ``` "F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1 ``` Expected: 1 failure (outfit field does not exist on CharacterData). - [ ] **Step 3: Update `scripts/characters/character_data.gd`** Replace the entire file with: ```gdscript ## CharacterData — Resource holding all persistent state for one character. class_name CharacterData extends Resource enum State { HEALTHY, SICK, SLEEPING, TIRED, PREGNANT, BABY } enum Species { BUNNY, KITTEN } @export var id: String = "" @export var display_name: String = "" @export var species: Species = Species.BUNNY @export var state: State = State.HEALTHY @export var current_floor: int = 0 @export var position: Vector2 = Vector2.ZERO @export var outfit: Array[String] = ["", "", ""] ``` - [ ] **Step 4: Run tests — verify new test PASSES, all 60 pass** ``` "F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1 ``` Expected: 60/60 passed. - [ ] **Step 5: Commit** ```bash git add scripts/characters/character_data.gd test/unit/test_character_v2.gd git commit -m "feat(character-data): add outfit array field for three outfit layer slots" ``` --- ## Task 5: Character animation state API **Files:** - Modify: `scripts/characters/character.gd` - Modify: `test/unit/test_character_v2.gd` - [ ] **Step 1: Add animation state tests to `test/unit/test_character_v2.gd`** Append these functions: ```gdscript func test_default_animation_state_is_idle() -> void: assert_eq(_char.get_animation_state(), "idle") func test_set_animation_state_sitting() -> void: _char.set_animation_state("sitting") assert_eq(_char.get_animation_state(), "sitting") func test_set_animation_state_lying() -> void: _char.set_animation_state("lying") assert_eq(_char.get_animation_state(), "lying") func test_set_animation_state_held() -> void: _char.set_animation_state("held") assert_eq(_char.get_animation_state(), "held") func test_set_animation_state_happy() -> void: _char.set_animation_state("happy") assert_eq(_char.get_animation_state(), "happy") func test_set_animation_state_sleeping() -> void: _char.set_animation_state("sleeping") assert_eq(_char.get_animation_state(), "sleeping") ``` - [ ] **Step 2: Run tests — verify 6 new tests FAIL** ``` "F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1 ``` Expected: 6 failures (methods don't exist yet). 60 existing pass. - [ ] **Step 3: Add animation state API to `scripts/characters/character.gd`** Add `var _current_anim: String = "idle"` after the existing `var _is_held: bool = false` line. Add these two methods after `set_state()`: ```gdscript func set_animation_state(anim: String) -> void: _current_anim = anim var sprite: AnimatedSprite2D = get_node_or_null("AnimatedSprite2D") as AnimatedSprite2D if sprite == null or sprite.sprite_frames == null: return if sprite.sprite_frames.has_animation(anim): sprite.play(anim) func get_animation_state() -> String: return _current_anim ``` Also update `_on_drag_picked_up` to set animation: ```gdscript func _on_drag_picked_up(_pos: Vector2) -> void: _is_held = true set_animation_state("held") character_picked_up.emit(self) ``` And update `_on_drag_released`: ```gdscript func _on_drag_released(pos: Vector2) -> void: _is_held = false set_animation_state("idle") if data == null or data.id.is_empty(): return GameState.set_character_position(character_id, global_position) character_placed.emit(self, global_position) ``` - [ ] **Step 4: Run tests — verify 6 new tests PASS, 66 total** ``` "F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1 ``` Expected: 66/66 passed. - [ ] **Step 5: Commit** ```bash git add scripts/characters/character.gd test/unit/test_character_v2.gd git commit -m "feat(character): add animation state API (set/get_animation_state)" ``` --- ## Task 6: Character outfit layer API **Files:** - Modify: `scripts/characters/character.gd` - Modify: `test/unit/test_character_v2.gd` - [ ] **Step 1: Add outfit layer tests to `test/unit/test_character_v2.gd`** Append these functions: ```gdscript func test_get_outfit_returns_empty_for_all_layers_initially() -> void: assert_eq(_char.get_outfit(1), "") assert_eq(_char.get_outfit(2), "") assert_eq(_char.get_outfit(3), "") func test_set_outfit_stores_item_id() -> void: _char.set_outfit(1, "white_coat", null) assert_eq(_char.get_outfit(1), "white_coat") func test_set_outfit_does_not_affect_other_layers() -> void: _char.set_outfit(1, "white_coat", null) assert_eq(_char.get_outfit(2), "") assert_eq(_char.get_outfit(3), "") func test_clear_outfit_returns_item_id() -> void: _char.set_outfit(2, "cast_arm", null) var returned: String = _char.clear_outfit(2) assert_eq(returned, "cast_arm") func test_clear_outfit_empties_layer() -> void: _char.set_outfit(2, "cast_arm", null) _char.clear_outfit(2) assert_eq(_char.get_outfit(2), "") func test_set_outfit_invalid_layer_zero_is_noop() -> void: _char.set_outfit(0, "white_coat", null) assert_eq(_char.get_outfit(1), "") func test_set_outfit_invalid_layer_four_is_noop() -> void: _char.set_outfit(4, "white_coat", null) assert_eq(_char.get_outfit(3), "") ``` - [ ] **Step 2: Run tests — verify 7 new tests FAIL** ``` "F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1 ``` Expected: 7 failures. 66 existing pass. - [ ] **Step 3: Add outfit API to `scripts/characters/character.gd`** Add these three methods after `get_animation_state()`: ```gdscript func set_outfit(layer: int, item_id: String, texture: Texture2D) -> void: if layer < 1 or layer > 3: return if data != null: data.outfit[layer - 1] = item_id var layer_node: Sprite2D = get_node_or_null("OutfitLayer%d" % layer) as Sprite2D if layer_node == null: return layer_node.texture = texture layer_node.visible = not item_id.is_empty() func clear_outfit(layer: int) -> String: if layer < 1 or layer > 3: return "" var old_id: String = "" if data != null: old_id = data.outfit[layer - 1] data.outfit[layer - 1] = "" var layer_node: Sprite2D = get_node_or_null("OutfitLayer%d" % layer) as Sprite2D if layer_node != null: layer_node.texture = null layer_node.visible = false return old_id func get_outfit(layer: int) -> String: if data == null or layer < 1 or layer > 3: return "" return data.outfit[layer - 1] ``` Also add `_refresh_outfit_layers()` call in `_ready()`, just after `_update_visual_state()`: ```gdscript func _ready() -> void: var drag: DragDropComponent = get_node_or_null("DragDropComponent") as DragDropComponent if drag != null: drag.drag_picked_up.connect(_on_drag_picked_up) drag.drag_released.connect(_on_drag_released) if data != null: _update_visual_state() _refresh_outfit_layers() ``` Add `_refresh_outfit_layers()` helper at the bottom of the file: ```gdscript func _refresh_outfit_layers() -> void: if data == null: return for i: int in range(3): var layer_node: Sprite2D = get_node_or_null("OutfitLayer%d" % (i + 1)) as Sprite2D if layer_node != null: layer_node.visible = not data.outfit[i].is_empty() ``` - [ ] **Step 4: Run tests — verify 7 new tests PASS, 73 total** ``` "F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1 ``` Expected: 73/73 passed. - [ ] **Step 5: Commit** ```bash git add scripts/characters/character.gd test/unit/test_character_v2.gd git commit -m "feat(character): add outfit layer API (set/clear/get_outfit per layer 1-3)" ``` --- ## Task 7: Character hand slot API **Files:** - Modify: `scripts/characters/character.gd` - Modify: `test/unit/test_character_v2.gd` - [ ] **Step 1: Add hand slot tests to `test/unit/test_character_v2.gd`** Append these functions: ```gdscript func test_both_hands_free_initially() -> void: assert_true(_char.is_hand_free("left")) assert_true(_char.is_hand_free("right")) func test_attach_item_to_left_hand() -> void: var item: Node2D = Node2D.new() add_child_autofree(item) _char.attach_item("left", item) assert_false(_char.is_hand_free("left")) func test_get_held_item_returns_attached_item() -> void: var item: Node2D = Node2D.new() add_child_autofree(item) _char.attach_item("right", item) assert_eq(_char.get_held_item("right"), item) func test_attach_to_occupied_hand_returns_false() -> void: var item1: Node2D = Node2D.new() var item2: Node2D = Node2D.new() add_child_autofree(item1) add_child_autofree(item2) _char.attach_item("left", item1) var result: bool = _char.attach_item("left", item2) assert_false(result) func test_attach_returns_true_on_success() -> void: var item: Node2D = Node2D.new() add_child_autofree(item) var result: bool = _char.attach_item("right", item) assert_true(result) func test_detach_item_frees_hand() -> void: var item: Node2D = Node2D.new() add_child_autofree(item) _char.attach_item("left", item) _char.detach_item("left") assert_true(_char.is_hand_free("left")) func test_detach_returns_item() -> void: var item: Node2D = Node2D.new() add_child_autofree(item) _char.attach_item("right", item) var returned: Node2D = _char.detach_item("right") assert_eq(returned, item) func test_detach_from_empty_hand_returns_null() -> void: var returned: Node2D = _char.detach_item("left") assert_null(returned) ``` - [ ] **Step 2: Run tests — verify 8 new tests FAIL** ``` "F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1 ``` Expected: 8 failures. 73 existing pass. - [ ] **Step 3: Add hand slot API to `scripts/characters/character.gd`** Add these four methods after `get_outfit()`: ```gdscript func attach_item(hand: String, item: Node2D) -> bool: if hand != "left" and hand != "right": return false var slot: Node2D = get_node_or_null("Hand" + hand.capitalize()) as Node2D if slot == null: return false if slot.get_child_count() > 0: return false var old_parent: Node = item.get_parent() if old_parent != null: old_parent.remove_child(item) slot.add_child(item) item.position = Vector2.ZERO return true func detach_item(hand: String) -> Node2D: if hand != "left" and hand != "right": return null var slot: Node2D = get_node_or_null("Hand" + hand.capitalize()) as Node2D if slot == null or slot.get_child_count() == 0: return null var item: Node2D = slot.get_child(0) as Node2D slot.remove_child(item) var scene_parent: Node = get_parent() if scene_parent != null: scene_parent.add_child(item) return item func get_held_item(hand: String) -> Node2D: if hand != "left" and hand != "right": return null var slot: Node2D = get_node_or_null("Hand" + hand.capitalize()) as Node2D if slot == null or slot.get_child_count() == 0: return null return slot.get_child(0) as Node2D func is_hand_free(hand: String) -> bool: return get_held_item(hand) == null ``` - [ ] **Step 4: Run tests — verify 8 new tests PASS, 81 total** ``` "F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1 ``` Expected: 81/81 passed. - [ ] **Step 5: Commit** ```bash git add scripts/characters/character.gd test/unit/test_character_v2.gd git commit -m "feat(character): add hand slot API (attach/detach/get_held_item/is_hand_free)" ``` --- ## Task 8: SnapReceiver full implementation + tests **Files:** - Modify: `scripts/characters/snap_receiver.gd` - Create: `test/unit/test_snap_receiver.gd` - [ ] **Step 1: Write the SnapReceiver tests** Create `test/unit/test_snap_receiver.gd`: ```gdscript ## Tests for SnapReceiver — snap detection when Character is released near a SnapPoint. extends GutTest var _char: Character var _snap: SnapPoint func before_each() -> void: _char = preload("res://scenes/characters/Character.tscn").instantiate() as Character add_child_autofree(_char) _snap = SnapPoint.new() _snap.pose = "sitting" add_child_autofree(_snap) func _get_receiver() -> SnapReceiver: return _char.get_node("SnapReceiver") as SnapReceiver func test_snap_receiver_exists_on_character() -> void: assert_not_null(_get_receiver()) func test_no_current_snap_initially() -> void: assert_null(_get_receiver().get_current_snap()) func test_snap_detected_when_character_released_within_radius() -> void: _snap.global_position = Vector2(0.0, 0.0) _char.global_position = Vector2(50.0, 0.0) _get_receiver()._on_drag_released(Vector2(50.0, 0.0)) assert_eq(_get_receiver().get_current_snap(), _snap) func test_no_snap_when_released_outside_radius() -> void: _snap.global_position = Vector2(0.0, 0.0) _char.global_position = Vector2(200.0, 0.0) _get_receiver()._on_drag_released(Vector2(200.0, 0.0)) assert_null(_get_receiver().get_current_snap()) func test_character_position_set_to_snap_point_on_snap() -> void: _snap.global_position = Vector2(100.0, 100.0) _char.global_position = Vector2(120.0, 100.0) _get_receiver()._on_drag_released(Vector2(120.0, 100.0)) assert_eq(_char.global_position, Vector2(100.0, 100.0)) func test_character_animation_set_to_snap_pose_on_snap() -> void: _snap.global_position = Vector2(0.0, 0.0) _snap.pose = "lying" _char.global_position = Vector2(50.0, 0.0) _get_receiver()._on_drag_released(Vector2(50.0, 0.0)) assert_eq(_char.get_animation_state(), "lying") func test_pickup_clears_current_snap() -> void: _snap.global_position = Vector2(0.0, 0.0) _char.global_position = Vector2(50.0, 0.0) _get_receiver()._on_drag_released(Vector2(50.0, 0.0)) _get_receiver()._on_drag_picked_up(Vector2(50.0, 0.0)) assert_null(_get_receiver().get_current_snap()) func test_pickup_frees_snap_point() -> void: _snap.global_position = Vector2(0.0, 0.0) _char.global_position = Vector2(50.0, 0.0) _get_receiver()._on_drag_released(Vector2(50.0, 0.0)) _get_receiver()._on_drag_picked_up(Vector2(50.0, 0.0)) assert_true(_snap.is_free()) func test_second_character_cannot_snap_to_occupied_point() -> void: _snap.global_position = Vector2(0.0, 0.0) _char.global_position = Vector2(50.0, 0.0) _get_receiver()._on_drag_released(Vector2(50.0, 0.0)) var char2: Character = preload("res://scenes/characters/Character.tscn").instantiate() as Character add_child_autofree(char2) char2.global_position = Vector2(60.0, 0.0) var recv2: SnapReceiver = char2.get_node("SnapReceiver") as SnapReceiver recv2._on_drag_released(Vector2(60.0, 0.0)) assert_null(recv2.get_current_snap()) ``` - [ ] **Step 2: Run tests — verify 8 new tests FAIL (most of them)** ``` "F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1 ``` Expected: several failures for snap detection tests. `test_snap_receiver_exists_on_character` may pass already. 81 existing tests pass. - [ ] **Step 3: Implement full `scripts/characters/snap_receiver.gd`** Replace the entire file: ```gdscript ## SnapReceiver — scans for nearby SnapPoints when the parent Character is released. ## Attach as child of Character. Connects automatically to DragDropComponent signals. class_name SnapReceiver extends Node const SCAN_RADIUS: float = 80.0 var _current_snap: SnapPoint = null var _character: Character func _ready() -> void: _character = get_parent() as Character var drag: DragDropComponent = _character.get_node_or_null("DragDropComponent") as DragDropComponent if drag != null: drag.drag_picked_up.connect(_on_drag_picked_up) drag.drag_released.connect(_on_drag_released) func _on_drag_picked_up(_pos: Vector2) -> void: if _current_snap != null: _current_snap.unsnap() _current_snap = null _character.set_animation_state("held") func _on_drag_released(_pos: Vector2) -> void: var nearest: SnapPoint = _find_nearest_accepting_snap() if nearest != null: _current_snap = nearest nearest.snap(_character) _character.global_position = nearest.global_position _character.set_animation_state(nearest.pose) else: _character.set_animation_state("idle") func _find_nearest_accepting_snap() -> SnapPoint: var best: SnapPoint = null var best_dist: float = SCAN_RADIUS for node: Node in get_tree().get_nodes_in_group("snap_points"): var snap_point: SnapPoint = node as SnapPoint if snap_point == null: continue if not snap_point.accepts(_character): continue var dist: float = _character.global_position.distance_to(snap_point.global_position) if dist < best_dist: best_dist = dist best = snap_point return best func get_current_snap() -> SnapPoint: return _current_snap func force_unsnap() -> void: if _current_snap != null: _current_snap.unsnap() _current_snap = null _character.set_animation_state("idle") ``` - [ ] **Step 4: Run all tests — verify 8 new tests PASS, 89 total** ``` "F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1 ``` Expected: 89/89 passed. 0 failures. - [ ] **Step 5: Commit** ```bash git add scripts/characters/snap_receiver.gd test/unit/test_snap_receiver.gd git commit -m "feat(snap-receiver): implement snap detection, position snapping, and pose animation trigger" ``` --- ## Final Check - [ ] **Run full test suite one last time** ``` "F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1 ``` Expected output: ``` Scripts 8 Tests 89 Passing Tests 89 Asserts (varies) ---- All tests passed! ---- ``` - [ ] **Verify git log shows 7 clean commits** ```bash git log --oneline -8 ``` Expected (most recent first): ``` feat(snap-receiver): implement snap detection, position snapping, and pose animation trigger feat(character): add hand slot API (attach/detach/get_held_item/is_hand_free) feat(character): add outfit layer API (set/clear/get_outfit per layer 1-3) feat(character): add animation state API (set/get_animation_state) feat(character-data): add outfit array field for three outfit layer slots feat(character): add AnimatedSprite2D, OutfitLayers, HandSlots, SnapReceiver to Character scene feat(snap-receiver): add SnapReceiver stub (full implementation in sprint-15 task 7) feat(snap-point): add SnapPoint node with pose, baby_only filter, and occupant tracking ```