import base64 import html from pathlib import Path from character import OWN_FRAGMENTS from sting import (flatline_wav_bytes, heartbeat_wav_bytes, sigh_wav_bytes, sting_wav_bytes) def _tone_class(tone: int) -> str: """Map the tone accumulator to a scene CSS class.""" if tone >= 15: return "tone-warm" if tone <= -22: return "tone-hostile" if tone <= -15: return "tone-wounded" return "tone-neutral" _PLACEHOLDER = "...nothing yet. tell me something true." _PANEL = ( "background:#0c0a12;border:1px solid #2a1f3d;border-radius:4px;" "padding:10px;" ) _HEADER = ( "font-size:10px;letter-spacing:2px;text-transform:uppercase;color:#5a4570;" "padding-bottom:6px;border-bottom:1px solid #1e1628;margin-bottom:8px;" ) _ITEM = ( "background:#110d1a;border:1px solid #2a1f3d;border-left:2px solid #5a3a7a;" "border-radius:0 3px 3px 0;padding:5px 8px;margin-bottom:6px;" "font-size:11px;color:#9a80b8;line-height:1.4;" ) _EMPTY = "font-size:11px;color:#4a3f56;font-style:italic;" _STRUCK = "text-decoration:line-through;opacity:0.35;" _MINE = "font-size:12px;color:#7a5f96;font-style:italic;letter-spacing:2px;" _CLAIMED = "border-left-color:#3a2a50;color:#5a4f72;" _WOUND = "border-left-color:#5a2a2a;color:#6a3a3a;letter-spacing:2px;" _WOUND_REVEALED = "border-left-color:#8a3030;color:#b87878;" _ASSETS = Path(__file__).parent / "assets" def _load_image(name: str) -> str: path = _ASSETS / f"hollow_{name}_cut.webp" if not path.is_file(): raise FileNotFoundError( f"Entity silhouette missing: {path} — assets/hollow_*_cut.webp must be committed." ) return base64.b64encode(path.read_bytes()).decode() _IMG = {name: _load_image(name) for name in ("base", "terror", "almost", "end", "rage", "peace")} _STING = base64.b64encode(sting_wav_bytes()).decode() _HEARTBEAT = base64.b64encode(heartbeat_wav_bytes()).decode() _SIGH = base64.b64encode(sigh_wav_bytes()).decode() _FLATLINE = base64.b64encode(flatline_wav_bytes()).decode() def _src(name: str) -> str: return f"data:image/webp;base64,{_IMG[name]}" def _face_for(affinity: int, mode: str) -> str: """Which cut-out face represents the child in this scene state.""" if mode in ("rage", "frenzy"): return "rage" if mode in ("end", "end_settle", "convulse_loop"): return "end" if mode in ("peace", "peace_settle", "peace_dissolve", "convulse_good"): return "peace" return "almost" if affinity >= 76 else "base" def render_entity(affinity: int, mode: str = "idle", seq: int = 0, tone: int = 0, cue: str = "") -> str: """The entity panel: Hollow as a free-standing silhouette in the scene. mode: "idle" | "flash" (memory captured) | "flash_strong" (recall turn) | "end" (visitor loop) | "rage" (bad finale) | "peace" / "peace_dissolve" (redemption — first smile, then release) seq: turn counter — alternates one-shot animation names (flash-0/flash-1) so the flash restarts even when Gradio morphs the DOM in place. tone: the world's emotional temperature, rendered through the scene CSS. """ a = max(0, min(100, int(affinity))) t = min(a, 45) / 45 # fully materialized by bond 45 # veil the SHARP child in shadow instead of blurring it (blur reads as # a broken/loading image): dark and fog-shrouded at low bond, emerging brightness = round(0.72 + 0.28 * t, 2) opacity = round(0.82 + 0.18 * t, 2) almost_on = a >= 76 flash_key = f"flash-{seq % 2}" tone_class = _tone_class(tone) # a stronger inner/outer glow so the silhouette reads against the fog look = ( f"filter:brightness({brightness}) " f"drop-shadow(0 0 20px rgba(140,120,170,{round(0.25 + 0.25*t,2)})) " f"drop-shadow(0 0 50px rgba(100,80,140,{round(0.15 + 0.20*t,2)}));" f"opacity:{opacity};" ) ghost = "" tint = "" flash = "" if mode == "build": # finale suspense bed: the child stays the materialized smudge while a # heartbeat loops and a red pulse throbs. The HTML is byte-identical # across recital steps, so Gradio doesn't remount it and the looped # plays continuously (no JS) until the climax swaps the mode. figure = ( '' '' f'' "" ) tint = '' ghost = ( f'' "" ) elif mode == "rage": figure = ( '' '' f'' "" ) ghost = ( f'" ) tint = '' elif mode == "frenzy": # the bad finale's convulsion: every face fights for the body for a # few seconds. Beneath the flicker the child stays the smudge — the # rage face is NOT revealed until the next step. The terror face stabs # the full screen three times and the sting plays once. figure = ( '' '' f'' "" ) layers = "".join( f'' for i, n in enumerate(("terror", "end", "base", "rage")) ) flash = f'{layers}' ghost = ( '" f'' ) tint = '' elif mode == "convulse_good": # redemption climax: a SOFT, fast tremor through the child's gentler # selves that EASES into the smile — the settle-face holds `peace` at # the end, so the peace_settle render is the same frame (no visible # pop). Silent here: the relief sigh plays on the settle, when the # smile lands. figure = ( '' '' f'' "" ) layers = "".join( f'' for i, n in enumerate(("almost", "base", "peace")) ) settle = f'' flash = f'{layers}{settle}' elif mode == "convulse_loop": # visitor-loop climax: the hard convulsion (frenzy wrapper, faster via # entity-convulse-loop) easing into the `end` face — the settle-face # holds it so the end_settle render is seamless. Silent here: the # flatline plays on the settle. figure = ( '' '' f'' "" ) layers = "".join( f'' for i, n in enumerate(("terror", "end", "base", "end")) ) settle = f'' flash = (f'' f'{layers}{settle}') elif mode == "end": figure = ( '' '' f'' "" ) ghost = ( f'" ) elif mode == "end_settle": # the `end` face lands and the heartbeat flatlines, exactly now — the # convulsion already settled on this face, so the swap is seamless figure = ( '' '' f'' "" ) ghost = f'' elif mode == "peace": # redemption: the first true smile, fully clear, no ghost, no menace figure = ( '' '' f'' "" ) elif mode == "peace_settle": # the smile lands — the same frame the convulsion settled on, so the # swap is seamless — and the relief sigh plays exactly now figure = ( '' '' f'' "" ) ghost = f'' elif mode == "peace_dissolve": # the happy child dissolves into the fog — released, not taken figure = ( '' '' f'' "" ) else: # idle / flash / flash_strong: a free-standing silhouette that # materializes with bond. At Almost tier the "almost" face overlays. face = _face_for(a, mode) extra = f" entity-flash-{seq % 2}" if mode in ("flash", "flash_strong") else "" sway = "" if almost_on else " entity-sway" almost_layer = "" if almost_on and face == "base": almost_layer = ( f'' ) figure = ( f'' '' f'' f'{almost_layer}' "" ) if mode in ("flash", "flash_strong"): strong = " entity-flash-strong" if mode == "flash_strong" else "" # a fast subliminal flicker through every face — too quick to read, # the silhouette seems to convulse through what it could become layers = "".join( f'' for i, n in enumerate(("terror", "almost", "end", "base")) ) flash = ( f'' f"{layers}" ) if mode == "flash_strong": ghost = ( f'" ) # no per-reply chime: the child now SPEAKS every reply (voice) and the # text streams — the chime was redundant feedback and, with streaming, # fired out of sync (at stream start, then again at the end). # tone-now marker: a head-JS MutationObserver reads data-tone and lifts the # tone class onto #game-view (the common ancestor of #game-bg/#game-vig/ # #game-scene) so the whole world tints, not just this panel's silhouette. tone_token = tone_class.split("-", 1)[1] if "-" in tone_class else "neutral" cue_mark = (f'') if cue else "" return ( cue_mark + f'' f'' f'{figure}{flash}{tint}' '' f'{ghost}' ) def _wound_entries(wounds: list[str], revealed: int) -> list[str]: """Wounds render redacted (the player sees SOMETHING is being kept, not what); the bad finale raises `revealed` to unredact them one by one. Redacted text is never placed in the DOM — no inspect-element spoilers.""" parts = [] for i, w in enumerate(wounds): if i < revealed: parts.append( f'{html.escape(w, quote=True)}' ) else: blocks = "▮" * (4 + (i * 3) % 4) parts.append(f'{blocks}') return parts def render_recovered(fragments_told: int, claimed: list[str] | None = None) -> str: """The child's own recovered memories (its real past) + the memories it has claimed from the visitor, now worn as its own — the right-hand drawer.""" n = max(0, min(int(fragments_told), len(OWN_FRAGMENTS))) rows = [ f'' f'{html.escape(OWN_FRAGMENTS[i], quote=True)}' for i in range(n) ] for mem in (claimed or []): rows.append( f'' f'{html.escape(mem, quote=True)}' ) count = len(rows) head = f'✦ What it remembers · {count}' body = "".join(rows) if rows else f'it remembers nothing of itself.' return f'{head}{body}' def render_treasure(treasure: list[str], struck: set[str] | None = None, mine: bool = False, claimed: set[str] | None = None, wounds: list[str] | None = None, revealed: int = 0, yours: bool = False, just_claimed: str | None = None) -> str: struck = struck or set() claimed = claimed or set() wounds = wounds or [] title = "✦ Your Words" if yours else "✦ Treasure" item_count = len(treasure) header = f'{title} · {item_count}' if mine: body = f'...mine now.' elif yours: # the bad finale's turn: shared memories are discarded; its treasure # is what the visitor said to it body = "".join(_wound_entries(wounds, revealed)) elif not treasure and not wounds: body = f'{_PLACEHOLDER}' else: n = len(treasure) parts = [] for i, m in enumerate(treasure): # older memories sink into the dark; newest stays fully lit age_op = max(0.45, round(1 - 0.07 * (n - 1 - i), 2)) classes = ["treasure-item"] if m == just_claimed: classes.append("claiming") if m in claimed: classes.append("claimed") style = f"{_ITEM}opacity:{age_op};" if m in claimed: style += _CLAIMED if m in struck: style += _STRUCK cls = " ".join(classes) parts.append( f'{html.escape(m, quote=True)}' ) parts.extend(_wound_entries(wounds, revealed)) body = "".join(parts) return ( f'' f"{header}" f'{body}' "" )