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 Control, Property
7from simvx.core.input import MouseButton
8from simvx.core.ui.enums import AnchorPreset
9
10from ._back_button import check_menu_click, draw_back_button
11
12VERSION = "0.4.0"
13
14
15class TitleScreen(Control):
16 """Title screen overlay: first thing shown on game start."""
17
18 visible = Property(
19 False,
20 coerce=bool,
21 hint="Whether this node and its subtree are drawn",
22 on_change="_on_visible_changed",
23 )
24
25 # on_draw animates every frame off self._timer (bg pulse, drifting motes,
26 # title glow, selector pulse) -> retained 2D must re-run it each frame.
27 dynamic = True
28
29 OPTIONS = ["New Game", "Continue", "Settings", "Controls", "Credits", "Quit"]
30
31 def __init__(self, on_action=None, save_path: Path | None = None, **kwargs):
32 super().__init__(name="TitleScreen", **kwargs)
33 self.set_anchor_preset(AnchorPreset.FULL_RECT)
34
35 self._selected = 0
36 self._on_action = on_action
37 self._save_path = save_path
38 self._timer = 0.0
39 self._sub_screen: str | None = None # "controls" or "credits"
40
41 @property
42 def has_save(self) -> bool:
43 return self._save_path is not None and self._save_path.exists()
44
45 def show(self):
46 self._selected = 0
47 self._timer = 0.0
48 self._sub_screen = None
49 # Esc on title screen does nothing -> dismiss disabled.
50 self.show_overlay("blocking", dismiss=False)
51
52 # -- Frame update --
53
54 def on_update(self, dt: float):
55 self._timer += dt
56
57 # -- Input --
58
59 def _on_gui_input(self, event):
60 if not self.visible:
61 return
62 if self._sub_screen is not None:
63 if event.key and event.pressed and event.key in ("escape", "space", "enter", "return"):
64 self._sub_screen = None
65 event.handled = True
66 return
67 if event.button == MouseButton.LEFT and event.pressed:
68 self._sub_screen = None
69 event.handled = True
70 return
71 return
72
73 if event.button == MouseButton.LEFT and event.pressed:
74 self._handle_click(float(event.position[0]), float(event.position[1]), event)
75 return
76
77 if event.key and event.pressed:
78 if event.key in ("w", "up"):
79 self._selected = max(self._selected - 1, 0)
80 event.handled = True
81 elif event.key in ("s", "down"):
82 self._selected = min(self._selected + 1, len(self.OPTIONS) - 1)
83 event.handled = True
84 elif event.key in ("space", "enter", "return", "e"):
85 self._confirm()
86 event.handled = True
87
88 def _handle_click(self, mx: float, my: float, event):
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 event.handled = 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.draw_rect((0, 0), (sw, sh), colour=(0.05 + pulse, 0.04, 0.06, 1.0), filled=True)
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.draw_circle((px, py), 2 + i % 3, colour=(0.6, 0.5, 0.2, alpha), filled=True)
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.draw_rect((10, 10), (sw - 20, bw), colour=border_c, filled=True)
199 renderer.draw_rect((10, sh - 10 - bw), (sw - 20, bw), colour=border_c, filled=True)
200 renderer.draw_rect((10, 10), (bw, sh - 20), colour=border_c, filled=True)
201 renderer.draw_rect((sw - 10 - bw, 10), (bw, sh - 20), colour=border_c, filled=True)
202 for ccx, ccy in [(20, 20), (sw - 28, 20), (20, sh - 28), (sw - 28, sh - 28)]:
203 renderer.draw_rect((ccx, ccy), (8, 8), colour=(0.6, 0.5, 0.2, 0.5), filled=True)
204
205 # Title centred in the top band: fit-to-width handles narrow viewports.
206 renderer.draw_text(
207 "DUNGEON EXPLORER",
208 rect=(20, title_y + 2, sw - 40, 40),
209 scale=title_scale,
210 alignment="center",
211 vertical_alignment="top",
212 fit_to_width=True,
213 min_scale=1.0,
214 colour=(0.0, 0.0, 0.0, 0.6),
215 )
216 glow = 0.85 + 0.15 * math.sin(t * 2.0)
217 renderer.draw_text(
218 "DUNGEON EXPLORER",
219 rect=(20, title_y, sw - 40, 40),
220 scale=title_scale,
221 alignment="center",
222 vertical_alignment="top",
223 fit_to_width=True,
224 min_scale=1.0,
225 colour=(1.0, glow, 0.2, 1.0),
226 )
227 renderer.draw_text(
228 "A descent into the depths...",
229 rect=(20, subtitle_y, sw - 40, 22),
230 scale=1.0,
231 alignment="center",
232 vertical_alignment="top",
233 fit_to_width=True,
234 min_scale=0.7,
235 colour=(0.5, 0.5, 0.5, 1.0),
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.draw_text(
252 f"{prefix}{opt}",
253 rect=(opt_x, oy + i * spacing, opt_w, spacing),
254 scale=opt_scale,
255 alignment="center",
256 vertical_alignment="center",
257 fit_to_width=True,
258 min_scale=0.9,
259 colour=colour,
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.draw_text(
268 "Arrow keys: Navigate Space: Select",
269 rect=(footer_inset_x, footer_top, sw - 2 * footer_inset_x, self.FOOTER_H - 22),
270 scale=0.9,
271 alignment="center",
272 vertical_alignment="center",
273 fit_to_width=True,
274 min_scale=0.6,
275 colour=(0.4, 0.4, 0.4, 1.0),
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.draw_text(
281 f"v{VERSION}",
282 rect=(sw - version_w - 28, sh - 28, version_w, 16),
283 scale=0.8,
284 alignment="right",
285 vertical_alignment="center",
286 fit_to_width=True,
287 min_scale=0.6,
288 colour=(0.3, 0.3, 0.3, 1.0),
289 )
290
291 def _draw_controls(self, renderer, sw: float, sh: float):
292 renderer.draw_rect((0, 0), (sw, sh), colour=(0.05, 0.04, 0.06, 1.0), filled=True)
293 renderer.draw_text(
294 "CONTROLS",
295 rect=(20, 50, sw - 40, 40),
296 scale=2.5,
297 alignment="center",
298 vertical_alignment="top",
299 fit_to_width=True,
300 min_scale=1.4,
301 colour=(1.0, 0.85, 0.2, 1.0),
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.draw_text(
330 key,
331 rect=(list_x, ry, key_w, row_h),
332 scale=1.1,
333 alignment="left",
334 vertical_alignment="center",
335 fit_to_width=True,
336 min_scale=0.7,
337 colour=(1.0, 0.9, 0.3, 1.0),
338 )
339 renderer.draw_text(
340 desc,
341 rect=(desc_x, ry, desc_w, row_h),
342 scale=1.0,
343 alignment="left",
344 vertical_alignment="center",
345 fit_to_width=True,
346 min_scale=0.7,
347 colour=(0.7, 0.7, 0.7, 1.0),
348 )
349
350 footer_inset_x = 24.0
351 footer_top = sh - self.FOOTER_H
352 renderer.draw_text(
353 "Press Space or Esc to return",
354 rect=(footer_inset_x, footer_top, sw - 2 * footer_inset_x, self.FOOTER_H - 22),
355 scale=0.9,
356 alignment="center",
357 vertical_alignment="center",
358 fit_to_width=True,
359 min_scale=0.6,
360 colour=(0.4, 0.4, 0.4, 1.0),
361 )
362 draw_back_button(renderer)
363
364 def _draw_credits(self, renderer, sw: float, sh: float):
365 renderer.draw_rect((0, 0), (sw, sh), colour=(0.05, 0.04, 0.06, 1.0), filled=True)
366 renderer.draw_text(
367 "CREDITS",
368 rect=(20, 70, sw - 40, 40),
369 scale=2.5,
370 alignment="center",
371 vertical_alignment="top",
372 fit_to_width=True,
373 min_scale=1.4,
374 colour=(1.0, 0.85, 0.2, 1.0),
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.draw_text(
398 text,
399 rect=(list_x, list_top + i * row_h, list_w, row_h),
400 scale=1.1,
401 alignment="center",
402 vertical_alignment="center",
403 fit_to_width=True,
404 min_scale=0.7,
405 colour=colour,
406 )
407
408 footer_inset_x = 24.0
409 footer_top = sh - self.FOOTER_H
410 renderer.draw_text(
411 "Press Space or Esc to return",
412 rect=(footer_inset_x, footer_top, sw - 2 * footer_inset_x, self.FOOTER_H - 22),
413 scale=0.9,
414 alignment="center",
415 vertical_alignment="center",
416 fit_to_width=True,
417 min_scale=0.6,
418 colour=(0.4, 0.4, 0.4, 1.0),
419 )
420 draw_back_button(renderer)