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.text(text, (x + off, y + off), fill=shadow, scale=scale)
 30    renderer.text(text, (x, y), fill=colour, scale=scale)
 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.rect((cx + ux * s, top + uy * s), (uw * s, uh * s), fill=col)
 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.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.rect((0, 0), (w, h), fill=C.BG)
 75        renderer.rect((0, 0), (w, h * 0.42), fill=(0.10, 0.08, 0.12, 0.5))
 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.rect(
 90            (0, title_y - t_h * 0.2), (w, t_h * 1.5), fill=(C.PLAYER_HAT[0], C.PLAYER_HAT[1], C.PLAYER_HAT[2], glow)
 91        )
 92
 93        _helmet(renderer, cx, title_y - helm_h - 6, helm_s)
 94        _centre(renderer, title, cx, title_y, t_scale, C.PLAYER_HAT, shadow=(0.15, 0.02, 0.02, 0.9))
 95
 96        sub = "FIREFIGHT THE BUILDING"
 97        s_scale = renderer.fit_scale(sub, avail * 0.9, base_scale=3.0)
 98        sub_y = title_y + t_h + h * 0.035
 99        s_h = renderer.text_height(sub, s_scale)
100        _centre(renderer, sub, cx, sub_y, s_scale, C.HUD_FG)
101        # Accent rule under the subtitle.
102        rule_w = renderer.text_width(sub, s_scale)
103        renderer.rect((cx - rule_w / 2, sub_y + s_h + 6), (rule_w, 2), fill=C.PLAYER_HAT)
104
105        # ---- Prompt (bottom): placed first so the panel can centre above it.
106        prompt = "PRESS  ENTER  OR  CLICK  TO BEGIN"
107        p_scale = renderer.fit_scale(prompt, avail, base_scale=3.0)
108        p_h = renderer.text_height(prompt, p_scale)
109        esc_h = renderer.text_height("ESC  QUIT", 2.0)
110        esc_y = h - esc_h - h * 0.05
111        prompt_y = esc_y - p_h - h * 0.03
112        if int(self._t * 2) % 2 == 0:
113            _centre(renderer, prompt, cx, prompt_y, p_scale, C.YELLOW)
114        _centre(renderer, "ESC  QUIT", cx, esc_y, 2.0, C.HUD_DIM)
115
116        # ---- Controls panel (centred in the gap between subtitle and prompt) ----
117        rows = [
118            ("ARROWS / WASD", "MOVE  CLIMB  AIM"),
119            ("SPACE", "JUMP"),
120            ("SHIFT", "SPRAY WATER"),
121            ("E", "GRAB / THROW CIVILIAN"),
122        ]
123        pw = min(avail, 620.0)
124        pad = pw * 0.05
125        key_col = pw * 0.42
126        desc_col = pw - key_col - pad * 2
127        # One content scale that fits both the widest key and the widest desc.
128        cs = min(
129            renderer.fit_scale("ARROWS / WASD", key_col, base_scale=2.0),
130            renderer.fit_scale("GRAB / THROW CIVILIAN", desc_col, base_scale=2.0),
131        )
132        row_h = renderer.text_height("X", cs) + max(16.0, cs * 9.0)
133        panel_h = pad * 2 + row_h * len(rows)
134        px = cx - pw / 2
135        gap_top = sub_y + s_h + h * 0.04
136        py = max(gap_top, (gap_top + (prompt_y - h * 0.03)) / 2 - panel_h / 2)
137
138        panel_col = (C.HUD_BG[0], C.HUD_BG[1], C.HUD_BG[2], 0.7)
139        renderer.rect((px, py), (pw, panel_h), fill=panel_col)
140        renderer.rect((px, py), (pw, panel_h), stroke=C.HUD_BORDER, width=2)
141        renderer.rect((px, py), (4, panel_h), fill=C.PLAYER_HAT)  # accent edge
142        for i, (key, desc) in enumerate(rows):
143            ly = py + pad + i * row_h
144            renderer.text(key, (px + pad, ly), fill=C.YELLOW, scale=cs)
145            renderer.text(desc, (px + pad + key_col, ly), fill=C.HUD_FG, scale=cs)
146
147
148class EndScreen(Node):
149    restart = Signal()
150
151    # ``on_draw`` blinks the "press start" prompt via ``int(self._t * 2) % 2`` from
152    # the non-Property ``_t`` advanced every tick, so it genuinely redraws every
153    # frame: declare it dynamic so the prompt keeps blinking.
154    dynamic = True
155
156    def __init__(
157        self,
158        *,
159        victory: bool,
160        score: int,
161        rescued: int,
162        civilians_total: int,
163        casualties: int = 0,
164        reason: str = "",
165        **kwargs,
166    ):
167        super().__init__(**kwargs)
168        self.victory = victory
169        self.score = score
170        self.rescued = rescued
171        self.civilians_total = civilians_total
172        self.casualties = casualties
173        self.reason = reason
174        self._t = 0.0
175
176    def on_update(self, dt: float):
177        self._t += dt
178        if Input.just_pressed("start"):
179            self.restart.emit()
180
181    def _grade(self) -> tuple[str, tuple]:
182        if self.victory and self.casualties == 0:
183            return "PERFECT RESCUE", C.GREEN
184        if self.victory:
185            return "GOOD WORK", C.YELLOW
186        if self.rescued > 0:
187            return "PARTIAL RESCUE", C.YELLOW
188        return "TRY AGAIN", C.RED
189
190    def on_draw(self, renderer):
191        w, h = _screen(self)
192        renderer.rect((0, 0), (w, h), fill=C.BG)
193        cx = w / 2
194        margin = max(24.0, w * 0.06)
195        avail = w - 2 * margin
196
197        # Stacked block: each line advances the cursor by its own measured height
198        # plus a gap, so nothing lands on top of the line above it at any size.
199        title = "RESCUE COMPLETE!" if self.victory else "GAME OVER"
200        t_scale = renderer.fit_scale(title, avail, base_scale=6.0)
201        grade, gcol = self._grade()
202        g_scale = renderer.fit_scale(grade, avail, base_scale=3.0)
203        lines = [
204            f"SCORE     {self.score}",
205            f"RESCUED     {self.rescued} / {self.civilians_total}",
206        ]
207        l_scale = min(renderer.fit_scale(line, avail, base_scale=3.0) for line in lines)
208
209        y = h * 0.14
210        _centre(renderer, title, cx, y, t_scale, C.GREEN if self.victory else C.RED, shadow=(0.15, 0.02, 0.02, 0.9))
211        y += renderer.text_height(title, t_scale) + h * 0.03
212
213        _centre(renderer, grade, cx, y, g_scale, gcol)
214        y += renderer.text_height(grade, g_scale) + h * 0.02
215
216        if self.reason:
217            r_scale = renderer.fit_scale(self.reason, avail, base_scale=2.0)
218            _centre(renderer, self.reason, cx, y, r_scale, C.HUD_DIM)
219            y += renderer.text_height(self.reason, r_scale) + h * 0.02
220
221        y += h * 0.03
222        for line in lines:
223            _centre(renderer, line, cx, y, l_scale, C.HUD_FG)
224            y += renderer.text_height(line, l_scale) + h * 0.015
225
226        prompt = "ENTER  OR  CLICK  TO PLAY AGAIN"
227        if int(self._t * 2) % 2 == 0:
228            p_scale = renderer.fit_scale(prompt, avail, base_scale=3.0)
229            p_h = renderer.text_height(prompt, p_scale)
230            _centre(renderer, prompt, cx, h - p_h - h * 0.06, p_scale, C.YELLOW)