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)