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"