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)