Files
Cozypaw-Hospital/docs/superpowers/plans/2026-05-08-sprint-17-hand-slots-outfits.md
T

28 KiB

Sprint 17 — Hand-Slots + Outfits 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: Enable items to be held in Character hand slots and outfit items to be dragged onto characters to dress them, with tap-to-undress and save persistence.

Architecture: Three new concerns, each self-contained: (1) HoldableItem scans the "characters" group on release and attaches itself to the nearest free HandLeft/HandRight. (2) OutfitItem extends HoldableItem — on release near a character body it applies itself to an outfit layer and hides. (3) GameState extended to persist outfit + held-item state per character (save format v2). No new scene files required — all logic is GDScript.

Tech Stack: Godot 4.6.2, GDScript static typing, GUT v9.6.0, headless runner.

GDD Reference: docs/game-design.md — Kapitel 5.2 (Hand-Slot System) + 5.3 (Outfit Layer System) + Kapitel 8 (Save Format v2).

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: 115 tests passing before this sprint starts.


Context: what Sprint 15 already built

Character.tscn already has HandLeft (Node2D at (-32,-30)) and HandRight (Node2D at (32,-30)) as attachment points. character.gd already has attach_item(hand, item), detach_item(hand), get_held_item(hand), is_hand_free(hand), set_outfit(layer, id, texture), clear_outfit(layer), get_outfit(layer). CharacterData already has outfit: Array[String].

What is missing: (1) Characters are not registered in any group, so items cannot find them by scanning. (2) detach_item does not preserve global_position when reparenting — it will silently teleport items to (0,0). (3) No HoldableItem class. (4) No OutfitItem class. (5) GameState only saves character positions, not outfit or held items.


File Map

Action Path Purpose
Modify scripts/characters/character.gd group registration, detach fix, tap detection, apply/remove outfit
Create scripts/objects/holdable_item.gd base class: attach to hand slot on drag release
Create scripts/objects/outfit_item.gd extends HoldableItem: apply outfit on drop near character
Modify scripts/characters/character_data.gd add held_left, held_right String fields
Modify scripts/autoload/GameState.gd save/load outfit + held items per character (v2 format)
Modify test/unit/test_character_v2.gd add group, detach-position, tap, apply/remove outfit tests
Create test/unit/test_holdable_item.gd HoldableItem unit tests
Create test/unit/test_outfit_item.gd OutfitItem unit tests
Modify test/unit/test_game_state.gd extend with outfit + held-item save tests

Task 1: Character group + HoldableItem + detach fix

Files:

  • Modify: scripts/characters/character.gd
  • Create: scripts/objects/holdable_item.gd
  • Modify: test/unit/test_character_v2.gd
  • Create: test/unit/test_holdable_item.gd

Step 1: Write the failing tests

Add to test/unit/test_character_v2.gd (after the last existing test):

func test_character_is_in_characters_group() -> void:
	assert_true(_char.is_in_group("characters"))


func test_detach_item_preserves_global_position() -> void:
	var item: Node2D = Node2D.new()
	add_child_autofree(item)
	_char.global_position = Vector2(200.0, 300.0)
	_char.attach_item("left", item)
	# HandLeft is at offset (-32, -30) relative to character
	var expected_global: Vector2 = _char.get_node("HandLeft").global_position
	_char.detach_item("left")
	assert_eq(item.global_position, expected_global)

Create test/unit/test_holdable_item.gd:

## Tests for HoldableItem — hand slot attachment on drag release.
extends GutTest


func test_holdable_item_id_default_empty() -> void:
	var item: HoldableItem = HoldableItem.new()
	add_child_autofree(item)
	assert_eq(item.item_id, "")


func test_holdable_item_is_not_in_hand_initially() -> void:
	var item: HoldableItem = HoldableItem.new()
	add_child_autofree(item)
	assert_false(item.is_in_hand_slot())


func test_holdable_item_attaches_to_nearest_free_hand() -> void:
	var character: Character = preload("res://scenes/characters/Character.tscn").instantiate() as Character
	add_child_autofree(character)
	var item: HoldableItem = HoldableItem.new()
	add_child_autofree(item)
	item.item_id = "test_item"
	# Place item at HandLeft position
	item.global_position = character.get_node("HandLeft").global_position
	item._on_drag_released(item.global_position)
	assert_true(item.is_in_hand_slot())


func test_holdable_item_does_not_attach_if_no_character_in_range() -> void:
	var item: HoldableItem = HoldableItem.new()
	add_child_autofree(item)
	item.global_position = Vector2(9999.0, 9999.0)
	item._on_drag_released(item.global_position)
	assert_false(item.is_in_hand_slot())


func test_holdable_item_does_not_attach_to_occupied_hand() -> void:
	var character: Character = preload("res://scenes/characters/Character.tscn").instantiate() as Character
	add_child_autofree(character)
	var item1: HoldableItem = HoldableItem.new()
	var item2: HoldableItem = HoldableItem.new()
	add_child_autofree(item1)
	add_child_autofree(item2)
	# Fill left hand manually
	character.attach_item("left", item1)
	character.attach_item("right", item1)  # fill both
	# item2 tries to attach but both hands full
	item2.global_position = character.global_position
	item2._on_drag_released(item2.global_position)
	assert_false(item2.is_in_hand_slot())


func test_holdable_item_detaches_on_pickup_when_in_slot() -> void:
	var character: Character = preload("res://scenes/characters/Character.tscn").instantiate() as Character
	add_child_autofree(character)
	var item: HoldableItem = HoldableItem.new()
	add_child_autofree(item)
	character.attach_item("left", item)
	assert_true(item.is_in_hand_slot())
	item._on_drag_picked_up(item.global_position)
	assert_false(item.is_in_hand_slot())


func test_holdable_item_detach_preserves_global_position() -> void:
	var character: Character = preload("res://scenes/characters/Character.tscn").instantiate() as Character
	add_child_autofree(character)
	var item: HoldableItem = HoldableItem.new()
	add_child_autofree(item)
	character.attach_item("left", item)
	var hand_pos: Vector2 = character.get_node("HandLeft").global_position
	item._on_drag_picked_up(hand_pos)
	assert_eq(item.global_position, hand_pos)

Step 2: Run import + tests — verify new tests FAIL

"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless --import 2>&1
"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: 9 new failures. Existing 115 pass.

Step 3: Modify scripts/characters/character.gd

Change 1: Add group registration in _ready() (after the drag connection block):

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_to_group("characters")

Change 2: Fix detach_item to preserve global_position:

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
	var saved_pos: Vector2 = item.global_position
	slot.remove_child(item)
	var scene_parent: Node = get_parent()
	if scene_parent != null:
		scene_parent.add_child(item)
	item.global_position = saved_pos
	return item

Step 4: Create scripts/objects/holdable_item.gd

## HoldableItem — Node2D that can be held in a Character's HandLeft or HandRight slot.
## Attach DragDropComponent as a child. On drag_released scans "characters" group for
## the nearest free hand slot within HAND_SLOT_RADIUS.
class_name HoldableItem extends Node2D

signal item_picked_up(item: HoldableItem)
signal item_placed(item: HoldableItem)

const HAND_SLOT_RADIUS: float = 60.0


@export var item_id: String = ""


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)


func _on_drag_picked_up(_pos: Vector2) -> void:
	if is_in_hand_slot():
		_detach_from_hand_slot()
	item_picked_up.emit(self)


func _on_drag_released(_pos: Vector2) -> void:
	var result: Array = _find_nearest_free_hand_slot()
	if not result.is_empty():
		var character: Character = result[0] as Character
		var hand: String = result[1] as String
		character.attach_item(hand, self)
	item_placed.emit(self)


func is_in_hand_slot() -> bool:
	var p: Node = get_parent()
	if p == null:
		return false
	return p.name == "HandLeft" or p.name == "HandRight"


func _detach_from_hand_slot() -> void:
	var hand_slot: Node = get_parent()
	var character: Character = hand_slot.get_parent() as Character
	if character == null:
		return
	var hand: String = "left" if hand_slot.name == "HandLeft" else "right"
	character.detach_item(hand)


func _find_nearest_free_hand_slot() -> Array:
	var best_dist: float = HAND_SLOT_RADIUS
	var best_character: Character = null
	var best_hand: String = ""
	for node: Node in get_tree().get_nodes_in_group("characters"):
		var character: Character = node as Character
		if character == null:
			continue
		for hand: String in ["left", "right"]:
			if not character.is_hand_free(hand):
				continue
			var slot: Node2D = character.get_node_or_null("Hand" + hand.capitalize()) as Node2D
			if slot == null:
				continue
			var dist: float = global_position.distance_to(slot.global_position)
			if dist < best_dist:
				best_dist = dist
				best_character = character
				best_hand = hand
	if best_character == null:
		return []
	return [best_character, best_hand]

Step 5: Run import + tests — verify all pass (124 total)

"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless --import 2>&1
"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: 124/124 passed.

Step 6: Commit

cd "F:/Development/_gameDev/Cozypaw-Hospital/.worktrees/sprint-17-hand-slots-outfits"
git add scripts/characters/character.gd
git add scripts/objects/holdable_item.gd
git add test/unit/test_character_v2.gd
git add test/unit/test_holdable_item.gd
git commit -m "feat(items): add HoldableItem with hand slot detection, fix detach_item position"

Task 2: OutfitItem + apply-on-drop + tap-to-undress

Files:

  • Modify: scripts/characters/character.gd
  • Create: scripts/objects/outfit_item.gd
  • Modify: test/unit/test_character_v2.gd
  • Create: test/unit/test_outfit_item.gd

Step 1: Write the failing tests

Add to test/unit/test_character_v2.gd:

func test_apply_outfit_item_hides_item() -> void:
	var item: Node2D = Node2D.new()
	add_child_autofree(item)
	_char.apply_outfit_item(1, "white_coat", null, item)
	assert_false(item.visible)


func test_apply_outfit_item_sets_outfit_data() -> void:
	var item: Node2D = Node2D.new()
	add_child_autofree(item)
	_char.apply_outfit_item(1, "white_coat", null, item)
	assert_eq(_char.get_outfit(1), "white_coat")


func test_remove_outfit_restores_item_visibility() -> void:
	var item: Node2D = Node2D.new()
	add_child_autofree(item)
	_char.apply_outfit_item(2, "cast_arm", null, item)
	_char.remove_outfit(2)
	assert_true(item.visible)


func test_remove_outfit_clears_outfit_data() -> void:
	var item: Node2D = Node2D.new()
	add_child_autofree(item)
	_char.apply_outfit_item(2, "cast_arm", null, item)
	_char.remove_outfit(2)
	assert_eq(_char.get_outfit(2), "")


func test_apply_outfit_item_replaces_existing() -> void:
	var item1: Node2D = Node2D.new()
	var item2: Node2D = Node2D.new()
	add_child_autofree(item1)
	add_child_autofree(item2)
	_char.apply_outfit_item(1, "white_coat", null, item1)
	_char.apply_outfit_item(1, "doctor_coat", null, item2)
	assert_true(item1.visible)
	assert_false(item2.visible)
	assert_eq(_char.get_outfit(1), "doctor_coat")


func test_tap_removes_topmost_active_outfit_layer() -> void:
	var item1: Node2D = Node2D.new()
	var item3: Node2D = Node2D.new()
	add_child_autofree(item1)
	add_child_autofree(item3)
	_char.apply_outfit_item(1, "white_coat", null, item1)
	_char.apply_outfit_item(3, "stethoscope", null, item3)
	_char._handle_outfit_tap()
	# layer 3 is topmost active — it should be removed first
	assert_eq(_char.get_outfit(3), "")
	assert_eq(_char.get_outfit(1), "white_coat")


func test_tap_noop_when_no_outfit_active() -> void:
	_char._handle_outfit_tap()
	assert_eq(_char.get_outfit(1), "")
	assert_eq(_char.get_outfit(2), "")
	assert_eq(_char.get_outfit(3), "")

Create test/unit/test_outfit_item.gd:

## Tests for OutfitItem — applies outfit layer when dropped near a character.
extends GutTest


func test_outfit_item_default_layer_is_one() -> void:
	var item: OutfitItem = OutfitItem.new()
	add_child_autofree(item)
	assert_eq(item.outfit_layer, 1)


func test_outfit_item_applies_to_character_on_release_in_range() -> void:
	var character: Character = preload("res://scenes/characters/Character.tscn").instantiate() as Character
	add_child_autofree(character)
	var data: CharacterData = CharacterData.new()
	character.data = data
	var item: OutfitItem = OutfitItem.new()
	add_child_autofree(item)
	item.item_id = "white_coat"
	item.outfit_layer = 1
	item.outfit_sprite = null
	# Place item near character
	item.global_position = character.global_position
	item._on_drag_released(item.global_position)
	assert_eq(character.get_outfit(1), "white_coat")


func test_outfit_item_hides_after_applying() -> void:
	var character: Character = preload("res://scenes/characters/Character.tscn").instantiate() as Character
	add_child_autofree(character)
	var data: CharacterData = CharacterData.new()
	character.data = data
	var item: OutfitItem = OutfitItem.new()
	add_child_autofree(item)
	item.item_id = "white_coat"
	item.outfit_layer = 1
	item.global_position = character.global_position
	item._on_drag_released(item.global_position)
	assert_false(item.visible)


func test_outfit_item_falls_back_to_hand_slot_if_no_character_in_range() -> void:
	var character: Character = preload("res://scenes/characters/Character.tscn").instantiate() as Character
	add_child_autofree(character)
	var item: OutfitItem = OutfitItem.new()
	add_child_autofree(item)
	item.item_id = "white_coat"
	item.outfit_layer = 1
	# Place item near hand slot but NOT near character body
	item.global_position = character.get_node("HandLeft").global_position
	item._on_drag_released(item.global_position)
	# Should attach to hand slot, not apply as outfit
	assert_true(item.is_in_hand_slot())
	assert_eq(character.get_outfit(1), "")


func test_outfit_item_does_not_apply_if_far_from_character() -> void:
	var character: Character = preload("res://scenes/characters/Character.tscn").instantiate() as Character
	add_child_autofree(character)
	var data: CharacterData = CharacterData.new()
	character.data = data
	var item: OutfitItem = OutfitItem.new()
	add_child_autofree(item)
	item.item_id = "white_coat"
	item.outfit_layer = 1
	item.global_position = Vector2(9999.0, 9999.0)
	item._on_drag_released(item.global_position)
	assert_eq(character.get_outfit(1), "")
	assert_true(item.visible)

Step 2: Run tests — verify new tests FAIL

Expected: 12 new failures. Existing 124 pass.

Step 3: Modify scripts/characters/character.gd

Add these new members and methods. Insert the var declarations after the existing var _current_anim: String = "idle" line:

var _current_anim: String = "idle"
var _drag_start_position: Vector2 = Vector2.ZERO
var _outfit_item_refs: Array = [null, null, null]

const _TAP_THRESHOLD: float = 10.0

Replace _on_drag_picked_up:

func _on_drag_picked_up(pos: Vector2) -> void:
	_is_held = true
	_drag_start_position = pos
	set_animation_state("held")
	character_picked_up.emit(self)

Replace _on_drag_released:

func _on_drag_released(pos: Vector2) -> void:
	_is_held = false
	var drag_distance: float = pos.distance_to(_drag_start_position)
	if drag_distance < _TAP_THRESHOLD:
		_handle_outfit_tap()
		return
	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)

Add new public methods (before _update_visual_state):

func apply_outfit_item(layer: int, item_id: String, texture: Texture2D, item_node: Node2D) -> void:
	if layer < 1 or layer > 3:
		return
	var i: int = layer - 1
	var existing: Node2D = _outfit_item_refs[i] as Node2D
	if existing != null:
		existing.global_position = global_position + Vector2(0.0, 60.0)
		existing.visible = true
	_outfit_item_refs[i] = item_node
	set_outfit(layer, item_id, texture)
	if item_node != null:
		item_node.visible = false


func remove_outfit(layer: int) -> void:
	if layer < 1 or layer > 3:
		return
	var i: int = layer - 1
	clear_outfit(layer)
	var item_ref: Node2D = _outfit_item_refs[i] as Node2D
	if item_ref != null:
		_outfit_item_refs[i] = null
		item_ref.global_position = global_position + Vector2(0.0, 60.0)
		item_ref.visible = true


func _handle_outfit_tap() -> void:
	for layer: int in range(3, 0, -1):
		if not get_outfit(layer).is_empty():
			remove_outfit(layer)
			return

Step 4: Create scripts/objects/outfit_item.gd

## OutfitItem — HoldableItem that applies an outfit layer to a Character when dropped
## within OUTFIT_APPLY_RADIUS of the character's center. If no character is in range,
## falls back to normal hand-slot attachment.
class_name OutfitItem extends HoldableItem

const OUTFIT_APPLY_RADIUS: float = 80.0

@export var outfit_layer: int = 1
@export var outfit_sprite: Texture2D


func _on_drag_released(_pos: Vector2) -> void:
	var character: Character = _find_nearest_character()
	if character != null:
		character.apply_outfit_item(outfit_layer, item_id, outfit_sprite, self)
		return
	super._on_drag_released(_pos)


func _find_nearest_character() -> Character:
	var best_dist: float = OUTFIT_APPLY_RADIUS
	var best: Character = null
	for node: Node in get_tree().get_nodes_in_group("characters"):
		var character: Character = node as Character
		if character == null:
			continue
		var dist: float = global_position.distance_to(character.global_position)
		if dist < best_dist:
			best_dist = dist
			best = character
	return best

Step 5: Run import + tests — verify all pass (136 total)

"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless --import 2>&1
"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: 136/136 passed.

Step 6: Commit

git add scripts/characters/character.gd
git add scripts/objects/outfit_item.gd
git add test/unit/test_character_v2.gd
git add test/unit/test_outfit_item.gd
git commit -m "feat(items): add OutfitItem with apply-on-drop and tap-to-undress on Character"

Task 3: GameState v2 — outfit + held items persisted

Files:

  • Modify: scripts/characters/character_data.gd
  • Modify: scripts/autoload/GameState.gd
  • Modify: test/unit/test_game_state.gd

Step 1: Write the failing tests

Check if test/unit/test_game_state.gd exists. If not, create it. If it exists, append these tests:

## Tests for GameState — character outfit and held-item persistence.
extends GutTest


func before_each() -> void:
	GameState.apply_save_data({})


func test_character_data_has_held_left_field() -> void:
	var cd: CharacterData = CharacterData.new()
	assert_eq(cd.held_left, "")


func test_character_data_has_held_right_field() -> void:
	var cd: CharacterData = CharacterData.new()
	assert_eq(cd.held_right, "")


func test_set_character_outfit_stores_value() -> void:
	GameState.set_character_outfit("bunny_f", ["white_coat", "", "stethoscope"])
	assert_eq(GameState.get_character_outfit("bunny_f"), ["white_coat", "", "stethoscope"])


func test_get_character_outfit_returns_empty_array_for_unknown() -> void:
	var result: Array = GameState.get_character_outfit("unknown_id")
	assert_eq(result, ["", "", ""])


func test_set_character_held_item_left() -> void:
	GameState.set_character_held_item("bunny_f", "left", "medicine_blue")
	assert_eq(GameState.get_character_held_item("bunny_f", "left"), "medicine_blue")


func test_get_character_held_item_returns_empty_for_unknown() -> void:
	assert_eq(GameState.get_character_held_item("unknown", "left"), "")


func test_save_data_includes_outfit() -> void:
	GameState.set_character_outfit("bunny_f", ["white_coat", "", ""])
	var data: Dictionary = GameState.get_save_data()
	assert_true(data.has("character_outfits"))
	assert_eq(data["character_outfits"]["bunny_f"], ["white_coat", "", ""])


func test_save_data_includes_held_items() -> void:
	GameState.set_character_held_item("bunny_f", "right", "medicine_blue")
	var data: Dictionary = GameState.get_save_data()
	assert_true(data.has("character_held_items"))
	assert_eq(data["character_held_items"]["bunny_f"]["right"], "medicine_blue")


func test_apply_save_data_restores_outfit() -> void:
	var save: Dictionary = {
		"character_outfits": {
			"bunny_f": ["doctor_coat", "", "stethoscope"]
		}
	}
	GameState.apply_save_data(save)
	assert_eq(GameState.get_character_outfit("bunny_f"), ["doctor_coat", "", "stethoscope"])


func test_apply_save_data_restores_held_items() -> void:
	var save: Dictionary = {
		"character_held_items": {
			"kitten_f": {"left": "gel_tube", "right": ""}
		}
	}
	GameState.apply_save_data(save)
	assert_eq(GameState.get_character_held_item("kitten_f", "left"), "gel_tube")

Step 2: Run tests — verify new tests FAIL

Expected: 11 new failures. Existing 136 pass.

Step 3: Modify scripts/characters/character_data.gd

Add two new fields after outfit:

## 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] = ["", "", ""]
@export var held_left: String = ""
@export var held_right: String = ""

Step 4: Modify scripts/autoload/GameState.gd

Full replacement of the file to add outfit + held-item tracking:

## GameState — global game state: character positions, outfit, held items, object states, current room.
extends Node

signal state_changed
signal character_moved(character_id: String, position: Vector2)

var _character_positions: Dictionary = {}
var _character_outfits: Dictionary = {}
var _character_held_items: Dictionary = {}
var _object_states: Dictionary = {}
var current_room: String = "reception"
var music_volume: float = 0.6
var sfx_volume: float = 1.0


func has_character_position(id: String) -> bool:
	return _character_positions.has(id)


func get_character_position(id: String) -> Vector2:
	return _character_positions.get(id, Vector2.ZERO)


func set_character_position(id: String, pos: Vector2) -> void:
	_character_positions[id] = pos
	character_moved.emit(id, pos)
	state_changed.emit()


func get_character_outfit(id: String) -> Array:
	return _character_outfits.get(id, ["", "", ""])


func set_character_outfit(id: String, outfit: Array) -> void:
	_character_outfits[id] = outfit
	state_changed.emit()


func get_character_held_item(id: String, hand: String) -> String:
	if not _character_held_items.has(id):
		return ""
	return _character_held_items[id].get(hand, "")


func set_character_held_item(id: String, hand: String, item_id: String) -> void:
	if not _character_held_items.has(id):
		_character_held_items[id] = {"left": "", "right": ""}
	_character_held_items[id][hand] = item_id
	state_changed.emit()


func get_object_state(id: String) -> String:
	return _object_states.get(id, "idle")


func set_object_state(id: String, state: String) -> void:
	_object_states[id] = state
	state_changed.emit()


func get_save_data() -> Dictionary:
	var positions: Dictionary = {}
	for key: String in _character_positions:
		var pos: Vector2 = _character_positions[key]
		positions[key] = [pos.x, pos.y]
	return {
		"version": 2,
		"character_positions": positions,
		"character_outfits": _character_outfits.duplicate(true),
		"character_held_items": _character_held_items.duplicate(true),
		"object_states": _object_states,
		"current_room": current_room,
		"music_volume": music_volume,
		"sfx_volume": sfx_volume,
	}


func apply_save_data(data: Dictionary) -> void:
	if data.has("character_positions"):
		_character_positions = {}
		for key: String in data["character_positions"]:
			var val: Variant = data["character_positions"][key]
			if val is Array and val.size() >= 2:
				_character_positions[key] = Vector2(val[0], val[1])
	if data.has("character_outfits"):
		_character_outfits = data["character_outfits"].duplicate(true)
	else:
		_character_outfits = {}
	if data.has("character_held_items"):
		_character_held_items = data["character_held_items"].duplicate(true)
	else:
		_character_held_items = {}
	if data.has("object_states"):
		_object_states = data["object_states"]
	if data.has("current_room"):
		current_room = data["current_room"]
	if data.has("music_volume"):
		music_volume = data["music_volume"]
	if data.has("sfx_volume"):
		sfx_volume = data["sfx_volume"]

Step 5: Run import + tests — verify all pass (147 total)

"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless --import 2>&1
"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: 147/147 passed.

Step 6: Commit

git add scripts/characters/character_data.gd
git add scripts/autoload/GameState.gd
git add test/unit/test_game_state.gd
git commit -m "feat(save): extend GameState to v2 — outfit and held items persisted per character"

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              13
Tests               147
Passing Tests       147
---- All tests passed! ----
  • Verify git log shows 3 clean commits
git log --oneline -4

Expected:

feat(save): extend GameState to v2 — outfit and held items persisted per character
feat(items): add OutfitItem with apply-on-drop and tap-to-undress on Character
feat(items): add HoldableItem with hand slot detection, fix detach_item position

Notes for implementer

  • _outfit_item_refs typed as Array not Array[Node2D] — GDScript typed arrays with null defaults can cause issues in some Godot versions. Use untyped Array with explicit as Node2D casts.
  • test_outfit_item_falls_back_to_hand_slot_if_no_character_in_range — This test places the item near a HandLeft but NOT near the character body. The OUTFIT_APPLY_RADIUS (80px) check on character.global_position will fail (HandLeft is only 32px offset), but since the HandLeft is within HAND_SLOT_RADIUS (60px) of HandLeft position, the HoldableItem fallback will attach it to the hand. Verify position arithmetic in your test setup.
  • before_each in test_game_state.gd calls GameState.apply_save_data({}) — this resets state between tests. The autoload singleton persists between GUT test functions, so the reset is mandatory.
  • _handle_outfit_tap is public (no underscore would be cleaner, but the existing convention uses underscore for private methods). Keep the underscore — tests call it directly. This is acceptable for unit testing.
  • _TAP_THRESHOLD needs to be a constant not a const with underscore (underscore prefix = private, which is fine here since it's internal to character.gd).