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)