Source code for simvx.core.ui.splash

"""Boot / loading splash screen (design/splash_screen_design.md).

:class:`SplashScreen` is a full-screen anchored Control drawn through the normal
2D UI pass, so it renders identically on the desktop (Vulkan) and web (WebGPU)
backends and in the headless test harness. The desktop ``FrameLoop`` presents it
across the first-frame pipeline compile; games reuse the same control for heavy
scene transitions by binding ``progress`` to an asset ``BatchHandle``.

Sequence (the user-approved boot look): the whole splash fades in from black,
holds while loading with a segmented gradient progress bar, fades back to black
once loading is complete and the minimum display time has elapsed, then the
black backdrop itself fades out to reveal the scene. Fade phases advance on
frame ``dt`` (smooth at any frame rate); the minimum-display floor is measured
in wall-clock time so a synchronous load stall (desktop pipeline compile, no
frames presented) still counts toward it.

The branded face (wordmark, gradient, credit) comes from
:mod:`simvx.core.branding`; whether it appears at all is the branding gate's
decision, resolved by the driving app, not here.
"""

from __future__ import annotations

import time
from collections.abc import Callable
from typing import Any

from .. import branding
from ..signals import Signal
from .core import Control
from .enums import AnchorPreset

__all__ = ["SplashScreen", "AttributionWatermark"]


def _hex_rgb(hex_str: str) -> tuple[float, float, float]:
    """Parse ``#rrggbb`` into an RGB float triple."""
    h = hex_str.lstrip("#")
    return (int(h[0:2], 16) / 255.0, int(h[2:4], 16) / 255.0, int(h[4:6], 16) / 255.0)


_GRADIENT_LEFT = _hex_rgb(branding.BRAND_GRADIENT[0])
_GRADIENT_RIGHT = _hex_rgb(branding.BRAND_GRADIENT[1])


def _lerp_rgb(t: float) -> tuple[float, float, float]:
    """Sample the brand gradient at ``t`` in [0, 1] (left colour to right colour)."""
    la, lb = _GRADIENT_LEFT, _GRADIENT_RIGHT
    return (la[0] + (lb[0] - la[0]) * t, la[1] + (lb[1] - la[1]) * t, la[2] + (lb[2] - la[2]) * t)


def _smoothstep(t: float) -> float:
    t = max(0.0, min(1.0, t))
    return t * t * (3.0 - 2.0 * t)


class _FullscreenOverlay(Control):
    """A Control that always covers its render target and draws on top.

    On entering the tree it registers itself on the overlay layer (modality
    ``"none"``: drawn above everything by the overlay collector, captures no
    input), and while registered its rect resolves to the FULL render target
    (the enclosing SubViewport, else the screen) regardless of how small or
    unanchored its parent is. That is the defining semantic of a splash or
    watermark: ``add_child`` it anywhere and it covers the screen; a parent's
    100x30 default size must never shrink it. As a tree ROOT (test harnesses)
    it degrades to plain FULL_RECT anchoring, drawn by the normal root walk.
    """

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.set_anchor_preset(AnchorPreset.FULL_RECT)  # the root/fallback case
        self.mouse_filter = False  # never intercept the scene's input

    def on_enter_tree(self) -> None:
        tree = self._tree
        if tree is not None and tree.root is not self and not self._is_overlay:
            self.show_overlay("none")

    def _cover_rect(self) -> tuple[float, float, float, float] | None:
        """The full rect of this overlay's render target, or None if unresolvable."""
        node = self.parent
        while node is not None:
            if getattr(node, "_is_subviewport", False):
                size = getattr(node, "size", None)
                if size is not None:
                    return (0.0, 0.0, float(size[0]), float(size[1]))
                break
            node = getattr(node, "parent", None)
        if self._tree is not None:
            w, h = self._tree.screen_size
            return (0.0, 0.0, float(w), float(h))
        return None

    def get_rect(self) -> tuple[float, float, float, float]:
        if self._is_overlay:
            rect = self._cover_rect()
            if rect is not None:
                return rect
        return super().get_rect()

    def get_global_rect(self) -> tuple[float, float, float, float]:
        if self._is_overlay:
            rect = self._cover_rect()
            if rect is not None:
                return rect
        return super().get_global_rect()


[docs] class SplashScreen(_FullscreenOverlay): """Full-screen boot/loading splash, drawn via the 2D UI pass on every backend. States: ``fade_in`` (content fades up from black) -> ``loading`` (hold, bar advances) -> ``fade_out`` (content fades back to black) -> ``reveal`` (the black backdrop fades off the scene) -> ``done`` (:attr:`finished` emitted). Dismissal is ``max(load complete, min_display_time)``: the splash leaves as soon as loading is done once the floor is met, and never flashes sub-second on fast hardware. ``notify_loaded()`` marks loading complete; a ``progress`` source reaching 1.0 does the same automatically. Example (scene transition):: batch = assets.load_batch([...]) splash = SplashScreen(progress=batch, min_display_time=0.5) root.add_child(splash) splash.finished.connect(splash.destroy) """ _draw_caching = True # frozen hold frames replay the cached draw # Fixed backdrop: near-black with a hint of the brand green (the approved look). # Values are linear (the 2D pass renders into the HDR chain): ~#0b0f0d on screen. BACKGROUND: tuple[float, float, float, float] = (0.0035, 0.0055, 0.0045, 1.0) _GRID_SPACING = 48.0 _BAR_SEGMENTS = 28 def __init__( self, *, logo: str | int | None = None, logo_size: tuple[float, float] | None = None, progress: Callable[[], float] | Any | None = None, min_display_time: float = 1.0, fade_in_time: float = 0.25, fade_out_time: float = 0.25, skippable: bool = False, attribution: bool = True, **kwargs, ): """Create a splash screen. Args: logo: ``None`` draws the branded SimVX wordmark. A ``str`` image path (``pkg://`` or filesystem) or an ``int`` bindless texture id replaces it with a game logo (the SimVX credit then falls back to the persistent watermark, enforced by the driving app). logo_size: Pixel size for a custom ``logo``; ``None`` fits a box of 35% of the screen's short side. progress: A ``() -> float`` callable or any object with a ``progress`` attribute in [0, 1] (e.g. an asset ``BatchHandle``). ``None`` renders an indeterminate shimmer until :meth:`set_progress` or :meth:`notify_loaded` is called. min_display_time: Minimum total on-screen time in seconds, fades included (anti-flicker floor). ``0`` dismisses as soon as loaded. fade_in_time / fade_out_time: Fade-from-black / fade-to-black durations in seconds. The reveal (backdrop off the scene) reuses ``fade_out_time``. skippable: When True, any key/click after loading completes skips the remaining hold (never before loading is done). attribution: Render the "Made with SimVX" credit line (the branding gate may rely on it; see LICENSE-EXCEPTION.md). """ super().__init__(**kwargs) self.logo = logo self.logo_size = logo_size self.min_display_time = float(min_display_time) self.fade_in_time = float(fade_in_time) self.fade_out_time = float(fade_out_time) self.skippable = bool(skippable) self.attribution = bool(attribution) #: Emitted once, when the reveal completes and the splash is gone. self.finished = Signal() self._progress_source = progress self._milestone = 0.0 # target set via set_progress() self._determinate = progress is not None self._displayed = 0.0 # eased value actually drawn self._caption = "" self._loaded = False self._skip_requested = False self._state = "fade_in" self._phase_t = 0.0 # seconds into the current fade phase (dt-driven) self._content_alpha = 0.0 self._backdrop_alpha = 1.0 self._shown_wall: float | None = None self._now_fn: Callable[[], float] = time.perf_counter # ------------------------------------------------------------------ control API
[docs] @property def state(self) -> str: """Current phase: ``fade_in`` / ``loading`` / ``fade_out`` / ``reveal`` / ``done``.""" return self._state
[docs] @property def loaded(self) -> bool: """Whether loading has been reported complete.""" return self._loaded
[docs] def set_progress(self, fraction: float, caption: str | None = None) -> None: """Advance the milestone-driven bar to ``fraction`` in [0, 1]. The bar becomes determinate on first call and only ever moves forward. ``caption`` (when given) replaces the stage line under the bar. Reaching 1.0 marks loading complete. """ self._determinate = True self._milestone = max(self._milestone, max(0.0, min(1.0, float(fraction)))) if caption is not None and caption != self._caption: self._caption = caption self.queue_redraw() if self._milestone >= 1.0: self.notify_loaded()
[docs] def notify_loaded(self) -> None: """Mark loading complete: the splash dismisses once the min-display floor is met.""" if not self._loaded: self._loaded = True self._milestone = 1.0 self._determinate = True
[docs] def skip(self) -> None: """Skip the remaining hold (honoured only after loading is complete).""" if self._loaded: self._skip_requested = True
# ------------------------------------------------------------------ lifecycle
[docs] def on_ready(self) -> None: self._shown_wall = self._now_fn()
[docs] def on_update(self, dt: float) -> None: if self._state == "done": return # Pull an external progress source (callable or .progress object). src = self._progress_source if src is not None: value = src() if callable(src) else getattr(src, "progress", 0.0) self.set_progress(float(value)) target = self._milestone if self._determinate else 0.0 redraw = False # Ease the displayed bar toward its target (only moves on presented frames, # so it never fakes progress during a synchronous stall). if self._determinate and abs(target - self._displayed) > 0.001: self._displayed += (target - self._displayed) * min(1.0, dt * 8.0) if target - self._displayed < 0.002: self._displayed = target redraw = True elif not self._determinate: redraw = True # shimmer animates off tree.now if self._state == "fade_in": self._phase_t += dt t = 1.0 if self.fade_in_time <= 0 else self._phase_t / self.fade_in_time self._content_alpha = _smoothstep(t) redraw = True if t >= 1.0: self._state = "loading" self._phase_t = 0.0 elif self._state == "loading": if self._dismiss_due(): self._state = "fade_out" self._phase_t = 0.0 elif self._state == "fade_out": self._phase_t += dt t = 1.0 if self.fade_out_time <= 0 else self._phase_t / self.fade_out_time self._content_alpha = 1.0 - _smoothstep(t) redraw = True if t >= 1.0: self._state = "reveal" self._phase_t = 0.0 elif self._state == "reveal": self._phase_t += dt t = 1.0 if self.fade_out_time <= 0 else self._phase_t / self.fade_out_time self._backdrop_alpha = 1.0 - _smoothstep(t) redraw = True if t >= 1.0: self._state = "done" self.visible = False self.finished.emit() if redraw and self._state != "done": self.queue_redraw()
def _dismiss_due(self) -> bool: """Whether the hold should end: loaded AND the min-display floor is met. The floor is wall-clock (a frozen synchronous stall still counts) and is scheduled so the fade-out lands ON the floor, keeping total on-screen time at ``max(load, min_display_time)``. """ if not self._loaded: return False if self._skip_requested: return True if self._shown_wall is None: return True elapsed = self._now_fn() - self._shown_wall return elapsed >= self.min_display_time - self.fade_out_time
[docs] def on_unhandled_input(self, event) -> None: if self.skippable and self._loaded and getattr(event, "pressed", False): self.skip()
# ------------------------------------------------------------------ drawing
[docs] def on_draw(self, renderer) -> None: x, y, w, h = self.get_global_rect() ca = self._content_alpha ba = self._backdrop_alpha if ba > 0.0: bg = self.BACKGROUND renderer.draw_rect((x, y), (w, h), colour=(bg[0], bg[1], bg[2], ba), filled=True) if ca <= 0.0: return cx = x + w / 2.0 cy = y + h / 2.0 self._draw_grid(renderer, x, y, w, h, cx, cy, ca) # Wordmark block sits above centre; the bar block below it. px = max(34.0, min(76.0, min(w * 0.085, h * 0.12))) if self.logo is None: self._draw_wordmark(renderer, cx, cy - px * 1.1, px, ca) else: self._draw_logo(renderer, cx, cy - px * 1.1, w, h, ca) self._draw_bar(renderer, cx, cy + px * 0.7, w, ca) if self.attribution and branding.show_splash(): self._draw_credit(renderer, cx, y + h, ca)
def _draw_grid(self, renderer, x, y, w, h, cx, cy, ca) -> None: """Faint square grid, brightest at screen centre, dissolving to the edges.""" spacing = self._GRID_SPACING line = (_GRADIENT_LEFT[0], _GRADIENT_LEFT[1], _GRADIENT_LEFT[2]) falloff = 0.62 * max(w, h) base = 0.06 * ca gy = y + (spacing - (cy - y) % spacing) % spacing while gy <= y + h: gx = x + (spacing - (cx - x) % spacing) % spacing while gx <= x + w: dx, dy = gx - cx, gy - cy fade = max(0.0, 1.0 - (dx * dx + dy * dy) ** 0.5 / falloff) a = base * fade * fade if a > 0.004: c = (line[0], line[1], line[2], a) renderer.draw_line((gx - spacing, gy), (gx, gy), colour=c) renderer.draw_line((gx, gy - spacing), (gx, gy), colour=c) gx += spacing gy += spacing def _draw_wordmark(self, renderer, cx, cy, px, ca) -> None: """The SIMVX wordmark, letter-spaced, gradient green (left) to blue (right).""" text = branding.WORDMARK scale = px / 16.0 spacing = px * 0.18 widths = [renderer.text_width(ch, scale) for ch in text] total = sum(widths) + spacing * (len(text) - 1) lx = cx - total / 2.0 ly = cy - px / 2.0 span = max(1, len(text) - 1) # full gradient span: pure green left, pure blue right for i, ch in enumerate(text): r, g, b = _lerp_rgb(i / span) renderer.draw_text(ch, (lx, ly), colour=(r, g, b, ca), scale=scale) lx += widths[i] + spacing def _draw_logo(self, renderer, cx, cy, w, h, ca) -> None: """A game-supplied logo (image path or bindless texture id), centred.""" if self.logo_size is not None: lw, lh = self.logo_size else: side = min(w, h) * 0.35 lw = lh = side tint = (1.0, 1.0, 1.0, ca) if isinstance(self.logo, int): renderer.draw_texture(self.logo, cx - lw / 2.0, cy - lh / 2.0, lw, lh, colour=tint) else: renderer.draw_image(str(self.logo), cx - lw / 2.0, cy - lh / 2.0, lw, lh, colour=tint) def _draw_bar(self, renderer, cx, top, w, ca) -> None: """Segmented gradient bar with a caption (left) and percentage (right) row.""" bw = min(420.0, w * 0.42) bh = 6.0 gap = 3.0 # Fewer segments on narrow screens so each stays >= ~8 px wide. n = max(10, min(self._BAR_SEGMENTS, int(bw / 11.0))) x0 = cx - bw / 2.0 seg_w = (bw - gap * (n - 1)) / n # Caption + percentage row (fixed height: nothing jumps as text changes). row_y = top bar_y = top + 22.0 if self._caption: renderer.draw_text(self._caption.upper(), (x0, row_y), colour=(0.62, 0.75, 0.70, 0.8 * ca), scale=0.72) if self._determinate: pct = f"{int(self._displayed * 100):d}%" pw = renderer.text_width(pct, 0.72) renderer.draw_text(pct, (x0 + bw - pw, row_y), colour=(0.85, 0.95, 0.90, 0.9 * ca), scale=0.72) if self._determinate: lit = round(self._displayed * n) shimmer_at = -1 else: # Indeterminate: a 6-segment band cycling along the bar (scene-time paced). now = self.tree.now if self.tree is not None else 0.0 shimmer_at = int(now * 18.0) % n lit = 0 span = max(1, n - 1) # full gradient span, matching the wordmark for i in range(n): sx = x0 + i * (seg_w + gap) if self._determinate and i < lit: r, g, b = _lerp_rgb(i / span) c = (r, g, b, ca) elif shimmer_at >= 0 and (i - shimmer_at) % n < 6: fade = 1.0 - ((i - shimmer_at) % n) / 6.0 r, g, b = _lerp_rgb(i / span) c = (r, g, b, (0.15 + 0.85 * fade) * ca) else: c = (1.0, 1.0, 1.0, 0.10 * ca) renderer.draw_rect((sx, bar_y), (seg_w, bh), colour=c, filled=True) def _draw_credit(self, renderer, cx, bottom, ca) -> None: made = branding.SPLASH_TEXT mw = renderer.text_width(made, 0.85) renderer.draw_text(made, (cx - mw / 2.0, bottom - 58.0), colour=(0.9, 0.95, 0.93, 0.5 * ca), scale=0.85) credit = branding.ATTRIBUTION_CREDIT cw = renderer.text_width(credit, 0.62) renderer.draw_text(credit, (cx - cw / 2.0, bottom - 36.0), colour=(0.7, 0.78, 0.75, 0.32 * ca), scale=0.62)
[docs] class AttributionWatermark(_FullscreenOverlay): """The persistent "Made with SimVX" corner watermark (LICENSE-EXCEPTION.md #3). The fallback branding form when a game replaces the default splash face (or suppresses the splash) on a branded build: small, unobtrusive, bottom-right, never intercepts input. Removal goes through the two-step unbranded gate (:func:`simvx.core.branding.unbranded_allowed`), not through this class. """ _draw_caching = True
[docs] def on_draw(self, renderer) -> None: x, y, w, h = self.get_global_rect() text = branding.SPLASH_TEXT scale = 0.7 tw = renderer.text_width(text, scale) tx, ty = x + w - tw - 12.0, y + h - 26.0 # A one-pixel shadow keeps the mark legible on any scene without a scrim. renderer.draw_text(text, (tx + 1, ty + 1), colour=(0.0, 0.0, 0.0, 0.35), scale=scale) g = _GRADIENT_LEFT renderer.draw_text(text, (tx, ty), colour=(g[0], g[1], g[2], 0.4), scale=scale)