nodes/mob.pyΒΆ

Part of Squash the Creeps.

  1"""Mob: chases the player; dies when squashed or off-screen.
  2
  3Mobs spawn at a random point on the arena boundary, pick a velocity
  4roughly aimed at the player (with a +/- 45-degree wobble) and march in
  5a straight line until either the player squashes them from above or
  6they wander past the arena's despawn radius.
  7
  8A mob is a scripted walker rather than a simulated one: it carries a
  9collider so the player's overlap query can find it, but it masks nothing,
 10so it never blocks the player or another mob.
 11"""
 12
 13from __future__ import annotations
 14
 15import math
 16import random
 17
 18from simvx.core import (
 19    CharacterBody3D,
 20    Material,
 21    Mesh,
 22    MeshInstance3D,
 23    Node3D,
 24    Property,
 25    Quat,
 26    Signal,
 27    SphereShape3D,
 28    Vec3,
 29)
 30
 31from .arena import LAYER_MOB
 32
 33MOB_RADIUS = 0.7
 34MOB_HEIGHT = 1.0
 35
 36
 37class Mob(CharacterBody3D):
 38    """Walking creep."""
 39
 40    min_speed = Property(10.0, range=(1, 50), hint="Minimum chase speed (m/s)")
 41    max_speed = Property(18.0, range=(1, 50), hint="Maximum chase speed (m/s)")
 42
 43    squashed = Signal()
 44
 45    def __init__(self, **kwargs):
 46        super().__init__(
 47            shape=SphereShape3D(radius=MOB_RADIUS),
 48            collision_layer=LAYER_MOB,
 49            collision_mask=0,
 50            **kwargs,
 51        )
 52        self.add_to_group("mob")
 53        self._dying = False
 54
 55        body_mat = Material(colour=(0.55, 0.25, 0.85, 1.0), roughness=0.4, metallic=0.0)
 56        eye_mat = Material(colour=(1.0, 0.95, 0.2, 1.0), roughness=0.2, metallic=0.0)
 57
 58        # A pivot lets us rotate the visual around the body's centre while
 59        # the collider stays axis-aligned.
 60        self._pivot = self.add_child(Node3D(name="MobPivot"))
 61
 62        self._body_mesh = self._pivot.add_child(
 63            MeshInstance3D(
 64                name="Body",
 65                mesh=Mesh.cylinder(radius=MOB_RADIUS, height=MOB_HEIGHT, segments=18),
 66                material=body_mat,
 67                position=Vec3(0, MOB_HEIGHT * 0.5, 0),
 68            )
 69        )
 70        self._head_mesh = self._pivot.add_child(
 71            MeshInstance3D(
 72                name="Head",
 73                mesh=Mesh.sphere(radius=MOB_RADIUS * 0.85, rings=12, segments=16),
 74                material=body_mat,
 75                position=Vec3(0, MOB_HEIGHT + MOB_RADIUS * 0.3, 0),
 76            )
 77        )
 78        # Bright yellow eyes: give the mob a visible facing.
 79        eye_y = MOB_HEIGHT + MOB_RADIUS * 0.45
 80        self._pivot.add_child(
 81            MeshInstance3D(
 82                name="EyeL",
 83                mesh=Mesh.sphere(radius=0.1, rings=6, segments=10),
 84                material=eye_mat,
 85                position=Vec3(-0.22, eye_y, -MOB_RADIUS * 0.55),
 86            )
 87        )
 88        self._pivot.add_child(
 89            MeshInstance3D(
 90                name="EyeR",
 91                mesh=Mesh.sphere(radius=0.1, rings=6, segments=10),
 92                material=eye_mat,
 93                position=Vec3(0.22, eye_y, -MOB_RADIUS * 0.55),
 94            )
 95        )
 96
 97        self._anim_time = random.uniform(0.0, math.pi * 2)
 98        self._wobble_speed = 1.0
 99
100    def initialize(self, start_position: Vec3, player_position: Vec3) -> None:
101        """Aim at the player from start_position with a randomised speed.
102
103        Mirrors ``Mob.gd::initialize``: ignore the player's Y, jitter heading
104        by +/- 45 degrees, scale walk animation rate with chosen speed.
105        """
106        target = Vec3(player_position.x, start_position.y, player_position.z)
107        diff = target - start_position
108        yaw = 0.0 if diff.length() < 1e-4 else math.atan2(diff.x, diff.z)
109        yaw += random.uniform(-math.pi / 4, math.pi / 4)
110
111        self.position = start_position
112        self._pivot.rotation = Quat.from_euler(0.0, yaw, 0.0)
113
114        speed = random.uniform(self.min_speed, self.max_speed)
115        # Forward = direction yaw points along XZ.
116        forward = Vec3(math.sin(yaw), 0.0, math.cos(yaw))
117        self.velocity = forward * speed
118        self._wobble_speed = max(speed / max(self.min_speed, 1e-3), 1.0)
119
120    def on_fixed_update(self, dt: float):
121        if self._dying:
122            return
123        self.position = Vec3(
124            self.position.x + self.velocity.x * dt,
125            self.position.y,
126            self.position.z + self.velocity.z * dt,
127        )
128        self._anim_time += dt * self._wobble_speed * 6.0
129        bob = math.sin(self._anim_time) * 0.08
130        self._body_mesh.position = Vec3(0, MOB_HEIGHT * 0.5 + bob, 0)
131        self._head_mesh.position = Vec3(0, MOB_HEIGHT + MOB_RADIUS * 0.3 + bob, 0)
132
133    def squash(self) -> None:
134        if self._dying:
135            return
136        self._dying = True
137        self.squashed.emit()
138        self.destroy()