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