Add Use Spot Overlay and Target Capture Bar functionality

- Introduced `StageUseTargetCaptureBar` class for managing user interactions with props and their use spots.
- Implemented UI elements including buttons for changing props and canceling actions.
- Added `PropUseSpotData` class for validation and serialization of prop use spots.
- Created tests for validating use spot data migration, serialization, and rule matching.
- Ensured proper handling of edge cases in use spot data, including unique IDs and finite positions.
This commit is contained in:
2026-10-13 11:23:44 -04:00
parent 235c6aa6a6
commit d8b1b1164b
42 changed files with 3847 additions and 50 deletions
+1 -1
View File
@@ -76,7 +76,7 @@ func _test_legacy_migration() -> void:
var migrated: Dictionary = STAGE_SCENE_IO._migrate_to_current(legacy)
var prop_entry: Dictionary = migrated["objects"][1]
var roles: Dictionary = prop_entry["roles"]
_expect(migrated["version"] == "1.2", "old stage version migrates to 1.2")
_expect(migrated["version"] == "1.3", "old stage version migrates to 1.3")
_expect(prop_entry["stage_object_id"] == "prop_a", "1.0 prop IDs migrate from file IDs")
_expect(String(roles["start_motion"]) == PROP_STAGE_ROLES.START_PHYSICS, "legacy prop keeps dynamic start behavior")
_expect(bool(roles["blocks_stickmen"]) and bool(roles["legacy_draw_order"]), "legacy prop keeps collisions and draw order")
+187
View File
@@ -0,0 +1,187 @@
extends SceneTree
const PROP_USE_SPOT_DATA := preload("res://scripts/props/prop_use_spot_data.gd")
const PROP_BLOCK_SCRIPT := preload("res://scripts/props/prop_block.gd")
const STAGE_SCENE_IO := preload("res://scripts/stage/stage_scene_io.gd")
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
const STAGE_RULE_ENGINE := preload("res://scripts/stage/stage_rule_engine.gd")
const PROP_INTERACTOR_SCRIPT := preload("res://scripts/characters/stickman/stickman_prop_interactor.gd")
const STICKMAN_RIG_SCENE := preload("res://scenes/characters/stickman/stickman_rig.tscn")
var _checks: int = 0
var _failures: int = 0
var _last_use_failure: String = ""
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_test_migration_and_schema()
_test_spot_round_trip()
_test_action_and_trigger_references()
_test_rule_matching()
await _test_coordinates_and_empty_hands()
print("----------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d checks)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d checks" % [_failures, _checks])
quit(1)
func _expect(condition: bool, message: String) -> void:
_checks += 1
if condition:
print("PASS: " + message)
else:
_failures += 1
push_error("Phase 17b check failed: " + message)
func _test_migration_and_schema() -> void:
for version: String in ["1.0", "1.1", "1.2"]:
var legacy: Dictionary = {"version": version, "objects": [{"file_id": "prop_a", "type": "prop"}]}
var migrated: Dictionary = STAGE_SCENE_IO._migrate_to_current(legacy)
var entry: Dictionary = migrated["objects"][0]
_expect(String(migrated["version"]) == "1.3" and entry.get("use_spots") == [] and int(entry.get("next_use_spot_number", 0)) == 1, "%s prop receives v1.3 empty Use spot defaults" % version)
var valid_root: Dictionary = _valid_root()
_expect(bool(STAGE_SCENE_IO._validate_root(valid_root).get("ok", false)), "valid Use spot data passes root validation")
var duplicate: Dictionary = _valid_root()
duplicate["objects"][0]["use_spots"] = [
{"id": "spot_1", "number": 1, "name": "A", "position": {"x": 2.0, "y": 3.0}},
{"id": "spot_1", "number": 1, "name": "B", "position": {"x": 4.0, "y": 5.0}},
]
duplicate["objects"][0]["next_use_spot_number"] = 2
_expect(not bool(STAGE_SCENE_IO._validate_root(duplicate).get("ok", false)), "duplicate Use spot IDs and numbers are rejected")
var nonfinite: Dictionary = _valid_root()
nonfinite["objects"][0]["use_spots"][0]["position"] = {"x": INF, "y": 0.0}
_expect(not bool(STAGE_SCENE_IO._validate_root(nonfinite).get("ok", false)), "nonfinite Use spot coordinates are rejected")
var bad_counter: Dictionary = _valid_root()
bad_counter["objects"][0]["next_use_spot_number"] = 1
_expect(not bool(STAGE_SCENE_IO._validate_root(bad_counter).get("ok", false)), "Use spot counter must be greater than stored numbers")
func _test_spot_round_trip() -> void:
var spots: Array[Dictionary] = [{"id": "spot_2", "number": 2, "name": "Button", "position": Vector2(24.5, -18.25)}]
var encoded: Array[Dictionary] = PROP_USE_SPOT_DATA.encode(spots)
var decoded: Array[Dictionary] = PROP_USE_SPOT_DATA.decode(encoded)
_expect(encoded[0]["position"] == {"x": 24.5, "y": -18.25}, "spot serializer writes local point coordinates")
_expect(decoded[0]["position"] == Vector2(24.5, -18.25), "spot decoder restores nonzero vector coordinates")
_expect(String(decoded[0]["id"]) == "spot_2" and int(decoded[0]["number"]) == 2, "spot serializer preserves stable identity")
func _test_action_and_trigger_references() -> void:
var action: Dictionary = {"type": "use_prop", "target_prop_id": "prop_missing", "target_spot_id": "spot_9"}
var encoded_queue: Dictionary = STAGE_SCENE_IO.encode_queue([action])
var decoded_queue: Dictionary = STAGE_SCENE_IO.decode_queue(encoded_queue["actions"])
var queue_action: Dictionary = decoded_queue["actions"][0]
_expect(queue_action == action, "Use queue action preserves missing stable prop and spot IDs for repair")
var flat_action: Dictionary = action.duplicate(true)
var consequence: Dictionary = ACTION_REGISTRY.to_rule_action(flat_action, 42)
var rule: Dictionary = {
"id": 3,
"trigger": {"type": "prop_used", "source": 42, "target": -1, "params": {"target_prop_id": "prop_missing", "spot_scope": "specific", "target_spot_id": "spot_9"}},
"actions": [consequence],
}
var encoded_rules: Dictionary = STAGE_SCENE_IO.encode_rules([rule], {42: "actor_a"})
var encoded_trigger: Dictionary = encoded_rules["rules"][0]["trigger"]
_expect(encoded_trigger["source_id"] == "actor_a" and encoded_trigger["target_id"] == null, "Used prop saves actor source and null target")
var decoded_rules: Dictionary = STAGE_SCENE_IO.decode_rules(encoded_rules["rules"], {"actor_a": 42}, {})
var decoded_trigger: Dictionary = decoded_rules["rules"][0]["trigger"]
_expect(String(decoded_trigger["params"]["target_prop_id"]) == "prop_missing" and String(decoded_trigger["params"]["target_spot_id"]) == "spot_9", "Used prop round-trip preserves repairable stable target IDs")
var invalid_target: Dictionary = rule.duplicate(true)
invalid_target["trigger"]["target"] = 88
_expect(STAGE_SCENE_IO.encode_rules([invalid_target], {42: "actor_a"})["rules"].is_empty(), "Used prop in-memory target must be exactly -1")
var saved_bad_target: Dictionary = encoded_rules["rules"][0].duplicate(true)
saved_bad_target["trigger"]["target_id"] = "other_prop"
_expect(STAGE_SCENE_IO.decode_rules([saved_bad_target], {"actor_a": 42, "other_prop": 99}, {}).get("rules", []).is_empty(), "Used prop saved target_id must be null")
var any_with_spot: Dictionary = rule.duplicate(true)
any_with_spot["trigger"]["params"] = {"target_prop_id": "prop_missing", "spot_scope": "any", "target_spot_id": "spot_9"}
_expect(STAGE_SCENE_IO.encode_rules([any_with_spot], {42: "actor_a"})["rules"].is_empty(), "Any Used prop trigger omits a specific spot ID")
func _test_rule_matching() -> void:
var actor: Node2D = Node2D.new()
var prop: PropBlock = PROP_BLOCK_SCRIPT.new()
actor.name = "UseActor"
prop.stage_object_id = "prop_live"
root.add_child(actor)
root.add_child(prop)
var engine: StageRuleEngine = STAGE_RULE_ENGINE.new()
var base: Dictionary = {"type": "prop_used", "source": actor.get_instance_id(), "target": -1, "params": {"target_prop_id": "prop_live", "spot_scope": "specific", "target_spot_id": "spot_2"}}
var rule: Dictionary = {"id": 1, "trigger": base, "actions": []}
var event: Dictionary = {"type": "prop_used", "source": actor, "target": prop, "spot_id": "spot_2"}
_expect(engine.matches_rule(rule, event), "specific Used prop rule matches exact live prop and spot")
event["spot_id"] = "spot_1"
_expect(not engine.matches_rule(rule, event), "specific Used prop rule rejects a different spot")
var any_rule: Dictionary = rule.duplicate(true)
any_rule["trigger"]["params"] = {"target_prop_id": "prop_live", "spot_scope": "any"}
event["spot_id"] = "spot_1"
_expect(engine.matches_rule(any_rule, event), "Any Used prop rule matches another spot on the same prop")
any_rule["trigger"]["params"]["target_prop_id"] = "prop_missing"
_expect(not engine.matches_rule(any_rule, event), "Used prop wildcard target does not match a different prop")
actor.queue_free()
prop.queue_free()
func _test_coordinates_and_empty_hands() -> void:
var prop: PropBlock = PROP_BLOCK_SCRIPT.new()
prop.shape_type = PropBlock.ShapeType.POLYGON
prop.polygon_points = PackedVector2Array([Vector2(-40.0, -30.0), Vector2(40.0, -30.0), Vector2(40.0, 30.0), Vector2(-40.0, 30.0)])
prop.position = Vector2(180.0, 90.0)
prop.rotation = deg_to_rad(27.0)
prop.scale = Vector2(-1.5, 1.25)
root.add_child(prop)
await process_frame
var spot_id: String = prop.add_use_spot(Vector2(12.0, -8.0))
var expected_world: Vector2 = prop.global_transform * Vector2(12.0, -8.0)
_expect(spot_id == "spot_1" and prop.use_spot_world_position(spot_id).is_equal_approx(expected_world), "spot follows prop position, rotation, and signed EDIT scale")
var inverse_point: Vector2 = prop.use_spot_local_from_world(expected_world)
_expect(inverse_point.is_equal_approx(Vector2(12.0, -8.0)), "world authoring coordinate converts back to prop-local space")
prop.begin_play_instance_scale()
var play_expected: Vector2 = prop.global_transform * (Vector2(12.0, -8.0) * Vector2(-1.5, 1.25))
_expect(prop.use_spot_world_position(spot_id).is_equal_approx(play_expected), "signed authored scale is baked into PLAY spot position")
prop.end_play_instance_scale()
_expect(prop.scale.is_equal_approx(Vector2(-1.5, 1.25)), "STOP restores authored prop scale")
var rig: StickmanRig = STICKMAN_RIG_SCENE.instantiate() as StickmanRig
root.add_child(rig)
await process_frame
var interactor: Node = rig.get_node("RigComponents/PropInteractor")
var held: PropBlock = PROP_BLOCK_SCRIPT.new()
root.add_child(held)
interactor.set("_held_prop", held)
prop.apply_runtime_role(PropBlock.RuntimeRole.START_PHYSICS)
prop.linear_velocity = Vector2(31.0, -4.0)
var start_freeze: bool = prop.freeze
var start_layer: int = prop.collision_layer
var start_mask: int = prop.collision_mask
interactor.connect("use_failed", Callable(self, "_on_use_failed"))
var started: bool = bool(interactor.call("start_action", {"type": "use_prop", "target_prop_id": prop.stage_object_id, "target_spot_id": spot_id}))
_expect(not started and _last_use_failure == "hands_full", "Use rejects an actor with full hands")
_expect(prop.runtime_role == PropBlock.RuntimeRole.START_PHYSICS and prop.freeze == start_freeze and prop.collision_layer == start_layer and prop.collision_mask == start_mask, "failed Use leaves target physics profile unchanged")
_expect(prop.linear_velocity.is_equal_approx(Vector2(31.0, -4.0)), "failed Use does not alter target velocity")
held.queue_free()
rig.queue_free()
prop.queue_free()
await process_frame
func _on_use_failed(reason_code: String, _prop: PropBlock, _spot_id: String) -> void:
_last_use_failure = reason_code
func _valid_root() -> Dictionary:
return {
"version": "1.3",
"objects": [{
"file_id": "prop_file",
"type": "prop",
"stage_object_id": "prop_live",
"use_spots": [{"id": "spot_1", "number": 1, "name": "Button", "position": {"x": 24.0, "y": -18.0}}],
"next_use_spot_number": 2,
}],
}
+1
View File
@@ -0,0 +1 @@
uid://ckpf6m2c4knov