nodes/victory_screen.pyΒΆ
Part of Dungeon Explorer.
1"""Victory sequence: gold theme, particle fireworks, final stats, credits option."""
2
3import math
4import random
5
6from simvx.core import Control, Property
7from simvx.core.input import MouseButton
8from simvx.core.ui.enums import AnchorPreset
9
10
11class _Firework:
12 """Simple firework particle for victory effect."""
13
14 __slots__ = ("x", "y", "vx", "vy", "life", "colour")
15
16 def __init__(self, x: float, y: float, colour: tuple):
17 angle = random.uniform(0, 2 * math.pi)
18 speed = random.uniform(40, 140)
19 self.x = x
20 self.y = y
21 self.vx = math.cos(angle) * speed
22 self.vy = math.sin(angle) * speed
23 self.life = random.uniform(0.5, 1.5)
24 self.colour = colour
25
26
27class VictoryScreen(Control):
28 """Victory celebration overlay with gold theme and fireworks.
29
30 Routes via ``on_continue`` callback. Esc / outside-click is suppressed
31 until the 5 second reveal window closes; thereafter Space/Enter or a
32 bottom-area click triggers ``on_continue``.
33 """
34
35 visible = Property(
36 False,
37 coerce=bool,
38 hint="Whether this node and its subtree are drawn",
39 on_change="_on_visible_changed",
40 )
41
42 # on_draw animates every frame off self._timer + drifting fireworks
43 # particles -> retained 2D must re-run it each frame.
44 dynamic = True
45
46 def __init__(self, on_continue=None, **kwargs):
47 super().__init__(name="VictoryScreen", **kwargs)
48 self.set_anchor_preset(AnchorPreset.FULL_RECT)
49
50 self._on_continue = on_continue
51 self._timer = 0.0
52 self._player = None
53 self._dungeon_level = 1
54 self._fireworks: list[_Firework] = []
55 self._next_burst = 0.0
56 self._show_credits = False
57
58 # -- Public API --
59
60 def set_context(self, player=None, dungeon_level: int = 1) -> None:
61 self._player = player
62 self._dungeon_level = dungeon_level
63
64 def show(self):
65 self._timer = 0.0
66 self._fireworks.clear()
67 self._next_burst = 1.0
68 self._show_credits = False
69 self.show_overlay("blocking", dismiss=False)
70
71 # -- Frame update --
72
73 def on_update(self, dt: float):
74 self._timer += dt
75 self._update_fireworks(dt)
76
77 def _update_fireworks(self, dt: float):
78 if self._timer > 1.0:
79 self._next_burst -= dt
80 if self._next_burst <= 0:
81 self._spawn_burst()
82 self._next_burst = random.uniform(0.3, 0.8)
83
84 for p in self._fireworks:
85 p.x += p.vx * dt
86 p.y += p.vy * dt
87 p.vy += 60 * dt # gravity
88 p.life -= dt
89 self._fireworks = [p for p in self._fireworks if p.life > 0]
90
91 def _spawn_burst(self):
92 bx = random.uniform(200, 1080)
93 by = random.uniform(100, 350)
94 colours = [
95 (1.0, 0.85, 0.2, 1.0),
96 (1.0, 0.7, 0.1, 1.0),
97 (1.0, 1.0, 0.5, 1.0),
98 (0.9, 0.6, 0.0, 1.0),
99 ]
100 c = random.choice(colours)
101 for _ in range(random.randint(8, 16)):
102 self._fireworks.append(_Firework(bx, by, c))
103
104 # -- Input --
105
106 def _on_gui_input(self, event):
107 if not self.visible:
108 return
109
110 if self._show_credits:
111 if event.key and event.pressed and event.key in ("escape", "space", "enter", "return"):
112 self._show_credits = False
113 event.handled = True
114 elif event.button == MouseButton.LEFT and event.pressed:
115 self._show_credits = False
116 event.handled = True
117 return
118
119 if self._timer > 5.0:
120 if event.key and event.pressed:
121 if event.key in ("space", "enter", "return"):
122 self._continue()
123 event.handled = True
124 return
125 if event.key == "e":
126 self._show_credits = True
127 event.handled = True
128 return
129 if event.button == MouseButton.LEFT and event.pressed:
130 sh = self._screen_size()[1]
131 my = float(event.position[1])
132 if my > sh - 100:
133 self._continue()
134 event.handled = True
135 elif my > sh - 60:
136 self._show_credits = True
137 event.handled = True
138
139 def _continue(self):
140 self.cancel_requested.emit()
141 self.close_overlay()
142 if self._on_continue:
143 self._on_continue()
144
145 # -- Rendering --
146
147 def _screen_size(self) -> tuple[float, float]:
148 if self.tree is not None:
149 sw, sh = self.tree.screen_size
150 return float(sw), float(sh)
151 return 1280.0, 720.0
152
153 def on_draw(self, renderer):
154 if not self.visible:
155 return
156 sw, sh = self._screen_size()
157
158 if self._show_credits:
159 self._draw_credits(renderer, sw, sh)
160 return
161
162 alpha = min(1.0, self._timer / 2.0)
163 renderer.draw_rect((0, 0), (sw, sh), colour=(0.06, 0.04, 0.0, 0.9 * alpha), filled=True)
164
165 border_c = (0.6, 0.45, 0.1, 0.5 * alpha)
166 renderer.draw_rect((15, 15), (sw - 30, 3), colour=border_c, filled=True)
167 renderer.draw_rect((15, sh - 18), (sw - 30, 3), colour=border_c, filled=True)
168 renderer.draw_rect((15, 15), (3, sh - 30), colour=border_c, filled=True)
169 renderer.draw_rect((sw - 18, 15), (3, sh - 30), colour=border_c, filled=True)
170
171 cx = sw / 2
172
173 for p in self._fireworks:
174 a = min(1.0, p.life)
175 r, g, b, _ = p.colour
176 renderer.draw_circle((p.x, p.y), 2, colour=(r, g, b, a), filled=True)
177
178 if self._timer > 0.5:
179 t_a = min(1.0, (self._timer - 0.5) * 2)
180 glow = 0.85 + 0.15 * math.sin(self._timer * 2.5)
181 renderer.draw_text("VICTORY!", (cx - 60 + 2, 162), scale=3.0, colour=(0.0, 0.0, 0.0, t_a * 0.5))
182 renderer.draw_text("VICTORY!", (cx - 60, 160), scale=3.0, colour=(1.0, glow, 0.2, t_a))
183
184 if self._timer > 2.0:
185 renderer.draw_text(
186 "The Elder Dragon has been defeated!", (cx - 140, 260), scale=1.3, colour=(0.9, 0.9, 0.9, 1.0)
187 )
188
189 if self._timer > 3.0:
190 sy = 320
191 renderer.draw_text("-- Adventure Summary --", (cx - 80, sy), scale=1.1, colour=(1.0, 0.85, 0.3, 1.0))
192 sy += 28
193 if self._player:
194 stats = [
195 (f"Level: {self._player.level}", (0.9, 0.9, 0.9, 1.0)),
196 (f"Dungeon Floor: {self._dungeon_level}", (0.9, 0.9, 0.9, 1.0)),
197 (f"Gold Earned: {self._player.gold}", (1.0, 0.85, 0.2, 1.0)),
198 ]
199 for text, colour in stats:
200 renderer.draw_text(text, (cx - 70, sy), scale=1.0, colour=colour)
201 sy += 24
202
203 if self._timer > 3.5:
204 renderer.draw_text(
205 "The dungeon remains... endless depths await.", (cx - 160, 460), scale=1.1, colour=(0.6, 0.6, 0.7, 1.0)
206 )
207
208 if self._timer > 5.0:
209 pulse = 0.5 + 0.5 * math.sin(self._timer * 3.0)
210 renderer.draw_text(
211 "Press SPACE to continue (free play)", (cx - 130, sh - 80), scale=1.2, colour=(0.7, 0.7, 0.7, pulse)
212 )
213 renderer.draw_text("Press E for credits", (cx - 60, sh - 50), scale=0.9, colour=(0.5, 0.5, 0.5, 1.0))
214
215 def _draw_credits(self, renderer, sw: float, sh: float):
216 renderer.draw_rect((0, 0), (sw, sh), colour=(0.05, 0.04, 0.02, 1.0), filled=True)
217 cx = sw / 2
218 renderer.draw_text("CREDITS", (cx - 45, 80), scale=2.5, colour=(1.0, 0.85, 0.2, 1.0))
219
220 lines = [
221 ("Game Design & Programming", (0.9, 0.9, 0.9, 1.0)),
222 ("Built with SimVX Engine", (0.7, 0.7, 0.7, 1.0)),
223 ("", (0, 0, 0, 0)),
224 ("Thank you for playing!", (1.0, 0.9, 0.3, 1.0)),
225 ]
226 y = 180
227 for text, colour in lines:
228 if text:
229 renderer.draw_text(text, (cx - 120, y), scale=1.1, colour=colour)
230 y += 32
231
232 for p in self._fireworks:
233 a = min(1.0, p.life)
234 r, g, b, _ = p.colour
235 renderer.draw_circle((p.x, p.y), 2, colour=(r, g, b, a), filled=True)
236
237 renderer.draw_text("Press Space or Esc to return", (cx - 100, sh - 50), scale=0.9, colour=(0.4, 0.4, 0.4, 1.0))