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``: a press the UI
 29# does not take 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.emit(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 event.point is not None:
437            self.scatter_from(event.point)
438        self.creature_clicked.emit("Fish")
439
440
441# ============================================================================
442# CoralFormation
443# ============================================================================
444
445
446class CoralFormation(Node3D):
447    """Mushroom coral cluster: glowing caps on short stalks, reads clearly as coral."""
448
449    def __init__(self, colour_index: int = 0, **kw):
450        super().__init__(**kw)
451        self._colour_index = colour_index % len(CORAL_COLOURS)
452        self._time = 0.0
453        self._caps: list[tuple[MeshInstance3D, float]] = []  # (mesh, base_y_scale)
454
455    def on_ready(self):
456        trunk_colour, tip_emissive = CORAL_COLOURS[self._colour_index]
457        rng = np.random.default_rng(self._colour_index * 31 + 11)
458
459        # Stalk material: dark with faint glow
460        stalk_mat = Material(
461            colour=(*trunk_colour, 1.0),
462            roughness=0.8,
463            metallic=0.1,
464            emissive_colour=(*tip_emissive[:3], tip_emissive[3] * 0.1),
465        )
466        # Cap material: bright glowing dome
467        cap_mat = Material(
468            colour=(*tip_emissive[:3], 1.0),
469            roughness=0.3,
470            metallic=0.15,
471            emissive_colour=tip_emissive,
472        )
473
474        stalk_mesh = Mesh.cylinder(radius=1.0, height=1.0, segments=6)
475        cap_mesh = Mesh.sphere(radius=1.0, rings=6, segments=8)
476
477        # Central thick stalk
478        central_h = 0.5 + rng.uniform(0, 0.3)
479        trunk = MeshInstance3D(
480            name="Trunk",
481            mesh=stalk_mesh,
482            material=stalk_mat,
483            position=Vec3(0, central_h * 0.5, 0),
484            scale=Vec3(0.12, central_h, 0.12),
485        )
486        self.add_child(trunk)
487
488        # 4-6 mushroom caps spread out around the base
489        n_caps = 4 + int(rng.integers(0, 3))
490        for i in range(n_caps):
491            angle = (i / n_caps) * math.tau + rng.uniform(-0.3, 0.3)
492            dist = 0.3 + rng.uniform(0, 0.5)
493            height = 0.25 + rng.uniform(0, 0.55)
494            cap_r = 0.1 + rng.uniform(0, 0.14)
495
496            # Short stalk for this cap
497            sx = dist * math.cos(angle)
498            sz = dist * math.sin(angle)
499            stalk = MeshInstance3D(
500                name=f"Stalk_{i}",
501                mesh=stalk_mesh,
502                material=stalk_mat,
503                position=Vec3(sx, height * 0.5, sz),
504                scale=Vec3(0.04, height, 0.04),
505            )
506            self.add_child(stalk)
507
508            # Glowing cap on top: flattened sphere
509            cap = MeshInstance3D(
510                name=f"Cap_{i}",
511                mesh=cap_mesh,
512                material=cap_mat,
513                position=Vec3(sx, height + cap_r * 0.3, sz),
514                scale=Vec3(cap_r, cap_r * 0.5, cap_r),
515            )
516            self.add_child(cap)
517            self._caps.append((cap, cap_r * 0.5))
518
519        # Central glow
520        light = PointLight3D(name="CoralGlow", position=Vec3(0, 0.5, 0))
521        light.colour = tip_emissive[:3]
522        light.intensity = 1.5
523        light.range = 5.0
524        self.add_child(light)
525
526    def on_update(self, dt: float):
527        self._time += dt
528        for i, (cap, base_sy) in enumerate(self._caps):
529            pulse = 1.0 + 0.06 * math.sin(self._time * 0.8 + i * 1.2)
530            sx, sz = float(cap.scale.x), float(cap.scale.z)
531            cap.scale = Vec3(sx, base_sy * pulse, sz)
532
533
534# ============================================================================
535# Anemone
536# ============================================================================
537
538
539class Anemone(Node3D):
540    """Sea anemone: cylindrical column with dense crown of soft tentacles."""
541
542    creature_clicked = Signal()
543
544    def __init__(self, colour: tuple = (0.0, 0.9, 0.7, 3.0), **kw):
545        super().__init__(**kw)
546        self._colour = colour
547        self._time = 0.0
548        self._tentacles: list[Node3D] = []
549        self._retract = 0.0
550
551    def on_ready(self):
552        c = self._colour
553
554        # Column/stalk: tall cylinder
555        col_mat = Material(
556            colour=(0.06, 0.04, 0.03, 1.0),
557            roughness=0.85,
558            emissive_colour=(c[0] * 0.3, c[1] * 0.3, c[2] * 0.3, 0.3),
559        )
560        column = MeshInstance3D(
561            name="AnemColumn",
562            mesh=Mesh.cylinder(radius=0.15, height=0.5, segments=10),
563            material=col_mat,
564            position=Vec3(0, 0.25, 0),
565        )
566        self.add_child(column)
567
568        # Oral disc: flat disc at top of column
569        disc_mat = Material(
570            colour=(c[0] * 0.15, c[1] * 0.15, c[2] * 0.15, 1.0),
571            roughness=0.6,
572            emissive_colour=(c[0], c[1], c[2], c[3] * 0.2),
573        )
574        disc = MeshInstance3D(
575            name="AnemDisc",
576            mesh=Mesh.cylinder(radius=0.3, height=0.04, segments=12),
577            material=disc_mat,
578            position=Vec3(0, 0.52, 0),
579        )
580        self.add_child(disc)
581
582        # Tentacles: soft cylinders in 3 concentric rings
583        # Inner ring: 8 short, middle ring: 12 medium, outer ring: 16 long
584        tent_mesh = Mesh.cylinder(radius=0.02, height=0.3, segments=5)
585        # Small sphere at tentacle tip for "bubble tip" look
586        tip_mesh = Mesh.sphere(radius=0.025, rings=4, segments=5)
587
588        tent_mat = Material(
589            colour=(c[0] * 0.15, c[1] * 0.15, c[2] * 0.15, 1.0),
590            roughness=0.5,
591            emissive_colour=(c[0], c[1], c[2], c[3] * 0.5),
592        )
593        tip_mat = Material(
594            colour=(c[0] * 0.25, c[1] * 0.25, c[2] * 0.25, 1.0),
595            roughness=0.35,
596            emissive_colour=(c[0], c[1], c[2], c[3] * 0.75),
597        )
598
599        rings = [(6, 0.12, 0.2), (8, 0.2, 0.3), (10, 0.28, 0.4)]  # (count, radius, height)
600        idx = 0
601        for n_ring, ring_r, tent_h in rings:
602            for j in range(n_ring):
603                angle = (j / n_ring) * math.tau + ring_r * 2.0  # Offset per ring
604                x = ring_r * math.cos(angle)
605                z = ring_r * math.sin(angle)
606                # Tentacle leans slightly outward
607                lean = 0.15 + ring_r * 0.3
608                tent = Node3D(name=f"Tent_{idx}", position=Vec3(x, 0.52, z))
609                tent.rotation = Quat.from_euler(lean * math.cos(angle), 0, lean * math.sin(angle))
610                # Cylinder body
611                body = MeshInstance3D(
612                    name=f"TentBody_{idx}",
613                    mesh=tent_mesh,
614                    material=tent_mat,
615                    scale=Vec3(1, tent_h / 0.3, 1),
616                    position=Vec3(0, tent_h * 0.5, 0),
617                )
618                tent.add_child(body)
619                # Bubble tip
620                tip = MeshInstance3D(
621                    name=f"TentTip_{idx}",
622                    mesh=tip_mesh,
623                    material=tip_mat,
624                    position=Vec3(0, tent_h, 0),
625                )
626                tent.add_child(tip)
627                self.add_child(tent)
628                self._tentacles.append(tent)
629                idx += 1
630
631        # Central glow: bright enough to illuminate nearby floor
632        light = PointLight3D(name="AnemLight", position=Vec3(0, 0.6, 0))
633        light.colour = c[:3]
634        light.intensity = 2.0
635        light.range = 7.0
636        self.add_child(light)
637
638        col = CollisionShape3D(shape=SphereShape3D(radius=0.5), pickable=True, name="AnemCol")
639        self.add_child(col)
640
641    def on_update(self, dt: float):
642        self._time += dt
643        if self._retract > 0:
644            self._retract = max(0, self._retract - dt * 0.8)
645
646        retract_s = 1.0 - self._retract * 0.6
647        for i, tent in enumerate(self._tentacles):
648            # Gentle swaying: each tentacle has unique phase
649            phase = i * 0.35
650            sway_x = math.sin(self._time * 0.7 + phase) * 0.1 * retract_s
651            sway_z = math.cos(self._time * 0.5 + phase * 1.3) * 0.08 * retract_s
652            # Keep the base outward lean and add sway on top
653            base_lean = 0.15
654            tent.rotation = Quat.from_euler(base_lean + sway_x, 0, sway_z)
655            tent.scale = Vec3(retract_s, retract_s, retract_s)
656
657    def on_picked(self, event):
658        self._retract = 1.0
659        self.creature_clicked.emit("Anemone")