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)