OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / creatures / ground_crawler.gd

733 lines · gd ref:
class_name GroundCrawler extends Node3D
## Lizard creature: grounded serpent body chain + IK legs with trajectory-
## predicted foot placement. Body undulates via head sine wave, legs plant
## by predicting future body position along the sine+trajectory path.
## Call initialize(rng) after adding to scene tree.

# ---- Species parameters ----

var segment_count: int = 5
var creature_scale: float = 1.0
var move_speed: float = 2.5
var wave_amplitude: float = 1.0
var wave_frequency: float = 0.8
var body_color: Color = Color(0.45, 0.35, 0.2)
var size_skew: float = 0.0  ## -1 = largest at head, +1 = largest at tail

# ---- Derived dimensions ----

var body_height: float = 0.4
var segment_radius: float = 0.2
var segment_spacing: float = 0.3
var connector_radius: float = 0.1  ## Small spheres between main segments
var leg_spread: float = 0.5      ## How far out legs reach laterally
var leg_length: float = 0.5      ## Total leg length (upper + lower)
var upper_leg_length: float = 0.25
var lower_leg_length: float = 0.25
var step_height: float = 0.15    ## Arc lift during step

# ---- Body chain ----

var _body_mm: MultiMeshInstance3D = null  ## Body spheres + connectors (1 draw call)
var _body_instance_count: int = 0
var _segment_radii: Array[float] = []    ## Per-segment radius (taper)
var _segment_colors: Array[Color] = []
var _segment_positions: Array[Vector3] = []
var _head_direction: Vector3 = Vector3.FORWARD
var _time: float = 0.0

# ---- Legs (2 per segment: left at even index, right at odd) ----
# Each leg is pure data — no CrawlerLeg dependency for clarity.

var _leg_count: int = 0
var _foot_planted: Array[Vector3] = []    ## Where foot is currently planted
var _foot_target: Array[Vector3] = []     ## Where foot is stepping toward
var _foot_current: Array[Vector3] = []    ## Current visual foot position (during arc)
var _foot_stepping: Array[bool] = []      ## Mid-step?
var _foot_arc_t: Array[float] = []        ## 0-1 step arc progress
var _leg_mm: MultiMeshInstance3D = null   ## All leg capsules (1 draw call)
var _leg_instance_count: int = 0          ## upper + lower per leg

const STEP_THRESHOLD: float = 0.35  ## Drift before step triggers (scaled)
const STEP_SPEED: float = 10.0      ## Arc travel speed
const PREDICT_TIME: float = 0.3     ## Seconds ahead to predict foot target

# ---- Steering: a bot drives the body by TARGET (bot MVP, 2026-10-04) ----
#
# The crawler always walked toward a private `_wander_target` that it picked itself.
# A brain (BotRegistry.move -> set_target) now supplies that target instead, so the
# body keeps its own gait, undulation and IK legs — the brain only says WHERE.
# Remote peers call set_target with the replicated position, so the legs animate
# there too instead of a frozen body sliding along. No target = wander, as before.

## Arrived within this (scaled) distance of the target: stand still.
const ARRIVE_RADIUS: float = 0.6
## Further than this from a new target: place the body there (spawn, teleport).
const SNAP_DISTANCE: float = 8.0

var _external_target: bool = false
var _arrived: bool = false


## Walk to `pos` with this body's own gait. The bot body contract (bot_marker.gd has
## the same method).
func set_target(pos: Vector3) -> void:
	if not _initialized:
		global_position = pos
		return
	var head: Vector3 = _segment_positions[0] if _segment_positions.size() > 0 else global_position
	if Vector2(pos.x - head.x, pos.z - head.z).length() > SNAP_DISTANCE:
		_teleport(pos)
	_external_target = true
	_wander_target = pos
	_arrived = false


## Go back to wandering on its own.
func clear_target() -> void:
	_external_target = false
	_arrived = false
	_pick_wander_target()


func _teleport(pos: Vector3) -> void:
	global_position = pos
	_init_positions()
	_plant_all_feet()


# ---- Wandering ----

var _wander_target: Vector3 = Vector3.ZERO
var _wander_timer: float = 0.0
var _rng: RandomNumberGenerator
var _initialized: bool = false
var _exclude_rids: Array[RID] = []

enum LODLevel { FULL, MEDIUM, LOW }
var _lod: int = LODLevel.FULL
var _frame_id: int = 0
static var _instance_counter: int = 0
var _stagger_offset: int = 0
const PHYSICS_STAGGER: int = 3  ## Full physics every Nth frame


func update_lod(player_pos: Vector3, camera: Camera3D = null) -> void:
	var dist_sq: float = global_position.distance_squared_to(player_pos)
	if dist_sq < 400.0:
		_lod = LODLevel.FULL
	elif dist_sq < 2500.0:
		_lod = LODLevel.MEDIUM
	else:
		_lod = LODLevel.LOW
	if camera and not camera.is_position_in_frustum(global_position):
		_lod = LODLevel.LOW


func initialize(rng: RandomNumberGenerator) -> void:
	_stagger_offset = _instance_counter % PHYSICS_STAGGER
	_instance_counter += 1
	_rng = RandomNumberGenerator.new()
	_rng.seed = rng.randi()

	segment_count = rng.randi_range(3, 6)
	creature_scale = rng.randf_range(0.8, 2.5)
	move_speed = rng.randf_range(1.5, 4.0)
	wave_amplitude = rng.randf_range(0.5, 1.5)
	wave_frequency = rng.randf_range(0.5, 1.2)

	var hue: float = rng.randf_range(0.05, 0.25)
	var sat: float = rng.randf_range(0.3, 0.7)
	var val: float = rng.randf_range(0.25, 0.5)
	body_color = Color.from_hsv(hue, sat, val)

	body_height = 0.35 * creature_scale
	segment_radius = 0.22 * creature_scale  # Main spheres larger
	connector_radius = segment_radius * 0.55  # Connectors about half size
	segment_spacing = segment_radius * 1.6  # Spacing for main segments (connectors fill gaps)

	# Random skew: biggest sphere at head or tail
	size_skew = rng.randf_range(-1.0, 1.0)
	leg_spread = 0.45 * creature_scale
	upper_leg_length = 0.3 * creature_scale
	lower_leg_length = 0.3 * creature_scale
	leg_length = upper_leg_length + lower_leg_length
	step_height = 0.15 * creature_scale

	_head_direction = Vector3.FORWARD
	_leg_count = segment_count * 2

	_create_body()
	_init_positions()
	_create_legs()
	_plant_all_feet()
	_pick_wander_target()
	_initialized = true


# ---- Body spheres ----

# Static material cache: key = snapped color hash, value = StandardMaterial3D
static var _mat_cache: Dictionary = {}

static func _get_shared_mat(color: Color) -> StandardMaterial3D:
	# Snap color to reduce cache entries (256 shades per channel is plenty)
	var key: int = (int(color.r * 63) << 12) | (int(color.g * 63) << 6) | int(color.b * 63)
	if _mat_cache.has(key):
		return _mat_cache[key]
	var mat := StandardMaterial3D.new()
	mat.albedo_color = color
	mat.roughness = 0.8
	_mat_cache[key] = mat
	return mat


func _create_body() -> void:
	_segment_radii.clear()
	_segment_colors.clear()
	for i in range(segment_count):
		var t: float = float(i) / float(segment_count - 1) if segment_count > 1 else 0.5
		var peak: float = 0.3 - size_skew * 0.3
		var dist: float = absf(t - peak)
		var taper: float = clampf(1.0 - dist * 0.8, 0.5, 1.0)
		_segment_radii.append(segment_radius * taper)
		_segment_colors.append(body_color.darkened(t * 0.3))

	# Body = segment_count spheres + (segment_count - 1) connectors
	_body_instance_count = segment_count + maxi(segment_count - 1, 0)

	var sphere := SphereMesh.new()
	sphere.radius = 1.0
	sphere.height = 2.0
	sphere.radial_segments = 8
	sphere.rings = 4

	var mm := MultiMesh.new()
	mm.transform_format = MultiMesh.TRANSFORM_3D
	mm.use_colors = true
	mm.mesh = sphere
	mm.instance_count = _body_instance_count

	_body_mm = MultiMeshInstance3D.new()
	_body_mm.multimesh = mm
	_body_mm.name = "BodyMM"
	_body_mm.top_level = true  # Render in world space (transforms are world-space)
	add_child(_body_mm)


func _init_positions() -> void:
	_segment_positions.clear()
	var start: Vector3 = global_position + Vector3.UP * body_height
	for i in range(segment_count):
		_segment_positions.append(start - _head_direction * segment_spacing * float(i))
	# Initial visual sync handled by first _sync_visuals call


# ---- Leg meshes ----

func _create_legs() -> void:
	for i in range(_leg_count):
		_foot_planted.append(Vector3.ZERO)
		_foot_target.append(Vector3.ZERO)
		_foot_current.append(Vector3.ZERO)
		_foot_stepping.append(false)
		_foot_arc_t.append(1.0)

	_leg_instance_count = _leg_count * 2  # upper + lower per leg

	var capsule := CapsuleMesh.new()
	capsule.radius = 1.0
	capsule.height = 2.0

	var leg_mat: StandardMaterial3D = _get_shared_mat(body_color.darkened(0.2))
	capsule.material = leg_mat

	var mm := MultiMesh.new()
	mm.transform_format = MultiMesh.TRANSFORM_3D
	mm.mesh = capsule
	mm.instance_count = _leg_instance_count

	_leg_mm = MultiMeshInstance3D.new()
	_leg_mm.multimesh = mm
	_leg_mm.name = "LegMM"
	_leg_mm.top_level = true
	add_child(_leg_mm)


func _plant_all_feet() -> void:
	var space: PhysicsDirectSpaceState3D = get_world_3d().direct_space_state
	for i in range(_leg_count):
		var ground_pos: Vector3 = _get_foot_ground_target(i, space)
		_foot_planted[i] = ground_pos
		_foot_target[i] = ground_pos
		_foot_current[i] = ground_pos
		_foot_stepping[i] = false
		_foot_arc_t[i] = 1.0


# ---- Main loop ----

## Set by a CreatureScheduler: it ticks this creature at its tier's rate (sim_tick), so
## the engine's own physics tick stands down. A creature outside a spawner (a bot's
## body) keeps ticking itself.
var scheduled := false


func _physics_process(delta: float) -> void:
	if not _initialized or scheduled:
		return
	sim_tick(delta)


## One tick of `delta` seconds — every physics frame on its own, or at a tier's rate
## (with the time it missed) under a scheduler.
func sim_tick(delta: float) -> void:
	if not _initialized:
		return
	# Benchmark (#114 §7): this tick's CPU and its share of the ground sweeps.
	var bench_t0 := Time.get_ticks_usec()
	var bench_q0 := SphereSweep.calls
	_tick(delta)
	SimStats.add("creature.crawler", Time.get_ticks_usec() - bench_t0, SphereSweep.calls - bench_q0)


func _tick(delta: float) -> void:
	_time += delta
	_frame_id += 1

	var do_physics: bool = (_lod == LODLevel.FULL) and (_frame_id % PHYSICS_STAGGER == _stagger_offset)

	_update_wander(delta)

	if do_physics:
		# FULL + physics frame: ground snap all segments + feet
		# FULL + physics frame: ground snap all segments + feet
		var space: PhysicsDirectSpaceState3D = get_world_3d().direct_space_state
		if space == null:
			return
		_update_head(delta, space)
		_update_body(delta, space)
		_update_feet(delta, space)
	else:
		# FULL + interpolation frame: advance positions without physics
		_update_head_no_snap(delta)
		_update_body_no_snap(delta)
		_advance_foot_arcs(delta)
	_sync_visuals()


# ---- Wandering ----

func _update_wander(delta: float) -> void:
	var to_target: Vector3 = _wander_target - _segment_positions[0]
	to_target.y = 0.0
	if _external_target:
		# A brain's target is never replaced; reaching it means standing still.
		_arrived = to_target.length() < ARRIVE_RADIUS * creature_scale
		return
	_wander_timer -= delta
	if to_target.length() < 1.0 or _wander_timer <= 0.0:
		_pick_wander_target()


## Current walking speed: zero once a brain's target is reached.
func _speed() -> float:
	return 0.0 if _arrived else move_speed


func _pick_wander_target() -> void:
	var angle: float = _rng.randf() * TAU
	var radius: float = _rng.randf_range(5.0, 15.0)
	var head: Vector3 = _segment_positions[0] if _segment_positions.size() > 0 else global_position
	_wander_target = head + Vector3(cos(angle) * radius, 0.0, sin(angle) * radius)
	_wander_timer = _rng.randf_range(3.0, 8.0)


# ---- Head: steer toward target + lateral sine wave (creates the S-curve) ----

## Interpolation frame: move head without ground snap
func _update_head_no_snap(delta: float) -> void:
	var head_pos: Vector3 = _segment_positions[0]
	var to_target: Vector3 = (_wander_target - head_pos)
	to_target.y = 0.0
	if to_target.length() > 0.1:
		_head_direction = _head_direction.lerp(to_target.normalized(), 2.0 * delta).normalized()
	var right: Vector3 = _head_direction.cross(Vector3.UP)
	if right.length_squared() < 0.001:
		right = _head_direction.cross(Vector3.RIGHT)
	right = right.normalized()
	var h_wave: float = sin(_time * wave_frequency * TAU) * wave_amplitude
	var velocity: Vector3 = Vector3.ZERO if _arrived else _head_direction * _speed() + right * h_wave
	head_pos += velocity * delta
	_segment_positions[0] = head_pos
	global_position = head_pos
	rotation.y = atan2(_head_direction.x, _head_direction.z)


func _update_head(delta: float, space: PhysicsDirectSpaceState3D) -> void:
	var head_pos: Vector3 = _segment_positions[0]
	var to_target: Vector3 = (_wander_target - head_pos)
	to_target.y = 0.0
	if to_target.length() > 0.1:
		_head_direction = _head_direction.lerp(to_target.normalized(), 2.0 * delta).normalized()

	# Lateral sine on head velocity — the body S-curve forms from the trail
	var right: Vector3 = _head_direction.cross(Vector3.UP)
	if right.length_squared() < 0.001:
		right = _head_direction.cross(Vector3.RIGHT)
	right = right.normalized()

	var h_wave: float = sin(_time * wave_frequency * TAU) * wave_amplitude
	var velocity: Vector3 = Vector3.ZERO if _arrived else _head_direction * _speed() + right * h_wave
	head_pos += velocity * delta

	# Ground snap
	head_pos.y = _ground_height(space, head_pos) + body_height
	_segment_positions[0] = head_pos

	global_position = head_pos
	rotation.y = atan2(_head_direction.x, _head_direction.z)


# ---- Body: chain follow + ground snap ----

func _update_body(delta: float, space: PhysicsDirectSpaceState3D) -> void:
	for i in range(1, segment_count):
		var ahead: Vector3 = _segment_positions[i - 1]
		var current: Vector3 = _segment_positions[i]
		var dir: Vector3 = (ahead - current)
		if dir.length() > 0.001:
			dir = dir.normalized()
		else:
			dir = _head_direction
		var ideal: Vector3 = ahead - dir * segment_spacing
		current = current.lerp(ideal, 10.0 * delta)
		current.y = _ground_height(space, current) + body_height
		_segment_positions[i] = current


## MEDIUM LOD: body chain follow without ground-snap physics queries
func _update_body_no_snap(delta: float) -> void:
	for i in range(1, segment_count):
		var ahead: Vector3 = _segment_positions[i - 1]
		var current: Vector3 = _segment_positions[i]
		var dir: Vector3 = (ahead - current)
		if dir.length() > 0.001:
			dir = dir.normalized()
		else:
			dir = _head_direction
		var ideal: Vector3 = ahead - dir * segment_spacing
		current = current.lerp(ideal, 10.0 * delta)
		# No ground snap — keep last known Y
		_segment_positions[i] = current


## MEDIUM LOD: advance stepping foot arcs without ground queries.
## Clamp feet to max leg reach so legs don't stretch to infinity.
func _advance_foot_arcs(delta: float) -> void:
	for i in range(_leg_count):
		if _foot_stepping[i]:
			_foot_arc_t[i] = minf(_foot_arc_t[i] + delta * STEP_SPEED, 1.0)
			var t: float = _foot_arc_t[i]
			var linear: Vector3 = _foot_planted[i].lerp(_foot_target[i], t)
			var arc_lift: float = 4.0 * t * (1.0 - t) * step_height
			_foot_current[i] = linear + Vector3.UP * arc_lift
			if t >= 1.0:
				_foot_stepping[i] = false
				_foot_planted[i] = _foot_target[i]
				_foot_current[i] = _foot_target[i]
		# Clamp planted feet: if body moved too far, snap foot to body
		var seg_idx: int = i / 2
		var body_pos: Vector3 = _segment_positions[seg_idx] if seg_idx < _segment_positions.size() else global_position
		var drift: float = _foot_current[i].distance_to(body_pos)
		if drift > leg_length * 2.0:
			var is_left: bool = (i % 2 == 0)
			var fwd: Vector3 = _get_segment_forward(seg_idx)
			var right: Vector3 = fwd.cross(Vector3.UP).normalized()
			var lateral: Vector3 = -right if is_left else right
			_foot_planted[i] = body_pos + lateral * leg_spread
			_foot_planted[i].y = body_pos.y - body_height
			_foot_current[i] = _foot_planted[i]
			_foot_target[i] = _foot_planted[i]
			_foot_stepping[i] = false


func _ground_height(space: PhysicsDirectSpaceState3D, pos: Vector3) -> float:
	var from: Vector3 = Vector3(pos.x, pos.y + body_height * 4.0, pos.z)
	var to: Vector3 = Vector3(pos.x, pos.y - body_height * 4.0, pos.z)
	var result: Dictionary = SphereSweep.sweep(space, from, to, segment_radius * 0.2, _exclude_rids)
	if not result.is_empty():
		return result["point"].y
	return 0.0


# ---- Feet: predict target from trajectory, arc step, IK ----

func _get_gait_group(leg_idx: int) -> int:
	# Alternating diagonal gait: left-front(0) + right-rear(3) = group 0
	# right-front(1) + left-rear(2) = group 1, etc.
	var seg_idx: int = leg_idx / 2
	var is_left: bool = (leg_idx % 2 == 0)
	return (seg_idx + (0 if is_left else 1)) % 2


func _update_feet(delta: float, space: PhysicsDirectSpaceState3D) -> void:
	# Check which gait groups currently have a leg mid-step
	var group_stepping: Array[bool] = [false, false]
	for i in range(_leg_count):
		if _foot_stepping[i]:
			group_stepping[_get_gait_group(i)] = true

	for i in range(_leg_count):
		var gait_group: int = _get_gait_group(i)

		if _foot_stepping[i]:
			# Advance arc
			_foot_arc_t[i] = minf(_foot_arc_t[i] + delta * STEP_SPEED, 1.0)
			var t: float = _foot_arc_t[i]
			var linear: Vector3 = _foot_planted[i].lerp(_foot_target[i], t)
			var arc_lift: float = 4.0 * t * (1.0 - t) * step_height
			_foot_current[i] = linear + Vector3.UP * arc_lift

			if t >= 1.0:
				_foot_stepping[i] = false
				_foot_planted[i] = _foot_target[i]
				_foot_current[i] = _foot_target[i]
				group_stepping[gait_group] = false
		else:
			_foot_current[i] = _foot_planted[i]
			var ideal: Vector3 = _get_foot_ground_target(i, space)
			var drift: float = _foot_planted[i].distance_to(ideal)
			var threshold: float = STEP_THRESHOLD * creature_scale

			if drift > threshold and not group_stepping[gait_group]:
				_foot_target[i] = _predict_foot_target(i, space)
				_foot_arc_t[i] = 0.0
				_foot_stepping[i] = true
				group_stepping[gait_group] = true


## Compute where the foot SHOULD be right now (ideal resting position on ground).
## Uses parent segment's ground height instead of a separate SphereSweep per leg.
func _get_foot_ground_target(leg_idx: int, _space: PhysicsDirectSpaceState3D) -> Vector3:
	var seg_idx: int = leg_idx / 2
	var is_left: bool = (leg_idx % 2 == 0)
	var body_pos: Vector3 = _segment_positions[seg_idx] if seg_idx < _segment_positions.size() else global_position

	var fwd: Vector3 = _get_segment_forward(seg_idx)
	var right: Vector3 = fwd.cross(Vector3.UP).normalized()

	var lateral_dir: Vector3 = -right if is_left else right
	var target_xz: Vector3 = body_pos + lateral_dir * leg_spread
	# Use segment's ground-snapped Y (already computed in _update_body)
	# instead of a separate SphereSweep per foot
	target_xz.y = body_pos.y - body_height
	return target_xz


## Predict where the foot should LAND by looking ahead along the trajectory.
## Uses segment Y instead of per-foot SphereSweep.
func _predict_foot_target(leg_idx: int, _space: PhysicsDirectSpaceState3D) -> Vector3:
	var seg_idx: int = leg_idx / 2
	var is_left: bool = (leg_idx % 2 == 0)
	var body_pos: Vector3 = _segment_positions[seg_idx] if seg_idx < _segment_positions.size() else global_position

	var fwd: Vector3 = _get_segment_forward(seg_idx)
	var right: Vector3 = fwd.cross(Vector3.UP)
	if right.length_squared() < 0.001:
		right = Vector3.RIGHT
	right = right.normalized()

	var future_time: float = _time + PREDICT_TIME
	var h_wave: float = sin(future_time * wave_frequency * TAU) * wave_amplitude
	var future_offset: Vector3 = Vector3.ZERO if _arrived else (_head_direction * _speed() + right * h_wave) * PREDICT_TIME
	var future_body: Vector3 = body_pos + future_offset

	var lateral_dir: Vector3 = -right if is_left else right
	var target_xz: Vector3 = future_body + lateral_dir * leg_spread
	# Foot lands at segment ground height
	target_xz.y = body_pos.y - body_height
	return target_xz


func _get_segment_forward(seg_idx: int) -> Vector3:
	var fwd: Vector3
	if seg_idx > 0 and seg_idx < _segment_positions.size():
		fwd = (_segment_positions[seg_idx - 1] - _segment_positions[seg_idx])
	elif seg_idx < _segment_positions.size() - 1:
		fwd = (_segment_positions[seg_idx] - _segment_positions[seg_idx + 1])
	else:
		fwd = _head_direction
	fwd.y = 0.0
	if fwd.length_squared() < 0.001:
		fwd = _head_direction
	return fwd.normalized()


# ---- Sync visuals: body spheres + leg IK ----

## ---- POSE + RENDER INTERPOLATION (#114, user 2026-10-13: "20 Hz near tier and
## interpolate") ----------------------------------------------------------------------
##
## Drawing was 41-56 % of a crawler's tick (bench_creatures' visuals counter), almost all
## of it the per-leg IK solve. So the work is split: _compute_pose() runs at the SIM rate
## and does the IK into a flat pose — segment centres + radii, and shoulder / knee / foot
## per leg; _draw_pose(alpha) runs every RENDER frame while interpolating and only blends
## the previous pose into the current one and writes transforms (no acos, no IK). Legs
## blend too, so a 20 Hz body never leaves its legs behind. Rendering sits one tick
## behind the simulation — the standard fixed-timestep trade (Gaffer, "Fix Your Timestep!").
## Not interpolating (60 Hz, or outside a scheduler), each tick draws at alpha 1 at once.
var _pose_prev := PackedVector3Array()
var _pose_cur := PackedVector3Array()
var _radii_prev := PackedFloat32Array()
var _radii_cur := PackedFloat32Array()
## Set by CreatureScheduler for a NEAR creature ticked every `_tick_period` physics frames.
var interpolating := false
var _tick_pf := 0               # Engine physics frame of the last tick
var _tick_period := 1
## The head sphere's position as last DRAWN (gate_creature_tiers T9).
var drawn_head := Vector3.ZERO


func _sync_visuals() -> void:
	# Benchmark (#114): the sim-rate share of drawing (pose + IK), and with it the draw
	# when not interpolating.
	var vis_t0 := Time.get_ticks_usec()
	_compute_pose()
	_tick_pf = Engine.get_physics_frames()
	if not interpolating:
		_draw_pose(1.0)
	SimStats.add("creature.crawler.visuals", Time.get_ticks_usec() - vis_t0)


func _process(_delta: float) -> void:
	if not interpolating or not _initialized or _pose_cur.is_empty():
		return
	var t0 := Time.get_ticks_usec()
	var since := float(Engine.get_physics_frames() - _tick_pf) + Engine.get_physics_interpolation_fraction()
	_draw_pose(clampf(since / float(maxi(_tick_period, 1)), 0.0, 1.0))
	SimStats.add("creature.crawler.draw", Time.get_ticks_usec() - t0)


## Blend the last two poses and write the MultiMesh transforms. No IK here.
func _draw_pose(alpha: float) -> void:
	var n_seg := _radii_cur.size()
	if n_seg == 0:
		return
	var pp := _pose_prev if _pose_prev.size() == _pose_cur.size() else _pose_cur
	var rp := _radii_prev if _radii_prev.size() == n_seg else _radii_cur
	if _body_mm and _body_mm.multimesh:
		var bmm: MultiMesh = _body_mm.multimesh
		var idx := 0
		for i in n_seg:
			var p: Vector3 = pp[i].lerp(_pose_cur[i], alpha)
			if i == 0:
				drawn_head = p        # what was drawn (a headless renderer cannot read it back)
			var r: float = lerpf(rp[i], _radii_cur[i], alpha)
			bmm.set_instance_transform(idx, Transform3D(Basis.IDENTITY.scaled(Vector3(r, r, r)), p))
			idx += 1
		var cr: float = connector_radius
		for i in n_seg - 1:
			var mid: Vector3 = (pp[i].lerp(_pose_cur[i], alpha) + pp[i + 1].lerp(_pose_cur[i + 1], alpha)) * 0.5
			bmm.set_instance_transform(idx, Transform3D(Basis.IDENTITY.scaled(Vector3(cr, cr, cr)), mid))
			idx += 1
	if _leg_mm == null or _leg_mm.multimesh == null:
		return
	var lmm: MultiMesh = _leg_mm.multimesh
	var leg_radius: float = 0.02 * creature_scale
	var lidx := 0
	var base := n_seg
	for i in _leg_count:
		var o := base + i * 3
		if o + 2 >= _pose_cur.size():
			break
		var shoulder: Vector3 = pp[o].lerp(_pose_cur[o], alpha)
		var knee: Vector3 = pp[o + 1].lerp(_pose_cur[o + 1], alpha)
		var foot: Vector3 = pp[o + 2].lerp(_pose_cur[o + 2], alpha)
		lmm.set_instance_transform(lidx, _capsule_transform(shoulder, knee, leg_radius))
		lidx += 1
		lmm.set_instance_transform(lidx, _capsule_transform(knee, foot, leg_radius))
		lidx += 1


## The SIM-RATE half: this tick's pose (segment centres, pulse radii, and every leg's
## shoulder / knee / foot from the two-bone IK) — the old _sync_visuals maths, unchanged,
## writing points instead of transforms. The previous pose is kept for blending.
func _compute_pose() -> void:
	_pose_prev = _pose_cur.duplicate()
	_radii_prev = _radii_cur.duplicate()
	var pose := PackedVector3Array()
	var radii := PackedFloat32Array()
	# Body spheres with subtle pulsation (colours are fixed: set here, not per frame)
	var bmm: MultiMesh = _body_mm.multimesh if _body_mm and _body_mm.multimesh else null
	for i in range(segment_count):
		var phase: float = _time * wave_frequency * TAU - float(i) * 0.6
		var pulse: float = 1.0 + sin(phase) * 0.08
		radii.append(_segment_radii[i] * pulse)
		pose.append(_segment_positions[i])
		if bmm != null:
			bmm.set_instance_color(i, _segment_colors[i])
	if bmm != null:
		for i in range(segment_count - 1):
			bmm.set_instance_color(segment_count + i, body_color.darkened(0.15))

	# Leg IK: two-bone solve from body shoulder to foot
	for i in range(_leg_count):
		var seg_idx: int = i / 2
		var is_left: bool = (i % 2 == 0)
		var body_pos: Vector3 = _segment_positions[seg_idx] if seg_idx < _segment_positions.size() else global_position

		var fwd: Vector3 = _get_segment_forward(seg_idx)
		var right: Vector3 = fwd.cross(Vector3.UP)
		if right.length_squared() < 0.001:
			right = Vector3.RIGHT
		right = right.normalized()
		var lateral_dir: Vector3 = -right if is_left else right
		var shoulder: Vector3 = body_pos + lateral_dir * segment_radius * 0.8

		var foot: Vector3 = _foot_current[i]

		var to_foot: Vector3 = foot - shoulder
		var reach: float = to_foot.length()
		var max_reach: float = upper_leg_length + lower_leg_length

		var pole: Vector3 = (lateral_dir * 0.5 + Vector3.UP * 0.5).normalized()

		var knee: Vector3
		if reach >= max_reach * 0.99 or reach < 0.001:
			knee = (shoulder + foot) * 0.5 + pole * upper_leg_length * 0.3
		else:
			var a: float = upper_leg_length
			var b: float = lower_leg_length
			var d: float = reach
			var cos_a: float = clampf((a * a + d * d - b * b) / (2.0 * a * d), -1.0, 1.0)
			var angle: float = acos(cos_a)
			var fwd_dir: Vector3 = to_foot.normalized()
			var axis: Vector3 = fwd_dir.cross(pole)
			if axis.length_squared() > 0.001:
				axis = axis.normalized()
				knee = shoulder + fwd_dir.rotated(axis, angle) * a
			else:
				knee = shoulder + fwd_dir * a

		pose.append(shoulder)
		pose.append(knee)
		pose.append(foot)
	_pose_cur = pose
	_radii_cur = radii


func _capsule_transform(from_pos: Vector3, to_pos: Vector3, radius: float) -> Transform3D:
	var dir: Vector3 = to_pos - from_pos
	var length: float = dir.length()
	if length < 0.001:
		return Transform3D(Basis.IDENTITY.scaled(Vector3(radius, radius, radius)), from_pos)
	var up: Vector3 = dir.normalized()
	var right: Vector3 = up.cross(Vector3.FORWARD)
	if right.length_squared() < 0.001:
		right = up.cross(Vector3.RIGHT)
	right = right.normalized()
	var fwd: Vector3 = right.cross(up).normalized()
	var basis: Basis = Basis(right * radius, up * (length * 0.5), fwd * radius)
	return Transform3D(basis, (from_pos + to_pos) * 0.5)