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