nodes/player.pyΒΆ

Part of Squash the Creeps.

  1"""Player: a CharacterBody3D steered by keyboard or pointer, with jump and bounce.
  2
  3Mirrors the upstream Godot player. WASD (or a held pointer drag) steers on the
  4XZ plane, gravity accelerates the body downwards every physics tick, jump adds
  5an upward impulse, and landing on a creep from above squashes it and bounces
  6the player back up. Motion goes through ``CharacterBody3D.move_and_slide``, so
  7the arena's static ground collider is what stops the fall, and creep contact is
  8found with a masked overlap query rather than a hand-rolled distance test.
  9"""
 10
 11from __future__ import annotations
 12
 13import math
 14
 15from simvx.core import (
 16    CharacterBody3D,
 17    Input,
 18    Material,
 19    Mesh,
 20    MeshInstance3D,
 21    MouseButton,
 22    Node3D,
 23    Property,
 24    Quat,
 25    Signal,
 26    SphereShape3D,
 27    Vec3,
 28)
 29
 30from .arena import GROUND_Y, LAYER_GROUND, LAYER_MOB, LAYER_PLAYER
 31
 32PLAYER_RADIUS = 0.8
 33PLAYER_HEIGHT = 1.6
 34
 35# Metres the player's centre must clear a creep's before a fall counts as a
 36# stomp rather than a fatal bump.
 37SQUASH_MIN_HEIGHT = 0.6
 38
 39# Pointer steering: pixels of drag before a held pointer starts steering, and
 40# the longest press that still counts as a tap (which jumps instead).
 41POINTER_DEAD_ZONE = 26.0
 42TAP_MAX_SECONDS = 0.25
 43
 44
 45class Player(CharacterBody3D):
 46    """Player character.
 47
 48    Sits on ``LAYER_PLAYER`` and masks only ``LAYER_GROUND``, so the
 49    collide-and-slide sweep is blocked by the arena floor and by nothing else:
 50    creeps are found deliberately, by an overlap query, instead of shoving the
 51    player around.
 52    """
 53
 54    visible = Property(
 55        False,
 56        coerce=bool,
 57        hint="Whether this node and its subtree are drawn",
 58        on_change="_on_visible_changed",
 59    )
 60
 61    speed = Property(14.0, range=(1, 40), hint="Horizontal speed (m/s)")
 62    jump_impulse = Property(20.0, range=(5, 50), hint="Vertical impulse on jump (m/s)")
 63    bounce_impulse = Property(16.0, range=(5, 50), hint="Vertical impulse after squashing a mob (m/s)")
 64    fall_acceleration = Property(75.0, range=(1, 200), hint="Gravity acceleration (m/s^2)")
 65
 66    hit = Signal()
 67    squashed_mob = Signal()  # fires when this player lands on a mob
 68
 69    def __init__(self, **kwargs):
 70        super().__init__(
 71            shape=SphereShape3D(radius=PLAYER_RADIUS),
 72            collision_layer=LAYER_PLAYER,
 73            collision_mask=LAYER_GROUND,
 74            **kwargs,
 75        )
 76        self.add_to_group("player")
 77        self._facing_yaw = 0.0
 78        self._anim_time = 0.0
 79        self._dead = False
 80
 81        # Input latched on the frame clock and consumed by the fixed step.
 82        self._move_input = Vec3()
 83        self._jump_queued = False
 84        self._pointer_origin = None
 85        self._pointer_held = 0.0
 86
 87        # Hidden and inert until the root starts a run.
 88        self.active = False
 89
 90        # A small pivot we tilt forwards/backwards to mimic the upstream
 91        # arc-on-jump rotation. Visual mesh is a child of the pivot so the
 92        # body collider stays axis-aligned.
 93        self._pivot = self.add_child(Node3D(name="Pivot"))
 94
 95        # Body: slightly elongated capsule-like cylinder with a head sphere.
 96        body_mat = Material(colour=(0.95, 0.85, 0.55, 1.0), roughness=0.55, metallic=0.0)
 97        head_mat = Material(colour=(0.95, 0.85, 0.55, 1.0), roughness=0.45, metallic=0.0)
 98        eye_mat = Material(colour=(0.05, 0.05, 0.08, 1.0), roughness=0.2, metallic=0.0)
 99
100        self._body_mesh = self._pivot.add_child(
101            MeshInstance3D(
102                name="Body",
103                mesh=Mesh.cylinder(radius=PLAYER_RADIUS, height=PLAYER_HEIGHT, segments=20),
104                material=body_mat,
105                position=Vec3(0, PLAYER_HEIGHT * 0.5, 0),
106            )
107        )
108        self._head_mesh = self._pivot.add_child(
109            MeshInstance3D(
110                name="Head",
111                mesh=Mesh.sphere(radius=PLAYER_RADIUS * 0.85, rings=14, segments=18),
112                material=head_mat,
113                position=Vec3(0, PLAYER_HEIGHT + PLAYER_RADIUS * 0.4, 0),
114            )
115        )
116        # Eyes: give the player a clear "front" so rotation reads on screen.
117        eye_offset = PLAYER_RADIUS * 0.45
118        eye_y = PLAYER_HEIGHT + PLAYER_RADIUS * 0.55
119        self._pivot.add_child(
120            MeshInstance3D(
121                name="EyeL",
122                mesh=Mesh.sphere(radius=0.12, rings=6, segments=10),
123                material=eye_mat,
124                position=Vec3(-0.28, eye_y, -eye_offset),
125            )
126        )
127        self._pivot.add_child(
128            MeshInstance3D(
129                name="EyeR",
130                mesh=Mesh.sphere(radius=0.12, rings=6, segments=10),
131                material=eye_mat,
132                position=Vec3(0.28, eye_y, -eye_offset),
133            )
134        )
135
136        # Spawn one radius above the arena top so the sphere collider rests on the floor.
137        self.position = Vec3(0.0, GROUND_Y + PLAYER_RADIUS, 0.0)
138        self.velocity = Vec3()
139
140    # -- run control --------------------------------------------------------
141
142    def start(self) -> None:
143        """Reveal the player and begin taking input."""
144        self.active = True
145        self.visible = True
146
147    def die(self):
148        if self._dead:
149            return
150        self._dead = True
151        self.hit.emit()
152
153    @property
154    def is_dead(self) -> bool:
155        return self._dead
156
157    # -- input --------------------------------------------------------------
158
159    def on_update(self, dt: float):
160        """Sample input on the frame clock and latch it for the fixed step.
161
162        ``just_pressed`` / ``just_released`` live for exactly one frame, while
163        ``on_fixed_update`` may run zero or several times per frame, so polling
164        the edges from the fixed step drops taps above 60 fps and repeats them
165        below it. Reading them here and latching keeps every press.
166        """
167        if self._dead or not self.active:
168            return
169        self._move_input = Vec3(
170            Input.get_axis("move_left", "move_right"),
171            0.0,
172            Input.get_axis("move_forward", "move_back"),
173        )
174        self._update_pointer(dt)
175        if Input.is_action_just_pressed("jump"):
176            self._jump_queued = True
177
178    def _update_pointer(self, dt: float) -> None:
179        """Hold and drag to steer, tap to jump.
180
181        The camera is a fixed 45-degree isometric rig looking down -Z, so screen
182        right is world +X and screen down is world +Z: a drag maps straight onto
183        the ground plane with no unprojection.
184        """
185        if Input.is_mouse_button_just_pressed(MouseButton.LEFT):
186            self._pointer_origin = Input.mouse_position
187            self._pointer_held = 0.0
188        if self._pointer_origin is None:
189            return
190
191        drag = Input.mouse_position - self._pointer_origin
192        if Input.is_mouse_button_pressed(MouseButton.LEFT):
193            self._pointer_held += dt
194            if drag.length() > POINTER_DEAD_ZONE:
195                self._move_input = Vec3(drag.x, 0.0, drag.y)
196        if Input.is_mouse_button_just_released(MouseButton.LEFT):
197            if self._pointer_held <= TAP_MAX_SECONDS and drag.length() <= POINTER_DEAD_ZONE:
198                self._jump_queued = True
199            self._pointer_origin = None
200
201    # -- simulation ---------------------------------------------------------
202
203    def on_fixed_update(self, dt: float):
204        if self._dead or not self.active:
205            return
206
207        direction = self._move_input
208        if direction.length() > 1e-4:
209            direction = direction.normalized()
210            self._facing_yaw = math.atan2(direction.x, direction.z)
211            self._anim_time += dt * 12.0
212        else:
213            direction = Vec3()
214            self._anim_time += dt * 3.0
215
216        self.velocity.x = direction.x * self.speed
217        self.velocity.z = direction.z * self.speed
218
219        if self._jump_queued:
220            self._jump_queued = False
221            if self.is_on_floor():
222                self.velocity.y = self.jump_impulse
223
224        self.velocity.y -= self.fall_acceleration * dt
225        self.move_and_slide(dt)
226
227        self._resolve_mob_contacts()
228        self._update_visual()
229
230    def _resolve_mob_contacts(self) -> None:
231        """Squash a creep landed on from above; any other contact is fatal.
232
233        The overlap query is masked to ``LAYER_MOB``, so neither the player's own
234        body nor the arena ground can answer it. Coming down onto a creep from
235        clear air squashes it and bounces, in the spirit of upstream's
236        ``dot(UP, collision.normal) > 0.1`` test; walking into one ends the run.
237        Only the first hit is resolved: a bounce or a death settles the tick.
238        """
239        query = self.physics
240        if query is None:
241            return
242        hits = query.overlap(self.shape, self.position, mask=LAYER_MOB)
243        if not hits:
244            return
245        mob = hits[0]
246        falling_from_above = self.velocity.y < 0.0 and self.position.y - mob.position.y > SQUASH_MIN_HEIGHT
247        if not falling_from_above:
248            self.die()
249            return
250        mob.squash()
251        self.velocity.y = self.bounce_impulse
252        self.squashed_mob.emit()
253
254    def _update_visual(self) -> None:
255        """Yaw towards the heading, tilt into the jump arc, bob while grounded."""
256        tilt = (math.pi / 6.0) * (self.velocity.y / max(self.jump_impulse, 1e-3))
257        self._pivot.rotation = Quat.from_euler(tilt, self._facing_yaw, 0.0)
258        bob = math.sin(self._anim_time) * 0.06 if self.is_on_floor() else 0.0
259        self._body_mesh.position = Vec3(0, PLAYER_HEIGHT * 0.5 + bob, 0)
260        self._head_mesh.position = Vec3(0, PLAYER_HEIGHT + PLAYER_RADIUS * 0.4 + bob, 0)