nodes/player.pyΒΆ

Part of Squash the Creeps.

  1"""Player: a CharacterBody3D steered by keys, stick or the drawn pad, with jump and bounce.
  2
  3Mirrors the upstream Godot player. One typed ``move`` action steers on the XZ
  4plane, gravity accelerates the body downwards every physics tick, jump adds an
  5upward impulse, and landing on a creep from above squashes it and bounces the
  6player back up. Motion goes through ``CharacterBody3D.move_and_slide``, so the
  7arena'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    Node3D,
 22    Property,
 23    Quat,
 24    Signal,
 25    SphereShape3D,
 26    Vec3,
 27)
 28
 29from .arena import GROUND_Y, LAYER_GROUND, LAYER_MOB, LAYER_PLAYER
 30from .controls import 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
 40class Player(CharacterBody3D):
 41    """Player character.
 42
 43    Sits on ``LAYER_PLAYER`` and masks only ``LAYER_GROUND``, so the
 44    collide-and-slide sweep is blocked by the arena floor and by nothing else:
 45    creeps are found deliberately, by an overlap query, instead of shoving the
 46    player around.
 47    """
 48
 49    #: The player brings its own controls, so a test that loads the player
 50    #: alone has them too.
 51    input_context = PLAYER
 52
 53    visible = Property(
 54        False,
 55        coerce=bool,
 56        hint="Whether this node and its subtree are drawn",
 57        on_change="_on_visible_changed",
 58    )
 59
 60    speed = Property(14.0, range=(1, 40), hint="Horizontal speed (m/s)")
 61    jump_impulse = Property(20.0, range=(5, 50), hint="Vertical impulse on jump (m/s)")
 62    bounce_impulse = Property(16.0, range=(5, 50), hint="Vertical impulse after squashing a mob (m/s)")
 63    fall_acceleration = Property(75.0, range=(1, 200), hint="Gravity acceleration (m/s^2)")
 64
 65    hit = Signal()
 66    squashed_mob = Signal()  # fires when this player lands on a mob
 67
 68    def __init__(self, **kwargs):
 69        super().__init__(
 70            shape=SphereShape3D(radius=PLAYER_RADIUS),
 71            collision_layer=LAYER_PLAYER,
 72            collision_mask=LAYER_GROUND,
 73            **kwargs,
 74        )
 75        self.add_to_group("player")
 76        self._facing_yaw = 0.0
 77        self._anim_time = 0.0
 78        self._dead = False
 79
 80        # Hidden and inert until the root starts a run.
 81        self.active = False
 82
 83        # A small pivot we tilt forwards/backwards to mimic the upstream
 84        # arc-on-jump rotation. Visual mesh is a child of the pivot so the
 85        # body collider stays axis-aligned.
 86        self._pivot = self.add_child(Node3D(name="Pivot"))
 87
 88        # Body: slightly elongated capsule-like cylinder with a head sphere.
 89        body_mat = Material(colour=(0.95, 0.85, 0.55, 1.0), roughness=0.55, metallic=0.0)
 90        head_mat = Material(colour=(0.95, 0.85, 0.55, 1.0), roughness=0.45, metallic=0.0)
 91        eye_mat = Material(colour=(0.05, 0.05, 0.08, 1.0), roughness=0.2, metallic=0.0)
 92
 93        self._body_mesh = self._pivot.add_child(
 94            MeshInstance3D(
 95                name="Body",
 96                mesh=Mesh.cylinder(radius=PLAYER_RADIUS, height=PLAYER_HEIGHT, segments=20),
 97                material=body_mat,
 98                position=Vec3(0, PLAYER_HEIGHT * 0.5, 0),
 99            )
100        )
101        self._head_mesh = self._pivot.add_child(
102            MeshInstance3D(
103                name="Head",
104                mesh=Mesh.sphere(radius=PLAYER_RADIUS * 0.85, rings=14, segments=18),
105                material=head_mat,
106                position=Vec3(0, PLAYER_HEIGHT + PLAYER_RADIUS * 0.4, 0),
107            )
108        )
109        # Eyes: give the player a clear "front" so rotation reads on screen.
110        eye_offset = PLAYER_RADIUS * 0.45
111        eye_y = PLAYER_HEIGHT + PLAYER_RADIUS * 0.55
112        self._pivot.add_child(
113            MeshInstance3D(
114                name="EyeL",
115                mesh=Mesh.sphere(radius=0.12, rings=6, segments=10),
116                material=eye_mat,
117                position=Vec3(-0.28, eye_y, -eye_offset),
118            )
119        )
120        self._pivot.add_child(
121            MeshInstance3D(
122                name="EyeR",
123                mesh=Mesh.sphere(radius=0.12, rings=6, segments=10),
124                material=eye_mat,
125                position=Vec3(0.28, eye_y, -eye_offset),
126            )
127        )
128
129        # Spawn one radius above the arena top so the sphere collider rests on the floor.
130        self.position = Vec3(0.0, GROUND_Y + PLAYER_RADIUS, 0.0)
131        self.velocity = Vec3()
132
133    # -- run control --------------------------------------------------------
134
135    def start(self) -> None:
136        """Reveal the player and begin taking input."""
137        self.active = True
138        self.visible = True
139
140    def die(self):
141        if self._dead:
142            return
143        self._dead = True
144        self.hit.emit()
145
146    @property
147    def is_dead(self) -> bool:
148        return self._dead
149
150    # -- simulation ---------------------------------------------------------
151
152    def on_fixed_update(self, dt: float):
153        if self._dead or not self.active:
154            return
155
156        # The camera is a fixed 45-degree isometric rig looking down -Z, so
157        # screen right is world +X and screen down is world +Z: the action's
158        # vector lands on the ground plane with no unprojection. Keys, a stick
159        # and the drawn pad all feed it, and the one furthest from rest wins.
160        move = Input.value("move")
161        direction = Vec3(move.x, 0.0, move.y)
162        if direction.length() > 1e-4:
163            direction = direction.normalized()
164            self._facing_yaw = math.atan2(direction.x, direction.z)
165            self._anim_time += dt * 12.0
166        else:
167            direction = Vec3()
168            self._anim_time += dt * 3.0
169
170        self.velocity.x = direction.x * self.speed
171        self.velocity.z = direction.z * self.speed
172
173        # A tick is handed the frame's edges on the frame's first tick, so a
174        # press fires once whatever the display and the tick rate are doing.
175        if Input.just_pressed("jump") and self.is_on_floor():
176            self.velocity.y = self.jump_impulse
177
178        self.velocity.y -= self.fall_acceleration * dt
179        self.move_and_slide(dt)
180
181        self._resolve_mob_contacts()
182        self._update_visual()
183
184    def _resolve_mob_contacts(self) -> None:
185        """Squash a creep landed on from above; any other contact is fatal.
186
187        The overlap query is masked to ``LAYER_MOB``, so neither the player's own
188        body nor the arena ground can answer it. Coming down onto a creep from
189        clear air squashes it and bounces, in the spirit of upstream's
190        ``dot(UP, collision.normal) > 0.1`` test; walking into one ends the run.
191        Only the first hit is resolved: a bounce or a death settles the tick.
192        """
193        query = self.physics
194        if query is None:
195            return
196        hits = query.overlap(self.shape, self.position, mask=LAYER_MOB)
197        if not hits:
198            return
199        mob = hits[0]
200        falling_from_above = self.velocity.y < 0.0 and self.position.y - mob.position.y > SQUASH_MIN_HEIGHT
201        if not falling_from_above:
202            self.die()
203            return
204        mob.squash()
205        self.velocity.y = self.bounce_impulse
206        self.squashed_mob.emit()
207
208    def _update_visual(self) -> None:
209        """Yaw towards the heading, tilt into the jump arc, bob while grounded."""
210        tilt = (math.pi / 6.0) * (self.velocity.y / max(self.jump_impulse, 1e-3))
211        self._pivot.rotation = Quat.from_euler(tilt, self._facing_yaw, 0.0)
212        bob = math.sin(self._anim_time) * 0.06 if self.is_on_floor() else 0.0
213        self._body_mesh.position = Vec3(0, PLAYER_HEIGHT * 0.5 + bob, 0)
214        self._head_mesh.position = Vec3(0, PLAYER_HEIGHT + PLAYER_RADIUS * 0.4 + bob, 0)