diff --git a/scripts/objects/snap_point.gd b/scripts/objects/snap_point.gd new file mode 100644 index 0000000..c8e7e41 --- /dev/null +++ b/scripts/objects/snap_point.gd @@ -0,0 +1,42 @@ +## 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 not baby_only: + return true + if character.data == null: + return false + return character.data.state == CharacterData.State.BABY + + +func snap(character: Character) -> void: + occupant = character + character_snapped.emit(character) + + +func unsnap() -> void: + if occupant == null: + return + var prev: Character = occupant + occupant = null + character_unsnapped.emit(prev) diff --git a/test/unit/test_snap_point.gd b/test/unit/test_snap_point.gd new file mode 100644 index 0000000..3f61aee --- /dev/null +++ b/test/unit/test_snap_point.gd @@ -0,0 +1,75 @@ +## 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")