nodes/credits_screen.py

Part of Dungeon Explorer.

  1"""Animated credits screen with scrolling text."""
  2
  3from simvx.core import Control, Property
  4from simvx.core.input import MouseButton
  5from simvx.core.ui.enums import AnchorPreset
  6
  7from ._back_button import back_button_hit, draw_back_button
  8
  9
 10class CreditsScreen(Control):
 11    """Animated credits scroll. Skippable with Space/Escape/click."""
 12
 13    visible = Property(
 14        False,
 15        coerce=bool,
 16        hint="Whether this node and its subtree are drawn",
 17        on_change="_on_visible_changed",
 18    )
 19
 20    # on_draw renders text at self._scroll_y, which drifts every frame in
 21    # on_update -> retained 2D must re-run it each frame.
 22    dynamic = True
 23
 24    CREDITS = [
 25        ("DUNGEON EXPLORER", "title"),
 26        ("", ""),
 27        ("Design & Programming", "heading"),
 28        ("Built with SimVX Engine", "body"),
 29        ("", ""),
 30        ("Engine", "heading"),
 31        ("SimVX - Pure Python Game Engine", "body"),
 32        ("Vulkan GPU-Driven Rendering", "body"),
 33        ("Node-Based Scene Hierarchy", "body"),
 34        ("", ""),
 35        ("Art Direction", "heading"),
 36        ("Procedural Draw API Visuals", "body"),
 37        ("No External Assets Required", "body"),
 38        ("", ""),
 39        ("Audio", "heading"),
 40        ("Procedural Waveform Synthesis", "body"),
 41        ("", ""),
 42        ("Special Thanks", "heading"),
 43        ("The roguelike community", "body"),
 44        ("Classic dungeon crawlers", "body"),
 45        ("Open-source game development", "body"),
 46        ("", ""),
 47        ("", ""),
 48        ("Thank you for playing!", "title"),
 49    ]
 50
 51    def __init__(self, on_done=None, **kwargs):
 52        super().__init__(name="CreditsScreen", **kwargs)
 53        self.set_anchor_preset(AnchorPreset.FULL_RECT)
 54
 55        self._on_done = on_done
 56        self._scroll_y = 720.0
 57        self._timer = 0.0
 58
 59        self.cancel_requested.connect(self._fire_done)
 60
 61    # -- Public API --
 62
 63    def show(self):
 64        self._scroll_y = 720.0
 65        self._timer = 0.0
 66        self.show_overlay("blocking", dismiss=True)
 67
 68    def _fire_done(self):
 69        if self._on_done:
 70            self._on_done()
 71
 72    # -- Frame update --
 73
 74    def on_update(self, dt: float):
 75        self._timer += dt
 76        self._scroll_y -= dt * 40.0
 77        # Auto-close when scrolled past all content.
 78        if self._scroll_y < -len(self.CREDITS) * 40 and self.visible:
 79            self.cancel_requested.emit()
 80            self.close_overlay()
 81
 82    # -- Input --
 83
 84    def _on_gui_input(self, event):
 85        if not self.visible:
 86            return
 87        if event.button == MouseButton.LEFT and event.pressed:
 88            mx, my = float(event.position[0]), float(event.position[1])
 89            if back_button_hit(mx, my):
 90                self.cancel_requested.emit()
 91                self.close_overlay()
 92                event.handled = True
 93                return
 94            # Any click skips the credits.
 95            self.cancel_requested.emit()
 96            self.close_overlay()
 97            event.handled = True
 98        elif event.key and event.pressed and event.key in ("space", "enter", "return"):
 99            self.cancel_requested.emit()
100            self.close_overlay()
101            event.handled = True
102        # Esc handled by router → cancel_requested.
103
104    # -- Rendering --
105
106    def _screen_size(self) -> tuple[float, float]:
107        if self.tree is not None:
108            sw, sh = self.tree.screen_size
109            return float(sw), float(sh)
110        return 1280.0, 720.0
111
112    def on_draw(self, renderer):
113        if not self.visible:
114            return
115        sw, sh = self._screen_size()
116        renderer.draw_rect((0, 0), (sw, sh), colour=(0.03, 0.02, 0.05, 1.0), filled=True)
117
118        cx = sw / 2
119        y = self._scroll_y
120        # Reserve the bottom strip for the "Press Space to skip" footer so it
121        # never collides with the scrolling credits text.
122        footer_band = 50
123        scroll_bottom = sh - footer_band
124
125        for text, style in self.CREDITS:
126            if 0 < y < scroll_bottom and text:
127                if style == "title":
128                    renderer.draw_text(text, (cx - len(text) * 7, y), scale=2.0, colour=(1.0, 0.85, 0.2, 1.0))
129                elif style == "heading":
130                    renderer.draw_text(text, (cx - len(text) * 5, y), scale=1.3, colour=(0.9, 0.9, 0.9, 1.0))
131                else:
132                    renderer.draw_text(text, (cx - len(text) * 4, y), scale=1.0, colour=(0.6, 0.6, 0.6, 1.0))
133            y += 40
134
135        # Background strip behind the footer to mask the tail of any line
136        # passing through the band as it scrolls upward.
137        renderer.draw_rect((0, sh - footer_band), (sw, footer_band), colour=(0.03, 0.02, 0.05, 1.0), filled=True)
138        renderer.draw_text("Press Space to skip", (cx - 60, sh - 30), scale=0.8, colour=(0.3, 0.3, 0.3, 1.0))
139        draw_back_button(renderer)