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
# ---- 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 ----
func _physics_process(delta: float) -> void:
if not _initialized:
return
_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:
_wander_timer -= delta
var to_target: Vector3 = _wander_target - _segment_positions[0]
to_target.y = 0.0
if to_target.length() < 1.0 or _wander_timer <= 0.0:
_pick_wander_target()
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 = _head_direction * move_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 = _head_direction * move_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 = _head_direction * move_speed * PREDICT_TIME + 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 ----
func _sync_visuals() -> void:
# Body spheres + connectors via MultiMesh
if _body_mm and _body_mm.multimesh:
var bmm: MultiMesh = _body_mm.multimesh
var idx: int = 0
# Main body spheres with subtle pulsation
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
var r: float = _segment_radii[i] * pulse
bmm.set_instance_transform(idx, Transform3D(Basis.IDENTITY.scaled(Vector3(r, r, r)), _segment_positions[i]))
bmm.set_instance_color(idx, _segment_colors[i])
idx += 1
# Connector spheres: midpoint between adjacent segments
for i in range(segment_count - 1):
if i + 1 < _segment_positions.size():
var mid: Vector3 = (_segment_positions[i] + _segment_positions[i + 1]) * 0.5
var cr: float = connector_radius
bmm.set_instance_transform(idx, Transform3D(Basis.IDENTITY.scaled(Vector3(cr, cr, cr)), mid))
bmm.set_instance_color(idx, body_color.darkened(0.15))
idx += 1
# Leg IK: two-bone solve from body shoulder to foot → MultiMesh
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: int = 0
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
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
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)