class_name Hopper extends Node3D
## Legless vertical creature that hops around randomly.
## Body is a vertical stack of spheres that compresses/stretches with
## jump force — undulations travel up the body as vertical wave.
## Call initialize(rng) after adding to scene tree.
# ---- Species parameters (set by initialize) ----
var segment_count: int = 6
var creature_scale: float = 1.0
var hop_force: float = 8.0 ## Upward impulse per hop
var hop_interval: float = 1.5 ## Seconds between hops
var lateral_speed: float = 3.0 ## Horizontal drift per hop
var wave_speed: float = 3.0 ## Vertical undulation wave speed
var wave_strength: float = 0.3 ## Vertical compression/stretch amount
var segment_radius: float = 0.2
var segment_spacing: float = 0.25
var body_color: Color = Color(0.5, 0.3, 0.6)
var size_skew: float = 0.0 ## -1 = largest at bottom, +1 = largest at top
# ---- Physics state ----
var _velocity: Vector3 = Vector3.ZERO
var _on_ground: bool = false
var _bounce_time: float = 0.0 ## Time since last bounce (drives undulation)
var _hop_dir: Vector3 = Vector3.ZERO
# ---- Impact squash ----
var _impact_strength: float = 0.0 ## 0-1, how hard we landed (from velocity)
var _impact_time: float = 99.0 ## Time since impact (drives decay)
const IMPACT_STIFFNESS: float = 12.0 ## How fast squash recovers (higher = snappier)
const IMPACT_SCALE: float = 0.02 ## velocity → squash factor
# ---- Internal ----
var _mm: MultiMeshInstance3D = null
var _segment_tapers: Array[float] = []
var _segment_colors: Array[Color] = []
var _base_position: Vector3 = Vector3.ZERO
## The ground the last FULL-LOD sweep found (where it spawned until then).
var _last_ground_y: float = 0.0
var _have_ground := false
var _rng: RandomNumberGenerator
var _initialized: bool = false
var _time: float = 0.0
const GRAVITY: float = 20.0
const GROUND_SNAP: float = 0.05
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 < 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:
_rng = RandomNumberGenerator.new()
_rng.seed = rng.randi()
# Random species
var size_roll: float = rng.randf()
if size_roll < 0.6:
segment_count = rng.randi_range(3, 5) # small
elif size_roll < 0.9:
segment_count = rng.randi_range(6, 10) # medium
else:
segment_count = rng.randi_range(12, 18) # tall
creature_scale = rng.randf_range(0.5, 1.8)
hop_force = rng.randf_range(5.0, 14.0) * creature_scale
hop_interval = rng.randf_range(0.8, 2.5)
lateral_speed = rng.randf_range(1.5, 5.0)
wave_speed = rng.randf_range(2.0, 5.0)
wave_strength = rng.randf_range(0.15, 0.5)
segment_radius = 0.3 * creature_scale
segment_spacing = segment_radius * 1.3
# Random skew: biggest sphere at top or bottom
size_skew = rng.randf_range(-1.0, 1.0)
var hue: float = rng.randf()
var sat: float = rng.randf_range(0.5, 0.9)
var val: float = rng.randf_range(0.4, 0.8)
body_color = Color.from_hsv(hue, sat, val)
_create_segments()
_base_position = global_position
# Spawned ON the ground (the spawner swept for it): that is the ground until a FULL
# tick sweeps again.
_last_ground_y = global_position.y
_have_ground = true
_bounce_time = 0.0
_pick_hop_dir()
_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) if segment_count > 1 else 0.5
var peak: float = 0.5 + size_skew * 0.4
var dist_from_peak: float = absf(t - peak)
var taper: float = clampf(1.0 - dist_from_peak * 1.2, 0.3, 1.0)
_segment_tapers.append(taper)
var seg_color: Color = body_color
if t < 0.3:
seg_color = body_color.darkened(0.2 * (1.0 - t / 0.3))
elif t > 0.7:
seg_color = body_color.lightened(0.3 * (t - 0.7) / 0.3)
_segment_colors.append(seg_color)
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)
## 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 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.hopper", Time.get_ticks_usec() - bench_t0, SphereSweep.calls - bench_q0)
func _tick(delta: float) -> void:
_time += delta
_bounce_time += delta
_impact_time += delta
# Gravity + velocity
_velocity.y -= GRAVITY * delta
_base_position += _velocity * delta
# Ground collision — skip physics query at LOW/MEDIUM, use last known ground
var ground_y: float = _base_position.y # fallback: current position
if _lod == LODLevel.FULL:
var space: PhysicsDirectSpaceState3D = get_world_3d().direct_space_state
if space:
var sweep_from: Vector3 = _base_position + Vector3.UP * 5.0
var sweep_to: Vector3 = _base_position - Vector3.UP * 5.0
var result: Dictionary = SphereSweep.sweep(space, sweep_from, sweep_to, segment_radius * 0.3, [])
if not result.is_empty():
ground_y = result["point"].y
_last_ground_y = ground_y
_have_ground = true
else:
ground_y = _last_ground_y if _have_ground else 0.0
else:
# THE LAST GROUND IT STOOD ON, not y = 0: "flat ground at distance" dropped every
# far hopper on raised terrain through the world (CreatureScheduler tiers keep most
# creatures off FULL, so this is the common path now).
ground_y = _last_ground_y if _have_ground else 0.0
if _base_position.y <= ground_y + GROUND_SNAP:
_base_position.y = ground_y
# Record impact from landing velocity
if not _on_ground and _velocity.y < -1.0:
_impact_strength = clampf(absf(_velocity.y) * IMPACT_SCALE, 0.0, 0.6)
_impact_time = 0.0
# Bounce immediately on ground contact
if _velocity.y < -1.0:
_hop()
elif not _on_ground:
_hop()
_on_ground = true
else:
_on_ground = false
global_position = _base_position
_sync_visuals()
func _hop() -> void:
_velocity.y = hop_force
_pick_hop_dir()
_velocity.x = _hop_dir.x * lateral_speed
_velocity.z = _hop_dir.z * lateral_speed
_bounce_time = 0.0 # Reset undulation phase on each bounce
_on_ground = false
func _pick_hop_dir() -> void:
if _external_target:
# Hop TOWARD the brain's target; within reach, bounce in place.
var to: Vector3 = _target - _base_position
to.y = 0.0
_hop_dir = Vector3.ZERO if to.length() < ARRIVE_RADIUS * creature_scale else to.normalized()
return
var angle: float = _rng.randf() * TAU
_hop_dir = Vector3(cos(angle), 0, sin(angle))
# ---- 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 hop. The direction is chosen at each landing, so a new target takes effect
# on the next hop.
const ARRIVE_RADIUS: float = 0.8
const SNAP_DISTANCE: float = 8.0
var _external_target: bool = false
var _target: Vector3 = Vector3.ZERO
func set_target(pos: Vector3) -> void:
if not _initialized:
global_position = pos
return
if Vector2(pos.x - _base_position.x, pos.z - _base_position.z).length() > SNAP_DISTANCE:
_base_position = pos
_velocity = Vector3.ZERO
global_position = pos
_external_target = true
_target = pos
func clear_target() -> void:
_external_target = false
func _sync_visuals() -> void:
if _mm == null or _mm.multimesh == null:
return
var bmm: MultiMesh = _mm.multimesh
var squash_amount: float = _impact_strength * exp(-_impact_time * IMPACT_STIFFNESS)
var spring: float = squash_amount * cos(_impact_time * IMPACT_STIFFNESS * 0.8)
var squash_y: float = 1.0 - spring
var wave_decay: float = exp(-_bounce_time * 3.0)
for i in range(segment_count):
var t: float = float(i) / float(segment_count)
var r: float = segment_radius * _segment_tapers[i]
var base_y: float = _base_position.y + segment_spacing * float(i) * squash_y
var phase: float = _bounce_time * wave_speed * TAU - float(i) * 0.6
var wave_offset: float = sin(phase) * wave_strength * segment_radius * wave_decay
var pos := Vector3(_base_position.x, base_y + wave_offset, _base_position.z)
var scale_pulse: float = 1.0 + sin(phase + 0.5) * 0.2 * wave_decay
var local_squash: float = 1.0 - t
var sy: float = r * scale_pulse * (1.0 - spring * local_squash * 0.5)
var sxz: float = r * scale_pulse * (1.0 + spring * local_squash * 0.3)
bmm.set_instance_transform(i, Transform3D(Basis.IDENTITY.scaled(Vector3(sxz, sy, sxz)), pos))
bmm.set_instance_color(i, _segment_colors[i])