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