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))