diff --git a/scripts/autoload/GameState.gd b/scripts/autoload/GameState.gd index 00eb00b..8f7d4eb 100644 --- a/scripts/autoload/GameState.gd +++ b/scripts/autoload/GameState.gd @@ -1,10 +1,12 @@ -## GameState — global game state: character positions, object states, current room. +## 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 @@ -25,6 +27,28 @@ func set_character_position(id: String, pos: Vector2) -> void: 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") @@ -40,7 +64,10 @@ func get_save_data() -> Dictionary: 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, @@ -55,6 +82,14 @@ func apply_save_data(data: Dictionary) -> void: 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"): diff --git a/scripts/characters/character.gd b/scripts/characters/character.gd index 30cf39e..de94f06 100644 --- a/scripts/characters/character.gd +++ b/scripts/characters/character.gd @@ -13,7 +13,11 @@ signal state_changed(new_state: CharacterData.State) var _is_held: bool = false 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 +const _ITEM_DROP_OFFSET: Vector2 = Vector2(0.0, 60.0) const _STATE_COLORS: Dictionary = { CharacterData.State.HEALTHY: Color(0.6, 0.8, 1.0), CharacterData.State.SICK: Color(0.7, 0.9, 0.7), @@ -32,6 +36,7 @@ func _ready() -> void: if data != null: _update_visual_state() _refresh_outfit_layers() + add_to_group("characters") func set_state(new_state: CharacterData.State) -> void: @@ -110,10 +115,12 @@ func detach_item(hand: String) -> 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 @@ -146,14 +153,53 @@ func _update_visual_state() -> void: ear_right.color = color -func _on_drag_picked_up(_pos: Vector2) -> void: +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 + _ITEM_DROP_OFFSET + 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 + _ITEM_DROP_OFFSET + 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 + + +func _on_drag_picked_up(pos: Vector2) -> void: _is_held = true + _drag_start_position = pos set_animation_state("held") character_picked_up.emit(self) 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: + set_animation_state("idle") + _handle_outfit_tap() + return set_animation_state("idle") if data == null or data.id.is_empty(): return diff --git a/scripts/characters/character_data.gd b/scripts/characters/character_data.gd index ac9f820..a01dbe2 100644 --- a/scripts/characters/character_data.gd +++ b/scripts/characters/character_data.gd @@ -11,3 +11,5 @@ enum Species { BUNNY, KITTEN } @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 = "" diff --git a/scripts/objects/holdable_item.gd b/scripts/objects/holdable_item.gd new file mode 100644 index 0000000..59d7bf6 --- /dev/null +++ b/scripts/objects/holdable_item.gd @@ -0,0 +1,73 @@ +## 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] diff --git a/scripts/objects/outfit_item.gd b/scripts/objects/outfit_item.gd new file mode 100644 index 0000000..d993c7d --- /dev/null +++ b/scripts/objects/outfit_item.gd @@ -0,0 +1,31 @@ +## OutfitItem — HoldableItem that applies an outfit layer to a Character when dropped +## within OUTFIT_APPLY_RADIUS of the character's center. Falls back to hand slot +## attachment if no character body is in range. +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 diff --git a/test/unit/test_character_v2.gd b/test/unit/test_character_v2.gd index af043df..10aa1cc 100644 --- a/test/unit/test_character_v2.gd +++ b/test/unit/test_character_v2.gd @@ -169,3 +169,78 @@ func test_detach_returns_item() -> void: func test_detach_from_empty_hand_returns_null() -> void: var returned: Node2D = _char.detach_item("left") assert_null(returned) + + +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) + var expected_global: Vector2 = (_char.get_node_or_null("HandLeft") as Node2D).global_position + _char.detach_item("left") + assert_eq(item.global_position, expected_global) + + +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() + 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), "") diff --git a/test/unit/test_game_state.gd b/test/unit/test_game_state.gd index a89616d..a8a8f9a 100644 --- a/test/unit/test_game_state.gd +++ b/test/unit/test_game_state.gd @@ -77,3 +77,71 @@ func test_apply_save_data_with_empty_dict_does_not_crash() -> void: _state.set_character_position("bunny_01", Vector2(10.0, 20.0)) _state.apply_save_data({}) assert_eq(_state.get_character_position("bunny_01"), Vector2(10.0, 20.0)) + + +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: + _state.set_character_outfit("bunny_f", ["white_coat", "", "stethoscope"]) + assert_eq(_state.get_character_outfit("bunny_f"), ["white_coat", "", "stethoscope"]) + + +func test_get_character_outfit_returns_empty_array_for_unknown() -> void: + var result: Array = _state.get_character_outfit("unknown_id") + assert_eq(result, ["", "", ""]) + + +func test_set_character_held_item_left() -> void: + _state.set_character_held_item("bunny_f", "left", "medicine_blue") + assert_eq(_state.get_character_held_item("bunny_f", "left"), "medicine_blue") + + +func test_get_character_held_item_returns_empty_for_unknown() -> void: + assert_eq(_state.get_character_held_item("unknown", "left"), "") + + +func test_save_data_includes_outfit() -> void: + _state.set_character_outfit("bunny_f", ["white_coat", "", ""]) + var data: Dictionary = _state.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: + _state.set_character_held_item("bunny_f", "right", "medicine_blue") + var data: Dictionary = _state.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"] + } + } + _state.apply_save_data(save) + assert_eq(_state.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": ""} + } + } + _state.apply_save_data(save) + assert_eq(_state.get_character_held_item("kitten_f", "left"), "gel_tube") + + +func test_save_data_has_version_two() -> void: + var data: Dictionary = _state.get_save_data() + assert_eq(data.get("version", 0), 2) diff --git a/test/unit/test_holdable_item.gd b/test/unit/test_holdable_item.gd new file mode 100644 index 0000000..5511cdc --- /dev/null +++ b/test/unit/test_holdable_item.gd @@ -0,0 +1,71 @@ +## 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" + 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: Node2D = Node2D.new() + var item2: HoldableItem = HoldableItem.new() + add_child_autofree(item1) + add_child_autofree(item2) + character.attach_item("left", item1) + var item_filler: Node2D = Node2D.new() + add_child_autofree(item_filler) + character.attach_item("right", item_filler) + 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) diff --git a/test/unit/test_outfit_item.gd b/test/unit/test_outfit_item.gd new file mode 100644 index 0000000..a0f1c57 --- /dev/null +++ b/test/unit/test_outfit_item.gd @@ -0,0 +1,63 @@ +## 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 + 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_stays_visible_if_no_character_in_range() -> void: + 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_true(item.visible) + assert_false(item.is_in_hand_slot()) + + +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)