| … | … | @@ -0,0 +1,7 @@ |
| 1 | # Godot | |
| 2 | .godot/ | |
| 3 | *.uid | |
| 4 | ||
| 5 | # OS | |
| 6 | .DS_Store | |
| 7 | Thumbs.db |
| … | … | @@ -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 | ||
| 5 | config_version=5 | |
| 6 | ||
| 7 | [application] | |
| 8 | ||
| 9 | config/name="Hyper Creatures" | |
| 10 | run/main_scene="res://scenes/test/creature_test.tscn" | |
| 11 | config/features=PackedStringArray("4.6") | |
| 12 | ||
| 13 | [display] | |
| 14 | ||
| 15 | window/size/viewport_width=1280 | |
| 16 | window/size/viewport_height=720 |
| … | … | @@ -0,0 +1,116 @@ |
| 1 | class_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 | ||
| 9 | var upper_length: float = 0.3 | |
| 10 | var lower_length: float = 0.3 | |
| 11 | var attach_offset: Vector3 = Vector3.ZERO ## Body-local offset from center | |
| 12 | var step_threshold: float = 0.3 ## Drift distance before step triggers | |
| 13 | var arc_height: float = 0.12 ## How far foot lifts during step | |
| 14 | var arc_speed: float = 8.0 ## Step arc travel speed | |
| 15 | var sweep_radius: float = 0.06 ## SphereSweep radius | |
| 16 | var sweep_depth: float = 0.8 ## How far to sweep downward | |
| 17 | var revalidate_interval: int = 4 ## Revalidate every Nth frame (staggered) | |
| 18 | ||
| 19 | # ---- State ---- | |
| 20 | ||
| 21 | var foot_position: Vector3 = Vector3.ZERO ## Current world position | |
| 22 | var foot_normal: Vector3 = Vector3.UP ## Ground normal at foot | |
| 23 | var foot_target: Vector3 = Vector3.ZERO ## Step destination | |
| 24 | var foot_arc_origin: Vector3 = Vector3.ZERO ## Step start point | |
| 25 | var foot_valid: bool = false ## Is foot on a valid surface? | |
| 26 | var is_stepping: bool = false ## Currently mid-step? | |
| 27 | var arc_t: float = 1.0 ## 0..1 progress through step arc | |
| 28 | var weight: float = 1.0 ## 0=moving, 1=planted (gait coordination) | |
| 29 | var group_id: int = 0 ## Alternating group assignment | |
| 30 | var leg_index: int = 0 ## Set by parent; staggers revalidation | |
| 31 | var _frame_counter: int = 0 ## Incremented each update call | |
| 32 | ||
| 33 | ||
| 34 | ## Where the foot wants to be in world space. | |
| 35 | func 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. | |
| 40 | func 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). | |
| 86 | func 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. | |
| 98 | func _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. | |
| 105 | func _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 |
| … | … | @@ -0,0 +1,234 @@ |
| 1 | class_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 | ||
| 7 | var segment_count: int = 8 | |
| 8 | var creature_scale: float = 1.0 | |
| 9 | var fly_speed: float = 4.0 | |
| 10 | var wave_amplitude: float = 1.5 | |
| 11 | var wave_frequency: float = 0.8 | |
| 12 | var vertical_amplitude: float = 0.6 | |
| 13 | var vertical_frequency: float = 0.5 | |
| 14 | var preferred_altitude: float = 20.0 | |
| 15 | var segment_radius: float = 0.15 | |
| 16 | var segment_spacing: float = 0.27 | |
| 17 | var head_color: Color = Color(0.3, 0.5, 0.2) | |
| 18 | ||
| 19 | # ---- Internal state ---- | |
| 20 | ||
| 21 | var _mm: MultiMeshInstance3D = null ## Single draw call for all segments | |
| 22 | var _segment_tapers: Array[float] = [] | |
| 23 | var _segment_colors: Array[Color] = [] | |
| 24 | var _segment_positions: Array[Vector3] = [] | |
| 25 | var _head_direction: Vector3 = Vector3.FORWARD | |
| 26 | var _time: float = 0.0 | |
| 27 | var _wander_target: Vector3 = Vector3.ZERO | |
| 28 | var _wander_timer: float = 0.0 | |
| 29 | var _rng: RandomNumberGenerator | |
| 30 | var _initialized: bool = false | |
| 31 | ||
| 32 | enum LODLevel { FULL, MEDIUM, LOW } | |
| 33 | var _lod: int = LODLevel.FULL | |
| 34 | ||
| 35 | func 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 | ||
| 48 | func 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 | ||
| 93 | func _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 | ||
| 121 | func _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 | ||
| 132 | func _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 | ||
| 149 | func _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 | ||
| 158 | func _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 | ||
| 173 | func _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 | ||
| 199 | func _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 | ||
| 213 | func _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 | |
| 227 | func _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]) |
| … | … | @@ -0,0 +1,579 @@ |
| 1 | class_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 | ||
| 9 | var segment_count: int = 5 | |
| 10 | var creature_scale: float = 1.0 | |
| 11 | var move_speed: float = 2.5 | |
| 12 | var wave_amplitude: float = 1.0 | |
| 13 | var wave_frequency: float = 0.8 | |
| 14 | var body_color: Color = Color(0.45, 0.35, 0.2) | |
| 15 | var size_skew: float = 0.0 ## -1 = largest at head, +1 = largest at tail | |
| 16 | ||
| 17 | # ---- Derived dimensions ---- | |
| 18 | ||
| 19 | var body_height: float = 0.4 | |
| 20 | var segment_radius: float = 0.2 | |
| 21 | var segment_spacing: float = 0.3 | |
| 22 | var connector_radius: float = 0.1 ## Small spheres between main segments | |
| 23 | var leg_spread: float = 0.5 ## How far out legs reach laterally | |
| 24 | var leg_length: float = 0.5 ## Total leg length (upper + lower) | |
| 25 | var upper_leg_length: float = 0.25 | |
| 26 | var lower_leg_length: float = 0.25 | |
| 27 | var step_height: float = 0.15 ## Arc lift during step | |
| 28 | ||
| 29 | # ---- Body chain ---- | |
| 30 | ||
| 31 | var _body_mm: MultiMeshInstance3D = null ## Body spheres + connectors (1 draw call) | |
| 32 | var _body_instance_count: int = 0 | |
| 33 | var _segment_radii: Array[float] = [] ## Per-segment radius (taper) | |
| 34 | var _segment_colors: Array[Color] = [] | |
| 35 | var _segment_positions: Array[Vector3] = [] | |
| 36 | var _head_direction: Vector3 = Vector3.FORWARD | |
| 37 | var _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 | ||
| 42 | var _leg_count: int = 0 | |
| 43 | var _foot_planted: Array[Vector3] = [] ## Where foot is currently planted | |
| 44 | var _foot_target: Array[Vector3] = [] ## Where foot is stepping toward | |
| 45 | var _foot_current: Array[Vector3] = [] ## Current visual foot position (during arc) | |
| 46 | var _foot_stepping: Array[bool] = [] ## Mid-step? | |
| 47 | var _foot_arc_t: Array[float] = [] ## 0-1 step arc progress | |
| 48 | var _leg_mm: MultiMeshInstance3D = null ## All leg capsules (1 draw call) | |
| 49 | var _leg_instance_count: int = 0 ## upper + lower per leg | |
| 50 | ||
| 51 | const STEP_THRESHOLD: float = 0.35 ## Drift before step triggers (scaled) | |
| 52 | const STEP_SPEED: float = 10.0 ## Arc travel speed | |
| 53 | const PREDICT_TIME: float = 0.3 ## Seconds ahead to predict foot target | |
| 54 | ||
| 55 | # ---- Wandering ---- | |
| 56 | ||
| 57 | var _wander_target: Vector3 = Vector3.ZERO | |
| 58 | var _wander_timer: float = 0.0 | |
| 59 | var _rng: RandomNumberGenerator | |
| 60 | var _initialized: bool = false | |
| 61 | var _exclude_rids: Array[RID] = [] | |
| 62 | ||
| 63 | enum LODLevel { FULL, MEDIUM, LOW } | |
| 64 | var _lod: int = LODLevel.FULL | |
| 65 | var _frame_id: int = 0 | |
| 66 | static var _instance_counter: int = 0 | |
| 67 | var _stagger_offset: int = 0 | |
| 68 | const PHYSICS_STAGGER: int = 3 ## Full physics every Nth frame | |
| 69 | ||
| 70 | ||
| 71 | func 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 | ||
| 83 | func 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 | |
| 127 | static var _mat_cache: Dictionary = {} | |
| 128 | ||
| 129 | static 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 | ||
| 141 | func _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 | ||
| 174 | func _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 | ||
| 184 | func _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 | ||
| 213 | func _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 | ||
| 226 | func _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 | ||
| 255 | func _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 | ||
| 263 | func _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 | |
| 274 | func _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 | ||
| 292 | func _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 | ||
| 319 | func _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 | |
| 335 | func _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. | |
| 352 | func _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 | ||
| 380 | func _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 | ||
| 391 | func _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 | ||
| 399 | func _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. | |
| 437 | func _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. | |
| 455 | func _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 | ||
| 478 | func _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 | ||
| 494 | func _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 | ||
| 567 | func _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) |
| … | … | @@ -0,0 +1,220 @@ |
| 1 | class_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 | ||
| 9 | var segment_count: int = 6 | |
| 10 | var creature_scale: float = 1.0 | |
| 11 | var hop_force: float = 8.0 ## Upward impulse per hop | |
| 12 | var hop_interval: float = 1.5 ## Seconds between hops | |
| 13 | var lateral_speed: float = 3.0 ## Horizontal drift per hop | |
| 14 | var wave_speed: float = 3.0 ## Vertical undulation wave speed | |
| 15 | var wave_strength: float = 0.3 ## Vertical compression/stretch amount | |
| 16 | var segment_radius: float = 0.2 | |
| 17 | var segment_spacing: float = 0.25 | |
| 18 | var body_color: Color = Color(0.5, 0.3, 0.6) | |
| 19 | var size_skew: float = 0.0 ## -1 = largest at bottom, +1 = largest at top | |
| 20 | ||
| 21 | # ---- Physics state ---- | |
| 22 | ||
| 23 | var _velocity: Vector3 = Vector3.ZERO | |
| 24 | var _on_ground: bool = false | |
| 25 | var _bounce_time: float = 0.0 ## Time since last bounce (drives undulation) | |
| 26 | var _hop_dir: Vector3 = Vector3.ZERO | |
| 27 | ||
| 28 | # ---- Impact squash ---- | |
| 29 | var _impact_strength: float = 0.0 ## 0-1, how hard we landed (from velocity) | |
| 30 | var _impact_time: float = 99.0 ## Time since impact (drives decay) | |
| 31 | const IMPACT_STIFFNESS: float = 12.0 ## How fast squash recovers (higher = snappier) | |
| 32 | const IMPACT_SCALE: float = 0.02 ## velocity → squash factor | |
| 33 | ||
| 34 | # ---- Internal ---- | |
| 35 | ||
| 36 | var _mm: MultiMeshInstance3D = null | |
| 37 | var _segment_tapers: Array[float] = [] | |
| 38 | var _segment_colors: Array[Color] = [] | |
| 39 | var _base_position: Vector3 = Vector3.ZERO | |
| 40 | var _rng: RandomNumberGenerator | |
| 41 | var _initialized: bool = false | |
| 42 | var _time: float = 0.0 | |
| 43 | ||
| 44 | const GRAVITY: float = 20.0 | |
| 45 | const GROUND_SNAP: float = 0.05 | |
| 46 | ||
| 47 | enum LODLevel { FULL, MEDIUM, LOW } | |
| 48 | var _lod: int = LODLevel.FULL | |
| 49 | ||
| 50 | func 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 | ||
| 62 | func 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 | ||
| 100 | func _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 | ||
| 136 | func _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 | ||
| 181 | func _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 | ||
| 190 | func _pick_hop_dir() -> void: | |
| 191 | var angle: float = _rng.randf() * TAU | |
| 192 | _hop_dir = Vector3(cos(angle), 0, sin(angle)) | |
| 193 | ||
| 194 | ||
| 195 | func _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]) |
| … | … | @@ -0,0 +1,78 @@ |
| 1 | class_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). | |
| 8 | static 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) |
| … | … | @@ -0,0 +1,165 @@ |
| 1 | extends 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 | ||
| 13 | var _rng := RandomNumberGenerator.new() | |
| 14 | var _creature: Node3D = null | |
| 15 | var _creatures_paused: bool = false | |
| 16 | ||
| 17 | # Fly camera | |
| 18 | var _fly_speed: float = 10.0 | |
| 19 | var _mouse_sens: float = 0.003 | |
| 20 | var _cam_yaw: float = 0.0 | |
| 21 | var _cam_pitch: float = -0.3 | |
| 22 | ||
| 23 | ||
| 24 | func _ready() -> void: | |
| 25 | _rng.randomize() | |
| 26 | Input.mouse_mode = Input.MOUSE_MODE_CAPTURED | |
| 27 | camera.global_position = Vector3(0, 3, 8) | |
| 28 | ||
| 29 | ||
| 30 | func _process(delta: float) -> void: | |
| 31 | _fly_camera(delta) | |
| 32 | _update_hud() | |
| 33 | ||
| 34 | ||
| 35 | func _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 | ||
| 59 | func _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 | ||
| 81 | func _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 | ||
| 129 | func _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 | ||
| 155 | func _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 | ||
| 162 | func _rescale(factor: float) -> void: | |
| 163 | if _creature and is_instance_valid(_creature): | |
| 164 | _creature.scale *= factor | |
| 165 | print("Scale: %.2f" % _creature.scale.x) |
| … | … | @@ -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"] | |
| 6 | albedo_color = Color(0.35, 0.4, 0.35, 1) | |
| 7 | ||
| 8 | [sub_resource type="PlaneMesh" id="PlaneMesh_1"] | |
| 9 | size = Vector2(100, 100) | |
| 10 | material = SubResource("FloorMat") | |
| 11 | ||
| 12 | [sub_resource type="BoxShape3D" id="FloorShape"] | |
| 13 | size = Vector3(100, 0.1, 100) | |
| 14 | ||
| 15 | [sub_resource type="PhysicalSkyMaterial" id="SkyMat"] | |
| 16 | ||
| 17 | [sub_resource type="Sky" id="Sky_1"] | |
| 18 | sky_material = SubResource("SkyMat") | |
| 19 | ||
| 20 | [sub_resource type="Environment" id="Env_1"] | |
| 21 | background_mode = 2 | |
| 22 | sky = SubResource("Sky_1") | |
| 23 | ambient_light_source = 3 | |
| 24 | ambient_light_color = Color(0.8, 0.85, 0.9, 1) | |
| 25 | ||
| 26 | [node name="CreatureTest" type="Node3D"] | |
| 27 | script = ExtResource("1") | |
| 28 | ||
| 29 | [node name="Floor" type="StaticBody3D" parent="."] | |
| 30 | ||
| 31 | [node name="MeshInstance3D" type="MeshInstance3D" parent="Floor"] | |
| 32 | mesh = SubResource("PlaneMesh_1") | |
| 33 | ||
| 34 | [node name="CollisionShape3D" type="CollisionShape3D" parent="Floor"] | |
| 35 | transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.05, 0) | |
| 36 | shape = SubResource("FloorShape") | |
| 37 | ||
| 38 | [node name="Camera3D" type="Camera3D" parent="."] | |
| 39 | transform = Transform3D(1, 0, 0, 0, 0.955, 0.296, 0, -0.296, 0.955, 0, 3, 8) | |
| 40 | fov = 70.0 | |
| 41 | ||
| 42 | [node name="CanvasLayer" type="CanvasLayer" parent="."] | |
| 43 | ||
| 44 | [node name="StatsLabel" type="Label" parent="CanvasLayer"] | |
| 45 | offset_left = 10.0 | |
| 46 | offset_top = 10.0 | |
| 47 | offset_right = 450.0 | |
| 48 | offset_bottom = 250.0 | |
| 49 | text = "Press [1] for Crawler, [2] for Serpent" | |
| 50 | ||
| 51 | [node name="WorldEnvironment" type="WorldEnvironment" parent="."] | |
| 52 | environment = SubResource("Env_1") | |
| 53 | ||
| 54 | [node name="DirectionalLight3D" type="DirectionalLight3D" parent="."] | |
| 55 | transform = Transform3D(0.568, 0.495, 0.657, 0, -0.799, 0.602, 0.823, -0.342, -0.454, 0, 10, 0) | |
| 56 | shadow_enabled = true |
| … | … | @@ -0,0 +1,66 @@ |
| 1 | class_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 | ||
| 6 | static var _shape_cache: Dictionary = {} ## radius → SphereShape3D | |
| 7 | static var _params: PhysicsShapeQueryParameters3D = null | |
| 8 | ||
| 9 | ## Sweep a sphere from `from` to `to`. Returns { "point", "normal", "fraction" } or empty. | |
| 10 | static 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 | } |