nodes/credits_screen.py

Part of Dungeon Explorer.

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