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"