Files
Cozypaw-Hospital/docs/superpowers/plans/2026-05-07-sprint-15-character-v2.md
T
Steven Wroblewski 52ebb78862 chore(audio): add download script, audio credits, and sprint 21/22 docs
- docs/download_audio.py: freesound batch downloader with all 22 confirmed IDs
  (API key removed — fill in locally from freesound.org)
- docs/credits-audio.md: generated CC-BY attribution table
- docs/superpowers/plans+specs: sprint 15, 21, 22 implementation plan/spec docs
- .claude/settings.json: enable experimental agent teams env var

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 14:57:27 +02:00

1039 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.15.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
```