afterglow/sim/entities.py¶
Part of Afterglow.
1"""Afterglow entities + the Player controller (pure Python, GPU-free).
2
3All entities are plain classes (not Node subclasses) so the whole sim unit-tests
4with zero engine deps. Coordinates are logical pixels, y-DOWN; the Player tracks
5a top-left position plus a hitbox offset, matching the Celeste-port shape.
6
7The controller reuses the proven feel of the reference port (appr-based accel,
8coyote + jump-buffer, variable jump height, a fixed-duration air dash with an
9accel curve) retuned to our 8px tile world. All collision goes through the
10owning Room via the small protocol the Room provides to ``update``.
11"""
12
13from __future__ import annotations
14
15import math
16
17# -- tuning (logical px, seconds; the Room steps at a fixed 60 Hz) -----------
18
19GRAVITY = 900.0 # px/s^2, normal fall
20GRAVITY_APEX = 450.0 # softer gravity near the apex for a floaty, readable arc
21APEX_SPEED = 40.0 # |vy| below this counts as "near apex"
22MAX_FALL = 320.0 # terminal fall speed
23WALL_SLIDE_FALL = 96.0 # capped fall while pressing into a wall
24
25RUN_SPEED = 110.0
26GROUND_ACCEL = 900.0
27AIR_ACCEL = 600.0
28GROUND_FRICTION = 1100.0
29AIR_FRICTION = 200.0
30
31JUMP_SPEED = 300.0 # initial upward speed of a full jump
32JUMP_CUT = 0.45 # releasing jump early scales remaining upward speed by this
33COYOTE_TIME = 0.10
34JUMP_BUFFER = 0.10
35
36WALL_JUMP_X = 130.0
37WALL_JUMP_Y = 290.0
38
39DASH_SPEED = 320.0
40DASH_TIME = 0.13 # fixed dash duration
41DASH_END_SPEED = 130.0 # speed the dash decays toward over its duration
42GLOW_FROM_ORB = 4.0 # seconds of glow granted by a glow orb
43CRYSTAL_BOOST = 1.18 # speed multiplier when shattering a crystal
44PICKUP_PAD = 3.0 # px the shard/orb grab box grows past its 8px sprite so near-misses count
45
46_INV_SQRT2 = 1.0 / math.sqrt(2.0)
47
48
49def appr(val: float, target: float, amt: float) -> float:
50 """Move ``val`` toward ``target`` by at most ``amt`` (the port's helper)."""
51 return max(val - amt, target) if val > target else min(val + amt, target)
52
53
54def sign(x: float) -> int:
55 return 1 if x > 0 else -1 if x < 0 else 0
56
57
58# -- entities ----------------------------------------------------------------
59
60
61class Entity:
62 """Base: axis-aligned box at top-left (x, y) with size (w, h)."""
63
64 __slots__ = ("x", "y", "w", "h", "kind")
65
66 def __init__(self, x: float, y: float, w: float, h: float, kind: str):
67 self.x = float(x)
68 self.y = float(y)
69 self.w = float(w)
70 self.h = float(h)
71 self.kind = kind
72
73 @property
74 def cx(self) -> float:
75 return self.x + self.w * 0.5
76
77 @property
78 def cy(self) -> float:
79 return self.y + self.h * 0.5
80
81 def overlaps(self, other: Entity) -> bool:
82 return (
83 self.x < other.x + other.w
84 and self.x + self.w > other.x
85 and self.y < other.y + other.h
86 and self.y + self.h > other.y
87 )
88
89 def overlaps_padded(self, other: Entity, pad: float) -> bool:
90 """Overlap test with ``other``'s box grown by ``pad`` px on every side.
91
92 Used for touch-collect pickups (shards, orbs) so a near-miss still counts:
93 the visual is a small star/orb but the grab box is forgivingly larger.
94 """
95 return (
96 self.x < other.x + other.w + pad
97 and self.x + self.w > other.x - pad
98 and self.y < other.y + other.h + pad
99 and self.y + self.h > other.y - pad
100 )
101
102
103class Crystal(Entity):
104 """Resonance crystal: dashing through it shatters it and refills the dash."""
105
106 __slots__ = ("alive",)
107
108 def __init__(self, x: float, y: float):
109 super().__init__(x, y, 8, 8, "crystal")
110 self.alive = True
111
112
113class GlowOrb(Entity):
114 """Glow orb: touching it grants glow; respawns after a cooldown."""
115
116 __slots__ = ("available", "respawn")
117 RESPAWN_TIME = 3.0
118
119 def __init__(self, x: float, y: float):
120 super().__init__(x, y, 8, 8, "orb")
121 self.available = True
122 self.respawn = 0.0
123
124 def tick(self, dt: float) -> None:
125 if not self.available:
126 self.respawn -= dt
127 if self.respawn <= 0.0:
128 self.available = True
129 self.respawn = 0.0
130
131 def consume(self) -> None:
132 self.available = False
133 self.respawn = self.RESPAWN_TIME
134
135
136class LightGate(Entity):
137 """Light-gate: a solid that exists only while the player is glowing.
138
139 ``phased`` is set while the player is standing inside a just-solidified gate
140 so collision treats it as passable until the player steps clear: otherwise a
141 gate that hardens around the player would trap them.
142 """
143
144 __slots__ = ("solid", "phased")
145
146 def __init__(self, x: float, y: float):
147 super().__init__(x, y, 8, 8, "gate")
148 self.solid = False
149 self.phased = False
150
151
152class Spring(Entity):
153 """Spring: launches the player upward on contact."""
154
155 __slots__ = ("cooldown",)
156 LAUNCH_SPEED = 420.0
157
158 def __init__(self, x: float, y: float):
159 super().__init__(x, y + 4, 8, 4, "spring")
160 self.cooldown = 0.0
161
162 def tick(self, dt: float) -> None:
163 if self.cooldown > 0.0:
164 self.cooldown -= dt
165
166
167class Shard(Entity):
168 """Hidden collectible shard: sets room.shard_collected on pickup."""
169
170 __slots__ = ("collected",)
171
172 def __init__(self, x: float, y: float):
173 super().__init__(x, y, 8, 8, "shard")
174 self.collected = False
175
176
177class MovingPlatform(Entity):
178 """Path-driven solid: oscillates and acts as a moving solid for collision."""
179
180 __slots__ = ("anchor_x", "anchor_y", "range_px", "speed", "_t", "dx", "dy")
181
182 def __init__(self, x: float, y: float):
183 super().__init__(x, y, 16, 4, "platform")
184 self.anchor_x = float(x)
185 self.anchor_y = float(y)
186 self.range_px = 32.0
187 self.speed = 1.2 # radians/s of the oscillation
188 self._t = 0.0
189 self.dx = 0.0
190 self.dy = 0.0
191
192 def tick(self, dt: float) -> None:
193 self._t += dt * self.speed
194 nx = self.anchor_x + math.sin(self._t) * self.range_px
195 self.dx = nx - self.x
196 self.dy = 0.0
197 self.x = nx
198
199
200class Player(Entity):
201 """The Wisp. Top-left position + hitbox; full precision-platformer feel."""
202
203 __slots__ = (
204 "vx",
205 "vy",
206 "facing",
207 "on_ground",
208 "on_wall",
209 "state",
210 "dash_charges",
211 "glow_timer",
212 "alive",
213 "_coyote",
214 "_buffer",
215 "_dash_time",
216 "_dash_vx",
217 "_dash_vy",
218 "_prev_jump",
219 "_prev_dash",
220 "_jump_active",
221 )
222
223 MAX_DASH = 1
224
225 def __init__(self, x: float, y: float):
226 super().__init__(x, y, 6, 8, "player")
227 self.vx = 0.0
228 self.vy = 0.0
229 self.facing = 1
230 self.on_ground = False
231 self.on_wall = 0
232 self.state = "idle"
233 self.dash_charges = self.MAX_DASH
234 self.glow_timer = 0.0
235 self.alive = True
236 self._coyote = 0.0
237 self._buffer = 0.0
238 self._dash_time = 0.0
239 self._dash_vx = 0.0
240 self._dash_vy = 0.0
241 self._prev_jump = False
242 self._prev_dash = False
243 self._jump_active = False
244
245 @property
246 def glowing(self) -> bool:
247 return self.glow_timer > 0.0
248
249 def refill_dash(self) -> None:
250 self.dash_charges = self.MAX_DASH
251
252 # -- controller ----------------------------------------------------------
253
254 def update(self, dt: float, inp, room) -> None:
255 """Advance one fixed tick. ``room`` supplies collision + event hooks."""
256 if self.glow_timer > 0.0:
257 self.glow_timer = max(0.0, self.glow_timer - dt)
258
259 jump_press = inp.jump_pressed and not self._prev_jump
260 dash_press = inp.dash_pressed and not self._prev_dash
261 self._prev_jump = inp.jump_pressed
262 self._prev_dash = inp.dash_pressed
263
264 was_ground = self.on_ground
265 self.on_ground = room.solid_below(self)
266 self.on_wall = room.wall_dir(self)
267
268 if self.on_ground:
269 self._coyote = COYOTE_TIME
270 self.refill_dash()
271 if not was_ground and self.vy > 0.0:
272 room.emit("land", self.cx, self.y + self.h)
273 else:
274 self._coyote = max(0.0, self._coyote - dt)
275
276 if jump_press:
277 self._buffer = JUMP_BUFFER
278 else:
279 self._buffer = max(0.0, self._buffer - dt)
280
281 if self._dash_time > 0.0:
282 self._update_dash(dt, room)
283 else:
284 # One dash per press. Dashing through a crystal refills the charge
285 # (Room._dash_shatter -> refill_dash); pressing dash again uses it.
286 if dash_press and self.dash_charges > 0:
287 self._start_dash(inp, room)
288 else:
289 self._update_walk(dt, inp)
290 self._try_jump(inp, room)
291
292 if inp.move_x != 0.0 and self._dash_time <= 0.0:
293 self.facing = 1 if inp.move_x > 0.0 else -1
294
295 room.move_and_collide(self, self.vx * dt, self.vy * dt)
296 self._update_state(inp)
297
298 def _update_walk(self, dt: float, inp) -> None:
299 accel = GROUND_ACCEL if self.on_ground else AIR_ACCEL
300 if inp.move_x != 0.0:
301 self.vx = appr(self.vx, inp.move_x * RUN_SPEED, accel * dt)
302 else:
303 friction = GROUND_FRICTION if self.on_ground else AIR_FRICTION
304 self.vx = appr(self.vx, 0.0, friction * dt)
305
306 # Variable jump height: cut upward speed when jump is released early.
307 if self._jump_active and not inp.jump_held and self.vy < 0.0:
308 self.vy *= JUMP_CUT
309 self._jump_active = False
310
311 pushing_wall = self.on_wall != 0 and sign(inp.move_x) == self.on_wall
312 wall_sliding = pushing_wall and not self.on_ground and self.vy > 0.0
313
314 g = GRAVITY_APEX if abs(self.vy) < APEX_SPEED else GRAVITY
315 if not self.on_ground:
316 cap = WALL_SLIDE_FALL if wall_sliding else MAX_FALL
317 self.vy = min(self.vy + g * dt, cap)
318 elif self.vy > 0.0:
319 self.vy = 0.0
320
321 def _try_jump(self, inp, room) -> None:
322 if self._buffer <= 0.0:
323 return
324 if self._coyote > 0.0:
325 self.vy = -JUMP_SPEED
326 self._jump_active = True
327 self._buffer = 0.0
328 self._coyote = 0.0
329 room.emit("jump")
330 elif self.on_wall != 0:
331 self.vy = -WALL_JUMP_Y
332 self.vx = -self.on_wall * WALL_JUMP_X
333 self.facing = -self.on_wall
334 self._jump_active = True
335 self._buffer = 0.0
336 room.emit("jump")
337
338 def _start_dash(self, inp, room) -> None:
339 dx, dy = inp.move_x, inp.move_y
340 if dx == 0.0 and dy == 0.0:
341 dx, dy = float(self.facing), 0.0
342 mag = math.hypot(dx, dy)
343 ux, uy = dx / mag, dy / mag
344 self.dash_charges -= 1
345 self._dash_time = DASH_TIME
346 self._dash_vx = ux * DASH_SPEED
347 self._dash_vy = uy * DASH_SPEED
348 self.vx = self._dash_vx
349 self.vy = self._dash_vy
350 self._jump_active = False
351 # (cx, cy) keeps the event's positional contract; (ux, uy) is the extra
352 # payload the trail needs to know which way the burst streams.
353 room.emit("dash", self.cx, self.cy, ux, uy)
354
355 def _update_dash(self, dt: float, room) -> None:
356 self._dash_time -= dt
357 # Decay dash velocity toward a lower end speed over the dash window.
358 ex = sign(self._dash_vx) * DASH_END_SPEED if self._dash_vx else 0.0
359 ey = sign(self._dash_vy) * DASH_END_SPEED if self._dash_vy else 0.0
360 rate = (DASH_SPEED - DASH_END_SPEED) / DASH_TIME
361 self.vx = appr(self.vx, ex, rate * dt)
362 self.vy = appr(self.vy, ey, rate * dt)
363 if self._dash_time <= 0.0:
364 self._dash_time = 0.0
365
366 def _update_state(self, inp) -> None:
367 if self._dash_time > 0.0:
368 self.state = "dash"
369 elif not self.on_ground:
370 if self.on_wall != 0 and sign(inp.move_x) == self.on_wall and self.vy > 0.0:
371 self.state = "wallslide"
372 else:
373 self.state = "jump" if self.vy < 0.0 else "fall"
374 elif abs(self.vx) > 1.0:
375 self.state = "run"
376 else:
377 self.state = "idle"