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