Add new scene files and implement stage impact resolver

- Created three new scene files: `wobble.scene`, `wobble2.scene`, and `wobble3.scene` with various terrain and prop configurations.
- Introduced `stage_impact_resolver.gd` script to manage impact detection and response between props and stickmen.
- Added unit tests for actor blocking overlap to ensure proper interaction with stationary props.
This commit is contained in:
2026-10-14 13:08:34 -04:00
parent 628c07e70e
commit 5c3b056a13
21 changed files with 1891 additions and 22 deletions
@@ -71,6 +71,9 @@ 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
## Temporary, throttled diagnostics for actor-blocking prop sweep queries.
const ACTOR_PROP_DEBUG_ENABLED: bool = true
const ACTOR_PROP_DEBUG_INTERVAL_FRAMES: int = 30
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},
@@ -83,6 +86,14 @@ const COLLISION_PARTS: Dictionary = {
"right_upper_leg": {"visual": "RightUpperLeg", "bone": "Torso/RightUpperLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
"right_lower_leg": {"visual": "RightLowerLeg", "bone": "Torso/RightUpperLeg/RightLowerLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
}
const ACTOR_BLOCKING_SWEEP_PARTS: Array[String] = [
"torso",
"head",
"left_upper_leg",
"left_lower_leg",
"right_upper_leg",
"right_lower_leg",
]
const REACTION_BASE_ANGLE: float = 0.35
const REACTION_REFERENCE_SPEED: float = 400.0
const REACTION_MAX_ANGLE: float = 0.6
@@ -101,6 +112,7 @@ const REACTION_TARGETS: Dictionary = {
}
signal arrived(target: Vector2)
signal blocking_prop_sweep_hit(prop: PropBlock, normal: Vector2, world_position: Vector2, requested_velocity: Vector2)
var _rig: StickmanRig = null
var foot_contacts: Dictionary = {"Left_Leg": {"valid": false, "locked": false}, "Right_Leg": {"valid": false, "locked": false}}
@@ -146,6 +158,11 @@ var _blocked_walk_elapsed: float = 0.0
var _walk_failed: bool = false
var collision_bodies: Dictionary = {}
var collision_lengths: Dictionary = {}
var collision_proxy_velocities: Dictionary = {}
var _collision_proxy_positions: Dictionary = {}
var _last_sweep_hit_tick: Dictionary = {}
var _actor_prop_debug_last_sample_frame: int = -1
var _actor_prop_debug_last_hit_tick: Dictionary = {}
var _collision_visuals: Dictionary = {}
var reaction_bones: Dictionary = {}
var impact_reactions: Dictionary = {}
@@ -176,6 +193,9 @@ func configure(rig: StickmanRig) -> void:
func setup_collision_bodies() -> void:
collision_bodies.clear()
collision_lengths.clear()
collision_proxy_velocities.clear()
_collision_proxy_positions.clear()
_last_sweep_hit_tick.clear()
_collision_visuals.clear()
reaction_bones.clear()
if _rig == null or _rig._body_container == null or _rig._skeleton == null:
@@ -226,14 +246,42 @@ func setup_collision_bodies() -> void:
proxy.global_transform = visual.global_transform
collision_bodies[key] = proxy
_collision_visuals[key] = visual
_collision_proxy_positions[proxy.get_instance_id()] = proxy.global_position
collision_proxy_velocities[proxy.get_instance_id()] = Vector2.ZERO
func sync_collision_bodies() -> void:
var physics_delta: float = 1.0 / maxf(1.0, float(Engine.physics_ticks_per_second))
for key: String in collision_bodies:
var proxy: AnimatableBody2D = collision_bodies.get(key) as AnimatableBody2D
var visual: Node2D = _collision_visuals.get(key) as Node2D
if proxy != null and visual != null and is_instance_valid(proxy) and is_instance_valid(visual):
var proxy_id: int = proxy.get_instance_id()
var target_position: Vector2 = visual.global_position
var previous_position: Vector2 = _collision_proxy_positions.get(proxy_id, target_position) as Vector2
var velocity: Vector2 = (target_position - previous_position) / physics_delta
collision_proxy_velocities[proxy_id] = velocity if velocity.is_finite() else Vector2.ZERO
proxy.global_transform = visual.global_transform
_collision_proxy_positions[proxy_id] = proxy.global_position
func collision_proxy_velocity(body: PhysicsBody2D) -> Vector2:
if body == null or not is_instance_valid(body):
return Vector2.ZERO
var velocity: Vector2 = collision_proxy_velocities.get(body.get_instance_id(), Vector2.ZERO) as Vector2
return velocity if velocity.is_finite() else Vector2.ZERO
func reset_collision_proxy_velocity_baselines() -> void:
_collision_proxy_positions.clear()
collision_proxy_velocities.clear()
for proxy_value: Variant in collision_bodies.values():
var proxy: AnimatableBody2D = proxy_value as AnimatableBody2D
if proxy == null or not is_instance_valid(proxy):
continue
var proxy_id: int = proxy.get_instance_id()
_collision_proxy_positions[proxy_id] = proxy.global_position
collision_proxy_velocities[proxy_id] = Vector2.ZERO
func mounted_visual_bounds_local(part: Node2D) -> Dictionary:
@@ -362,6 +410,9 @@ func walk_to(target: Vector2, speed: float = -1.0) -> bool:
func move_to(target: Vector2, style: String = ACTION_REGISTRY.WALK, speed_override: float = -1.0) -> bool:
if ACTOR_PROP_DEBUG_ENABLED:
_actor_prop_debug_last_sample_frame = -1
_actor_prop_debug_last_hit_tick.clear()
if _walk_debug_enabled():
print("[WalkDebug] MOVE_TO style=%s target=%s root=%s" % [style, target, _rig.global_position if _rig != null else Vector2.ZERO])
if _rig == null or _rig.state != StickmanRig.RigState.ANIMATED:
@@ -557,8 +608,17 @@ func _update_blocked_walk(delta: float) -> void:
func _sweep_actor_blocking_props(motion: Vector2) -> Dictionary:
if _rig == null or is_zero_approx(motion.x):
return {"fraction": 1.0}
var debug_frame: int = Engine.get_physics_frames()
var debug_sample: bool = ACTOR_PROP_DEBUG_ENABLED and (
_actor_prop_debug_last_sample_frame < 0
or debug_frame - _actor_prop_debug_last_sample_frame >= ACTOR_PROP_DEBUG_INTERVAL_FRAMES
)
if debug_sample:
_actor_prop_debug_last_sample_frame = debug_frame
var space_state: PhysicsDirectSpaceState2D = _rig.get_world_2d().direct_space_state
if space_state == null:
if debug_sample:
print("[PropCollisionDebug][Sweep] no_space_state rig=%s motion=%s" % [_rig.name, str(motion)])
return {"fraction": 1.0}
var excluded: Array[RID] = []
for own_proxy: AnimatableBody2D in collision_bodies.values():
@@ -568,12 +628,24 @@ func _sweep_actor_blocking_props(motion: Vector2) -> Dictionary:
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 best_hit_fraction: float = INF
var best_hit: Dictionary = {}
var requested_velocity: Vector2 = motion * float(Engine.physics_ticks_per_second)
if debug_sample:
print(
"[PropCollisionDebug][Sweep] rig=%s root=%s motion=%s mask=%d excluded=%d"
% [_rig.name, str(_rig.global_position), str(motion), STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER, excluded.size()]
)
for part_name: String in ACTOR_BLOCKING_SWEEP_PARTS:
var proxy: AnimatableBody2D = collision_bodies.get(part_name) as AnimatableBody2D
if proxy == null or not is_instance_valid(proxy):
if debug_sample:
print("[PropCollisionDebug][SweepPart] part=%s missing_proxy" % part_name)
continue
var shape_node := proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
if shape_node == null or shape_node.shape == null:
if debug_sample:
print("[PropCollisionDebug][SweepPart] part=%s missing_shape" % part_name)
continue
var query := PhysicsShapeQueryParameters2D.new()
query.shape = shape_node.shape
@@ -581,11 +653,143 @@ func _sweep_actor_blocking_props(motion: Vector2) -> Dictionary:
query.motion = motion
query.collision_mask = STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER
query.exclude = excluded
# cast_motion() ignores shapes that already overlap at the start. A
# stickman can begin PLAY partly inside a placed prop, so inspect the
# starting pose and block only motion that pushes farther into it. Motion
# away from the contact normal remains free so the stickman can get out.
var start_hit: Dictionary = space_state.get_rest_info(query)
var start_prop: PropBlock = _prop_from_rest_info(start_hit)
var start_normal: Vector2 = start_hit.get("normal", Vector2.ZERO) as Vector2
var start_dot: float = start_normal.dot(motion)
if debug_sample:
print(
"[PropCollisionDebug][SweepPart] part=%s shape=%s origin=%s disabled=%s start_id=%d start_prop=%s start_blocking=%s start_normal=%s dot=%s"
% [
part_name,
shape_node.shape.get_class(),
str(query.transform.origin),
str(shape_node.disabled),
int(start_hit.get("collider_id", 0)),
start_prop.name if start_prop != null and is_instance_valid(start_prop) else "<unresolved>",
str(start_prop.is_actor_blocking()) if start_prop != null and is_instance_valid(start_prop) else "false",
str(start_normal),
str(start_dot),
]
)
if not start_hit.is_empty():
var start_position: Vector2 = start_hit.get("point", Vector2.ZERO) as Vector2
if (
start_prop != null
and is_instance_valid(start_prop)
and start_prop.is_actor_blocking()
and start_normal.is_finite()
and not start_normal.is_zero_approx()
and start_position.is_finite()
and start_dot < -0.001
):
safe_fraction = 0.0
if best_hit_fraction > 0.0:
best_hit_fraction = 0.0
best_hit = {
"prop": start_prop,
"normal": start_normal.normalized(),
"position": start_position,
}
continue
var cast: PackedFloat32Array = space_state.cast_motion(query)
if cast.size() >= 1:
safe_fraction = minf(safe_fraction, cast[0])
if cast.size() < 1:
if debug_sample:
print("[PropCollisionDebug][SweepPart] part=%s cast returned no fractions" % part_name)
continue
safe_fraction = minf(safe_fraction, cast[0])
if debug_sample:
print(
"[PropCollisionDebug][SweepPart] part=%s cast_safe=%.4f cast_unsafe=%.4f"
% [part_name, cast[0], cast[1] if cast.size() > 1 else cast[0]]
)
if cast[0] >= 0.999:
continue
var unsafe_fraction: float = cast[1] if cast.size() > 1 else cast[0]
var rest_query: PhysicsShapeQueryParameters2D = PhysicsShapeQueryParameters2D.new()
rest_query.shape = shape_node.shape
rest_query.transform = shape_node.global_transform
rest_query.transform.origin += motion * minf(1.0, unsafe_fraction + 0.001)
rest_query.collision_mask = STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER
rest_query.exclude = excluded
var hit: Dictionary = space_state.get_rest_info(rest_query)
var prop: PropBlock = _prop_from_rest_info(hit)
if debug_sample:
print(
"[PropCollisionDebug][SweepHit] part=%s unsafe=%.4f id=%d prop=%s blocking=%s normal=%s point=%s"
% [
part_name,
unsafe_fraction,
int(hit.get("collider_id", 0)),
prop.name if prop != null and is_instance_valid(prop) else "<unresolved>",
str(prop.is_actor_blocking()) if prop != null and is_instance_valid(prop) else "false",
str(hit.get("normal", Vector2.ZERO)),
str(hit.get("point", Vector2.ZERO)),
]
)
if prop == null or not is_instance_valid(prop) or not prop.is_actor_blocking():
continue
if unsafe_fraction >= best_hit_fraction:
continue
var normal: Vector2 = hit.get("normal", Vector2.ZERO) as Vector2
var world_position: Vector2 = hit.get("point", Vector2.ZERO) as Vector2
if not normal.is_finite() or normal.is_zero_approx() or not world_position.is_finite():
continue
var relative_velocity: Vector2 = requested_velocity - prop.get_impact_velocity()
if normal.dot(relative_velocity) > 0.0:
normal = -normal
best_hit_fraction = unsafe_fraction
best_hit = {"prop": prop, "normal": normal.normalized(), "position": world_position}
if debug_sample:
var result_prop: PropBlock = best_hit.get("prop") as PropBlock
print(
"[PropCollisionDebug][SweepResult] rig=%s fraction=%.4f hit=%s"
% [_rig.name, safe_fraction, result_prop.name if result_prop != null and is_instance_valid(result_prop) else "<none>"]
)
if not best_hit.is_empty():
var hit_prop: PropBlock = best_hit["prop"] as PropBlock
var hit_tick: int = Engine.get_physics_frames()
var hit_id: int = hit_prop.get_instance_id()
if int(_last_sweep_hit_tick.get(hit_id, -1)) != hit_tick:
_last_sweep_hit_tick[hit_id] = hit_tick
var last_debug_hit_tick: int = int(_actor_prop_debug_last_hit_tick.get(hit_id, -ACTOR_PROP_DEBUG_INTERVAL_FRAMES))
if ACTOR_PROP_DEBUG_ENABLED and hit_tick - last_debug_hit_tick >= ACTOR_PROP_DEBUG_INTERVAL_FRAMES:
_actor_prop_debug_last_hit_tick[hit_id] = hit_tick
print(
"[PropCollisionDebug][Impact] rig=%s prop=%s id=%s normal=%s position=%s requested_velocity=%s"
% [
_rig.name,
hit_prop.name,
hit_prop.stage_object_id,
str(best_hit["normal"]),
str(best_hit["position"]),
str(requested_velocity),
]
)
blocking_prop_sweep_hit.emit(
hit_prop,
best_hit["normal"] as Vector2,
best_hit["position"] as Vector2,
requested_velocity
)
return {"fraction": safe_fraction}
func _prop_from_rest_info(rest_info: Dictionary) -> PropBlock:
# PhysicsDirectSpaceState2D.get_rest_info() returns collider_id rather than
# the `collider` object key used by ray/shape intersection queries.
var collider_id: int = int(rest_info.get("collider_id", 0))
if collider_id <= 0:
return null
var collider: PropBlock = instance_from_id(collider_id) as PropBlock
if collider == null or not is_instance_valid(collider):
return null
return collider
func _settle_walk_markers(delta: float) -> void:
if not _settling or _rig == null:
return
@@ -826,6 +1030,7 @@ func prepare_for_ragdoll() -> void:
set_terrain_following_enabled(false)
func reset_for_ragdoll_handoff() -> void:
reset_collision_proxy_velocity_baselines()
_cancel_landing_impact(false)
air_pose_base.clear()
air_pose_weight = 0.0
@@ -177,6 +177,7 @@ signal state_changed(new_state: int)
# ---------------------------------------------------------------------------
signal arrived(target: Vector2) # walk_to reached its destination
signal blocking_prop_sweep_hit(rig: StickmanRig, prop: PropBlock, normal: Vector2, world_position: Vector2, requested_velocity: Vector2)
signal action_started(action: Dictionary, index: int)
signal action_finished(action: Dictionary, index: int)
signal queue_finished # queue ran to completion (not on stop)
@@ -230,6 +231,7 @@ var source_stk_path: String = ""
## boundaries while this facade preserves the long-standing public API.
var _locomotion_component: StickmanLocomotion = null
var _ragdoll_component: StickmanRagdoll = null
var _ragdoll_velocity_samples: Dictionary = {}
var _action_runner_component: StickmanActionRunner = null
var _prop_interactor_component: Node = null
var _simulation_paused: bool = false
@@ -257,6 +259,7 @@ func _ready() -> void:
if _locomotion_component != null:
_locomotion_component.configure(self)
_locomotion_component.arrived.connect(func(target: Vector2) -> void: arrived.emit(target))
_locomotion_component.blocking_prop_sweep_hit.connect(_on_blocking_prop_sweep_hit)
if _ragdoll_component != null:
_ragdoll_component.configure(self, _locomotion_component)
_ragdoll_component.recovery_requested.connect(func() -> void: request_recovery())
@@ -326,6 +329,7 @@ func _ready() -> void:
func _physics_process(delta: float) -> void:
if _simulation_paused:
return
_sample_ragdoll_velocity_baselines()
if _ragdoll_component != null:
_ragdoll_component.tick_pre_movement(delta, int(state), rest_timeout, auto_recover)
if _locomotion_component != null:
@@ -347,6 +351,32 @@ func get_collision_bodies() -> Dictionary:
return _locomotion_component.collision_bodies if _locomotion_component != null else {}
func get_ragdoll_bodies() -> Array[RigidBody2D]:
if _ragdoll_component == null:
var empty: Array[RigidBody2D] = []
return empty
return _ragdoll_component.get_body_nodes()
func collision_proxy_velocity(body: PhysicsBody2D) -> Vector2:
return _locomotion_component.collision_proxy_velocity(body) if _locomotion_component != null else Vector2.ZERO
func ragdoll_body_velocity(body: RigidBody2D) -> Vector2:
if body == null or not is_instance_valid(body):
return Vector2.ZERO
var velocity: Vector2 = _ragdoll_velocity_samples.get(body.get_instance_id(), body.linear_velocity) as Vector2
return velocity if velocity.is_finite() else Vector2.ZERO
func _sample_ragdoll_velocity_baselines() -> void:
if _ragdoll_component == null:
return
for body: RigidBody2D in _ragdoll_component.get_body_nodes():
if body != null and is_instance_valid(body):
_ragdoll_velocity_samples[body.get_instance_id()] = body.linear_velocity
func get_collision_body_rids() -> Array[RID]:
var result: Array[RID] = []
for proxy: AnimatableBody2D in get_collision_bodies().values():
@@ -492,6 +522,7 @@ func _set_state(next_state: int) -> void:
if int(state) == next_state:
return
state = next_state
reset_collision_proxy_velocity_baselines()
state_changed.emit(int(state))
@@ -595,6 +626,7 @@ func snap_to_standing() -> void:
_action_runner_component.clear_pause_state()
if _ragdoll_component != null:
_ragdoll_component.clear_pause_snapshot()
reset_collision_proxy_velocity_baselines()
## Restores the spawn-marker pose through the ragdoll component. Kept on the
@@ -687,6 +719,23 @@ func _resolve_limb_bones() -> void:
func set_collision_bodies_enabled(enabled: bool) -> void:
if _locomotion_component != null:
_locomotion_component.set_collision_bodies_enabled(enabled)
reset_collision_proxy_velocity_baselines()
func reset_collision_proxy_velocity_baselines() -> void:
if _locomotion_component != null:
_locomotion_component.reset_collision_proxy_velocity_baselines()
_ragdoll_velocity_samples.clear()
_sample_ragdoll_velocity_baselines()
func _on_blocking_prop_sweep_hit(
prop: PropBlock,
normal: Vector2,
world_position: Vector2,
requested_velocity: Vector2
) -> void:
blocking_prop_sweep_hit.emit(self, prop, normal, world_position, requested_velocity)
func apply_collision_reaction(