AndrewRqy
Clean-Space pass: drop self-test, legacy unused handler, BISECT_MINIMAL branch, duplicate banner PNG, stale comments
f9490c0 | """The Wizard's Oracles — Gradio Blocks UI. | |
| Single-file demo. Player writes 5 sealed oracles, hands them to a young hero, | |
| then watches as the LLM is forced to interpret whatever was written as the | |
| means of the hero's salvation against 5 lethal trials. | |
| State machine: | |
| inscribe → send_off → trial (1..5) → epilogue → done | |
| Run: | |
| python app.py # launches Gradio (uses ./run.sh for full setup) | |
| """ | |
| from __future__ import annotations | |
| import base64 | |
| import os | |
| import sys | |
| from urllib.parse import quote as _url_quote | |
| from pathlib import Path | |
| from typing import Optional | |
| # Ensure ``oracles`` package is importable when run directly. | |
| _HERE = Path(__file__).resolve().parent | |
| if str(_HERE) not in sys.path: | |
| sys.path.insert(0, str(_HERE)) | |
| import gradio as gr # noqa: E402 | |
| from oracles.images import generate_scene_image # noqa: E402 | |
| from oracles.llm_client import LLMClient # noqa: E402 | |
| from oracles.obstacles import generate_obstacles # noqa: E402 | |
| from oracles.resolution import ( # noqa: E402 | |
| generate_dragon_interlude, | |
| generate_epilogue, | |
| generate_interlude, | |
| kick_background_epilogue, | |
| kick_background_interlude, | |
| precompute_all_resolutions, | |
| resolve_trial, | |
| ) | |
| from oracles.state import ( # noqa: E402 | |
| LANG_LABELS, | |
| LANG_PROMPT_NAME, | |
| NUM_ORACLES, | |
| NUM_TRIALS, | |
| GameState, | |
| fresh_state, | |
| ) | |
| from oracles.ui_strings import t as _t, tlang as _tlang # noqa: E402 | |
| from oracles.wizard_text import ( # noqa: E402 | |
| sigil_svg as _sigil_svg, | |
| text_for as _wizard_text_for, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Module-level client + asset dir | |
| # --------------------------------------------------------------------------- | |
| CLIENT = LLMClient() | |
| OUT_DIR = str(_HERE / "assets" / "generated") | |
| os.makedirs(OUT_DIR, exist_ok=True) | |
| # Parallax PNG bundled into the banner. Resized to 1024x192 (~200KB) from the | |
| # parent Forest Focus cozy-forest near layer — same visual register, but small | |
| # enough to inline as a data URI without bloating the page config. | |
| _PARALLAX_PNG = _HERE / "assets" / "parallax" / "banner_near.png" | |
| # Optional Klein-generated grimoire page texture. If present, embedded into | |
| # the CSS as a background-image on .grimoire-page so the parchment looks | |
| # like real aged paper instead of CSS-only gradients. Skipped silently if | |
| # the user hasn't generated it. | |
| _GRIMOIRE_TEXTURE_PNG = _HERE / "assets" / "sprites" / "grimoire_page_texture.png" | |
| def _full_visual_mode(state: Optional["GameState"] = None) -> bool: | |
| """Visual-mode toggle. Resolution order: | |
| 1. ``state.visual_mode`` if set to "full" or "lean" (per-session override). | |
| 2. ``$ORACLES_VISUAL_MODE`` env var (process-wide default). | |
| 3. ``lean``. | |
| - ``lean``: skip heavy decorative PNGs — parallax banner, grimoire | |
| parchment, the four phase backdrops, the scene landscapes, the | |
| wizard-desk backdrop, the open-book illustration, and the | |
| pipeline-demo card backdrop. ~2-3 MB saved per cold load on HF | |
| Space's slow egress bandwidth. | |
| - ``full``: enable everything. Use locally where bandwidth is fast, | |
| or flip the dropdown on inscribe step 0. | |
| """ | |
| if state is not None: | |
| vm = (getattr(state, "visual_mode", "") or "").strip().lower() | |
| if vm in ("full", "lean"): | |
| return vm == "full" | |
| return os.environ.get("ORACLES_VISUAL_MODE", "lean").strip().lower() == "full" | |
| # --------------------------------------------------------------------------- | |
| # Pixel-art book frame: leather binding (via CSS border-image 9-slice), a | |
| # vertical spine with raised bands and stitching, and a red ribbon bookmark. | |
| # All inline SVGs so the book "is" pixel art without depending on an external | |
| # asset run. | |
| # --------------------------------------------------------------------------- | |
| def _book_frame_svg() -> str: | |
| """60x60 SVG used as the border-image source for the leather binding. | |
| 9-sliced at 20 units → corners stay crisp, edge slices stretch. | |
| Leather is warmer/browner than before so it doesn't merge with the | |
| dark purple page palette.""" | |
| return ( | |
| "<svg xmlns='http://www.w3.org/2000/svg' " | |
| "width='60' height='60' viewBox='0 0 60 60' " | |
| "shape-rendering='crispEdges'>" | |
| # leather on the 8 border slices (center 20x20 left transparent). | |
| # Warmer brown so it reads as leather against the dark purple pages. | |
| "<rect x='0' y='0' width='60' height='20' fill='#3a2410'/>" | |
| "<rect x='0' y='40' width='60' height='20' fill='#3a2410'/>" | |
| "<rect x='0' y='20' width='20' height='20' fill='#3a2410'/>" | |
| "<rect x='40' y='20' width='20' height='20' fill='#3a2410'/>" | |
| # leather grain (darker/lighter horizontal lines for texture) | |
| "<rect x='0' y='5' width='60' height='1' fill='#1a0e08' opacity='0.8'/>" | |
| "<rect x='0' y='11' width='60' height='1' fill='#5a3820' opacity='0.6'/>" | |
| "<rect x='0' y='49' width='60' height='1' fill='#1a0e08' opacity='0.8'/>" | |
| "<rect x='0' y='55' width='60' height='1' fill='#5a3820' opacity='0.6'/>" | |
| # leather vertical grain on the side edges | |
| "<rect x='5' y='20' width='1' height='20' fill='#1a0e08' opacity='0.8'/>" | |
| "<rect x='11' y='20' width='1' height='20' fill='#5a3820' opacity='0.6'/>" | |
| "<rect x='49' y='20' width='1' height='20' fill='#1a0e08' opacity='0.8'/>" | |
| "<rect x='55' y='20' width='1' height='20' fill='#5a3820' opacity='0.6'/>" | |
| # outer leather edge - slightly lighter strip on the outside | |
| "<rect x='0' y='0' width='60' height='1' fill='#5a3820'/>" | |
| "<rect x='0' y='59' width='60' height='1' fill='#1a0e08'/>" | |
| "<rect x='0' y='0' width='1' height='60' fill='#5a3820'/>" | |
| "<rect x='59' y='0' width='1' height='60' fill='#1a0e08'/>" | |
| # brass corner — TOP-LEFT | |
| "<rect x='0' y='0' width='16' height='3' fill='#b08840'/>" | |
| "<rect x='0' y='0' width='3' height='16' fill='#b08840'/>" | |
| "<rect x='0' y='0' width='14' height='1' fill='#dca860'/>" | |
| "<rect x='0' y='0' width='1' height='14' fill='#dca860'/>" | |
| "<rect x='0' y='3' width='16' height='1' fill='#604018'/>" | |
| "<rect x='3' y='0' width='1' height='16' fill='#604018'/>" | |
| "<rect x='10' y='2' width='2' height='2' fill='#604018'/>" | |
| "<rect x='2' y='10' width='2' height='2' fill='#604018'/>" | |
| # brass corner — TOP-RIGHT | |
| "<rect x='44' y='0' width='16' height='3' fill='#b08840'/>" | |
| "<rect x='57' y='0' width='3' height='16' fill='#b08840'/>" | |
| "<rect x='46' y='0' width='14' height='1' fill='#dca860'/>" | |
| "<rect x='59' y='0' width='1' height='14' fill='#dca860'/>" | |
| "<rect x='44' y='3' width='16' height='1' fill='#604018'/>" | |
| "<rect x='56' y='0' width='1' height='16' fill='#604018'/>" | |
| "<rect x='48' y='2' width='2' height='2' fill='#604018'/>" | |
| "<rect x='56' y='10' width='2' height='2' fill='#604018'/>" | |
| # brass corner — BOTTOM-LEFT | |
| "<rect x='0' y='57' width='16' height='3' fill='#b08840'/>" | |
| "<rect x='0' y='44' width='3' height='16' fill='#b08840'/>" | |
| "<rect x='0' y='59' width='14' height='1' fill='#dca860'/>" | |
| "<rect x='0' y='46' width='1' height='14' fill='#dca860'/>" | |
| "<rect x='0' y='56' width='16' height='1' fill='#604018'/>" | |
| "<rect x='3' y='44' width='1' height='16' fill='#604018'/>" | |
| "<rect x='10' y='56' width='2' height='2' fill='#604018'/>" | |
| "<rect x='2' y='48' width='2' height='2' fill='#604018'/>" | |
| # brass corner — BOTTOM-RIGHT | |
| "<rect x='44' y='57' width='16' height='3' fill='#b08840'/>" | |
| "<rect x='57' y='44' width='3' height='16' fill='#b08840'/>" | |
| "<rect x='46' y='59' width='14' height='1' fill='#dca860'/>" | |
| "<rect x='59' y='46' width='1' height='14' fill='#dca860'/>" | |
| "<rect x='44' y='56' width='16' height='1' fill='#604018'/>" | |
| "<rect x='56' y='44' width='1' height='16' fill='#604018'/>" | |
| "<rect x='48' y='56' width='2' height='2' fill='#604018'/>" | |
| "<rect x='56' y='48' width='2' height='2' fill='#604018'/>" | |
| # amber inner trim — visible just inside the leather frame | |
| "<rect x='16' y='14' width='28' height='2' fill='#f0c060'/>" | |
| "<rect x='16' y='44' width='28' height='2' fill='#f0c060'/>" | |
| "<rect x='14' y='16' width='2' height='28' fill='#f0c060'/>" | |
| "<rect x='44' y='16' width='2' height='28' fill='#f0c060'/>" | |
| # darker amber rule just inside the inner trim | |
| "<rect x='17' y='17' width='26' height='1' fill='#604018'/>" | |
| "<rect x='17' y='42' width='26' height='1' fill='#604018'/>" | |
| "<rect x='17' y='17' width='1' height='26' fill='#604018'/>" | |
| "<rect x='42' y='17' width='1' height='26' fill='#604018'/>" | |
| "</svg>" | |
| ) | |
| def _book_spine_svg() -> str: | |
| """40×200 tall stretchable spine with five raised bands and amber | |
| stitching dots. Brighter, more contrasted than the surrounding leather | |
| so the binding clearly reads as a separate strip down the centre. | |
| ``preserveAspectRatio='none'`` lets it stretch vertically with the | |
| grimoire.""" | |
| bands = [] | |
| # five raised horizontal bands, each with a top highlight + bottom shadow | |
| for y in (28, 63, 96, 129, 164): | |
| bands.append(f"<rect x='0' y='{y}' width='40' height='8' fill='#6a4828'/>") | |
| bands.append(f"<rect x='0' y='{y}' width='40' height='2' fill='#9a7048'/>") | |
| bands.append(f"<rect x='0' y='{y + 6}' width='40' height='2' fill='#1a0e08'/>") | |
| # amber stitching dots in the gaps between bands | |
| stitch = [] | |
| for y in (12, 48, 83, 116, 149, 184): | |
| stitch.append(f"<rect x='17' y='{y}' width='6' height='3' fill='#f8d870'/>") | |
| return ( | |
| "<svg xmlns='http://www.w3.org/2000/svg' " | |
| "width='40' height='200' viewBox='0 0 40 200' " | |
| "preserveAspectRatio='none' shape-rendering='crispEdges'>" | |
| # base spine — medium-warm brown, brighter than surrounding leather | |
| "<rect x='0' y='0' width='40' height='200' fill='#4a3218'/>" | |
| # darker edges where the spine curves into the cover | |
| "<rect x='0' y='0' width='4' height='200' fill='#1a0e08'/>" | |
| "<rect x='36' y='0' width='4' height='200' fill='#1a0e08'/>" | |
| # subtle highlights just inside the dark edges (the spine's curve) | |
| "<rect x='4' y='0' width='2' height='200' fill='#3a2410'/>" | |
| "<rect x='34' y='0' width='2' height='200' fill='#3a2410'/>" | |
| # central lighter highlight running down the middle | |
| "<rect x='19' y='0' width='3' height='200' fill='#6a4828'/>" | |
| + "".join(bands) | |
| + "".join(stitch) | |
| + "</svg>" | |
| ) | |
| def _book_ribbon_svg() -> str: | |
| """Small red ribbon bookmark, drawn at 28×44 with chunky pixel V-cut.""" | |
| return ( | |
| "<svg xmlns='http://www.w3.org/2000/svg' " | |
| "width='28' height='44' viewBox='0 0 28 44' " | |
| "shape-rendering='crispEdges'>" | |
| # ribbon body | |
| "<rect x='8' y='0' width='12' height='32' fill='#8a2020'/>" | |
| # highlight (left edge) | |
| "<rect x='8' y='0' width='2' height='32' fill='#aa3030'/>" | |
| # shadow (right edge) | |
| "<rect x='18' y='0' width='2' height='32' fill='#601010'/>" | |
| # subtle fold line down the centre | |
| "<rect x='13' y='0' width='1' height='32' fill='#601010' opacity='0.7'/>" | |
| # chunky V-cut bottom | |
| "<rect x='8' y='32' width='12' height='2' fill='#8a2020'/>" | |
| "<rect x='9' y='34' width='10' height='2' fill='#8a2020'/>" | |
| "<rect x='10' y='36' width='8' height='2' fill='#8a2020'/>" | |
| "<rect x='11' y='38' width='6' height='2' fill='#8a2020'/>" | |
| "<rect x='12' y='40' width='4' height='2' fill='#8a2020'/>" | |
| "<rect x='13' y='42' width='2' height='2' fill='#8a2020'/>" | |
| # highlight on V-cut | |
| "<rect x='8' y='32' width='2' height='2' fill='#aa3030'/>" | |
| "<rect x='9' y='34' width='2' height='2' fill='#aa3030'/>" | |
| "<rect x='10' y='36' width='2' height='2' fill='#aa3030'/>" | |
| "<rect x='11' y='38' width='2' height='2' fill='#aa3030'/>" | |
| "<rect x='12' y='40' width='2' height='2' fill='#aa3030'/>" | |
| # shadow on V-cut | |
| "<rect x='18' y='32' width='2' height='2' fill='#601010'/>" | |
| "<rect x='17' y='34' width='2' height='2' fill='#601010'/>" | |
| "<rect x='16' y='36' width='2' height='2' fill='#601010'/>" | |
| "<rect x='15' y='38' width='2' height='2' fill='#601010'/>" | |
| "</svg>" | |
| ) | |
| def _grimoire_book_css() -> str: | |
| """Compose the dynamic CSS for the pixel-art book frame. | |
| Always returns a non-empty string (the SVGs are inline) — distinct from | |
| the optional ``_grimoire_texture_css`` which only fires when the Klein | |
| parchment PNG is on disk. | |
| """ | |
| frame_b64 = base64.b64encode(_book_frame_svg().encode("utf-8")).decode("ascii") | |
| spine_b64 = base64.b64encode(_book_spine_svg().encode("utf-8")).decode("ascii") | |
| ribbon_b64 = base64.b64encode(_book_ribbon_svg().encode("utf-8")).decode("ascii") | |
| frame_uri = f"data:image/svg+xml;base64,{frame_b64}" | |
| spine_uri = f"data:image/svg+xml;base64,{spine_b64}" | |
| ribbon_uri = f"data:image/svg+xml;base64,{ribbon_b64}" | |
| return f""" | |
| /* ---------- Pixel-art book frame ---------- */ | |
| .oracle-grimoire {{ | |
| /* Replace the plain amber border with a 9-sliced leather frame. | |
| The 20-unit slice in the 60x60 source means corner art (brass | |
| protectors + amber trim) stays crisp; only the edge middles | |
| stretch. */ | |
| border: 20px solid transparent !important; | |
| border-image: url('{frame_uri}') 20 / 20px / 0 stretch !important; | |
| /* Stretchable centre spine sits as background-image, centred. */ | |
| background-image: url('{spine_uri}') !important; | |
| background-position: center center !important; | |
| background-repeat: no-repeat !important; | |
| background-size: 32px 100% !important; | |
| image-rendering: pixelated !important; | |
| overflow: visible !important; | |
| }} | |
| /* Override the previous ::before (dark spine line) with the ribbon | |
| bookmark hanging from the top of the binding. */ | |
| .oracle-grimoire::before {{ | |
| content: "" !important; | |
| position: absolute !important; | |
| top: -6px !important; | |
| left: 50% !important; | |
| transform: translateX(-50%) !important; | |
| width: 28px !important; | |
| height: 44px !important; | |
| background: url('{ribbon_uri}') no-repeat center !important; | |
| background-size: 100% 100% !important; | |
| z-index: 10 !important; | |
| pointer-events: none !important; | |
| image-rendering: pixelated !important; | |
| box-shadow: none !important; | |
| }} | |
| /* The stitching dots and inner-amber-border live in the SVG now — hide | |
| the legacy ::after pseudo-element decorations. */ | |
| .oracle-grimoire::after {{ | |
| display: none !important; | |
| }} | |
| /* The leather frame already provides the chunky border + drop shadow, | |
| so soften the existing inset shadow that used to imply page curl — | |
| the SVG amber trim does that visual now. */ | |
| """ | |
| def _grimoire_texture_css() -> str: | |
| """Return a small CSS snippet that adds the Klein-generated parchment | |
| texture as a background-image on .grimoire-page, if the PNG exists. | |
| Returns empty string otherwise (so the CSS-only gradients still apply). | |
| Skipped entirely in lean mode (default) — the texture is ~70 KB | |
| base64-inlined into the initial CSS, which on HF Space's slow egress | |
| adds noticeable cold-load time. Set ORACLES_VISUAL_MODE=full to enable. | |
| """ | |
| if not _full_visual_mode(): | |
| return "" | |
| if not _GRIMOIRE_TEXTURE_PNG.exists(): | |
| return "" | |
| try: | |
| png_b64 = base64.b64encode(_GRIMOIRE_TEXTURE_PNG.read_bytes()).decode("ascii") | |
| except OSError: | |
| return "" | |
| return ( | |
| "\n/* Klein-generated parchment texture (loaded only when the PNG exists). " | |
| "Stacked above the gradients so the warm pixel-art grain shows through. " | |
| "A dark linear-gradient is layered ON TOP at high opacity so the cream " | |
| "wizard-monologue text remains readable — the parchment grain is still " | |
| "visible underneath but the text contrast wins. */\n" | |
| ".grimoire-page {\n" | |
| " background-image:\n" | |
| " linear-gradient(180deg,\n" | |
| " rgba(24, 20, 31, 0.82) 0%,\n" | |
| " rgba(37, 29, 46, 0.86) 100%),\n" | |
| " radial-gradient(ellipse 70% 40% at 50% 0%,\n" | |
| " rgba(240, 192, 96, 0.18) 0%, transparent 70%),\n" | |
| f" url('data:image/png;base64,{png_b64}') !important;\n" | |
| " background-size: 100% 100%, 100% 100%, cover !important;\n" | |
| " background-repeat: no-repeat, no-repeat, no-repeat !important;\n" | |
| " image-rendering: pixelated !important;\n" | |
| "}\n" | |
| ) | |
| # Sub-state within a trial: A = waiting to open, B = oracle drawn & resolved | |
| TRIAL_STATE_AWAIT = "await" | |
| TRIAL_STATE_REVEAL = "reveal" | |
| _ROMAN = {1: "I", 2: "II", 3: "III", 4: "IV", 5: "V"} | |
| # Gradio file-route URL prefix. Differs between major versions: | |
| # - Gradio 4.x → "/file=" | |
| # - Gradio 5.x and 6.x → "/gradio_api/file=" | |
| # Computed once at module load; cheap to read. | |
| try: | |
| _GRADIO_MAJOR = int(gr.__version__.split(".", 1)[0]) | |
| except Exception: | |
| _GRADIO_MAJOR = 5 | |
| _FILE_URL_PREFIX = "/file=" if _GRADIO_MAJOR < 5 else "/gradio_api/file=" | |
| def _sprite_data_uri_app(name: str) -> str: | |
| """Return a URL to the sprite. Used everywhere a CSS ``url(...)`` or | |
| ``<img src=...>`` needs the sprite. Originally returned a base64 data | |
| URI, which baked ~30 sprites worth of binary data straight into the | |
| initial HTML response (~1-3 MB) and made the first paint take 3+ | |
| minutes on HF Spaces CPU-basic. Now returns the Gradio file route | |
| (``/gradio_api/file=<path>``) so the browser fetches each sprite | |
| lazily in parallel — initial HTML drops to ~50 KB. Gradio's launch | |
| is set up with ``allowed_paths=[assets]`` so the path is servable. | |
| Cached so we don't stat the disk on every call. Returns empty string | |
| if the file is missing. | |
| """ | |
| cache = _sprite_data_uri_app.__dict__.setdefault("_cache", {}) | |
| if name in cache: | |
| return cache[name] | |
| path = _HERE / "assets" / "sprites" / f"{name}.png" | |
| if not path.exists(): | |
| cache[name] = "" | |
| return "" | |
| # Gradio's file route prefix depends on the running major version. | |
| # The /gradio_api/file= path works on 5+; legacy /file= works on 4.x. | |
| # _FILE_URL_PREFIX is computed once at module load based on | |
| # gr.__version__. | |
| # | |
| # URL-encode the path so any spaces or special chars (e.g. the | |
| # "CHAI Project" path on the dev machine) survive the trip through | |
| # an HTML <img src=…> attribute. safe="/" preserves the path | |
| # separators which browsers + Gradio's route both need verbatim. | |
| uri = f"{_FILE_URL_PREFIX}{_url_quote(path.as_posix(), safe='/')}" | |
| cache[name] = uri | |
| return uri | |
| def _sprite_data_uri_app_base64(name: str) -> str: | |
| """Fallback that base64-encodes a sprite — only used if the file-route | |
| approach doesn't work in some environment (e.g. the iframe can't reach | |
| /gradio_api/file=). Cached separately so we don't accidentally bake | |
| base64 into a path-style call site.""" | |
| cache = _sprite_data_uri_app_base64.__dict__.setdefault("_cache", {}) | |
| if name in cache: | |
| return cache[name] | |
| path = _HERE / "assets" / "sprites" / f"{name}.png" | |
| if not path.exists(): | |
| cache[name] = "" | |
| return "" | |
| try: | |
| png_b64 = base64.b64encode(path.read_bytes()).decode("ascii") | |
| except OSError: | |
| cache[name] = "" | |
| return "" | |
| uri = f"data:image/png;base64,{png_b64}" | |
| cache[name] = uri | |
| return uri | |
| # --------------------------------------------------------------------------- | |
| # Grimoire — book-spread renderer for the inscribe phase. | |
| # The left page is the wizard's voice (markdown-like rendering of wizard_text). | |
| # The right page is whatever input the current step asks for. | |
| # --------------------------------------------------------------------------- | |
| _GRIMOIRE_NUM_STEPS = 9 # 0..8 | |
| _GRIMOIRE_ORACLE_STEPS = (3, 4, 5, 6, 7) # which steps capture an oracle | |
| def _grimoire_render_markdown(raw: str) -> str: | |
| """Render the lightweight markdown used in wizard_text into HTML. | |
| Supports: | |
| - ``**bold**`` → ``<strong>`` | |
| - ``*italic*`` → ``<em>`` | |
| - lines starting with ``> `` → wrapped in a single ``<blockquote>`` | |
| - paragraphs split on blank lines | |
| """ | |
| import re | |
| def _inline(line: str) -> str: | |
| out = _md_safe(line) | |
| out = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", out) | |
| out = re.sub(r"(?<!\*)\*([^*]+)\*(?!\*)", r"<em>\1</em>", out) | |
| return out | |
| blocks: list[str] = [] | |
| for raw_block in raw.split("\n\n"): | |
| block = raw_block.strip() | |
| if not block: | |
| continue | |
| if block.startswith(">"): | |
| quote = "\n".join( | |
| ln[1:].lstrip() if ln.startswith(">") else ln | |
| for ln in block.splitlines() | |
| ) | |
| blocks.append(f"<blockquote>{_inline(quote)}</blockquote>") | |
| else: | |
| blocks.append(f"<p>{_inline(' '.join(block.split()))}</p>") | |
| return "".join(blocks) | |
| def _grimoire_left_html(state: GameState) -> str: | |
| """Render the left page: wizard's text for the current inscribe step, | |
| with hero/village placeholders substituted, the step marker in the | |
| corner, and the step-themed pixel sigil tucked into the bottom-inner | |
| corner. | |
| Cached by (lang, inscribe_step, hero_name, village_name). All four | |
| determine the output exactly; nothing else does. On grimoire spread | |
| transitions, only ``inscribe_step`` changes, so the cache stays warm. | |
| """ | |
| lang_code = state.lang or "en" | |
| cache_key = ( | |
| lang_code, | |
| state.inscribe_step, | |
| state.hero_name or "Tobin", | |
| state.village_name or "the Hollow", | |
| ) | |
| if cache_key in _GRIMOIRE_LEFT_CACHE: | |
| return _GRIMOIRE_LEFT_CACHE[cache_key] | |
| raw = _wizard_text_for(lang_code, state.inscribe_step) | |
| raw = (raw | |
| .replace("{hero_name}", state.hero_name or "Tobin") | |
| .replace("{village_name}", state.village_name or "the Hollow")) | |
| body = _grimoire_render_markdown(raw) | |
| step = state.inscribe_step + 1 | |
| lang = _tlang(state) | |
| marker_text = _t("grimoire_step_marker", lang).format( | |
| step=step, GRIMOIRE_NUM_STEPS=_GRIMOIRE_NUM_STEPS | |
| ) | |
| marker = f"<div class='grimoire-step-marker'>{marker_text}</div>" | |
| sigil = ( | |
| "<div class='grimoire-sigil grimoire-sigil-left'>" | |
| + _sigil_svg(state.inscribe_step) | |
| + "</div>" | |
| ) | |
| result = f"<div lang='{lang}' class='grimoire-text-body'>{body}</div>{marker}{sigil}" | |
| _GRIMOIRE_LEFT_CACHE[cache_key] = result | |
| return result | |
| def _grimoire_right_sigil_html(state: GameState) -> str: | |
| """The same sigil, rendered for the right page (mirrored placement).""" | |
| return ( | |
| "<div class='grimoire-sigil grimoire-sigil-right'>" | |
| + _sigil_svg(state.inscribe_step) | |
| + "</div>" | |
| ) | |
| def _grimoire_progress_html(state: GameState) -> str: | |
| """Five-dot progress indicator for the oracles, rendered at the bottom of | |
| the right page.""" | |
| n_sealed = sum(1 for o in state.oracles if (o.text or "").strip()) | |
| dots = [] | |
| for i in range(NUM_ORACLES): | |
| cls = "dot done" if i < n_sealed else "dot" | |
| dots.append(f"<span class='{cls}'>◆</span>") | |
| return "<div class='grimoire-progress'>" + "".join(dots) + "</div>" | |
| def _grimoire_summary_html(state: GameState) -> str: | |
| """Step-8 summary panel: shows all five sealed oracles, with the open- | |
| spell-book pixel art crowning the spread to mark the grimoire complete.""" | |
| lang = _tlang(state) | |
| blank_label = _t("blank_oracle", lang) | |
| items = [] | |
| for o in state.oracles: | |
| text = (o.text or "").strip() or blank_label | |
| items.append(f"<li><em>{_md_safe(text)}</em></li>") | |
| # Lean mode (default for HF Space): skip the open-book illustration — | |
| # the PNG was ~200-300 KB. The heading + numbered list carry the | |
| # meaning. Full mode restores the illustration above the list. | |
| if _full_visual_mode(state): | |
| book_uri = _sprite_data_uri_app("open_book_spread") | |
| alt_text = _t("alt_sealed_grimoire", lang) | |
| book_img = ( | |
| f"<img class='grimoire-summary-book' src='{book_uri}' " | |
| f"alt='{_md_safe(alt_text)}'/>" | |
| if book_uri else "" | |
| ) | |
| else: | |
| book_img = "" | |
| header = _md_safe(_t("summary_five_sealed", lang)) | |
| return ( | |
| "<div class='grimoire-summary'>" | |
| + book_img | |
| + f"<strong>{header}</strong>" | |
| "<ol>" + "".join(items) + "</ol>" | |
| "</div>" | |
| ) | |
| def _grimoire_error_html(state: GameState) -> str: | |
| err = (state.grimoire_error or "").strip() | |
| if not err: | |
| return "" | |
| return f"<div class='oracle-error'>{_md_safe(err)}</div>" | |
| # _PROLOGUE_CACHE, _load_prologue, _prologue_html — REMOVED. | |
| # These rendered a separate "prologue" card from prompts/prologue_{en,zh}.txt | |
| # but nothing on the page ever called _prologue_html, so the prologue | |
| # never reached the player. The grimoire's wizard_text monologue does | |
| # the world-introduction job now. | |
| def _pipeline_demo_html(state: Optional[GameState] = None) -> str: | |
| """Three side-by-side 'thought-bubble' panels showing the wizard daydreaming | |
| about three example pipelines — one per humor mode (A wild-imagination, | |
| B accidental-trip, C last-minute-revelation). Each panel now embeds two | |
| pixel-art sprites (the obstacle + the hero's resolved pose) over a soft | |
| night-sky backdrop, so the gag reads visually before the caption is read. | |
| Shown only on the very first grimoire spread (step 0). Hidden via | |
| `_pipeline_demo_visibility` after the player advances. | |
| """ | |
| lang = _tlang(state) | |
| # Pixel-art sprites + backdrop pre-loaded as data URIs. The | |
| # `_sprite_data_uri_app` helper caches per name, so reading the same | |
| # sprite twice in this function only hits disk once. | |
| # Lean mode (default for HF Space): skip the demo card painted | |
| # backdrop (~200-400 KB). Panels still have CSS borders + shading. | |
| # Full mode restores the painted background. | |
| if _full_visual_mode(state): | |
| bg = _sprite_data_uri_app("demo_card_backdrop") | |
| bg_style = f"background-image:url('{bg}');" if bg else "" | |
| else: | |
| bg_style = "" | |
| tag_inscribes = _t("demo_tag_inscribes", lang) | |
| tag_meets = _t("demo_tag_meets", lang) | |
| tag_somehow = _t("demo_tag_somehow", lang) | |
| def _panel(mode: str, mode_label: str, oracle: str, | |
| encounter_text: str, resolve_text: str, | |
| encounter_sprite: str, resolve_sprite: str) -> str: | |
| # Lean mode: drop the in-panel encounter/resolve sprites too. They | |
| # were the only image content left in the demo panels after the | |
| # painted backdrop was stripped earlier — and a row of small sprites | |
| # still costs a half-dozen HTTP requests per page-load. | |
| if _full_visual_mode(state): | |
| encounter_uri = _sprite_data_uri_app(encounter_sprite) | |
| resolve_uri = _sprite_data_uri_app(resolve_sprite) | |
| else: | |
| encounter_uri = "" | |
| resolve_uri = "" | |
| encounter_img = ( | |
| f"<img class='pipeline-demo-sprite pipeline-demo-sprite-foe' " | |
| f"src='{encounter_uri}' alt='{encounter_sprite}'/>" | |
| if encounter_uri else "" | |
| ) | |
| resolve_img = ( | |
| f"<img class='pipeline-demo-sprite pipeline-demo-sprite-hero' " | |
| f"src='{resolve_uri}' alt='{resolve_sprite}'/>" | |
| if resolve_uri else "" | |
| ) | |
| mode_badge_text = _t("demo_mode", lang).format(mode=mode) | |
| return ( | |
| f"<div class='pipeline-demo-panel' data-mode='{mode}'>" | |
| "<div class='pipeline-demo-scene'>" | |
| + encounter_img | |
| + resolve_img + | |
| "</div>" | |
| f"<div class='pipeline-demo-mode-row'>" | |
| f"<span class='pipeline-demo-mode-badge'>{mode_badge_text}</span>" | |
| f"<span class='pipeline-demo-mode-label'>{mode_label}</span>" | |
| "</div>" | |
| "<div class='pipeline-demo-step'>" | |
| f"<span class='pipeline-demo-tag'>{tag_inscribes}</span>" | |
| f"<code>{oracle}</code>" | |
| "</div>" | |
| "<div class='pipeline-demo-step'>" | |
| f"<span class='pipeline-demo-tag'>{tag_meets}</span>" | |
| f"{encounter_text}" | |
| "</div>" | |
| "<div class='pipeline-demo-step pipeline-demo-resolution'>" | |
| f"<span class='pipeline-demo-tag'>{tag_somehow}</span>" | |
| f"{resolve_text}" | |
| "</div>" | |
| "</div>" | |
| ) | |
| header = _t("demo_header", lang) | |
| footer = _t("demo_footer", lang) | |
| label_a = _t("demo_mode_a_label", lang) | |
| label_b = _t("demo_mode_b_label", lang) | |
| label_c = _t("demo_mode_c_label", lang) | |
| return ( | |
| f"<div class='oracle-pipeline-demo' style=\"{bg_style}\">" | |
| "<div class='pipeline-demo-header'>" | |
| "<span class='float'>💭</span>" | |
| f"{header}" | |
| "</div>" | |
| "<div class='pipeline-demo-grid'>" | |
| # Panel A — Mode A WILD IMAGINATION (phone → hornets) | |
| + _panel( | |
| mode="A", | |
| mode_label=label_a, | |
| oracle="phone", | |
| encounter_text="a swarm of glass-winged hornets, daggers humming", | |
| resolve_text=( | |
| "a brass horn drops into his hand. He calls the swarm's " | |
| "mother. She is most disappointed. The hornets file home " | |
| "single-file, none of them making eye contact." | |
| ), | |
| encounter_sprite="demo_hornet_dagger", | |
| resolve_sprite="demo_hero_smug_phone", | |
| ) | |
| # Panel B — Mode B ACCIDENTAL TRIP (Neil → archers → trip) | |
| + _panel( | |
| mode="B", | |
| mode_label=label_b, | |
| oracle="Neil", | |
| encounter_text=( | |
| "an ambush — hidden archers at the crossroads, bows drawn" | |
| ), | |
| resolve_text=( | |
| "baffled by 'Neil', he turns calling the name, trips on a " | |
| "wagon-axle, and four arrows hiss through the air his " | |
| "throat had been in. He thanks Neil sincerely." | |
| ), | |
| encounter_sprite="demo_bandit_archer", | |
| resolve_sprite="demo_hero_tripped", | |
| ) | |
| # Panel C — Mode C LAST-MINUTE REVELATION (blue → wraiths → jays) | |
| + _panel( | |
| mode="C", | |
| mode_label=label_c, | |
| oracle="blue", | |
| encounter_text=( | |
| "fen-wraith herons closing in with toothed beaks" | |
| ), | |
| resolve_text=( | |
| "he waves a blue scarf — fails. A jay scolds " | |
| "<em>jay-JAY-jay</em>. He gets it: <strong>blue jay.</strong> " | |
| "He whistles. A furious cobalt mob scatters the wraiths." | |
| ), | |
| encounter_sprite="demo_wraith_heron", | |
| resolve_sprite="demo_bluejay_angry", | |
| ) | |
| + "</div>" | |
| "<div class='pipeline-demo-footer'>" | |
| f"{footer}" | |
| "</div>" | |
| "</div>" | |
| ) | |
| def _inscribe_decoration_html(state: Optional[GameState] = None) -> str: | |
| """A small framing illustration at the top of the inscribe panel: the | |
| wizard (the player) on the left, the hero on the right, with the five | |
| sealed oracles passing between them. Sets the whole game's framing — | |
| 'YOU are the wizard.' | |
| Cached by (theme, lang) — those are the only fields the rendered HTML | |
| depends on. Called on every click; caching saves a few hundred μs of | |
| string building per transition. | |
| """ | |
| theme_key_for_sprites = ( | |
| getattr(state, "theme", "fantasy") if state else "fantasy" | |
| ) or "fantasy" | |
| lang_for_cache = _tlang(state) | |
| cache_key = (theme_key_for_sprites, lang_for_cache, _full_visual_mode(state)) | |
| if cache_key in _INSCRIBE_DECORATION_CACHE: | |
| return _INSCRIBE_DECORATION_CACHE[cache_key] | |
| from oracles.themes import get_theme as _get_theme | |
| wizard_uri = ( | |
| _sprite_data_uri_app(f"wizard_{theme_key_for_sprites}") | |
| or _sprite_data_uri_app("wizard") | |
| ) | |
| hero_uri = ( | |
| _sprite_data_uri_app(f"hero_{theme_key_for_sprites}") | |
| or _sprite_data_uri_app("hero") | |
| ) | |
| # Lean mode (default for HF Space): skip the wizard-desk backdrop — | |
| # the PNG is ~200-400 KB. Full mode restores it as a fade-behind | |
| # layer for the mentor/apprentice figures. | |
| desk_uri = _sprite_data_uri_app("wizard_desk_scene") if _full_visual_mode(state) else "" | |
| if not wizard_uri or not hero_uri: | |
| fallback = ( | |
| "<div class='oracle-inscribe-figs' style='text-align:center;" | |
| "padding:14px;font-style:italic;color:var(--w-mute,#b4a890)'>" | |
| "(sprites not yet generated — run " | |
| "<code>modal run oracles_app/modal_backend/modal_klein_oracles.py</code>)" | |
| "</div>" | |
| ) | |
| _INSCRIBE_DECORATION_CACHE[cache_key] = fallback | |
| return fallback | |
| bg_style = "" | |
| if desk_uri: | |
| # Layer the wizard-desk texture behind the figures at low opacity | |
| # so the wizard and hero pop, but the desk's candle + parchment | |
| # mood comes through. | |
| bg_style = ( | |
| f"background:" | |
| f" linear-gradient(rgba(24,20,31,0.66), rgba(24,20,31,0.82))," | |
| f" url('{desk_uri}') center/cover no-repeat;" | |
| "image-rendering: pixelated;" | |
| ) | |
| # Theme-aware captions so each world reskins the labels. | |
| from oracles.themes import get_theme as _get_theme | |
| theme_key = getattr(state, "theme", "fantasy") if state else "fantasy" | |
| th = _get_theme(theme_key) | |
| lang = _tlang(state) | |
| mentor_caption_raw = th.localized_field("mentor_caption", lang) \ | |
| or f"the {th.mentor_archetype}" | |
| finale_caption_raw = th.localized_field("finale_caption", lang) \ | |
| or f"off to {th.goal_verb} {th.finale_descriptor}" | |
| artifact_raw = th.localized_field("oracle_artifact", lang) \ | |
| or th.oracle_artifact | |
| if lang == "zh": | |
| arrow_caption_raw = f"五{artifact_raw}" | |
| else: | |
| arrow_caption_raw = f"five {artifact_raw}" | |
| mentor_caption = _md_safe(mentor_caption_raw) | |
| finale_caption = _md_safe(finale_caption_raw) | |
| arrow_caption = _md_safe(arrow_caption_raw) | |
| you_label = _md_safe(_t("figure_you", lang)) | |
| # Theme-aware role title — render it as-is without a "Your" prefix | |
| # (the labels already start with "the …", so "YOUR THE CHOSEN BOY" | |
| # would read wrong). Falls back to the bilingual "figure_apprentice" | |
| # key when a theme doesn't set apprentice_role. | |
| _theme_obj = _get_theme(theme_key_for_sprites) | |
| role_raw = _theme_obj.localized_field("apprentice_role", lang).strip() | |
| if role_raw: | |
| if lang == "zh": | |
| champion_label = _md_safe(role_raw) | |
| else: | |
| champion_label = _md_safe(role_raw).upper() | |
| else: | |
| champion_label = _md_safe(_t("figure_apprentice", lang)) | |
| result = ( | |
| f"<div class='oracle-inscribe-figs' style=\"{bg_style}\">" | |
| f"<div class='oracle-fig'>" | |
| f"<img src='{wizard_uri}' alt='mentor'/>" | |
| f"<div class='oracle-fig-label'>{you_label}<br><span>{mentor_caption}</span></div>" | |
| "</div>" | |
| "<div class='oracle-fig-arrow'>" | |
| "<div class='oracle-arrow-scrolls'>I II III IV V</div>" | |
| "<div class='oracle-arrow-line'>━━━━━━></div>" | |
| f"<div class='oracle-arrow-caption'>{arrow_caption}</div>" | |
| "</div>" | |
| f"<div class='oracle-fig'>" | |
| f"<img src='{hero_uri}' alt='champion'/>" | |
| f"<div class='oracle-fig-label'>{champion_label}<br><span>{finale_caption}</span></div>" | |
| "</div>" | |
| "</div>" | |
| ) | |
| _INSCRIBE_DECORATION_CACHE[cache_key] = result | |
| return result | |
| # --------------------------------------------------------------------------- | |
| # Memoization caches for HTML helpers that are recomputed on every click. | |
| # Each helper is a pure function of a small slice of state (theme + lang + | |
| # mode at most). Caching by the relevant tuple turns repeated identical | |
| # calls into a dict lookup. On a 6-theme × 2-lang × 5-mode app, the caches | |
| # saturate at ~60 entries — bounded, no eviction needed. | |
| # --------------------------------------------------------------------------- | |
| _BANNER_HTML_CACHE: dict = {} | |
| _THEME_STYLE_CACHE: dict = {} | |
| _INSCRIBE_DECORATION_CACHE: dict = {} | |
| _LOCALIZED_CHROME_CACHE: dict = {} | |
| _GRIMOIRE_LEFT_CACHE: dict = {} | |
| _BADGE_CACHE: dict = {} | |
| def _banner_html(state: Optional[GameState] = None) -> str: | |
| """Top-of-page parallax banner with the game title overlaid. | |
| Cached by (theme, lang, mode) — these are the only fields the rendered | |
| HTML depends on. The bundle re-runs this helper on every click; without | |
| caching, each call rebuilds the same string. Cache hit is a dict | |
| lookup; cache miss recomputes. | |
| """ | |
| theme_key = getattr(state, "theme", "fantasy") if state else "fantasy" | |
| lang = _tlang(state) | |
| mode = getattr(state, "mode", "inscribe") if state else "inscribe" | |
| vm_full = _full_visual_mode(state) | |
| cache_key = (theme_key, lang, mode, vm_full) | |
| if cache_key in _BANNER_HTML_CACHE: | |
| return _BANNER_HTML_CACHE[cache_key] | |
| from oracles.themes import get_theme as _get_theme | |
| theme = _get_theme(theme_key) | |
| # Game-wide title — every theme is a chapter under the same banner. | |
| game_title = "弟子" if lang == "zh" else "THE APPRENTICE" | |
| theme_label = theme.localized_field("display_name", lang) or "The Apprentice" | |
| if lang != "zh": | |
| theme_label = theme_label.upper() | |
| title = _md_safe(game_title).replace("'", "’").replace("'", "’") | |
| subtitle_raw = ( | |
| theme.localized_field("banner_subtitle", lang) | |
| or _t("banner_default_subtitle", lang) | |
| ) | |
| subtitle = _md_safe(f"{theme_label} — {subtitle_raw}") | |
| # Decide tall vs compact based on phase. Inscribe = tall (intro vibe). | |
| # Trial/epilogue = compact (give screen real estate to the visuals). | |
| is_compact = mode in ("trial", "epilogue", "send_off") | |
| extra_cls = " oracle-banner-compact" if is_compact else "" | |
| # Lean mode (default for HF Space): use a CSS gradient instead of the | |
| # ~455 KB parallax PNG. Full mode loads the PNG via Gradio's file | |
| # route so the browser caches it after the first request. | |
| if _full_visual_mode(state) and _PARALLAX_PNG.exists(): | |
| src = f"{_FILE_URL_PREFIX}{_url_quote(_PARALLAX_PNG.as_posix(), safe='/')}" | |
| result = ( | |
| f"<div class='oracle-banner{extra_cls}'>" | |
| f"<img src='{src}' alt='banner'/>" | |
| "<div class='oracle-banner-overlay'>" | |
| f"<div class='oracle-banner-title'>{title}</div>" | |
| f"<div class='oracle-banner-sub'>{subtitle}</div>" | |
| "</div></div>" | |
| ) | |
| else: | |
| result = ( | |
| f"<div class='oracle-banner{extra_cls}' style='background:" | |
| "linear-gradient(180deg,var(--w-panel) 0%, var(--w-night) 100%);'>" | |
| "<div class='oracle-banner-overlay'>" | |
| f"<div class='oracle-banner-title'>{title}</div>" | |
| f"<div class='oracle-banner-sub'>{subtitle}</div>" | |
| "</div></div>" | |
| ) | |
| _BANNER_HTML_CACHE[cache_key] = result | |
| return result | |
| def _processing_overlay_html(state: Optional[GameState] = None) -> str: | |
| """Floating modal overlay shown while a generator handler is mid-LLM-call. | |
| A walking-hero sprite paces across a small horizon strip with a caption | |
| explaining what he's doing. Empty string when no work is in flight.""" | |
| msg = getattr(state, "processing_msg", "") if state else "" | |
| if not msg: | |
| return "" | |
| # Theme-aware hero sprite — fall back to canonical "hero" when no | |
| # theme-suffixed variant is on disk. | |
| theme_key = getattr(state, "theme", "fantasy") or "fantasy" | |
| hero_uri = ( | |
| _sprite_data_uri_app(f"hero_{theme_key}") | |
| or _sprite_data_uri_app("hero") | |
| ) | |
| hero_img = ( | |
| f"<img class='oracle-processing-hero' src='{hero_uri}' alt='hero'/>" | |
| if hero_uri else "" | |
| ) | |
| # Four landscape sprites stacked as crossfade layers. Each one fades | |
| # in for ~3.5s then fades out for 0.5s while the hero walks through | |
| # one 4-second exit cycle — so a new landscape appears every time the | |
| # hero exits the right edge. Layers missing on disk are simply omitted. | |
| # The first layer is theme-specific when available; the journey ones | |
| # stay fantasy (generic horizon scenes). | |
| theme_scene = f"scene_{theme_key}_landscape" | |
| layer_names = [ | |
| theme_scene if _sprite_data_uri_app(theme_scene) else "processing_horizon", | |
| "journey_dawn_meadow", | |
| "journey_dusk_forest", | |
| "journey_misty_valley", | |
| ] | |
| layers = [] | |
| for i, name in enumerate(layer_names): | |
| uri = _sprite_data_uri_app(name) | |
| if not uri: | |
| continue | |
| # The theme-scene backdrops (scene_<theme>_landscape) are square | |
| # Klein vignettes with chroma-keyed transparent margins; `cover` | |
| # scales them up so the centered content gets cropped + the | |
| # transparent margins reveal a void. Mark this first layer so | |
| # CSS can size it with `contain` + a solid dark fill instead. | |
| extra_cls = " oracle-processing-bg-theme" if name.startswith("scene_") else "" | |
| layers.append( | |
| f"<div class='oracle-processing-bg oracle-processing-bg-{i+1}{extra_cls}' " | |
| f"style=\"background-image:url('{uri}');\"></div>" | |
| ) | |
| bg_html = "".join(layers) | |
| return ( | |
| "<div class='oracle-processing-modal'>" | |
| "<div class='oracle-processing-overlay'>" | |
| + bg_html + | |
| f"<div class='oracle-processing-caption'>{_md_safe(msg)}</div>" | |
| f"{hero_img}" | |
| "</div>" | |
| "</div>" | |
| ) | |
| def _global_sprite_vars_html() -> str: | |
| """One-time ``<style>`` block that pushes a few sprite data URIs onto | |
| ``:root`` as CSS variables. CSS rules elsewhere reference them via | |
| ``var(--ground-tile-uri)`` etc. so we keep large data URIs out of the | |
| main stylesheet string.""" | |
| ground = _sprite_data_uri_app("ground_tile_grass") | |
| if not ground: | |
| return "" | |
| return ( | |
| "<style data-sprite-vars>\n" | |
| f":root {{ --ground-tile-uri: url('{ground}') !important; }}\n" | |
| "</style>" | |
| ) | |
| def _phase_backdrop_styles_html() -> str: | |
| """One-time ``<style>`` block (built at app startup) that wires the | |
| per-phase pixel-art ambient textures onto the phase Group wrappers via | |
| their ``elem_id`` attributes. Returns an empty string if any expected | |
| texture is missing from disk OR if visual mode is lean. | |
| Mapping: | |
| #oracle-inscribe-grp ← grimoire_cover_texture | |
| #oracle-send-off-grp ← stormy_plain_texture | |
| #oracle-trial-grp ← night_sky_texture | |
| #oracle-epilogue-grp ← forest_dusk_texture | |
| """ | |
| if not _full_visual_mode(): | |
| return "" | |
| mapping = { | |
| "oracle-inscribe-grp": "grimoire_cover_texture", | |
| "oracle-send-off-grp": "stormy_plain_texture", | |
| "oracle-trial-grp": "night_sky_texture", | |
| "oracle-epilogue-grp": "forest_dusk_texture", | |
| } | |
| rules = [] | |
| for elem_id, sprite_name in mapping.items(): | |
| uri = _sprite_data_uri_app(sprite_name) | |
| if not uri: | |
| continue | |
| rules.append(f"#{elem_id} {{ background-image: url('{uri}') !important; }}") | |
| if not rules: | |
| return "" | |
| return "<style data-phase-backdrops>\n" + "\n".join(rules) + "\n</style>" | |
| def _theme_style_overrides_html(state: Optional[GameState] = None) -> str: | |
| """Emit a tiny ``<style>`` block that overrides CSS custom properties on | |
| ``:root`` based on the active theme's palette. This reskins the entire | |
| page (banner, panels, buttons, accents) without touching component code. | |
| Returns an empty string when the active theme has no palette overrides | |
| (fantasy uses the baseline). The block is injected into a gr.HTML slot | |
| at the top of the layout that re-renders on every event. | |
| """ | |
| theme_key = getattr(state, "theme", "fantasy") if state else "fantasy" | |
| if theme_key in _THEME_STYLE_CACHE: | |
| return _THEME_STYLE_CACHE[theme_key] | |
| from oracles.themes import get_theme as _get_theme | |
| theme = _get_theme(theme_key) | |
| if not theme.palette: | |
| _THEME_STYLE_CACHE[theme_key] = "" | |
| return "" | |
| decls = "\n ".join( | |
| f"--{name}: {value} !important;" | |
| for name, value in theme.palette.items() | |
| ) | |
| result = f"<style data-theme='{theme.key}'>\n:root {{\n {decls}\n}}\n</style>" | |
| _THEME_STYLE_CACHE[theme_key] = result | |
| return result | |
| # --------------------------------------------------------------------------- | |
| # CSS | |
| # --------------------------------------------------------------------------- | |
| CSS = """ | |
| /* Fonts are loaded via the theme's GoogleFont() declarations (Press Start 2P | |
| + VT323), not via @import. Gradio injects user CSS as a constructed | |
| stylesheet which doesn't allow @import — leaving the @import in here | |
| spams the browser console with "@import rules are not allowed here". */ | |
| :root { | |
| --w-night: #18141f; /* page background */ | |
| --w-panel: #2a2438; /* cards */ | |
| --w-amber: #f0c060; /* accent */ | |
| --w-moss: #7ea687; /* secondary accent */ | |
| --w-cream: #ede4d3; /* primary text */ | |
| --w-mute: #b4a890; /* dimmer text */ | |
| --w-shadow: #0c0a13; /* chunky drop shadow */ | |
| --w-dragon: #c84a3a; /* dragon accent */ | |
| --w-dragon-deep: #3a0e0e; | |
| } | |
| /* ---------- Off-brand sweep — hide every Gradio default chrome bit ---------- */ | |
| /* The "Built with Gradio" footer + API badge in the corner are dead | |
| giveaways that this is a Gradio app. The Off-Brand badge wants the | |
| judge to not be able to tell at a glance. */ | |
| footer, | |
| .footer, | |
| .gradio-container > .footer, | |
| .gradio-container footer, | |
| a[href*="gradio.app"], | |
| a[href*="huggingface.co/spaces"][title*="Built with"], | |
| .svelte-built-with-gradio, | |
| .show-api, | |
| .api-docs, | |
| .use-via-api, | |
| .built-with { | |
| display: none !important; | |
| visibility: hidden !important; | |
| height: 0 !important; | |
| } | |
| /* The "Use via API" pill in the bottom-right of HF Spaces. */ | |
| .api-docs-btn, | |
| button.api-docs-btn, | |
| .gradio-container .footer * { | |
| display: none !important; | |
| } | |
| /* Just suppress the Gradio focus-blue ring + their default fonts. | |
| Do NOT zero out block backgrounds — they're providing the | |
| dark-on-light text contrast on the inscribe pages. */ | |
| .gradio-container .info, | |
| .gradio-container .info-text { | |
| font-family: 'VT323', monospace !important; | |
| color: var(--w-cream, #ede4d3) !important; | |
| font-size: 17px !important; | |
| letter-spacing: 0.3px !important; | |
| line-height: 1.5 !important; | |
| opacity: 0.95 !important; | |
| } | |
| .gradio-container label, | |
| .gradio-container .label-wrap, | |
| .gradio-container .label { | |
| font-family: 'Press Start 2P', monospace !important; | |
| font-size: 12px !important; | |
| color: var(--w-amber, #f0c060) !important; | |
| letter-spacing: 1px !important; | |
| } | |
| /* Stop Gradio's default focus glow — we use our own border tinge. */ | |
| .gradio-container *:focus-visible { | |
| outline: 2px solid var(--w-amber, #f0c060) !important; | |
| outline-offset: 1px !important; | |
| box-shadow: none !important; | |
| } | |
| /* Belt-and-suspenders: kill any Gradio default loading / progress / spinner | |
| indicators that try to flash on a yielded handler. We already pass | |
| ``show_progress="hidden"`` on every click + change, but Gradio's CSS | |
| sometimes still inserts a momentary status overlay before the first | |
| yield lands — that brief flash is what the user perceived as flicker | |
| when transitioning between pages. */ | |
| .progress-bar-wrap, | |
| .progress-bar, | |
| .progress-text, | |
| .gradio-container .progress, | |
| .gradio-container .progress-level, | |
| .gradio-container .progress-level-inner, | |
| .gradio-container .progress-bar, | |
| .gradio-container .progress-bar-wrap, | |
| .gradio-container .pending, | |
| .gradio-container .generating, | |
| .gradio-container [class*="progress-level"], | |
| .gradio-container [class*="status"]:not(.oracle-processing-modal):not(.oracle-processing-overlay), | |
| .gradio-container .wrap.default, | |
| .gradio-container .loading, | |
| .gradio-container .spin, | |
| .gradio-container .spinner { | |
| display: none !important; | |
| visibility: hidden !important; | |
| opacity: 0 !important; | |
| } | |
| /* The block-wrapper that Gradio adds a "pending" class to during a | |
| click — suppress its opacity transition so the page doesn't dim. */ | |
| .gradio-container .block.pending, | |
| .gradio-container .block.generating { | |
| opacity: 1 !important; | |
| pointer-events: auto !important; | |
| } | |
| /* ---------- Force dark theme on the whole gradio container ---------- */ | |
| .gradio-container, body, .dark, .gradio-container .form, .gradio-container .block { | |
| background: var(--w-night) !important; | |
| color: var(--w-cream) !important; | |
| font-family: 'VT323', 'Courier New', monospace !important; | |
| } | |
| .gradio-container * { image-rendering: pixelated; } | |
| /* Make gradio's own labels readable on dark */ | |
| .gradio-container label, .gradio-container .label, .gradio-container .label-wrap, | |
| .gradio-container span, .gradio-container p, .gradio-container li { | |
| color: var(--w-cream) !important; | |
| } | |
| .gradio-container input, .gradio-container textarea { | |
| background: var(--w-panel) !important; | |
| color: var(--w-cream) !important; | |
| border: 2px solid var(--w-amber) !important; | |
| box-shadow: 3px 3px 0 var(--w-shadow) !important; | |
| font-family: 'VT323', monospace !important; | |
| font-size: 18px !important; | |
| } | |
| .gradio-container input:focus, .gradio-container textarea:focus { | |
| outline: none !important; | |
| border-color: var(--w-moss) !important; | |
| } | |
| .gradio-container button { | |
| background: var(--w-amber) !important; | |
| color: var(--w-shadow) !important; | |
| font-family: 'Press Start 2P', monospace !important; | |
| font-size: 11px !important; | |
| letter-spacing: 1px !important; | |
| border: none !important; | |
| box-shadow: 4px 4px 0 var(--w-shadow) !important; | |
| padding: 12px 18px !important; | |
| border-radius: 0 !important; | |
| transition: transform 0.05s, box-shadow 0.05s; | |
| } | |
| .gradio-container button:hover { | |
| background: var(--w-moss) !important; | |
| } | |
| .gradio-container button:active { | |
| transform: translate(2px, 2px); | |
| box-shadow: 2px 2px 0 var(--w-shadow) !important; | |
| } | |
| /* ---------- Parallax banner ---------- */ | |
| .oracle-banner { | |
| width: 100%; | |
| height: 200px; | |
| overflow: hidden; | |
| position: relative; | |
| border-bottom: 4px solid var(--w-shadow); | |
| margin-bottom: 0; | |
| } | |
| .oracle-banner img { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: cover; | |
| object-position: center 60%; | |
| image-rendering: pixelated; | |
| filter: brightness(0.65) saturate(1.1); | |
| display: block; | |
| } | |
| .oracle-banner-overlay { | |
| position: absolute; | |
| inset: 0; | |
| background: linear-gradient( | |
| 180deg, | |
| rgba(24, 20, 31, 0.05) 0%, | |
| rgba(24, 20, 31, 0.45) 60%, | |
| rgba(24, 20, 31, 0.95) 100% | |
| ); | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: center; | |
| align-items: center; | |
| text-align: center; | |
| padding: 0 20px; | |
| } | |
| .oracle-banner-overlay .oracle-banner-title { | |
| font-family: 'Press Start 2P', monospace !important; | |
| font-size: 28px; | |
| color: var(--w-amber); | |
| text-shadow: | |
| 4px 4px 0 var(--w-shadow), | |
| 0 0 24px rgba(240, 192, 96, 0.5); | |
| letter-spacing: 4px; | |
| margin: 0; | |
| } | |
| .oracle-banner-overlay .oracle-banner-sub { | |
| font-family: 'VT323', monospace !important; | |
| color: var(--w-cream); | |
| font-size: 20px; | |
| margin-top: 12px; | |
| opacity: 0.95; | |
| text-shadow: 2px 2px 0 var(--w-shadow); | |
| } | |
| /* ---------- Title / badge ---------- */ | |
| .oracle-title { | |
| font-family: 'Press Start 2P', monospace !important; | |
| font-size: 14px !important; | |
| color: var(--w-amber) !important; | |
| text-shadow: 3px 3px 0 var(--w-shadow); | |
| letter-spacing: 2px; | |
| margin: 0 0 8px 0; | |
| } | |
| .oracle-subtitle { | |
| font-style: italic; | |
| color: var(--w-mute) !important; | |
| font-size: 16px; | |
| margin-bottom: 8px; | |
| } | |
| .oracle-badge { | |
| display: inline-block; | |
| padding: 6px 14px; | |
| background: var(--w-amber); | |
| color: var(--w-shadow) !important; | |
| font-family: 'Press Start 2P', monospace !important; | |
| font-weight: 400; | |
| font-size: 10px; | |
| letter-spacing: 1px; | |
| margin-left: 8px; | |
| border: 2px solid var(--w-shadow); | |
| box-shadow: 3px 3px 0 var(--w-shadow); | |
| vertical-align: middle; | |
| } | |
| .oracle-mock-banner { | |
| font-style: italic; | |
| color: var(--w-amber) !important; | |
| font-size: 14px; | |
| margin: 4px 0 10px 0; | |
| border: 1px dashed var(--w-amber); | |
| padding: 4px 10px; | |
| display: inline-block; | |
| } | |
| /* ---------- Card (the main panel for trial / epilogue) ---------- */ | |
| .oracle-card { | |
| background: var(--w-panel); | |
| border: 4px solid var(--w-amber); | |
| box-shadow: 6px 6px 0 var(--w-shadow); | |
| padding: 22px 26px; | |
| margin: 14px 0 18px 0; | |
| color: var(--w-cream) !important; | |
| } | |
| .oracle-card[data-dragon="true"] { | |
| background: linear-gradient(160deg, var(--w-dragon-deep) 0%, #1a0606 100%); | |
| border-color: var(--w-dragon); | |
| box-shadow: 6px 6px 0 #260606; | |
| } | |
| .oracle-card[data-dragon="true"] .oracle-trial-heading { | |
| color: var(--w-dragon); | |
| text-shadow: 3px 3px 0 #1a0606; | |
| } | |
| .oracle-trial-heading { | |
| font-family: 'Press Start 2P', monospace !important; | |
| font-size: 18px; | |
| color: var(--w-amber); | |
| text-shadow: 3px 3px 0 var(--w-shadow); | |
| margin: 0 0 14px 0; | |
| letter-spacing: 2px; | |
| /* Buffering panel — same treatment as narration/tactic so the | |
| heading stays readable on any scene image behind it. */ | |
| background: rgba(20, 14, 30, 0.78); | |
| border: 1px solid rgba(240, 192, 96, 0.35); | |
| padding: 10px 16px; | |
| display: inline-block; | |
| } | |
| .oracle-obstacle { | |
| font-size: 19px; | |
| line-height: 1.5; | |
| color: var(--w-cream) !important; | |
| margin-bottom: 14px; | |
| /* Buffering for the obstacle setup text. */ | |
| background: rgba(20, 14, 30, 0.72); | |
| border: 1px solid rgba(240, 192, 96, 0.20); | |
| padding: 12px 16px; | |
| } | |
| /* ---------- Oracle quote blockquote ---------- */ | |
| blockquote.oracle-quote { | |
| border-left: 6px solid var(--w-amber); | |
| margin: 12px 0 16px 0; | |
| padding: 10px 16px; | |
| font-style: italic; | |
| background: rgba(24, 20, 31, 0.55); | |
| color: var(--w-cream) !important; | |
| border-radius: 0; | |
| white-space: pre-wrap; | |
| font-size: 18px; | |
| } | |
| .oracle-card[data-dragon="true"] blockquote.oracle-quote { | |
| border-left-color: var(--w-dragon); | |
| color: #ffd9c3 !important; | |
| background: rgba(0,0,0,0.4); | |
| } | |
| .oracle-narration { | |
| font-size: 18px; | |
| line-height: 1.6; | |
| color: var(--w-cream) !important; | |
| /* Inline dark backdrop — every LLM-generated narration sits on a | |
| scene image with unpredictable brightness. Without a buffer the | |
| cream text disappears whenever a light patch of the scene sits | |
| under it. This semi-transparent panel rides ON TOP of the scene | |
| image and gives the text its own consistent contrast layer. */ | |
| background: rgba(20, 14, 30, 0.72); | |
| border: 1px solid rgba(240, 192, 96, 0.25); | |
| padding: 14px 18px; | |
| box-shadow: 0 0 22px rgba(0, 0, 0, 0.35) inset; | |
| border-radius: 0; | |
| } | |
| /* The trial-await "He has N oracles left" status line is rendered as a | |
| bare .oracle-narration (no scene image, no .oracle-bg). Gradio's host | |
| CSS has at times beaten the base rule above on cascade, leaving the | |
| text floating on the raw scene. Lock the backdrop with !important so | |
| the status line always reads against a dark panel. Scoped via :not() | |
| so the .oracle-bg case (which provides its own backdrop via ::before) | |
| is untouched. */ | |
| .oracle-narration:not(.oracle-bg) { | |
| background-color: rgba(20, 14, 30, 0.85) !important; | |
| border: 1px solid rgba(240, 192, 96, 0.28) !important; | |
| padding: 14px 18px !important; | |
| box-shadow: 0 0 22px rgba(0, 0, 0, 0.40) inset !important; | |
| } | |
| .oracle-tactic { | |
| font-size: 16px; | |
| color: var(--w-amber) !important; | |
| margin-top: 14px; | |
| font-style: italic; | |
| border-top: 1px dashed var(--w-amber); | |
| padding-top: 8px; | |
| /* Same buffering treatment for the tactic one-liner. */ | |
| background: rgba(20, 14, 30, 0.70); | |
| border: 1px solid rgba(240, 192, 96, 0.25); | |
| padding: 10px 16px; | |
| margin-top: 12px; | |
| } | |
| .oracle-card[data-dragon="true"] .oracle-tactic { | |
| color: var(--w-dragon) !important; | |
| border-top-color: var(--w-dragon); | |
| } | |
| /* ---------- Inscribe phase ---------- */ | |
| .oracle-inscribe-help { | |
| background: var(--w-panel); | |
| border-left: 4px solid var(--w-amber); | |
| padding: 12px 16px; | |
| margin-bottom: 14px; | |
| font-size: 17px; | |
| color: var(--w-cream) !important; | |
| box-shadow: 4px 4px 0 var(--w-shadow); | |
| } | |
| .oracle-error { | |
| color: #ff8a78 !important; | |
| font-weight: 700; | |
| font-size: 16px; | |
| margin-top: 8px; | |
| min-height: 1.2em; | |
| } | |
| .oracle-sealed-list { | |
| color: var(--w-cream) !important; | |
| line-height: 1.7; | |
| font-size: 18px; | |
| } | |
| .oracle-sealed-list strong { | |
| color: var(--w-amber) !important; | |
| font-family: 'Press Start 2P', monospace !important; | |
| font-size: 11px; | |
| letter-spacing: 1px; | |
| } | |
| .oracle-sealed-list ol { | |
| padding-left: 28px; | |
| } | |
| .oracle-sealed-list li { | |
| color: var(--w-cream) !important; | |
| margin-bottom: 6px; | |
| } | |
| .oracle-sealed-list li::marker { | |
| color: var(--w-amber); | |
| font-weight: bold; | |
| } | |
| /* ---------- Image wrap ---------- */ | |
| .oracle-image-wrap { | |
| text-align: center; | |
| margin: 14px 0; | |
| } | |
| .oracle-image-wrap img { | |
| max-width: 100%; | |
| height: auto; | |
| border: 4px solid var(--w-amber); | |
| box-shadow: 6px 6px 0 var(--w-shadow); | |
| image-rendering: pixelated; | |
| } | |
| .oracle-card[data-dragon="true"] .oracle-image-wrap img { | |
| border-color: var(--w-dragon); | |
| } | |
| /* ---------- Pixel decoration: small inline SVG icons ---------- */ | |
| .oracle-icon { | |
| display: inline-block; | |
| vertical-align: middle; | |
| image-rendering: pixelated; | |
| margin-right: 6px; | |
| } | |
| /* ---------- The grimoire (inscribe page, book spread) ---------- */ | |
| /* The .oracle-grimoire is on a gr.Row. Gradio's row is already flex with | |
| horizontal direction, so we just style the band and let the two child | |
| columns sit side by side. Gradio uses gap by default; we override. */ | |
| .oracle-grimoire { | |
| background: var(--w-shadow) !important; | |
| /* Chunky pixel-art amber border with darker inner stitch */ | |
| border: 6px solid var(--w-amber) !important; | |
| box-shadow: | |
| 10px 10px 0 var(--w-shadow), | |
| inset 0 0 0 2px #6a4818 !important; | |
| margin: 14px 0 18px 0 !important; | |
| padding: 0 !important; | |
| gap: 0 !important; | |
| flex-wrap: nowrap !important; | |
| align-items: stretch !important; | |
| position: relative; | |
| } | |
| .oracle-grimoire > * { | |
| margin: 0 !important; | |
| border-radius: 0 !important; | |
| } | |
| /* Visible book spine running down the centre where the two pages meet */ | |
| .oracle-grimoire::before { | |
| content: ""; | |
| position: absolute; | |
| top: 0; bottom: 0; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| width: 10px; | |
| background: linear-gradient(180deg, | |
| transparent 0%, | |
| #150f1c 8%, | |
| #0a0710 50%, | |
| #150f1c 92%, | |
| transparent 100%); | |
| box-shadow: | |
| -7px 0 14px rgba(0,0,0,0.75), | |
| 7px 0 14px rgba(0,0,0,0.75); | |
| z-index: 5; | |
| pointer-events: none; | |
| } | |
| /* Five amber "stitching" dots vertically along the spine */ | |
| .oracle-grimoire::after { | |
| content: ""; | |
| position: absolute; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| top: 50%; | |
| width: 4px; | |
| height: 4px; | |
| background: var(--w-amber); | |
| box-shadow: | |
| 0 -76px 0 var(--w-amber), | |
| 0 -38px 0 var(--w-amber), | |
| 0 38px 0 var(--w-amber), | |
| 0 76px 0 var(--w-amber); | |
| z-index: 6; | |
| pointer-events: none; | |
| } | |
| /* Each .grimoire-page is on a gr.Column inside the row. */ | |
| .grimoire-page { | |
| background: | |
| /* candlelight halo at the top of the page */ | |
| radial-gradient(ellipse 70% 40% at 50% 0%, | |
| rgba(240, 192, 96, 0.12) 0%, transparent 70%), | |
| /* faint horizontal ruled-paper lines (writing guides) */ | |
| repeating-linear-gradient(0deg, | |
| transparent 0px, transparent 32px, | |
| rgba(240, 192, 96, 0.045) 32px, | |
| rgba(240, 192, 96, 0.045) 33px), | |
| /* warm aged-page tint over the base */ | |
| linear-gradient(180deg, #2f2638 0%, #251d2e 100%) !important; | |
| padding: 38px 36px 34px 36px !important; | |
| color: var(--w-cream) !important; | |
| font-size: 22px !important; | |
| line-height: 1.7 !important; | |
| min-height: 460px; | |
| position: relative; | |
| overflow: hidden; | |
| flex: 1 1 50% !important; | |
| min-width: 0 !important; | |
| } | |
| /* Gradio wraps gr.HTML output in its own div which resets font-size on the | |
| inner content — these selectors win because they target the inner div | |
| directly with !important. */ | |
| .grimoire-page .grimoire-text-body, | |
| .grimoire-page .grimoire-text-body p, | |
| .grimoire-page .grimoire-text-body li, | |
| .grimoire-page .grimoire-text-body strong, | |
| .grimoire-page .grimoire-text-body em, | |
| .grimoire-page .grimoire-text-body blockquote { | |
| font-size: 22px !important; | |
| line-height: 1.7 !important; | |
| } | |
| /* Hand-drawn amber rule near the top of each page */ | |
| .grimoire-page::before { | |
| content: ""; | |
| position: absolute; | |
| top: 18px; | |
| left: 30px; | |
| right: 30px; | |
| height: 2px; | |
| background: linear-gradient(90deg, | |
| transparent 0%, | |
| rgba(240, 192, 96, 0.45) 18%, | |
| rgba(240, 192, 96, 0.70) 50%, | |
| rgba(240, 192, 96, 0.45) 82%, | |
| transparent 100%); | |
| pointer-events: none; | |
| z-index: 1; | |
| } | |
| /* Per-step pixel-art sigil rendered inline (via _grimoire_left_html and | |
| _grimoire_right_sigil_html). Positioned at the inner-bottom corner of | |
| each page so the two sigils mirror across the spine. */ | |
| .grimoire-sigil { | |
| position: absolute; | |
| bottom: 14px; | |
| width: 36px; | |
| height: 36px; | |
| opacity: 0.9; | |
| pointer-events: none; | |
| z-index: 2; | |
| filter: drop-shadow(2px 2px 0 var(--w-shadow)); | |
| } | |
| .grimoire-sigil-left { right: 28px; } | |
| .grimoire-sigil-right { left: 28px; } | |
| /* Illuminated drop-cap: first letter of the wizard's first paragraph | |
| on the left page renders as a chunky pixel-block initial. | |
| Chinese characters look forced as drop-caps — the rule is suppressed | |
| when the inner div has lang="zh" (set by the renderer). */ | |
| .grimoire-text-body[lang="zh"] p:first-of-type::first-letter, | |
| .grimoire-text-body[lang="zh"] p:first-of-type strong:first-of-type::first-letter { | |
| font-size: inherit !important; | |
| color: inherit !important; | |
| text-shadow: none !important; | |
| float: none !important; | |
| margin: 0 !important; | |
| padding: 0 !important; | |
| font-family: inherit !important; | |
| } | |
| .grimoire-page.grimoire-left p:first-of-type::first-letter { | |
| font-family: 'Press Start 2P', 'Courier New', monospace !important; | |
| font-size: 40px !important; | |
| line-height: 1 !important; | |
| color: var(--w-amber) !important; | |
| text-shadow: 3px 3px 0 var(--w-shadow); | |
| float: left; | |
| margin: 4px 8px 0 0; | |
| padding: 2px 4px 0 0; | |
| font-style: normal !important; | |
| } | |
| /* Parchment curl: deeper shadow at the spine edge of each page */ | |
| .grimoire-page.grimoire-left { | |
| box-shadow: | |
| inset -18px 0 24px rgba(0,0,0,0.55), | |
| inset 0 0 28px rgba(0,0,0,0.25); | |
| font-family: 'VT323', 'Courier New', monospace !important; | |
| font-style: italic; | |
| color: var(--w-cream) !important; | |
| } | |
| .grimoire-page.grimoire-right { | |
| box-shadow: | |
| inset 18px 0 24px rgba(0,0,0,0.55), | |
| inset 0 0 28px rgba(0,0,0,0.25); | |
| } | |
| .grimoire-page.grimoire-left p { | |
| margin: 8px 0; | |
| color: var(--w-cream) !important; | |
| } | |
| .grimoire-page.grimoire-left strong { | |
| color: var(--w-amber) !important; | |
| font-style: normal; | |
| font-weight: 600; | |
| } | |
| .grimoire-page.grimoire-left em { | |
| color: var(--w-amber) !important; | |
| font-style: italic; | |
| } | |
| .grimoire-page.grimoire-left blockquote { | |
| border-left: 4px solid var(--w-amber); | |
| margin: 12px 0; | |
| padding: 6px 14px; | |
| background: rgba(0, 0, 0, 0.3); | |
| font-style: italic; | |
| color: var(--w-cream) !important; | |
| } | |
| .grimoire-page.grimoire-left .grimoire-step-marker { | |
| /* In normal flow now (was absolute), rendered AFTER the text body | |
| so it sits BELOW the wizard's speech rather than overlapping. */ | |
| display: inline-block; | |
| margin: 20px 0 0 0; | |
| font-family: 'Press Start 2P', monospace !important; | |
| font-size: 10px; | |
| color: var(--w-amber); | |
| letter-spacing: 1px; | |
| opacity: 0.85; | |
| background: rgba(24, 20, 31, 0.78); | |
| padding: 4px 8px; | |
| border: 1px solid rgba(240, 192, 96, 0.45); | |
| pointer-events: none; | |
| } | |
| /* Reserve a right-side rectangle in the upper corner so the first | |
| paragraph's first 1-2 lines wrap before the marker instead of | |
| running underneath it. Done with a floating phantom block so the | |
| text envelope flows naturally around both the drop-cap (left) AND | |
| the marker zone (right). */ | |
| .grimoire-page.grimoire-left p:first-of-type::before { | |
| content: ""; | |
| float: right; | |
| width: 96px; | |
| height: 36px; | |
| margin: 0 0 6px 8px; | |
| shape-outside: inset(0 0 0 0); | |
| } | |
| .grimoire-page.grimoire-right { | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: center; | |
| } | |
| .grimoire-page.grimoire-right .grimoire-right-helper { | |
| font-family: 'VT323', monospace !important; | |
| color: var(--w-mute); | |
| font-size: 16px; | |
| margin-bottom: 12px; | |
| font-style: italic; | |
| } | |
| .grimoire-page.grimoire-right textarea, | |
| .grimoire-page.grimoire-right input { | |
| background: rgba(0,0,0,0.35) !important; | |
| border: 2px solid var(--w-amber) !important; | |
| color: var(--w-cream) !important; | |
| font-family: 'VT323', monospace !important; | |
| font-size: 19px !important; | |
| padding: 12px !important; | |
| box-shadow: 3px 3px 0 var(--w-shadow) !important; | |
| } | |
| .grimoire-summary { | |
| color: var(--w-cream) !important; | |
| font-family: 'VT323', monospace !important; | |
| font-size: 17px; | |
| line-height: 1.5; | |
| background: rgba(0,0,0,0.3); | |
| border-left: 4px solid var(--w-amber); | |
| padding: 10px 14px; | |
| margin: 8px 0; | |
| } | |
| .grimoire-summary strong { | |
| color: var(--w-amber) !important; | |
| } | |
| .grimoire-summary ol { | |
| margin: 6px 0 6px 18px; | |
| padding: 0; | |
| } | |
| .grimoire-summary ol li { | |
| color: var(--w-cream) !important; | |
| margin: 4px 0; | |
| } | |
| .grimoire-progress { | |
| text-align: center; | |
| margin: 6px 0 0 0; | |
| letter-spacing: 6px; | |
| font-family: 'Press Start 2P', monospace !important; | |
| font-size: 12px; | |
| color: var(--w-amber); | |
| } | |
| .grimoire-progress .dot { | |
| color: var(--w-mute); | |
| } | |
| .grimoire-progress .dot.done { | |
| color: var(--w-amber); | |
| } | |
| @media (max-width: 720px) { | |
| .oracle-grimoire { | |
| flex-direction: column !important; | |
| } | |
| .grimoire-page { | |
| flex: 1 1 100% !important; | |
| min-height: 220px !important; | |
| } | |
| .grimoire-page.grimoire-left { | |
| border-right: none !important; | |
| border-bottom: 2px solid var(--w-amber); | |
| } | |
| } | |
| /* ---------- Prologue (legacy, kept for any references) ---------- */ | |
| .oracle-prologue { | |
| background: var(--w-panel); | |
| border: 4px solid var(--w-amber); | |
| box-shadow: 6px 6px 0 var(--w-shadow); | |
| padding: 22px 26px; | |
| margin: 0 0 18px 0; | |
| color: var(--w-cream) !important; | |
| font-size: 18px; | |
| line-height: 1.6; | |
| } | |
| .oracle-prologue-title { | |
| font-family: 'Press Start 2P', monospace !important; | |
| font-size: 12px; | |
| color: var(--w-amber); | |
| letter-spacing: 2px; | |
| text-shadow: 2px 2px 0 var(--w-shadow); | |
| margin-bottom: 12px; | |
| text-align: center; | |
| } | |
| .oracle-prologue p { | |
| margin: 8px 0; | |
| color: var(--w-cream) !important; | |
| } | |
| .oracle-prologue strong { | |
| color: var(--w-amber) !important; | |
| font-weight: 600; | |
| } | |
| .oracle-prologue em { | |
| color: var(--w-mute); | |
| font-style: italic; | |
| } | |
| .oracle-prologue-quote { | |
| border-left: 4px solid var(--w-amber); | |
| margin: 12px 0; | |
| padding: 6px 14px; | |
| background: rgba(0, 0, 0, 0.3); | |
| font-style: italic; | |
| color: var(--w-cream) !important; | |
| } | |
| /* ---------- Interlude (above the next trial's setup) ---------- */ | |
| .oracle-interlude { | |
| background: rgba(42, 36, 56, 0.6); | |
| border-left: 4px solid var(--w-moss); | |
| padding: 14px 18px; | |
| margin: 0 0 16px 0; | |
| font-style: italic; | |
| color: var(--w-cream) !important; | |
| font-size: 17px; | |
| line-height: 1.55; | |
| } | |
| .oracle-interlude p { | |
| margin: 6px 0; | |
| color: var(--w-cream) !important; | |
| } | |
| .oracle-interlude.oracle-interlude-dragon { | |
| border-left-color: var(--w-dragon); | |
| background: rgba(58, 14, 14, 0.45); | |
| color: #ffd9c3 !important; | |
| } | |
| .oracle-interlude.oracle-interlude-dragon p { | |
| color: #ffd9c3 !important; | |
| } | |
| /* ---------- Inscribe-phase figure row (wizard → 5 oracles → hero) ---------- */ | |
| .oracle-inscribe-figs { | |
| display: flex; | |
| align-items: flex-end; | |
| justify-content: space-between; | |
| gap: 24px; | |
| padding: 24px 18px; | |
| margin: 12px 0 20px 0; | |
| background: var(--w-panel); | |
| border: 4px solid var(--w-amber); | |
| box-shadow: 6px 6px 0 var(--w-shadow); | |
| } | |
| .oracle-inscribe-figs .oracle-fig { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| flex: 0 0 auto; | |
| } | |
| .oracle-inscribe-figs .oracle-fig img { | |
| width: 128px; | |
| height: 128px; | |
| image-rendering: pixelated; | |
| filter: drop-shadow(4px 4px 0 var(--w-shadow)); | |
| } | |
| .oracle-inscribe-figs .oracle-fig-label { | |
| margin-top: 12px; | |
| font-family: 'Press Start 2P', monospace !important; | |
| font-size: 12px; /* was 10 */ | |
| color: var(--w-amber); | |
| text-align: center; | |
| letter-spacing: 1px; | |
| line-height: 1.6; | |
| } | |
| .oracle-inscribe-figs .oracle-fig-label span { | |
| display: block; | |
| font-family: 'VT323', monospace !important; | |
| font-size: 20px; /* was 16 */ | |
| color: var(--w-mute); | |
| font-style: italic; | |
| letter-spacing: 0; | |
| margin-top: 6px; | |
| } | |
| .oracle-inscribe-figs .oracle-fig-arrow { | |
| flex: 1 1 auto; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| color: var(--w-cream); | |
| padding: 0 16px; | |
| min-width: 0; | |
| } | |
| .oracle-arrow-scrolls { | |
| font-family: 'Press Start 2P', monospace !important; | |
| font-size: 14px; | |
| color: var(--w-amber); | |
| letter-spacing: 1px; | |
| margin-bottom: 6px; | |
| text-shadow: 2px 2px 0 var(--w-shadow); | |
| } | |
| .oracle-arrow-line { | |
| font-family: 'VT323', monospace !important; | |
| font-size: 32px; | |
| color: var(--w-amber); | |
| line-height: 1; | |
| letter-spacing: 0; | |
| } | |
| .oracle-arrow-caption { | |
| font-family: 'VT323', monospace !important; | |
| font-size: 22px; /* was 18 */ | |
| color: var(--w-mute); | |
| font-style: italic; | |
| margin-top: 6px; | |
| } | |
| /* ---------- Chronicle accordion text ---------- */ | |
| .gradio-container .label-wrap, .gradio-container details summary { | |
| color: var(--w-amber) !important; | |
| font-family: 'Press Start 2P', monospace !important; | |
| font-size: 11px !important; | |
| letter-spacing: 1px !important; | |
| } | |
| .gradio-container details h3, .gradio-container details strong { | |
| color: var(--w-amber) !important; | |
| font-family: 'Press Start 2P', monospace !important; | |
| font-size: 12px !important; | |
| } | |
| .gradio-container details em { | |
| color: var(--w-mute) !important; | |
| } | |
| /* ---------- Story-tree visualization (epilogue/chronicle) ---------- */ | |
| .story-tree { | |
| background: linear-gradient(180deg, rgba(0,0,0,0.35) 0%, rgba(0,0,0,0.55) 100%); | |
| border: 2px solid var(--w-amber); | |
| box-shadow: 5px 5px 0 var(--w-shadow); | |
| padding: 18px 16px 22px 16px; | |
| margin: 0 0 18px 0; | |
| font-family: 'VT323', monospace; | |
| color: var(--w-cream); | |
| } | |
| .story-tree-header { | |
| text-align: center; | |
| margin-bottom: 14px; | |
| padding-bottom: 10px; | |
| border-bottom: 1px dashed rgba(240, 192, 96, 0.4); | |
| } | |
| .story-tree-title { | |
| font-family: 'Press Start 2P', monospace; | |
| color: var(--w-amber); | |
| font-size: 14px; | |
| letter-spacing: 2px; | |
| margin-bottom: 4px; | |
| } | |
| .story-tree-hint { | |
| color: var(--w-mute); | |
| font-size: 14px; | |
| font-style: italic; | |
| } | |
| .story-tree-grid { | |
| display: grid; | |
| grid-template-columns: repeat(6, 1fr); | |
| gap: 8px; | |
| align-items: start; | |
| } | |
| .story-tree-col { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| min-width: 0; | |
| } | |
| .story-tree-col-label { | |
| font-family: 'Press Start 2P', monospace; | |
| color: var(--w-amber); | |
| font-size: 10px; | |
| text-align: center; | |
| letter-spacing: 1px; | |
| padding: 4px 0; | |
| border-bottom: 1px solid rgba(240, 192, 96, 0.3); | |
| margin-bottom: 4px; | |
| } | |
| .story-node { | |
| border: 2px solid var(--w-shadow); | |
| background: rgba(20, 14, 30, 0.7); | |
| padding: 6px 8px; | |
| min-height: 64px; | |
| border-radius: 0; | |
| transition: transform 0.18s ease, box-shadow 0.18s ease; | |
| } | |
| .story-node.visited { | |
| border-color: var(--w-amber); | |
| background: linear-gradient(180deg, rgba(60, 42, 16, 0.95) 0%, rgba(36, 28, 18, 0.95) 100%); | |
| box-shadow: 3px 3px 0 var(--w-shadow), 0 0 12px rgba(240, 192, 96, 0.25); | |
| /* Subtle breath so the walked path looks alive vs. the static | |
| masked nodes. Same keyframe-anchor pattern (0% = 100% = resting). */ | |
| animation: story-node-breath 5.5s ease-in-out infinite; | |
| } | |
| @keyframes story-node-breath { | |
| 0% { box-shadow: 3px 3px 0 var(--w-shadow), 0 0 12px rgba(240, 192, 96, 0.25); } | |
| 50% { box-shadow: 3px 3px 0 var(--w-shadow), 0 0 18px rgba(240, 192, 96, 0.42); } | |
| 100% { box-shadow: 3px 3px 0 var(--w-shadow), 0 0 12px rgba(240, 192, 96, 0.25); } | |
| } | |
| .story-node.visited:hover { | |
| transform: translate(-1px, -1px); | |
| box-shadow: 4px 4px 0 var(--w-shadow), 0 0 22px rgba(240, 192, 96, 0.55); | |
| } | |
| .story-node.masked { | |
| border-color: rgba(120, 110, 100, 0.35); | |
| background: rgba(20, 14, 30, 0.45); | |
| color: var(--w-mute); | |
| filter: blur(0.5px); | |
| opacity: 0.55; | |
| } | |
| .story-node.masked .story-node-title { | |
| color: rgba(180, 168, 144, 0.6); | |
| font-style: italic; | |
| } | |
| .story-node.ending.visited { | |
| border-color: #ffd966; | |
| background: linear-gradient(180deg, rgba(120, 80, 20, 0.95) 0%, rgba(60, 40, 16, 0.95) 100%); | |
| box-shadow: 3px 3px 0 var(--w-shadow), 0 0 20px rgba(255, 217, 102, 0.5); | |
| } | |
| .story-node-title { | |
| font-family: 'Press Start 2P', monospace; | |
| color: var(--w-amber); | |
| font-size: 9px; | |
| letter-spacing: 0.5px; | |
| margin-bottom: 4px; | |
| line-height: 1.3; | |
| word-break: break-word; | |
| } | |
| .story-node.masked .story-node-title { | |
| font-family: 'VT323', monospace; | |
| font-size: 16px; | |
| color: rgba(180, 168, 144, 0.6); | |
| } | |
| .story-node-tag { | |
| font-family: 'VT323', monospace; | |
| color: var(--w-mute); | |
| font-size: 12px; | |
| margin-bottom: 4px; | |
| } | |
| .story-node-setup { | |
| font-family: 'VT323', monospace; | |
| color: var(--w-cream); | |
| font-size: 13px; | |
| line-height: 1.35; | |
| opacity: 0.92; | |
| } | |
| @media (max-width: 900px) { | |
| .story-tree-grid { | |
| grid-template-columns: repeat(3, 1fr); | |
| gap: 6px; | |
| } | |
| .story-tree-col-label { font-size: 9px; } | |
| .story-node-title { font-size: 8px; } | |
| } | |
| /* ---------- Ending banner on the epilogue screen ---------- */ | |
| .ending-banner { | |
| background: linear-gradient(180deg, rgba(120, 80, 20, 0.92) 0%, rgba(36, 24, 14, 0.92) 100%); | |
| border: 2px solid #ffd966; | |
| box-shadow: 4px 4px 0 var(--w-shadow), 0 0 28px rgba(255, 217, 102, 0.35); | |
| padding: 16px 18px 18px 18px; | |
| margin: 12px 0 18px 0; | |
| text-align: center; | |
| color: var(--w-cream); | |
| font-family: 'VT323', monospace; | |
| position: relative; | |
| } | |
| .ending-eyebrow { | |
| font-family: 'Press Start 2P', monospace; | |
| color: #ffd966; | |
| font-size: 10px; | |
| letter-spacing: 2px; | |
| margin-bottom: 8px; | |
| text-shadow: 1px 1px 0 var(--w-shadow); | |
| } | |
| .ending-title { | |
| font-family: 'Press Start 2P', monospace; | |
| color: #ffd966; | |
| font-size: 16px; | |
| letter-spacing: 1.5px; | |
| line-height: 1.5; | |
| margin-bottom: 14px; | |
| text-shadow: 2px 2px 0 var(--w-shadow); | |
| } | |
| .ending-pips { | |
| display: grid; | |
| grid-template-columns: repeat(5, 1fr); | |
| gap: 6px; | |
| margin-top: 10px; | |
| } | |
| .ending-pip { | |
| border: 2px solid var(--w-shadow); | |
| padding: 6px 4px; | |
| font-family: 'VT323', monospace; | |
| font-size: 13px; | |
| line-height: 1.25; | |
| text-align: center; | |
| min-height: 44px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| word-break: break-word; | |
| } | |
| .ending-pip.lit { | |
| background: linear-gradient(180deg, rgba(255, 217, 102, 0.95) 0%, rgba(180, 130, 30, 0.95) 100%); | |
| border-color: #ffd966; | |
| color: #1a1018; | |
| font-family: 'Press Start 2P', monospace; | |
| font-size: 9px; | |
| letter-spacing: 0.5px; | |
| text-shadow: 1px 1px 0 rgba(255, 240, 200, 0.7); | |
| box-shadow: 0 0 12px rgba(255, 217, 102, 0.5); | |
| /* Subtle pulse so the player's eye lands on the ending they reached. | |
| Keyframes anchored at the resting glow (0% == 100%) so the | |
| Gradio-yield restart never snaps to a darker state — the same | |
| fix pattern used for every other animation in the app. */ | |
| animation: ending-pip-pulse 3.4s ease-in-out infinite; | |
| } | |
| @keyframes ending-pip-pulse { | |
| 0% { box-shadow: 0 0 12px rgba(255, 217, 102, 0.50); | |
| transform: scale(1.00); } | |
| 50% { box-shadow: 0 0 22px rgba(255, 217, 102, 0.85); | |
| transform: scale(1.025); } | |
| 100% { box-shadow: 0 0 12px rgba(255, 217, 102, 0.50); | |
| transform: scale(1.00); } | |
| } | |
| .ending-pip.dim { | |
| background: rgba(20, 14, 30, 0.7); | |
| color: rgba(180, 168, 144, 0.55); | |
| border-color: rgba(120, 110, 100, 0.35); | |
| filter: blur(0.5px); | |
| font-style: italic; | |
| } | |
| .ending-pip-tag { | |
| font-size: 11px; | |
| opacity: 0.7; | |
| margin-left: 2px; | |
| } | |
| @media (max-width: 700px) { | |
| .ending-pips { grid-template-columns: repeat(2, 1fr); } | |
| .ending-title { font-size: 13px; } | |
| } | |
| /* ---------- Motion layer (overnight overhaul) ---------- */ | |
| /* Subtle decorative animations to make the static page feel alive. | |
| All animations respect prefers-reduced-motion (see bottom of block). */ | |
| /* (1) Candle flicker on the grimoire pages. | |
| We add a NEW pseudo-element to .grimoire-page (::after) that overlays a | |
| candlelight halo at the top, then pulse its opacity irregularly. | |
| We don't touch .grimoire-page::before (the hand-drawn rule) to avoid | |
| conflicting with its existing transform/positioning. */ | |
| .grimoire-page::after { | |
| content: ""; | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| height: 40%; | |
| background: radial-gradient(ellipse 70% 100% at 50% 0%, | |
| rgba(240, 192, 96, 0.18) 0%, | |
| rgba(240, 192, 96, 0.08) 40%, | |
| transparent 75%); | |
| pointer-events: none; | |
| z-index: 0; | |
| opacity: 0.95; | |
| animation: grimoire-candle-flicker 2.5s ease-in-out infinite alternate; | |
| } | |
| @keyframes grimoire-candle-flicker { | |
| 0% { opacity: 0.88; } | |
| 23% { opacity: 1.00; } | |
| 47% { opacity: 0.92; } | |
| 71% { opacity: 0.97; } | |
| 88% { opacity: 0.85; } | |
| 100% { opacity: 0.96; } | |
| } | |
| /* Also expose an explicit class hook in case it's added on the element. */ | |
| .grimoire-candle-flicker { | |
| animation: grimoire-candle-flicker 2.5s ease-in-out infinite alternate; | |
| } | |
| /* (2) Ribbon sway on the grimoire bookmark. | |
| We need to preserve the existing translateX(-50%) positioning of | |
| .oracle-grimoire::before, so we override transform-origin and add | |
| the sway via animation that drives the rotation while keeping the | |
| horizontal centering. */ | |
| .oracle-grimoire::before { | |
| transform-origin: top center; | |
| animation: grimoire-ribbon-sway 4s ease-in-out infinite alternate; | |
| } | |
| @keyframes grimoire-ribbon-sway { | |
| 0% { transform: translateX(-50%) rotate(-2deg); } | |
| 50% { transform: translateX(-50%) rotate(0deg); } | |
| 100% { transform: translateX(-50%) rotate(2deg); } | |
| } | |
| /* (3) Wax-seal pulse on grimoire progress dots. | |
| Plays once each time a dot's class becomes .done (Gradio re-renders the | |
| inner HTML so the animation fires fresh on each new sealed oracle). */ | |
| .grimoire-progress .dot.done { | |
| display: inline-block; | |
| animation: grimoire-seal-stamp 0.6s ease-out; | |
| transform-origin: center; | |
| } | |
| @keyframes grimoire-seal-stamp { | |
| 0% { transform: scale(1.6) rotate(-8deg); opacity: 0.4; } | |
| 55% { transform: scale(0.9) rotate(3deg); opacity: 1; } | |
| 100% { transform: scale(1) rotate(0deg); opacity: 1; } | |
| } | |
| /* (4) Sprite hover glow on the trial scene cards. */ | |
| .oracle-image-wrap img { | |
| transition: transform 0.3s ease, filter 0.3s ease, box-shadow 0.3s ease; | |
| } | |
| .oracle-image-wrap img:hover { | |
| transform: scale(1.04); | |
| filter: drop-shadow(0 0 10px rgba(240, 192, 96, 0.55)) | |
| drop-shadow(0 0 18px rgba(240, 192, 96, 0.25)); | |
| } | |
| .oracle-card[data-dragon="true"] .oracle-image-wrap img:hover { | |
| filter: drop-shadow(0 0 10px rgba(200, 74, 58, 0.55)) | |
| drop-shadow(0 0 18px rgba(200, 74, 58, 0.25)); | |
| } | |
| /* (5) Spread entrance fade — DISABLED. | |
| This animation re-fired on every generator yield because Gradio swaps | |
| inner HTML each tick, restarting the keyframes at opacity:0 + | |
| translateX(-12px). The result was a visible 0.5s shift-and-fade on | |
| every button click — what the user described as "all blocks | |
| displaced for a second". The grimoire pages now render statically. */ | |
| @keyframes grimoire-spread-enter { | |
| 0% { opacity: 1; transform: translateX(0); } | |
| 100% { opacity: 1; transform: translateX(0); } | |
| } | |
| /* (6) Candlelight breathing on the trial scene cards. | |
| Can't style internals of an <img> data-URI SVG, so we breathe the whole | |
| card's brightness/filter instead. Skipped the per-rect twinkle for that | |
| reason — documented in PIVOT_LOG. */ | |
| .oracle-image-wrap img { | |
| /* Atmosphere preserved — keyframes now pin frame 0% and 100% to | |
| the resting visual state and put the variation at 50%. Gradio's | |
| inner-HTML rebuilds on each yield still restart the animation | |
| at 0%, but 0% is now indistinguishable from steady-state, so | |
| there's no visible snap. */ | |
| animation: oracle-card-breathe 5s ease-in-out infinite; | |
| } | |
| @keyframes oracle-card-breathe { | |
| 0% { filter: brightness(1.00) saturate(1.04); } | |
| 50% { filter: brightness(1.06) saturate(1.08); } | |
| 100% { filter: brightness(1.00) saturate(1.04); } | |
| } | |
| /* Don't override the hover filter glow — re-state the hover rule AFTER the | |
| breathing keyframe so :hover wins precedence. */ | |
| .oracle-image-wrap img:hover { | |
| animation: none; | |
| transform: scale(1.04); | |
| filter: drop-shadow(0 0 10px rgba(240, 192, 96, 0.55)) | |
| drop-shadow(0 0 18px rgba(240, 192, 96, 0.25)); | |
| } | |
| .oracle-card[data-dragon="true"] .oracle-image-wrap img:hover { | |
| filter: drop-shadow(0 0 10px rgba(200, 74, 58, 0.55)) | |
| drop-shadow(0 0 18px rgba(200, 74, 58, 0.25)); | |
| } | |
| /* ---------- Wizard's Daydream — pipeline demo card on step 0 ---------- */ | |
| .oracle-pipeline-demo { | |
| background: linear-gradient(180deg, #2f2638 0%, #251d2e 100%); | |
| border: 4px solid var(--w-amber); | |
| box-shadow: 6px 6px 0 var(--w-shadow); | |
| padding: 18px 22px 14px 22px; | |
| margin: 14px 0 18px 0; | |
| color: var(--w-cream) !important; | |
| position: relative; | |
| } | |
| .oracle-pipeline-demo::before { | |
| /* Thought-bubble tail pointing up from the card's top-left corner, | |
| suggesting these are the wizard's musings drifting up. */ | |
| content: ""; | |
| position: absolute; | |
| top: -10px; left: 26px; | |
| width: 14px; height: 14px; | |
| background: var(--w-panel); | |
| border: 4px solid var(--w-amber); | |
| border-bottom: none; | |
| border-right: none; | |
| transform: rotate(45deg); | |
| z-index: 1; | |
| } | |
| .pipeline-demo-header { | |
| font-family: 'Press Start 2P', monospace !important; | |
| font-size: 11px; | |
| color: var(--w-amber) !important; | |
| text-shadow: 2px 2px 0 var(--w-shadow); | |
| letter-spacing: 1.5px; | |
| margin-bottom: 14px; | |
| text-align: center; | |
| } | |
| .pipeline-demo-header span.float { | |
| /* a small wobbling thought-cloud emoji for charm */ | |
| display: inline-block; | |
| margin-right: 8px; | |
| animation: pipeline-cloud-bob 3.5s ease-in-out infinite alternate; | |
| } | |
| @keyframes pipeline-cloud-bob { | |
| 0% { transform: translateY(0); } | |
| 100% { transform: translateY(-3px); } | |
| } | |
| .pipeline-demo-grid { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr 1fr; | |
| gap: 12px; | |
| margin-bottom: 12px; | |
| } | |
| @media (max-width: 820px) { | |
| .pipeline-demo-grid { grid-template-columns: 1fr; } | |
| } | |
| .pipeline-demo-panel { | |
| /* Bumped opacity 0.30 → 0.72 so the example-usage text on the | |
| front page reads clearly regardless of what scene/texture sits | |
| behind it. Same buffering treatment as the trial narration. */ | |
| background: rgba(20, 14, 30, 0.72); | |
| border: 1px solid rgba(240, 192, 96, 0.25); | |
| border-left: 3px solid var(--w-moss); | |
| padding: 14px 16px; | |
| font-size: 15px; | |
| line-height: 1.55; | |
| box-shadow: 2px 2px 0 rgba(0,0,0,0.4); | |
| } | |
| .pipeline-demo-panel:nth-child(1) { border-left-color: #f0c060; } | |
| .pipeline-demo-panel:nth-child(2) { border-left-color: #7ea687; } | |
| .pipeline-demo-panel:nth-child(3) { border-left-color: #c84a3a; } | |
| /* ---- Sprite scene + mode row inside each demo panel ---- */ | |
| .pipeline-demo-scene { | |
| position: relative; | |
| width: 100%; | |
| height: 130px; | |
| margin-bottom: 8px; | |
| background: linear-gradient(180deg, | |
| rgba(40, 24, 56, 0.55) 0%, | |
| rgba(40, 24, 56, 0.55) 62%, | |
| /* horizon line */ | |
| rgba(120, 84, 48, 0.85) 64%, | |
| rgba(78, 52, 26, 0.95) 80%, | |
| rgba(40, 24, 8, 1) 100%); | |
| border: 2px solid var(--w-amber); | |
| box-shadow: 2px 2px 0 var(--w-shadow); | |
| overflow: hidden; | |
| image-rendering: pixelated; | |
| } | |
| /* Painted pixel-art ground tile (grass-on-dirt) anchored at the bottom | |
| of the scene. Tiles horizontally so the panel can be any width. The | |
| data URI is injected from a build-time helper so we don't bake an | |
| 800KB image into the static CSS string. */ | |
| .pipeline-demo-scene::after { | |
| content: ""; | |
| position: absolute; | |
| bottom: 0; left: 0; right: 0; | |
| height: 36px; | |
| background-image: var(--ground-tile-uri, none); | |
| background-repeat: repeat-x; | |
| background-size: auto 36px; | |
| background-position: bottom left; | |
| image-rendering: pixelated; | |
| pointer-events: none; | |
| z-index: 0; | |
| } | |
| .pipeline-demo-sprite { | |
| position: absolute; | |
| bottom: 4px; | |
| width: 96px; | |
| height: 96px; | |
| image-rendering: pixelated; | |
| filter: drop-shadow(2px 2px 0 var(--w-shadow)); | |
| } | |
| .pipeline-demo-sprite-foe { left: 6px; transform: scaleX(1); } | |
| .pipeline-demo-sprite-hero { right: 6px; } | |
| .pipeline-demo-mode-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| margin-bottom: 6px; | |
| } | |
| .pipeline-demo-mode-label { | |
| font-family: 'VT323', monospace !important; | |
| font-size: 16px; | |
| color: var(--w-amber); | |
| font-style: italic; | |
| } | |
| /* For demo card itself: layer the backdrop image (inline-styled by | |
| _pipeline_demo_html) under the existing gradient so the painted backdrop | |
| reads as ambience but text stays legible. */ | |
| .oracle-pipeline-demo { | |
| background-blend-mode: normal !important; | |
| background-size: cover !important; | |
| background-position: center 30% !important; | |
| background-repeat: no-repeat !important; | |
| } | |
| .oracle-pipeline-demo::after { | |
| content: ""; | |
| position: absolute; | |
| inset: 0; | |
| background: linear-gradient(180deg, | |
| rgba(24,20,31,0.25) 0%, | |
| rgba(24,20,31,0.55) 100%); | |
| pointer-events: none; | |
| z-index: 0; | |
| } | |
| .oracle-pipeline-demo > * { position: relative; z-index: 1; } | |
| .pipeline-demo-step { | |
| margin: 5px 0; | |
| color: var(--w-cream); | |
| } | |
| .pipeline-demo-tag { | |
| font-family: 'Press Start 2P', monospace !important; | |
| font-size: 9px; | |
| color: var(--w-amber) !important; | |
| letter-spacing: 1px; | |
| margin-right: 6px; | |
| display: inline-block; | |
| } | |
| .pipeline-demo-step code { | |
| background: rgba(240,192,96,0.18); | |
| color: var(--w-amber); | |
| padding: 1px 7px; | |
| font-family: 'VT323', monospace; | |
| font-size: 18px; | |
| border-radius: 2px; | |
| border: 1px solid rgba(240,192,96,0.35); | |
| } | |
| .pipeline-demo-resolution { | |
| border-top: 1px dashed var(--w-mute); | |
| padding-top: 8px; | |
| margin-top: 8px; | |
| font-style: italic; | |
| color: var(--w-cream); | |
| } | |
| .pipeline-demo-mode-badge { | |
| display: inline-block; | |
| font-family: 'Press Start 2P', monospace !important; | |
| font-size: 8px; | |
| color: var(--w-shadow); | |
| background: var(--w-amber); | |
| padding: 2px 6px; | |
| margin-left: 6px; | |
| letter-spacing: 1px; | |
| vertical-align: middle; | |
| } | |
| .pipeline-demo-footer { | |
| text-align: center; | |
| font-size: 16px; | |
| color: var(--w-cream); | |
| border-top: 1px solid var(--w-amber); | |
| padding-top: 12px; | |
| margin-top: 6px; | |
| line-height: 1.45; | |
| } | |
| .pipeline-demo-footer strong { | |
| color: var(--w-amber) !important; | |
| font-family: 'Press Start 2P', monospace !important; | |
| font-size: 10px; | |
| letter-spacing: 1.5px; | |
| margin: 0 2px; | |
| } | |
| /* ---------- Walking-hero processing overlay ---------- | |
| Replaces Gradio's generic spinner with a pixel-art hero crossing a small | |
| horizon strip — gives the LLM-wait a narrative feel ("the hero is | |
| walking to the next obstacle") instead of a tech progress bar. | |
| The data URI for the hero sprite is injected at app startup via a | |
| <style> block in _processing_overlay_styles_html so we don't need to | |
| bake it into the static CSS string. */ | |
| .gradio-container .progress, | |
| .gradio-container .progress-text, | |
| .gradio-container .progress-bar, | |
| .gradio-container .progress-level, | |
| .gradio-container .progress-level-inner { | |
| color: var(--w-amber) !important; | |
| } | |
| .gradio-container .progress-text, | |
| .gradio-container .meta-text { | |
| font-family: 'Press Start 2P', monospace !important; | |
| font-size: 11px !important; | |
| letter-spacing: 1px !important; | |
| } | |
| /* Floating modal that dims the page while the hero walks across a | |
| pixel-art horizon. Sits above all other UI via z-index, centered | |
| regardless of scroll position. */ | |
| .oracle-processing-modal { | |
| position: fixed; | |
| inset: 0; | |
| background: rgba(8, 6, 14, 0.55); | |
| backdrop-filter: blur(3px); | |
| -webkit-backdrop-filter: blur(3px); | |
| z-index: 9999; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| animation: oracle-modal-fade-in 0.25s ease-out; | |
| } | |
| @keyframes oracle-modal-fade-in { | |
| 0% { opacity: 0; } | |
| 100% { opacity: 1; } | |
| } | |
| .oracle-processing-overlay { | |
| position: relative; | |
| width: 92%; | |
| max-width: 540px; | |
| height: 200px; | |
| background-color: rgba(34, 22, 50, 1); | |
| border: 4px solid var(--w-amber); | |
| box-shadow: | |
| 8px 8px 0 var(--w-shadow), | |
| 0 0 60px rgba(240, 192, 96, 0.25); | |
| overflow: hidden; | |
| image-rendering: pixelated; | |
| animation: oracle-modal-pop 0.35s cubic-bezier(.18,.89,.32,1.28); | |
| } | |
| /* ---- Rotating journey backdrops ---- | |
| 4 stacked layers, each fades in for ~3.5s then out for 0.5s every 16s | |
| (offset by 4s, matching the hero's walk cycle). Effect: a new landscape | |
| appears each time the hero exits the right edge. */ | |
| .oracle-processing-bg { | |
| position: absolute; | |
| inset: 0; | |
| background-position: center bottom; | |
| background-size: cover; | |
| background-repeat: no-repeat; | |
| image-rendering: pixelated; | |
| opacity: 0; | |
| animation: oracle-bg-cycle 16s linear infinite; | |
| z-index: 0; | |
| } | |
| /* Theme-scene backdrops are square Klein vignettes with chroma-keyed | |
| transparent margins. `cover` would crop the centered subject + leave | |
| transparent voids. `contain` keeps the subject whole, and the dark | |
| solid fill replaces the magenta-keyed margins so nothing bleeds. */ | |
| .oracle-processing-bg.oracle-processing-bg-theme { | |
| background-size: contain !important; | |
| background-position: center center !important; | |
| background-color: rgba(20, 14, 30, 0.95); | |
| } | |
| .oracle-processing-bg-1 { animation-delay: 0s; } | |
| .oracle-processing-bg-2 { animation-delay: -4s; } | |
| .oracle-processing-bg-3 { animation-delay: -8s; } | |
| .oracle-processing-bg-4 { animation-delay: -12s; } | |
| @keyframes oracle-bg-cycle { | |
| 0% { opacity: 0; } | |
| 2% { opacity: 1; } /* fade in over first 2% (0.32s) */ | |
| 22% { opacity: 1; } /* visible for 20% (3.2s) */ | |
| 25% { opacity: 0; } /* fade out over 3% (0.48s) */ | |
| 100% { opacity: 0; } /* hidden for the remaining 75% (12s) */ | |
| } | |
| @keyframes oracle-modal-pop { | |
| 0% { transform: scale(0.85); opacity: 0; } | |
| 100% { transform: scale(1); opacity: 1; } | |
| } | |
| .oracle-processing-overlay::before { | |
| /* Distant tiny stars + tiny mountains drawn via repeating gradients */ | |
| content: ""; | |
| position: absolute; | |
| top: 4px; left: 0; right: 0; height: 18px; | |
| background: | |
| radial-gradient(circle at 12% 30%, #ede4d3 0 1px, transparent 2px), | |
| radial-gradient(circle at 28% 60%, #ede4d3 0 1px, transparent 2px), | |
| radial-gradient(circle at 47% 25%, #ede4d3 0 1px, transparent 2px), | |
| radial-gradient(circle at 68% 50%, #ede4d3 0 1px, transparent 2px), | |
| radial-gradient(circle at 84% 30%, #ede4d3 0 1px, transparent 2px); | |
| pointer-events: none; | |
| opacity: 0.85; | |
| } | |
| .oracle-processing-hero { | |
| position: absolute; | |
| bottom: 4px; | |
| width: 64px; height: 64px; | |
| z-index: 2; | |
| image-rendering: pixelated; | |
| filter: drop-shadow(2px 2px 0 var(--w-shadow)); | |
| animation: hero-walk 4s linear infinite, hero-bob 0.45s steps(2) infinite; | |
| } | |
| @keyframes hero-walk { | |
| 0% { left: -64px; } | |
| 100% { left: 100%; } | |
| } | |
| @keyframes hero-bob { | |
| 0% { transform: translateY(0); } | |
| 50% { transform: translateY(-3px); } | |
| 100% { transform: translateY(0); } | |
| } | |
| .oracle-processing-caption { | |
| position: absolute; | |
| top: 6px; left: 10px; right: 10px; | |
| font-family: 'Press Start 2P', monospace !important; | |
| font-size: 10px !important; | |
| color: var(--w-amber); | |
| letter-spacing: 1px; | |
| text-shadow: 2px 2px 0 var(--w-shadow), 0 0 8px rgba(0,0,0,0.8); | |
| z-index: 3; | |
| background: rgba(8, 6, 14, 0.45); | |
| padding: 4px 8px; | |
| border: 1px solid rgba(240, 192, 96, 0.55); | |
| } | |
| /* ---------- Open-book spread on the closing grimoire spread (step 8) ---------- */ | |
| .grimoire-summary-book { | |
| display: block; | |
| width: 132px; | |
| height: auto; | |
| margin: 0 auto 12px auto; | |
| image-rendering: pixelated; | |
| filter: drop-shadow(3px 3px 0 var(--w-shadow)); | |
| } | |
| /* ---------- Per-phase ambient texture backdrops on the Group wrappers ---------- */ | |
| /* Each phase Group can opt into a tiled pixel-art ambient backdrop. The | |
| data-URI fill is injected at render time via a Python helper so the CSS | |
| stays static; the gradient overlay keeps text legible. */ | |
| .phase-bg { | |
| position: relative; | |
| background-repeat: repeat; | |
| background-size: 320px; | |
| image-rendering: pixelated; | |
| /* Suppress the "blocks displaced for a second" click-flash by | |
| isolating layout. ``contain: layout`` tells the browser the | |
| phase-wrapper's children can't affect ancestor layout — so when | |
| Gradio toggles visible=True/False on a sibling group, this | |
| wrapper doesn't need to be re-measured. */ | |
| contain: layout style; | |
| } | |
| /* Reserve a stable height for whichever phase is mounted so toggling | |
| sibling phase visibility doesn't cause vertical page jump. */ | |
| #oracle-inscribe-grp, | |
| #oracle-send-off-grp, | |
| #oracle-trial-grp, | |
| #oracle-epilogue-grp { | |
| min-height: 720px; | |
| } | |
| .phase-bg::before { | |
| content: ""; | |
| position: absolute; | |
| inset: 0; | |
| background: linear-gradient( | |
| 180deg, | |
| rgba(24, 20, 31, 0.78) 0%, | |
| rgba(24, 20, 31, 0.88) 100% | |
| ); | |
| pointer-events: none; | |
| z-index: 0; | |
| } | |
| .phase-bg > * { position: relative; z-index: 1; } | |
| /* ---------- Composed trial scene — animated layered backdrop ---------- */ | |
| .oracle-composed-scene { | |
| position: relative; | |
| width: 100%; | |
| /* Aspect-ratio-based height. The Klein-generated scene sprites are | |
| square (1:1) but the trial card is wider than tall. With | |
| `background-size: cover` the scene fills the panel and gets | |
| lightly cropped top/bottom. Using a 16:9 panel keeps the | |
| composition balanced and matches modern viewport norms. */ | |
| aspect-ratio: 16 / 9; | |
| min-height: 320px; | |
| max-height: 520px; | |
| border: 4px solid var(--w-amber); | |
| box-shadow: 8px 8px 0 var(--w-shadow); | |
| overflow: hidden; | |
| image-rendering: pixelated; | |
| background: var(--w-night); | |
| margin: 14px 0 18px 0; | |
| } | |
| .oracle-composed-scene[data-dragon="true"] { | |
| border-color: var(--w-dragon); | |
| box-shadow: 8px 8px 0 #260606; | |
| } | |
| /* Layer 1: env backdrop fills the frame and very slowly parallax-drifts. | |
| Position-absolute + zoom slightly larger than the box so the drift | |
| doesn't expose edges. */ | |
| .oracle-composed-scene .scene-bg { | |
| position: absolute; | |
| inset: 0; | |
| width: 100%; | |
| height: 100%; | |
| background-size: cover; /* fill the panel edge-to-edge */ | |
| background-position: center; | |
| background-repeat: no-repeat; | |
| background-color: var(--w-night); | |
| image-rendering: pixelated; | |
| z-index: 0; | |
| /* NO animation. The previous drift caused flicker every time | |
| Gradio replaced the inner HTML (yield) because animations | |
| restart on element creation. */ | |
| } | |
| /* Dim the backdrop a touch so foreground sprites pop. */ | |
| .oracle-composed-scene .scene-bg::after { | |
| content: ""; | |
| position: absolute; | |
| inset: 0; | |
| background: linear-gradient( | |
| 180deg, | |
| rgba(24, 20, 31, 0.25) 0%, | |
| rgba(24, 20, 31, 0.55) 100% | |
| ); | |
| pointer-events: none; | |
| } | |
| /* Layer 2: pixel ground strip with grass tufts at the base of the frame. | |
| Uses the same painted ground tile we already injected as | |
| --ground-tile-uri (falls back to a CSS gradient). */ | |
| .oracle-composed-scene .scene-ground { | |
| position: absolute; | |
| bottom: 0; | |
| left: 0; | |
| right: 0; | |
| height: 56px; | |
| background-image: var(--ground-tile-uri, none); | |
| background-size: auto 56px; | |
| background-repeat: repeat-x; | |
| background-position: bottom left; | |
| image-rendering: pixelated; | |
| z-index: 1; | |
| box-shadow: inset 0 24px 24px -16px rgba(0,0,0,0.5); | |
| } | |
| /* Layer 3: hero sprite on the LEFT — idle bob + slight bob-shadow. */ | |
| .oracle-composed-scene .scene-hero { | |
| position: absolute; | |
| bottom: 28px; | |
| left: 10%; | |
| width: 132px; | |
| height: 132px; | |
| image-rendering: pixelated; | |
| filter: drop-shadow(3px 3px 0 var(--w-shadow)); | |
| z-index: 3; | |
| /* Atmosphere preserved — keyframes anchor 0% and 100% to the | |
| resting state so Gradio's per-yield rebuild lands on an | |
| indistinguishable frame. Variation lives at 50%. */ | |
| animation: scene-hero-bob 1.2s ease-in-out infinite; | |
| } | |
| @keyframes scene-hero-bob { | |
| 0% { transform: translateY(0); } | |
| 50% { transform: translateY(-4px); } | |
| 100% { transform: translateY(0); } | |
| } | |
| /* Layer 4: obstacle sprite on the RIGHT — same flicker fix as scene-hero. */ | |
| .oracle-composed-scene .scene-obstacle { | |
| position: absolute; | |
| bottom: 22px; | |
| right: 10%; | |
| width: 156px; | |
| height: 156px; | |
| image-rendering: pixelated; | |
| filter: drop-shadow(3px 3px 0 var(--w-shadow)); | |
| z-index: 2; | |
| transform-origin: bottom center; | |
| } | |
| .oracle-composed-scene .scene-obstacle.anim-breathe { | |
| animation: scene-monster-breathe 2.4s ease-in-out infinite; | |
| } | |
| .oracle-composed-scene .scene-obstacle.anim-breathe-slow { | |
| animation: scene-monster-breathe 4.0s ease-in-out infinite; | |
| } | |
| .oracle-composed-scene .scene-obstacle.anim-stomp { | |
| animation: scene-monster-stomp 1.6s ease-in-out infinite; | |
| } | |
| @keyframes scene-monster-breathe { | |
| 0% { transform: scale(1.00); } | |
| 50% { transform: scale(1.07); } | |
| 100% { transform: scale(1.00); } | |
| } | |
| @keyframes scene-monster-stomp { | |
| 0% { transform: translateY(0) scaleY(1.00); } | |
| 45% { transform: translateY(-3px) scaleY(0.97); } | |
| 55% { transform: translateY(-3px) scaleY(0.97); } | |
| 100% { transform: translateY(0) scaleY(1.00); } | |
| } | |
| /* Layer 5: force-trial atmospheric particles — small twinkling pixel | |
| sparkles drifting across the scene, CSS-only via repeating radial | |
| gradients animated with background-position. */ | |
| .oracle-composed-scene .scene-particles { | |
| position: absolute; | |
| inset: 0; | |
| pointer-events: none; | |
| z-index: 4; | |
| background: | |
| radial-gradient(circle 1px at 18% 28%, #ede4d3 0 1px, transparent 2px), | |
| radial-gradient(circle 1px at 47% 52%, #ede4d3 0 1px, transparent 2px), | |
| radial-gradient(circle 1px at 72% 18%, #ede4d3 0 1px, transparent 2px), | |
| radial-gradient(circle 1px at 88% 65%, #ede4d3 0 1px, transparent 2px); | |
| opacity: 0.7; | |
| animation: scene-particles-drift 6s linear infinite; | |
| } | |
| @keyframes scene-particles-drift { | |
| 0% { background-position: 0 -60px, 0 -60px, 0 -60px, 0 -60px; opacity: 0.7; } | |
| 50% { background-position: 0 0, 0 0, 0 0, 0 0; opacity: 0.9; } | |
| 100% { background-position: 0 -60px, 0 -60px, 0 -60px, 0 -60px; opacity: 0.7; } | |
| } | |
| /* Caption strip at the very bottom of the scene — tiny pixel-font tag. */ | |
| .oracle-composed-scene .scene-caption { | |
| position: absolute; | |
| bottom: 4px; | |
| left: 8px; | |
| right: 8px; | |
| z-index: 5; | |
| font-family: 'VT323', monospace !important; | |
| font-size: 16px; | |
| color: var(--w-amber); | |
| text-shadow: 2px 2px 0 var(--w-shadow); | |
| background: rgba(8, 6, 14, 0.50); | |
| padding: 4px 10px; | |
| border-left: 3px solid var(--w-amber); | |
| font-style: italic; | |
| } | |
| /* ---------- Compact banner during trial/epilogue phases ---------- | |
| Once the player leaves the inscribe phase, the banner collapses to a | |
| ~72px strip so the trial visual gets the vertical real estate. Title | |
| shrinks proportionally so the brand still reads. A CSS transition | |
| makes the resize smooth when the player advances. */ | |
| .oracle-banner { | |
| transition: height 0.4s ease-out; | |
| } | |
| .oracle-banner.oracle-banner-compact { | |
| height: 72px !important; | |
| border-bottom-width: 2px !important; | |
| } | |
| .oracle-banner.oracle-banner-compact .oracle-banner-overlay { | |
| background: linear-gradient( | |
| 180deg, | |
| rgba(24, 20, 31, 0.20) 0%, | |
| rgba(24, 20, 31, 0.65) 100% | |
| ) !important; | |
| padding: 0 16px !important; | |
| /* Keep title + subtitle on one line each, tightly stacked. */ | |
| justify-content: center !important; | |
| } | |
| .oracle-banner.oracle-banner-compact .oracle-banner-title { | |
| font-size: 16px !important; | |
| letter-spacing: 2px !important; | |
| text-shadow: 2px 2px 0 var(--w-shadow) !important; | |
| margin: 0 !important; | |
| line-height: 1.0 !important; | |
| } | |
| .oracle-banner.oracle-banner-compact .oracle-banner-sub { | |
| font-size: 13px !important; | |
| margin-top: 4px !important; | |
| line-height: 1.1 !important; | |
| /* Don't let a long theme subtitle wrap to a 3rd line and balloon | |
| the strip. */ | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| max-width: 95%; | |
| } | |
| /* Image inside the compact banner gets cropped tighter so the title | |
| stays legible on top. */ | |
| .oracle-banner.oracle-banner-compact img { | |
| object-position: center 40% !important; | |
| filter: brightness(0.55) saturate(1.1) !important; | |
| } | |
| /* ---------- Bigger, more readable trial typography ---------- */ | |
| .oracle-card .oracle-trial-heading { | |
| font-size: 22px !important; | |
| margin-bottom: 18px !important; | |
| letter-spacing: 2px !important; | |
| } | |
| .oracle-card .oracle-obstacle { | |
| font-size: 21px !important; | |
| line-height: 1.65 !important; | |
| margin-bottom: 16px !important; | |
| } | |
| .oracle-card blockquote.oracle-quote { | |
| font-size: 22px !important; | |
| line-height: 1.55 !important; | |
| padding: 14px 20px !important; | |
| margin: 16px 0 !important; | |
| } | |
| .oracle-card .oracle-narration { | |
| font-size: 22px !important; | |
| line-height: 1.70 !important; | |
| } | |
| .oracle-card .oracle-narration p { | |
| margin: 14px 0 !important; | |
| } | |
| .oracle-card .oracle-tactic { | |
| font-size: 19px !important; | |
| margin-top: 18px !important; | |
| padding-top: 12px !important; | |
| } | |
| /* The reveal text card gets extra top breathing room since the image | |
| sits above it now. */ | |
| .oracle-card.oracle-bg { | |
| padding: 26px 30px !important; | |
| margin-top: 14px !important; | |
| } | |
| /* Trial scene image — larger now that it sits at the top, with breathing | |
| animation that's hover-pausable. */ | |
| .oracle-image-wrap img, | |
| .oracle-image-wrap svg { | |
| max-width: 100% !important; | |
| max-height: 360px !important; | |
| width: auto !important; | |
| height: auto !important; | |
| } | |
| /* ---------- Finer ambient animations (parallax + slow drift) ---------- */ | |
| /* Each backdropped card gets a very slow background-position drift so the | |
| pixel backdrop feels alive instead of static. Combined with the | |
| existing breathing brightness cycle this gives "depth" without being | |
| distracting. */ | |
| .oracle-card.oracle-bg, | |
| .oracle-prologue.oracle-bg, | |
| .oracle-narration.oracle-bg, | |
| .oracle-interlude.oracle-bg { | |
| /* card-breathe + bg-drift kept, but keyframes now anchor 0% and | |
| 100% to the same visual state so the per-yield restart is | |
| invisible. Variation lives at 50%. */ | |
| animation: | |
| oracle-card-breathe 6s ease-in-out infinite, | |
| oracle-bg-drift 28s ease-in-out infinite !important; | |
| } | |
| @keyframes oracle-bg-drift { | |
| 0% { background-position: 65% 55%; } | |
| 50% { background-position: 50% 50%; } | |
| 100% { background-position: 65% 55%; } | |
| } | |
| .oracle-card.oracle-bg[data-dragon="true"] { | |
| animation: | |
| oracle-card-breathe-dragon 6s ease-in-out infinite, | |
| oracle-bg-drift 28s ease-in-out infinite !important; | |
| } | |
| @keyframes oracle-card-breathe-dragon { | |
| 0% { filter: brightness(1.00) saturate(1.04) hue-rotate(0deg); } | |
| 50% { filter: brightness(1.08) saturate(1.10) hue-rotate(-3deg); } | |
| 100% { filter: brightness(1.00) saturate(1.04) hue-rotate(0deg); } | |
| } | |
| /* CASCADE FADE-IN REMOVED: | |
| The cascade re-fired on every UI bundle update (Gradio replaces inner | |
| HTML on each handler yield), causing visible flicker mid-trial. | |
| The breathing + bg-drift animations alone are enough atmosphere. */ | |
| /* Faint amber-twinkle decoration sprinkled over the trial card edges */ | |
| .oracle-card.oracle-bg::before { | |
| box-shadow: | |
| 20px 30px 0 1px rgba(240, 192, 96, 0.22), | |
| 180px 14px 0 1px rgba(240, 192, 96, 0.15), | |
| 300px 50px 0 1px rgba(240, 192, 96, 0.22); | |
| animation: amber-twinkle 3.5s ease-in-out infinite alternate; | |
| } | |
| @keyframes amber-twinkle { | |
| 0% { opacity: 0.55; } | |
| 100% { opacity: 1.0; } | |
| } | |
| /* ---------- (removed) breathing animation on backdropped panels ---------- | |
| The keyframe-driven brightness cycle restarted every time Gradio | |
| replaced the inner HTML on a generator yield (each click yields | |
| 2-3 times), producing visible flicker mid-trial. Cards now stay at | |
| a steady brightness — backdrop is enough atmosphere. */ | |
| /* Pixel ground line + grass tufts on the bottom of every backdropped | |
| panel — matches the demo-scene aesthetic so sprites have something | |
| to stand on visually. */ | |
| .oracle-card.oracle-bg::after, | |
| .oracle-prologue.oracle-bg::after, | |
| .oracle-narration.oracle-bg::after, | |
| .oracle-interlude.oracle-bg::after { | |
| content: ""; | |
| position: absolute; | |
| bottom: 0; left: 0; right: 0; | |
| height: 28px; | |
| background: | |
| /* grass tufts (sparse) */ | |
| linear-gradient(90deg, | |
| transparent 0 16px, | |
| rgba(80, 110, 50, 0.85) 16px 22px, | |
| transparent 22px 60px, | |
| rgba(80, 110, 50, 0.85) 60px 64px, | |
| transparent 64px 96px, | |
| rgba(80, 110, 50, 0.85) 96px 104px, | |
| transparent 104px), | |
| /* dirt band */ | |
| linear-gradient(180deg, | |
| transparent 0%, | |
| rgba(78, 52, 26, 0.0) 0%, | |
| rgba(78, 52, 26, 0.55) 40%, | |
| rgba(40, 24, 8, 0.85) 100%); | |
| background-size: 132px 6px, 100% 100%; | |
| background-repeat: repeat-x, no-repeat; | |
| background-position: bottom 8px left, bottom; | |
| pointer-events: none; | |
| z-index: 0; | |
| } | |
| /* The grass strip overlay would lay on TOP of text otherwise; the panel's | |
| ::before already sets up an overlay layer at z-index 0, so we lift it | |
| to z-index 1 so ::after doesn't intercept content. */ | |
| .oracle-card.oracle-bg > *, | |
| .oracle-prologue.oracle-bg > *, | |
| .oracle-narration.oracle-bg > *, | |
| .oracle-interlude.oracle-bg > * { | |
| position: relative; | |
| z-index: 2; | |
| } | |
| /* ---------- Panel backdrops (send-off, interludes, trials, epilogue) ---------- */ | |
| /* Each panel card can opt into a faint pixel-art backdrop image via these | |
| utility classes. The inline `background-image` data URI is injected at | |
| render time from a Python helper; the CSS handles overlay + blending so | |
| text stays legible no matter how busy the image is. */ | |
| .oracle-card.oracle-bg, | |
| .oracle-prologue.oracle-bg, | |
| .oracle-interlude.oracle-bg, | |
| .oracle-narration.oracle-bg { | |
| position: relative; | |
| background-size: cover !important; | |
| background-position: center !important; | |
| background-repeat: no-repeat !important; | |
| image-rendering: pixelated; | |
| } | |
| .oracle-card.oracle-bg::before, | |
| .oracle-prologue.oracle-bg::before, | |
| .oracle-interlude.oracle-bg::before, | |
| .oracle-narration.oracle-bg::before { | |
| content: ""; | |
| position: absolute; | |
| inset: 0; | |
| background: linear-gradient( | |
| 180deg, | |
| rgba(24, 20, 31, 0.62) 0%, | |
| rgba(24, 20, 31, 0.80) 100% | |
| ); | |
| pointer-events: none; | |
| z-index: 0; | |
| } | |
| .oracle-card.oracle-bg > *, | |
| .oracle-prologue.oracle-bg > *, | |
| .oracle-interlude.oracle-bg > *, | |
| .oracle-narration.oracle-bg > * { | |
| position: relative; | |
| z-index: 1; | |
| } | |
| /* Dragon-trial backdrop overlay is slightly more red-tinted */ | |
| .oracle-card.oracle-bg[data-dragon="true"]::before { | |
| background: linear-gradient( | |
| 180deg, | |
| rgba(58, 14, 14, 0.55) 0%, | |
| rgba(26, 6, 6, 0.85) 100% | |
| ); | |
| } | |
| /* Accessibility: honor prefers-reduced-motion. */ | |
| @media (prefers-reduced-motion: reduce) { | |
| *, *::before, *::after { | |
| animation-duration: 0.001ms !important; | |
| animation-iteration-count: 1 !important; | |
| transition-duration: 0.001ms !important; | |
| } | |
| } | |
| """ | |
| # --------------------------------------------------------------------------- | |
| # Render helpers | |
| # --------------------------------------------------------------------------- | |
| def _md_safe(text: str) -> str: | |
| """HTML-escape user-provided text. Newlines -> <br>.""" | |
| return ( | |
| (text or "") | |
| .replace("&", "&") | |
| .replace("<", "<") | |
| .replace(">", ">") | |
| .replace("\n", "<br>") | |
| ) | |
| def _md_block(text: str) -> str: | |
| """Escape and split into <p> blocks on blank lines (for narrations).""" | |
| paragraphs = [p.strip() for p in (text or "").split("\n\n") if p.strip()] | |
| if not paragraphs: | |
| return "" | |
| return "".join(f"<p>{_md_safe(p)}</p>" for p in paragraphs) | |
| def _badge_html(state: GameState, trial_substate: str = TRIAL_STATE_AWAIT) -> str: | |
| """Small status chip shown under the banner — the title itself is in | |
| the parallax banner so this is just a one-line state indicator. | |
| Cached by (lang, mode, current_trial). On a grimoire-spread click | |
| none of those change, so the cache hits every time. | |
| """ | |
| lang = _tlang(state) | |
| cache_key = (lang, state.mode, state.current_trial) | |
| if cache_key in _BADGE_CACHE: | |
| return _BADGE_CACHE[cache_key] | |
| if state.mode == "inscribe": | |
| label = _t("badge_inscribing", lang) | |
| elif state.mode == "send_off": | |
| label = _t("badge_sealed", lang) | |
| elif state.mode == "trial": | |
| label = _t("badge_trial", lang).format( | |
| current=state.current_trial, total=NUM_TRIALS | |
| ) | |
| elif state.mode == "epilogue": | |
| label = _t("badge_return", lang) | |
| else: | |
| label = _t("badge_done", lang) | |
| result = ( | |
| "<div style='margin: 14px 0 4px 0;'>" | |
| f"<span class='oracle-badge'>{label}</span>" | |
| "</div>" | |
| ) | |
| _BADGE_CACHE[cache_key] = result | |
| return result | |
| def _sealed_list_html(state: GameState) -> str: | |
| """Numbered, italicized list of the inscribed oracles for the send-off panel.""" | |
| if not state.oracles: | |
| return "" | |
| lang = _tlang(state) | |
| silence_label = _t("blank_silence", lang) | |
| items = [] | |
| for o in state.oracles: | |
| text = (o.text or "").strip() or silence_label | |
| items.append( | |
| f"<li><em>{_md_safe(text)}</em></li>" | |
| ) | |
| header = _md_safe(_t("sealed_list_header", lang)) | |
| return ( | |
| "<div class='oracle-sealed-list'>" | |
| f"<p><strong>{header}</strong></p>" | |
| "<ol>" + "".join(items) + "</ol>" | |
| "</div>" | |
| ) | |
| # Per-trial backdrop mapping: each of the 5 trials gets a distinct | |
| # pixel-art landscape so the journey feels visually varied. Trial 5 | |
| # always uses the dragon throne room (its theme override is intentional). | |
| _TRIAL_BACKDROPS = { | |
| 1: "trial_crossroads", # start of the journey | |
| 2: "trial_marsh", | |
| 3: "trial_storm_heath", | |
| 4: "trial_cave_entrance", | |
| 5: "dragon_throne_room", # finale; matches the existing fantasy art | |
| } | |
| def _bg_style(sprite_name: str) -> str: | |
| """Return an inline ``style="background-image:url(...)"`` for a panel | |
| backdrop, or an empty string when the sprite isn't on disk.""" | |
| uri = _sprite_data_uri_app(sprite_name) | |
| return f"style=\"background-image:url('{uri}');\"" if uri else "" | |
| def _send_off_narration_html(state: GameState) -> str: | |
| lang = _tlang(state) | |
| hero = state.hero_name or ("the hero" if lang == "en" else "少年") | |
| village = state.village_name or ("his village" if lang == "en" else "他的村庄") | |
| para = _t("tmpl_send_off_narration", lang).format(hero=hero, village=village) | |
| bg = _bg_style("send_off_road") | |
| return f"<div class='oracle-narration oracle-bg' {bg}><p>{_md_safe(para)}</p></div>" | |
| def _trial_heading_html(state: GameState) -> str: | |
| n = state.current_trial | |
| ob = state.current_obstacle() | |
| lang = _tlang(state) | |
| if ob is not None and ob.is_dragon: | |
| # Theme-aware finale heading: use the theme's finale_descriptor | |
| # (e.g. "the warlord-king of the Iron Plains") instead of always | |
| # saying "Dragon". Fantasy still reads "Trial 5: The Dragon" | |
| # because its finale_descriptor IS "the evil dragon". | |
| from oracles.themes import get_theme as _get_theme | |
| theme = _get_theme(getattr(state, "theme", "fantasy") or "fantasy") | |
| finale_name = theme.localized_field("finale_descriptor", lang) \ | |
| or theme.finale_descriptor or "" | |
| # Strip a leading "the " for the title case. | |
| clean = finale_name.strip() | |
| for prefix in ("the ", "The ", "THE "): | |
| if clean.startswith(prefix): | |
| clean = clean[len(prefix):] | |
| break | |
| title = ( | |
| (f"第 {n} 难:{clean}" if lang == "zh" else f"Trial {n}: The {clean}") | |
| if clean else _t("trial_dragon_title", lang).format(n=n) | |
| ) | |
| else: | |
| title = _t("trial_normal_title", lang).format(n=n, total=NUM_TRIALS) | |
| dragon_attr = ' data-dragon="true"' if (ob is not None and ob.is_dragon) else "" | |
| # Per-trial pixel-art backdrop so each fight feels visually distinct. | |
| bg_sprite = _TRIAL_BACKDROPS.get(n, "forest_path") | |
| bg = _bg_style(bg_sprite) | |
| return ( | |
| f"<div class='oracle-card oracle-bg'{dragon_attr} {bg}>" | |
| f"<div class='oracle-trial-heading'>{_md_safe(title)}</div>" | |
| + ( | |
| f"<div class='oracle-obstacle'>{_md_safe(ob.setup)}</div>" | |
| if ob is not None | |
| else "" | |
| ) | |
| + "</div>" | |
| ) | |
| def _interlude_html(state: GameState) -> str: | |
| """Interlude paragraph shown above the next trial's await view. | |
| Indexed by `state.current_trial - 2` (interlude[0] sits above trial 2). | |
| Returns empty HTML if no interlude is set for this transition (e.g. on | |
| trial 1, before the first encounter).""" | |
| idx = state.current_trial - 2 | |
| if idx < 0 or idx >= len(state.interludes): | |
| return "" | |
| text = (state.interludes[idx] or "").strip() | |
| if not text: | |
| return "" | |
| is_dragon_lead = (state.current_trial == NUM_TRIALS) | |
| extra_class = " oracle-interlude-dragon" if is_dragon_lead else "" | |
| paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()] or [text] | |
| body = "".join(f"<p>{_md_safe(p)}</p>" for p in paragraphs) | |
| bg = _bg_style("interlude_path") | |
| return f"<div class='oracle-interlude oracle-bg{extra_class}' {bg}>{body}</div>" | |
| def _trial_await_html(state: GameState) -> str: | |
| """State A within a trial: oracle not yet drawn. Optionally preceded by | |
| an interlude paragraph bridging from the previous trial. | |
| On trial 1 specifically, the send-off narration ('Hero kneels by the | |
| well at dawn…') sits at the very top — it's the first thing the player | |
| reads after closing the grimoire, since we merged the old send_off | |
| intermediate panel into the begin-journey transition.""" | |
| remaining = len(state.unopened()) | |
| lang = _tlang(state) | |
| parts = [] | |
| if state.current_trial == 1: | |
| # Replaces the now-removed send_off panel. Carries the painted | |
| # `send_off_road` backdrop so the leave-the-village atmosphere | |
| # opens the journey visually. | |
| parts.append(_send_off_narration_html(state)) | |
| parts.append(_interlude_html(state)) | |
| if remaining == 1: | |
| remaining_msg = _t("trial_remaining_one", lang).format(remaining=remaining) | |
| else: | |
| remaining_msg = _t("trial_remaining_many", lang).format(remaining=remaining) | |
| parts.append( | |
| "<div class='oracle-narration'>" | |
| f"<p>{remaining_msg}</p>" | |
| "</div>" | |
| ) | |
| return "".join(parts) | |
| def _trial_reveal_html(state: GameState) -> str: | |
| """State B within a trial: oracle drawn + resolution shown (no image — that's separate).""" | |
| if not state.resolutions: | |
| return "" | |
| res = state.resolutions[-1] | |
| oracle = res.oracle | |
| lang = _tlang(state) | |
| roman = _ROMAN.get(oracle.index, str(oracle.index)) | |
| blank_quote = _t("parchment_blank_quote", lang) | |
| quote_safe = _md_safe(oracle.text or blank_quote) | |
| narration_html = _md_block(res.narration) | |
| tactic = (res.tactic or "").strip() | |
| is_dragon = bool(res.obstacle.is_dragon) | |
| dragon_attr = ' data-dragon="true"' if is_dragon else "" | |
| oracle_label = _md_safe( | |
| _t("oracle_label_sealed_since", lang).format(roman=roman) | |
| ) | |
| tactic_html = "" | |
| if tactic: | |
| # Apply the localized template, then HTML-escape the whole rendered | |
| # string so any < or > in the tactic itself is safe. | |
| tactic_line = _t("tactic_prefix", lang).format(tactic=tactic) | |
| tactic_html = f"<div class='oracle-tactic'>{_md_safe(tactic_line)}</div>" | |
| return ( | |
| f"<div class='oracle-card'{dragon_attr}>" | |
| f"<div style='font-weight:600; margin-bottom:6px;'>" | |
| f"{oracle_label}</div>" | |
| f"<blockquote class='oracle-quote'>{quote_safe}</blockquote>" | |
| f"<div class='oracle-narration'>{narration_html}</div>" | |
| + tactic_html | |
| + "</div>" | |
| ) | |
| def _image_html_for(path: str, caption: str) -> str: | |
| """Render the resolution image as inline HTML. | |
| - PNG: read bytes, base64-embed. | |
| - SVG (anything not .png): read content, base64-embed as image/svg+xml. | |
| - Missing/empty path: caption-only fallback. | |
| """ | |
| cap_safe = _md_safe(caption or "") | |
| if not path or not os.path.exists(path): | |
| if not cap_safe: | |
| return "" | |
| return ( | |
| "<div class='oracle-image-wrap'>" | |
| f"<p style='font-style:italic;color:#7a6535'>{cap_safe}</p>" | |
| "</div>" | |
| ) | |
| ext = os.path.splitext(path)[1].lower() | |
| try: | |
| with open(path, "rb") as f: | |
| data = f.read() | |
| except OSError: | |
| return ( | |
| "<div class='oracle-image-wrap'>" | |
| f"<p style='font-style:italic;color:#7a6535'>{cap_safe}</p>" | |
| "</div>" | |
| ) | |
| if ext == ".png": | |
| mime = "image/png" | |
| else: | |
| mime = "image/svg+xml" | |
| b64 = base64.b64encode(data).decode("ascii") | |
| return ( | |
| "<div class='oracle-image-wrap'>" | |
| f"<img src='data:{mime};base64,{b64}' alt='{cap_safe}' " | |
| "style='max-width:100%; max-height:480px;'/>" | |
| "</div>" | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Composed trial scene — replaces the old single-image trial card. | |
| # Classifies the obstacle text by keyword, picks an environment backdrop + | |
| # obstacle creature sprite + hero sprite, then composes them with CSS | |
| # animations (hero idle bob, obstacle breathing scale, etc.) so the scene | |
| # feels alive instead of static. Falls back to the old mock SVG when no | |
| # good match is found. | |
| # --------------------------------------------------------------------------- | |
| # Each entry: list of (keyword_token, weight) — first match (after lowercasing | |
| # the obstacle setup) classifies the scene. Order matters: more specific | |
| # keywords go first so "dragon" beats "scaly thing" via the wider net. | |
| _OBSTACLE_KEYWORDS = { | |
| "dragon": ["dragon", "vraskar", "wyrm", "draconic", "scaled beast", | |
| "warlord", "demon-king", "sovereign", "apep", "serpent", | |
| "warden", "world-eater", "world-eating", | |
| # zh | |
| "巨龙", "龙王", "魔王", "至尊", "巨蛇", "看守", "霸主"], | |
| "guardian": ["sphinx", "vizier", "witch", "knight", "judge", "sage", | |
| "guardian", "bureaucrat", "official", "scribe of", | |
| "monk", "priest", "toll", "beggar", "watcher", | |
| "celestial", "scholar", | |
| # zh | |
| "斯芬克斯", "守卫", "守护", "判官", "贤者", "祭司", "僧", | |
| "石像", "石狮", "石像鬼", "维齐尔", "巫女"], | |
| "brute": ["giant", "troll", "ogre", "wolf", "bear", "swine", "sow", | |
| "monster", "hornet", "wasp", "owl", "heron", "wraith", | |
| "beast", "spider", "bandit", "archer", "fox-spirit", | |
| # zh | |
| "巨人", "山妖", "妖怪", "狼", "熊", "鬼", "怪兽", | |
| "胡蜂", "黄蜂", "强盗", "弓手", "狐妖", "野兽"], | |
| "water": ["river", "marsh", "swamp", "lake", "pond", "water", | |
| "flood", "tide", "current", "yellow spring", "delta", | |
| "reeds", "stream", "well", | |
| # zh | |
| "河", "湖", "潭", "沼", "泽", "水", "洪", "井", | |
| "黄泉", "溪", "瀑"], | |
| "stone": ["mountain", "boulder", "cliff", "rock", "stone", | |
| "scree", "monolith", "pillar", "pillars", "ruin", | |
| "monolithic", "obelisk", "wall", "fortress", | |
| # zh | |
| "山", "崖", "石", "岩", "巨石", "城墙", "废墟", | |
| "石柱", "山岭"], | |
| "force": ["storm", "lightning", "thunder", "wind", "gale", | |
| "curse", "fog", "mist", "snow", "frost", "blizzard", | |
| "fire", "ash", "famine", "plague", "radiation", | |
| "EM cloud", "static", | |
| # zh | |
| "风暴", "雷", "电", "风", "诅咒", "雾", "霾", | |
| "雪", "霜", "火", "灰", "瘟疫", "辐射"], | |
| "barrier": ["chasm", "abyss", "bridge", "ford", "pass", "gate", | |
| "doorway", "gap", "thicket", "thorns", "brambles", | |
| "bunker door", "blast door", | |
| # zh | |
| "深渊", "鸿沟", "裂", "桥", "门", "关", "隘", | |
| "荆棘", "丛"], | |
| "cave": ["cave", "lair", "cavern", "tomb", "crypt", "vault", | |
| "den", "burrow", "ruin", | |
| # zh | |
| "洞", "穴", "窟", "墓", "陵", "巢"], | |
| } | |
| def _classify_obstacle_scene(obstacle_setup: str, is_dragon_trial: bool) -> str: | |
| """Pick a scene category from the obstacle setup text. Returns one of | |
| the keys of _OBSTACLE_KEYWORDS, or 'default' on no match. Trial-5 | |
| forces 'dragon' even if the theme overrode the final monster.""" | |
| if is_dragon_trial: | |
| return "dragon" | |
| text = (obstacle_setup or "").lower() | |
| for category, kws in _OBSTACLE_KEYWORDS.items(): | |
| for kw in kws: | |
| if kw in text: | |
| return category | |
| return "default" | |
| # Category → (env_backdrop_sprite, obstacle_creature_sprite_or_None, | |
| # animation_class_for_obstacle). | |
| _SCENE_FOR_CATEGORY = { | |
| "dragon": ("dragon_throne_room", "dragon", "anim-breathe-slow"), | |
| "guardian": ("sphinx_temple_pillars", "sphinx", "anim-breathe"), | |
| "brute": ("forest_path", "giant", "anim-stomp"), | |
| "water": ("trial_marsh", None, ""), | |
| "stone": ("mountain_pass", None, ""), | |
| # `force` obstacle name `force` only resolves for non-fantasy themes | |
| # (via the _theme_pick fallback to `force_<theme>`). For fantasy it | |
| # silently degrades to env-only since no canonical `force.png` exists. | |
| "force": ("trial_storm_heath", "force", "anim-breathe-slow"), | |
| "barrier": ("trial_chasm", None, ""), | |
| "cave": ("trial_cave_entrance", "troll", "anim-breathe"), | |
| "default": ("forest_path", None, ""), | |
| } | |
| # Non-fantasy themes ship 4 per-theme env scenes (finale / arena / | |
| # passage / trap). This maps the fantasy-flavored scene-category above | |
| # to the closest themed slot — the runtime tries scene_<theme>_<slot> | |
| # first and falls back to scene_<theme>_landscape if missing. | |
| _THEMED_ENV_SLOT = { | |
| "dragon": "finale", | |
| "guardian": "arena", | |
| "brute": "arena", | |
| "water": "passage", | |
| "stone": "passage", | |
| "force": "arena", | |
| "barrier": "passage", | |
| "cave": "passage", | |
| "default": "arena", | |
| # Special raw category names (when env_name is already a sprite name, | |
| # not a category) get their own slot via _categorize_env() below. | |
| } | |
| def _themed_env_slot(env_name: str) -> str: | |
| """Resolve a fantasy env sprite name to a themed-env slot key. | |
| Handles both category-style names (``dragon``) and direct sprite | |
| names (``dragon_throne_room``).""" | |
| if env_name in _THEMED_ENV_SLOT: | |
| return _THEMED_ENV_SLOT[env_name] | |
| # Direct sprite-name fallback: classify by name fragment | |
| if "dragon" in env_name or "throne" in env_name or "finale" in env_name: | |
| return "finale" | |
| if "sphinx" in env_name or "temple" in env_name or "arena" in env_name: | |
| return "arena" | |
| if "marsh" in env_name or "chasm" in env_name or "cave" in env_name or "passage" in env_name: | |
| return "passage" | |
| if "bazaar" in env_name or "market" in env_name or "trap" in env_name: | |
| return "trap" | |
| return "arena" | |
| def _composed_scene_html(state: GameState) -> str: | |
| """Build a layered animated scene for the current trial reveal. | |
| Layers (bottom→top): | |
| 1. Environment backdrop (full-frame pixel-art landscape) | |
| 2. CSS ground strip with grass tufts | |
| 3. Hero sprite on the left, idle-bobbing | |
| 4. Obstacle sprite (when applicable) on the right, breathing/stomping | |
| 5. Atmospheric particle overlay (CSS-only sparkles) for force trials | |
| """ | |
| if not state.resolutions: | |
| return "" | |
| res = state.resolutions[-1] | |
| is_dragon = bool(res.obstacle and res.obstacle.is_dragon) | |
| category = _classify_obstacle_scene(res.obstacle.setup or "", is_dragon) | |
| env_name, ob_name, ob_anim = _SCENE_FOR_CATEGORY.get( | |
| category, _SCENE_FOR_CATEGORY["default"] | |
| ) | |
| # Theme-aware sprite picks. Lookup order per layer: | |
| # 1. theme-specific category match (e.g. `giant_space_cowboy`) | |
| # 2. for the ENV layer only: theme landscape (`scene_<theme>_landscape`) | |
| # — universal backdrop for non-fantasy themes that don't ship | |
| # per-category scenes | |
| # 3. fantasy canonical name (e.g. `giant`) | |
| theme_key = (getattr(state, "theme", "fantasy") or "fantasy") | |
| def _theme_pick(category: str, is_env: bool = False) -> str: | |
| themed = _sprite_data_uri_app(f"{category}_{theme_key}") | |
| if themed: | |
| return themed | |
| if is_env and theme_key != "fantasy": | |
| # Prefer the per-category themed env scene | |
| # (scene_<theme>_<finale|arena|passage|trap>) over the | |
| # single generic landscape backdrop. Falls through to the | |
| # landscape if a slot-specific PNG isn't on disk. | |
| slot = _themed_env_slot(category) | |
| themed_slot = _sprite_data_uri_app(f"scene_{theme_key}_{slot}") | |
| if themed_slot: | |
| return themed_slot | |
| landscape = _sprite_data_uri_app(f"scene_{theme_key}_landscape") | |
| if landscape: | |
| return landscape | |
| return _sprite_data_uri_app(category) or "" | |
| # Lean mode: drop the landscape backdrop only. Hero + obstacle sprites | |
| # stay because they're the visual hook of every trial — the rest of | |
| # the panel is a CSS-gradient sky in lean. | |
| env_uri = _theme_pick(env_name, is_env=True) if _full_visual_mode(state) else "" | |
| hero_uri = _theme_pick("hero") | |
| ob_uri = _theme_pick(ob_name) if ob_name else "" | |
| dragon_attr = ' data-dragon="true"' if is_dragon else "" | |
| hero_img = ( | |
| f"<img class='scene-hero' src='{hero_uri}' alt='hero'/>" | |
| if hero_uri else "" | |
| ) | |
| ob_img = ( | |
| f"<img class='scene-obstacle {ob_anim}' src='{ob_uri}' " | |
| f"alt='obstacle'/>" | |
| if ob_uri else "" | |
| ) | |
| # Atmospheric overlay only for "force" trials (storms etc.) so the | |
| # standalone backdrops don't get over-decorated. | |
| force_overlay = ( | |
| "<div class='scene-particles'></div>" | |
| if category == "force" else "" | |
| ) | |
| cap = _md_safe(res.image_caption or res.tactic or "") | |
| # Empty env_uri ⇒ scene-bg falls back to its CSS gradient (defined in | |
| # the main stylesheet) instead of trying to load a heavy PNG that | |
| # doesn't exist at the empty URL. | |
| bg_attr = f"background-image:url('{env_uri}');" if env_uri else "" | |
| return ( | |
| f"<div class='oracle-image-wrap oracle-composed-scene'{dragon_attr}>" | |
| f"<div class='scene-bg' style=\"{bg_attr}\"></div>" | |
| "<div class='scene-ground'></div>" | |
| f"{force_overlay}" | |
| f"{hero_img}" | |
| f"{ob_img}" | |
| f"<div class='scene-caption'>{cap}</div>" | |
| "</div>" | |
| ) | |
| def _resolution_image_html(state: GameState) -> str: | |
| if not state.resolutions: | |
| return "" | |
| return _composed_scene_html(state) | |
| def _story_tree_html(state: GameState) -> str: | |
| """Render the branching story tree as an HTML grid with the player's | |
| path highlighted and unexplored nodes masked as ???. | |
| Empty when ``state.story_path`` is empty (non-fantasy themes still use | |
| the old random-obstacle flow which has no tree to draw). | |
| """ | |
| if not state or not getattr(state, "story_path", None): | |
| return "" | |
| try: | |
| from oracles.story_graph import STORY_GRAPH, ENDINGS, get_node | |
| except Exception: | |
| return "" | |
| lang = _tlang(state) | |
| visited = set(state.story_path) | |
| leaf = get_node(state.story_path[-1]) if state.story_path else None | |
| visited_ending_id = leaf.ending_id if leaf and leaf.ending_id else None | |
| # Group nodes by trial column (1..5) | |
| cols: list = [[] for _ in range(5)] | |
| for n in STORY_GRAPH.values(): | |
| if 1 <= n.trial <= 5: | |
| cols[n.trial - 1].append(n) | |
| for col in cols: | |
| col.sort(key=lambda n: n.id) | |
| # Localized strings | |
| masked_label = "未发掘" if lang == "zh" else "Undiscovered" | |
| title_label = "故事之树" if lang == "zh" else "Story Tree" | |
| hint_label = "走过的路 / 未走的路" if lang == "zh" else "Paths taken vs. paths untaken" | |
| trial_lbl = "试炼" if lang == "zh" else "Trial" | |
| ending_lbl = "结局" if lang == "zh" else "Ending" | |
| html = ['<div class="story-tree">'] | |
| html.append( | |
| f'<div class="story-tree-header">' | |
| f'<div class="story-tree-title">{title_label}</div>' | |
| f'<div class="story-tree-hint">{hint_label}</div>' | |
| f'</div>' | |
| ) | |
| html.append('<div class="story-tree-grid">') | |
| # Trial columns 1..5 | |
| for ci, col in enumerate(cols): | |
| html.append(f'<div class="story-tree-col">') | |
| html.append(f'<div class="story-tree-col-label">{trial_lbl} {ci+1}</div>') | |
| for node in col: | |
| is_visited = node.id in visited | |
| cls = "story-node visited" if is_visited else "story-node masked" | |
| # Use the concept's first phrase as a theme-neutral title | |
| # (e.g. "OPENING SWARM", "FIRST GATEKEEPER") so the tree viz | |
| # works across themes without showing fantasy-flavored | |
| # node-ids to the player. | |
| raw_title = (node.concept or node.id).split(".")[0].strip() | |
| if not raw_title or len(raw_title) > 40: | |
| raw_title = node.id.replace("_", " ").title() | |
| title_text = raw_title.title() if raw_title.isupper() else raw_title | |
| if is_visited: | |
| # Prefer the themed setup the player actually saw at runtime | |
| # (stored on state during obstacle generation); fall back to | |
| # the hand-authored setup for fantasy / mock runs. | |
| themed = "" | |
| idx_in_path = state.story_path.index(node.id) if node.id in state.story_path else -1 | |
| if 0 <= idx_in_path < len(state.obstacles): | |
| themed = state.obstacles[idx_in_path].setup or "" | |
| if not themed: | |
| themed = node.setup(lang) or "" | |
| setup = themed[:90] | |
| if len(themed) > 90: | |
| setup += "…" | |
| inner = ( | |
| f'<div class="story-node-title">{_md_safe(title_text)}</div>' | |
| f'<div class="story-node-tag">[{_md_safe(node.tag)}]</div>' | |
| f'<div class="story-node-setup">{_md_safe(setup)}</div>' | |
| ) | |
| else: | |
| inner = ( | |
| f'<div class="story-node-title">???</div>' | |
| f'<div class="story-node-tag">[{_md_safe(masked_label)}]</div>' | |
| ) | |
| html.append(f'<div class="{cls}">{inner}</div>') | |
| html.append('</div>') | |
| # Endings column | |
| html.append('<div class="story-tree-col story-tree-endings">') | |
| html.append(f'<div class="story-tree-col-label">{ending_lbl}</div>') | |
| from oracles.story_graph import ENDINGS as _ENDINGS | |
| for ending in _ENDINGS.values(): | |
| is_visited = (ending.id == visited_ending_id) | |
| cls = "story-node ending visited" if is_visited else "story-node ending masked" | |
| if is_visited: | |
| title_text = ending.title(lang) | |
| inner = ( | |
| f'<div class="story-node-title">★ {_md_safe(title_text)}</div>' | |
| f'<div class="story-node-tag">[{ending.id}]</div>' | |
| ) | |
| else: | |
| inner = ( | |
| f'<div class="story-node-title">???</div>' | |
| f'<div class="story-node-tag">[{_md_safe(masked_label)}]</div>' | |
| ) | |
| html.append(f'<div class="{cls}">{inner}</div>') | |
| html.append('</div>') | |
| html.append('</div>') # /grid | |
| html.append('</div>') # /tree | |
| return "".join(html) | |
| def _chronicle_md(state: GameState) -> str: | |
| lang = _tlang(state) | |
| if not state.resolutions: | |
| return _t("chronicle_empty", lang) | |
| silence_label = _t("blank_silence", lang) | |
| lines = [] | |
| for res in state.resolutions: | |
| n = res.trial_index | |
| ob_setup = (res.obstacle.setup or "").strip() | |
| # Trim long obstacles for the chronicle line. | |
| if len(ob_setup) > 140: | |
| ob_setup = ob_setup[:140].rstrip() + "…" | |
| oracle_text = (res.oracle.text or silence_label).strip().replace("\n", " ") | |
| if len(oracle_text) > 80: | |
| oracle_text = oracle_text[:80].rstrip() + "…" | |
| roman = _ROMAN.get(res.oracle.index, str(res.oracle.index)) | |
| tactic = (res.tactic or "").strip() | |
| heading = _t("chronicle_trial_heading", lang).format(n=n) | |
| ob_line = _t("chronicle_obstacle", lang).format(ob_setup=ob_setup) | |
| or_line = _t("chronicle_oracle", lang).format( | |
| roman=roman, oracle_text=oracle_text | |
| ) | |
| ta_line = _t("chronicle_tactic", lang).format(tactic=tactic) | |
| lines.append( | |
| f"{heading}\n" | |
| f"{ob_line}\n\n" | |
| f"{or_line}\n\n" | |
| f"{ta_line}\n" | |
| ) | |
| chronicle_text = "\n---\n".join(lines) | |
| # Prepend the branching-story-tree viz when the player walked the | |
| # fantasy graph. Other themes have no tree to draw; chronicle alone. | |
| tree = _story_tree_html(state) | |
| if tree: | |
| return tree + "\n\n---\n\n" + chronicle_text | |
| return chronicle_text | |
| def _epilogue_html(state: GameState) -> str: | |
| text = (state.epilogue or "").strip() | |
| if not text: | |
| return "" | |
| lang = _tlang(state) | |
| bg = _bg_style("epilogue_homecoming") | |
| heading = _md_safe(_t("epilogue_heading_return", lang)) | |
| # If the player walked the story tree, look up the leaf node's | |
| # ending and stamp the title prominently above the narration so the | |
| # finale screen tells them WHICH of the 5 endings they earned. | |
| ending_banner = "" | |
| if getattr(state, "story_path", None): | |
| try: | |
| from oracles.story_graph import get_node, get_ending, ENDINGS | |
| leaf = get_node(state.story_path[-1]) | |
| if leaf is not None and leaf.ending_id: | |
| ending = get_ending(leaf.ending_id) | |
| e_title = ending.title(lang) | |
| eyebrow = "结局" if lang == "zh" else "Ending" | |
| tag_label = "未发掘" if lang == "zh" else "undiscovered" | |
| # Build the 5-of-5 progress strip — visited ending lit, others masked. | |
| # We only know what THIS run produced; without cross-run persistence | |
| # we can't show "ever discovered" — but we CAN show the player which | |
| # slot in the gallery this run filled. | |
| pip_html = [] | |
| for eid, e in ENDINGS.items(): | |
| is_this = (eid == leaf.ending_id) | |
| cls = "ending-pip lit" if is_this else "ending-pip dim" | |
| label = e.title(lang) if is_this else f"??? <span class='ending-pip-tag'>[{tag_label}]</span>" | |
| pip_html.append(f"<div class='{cls}'>{label}</div>") | |
| pips = "".join(pip_html) | |
| ending_banner = ( | |
| "<div class='ending-banner'>" | |
| f"<div class='ending-eyebrow'>{_md_safe(eyebrow)}</div>" | |
| f"<div class='ending-title'>★ {_md_safe(e_title)}</div>" | |
| f"<div class='ending-pips'>{pips}</div>" | |
| "</div>" | |
| ) | |
| except Exception: | |
| pass | |
| return ( | |
| f"<div class='oracle-card oracle-bg' {bg}>" | |
| f"<div class='oracle-trial-heading'>{heading}</div>" | |
| + ending_banner + | |
| f"<div class='oracle-narration'>{_md_block(text)}</div>" | |
| "</div>" | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Visibility helper | |
| # --------------------------------------------------------------------------- | |
| def _visibility_for(state: GameState, trial_substate: str): | |
| """Return visibility updates for each panel in order: | |
| inscribe_grp, send_off_grp, trial_grp, trial_await_grp, trial_reveal_grp, | |
| epilogue_grp, epilogue_ending_grp, epilogue_summary_grp. | |
| """ | |
| in_trial = state.mode == "trial" | |
| in_epilogue = state.mode == "epilogue" | |
| sub = getattr(state, "epilogue_substate", "ending") or "ending" | |
| return ( | |
| gr.update(visible=(state.mode == "inscribe")), # inscribe_grp | |
| gr.update(visible=(state.mode == "send_off")), # send_off_grp | |
| gr.update(visible=in_trial), # trial_grp (outer) | |
| gr.update(visible=in_trial and trial_substate == TRIAL_STATE_AWAIT), | |
| gr.update(visible=in_trial and trial_substate == TRIAL_STATE_REVEAL), | |
| gr.update(visible=in_epilogue), # epilogue_grp (outer) | |
| gr.update(visible=in_epilogue and sub == "ending"), # ending card | |
| gr.update(visible=in_epilogue and sub == "summary"), # summary card | |
| ) | |
| def _grimoire_right_visibility(state: GameState): | |
| """Return 9 visibility updates for the right-page sub-groups, indexed | |
| by inscribe_step (0..8). Only one is visible at a time when the | |
| inscribe panel is showing.""" | |
| in_inscribe = (state.mode == "inscribe") | |
| step = state.inscribe_step | |
| flags = [in_inscribe and step == i for i in range(_GRIMOIRE_NUM_STEPS)] | |
| return tuple(gr.update(visible=v) for v in flags) | |
| # --------------------------------------------------------------------------- | |
| # Localized-chrome updates — the gradio components below are created with | |
| # English defaults at build time; this helper returns gr.update() calls so | |
| # every event re-emits their labels/info/placeholders/button text in the | |
| # active language. Order matches the matching slice of the outputs list at | |
| # the bottom of build_app(). | |
| # --------------------------------------------------------------------------- | |
| def _localized_chrome_updates(state: GameState): | |
| """Return a tuple of gr.update() calls in the canonical order: | |
| narration_length_dropdown, hero_name_box, grimoire_name_btn, | |
| village_name_box, grimoire_village_btn, | |
| oracle_box_1, grimoire_oracle1_btn, | |
| oracle_box_2, grimoire_oracle2_btn, | |
| oracle_box_3, grimoire_oracle3_btn, | |
| oracle_box_4, grimoire_oracle4_btn, | |
| oracle_box_5, grimoire_oracle5_btn, | |
| grimoire_lang_btn, grimoire_begin_btn, | |
| open_oracle_btn, continue_btn, restart_btn, | |
| language_dropdown (info only), theme_dropdown (info only), | |
| chronicle_accordion (label only). | |
| Each entry is a gr.update() with only the localized props set so we | |
| don't clobber values the user has just typed in. | |
| """ | |
| lang = _tlang(state) | |
| nl_current_key = getattr(state, "narration_length", "medium") or "medium" | |
| theme_key = getattr(state, "theme", "fantasy") or "fantasy" | |
| # Cache by (lang, narration-length key, theme key) — the only fields | |
| # the returned tuple of gr.update() objects actually depends on. The | |
| # theme is in the key because _theme_dropdown_update inspects state.theme. | |
| cache_key = (lang, nl_current_key, theme_key) | |
| if cache_key in _LOCALIZED_CHROME_CACHE: | |
| return _LOCALIZED_CHROME_CACHE[cache_key] | |
| from oracles.state import NARRATION_LENGTHS as _NL | |
| nl_choices = [v[0][lang] if isinstance(v[0], dict) else v[0] | |
| for v in _NL.values()] | |
| nl_default_label = _NL["medium"][0] | |
| nl_default = (nl_default_label[lang] if isinstance(nl_default_label, dict) | |
| else nl_default_label) | |
| nl_current_entry = _NL.get(nl_current_key, _NL["medium"]) | |
| nl_current_label = nl_current_entry[0] | |
| nl_current = (nl_current_label[lang] if isinstance(nl_current_label, dict) | |
| else nl_current_label) | |
| result = ( | |
| gr.update( | |
| label=_t("label_narration_length", lang), | |
| info=_t("info_narration_length", lang), | |
| choices=nl_choices, | |
| value=nl_current, | |
| ), | |
| gr.update( | |
| label=_t("label_hero_name", lang), | |
| placeholder=_t("ph_hero_name", lang), | |
| ), | |
| gr.update(value=_t("btn_name_him", lang)), | |
| gr.update( | |
| label=_t("label_village", lang), | |
| placeholder=_t("ph_village", lang), | |
| ), | |
| gr.update(value=_t("btn_place_him", lang)), | |
| gr.update( | |
| label=_t("label_oracle_1", lang), | |
| placeholder=_t("ph_oracle_1", lang), | |
| ), | |
| gr.update(value=_t("btn_seal_first", lang)), | |
| gr.update(label=_t("label_oracle_2", lang)), | |
| gr.update(value=_t("btn_seal_second", lang)), | |
| gr.update(label=_t("label_oracle_3", lang)), | |
| gr.update(value=_t("btn_seal_third", lang)), | |
| gr.update(label=_t("label_oracle_4", lang)), | |
| gr.update(value=_t("btn_seal_fourth", lang)), | |
| gr.update( | |
| label=_t("label_oracle_5", lang), | |
| placeholder=_t("ph_oracle_5", lang), | |
| ), | |
| gr.update(value=_t("btn_seal_last", lang)), | |
| gr.update(value=_t("btn_inscribe_choice", lang)), | |
| gr.update(value=_t("btn_let_journey_begin", lang)), | |
| gr.update(value=_t("btn_open_oracle", lang)), | |
| gr.update(value=_t("btn_continue", lang)), | |
| gr.update(value=_t("btn_to_chronicle", lang)), | |
| gr.update(value=_t("btn_new_tale", lang)), | |
| gr.update(info=_t("info_language_dropdown", lang)), | |
| _theme_dropdown_update(state, lang), | |
| gr.update(label=_t("label_chronicle_accordion", lang)), | |
| ) | |
| _LOCALIZED_CHROME_CACHE[cache_key] = result | |
| return result | |
| def _theme_dropdown_update(state: GameState, lang: str): | |
| """Rebuild the theme dropdown with localized labels. value uses the | |
| same English ``display_name`` as the key (build_app's _fantasy.display_name) | |
| so it stays stable across rerenders — but Gradio's value-matching is | |
| against the label text, so we map the current theme key to its localized | |
| display name.""" | |
| from oracles.themes import THEMES | |
| choices = _theme_dropdown_choices(lang) | |
| current_key = getattr(state, "theme", "fantasy") or "fantasy" | |
| current_theme = THEMES.get(current_key, THEMES["fantasy"]) | |
| current_label = current_theme.localized_field("display_name", lang) | |
| return gr.update( | |
| info=_t("info_theme_dropdown", lang), | |
| choices=choices, | |
| value=current_label, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # UI bundle — every event handler returns the same shape (17 slots) | |
| # --------------------------------------------------------------------------- | |
| # | |
| # Order: | |
| # 0 state | |
| # 1 trial_substate (str) | |
| # 2 header_html | |
| # 3 inscribe_error_md | |
| # 4 send_off_sealed_html | |
| # 5 send_off_narration_html | |
| # 6 trial_heading_html | |
| # 7 trial_await_html | |
| # 8 trial_reveal_html | |
| # 9 trial_image_html | |
| # 10 epilogue_html | |
| # 11 chronicle_md | |
| # 12 inscribe_grp | |
| # 13 send_off_grp | |
| # 14 trial_grp | |
| # 15 trial_await_grp | |
| # 16 trial_reveal_grp | |
| # 17 epilogue_grp | |
| def _ui_bundle( | |
| state: GameState, | |
| trial_substate: str = TRIAL_STATE_AWAIT, | |
| *, | |
| inscribe_error: str = "", | |
| ): | |
| (insc_v, send_v, trial_v, await_v, reveal_v, | |
| epi_v, epi_end_v, epi_sum_v) = _visibility_for(state, trial_substate) | |
| rg = _grimoire_right_visibility(state) # 9 visibility updates | |
| new_bundle = ( | |
| # 0..17: existing slots (unchanged) | |
| state, | |
| trial_substate, | |
| _badge_html(state, trial_substate), | |
| inscribe_error, | |
| _sealed_list_html(state) if state.mode == "send_off" else "", | |
| _send_off_narration_html(state) if state.mode == "send_off" else "", | |
| _trial_heading_html(state) if state.mode == "trial" else "", | |
| _trial_await_html(state) | |
| if state.mode == "trial" and trial_substate == TRIAL_STATE_AWAIT | |
| else "", | |
| _trial_reveal_html(state) | |
| if state.mode == "trial" and trial_substate == TRIAL_STATE_REVEAL | |
| else "", | |
| _resolution_image_html(state) | |
| if state.mode == "trial" and trial_substate == TRIAL_STATE_REVEAL | |
| else "", | |
| _epilogue_html(state) if state.mode == "epilogue" else "", | |
| _chronicle_md(state), | |
| insc_v, | |
| send_v, | |
| trial_v, | |
| await_v, | |
| reveal_v, | |
| epi_v, | |
| epi_end_v, # epilogue_ending_grp visibility | |
| epi_sum_v, # epilogue_summary_grp visibility | |
| # 18..21: grimoire content slots | |
| _grimoire_left_html(state), | |
| _grimoire_progress_html(state), | |
| _grimoire_summary_html(state) if state.inscribe_step == 8 else "", | |
| _grimoire_error_html(state), | |
| # 22..30: visibility for the 9 right-page sub-groups (steps 0..8) | |
| *rg, | |
| # 31: right-page mirrored sigil | |
| _grimoire_right_sigil_html(state), | |
| # 32: wizard's-daydream pipeline-demo card (visible only on step 0 | |
| # of the inscribe phase). | |
| gr.update(visible=(state.mode == "inscribe" and state.inscribe_step == 0)), | |
| # 33..35: theme-driven chrome — palette CSS overrides, banner with | |
| # theme title/subtitle, and inscribe-figs decoration with theme | |
| # mentor/finale captions. All re-render on every event so picking | |
| # a theme on grimoire spread 0 reskins the entire page. | |
| _theme_style_overrides_html(state), | |
| _banner_html(state), | |
| _inscribe_decoration_html(state), | |
| # 36: walking-hero processing overlay (visible only when a | |
| # generator handler has set state.processing_msg before its slow | |
| # work and cleared it after). | |
| _processing_overlay_html(state), | |
| # 37..58: localized chrome updates — labels/info/placeholders/button | |
| # values for every component below spread 0 of the grimoire so the | |
| # whole UI flips to the active language when the player picks | |
| # Simplified Chinese. | |
| *_localized_chrome_updates(state), | |
| ) | |
| # NOTE: gr.skip() diff was tried here to reduce payload by sending | |
| # only changed outputs. It broke multi-yield generator handlers like | |
| # handle_grimoire_begin → handle_send_off (an internal _ui_bundle | |
| # call updated the cache to a state that was never sent to the | |
| # client, so the next yield diffed everything as "unchanged" and | |
| # the client never received the new page). The memoized HTML helpers | |
| # still provide most of the speedup; this path now just returns | |
| # the full bundle. | |
| return new_bundle | |
| # --------------------------------------------------------------------------- | |
| # Event handlers | |
| # --------------------------------------------------------------------------- | |
| def _label_to_lang_code(label: str) -> str: | |
| return next( | |
| (code for code, l in LANG_LABELS.items() if l == label), "en" | |
| ) | |
| def _theme_dropdown_choices(lang: str): | |
| """Return the (label, value_key) tuples for the theme dropdown in the | |
| given language. value_key is always the English ``display_name`` (used | |
| as the stable key for lookup), label switches to the localized field | |
| so the player sees themes in their tongue.""" | |
| from oracles.themes import THEMES | |
| out = [] | |
| for t in THEMES.values(): | |
| name = t.localized_field("display_name", lang) | |
| blurb = t.localized_field("blurb", lang) | |
| out.append((f"{name} — {blurb}", name)) | |
| return out | |
| def _apply_spread0_choices( | |
| state: GameState, | |
| language_label: str, | |
| theme_label: str = "", | |
| narration_label: str = "", | |
| visual_mode_label: str = "", | |
| ) -> GameState: | |
| """Mutate state from the four spread-0 dropdown values. Shared by | |
| the live-preview .change() handler and the click-to-advance button.""" | |
| if visual_mode_label: | |
| # Map the human label back to the internal key. | |
| if "Full" in visual_mode_label: | |
| state.visual_mode = "full" | |
| elif "Lean" in visual_mode_label: | |
| state.visual_mode = "lean" | |
| from oracles.themes import THEMES, DEFAULT_THEME | |
| from oracles.state import NARRATION_LENGTHS as _NL | |
| state.lang = _label_to_lang_code(language_label) | |
| chosen_key = DEFAULT_THEME | |
| for key, theme in THEMES.items(): | |
| if theme_label and ( | |
| theme.display_name == theme_label | |
| or theme.localized_field("display_name", "zh") == theme_label | |
| ): | |
| chosen_key = key | |
| break | |
| state.theme = chosen_key | |
| chosen_nl = "medium" | |
| for key, (label, *_rest) in _NL.items(): | |
| if isinstance(label, dict): | |
| if narration_label in label.values(): | |
| chosen_nl = key | |
| break | |
| elif label == narration_label: | |
| chosen_nl = key | |
| break | |
| state.narration_length = chosen_nl | |
| # Refresh theme defaults if the player hasn't customized yet. When | |
| # fantasy + Chinese, swap to Chinese-friendly placeholder names so | |
| # they don't look out of place in the wizard's Chinese monologue. | |
| theme_obj = THEMES[chosen_key] | |
| if state.hero_name in ("", "Tobin", "托宾"): | |
| if chosen_key == "fantasy" and state.lang == "zh": | |
| state.hero_name = "小明" | |
| else: | |
| state.hero_name = theme_obj.hero_default | |
| if state.village_name in ("", "the Hollow", "凡人村", "洼谷"): | |
| if chosen_key == "fantasy" and state.lang == "zh": | |
| state.village_name = "凡人村" | |
| else: | |
| state.village_name = theme_obj.village_default | |
| return state | |
| def handle_lang_theme_preview(state: GameState, language_label: str, | |
| theme_label: str = "", | |
| narration_label: str = "", | |
| visual_mode_label: str = ""): | |
| """Live-preview handler bound to the dropdowns' ``.change()`` event. | |
| Re-renders every theme/lang-driven slot (banner title, palette, | |
| inscribe-figs captions, grimoire wizard text on the left page, | |
| pipeline-demo, etc.) without advancing past inscribe step 0. Lets | |
| the player see the world reskin instantly when they switch theme | |
| or language.""" | |
| if state.mode != "inscribe" or state.inscribe_step != 0: | |
| return _ui_bundle(state) | |
| _apply_spread0_choices(state, language_label, theme_label, | |
| narration_label, visual_mode_label) | |
| state.grimoire_error = "" | |
| return _ui_bundle(state) | |
| def handle_grimoire_lang(state: GameState, language_label: str, | |
| theme_label: str = "", | |
| narration_label: str = "", | |
| visual_mode_label: str = ""): | |
| """Step 0 → 1: apply the language/theme/narration picks and advance. | |
| The actual mutation lives in `_apply_spread0_choices` so the live- | |
| preview .change() handler can share the same logic without advancing | |
| the step.""" | |
| if state.mode != "inscribe" or state.inscribe_step != 0: | |
| return _ui_bundle(state) | |
| _apply_spread0_choices(state, language_label, theme_label, | |
| narration_label, visual_mode_label) | |
| state.grimoire_error = "" | |
| state.inscribe_step = 1 | |
| return _ui_bundle(state) | |
| def handle_grimoire_name(state: GameState, hero_name: str): | |
| """Step 1 → 2: inscribe the hero's name.""" | |
| if state.mode != "inscribe" or state.inscribe_step != 1: | |
| return _ui_bundle(state) | |
| cleaned = (hero_name or "").strip() | |
| if not cleaned: | |
| state.grimoire_error = _t("err_hero_blank", _tlang(state)) | |
| return _ui_bundle(state) | |
| state.hero_name = cleaned | |
| state.grimoire_error = "" | |
| state.inscribe_step = 2 | |
| return _ui_bundle(state) | |
| def handle_grimoire_village(state: GameState, village_name: str): | |
| """Step 2 → 3: name the village.""" | |
| if state.mode != "inscribe" or state.inscribe_step != 2: | |
| return _ui_bundle(state) | |
| cleaned = (village_name or "").strip() | |
| if not cleaned: | |
| state.grimoire_error = _t("err_village_blank", _tlang(state)) | |
| return _ui_bundle(state) | |
| state.village_name = cleaned | |
| state.grimoire_error = "" | |
| state.inscribe_step = 3 | |
| return _ui_bundle(state) | |
| def handle_grimoire_oracle(state: GameState, oracle_text: str): | |
| """Step N ∈ {3..7} → N+1: seal one oracle and advance. | |
| Step 7 (the final oracle) advances to step 8 (the closing spread). | |
| """ | |
| if state.mode != "inscribe" or state.inscribe_step not in _GRIMOIRE_ORACLE_STEPS: | |
| return _ui_bundle(state) | |
| cleaned = (oracle_text or "").rstrip() | |
| if not cleaned.strip(): | |
| lang = _tlang(state) | |
| ordinal_keys = [ | |
| "ord_first", "ord_second", "ord_third", "ord_fourth", "ord_fifth", | |
| ] | |
| idx = state.inscribe_step - 3 | |
| ord_key = ordinal_keys[idx] if 0 <= idx < len(ordinal_keys) else "ord_next" | |
| ord_word = _t(ord_key, lang) | |
| state.grimoire_error = _t("err_parchment_blank", lang).format( | |
| ord_word=ord_word | |
| ) | |
| return _ui_bundle(state) | |
| oracle_idx = state.inscribe_step - 3 # 0..4 | |
| state.oracles[oracle_idx].text = cleaned | |
| state.grimoire_error = "" | |
| state.inscribe_step += 1 | |
| return _ui_bundle(state) | |
| def handle_grimoire_begin(state: GameState): | |
| """Step 8 → trial 1 (one click, one wait). | |
| Yields TWICE so the player sees a walking-hero overlay during the slow | |
| obstacle-generation LLM call: | |
| 1. Immediate yield — UI gets the overlay shown via state.processing_msg. | |
| 2. After the work — UI gets the final trial-1 await view with the | |
| overlay cleared. | |
| Previously this flipped to a 'send_off' intermediate mode that | |
| required a second button click to actually start. We now fold the two | |
| actions together so the player goes directly from the closing grimoire | |
| spread into trial 1 — obstacle generation + precompute happen during | |
| the single waiting period. The send-off narration is rendered above | |
| the trial-1 await screen instead of in its own panel.""" | |
| if state.mode != "inscribe" or state.inscribe_step != 8: | |
| yield _ui_bundle(state) | |
| return | |
| state.grimoire_error = "" | |
| # First yield — show the walking-hero overlay so the wait feels | |
| # narrative, not technical. | |
| state.processing_msg = _t("msg_walking_east", _tlang(state)) | |
| yield _ui_bundle(state) | |
| # Slow work: obstacle generation + background precompute. | |
| state.mode = "send_off" # transient — handle_send_off flips to "trial" | |
| bundle = handle_send_off(state) | |
| # The bundle's state reference is the same object, so mutating | |
| # processing_msg before re-rendering clears the overlay. | |
| state.processing_msg = "" | |
| yield _ui_bundle(state, trial_substate=TRIAL_STATE_AWAIT) | |
| def handle_send_off(state: GameState): | |
| """send_off → trial 1. Generates the 5 obstacles AND kicks off the | |
| background precompute so trial reveals are instant in live mode.""" | |
| if state.mode != "send_off": | |
| return _ui_bundle(state) | |
| language = LANG_PROMPT_NAME.get(state.lang, "English") | |
| try: | |
| obstacles = generate_obstacles(state, CLIENT, language=language) | |
| except Exception as e: | |
| # LLM unreachable. Build placeholder obstacles so the player can see | |
| # the error in-game instead of a blank screen, then continue. The | |
| # message is visible in trial 1's setup. | |
| from oracles.state import Obstacle as _Obstacle, DRAGON_TRIAL as _DRAGON | |
| msg = ( | |
| f"[The {getattr(state, 'theme', 'fantasy')} world will not open: " | |
| f"{e}. Check MODAL_URL / MODAL_KEY / MODAL_SECRET, then restart.]" | |
| ) | |
| obstacles = [ | |
| _Obstacle(index=i, setup=msg, is_dragon=(i == _DRAGON)) | |
| for i in range(1, 6) | |
| ] | |
| state.obstacles = obstacles | |
| state.current_trial = 1 | |
| state.mode = "trial" | |
| # Kick off background precompute. In mock mode this is a no-op. | |
| # When live, this fills state.resolution_cache while the player reads | |
| # the send-off paragraph and the trial-1 setup, so the first | |
| # "open oracle" click reveals instantly. | |
| try: | |
| precompute_all_resolutions(state, CLIENT, language=language) | |
| except Exception: | |
| pass | |
| return _ui_bundle(state, trial_substate=TRIAL_STATE_AWAIT) | |
| def handle_open_oracle(state: GameState, trial_substate: str): | |
| """Within a trial (state A → state B). Draw oracle, resolve, render image. | |
| Generator: yields once with the walking-hero overlay during the LLM | |
| call (or instantly when the cache is hot), then yields the final | |
| reveal state.""" | |
| if state.mode != "trial" or trial_substate != TRIAL_STATE_AWAIT: | |
| yield _ui_bundle(state, trial_substate) | |
| return | |
| obstacle = state.current_obstacle() | |
| if obstacle is None: | |
| # No obstacle for this trial — shouldn't happen, but bail gracefully. | |
| yield _ui_bundle(state, trial_substate) | |
| return | |
| # Draw a random unopened oracle and mark it. | |
| try: | |
| oracle = state.draw_random_oracle() | |
| except RuntimeError: | |
| # No oracles left — shouldn't happen since we only have 5 trials. | |
| yield _ui_bundle(state, trial_substate) | |
| return | |
| oracle.opened = True | |
| oracle.opened_at_trial = state.current_trial | |
| # Cache lookup first — precompute_all_resolutions populates this in the | |
| # background. Cache hit → instant reveal. Miss → synchronous LLM call. | |
| cache_key = (oracle.index, obstacle.index) | |
| resolution = state.resolution_cache.get(cache_key) | |
| if resolution is None: | |
| lang = _tlang(state) | |
| # Show the walking-hero modal while the LLM cooks the resolution. | |
| state.processing_msg = _t("msg_reading_oracle", lang) | |
| yield _ui_bundle(state, trial_substate) | |
| language = LANG_PROMPT_NAME.get(state.lang, "English") | |
| try: | |
| resolution = resolve_trial( | |
| obstacle, | |
| oracle, | |
| state.hero_name, | |
| state.village_name, | |
| CLIENT, | |
| language=language, | |
| theme=getattr(state, "theme", "fantasy"), | |
| narration_length=getattr(state, "narration_length", "medium"), | |
| ) | |
| except Exception as e: | |
| # LLM failure mid-trial: surface the error to the player instead | |
| # of crashing the UI. They can restart once Modal is back up. | |
| from oracles.state import Resolution as _Resolution | |
| msg = _t("msg_oracle_quivers", lang).format(e=e) | |
| resolution = _Resolution( | |
| trial_index=obstacle.index, | |
| obstacle=obstacle, | |
| oracle=oracle, | |
| narration=msg, | |
| tactic=_t("msg_no_tactic", lang), | |
| image_path="", | |
| image_caption="LLM unreachable", | |
| ) | |
| # Render scene image (mock; SVG card). use_klein=False per task spec. | |
| try: | |
| img = generate_scene_image(resolution, out_dir=OUT_DIR, use_klein=False) | |
| resolution.image_path = img.path | |
| resolution.image_caption = img.caption | |
| except Exception: | |
| # The mock path is supposed to be safe, but if anything explodes, keep | |
| # the caption-only state set by resolve_trial. | |
| resolution.image_path = "" | |
| resolution.image_caption = resolution.image_caption or ( | |
| _t("msg_trial_caption_fallback", _tlang(state)).format( | |
| n=resolution.trial_index, tactic=resolution.tactic | |
| ) | |
| ) | |
| state.resolutions.append(resolution) | |
| # Kick off background generation of the *next* bridge while the user | |
| # reads this trial's narration. By the time they click Continue, the | |
| # interlude (or epilogue, after trial 5) is already cached on state. | |
| language = LANG_PROMPT_NAME.get(state.lang, "English") | |
| theme_key = getattr(state, "theme", "fantasy") or "fantasy" | |
| if state.current_trial < NUM_TRIALS: | |
| kick_background_interlude( | |
| state, state.current_trial, | |
| CLIENT, language=language, theme=theme_key, | |
| ) | |
| else: | |
| kick_background_epilogue( | |
| state, CLIENT, language=language, theme=theme_key, | |
| ) | |
| # Clear the modal overlay before showing the reveal. | |
| state.processing_msg = "" | |
| yield _ui_bundle(state, trial_substate=TRIAL_STATE_REVEAL) | |
| def handle_continue(state: GameState, trial_substate: str): | |
| """trial N → trial N+1, or trial 5 → epilogue. | |
| Generator: yields the walking-hero overlay during interlude/epilogue | |
| generation (skipping the overlay yield when the precompute cache is | |
| already warm and the click resolves instantly). | |
| """ | |
| if state.mode != "trial" or trial_substate != TRIAL_STATE_REVEAL: | |
| yield _ui_bundle(state, trial_substate) | |
| return | |
| language = LANG_PROMPT_NAME.get(state.lang, "English") | |
| theme_key = getattr(state, "theme", "fantasy") or "fantasy" | |
| # Decide whether the next bridge is already cached so we can skip | |
| # the "walking hero" yield when it would just flicker for one frame. | |
| next_idx_advance = state.current_trial + 1 | |
| if state.current_trial < NUM_TRIALS: | |
| slot = state.current_trial - 1 | |
| cache_warm = ( | |
| 0 <= slot < len(state.interludes) | |
| and (state.interludes[slot] or "").strip() != "" | |
| ) | |
| else: | |
| cache_warm = (state.epilogue or "").strip() != "" | |
| if not cache_warm: | |
| lang_code = _tlang(state) | |
| state.processing_msg = ( | |
| _t("msg_walking_between_trials", lang_code) | |
| if state.current_trial < NUM_TRIALS | |
| else _t("msg_climbing_final", lang_code) | |
| ) | |
| yield _ui_bundle(state, trial_substate) | |
| if state.current_trial < NUM_TRIALS: | |
| # Generate the interlude bridging trial N → trial N+1. | |
| # The previous trial's obstacle + tactic come from state.resolutions[-1]. | |
| prev_trial_idx = state.current_trial | |
| next_trial_idx = state.current_trial + 1 | |
| prev_res = state.resolutions[-1] if state.resolutions else None | |
| next_ob = next( | |
| (ob for ob in state.obstacles if ob.index == next_trial_idx), | |
| None, | |
| ) | |
| if prev_res is not None and next_ob is not None: | |
| trials_remaining = NUM_TRIALS - prev_trial_idx | |
| slot = prev_trial_idx - 1 | |
| cached = "" | |
| if 0 <= slot < len(state.interludes): | |
| cached = (state.interludes[slot] or "").strip() | |
| if cached: | |
| # Background precompute already filled it. Instant. | |
| text = cached | |
| else: | |
| # Precompute missed (still in flight, or never fired). | |
| # Fall through to a synchronous call. | |
| try: | |
| if next_ob.is_dragon: | |
| four_tactics = [ | |
| (r.tactic or "").strip() for r in state.resolutions | |
| ] | |
| text = generate_dragon_interlude( | |
| prev_res.obstacle, four_tactics, | |
| state.hero_name, state.village_name, | |
| CLIENT, language=language, | |
| theme=theme_key, | |
| ) | |
| else: | |
| text = generate_interlude( | |
| prev_res.obstacle, next_ob, | |
| prev_res.tactic or "", | |
| state.hero_name, state.village_name, | |
| trials_remaining=trials_remaining, | |
| client=CLIENT, language=language, | |
| theme=theme_key, | |
| ) | |
| except Exception as e: | |
| text = _t("msg_road_silent", _tlang(state)).format(e=e) | |
| if 0 <= slot < len(state.interludes): | |
| state.interludes[slot] = text | |
| state.current_trial = next_trial_idx | |
| state.processing_msg = "" | |
| yield _ui_bundle(state, trial_substate=TRIAL_STATE_AWAIT) | |
| return | |
| # Trial 5 just resolved — move to epilogue, opening on the | |
| # cinematic ending-card substate. Player clicks Continue to see the | |
| # chronicle/tree summary card. | |
| cached_epi = (state.epilogue or "").strip() | |
| if cached_epi: | |
| pass | |
| else: | |
| try: | |
| epi = generate_epilogue(state, CLIENT, language=language, theme=theme_key) | |
| except Exception as e: | |
| epi = _t("msg_mentor_silent", _tlang(state)).format(e=e) | |
| state.epilogue = (epi or "").strip() | |
| state.mode = "epilogue" | |
| state.epilogue_substate = "ending" | |
| state.current_trial = NUM_TRIALS + 1 | |
| state.processing_msg = "" | |
| yield _ui_bundle(state) | |
| def handle_view_summary(state: GameState): | |
| """On the ending card, the player clicks Continue → flip to the | |
| summary card (chronicle list + story-tree viz).""" | |
| if state.mode != "epilogue": | |
| return _ui_bundle(state) | |
| state.epilogue_substate = "summary" | |
| return _ui_bundle(state) | |
| def handle_restart(state: GameState): | |
| """epilogue → inscribe (fresh state, hero/village + theme preserved).""" | |
| hero = state.hero_name or "Tobin" | |
| village = state.village_name or "the Hollow" | |
| theme = getattr(state, "theme", "fantasy") or "fantasy" | |
| new_state = fresh_state(hero_name=hero, village_name=village, theme=theme) | |
| return _ui_bundle(new_state, trial_substate=TRIAL_STATE_AWAIT) | |
| # --------------------------------------------------------------------------- | |
| # Blocks construction | |
| # --------------------------------------------------------------------------- | |
| def build_app() -> gr.Blocks: | |
| initial_state = fresh_state() | |
| # Compose CSS: the static block + the inline pixel-art book frame + | |
| # an optional Klein parchment-texture block (empty if the PNG isn't on | |
| # disk). The book frame CSS uses !important overrides so order doesn't | |
| # strictly matter, but putting it AFTER the static block keeps the | |
| # override semantics clearer in the rendered <style>. | |
| composed_css = CSS + _grimoire_book_css() + _grimoire_texture_css() | |
| # Custom Base theme — we override every visible token to match the | |
| # pixel-art palette (--w-amber / --w-night / --w-cream / --w-shadow). | |
| # ``Base`` ships with the least amount of opinionated default styling | |
| # vs. ``Soft`` so our CSS sweep below has less stock-Gradio look to | |
| # fight against. Off-Brand badge: a judge should not be able to | |
| # identify this as a Gradio app at first glance. | |
| _apprentice_theme = gr.themes.Base( | |
| primary_hue=gr.themes.colors.amber, | |
| secondary_hue=gr.themes.colors.amber, | |
| neutral_hue=gr.themes.colors.stone, | |
| text_size=gr.themes.sizes.text_md, | |
| spacing_size=gr.themes.sizes.spacing_md, | |
| radius_size=gr.themes.sizes.radius_none, # NES-style sharp corners | |
| font=[gr.themes.GoogleFont("VT323"), "monospace"], | |
| font_mono=[gr.themes.GoogleFont("Press Start 2P"), "monospace"], | |
| ).set( | |
| body_background_fill="#18141f", | |
| body_text_color="#ede4d3", | |
| background_fill_primary="#18141f", | |
| background_fill_secondary="#251d2e", | |
| block_background_fill="#251d2e", | |
| border_color_primary="#f0c060", | |
| button_primary_background_fill="#f0c060", | |
| button_primary_text_color="#251d2e", | |
| button_secondary_background_fill="#251d2e", | |
| button_secondary_text_color="#ede4d3", | |
| input_background_fill="#251d2e", | |
| ) | |
| with gr.Blocks( | |
| theme=_apprentice_theme, | |
| css=composed_css, | |
| title="The Apprentice", | |
| analytics_enabled=False, | |
| ) as demo: | |
| state = gr.State(initial_state) | |
| trial_substate = gr.State(TRIAL_STATE_AWAIT) | |
| # Per-theme CSS variable overrides — injected dynamically so changing | |
| # the theme on grimoire spread 0 reskins the entire page (banner, | |
| # cards, buttons, accents). | |
| theme_style_html = gr.HTML(_theme_style_overrides_html(initial_state)) | |
| # Parallax banner — title + subtitle pulled from the active theme. | |
| banner_html = gr.HTML(_banner_html(initial_state)) | |
| # Tiny status chip beneath the banner (Trial N/5, Inscribing, etc.) | |
| header_html = gr.HTML(_badge_html(initial_state)) | |
| # Walking-hero processing overlay — visible only while a generator | |
| # handler has work in flight (state.processing_msg != ""). | |
| processing_overlay_html = gr.HTML(_processing_overlay_html(initial_state)) | |
| if CLIENT.using_mock: | |
| gr.HTML( | |
| "<div class='oracle-mock-banner'>" | |
| "Running in mock mode — set MODAL_URL (and unset " | |
| "ORACLES_FORCE_MOCK) to enable the live LLM." | |
| "</div>" | |
| ) | |
| # Static sprite-derived CSS — emits :root CSS variables (ground | |
| # tile URI, etc) and #oracle-{phase}-grp backdrop rules so the | |
| # main stylesheet stays free of big data URIs. | |
| gr.HTML(_global_sprite_vars_html()) | |
| gr.HTML(_phase_backdrop_styles_html()) | |
| # ---- inscribe panel: the grimoire (book spread) ---------------- | |
| with gr.Group(visible=True, elem_id="oracle-inscribe-grp", | |
| elem_classes=["phase-bg"]) as inscribe_grp: | |
| # Inscribe-figs decoration (wizard ←→ hero figures) sits at | |
| # the top so the journey framing is the first thing the player | |
| # sees on every inscribe spread. | |
| inscribe_decoration_html = gr.HTML( | |
| _inscribe_decoration_html(initial_state) | |
| ) | |
| # The book is a Gradio Row with two Column children — using | |
| # Gradio's own layout primitives means our CSS classes apply | |
| # to real wrapper elements (you can't wrap components with | |
| # raw <div> tags — Gradio puts each HTML component inside its | |
| # own wrapper, so the open tag self-closes immediately). | |
| with gr.Row(elem_classes=["oracle-grimoire"]): | |
| with gr.Column(elem_classes=["grimoire-page", "grimoire-left"]): | |
| grimoire_left_html = gr.HTML(_grimoire_left_html(initial_state)) | |
| with gr.Column(elem_classes=["grimoire-page", "grimoire-right"]): | |
| # Mirrored sigil for this spread on the right page. | |
| grimoire_right_sigil_html = gr.HTML( | |
| _grimoire_right_sigil_html(initial_state) | |
| ) | |
| # The wizard's prompt asks for input on the right page. | |
| # ONE sub-group per inscribe step (0..8); only one visible. | |
| with gr.Group(visible=True) as right_lang_grp: # step 0 | |
| language_dropdown = gr.Dropdown( | |
| label="Tongue / 语言", | |
| choices=list(LANG_LABELS.values()), | |
| value=LANG_LABELS["en"], | |
| info=( | |
| "The mentor will write in this tongue for the " | |
| "rest of the tale." | |
| ), | |
| ) | |
| # ---- Theme picker (new) ------------------------------ | |
| # Each theme reshapes the world: the mentor, the | |
| # obstacles, the finale, the style of narration. | |
| from oracles.themes import THEMES as _THEMES_FOR_UI, \ | |
| DEFAULT_THEME as _DEFAULT_THEME_FOR_UI | |
| # Gradio Dropdown supports (display_label, value) | |
| # tuples — the user sees the verbose label, the | |
| # handler receives the bare display_name. | |
| _theme_choices = [ | |
| (f"{t.display_name} — {t.blurb}", t.display_name) | |
| for t in _THEMES_FOR_UI.values() | |
| ] | |
| _fantasy = _THEMES_FOR_UI[_DEFAULT_THEME_FOR_UI] | |
| theme_dropdown = gr.Dropdown( | |
| label="World / 世界", | |
| choices=_theme_choices, | |
| value=_fantasy.display_name, | |
| info=( | |
| "The shape of the world the hero walks. " | |
| "Each setting has its own mentor, obstacles, " | |
| "and final reckoning." | |
| ), | |
| ) | |
| # ---- Narration length picker --------------------- | |
| from oracles.state import NARRATION_LENGTHS as _NL | |
| # Labels are now `{"en": ..., "zh": ...}` dicts; the | |
| # initial render uses the English variant — the | |
| # bundle's localized-chrome updates swap in the | |
| # Chinese ones once the player picks zh. | |
| _length_choices = [ | |
| (v[0]["en"] if isinstance(v[0], dict) else v[0]) | |
| for v in _NL.values() | |
| ] | |
| _medium_label = _NL["medium"][0] | |
| _medium_default = ( | |
| _medium_label["en"] | |
| if isinstance(_medium_label, dict) | |
| else _medium_label | |
| ) | |
| narration_length_dropdown = gr.Dropdown( | |
| label="Narration length per trial", | |
| choices=_length_choices, | |
| value=_medium_default, | |
| info=( | |
| "How many words you want the mentor to spin " | |
| "per trial. Shorter = snappier, longer = more " | |
| "vivid. You can rerun the journey with a " | |
| "different choice." | |
| ), | |
| ) | |
| # Visual-mode selector — flips the full-fat decorative | |
| # PNGs (parallax banner, scene landscapes, etc.) on | |
| # and off live. Default reads from the env var the | |
| # process launched with (HF Space → lean, ./run.sh | |
| # --full → full). The player can override either way. | |
| _vm_initial = ("Full visuals (all backdrops)" | |
| if _full_visual_mode() else | |
| "Lean (fast on slow networks)") | |
| visual_mode_dropdown = gr.Dropdown( | |
| label="Visuals", | |
| choices=[ | |
| "Lean (fast on slow networks)", | |
| "Full visuals (all backdrops)", | |
| ], | |
| value=_vm_initial, | |
| info=( | |
| "Lean skips the heavy decorative PNGs for " | |
| "fast loading. Full adds the parallax banner, " | |
| "scene landscapes, parchment, etc. The choice " | |
| "applies instantly." | |
| ), | |
| ) | |
| grimoire_lang_btn = gr.Button( | |
| "Inscribe my choice →", variant="primary", size="lg" | |
| ) | |
| with gr.Group(visible=False) as right_name_grp: # step 1 | |
| hero_name_box = gr.Textbox( | |
| label="The hero's name", | |
| value="Tobin", lines=1, | |
| placeholder="A name a dragon would forget…", | |
| ) | |
| grimoire_name_btn = gr.Button( | |
| "Name him →", variant="primary", size="lg" | |
| ) | |
| with gr.Group(visible=False) as right_village_grp: # step 2 | |
| village_name_box = gr.Textbox( | |
| label="His village", | |
| value="the Hollow", lines=1, | |
| placeholder="A place with a well and a bell…", | |
| ) | |
| grimoire_village_btn = gr.Button( | |
| "Place him →", variant="primary", size="lg" | |
| ) | |
| with gr.Group(visible=False) as right_oracle1_grp: # step 3 | |
| oracle_box_1 = gr.Textbox( | |
| label="The first parchment", | |
| lines=3, placeholder="Anything you write may save him.", | |
| ) | |
| grimoire_oracle1_btn = gr.Button( | |
| "Seal the first →", variant="primary", size="lg" | |
| ) | |
| with gr.Group(visible=False) as right_oracle2_grp: # step 4 | |
| oracle_box_2 = gr.Textbox( | |
| label="The second parchment", lines=3, placeholder="…", | |
| ) | |
| grimoire_oracle2_btn = gr.Button( | |
| "Seal the second →", variant="primary", size="lg" | |
| ) | |
| with gr.Group(visible=False) as right_oracle3_grp: # step 5 | |
| oracle_box_3 = gr.Textbox( | |
| label="The third parchment", lines=3, placeholder="…", | |
| ) | |
| grimoire_oracle3_btn = gr.Button( | |
| "Seal the third →", variant="primary", size="lg" | |
| ) | |
| with gr.Group(visible=False) as right_oracle4_grp: # step 6 | |
| oracle_box_4 = gr.Textbox( | |
| label="The fourth parchment", lines=3, placeholder="…", | |
| ) | |
| grimoire_oracle4_btn = gr.Button( | |
| "Seal the fourth →", variant="primary", size="lg" | |
| ) | |
| with gr.Group(visible=False) as right_oracle5_grp: # step 7 | |
| oracle_box_5 = gr.Textbox( | |
| label="The fifth (and final) parchment", | |
| lines=3, placeholder="He will open this at the dragon.", | |
| ) | |
| grimoire_oracle5_btn = gr.Button( | |
| "Seal the last →", variant="primary", size="lg" | |
| ) | |
| with gr.Group(visible=False) as right_closing_grp: # step 8 | |
| grimoire_summary_html = gr.HTML("") | |
| grimoire_begin_btn = gr.Button( | |
| "Let the journey begin →", variant="primary", size="lg" | |
| ) | |
| grimoire_error_html = gr.HTML( | |
| "", elem_classes=["grimoire-error-slot"] | |
| ) | |
| grimoire_progress_html = gr.HTML( | |
| _grimoire_progress_html(initial_state) | |
| ) | |
| # Legacy slot — handlers still feed this for back-compat, hidden in UI. | |
| inscribe_error_md = gr.HTML("", visible=False) | |
| # Wizard's-daydream demonstration of the gameplay loop. Moved | |
| # BELOW the grimoire so the player makes their language/theme/ | |
| # narration-length choices first, then reads the examples. | |
| # Hidden via the bundle's pipeline_demo_grp visibility update. | |
| with gr.Group(visible=True) as pipeline_demo_grp: | |
| gr.HTML(_pipeline_demo_html()) | |
| # ---- send_off panel -------------------------------------------- | |
| with gr.Group(visible=False, elem_id="oracle-send-off-grp", | |
| elem_classes=["phase-bg"]) as send_off_grp: | |
| send_off_sealed_html = gr.HTML("") | |
| send_off_narration_html = gr.HTML("") | |
| send_off_btn = gr.Button( | |
| "The hero leaves the village.", variant="primary", size="lg" | |
| ) | |
| # ---- trial panel ----------------------------------------------- | |
| with gr.Group(visible=False, elem_id="oracle-trial-grp", | |
| elem_classes=["phase-bg"]) as trial_grp: | |
| trial_heading_html = gr.HTML("") | |
| with gr.Group(visible=False) as trial_await_grp: | |
| trial_await_html = gr.HTML("") | |
| open_oracle_btn = gr.Button( | |
| "He opens one of the mentor's oracles.", | |
| variant="primary", | |
| size="lg", | |
| ) | |
| with gr.Group(visible=False) as trial_reveal_grp: | |
| # Image at top + bigger, text below for legibility. | |
| trial_image_html = gr.HTML("") | |
| trial_reveal_html = gr.HTML("") | |
| continue_btn = gr.Button( | |
| "Continue.", variant="primary", size="lg" | |
| ) | |
| # ---- epilogue panel — two stacked sub-cards -------------------- | |
| # Card 1 (epilogue_ending_grp): cinematic ENDING — title banner + | |
| # wizard's narration + a 'Continue to the chronicle' button. | |
| # Card 2 (epilogue_summary_grp): the CHRONICLE — story-tree viz + | |
| # trial-by-trial recap + 'Begin a new tale' restart button. | |
| # Substate ``state.epilogue_substate`` flips between them; the | |
| # initial mount lands on the ending card. | |
| with gr.Group(visible=False, elem_id="oracle-epilogue-grp", | |
| elem_classes=["phase-bg"]) as epilogue_grp: | |
| with gr.Group(visible=True) as epilogue_ending_grp: | |
| epilogue_html = gr.HTML("") | |
| continue_to_summary_btn = gr.Button( | |
| "Continue to the chronicle", variant="primary" | |
| ) | |
| with gr.Group(visible=False) as epilogue_summary_grp: | |
| with gr.Accordion("Chronicle", open=True) as chronicle_accordion: | |
| chronicle_md = gr.Markdown(_chronicle_md(initial_state)) | |
| restart_btn = gr.Button("Begin a new tale", variant="primary") | |
| # ---- bound outputs (must match the order of _ui_bundle) -------- | |
| # Note: chronicle_md is used in both the visible epilogue accordion AND | |
| # as a slot updated by every handler. Place it AFTER the epilogue group | |
| # so its variable is defined before we reference it here. | |
| outputs = [ | |
| state, | |
| trial_substate, | |
| header_html, | |
| inscribe_error_md, | |
| send_off_sealed_html, | |
| send_off_narration_html, | |
| trial_heading_html, | |
| trial_await_html, | |
| trial_reveal_html, | |
| trial_image_html, | |
| epilogue_html, | |
| chronicle_md, | |
| inscribe_grp, | |
| send_off_grp, | |
| trial_grp, | |
| trial_await_grp, | |
| trial_reveal_grp, | |
| epilogue_grp, | |
| epilogue_ending_grp, | |
| epilogue_summary_grp, | |
| # 18..21 | |
| grimoire_left_html, | |
| grimoire_progress_html, | |
| grimoire_summary_html, | |
| grimoire_error_html, | |
| # 22..30 — visibility for 9 right-page sub-groups (steps 0..8) | |
| right_lang_grp, | |
| right_name_grp, | |
| right_village_grp, | |
| right_oracle1_grp, | |
| right_oracle2_grp, | |
| right_oracle3_grp, | |
| right_oracle4_grp, | |
| right_oracle5_grp, | |
| right_closing_grp, | |
| grimoire_right_sigil_html, | |
| # 32 — wizard's-daydream pipeline-demo group (visible only on | |
| # inscribe step 0); the bundle's visibility update lives in | |
| # the matching final slot. | |
| pipeline_demo_grp, | |
| # 33..35 — theme-driven chrome slots that re-render on every | |
| # event so theme picks reskin the page immediately. | |
| theme_style_html, | |
| banner_html, | |
| inscribe_decoration_html, | |
| # 36 — walking-hero processing overlay, driven by generator | |
| # handlers via state.processing_msg. | |
| processing_overlay_html, | |
| # 37..58 — localized chrome (labels/info/placeholders/button | |
| # text) for every component below grimoire spread 0. Order | |
| # MUST match the tuple returned by _localized_chrome_updates(). | |
| narration_length_dropdown, | |
| hero_name_box, | |
| grimoire_name_btn, | |
| village_name_box, | |
| grimoire_village_btn, | |
| oracle_box_1, | |
| grimoire_oracle1_btn, | |
| oracle_box_2, | |
| grimoire_oracle2_btn, | |
| oracle_box_3, | |
| grimoire_oracle3_btn, | |
| oracle_box_4, | |
| grimoire_oracle4_btn, | |
| oracle_box_5, | |
| grimoire_oracle5_btn, | |
| grimoire_lang_btn, | |
| grimoire_begin_btn, | |
| open_oracle_btn, | |
| continue_btn, | |
| continue_to_summary_btn, | |
| restart_btn, | |
| language_dropdown, | |
| theme_dropdown, | |
| chronicle_accordion, | |
| ] | |
| # ---- wire events ----------------------------------------------- | |
| # Spread 0 LIVE preview: every dropdown change re-renders the | |
| # banner, palette, captions, grimoire wizard text, and demo card | |
| # in the new theme/language WITHOUT advancing the step. The | |
| # actual step advance still requires clicking the button. | |
| _spread0_inputs = [state, language_dropdown, theme_dropdown, | |
| narration_length_dropdown, visual_mode_dropdown] | |
| for _dd in (language_dropdown, theme_dropdown, narration_length_dropdown, | |
| visual_mode_dropdown): | |
| _dd.change( | |
| handle_lang_theme_preview, | |
| inputs=_spread0_inputs, | |
| outputs=outputs, | |
| ) | |
| # Grimoire steps: one handler per spread. | |
| grimoire_lang_btn.click( | |
| handle_grimoire_lang, | |
| inputs=_spread0_inputs, | |
| outputs=outputs, | |
| show_progress="hidden", | |
| ) | |
| grimoire_name_btn.click( | |
| handle_grimoire_name, | |
| inputs=[state, hero_name_box], | |
| outputs=outputs, | |
| show_progress="hidden", | |
| ) | |
| grimoire_village_btn.click( | |
| handle_grimoire_village, | |
| inputs=[state, village_name_box], | |
| outputs=outputs, | |
| show_progress="hidden", | |
| ) | |
| grimoire_oracle1_btn.click( | |
| handle_grimoire_oracle, | |
| inputs=[state, oracle_box_1], | |
| outputs=outputs, | |
| show_progress="hidden", | |
| ) | |
| grimoire_oracle2_btn.click( | |
| handle_grimoire_oracle, | |
| inputs=[state, oracle_box_2], | |
| outputs=outputs, | |
| show_progress="hidden", | |
| ) | |
| grimoire_oracle3_btn.click( | |
| handle_grimoire_oracle, | |
| inputs=[state, oracle_box_3], | |
| outputs=outputs, | |
| show_progress="hidden", | |
| ) | |
| grimoire_oracle4_btn.click( | |
| handle_grimoire_oracle, | |
| inputs=[state, oracle_box_4], | |
| outputs=outputs, | |
| show_progress="hidden", | |
| ) | |
| grimoire_oracle5_btn.click( | |
| handle_grimoire_oracle, | |
| inputs=[state, oracle_box_5], | |
| outputs=outputs, | |
| show_progress="hidden", | |
| ) | |
| grimoire_begin_btn.click( | |
| handle_grimoire_begin, | |
| inputs=[state], | |
| outputs=outputs, | |
| show_progress="hidden", | |
| ) | |
| send_off_btn.click( | |
| handle_send_off, | |
| inputs=[state], | |
| outputs=outputs, | |
| show_progress="hidden", | |
| ) | |
| open_oracle_btn.click( | |
| handle_open_oracle, | |
| inputs=[state, trial_substate], | |
| outputs=outputs, | |
| show_progress="hidden", | |
| ) | |
| continue_btn.click( | |
| handle_continue, | |
| inputs=[state, trial_substate], | |
| outputs=outputs, | |
| show_progress="hidden", | |
| ) | |
| continue_to_summary_btn.click( | |
| handle_view_summary, | |
| inputs=[state], | |
| outputs=outputs, | |
| show_progress="hidden", | |
| ) | |
| restart_btn.click( | |
| handle_restart, | |
| inputs=[state], | |
| outputs=outputs, | |
| show_progress="hidden", | |
| ) | |
| return demo | |
| # --------------------------------------------------------------------------- | |
| # Entry point | |
| # --------------------------------------------------------------------------- | |
| # | |
| # On HF Spaces, Gradio's launcher imports app.py as a module and looks for a | |
| # top-level ``demo`` variable to serve. If it doesn't find one, it falls back | |
| # to its own ``.launch()`` with DEFAULT settings — which means everything we | |
| # pass to ``demo.launch(...)`` below would be ignored (no theme, no css, no | |
| # ssr_mode=False, no allowed_paths). So we build the demo at module scope. | |
| # Register the assets directory with Gradio's static file router so | |
| # /gradio_api/file=<absolute-path> URLs served at runtime resolve to actual | |
| # files. ``set_static_paths`` is more reliable than ``allowed_paths`` on | |
| # launch() because it takes effect at module load, before any request hits. | |
| try: | |
| gr.set_static_paths(paths=[_HERE / "assets"]) | |
| except Exception: | |
| # Gradio versions without set_static_paths fall back to ``allowed_paths`` | |
| # via demo.launch(...) — harmless to skip here. | |
| pass | |
| demo = build_app() | |
| def main() -> int: | |
| # Local entry point — uses the module-level ``demo`` so the local run | |
| # matches the HF Space behavior exactly. | |
| # ssr_mode is Gradio 5+ only. The local dev env may still be on | |
| # Gradio 4.44 (the version this app was originally built against), | |
| # which would TypeError on the unknown kwarg. Try-with-ssr first, | |
| # fall back to without on TypeError. | |
| launch_kwargs = dict( | |
| server_name="0.0.0.0", | |
| server_port=7860, | |
| allowed_paths=[str(_HERE / "assets")], | |
| show_error=True, | |
| ) | |
| try: | |
| demo.launch(ssr_mode=False, **launch_kwargs) | |
| except TypeError: | |
| demo.launch(**launch_kwargs) | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |