OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / player / procedural / wall_climb.gd

381 lines · gd ref:
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()