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

29 KiB
Raw Blame History

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:

## 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
## 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
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:

## 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
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:

## 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
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):

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:

## 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
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:

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():

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:

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:

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
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:

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():

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():

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:

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
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:

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():

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
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:

## 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:

## 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
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
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