nodes/menu.pyΒΆ

Part of Mr. Rescue.

  1"""Title and end screens.
  2
  3Both lay out against the live framebuffer size (``self.tree.screen_size``) so
  4they stay centred on any window/canvas size, desktop or responsive web.
  5"""
  6
  7from __future__ import annotations
  8
  9import math
 10
 11from simvx.core import Input, Node, Signal
 12
 13from . import colours as C
 14
 15
 16def _screen(node, default=(1024, 800)) -> tuple[int, int]:
 17    if node.tree is None:
 18        return default
 19    w, h = node.tree.screen_size
 20    return int(w), int(h)
 21
 22
 23def _centre(renderer, text, cx, y, scale, colour, *, shadow=None):
 24    """Draw horizontally-centred text, with an optional drop shadow."""
 25    tw = renderer.text_width(text, scale)
 26    x = cx - tw / 2
 27    if shadow is not None:
 28        off = max(1.5, scale * 0.5)
 29        renderer.draw_text(text, (x + off, y + off), scale=scale, colour=shadow)
 30    renderer.draw_text(text, (x, y), scale=scale, colour=colour)
 31
 32
 33HELMET_UNITS = 7  # height of the helmet motif in its own units (see _helmet)
 34
 35
 36def _helmet(renderer, cx, top, s):
 37    """A tiny pixel firefighter helmet, ``s`` px per unit, centred on ``cx``."""
 38
 39    def rect(ux, uy, uw, uh, col):
 40        renderer.draw_rect((cx + ux * s, top + uy * s), (uw * s, uh * s), colour=col, filled=True)
 41
 42    dark = (0.55, 0.10, 0.10, 1.0)
 43    rect(-5, 2, 10, 4, C.PLAYER_HAT)  # dome
 44    rect(-4, 0, 8, 2, C.PLAYER_HAT)  # crest top
 45    rect(-7, 5, 14, 2, C.PLAYER_HAT)  # brim
 46    rect(-7, 6, 14, 1, dark)  # brim shadow
 47    rect(-1, 0, 2, 5, (1.0, 0.85, 0.30, 1.0))  # badge ridge
 48
 49
 50class TitleScreen(Node):
 51    start = Signal()
 52
 53    # ``on_draw`` renders a pulsing fire-glow band whose intensity is
 54    # ``sin(self._t * 3)`` from the non-Property ``_t`` advanced every tick, so it
 55    # genuinely redraws every frame: declare it dynamic so the pulse keeps animating.
 56    dynamic = True
 57
 58    def __init__(self, **kwargs):
 59        super().__init__(**kwargs)
 60        self._t = 0.0
 61
 62    def on_update(self, dt: float):
 63        self._t += dt
 64        if Input.is_action_just_pressed("start"):
 65            self.start.emit()
 66
 67    def on_draw(self, renderer):
 68        w, h = _screen(self)
 69        cx = w / 2
 70        margin = max(24.0, w * 0.06)
 71        avail = w - 2 * margin
 72
 73        # Background: base fill + a soft warm vignette band behind the logo.
 74        renderer.draw_rect((0, 0), (w, h), colour=C.BG, filled=True)
 75        renderer.draw_rect((0, 0), (w, h * 0.42), colour=(0.10, 0.08, 0.12, 0.5), filled=True)
 76
 77        # ---- Title block (top ~quarter) ----
 78        title = "MR. RESCUE"
 79        t_scale = renderer.fit_scale(title, avail, base_scale=9.0)
 80        t_h = renderer.text_height(title, t_scale)
 81        # The helmet sits above the logo, so the logo starts low enough to leave
 82        # the whole motif on screen rather than slicing its crest off the top.
 83        helm_s = max(2.0, t_scale * 0.7)
 84        helm_h = HELMET_UNITS * helm_s
 85        title_y = max(h * 0.07, helm_h + h * 0.03)
 86
 87        # Pulsing fire glow band sized to the logo.
 88        glow = 0.18 + 0.16 * (0.5 + 0.5 * math.sin(self._t * 3.0))
 89        renderer.draw_rect(
 90            (0, title_y - t_h * 0.2),
 91            (w, t_h * 1.5),
 92            colour=(C.PLAYER_HAT[0], C.PLAYER_HAT[1], C.PLAYER_HAT[2], glow),
 93            filled=True,
 94        )
 95
 96        _helmet(renderer, cx, title_y - helm_h - 6, helm_s)
 97        _centre(renderer, title, cx, title_y, t_scale, C.PLAYER_HAT, shadow=(0.15, 0.02, 0.02, 0.9))
 98
 99        sub = "FIREFIGHT THE BUILDING"
100        s_scale = renderer.fit_scale(sub, avail * 0.9, base_scale=3.0)
101        sub_y = title_y + t_h + h * 0.035
102        s_h = renderer.text_height(sub, s_scale)
103        _centre(renderer, sub, cx, sub_y, s_scale, C.HUD_FG)
104        # Accent rule under the subtitle.
105        rule_w = renderer.text_width(sub, s_scale)
106        renderer.draw_rect((cx - rule_w / 2, sub_y + s_h + 6), (rule_w, 2), colour=C.PLAYER_HAT, filled=True)
107
108        # ---- Prompt (bottom): placed first so the panel can centre above it.
109        prompt = "PRESS  ENTER  OR  CLICK  TO BEGIN"
110        p_scale = renderer.fit_scale(prompt, avail, base_scale=3.0)
111        p_h = renderer.text_height(prompt, p_scale)
112        esc_h = renderer.text_height("ESC  QUIT", 2.0)
113        esc_y = h - esc_h - h * 0.05
114        prompt_y = esc_y - p_h - h * 0.03
115        if int(self._t * 2) % 2 == 0:
116            _centre(renderer, prompt, cx, prompt_y, p_scale, C.YELLOW)
117        _centre(renderer, "ESC  QUIT", cx, esc_y, 2.0, C.HUD_DIM)
118
119        # ---- Controls panel (centred in the gap between subtitle and prompt) ----
120        rows = [
121            ("ARROWS / WASD", "MOVE  CLIMB  AIM"),
122            ("SPACE", "JUMP"),
123            ("SHIFT", "SPRAY WATER"),
124            ("E", "GRAB / THROW CIVILIAN"),
125        ]
126        pw = min(avail, 620.0)
127        pad = pw * 0.05
128        key_col = pw * 0.42
129        desc_col = pw - key_col - pad * 2
130        # One content scale that fits both the widest key and the widest desc.
131        cs = min(
132            renderer.fit_scale("ARROWS / WASD", key_col, base_scale=2.0),
133            renderer.fit_scale("GRAB / THROW CIVILIAN", desc_col, base_scale=2.0),
134        )
135        row_h = renderer.text_height("X", cs) + max(16.0, cs * 9.0)
136        panel_h = pad * 2 + row_h * len(rows)
137        px = cx - pw / 2
138        gap_top = sub_y + s_h + h * 0.04
139        py = max(gap_top, (gap_top + (prompt_y - h * 0.03)) / 2 - panel_h / 2)
140
141        panel_col = (C.HUD_BG[0], C.HUD_BG[1], C.HUD_BG[2], 0.7)
142        renderer.draw_rect((px, py), (pw, panel_h), colour=panel_col, filled=True)
143        renderer.draw_rect((px, py), (pw, panel_h), colour=C.HUD_BORDER, filled=False, thickness=2)
144        renderer.draw_rect((px, py), (4, panel_h), colour=C.PLAYER_HAT, filled=True)  # accent edge
145        for i, (key, desc) in enumerate(rows):
146            ly = py + pad + i * row_h
147            renderer.draw_text(key, (px + pad, ly), scale=cs, colour=C.YELLOW)
148            renderer.draw_text(desc, (px + pad + key_col, ly), scale=cs, colour=C.HUD_FG)
149
150
151class EndScreen(Node):
152    restart = Signal()
153
154    # ``on_draw`` blinks the "press start" prompt via ``int(self._t * 2) % 2`` from
155    # the non-Property ``_t`` advanced every tick, so it genuinely redraws every
156    # frame: declare it dynamic so the prompt keeps blinking.
157    dynamic = True
158
159    def __init__(
160        self,
161        *,
162        victory: bool,
163        score: int,
164        rescued: int,
165        civilians_total: int,
166        casualties: int = 0,
167        reason: str = "",
168        **kwargs,
169    ):
170        super().__init__(**kwargs)
171        self.victory = victory
172        self.score = score
173        self.rescued = rescued
174        self.civilians_total = civilians_total
175        self.casualties = casualties
176        self.reason = reason
177        self._t = 0.0
178
179    def on_update(self, dt: float):
180        self._t += dt
181        if Input.is_action_just_pressed("start"):
182            self.restart.emit()
183
184    def _grade(self) -> tuple[str, tuple]:
185        if self.victory and self.casualties == 0:
186            return "PERFECT RESCUE", C.GREEN
187        if self.victory:
188            return "GOOD WORK", C.YELLOW
189        if self.rescued > 0:
190            return "PARTIAL RESCUE", C.YELLOW
191        return "TRY AGAIN", C.RED
192
193    def on_draw(self, renderer):
194        w, h = _screen(self)
195        renderer.draw_rect((0, 0), (w, h), colour=C.BG, filled=True)
196        cx = w / 2
197        margin = max(24.0, w * 0.06)
198        avail = w - 2 * margin
199
200        # Stacked block: each line advances the cursor by its own measured height
201        # plus a gap, so nothing lands on top of the line above it at any size.
202        title = "RESCUE COMPLETE!" if self.victory else "GAME OVER"
203        t_scale = renderer.fit_scale(title, avail, base_scale=6.0)
204        grade, gcol = self._grade()
205        g_scale = renderer.fit_scale(grade, avail, base_scale=3.0)
206        lines = [
207            f"SCORE     {self.score}",
208            f"RESCUED     {self.rescued} / {self.civilians_total}",
209        ]
210        l_scale = min(renderer.fit_scale(line, avail, base_scale=3.0) for line in lines)
211
212        y = h * 0.14
213        _centre(renderer, title, cx, y, t_scale, C.GREEN if self.victory else C.RED, shadow=(0.15, 0.02, 0.02, 0.9))
214        y += renderer.text_height(title, t_scale) + h * 0.03
215
216        _centre(renderer, grade, cx, y, g_scale, gcol)
217        y += renderer.text_height(grade, g_scale) + h * 0.02
218
219        if self.reason:
220            r_scale = renderer.fit_scale(self.reason, avail, base_scale=2.0)
221            _centre(renderer, self.reason, cx, y, r_scale, C.HUD_DIM)
222            y += renderer.text_height(self.reason, r_scale) + h * 0.02
223
224        y += h * 0.03
225        for line in lines:
226            _centre(renderer, line, cx, y, l_scale, C.HUD_FG)
227            y += renderer.text_height(line, l_scale) + h * 0.015
228
229        prompt = "ENTER  OR  CLICK  TO PLAY AGAIN"
230        if int(self._t * 2) % 2 == 0:
231            p_scale = renderer.fit_scale(prompt, avail, base_scale=3.0)
232            p_h = renderer.text_height(prompt, p_scale)
233            _centre(renderer, prompt, cx, h - p_h - h * 0.06, p_scale, C.YELLOW)