OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / creatures / hopper.gd

290 lines · gd ref:
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])