OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-creatures commits / 60fbd04

Initial hyper-creatures repo — standalone procedural creature system
Extracted from hyper-tps. Core creatures (Tier 1): - GroundCrawler: procedural lizard with MultiMesh body + 8 IK legs - FlyingSerpent: sinusoidal flying snake with sphere segments - Hopper: bouncing creature with squash/spring animation - CrawlerLeg: leg state management (RefCounted) - TwoBoneIK: two-bone IK solver (static math) - SphereSweep: cached physics sweep utility Test scene: creature_test.tscn — spawn/kill single creatures on flat plane. No networking dependencies (spawner/sync not included yet — Tier 2). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
60fbd0415085d4a7b240ccd0306a112d8864c5ba
by plankatron <fleeky@gmail.com> · 2026-04-04T19:12:20.000Z

Changed files (10) +1537 -0

.gitignore +7 -0
……@@ -0,0 +1,7 @@
1# Godot
2.godot/
3*.uid
4
5# OS
6.DS_Store
7Thumbs.db
project.godot +16 -0
……@@ -0,0 +1,16 @@
1; Engine configuration file.
2; It's best edited using the editor UI and not directly,
3; but it can also be edited manually with care.
4
5config_version=5
6
7[application]
8
9config/name="Hyper Creatures"
10run/main_scene="res://scenes/test/creature_test.tscn"
11config/features=PackedStringArray("4.6")
12
13[display]
14
15window/size/viewport_width=1280
16window/size/viewport_height=720
scenes/creatures/crawler_leg.gd +116 -0
……@@ -0,0 +1,116 @@
1class_name CrawlerLeg extends RefCounted
2## Single procedural leg for ground crawlers. Pure data — no nodes.
3## Sphere-sweeps downward to find ground, steps with arc interpolation
4## when foot drifts too far from ideal position. Uses gait groups so
5## legs in the same group don't step simultaneously.
6
7# ---- Configuration (set by parent creature) ----
8
9var upper_length: float = 0.3
10var lower_length: float = 0.3
11var attach_offset: Vector3 = Vector3.ZERO ## Body-local offset from center
12var step_threshold: float = 0.3 ## Drift distance before step triggers
13var arc_height: float = 0.12 ## How far foot lifts during step
14var arc_speed: float = 8.0 ## Step arc travel speed
15var sweep_radius: float = 0.06 ## SphereSweep radius
16var sweep_depth: float = 0.8 ## How far to sweep downward
17var revalidate_interval: int = 4 ## Revalidate every Nth frame (staggered)
18
19# ---- State ----
20
21var foot_position: Vector3 = Vector3.ZERO ## Current world position
22var foot_normal: Vector3 = Vector3.UP ## Ground normal at foot
23var foot_target: Vector3 = Vector3.ZERO ## Step destination
24var foot_arc_origin: Vector3 = Vector3.ZERO ## Step start point
25var foot_valid: bool = false ## Is foot on a valid surface?
26var is_stepping: bool = false ## Currently mid-step?
27var arc_t: float = 1.0 ## 0..1 progress through step arc
28var weight: float = 1.0 ## 0=moving, 1=planted (gait coordination)
29var group_id: int = 0 ## Alternating group assignment
30var leg_index: int = 0 ## Set by parent; staggers revalidation
31var _frame_counter: int = 0 ## Incremented each update call
32
33
34## Where the foot wants to be in world space.
35func get_ideal_position(body_transform: Transform3D) -> Vector3:
36 return body_transform * attach_offset
37
38
39## Main update. Returns true if foot is on a valid surface.
40func update(space: PhysicsDirectSpaceState3D, delta: float,
41 body_transform: Transform3D, surface_normal: Vector3,
42 can_step: bool, exclude: Array[RID] = []) -> bool:
43 _frame_counter += 1
44 var ideal: Vector3 = get_ideal_position(body_transform)
45
46 # If currently stepping, advance arc
47 if is_stepping:
48 weight = 0.3
49 arc_t = clampf(arc_t + delta * arc_speed, 0.0, 1.0)
50 foot_position = _arc_interpolate(foot_arc_origin, foot_target, surface_normal, arc_t)
51 if arc_t >= 1.0:
52 # Step complete — plant
53 is_stepping = false
54 weight = 1.0
55 foot_position = foot_target
56 return foot_valid
57
58 # Foot is planted
59 weight = 1.0
60 var drift: float = foot_position.distance_to(ideal)
61
62 if drift > step_threshold and can_step:
63 # Initiate step: sweep for new surface at ideal position
64 var sweep_from: Vector3 = ideal + surface_normal * sweep_depth * 0.5
65 var sweep_to: Vector3 = ideal - surface_normal * sweep_depth
66 var result: Dictionary = SphereSweep.sweep(space, sweep_from, sweep_to,
67 sweep_radius, exclude)
68 if not result.is_empty():
69 foot_arc_origin = foot_position
70 foot_target = result["point"]
71 foot_normal = result["normal"]
72 arc_t = 0.0
73 is_stepping = true
74 foot_valid = true
75 else:
76 foot_valid = false
77 else:
78 # Still close enough — re-validate surface position (staggered)
79 if (_frame_counter + leg_index) % revalidate_interval == 0:
80 _revalidate(space, ideal, surface_normal, delta, exclude)
81
82 return foot_valid
83
84
85## Initialize foot position directly (e.g. on spawn).
86func plant_at(pos: Vector3, normal: Vector3) -> void:
87 foot_position = pos
88 foot_normal = normal
89 foot_target = pos
90 foot_arc_origin = pos
91 foot_valid = true
92 is_stepping = false
93 arc_t = 1.0
94 weight = 1.0
95
96
97## Parabolic arc lift: linear interpolation plus normal-direction bump.
98func _arc_interpolate(from: Vector3, to: Vector3, normal: Vector3, t: float) -> Vector3:
99 var linear: Vector3 = from.lerp(to, t)
100 var arc_offset: float = 4.0 * t * (1.0 - t) * arc_height
101 return linear + normal * arc_offset
102
103
104## Re-sweep a planted foot to track surface curvature.
105func _revalidate(space: PhysicsDirectSpaceState3D, ideal: Vector3,
106 surface_normal: Vector3, delta: float, exclude: Array[RID]) -> void:
107 var sweep_from: Vector3 = ideal + surface_normal * sweep_depth * 0.5
108 var sweep_to: Vector3 = ideal - surface_normal * sweep_depth
109 var result: Dictionary = SphereSweep.sweep(space, sweep_from, sweep_to,
110 sweep_radius, exclude)
111 if not result.is_empty():
112 foot_position = foot_position.lerp(result["point"], 5.0 * delta)
113 foot_normal = result["normal"]
114 foot_valid = true
115 else:
116 foot_valid = false
scenes/creatures/flying_serpent.gd +234 -0
……@@ -0,0 +1,234 @@
1class_name FlyingSerpent extends Node3D
2## Flying snake-like creature made of rigid sphere segments with sinusoidal 3D movement.
3## Call initialize(rng) after adding to scene tree to generate random species.
4
5# ---- Species parameters (set by initialize) ----
6
7var segment_count: int = 8
8var creature_scale: float = 1.0
9var fly_speed: float = 4.0
10var wave_amplitude: float = 1.5
11var wave_frequency: float = 0.8
12var vertical_amplitude: float = 0.6
13var vertical_frequency: float = 0.5
14var preferred_altitude: float = 20.0
15var segment_radius: float = 0.15
16var segment_spacing: float = 0.27
17var head_color: Color = Color(0.3, 0.5, 0.2)
18
19# ---- Internal state ----
20
21var _mm: MultiMeshInstance3D = null ## Single draw call for all segments
22var _segment_tapers: Array[float] = []
23var _segment_colors: Array[Color] = []
24var _segment_positions: Array[Vector3] = []
25var _head_direction: Vector3 = Vector3.FORWARD
26var _time: float = 0.0
27var _wander_target: Vector3 = Vector3.ZERO
28var _wander_timer: float = 0.0
29var _rng: RandomNumberGenerator
30var _initialized: bool = false
31
32enum LODLevel { FULL, MEDIUM, LOW }
33var _lod: int = LODLevel.FULL
34
35func update_lod(player_pos: Vector3, camera: Camera3D = null) -> void:
36 var dist_sq: float = global_position.distance_squared_to(player_pos)
37 if dist_sq < 2500.0: # < 50m
38 _lod = LODLevel.FULL
39 elif dist_sq < 10000.0: # < 100m
40 _lod = LODLevel.MEDIUM
41 else:
42 _lod = LODLevel.LOW
43 # Off-screen: drop to MEDIUM (not LOW — frozen serpents are very visible in sky)
44 if camera and not camera.is_position_in_frustum(global_position):
45 _lod = LODLevel.MEDIUM
46
47
48func initialize(rng: RandomNumberGenerator) -> void:
49 _rng = RandomNumberGenerator.new()
50 _rng.seed = rng.randi()
51
52 # Length class: 0=short (common), 1=medium, 2=long (rare)
53 var length_roll: float = rng.randf()
54 var length_class: int
55 if length_roll < 0.5:
56 length_class = 0 # short — common
57 elif length_roll < 0.85:
58 length_class = 1 # medium
59 else:
60 length_class = 2 # long — rare
61
62 match length_class:
63 0:
64 segment_count = rng.randi_range(3, 5)
65 1:
66 segment_count = rng.randi_range(8, 12)
67 2:
68 segment_count = rng.randi_range(15, 25)
69
70 creature_scale = rng.randf_range(0.6, 2.0)
71 fly_speed = rng.randf_range(2.0, 6.0)
72 wave_amplitude = rng.randf_range(0.8, 2.5)
73 wave_frequency = rng.randf_range(0.4, 1.2)
74 vertical_amplitude = rng.randf_range(0.3, 1.2)
75 vertical_frequency = rng.randf_range(0.3, 0.8)
76 preferred_altitude = rng.randf_range(10.0, 40.0)
77
78 segment_radius = 0.3 * creature_scale
79 segment_spacing = segment_radius * 1.4 # Tight overlap for chunky body
80
81 # Random head color — varied hue range
82 var hue: float = rng.randf() # full hue range
83 var sat: float = rng.randf_range(0.4, 0.9)
84 var val: float = rng.randf_range(0.4, 0.8)
85 head_color = Color.from_hsv(hue, sat, val)
86
87 _create_segments()
88 _init_positions()
89 _pick_wander_target()
90 _initialized = true
91
92
93func _create_segments() -> void:
94 _segment_tapers.clear()
95 _segment_colors.clear()
96 for i in range(segment_count):
97 var t: float = float(i) / float(segment_count)
98 _segment_tapers.append(1.0 - t * 0.6)
99 _segment_colors.append(head_color.darkened(t * 0.5))
100
101 # Unit sphere — scaled per instance via Transform3D
102 var sphere := SphereMesh.new()
103 sphere.radius = 1.0
104 sphere.height = 2.0
105 sphere.radial_segments = 8
106 sphere.rings = 4
107
108 var mm := MultiMesh.new()
109 mm.transform_format = MultiMesh.TRANSFORM_3D
110 mm.use_colors = true
111 mm.mesh = sphere
112 mm.instance_count = segment_count
113
114 _mm = MultiMeshInstance3D.new()
115 _mm.multimesh = mm
116 _mm.name = "BodyMM"
117 _mm.top_level = true
118 add_child(_mm)
119
120
121func _init_positions() -> void:
122 _segment_positions.clear()
123 # Place segments in a line behind the starting position at preferred altitude
124 var start_pos: Vector3 = global_position
125 start_pos.y = preferred_altitude
126 for i in range(segment_count):
127 _segment_positions.append(start_pos - _head_direction * segment_spacing * float(i))
128 # Sync initial visual positions
129 _sync_visuals_sparse()
130
131
132func _process(delta: float) -> void:
133 if not _initialized:
134 return
135
136 _time += delta
137 _update_wander(delta)
138 _update_head(delta)
139
140 _update_body(delta)
141
142 if _lod == LODLevel.FULL:
143 _sync_visuals()
144 else:
145 # MEDIUM/LOW: update positions only (skip pulse animation)
146 _sync_visuals_sparse()
147
148
149func _update_wander(delta: float) -> void:
150 _wander_timer -= delta
151 var to_target: Vector3 = _wander_target - _segment_positions[0]
152 var dist: float = to_target.length()
153
154 if dist < 2.0 or _wander_timer <= 0.0:
155 _pick_wander_target()
156
157
158func _pick_wander_target() -> void:
159 var angle: float = _rng.randf() * TAU
160 var radius: float = _rng.randf_range(10.0, 30.0)
161 var alt_offset: float = _rng.randf_range(-5.0, 5.0)
162 var head_pos: Vector3 = _segment_positions[0] if _segment_positions.size() > 0 else global_position
163 _wander_target = head_pos + Vector3(
164 cos(angle) * radius,
165 alt_offset,
166 sin(angle) * radius
167 )
168 # Clamp altitude to reasonable range
169 _wander_target.y = clampf(_wander_target.y, preferred_altitude - 10.0, preferred_altitude + 10.0)
170 _wander_timer = _rng.randf_range(3.0, 8.0)
171
172
173func _update_head(delta: float) -> void:
174 var head_pos: Vector3 = _segment_positions[0]
175
176 # Steer toward wander target
177 var to_target: Vector3 = (_wander_target - head_pos).normalized()
178 _head_direction = _head_direction.lerp(to_target, 2.0 * delta).normalized()
179
180 # Compute perpendicular vectors for wave offset
181 var right: Vector3 = _head_direction.cross(Vector3.UP)
182 if right.length_squared() < 0.001:
183 right = _head_direction.cross(Vector3.RIGHT)
184 right = right.normalized()
185 var up: Vector3 = right.cross(_head_direction).normalized()
186
187 # Sinusoidal wave offsets
188 var h_wave: float = sin(_time * wave_frequency * TAU) * wave_amplitude
189 var v_wave: float = sin(_time * vertical_frequency * TAU) * vertical_amplitude
190
191 # Apply as velocity offset (multiply by delta * speed), not absolute position
192 var base_velocity: Vector3 = _head_direction * fly_speed
193 var wave_velocity: Vector3 = right * h_wave + up * v_wave
194 head_pos += (base_velocity + wave_velocity) * delta
195
196 _segment_positions[0] = head_pos
197
198
199func _update_body(delta: float) -> void:
200 for i in range(1, segment_count):
201 var target: Vector3 = _segment_positions[i - 1]
202 var current: Vector3 = _segment_positions[i]
203
204 # Direction from current segment toward the one ahead
205 var dir: Vector3 = (target - current).normalized()
206 # Ideal position: segment_spacing behind the one ahead
207 var ideal: Vector3 = target - dir * segment_spacing
208
209 # Smooth follow — not instant snapping
210 _segment_positions[i] = current.lerp(ideal, 10.0 * delta)
211
212
213func _sync_visuals() -> void:
214 if _mm == null or _mm.multimesh == null:
215 return
216 var bmm: MultiMesh = _mm.multimesh
217 for i in range(segment_count):
218 var r: float = segment_radius * _segment_tapers[i]
219 var phase: float = _time * wave_frequency * TAU - float(i) * 0.8
220 var pulse: float = 1.0 + sin(phase) * 0.2
221 r *= pulse
222 bmm.set_instance_transform(i, Transform3D(Basis.IDENTITY.scaled(Vector3(r, r, r)), _segment_positions[i]))
223 bmm.set_instance_color(i, _segment_colors[i])
224
225
226## MEDIUM/LOW: positions only, no pulse animation
227func _sync_visuals_sparse() -> void:
228 if _mm == null or _mm.multimesh == null:
229 return
230 var bmm: MultiMesh = _mm.multimesh
231 for i in range(segment_count):
232 var r: float = segment_radius * _segment_tapers[i]
233 bmm.set_instance_transform(i, Transform3D(Basis.IDENTITY.scaled(Vector3(r, r, r)), _segment_positions[i]))
234 bmm.set_instance_color(i, _segment_colors[i])
scenes/creatures/ground_crawler.gd +579 -0
……@@ -0,0 +1,579 @@
1class_name GroundCrawler extends Node3D
2## Lizard creature: grounded serpent body chain + IK legs with trajectory-
3## predicted foot placement. Body undulates via head sine wave, legs plant
4## by predicting future body position along the sine+trajectory path.
5## Call initialize(rng) after adding to scene tree.
6
7# ---- Species parameters ----
8
9var segment_count: int = 5
10var creature_scale: float = 1.0
11var move_speed: float = 2.5
12var wave_amplitude: float = 1.0
13var wave_frequency: float = 0.8
14var body_color: Color = Color(0.45, 0.35, 0.2)
15var size_skew: float = 0.0 ## -1 = largest at head, +1 = largest at tail
16
17# ---- Derived dimensions ----
18
19var body_height: float = 0.4
20var segment_radius: float = 0.2
21var segment_spacing: float = 0.3
22var connector_radius: float = 0.1 ## Small spheres between main segments
23var leg_spread: float = 0.5 ## How far out legs reach laterally
24var leg_length: float = 0.5 ## Total leg length (upper + lower)
25var upper_leg_length: float = 0.25
26var lower_leg_length: float = 0.25
27var step_height: float = 0.15 ## Arc lift during step
28
29# ---- Body chain ----
30
31var _body_mm: MultiMeshInstance3D = null ## Body spheres + connectors (1 draw call)
32var _body_instance_count: int = 0
33var _segment_radii: Array[float] = [] ## Per-segment radius (taper)
34var _segment_colors: Array[Color] = []
35var _segment_positions: Array[Vector3] = []
36var _head_direction: Vector3 = Vector3.FORWARD
37var _time: float = 0.0
38
39# ---- Legs (2 per segment: left at even index, right at odd) ----
40# Each leg is pure data — no CrawlerLeg dependency for clarity.
41
42var _leg_count: int = 0
43var _foot_planted: Array[Vector3] = [] ## Where foot is currently planted
44var _foot_target: Array[Vector3] = [] ## Where foot is stepping toward
45var _foot_current: Array[Vector3] = [] ## Current visual foot position (during arc)
46var _foot_stepping: Array[bool] = [] ## Mid-step?
47var _foot_arc_t: Array[float] = [] ## 0-1 step arc progress
48var _leg_mm: MultiMeshInstance3D = null ## All leg capsules (1 draw call)
49var _leg_instance_count: int = 0 ## upper + lower per leg
50
51const STEP_THRESHOLD: float = 0.35 ## Drift before step triggers (scaled)
52const STEP_SPEED: float = 10.0 ## Arc travel speed
53const PREDICT_TIME: float = 0.3 ## Seconds ahead to predict foot target
54
55# ---- Wandering ----
56
57var _wander_target: Vector3 = Vector3.ZERO
58var _wander_timer: float = 0.0
59var _rng: RandomNumberGenerator
60var _initialized: bool = false
61var _exclude_rids: Array[RID] = []
62
63enum LODLevel { FULL, MEDIUM, LOW }
64var _lod: int = LODLevel.FULL
65var _frame_id: int = 0
66static var _instance_counter: int = 0
67var _stagger_offset: int = 0
68const PHYSICS_STAGGER: int = 3 ## Full physics every Nth frame
69
70
71func update_lod(player_pos: Vector3, camera: Camera3D = null) -> void:
72 var dist_sq: float = global_position.distance_squared_to(player_pos)
73 if dist_sq < 400.0:
74 _lod = LODLevel.FULL
75 elif dist_sq < 2500.0:
76 _lod = LODLevel.MEDIUM
77 else:
78 _lod = LODLevel.LOW
79 if camera and not camera.is_position_in_frustum(global_position):
80 _lod = LODLevel.LOW
81
82
83func initialize(rng: RandomNumberGenerator) -> void:
84 _stagger_offset = _instance_counter % PHYSICS_STAGGER
85 _instance_counter += 1
86 _rng = RandomNumberGenerator.new()
87 _rng.seed = rng.randi()
88
89 segment_count = rng.randi_range(3, 6)
90 creature_scale = rng.randf_range(0.8, 2.5)
91 move_speed = rng.randf_range(1.5, 4.0)
92 wave_amplitude = rng.randf_range(0.5, 1.5)
93 wave_frequency = rng.randf_range(0.5, 1.2)
94
95 var hue: float = rng.randf_range(0.05, 0.25)
96 var sat: float = rng.randf_range(0.3, 0.7)
97 var val: float = rng.randf_range(0.25, 0.5)
98 body_color = Color.from_hsv(hue, sat, val)
99
100 body_height = 0.35 * creature_scale
101 segment_radius = 0.22 * creature_scale # Main spheres larger
102 connector_radius = segment_radius * 0.55 # Connectors about half size
103 segment_spacing = segment_radius * 1.6 # Spacing for main segments (connectors fill gaps)
104
105 # Random skew: biggest sphere at head or tail
106 size_skew = rng.randf_range(-1.0, 1.0)
107 leg_spread = 0.45 * creature_scale
108 upper_leg_length = 0.3 * creature_scale
109 lower_leg_length = 0.3 * creature_scale
110 leg_length = upper_leg_length + lower_leg_length
111 step_height = 0.15 * creature_scale
112
113 _head_direction = Vector3.FORWARD
114 _leg_count = segment_count * 2
115
116 _create_body()
117 _init_positions()
118 _create_legs()
119 _plant_all_feet()
120 _pick_wander_target()
121 _initialized = true
122
123
124# ---- Body spheres ----
125
126# Static material cache: key = snapped color hash, value = StandardMaterial3D
127static var _mat_cache: Dictionary = {}
128
129static func _get_shared_mat(color: Color) -> StandardMaterial3D:
130 # Snap color to reduce cache entries (256 shades per channel is plenty)
131 var key: int = (int(color.r * 63) << 12) | (int(color.g * 63) << 6) | int(color.b * 63)
132 if _mat_cache.has(key):
133 return _mat_cache[key]
134 var mat := StandardMaterial3D.new()
135 mat.albedo_color = color
136 mat.roughness = 0.8
137 _mat_cache[key] = mat
138 return mat
139
140
141func _create_body() -> void:
142 _segment_radii.clear()
143 _segment_colors.clear()
144 for i in range(segment_count):
145 var t: float = float(i) / float(segment_count - 1) if segment_count > 1 else 0.5
146 var peak: float = 0.3 - size_skew * 0.3
147 var dist: float = absf(t - peak)
148 var taper: float = clampf(1.0 - dist * 0.8, 0.5, 1.0)
149 _segment_radii.append(segment_radius * taper)
150 _segment_colors.append(body_color.darkened(t * 0.3))
151
152 # Body = segment_count spheres + (segment_count - 1) connectors
153 _body_instance_count = segment_count + maxi(segment_count - 1, 0)
154
155 var sphere := SphereMesh.new()
156 sphere.radius = 1.0
157 sphere.height = 2.0
158 sphere.radial_segments = 8
159 sphere.rings = 4
160
161 var mm := MultiMesh.new()
162 mm.transform_format = MultiMesh.TRANSFORM_3D
163 mm.use_colors = true
164 mm.mesh = sphere
165 mm.instance_count = _body_instance_count
166
167 _body_mm = MultiMeshInstance3D.new()
168 _body_mm.multimesh = mm
169 _body_mm.name = "BodyMM"
170 _body_mm.top_level = true # Render in world space (transforms are world-space)
171 add_child(_body_mm)
172
173
174func _init_positions() -> void:
175 _segment_positions.clear()
176 var start: Vector3 = global_position + Vector3.UP * body_height
177 for i in range(segment_count):
178 _segment_positions.append(start - _head_direction * segment_spacing * float(i))
179 # Initial visual sync handled by first _sync_visuals call
180
181
182# ---- Leg meshes ----
183
184func _create_legs() -> void:
185 for i in range(_leg_count):
186 _foot_planted.append(Vector3.ZERO)
187 _foot_target.append(Vector3.ZERO)
188 _foot_current.append(Vector3.ZERO)
189 _foot_stepping.append(false)
190 _foot_arc_t.append(1.0)
191
192 _leg_instance_count = _leg_count * 2 # upper + lower per leg
193
194 var capsule := CapsuleMesh.new()
195 capsule.radius = 1.0
196 capsule.height = 2.0
197
198 var leg_mat: StandardMaterial3D = _get_shared_mat(body_color.darkened(0.2))
199 capsule.material = leg_mat
200
201 var mm := MultiMesh.new()
202 mm.transform_format = MultiMesh.TRANSFORM_3D
203 mm.mesh = capsule
204 mm.instance_count = _leg_instance_count
205
206 _leg_mm = MultiMeshInstance3D.new()
207 _leg_mm.multimesh = mm
208 _leg_mm.name = "LegMM"
209 _leg_mm.top_level = true
210 add_child(_leg_mm)
211
212
213func _plant_all_feet() -> void:
214 var space: PhysicsDirectSpaceState3D = get_world_3d().direct_space_state
215 for i in range(_leg_count):
216 var ground_pos: Vector3 = _get_foot_ground_target(i, space)
217 _foot_planted[i] = ground_pos
218 _foot_target[i] = ground_pos
219 _foot_current[i] = ground_pos
220 _foot_stepping[i] = false
221 _foot_arc_t[i] = 1.0
222
223
224# ---- Main loop ----
225
226func _physics_process(delta: float) -> void:
227 if not _initialized:
228 return
229 _time += delta
230 _frame_id += 1
231
232 var do_physics: bool = (_lod == LODLevel.FULL) and (_frame_id % PHYSICS_STAGGER == _stagger_offset)
233
234 _update_wander(delta)
235
236 if do_physics:
237 # FULL + physics frame: ground snap all segments + feet
238 # FULL + physics frame: ground snap all segments + feet
239 var space: PhysicsDirectSpaceState3D = get_world_3d().direct_space_state
240 if space == null:
241 return
242 _update_head(delta, space)
243 _update_body(delta, space)
244 _update_feet(delta, space)
245 else:
246 # FULL + interpolation frame: advance positions without physics
247 _update_head_no_snap(delta)
248 _update_body_no_snap(delta)
249 _advance_foot_arcs(delta)
250 _sync_visuals()
251
252
253# ---- Wandering ----
254
255func _update_wander(delta: float) -> void:
256 _wander_timer -= delta
257 var to_target: Vector3 = _wander_target - _segment_positions[0]
258 to_target.y = 0.0
259 if to_target.length() < 1.0 or _wander_timer <= 0.0:
260 _pick_wander_target()
261
262
263func _pick_wander_target() -> void:
264 var angle: float = _rng.randf() * TAU
265 var radius: float = _rng.randf_range(5.0, 15.0)
266 var head: Vector3 = _segment_positions[0] if _segment_positions.size() > 0 else global_position
267 _wander_target = head + Vector3(cos(angle) * radius, 0.0, sin(angle) * radius)
268 _wander_timer = _rng.randf_range(3.0, 8.0)
269
270
271# ---- Head: steer toward target + lateral sine wave (creates the S-curve) ----
272
273## Interpolation frame: move head without ground snap
274func _update_head_no_snap(delta: float) -> void:
275 var head_pos: Vector3 = _segment_positions[0]
276 var to_target: Vector3 = (_wander_target - head_pos)
277 to_target.y = 0.0
278 if to_target.length() > 0.1:
279 _head_direction = _head_direction.lerp(to_target.normalized(), 2.0 * delta).normalized()
280 var right: Vector3 = _head_direction.cross(Vector3.UP)
281 if right.length_squared() < 0.001:
282 right = _head_direction.cross(Vector3.RIGHT)
283 right = right.normalized()
284 var h_wave: float = sin(_time * wave_frequency * TAU) * wave_amplitude
285 var velocity: Vector3 = _head_direction * move_speed + right * h_wave
286 head_pos += velocity * delta
287 _segment_positions[0] = head_pos
288 global_position = head_pos
289 rotation.y = atan2(_head_direction.x, _head_direction.z)
290
291
292func _update_head(delta: float, space: PhysicsDirectSpaceState3D) -> void:
293 var head_pos: Vector3 = _segment_positions[0]
294 var to_target: Vector3 = (_wander_target - head_pos)
295 to_target.y = 0.0
296 if to_target.length() > 0.1:
297 _head_direction = _head_direction.lerp(to_target.normalized(), 2.0 * delta).normalized()
298
299 # Lateral sine on head velocity — the body S-curve forms from the trail
300 var right: Vector3 = _head_direction.cross(Vector3.UP)
301 if right.length_squared() < 0.001:
302 right = _head_direction.cross(Vector3.RIGHT)
303 right = right.normalized()
304
305 var h_wave: float = sin(_time * wave_frequency * TAU) * wave_amplitude
306 var velocity: Vector3 = _head_direction * move_speed + right * h_wave
307 head_pos += velocity * delta
308
309 # Ground snap
310 head_pos.y = _ground_height(space, head_pos) + body_height
311 _segment_positions[0] = head_pos
312
313 global_position = head_pos
314 rotation.y = atan2(_head_direction.x, _head_direction.z)
315
316
317# ---- Body: chain follow + ground snap ----
318
319func _update_body(delta: float, space: PhysicsDirectSpaceState3D) -> void:
320 for i in range(1, segment_count):
321 var ahead: Vector3 = _segment_positions[i - 1]
322 var current: Vector3 = _segment_positions[i]
323 var dir: Vector3 = (ahead - current)
324 if dir.length() > 0.001:
325 dir = dir.normalized()
326 else:
327 dir = _head_direction
328 var ideal: Vector3 = ahead - dir * segment_spacing
329 current = current.lerp(ideal, 10.0 * delta)
330 current.y = _ground_height(space, current) + body_height
331 _segment_positions[i] = current
332
333
334## MEDIUM LOD: body chain follow without ground-snap physics queries
335func _update_body_no_snap(delta: float) -> void:
336 for i in range(1, segment_count):
337 var ahead: Vector3 = _segment_positions[i - 1]
338 var current: Vector3 = _segment_positions[i]
339 var dir: Vector3 = (ahead - current)
340 if dir.length() > 0.001:
341 dir = dir.normalized()
342 else:
343 dir = _head_direction
344 var ideal: Vector3 = ahead - dir * segment_spacing
345 current = current.lerp(ideal, 10.0 * delta)
346 # No ground snap — keep last known Y
347 _segment_positions[i] = current
348
349
350## MEDIUM LOD: advance stepping foot arcs without ground queries.
351## Clamp feet to max leg reach so legs don't stretch to infinity.
352func _advance_foot_arcs(delta: float) -> void:
353 for i in range(_leg_count):
354 if _foot_stepping[i]:
355 _foot_arc_t[i] = minf(_foot_arc_t[i] + delta * STEP_SPEED, 1.0)
356 var t: float = _foot_arc_t[i]
357 var linear: Vector3 = _foot_planted[i].lerp(_foot_target[i], t)
358 var arc_lift: float = 4.0 * t * (1.0 - t) * step_height
359 _foot_current[i] = linear + Vector3.UP * arc_lift
360 if t >= 1.0:
361 _foot_stepping[i] = false
362 _foot_planted[i] = _foot_target[i]
363 _foot_current[i] = _foot_target[i]
364 # Clamp planted feet: if body moved too far, snap foot to body
365 var seg_idx: int = i / 2
366 var body_pos: Vector3 = _segment_positions[seg_idx] if seg_idx < _segment_positions.size() else global_position
367 var drift: float = _foot_current[i].distance_to(body_pos)
368 if drift > leg_length * 2.0:
369 var is_left: bool = (i % 2 == 0)
370 var fwd: Vector3 = _get_segment_forward(seg_idx)
371 var right: Vector3 = fwd.cross(Vector3.UP).normalized()
372 var lateral: Vector3 = -right if is_left else right
373 _foot_planted[i] = body_pos + lateral * leg_spread
374 _foot_planted[i].y = body_pos.y - body_height
375 _foot_current[i] = _foot_planted[i]
376 _foot_target[i] = _foot_planted[i]
377 _foot_stepping[i] = false
378
379
380func _ground_height(space: PhysicsDirectSpaceState3D, pos: Vector3) -> float:
381 var from: Vector3 = Vector3(pos.x, pos.y + body_height * 4.0, pos.z)
382 var to: Vector3 = Vector3(pos.x, pos.y - body_height * 4.0, pos.z)
383 var result: Dictionary = SphereSweep.sweep(space, from, to, segment_radius * 0.2, _exclude_rids)
384 if not result.is_empty():
385 return result["point"].y
386 return 0.0
387
388
389# ---- Feet: predict target from trajectory, arc step, IK ----
390
391func _get_gait_group(leg_idx: int) -> int:
392 # Alternating diagonal gait: left-front(0) + right-rear(3) = group 0
393 # right-front(1) + left-rear(2) = group 1, etc.
394 var seg_idx: int = leg_idx / 2
395 var is_left: bool = (leg_idx % 2 == 0)
396 return (seg_idx + (0 if is_left else 1)) % 2
397
398
399func _update_feet(delta: float, space: PhysicsDirectSpaceState3D) -> void:
400 # Check which gait groups currently have a leg mid-step
401 var group_stepping: Array[bool] = [false, false]
402 for i in range(_leg_count):
403 if _foot_stepping[i]:
404 group_stepping[_get_gait_group(i)] = true
405
406 for i in range(_leg_count):
407 var gait_group: int = _get_gait_group(i)
408
409 if _foot_stepping[i]:
410 # Advance arc
411 _foot_arc_t[i] = minf(_foot_arc_t[i] + delta * STEP_SPEED, 1.0)
412 var t: float = _foot_arc_t[i]
413 var linear: Vector3 = _foot_planted[i].lerp(_foot_target[i], t)
414 var arc_lift: float = 4.0 * t * (1.0 - t) * step_height
415 _foot_current[i] = linear + Vector3.UP * arc_lift
416
417 if t >= 1.0:
418 _foot_stepping[i] = false
419 _foot_planted[i] = _foot_target[i]
420 _foot_current[i] = _foot_target[i]
421 group_stepping[gait_group] = false
422 else:
423 _foot_current[i] = _foot_planted[i]
424 var ideal: Vector3 = _get_foot_ground_target(i, space)
425 var drift: float = _foot_planted[i].distance_to(ideal)
426 var threshold: float = STEP_THRESHOLD * creature_scale
427
428 if drift > threshold and not group_stepping[gait_group]:
429 _foot_target[i] = _predict_foot_target(i, space)
430 _foot_arc_t[i] = 0.0
431 _foot_stepping[i] = true
432 group_stepping[gait_group] = true
433
434
435## Compute where the foot SHOULD be right now (ideal resting position on ground).
436## Uses parent segment's ground height instead of a separate SphereSweep per leg.
437func _get_foot_ground_target(leg_idx: int, _space: PhysicsDirectSpaceState3D) -> Vector3:
438 var seg_idx: int = leg_idx / 2
439 var is_left: bool = (leg_idx % 2 == 0)
440 var body_pos: Vector3 = _segment_positions[seg_idx] if seg_idx < _segment_positions.size() else global_position
441
442 var fwd: Vector3 = _get_segment_forward(seg_idx)
443 var right: Vector3 = fwd.cross(Vector3.UP).normalized()
444
445 var lateral_dir: Vector3 = -right if is_left else right
446 var target_xz: Vector3 = body_pos + lateral_dir * leg_spread
447 # Use segment's ground-snapped Y (already computed in _update_body)
448 # instead of a separate SphereSweep per foot
449 target_xz.y = body_pos.y - body_height
450 return target_xz
451
452
453## Predict where the foot should LAND by looking ahead along the trajectory.
454## Uses segment Y instead of per-foot SphereSweep.
455func _predict_foot_target(leg_idx: int, _space: PhysicsDirectSpaceState3D) -> Vector3:
456 var seg_idx: int = leg_idx / 2
457 var is_left: bool = (leg_idx % 2 == 0)
458 var body_pos: Vector3 = _segment_positions[seg_idx] if seg_idx < _segment_positions.size() else global_position
459
460 var fwd: Vector3 = _get_segment_forward(seg_idx)
461 var right: Vector3 = fwd.cross(Vector3.UP)
462 if right.length_squared() < 0.001:
463 right = Vector3.RIGHT
464 right = right.normalized()
465
466 var future_time: float = _time + PREDICT_TIME
467 var h_wave: float = sin(future_time * wave_frequency * TAU) * wave_amplitude
468 var future_offset: Vector3 = _head_direction * move_speed * PREDICT_TIME + right * h_wave * PREDICT_TIME
469 var future_body: Vector3 = body_pos + future_offset
470
471 var lateral_dir: Vector3 = -right if is_left else right
472 var target_xz: Vector3 = future_body + lateral_dir * leg_spread
473 # Foot lands at segment ground height
474 target_xz.y = body_pos.y - body_height
475 return target_xz
476
477
478func _get_segment_forward(seg_idx: int) -> Vector3:
479 var fwd: Vector3
480 if seg_idx > 0 and seg_idx < _segment_positions.size():
481 fwd = (_segment_positions[seg_idx - 1] - _segment_positions[seg_idx])
482 elif seg_idx < _segment_positions.size() - 1:
483 fwd = (_segment_positions[seg_idx] - _segment_positions[seg_idx + 1])
484 else:
485 fwd = _head_direction
486 fwd.y = 0.0
487 if fwd.length_squared() < 0.001:
488 fwd = _head_direction
489 return fwd.normalized()
490
491
492# ---- Sync visuals: body spheres + leg IK ----
493
494func _sync_visuals() -> void:
495 # Body spheres + connectors via MultiMesh
496 if _body_mm and _body_mm.multimesh:
497 var bmm: MultiMesh = _body_mm.multimesh
498 var idx: int = 0
499 # Main body spheres with subtle pulsation
500 for i in range(segment_count):
501 var phase: float = _time * wave_frequency * TAU - float(i) * 0.6
502 var pulse: float = 1.0 + sin(phase) * 0.08
503 var r: float = _segment_radii[i] * pulse
504 bmm.set_instance_transform(idx, Transform3D(Basis.IDENTITY.scaled(Vector3(r, r, r)), _segment_positions[i]))
505 bmm.set_instance_color(idx, _segment_colors[i])
506 idx += 1
507 # Connector spheres: midpoint between adjacent segments
508 for i in range(segment_count - 1):
509 if i + 1 < _segment_positions.size():
510 var mid: Vector3 = (_segment_positions[i] + _segment_positions[i + 1]) * 0.5
511 var cr: float = connector_radius
512 bmm.set_instance_transform(idx, Transform3D(Basis.IDENTITY.scaled(Vector3(cr, cr, cr)), mid))
513 bmm.set_instance_color(idx, body_color.darkened(0.15))
514 idx += 1
515
516 # Leg IK: two-bone solve from body shoulder to foot → MultiMesh
517 if _leg_mm == null or _leg_mm.multimesh == null:
518 return
519 var lmm: MultiMesh = _leg_mm.multimesh
520 var leg_radius: float = 0.02 * creature_scale
521 var lidx: int = 0
522
523 for i in range(_leg_count):
524 var seg_idx: int = i / 2
525 var is_left: bool = (i % 2 == 0)
526 var body_pos: Vector3 = _segment_positions[seg_idx] if seg_idx < _segment_positions.size() else global_position
527
528 var fwd: Vector3 = _get_segment_forward(seg_idx)
529 var right: Vector3 = fwd.cross(Vector3.UP)
530 if right.length_squared() < 0.001:
531 right = Vector3.RIGHT
532 right = right.normalized()
533 var lateral_dir: Vector3 = -right if is_left else right
534 var shoulder: Vector3 = body_pos + lateral_dir * segment_radius * 0.8
535
536 var foot: Vector3 = _foot_current[i]
537
538 var to_foot: Vector3 = foot - shoulder
539 var reach: float = to_foot.length()
540 var max_reach: float = upper_leg_length + lower_leg_length
541
542 var pole: Vector3 = (lateral_dir * 0.5 + Vector3.UP * 0.5).normalized()
543
544 var knee: Vector3
545 if reach >= max_reach * 0.99 or reach < 0.001:
546 knee = (shoulder + foot) * 0.5 + pole * upper_leg_length * 0.3
547 else:
548 var a: float = upper_leg_length
549 var b: float = lower_leg_length
550 var d: float = reach
551 var cos_a: float = clampf((a * a + d * d - b * b) / (2.0 * a * d), -1.0, 1.0)
552 var angle: float = acos(cos_a)
553 var fwd_dir: Vector3 = to_foot.normalized()
554 var axis: Vector3 = fwd_dir.cross(pole)
555 if axis.length_squared() > 0.001:
556 axis = axis.normalized()
557 knee = shoulder + fwd_dir.rotated(axis, angle) * a
558 else:
559 knee = shoulder + fwd_dir * a
560
561 lmm.set_instance_transform(lidx, _capsule_transform(shoulder, knee, leg_radius))
562 lidx += 1
563 lmm.set_instance_transform(lidx, _capsule_transform(knee, foot, leg_radius))
564 lidx += 1
565
566
567func _capsule_transform(from_pos: Vector3, to_pos: Vector3, radius: float) -> Transform3D:
568 var dir: Vector3 = to_pos - from_pos
569 var length: float = dir.length()
570 if length < 0.001:
571 return Transform3D(Basis.IDENTITY.scaled(Vector3(radius, radius, radius)), from_pos)
572 var up: Vector3 = dir.normalized()
573 var right: Vector3 = up.cross(Vector3.FORWARD)
574 if right.length_squared() < 0.001:
575 right = up.cross(Vector3.RIGHT)
576 right = right.normalized()
577 var fwd: Vector3 = right.cross(up).normalized()
578 var basis: Basis = Basis(right * radius, up * (length * 0.5), fwd * radius)
579 return Transform3D(basis, (from_pos + to_pos) * 0.5)
scenes/creatures/hopper.gd +220 -0
……@@ -0,0 +1,220 @@
1class_name Hopper extends Node3D
2## Legless vertical creature that hops around randomly.
3## Body is a vertical stack of spheres that compresses/stretches with
4## jump force — undulations travel up the body as vertical wave.
5## Call initialize(rng) after adding to scene tree.
6
7# ---- Species parameters (set by initialize) ----
8
9var segment_count: int = 6
10var creature_scale: float = 1.0
11var hop_force: float = 8.0 ## Upward impulse per hop
12var hop_interval: float = 1.5 ## Seconds between hops
13var lateral_speed: float = 3.0 ## Horizontal drift per hop
14var wave_speed: float = 3.0 ## Vertical undulation wave speed
15var wave_strength: float = 0.3 ## Vertical compression/stretch amount
16var segment_radius: float = 0.2
17var segment_spacing: float = 0.25
18var body_color: Color = Color(0.5, 0.3, 0.6)
19var size_skew: float = 0.0 ## -1 = largest at bottom, +1 = largest at top
20
21# ---- Physics state ----
22
23var _velocity: Vector3 = Vector3.ZERO
24var _on_ground: bool = false
25var _bounce_time: float = 0.0 ## Time since last bounce (drives undulation)
26var _hop_dir: Vector3 = Vector3.ZERO
27
28# ---- Impact squash ----
29var _impact_strength: float = 0.0 ## 0-1, how hard we landed (from velocity)
30var _impact_time: float = 99.0 ## Time since impact (drives decay)
31const IMPACT_STIFFNESS: float = 12.0 ## How fast squash recovers (higher = snappier)
32const IMPACT_SCALE: float = 0.02 ## velocity → squash factor
33
34# ---- Internal ----
35
36var _mm: MultiMeshInstance3D = null
37var _segment_tapers: Array[float] = []
38var _segment_colors: Array[Color] = []
39var _base_position: Vector3 = Vector3.ZERO
40var _rng: RandomNumberGenerator
41var _initialized: bool = false
42var _time: float = 0.0
43
44const GRAVITY: float = 20.0
45const GROUND_SNAP: float = 0.05
46
47enum LODLevel { FULL, MEDIUM, LOW }
48var _lod: int = LODLevel.FULL
49
50func update_lod(player_pos: Vector3, camera: Camera3D = null) -> void:
51 var dist_sq: float = global_position.distance_squared_to(player_pos)
52 if dist_sq < 400.0:
53 _lod = LODLevel.FULL
54 elif dist_sq < 2500.0:
55 _lod = LODLevel.MEDIUM
56 else:
57 _lod = LODLevel.LOW
58 if camera and not camera.is_position_in_frustum(global_position):
59 _lod = LODLevel.LOW
60
61
62func initialize(rng: RandomNumberGenerator) -> void:
63 _rng = RandomNumberGenerator.new()
64 _rng.seed = rng.randi()
65
66 # Random species
67 var size_roll: float = rng.randf()
68 if size_roll < 0.6:
69 segment_count = rng.randi_range(3, 5) # small
70 elif size_roll < 0.9:
71 segment_count = rng.randi_range(6, 10) # medium
72 else:
73 segment_count = rng.randi_range(12, 18) # tall
74
75 creature_scale = rng.randf_range(0.5, 1.8)
76 hop_force = rng.randf_range(5.0, 14.0) * creature_scale
77 hop_interval = rng.randf_range(0.8, 2.5)
78 lateral_speed = rng.randf_range(1.5, 5.0)
79 wave_speed = rng.randf_range(2.0, 5.0)
80 wave_strength = rng.randf_range(0.15, 0.5)
81
82 segment_radius = 0.3 * creature_scale
83 segment_spacing = segment_radius * 1.3
84
85 # Random skew: biggest sphere at top or bottom
86 size_skew = rng.randf_range(-1.0, 1.0)
87
88 var hue: float = rng.randf()
89 var sat: float = rng.randf_range(0.5, 0.9)
90 var val: float = rng.randf_range(0.4, 0.8)
91 body_color = Color.from_hsv(hue, sat, val)
92
93 _create_segments()
94 _base_position = global_position
95 _bounce_time = 0.0
96 _pick_hop_dir()
97 _initialized = true
98
99
100func _create_segments() -> void:
101 _segment_tapers.clear()
102 _segment_colors.clear()
103 for i in range(segment_count):
104 var t: float = float(i) / float(segment_count) if segment_count > 1 else 0.5
105 var peak: float = 0.5 + size_skew * 0.4
106 var dist_from_peak: float = absf(t - peak)
107 var taper: float = clampf(1.0 - dist_from_peak * 1.2, 0.3, 1.0)
108 _segment_tapers.append(taper)
109
110 var seg_color: Color = body_color
111 if t < 0.3:
112 seg_color = body_color.darkened(0.2 * (1.0 - t / 0.3))
113 elif t > 0.7:
114 seg_color = body_color.lightened(0.3 * (t - 0.7) / 0.3)
115 _segment_colors.append(seg_color)
116
117 var sphere := SphereMesh.new()
118 sphere.radius = 1.0
119 sphere.height = 2.0
120 sphere.radial_segments = 8
121 sphere.rings = 4
122
123 var mm := MultiMesh.new()
124 mm.transform_format = MultiMesh.TRANSFORM_3D
125 mm.use_colors = true
126 mm.mesh = sphere
127 mm.instance_count = segment_count
128
129 _mm = MultiMeshInstance3D.new()
130 _mm.multimesh = mm
131 _mm.name = "BodyMM"
132 _mm.top_level = true
133 add_child(_mm)
134
135
136func _physics_process(delta: float) -> void:
137 if not _initialized:
138 return
139 _time += delta
140 _bounce_time += delta
141 _impact_time += delta
142
143 # Gravity + velocity
144 _velocity.y -= GRAVITY * delta
145 _base_position += _velocity * delta
146
147 # Ground collision — skip physics query at LOW/MEDIUM, use last known ground
148 var ground_y: float = _base_position.y # fallback: current position
149 if _lod == LODLevel.FULL:
150 var space: PhysicsDirectSpaceState3D = get_world_3d().direct_space_state
151 if space:
152 var sweep_from: Vector3 = _base_position + Vector3.UP * 5.0
153 var sweep_to: Vector3 = _base_position - Vector3.UP * 5.0
154 var result: Dictionary = SphereSweep.sweep(space, sweep_from, sweep_to, segment_radius * 0.3, [])
155 if not result.is_empty():
156 ground_y = result["point"].y
157 else:
158 ground_y = 0.0
159 else:
160 ground_y = 0.0 # Assume flat ground at distance
161
162 if _base_position.y <= ground_y + GROUND_SNAP:
163 _base_position.y = ground_y
164 # Record impact from landing velocity
165 if not _on_ground and _velocity.y < -1.0:
166 _impact_strength = clampf(absf(_velocity.y) * IMPACT_SCALE, 0.0, 0.6)
167 _impact_time = 0.0
168 # Bounce immediately on ground contact
169 if _velocity.y < -1.0:
170 _hop()
171 elif not _on_ground:
172 _hop()
173 _on_ground = true
174 else:
175 _on_ground = false
176
177 global_position = _base_position
178 _sync_visuals()
179
180
181func _hop() -> void:
182 _velocity.y = hop_force
183 _pick_hop_dir()
184 _velocity.x = _hop_dir.x * lateral_speed
185 _velocity.z = _hop_dir.z * lateral_speed
186 _bounce_time = 0.0 # Reset undulation phase on each bounce
187 _on_ground = false
188
189
190func _pick_hop_dir() -> void:
191 var angle: float = _rng.randf() * TAU
192 _hop_dir = Vector3(cos(angle), 0, sin(angle))
193
194
195func _sync_visuals() -> void:
196 if _mm == null or _mm.multimesh == null:
197 return
198 var bmm: MultiMesh = _mm.multimesh
199
200 var squash_amount: float = _impact_strength * exp(-_impact_time * IMPACT_STIFFNESS)
201 var spring: float = squash_amount * cos(_impact_time * IMPACT_STIFFNESS * 0.8)
202 var squash_y: float = 1.0 - spring
203 var wave_decay: float = exp(-_bounce_time * 3.0)
204
205 for i in range(segment_count):
206 var t: float = float(i) / float(segment_count)
207 var r: float = segment_radius * _segment_tapers[i]
208
209 var base_y: float = _base_position.y + segment_spacing * float(i) * squash_y
210 var phase: float = _bounce_time * wave_speed * TAU - float(i) * 0.6
211 var wave_offset: float = sin(phase) * wave_strength * segment_radius * wave_decay
212 var pos := Vector3(_base_position.x, base_y + wave_offset, _base_position.z)
213
214 var scale_pulse: float = 1.0 + sin(phase + 0.5) * 0.2 * wave_decay
215 var local_squash: float = 1.0 - t
216 var sy: float = r * scale_pulse * (1.0 - spring * local_squash * 0.5)
217 var sxz: float = r * scale_pulse * (1.0 + spring * local_squash * 0.3)
218
219 bmm.set_instance_transform(i, Transform3D(Basis.IDENTITY.scaled(Vector3(sxz, sy, sxz)), pos))
220 bmm.set_instance_color(i, _segment_colors[i])
scenes/creatures/two_bone_ik.gd +78 -0
……@@ -0,0 +1,78 @@
1class_name TwoBoneIK extends RefCounted
2## Shared two-bone IK solver for procedural animation.
3## Extracted from ProceduralAnimator for reuse by creature systems.
4
5
6## Two-bone IK: rotate upper and lower bones so the tip reaches target.
7## target_pos is in skeleton-local space (NOT world space).
8static func solve(skeleton: Skeleton3D, upper_idx: int, lower_idx: int,
9 tip_idx: int, target_pos: Vector3) -> void:
10 var upper_global: Transform3D = skeleton.get_bone_global_pose(upper_idx)
11 var lower_global: Transform3D = skeleton.get_bone_global_pose(lower_idx)
12 var tip_global: Transform3D = skeleton.get_bone_global_pose(tip_idx)
13
14 var upper_pos: Vector3 = upper_global.origin
15 var lower_pos: Vector3 = lower_global.origin
16 var tip_pos: Vector3 = tip_global.origin
17
18 var upper_len: float = upper_pos.distance_to(lower_pos)
19 var lower_len: float = lower_pos.distance_to(tip_pos)
20 var target_dist: float = upper_pos.distance_to(target_pos)
21
22 # Clamp target to reachable range
23 var max_reach: float = upper_len + lower_len - 0.01
24 if target_dist > max_reach:
25 target_pos = upper_pos + (target_pos - upper_pos).normalized() * max_reach
26 target_dist = max_reach
27
28 # Rotate upper bone to aim at target
29 var current_aim: Vector3 = (tip_pos - upper_pos).normalized()
30 var aim_dir: Vector3 = (target_pos - upper_pos).normalized()
31 if current_aim.length_squared() > 0.001 and aim_dir.length_squared() > 0.001:
32 var dot: float = current_aim.dot(aim_dir)
33 if dot < 0.9999:
34 var rot: Quaternion
35 if dot < -0.9999:
36 var fallback_axis: Vector3 = Vector3.UP.cross(current_aim)
37 if fallback_axis.length_squared() < 0.001:
38 fallback_axis = Vector3.RIGHT.cross(current_aim)
39 rot = Quaternion(fallback_axis.normalized(), PI)
40 else:
41 rot = Quaternion(current_aim, aim_dir)
42 # Convert world rotation to bone-local
43 var parent_idx: int = skeleton.get_bone_parent(upper_idx)
44 var upper_pose_rot: Quaternion = skeleton.get_bone_pose_rotation(upper_idx)
45 if parent_idx >= 0:
46 var parent_basis: Basis = skeleton.get_bone_global_pose(parent_idx).basis
47 var local_rot: Quaternion = (parent_basis.inverse() * Basis(rot) * parent_basis).get_rotation_quaternion()
48 skeleton.set_bone_pose_rotation(upper_idx, local_rot * upper_pose_rot)
49 else:
50 skeleton.set_bone_pose_rotation(upper_idx, rot * upper_pose_rot)
51
52 # Recompute after upper rotation
53 lower_global = skeleton.get_bone_global_pose(lower_idx)
54 tip_global = skeleton.get_bone_global_pose(tip_idx)
55 tip_pos = tip_global.origin
56
57 # Rotate lower bone to close remaining gap
58 var lower_current: Vector3 = (tip_pos - lower_global.origin).normalized()
59 var lower_aim: Vector3 = (target_pos - lower_global.origin).normalized()
60 if lower_current.length_squared() > 0.001 and lower_aim.length_squared() > 0.001:
61 var dot2: float = lower_current.dot(lower_aim)
62 if dot2 < 0.9999:
63 var rot2: Quaternion
64 if dot2 < -0.9999:
65 var fallback2: Vector3 = Vector3.UP.cross(lower_current)
66 if fallback2.length_squared() < 0.001:
67 fallback2 = Vector3.RIGHT.cross(lower_current)
68 rot2 = Quaternion(fallback2.normalized(), PI)
69 else:
70 rot2 = Quaternion(lower_current, lower_aim)
71 var parent_idx2: int = skeleton.get_bone_parent(lower_idx)
72 var lower_pose_rot: Quaternion = skeleton.get_bone_pose_rotation(lower_idx)
73 if parent_idx2 >= 0:
74 var parent_basis2: Basis = skeleton.get_bone_global_pose(parent_idx2).basis
75 var local_rot2: Quaternion = (parent_basis2.inverse() * Basis(rot2) * parent_basis2).get_rotation_quaternion()
76 skeleton.set_bone_pose_rotation(lower_idx, local_rot2 * lower_pose_rot)
77 else:
78 skeleton.set_bone_pose_rotation(lower_idx, rot2 * lower_pose_rot)
scenes/test/creature_test.gd +165 -0
……@@ -0,0 +1,165 @@
1extends Node3D
2## Minimal creature test — flat plane, single creature, flymode camera.
3##
4## Controls:
5## [1] Spawn GroundCrawler [2] Spawn FlyingSerpent
6## [3] Kill all creatures [4] Toggle pause creature AI
7## [+/-] Scale creature [F12] Screenshot
8## WASD: fly camera Mouse: look Scroll: speed
9
10@onready var stats_label: Label = $CanvasLayer/StatsLabel
11@onready var camera: Camera3D = $Camera3D
12
13var _rng := RandomNumberGenerator.new()
14var _creature: Node3D = null
15var _creatures_paused: bool = false
16
17# Fly camera
18var _fly_speed: float = 10.0
19var _mouse_sens: float = 0.003
20var _cam_yaw: float = 0.0
21var _cam_pitch: float = -0.3
22
23
24func _ready() -> void:
25 _rng.randomize()
26 Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
27 camera.global_position = Vector3(0, 3, 8)
28
29
30func _process(delta: float) -> void:
31 _fly_camera(delta)
32 _update_hud()
33
34
35func _fly_camera(delta: float) -> void:
36 var input := Vector3.ZERO
37 if Input.is_key_pressed(KEY_W):
38 input.z -= 1.0
39 if Input.is_key_pressed(KEY_S):
40 input.z += 1.0
41 if Input.is_key_pressed(KEY_A):
42 input.x -= 1.0
43 if Input.is_key_pressed(KEY_D):
44 input.x += 1.0
45 if Input.is_key_pressed(KEY_E) or Input.is_key_pressed(KEY_SPACE):
46 input.y += 1.0
47 if Input.is_key_pressed(KEY_Q) or Input.is_key_pressed(KEY_CTRL):
48 input.y -= 1.0
49
50 var speed := _fly_speed
51 if Input.is_key_pressed(KEY_SHIFT):
52 speed *= 3.0
53
54 if input.length_squared() > 0.0:
55 input = input.normalized()
56 camera.global_position += camera.global_transform.basis * input * speed * delta
57
58
59func _update_hud() -> void:
60 if not stats_label:
61 return
62
63 var info: String = "No creature"
64 if _creature and is_instance_valid(_creature):
65 var pos: Vector3 = _creature.global_position
66 if _creature is GroundCrawler:
67 info = "GroundCrawler (segs=%d legs=%d scale=%.2f)" % [_creature.segment_count, _creature.segment_count * 2, _creature.creature_scale]
68 elif _creature is FlyingSerpent:
69 info = "FlyingSerpent (segs=%d scale=%.2f)" % [_creature.segment_count, _creature.creature_scale]
70 elif _creature is Hopper:
71 info = "Hopper (segs=%d scale=%.2f)" % [_creature.segment_count, _creature.creature_scale]
72 info += "\nPos: (%.1f, %.1f, %.1f)" % [pos.x, pos.y, pos.z]
73 else:
74 _creature = null
75
76 var pause_str: String = " [AI PAUSED]" if _creatures_paused else ""
77 stats_label.text = "FPS: %d%s\n%s\n\n[1] Crawler [2] Serpent [3] Hopper\n[4] Kill all [5] Pause AI [+/-] Scale\nWASD: fly EQ: up/down Scroll: speed" % [
78 Engine.get_frames_per_second(), pause_str, info]
79
80
81func _unhandled_input(event: InputEvent) -> void:
82 if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
83 _cam_yaw -= event.relative.x * _mouse_sens
84 _cam_pitch = clampf(_cam_pitch - event.relative.y * _mouse_sens, -PI * 0.49, PI * 0.49)
85 camera.rotation = Vector3(_cam_pitch, _cam_yaw, 0)
86
87 if event is InputEventMouseButton:
88 if event.button_index == MOUSE_BUTTON_WHEEL_UP and event.pressed:
89 _fly_speed = minf(100.0, _fly_speed * 1.2)
90 elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN and event.pressed:
91 _fly_speed = maxf(1.0, _fly_speed / 1.2)
92
93 if event.is_action_pressed("ui_cancel"):
94 if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
95 Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
96 else:
97 Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
98
99 if not event is InputEventKey or not event.pressed:
100 return
101
102 match event.keycode:
103 KEY_1:
104 _spawn_type("crawler")
105 KEY_2:
106 _spawn_type("serpent")
107 KEY_3:
108 _spawn_type("hopper")
109 KEY_4:
110 _kill_all()
111 KEY_5:
112 _creatures_paused = !_creatures_paused
113 for child in get_children():
114 if child is GroundCrawler or child is FlyingSerpent or child is Hopper:
115 child.set_process(!_creatures_paused)
116 child.set_physics_process(!_creatures_paused)
117 KEY_EQUAL:
118 _rescale(1.2)
119 KEY_MINUS:
120 _rescale(1.0 / 1.2)
121 KEY_F12:
122 var img: Image = get_viewport().get_texture().get_image()
123 var ts: String = Time.get_datetime_string_from_system().replace(":", "-")
124 var path: String = "/home/fleeky/Desktop/hyper-tps/creature_%s.png" % ts
125 img.save_png(path)
126 print("Screenshot saved: %s" % path)
127
128
129func _spawn_type(type: String) -> void:
130 _kill_all()
131 match type:
132 "crawler":
133 _creature = GroundCrawler.new()
134 _creature.name = "TestCrawler"
135 add_child(_creature)
136 _creature.global_position = Vector3(0, 0.5, 0)
137 _creature.initialize(_rng)
138 print("Spawned GroundCrawler (segs=%d)" % _creature.segment_count)
139 "serpent":
140 _creature = FlyingSerpent.new()
141 _creature.name = "TestSerpent"
142 add_child(_creature)
143 _creature.global_position = Vector3(0, 5.0, 0)
144 _creature.initialize(_rng)
145 print("Spawned FlyingSerpent (segments=%d)" % _creature.segment_count)
146 "hopper":
147 _creature = Hopper.new()
148 _creature.name = "TestHopper"
149 add_child(_creature)
150 _creature.global_position = Vector3(0, 0.1, 0)
151 _creature.initialize(_rng)
152 print("Spawned Hopper (segments=%d)" % _creature.segment_count)
153
154
155func _kill_all() -> void:
156 for child in get_children():
157 if child is GroundCrawler or child is FlyingSerpent or child is Hopper:
158 child.queue_free()
159 _creature = null
160
161
162func _rescale(factor: float) -> void:
163 if _creature and is_instance_valid(_creature):
164 _creature.scale *= factor
165 print("Scale: %.2f" % _creature.scale.x)
scenes/test/creature_test.tscn +56 -0
……@@ -0,0 +1,56 @@
1[gd_scene format=3]
2
3[ext_resource type="Script" path="res://scenes/test/creature_test.gd" id="1"]
4
5[sub_resource type="StandardMaterial3D" id="FloorMat"]
6albedo_color = Color(0.35, 0.4, 0.35, 1)
7
8[sub_resource type="PlaneMesh" id="PlaneMesh_1"]
9size = Vector2(100, 100)
10material = SubResource("FloorMat")
11
12[sub_resource type="BoxShape3D" id="FloorShape"]
13size = Vector3(100, 0.1, 100)
14
15[sub_resource type="PhysicalSkyMaterial" id="SkyMat"]
16
17[sub_resource type="Sky" id="Sky_1"]
18sky_material = SubResource("SkyMat")
19
20[sub_resource type="Environment" id="Env_1"]
21background_mode = 2
22sky = SubResource("Sky_1")
23ambient_light_source = 3
24ambient_light_color = Color(0.8, 0.85, 0.9, 1)
25
26[node name="CreatureTest" type="Node3D"]
27script = ExtResource("1")
28
29[node name="Floor" type="StaticBody3D" parent="."]
30
31[node name="MeshInstance3D" type="MeshInstance3D" parent="Floor"]
32mesh = SubResource("PlaneMesh_1")
33
34[node name="CollisionShape3D" type="CollisionShape3D" parent="Floor"]
35transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.05, 0)
36shape = SubResource("FloorShape")
37
38[node name="Camera3D" type="Camera3D" parent="."]
39transform = Transform3D(1, 0, 0, 0, 0.955, 0.296, 0, -0.296, 0.955, 0, 3, 8)
40fov = 70.0
41
42[node name="CanvasLayer" type="CanvasLayer" parent="."]
43
44[node name="StatsLabel" type="Label" parent="CanvasLayer"]
45offset_left = 10.0
46offset_top = 10.0
47offset_right = 450.0
48offset_bottom = 250.0
49text = "Press [1] for Crawler, [2] for Serpent"
50
51[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
52environment = SubResource("Env_1")
53
54[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
55transform = Transform3D(0.568, 0.495, 0.657, 0, -0.799, 0.602, 0.823, -0.342, -0.454, 0, 10, 0)
56shadow_enabled = true
scenes/utils/sphere_sweep.gd +66 -0
……@@ -0,0 +1,66 @@
1class_name SphereSweep extends RefCounted
2## Shared sphere sweep utility. Wraps cast_motion + get_rest_info with
3## Overgrowth-style sliding sphere refinement for accurate surface normals.
4## Caches SphereShape3D by radius to avoid per-call allocations.
5
6static var _shape_cache: Dictionary = {} ## radius → SphereShape3D
7static var _params: PhysicsShapeQueryParameters3D = null
8
9## Sweep a sphere from `from` to `to`. Returns { "point", "normal", "fraction" } or empty.
10static func sweep(space: PhysicsDirectSpaceState3D, from: Vector3, to: Vector3,
11 radius: float, exclude: Array[RID] = [], collision_mask: int = 0xFFFFFFFF) -> Dictionary:
12 var motion: Vector3 = to - from
13
14 # Cache shapes by radius (snapped to 3 decimals to avoid unbounded growth)
15 var key: float = snappedf(radius, 0.001)
16 var shape: SphereShape3D
17 if _shape_cache.has(key):
18 shape = _shape_cache[key]
19 else:
20 shape = SphereShape3D.new()
21 shape.radius = key
22 _shape_cache[key] = shape
23
24 # Reuse params object
25 if _params == null:
26 _params = PhysicsShapeQueryParameters3D.new()
27 _params.shape = shape
28 _params.transform = Transform3D(Basis.IDENTITY, from)
29 _params.motion = motion
30 _params.collision_mask = collision_mask
31 if not exclude.is_empty():
32 _params.exclude = exclude
33 else:
34 _params.exclude = []
35
36 var fractions: PackedFloat32Array = space.cast_motion(_params)
37 if fractions.is_empty() or fractions[1] >= 1.0:
38 return {}
39
40 var contact_t: float = fractions[1]
41 var contact_pos: Vector3 = from + motion * contact_t
42 _params.transform = Transform3D(Basis.IDENTITY, contact_pos)
43
44 var rest: Dictionary = space.get_rest_info(_params)
45 if rest.is_empty():
46 return {
47 "point": contact_pos - motion.normalized() * radius,
48 "normal": -motion.normalized(),
49 "fraction": contact_t
50 }
51
52 var hit_point: Vector3 = rest["point"]
53 var hit_normal: Vector3 = rest["normal"]
54
55 # Sliding sphere refinement for accurate surface normal
56 var slide_pos: Vector3 = hit_point + hit_normal * radius
57 _params.transform = Transform3D(Basis.IDENTITY, slide_pos)
58 var slide_rest: Dictionary = space.get_rest_info(_params)
59 if not slide_rest.is_empty():
60 hit_normal = slide_rest["normal"]
61
62 return {
63 "point": hit_point,
64 "normal": hit_normal,
65 "fraction": contact_t
66 }