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"]