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)