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.state = gs.GameState()
70 self._mouse_drag_action: str | None = None # "request" | "cancel"
71 self._game_over_announced = False
72
73 # ----------------------------------------------------------------- ready
74 def on_ready(self) -> None:
75 # Seed initial process targets
76 self._refresh_targets()
77
78 def restart(self) -> None:
79 """Boot a fresh machine in place: new simulation, same node."""
80 self.state = gs.GameState()
81 self._mouse_drag_action = None
82 self._game_over_announced = False
83 self._refresh_targets()
84
85 # ----------------------------------------------------------------- tick
86 def on_update(self, dt: float) -> None:
87 # Run simulation
88 dt_ms = int(dt * 1000)
89 if dt_ms <= 0:
90 dt_ms = 16
91 gs.tick(self.state, dt_ms)
92
93 # Update animation targets and glide processes toward them
94 self._refresh_targets()
95 self._glide_processes(dt)
96
97 # Handle input
98 self._handle_input()
99
100 # Game-over fire-once signal
101 if self.state.game_over and not self._game_over_announced:
102 self._game_over_announced = True
103 self.game_over_changed.emit()
104
105 # ----------------------------------------------------------------- draw
106 def on_draw(self, renderer) -> None:
107 # The root paints the background; the stage only draws the playfield.
108 self._draw_io_queue(renderer)
109 self._draw_uptime_and_score(renderer)
110 self._draw_cpus(renderer)
111 self._draw_idle_slots(renderer)
112 self._draw_ragequit_slots(renderer)
113 self._draw_pages(renderer)
114 self._draw_processes(renderer)
115
116 # Section labels
117 renderer.text("Idle Processes:", (PROCESS_AREA_X, 120), fill=theme.WHITE, scale=0.9)
118 renderer.text(
119 f"User Ragequits ({self.state.stats.ragequits} / {gs.MAX_RAGEQUITS}):",
120 (PROCESS_AREA_X, self.viewport_size.y - RAGEQUIT_Y_FROM_BOTTOM - 22),
121 fill=theme.WHITE,
122 scale=0.85,
123 )
124 renderer.text("Memory Pages in RAM:", (RAM_AREA_X, 120), fill=theme.WHITE, scale=0.85)
125 # Disk label
126 disk_y = RAM_Y + gs.NUM_RAM_ROWS * (PAGE_H + PAGE_GAP) + 10
127 renderer.text("Memory Pages on Disk:", (RAM_AREA_X, disk_y), fill=theme.WHITE, scale=0.85)
128
129 # =========================================================== layout
130
131 def _cpu_xy(self, idx: int) -> tuple[float, float]:
132 return (CPU_X0 + idx * (CPU_SIZE + PROCESS_GAP), CPU_Y)
133
134 def _idle_slot_xy(self, idx: int) -> tuple[float, float]:
135 row, col = divmod(idx, gs.NUM_PROCESS_SLOT_COLS)
136 x = PROCESS_AREA_X + col * (PROCESS_SIZE + PROCESS_GAP)
137 y = PROCESS_AREA_Y + row * (PROCESS_SIZE + PROCESS_GAP)
138 return (x, y)
139
140 def _ragequit_slot_xy(self, idx: int) -> tuple[float, float]:
141 x = PROCESS_AREA_X + idx * (PROCESS_SIZE + PROCESS_GAP)
142 y = self.viewport_size.y - RAGEQUIT_Y_FROM_BOTTOM
143 return (x, y)
144
145 def _ram_slot_xy(self, idx: int) -> tuple[float, float]:
146 row, col = divmod(idx, gs.PAGES_PER_ROW)
147 x = RAM_AREA_X + col * (PAGE_W + PAGE_GAP)
148 y = RAM_Y + row * (PAGE_H + PAGE_GAP)
149 return (x, y)
150
151 def _disk_slot_xy(self, idx: int) -> tuple[float, float]:
152 row, col = divmod(idx, gs.PAGES_PER_ROW)
153 ram_y_end = RAM_Y + gs.NUM_RAM_ROWS * (PAGE_H + PAGE_GAP) + 35
154 x = RAM_AREA_X + col * (PAGE_W + PAGE_GAP)
155 y = ram_y_end + row * (PAGE_H + PAGE_GAP)
156 return (x, y)
157
158 # =========================================================== animation
159
160 def _refresh_targets(self) -> None:
161 # Set per-process target_x/y based on logical location
162 for p in self.state.processes.values():
163 if p.has_cpu and p.cpu_idx is not None:
164 p.target_x, p.target_y = self._cpu_xy(p.cpu_idx)
165 elif p.slot_idx is not None:
166 p.target_x, p.target_y = self._idle_slot_xy(p.slot_idx)
167 else:
168 p.target_x, p.target_y = -200, -200
169 self._place_if_new(p)
170 # Dead processes (in ragequit slots)
171 for p in self.state.dead_processes():
172 if p.ragequit_slot_idx is not None:
173 p.target_x, p.target_y = self._ragequit_slot_xy(p.ragequit_slot_idx)
174 self._place_if_new(p)
175 # Gracefully ended processes glide up & off
176 for p in self.state.processes.values():
177 if p.has_ended_gracefully:
178 p.target_y = -PROCESS_SIZE - 10
179
180 def _place_if_new(self, p: gs.Process) -> None:
181 """Park a just-spawned process below the screen so it glides up into its slot."""
182 if not p.needs_placement:
183 return
184 p.needs_placement = False
185 p.pos_x = p.target_x
186 p.pos_y = self.viewport_size.y + SPAWN_DROP_PX
187
188 def _glide_processes(self, dt: float) -> None:
189 speed = ANIMATION_SPEED_PX_PER_S * dt
190 for p in self.state.all_processes():
191 dx = p.target_x - p.pos_x
192 dy = p.target_y - p.pos_y
193 dist = (dx * dx + dy * dy) ** 0.5
194 if dist <= speed:
195 p.pos_x = p.target_x
196 p.pos_y = p.target_y
197 else:
198 p.pos_x += (dx / dist) * speed
199 p.pos_y += (dy / dist) * speed
200
201 # =========================================================== input
202
203 def _handle_input(self) -> None:
204 mp = Input.mouse_position
205 click_left = Input.just_pressed("primary")
206 click_right = Input.just_pressed("secondary")
207 release_left = Input.just_released("primary")
208 held_left = Input.pressed("primary")
209 space = Input.just_pressed("deliver_io")
210
211 # I/O queue click / space
212 if space or (click_left and self._point_in_rect(mp, IO_QUEUE_X, IO_QUEUE_Y, IO_QUEUE_W, IO_QUEUE_H)):
213 self.state.deliver_io_events()
214
215 # Process click → toggle CPU
216 if click_left or click_right:
217 for p in self.state.processes.values():
218 if p.state == gs.ProcessState.ENDED:
219 continue
220 if self._point_in_rect(mp, p.pos_x, p.pos_y, PROCESS_SIZE, PROCESS_SIZE):
221 self.state.toggle_process(p)
222 break
223
224 # Page click → request/cancel swap (single-click toggle).
225 if click_left:
226 for page in self.state.pages():
227 slot = self.state.page_slot_index(page)
228 if slot is None:
229 continue
230 side, idx = slot
231 px, py = self._ram_slot_xy(idx) if side == "ram" else self._disk_slot_xy(idx)
232 if self._point_in_rect(mp, px, py, PAGE_W, PAGE_H):
233 if page.swap_requested:
234 self.state.cancel_page_swap(page)
235 else:
236 self.state.request_page_swap(page)
237 break
238 # Drag-paint over pages: held LEFT and motion
239 if held_left and not click_left:
240 self._handle_drag_paint(mp)
241 if release_left:
242 self._mouse_drag_action = None
243
244 def _handle_drag_paint(self, mp: Vec2) -> None:
245 for page in self.state.pages():
246 slot = self.state.page_slot_index(page)
247 if slot is None:
248 continue
249 side, idx = slot
250 px, py = self._ram_slot_xy(idx) if side == "ram" else self._disk_slot_xy(idx)
251 if self._point_in_rect(mp, px, py, PAGE_W, PAGE_H):
252 if self._mouse_drag_action is None:
253 self._mouse_drag_action = "cancel" if page.swap_requested else "request"
254 if self._mouse_drag_action == "request" and not page.swap_requested:
255 self.state.request_page_swap(page)
256 elif self._mouse_drag_action == "cancel" and page.swap_requested:
257 self.state.cancel_page_swap(page)
258 break
259
260 @staticmethod
261 def _point_in_rect(mp: Vec2, x: float, y: float, w: float, h: float) -> bool:
262 return x <= mp.x <= x + w and y <= mp.y <= y + h
263
264 # =========================================================== draw
265
266 def _draw_io_queue(self, renderer) -> None:
267 blink = self.state.io_event_count > 0 and int(self.state.now_ms / 333) % 2 == 1
268 colour = theme.TEAL if blink else theme.WHITE
269 renderer.rect((IO_QUEUE_X, IO_QUEUE_Y), (IO_QUEUE_W, IO_QUEUE_H), fill=colour)
270 renderer.text(
271 f"I/O EVENTS ({self.state.io_event_count})", (IO_QUEUE_X + 16, IO_QUEUE_Y + 8), fill=theme.BLACK, scale=0.85
272 )
273
274 def _draw_uptime_and_score(self, renderer) -> None:
275 ms = self.state.stats.uptime_ms
276 seconds = ms // 1000
277 mm, ss = divmod(seconds, 60)
278 uptime_text = f"Uptime {mm:02d}:{ss:02d}"
279 # Centre the uptime
280 cx = self.viewport_size.x * 0.5 - 60
281 renderer.text(uptime_text, (cx, IO_QUEUE_Y + 8), fill=theme.WHITE, scale=1.0)
282 # Score on the right
283 score_text = f"Score {self.state.stats.score}"
284 renderer.text(score_text, (self.viewport_size.x - 220, IO_QUEUE_Y + 8), fill=theme.WHITE, scale=1.0)
285
286 def _draw_cpus(self, renderer) -> None:
287 for i in range(gs.NUM_CPUS):
288 x, y = self._cpu_xy(i)
289 # Outer white border, inner black
290 renderer.rect((x, y), (CPU_SIZE, CPU_SIZE), fill=theme.WHITE)
291 renderer.rect((x + 2, y + 2), (CPU_SIZE - 4, CPU_SIZE - 4), fill=theme.BLACK)
292 # CPU label drawn behind processes (will be visually behind a process if one is on it)
293 renderer.text(f"CPU {i + 1}", (x + 10, y + 24), fill=theme.WHITE, scale=0.85)
294
295 def _draw_idle_slots(self, renderer) -> None:
296 for i in range(gs.MAX_PROCESSES):
297 x, y = self._idle_slot_xy(i)
298 renderer.rect((x, y), (PROCESS_SIZE, PROCESS_SIZE), fill=theme.ALMOST_BLACK)
299 # Subtle border
300 renderer.rect((x, y), (PROCESS_SIZE, PROCESS_SIZE), stroke=theme.DARK_GREY)
301
302 def _draw_ragequit_slots(self, renderer) -> None:
303 for i in range(gs.MAX_RAGEQUITS):
304 x, y = self._ragequit_slot_xy(i)
305 renderer.rect((x, y), (PROCESS_SIZE, PROCESS_SIZE), fill=theme.ALMOST_BLACK)
306 renderer.rect((x, y), (PROCESS_SIZE, PROCESS_SIZE), stroke=theme.DARK_GREY)
307
308 def _draw_pages(self, renderer) -> None:
309 # Slots backdrop first
310 for i in range(gs.NUM_RAM_ROWS * gs.PAGES_PER_ROW):
311 x, y = self._ram_slot_xy(i)
312 renderer.rect((x, y), (PAGE_W, PAGE_H), fill=theme.ALMOST_BLACK)
313 renderer.rect((x, y), (PAGE_W, PAGE_H), stroke=theme.DARK_GREY)
314 for i in range(gs.NUM_DISK_ROWS * gs.PAGES_PER_ROW):
315 x, y = self._disk_slot_xy(i)
316 renderer.rect((x, y), (PAGE_W, PAGE_H), fill=theme.ALMOST_BLACK)
317 renderer.rect((x, y), (PAGE_W, PAGE_H), stroke=theme.DARK_GREY)
318
319 for page in self.state.pages():
320 colour = theme.DARK_GREY
321 if page.swap_requested:
322 colour = theme.TEAL
323 elif page.display_blink:
324 colour = theme.BLUE
325 elif page.in_use:
326 colour = theme.WHITE
327 slot = self.state.page_slot_index(page)
328 if slot is None:
329 continue
330 side, idx = slot
331 x, y = self._ram_slot_xy(idx) if side == "ram" else self._disk_slot_xy(idx)
332 renderer.rect((x, y), (PAGE_W, PAGE_H), fill=colour)
333 # PID number only (page is too small for "PID nn")
334 renderer.text(f"P{page.pid}", (x + 4, y + 8), fill=theme.BLACK, scale=0.7)
335 # Progress bar for swap
336 if page.swap_in_progress:
337 bar_w = (PAGE_W - 4) * page.swap_progress
338 renderer.rect((x + 2, y + PAGE_H - 4), (bar_w, 2), fill=theme.BLACK)
339
340 def _draw_processes(self, renderer) -> None:
341 for p in self.state.all_processes():
342 x, y = p.pos_x, p.pos_y
343 # Off-screen culling
344 if y < -PROCESS_SIZE or y > self.viewport_size.y + PROCESS_SIZE:
345 continue
346 if p.has_ended_gracefully:
347 colour = theme.LIGHT_BLUE
348 face = theme.GRACEFUL_FACE
349 else:
350 colour = theme.STARVATION_COLOURS[p.starvation_level]
351 face = theme.STARVATION_FACES[p.starvation_level]
352 if p.blink_state:
353 colour = theme.BLUE
354 renderer.rect((x, y), (PROCESS_SIZE, PROCESS_SIZE), fill=colour)
355 # Border for definition
356 renderer.rect((x, y), (PROCESS_SIZE, PROCESS_SIZE), stroke=theme.BLACK)
357 # Face glyph in upper-left (larger)
358 renderer.text(face, (x + 4, y + 6), fill=theme.BLACK, scale=1.4)
359 # PID hugging the top-right corner, measured so long ids never spill
360 # into the neighbouring slot.
361 pid = str(p.pid)
362 pid_x = x + PROCESS_SIZE - 5 - measure_text_width(pid, 0.8)
363 renderer.text(pid, (pid_x, y + 8), fill=theme.BLACK, scale=0.8)
364 # I/O hourglass icon: bottom-right, pulses with state
365 if p.is_waiting_for_io:
366 # White rounded box with hourglass-style 'X' shape
367 ix, iy = x + 38, y + 36
368 renderer.rect((ix, iy), (22, 22), fill=theme.WHITE)
369 renderer.rect((ix, iy), (22, 22), stroke=theme.BLACK)
370 # Hourglass shape: top trapezoid + bottom trapezoid as solid rects
371 renderer.rect((ix + 4, iy + 4), (14, 2), fill=theme.BLACK)
372 renderer.rect((ix + 4, iy + 16), (14, 2), fill=theme.BLACK)
373 renderer.rect((ix + 9, iy + 6), (4, 10), fill=theme.BLACK)
374
375 # Progress bar: happiness or time-to-death
376 if p.is_progressing_to_happiness:
377 w = PROCESS_SIZE - 4
378 # state_duration / happiness_ms
379 dur = p.current_state_duration_ms(self.state.now_ms)
380 bar_w = max(0, w - max(0, gs.PROCESS_HAPPINESS_MS - dur) * w / gs.PROCESS_HAPPINESS_MS)
381 bar_w = min(w, bar_w)
382 renderer.rect((x + 2, y + PROCESS_SIZE - 4), (bar_w, 2), fill=theme.BLUE)
383 elif p.starvation_level == gs.LAST_ALIVE_STARVATION_LEVEL and p.state != gs.ProcessState.RUNNING:
384 w = PROCESS_SIZE - 4
385 ttd = p.time_to_termination_ms(self.state.now_ms)
386 if ttd != float("inf"):
387 bar_w = (ttd / gs.TIME_BETWEEN_STARVATION_LEVELS_MS) * w
388 bar_w = max(0, min(w, bar_w))
389 renderer.rect((x + 2, y + PROCESS_SIZE - 4), (bar_w, 2), fill=theme.BLUE)
390
391
392__all__ = ["DESIGN_HEIGHT", "DESIGN_WIDTH", "StageNode"]