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._bind_entity_lists()
 85        self.player = Player(self._spawn_x, self._spawn_y)
 86
 87        self.won = False
 88        self.dead = False
 89        self.time = 0.0
 90        self.deaths = 0
 91        self.shard_collected = False
 92
 93        self._accum = 0.0
 94        self._pending_jump = False  # press edges seen since the last fixed tick
 95        self._pending_dash = False
 96        self._events: list[tuple] = []
 97
 98    # -- parsing -------------------------------------------------------------
 99
100    def _parse(self) -> None:
101        for ty, row in enumerate(self.grid):
102            for tx, char in enumerate(row):
103                px, py = tx * TILE_SIZE, ty * TILE_SIZE
104                if char == "P":
105                    self._spawn_x, self._spawn_y = float(px), float(py)
106                    row[tx] = "."
107                elif char == "E":
108                    self._exit = (float(px), float(py))
109                    self.entities.append(_Exit(px, py))
110                    row[tx] = "."
111                elif char in _ENTITY_FACTORY:
112                    self.entities.append(_ENTITY_FACTORY[char](px, py))
113                    row[tx] = "."
114
115    def _bind_entity_lists(self) -> None:
116        # The tick and the per-pixel collision read each entity kind from its own list
117        # rather than filtering every entity every time. Order within a list is entity order.
118        ents = self.entities
119        if not hasattr(self, "_kind_index"):
120            self._kind_index = {
121                "_tickers": [i for i, e in enumerate(ents) if hasattr(e, "tick")],
122                "_gates": [i for i, e in enumerate(ents) if isinstance(e, LightGate)],
123                "_platforms": [i for i, e in enumerate(ents) if isinstance(e, MovingPlatform)],
124                "_crystals": [i for i, e in enumerate(ents) if isinstance(e, Crystal)],
125                "_touchables": [i for i, e in enumerate(ents) if isinstance(e, GlowOrb | Shard | Spring | _Exit)],
126            }
127        for name, index in self._kind_index.items():
128            setattr(self, name, [ents[i] for i in index])
129
130    def __deepcopy__(self, memo: dict) -> Room:
131        """A snapshot: the dynamic state copied, the static tile grid and definition shared.
132
133        The grid is only written while parsing, and every entity holds plain scalars, so
134        a snapshot is cheap enough to branch a search from every state.
135        """
136        clone = Room.__new__(Room)
137        memo[id(self)] = clone
138        clone.__dict__.update(self.__dict__)
139        clone.entities = [e.__copy__() for e in self.entities]
140        clone.player = self.player.__copy__()
141        clone._events = list(self._events)
142        clone._bind_entity_lists()
143        return clone
144
145    # -- public stepping -----------------------------------------------------
146
147    def step(self, dt: float, inp: InputState) -> None:
148        """Advance by ``dt`` real seconds via a fixed 60 Hz accumulator.
149
150        Press edges (jump/dash) are latched across frames so a tap that lands
151        between two fixed ticks (common above 60 fps, where some frames run zero
152        ticks) still reaches the next tick. At exactly 60 fps this is a no-op:
153        every frame runs one tick that consumes the edge the same frame.
154        """
155        self._pending_jump = self._pending_jump or inp.jump_pressed
156        self._pending_dash = self._pending_dash or inp.dash_pressed
157        self._accum += dt
158        steps = 0
159        while self._accum >= FIXED_DT and steps < MAX_STEPS:
160            self._accum -= FIXED_DT
161            if inp.jump_pressed != self._pending_jump or inp.dash_pressed != self._pending_dash:
162                inp = replace(inp, jump_pressed=self._pending_jump, dash_pressed=self._pending_dash)
163            self._tick(inp)
164            self._pending_jump = False
165            self._pending_dash = False
166            steps += 1
167        if steps == MAX_STEPS:
168            self._accum = 0.0  # drop the backlog instead of spiralling
169
170    def reset(self) -> None:
171        """Respawn at spawn. Deaths persist; time resets."""
172        self.player = Player(self._spawn_x, self._spawn_y)
173        self.time = 0.0
174        self.won = False
175        self.dead = False
176        self.shard_collected = False
177        self._accum = 0.0
178        self._events.clear()
179        for e in self.entities:
180            if isinstance(e, Crystal):
181                e.alive = True
182            elif isinstance(e, GlowOrb):
183                e.available = True
184                e.respawn = 0.0
185            elif isinstance(e, LightGate):
186                e.solid = False
187                e.phased = False
188            elif isinstance(e, Shard):
189                e.collected = False
190
191    def drain_events(self) -> list[tuple]:
192        out = self._events
193        self._events = []
194        return out
195
196    def emit(self, *event: object) -> None:
197        self._events.append(tuple(event))
198
199    # -- per-tick simulation -------------------------------------------------
200
201    def _tick(self, inp: InputState) -> None:
202        if self.won or self.dead:
203            return
204        self.time += FIXED_DT
205
206        for e in self._tickers:
207            e.tick(FIXED_DT)
208        p = self.player
209        glowing = p.glowing
210        for e in self._gates:
211            e.solid = glowing
212            # Don't trap the player: a gate that hardens while they overlap it
213            # stays passable until they step clear of it.
214            e.phased = glowing and p.overlaps(e)
215
216        self.player.update(FIXED_DT, inp, self)
217        self._resolve_entity_touches()
218        self._check_hazards()
219
220    def _resolve_entity_touches(self) -> None:
221        p = self.player
222        for e in self._touchables:
223            if isinstance(e, GlowOrb):
224                if e.available and p.overlaps_padded(e, PICKUP_PAD):
225                    e.consume()
226                    p.glow_timer = max(p.glow_timer, GLOW_FROM_ORB)
227                    self.emit("orb", e.cx, e.cy)
228            elif isinstance(e, Shard):
229                if not e.collected and p.overlaps_padded(e, PICKUP_PAD):
230                    e.collected = True
231                    self.shard_collected = True
232                    self.emit("shard", e.cx, e.cy)
233            elif isinstance(e, Spring):
234                if e.cooldown <= 0.0 and p.overlaps(e) and p.vy >= 0.0:
235                    p.vy = -Spring.LAUNCH_SPEED
236                    p.refill_dash()
237                    e.cooldown = 0.25
238                    self.emit("spring", e.cx, e.cy)
239            elif isinstance(e, _Exit):
240                if p.overlaps(e):
241                    self.won = True
242                    self.emit("win")
243
244    def _check_hazards(self) -> None:
245        p = self.player
246        # Spike tiles overlapping the hitbox, or falling out of bounds.
247        if self._hazard_overlap(p) or p.y > self.h * TILE_SIZE + TILE_SIZE:
248            self._die()
249
250    def _die(self) -> None:
251        p = self.player
252        self.emit("death", p.cx, p.cy)
253        self.deaths += 1
254        self.dead = True
255        # The Wisp stops being a live actor until ``reset`` spawns a fresh one;
256        # the view hides its sprite while the death burst plays.
257        p.alive = False
258
259    # -- collision protocol used by Player.update ----------------------------
260
261    def is_solid_at(self, px: float, py: float) -> bool:
262        """O(1) static-tile solid/hazard lookup at a logical pixel."""
263        tx = int(px // TILE_SIZE)
264        ty = int(py // TILE_SIZE)
265        if tx < 0 or ty < 0 or tx >= self.w or ty >= self.h:
266            return tx < 0 or tx >= self.w  # walls left/right are solid, top open
267        return is_solid(self.grid[ty][tx])
268
269    def _box_hits_solid(self, x: float, y: float, w: float, h: float) -> bool:
270        x0 = int(x // TILE_SIZE)
271        x1 = int((x + w - 1e-4) // TILE_SIZE)
272        y0 = int(y // TILE_SIZE)
273        y1 = int((y + h - 1e-4) // TILE_SIZE)
274        for ty in range(y0, y1 + 1):
275            for tx in range(x0, x1 + 1):
276                if tx < 0 or tx >= self.w:
277                    return True  # side walls
278                if ty < 0 or ty >= self.h:
279                    continue  # open top/bottom (bottom handled as a death plane)
280                if is_solid(self.grid[ty][tx]):
281                    return True
282        for e in self._gates:
283            if e.solid and not e.phased and x < e.x + e.w and x + w > e.x and y < e.y + e.h and y + h > e.y:
284                return True
285        for e in self._platforms:
286            if x < e.x + e.w and x + w > e.x and y < e.y + e.h and y + h > e.y:
287                return True
288        return False
289
290    def solid_below(self, p: Player) -> bool:
291        return self._box_hits_solid(p.x, p.y + p.h, p.w, 1.0)
292
293    def wall_dir(self, p: Player) -> int:
294        if self._box_hits_solid(p.x - 1.0, p.y, 1.0, p.h):
295            return -1
296        if self._box_hits_solid(p.x + p.w, p.y, 1.0, p.h):
297            return 1
298        return 0
299
300    def move_and_collide(self, p: Player, dx: float, dy: float) -> None:
301        """Swept per-axis movement, 1px steps, dash-through-crystal handling."""
302        self._step_axis(p, dx, 0.0)
303        self._step_axis(p, 0.0, dy)
304
305    def _step_axis(self, p: Player, dx: float, dy: float) -> None:
306        if dx == 0.0 and dy == 0.0:
307            return
308        remaining = dx if dx != 0.0 else dy
309        step = 1.0 if remaining > 0.0 else -1.0
310        moving_x = dx != 0.0
311        # Nothing moves during the sweep, so when the box swept over the whole move (padded
312        # past any rounding in the steps) is clear, no step can hit and none is tested.
313        pad = 1e-3
314        if moving_x:
315            swept_clear = not self._box_hits_solid(min(p.x, p.x + dx) - pad, p.y, p.w + abs(dx) + 2 * pad, p.h)
316        else:
317            swept_clear = not self._box_hits_solid(p.x, min(p.y, p.y + dy) - pad, p.w, p.h + abs(dy) + 2 * pad)
318        while abs(remaining) > 1e-6:
319            move = step if abs(remaining) >= 1.0 else remaining
320            nx = p.x + (move if moving_x else 0.0)
321            ny = p.y + (0.0 if moving_x else move)
322            if not swept_clear and self._box_hits_solid(nx, ny, p.w, p.h):
323                if moving_x:
324                    p.vx = 0.0
325                else:
326                    p.vy = 0.0
327                break
328            p.x, p.y = nx, ny
329            self._dash_shatter(p)
330            remaining -= move
331
332    def _dash_shatter(self, p: Player) -> None:
333        # Dashing through a crystal shatters it and refills the dash; the player
334        # presses dash again to use the refreshed charge. Only while dashing.
335        if p._dash_time <= 0.0:
336            return
337        for e in self._crystals:
338            if e.alive and p.overlaps(e):
339                e.alive = False
340                p.refill_dash()
341                p.vx *= CRYSTAL_BOOST
342                p.vy *= CRYSTAL_BOOST
343                self.emit("crystal", e.cx, e.cy)
344
345    def _hazard_overlap(self, p: Player) -> bool:
346        x0 = int(p.x // TILE_SIZE)
347        x1 = int((p.x + p.w - 1e-4) // TILE_SIZE)
348        y0 = int(p.y // TILE_SIZE)
349        y1 = int((p.y + p.h - 1e-4) // TILE_SIZE)
350        for ty in range(max(0, y0), min(self.h, y1 + 1)):
351            for tx in range(max(0, x0), min(self.w, x1 + 1)):
352                if is_hazard(self.grid[ty][tx]):
353                    return True
354        return False
355
356
357@dataclass(frozen=True)
358class _Exit:
359    """Internal room-exit marker entity (spawned from the 'E' grid char)."""
360
361    x: float
362    y: float
363    w: float = field(default=float(TILE_SIZE))
364    h: float = field(default=float(TILE_SIZE))
365    kind: str = "exit"
366
367    @property
368    def cx(self) -> float:
369        return self.x + self.w * 0.5
370
371    @property
372    def cy(self) -> float:
373        return self.y + self.h * 0.5
374
375    def __copy__(self) -> _Exit:
376        return self  # immutable, so a snapshot shares it