Spaces:
Sleeping
Sleeping
| 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 | |
| # <audio> plays continuously (no JS) until the climax swaps the mode. | |
| figure = ( | |
| '<div class="entity-silhouette">' | |
| '<div class="entity-glow"></div>' | |
| f'<img class="entity-img" src="{_src("base")}" style="{look}">' | |
| "</div>" | |
| ) | |
| tint = '<div class="entity-redpulse"></div>' | |
| ghost = ( | |
| f'<audio autoplay loop src="data:audio/wav;base64,{_HEARTBEAT}">' | |
| "</audio>" | |
| ) | |
| elif mode == "rage": | |
| figure = ( | |
| '<div class="entity-silhouette">' | |
| '<div class="entity-glow"></div>' | |
| f'<img class="entity-img entity-rage" src="{_src("rage")}" ' | |
| 'style="filter:brightness(1) ' | |
| 'drop-shadow(0 0 22px rgba(140,60,60,0.45)) ' | |
| 'drop-shadow(0 0 48px rgba(90,30,30,0.25));opacity:1;">' | |
| "</div>" | |
| ) | |
| ghost = ( | |
| f'<div class="entity-ghost {flash_key}" ' | |
| f"style=\"background-image:url('{_src('rage')}')\"></div>" | |
| ) | |
| tint = '<div class="entity-rage-tint"></div>' | |
| 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 = ( | |
| '<div class="entity-silhouette">' | |
| '<div class="entity-glow"></div>' | |
| f'<img class="entity-img" src="{_src("base")}" style="{look}">' | |
| "</div>" | |
| ) | |
| layers = "".join( | |
| f'<img class="entity-flick flick-{i}" src="{_src(n)}">' | |
| for i, n in enumerate(("terror", "end", "base", "rage")) | |
| ) | |
| flash = f'<div class="entity-frenzy-wrap {flash_key}">{layers}</div>' | |
| ghost = ( | |
| '<div class="entity-ghost entity-ghost-frenzy" ' | |
| f"style=\"background-image:url('{_src('terror')}')\"></div>" | |
| f'<audio class="cue-audio" src="data:audio/wav;base64,{_STING}"></audio>' | |
| ) | |
| tint = '<div class="entity-rage-tint"></div>' | |
| 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 = ( | |
| '<div class="entity-silhouette">' | |
| '<div class="entity-glow"></div>' | |
| f'<img class="entity-img" src="{_src("base")}" style="{look}">' | |
| "</div>" | |
| ) | |
| layers = "".join( | |
| f'<img class="entity-flick flick-{i}" src="{_src(n)}">' | |
| for i, n in enumerate(("almost", "base", "peace")) | |
| ) | |
| settle = f'<img class="entity-flick entity-settle-face" src="{_src("peace")}">' | |
| flash = f'<div class="entity-convulse-soft {flash_key}">{layers}{settle}</div>' | |
| 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 = ( | |
| '<div class="entity-silhouette">' | |
| '<div class="entity-glow"></div>' | |
| f'<img class="entity-img" src="{_src("base")}" style="{look}">' | |
| "</div>" | |
| ) | |
| layers = "".join( | |
| f'<img class="entity-flick flick-{i}" src="{_src(n)}">' | |
| for i, n in enumerate(("terror", "end", "base", "end")) | |
| ) | |
| settle = f'<img class="entity-flick entity-settle-face" src="{_src("end")}">' | |
| flash = (f'<div class="entity-frenzy-wrap entity-convulse-loop {flash_key}">' | |
| f'{layers}{settle}</div>') | |
| elif mode == "end": | |
| figure = ( | |
| '<div class="entity-silhouette">' | |
| '<div class="entity-glow"></div>' | |
| f'<img class="entity-img entity-end" src="{_src("end")}" ' | |
| 'style="filter:brightness(1) ' | |
| 'drop-shadow(0 0 18px rgba(130,110,160,0.35)) ' | |
| 'drop-shadow(0 0 42px rgba(90,70,120,0.20));opacity:1;">' | |
| "</div>" | |
| ) | |
| ghost = ( | |
| f'<div class="entity-ghost {flash_key}" ' | |
| f"style=\"background-image:url('{_src('terror')}')\"></div>" | |
| ) | |
| 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 = ( | |
| '<div class="entity-silhouette">' | |
| '<div class="entity-glow"></div>' | |
| f'<img class="entity-img entity-end" src="{_src("end")}" ' | |
| 'style="filter:brightness(1) ' | |
| 'drop-shadow(0 0 18px rgba(130,110,160,0.35)) ' | |
| 'drop-shadow(0 0 42px rgba(90,70,120,0.20));opacity:1;">' | |
| "</div>" | |
| ) | |
| ghost = f'<audio class="cue-audio" src="data:audio/wav;base64,{_FLATLINE}"></audio>' | |
| elif mode == "peace": | |
| # redemption: the first true smile, fully clear, no ghost, no menace | |
| figure = ( | |
| '<div class="entity-silhouette">' | |
| '<div class="entity-glow"></div>' | |
| f'<img class="entity-img entity-peace" src="{_src("peace")}" ' | |
| 'style="filter:brightness(1.05) ' | |
| 'drop-shadow(0 0 24px rgba(170,150,200,0.40)) ' | |
| 'drop-shadow(0 0 54px rgba(120,100,160,0.22));opacity:1;">' | |
| "</div>" | |
| ) | |
| 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 = ( | |
| '<div class="entity-silhouette">' | |
| '<div class="entity-glow"></div>' | |
| f'<img class="entity-img entity-peace" src="{_src("peace")}" ' | |
| 'style="filter:brightness(1.05) ' | |
| 'drop-shadow(0 0 24px rgba(170,150,200,0.40)) ' | |
| 'drop-shadow(0 0 54px rgba(120,100,160,0.22));opacity:1;">' | |
| "</div>" | |
| ) | |
| ghost = f'<audio class="cue-audio" src="data:audio/wav;base64,{_SIGH}"></audio>' | |
| elif mode == "peace_dissolve": | |
| # the happy child dissolves into the fog — released, not taken | |
| figure = ( | |
| '<div class="entity-silhouette">' | |
| '<div class="entity-glow"></div>' | |
| f'<img class="entity-img entity-peace entity-dissolve" ' | |
| f'src="{_src("peace")}" style="filter:brightness(1.05) ' | |
| 'drop-shadow(0 0 24px rgba(170,150,200,0.40)) ' | |
| 'drop-shadow(0 0 54px rgba(120,100,160,0.22));opacity:1;">' | |
| "</div>" | |
| ) | |
| 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'<img class="entity-img entity-almost" src="{_src("almost")}" ' | |
| f'style="filter:brightness({brightness});opacity:{opacity};">' | |
| ) | |
| figure = ( | |
| f'<div class="entity-silhouette{sway}">' | |
| '<div class="entity-glow"></div>' | |
| f'<img class="entity-img entity-face-{face}{extra}" src="{_src(face)}" style="{look}">' | |
| f'{almost_layer}' | |
| "</div>" | |
| ) | |
| 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'<img class="entity-flick flick-{i}" src="{_src(n)}">' | |
| for i, n in enumerate(("terror", "almost", "end", "base")) | |
| ) | |
| flash = ( | |
| f'<div class="entity-flash-wrap {flash_key}{strong}">' | |
| f"{layers}</div>" | |
| ) | |
| if mode == "flash_strong": | |
| ghost = ( | |
| f'<div class="entity-ghost {flash_key}" ' | |
| f"style=\"background-image:url('{_src('terror')}')\"></div>" | |
| ) | |
| # 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'<span class="cue-now" data-cue="{cue}" data-seq="{seq}" ' | |
| 'style="display:none"></span>') if cue else "" | |
| return ( | |
| cue_mark | |
| + f'<span class="tone-now" data-tone="{tone_token}" style="display:none"></span>' | |
| f'<div class="entity-scene {tone_class}">' | |
| f'{figure}{flash}{tint}' | |
| '</div>' | |
| 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'<div style="{_ITEM}{_WOUND_REVEALED}">{html.escape(w, quote=True)}</div>' | |
| ) | |
| else: | |
| blocks = "▮" * (4 + (i * 3) % 4) | |
| parts.append(f'<div style="{_ITEM}{_WOUND}">{blocks}</div>') | |
| 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'<div class="recovered-item" style="{_ITEM}{_CLAIMED}font-style:italic;">' | |
| f'{html.escape(OWN_FRAGMENTS[i], quote=True)}</div>' | |
| for i in range(n) | |
| ] | |
| for mem in (claimed or []): | |
| rows.append( | |
| f'<div class="recovered-item stolen" style="{_ITEM}{_CLAIMED}font-style:italic;">' | |
| f'{html.escape(mem, quote=True)}</div>' | |
| ) | |
| count = len(rows) | |
| head = f'<div class="drawer-head" style="{_HEADER}">✦ What it remembers <span class="drawer-count">· {count}</span></div>' | |
| body = "".join(rows) if rows else f'<div style="{_EMPTY}">it remembers nothing of itself.</div>' | |
| return f'<div class="recovered-panel" style="{_PANEL}">{head}{body}</div>' | |
| 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'<div class="drawer-head" style="{_HEADER}">{title} <span class="drawer-count">· {item_count}</span></div>' | |
| if mine: | |
| body = f'<div style="{_MINE}">...mine now.</div>' | |
| 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'<div style="{_EMPTY}">{_PLACEHOLDER}</div>' | |
| 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'<div class="{cls}" style="{style}">{html.escape(m, quote=True)}</div>' | |
| ) | |
| parts.extend(_wound_entries(wounds, revealed)) | |
| body = "".join(parts) | |
| return ( | |
| f'<div class="treasure-panel" style="{_PANEL}">' | |
| f"{header}" | |
| f'<div class="treasure-scroll">{body}</div>' | |
| "</div>" | |
| ) | |