afterglow/sim/room.py¶

Part of Afterglow.

  1"""Room: ASCII parser, fixed-timestep step, swept tile collision, events.
  2
  3Pure Python + GPU-free + deterministic. The Room owns the static tile grid and
  4every entity, parses an authored ASCII grid into both, and drives the Player
  5controller through a 60 Hz fixed-timestep accumulator. It exposes a tiny
  6collision/event protocol to ``Player.update`` and a drainable event queue for
  7the view/audio layers.
  8
  9Mapping for the later 3D view (documented for the diorama layer):
 10  logical (x, y) px, y-DOWN  ->  world (x * S, -y * S, 0).
 11"""
 12
 13from __future__ import annotations
 14
 15from dataclasses import dataclass, field, replace
 16
 17from .entities import (
 18    CRYSTAL_BOOST,
 19    GLOW_FROM_ORB,
 20    PICKUP_PAD,
 21    Crystal,
 22    GlowOrb,
 23    LightGate,
 24    MovingPlatform,
 25    Player,
 26    Shard,
 27    Spring,
 28)
 29from .tiles import TILE_SIZE, is_hazard, is_solid
 30
 31FIXED_DT = 1.0 / 60.0
 32MAX_STEPS = 5  # cap catch-up so a long frame can't spiral the sim
 33
 34
 35@dataclass
 36class InputState:
 37    """One frame of intent. move_x/move_y in -1..1; move_y also aims the dash."""
 38
 39    move_x: float = 0.0
 40    move_y: float = 0.0
 41    jump_pressed: bool = False  # press EDGE (this frame), latched across sub-tick frames
 42    jump_held: bool = False
 43    dash_pressed: bool = False  # press EDGE (this frame), latched across sub-tick frames
 44
 45
 46# char -> entity factory; the grid cell is cleared to '.' after spawning.
 47_ENTITY_FACTORY = {
 48    "C": Crystal,
 49    "O": GlowOrb,
 50    "G": LightGate,
 51    "S": Spring,
 52    "*": Shard,
 53    "-": MovingPlatform,
 54}
 55
 56
 57@dataclass
 58class RoomDef:
 59    """Authored room: an ASCII grid (equal-length rows) + metadata."""
 60
 61    name: str
 62    palette: str  # world id used by the view layer for theming
 63    grid: list[str]
 64
 65
 66class Room:
 67    def __init__(self, room_def: RoomDef):
 68        self._def = room_def
 69        self.name = room_def.name
 70        self.palette = room_def.palette
 71        self.tile_size = TILE_SIZE
 72
 73        rows = [r.replace(" ", ".") for r in room_def.grid]
 74        self.h = len(rows)
 75        self.w = len(rows[0]) if rows else 0
 76
 77        self.grid: list[list[str]] = [list(r) for r in rows]
 78        self.entities: list = []
 79        self._spawn_x = 0.0
 80        self._spawn_y = 0.0
 81        self._exit: tuple[float, float] = (0.0, 0.0)
 82
 83        self._parse()
 84        self.player = Player(self._spawn_x, self._spawn_y)
 85
 86        self.won = False
 87        self.dead = False
 88        self.time = 0.0
 89        self.deaths = 0
 90        self.shard_collected = False
 91
 92        self._accum = 0.0
 93        self._pending_jump = False  # press edges seen since the last fixed tick
 94        self._pending_dash = False
 95        self._events: list[tuple] = []
 96
 97    # -- parsing -------------------------------------------------------------
 98
 99    def _parse(self) -> None:
100        for ty, row in enumerate(self.grid):
101            for tx, char in enumerate(row):
102                px, py = tx * TILE_SIZE, ty * TILE_SIZE
103                if char == "P":
104                    self._spawn_x, self._spawn_y = float(px), float(py)
105                    row[tx] = "."
106                elif char == "E":
107                    self._exit = (float(px), float(py))
108                    self.entities.append(_Exit(px, py))
109                    row[tx] = "."
110                elif char in _ENTITY_FACTORY:
111                    self.entities.append(_ENTITY_FACTORY[char](px, py))
112                    row[tx] = "."
113
114    # -- public stepping -----------------------------------------------------
115
116    def step(self, dt: float, inp: InputState) -> None:
117        """Advance by ``dt`` real seconds via a fixed 60 Hz accumulator.
118
119        Press edges (jump/dash) are latched across frames so a tap that lands
120        between two fixed ticks (common above 60 fps, where some frames run zero
121        ticks) still reaches the next tick. At exactly 60 fps this is a no-op:
122        every frame runs one tick that consumes the edge the same frame.
123        """
124        self._pending_jump = self._pending_jump or inp.jump_pressed
125        self._pending_dash = self._pending_dash or inp.dash_pressed
126        self._accum += dt
127        steps = 0
128        while self._accum >= FIXED_DT and steps < MAX_STEPS:
129            self._accum -= FIXED_DT
130            self._tick(replace(inp, jump_pressed=self._pending_jump, dash_pressed=self._pending_dash))
131            self._pending_jump = False
132            self._pending_dash = False
133            steps += 1
134        if steps == MAX_STEPS:
135            self._accum = 0.0  # drop the backlog instead of spiralling
136
137    def reset(self) -> None:
138        """Respawn at spawn. Deaths persist; time resets."""
139        self.player = Player(self._spawn_x, self._spawn_y)
140        self.time = 0.0
141        self.won = False
142        self.dead = False
143        self.shard_collected = False
144        self._accum = 0.0
145        self._events.clear()
146        for e in self.entities:
147            if isinstance(e, Crystal):
148                e.alive = True
149            elif isinstance(e, GlowOrb):
150                e.available = True
151                e.respawn = 0.0
152            elif isinstance(e, LightGate):
153                e.solid = False
154                e.phased = False
155            elif isinstance(e, Shard):
156                e.collected = False
157
158    def drain_events(self) -> list[tuple]:
159        out = self._events
160        self._events = []
161        return out
162
163    def emit(self, *event: object) -> None:
164        self._events.append(tuple(event))
165
166    # -- per-tick simulation -------------------------------------------------
167
168    def _tick(self, inp: InputState) -> None:
169        if self.won or self.dead:
170            return
171        self.time += FIXED_DT
172
173        for e in self.entities:
174            tick = getattr(e, "tick", None)
175            if tick is not None:
176                tick(FIXED_DT)
177            if isinstance(e, LightGate):
178                e.solid = self.player.glowing
179                # Don't trap the player: a gate that hardens while they overlap it
180                # stays passable until they step clear of it.
181                e.phased = e.solid and self.player.overlaps(e)
182
183        self.player.update(FIXED_DT, inp, self)
184        self._resolve_entity_touches()
185        self._check_hazards()
186
187    def _resolve_entity_touches(self) -> None:
188        p = self.player
189        for e in self.entities:
190            if isinstance(e, GlowOrb):
191                if e.available and p.overlaps_padded(e, PICKUP_PAD):
192                    e.consume()
193                    p.glow_timer = max(p.glow_timer, GLOW_FROM_ORB)
194                    self.emit("orb", e.cx, e.cy)
195            elif isinstance(e, Shard):
196                if not e.collected and p.overlaps_padded(e, PICKUP_PAD):
197                    e.collected = True
198                    self.shard_collected = True
199                    self.emit("shard", e.cx, e.cy)
200            elif isinstance(e, Spring):
201                if e.cooldown <= 0.0 and p.overlaps(e) and p.vy >= 0.0:
202                    p.vy = -Spring.LAUNCH_SPEED
203                    p.refill_dash()
204                    e.cooldown = 0.25
205                    self.emit("spring", e.cx, e.cy)
206            elif isinstance(e, _Exit):
207                if p.overlaps(e):
208                    self.won = True
209                    self.emit("win")
210
211    def _check_hazards(self) -> None:
212        p = self.player
213        # Spike tiles overlapping the hitbox, or falling out of bounds.
214        if self._hazard_overlap(p) or p.y > self.h * TILE_SIZE + TILE_SIZE:
215            self._die()
216
217    def _die(self) -> None:
218        p = self.player
219        self.emit("death", p.cx, p.cy)
220        self.deaths += 1
221        self.dead = True
222        # The Wisp stops being a live actor until ``reset`` spawns a fresh one;
223        # the view hides its sprite while the death burst plays.
224        p.alive = False
225
226    # -- collision protocol used by Player.update ----------------------------
227
228    def is_solid_at(self, px: float, py: float) -> bool:
229        """O(1) static-tile solid/hazard lookup at a logical pixel."""
230        tx = int(px // TILE_SIZE)
231        ty = int(py // TILE_SIZE)
232        if tx < 0 or ty < 0 or tx >= self.w or ty >= self.h:
233            return tx < 0 or tx >= self.w  # walls left/right are solid, top open
234        return is_solid(self.grid[ty][tx])
235
236    def _box_hits_solid(self, x: float, y: float, w: float, h: float) -> bool:
237        x0 = int(x // TILE_SIZE)
238        x1 = int((x + w - 1e-4) // TILE_SIZE)
239        y0 = int(y // TILE_SIZE)
240        y1 = int((y + h - 1e-4) // TILE_SIZE)
241        for ty in range(y0, y1 + 1):
242            for tx in range(x0, x1 + 1):
243                if tx < 0 or tx >= self.w:
244                    return True  # side walls
245                if ty < 0 or ty >= self.h:
246                    continue  # open top/bottom (bottom handled as a death plane)
247                if is_solid(self.grid[ty][tx]):
248                    return True
249        for e in self._active_gates():
250            if x < e.x + e.w and x + w > e.x and y < e.y + e.h and y + h > e.y:
251                return True
252        for e in self.entities:
253            if isinstance(e, MovingPlatform):
254                if x < e.x + e.w and x + w > e.x and y < e.y + e.h and y + h > e.y:
255                    return True
256        return False
257
258    def _active_gates(self):
259        return (e for e in self.entities if isinstance(e, LightGate) and e.solid and not e.phased)
260
261    def solid_below(self, p: Player) -> bool:
262        return self._box_hits_solid(p.x, p.y + p.h, p.w, 1.0)
263
264    def wall_dir(self, p: Player) -> int:
265        if self._box_hits_solid(p.x - 1.0, p.y, 1.0, p.h):
266            return -1
267        if self._box_hits_solid(p.x + p.w, p.y, 1.0, p.h):
268            return 1
269        return 0
270
271    def move_and_collide(self, p: Player, dx: float, dy: float) -> None:
272        """Swept per-axis movement, 1px steps, dash-through-crystal handling."""
273        self._step_axis(p, dx, 0.0)
274        self._step_axis(p, 0.0, dy)
275
276    def _step_axis(self, p: Player, dx: float, dy: float) -> None:
277        if dx == 0.0 and dy == 0.0:
278            return
279        remaining = dx if dx != 0.0 else dy
280        step = 1.0 if remaining > 0.0 else -1.0
281        moving_x = dx != 0.0
282        while abs(remaining) > 1e-6:
283            move = step if abs(remaining) >= 1.0 else remaining
284            nx = p.x + (move if moving_x else 0.0)
285            ny = p.y + (0.0 if moving_x else move)
286            if self._box_hits_solid(nx, ny, p.w, p.h):
287                if moving_x:
288                    p.vx = 0.0
289                else:
290                    p.vy = 0.0
291                break
292            p.x, p.y = nx, ny
293            self._dash_shatter(p)
294            remaining -= move
295
296    def _dash_shatter(self, p: Player) -> None:
297        # Dashing through a crystal shatters it and refills the dash; the player
298        # presses dash again to use the refreshed charge. Only while dashing.
299        if p._dash_time <= 0.0:
300            return
301        for e in self.entities:
302            if isinstance(e, Crystal) and e.alive and p.overlaps(e):
303                e.alive = False
304                p.refill_dash()
305                p.vx *= CRYSTAL_BOOST
306                p.vy *= CRYSTAL_BOOST
307                self.emit("crystal", e.cx, e.cy)
308
309    def _hazard_overlap(self, p: Player) -> bool:
310        x0 = int(p.x // TILE_SIZE)
311        x1 = int((p.x + p.w - 1e-4) // TILE_SIZE)
312        y0 = int(p.y // TILE_SIZE)
313        y1 = int((p.y + p.h - 1e-4) // TILE_SIZE)
314        for ty in range(max(0, y0), min(self.h, y1 + 1)):
315            for tx in range(max(0, x0), min(self.w, x1 + 1)):
316                if is_hazard(self.grid[ty][tx]):
317                    return True
318        return False
319
320
321@dataclass
322class _Exit:
323    """Internal room-exit marker entity (spawned from the 'E' grid char)."""
324
325    x: float
326    y: float
327    w: float = field(default=float(TILE_SIZE))
328    h: float = field(default=float(TILE_SIZE))
329    kind: str = "exit"
330
331    @property
332    def cx(self) -> float:
333        return self.x + self.w * 0.5
334
335    @property
336    def cy(self) -> float:
337        return self.y + self.h * 0.5