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)