OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-creatures / scenes / creatures / flying_serpent.gd

235 lines · gd ref:
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()


func _process(delta: float) -> void:
	if not _initialized:
		return

	_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()


func _update_wander(delta: float) -> void:
	_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])