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