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:
+69
-12
@@ -327,6 +327,7 @@ var _rule_more_popup: PopupMenu = null
|
||||
## Edge-trigger bookkeeping for the geometric engine, keyed "<area_id>:<node_id>"
|
||||
## and "<rig_id>:<prop_id>".
|
||||
var _area_overlap: Dictionary = {}
|
||||
var _area_previous_bounds: Dictionary = {}
|
||||
var _collision_pairs: Dictionary = {}
|
||||
|
||||
## Lightweight toast text + countdown (cleared in _process on expiry).
|
||||
@@ -634,6 +635,7 @@ func _enter_edit_shared() -> void:
|
||||
if node is STICKMAN_RIG:
|
||||
var rig := node as STICKMAN_RIG
|
||||
rig.stop_queue()
|
||||
rig.set_terrain_following_enabled(false)
|
||||
rig.snap_to_standing()
|
||||
rig.clear_reactive_actions()
|
||||
# Freeze every prop (kinematic) so nothing keeps falling in EDIT — including
|
||||
@@ -653,6 +655,7 @@ func _enter_edit_shared() -> void:
|
||||
_director_visuals.set_enabled(true)
|
||||
# Clear edge-trigger state so PLAY overlaps do not leak stale results.
|
||||
_area_overlap.clear()
|
||||
_area_previous_bounds.clear()
|
||||
_collision_pairs.clear()
|
||||
|
||||
|
||||
@@ -713,6 +716,7 @@ func _enter_play_mode() -> void:
|
||||
_set_direct_controls_visible(false)
|
||||
# Clear edge-trigger state so PLAY overlaps start from a clean slate.
|
||||
_area_overlap.clear()
|
||||
_area_previous_bounds.clear()
|
||||
_collision_pairs.clear()
|
||||
for node: Node2D in _world_children_selectable():
|
||||
if node is RigidBody2D:
|
||||
@@ -722,6 +726,7 @@ func _enter_play_mode() -> void:
|
||||
if node is STICKMAN_RIG:
|
||||
var rig := node as STICKMAN_RIG
|
||||
rig.auto_recover = false
|
||||
rig.begin_terrain_fall()
|
||||
rig.start_queue()
|
||||
rig_count += 1
|
||||
_stage_dbg("PLAY rigs=%d" % rig_count)
|
||||
@@ -2016,14 +2021,15 @@ func _build_navigation() -> void:
|
||||
_rebake_navigation()
|
||||
|
||||
|
||||
## Rebuild the navigation mesh from every TerrainBlock child of World: each
|
||||
## block's world-space polygon is convex-decomposed and fan-triangulated into
|
||||
## one manual NavigationPolygon.
|
||||
## Rebuild navigation from exposed top edges only. Each qualifying terrain edge
|
||||
## becomes a shallow ribbon just above its collision surface, so agents route
|
||||
## over floors/ramps rather than through solid terrain polygons.
|
||||
func _rebake_navigation() -> void:
|
||||
if _nav_region == null or not is_instance_valid(_nav_region):
|
||||
return
|
||||
var verts := PackedVector2Array()
|
||||
var triangles: Array[PackedInt32Array] = []
|
||||
var surfaces: Array[Dictionary] = []
|
||||
for child: Node in _world.get_children():
|
||||
var block := child as TerrainBlock
|
||||
if block == null:
|
||||
@@ -2031,14 +2037,28 @@ func _rebake_navigation() -> void:
|
||||
var pts := PackedVector2Array()
|
||||
for p: Vector2 in block.polygon_points:
|
||||
pts.append(block.transform * p)
|
||||
var pieces := Geometry2D.decompose_polygon_in_convex(pts)
|
||||
if pieces.is_empty():
|
||||
pieces = [pts]
|
||||
for piece: PackedVector2Array in pieces:
|
||||
var base: int = verts.size()
|
||||
verts.append_array(piece)
|
||||
for i: int in range(1, piece.size() - 1):
|
||||
triangles.append(PackedInt32Array([base, base + i, base + i + 1]))
|
||||
for i: int in pts.size():
|
||||
var a := pts[i]
|
||||
var b := pts[(i + 1) % pts.size()]
|
||||
var edge := b - a
|
||||
if edge.length_squared() <= 0.001:
|
||||
continue
|
||||
# TerrainUtils stores clockwise polygons; this right normal is
|
||||
# outward, so only its upward-facing edges are walkable.
|
||||
var outward := Vector2(edge.y, -edge.x).normalized()
|
||||
if outward.y > -0.15:
|
||||
continue
|
||||
surfaces.append({"a": a, "b": b, "normal": outward})
|
||||
for surface: Dictionary in surfaces:
|
||||
_append_navigation_ribbon(verts, triangles, surface["a"], surface["b"], surface["normal"])
|
||||
# Join only short, climbable gaps between exposed surface ends. This connects
|
||||
# authored stair treads without making cliffs or terrain interiors walkable.
|
||||
for i: int in surfaces.size():
|
||||
for j: int in range(i + 1, surfaces.size()):
|
||||
for a: Vector2 in [surfaces[i]["a"], surfaces[i]["b"]]:
|
||||
for b: Vector2 in [surfaces[j]["a"], surfaces[j]["b"]]:
|
||||
if absf(a.x - b.x) <= 64.0 and absf(a.y - b.y) <= 96.0:
|
||||
_append_navigation_ribbon(verts, triangles, a, b, Vector2.UP)
|
||||
var poly := NavigationPolygon.new()
|
||||
poly.set_vertices(verts)
|
||||
for tri: PackedInt32Array in triangles:
|
||||
@@ -2047,6 +2067,24 @@ func _rebake_navigation() -> void:
|
||||
if DEBUG_STAGE:
|
||||
print("[nav] baked verts=%d polys=%d" % [verts.size(), triangles.size()])
|
||||
|
||||
|
||||
func _append_navigation_ribbon(
|
||||
verts: PackedVector2Array,
|
||||
triangles: Array[PackedInt32Array],
|
||||
a: Vector2,
|
||||
b: Vector2,
|
||||
normal: Vector2
|
||||
) -> void:
|
||||
if a.distance_squared_to(b) <= 0.001:
|
||||
return
|
||||
var base: int = verts.size()
|
||||
verts.append_array(PackedVector2Array([
|
||||
a + normal * 4.0, b + normal * 4.0,
|
||||
b - normal * 12.0, a - normal * 12.0,
|
||||
]))
|
||||
triangles.append(PackedInt32Array([base, base + 1, base + 2]))
|
||||
triangles.append(PackedInt32Array([base, base + 2, base + 3]))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signal handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -3110,12 +3148,31 @@ func _update_area_entry() -> void:
|
||||
movables.append(rig)
|
||||
elif node is PropBlock and not (node as PropBlock).freeze:
|
||||
movables.append(node)
|
||||
# Build one swept bound per movable before iterating areas. The stage parent
|
||||
# polls before child rigs apply this frame's free-fall displacement, so the
|
||||
# previous->current union prevents fast airborne motion from tunneling
|
||||
# through a trigger between two observed poses.
|
||||
var swept_bounds: Dictionary = {}
|
||||
var live_ids: Dictionary = {}
|
||||
for node: Node2D in movables:
|
||||
var node_id := node.get_instance_id()
|
||||
var current_bounds := STAGE_SELECTION.get_world_aabb(node).abs()
|
||||
var swept := current_bounds
|
||||
if _area_previous_bounds.has(node_id):
|
||||
swept = swept.merge((_area_previous_bounds[node_id] as Rect2).abs())
|
||||
swept_bounds[node_id] = swept
|
||||
_area_previous_bounds[node_id] = current_bounds
|
||||
live_ids[node_id] = true
|
||||
for old_id: int in _area_previous_bounds.keys().duplicate():
|
||||
if not live_ids.has(old_id):
|
||||
_area_previous_bounds.erase(old_id)
|
||||
for area: TriggerArea in areas:
|
||||
var area_rect := (area.global_transform * area.get_area_rect()).abs()
|
||||
for node: Node2D in movables:
|
||||
var point := _movable_point(node)
|
||||
var key := "%d:%d" % [area.get_instance_id(), node.get_instance_id()]
|
||||
var inside: bool = _movable_overlaps_area(node, area_rect)
|
||||
var inside: bool = (swept_bounds.get(
|
||||
node.get_instance_id(), Rect2()) as Rect2).intersects(area_rect, true)
|
||||
var was: bool = bool(_area_overlap.get(key, false))
|
||||
if inside and not was:
|
||||
_area_overlap[key] = true
|
||||
|
||||
+278
-17
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user