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