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