nodes/stage.py¶
Part of You’re the OS.
1"""StageNode: the playfield.
2
3Renders the simulation in ``state.GameState`` each frame; reads polled
4input actions and translates clicks into state mutations. No per-process child
5nodes; everything is drawn in ``on_draw`` for simplicity and to keep the
6render loop tight (42 processes + ~180 page slots is a lot of node
7allocation otherwise).
8"""
9
10from __future__ import annotations
11
12from simvx.core import Node2D, Signal, UpdateMode
13from simvx.core.input.state import Input
14from simvx.core.math.types import Vec2
15from simvx.core.text import measure_text_width
16
17from . import state as gs
18from . import theme
19
20# Design resolution: the window opens at this size and the playfield is laid out
21# from the top-left, so a larger viewport simply reveals more background.
22DESIGN_WIDTH = 1280
23DESIGN_HEIGHT = 720
24
25# Layout
26PROCESS_SIZE = 64
27PROCESS_GAP = 5
28PROCESS_AREA_X = 50
29PROCESS_AREA_Y = 155
30PROCESS_AREA_WIDTH = gs.NUM_PROCESS_SLOT_COLS * PROCESS_SIZE + (gs.NUM_PROCESS_SLOT_COLS - 1) * PROCESS_GAP + 60
31
32CPU_Y = 50
33CPU_SIZE = 64
34CPU_X0 = 50
35
36PAGE_W = 36
37PAGE_H = 32
38PAGE_GAP = 5
39RAM_Y = 155
40RAM_AREA_X = PROCESS_AREA_X + PROCESS_AREA_WIDTH + 30 # offset right of process area
41
42IO_QUEUE_X = 50
43IO_QUEUE_Y = 10
44IO_QUEUE_W = 128
45IO_QUEUE_H = 32
46
47# Distance from the bottom of the viewport to the top of the ragequit row. Sized
48# so the 64 px slots sit exactly on top of the root's 70 px controls strip.
49RAGEQUIT_Y_FROM_BOTTOM = 134
50
51ANIMATION_SPEED_PX_PER_S = 2100 # 35 px/frame at 60 fps in upstream
52SPAWN_DROP_PX = 80 # how far below the viewport a new process starts its glide
53
54
55class StageNode(Node2D):
56 """Owns the GameState and renders it. Everything is screen-space."""
57
58 # Continuous animation: processes glide toward targets + I/O queue and pages blink each frame.
59 dynamic = True
60
61 game_over_changed = Signal()
62
63 def __init__(self) -> None:
64 super().__init__(name="Stage")
65 # The root keeps running while the tree is paused so it can drive its
66 # title screen; saying PAUSABLE explicitly here freezes the simulation
67 # behind that screen instead of inheriting the root's ALWAYS.
68 self.update_mode = UpdateMode.PAUSABLE
69 self.viewport_size = Vec2(DESIGN_WIDTH, DESIGN_HEIGHT)
70 self.state = gs.GameState()
71 self._mouse_drag_action: str | None = None # "request" | "cancel"
72 self._game_over_announced = False
73
74 # ----------------------------------------------------------------- ready
75 def on_ready(self) -> None:
76 # Seed initial process targets
77 self._sync_viewport()
78 self._refresh_targets()
79
80 def restart(self) -> None:
81 """Boot a fresh machine in place: new simulation, same node."""
82 self.state = gs.GameState()
83 self._mouse_drag_action = None
84 self._game_over_announced = False
85 self._refresh_targets()
86
87 # ----------------------------------------------------------------- tick
88 def on_update(self, dt: float) -> None:
89 # Follow the live viewport so the bottom-anchored rows survive a resize.
90 self._sync_viewport()
91
92 # Run simulation
93 dt_ms = int(dt * 1000)
94 if dt_ms <= 0:
95 dt_ms = 16
96 gs.tick(self.state, dt_ms)
97
98 # Update animation targets and glide processes toward them
99 self._refresh_targets()
100 self._glide_processes(dt)
101
102 # Handle input
103 self._handle_input()
104
105 # Game-over fire-once signal
106 if self.state.game_over and not self._game_over_announced:
107 self._game_over_announced = True
108 self.game_over_changed()
109
110 def _sync_viewport(self) -> None:
111 w, h = self.tree.screen_size
112 if (w, h) != (self.viewport_size.x, self.viewport_size.y):
113 self.viewport_size = Vec2(w, h)
114
115 # ----------------------------------------------------------------- draw
116 def on_draw(self, renderer) -> None:
117 # The root paints the background; the stage only draws the playfield.
118 self._draw_io_queue(renderer)
119 self._draw_uptime_and_score(renderer)
120 self._draw_cpus(renderer)
121 self._draw_idle_slots(renderer)
122 self._draw_ragequit_slots(renderer)
123 self._draw_pages(renderer)
124 self._draw_processes(renderer)
125
126 # Section labels
127 renderer.draw_text("Idle Processes:", (PROCESS_AREA_X, 120), scale=0.9, colour=theme.WHITE)
128 renderer.draw_text(
129 f"User Ragequits ({self.state.stats.ragequits} / {gs.MAX_RAGEQUITS}):",
130 (PROCESS_AREA_X, self.viewport_size.y - RAGEQUIT_Y_FROM_BOTTOM - 22),
131 scale=0.85,
132 colour=theme.WHITE,
133 )
134 renderer.draw_text(
135 "Memory Pages in RAM:",
136 (RAM_AREA_X, 120),
137 scale=0.85,
138 colour=theme.WHITE,
139 )
140 # Disk label
141 disk_y = RAM_Y + gs.NUM_RAM_ROWS * (PAGE_H + PAGE_GAP) + 10
142 renderer.draw_text(
143 "Memory Pages on Disk:",
144 (RAM_AREA_X, disk_y),
145 scale=0.85,
146 colour=theme.WHITE,
147 )
148
149 # =========================================================== layout
150
151 def _cpu_xy(self, idx: int) -> tuple[float, float]:
152 return (CPU_X0 + idx * (CPU_SIZE + PROCESS_GAP), CPU_Y)
153
154 def _idle_slot_xy(self, idx: int) -> tuple[float, float]:
155 row, col = divmod(idx, gs.NUM_PROCESS_SLOT_COLS)
156 x = PROCESS_AREA_X + col * (PROCESS_SIZE + PROCESS_GAP)
157 y = PROCESS_AREA_Y + row * (PROCESS_SIZE + PROCESS_GAP)
158 return (x, y)
159
160 def _ragequit_slot_xy(self, idx: int) -> tuple[float, float]:
161 x = PROCESS_AREA_X + idx * (PROCESS_SIZE + PROCESS_GAP)
162 y = self.viewport_size.y - RAGEQUIT_Y_FROM_BOTTOM
163 return (x, y)
164
165 def _ram_slot_xy(self, idx: int) -> tuple[float, float]:
166 row, col = divmod(idx, gs.PAGES_PER_ROW)
167 x = RAM_AREA_X + col * (PAGE_W + PAGE_GAP)
168 y = RAM_Y + row * (PAGE_H + PAGE_GAP)
169 return (x, y)
170
171 def _disk_slot_xy(self, idx: int) -> tuple[float, float]:
172 row, col = divmod(idx, gs.PAGES_PER_ROW)
173 ram_y_end = RAM_Y + gs.NUM_RAM_ROWS * (PAGE_H + PAGE_GAP) + 35
174 x = RAM_AREA_X + col * (PAGE_W + PAGE_GAP)
175 y = ram_y_end + row * (PAGE_H + PAGE_GAP)
176 return (x, y)
177
178 # =========================================================== animation
179
180 def _refresh_targets(self) -> None:
181 # Set per-process target_x/y based on logical location
182 for p in self.state.processes.values():
183 if p.has_cpu and p.cpu_idx is not None:
184 p.target_x, p.target_y = self._cpu_xy(p.cpu_idx)
185 elif p.slot_idx is not None:
186 p.target_x, p.target_y = self._idle_slot_xy(p.slot_idx)
187 else:
188 p.target_x, p.target_y = -200, -200
189 self._place_if_new(p)
190 # Dead processes (in ragequit slots)
191 for p in self.state.dead_processes():
192 if p.ragequit_slot_idx is not None:
193 p.target_x, p.target_y = self._ragequit_slot_xy(p.ragequit_slot_idx)
194 self._place_if_new(p)
195 # Gracefully ended processes glide up & off
196 for p in self.state.processes.values():
197 if p.has_ended_gracefully:
198 p.target_y = -PROCESS_SIZE - 10
199
200 def _place_if_new(self, p: gs.Process) -> None:
201 """Park a just-spawned process below the screen so it glides up into its slot."""
202 if not p.needs_placement:
203 return
204 p.needs_placement = False
205 p.pos_x = p.target_x
206 p.pos_y = self.viewport_size.y + SPAWN_DROP_PX
207
208 def _glide_processes(self, dt: float) -> None:
209 speed = ANIMATION_SPEED_PX_PER_S * dt
210 for p in self.state.all_processes():
211 dx = p.target_x - p.pos_x
212 dy = p.target_y - p.pos_y
213 dist = (dx * dx + dy * dy) ** 0.5
214 if dist <= speed:
215 p.pos_x = p.target_x
216 p.pos_y = p.target_y
217 else:
218 p.pos_x += (dx / dist) * speed
219 p.pos_y += (dy / dist) * speed
220
221 # =========================================================== input
222
223 def _handle_input(self) -> None:
224 mp = Input.mouse_position
225 click_left = Input.is_action_just_pressed("primary")
226 click_right = Input.is_action_just_pressed("secondary")
227 release_left = Input.is_action_just_released("primary")
228 held_left = Input.is_action_pressed("primary")
229 space = Input.is_action_just_pressed("deliver_io")
230
231 # I/O queue click / space
232 if space or (click_left and self._point_in_rect(mp, IO_QUEUE_X, IO_QUEUE_Y, IO_QUEUE_W, IO_QUEUE_H)):
233 self.state.deliver_io_events()
234
235 # Process click → toggle CPU
236 if click_left or click_right:
237 for p in self.state.processes.values():
238 if p.state == gs.ProcessState.ENDED:
239 continue
240 if self._point_in_rect(mp, p.pos_x, p.pos_y, PROCESS_SIZE, PROCESS_SIZE):
241 self.state.toggle_process(p)
242 break
243
244 # Page click → request/cancel swap (single-click toggle).
245 if click_left:
246 for page in self.state.pages():
247 slot = self.state.page_slot_index(page)
248 if slot is None:
249 continue
250 side, idx = slot
251 px, py = self._ram_slot_xy(idx) if side == "ram" else self._disk_slot_xy(idx)
252 if self._point_in_rect(mp, px, py, PAGE_W, PAGE_H):
253 if page.swap_requested:
254 self.state.cancel_page_swap(page)
255 else:
256 self.state.request_page_swap(page)
257 break
258 # Drag-paint over pages: held LEFT and motion
259 if held_left and not click_left:
260 self._handle_drag_paint(mp)
261 if release_left:
262 self._mouse_drag_action = None
263
264 def _handle_drag_paint(self, mp: Vec2) -> None:
265 for page in self.state.pages():
266 slot = self.state.page_slot_index(page)
267 if slot is None:
268 continue
269 side, idx = slot
270 px, py = self._ram_slot_xy(idx) if side == "ram" else self._disk_slot_xy(idx)
271 if self._point_in_rect(mp, px, py, PAGE_W, PAGE_H):
272 if self._mouse_drag_action is None:
273 self._mouse_drag_action = "cancel" if page.swap_requested else "request"
274 if self._mouse_drag_action == "request" and not page.swap_requested:
275 self.state.request_page_swap(page)
276 elif self._mouse_drag_action == "cancel" and page.swap_requested:
277 self.state.cancel_page_swap(page)
278 break
279
280 @staticmethod
281 def _point_in_rect(mp: Vec2, x: float, y: float, w: float, h: float) -> bool:
282 return x <= mp.x <= x + w and y <= mp.y <= y + h
283
284 # =========================================================== draw
285
286 def _draw_io_queue(self, renderer) -> None:
287 blink = self.state.io_event_count > 0 and int(self.state.now_ms / 333) % 2 == 1
288 colour = theme.TEAL if blink else theme.WHITE
289 renderer.draw_rect((IO_QUEUE_X, IO_QUEUE_Y), (IO_QUEUE_W, IO_QUEUE_H), colour=colour, filled=True)
290 renderer.draw_text(
291 f"I/O EVENTS ({self.state.io_event_count})",
292 (IO_QUEUE_X + 16, IO_QUEUE_Y + 8),
293 scale=0.85,
294 colour=theme.BLACK,
295 )
296
297 def _draw_uptime_and_score(self, renderer) -> None:
298 ms = self.state.stats.uptime_ms
299 seconds = ms // 1000
300 mm, ss = divmod(seconds, 60)
301 uptime_text = f"Uptime {mm:02d}:{ss:02d}"
302 # Centre the uptime
303 cx = self.viewport_size.x * 0.5 - 60
304 renderer.draw_text(uptime_text, (cx, IO_QUEUE_Y + 8), scale=1.0, colour=theme.WHITE)
305 # Score on the right
306 score_text = f"Score {self.state.stats.score}"
307 renderer.draw_text(score_text, (self.viewport_size.x - 220, IO_QUEUE_Y + 8), scale=1.0, colour=theme.WHITE)
308
309 def _draw_cpus(self, renderer) -> None:
310 for i in range(gs.NUM_CPUS):
311 x, y = self._cpu_xy(i)
312 # Outer white border, inner black
313 renderer.draw_rect((x, y), (CPU_SIZE, CPU_SIZE), colour=theme.WHITE, filled=True)
314 renderer.draw_rect((x + 2, y + 2), (CPU_SIZE - 4, CPU_SIZE - 4), colour=theme.BLACK, filled=True)
315 # CPU label drawn behind processes (will be visually behind a process if one is on it)
316 renderer.draw_text(
317 f"CPU {i + 1}",
318 (x + 10, y + 24),
319 scale=0.85,
320 colour=theme.WHITE,
321 )
322
323 def _draw_idle_slots(self, renderer) -> None:
324 for i in range(gs.MAX_PROCESSES):
325 x, y = self._idle_slot_xy(i)
326 renderer.draw_rect((x, y), (PROCESS_SIZE, PROCESS_SIZE), colour=theme.ALMOST_BLACK, filled=True)
327 # Subtle border
328 renderer.draw_rect((x, y), (PROCESS_SIZE, PROCESS_SIZE), colour=theme.DARK_GREY, filled=False)
329
330 def _draw_ragequit_slots(self, renderer) -> None:
331 for i in range(gs.MAX_RAGEQUITS):
332 x, y = self._ragequit_slot_xy(i)
333 renderer.draw_rect((x, y), (PROCESS_SIZE, PROCESS_SIZE), colour=theme.ALMOST_BLACK, filled=True)
334 renderer.draw_rect((x, y), (PROCESS_SIZE, PROCESS_SIZE), colour=theme.DARK_GREY, filled=False)
335
336 def _draw_pages(self, renderer) -> None:
337 # Slots backdrop first
338 for i in range(gs.NUM_RAM_ROWS * gs.PAGES_PER_ROW):
339 x, y = self._ram_slot_xy(i)
340 renderer.draw_rect((x, y), (PAGE_W, PAGE_H), colour=theme.ALMOST_BLACK, filled=True)
341 renderer.draw_rect((x, y), (PAGE_W, PAGE_H), colour=theme.DARK_GREY, filled=False)
342 for i in range(gs.NUM_DISK_ROWS * gs.PAGES_PER_ROW):
343 x, y = self._disk_slot_xy(i)
344 renderer.draw_rect((x, y), (PAGE_W, PAGE_H), colour=theme.ALMOST_BLACK, filled=True)
345 renderer.draw_rect((x, y), (PAGE_W, PAGE_H), colour=theme.DARK_GREY, filled=False)
346
347 for page in self.state.pages():
348 colour = theme.DARK_GREY
349 if page.swap_requested:
350 colour = theme.TEAL
351 elif page.display_blink:
352 colour = theme.BLUE
353 elif page.in_use:
354 colour = theme.WHITE
355 slot = self.state.page_slot_index(page)
356 if slot is None:
357 continue
358 side, idx = slot
359 x, y = self._ram_slot_xy(idx) if side == "ram" else self._disk_slot_xy(idx)
360 renderer.draw_rect((x, y), (PAGE_W, PAGE_H), colour=colour, filled=True)
361 # PID number only (page is too small for "PID nn")
362 renderer.draw_text(
363 f"P{page.pid}",
364 (x + 4, y + 8),
365 scale=0.7,
366 colour=theme.BLACK,
367 )
368 # Progress bar for swap
369 if page.swap_in_progress:
370 bar_w = (PAGE_W - 4) * page.swap_progress
371 renderer.draw_rect((x + 2, y + PAGE_H - 4), (bar_w, 2), colour=theme.BLACK, filled=True)
372
373 def _draw_processes(self, renderer) -> None:
374 for p in self.state.all_processes():
375 x, y = p.pos_x, p.pos_y
376 # Off-screen culling
377 if y < -PROCESS_SIZE or y > self.viewport_size.y + PROCESS_SIZE:
378 continue
379 if p.has_ended_gracefully:
380 colour = theme.LIGHT_BLUE
381 face = theme.GRACEFUL_FACE
382 else:
383 colour = theme.STARVATION_COLOURS[p.starvation_level]
384 face = theme.STARVATION_FACES[p.starvation_level]
385 if p.blink_state:
386 colour = theme.BLUE
387 renderer.draw_rect((x, y), (PROCESS_SIZE, PROCESS_SIZE), colour=colour, filled=True)
388 # Border for definition
389 renderer.draw_rect((x, y), (PROCESS_SIZE, PROCESS_SIZE), colour=theme.BLACK, filled=False)
390 # Face glyph in upper-left (larger)
391 renderer.draw_text(face, (x + 4, y + 6), scale=1.4, colour=theme.BLACK)
392 # PID hugging the top-right corner, measured so long ids never spill
393 # into the neighbouring slot.
394 pid = str(p.pid)
395 pid_x = x + PROCESS_SIZE - 5 - measure_text_width(pid, 0.8)
396 renderer.draw_text(pid, (pid_x, y + 8), scale=0.8, colour=theme.BLACK)
397 # I/O hourglass icon: bottom-right, pulses with state
398 if p.is_waiting_for_io:
399 # White rounded box with hourglass-style 'X' shape
400 ix, iy = x + 38, y + 36
401 renderer.draw_rect((ix, iy), (22, 22), colour=theme.WHITE, filled=True)
402 renderer.draw_rect((ix, iy), (22, 22), colour=theme.BLACK, filled=False)
403 # Hourglass shape: top trapezoid + bottom trapezoid as solid rects
404 renderer.draw_rect((ix + 4, iy + 4), (14, 2), colour=theme.BLACK, filled=True)
405 renderer.draw_rect((ix + 4, iy + 16), (14, 2), colour=theme.BLACK, filled=True)
406 renderer.draw_rect((ix + 9, iy + 6), (4, 10), colour=theme.BLACK, filled=True)
407
408 # Progress bar: happiness or time-to-death
409 if p.is_progressing_to_happiness:
410 w = PROCESS_SIZE - 4
411 # state_duration / happiness_ms
412 dur = p.current_state_duration_ms(self.state.now_ms)
413 bar_w = max(0, w - max(0, gs.PROCESS_HAPPINESS_MS - dur) * w / gs.PROCESS_HAPPINESS_MS)
414 bar_w = min(w, bar_w)
415 renderer.draw_rect((x + 2, y + PROCESS_SIZE - 4), (bar_w, 2), colour=theme.BLUE, filled=True)
416 elif p.starvation_level == gs.LAST_ALIVE_STARVATION_LEVEL and p.state != gs.ProcessState.RUNNING:
417 w = PROCESS_SIZE - 4
418 ttd = p.time_to_termination_ms(self.state.now_ms)
419 if ttd != float("inf"):
420 bar_w = (ttd / gs.TIME_BETWEEN_STARVATION_LEVELS_MS) * w
421 bar_w = max(0, min(w, bar_w))
422 renderer.draw_rect((x + 2, y + PROCESS_SIZE - 4), (bar_w, 2), colour=theme.BLUE, filled=True)
423
424
425__all__ = ["DESIGN_HEIGHT", "DESIGN_WIDTH", "StageNode"]