creatures.py¶
Part of Deep Sea Aquarium.
1"""Bioluminescent sea creatures: Jellyfish, Fish, FishSchool, CoralFormation, Anemone."""
2
3import math
4
5import numpy as np
6from boids import compute_boids
7from meshgen import (
8 make_fish_body,
9 make_jellyfish_bell,
10 make_oral_arm,
11 make_tentacle_segment,
12)
13
14from simvx.core import (
15 CollisionShape3D,
16 Material,
17 Mesh,
18 MeshInstance3D,
19 Node3D,
20 PointLight3D,
21 Signal,
22 SphereShape3D,
23 Vec3,
24)
25from simvx.core.math.types import Quat
26
27# Picking opts in via a ``CollisionShape3D`` carrying a ``SphereShape3D`` (its
28# ``bounding_radius`` is the pick sphere) with ``pickable=True``: the tree's
29# ``input_cast`` ray-tests pickable colliders and delivers ``on_picked``.
30
31# ============================================================================
32# Jellyfish Species Definitions
33# ============================================================================
34
35# Each species defines: bell mesh params, colour, tentacle config, animation style
36JELLY_SPECIES = [
37 { # Moon Jelly (Aurelia): wide dome, UV-lit blue-white glow
38 "name": "Moon Jelly",
39 "bell": {"radius": 1.0, "height": 0.6, "profile": "dome", "rim_curl": 0.3, "flatness": 0.15},
40 "body_colour": (0.12, 0.2, 0.45, 0.22),
41 "emissive": (0.25, 0.4, 0.85, 3.0),
42 "rim_emissive": (0.55, 0.75, 1.0, 6.0),
43 "light_colour": (0.25, 0.4, 0.8),
44 "light_intensity": 1.6,
45 "marginal_tentacles": 12,
46 "marginal_length": 3,
47 "oral_arms": 4,
48 "oral_arm_length": 0.8,
49 "pulse_rate": 1.4,
50 "scale": 1.7,
51 "tentacle_alpha": 0.25,
52 },
53 { # Sea Nettle (Chrysaora): tall elongated bell, rich amber-orange under tank lights
54 "name": "Sea Nettle",
55 "bell": {"radius": 0.7, "height": 1.0, "profile": "tall", "rim_curl": 0.5, "apex_sharpness": 0.35},
56 "body_colour": (0.4, 0.18, 0.06, 0.35),
57 "emissive": (0.8, 0.4, 0.14, 4.0),
58 "rim_emissive": (1.0, 0.5, 0.2, 5.5),
59 "light_colour": (0.7, 0.3, 0.1),
60 "light_intensity": 1.6,
61 "marginal_tentacles": 6,
62 "marginal_length": 5,
63 "oral_arms": 4,
64 "oral_arm_length": 1.0,
65 "pulse_rate": 2.0,
66 "scale": 1.7,
67 "tentacle_alpha": 0.22,
68 },
69 { # Crystal Jelly (Aequorea): classic dome, vivid cyan-teal under blue LEDs
70 "name": "Crystal Jelly",
71 "bell": {"radius": 0.85, "height": 0.55, "profile": "dome", "rim_curl": 0.25},
72 "body_colour": (0.06, 0.28, 0.32, 0.2),
73 "emissive": (0.12, 0.55, 0.6, 3.0),
74 "rim_emissive": (0.22, 0.85, 0.9, 6.0),
75 "light_colour": (0.15, 0.55, 0.6),
76 "light_intensity": 1.8,
77 "marginal_tentacles": 10,
78 "marginal_length": 3,
79 "oral_arms": 0,
80 "oral_arm_length": 0.0,
81 "pulse_rate": 1.8,
82 "scale": 1.5,
83 "tentacle_alpha": 0.28,
84 },
85 { # Atolla Jelly: small round bulb, vivid violet-magenta, long trailing tentacles
86 "name": "Atolla Jelly",
87 "bell": {"radius": 0.55, "height": 0.55, "profile": "bulb", "rim_curl": 0.6, "apex_sharpness": 0.5},
88 "body_colour": (0.18, 0.06, 0.35, 0.32),
89 "emissive": (0.45, 0.15, 0.65, 4.0),
90 "rim_emissive": (0.6, 0.2, 0.8, 5.5),
91 "light_colour": (0.35, 0.1, 0.55),
92 "light_intensity": 1.6,
93 "marginal_tentacles": 5,
94 "marginal_length": 6,
95 "oral_arms": 2,
96 "oral_arm_length": 0.6,
97 "pulse_rate": 2.4,
98 "scale": 1.4,
99 "tentacle_alpha": 0.22,
100 },
101]
102
103# ============================================================================
104# Colour Palettes
105# ============================================================================
106
107FISH_COLOURS = [
108 (0.2, 0.5, 1.0, 2.5), # Vivid blue
109 (0.12, 0.7, 0.65, 2.5), # Bright teal
110 (0.28, 0.38, 0.9, 2.5), # Royal blue
111 (0.15, 0.6, 0.8, 2.2), # Cyan
112]
113
114FISH_WARM_COLOURS = [
115 (0.8, 0.4, 0.1, 2.2), # Rich amber
116 (0.7, 0.45, 0.12, 2.2), # Golden
117 (0.55, 0.25, 0.12, 2.2), # Burnt orange
118 (0.9, 0.35, 0.15, 2.0), # Tangerine
119]
120
121CORAL_COLOURS = [
122 ((0.04, 0.015, 0.025), (0.8, 0.3, 0.45, 4.5)), # Rose: brighter tips
123 ((0.04, 0.025, 0.015), (0.8, 0.45, 0.18, 4.5)), # Amber
124 ((0.025, 0.015, 0.04), (0.5, 0.25, 0.8, 4.5)), # Violet
125 ((0.015, 0.025, 0.03), (0.22, 0.6, 0.65, 4.5)), # Aqua
126]
127
128
129# ============================================================================
130# Jellyfish
131# ============================================================================
132
133
134class Jellyfish(Node3D):
135 """Bioluminescent jellyfish: species-specific body and behaviour."""
136
137 creature_clicked = Signal()
138
139 def __init__(self, species_index: int = 0, **kw):
140 super().__init__(**kw)
141 self._species = JELLY_SPECIES[species_index % len(JELLY_SPECIES)]
142 self._species_idx = species_index
143 self._time = 0.0
144 self._drift_dir = Vec3(0, 0, 0)
145 self._drift_timer = 0.0
146 self._bell: MeshInstance3D | None = None
147 self._bell_mat: Material | None = None
148 self._rim_mat: Material | None = None
149 self._light: PointLight3D | None = None
150 self._tentacle_chains: list[list[Node3D]] = []
151 self._oral_arm_nodes: list[Node3D] = []
152 self._pulse_boost = 0.0
153 self._rng = np.random.default_rng(species_index * 17 + 7)
154
155 def on_ready(self):
156 sp = self._species
157 body_colour = sp["body_colour"]
158 emissive = sp["emissive"]
159 rim_emissive = sp.get("rim_emissive", emissive)
160 light_colour = sp["light_colour"]
161 light_intensity = sp.get("light_intensity", 1.2)
162 scale = sp["scale"]
163 tent_alpha = sp.get("tentacle_alpha", 0.18)
164
165 # --- Bell (outer: translucent body, the rim layer provides the bright edge) ---
166 bell_mesh = make_jellyfish_bell(rings=18, segments=24, **sp["bell"])
167 bell_body_colour = (*body_colour[:3], body_colour[3] * 0.7)
168 bell_body_emissive = (*emissive[:3], emissive[3] * 0.55)
169 self._bell_mat = Material(
170 colour=bell_body_colour, blend="alpha", double_sided=True, emissive_colour=bell_body_emissive
171 )
172 self._bell = MeshInstance3D(name="Bell", mesh=bell_mesh, material=self._bell_mat)
173 self._bell.scale = Vec3(scale, scale, scale)
174 self.add_child(self._bell)
175
176 # --- Bell rim glow (slightly larger, bright edge halo) ---
177 rim_scale = scale * 1.03
178 self._rim_mat = rim_mat = Material(
179 colour=(*body_colour[:3], body_colour[3] * 0.5),
180 blend="alpha",
181 double_sided=True,
182 emissive_colour=rim_emissive,
183 )
184 rim = MeshInstance3D(name="BellRim", mesh=bell_mesh, material=rim_mat)
185 rim.scale = Vec3(rim_scale, rim_scale * 0.92, rim_scale)
186 self.add_child(rim)
187
188 # --- Oral arms (thick frilly arms from bell center) ---
189 n_oral = sp["oral_arms"]
190 if n_oral > 0:
191 arm_mesh = make_oral_arm(length=sp["oral_arm_length"], width=0.06)
192 arm_mat = Material(
193 colour=(*body_colour[:3], 0.3),
194 blend="alpha",
195 double_sided=True,
196 emissive_colour=(*emissive[:3], emissive[3] * 0.45),
197 )
198 for a in range(n_oral):
199 angle = (a / n_oral) * math.tau + self._species_idx * 0.5
200 arm = Node3D(name=f"OralArm_{a}", position=Vec3(0, -0.05, 0))
201 arm.rotate_y(angle)
202 mi = MeshInstance3D(name=f"OralArmMesh_{a}", mesh=arm_mesh, material=arm_mat)
203 arm.add_child(mi)
204 self.add_child(arm)
205 self._oral_arm_nodes.append(arm)
206
207 # --- Marginal tentacles (visible tendrils from bell rim) ---
208 n_marg = sp["marginal_tentacles"]
209 marg_len = sp["marginal_length"]
210 seg_mesh = make_tentacle_segment(length=0.4, radius=0.025)
211 tent_mat = Material(
212 colour=(*body_colour[:3], tent_alpha),
213 blend="alpha",
214 double_sided=True,
215 emissive_colour=(*emissive[:3], emissive[3] * 0.3),
216 )
217 bell_radius = sp["bell"]["radius"] * scale
218 for t in range(n_marg):
219 angle = (t / n_marg) * math.tau + self._species_idx * 0.4
220 r = bell_radius * (0.78 + 0.12 * (t % 3))
221 chain: list[Node3D] = []
222 parent_node: Node3D = self
223 for s in range(marg_len):
224 seg = Node3D(name=f"Tent_{t}_{s}")
225 if s == 0:
226 seg.position = Vec3(r * math.cos(angle), -0.08, r * math.sin(angle))
227 else:
228 seg.position = Vec3(0, -0.4, 0)
229 seg_mi = MeshInstance3D(name=f"TentMesh_{t}_{s}", mesh=seg_mesh, material=tent_mat)
230 seg.add_child(seg_mi)
231 parent_node.add_child(seg)
232 chain.append(seg)
233 parent_node = seg
234 self._tentacle_chains.append(chain)
235
236 # --- Interior glow light ---
237 self._light = PointLight3D(name="JellyLight", position=Vec3(0, 0.15, 0))
238 self._light.colour = light_colour
239 self._light.intensity = light_intensity
240 self._light.range = 8.0
241 self.add_child(self._light)
242
243 # Pickable collision
244 col = CollisionShape3D(shape=SphereShape3D(radius=bell_radius * 1.2), pickable=True, name="JellyCol")
245 self.add_child(col)
246
247 self._randomise_drift()
248
249 def on_update(self, dt: float):
250 self._time += dt
251 sp = self._species
252 pulse_rate = sp["pulse_rate"]
253 pulse = math.sin(self._time * pulse_rate)
254
255 # Bell pulsation: organic contraction + emissive breathing
256 if self._bell:
257 s = sp["scale"]
258 sy = s * (0.88 + 0.12 * pulse)
259 sx = s * (1.0 + 0.04 * math.sin(self._time * pulse_rate + math.pi))
260 self._bell.scale = Vec3(sx, sy, sx)
261 # Emissive glow pulses with contraction: brighter when compressed
262 emissive = sp["emissive"]
263 glow_factor = 0.55 + 0.2 * max(0, pulse) # 0.55 to 0.75
264 if self._bell_mat:
265 self._bell_mat.emissive_colour = (*emissive[:3], emissive[3] * glow_factor)
266 if self._rim_mat:
267 rim_e = sp.get("rim_emissive", emissive)
268 self._rim_mat.emissive_colour = (*rim_e[:3], rim_e[3] * (0.8 + 0.2 * max(0, pulse)))
269
270 # Tentacle wave: each chain gets unique phase
271 for ci, chain in enumerate(self._tentacle_chains):
272 phase = ci * 0.6 + self._species_idx * 1.3
273 for depth, seg in enumerate(chain):
274 wave = math.sin(self._time * 1.2 + depth * 0.7 + phase)
275 sway_x = wave * 0.18
276 sway_z = math.cos(self._time * 0.9 + depth * 0.5 + phase) * 0.1
277 seg.rotation = Quat.from_euler(sway_x, 0, sway_z)
278
279 # Oral arm sway: slower, broader motion
280 for ai, arm in enumerate(self._oral_arm_nodes):
281 sway = math.sin(self._time * 0.6 + ai * 1.5) * 0.15
282 arm.rotation = Quat.from_euler(
283 sway, ai / len(self._oral_arm_nodes) * math.tau + self._species_idx * 0.5, sway * 0.5
284 )
285
286 # Light pulse: breathing glow with slow secondary oscillation
287 if self._light:
288 base_intensity = self._species.get("light_intensity", 1.2)
289 breath = 0.15 * math.sin(self._time * 0.3 + self._species_idx * 1.7)
290 self._light.intensity = base_intensity + 0.5 * pulse + breath + self._pulse_boost * 2.0
291
292 # Reaction pulse decay
293 if self._pulse_boost > 0:
294 self._pulse_boost = max(0, self._pulse_boost - dt * 0.5)
295
296 # Drift movement
297 self._drift_timer -= dt
298 if self._drift_timer <= 0:
299 self._randomise_drift()
300 self.position = self.position + self._drift_dir * dt
301
302 def _randomise_drift(self):
303 self._drift_dir = Vec3(
304 self._rng.uniform(-0.25, 0.25),
305 self._rng.uniform(-0.04, 0.04),
306 self._rng.uniform(-0.25, 0.25),
307 )
308 self._drift_timer = self._rng.uniform(5.0, 10.0)
309
310 def on_picked(self, event):
311 self._pulse_boost = 1.0
312 self.creature_clicked(self._species["name"])
313
314
315# ============================================================================
316# Fish
317# ============================================================================
318
319
320class Fish(Node3D):
321 """Single bioluminescent fish."""
322
323 def __init__(self, emissive_colour: tuple = (0.0, 0.5, 1.0, 1.5), size_scale: float = 1.0, **kw):
324 super().__init__(**kw)
325 self._emissive = emissive_colour
326 self._size = size_scale
327 self._time = 0.0
328 self._body: MeshInstance3D | None = None
329
330 def on_ready(self):
331 body_mesh = make_fish_body(length=0.9, max_height=0.22, max_width=0.13, rings=10, segments=10)
332 e = self._emissive
333 # Darker base colour so the fish body silhouette reads through the glow
334 mat = Material(
335 colour=(e[0] * 0.12, e[1] * 0.12, e[2] * 0.12, 1.0),
336 roughness=0.35,
337 metallic=0.3,
338 emissive_colour=(e[0], e[1], e[2], e[3] * 0.5),
339 )
340 s = 1.0 * self._size
341 self._body = MeshInstance3D(
342 name="FishBody",
343 mesh=body_mesh,
344 material=mat,
345 scale=Vec3(s, s, s),
346 )
347 self.add_child(self._body)
348
349 col = CollisionShape3D(shape=SphereShape3D(radius=0.4), pickable=True, name="FishCol")
350 self.add_child(col)
351
352 def on_update(self, dt: float):
353 self._time += dt
354 # Gentle body oscillation for swimming feel
355 if self._body:
356 sway = math.sin(self._time * 6.0) * 0.08
357 self._body.rotation = Quat.from_euler(0, sway, 0)
358
359
360# ============================================================================
361# FishSchool
362# ============================================================================
363
364
365class FishSchool(Node3D):
366 """Boids-based school of fish."""
367
368 creature_clicked = Signal()
369
370 def __init__(self, count: int = 20, emissive_colours: list | None = None, **kw):
371 super().__init__(**kw)
372 self._count = count
373 self._colours = emissive_colours or FISH_COLOURS
374 self._fish: list[Fish] = []
375 self._positions: np.ndarray | None = None
376 self._velocities: np.ndarray | None = None
377 self._scatter_timer = 0.0
378 self._scatter_point: np.ndarray | None = None
379 self._rng = np.random.default_rng(42)
380
381 def on_ready(self):
382 self._positions = self._rng.uniform(-10, 10, (self._count, 3)).astype(np.float32)
383 self._positions[:, 1] = self._rng.uniform(-1.0, 2.0, self._count).astype(np.float32)
384 self._velocities = self._rng.uniform(-0.8, 0.8, (self._count, 3)).astype(np.float32)
385
386 for i in range(self._count):
387 colour = self._colours[i % len(self._colours)]
388 size = 0.5 + self._rng.uniform(0, 0.5) # 0.5x to 1.0x: smaller, natural variation
389 fish = Fish(emissive_colour=colour, size_scale=size, name=f"Fish_{i}")
390 fish.position = Vec3(*self._positions[i])
391 self._fish.append(fish)
392 self.add_child(fish)
393
394 def on_update(self, dt: float):
395 if self._positions is None or self._velocities is None:
396 return
397
398 # Compute boids acceleration
399 accel = compute_boids(self._positions, self._velocities)
400
401 # Apply scatter repulsion if active
402 if self._scatter_timer > 0 and self._scatter_point is not None:
403 self._scatter_timer -= dt
404 diff = self._positions - self._scatter_point[None, :]
405 dist = np.linalg.norm(diff, axis=1, keepdims=True)
406 dist = np.maximum(dist, 0.5)
407 accel += diff / (dist * dist) * 8.0
408
409 # Integrate with gentle damping
410 self._velocities += accel * dt
411 self._velocities *= 0.99 # Slight drag
412 # Clamp speed
413 speeds = np.linalg.norm(self._velocities, axis=1, keepdims=True)
414 too_fast = speeds > 2.5
415 if too_fast.any():
416 self._velocities = np.where(too_fast, self._velocities / speeds * 2.5, self._velocities)
417 self._positions += self._velocities * dt
418
419 # Update fish nodes
420 for i, fish in enumerate(self._fish):
421 pos = self._positions[i]
422 fish.position = Vec3(pos)
423 # Face velocity direction
424 vel = self._velocities[i]
425 speed = float(np.linalg.norm(vel))
426 if speed > 0.2:
427 fish.look_at(pos + vel * 2.0) # Look further ahead for smoother turns
428
429 def scatter_from(self, point: tuple | np.ndarray):
430 """Scatter fish away from a world point for 1.5s."""
431 self._scatter_timer = 1.5
432 self._scatter_point = np.array(point, dtype=np.float32)
433
434 def on_picked(self, event):
435 """Fish scatter on click."""
436 if hasattr(event, "ray_origin") and hasattr(event, "ray_direction"):
437 # Estimate hit point
438 hit_point = np.array(event.ray_origin) + np.array(event.ray_direction) * event.distance
439 self.scatter_from(hit_point)
440 self.creature_clicked("Fish")
441
442
443# ============================================================================
444# CoralFormation
445# ============================================================================
446
447
448class CoralFormation(Node3D):
449 """Mushroom coral cluster: glowing caps on short stalks, reads clearly as coral."""
450
451 def __init__(self, colour_index: int = 0, **kw):
452 super().__init__(**kw)
453 self._colour_index = colour_index % len(CORAL_COLOURS)
454 self._time = 0.0
455 self._caps: list[tuple[MeshInstance3D, float]] = [] # (mesh, base_y_scale)
456
457 def on_ready(self):
458 trunk_colour, tip_emissive = CORAL_COLOURS[self._colour_index]
459 rng = np.random.default_rng(self._colour_index * 31 + 11)
460
461 # Stalk material: dark with faint glow
462 stalk_mat = Material(
463 colour=(*trunk_colour, 1.0),
464 roughness=0.8,
465 metallic=0.1,
466 emissive_colour=(*tip_emissive[:3], tip_emissive[3] * 0.1),
467 )
468 # Cap material: bright glowing dome
469 cap_mat = Material(
470 colour=(*tip_emissive[:3], 1.0),
471 roughness=0.3,
472 metallic=0.15,
473 emissive_colour=tip_emissive,
474 )
475
476 stalk_mesh = Mesh.cylinder(radius=1.0, height=1.0, segments=6)
477 cap_mesh = Mesh.sphere(radius=1.0, rings=6, segments=8)
478
479 # Central thick stalk
480 central_h = 0.5 + rng.uniform(0, 0.3)
481 trunk = MeshInstance3D(
482 name="Trunk",
483 mesh=stalk_mesh,
484 material=stalk_mat,
485 position=Vec3(0, central_h * 0.5, 0),
486 scale=Vec3(0.12, central_h, 0.12),
487 )
488 self.add_child(trunk)
489
490 # 4-6 mushroom caps spread out around the base
491 n_caps = 4 + int(rng.integers(0, 3))
492 for i in range(n_caps):
493 angle = (i / n_caps) * math.tau + rng.uniform(-0.3, 0.3)
494 dist = 0.3 + rng.uniform(0, 0.5)
495 height = 0.25 + rng.uniform(0, 0.55)
496 cap_r = 0.1 + rng.uniform(0, 0.14)
497
498 # Short stalk for this cap
499 sx = dist * math.cos(angle)
500 sz = dist * math.sin(angle)
501 stalk = MeshInstance3D(
502 name=f"Stalk_{i}",
503 mesh=stalk_mesh,
504 material=stalk_mat,
505 position=Vec3(sx, height * 0.5, sz),
506 scale=Vec3(0.04, height, 0.04),
507 )
508 self.add_child(stalk)
509
510 # Glowing cap on top: flattened sphere
511 cap = MeshInstance3D(
512 name=f"Cap_{i}",
513 mesh=cap_mesh,
514 material=cap_mat,
515 position=Vec3(sx, height + cap_r * 0.3, sz),
516 scale=Vec3(cap_r, cap_r * 0.5, cap_r),
517 )
518 self.add_child(cap)
519 self._caps.append((cap, cap_r * 0.5))
520
521 # Central glow
522 light = PointLight3D(name="CoralGlow", position=Vec3(0, 0.5, 0))
523 light.colour = tip_emissive[:3]
524 light.intensity = 1.5
525 light.range = 5.0
526 self.add_child(light)
527
528 def on_update(self, dt: float):
529 self._time += dt
530 for i, (cap, base_sy) in enumerate(self._caps):
531 pulse = 1.0 + 0.06 * math.sin(self._time * 0.8 + i * 1.2)
532 sx, sz = float(cap.scale.x), float(cap.scale.z)
533 cap.scale = Vec3(sx, base_sy * pulse, sz)
534
535
536# ============================================================================
537# Anemone
538# ============================================================================
539
540
541class Anemone(Node3D):
542 """Sea anemone: cylindrical column with dense crown of soft tentacles."""
543
544 creature_clicked = Signal()
545
546 def __init__(self, colour: tuple = (0.0, 0.9, 0.7, 3.0), **kw):
547 super().__init__(**kw)
548 self._colour = colour
549 self._time = 0.0
550 self._tentacles: list[Node3D] = []
551 self._retract = 0.0
552
553 def on_ready(self):
554 c = self._colour
555
556 # Column/stalk: tall cylinder
557 col_mat = Material(
558 colour=(0.06, 0.04, 0.03, 1.0),
559 roughness=0.85,
560 emissive_colour=(c[0] * 0.3, c[1] * 0.3, c[2] * 0.3, 0.3),
561 )
562 column = MeshInstance3D(
563 name="AnemColumn",
564 mesh=Mesh.cylinder(radius=0.15, height=0.5, segments=10),
565 material=col_mat,
566 position=Vec3(0, 0.25, 0),
567 )
568 self.add_child(column)
569
570 # Oral disc: flat disc at top of column
571 disc_mat = Material(
572 colour=(c[0] * 0.15, c[1] * 0.15, c[2] * 0.15, 1.0),
573 roughness=0.6,
574 emissive_colour=(c[0], c[1], c[2], c[3] * 0.2),
575 )
576 disc = MeshInstance3D(
577 name="AnemDisc",
578 mesh=Mesh.cylinder(radius=0.3, height=0.04, segments=12),
579 material=disc_mat,
580 position=Vec3(0, 0.52, 0),
581 )
582 self.add_child(disc)
583
584 # Tentacles: soft cylinders in 3 concentric rings
585 # Inner ring: 8 short, middle ring: 12 medium, outer ring: 16 long
586 tent_mesh = Mesh.cylinder(radius=0.02, height=0.3, segments=5)
587 # Small sphere at tentacle tip for "bubble tip" look
588 tip_mesh = Mesh.sphere(radius=0.025, rings=4, segments=5)
589
590 tent_mat = Material(
591 colour=(c[0] * 0.15, c[1] * 0.15, c[2] * 0.15, 1.0),
592 roughness=0.5,
593 emissive_colour=(c[0], c[1], c[2], c[3] * 0.5),
594 )
595 tip_mat = Material(
596 colour=(c[0] * 0.25, c[1] * 0.25, c[2] * 0.25, 1.0),
597 roughness=0.35,
598 emissive_colour=(c[0], c[1], c[2], c[3] * 0.75),
599 )
600
601 rings = [(6, 0.12, 0.2), (8, 0.2, 0.3), (10, 0.28, 0.4)] # (count, radius, height)
602 idx = 0
603 for n_ring, ring_r, tent_h in rings:
604 for j in range(n_ring):
605 angle = (j / n_ring) * math.tau + ring_r * 2.0 # Offset per ring
606 x = ring_r * math.cos(angle)
607 z = ring_r * math.sin(angle)
608 # Tentacle leans slightly outward
609 lean = 0.15 + ring_r * 0.3
610 tent = Node3D(name=f"Tent_{idx}", position=Vec3(x, 0.52, z))
611 tent.rotation = Quat.from_euler(lean * math.cos(angle), 0, lean * math.sin(angle))
612 # Cylinder body
613 body = MeshInstance3D(
614 name=f"TentBody_{idx}",
615 mesh=tent_mesh,
616 material=tent_mat,
617 scale=Vec3(1, tent_h / 0.3, 1),
618 position=Vec3(0, tent_h * 0.5, 0),
619 )
620 tent.add_child(body)
621 # Bubble tip
622 tip = MeshInstance3D(
623 name=f"TentTip_{idx}",
624 mesh=tip_mesh,
625 material=tip_mat,
626 position=Vec3(0, tent_h, 0),
627 )
628 tent.add_child(tip)
629 self.add_child(tent)
630 self._tentacles.append(tent)
631 idx += 1
632
633 # Central glow: bright enough to illuminate nearby floor
634 light = PointLight3D(name="AnemLight", position=Vec3(0, 0.6, 0))
635 light.colour = c[:3]
636 light.intensity = 2.0
637 light.range = 7.0
638 self.add_child(light)
639
640 col = CollisionShape3D(shape=SphereShape3D(radius=0.5), pickable=True, name="AnemCol")
641 self.add_child(col)
642
643 def on_update(self, dt: float):
644 self._time += dt
645 if self._retract > 0:
646 self._retract = max(0, self._retract - dt * 0.8)
647
648 retract_s = 1.0 - self._retract * 0.6
649 for i, tent in enumerate(self._tentacles):
650 # Gentle swaying: each tentacle has unique phase
651 phase = i * 0.35
652 sway_x = math.sin(self._time * 0.7 + phase) * 0.1 * retract_s
653 sway_z = math.cos(self._time * 0.5 + phase * 1.3) * 0.08 * retract_s
654 # Keep the base outward lean and add sway on top
655 base_lean = 0.15
656 tent.rotation = Quat.from_euler(base_lean + sway_x, 0, sway_z)
657 tent.scale = Vec3(retract_s, retract_s, retract_s)
658
659 def on_picked(self, event):
660 self._retract = 1.0
661 self.creature_clicked("Anemone")