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")