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
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
_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)
func _physics_process(delta: float) -> void:
if not _initialized:
return
_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
else:
ground_y = 0.0
else:
ground_y = 0.0 # Assume flat ground at distance
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:
var angle: float = _rng.randf() * TAU
_hop_dir = Vector3(cos(angle), 0, sin(angle))
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])