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)