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
@@ -399,7 +399,7 @@ func _advance_to_next_action() -> void:
action.erase("speed")
_current_run_item["action"] = action
var action_type := String(action.get("type", ""))
var requires_animated := action_type == ACTION_REGISTRY.MOVE_TO or action_type == "pick_up_prop" or action_type == "drop_prop" or action_type == "throw_prop" or action_type == "set_down_prop"
var requires_animated := action_type == ACTION_REGISTRY.MOVE_TO or action_type == "pick_up_prop" or action_type == "drop_prop" or action_type == "throw_prop" or action_type == "set_down_prop" or action_type == "use_prop"
if requires_animated and _rig != null:
if _rig.state == StickmanRig.RigState.RECOVERING:
_run_queue.insert(_run_cursor, _current_run_item.duplicate(true))
@@ -443,7 +443,7 @@ func _begin_action(action: Dictionary, legacy_speed_override: float = -1.0) -> v
"recover":
_action_phase = ActionPhase.RECOVERING
recovery_requested.emit()
"pick_up_prop", "drop_prop", "throw_prop", "set_down_prop":
"pick_up_prop", "drop_prop", "throw_prop", "set_down_prop", "use_prop":
if _rig == null or not _rig.start_prop_action(action):
_finish_action()
else:
@@ -6,9 +6,10 @@ const ANCHOR_DATA := preload("res://scripts/props/prop_anchor_data.gd")
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
const STAGE_PHYSICS := preload("res://scripts/stage/stage_physics_policy.gd")
const USE_REACH_SOLVER := preload("res://scripts/characters/stickman/stickman_use_reach_solver.gd")
enum Hand { LEFT, RIGHT }
enum Phase { IDLE, APPROACHING, REACHING, PICKUP_RECOVERING, HELD, THROWING, SET_DOWN_APPROACHING, SET_DOWN_REACHING, SET_DOWN_RECOVERING }
enum Phase { IDLE, APPROACHING, REACHING, PICKUP_RECOVERING, HELD, THROWING, SET_DOWN_APPROACHING, SET_DOWN_REACHING, SET_DOWN_RECOVERING, USE_APPROACHING, USE_REACHING, USE_RECOVERING }
const MAX_REACH: float = 390.0
const APPROACH_GAP: float = 90.0
@@ -16,6 +17,12 @@ const APPROACH_REPLAN_LIMIT: int = 1
const REACH_DURATION: float = 0.34
const SET_DOWN_REACH_DURATION: float = 0.22
const SET_DOWN_RECOVER_DURATION: float = 0.24
const USE_REACH_DURATION: float = 0.28
const USE_RECOVER_DURATION: float = 0.20
const USE_MAX_REPLAN_DISPLACEMENT: float = 180.0
const USE_REPLAN_LIMIT: int = 1
## Temporary diagnostics for the Use reach failure report. Remove after diagnosis.
const USE_REACH_TRACE_ENABLED: bool = true
const PICKUP_RECOVER_DURATION: float = 0.32
const LOW_PICKUP_THRESHOLD: float = 110.0
const LOW_PICKUP_MAX_DROP: float = 110.0
@@ -38,6 +45,8 @@ const PROP_TRACE_ENABLED: bool = false
signal action_completed
signal prop_event(event_type: String, prop: PropBlock, reason: String)
signal use_completed(prop: PropBlock, spot_id: String, world_position: Vector2)
signal use_failed(reason_code: String, prop: PropBlock, spot_id: String)
var _rig: Node2D
var _locomotion: Node
@@ -91,6 +100,16 @@ var _set_down_recover_hand_start: Vector2 = Vector2.ZERO
var _trace_released_prop: PropBlock
var _trace_released_elapsed: float = 0.0
var _trace_released_watch_remaining: float = 0.0
var _use_prop: PropBlock
var _use_spot_id: String = ""
var _use_planned_spot_world: Vector2 = Vector2.INF
var _use_approach_root: Vector2 = Vector2.ZERO
var _use_approach_replans: int = 0
var _use_reach_elapsed: float = 0.0
var _use_recover_elapsed: float = 0.0
var _use_marker_start: Vector2 = Vector2.ZERO
var _use_marker_recover_start: Vector2 = Vector2.ZERO
var _use_hand: Hand = Hand.RIGHT
func _process(delta: float) -> void:
@@ -167,6 +186,8 @@ func start_action(action: Dictionary) -> bool:
return true
"set_down_prop":
return _begin_set_down(action)
"use_prop":
return _begin_use(action)
return false
@@ -193,6 +214,25 @@ func tick(delta: float) -> void:
_update_set_down_reach(delta)
Phase.SET_DOWN_RECOVERING:
_update_set_down_recovery(delta)
Phase.USE_APPROACHING:
if not _validate_use_target():
return
var live_spot: Vector2 = _use_prop.use_spot_world_position(_use_spot_id)
if not live_spot.is_finite():
_fail_use("spot_missing")
return
if live_spot.distance_to(_use_planned_spot_world) > USE_MAX_REPLAN_DISPLACEMENT:
_request_use_replan("target_moved")
return
if _locomotion != null and bool(_locomotion.call("consume_walk_failed")):
_fail_use("route_blocked")
return
if _locomotion != null and bool(_locomotion.call("consume_walk_completed")):
_on_use_approach_arrived()
Phase.USE_REACHING:
_update_use_reach(delta)
Phase.USE_RECOVERING:
_update_use_recovery(delta)
Phase.PICKUP_RECOVERING:
_update_pickup_recovery(delta)
Phase.HELD:
@@ -202,7 +242,7 @@ func tick(delta: float) -> void:
func cancel_action() -> void:
if (_phase == Phase.APPROACHING or _phase == Phase.SET_DOWN_APPROACHING) and _locomotion != null:
if (_phase == Phase.APPROACHING or _phase == Phase.SET_DOWN_APPROACHING or _phase == Phase.USE_APPROACHING) and _locomotion != null:
_locomotion.call("cancel_walk")
if _phase == Phase.SET_DOWN_APPROACHING or _phase == Phase.SET_DOWN_REACHING or _phase == Phase.SET_DOWN_RECOVERING:
_restore_hand_target()
@@ -232,6 +272,10 @@ func cancel_action() -> void:
_throw_elapsed = 0.0
_restore_hand_target()
_restore_throw_torso()
if _phase == Phase.USE_APPROACHING or _phase == Phase.USE_REACHING or _phase == Phase.USE_RECOVERING:
_restore_hand_target()
_restore_pickup_torso()
_clear_use_state()
if _phase != Phase.HELD:
_phase = Phase.IDLE
_active_action.clear()
@@ -560,27 +604,327 @@ func _invalid_throw_preview(effective_target: Vector2, start_position: Vector2)
func disarm_for_ragdoll() -> void:
var interrupted_pickup := _phase == Phase.APPROACHING or _phase == Phase.REACHING or _phase == Phase.PICKUP_RECOVERING
var interrupted_set_down := _phase == Phase.SET_DOWN_APPROACHING or _phase == Phase.SET_DOWN_REACHING or _phase == Phase.SET_DOWN_RECOVERING
var interrupted_use := _phase == Phase.USE_APPROACHING or _phase == Phase.USE_REACHING or _phase == Phase.USE_RECOVERING
var interrupted_windup := _phase == Phase.THROWING and not _throw_released
if interrupted_pickup or interrupted_set_down:
if interrupted_pickup or interrupted_set_down or interrupted_use:
cancel_action()
elif interrupted_windup:
cancel_action()
if _held_prop != null and is_instance_valid(_held_prop):
_release_held("ragdoll", false)
if interrupted_pickup or interrupted_set_down or interrupted_windup:
if interrupted_pickup or interrupted_set_down or interrupted_use or interrupted_windup:
action_completed.emit()
func reset_for_scene_exit() -> void:
if PROP_TRACE_ENABLED:
print("[PropTrace] reset_for_scene_exit actor=%s phase=%s held=%s" % [_trace_actor_name(), Phase.keys()[int(_phase)], _trace_prop_id(_held_prop)])
if _phase == Phase.APPROACHING or _phase == Phase.REACHING or _phase == Phase.PICKUP_RECOVERING or _phase == Phase.SET_DOWN_APPROACHING or _phase == Phase.SET_DOWN_REACHING or _phase == Phase.SET_DOWN_RECOVERING or (_phase == Phase.THROWING and not _throw_released):
if _phase == Phase.APPROACHING or _phase == Phase.REACHING or _phase == Phase.PICKUP_RECOVERING or _phase == Phase.SET_DOWN_APPROACHING or _phase == Phase.SET_DOWN_REACHING or _phase == Phase.SET_DOWN_RECOVERING or _phase == Phase.USE_APPROACHING or _phase == Phase.USE_REACHING or _phase == Phase.USE_RECOVERING or (_phase == Phase.THROWING and not _throw_released):
cancel_action()
if _held_prop != null and is_instance_valid(_held_prop):
_release_held("scene_reset", false)
_phase = Phase.IDLE
_active_action.clear()
_restore_hand_target()
_clear_use_state()
func get_held_prop() -> PropBlock:
return _held_prop if _held_prop != null and is_instance_valid(_held_prop) else null
func preview_use(prop: PropBlock, spot_id: String, actor_root: Vector2 = Vector2.INF, predicted_facing_left: bool = false) -> Dictionary:
if _rig == null or not is_instance_valid(_rig):
return {"known": true, "valid": false, "reason_code": "out_of_reach"}
var root: Vector2 = _rig.global_position if not actor_root.is_finite() else actor_root
return USE_REACH_SOLVER.solve(_rig, prop, spot_id, root, predicted_facing_left)
func _begin_use(action: Dictionary) -> bool:
var prop_id: String = String(action.get("target_prop_id", ""))
var spot_id: String = String(action.get("target_spot_id", ""))
if _phase != Phase.IDLE or _rig == null or int(_rig.get("state")) != StickmanRig.RigState.ANIMATED:
use_failed.emit("out_of_reach", null, spot_id)
return false
if _held_prop != null and is_instance_valid(_held_prop):
use_failed.emit("hands_full", null, spot_id)
return false
if prop_id.is_empty() or not _resolver.is_valid():
use_failed.emit("prop_missing", null, spot_id)
return false
var resolved: Variant = _resolver.call(prop_id)
if not resolved is PropBlock or not is_instance_valid(resolved as PropBlock) or (resolved as PropBlock).is_queued_for_deletion():
use_failed.emit("prop_missing", null, spot_id)
return false
var prop: PropBlock = resolved as PropBlock
var preview: Dictionary = USE_REACH_SOLVER.solve(_rig, prop, spot_id, _rig.global_position, _is_facing_left())
if not bool(preview.get("valid", false)):
use_failed.emit(String(preview.get("reason_code", "out_of_reach")), prop, spot_id)
return false
_use_prop = prop
_use_spot_id = spot_id
_use_planned_spot_world = preview.get("spot_world", Vector2.INF)
_use_approach_root = preview.get("approach_root", _rig.global_position)
_use_approach_replans = 0
_use_hand = int(preview.get("hand", Hand.RIGHT))
_hand = _use_hand
_active_action = action.duplicate(true)
_trace_use_reach("begin", "", preview)
var marker: Marker2D = _hand_marker()
if marker == null:
_clear_use_state()
use_failed.emit("out_of_reach", prop, spot_id)
return false
_marker_start_position = marker.position
if absf(_use_approach_root.x - _rig.global_position.x) > 8.0:
return _start_use_approach(preview)
_start_use_reach(preview)
return _phase == Phase.USE_REACHING
func _start_use_approach(preview: Dictionary) -> bool:
if _locomotion == null or _use_prop == null or not is_instance_valid(_use_prop):
_fail_use("prop_missing")
return false
_use_approach_root = preview.get("approach_root", _use_approach_root)
_use_planned_spot_world = preview.get("spot_world", _use_planned_spot_world)
_use_hand = int(preview.get("hand", _use_hand))
_hand = _use_hand
var facing_left: bool = bool(preview.get("facing_left", _is_facing_left()))
if facing_left != _is_facing_left():
_rig.call("set_facing_profile", StickmanRig.FacingProfile.LEFT if facing_left else StickmanRig.FacingProfile.RIGHT)
var target_feet: Vector2 = _use_approach_root - _locomotion.call("get_foot_offset")
if not bool(_locomotion.call("walk_to", target_feet, -1.0)):
_fail_use("route_blocked")
return false
_phase = Phase.USE_APPROACHING
return true
func _on_use_approach_arrived() -> void:
if not _validate_use_target():
return
var current_spot: Vector2 = _use_prop.use_spot_world_position(_use_spot_id)
if not current_spot.is_finite():
_fail_use("spot_missing")
return
if current_spot.distance_to(_use_planned_spot_world) > USE_MAX_REPLAN_DISPLACEMENT:
_request_use_replan("target_moved")
return
var preview: Dictionary = USE_REACH_SOLVER.solve(_rig, _use_prop, _use_spot_id, _rig.global_position, _is_facing_left())
_trace_use_reach("approach_arrived", String(preview.get("reason_code", "")), preview)
if not bool(preview.get("valid", false)):
_request_use_replan(String(preview.get("reason_code", "out_of_reach")))
return
if absf(Vector2(preview.get("approach_root", _rig.global_position)).x - _rig.global_position.x) > 14.0:
_request_use_replan("target_moved", preview)
return
_use_planned_spot_world = current_spot
_start_use_reach(preview)
func _request_use_replan(failure_reason: String, preview: Dictionary = {}) -> void:
if _phase == Phase.USE_APPROACHING and _locomotion != null:
_locomotion.call("cancel_walk")
if _use_approach_replans >= USE_REPLAN_LIMIT:
_fail_use("target_moved" if failure_reason == "target_moved" else failure_reason)
return
_use_approach_replans += 1
if preview.is_empty() and _use_prop != null and is_instance_valid(_use_prop):
preview = USE_REACH_SOLVER.solve(_rig, _use_prop, _use_spot_id, _rig.global_position, _is_facing_left())
if preview.is_empty() or not bool(preview.get("valid", false)):
_fail_use(failure_reason)
return
var new_spot: Vector2 = preview.get("spot_world", Vector2.INF)
if not new_spot.is_finite() or new_spot.distance_to(_use_planned_spot_world) > USE_MAX_REPLAN_DISPLACEMENT:
_fail_use("target_moved")
return
_use_planned_spot_world = new_spot
_use_approach_root = preview.get("approach_root", _rig.global_position)
_use_hand = int(preview.get("hand", _use_hand))
_hand = _use_hand
if absf(_use_approach_root.x - _rig.global_position.x) > 14.0:
_start_use_approach(preview)
else:
_start_use_reach(preview)
func _start_use_reach(preview: Dictionary) -> void:
_use_hand = int(preview.get("hand", _use_hand))
_hand = _use_hand
var marker: Marker2D = _hand_marker()
if marker == null:
_fail_use("out_of_reach")
return
# Locomotion replaces hand targets with its standing pose at arrival. Save
# that current pose as the recovery target instead of restoring the stale
# marker captured before the approach walk.
_marker_start_position = marker.position
_use_marker_start = marker.global_position
_use_reach_elapsed = 0.0
var torso_marker: Marker2D = _torso_marker()
_pickup_uses_crouch = false
if torso_marker != null:
_pickup_torso_start = torso_marker.position
_pickup_torso_start_rotation = torso_marker.rotation
_pickup_torso_crouched = _pickup_torso_start
_pickup_torso_crouched_rotation = _pickup_torso_start_rotation
var spot_world: Vector2 = preview.get("spot_world", _use_prop.use_spot_world_position(_use_spot_id))
var spot_local: Vector2 = _rig.to_local(spot_world)
var low_amount: float = clampf((spot_local.y - _pickup_torso_start.y - LOW_PICKUP_THRESHOLD) / LOW_PICKUP_MAX_DROP, 0.0, 1.0)
if low_amount > 0.0:
var toward_prop: float = signf(spot_local.x - _pickup_torso_start.x)
if is_zero_approx(toward_prop):
toward_prop = -1.0 if _is_facing_left() else 1.0
_pickup_torso_crouched += Vector2(toward_prop * LOW_PICKUP_LEAN * low_amount, LOW_PICKUP_MAX_DROP * low_amount)
_pickup_torso_crouched_rotation -= toward_prop * 0.10 * low_amount
_pickup_uses_crouch = true
_rig.call("_set_limb_mods_enabled", true)
_phase = Phase.USE_REACHING
_trace_use_reach("reach_started", "", preview)
func _update_use_reach(delta: float) -> void:
if not _validate_use_target():
return
var marker: Marker2D = _hand_marker()
if marker == null:
_fail_use("out_of_reach")
return
var spot_world: Vector2 = _use_prop.use_spot_world_position(_use_spot_id)
if not spot_world.is_finite():
_fail_use("spot_missing")
return
# Once reaching starts, do not drag the hand across a moving target. The
# approach gets one bounded replan; the reach phase only tolerates contact
# scale motion and then fails so DIRECT can be corrected.
if spot_world.distance_to(_use_planned_spot_world) > USE_REACH_SOLVER.CONTACT_TOLERANCE:
_fail_use("target_moved")
return
_use_reach_elapsed += delta
var weight: float = clampf(_use_reach_elapsed / USE_REACH_DURATION, 0.0, 1.0)
var eased_weight: float = smoothstep(0.0, 1.0, weight)
marker.global_position = _use_marker_start.lerp(spot_world, eased_weight)
_update_pickup_crouch(eased_weight)
if weight < 1.0:
return
var final_preview: Dictionary = USE_REACH_SOLVER.solve(_rig, _use_prop, _use_spot_id, _rig.global_position, _is_facing_left())
if not bool(final_preview.get("valid", false)) or int(final_preview.get("hand", -1)) != _use_hand:
_trace_use_reach("reach_check_failed", String(final_preview.get("reason_code", "out_of_reach")), final_preview)
_fail_use(String(final_preview.get("reason_code", "out_of_reach")))
return
var wrist_position: Vector2 = _solved_wrist_transform().origin
if wrist_position.distance_to(spot_world) > USE_REACH_SOLVER.CONTACT_TOLERANCE:
_trace_use_reach("contact_failed", "out_of_reach", final_preview)
_fail_use("out_of_reach")
return
use_completed.emit(_use_prop, _use_spot_id, spot_world)
_use_marker_recover_start = marker.global_position
_use_recover_elapsed = 0.0
_phase = Phase.USE_RECOVERING
func _update_use_recovery(delta: float) -> void:
_use_recover_elapsed += delta
var weight: float = smoothstep(0.0, 1.0, clampf(_use_recover_elapsed / USE_RECOVER_DURATION, 0.0, 1.0))
var marker: Marker2D = _hand_marker()
if marker != null:
marker.global_position = _use_marker_recover_start.lerp(_use_marker_start, weight)
_update_pickup_crouch(1.0 - weight)
if weight < 1.0:
return
_restore_hand_target()
_restore_pickup_torso()
_clear_use_state()
_phase = Phase.IDLE
_active_action.clear()
action_completed.emit()
func _validate_use_target() -> bool:
if _use_prop == null or not is_instance_valid(_use_prop) or _use_prop.is_queued_for_deletion():
_fail_use("prop_missing")
return false
if _use_prop.get_use_spot(_use_spot_id).is_empty():
_fail_use("spot_missing")
return false
if _use_prop.interaction_owner_id != 0 or _use_prop.interaction_reserved_by != 0:
_fail_use("prop_held")
return false
if _held_prop != null and is_instance_valid(_held_prop):
_fail_use("hands_full")
return false
return true
func _fail_use(reason_code: String) -> void:
var action_was_active: bool = _phase == Phase.USE_APPROACHING or _phase == Phase.USE_REACHING or _phase == Phase.USE_RECOVERING
_trace_use_reach("failed", reason_code)
if _phase == Phase.USE_APPROACHING and _locomotion != null:
_locomotion.call("cancel_walk")
var prop: PropBlock = _use_prop if _use_prop != null and is_instance_valid(_use_prop) else null
var spot_id: String = _use_spot_id
_restore_hand_target()
_restore_pickup_torso()
_clear_use_state()
_phase = Phase.IDLE
_active_action.clear()
use_failed.emit(reason_code, prop, spot_id)
if action_was_active:
action_completed.emit()
func _trace_use_reach(event_name: String, reason_code: String = "", preview: Dictionary = {}) -> void:
if not USE_REACH_TRACE_ENABLED:
return
var details: Dictionary = {
"event": event_name,
"reason": reason_code,
"phase": Phase.keys()[int(_phase)],
"actor": String(_rig.name) if _rig != null and is_instance_valid(_rig) else "<none>",
"root": _rig.global_position if _rig != null and is_instance_valid(_rig) else Vector2.INF,
"rig_transform": _rig.global_transform if _rig != null and is_instance_valid(_rig) else Transform2D.IDENTITY,
"rig_scale": _rig.global_scale if _rig != null and is_instance_valid(_rig) else Vector2.ZERO,
"facing_left": _is_facing_left(),
"hand": _use_hand,
"approach_root": _use_approach_root,
"planned_spot": _use_planned_spot_world,
"preview": preview,
}
var marker: Marker2D = _hand_marker()
if marker != null:
details["hand_marker_world"] = marker.global_position
var wrist_position: Vector2 = _solved_wrist_transform().origin if _rig != null and is_instance_valid(_rig) else Vector2.INF
details["wrist_world"] = wrist_position
if _use_prop != null and is_instance_valid(_use_prop):
var spot: Dictionary = _use_prop.get_use_spot(_use_spot_id)
var live_spot: Vector2 = _use_prop.use_spot_world_position(_use_spot_id)
details["prop_id"] = _use_prop.stage_object_id
details["prop_transform"] = _use_prop.global_transform
details["spot_id"] = _use_spot_id
details["spot_local"] = spot.get("position", Vector2.INF)
details["spot_world"] = live_spot
details["wrist_to_spot"] = wrist_position.distance_to(live_spot) if wrist_position.is_finite() and live_spot.is_finite() else INF
var suffix: String = "Left" if _use_hand == Hand.LEFT else "Right"
var upper: Bone2D = _rig.get_node_or_null(NodePath("Skeleton2D/Torso/%sUpperArm" % suffix)) as Bone2D
var lower: Bone2D = _rig.get_node_or_null(NodePath("Skeleton2D/Torso/%sUpperArm/%sLowerArm" % [suffix, suffix])) as Bone2D
if upper != null:
details["shoulder_world"] = upper.global_position
if lower != null:
details["elbow_world"] = lower.global_position
print("[UseReachTrace] %s" % str(details))
func _clear_use_state() -> void:
_use_prop = null
_use_spot_id = ""
_use_planned_spot_world = Vector2.INF
_use_approach_root = Vector2.ZERO
_use_approach_replans = 0
_use_reach_elapsed = 0.0
_use_recover_elapsed = 0.0
_use_marker_start = Vector2.ZERO
_use_marker_recover_start = Vector2.ZERO
func _begin_pickup(prop_id: String, requested_hand: String, action: Dictionary) -> bool:
@@ -183,6 +183,8 @@ signal queue_finished # queue ran to completion (not
signal queue_changed # any queue mutation
signal speech_finished # bubble auto-hid after speak()
signal prop_interaction_event(event_type: String, prop: PropBlock, reason: String)
signal use_completed(prop: PropBlock, spot_id: String, world_position: Vector2)
signal use_failed(reason_code: String, prop: PropBlock, spot_id: String)
# ---------------------------------------------------------------------------
# Internal state
@@ -262,6 +264,8 @@ func _ready() -> void:
if _prop_interactor_component != null:
_prop_interactor_component.call("configure", self, _locomotion_component, Callable())
_prop_interactor_component.connect("prop_event", _on_prop_interaction_event)
_prop_interactor_component.connect("use_completed", _on_prop_use_completed)
_prop_interactor_component.connect("use_failed", _on_prop_use_failed)
if _action_runner_component != null:
_action_runner_component.configure(self, _locomotion_component, _prop_interactor_component)
_action_runner_component.action_queue = _pending_action_queue
@@ -532,6 +536,12 @@ func cancel_prop_action() -> void:
_prop_interactor_component.call("cancel_action")
func get_held_prop() -> PropBlock:
if _prop_interactor_component == null:
return null
return _prop_interactor_component.call("get_held_prop") as PropBlock
func preview_prop_throw(target: Vector2, queue_prefix: Array[Dictionary] = []) -> Dictionary:
if _prop_interactor_component == null:
return {}
@@ -544,6 +554,12 @@ func preview_prop_set_down(target: Vector2, queue_prefix: Array[Dictionary] = []
return _prop_interactor_component.call("preview_set_down", target, queue_prefix)
func preview_prop_use(prop: PropBlock, spot_id: String, actor_root: Vector2 = Vector2.INF, predicted_facing_left: bool = false) -> Dictionary:
if _prop_interactor_component == null:
return {"known": true, "valid": false, "reason_code": "out_of_reach"}
return _prop_interactor_component.call("preview_use", prop, spot_id, actor_root, predicted_facing_left)
func reset_prop_interactions_for_scene_exit() -> void:
if _prop_interactor_component != null:
_prop_interactor_component.call("reset_for_scene_exit")
@@ -553,6 +569,14 @@ func _on_prop_interaction_event(event_type: String, prop: PropBlock, reason: Str
prop_interaction_event.emit(event_type, prop, reason)
func _on_prop_use_completed(prop: PropBlock, spot_id: String, world_position: Vector2) -> void:
use_completed.emit(prop, spot_id, world_position)
func _on_prop_use_failed(reason_code: String, prop: PropBlock, spot_id: String) -> void:
use_failed.emit(reason_code, prop, spot_id)
## Public stand-up request. No-op unless the rig is in RAGDOLL.
func request_recovery() -> void:
if state == RigState.RAGDOLL and _ragdoll_component != null:
@@ -0,0 +1,285 @@
class_name StickmanUseReachSolver
extends RefCounted
## Shared, nonmutating reach estimate for DIRECT previews and PLAY actions.
const STAGE_PHYSICS := preload("res://scripts/stage/stage_physics_policy.gd")
const APPROACH_GAP: float = 110.0
const CONTACT_TOLERANCE: float = 34.0
const MAX_ROUTE_LENGTH: float = 5000.0
static func solve(rig: StickmanRig, prop: PropBlock, spot_id: String, actor_root: Vector2, predicted_facing_left: bool, _options: Dictionary = {}) -> Dictionary:
var result: Dictionary = {"known": true, "valid": false, "reason_code": "out_of_reach", "actor_root": actor_root, "spot_world": Vector2.INF, "approach_root": actor_root, "approach_feet": Vector2.INF, "facing_left": predicted_facing_left, "hand": 1, "reach_start": actor_root, "reach_end": Vector2.INF}
if rig == null or not is_instance_valid(rig) or not actor_root.is_finite():
result["reason_code"] = "out_of_reach"
return result
if not _transform_is_finite_invertible(rig.global_transform):
result["reason_code"] = "out_of_reach"
return result
if not _has_ground_support(rig, actor_root):
result["reason_code"] = "route_blocked"
return result
if prop == null or not is_instance_valid(prop) or prop.is_queued_for_deletion():
result["reason_code"] = "prop_missing"
return result
if not _transform_is_finite_invertible(prop.global_transform):
result["reason_code"] = "spot_missing"
return result
if prop.get_use_spot(spot_id).is_empty():
result["reason_code"] = "spot_missing"
return result
var held_prop: PropBlock = rig.get_held_prop() if rig.has_method("get_held_prop") else null
if held_prop != null and is_instance_valid(held_prop):
result["reason_code"] = "hands_full"
return result
if prop.interaction_owner_id != 0 or prop.interaction_reserved_by != 0:
result["reason_code"] = "prop_held"
return result
var spot_world: Vector2 = prop.use_spot_world_position(spot_id)
if not spot_world.is_finite():
result["reason_code"] = "spot_missing"
return result
result["spot_world"] = spot_world
result["reach_end"] = spot_world
var candidates: Array[Dictionary] = []
var saw_ground_route: bool = false
var saw_clear_standing: bool = false
for hand_value: int in [0, 1]:
var current_wrist: Vector2 = _wrist_world(rig, hand_value)
var shoulder: Vector2 = _shoulder_world(rig, hand_value)
var arm_reach: float = _arm_reach(rig, hand_value)
if not current_wrist.is_finite() or not shoulder.is_finite() or arm_reach <= 0.0:
continue
# Reach is measured from the shoulder joint. The wrist's current pose may
# be far from the spot even though IK can rotate the arm to touch it; this
# is especially important after locomotion changes the hand-marker pose.
if shoulder.distance_to(spot_world) <= arm_reach and _hand_path_is_clear(rig, prop, shoulder, spot_world) and _has_ground_support(rig, actor_root) and _standing_clear(rig, actor_root):
candidates.append({"root": actor_root, "hand": hand_value, "facing_left": predicted_facing_left, "wrist": current_wrist, "distance": 0.0})
continue
for side: float in [-1.0, 1.0]:
var approach_root: Vector2 = Vector2(spot_world.x + side * APPROACH_GAP, actor_root.y)
var facing_left: bool = approach_root.x > spot_world.x
var shoulder_offset: Vector2 = _shoulder_offset(rig, hand_value, predicted_facing_left)
var wrist_offset: Vector2 = _wrist_offset(rig, hand_value, predicted_facing_left)
if predicted_facing_left != facing_left:
shoulder_offset.x = -shoulder_offset.x
wrist_offset.x = -wrist_offset.x
var candidate_shoulder: Vector2 = approach_root + shoulder_offset
var candidate_wrist: Vector2 = approach_root + wrist_offset
if candidate_shoulder.distance_to(spot_world) > arm_reach or not _hand_path_is_clear(rig, prop, candidate_shoulder, spot_world):
continue
var route: Dictionary = _grounded_route(rig, actor_root, approach_root)
if not bool(route.get("valid", false)):
continue
saw_ground_route = true
if not _has_ground_support(rig, approach_root) or not _standing_clear(rig, approach_root):
continue
saw_clear_standing = true
candidates.append({"root": approach_root, "hand": hand_value, "facing_left": facing_left, "wrist": candidate_wrist, "distance": float(route.get("distance", INF))})
if candidates.is_empty():
if not saw_ground_route and not _has_any_ground_route(rig, actor_root, spot_world):
result["reason_code"] = "route_blocked"
elif saw_ground_route and not saw_clear_standing:
result["reason_code"] = "no_standing_room"
else:
result["reason_code"] = "out_of_reach"
return result
candidates.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return float(a.get("distance", INF)) < float(b.get("distance", INF))
)
var chosen: Dictionary = candidates[0]
var approach_root: Vector2 = chosen["root"]
result["valid"] = true
result["reason_code"] = ""
result["approach_root"] = approach_root
result["approach_feet"] = approach_root - StickmanLocomotion.FOOT_OFFSET
result["facing_left"] = bool(chosen["facing_left"])
result["hand"] = int(chosen["hand"])
result["reach_start"] = chosen["wrist"]
return result
static func _wrist_world(rig: StickmanRig, hand_value: int) -> Vector2:
var suffix: String = "Left" if hand_value == 0 else "Right"
var lower_visual: Node2D = rig.get_node_or_null(NodePath("Body/%sLowerArm" % suffix)) as Node2D
var lower_bone: Bone2D = rig.get_node_or_null(NodePath("Skeleton2D/Torso/%sUpperArm/%sLowerArm" % [suffix, suffix])) as Bone2D
if lower_visual == null or lower_bone == null:
var hand_marker: Marker2D = rig.get_node_or_null(NodePath("IK_Targets/%s_Hand" % suffix)) as Marker2D
return hand_marker.global_position if hand_marker != null else Vector2.INF
return lower_visual.to_global(Vector2(0.0, lower_bone.length))
static func _shoulder_world(rig: StickmanRig, hand_value: int) -> Vector2:
var suffix: String = "Left" if hand_value == 0 else "Right"
var upper: Bone2D = rig.get_node_or_null(NodePath("Skeleton2D/Torso/%sUpperArm" % suffix)) as Bone2D
return upper.global_position if upper != null else rig.global_position
static func _arm_reach(rig: StickmanRig, hand_value: int) -> float:
var suffix: String = "Left" if hand_value == 0 else "Right"
var upper: Bone2D = rig.get_node_or_null(NodePath("Skeleton2D/Torso/%sUpperArm" % suffix)) as Bone2D
var lower: Bone2D = rig.get_node_or_null(NodePath("Skeleton2D/Torso/%sUpperArm/%sLowerArm" % [suffix, suffix])) as Bone2D
if upper == null or lower == null:
return 0.0
if not is_finite(upper.length) or not is_finite(lower.length):
return 0.0
# Bone lengths are authored in local space. Measure their world-space
# endpoints so resized, mirrored, and nonuniformly scaled rigs use the same
# reach that the IK solver can actually produce.
var shoulder: Vector2 = upper.global_position
var elbow: Vector2 = lower.global_position
var wrist: Vector2 = _wrist_world(rig, hand_value)
if not shoulder.is_finite() or not elbow.is_finite() or not wrist.is_finite():
return 0.0
return shoulder.distance_to(elbow) + elbow.distance_to(wrist)
static func _transform_is_finite_invertible(value: Transform2D) -> bool:
return value.origin.is_finite() and value.x.is_finite() and value.y.is_finite() and is_finite(value.determinant()) and absf(value.determinant()) > 0.0001
static func _shoulder_offset(rig: StickmanRig, hand_value: int, facing_left: bool) -> Vector2:
var local: Vector2 = rig.to_local(_shoulder_world(rig, hand_value))
if rig.get_facing_profile() == StickmanRig.FacingProfile.LEFT:
local.x = -local.x
if facing_left:
local.x = -local.x
return rig.global_transform.basis_xform(local)
static func _wrist_offset(rig: StickmanRig, hand_value: int, facing_left: bool) -> Vector2:
var local: Vector2 = rig.to_local(_wrist_world(rig, hand_value))
if rig.get_facing_profile() == StickmanRig.FacingProfile.LEFT:
local.x = -local.x
if facing_left:
local.x = -local.x
return rig.global_transform.basis_xform(local)
static func _hand_path_is_clear(rig: StickmanRig, prop: PropBlock, start: Vector2, finish: Vector2) -> bool:
if not start.is_finite() or not finish.is_finite():
return false
var state: PhysicsDirectSpaceState2D = rig.get_world_2d().direct_space_state
if state == null:
return true
var query: PhysicsRayQueryParameters2D = PhysicsRayQueryParameters2D.create(start, finish)
query.collision_mask = STAGE_PHYSICS.TERRAIN_LAYER | STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER
var excluded: Array[RID] = rig.get_collision_body_rids()
if prop.get_rid().is_valid():
excluded.append(prop.get_rid())
query.exclude = excluded
return state.intersect_ray(query).is_empty()
static func _grounded_route(rig: StickmanRig, from_root: Vector2, to_root: Vector2) -> Dictionary:
var locomotion: StickmanLocomotion = rig.get_locomotion()
if locomotion == null or locomotion._nav_agent == null:
return {"valid": false, "distance": INF}
var nav: NavigationAgent2D = locomotion._nav_agent
var map: RID = nav.get_navigation_map()
if not map.is_valid() or NavigationServer2D.map_get_iteration_id(map) == 0:
return {"valid": false, "distance": INF}
var from_feet: Vector2 = from_root - StickmanLocomotion.FOOT_OFFSET
var to_feet: Vector2 = to_root - StickmanLocomotion.FOOT_OFFSET
var path: PackedVector2Array = NavigationServer2D.map_get_path(map, from_feet, to_feet, true)
if path.is_empty() or path[0].distance_to(from_feet) > 180.0 or path[path.size() - 1].distance_to(to_feet) > 180.0:
return {"valid": false, "distance": INF}
if not _actor_path_is_clear(rig, from_feet, path, from_root - rig.global_position):
return {"valid": false, "distance": INF}
var distance: float = 0.0
for index: int in range(1, path.size()):
distance += path[index - 1].distance_to(path[index])
if distance > MAX_ROUTE_LENGTH:
return {"valid": false, "distance": INF}
return {"valid": true, "distance": distance}
static func _actor_path_is_clear(rig: StickmanRig, from_feet: Vector2, path: PackedVector2Array, initial_offset: Vector2) -> bool:
var state: PhysicsDirectSpaceState2D = rig.get_world_2d().direct_space_state
if state == null:
return false
var excluded: Array[RID] = rig.get_collision_body_rids()
var bodies: Dictionary = rig.get_collision_bodies()
var route_points: PackedVector2Array = PackedVector2Array()
route_points.append(from_feet)
route_points.append_array(path)
var offset: Vector2 = initial_offset
for index: int in range(1, route_points.size()):
var motion: Vector2 = route_points[index] - route_points[index - 1]
if absf(motion.x) <= 0.001:
offset += motion
continue
for part_name: String in ["torso", "head"]:
var proxy: AnimatableBody2D = bodies.get(part_name) as AnimatableBody2D
if proxy == null or not is_instance_valid(proxy):
continue
var shape_node: CollisionShape2D = proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
if shape_node == null or shape_node.shape == null:
continue
var query := PhysicsShapeQueryParameters2D.new()
query.shape = shape_node.shape
query.transform = shape_node.global_transform
query.transform.origin += offset
query.motion = motion
query.collision_mask = STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER
query.exclude = excluded
var cast: PackedFloat32Array = state.cast_motion(query)
if cast.size() > 0 and cast[0] < 0.999:
return false
offset += motion
return true
static func _has_ground_support(rig: StickmanRig, root_position: Vector2) -> bool:
var state: PhysicsDirectSpaceState2D = rig.get_world_2d().direct_space_state
if state == null:
return false
var feet: Vector2 = root_position - StickmanLocomotion.FOOT_OFFSET
var query: PhysicsRayQueryParameters2D = PhysicsRayQueryParameters2D.create(feet + Vector2(0.0, -24.0), feet + Vector2(0.0, 88.0))
query.collision_mask = STAGE_PHYSICS.TERRAIN_LAYER
query.exclude = rig.get_collision_body_rids()
var hit: Dictionary = state.intersect_ray(query)
return not hit.is_empty() and (hit.get("normal", Vector2.UP) as Vector2).dot(Vector2.UP) >= 0.7
static func _standing_clear(rig: StickmanRig, root_position: Vector2) -> bool:
var state: PhysicsDirectSpaceState2D = rig.get_world_2d().direct_space_state
if state == null:
return false
var motion: Vector2 = root_position - rig.global_position
var excluded: Array[RID] = rig.get_collision_body_rids()
for proxy: AnimatableBody2D in rig.get_collision_bodies().values():
if proxy == null or not is_instance_valid(proxy):
continue
var shape_node: CollisionShape2D = proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
if shape_node == null or shape_node.shape == null:
continue
var query: PhysicsShapeQueryParameters2D = PhysicsShapeQueryParameters2D.new()
query.shape = shape_node.shape
var candidate_transform: Transform2D = shape_node.global_transform
candidate_transform.origin += motion
query.transform = candidate_transform
query.collision_mask = STAGE_PHYSICS.ACTOR_LAYER | STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER
query.exclude = excluded
query.margin = 2.0
if not state.intersect_shape(query, 8).is_empty():
return false
return true
static func _has_any_ground_route(rig: StickmanRig, actor_root: Vector2, spot_world: Vector2) -> bool:
for side: float in [-1.0, 1.0]:
var candidate: Vector2 = Vector2(spot_world.x + side * APPROACH_GAP, actor_root.y)
var locomotion: StickmanLocomotion = rig.get_locomotion()
if locomotion == null or locomotion._nav_agent == null:
continue
var nav: NavigationAgent2D = locomotion._nav_agent
var map: RID = nav.get_navigation_map()
if not map.is_valid() or NavigationServer2D.map_get_iteration_id(map) == 0:
continue
var from_feet: Vector2 = actor_root - StickmanLocomotion.FOOT_OFFSET
var to_feet: Vector2 = candidate - StickmanLocomotion.FOOT_OFFSET
var path: PackedVector2Array = NavigationServer2D.map_get_path(map, from_feet, to_feet, true)
if not path.is_empty() and path[0].distance_to(from_feet) <= 180.0 and path[path.size() - 1].distance_to(to_feet) <= 180.0 and _actor_path_is_clear(rig, from_feet, path, actor_root - rig.global_position):
return true
return false
@@ -0,0 +1 @@
uid://doq1r0f3qgr2y