hollow / render.py
Pabloler21's picture
style(game): drawer counts + clearer tappable affordance
ad46c69
Raw
History Blame Contribute Delete
17.5 kB
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>"
)