52ebb78862
- 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>
1039 lines
29 KiB
Markdown
1039 lines
29 KiB
Markdown
# 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
|
||
```
|