class_name WallClimb extends RefCounted
## Spider-style 4-grip procedural locomotion system.
## Inspired by MeroVinggen/Godot-Spider-Procedural-Animation.
##
## Architecture (dual-marker system):
## grip_targets[i] — continuously updated by surface sweep (where foot SHOULD be)
## grip_positions[i] — only moves during step arc (where foot IS / IK target)
##
## Body position is driven by the controller but informed by grip centroid.
## Surface orientation emerges from averaged grip normals — no explicit
## wall detection needed for floor→wall→ceiling transitions.
enum Limb { LEFT_HAND, RIGHT_HAND, LEFT_FOOT, RIGHT_FOOT }
# Diagonal pairs: only one pair steps at a time
const PAIR_A: Array[int] = [0, 3] # LEFT_HAND + RIGHT_FOOT
const PAIR_B: Array[int] = [1, 2] # RIGHT_HAND + LEFT_FOOT
# Sweep config
const SWEEP_RADIUS: float = 0.08
const SWEEP_DEPTH: float = 1.2
const CLIMB_SPEED_UP: float = 2.0
const CLIMB_SPEED_LATERAL: float = 3.0
# Step config
var step_distance: float = 0.3 ## Distance between target and current to trigger step
var step_arc_height: float = 0.2 ## How high the foot lifts during a step
var step_speed: float = 8.0 ## Arc interpolation speed
var target_lead: float = 0.3 ## How far ahead to project targets when moving
# Grip layout (set by controller from exports)
var grip_offsets: Array = [
Vector3(-0.30, 0.40, 0.0), # LEFT_HAND
Vector3(0.30, 0.40, 0.0), # RIGHT_HAND
Vector3(-0.20, -0.60, 0.0), # LEFT_FOOT
Vector3(0.20, -0.60, 0.0), # RIGHT_FOOT
]
# State
var active: bool = false
var wall_normal: Vector3 = Vector3.ZERO ## Surface normal (away from surface)
var wall_right: Vector3 = Vector3.ZERO
var wall_up: Vector3 = Vector3.ZERO
var body_position: Vector3 = Vector3.ZERO
# Dual-marker arrays
var grip_positions: Array[Vector3] = [Vector3.ZERO, Vector3.ZERO, Vector3.ZERO, Vector3.ZERO] ## Current (IK targets)
var grip_targets: Array[Vector3] = [Vector3.ZERO, Vector3.ZERO, Vector3.ZERO, Vector3.ZERO] ## Ideal (from sweep)
var grip_normals: Array[Vector3] = [Vector3.ZERO, Vector3.ZERO, Vector3.ZERO, Vector3.ZERO]
var grip_arc_origins: Array[Vector3] = [Vector3.ZERO, Vector3.ZERO, Vector3.ZERO, Vector3.ZERO]
var grip_weights: Array[float] = [1.0, 1.0, 1.0, 1.0]
var grip_valid: Array[bool] = [false, false, false, false]
var grip_stepping: Array[bool] = [false, false, false, false]
var grip_arc_t: Array[float] = [1.0, 1.0, 1.0, 1.0]
# Gait
var _active_pair: int = 0 ## 0 = pair A stepping, 1 = pair B
var _is_stepping: bool = false
var _legs_finished: int = 0
var _legs_in_action: int = 0
var _exclude_rids: Array[RID] = []
# Debug
var debug_enabled: bool = false
var _debug_node: Node3D = null
var _debug_frame_count: int = 0
func set_exclude(rid: RID) -> void:
_exclude_rids = [rid]
func set_grip_layout(hand_fwd: float, hand_lat: float, foot_back: float, foot_lat: float) -> void:
grip_offsets[0] = Vector3(-hand_lat, hand_fwd, 0.0)
grip_offsets[1] = Vector3(hand_lat, hand_fwd, 0.0)
grip_offsets[2] = Vector3(-foot_lat, -foot_back, 0.0)
grip_offsets[3] = Vector3(foot_lat, -foot_back, 0.0)
func start(pos: Vector3, normal: Vector3, gravity_up: Vector3 = Vector3.UP) -> void:
active = true
body_position = pos
wall_normal = normal.normalized()
_compute_wall_basis(gravity_up)
_active_pair = 0
_is_stepping = false
_legs_finished = 0
_legs_in_action = 0
# Plant all grips at ideal positions
for i in range(4):
var ideal := _ideal_grip_pos(i)
grip_positions[i] = ideal
grip_targets[i] = ideal
grip_arc_origins[i] = ideal
grip_normals[i] = wall_normal
grip_valid[i] = true
grip_weights[i] = 1.0
grip_arc_t[i] = 1.0
grip_stepping[i] = false
func stop() -> Vector3:
active = false
_clear_debug()
return body_position
## Sync body position and update wall basis.
func sync(pos: Vector3, gravity_up: Vector3, facing_dir: Vector3 = Vector3.ZERO) -> void:
body_position = pos
_compute_wall_basis(gravity_up, facing_dir)
## Main update. Continuously updates targets, checks step triggers, advances arcs.
func update(space: PhysicsDirectSpaceState3D, delta: float,
move_velocity: Vector3) -> int:
if not active:
return 0
var is_moving: bool = move_velocity.length_squared() > 0.01
# 1. Continuously update grip targets (where feet SHOULD be).
# When moving, project search area ahead in movement direction.
var target_offset: Vector3 = Vector3.ZERO
if is_moving:
target_offset = move_velocity.normalized() * target_lead
for i in range(4):
_update_grip_target(space, i, target_offset)
# 2. Advance any in-progress step arcs
for i in range(4):
if grip_stepping[i]:
grip_arc_t[i] = clampf(grip_arc_t[i] + delta * step_speed, 0.0, 1.0)
grip_positions[i] = _arc_interpolate(
grip_arc_origins[i], grip_targets[i],
wall_normal, grip_arc_t[i])
grip_weights[i] = lerpf(0.3, 1.0, grip_arc_t[i])
if grip_arc_t[i] >= 1.0:
grip_stepping[i] = false
grip_weights[i] = 1.0
grip_positions[i] = grip_targets[i]
_on_leg_step_finished()
# 3. Check if active pair needs to step (distance threshold)
if not _is_stepping:
var pair: Array[int] = _get_pair(_active_pair)
for limb_idx in pair:
if grip_valid[limb_idx]:
var dist: float = grip_positions[limb_idx].distance_to(grip_targets[limb_idx])
if dist > step_distance:
# Trigger step for entire pair
_is_stepping = true
_legs_in_action = 0
_legs_finished = 0
for pair_limb in pair:
if grip_valid[pair_limb]:
_start_step(pair_limb)
_legs_in_action += 1
break
# 4. Feed grip normals back into wall_normal for surface tracking
var avg_normal: Vector3 = Vector3.ZERO
var normal_count: int = 0
for i in range(4):
if grip_valid[i] and not grip_stepping[i]:
avg_normal += grip_normals[i]
normal_count += 1
if normal_count > 0:
avg_normal = (avg_normal / float(normal_count)).normalized()
if avg_normal.length_squared() > 0.5:
wall_normal = wall_normal.slerp(avg_normal, 0.1).normalized()
# 5. Count valid grips
var valid_count: int = 0
for i in range(4):
if grip_valid[i]:
valid_count += 1
if debug_enabled:
_debug_frame_count += 1
if _debug_frame_count >= 3:
_debug_frame_count = 0
_update_debug()
return valid_count
## Get the centroid of all planted grip positions.
## The controller should position the body FROM this, not the other way around.
func get_grip_centroid() -> Vector3:
var centroid: Vector3 = Vector3.ZERO
var count: int = 0
for i in range(4):
if grip_valid[i]:
centroid += grip_positions[i]
count += 1
if count > 0:
return centroid / float(count)
return body_position
## Get averaged surface normal from planted grips.
func get_averaged_normal() -> Vector3:
var avg: Vector3 = Vector3.ZERO
var count: int = 0
for i in range(4):
if grip_valid[i] and not grip_stepping[i]:
avg += grip_normals[i]
count += 1
if count > 0:
return (avg / float(count)).normalized()
return wall_normal
## Check if any grip is mid-step.
func is_stepping() -> bool:
return _is_stepping
# ---- Internal ----
func _update_grip_target(space: PhysicsDirectSpaceState3D, limb: int, move_offset: Vector3) -> void:
var offset: Vector3 = _get_grip_offset(limb)
var ideal: Vector3 = body_position + wall_right * offset.x + wall_up * offset.y + move_offset
# Sweep along this grip's own last-known normal
var sweep_dir: Vector3 = grip_normals[limb] if grip_normals[limb].length_squared() > 0.5 else wall_normal
var sweep_from: Vector3 = ideal + sweep_dir * SWEEP_DEPTH * 0.5
var sweep_to: Vector3 = ideal - sweep_dir * SWEEP_DEPTH
var result: Dictionary = SphereSweep.sweep(space, sweep_from, sweep_to, SWEEP_RADIUS, _exclude_rids)
if not result.is_empty():
grip_targets[limb] = result["point"]
grip_normals[limb] = result["normal"]
grip_valid[limb] = true
else:
# Fallback: try global wall_normal
if sweep_dir != wall_normal:
sweep_from = ideal + wall_normal * SWEEP_DEPTH * 0.5
sweep_to = ideal - wall_normal * SWEEP_DEPTH
result = SphereSweep.sweep(space, sweep_from, sweep_to, SWEEP_RADIUS, _exclude_rids)
if not result.is_empty():
grip_targets[limb] = result["point"]
grip_normals[limb] = result["normal"]
grip_valid[limb] = true
return
# No surface found — DON'T invalidate. Keep grip planted at last position.
# A real lizard doesn't lift its foot when the ground disappears —
# it holds position until a step is triggered to a valid surface.
# The grip target stays at the last known good position.
grip_targets[limb] = grip_positions[limb]
func _start_step(limb: int) -> void:
grip_arc_origins[limb] = grip_positions[limb]
grip_arc_t[limb] = 0.0
grip_stepping[limb] = true
grip_weights[limb] = 0.3
func _on_leg_step_finished() -> void:
_legs_finished += 1
if _legs_finished >= _legs_in_action:
_legs_finished = 0
_legs_in_action = 0
_is_stepping = false
_active_pair = 1 - _active_pair
func _get_pair(pair_id: int) -> Array[int]:
return PAIR_A if pair_id == 0 else PAIR_B
func _ideal_grip_pos(limb: int) -> Vector3:
var offset: Vector3 = _get_grip_offset(limb)
return body_position + wall_right * offset.x + wall_up * offset.y + wall_normal * offset.z
func _get_grip_offset(limb: int) -> Vector3:
var base: Vector3 = grip_offsets[limb]
var ceiling_factor: float = clampf(wall_normal.dot(Vector3.DOWN), 0.0, 1.0)
var spread: float = 1.0 + ceiling_factor * 0.5
return Vector3(base.x * spread, base.y, base.z)
func _arc_interpolate(from: Vector3, to: Vector3, normal: Vector3, t: float) -> Vector3:
var linear: Vector3 = from.lerp(to, t)
var arc_offset: float = 4.0 * t * (1.0 - t) * step_arc_height
return linear + normal * arc_offset
func _compute_wall_basis(gravity_up: Vector3 = Vector3.UP, facing_dir: Vector3 = Vector3.ZERO) -> void:
var target_up: Vector3
var flat_surface: bool = absf(wall_normal.dot(gravity_up)) > 0.7
if flat_surface and facing_dir.length_squared() > 0.01:
var proj: Vector3 = (facing_dir - wall_normal * wall_normal.dot(facing_dir))
if proj.length_squared() > 0.01:
target_up = proj.normalized()
else:
target_up = wall_up if wall_up.length_squared() > 0.01 else Vector3.FORWARD
else:
var up_proj: Vector3 = (gravity_up - wall_normal * wall_normal.dot(gravity_up))
if up_proj.length_squared() > 0.01:
target_up = up_proj.normalized()
else:
target_up = wall_up if wall_up.length_squared() > 0.01 else Vector3.FORWARD
if wall_up.length_squared() > 0.01:
wall_up = wall_up.slerp(target_up, 0.4).normalized()
else:
wall_up = target_up
wall_right = wall_up.cross(wall_normal).normalized()
# ---- Debug Visualization ----
func setup_debug(parent: Node3D) -> void:
debug_enabled = true
_debug_node = Node3D.new()
_debug_node.name = "WallClimbDebug"
_debug_node.top_level = true
parent.add_child(_debug_node)
func _update_debug() -> void:
if _debug_node == null:
return
for child in _debug_node.get_children():
child.queue_free()
var colors: Array[Color] = [Color.RED, Color.BLUE, Color.YELLOW, Color.GREEN]
for i in range(4):
if not grip_valid[i]:
continue
var r: float = 0.06 if grip_stepping[i] else 0.04
_debug_node.add_child(_make_debug_sphere(grip_positions[i], r, colors[i]))
_debug_node.add_child(_make_debug_sphere(grip_targets[i], 0.02, Color(colors[i], 0.4)))
_debug_node.add_child(_make_debug_line(grip_positions[i], grip_targets[i], Color(1, 1, 1, 0.3)))
_debug_node.add_child(_make_debug_sphere(body_position, 0.06, Color.WHITE))
_debug_node.add_child(_make_debug_sphere(get_grip_centroid(), 0.04, Color.ORANGE))
func _make_debug_sphere(pos: Vector3, radius: float, color: Color) -> MeshInstance3D:
var mesh := SphereMesh.new()
mesh.radius = radius
mesh.height = radius * 2.0
mesh.radial_segments = 8
mesh.rings = 4
var mat := StandardMaterial3D.new()
mat.albedo_color = color
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.no_depth_test = true
mesh.material = mat
var inst := MeshInstance3D.new()
inst.mesh = mesh
inst.position = pos
return inst
func _make_debug_line(from: Vector3, to: Vector3, color: Color) -> MeshInstance3D:
var im := ImmediateMesh.new()
im.surface_begin(Mesh.PRIMITIVE_LINES)
im.surface_set_color(color)
im.surface_add_vertex(from)
im.surface_add_vertex(to)
im.surface_end()
var mat := StandardMaterial3D.new()
mat.albedo_color = color
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.no_depth_test = true
mat.vertex_color_use_as_albedo = true
var inst := MeshInstance3D.new()
inst.mesh = im
inst.material_override = mat
return inst
func _clear_debug() -> void:
if _debug_node != null:
for child in _debug_node.get_children():
child.queue_free()