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)