Implement Phase 17a Prop Roles UI/UX Specification and associated functionality

- Added UI/UX specification for prop roles and set down mechanics in phase 17a.
- Created `PropStageRoles` script to manage normalized behavior for props, including depth, motion, and collision settings.
- Introduced `StagePhysicsPolicy` for shared physics configurations related to props and actors.
- Developed comprehensive tests for prop roles, including default settings, legacy migration, serialization, and runtime behavior.
- Ensured proper handling of set down actions, including clearance and orientation preservation.
This commit is contained in:
2026-10-09 23:11:34 -04:00
parent 3f66cc3195
commit 1d4a71525a
52 changed files with 20051 additions and 109 deletions
@@ -69,8 +69,9 @@ func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void:
ActionPhase.NONE:
_advance_to_next_action()
ActionPhase.WALKING:
if _locomotion != null and _locomotion.consume_walk_completed():
_finish_action()
if _locomotion != null:
if _locomotion.consume_walk_failed() or _locomotion.consume_walk_completed():
_finish_action()
ActionPhase.SPEAKING:
if _primary_speech.is_empty():
_finish_action()
@@ -398,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"
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"
if requires_animated and _rig != null:
if _rig.state == StickmanRig.RigState.RECOVERING:
_run_queue.insert(_run_cursor, _current_run_item.duplicate(true))
@@ -442,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":
"pick_up_prop", "drop_prop", "throw_prop", "set_down_prop":
if _rig == null or not _rig.start_prop_action(action):
_finish_action()
else:
@@ -4,6 +4,7 @@ extends Node
## StickmanRig schedules this component and exposes its public facade.
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
const STAGE_PHYSICS := preload("res://scripts/stage/stage_physics_policy.gd")
enum TerrainMotion { DISABLED, AIRBORNE, GROUNDED, LANDING_IMPACT }
@@ -69,6 +70,7 @@ const ARM_COLLISION_RADIUS: float = 12.0
const LEG_COLLISION_RADIUS: float = 14.0
const HEAD_COLLISION_FALLBACK_CENTER: Vector2 = Vector2(0.0, -72.0)
const COLLISION_LAYER: int = 1
const WALK_BLOCKED_TIMEOUT: float = 0.45
const COLLISION_PARTS: Dictionary = {
"head": {"visual": "Head", "bone": "Torso/Head", "shape": "circle", "radius": HEAD_COLLISION_RADIUS},
"torso": {"visual": "Body", "bone": "Torso", "shape": "capsule", "radius": TORSO_COLLISION_RADIUS},
@@ -140,6 +142,8 @@ var _arrival_emitted: bool = false
var _walk_done: bool = false
var _leg_base_markers: Dictionary = {}
var _last_grounded_walk_velocity_x: float = 0.0
var _blocked_walk_elapsed: float = 0.0
var _walk_failed: bool = false
var collision_bodies: Dictionary = {}
var collision_lengths: Dictionary = {}
var _collision_visuals: Dictionary = {}
@@ -190,8 +194,8 @@ func setup_collision_bodies() -> void:
continue
var proxy := AnimatableBody2D.new()
proxy.name = "CollisionBody"
proxy.collision_layer = COLLISION_LAYER
proxy.collision_mask = COLLISION_LAYER
proxy.collision_layer = STAGE_PHYSICS.ACTOR_LAYER
proxy.collision_mask = STAGE_PHYSICS.actor_mask()
proxy.sync_to_physics = false
proxy.set_meta("stickman_rig", _rig)
proxy.set_meta("part_name", key)
@@ -254,11 +258,12 @@ func mounted_visual_bounds_local(part: Node2D) -> Dictionary:
func set_collision_bodies_enabled(enabled: bool) -> void:
var layer: int = COLLISION_LAYER if enabled else 0
var layer: int = STAGE_PHYSICS.ACTOR_LAYER if enabled else 0
var mask: int = STAGE_PHYSICS.actor_mask() if enabled else 0
for proxy: AnimatableBody2D in collision_bodies.values():
if proxy != null and is_instance_valid(proxy):
proxy.collision_layer = layer
proxy.collision_mask = layer
proxy.collision_mask = mask
func apply_collision_reaction(part_name: String, impact_direction: Vector2, impact_speed: float, impact_position: Vector2 = Vector2.INF) -> void:
@@ -378,6 +383,8 @@ func move_to(target: Vector2, style: String = ACTION_REGISTRY.WALK, speed_overri
_walk_speed_current = speed_override if speed_override > 0.0 else default_pace
_nav_agent.max_speed = _walk_speed_current
_walk_done = false
_walk_failed = false
_blocked_walk_elapsed = 0.0
_arrival_emitted = false
_settling = false
_settle_waiting_for_ground = false
@@ -387,6 +394,10 @@ func move_to(target: Vector2, style: String = ACTION_REGISTRY.WALK, speed_overri
_reset_foot_contacts()
var target_root: Vector2 = target + FOOT_OFFSET
if (target - (_rig.global_position - FOOT_OFFSET)).length() <= ARRIVE_DISTANCE:
var close_motion: Vector2 = target_root - _rig.global_position
var close_sweep: Dictionary = _sweep_actor_blocking_props(close_motion)
if float(close_sweep.get("fraction", 1.0)) < 0.999:
return false
_rig.global_position = target_root
_walking = false
_walk_done = true
@@ -411,6 +422,8 @@ func move_to(target: Vector2, style: String = ACTION_REGISTRY.WALK, speed_overri
func cancel_walk(restore_idle_pose: bool = true) -> void:
if _rig == null:
return
_walk_failed = false
_blocked_walk_elapsed = 0.0
var current_limb_rotations: Dictionary = _rig._capture_limb_rotations()
if _rig._anim_player != null and is_instance_valid(_rig._anim_player):
_rig._anim_player.stop(false)
@@ -457,6 +470,12 @@ func consume_walk_completed() -> bool:
_walk_done = false
return true
func consume_walk_failed() -> bool:
if not _walk_failed:
return false
_walk_failed = false
return true
func _update_walking(delta: float) -> void:
if not _walking:
return
@@ -485,6 +504,12 @@ func _update_walking(delta: float) -> void:
var root_target: Vector2
if _walk_mode == "nav":
if _nav_agent.is_navigation_finished() and dist_to_final <= 2.0 * ARRIVE_DISTANCE:
var snap_motion := Vector2(final_root.x - _rig.global_position.x, 0.0)
var snap_sweep: Dictionary = _sweep_actor_blocking_props(snap_motion)
if float(snap_sweep.get("fraction", 1.0)) < 0.999:
_update_blocked_walk(delta)
return
_blocked_walk_elapsed = 0.0
_rig.global_position.x = final_root.x
_finish_walk()
return
@@ -499,12 +524,68 @@ func _update_walking(delta: float) -> void:
else:
root_target = Vector2(final_root.x, _rig.global_position.y)
var before_x: float = _rig.global_position.x
_rig.global_position = _rig.global_position.move_toward(root_target, _walk_speed_current * delta)
var requested_motion := _rig.global_position.move_toward(root_target, _walk_speed_current * delta) - _rig.global_position
var sweep: Dictionary = _sweep_actor_blocking_props(requested_motion)
var safe_fraction: float = float(sweep.get("fraction", 1.0))
_rig.global_position += requested_motion * safe_fraction
if safe_fraction < 0.999:
_update_blocked_walk(delta)
else:
_blocked_walk_elapsed = 0.0
_last_grounded_walk_velocity_x = (_rig.global_position.x - before_x) / delta if delta > 0.0 else 0.0
if _walk_failed:
return
if dist_to_final <= ARRIVE_DISTANCE or absf(_rig.global_position.x - final_root.x) <= ARRIVE_DISTANCE:
var arrival_motion := Vector2(final_root.x - _rig.global_position.x, 0.0)
var arrival_sweep: Dictionary = _sweep_actor_blocking_props(arrival_motion)
if float(arrival_sweep.get("fraction", 1.0)) < 0.999:
_update_blocked_walk(delta)
return
_rig.global_position.x = final_root.x
_finish_walk()
func _update_blocked_walk(delta: float) -> void:
_blocked_walk_elapsed += delta
if _blocked_walk_elapsed < WALK_BLOCKED_TIMEOUT:
return
cancel_walk(false)
_walk_failed = true
_blocked_walk_elapsed = 0.0
func _sweep_actor_blocking_props(motion: Vector2) -> Dictionary:
if _rig == null or is_zero_approx(motion.x):
return {"fraction": 1.0}
var space_state: PhysicsDirectSpaceState2D = _rig.get_world_2d().direct_space_state
if space_state == null:
return {"fraction": 1.0}
var excluded: Array[RID] = []
for own_proxy: AnimatableBody2D in collision_bodies.values():
if own_proxy != null and is_instance_valid(own_proxy):
excluded.append(own_proxy.get_rid())
var held: PropBlock = _rig.get_held_prop() if _rig.has_method("get_held_prop") else null
if held != null and is_instance_valid(held):
excluded.append(held.get_rid())
var safe_fraction: float = 1.0
for part_name: String in ["torso", "head"]:
var proxy: AnimatableBody2D = collision_bodies.get(part_name) as AnimatableBody2D
if proxy == null or not is_instance_valid(proxy):
continue
var shape_node := 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.motion = motion
query.collision_mask = STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER
query.exclude = excluded
var cast: PackedFloat32Array = space_state.cast_motion(query)
if cast.size() >= 1:
safe_fraction = minf(safe_fraction, cast[0])
return {"fraction": safe_fraction}
func _settle_walk_markers(delta: float) -> void:
if not _settling or _rig == null:
return
@@ -984,7 +1065,7 @@ func _sweep_airborne_horizontal_motion(motion: Vector2) -> Vector2:
query.shape = shape_node.shape
query.transform = shape_node.global_transform
query.motion = motion
query.collision_mask = COLLISION_LAYER
query.collision_mask = STAGE_PHYSICS.ACTOR_QUERY_MASK
query.exclude = excluded
var cast: PackedFloat32Array = space_state.cast_motion(query)
if cast.size() >= 1:
@@ -4,14 +4,18 @@ extends Node
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")
enum Hand { LEFT, RIGHT }
enum Phase { IDLE, APPROACHING, REACHING, PICKUP_RECOVERING, HELD, THROWING }
enum Phase { IDLE, APPROACHING, REACHING, PICKUP_RECOVERING, HELD, THROWING, SET_DOWN_APPROACHING, SET_DOWN_REACHING, SET_DOWN_RECOVERING }
const MAX_REACH: float = 390.0
const APPROACH_GAP: float = 90.0
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 PICKUP_RECOVER_DURATION: float = 0.32
const LOW_PICKUP_THRESHOLD: float = 110.0
const LOW_PICKUP_MAX_DROP: float = 110.0
@@ -29,6 +33,7 @@ const THROW_SPIN_SPEED: float = 2.4
const THROW_TRAJECTORY_SEGMENTS: int = 24
const THROW_HEAD_CLEARANCE: float = 36.0
const THROW_REACH_MARGIN: float = 8.0
const PROP_TRACE_ENABLED: bool = true
signal action_completed
signal prop_event(event_type: String, prop: PropBlock, reason: String)
@@ -69,9 +74,33 @@ var _throw_torso_windup_rotation: float = 0.0
var _approach_replans: int = 0
var _marker_start_position: Vector2 = Vector2.ZERO
var _saved_body_state: Dictionary = {}
var _pickup_transform: Transform2D = Transform2D.IDENTITY
var _pickup_transform_valid: bool = false
var _last_transform: Transform2D = Transform2D.IDENTITY
var _sampled_linear_velocity: Vector2 = Vector2.ZERO
var _sampled_angular_velocity: float = 0.0
var _set_down_target: Vector2 = Vector2.ZERO
var _set_down_grip_target: Vector2 = Vector2.ZERO
var _set_down_approach_root: Vector2 = Vector2.ZERO
var _set_down_facing_left: bool = false
var _set_down_release_transform: Transform2D = Transform2D.IDENTITY
var _set_down_reach_start_transform: Transform2D = Transform2D.IDENTITY
var _set_down_recover_elapsed: float = 0.0
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
func _process(delta: float) -> void:
if not PROP_TRACE_ENABLED or _trace_released_prop == null or not is_instance_valid(_trace_released_prop) or _trace_released_watch_remaining <= 0.0:
return
_trace_released_elapsed += delta
_trace_released_watch_remaining -= delta
if _trace_released_elapsed < 0.5:
return
_trace_released_elapsed = 0.0
_trace_prop_state("released_watch", _trace_released_prop)
func _exit_tree() -> void:
@@ -135,6 +164,8 @@ func start_action(action: Dictionary) -> bool:
_throw_released = false
_phase = Phase.THROWING
return true
"set_down_prop":
return _begin_set_down(action)
return false
@@ -143,10 +174,24 @@ func tick(delta: float) -> void:
return
match _phase:
Phase.APPROACHING:
if _locomotion != null and bool(_locomotion.call("consume_walk_failed")):
_fail_pickup_approach("Too far to reach")
return
if _locomotion != null and bool(_locomotion.call("consume_walk_completed")):
_on_approach_arrived()
Phase.SET_DOWN_APPROACHING:
_update_held_transform(delta)
if _locomotion != null and bool(_locomotion.call("consume_walk_failed")):
_fail_set_down("Too far to reach")
return
if _locomotion != null and bool(_locomotion.call("consume_walk_completed")):
_on_set_down_approach_arrived()
Phase.REACHING:
_update_reach(delta)
Phase.SET_DOWN_REACHING:
_update_set_down_reach(delta)
Phase.SET_DOWN_RECOVERING:
_update_set_down_recovery(delta)
Phase.PICKUP_RECOVERING:
_update_pickup_recovery(delta)
Phase.HELD:
@@ -156,8 +201,14 @@ func tick(delta: float) -> void:
func cancel_action() -> void:
if _phase == Phase.APPROACHING and _locomotion != null:
if (_phase == Phase.APPROACHING or _phase == Phase.SET_DOWN_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()
_restore_pickup_torso()
_phase = Phase.HELD if _held_prop != null and is_instance_valid(_held_prop) else Phase.IDLE
_active_action.clear()
return
if _phase == Phase.APPROACHING or _phase == Phase.REACHING:
_clear_reservation()
_restore_hand_target()
@@ -224,6 +275,33 @@ func preview_throw(target: Vector2, queue_prefix: Array[Dictionary] = []) -> Dic
return _clip_preview_to_collision(solution, prop)
func preview_set_down(target: Vector2, queue_prefix: Array[Dictionary] = []) -> Dictionary:
if not target.is_finite():
return {"known": false, "valid": false, "reason": "Prop depends on earlier actions"}
var context := _build_throw_preview_context(queue_prefix)
if not bool(context.get("available", false)) or bool(context.get("uncertain", false)):
return {
"known": false,
"valid": false,
"reason": "Prop depends on earlier actions",
"target_position": target,
}
var prop: PropBlock = context["prop"]
var result := _solve_set_down_target(
target,
prop,
context.get("anchor", {}),
int(context.get("hand", Hand.RIGHT)),
context.get("root_position", _rig.global_position),
context.get("root_offset", Vector2.ZERO),
bool(context.get("facing_left", _is_facing_left())),
true
)
result["known"] = true
result["prop"] = prop
return result
func _build_throw_preview_context(queue_prefix: Array[Dictionary]) -> Dictionary:
if _rig == null or not is_instance_valid(_rig):
return {"available": false, "reason": "The acting stickman is unavailable."}
@@ -297,7 +375,7 @@ func _build_throw_preview_context(queue_prefix: Array[Dictionary]) -> Dictionary
has_prop = true
planned = true
plan_step = int(entry.get("step", 0))
"drop_prop", "throw_prop":
"drop_prop", "throw_prop", "set_down_prop":
if has_prop:
prop = null
anchor.clear()
@@ -315,6 +393,7 @@ func _build_throw_preview_context(queue_prefix: Array[Dictionary]) -> Dictionary
"hand": hand_value,
"root_position": root_position,
"root_offset": root_offset,
"facing_left": predicted_facing_left,
"planned": planned,
"uncertain": uncertain,
"plan_step": plan_step,
@@ -479,19 +558,22 @@ 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_windup := _phase == Phase.THROWING and not _throw_released
if _phase == Phase.APPROACHING or _phase == Phase.REACHING or _phase == Phase.PICKUP_RECOVERING:
if interrupted_pickup or interrupted_set_down:
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_windup:
if interrupted_pickup or interrupted_set_down or interrupted_windup:
action_completed.emit()
func reset_for_scene_exit() -> void:
if _phase == Phase.APPROACHING or _phase == Phase.REACHING or _phase == Phase.PICKUP_RECOVERING or (_phase == Phase.THROWING and not _throw_released):
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):
cancel_action()
if _held_prop != null and is_instance_valid(_held_prop):
_release_held("scene_reset", false)
@@ -501,6 +583,8 @@ func reset_for_scene_exit() -> void:
func _begin_pickup(prop_id: String, requested_hand: String, action: Dictionary) -> bool:
if PROP_TRACE_ENABLED:
print("[PropTrace] pickup_begin actor=%s target_id=%s hand=%s phase=%s" % [_trace_actor_name(), prop_id, requested_hand, Phase.keys()[int(_phase)]])
if _phase != Phase.IDLE or _held_prop != null or _rig == null:
return false
if int(_rig.get("state")) != 0:
@@ -511,8 +595,11 @@ func _begin_pickup(prop_id: String, requested_hand: String, action: Dictionary)
if not resolved is PropBlock:
return false
var prop := resolved as PropBlock
_trace_prop_state("pickup_resolved", prop)
if not is_instance_valid(prop) or prop.is_queued_for_deletion():
return false
if not prop.can_be_picked_up():
return false
if prop.interaction_owner_id != 0 or prop.interaction_reserved_by != 0:
return false
var picked := _choose_grip_and_hand(prop, requested_hand)
@@ -567,6 +654,506 @@ func _choose_grip_and_hand(prop: PropBlock, requested_hand: String) -> Dictionar
return candidates[0]
func _begin_set_down(action: Dictionary) -> bool:
if _held_prop == null or not is_instance_valid(_held_prop):
prop_event.emit("set_down_failed", null, "No prop is held")
return false
var raw_target: Variant = action.get("target_position", Vector2.INF)
if not raw_target is Vector2 or not (raw_target as Vector2).is_finite():
prop_event.emit("set_down_failed", _held_prop, "Choose a release place")
return false
if _phase != Phase.HELD and _phase != Phase.IDLE:
return false
var target := raw_target as Vector2
var preview := _solve_set_down_target(target, _held_prop, _held_anchor, int(_hand), _rig.global_position, Vector2.ZERO, _is_facing_left(), true)
if PROP_TRACE_ENABLED:
var resolved_transform: Transform2D = preview.get("release_transform", Transform2D.IDENTITY)
print("[PropTrace] set_down_begin actor=%s prop=%s requested=%s valid=%s reason=%s resolved=%s approach=%s grip=%s" % [_trace_actor_name(), _trace_prop_id(_held_prop), str(target), str(preview.get("valid", false)), String(preview.get("reason", "")), str(resolved_transform.origin), str(preview.get("approach_position", Vector2.INF)), str(preview.get("grip_position", Vector2.INF))])
if not bool(preview.get("valid", false)):
prop_event.emit("set_down_failed", _held_prop, String(preview.get("reason", "Too far to reach")))
return false
_set_down_target = target
_set_down_grip_target = preview["grip_position"]
_set_down_approach_root = preview["approach_position"]
_set_down_facing_left = bool(preview["facing_left"])
_set_down_release_transform = preview["release_transform"]
_active_action = action.duplicate(true)
if absf(_set_down_approach_root.x - _rig.global_position.x) > 8.0:
if _set_down_facing_left != _is_facing_left():
_rig.call("set_facing_profile", 0 if _set_down_facing_left else 1)
var foot_offset: Vector2 = _locomotion.call("get_foot_offset")
var target_feet := _set_down_approach_root - foot_offset
if not bool(_locomotion.call("move_to", target_feet, ACTION_REGISTRY.RUN)):
_active_action.clear()
prop_event.emit("set_down_failed", _held_prop, "Too far to reach")
return false
_phase = Phase.SET_DOWN_APPROACHING
_trace_prop_state("set_down_approaching", _held_prop)
return true
_begin_set_down_reach()
return true
func _solve_set_down_target(target: Vector2, prop: PropBlock, anchor: Dictionary, hand_value: int, actor_root: Vector2, root_offset: Vector2, facing_left: bool, check_occupancy: bool) -> Dictionary:
if not target.is_finite() or prop == null or not is_instance_valid(prop):
return {"valid": false, "reason": "Too far to reach"}
var release_transform := _set_down_transform_for_target(prop, target)
var clearance_lift := 0.0
if check_occupancy:
var resolved := _resolve_set_down_release_transform(prop, target)
if not bool(resolved.get("valid", false)):
return {"valid": false, "reason": String(resolved.get("reason", "No room to let go here")), "release_transform": release_transform}
release_transform = resolved["release_transform"]
clearance_lift = float(resolved.get("clearance_lift", 0.0))
var anchor_position: Vector2 = (anchor.get("position", Vector2.ZERO) as Vector2) * prop.get_interaction_scale()
var prop_geometry := _set_down_collision_geometry(prop)
var prop_shape: Shape2D = prop_geometry.get("shape") as Shape2D
var prop_bounds := _set_down_shape_bounds(prop_shape, release_transform * (prop_geometry.get("local_transform", Transform2D.IDENTITY) as Transform2D)) if prop_shape != null else Rect2(target, Vector2.ZERO)
var candidates: Array[Dictionary] = []
var current_grip_local := anchor_position
if facing_left:
current_grip_local.x = -current_grip_local.x
var current_grip := release_transform * current_grip_local
var current_wrist := _preview_wrist_position(hand_value, root_offset, facing_left)
if current_wrist.distance_to(current_grip) <= MAX_REACH and not _set_down_actor_overlaps_release(prop_bounds, actor_root, facing_left):
candidates.append({
"root": actor_root,
"facing_left": facing_left,
"grip": current_grip,
"distance": current_wrist.distance_to(current_grip),
})
for actor_on_right: bool in [false, true]:
var candidate_facing_left := actor_on_right
var grip_local := anchor_position
if candidate_facing_left:
grip_local.x = -grip_local.x
var grip_world := release_transform * grip_local
var side := 1.0 if actor_on_right else -1.0
var approach_root := Vector2(grip_world.x + side * APPROACH_GAP, actor_root.y)
for body_rect: Rect2 in _set_down_actor_body_rects_relative(candidate_facing_left):
var body_world := body_rect
body_world.position += actor_root
if body_world.end.y <= prop_bounds.position.y or body_world.position.y >= prop_bounds.end.y:
continue
if actor_on_right:
approach_root.x = maxf(approach_root.x, prop_bounds.end.x - body_rect.position.x + 8.0)
else:
approach_root.x = minf(approach_root.x, prop_bounds.position.x - body_rect.end.x - 8.0)
var candidate_offset := root_offset + (approach_root - actor_root)
var wrist := _preview_wrist_position(hand_value, candidate_offset, candidate_facing_left)
var distance := wrist.distance_to(grip_world)
if distance <= MAX_REACH and _set_down_route_is_clear(actor_root, approach_root, root_offset):
candidates.append({
"root": approach_root,
"facing_left": candidate_facing_left,
"grip": grip_world,
"distance": distance,
})
if candidates.is_empty():
return {"valid": false, "reason": "Too far to reach", "release_transform": release_transform}
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]
if not _set_down_route_is_clear(actor_root, chosen["root"], root_offset):
return {"valid": false, "reason": "Too far to reach", "release_transform": release_transform}
return {
"valid": true,
"reason": "",
"release_transform": release_transform,
"requested_target": target,
"clearance_lift": clearance_lift,
"target_adjusted": clearance_lift > 0.01,
"grip_position": chosen["grip"],
"approach_position": chosen["root"],
"facing_left": bool(chosen["facing_left"]),
"distance": float(chosen["distance"]),
}
func _set_down_route_is_clear(start_root: Vector2, target_root: Vector2, root_offset: Vector2) -> bool:
if _rig == null or not is_instance_valid(_rig):
return false
var nav_agent := _rig.get_node_or_null(NodePath("NavigationAgentAnchor/NavigationAgent2D")) as NavigationAgent2D
if nav_agent != null:
var nav_map: RID = nav_agent.get_navigation_map()
if NavigationServer2D.map_get_iteration_id(nav_map) > 0:
var foot_offset: Vector2 = _locomotion.call("get_foot_offset")
var path: PackedVector2Array = NavigationServer2D.map_get_path(nav_map, start_root - foot_offset, target_root - foot_offset, true)
if path.is_empty():
return false
var delta := Vector2(target_root.x - start_root.x, 0.0)
if is_zero_approx(delta.x):
return true
var space_state := _rig.get_world_2d().direct_space_state
if space_state == null:
return true
var excluded: Array[RID] = []
for rid: RID in _rig.call("get_collision_body_rids"):
excluded.append(rid)
if _held_prop != null and is_instance_valid(_held_prop):
excluded.append(_held_prop.get_rid())
var collision_bodies: Dictionary = _locomotion.get("collision_bodies") if _locomotion != null else {}
for part_name: String in ["torso", "head"]:
var proxy := collision_bodies.get(part_name) as AnimatableBody2D
if proxy == null or not is_instance_valid(proxy):
continue
var shape_node := 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 += root_offset
query.motion = delta
query.collision_mask = STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER
query.exclude = excluded
var cast: PackedFloat32Array = space_state.cast_motion(query)
if not cast.is_empty() and cast[0] < 0.999:
return false
return true
func _resolve_set_down_release_transform(prop: PropBlock, target: Vector2) -> Dictionary:
if prop == null or not is_instance_valid(prop):
return {"valid": false, "reason": "No room to let go here"}
var release_transform := _set_down_transform_for_target(prop, target)
var total_lift := 0.0
for _attempt: int in 8:
var overlap_info := _set_down_target_overlap_info(prop, release_transform)
var overlaps: Array = overlap_info.get("overlaps", [])
if overlaps.is_empty():
return {
"valid": true,
"release_transform": release_transform,
"clearance_lift": total_lift,
}
if bool(overlap_info.get("blocked", false)):
if PROP_TRACE_ENABLED:
for entry: Dictionary in overlaps:
var blocker: Object = entry.get("collider")
var blocker_position := (blocker as Node2D).global_position if blocker is Node2D else Vector2.INF
var part_name := String((blocker as Node).get_meta("part_name", "")) if blocker is Node else ""
print("[PropTrace] set_down_blocker prop=%s collider=%s class=%s part=%s adjustable=%s position=%s" % [_trace_prop_id(prop), String((blocker as Node).name) if blocker is Node else "<unknown>", blocker.get_class() if blocker is Object else "<unknown>", part_name, str(entry.get("adjustable", false)), str(blocker_position)])
return {"valid": false, "reason": "No room to let go here", "release_transform": release_transform}
var highest_support_top := INF
for entry: Dictionary in overlaps:
var collider: Object = entry.get("collider")
var support_top := _set_down_support_top_y(collider)
if not is_finite(support_top):
return {"valid": false, "reason": "No room to let go here", "release_transform": release_transform}
highest_support_top = minf(highest_support_top, support_top)
var geometry: Dictionary = _set_down_collision_geometry(prop)
var shape: Shape2D = geometry.get("shape") as Shape2D
if shape == null:
return {"valid": true, "release_transform": release_transform, "clearance_lift": total_lift}
var shape_transform: Transform2D = release_transform * (geometry.get("local_transform", Transform2D.IDENTITY) as Transform2D)
var shape_bounds := _set_down_shape_bounds(shape, shape_transform)
var lift := shape_bounds.end.y - highest_support_top + 2.0
if not is_finite(lift) or lift <= 0.0:
lift = 2.0
total_lift += lift
if total_lift > 8192.0:
return {"valid": false, "reason": "No room to let go here", "release_transform": release_transform}
release_transform.origin.y -= lift
return {"valid": false, "reason": "No room to let go here", "release_transform": release_transform}
func _set_down_collision_geometry(prop: PropBlock) -> Dictionary:
var collision_shape: Shape2D = null
var local_transform := Transform2D.IDENTITY
var polygon_node := prop.get_node_or_null(NodePath("CollisionPolygon2D")) as CollisionPolygon2D
if polygon_node != null and not polygon_node.disabled and polygon_node.polygon.size() >= 3:
var points := Geometry2D.convex_hull(polygon_node.polygon)
if points.size() >= 3:
var convex := ConvexPolygonShape2D.new()
convex.points = points
collision_shape = convex
local_transform = prop.global_transform.affine_inverse() * polygon_node.global_transform
var circle_node := prop.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
if collision_shape == null and circle_node != null and circle_node.shape != null and not circle_node.disabled:
collision_shape = circle_node.shape
local_transform = prop.global_transform.affine_inverse() * circle_node.global_transform
if collision_shape == null:
return {}
return {"shape": collision_shape, "local_transform": local_transform}
func _set_down_target_overlap_info(prop: PropBlock, release_transform: Transform2D) -> Dictionary:
var geometry := _set_down_collision_geometry(prop)
var collision_shape: Shape2D = geometry.get("shape") as Shape2D
if collision_shape == null:
return {"overlaps": [], "blocked": false}
var space_state: PhysicsDirectSpaceState2D = prop.get_world_2d().direct_space_state
if space_state == null:
return {"overlaps": [], "blocked": false}
var query := PhysicsShapeQueryParameters2D.new()
query.shape = collision_shape
query.transform = release_transform * (geometry.get("local_transform", Transform2D.IDENTITY) as Transform2D)
query.collision_mask = STAGE_PHYSICS.TERRAIN_LAYER | STAGE_PHYSICS.PROP_LAYER
if bool(prop.stage_roles.get("released_blocks_stickmen", true)):
query.collision_mask |= STAGE_PHYSICS.ACTOR_LAYER
query.exclude = [prop.get_rid()]
var overlaps: Array[Dictionary] = space_state.intersect_shape(query, 64)
var blocking_overlaps: Array[Dictionary] = []
var blocked := false
for overlap: Dictionary in overlaps:
var collider: Object = overlap.get("collider")
if collider == null:
continue
if collider is TerrainBlock:
blocking_overlaps.append({"collider": collider, "adjustable": true})
continue
if collider is PropBlock:
var other := collider as PropBlock
if PROP_STAGE_ROLES.props_should_collide(prop.stage_roles, PropBlock.RuntimeRole.RELEASED, other.stage_roles, int(other.runtime_role)):
var adjustable := bool(other.stage_roles.get("holds_other_props", false))
blocking_overlaps.append({"collider": collider, "adjustable": adjustable})
blocked = blocked or not adjustable
continue
if collider is CollisionObject2D and ((collider as CollisionObject2D).collision_layer & STAGE_PHYSICS.ACTOR_LAYER) != 0:
if collider is Node and (collider as Node).has_meta("stickman_rig") and (collider as Node).get_meta("stickman_rig") == _rig:
var part_name := String((collider as Node).get_meta("part_name", ""))
if part_name != "head" and part_name != "torso":
continue
blocking_overlaps.append({"collider": collider, "adjustable": false})
blocked = true
return {"overlaps": blocking_overlaps, "blocked": blocked}
func _set_down_actor_body_rects_relative(facing_left: bool) -> Array[Rect2]:
var result: Array[Rect2] = []
if _rig == null or not is_instance_valid(_rig) or _locomotion == null:
return result
var collision_bodies: Dictionary = _locomotion.get("collision_bodies")
for part_name: String in ["torso", "head"]:
var proxy := collision_bodies.get(part_name) as AnimatableBody2D
if proxy == null or not is_instance_valid(proxy):
continue
var shape_node := proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
if shape_node == null or shape_node.shape == null:
continue
var bounds := _set_down_shape_bounds(shape_node.shape, shape_node.global_transform)
var local_rect := Rect2(bounds.position - _rig.global_position, bounds.size)
if facing_left != _is_facing_left():
local_rect.position.x = -local_rect.end.x
result.append(local_rect)
return result
func _set_down_actor_overlaps_release(prop_bounds: Rect2, actor_root: Vector2, facing_left: bool) -> bool:
for body_rect: Rect2 in _set_down_actor_body_rects_relative(facing_left):
var body_world := body_rect
body_world.position += actor_root
if body_world.intersects(prop_bounds):
return true
return false
func _set_down_shape_bounds(shape: Shape2D, shape_transform: Transform2D) -> Rect2:
var local_rect := shape.get_rect()
var corners := [
local_rect.position,
Vector2(local_rect.end.x, local_rect.position.y),
local_rect.end,
Vector2(local_rect.position.x, local_rect.end.y),
]
var first := true
var bounds := Rect2()
for corner: Vector2 in corners:
var point := shape_transform * corner
if first:
bounds = Rect2(point, Vector2.ZERO)
first = false
else:
bounds = bounds.expand(point)
return bounds
func _set_down_transform_for_target(prop: PropBlock, target: Vector2) -> Transform2D:
var transform := prop.global_transform
if prop == _held_prop and _pickup_transform_valid:
transform = _pickup_transform
transform.origin = target
return transform
func _set_down_support_top_y(collider: Object) -> float:
var collision_polygon: CollisionPolygon2D = null
if collider is TerrainBlock:
collision_polygon = (collider as TerrainBlock).get_node_or_null(NodePath("CollisionPolygon2D")) as CollisionPolygon2D
elif collider is PropBlock:
collision_polygon = (collider as PropBlock).get_node_or_null(NodePath("CollisionPolygon2D")) as CollisionPolygon2D
if collision_polygon != null and not collision_polygon.disabled and collision_polygon.polygon.size() >= 3:
var top_y := INF
for point: Vector2 in collision_polygon.polygon:
top_y = minf(top_y, (collision_polygon.global_transform * point).y)
return top_y
if collider is PropBlock:
var circle_node := (collider as PropBlock).get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
if circle_node != null and circle_node.shape != null and not circle_node.disabled:
return _set_down_shape_bounds(circle_node.shape, circle_node.global_transform).position.y
return INF
func _begin_set_down_reach() -> void:
var marker := _hand_marker()
if marker == null or _held_prop == null or not is_instance_valid(_held_prop):
_fail_set_down("Too far to reach")
return
if _set_down_facing_left != _is_facing_left():
_rig.call("set_facing_profile", 0 if _set_down_facing_left else 1)
_set_down_grip_target = _set_down_release_transform * _scaled_anchor_position(_held_anchor, _held_prop, _set_down_facing_left)
_reach_start = marker.global_position
_reach_target = _set_down_grip_target
_reach_elapsed = 0.0
_set_down_reach_start_transform = _held_prop.global_transform
_prepare_set_down_crouch(_set_down_grip_target)
_phase = Phase.SET_DOWN_REACHING
_trace_prop_state("set_down_reaching", _held_prop)
func _on_set_down_approach_arrived() -> void:
_trace_prop_state("set_down_approach_arrived", _held_prop)
if _held_prop == null or not is_instance_valid(_held_prop):
_fail_set_down("No prop is held")
return
if not _refresh_set_down_release_target():
_fail_set_down("No room to let go here")
return
if _solved_wrist_transform().origin.distance_to(_set_down_grip_target) > MAX_REACH:
_fail_set_down("Too far to reach")
return
_begin_set_down_reach()
func _update_set_down_reach(delta: float) -> void:
if _held_prop == null or not is_instance_valid(_held_prop):
_fail_set_down("No prop is held")
return
var marker := _hand_marker()
if marker == null:
_fail_set_down("Too far to reach")
return
_reach_elapsed += delta
var weight := smoothstep(0.0, 1.0, clampf(_reach_elapsed / SET_DOWN_REACH_DURATION, 0.0, 1.0))
marker.global_position = _reach_start.lerp(_set_down_grip_target, weight)
_update_pickup_crouch(weight)
_update_held_transform(delta, true)
if _pickup_transform_valid:
_held_prop.global_transform = _set_down_reach_attachment_transform(marker.global_position, weight)
if weight < 1.0:
return
if _solved_wrist_transform().origin.distance_to(_set_down_grip_target) > MAX_REACH:
_fail_set_down("Too far to reach")
return
var previous_release_position := _set_down_release_transform.origin
if not _refresh_set_down_release_target():
_fail_set_down("No room to let go here")
return
if previous_release_position.distance_to(_set_down_release_transform.origin) > 1.0:
_reach_start = marker.global_position
_reach_target = _set_down_grip_target
_reach_elapsed = 0.0
_set_down_reach_start_transform = _held_prop.global_transform
return
marker.global_position = _set_down_grip_target
_held_prop.global_transform = _set_down_release_transform
_set_down_recover_hand_start = marker.position
_set_down_recover_elapsed = 0.0
if PROP_TRACE_ENABLED:
print("[PropTrace] set_down_release requested=%s final=%s" % [str(_set_down_target), str(_set_down_release_transform.origin)])
_release_held("set_down", false, Vector2.ZERO, 0.0, true, false)
_phase = Phase.SET_DOWN_RECOVERING
func _refresh_set_down_release_target() -> bool:
if _held_prop == null or not is_instance_valid(_held_prop):
return false
var result := _resolve_set_down_release_transform(_held_prop, _set_down_target)
if not bool(result.get("valid", false)):
return false
_set_down_release_transform = result["release_transform"]
_set_down_grip_target = _set_down_release_transform * _scaled_anchor_position(_held_anchor, _held_prop, _set_down_facing_left)
return true
func _set_down_reach_attachment_transform(grip_position: Vector2, weight: float) -> Transform2D:
var start_rotation := _set_down_reach_start_transform.get_rotation()
var target_rotation := _pickup_transform.get_rotation()
var start_scale := _set_down_reach_start_transform.get_scale()
var target_scale := _pickup_transform.get_scale()
var clamped_weight := clampf(weight, 0.0, 1.0)
var transform := Transform2D(
lerp_angle(start_rotation, target_rotation, clamped_weight),
Vector2.ZERO
).scaled(start_scale.lerp(target_scale, clamped_weight))
transform.origin = grip_position - transform.basis_xform(_scaled_anchor_position(_held_anchor, _held_prop, _set_down_facing_left))
return transform
func _prepare_set_down_crouch(grip_position: Vector2) -> void:
var torso_marker := _torso_marker()
_pickup_uses_crouch = false
if torso_marker == null:
return
_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 grip_local := _rig.to_local(grip_position)
var low_amount := clampf((grip_local.y - _pickup_torso_start.y - LOW_PICKUP_THRESHOLD) / LOW_PICKUP_MAX_DROP, 0.0, 1.0)
if low_amount <= 0.0:
return
var toward_prop := signf(grip_local.x - _pickup_torso_start.x)
if is_zero_approx(toward_prop):
toward_prop = -1.0 if _set_down_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)
func _update_set_down_recovery(delta: float) -> void:
_set_down_recover_elapsed += delta
var weight := smoothstep(0.0, 1.0, clampf(_set_down_recover_elapsed / SET_DOWN_RECOVER_DURATION, 0.0, 1.0))
var marker := _hand_marker()
if marker != null:
marker.position = _set_down_recover_hand_start.lerp(_marker_start_position, weight)
_update_pickup_crouch(1.0 - weight)
if weight < 1.0:
return
_restore_hand_target()
_restore_pickup_torso()
_phase = Phase.IDLE
_active_action.clear()
action_completed.emit()
func _fail_set_down(reason: String) -> void:
if _locomotion != null and _phase == Phase.SET_DOWN_APPROACHING:
_locomotion.call("cancel_walk")
var prop := _held_prop if _held_prop != null and is_instance_valid(_held_prop) else null
if PROP_TRACE_ENABLED:
print("[PropTrace] set_down_failed actor=%s prop=%s reason=%s phase=%s actor_position=%s" % [_trace_actor_name(), _trace_prop_id(prop), reason, Phase.keys()[int(_phase)], str(_rig.global_position if _rig != null else Vector2.INF)])
_restore_hand_target()
_restore_pickup_torso()
_phase = Phase.HELD if prop != null else Phase.IDLE
_active_action.clear()
prop_event.emit("set_down_failed", prop, reason)
action_completed.emit()
func _fail_pickup_approach(reason: String) -> void:
_clear_reservation()
_restore_hand_target()
_restore_pickup_torso()
_phase = Phase.IDLE
_active_action.clear()
prop_event.emit("pickup_failed", null, reason)
action_completed.emit()
func _start_approach(grip_position: Vector2) -> bool:
if _locomotion == null:
return false
@@ -683,13 +1270,12 @@ func _commit_pickup(marker: Marker2D) -> void:
"linear_velocity": _held_prop.linear_velocity,
"angular_velocity": _held_prop.angular_velocity,
}
_pickup_transform = _held_prop.global_transform
_pickup_transform_valid = true
_held_prop.interaction_reserved_by = 0
_held_prop.interaction_owner_id = _rig.get_instance_id()
_held_prop.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
_held_prop.freeze = true
_held_prop.sleeping = false
_held_prop.collision_layer = 0
_held_prop.collision_mask = 0
_held_prop.apply_runtime_role(PropBlock.RuntimeRole.HELD)
_trace_prop_state("pickup_committed", _held_prop)
if _pickup_uses_crouch:
_pickup_recover_elapsed = 0.0
_phase = Phase.PICKUP_RECOVERING
@@ -986,45 +1572,66 @@ func _quadratic_bezier(from: Vector2, control: Vector2, to: Vector2, weight: flo
return from * inverse * inverse + control * (2.0 * inverse * t) + to * t * t
func _release_held(reason: String, throwing: bool, throw_velocity: Vector2 = Vector2.ZERO, throw_spin: float = 0.0) -> void:
func _release_held(reason: String, throwing: bool, throw_velocity: Vector2 = Vector2.ZERO, throw_spin: float = 0.0, zero_velocity: bool = false, restore_hand: bool = true) -> void:
if _held_prop == null or not is_instance_valid(_held_prop):
_clear_held_state()
return
var prop := _held_prop
var release_velocity := throw_velocity if throwing else _sampled_linear_velocity
var release_angular := _sampled_angular_velocity
_trace_prop_state("release_begin:%s" % reason, prop)
var force_still_release: bool = zero_velocity or reason == "scene_reset"
var release_velocity := Vector2.ZERO if force_still_release else (throw_velocity if throwing else _sampled_linear_velocity)
var release_angular := 0.0 if force_still_release else (throw_spin if throwing else _sampled_angular_velocity)
if not release_velocity.is_finite():
release_velocity = Vector2.ZERO
if not is_finite(release_angular):
release_angular = 0.0
prop.interaction_owner_id = 0
prop.interaction_reserved_by = 0
prop.collision_layer = int(_saved_body_state.get("collision_layer", 1))
prop.collision_mask = int(_saved_body_state.get("collision_mask", 1))
prop.z_index = int(_saved_body_state.get("z_index", prop.z_index))
prop.z_as_relative = bool(_saved_body_state.get("z_as_relative", prop.z_as_relative))
prop.freeze_mode = int(_saved_body_state.get("freeze_mode", RigidBody2D.FREEZE_MODE_KINEMATIC))
prop.freeze = false if throwing else bool(_saved_body_state.get("freeze", false))
if throwing:
prop.sleeping = false
prop.linear_velocity = release_velocity
prop.angular_velocity = throw_spin
elif not prop.freeze:
prop.sleeping = false
prop.linear_velocity = release_velocity
prop.angular_velocity = release_angular
var target_role := PropBlock.RuntimeRole.RELEASED
if reason == "scene_reset":
target_role = PropBlock.RuntimeRole.START_PHYSICS if String(prop.stage_roles.get("start_motion", PROP_STAGE_ROLES.START_STILL)) == PROP_STAGE_ROLES.START_PHYSICS else PropBlock.RuntimeRole.START_STILL
prop.apply_runtime_role(target_role)
prop.sleeping = false
prop.linear_velocity = release_velocity
prop.angular_velocity = release_angular
_held_prop = null
_held_anchor.clear()
_saved_body_state.clear()
_pickup_transform_valid = false
_sampled_linear_velocity = Vector2.ZERO
_sampled_angular_velocity = 0.0
if not throwing:
if not throwing and restore_hand:
_restore_hand_target()
_phase = Phase.IDLE if not throwing else Phase.THROWING
if reason == "manual" or reason == "ragdoll":
if reason == "manual" or reason == "ragdoll" or reason == "set_down":
prop_event.emit("prop_dropped", prop, reason)
elif reason == "throw":
prop_event.emit("prop_thrown", prop, "")
if PROP_TRACE_ENABLED:
_trace_released_prop = prop
_trace_released_elapsed = 0.0
_trace_released_watch_remaining = 5.0
_trace_prop_state("release_complete:%s" % reason, prop)
func _trace_actor_name() -> String:
return String(_rig.name) if _rig != null and is_instance_valid(_rig) else "<no actor>"
func _trace_prop_id(prop: PropBlock) -> String:
if prop == null or not is_instance_valid(prop):
return "<none>"
return prop.stage_object_id if not prop.stage_object_id.is_empty() else String(prop.name)
func _trace_prop_state(event: String, prop: PropBlock) -> void:
if not PROP_TRACE_ENABLED:
return
var timestamp_ms := Time.get_ticks_msec()
if prop == null or not is_instance_valid(prop):
print("[PropTrace t=%dms] %s actor=%s prop=<none> phase=%s" % [timestamp_ms, event, _trace_actor_name(), Phase.keys()[int(_phase)]])
return
print("[PropTrace t=%dms] %s actor=%s prop=%s phase=%s role=%d freeze=%s layer=%d mask=%d position=%s rotation=%.3f velocity=%s owner=%d reserved=%d" % [timestamp_ms, event, _trace_actor_name(), _trace_prop_id(prop), Phase.keys()[int(_phase)], int(prop.runtime_role), str(prop.freeze), prop.collision_layer, prop.collision_mask, str(prop.global_position), prop.global_rotation, str(prop.linear_velocity), prop.interaction_owner_id, prop.interaction_reserved_by])
func _clear_reservation() -> void:
@@ -1041,6 +1648,7 @@ func _clear_held_state() -> void:
_held_prop = null
_held_anchor.clear()
_saved_body_state.clear()
_pickup_transform_valid = false
_phase = Phase.IDLE
_active_action.clear()
@@ -1091,6 +1699,13 @@ func _scaled_anchor(anchor: Dictionary, prop: PropBlock) -> Dictionary:
return result
func _scaled_anchor_position(anchor: Dictionary, prop: PropBlock, facing_left: bool) -> Vector2:
var local_position: Vector2 = (anchor.get("position", Vector2.ZERO) as Vector2) * prop.get_interaction_scale()
if facing_left:
local_position.x = -local_position.x
return local_position
func _grip_world_position(prop: PropBlock, anchor: Dictionary) -> Vector2:
return _grip_world_position_for_facing(prop, anchor, _is_facing_left())
@@ -21,6 +21,7 @@ const SIDE_PROFILE_KNEE_BIAS := deg_to_rad(3.0)
const GROUND_PROBE_START_OFFSET := 50.0
const GROUND_PROBE_END_OFFSET := 1500.0
const GROUND_PROBE_TOLERANCE := 200.0
const STAGE_PHYSICS := preload("res://scripts/stage/stage_physics_policy.gd")
const GROUND_PROBE_X_OFFSETS: Array[float] = [0.0, 25.0, -25.0, 50.0, -50.0, 100.0, -100.0]
const STAND_UP_DURATION := 2.0
const STAND_POSE: Dictionary = {
@@ -818,7 +819,7 @@ func _on_stand_up_finished(generation: int) -> void:
func _build_body(entry: Dictionary) -> void:
var key: String = str(entry["key"]); var body := RigidBody2D.new(); body.name = "Ragdoll_" + key
body.mass = float(entry["mass"]); body.linear_damp = float(entry["linear_damp"]); body.angular_damp = float(entry["angular_damp"]); body.collision_layer = 1; body.collision_mask = 1
body.mass = float(entry["mass"]); body.linear_damp = float(entry["linear_damp"]); body.angular_damp = float(entry["angular_damp"]); body.collision_layer = STAGE_PHYSICS.ACTOR_LAYER; body.collision_mask = STAGE_PHYSICS.actor_mask()
body.set_meta("stickman_rig", _rig); body.set_meta("part_name", key); body.set_meta("stickman_ragdoll_body", true)
var shape := CollisionShape2D.new()
if str(entry["kind"]) == "visual":
@@ -538,6 +538,12 @@ func preview_prop_throw(target: Vector2, queue_prefix: Array[Dictionary] = []) -
return _prop_interactor_component.call("preview_throw", target, queue_prefix)
func preview_prop_set_down(target: Vector2, queue_prefix: Array[Dictionary] = []) -> Dictionary:
if _prop_interactor_component == null:
return {"known": false, "valid": false, "reason": "Prop depends on earlier actions"}
return _prop_interactor_component.call("preview_set_down", target, queue_prefix)
func reset_prop_interactions_for_scene_exit() -> void:
if _prop_interactor_component != null:
_prop_interactor_component.call("reset_for_scene_exit")