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 _state: tuple[str, ...] = ("x", "y", "w", "h", "kind") # every slot down the class chain
67
68 def __init_subclass__(cls, **kwargs) -> None:
69 super().__init_subclass__(**kwargs)
70 cls._state = tuple(name for klass in reversed(cls.__mro__) for name in getattr(klass, "__slots__", ()))
71
72 def __copy__(self) -> Entity:
73 # Every slot holds a plain scalar, so a shallow copy is a full, independent one.
74 clone = object.__new__(type(self))
75 for name in self._state:
76 setattr(clone, name, getattr(self, name))
77 return clone
78
79 def __deepcopy__(self, memo: dict) -> Entity:
80 return self.__copy__()
81
82 def __init__(self, x: float, y: float, w: float, h: float, kind: str):
83 self.x = float(x)
84 self.y = float(y)
85 self.w = float(w)
86 self.h = float(h)
87 self.kind = kind
88
89 @property
90 def cx(self) -> float:
91 return self.x + self.w * 0.5
92
93 @property
94 def cy(self) -> float:
95 return self.y + self.h * 0.5
96
97 def overlaps(self, other: Entity) -> bool:
98 return (
99 self.x < other.x + other.w
100 and self.x + self.w > other.x
101 and self.y < other.y + other.h
102 and self.y + self.h > other.y
103 )
104
105 def overlaps_padded(self, other: Entity, pad: float) -> bool:
106 """Overlap test with ``other``'s box grown by ``pad`` px on every side.
107
108 Used for touch-collect pickups (shards, orbs) so a near-miss still counts:
109 the visual is a small star/orb but the grab box is forgivingly larger.
110 """
111 return (
112 self.x < other.x + other.w + pad
113 and self.x + self.w > other.x - pad
114 and self.y < other.y + other.h + pad
115 and self.y + self.h > other.y - pad
116 )
117
118
119class Crystal(Entity):
120 """Resonance crystal: dashing through it shatters it and refills the dash."""
121
122 __slots__ = ("alive",)
123
124 def __init__(self, x: float, y: float):
125 super().__init__(x, y, 8, 8, "crystal")
126 self.alive = True
127
128
129class GlowOrb(Entity):
130 """Glow orb: touching it grants glow; respawns after a cooldown."""
131
132 __slots__ = ("available", "respawn")
133 RESPAWN_TIME = 3.0
134
135 def __init__(self, x: float, y: float):
136 super().__init__(x, y, 8, 8, "orb")
137 self.available = True
138 self.respawn = 0.0
139
140 def tick(self, dt: float) -> None:
141 if not self.available:
142 self.respawn -= dt
143 if self.respawn <= 0.0:
144 self.available = True
145 self.respawn = 0.0
146
147 def consume(self) -> None:
148 self.available = False
149 self.respawn = self.RESPAWN_TIME
150
151
152class LightGate(Entity):
153 """Light-gate: a solid that exists only while the player is glowing.
154
155 ``phased`` is set while the player is standing inside a just-solidified gate
156 so collision treats it as passable until the player steps clear: otherwise a
157 gate that hardens around the player would trap them.
158 """
159
160 __slots__ = ("solid", "phased")
161
162 def __init__(self, x: float, y: float):
163 super().__init__(x, y, 8, 8, "gate")
164 self.solid = False
165 self.phased = False
166
167
168class Spring(Entity):
169 """Spring: launches the player upward on contact."""
170
171 __slots__ = ("cooldown",)
172 LAUNCH_SPEED = 420.0
173
174 def __init__(self, x: float, y: float):
175 super().__init__(x, y + 4, 8, 4, "spring")
176 self.cooldown = 0.0
177
178 def tick(self, dt: float) -> None:
179 if self.cooldown > 0.0:
180 self.cooldown -= dt
181
182
183class Shard(Entity):
184 """Hidden collectible shard: sets room.shard_collected on pickup."""
185
186 __slots__ = ("collected",)
187
188 def __init__(self, x: float, y: float):
189 super().__init__(x, y, 8, 8, "shard")
190 self.collected = False
191
192
193class MovingPlatform(Entity):
194 """Path-driven solid: oscillates and acts as a moving solid for collision."""
195
196 __slots__ = ("anchor_x", "anchor_y", "range_px", "speed", "_t", "dx", "dy")
197
198 def __init__(self, x: float, y: float):
199 super().__init__(x, y, 16, 4, "platform")
200 self.anchor_x = float(x)
201 self.anchor_y = float(y)
202 self.range_px = 32.0
203 self.speed = 1.2 # radians/s of the oscillation
204 self._t = 0.0
205 self.dx = 0.0
206 self.dy = 0.0
207
208 def tick(self, dt: float) -> None:
209 self._t += dt * self.speed
210 nx = self.anchor_x + math.sin(self._t) * self.range_px
211 self.dx = nx - self.x
212 self.dy = 0.0
213 self.x = nx
214
215
216class Player(Entity):
217 """The Wisp. Top-left position + hitbox; full precision-platformer feel."""
218
219 __slots__ = (
220 "vx",
221 "vy",
222 "facing",
223 "on_ground",
224 "on_wall",
225 "state",
226 "dash_charges",
227 "glow_timer",
228 "alive",
229 "_coyote",
230 "_buffer",
231 "_dash_time",
232 "_dash_vx",
233 "_dash_vy",
234 "_prev_jump",
235 "_prev_dash",
236 "_jump_active",
237 )
238
239 MAX_DASH = 1
240
241 def __init__(self, x: float, y: float):
242 super().__init__(x, y, 6, 8, "player")
243 self.vx = 0.0
244 self.vy = 0.0
245 self.facing = 1
246 self.on_ground = False
247 self.on_wall = 0
248 self.state = "idle"
249 self.dash_charges = self.MAX_DASH
250 self.glow_timer = 0.0
251 self.alive = True
252 self._coyote = 0.0
253 self._buffer = 0.0
254 self._dash_time = 0.0
255 self._dash_vx = 0.0
256 self._dash_vy = 0.0
257 self._prev_jump = False
258 self._prev_dash = False
259 self._jump_active = False
260
261 @property
262 def glowing(self) -> bool:
263 return self.glow_timer > 0.0
264
265 def refill_dash(self) -> None:
266 self.dash_charges = self.MAX_DASH
267
268 # -- controller ----------------------------------------------------------
269
270 def update(self, dt: float, inp, room) -> None:
271 """Advance one fixed tick. ``room`` supplies collision + event hooks."""
272 if self.glow_timer > 0.0:
273 self.glow_timer = max(0.0, self.glow_timer - dt)
274
275 jump_press = inp.jump_pressed and not self._prev_jump
276 dash_press = inp.dash_pressed and not self._prev_dash
277 self._prev_jump = inp.jump_pressed
278 self._prev_dash = inp.dash_pressed
279
280 was_ground = self.on_ground
281 self.on_ground = room.solid_below(self)
282 self.on_wall = room.wall_dir(self)
283
284 if self.on_ground:
285 self._coyote = COYOTE_TIME
286 self.refill_dash()
287 if not was_ground and self.vy > 0.0:
288 room.emit("land", self.cx, self.y + self.h)
289 else:
290 self._coyote = max(0.0, self._coyote - dt)
291
292 if jump_press:
293 self._buffer = JUMP_BUFFER
294 else:
295 self._buffer = max(0.0, self._buffer - dt)
296
297 if self._dash_time > 0.0:
298 self._update_dash(dt, room)
299 else:
300 # One dash per press. Dashing through a crystal refills the charge
301 # (Room._dash_shatter -> refill_dash); pressing dash again uses it.
302 if dash_press and self.dash_charges > 0:
303 self._start_dash(inp, room)
304 else:
305 self._update_walk(dt, inp)
306 self._try_jump(inp, room)
307
308 if inp.move_x != 0.0 and self._dash_time <= 0.0:
309 self.facing = 1 if inp.move_x > 0.0 else -1
310
311 room.move_and_collide(self, self.vx * dt, self.vy * dt)
312 self._update_state(inp)
313
314 def _update_walk(self, dt: float, inp) -> None:
315 accel = GROUND_ACCEL if self.on_ground else AIR_ACCEL
316 if inp.move_x != 0.0:
317 self.vx = appr(self.vx, inp.move_x * RUN_SPEED, accel * dt)
318 else:
319 friction = GROUND_FRICTION if self.on_ground else AIR_FRICTION
320 self.vx = appr(self.vx, 0.0, friction * dt)
321
322 # Variable jump height: cut upward speed when jump is released early.
323 if self._jump_active and not inp.jump_held and self.vy < 0.0:
324 self.vy *= JUMP_CUT
325 self._jump_active = False
326
327 pushing_wall = self.on_wall != 0 and sign(inp.move_x) == self.on_wall
328 wall_sliding = pushing_wall and not self.on_ground and self.vy > 0.0
329
330 g = GRAVITY_APEX if abs(self.vy) < APEX_SPEED else GRAVITY
331 if not self.on_ground:
332 cap = WALL_SLIDE_FALL if wall_sliding else MAX_FALL
333 self.vy = min(self.vy + g * dt, cap)
334 elif self.vy > 0.0:
335 self.vy = 0.0
336
337 def _try_jump(self, inp, room) -> None:
338 if self._buffer <= 0.0:
339 return
340 if self._coyote > 0.0:
341 self.vy = -JUMP_SPEED
342 self._jump_active = True
343 self._buffer = 0.0
344 self._coyote = 0.0
345 room.emit("jump")
346 elif self.on_wall != 0:
347 self.vy = -WALL_JUMP_Y
348 self.vx = -self.on_wall * WALL_JUMP_X
349 self.facing = -self.on_wall
350 self._jump_active = True
351 self._buffer = 0.0
352 room.emit("jump")
353
354 def _start_dash(self, inp, room) -> None:
355 dx, dy = inp.move_x, inp.move_y
356 if dx == 0.0 and dy == 0.0:
357 dx, dy = float(self.facing), 0.0
358 mag = math.hypot(dx, dy)
359 ux, uy = dx / mag, dy / mag
360 self.dash_charges -= 1
361 self._dash_time = DASH_TIME
362 self._dash_vx = ux * DASH_SPEED
363 self._dash_vy = uy * DASH_SPEED
364 self.vx = self._dash_vx
365 self.vy = self._dash_vy
366 self._jump_active = False
367 # (cx, cy) keeps the event's positional contract; (ux, uy) is the extra
368 # payload the trail needs to know which way the burst streams.
369 room.emit("dash", self.cx, self.cy, ux, uy)
370
371 def _update_dash(self, dt: float, room) -> None:
372 self._dash_time -= dt
373 # Decay dash velocity toward a lower end speed over the dash window.
374 ex = sign(self._dash_vx) * DASH_END_SPEED if self._dash_vx else 0.0
375 ey = sign(self._dash_vy) * DASH_END_SPEED if self._dash_vy else 0.0
376 rate = (DASH_SPEED - DASH_END_SPEED) / DASH_TIME
377 self.vx = appr(self.vx, ex, rate * dt)
378 self.vy = appr(self.vy, ey, rate * dt)
379 if self._dash_time <= 0.0:
380 self._dash_time = 0.0
381
382 def _update_state(self, inp) -> None:
383 if self._dash_time > 0.0:
384 self.state = "dash"
385 elif not self.on_ground:
386 if self.on_wall != 0 and sign(inp.move_x) == self.on_wall and self.vy > 0.0:
387 self.state = "wallslide"
388 else:
389 self.state = "jump" if self.vy < 0.0 else "fall"
390 elif abs(self.vx) > 1.0:
391 self.state = "run"
392 else:
393 self.state = "idle"