class_name FlyingSerpent extends Node3D
## Flying snake-like creature made of rigid sphere segments with sinusoidal 3D movement.
## Call initialize(rng) after adding to scene tree to generate random species.
# ---- Species parameters (set by initialize) ----
var segment_count: int = 8
var creature_scale: float = 1.0
var fly_speed: float = 4.0
var wave_amplitude: float = 1.5
var wave_frequency: float = 0.8
var vertical_amplitude: float = 0.6
var vertical_frequency: float = 0.5
var preferred_altitude: float = 20.0
var segment_radius: float = 0.15
var segment_spacing: float = 0.27
var head_color: Color = Color(0.3, 0.5, 0.2)
# ---- Internal state ----
var _mm: MultiMeshInstance3D = null ## Single draw call for all segments
var _segment_tapers: Array[float] = []
var _segment_colors: Array[Color] = []
var _segment_positions: Array[Vector3] = []
var _head_direction: Vector3 = Vector3.FORWARD
var _time: float = 0.0
var _wander_target: Vector3 = Vector3.ZERO
var _wander_timer: float = 0.0
var _rng: RandomNumberGenerator
var _initialized: bool = false
enum LODLevel { FULL, MEDIUM, LOW }
var _lod: int = LODLevel.FULL
func update_lod(player_pos: Vector3, camera: Camera3D = null) -> void:
var dist_sq: float = global_position.distance_squared_to(player_pos)
if dist_sq < 2500.0: # < 50m
_lod = LODLevel.FULL
elif dist_sq < 10000.0: # < 100m
_lod = LODLevel.MEDIUM
else:
_lod = LODLevel.LOW
# Off-screen: drop to MEDIUM (not LOW — frozen serpents are very visible in sky)
if camera and not camera.is_position_in_frustum(global_position):
_lod = LODLevel.MEDIUM
func initialize(rng: RandomNumberGenerator) -> void:
_rng = RandomNumberGenerator.new()
_rng.seed = rng.randi()
# Length class: 0=short (common), 1=medium, 2=long (rare)
var length_roll: float = rng.randf()
var length_class: int
if length_roll < 0.5:
length_class = 0 # short — common
elif length_roll < 0.85:
length_class = 1 # medium
else:
length_class = 2 # long — rare
match length_class:
0:
segment_count = rng.randi_range(3, 5)
1:
segment_count = rng.randi_range(8, 12)
2:
segment_count = rng.randi_range(15, 25)
creature_scale = rng.randf_range(0.6, 2.0)
fly_speed = rng.randf_range(2.0, 6.0)
wave_amplitude = rng.randf_range(0.8, 2.5)
wave_frequency = rng.randf_range(0.4, 1.2)
vertical_amplitude = rng.randf_range(0.3, 1.2)
vertical_frequency = rng.randf_range(0.3, 0.8)
preferred_altitude = rng.randf_range(10.0, 40.0)
segment_radius = 0.3 * creature_scale
segment_spacing = segment_radius * 1.4 # Tight overlap for chunky body
# Random head color — varied hue range
var hue: float = rng.randf() # full hue range
var sat: float = rng.randf_range(0.4, 0.9)
var val: float = rng.randf_range(0.4, 0.8)
head_color = Color.from_hsv(hue, sat, val)
_create_segments()
_init_positions()
_pick_wander_target()
_initialized = true
func _create_segments() -> void:
_segment_tapers.clear()
_segment_colors.clear()
for i in range(segment_count):
var t: float = float(i) / float(segment_count)
_segment_tapers.append(1.0 - t * 0.6)
_segment_colors.append(head_color.darkened(t * 0.5))
# Unit sphere — scaled per instance via Transform3D
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 = segment_count
_mm = MultiMeshInstance3D.new()
_mm.multimesh = mm
_mm.name = "BodyMM"
_mm.top_level = true
add_child(_mm)
func _init_positions() -> void:
_segment_positions.clear()
# Place segments in a line behind the starting position at preferred altitude
var start_pos: Vector3 = global_position
start_pos.y = preferred_altitude
for i in range(segment_count):
_segment_positions.append(start_pos - _head_direction * segment_spacing * float(i))
# Sync initial visual positions
_sync_visuals_sparse()
## Set by a CreatureScheduler: it ticks this creature at its tier's rate (sim_tick) —
## on the PHYSICS clock, so a serpent stops costing more the higher the frame rate.
var scheduled := false
func _process(delta: float) -> void:
if not _initialized or scheduled:
return
sim_tick(delta)
## One tick of `delta` seconds (see GroundCrawler.sim_tick).
func sim_tick(delta: float) -> void:
if not _initialized:
return
# Benchmark (#114 §7): no physics queries — pure CPU.
var bench_t0 := Time.get_ticks_usec()
_tick(delta)
SimStats.add("creature.serpent", Time.get_ticks_usec() - bench_t0)
func _tick(delta: float) -> void:
_time += delta
_update_wander(delta)
_update_head(delta)
_update_body(delta)
if _lod == LODLevel.FULL:
_sync_visuals()
else:
# MEDIUM/LOW: update positions only (skip pulse animation)
_sync_visuals_sparse()
# ---- Steering: a bot drives the body by TARGET (bot MVP, 2026-10-04) ----
#
# Same contract as GroundCrawler.set_target: the brain says WHERE, the body keeps
# its own flight. A serpent cannot stand still, so "arrived" is circling: the target
# is never replaced, and steering past it turns the body back — a natural orbit.
# Brains give GROUND positions, so the body flies BOT_HOVER above them, not into
# the floor.
const BOT_HOVER: float = 5.0
const SNAP_DISTANCE: float = 15.0
var _external_target: bool = false
func set_target(pos: Vector3) -> void:
var hover: Vector3 = pos + Vector3.UP * BOT_HOVER
if not _initialized:
global_position = pos
return
var head: Vector3 = _segment_positions[0] if _segment_positions.size() > 0 else global_position
preferred_altitude = hover.y
if head.distance_to(hover) > SNAP_DISTANCE:
global_position = hover
_init_positions()
_external_target = true
_wander_target = hover
func clear_target() -> void:
_external_target = false
_pick_wander_target()
func _update_wander(delta: float) -> void:
if _external_target:
return
_wander_timer -= delta
var to_target: Vector3 = _wander_target - _segment_positions[0]
var dist: float = to_target.length()
if dist < 2.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(10.0, 30.0)
var alt_offset: float = _rng.randf_range(-5.0, 5.0)
var head_pos: Vector3 = _segment_positions[0] if _segment_positions.size() > 0 else global_position
_wander_target = head_pos + Vector3(
cos(angle) * radius,
alt_offset,
sin(angle) * radius
)
# Clamp altitude to reasonable range
_wander_target.y = clampf(_wander_target.y, preferred_altitude - 10.0, preferred_altitude + 10.0)
_wander_timer = _rng.randf_range(3.0, 8.0)
func _update_head(delta: float) -> void:
var head_pos: Vector3 = _segment_positions[0]
# Steer toward wander target
var to_target: Vector3 = (_wander_target - head_pos).normalized()
_head_direction = _head_direction.lerp(to_target, 2.0 * delta).normalized()
# Compute perpendicular vectors for wave offset
var right: Vector3 = _head_direction.cross(Vector3.UP)
if right.length_squared() < 0.001:
right = _head_direction.cross(Vector3.RIGHT)
right = right.normalized()
var up: Vector3 = right.cross(_head_direction).normalized()
# Sinusoidal wave offsets
var h_wave: float = sin(_time * wave_frequency * TAU) * wave_amplitude
var v_wave: float = sin(_time * vertical_frequency * TAU) * vertical_amplitude
# Apply as velocity offset (multiply by delta * speed), not absolute position
var base_velocity: Vector3 = _head_direction * fly_speed
var wave_velocity: Vector3 = right * h_wave + up * v_wave
head_pos += (base_velocity + wave_velocity) * delta
_segment_positions[0] = head_pos
func _update_body(delta: float) -> void:
for i in range(1, segment_count):
var target: Vector3 = _segment_positions[i - 1]
var current: Vector3 = _segment_positions[i]
# Direction from current segment toward the one ahead
var dir: Vector3 = (target - current).normalized()
# Ideal position: segment_spacing behind the one ahead
var ideal: Vector3 = target - dir * segment_spacing
# Smooth follow — not instant snapping
_segment_positions[i] = current.lerp(ideal, 10.0 * delta)
func _sync_visuals() -> void:
if _mm == null or _mm.multimesh == null:
return
var bmm: MultiMesh = _mm.multimesh
for i in range(segment_count):
var r: float = segment_radius * _segment_tapers[i]
var phase: float = _time * wave_frequency * TAU - float(i) * 0.8
var pulse: float = 1.0 + sin(phase) * 0.2
r *= pulse
bmm.set_instance_transform(i, Transform3D(Basis.IDENTITY.scaled(Vector3(r, r, r)), _segment_positions[i]))
bmm.set_instance_color(i, _segment_colors[i])
## MEDIUM/LOW: positions only, no pulse animation
func _sync_visuals_sparse() -> void:
if _mm == null or _mm.multimesh == null:
return
var bmm: MultiMesh = _mm.multimesh
for i in range(segment_count):
var r: float = segment_radius * _segment_tapers[i]
bmm.set_instance_transform(i, Transform3D(Basis.IDENTITY.scaled(Vector3(r, r, r)), _segment_positions[i]))
bmm.set_instance_color(i, _segment_colors[i])