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
|
||||
|
||||
Reference in New Issue
Block a user