Add ramp scene and implement Phase 6 for dynamic IK and terrain-following locomotion

- Created a new scene file `ramp.scene` with terrain and stickman objects, including event rules for ragdoll and recovery actions.
- Documented Phase 6 implementation in `PHASE_6_GROUND_SNAPPING.md`, detailing the continuous dynamic IK, terrain-following locomotion, and constraints for stickman rigs.
- Defined scene structure, layered IK ownership, probe contracts, and per-frame solve order for terrain interaction.
- Verified functionality for terrain snapping, stance/swing blending, pelvis/root compensation, and navigation synchronization.
This commit is contained in:
2026-09-16 09:33:07 -04:00
parent a7c58705e1
commit 08247eadef
7 changed files with 1088 additions and 44 deletions
+278 -17
View File
@@ -240,11 +240,26 @@ const NAV_TARGET_DESIRED_DISTANCE := 12.0
## How many post-sync physics frames to wait for the nav agent's reachability
## flag before deciding the walk is genuinely off-mesh (latch "direct").
const LATCH_PROBE_MAX_FRAMES := 6
const TERRAIN_PROBE_UP := 96.0
const TERRAIN_PROBE_DOWN := 700.0
const TERRAIN_NORMAL_MIN_Y := -0.15
const TERRAIN_SUPPORT_RANGE := 160.0
const TERRAIN_MAX_FOOT_SURFACE_DROP := 72.0
const TERRAIN_FOOT_GUARD_HEIGHT := 24.0
const TERRAIN_CENTER_PROBE_OFFSETS: Array[float] = [-24.0, 0.0, 24.0]
const TERRAIN_FOOT_PROBE_OFFSETS: Array[float] = [-4.0, 0.0, 4.0]
const TERRAIN_GRAVITY := 1400.0
const TERRAIN_MAX_FALL_SPEED := 1800.0
const FOOT_BLEND_RESPONSE := 14.0
const PELVIS_MAX_OFFSET := 240.0
const PELVIS_RESPONSE := 10.0
const WALK_CYCLE_DURATION := 0.8
## Rig-local anchor for the speech bubble, above the head.
const SPEECH_BUBBLE_OFFSET := Vector2(0.0, -640.0)
## Debug gate for the Phase 3a walk/runner trace. Ship OFF.
const DEBUG_WALK := false
const DEBUG_TERRAIN := false
## Debug gate for the ragdoll-recovery re-solve trace. Ship OFF.
const DEBUG_RECOVERY := false
@@ -504,6 +519,16 @@ var _walk_mode_latched: bool = false
var _walk_latch_probe_frames: int = 0
var _walk_settle_frames: int = 0
var _walk_done: bool = false
var _leg_base_markers: Dictionary = {}
var _foot_contacts: Dictionary = {
"Left_Leg": {"valid": false, "locked": false},
"Right_Leg": {"valid": false, "locked": false},
}
var _terrain_pelvis_offset: float = 0.0
var _terrain_vertical_velocity: float = 0.0
var _terrain_airborne: bool = true
var _terrain_paused_walk_animation: bool = false
var _terrain_following_enabled: bool = false
var _ragdoll_at_rest: bool = false
@@ -540,6 +565,12 @@ func _ready() -> void:
_anim_player = get_node_or_null(NodePath(ANIMATION_PLAYER_PATH)) as AnimationPlayer
if _anim_player == null:
push_warning("StickmanRig: missing '%s' node in rig." % ANIMATION_PLAYER_PATH)
for leg_name: String in ["Left_Leg", "Right_Leg"]:
var base := get_node_or_null(NodePath("AnimationTargets/%s_Base" % leg_name)) as Marker2D
if base == null:
push_warning("StickmanRig: missing AnimationTargets/%s_Base." % leg_name)
else:
_leg_base_markers[leg_name] = base
_resolve_bend_modifications()
_resolve_limb_bones()
@@ -588,7 +619,9 @@ func _ready() -> void:
func _physics_process(delta: float) -> void:
_track_momentum(delta)
_update_rest_detection(delta)
_clear_terrain_pelvis_offset()
_update_walking(delta)
_update_terrain_following(delta)
_settle_walk_markers()
_update_speech(delta)
_update_runner(delta)
@@ -622,6 +655,218 @@ func _track_momentum(delta: float) -> void:
_prev_global_pos = global_position
_prev_global_rot = global_rotation
func _clear_terrain_pelvis_offset() -> void:
# Terrain height is accumulated into the runtime root while PLAY is active.
# This value is only the last frame's correction, not an offset to undo.
_terrain_pelvis_offset = 0.0
## Arms the animated terrain controller without teleporting. An unsupported rig
## free-falls until its centerline reaches a TerrainBlock support surface.
func begin_terrain_fall() -> void:
_terrain_following_enabled = true
_clear_terrain_pelvis_offset()
_terrain_pelvis_offset = 0.0
_terrain_vertical_velocity = 0.0
_terrain_airborne = true
_terrain_paused_walk_animation = false
for leg_name: String in _foot_contacts:
_foot_contacts[leg_name] = {"valid": false, "locked": false}
## Kept as a compatibility alias for callers introduced during Phase 6.
func snap_to_terrain() -> void:
begin_terrain_fall()
func set_terrain_following_enabled(enabled: bool) -> void:
_terrain_following_enabled = enabled
if enabled:
return
_clear_terrain_pelvis_offset()
_terrain_pelvis_offset = 0.0
_terrain_vertical_velocity = 0.0
_terrain_airborne = false
_terrain_paused_walk_animation = false
for leg_name: String in _foot_contacts:
_foot_contacts[leg_name] = {"valid": false, "locked": false}
for leg_name: String in ["Left_Leg", "Right_Leg"]:
var base := _leg_base_markers.get(leg_name) as Marker2D
var target := _get_ik_marker(leg_name)
if base != null and target != null:
target.position = base.position
func _probe_terrain_below(world_point: Vector2, probe_down: float = TERRAIN_PROBE_DOWN) -> Dictionary:
var space_state := get_world_2d().direct_space_state
if space_state == null:
return {"valid": false}
var query := PhysicsRayQueryParameters2D.create(
world_point + Vector2.UP * TERRAIN_PROBE_UP,
world_point + Vector2.DOWN * probe_down)
query.collision_mask = 1
query.collide_with_areas = false
var excluded: Array[RID] = []
for proxy: AnimatableBody2D in _collision_bodies.values():
if proxy != null and is_instance_valid(proxy):
var rid := proxy.get_rid()
if rid.is_valid():
excluded.append(rid)
query.exclude = excluded
var hit := space_state.intersect_ray(query)
if hit.is_empty() or not (hit.get("collider") is TerrainBlock):
return {"valid": false}
var normal: Vector2 = hit.get("normal", Vector2.ZERO)
if normal.y > TERRAIN_NORMAL_MIN_Y:
return {"valid": false}
return {"valid": true, "world_pos": hit.get("position", world_point), "normal": normal}
## Samples a small horizontal fan and returns the highest valid terrain surface.
## This keeps an overlapping ramp authoritative over flat ground underneath and
## prevents a single seam/edge miss from dropping the controller for one frame.
func _probe_terrain_fan(world_point: Vector2, offsets: Array[float]) -> Dictionary:
var best: Dictionary = {"valid": false}
for x_offset: float in offsets:
var hit := _probe_terrain_below(world_point + Vector2(x_offset, 0.0))
if not bool(hit.get("valid", false)):
continue
if not bool(best.get("valid", false)) \
or (hit["world_pos"] as Vector2).y < (best["world_pos"] as Vector2).y:
best = hit
return best
func _stance_weight(leg_name: String) -> float:
if not _walking or _anim_player == null:
return 1.0
var phase := fmod(_anim_player.get_current_animation_position(), WALK_CYCLE_DURATION)
# Continuous complementary weights remove the one-frame 0->1 target snap at
# each gait handoff while preserving the clip's alternating half-cycle.
var right_weight := 0.5 + 0.5 * cos(TAU * phase / WALK_CYCLE_DURATION)
return right_weight if leg_name == "Right_Leg" else 1.0 - right_weight
func _update_terrain_following(delta: float) -> void:
if not _terrain_following_enabled \
or state != RigState.ANIMATED or _leg_base_markers.size() != 2:
_terrain_pelvis_offset = 0.0
return
# Terrain following requires the live leg solvers; preserve the old pinned
# LEFT idle behavior only when no terrain controller is available.
_set_limb_mods_enabled(true)
var center_world := to_global(NAV_AGENT_LOCAL_POS)
var center_probe := _probe_terrain_fan(center_world, TERRAIN_CENTER_PROBE_OFFSETS)
var center_delta := INF
if bool(center_probe.get("valid", false)):
center_delta = (center_probe["world_pos"] as Vector2).y - center_world.y
var center_supported := bool(center_probe.get("valid", false)) \
and absf(center_delta) <= TERRAIN_SUPPORT_RANGE
var center_surface_y := (center_probe["world_pos"] as Vector2).y \
if bool(center_probe.get("valid", false)) else INF
var solved: Dictionary = {}
var debug_samples: Array[String] = []
for leg_name: String in ["Left_Leg", "Right_Leg"]:
var base := _leg_base_markers[leg_name] as Marker2D
var target := _get_ik_marker(leg_name)
if base == null or target == null:
continue
var base_world := base.global_position
var probe := _probe_terrain_fan(base_world, TERRAIN_FOOT_PROBE_OFFSETS)
var probe_delta := INF
if bool(probe.get("valid", false)):
probe_delta = (probe["world_pos"] as Vector2).y - base_world.y
# A foot may reach above/below the pelvis support surface, but must not
# choose the flat ground far underneath an overlapping ramp.
var within_reach := absf(probe_delta) <= PELVIS_MAX_OFFSET
if within_reach and center_supported:
within_reach = (probe["world_pos"] as Vector2).y \
<= center_surface_y + TERRAIN_MAX_FOOT_SURFACE_DROP
var stance := _stance_weight(leg_name)
debug_samples.append("%s hit=%s dy=%.1f stance=%.1f" % [
leg_name, str(bool(probe.get("valid", false))), probe_delta, stance])
var contact: Dictionary = _foot_contacts[leg_name]
if stance >= 0.5 and bool(probe.get("valid", false)) and within_reach:
if not bool(contact.get("locked", false)):
contact = {"valid": true, "locked": true, "world_pos": probe["world_pos"], "normal": probe["normal"]}
# Refresh a planted contact when a moved/edited terrain surface changes.
elif (contact.get("world_pos", base_world) as Vector2).distance_to(probe["world_pos"]) > 2.0:
contact["world_pos"] = probe["world_pos"]
elif stance < 0.5 or not within_reach:
contact = {"valid": bool(probe.get("valid", false)), "locked": false}
_foot_contacts[leg_name] = contact
var floor_world := base_world
if bool(contact.get("locked", false)):
floor_world = contact["world_pos"]
elif bool(probe.get("valid", false)) and within_reach:
floor_world = probe["world_pos"]
solved[leg_name] = {
"base": base_world,
"base_marker": base,
"floor": floor_world,
"surface": probe.get("world_pos", base_world) if within_reach else base_world,
"surface_valid": bool(probe.get("valid", false)) and within_reach,
"weight": stance,
"target": target,
}
if not center_supported:
_terrain_airborne = true
if _walking and _anim_player != null and _anim_player.is_playing():
_anim_player.pause()
_terrain_paused_walk_animation = true
_terrain_vertical_velocity = minf(
_terrain_vertical_velocity + TERRAIN_GRAVITY * delta,
TERRAIN_MAX_FALL_SPEED)
global_position.y += _terrain_vertical_velocity * delta
_terrain_pelvis_offset = 0.0
for leg_name: String in solved:
var air_item: Dictionary = solved[leg_name]
# Keep the animated leg pose rigidly attached to the falling root.
(air_item["target"] as Marker2D).position = \
(air_item["base_marker"] as Marker2D).position
if DEBUG_TERRAIN and Engine.get_physics_frames() % 30 == 0:
print("[terrain] AIR root=", global_position, " center_dy=", center_delta,
" walk=", _walking, " ", debug_samples)
return
var desired_offset := clampf(center_delta, -PELVIS_MAX_OFFSET, PELVIS_MAX_OFFSET)
if _terrain_airborne:
# Landing correction is immediate so a fast fall cannot tunnel a leg
# through a thin ramp between physics frames.
_terrain_pelvis_offset = desired_offset
else:
var pelvis_blend := 1.0 - exp(-PELVIS_RESPONSE * delta)
# Apply a fraction of the remaining error directly to the runtime root.
# The correction accumulates, allowing consecutive ramps to climb far
# beyond the single-frame compensation range.
_terrain_pelvis_offset = lerpf(0.0, desired_offset, pelvis_blend)
_terrain_airborne = false
_terrain_vertical_velocity = 0.0
if _terrain_paused_walk_animation and _walking and _anim_player != null:
_anim_player.play()
_terrain_paused_walk_animation = false
global_position.y += _terrain_pelvis_offset
if DEBUG_TERRAIN and Engine.get_physics_frames() % 30 == 0:
print("[terrain] GROUND root=", global_position, " offset=", _terrain_pelvis_offset,
" center_dy=", center_delta, " walk=", _walking, " ", debug_samples)
for leg_name: String in solved:
var item: Dictionary = solved[leg_name]
# Re-read the animated base after moving the root. Using the pre-pelvis
# world point here counter-moved the swing foot and visibly bent the knee.
var base_after := (item["base_marker"] as Marker2D).global_position
var contact_weight := float(item["weight"])
var surface: Vector2 = item["surface"]
if bool(item["surface_valid"]):
var clearance := surface.y - base_after.y
var guard_weight := 1.0 - clampf(
clearance / TERRAIN_FOOT_GUARD_HEIGHT, 0.0, 1.0)
contact_weight = maxf(contact_weight, smoothstep(0.0, 1.0, guard_weight))
var wanted := base_after
wanted.y = lerpf(base_after.y, (item["floor"] as Vector2).y, contact_weight)
if bool(item["surface_valid"]) and wanted.y > surface.y:
wanted.y = surface.y
(item["target"] as Marker2D).global_position = wanted
# ---------------------------------------------------------------------------
# Per-frame rest detection
# ---------------------------------------------------------------------------
@@ -835,6 +1080,11 @@ func initialize_spawn_pose_from_skeleton() -> void:
var target: Dictionary = pose[marker_name]
marker.position = target.get("pos", marker.position)
marker.rotation = target.get("rot", marker.rotation)
if marker_name == "Left_Leg" or marker_name == "Right_Leg":
var base_marker := get_node_or_null(
NodePath("AnimationTargets/%s_Base" % marker_name)) as Marker2D
if base_marker != null:
base_marker.position = marker.position
_spawn_pose = pose.duplicate(true)
_spawn_limb_rotations.clear()
for joint: String in LIMB_BONE_PATHS:
@@ -2172,6 +2422,8 @@ func _update_walking(delta: float) -> void:
if state != RigState.ANIMATED:
_cancel_walking(false)
return
if _terrain_following_enabled and _terrain_airborne:
return
# Defer all nav reads until the map has actually synchronized. An unsynced
# agent reports an empty, finished path (map iteration id == 0), which would
# otherwise end the walk after a single move_toward step (~5 px).
@@ -2207,7 +2459,9 @@ func _update_walking(delta: float) -> void:
# repath, so checking it directly would leave the path empty forever.
var next_feet := _nav_agent.get_next_path_position()
var final_root := _walk_target_feet + FOOT_OFFSET
var dist_to_final := global_position.distance_to(final_root)
# Terrain following owns vertical motion. Navigation supplies horizontal
# progress only, so slope elevation cannot fight the ankle/pelvis solve.
var dist_to_final := absf(global_position.x - final_root.x)
var root_target: Vector2
if _walk_mode == "nav":
# Terminate once the nav agent reports its path complete AND the rig is
@@ -2215,24 +2469,24 @@ func _update_walking(delta: float) -> void:
# feet-space) while the rig's hard arrival radius is 8 px, so chasing the
# last stale next-waypoint would oscillate the rig around the 16-px band.
if _nav_agent.is_navigation_finished() and dist_to_final <= 2.0 * ARRIVE_DISTANCE:
global_position = final_root
global_position.x = final_root.x
_finish_walk("arrive")
return
# Near the destination, ignore the (possibly behind-path) next waypoint
# and steer straight at the final target so the rig cannot reverse.
if dist_to_final <= 2.0 * ARRIVE_DISTANCE:
root_target = final_root
root_target = Vector2(final_root.x, global_position.y)
else:
root_target = next_feet + FOOT_OFFSET
root_target = Vector2(next_feet.x + FOOT_OFFSET.x, global_position.y)
else:
# DIRECT branch: an off-mesh waypoint is a supported case — steer
# straight at the clicked point, ignoring the nav mesh.
root_target = final_root
root_target = Vector2(final_root.x, global_position.y)
global_position = global_position.move_toward(root_target, _walk_speed_current * delta)
# Unified arrival radius against the FINAL target, in both branches. Snap
# the residual offset away so the rig lands exactly on the waypoint.
if dist_to_final <= ARRIVE_DISTANCE:
global_position = final_root
global_position.x = final_root.x
_finish_walk("arrive")
return
_walk_dbg("frame=%d idx=%d mode=%s root=(%.1f, %.1f) feet=(%.1f, %.1f) target=(%.1f, %.1f) dist=%.1f finished=%s reachable=%s final=(%.1f, %.1f) pts=%d next=(%.1f, %.1f) map_iter=%d" % [
@@ -2412,28 +2666,35 @@ func enqueue_reactive(actions: Array[Dictionary]) -> void:
_run_queue.append({ "action": action_queue[i].duplicate(true), "queue_index": i, "reactive": false })
_run_cursor = 0
var primary: Array[Dictionary] = []
var takeover_seen := false
for action: Dictionary in actions:
var role: int = ACTION_REGISTRY.reactive_role(action)
if role == ACTION_REGISTRY.ReactiveRole.OVERLAY:
_speak_overlay(String(action.get("text", "")), float(action.get("duration", 2.0)))
continue
if role == ACTION_REGISTRY.ReactiveRole.TAKEOVER:
_cancel_current_primary()
_overlay_speech.clear()
_hide_speech()
var kept: Array[Dictionary] = []
for item: Dictionary in _run_queue.slice(_run_cursor):
if not bool(item.get("reactive", false)):
kept.append(item)
_run_queue = kept
_run_cursor = 0
primary.clear()
# A rule batch such as [ragdoll, recover] is an ordered sequence.
# Interrupt the old work once at the first takeover; later takeover
# actions in the same batch must not erase earlier batch actions.
if not takeover_seen:
takeover_seen = true
_cancel_current_primary()
_overlay_speech.clear()
_hide_speech()
var kept: Array[Dictionary] = []
for item: Dictionary in _run_queue.slice(_run_cursor):
if not bool(item.get("reactive", false)):
kept.append(item)
_run_queue = kept
_run_cursor = 0
primary.clear()
primary.append(action.duplicate(true))
else:
primary.append(action.duplicate(true))
if primary.is_empty():
return
_cancel_current_primary()
if not takeover_seen:
_cancel_current_primary()
var injected: Array[Dictionary] = []
for action: Dictionary in primary:
injected.append({ "action": action, "queue_index": -1, "reactive": true })