nodes/title_screen.py¶
Part of Dungeon Explorer.
1"""Title screen: New Game / Continue / Quit with decorative styling."""
2
3import math
4from pathlib import Path
5
6from simvx.core import Anchor, Control, Property
7from simvx.core.input import Key, KeyEvent, Pointer, PointerEvent, PointerPhase
8
9from ._back_button import check_menu_click, draw_back_button
10
11VERSION = "0.4.0"
12
13
14class TitleScreen(Control):
15 """Title screen overlay: first thing shown on game start."""
16
17 visible = Property(
18 False,
19 coerce=bool,
20 hint="Whether this node and its subtree are drawn",
21 on_change="_on_visible_changed",
22 )
23
24 # on_draw animates every frame off self._timer (bg pulse, drifting motes,
25 # title glow, selector pulse) -> retained 2D must re-run it each frame.
26 dynamic = True
27
28 OPTIONS = ["New Game", "Continue", "Settings", "Controls", "Credits", "Quit"]
29
30 def __init__(self, on_action=None, save_path: Path | None = None, **kwargs):
31 super().__init__(name="TitleScreen", anchor=Anchor.FULL_RECT, **kwargs)
32
33 self._selected = 0
34 self._on_action = on_action
35 self._save_path = save_path
36 self._timer = 0.0
37 self._sub_screen: str | None = None # "controls" or "credits"
38
39 @property
40 def has_save(self) -> bool:
41 return self._save_path is not None and self._save_path.exists()
42
43 def show(self):
44 self._selected = 0
45 self._timer = 0.0
46 self._sub_screen = None
47 # Esc on title screen does nothing -> dismiss disabled.
48 self.show_overlay("blocking", dismiss=False)
49
50 # -- Frame update --
51
52 def on_update(self, dt: float):
53 self._timer += dt
54
55 # -- Input --
56
57 def on_gui_input(self, event):
58 if not self.visible:
59 return
60 if self._sub_screen is not None:
61 # Any click, Escape or confirm returns to the menu.
62 if isinstance(event, PointerEvent):
63 if event.button in Pointer.PRIMARY and event.phase is PointerPhase.DOWN:
64 self._sub_screen = None
65 return True
66 elif isinstance(event, KeyEvent) and event.pressed:
67 if event.key in (Key.ESCAPE, Key.SPACE, Key.ENTER):
68 self._sub_screen = None
69 return True
70 return None
71
72 if isinstance(event, PointerEvent):
73 if event.button in Pointer.PRIMARY and event.phase is PointerPhase.DOWN:
74 return self._handle_click(*event.position)
75 return None
76
77 if isinstance(event, KeyEvent) and event.pressed:
78 if event.key in (Key.W, Key.UP):
79 self._selected = max(self._selected - 1, 0)
80 return True
81 elif event.key in (Key.S, Key.DOWN):
82 self._selected = min(self._selected + 1, len(self.OPTIONS) - 1)
83 return True
84 elif event.key in (Key.SPACE, Key.ENTER, Key.E):
85 self._confirm()
86 return True
87
88 def _handle_click(self, mx: float, my: float) -> bool | None:
89 sw, sh = self._screen_size()
90 cx = sw / 2
91 _, _, oy, spacing, _ = self._menu_layout(sw, sh)
92 # Hit rect scales with the menu spacing; keep a wide click target on
93 # narrow viewports for finger-tap reach.
94 hit_w = min(max(sw * 0.55, 240.0), 480.0)
95 hit_h = spacing * 0.85
96 idx = check_menu_click(mx, my, len(self.OPTIONS), cx - hit_w / 2, oy, hit_w, hit_h, spacing)
97 if idx is not None:
98 self._selected = idx
99 self._confirm()
100 return True
101
102 def _confirm(self):
103 action = self.OPTIONS[self._selected].lower().replace(" ", "_")
104 if action == "settings":
105 if self._on_action:
106 self._on_action("settings")
107 return
108 if action == "controls":
109 self._sub_screen = "controls"
110 return
111 if action == "credits":
112 self._sub_screen = "credits"
113 return
114 if action == "continue" and not self.has_save:
115 return
116 if action == "new_game" and self.has_save:
117 if self._on_action:
118 self._on_action("confirm_new_game")
119 return
120 # new_game (no save), continue, quit: close before invoking the
121 # caller so the bridge doesn't see the title still in the overlay registry.
122 self.close_overlay()
123 if self._on_action:
124 self._on_action(action)
125
126 # -- Layout --
127
128 def _screen_size(self) -> tuple[float, float]:
129 if self.tree is not None:
130 sw, sh = self.tree.screen_size
131 return float(sw), float(sh)
132 return 1280.0, 720.0
133
134 # Bottom band reserved for the navigation hint + version label so neither
135 # ever touches the decorative border. 32px keeps a comfortable gap from
136 # the 10+3=13px border on every viewport.
137 FOOTER_H = 56
138
139 def _menu_layout(self, sw, sh):
140 """Continuous layout derived from viewport.
141
142 Returns (title_y, subtitle_y, options_y, spacing, scale). Same code
143 path drives mobile portrait through desktop: no aspect heuristic.
144 """
145 n = len(self.OPTIONS)
146 # Reserve a top band for the title/subtitle and a bottom band for the
147 # footer hint and version label.
148 top_pad = 30.0
149 title_band = max(min(sh * 0.22, 200.0), 90.0)
150 # Available vertical area for the option list (between title and footer).
151 options_area = sh - top_pad - title_band - self.FOOTER_H - 20.0
152 # Spacing scales with available space; clamped so options don't crowd
153 # on mobile and don't sprawl on wide desktops.
154 spacing = min(max(options_area / max(1, n), 36.0), 80.0)
155 # Option text scale follows spacing but is capped so labels never
156 # exceed the row height. Floor 1.1 keeps text legible on mobile.
157 scale = min(max(spacing / 30.0, 1.1), 2.2)
158 title_y = top_pad + title_band * 0.25
159 # Reserve space for the title block (~50px at scale 2.0+, more on
160 # large desktops where title_scale = min(3.0, sw/500)).
161 title_block_h = min(max(sh * 0.08, 44.0), 80.0)
162 subtitle_y = title_y + title_block_h
163 options_y = top_pad + title_band + 10
164 return title_y, subtitle_y, options_y, spacing, scale
165
166 # -- Rendering --
167
168 def on_draw(self, renderer):
169 if not self.visible:
170 return
171 sw, sh = self._screen_size()
172 if self._sub_screen == "controls":
173 self._draw_controls(renderer, sw, sh)
174 return
175 if self._sub_screen == "credits":
176 self._draw_credits(renderer, sw, sh)
177 return
178 self._draw_main(renderer, sw, sh)
179
180 def _draw_main(self, renderer, sw: float, sh: float):
181 t = self._timer
182 title_y, subtitle_y, oy, spacing, opt_scale = self._menu_layout(sw, sh)
183 title_scale = min(max(sw / 500, 1.4), 3.0)
184
185 pulse = 0.02 * math.sin(t * 0.5)
186 renderer.rect((0, 0), (sw, sh), fill=(0.05 + pulse, 0.04, 0.06, 1.0))
187
188 for i in range(12):
189 phase = t * 0.3 + i * 1.7
190 px = sw * 0.1 + (i * 107) % int(sw * 0.8)
191 py = sh * 0.2 + math.sin(phase) * 40 + (i * 73) % int(sh * 0.6)
192 alpha = 0.15 + 0.1 * math.sin(phase * 0.7)
193 renderer.circle((px, py), 2 + i % 3, fill=(0.6, 0.5, 0.2, alpha))
194
195 # Decorative border anchors to the viewport edges with a 10px inset.
196 border_c = (0.45, 0.35, 0.15, 0.6)
197 bw = 3
198 renderer.rect((10, 10), (sw - 20, bw), fill=border_c)
199 renderer.rect((10, sh - 10 - bw), (sw - 20, bw), fill=border_c)
200 renderer.rect((10, 10), (bw, sh - 20), fill=border_c)
201 renderer.rect((sw - 10 - bw, 10), (bw, sh - 20), fill=border_c)
202 for ccx, ccy in [(20, 20), (sw - 28, 20), (20, sh - 28), (sw - 28, sh - 28)]:
203 renderer.rect((ccx, ccy), (8, 8), fill=(0.6, 0.5, 0.2, 0.5))
204
205 # Title centred in the top band: fit-to-width handles narrow viewports.
206 renderer.text(
207 "DUNGEON EXPLORER",
208 fill=(0.0, 0.0, 0.0, 0.6),
209 scale=title_scale,
210 align="centre",
211 valign="top",
212 rect=(20, title_y + 2, sw - 40, 40),
213 fit_to_width=True,
214 min_scale=1.0,
215 )
216 glow = 0.85 + 0.15 * math.sin(t * 2.0)
217 renderer.text(
218 "DUNGEON EXPLORER",
219 fill=(1.0, glow, 0.2, 1.0),
220 scale=title_scale,
221 align="centre",
222 valign="top",
223 rect=(20, title_y, sw - 40, 40),
224 fit_to_width=True,
225 min_scale=1.0,
226 )
227 renderer.text(
228 "A descent into the depths...",
229 fill=(0.5, 0.5, 0.5, 1.0),
230 scale=1.0,
231 align="centre",
232 valign="top",
233 rect=(20, subtitle_y, sw - 40, 22),
234 fit_to_width=True,
235 min_scale=0.7,
236 )
237
238 # Centred option rows. Each row is a fit-to-width rect so long labels
239 # (e.g. "New Game") still fit on narrow viewports.
240 opt_w = min(max(sw * 0.7, 240.0), 520.0)
241 opt_x = (sw - opt_w) / 2
242 for i, opt in enumerate(self.OPTIONS):
243 if opt == "Continue" and not self.has_save:
244 colour = (0.3, 0.3, 0.3, 1.0) if i != self._selected else (0.4, 0.4, 0.3, 1.0)
245 elif i == self._selected:
246 sel_pulse = 0.9 + 0.1 * math.sin(t * 4.0)
247 colour = (1.0, sel_pulse, 0.3, 1.0)
248 else:
249 colour = (0.7, 0.7, 0.7, 1.0)
250 prefix = "> " if i == self._selected else " "
251 renderer.text(
252 f"{prefix}{opt}",
253 fill=colour,
254 scale=opt_scale,
255 align="centre",
256 valign="centre",
257 rect=(opt_x, oy + i * spacing, opt_w, spacing),
258 fit_to_width=True,
259 min_scale=0.9,
260 )
261
262 # Footer band: hint centred, version anchored to bottom-right with
263 # generous insets so neither touches the decorative border or corner
264 # marker on any viewport size.
265 footer_inset_x = 24.0
266 footer_top = sh - self.FOOTER_H
267 renderer.text(
268 "Arrow keys: Navigate Space: Select",
269 fill=(0.4, 0.4, 0.4, 1.0),
270 scale=0.9,
271 align="centre",
272 valign="centre",
273 rect=(footer_inset_x, footer_top, sw - 2 * footer_inset_x, self.FOOTER_H - 22),
274 fit_to_width=True,
275 min_scale=0.6,
276 )
277 # Version label: bottom-right corner, sitting in the lower half of the
278 # footer band with a 28px inset from the right edge.
279 version_w = 80.0
280 renderer.text(
281 f"v{VERSION}",
282 fill=(0.3, 0.3, 0.3, 1.0),
283 scale=0.8,
284 align="right",
285 valign="centre",
286 rect=(sw - version_w - 28, sh - 28, version_w, 16),
287 fit_to_width=True,
288 min_scale=0.6,
289 )
290
291 def _draw_controls(self, renderer, sw: float, sh: float):
292 renderer.rect((0, 0), (sw, sh), fill=(0.05, 0.04, 0.06, 1.0))
293 renderer.text(
294 "CONTROLS",
295 fill=(1.0, 0.85, 0.2, 1.0),
296 scale=2.5,
297 align="centre",
298 valign="top",
299 rect=(20, 50, sw - 40, 40),
300 fit_to_width=True,
301 min_scale=1.4,
302 )
303
304 controls = [
305 ("WASD / Arrow Keys", "Move"),
306 ("Space", "Attack / Confirm"),
307 ("Shift", "Dodge Roll"),
308 ("E", "Interact / Use Stairs"),
309 ("I", "Open Inventory"),
310 ("L", "Level Up (when points available)"),
311 ("K", "Skill Tree"),
312 ("J", "Quest Log"),
313 ("1-4", "Activate Abilities"),
314 ("Escape", "Pause / Back"),
315 ]
316 # Centre a 2-column layout under the title; key column left of the
317 # split, description column right. Row spacing scales with sh.
318 list_top = 120.0
319 list_bottom = sh - self.FOOTER_H
320 row_h = min(max((list_bottom - list_top) / len(controls), 24.0), 40.0)
321 # Use the same 70% panel width as the main menu options.
322 list_w = min(max(sw * 0.7, 280.0), 560.0)
323 list_x = (sw - list_w) / 2
324 key_w = list_w * 0.4
325 desc_x = list_x + key_w + 8
326 desc_w = list_x + list_w - desc_x
327 for i, (key, desc) in enumerate(controls):
328 ry = list_top + i * row_h
329 renderer.text(
330 key,
331 fill=(1.0, 0.9, 0.3, 1.0),
332 scale=1.1,
333 align="left",
334 valign="centre",
335 rect=(list_x, ry, key_w, row_h),
336 fit_to_width=True,
337 min_scale=0.7,
338 )
339 renderer.text(
340 desc,
341 fill=(0.7, 0.7, 0.7, 1.0),
342 scale=1.0,
343 align="left",
344 valign="centre",
345 rect=(desc_x, ry, desc_w, row_h),
346 fit_to_width=True,
347 min_scale=0.7,
348 )
349
350 footer_inset_x = 24.0
351 footer_top = sh - self.FOOTER_H
352 renderer.text(
353 "Press Space or Esc to return",
354 fill=(0.4, 0.4, 0.4, 1.0),
355 scale=0.9,
356 align="centre",
357 valign="centre",
358 rect=(footer_inset_x, footer_top, sw - 2 * footer_inset_x, self.FOOTER_H - 22),
359 fit_to_width=True,
360 min_scale=0.6,
361 )
362 draw_back_button(renderer)
363
364 def _draw_credits(self, renderer, sw: float, sh: float):
365 renderer.rect((0, 0), (sw, sh), fill=(0.05, 0.04, 0.06, 1.0))
366 renderer.text(
367 "CREDITS",
368 fill=(1.0, 0.85, 0.2, 1.0),
369 scale=2.5,
370 align="centre",
371 valign="top",
372 rect=(20, 70, sw - 40, 40),
373 fit_to_width=True,
374 min_scale=1.4,
375 )
376
377 credits_lines = [
378 ("Game Design & Programming", (0.9, 0.9, 0.9, 1.0)),
379 ("Built with SimVX Engine", (0.7, 0.7, 0.7, 1.0)),
380 ("", (0, 0, 0, 0)),
381 ("Engine", (0.9, 0.9, 0.9, 1.0)),
382 ("SimVX - Python Game Engine", (0.7, 0.7, 0.7, 1.0)),
383 ("Vulkan Rendering Backend", (0.7, 0.7, 0.7, 1.0)),
384 ("", (0, 0, 0, 0)),
385 ("Inspiration", (0.9, 0.9, 0.9, 1.0)),
386 ("Classic dungeon crawlers", (0.7, 0.7, 0.7, 1.0)),
387 ("Roguelike tradition", (0.7, 0.7, 0.7, 1.0)),
388 ]
389 list_top = 160.0
390 list_bottom = sh - self.FOOTER_H
391 row_h = min(max((list_bottom - list_top) / len(credits_lines), 22.0), 36.0)
392 list_w = min(max(sw * 0.7, 260.0), 520.0)
393 list_x = (sw - list_w) / 2
394 for i, (text, colour) in enumerate(credits_lines):
395 if not text:
396 continue
397 renderer.text(
398 text,
399 fill=colour,
400 scale=1.1,
401 align="centre",
402 valign="centre",
403 rect=(list_x, list_top + i * row_h, list_w, row_h),
404 fit_to_width=True,
405 min_scale=0.7,
406 )
407
408 footer_inset_x = 24.0
409 footer_top = sh - self.FOOTER_H
410 renderer.text(
411 "Press Space or Esc to return",
412 fill=(0.4, 0.4, 0.4, 1.0),
413 scale=0.9,
414 align="centre",
415 valign="centre",
416 rect=(footer_inset_x, footer_top, sw - 2 * footer_inset_x, self.FOOTER_H - 22),
417 fit_to_width=True,
418 min_scale=0.6,
419 )
420 draw_back_button(renderer)