| import os |
| |
| |
| |
| |
| os.environ.setdefault("OMP_NUM_THREADS", "4") |
| os.environ.setdefault("OPENBLAS_NUM_THREADS", "4") |
| os.environ.setdefault("MKL_NUM_THREADS", "4") |
|
|
| |
| |
| |
| |
| |
| |
| |
| os.environ["GRADIO_SSR_MODE"] = "False" |
|
|
| import base64 |
| import html |
| import io |
| import json |
| import re |
| import time |
| import wave |
|
|
| import gradio as gr |
|
|
| from character import (FRAGMENT_TONES, INTRO_CARDS, INTRO_SEQUENCES, |
| OPENING_LINE, OWN_FRAGMENTS, build_system_prompt) |
| from engine import run_turn, run_turn_stream |
| from finale import finale_steps, finale_steps_bad, finale_steps_good |
| from memory import apply_update, get_tier, decide_ending, pick_aware_memory, should_recall, style_signal |
| from render import render_entity, render_treasure, render_recovered, _tone_class |
| from sting import chime_wav_bytes, menu_loop_wav_bytes, type_tick_wav_bytes |
| from voice import speak |
|
|
|
|
| with open("assets/background.webp", "rb") as _bg: |
| _BACKGROUND_URI = "data:image/webp;base64," + base64.b64encode(_bg.read()).decode() |
|
|
|
|
| def _render_bond(affinity: int, tier: str, label: str = "bond") -> str: |
| """Custom HTML Bond meter — a thin thread with a heartbeat marker. |
| Replaces gr.Slider so we fully control the look (no Gradio orange/track).""" |
| pct = max(0, min(100, int(affinity))) |
| return ( |
| '<div class="bond-meter">' |
| '<div class="bond-head">' |
| f'<span class="bond-name">{label}</span>' |
| f'<span class="bond-tier">{tier}</span>' |
| '</div>' |
| '<div class="bond-track">' |
| f'<div class="bond-fill" style="width:{pct}%"></div>' |
| f'<div class="bond-beat" style="left:{pct}%"></div>' |
| '</div>' |
| '</div>' |
| ) |
|
|
|
|
| def _render_title(state: dict, show_end: bool = False) -> str: |
| """The top-left title: 'Hollow' until the child names itself. On the |
| finale's final frame (show_end), append a hidden end marker the head-JS |
| polls to reveal the end screen (same marker pattern as tone/cue).""" |
| name = state.get("chosen_name") |
| if name: |
| safe = html.escape(name, quote=True) |
| title = ( |
| f'<div id="game-title">{safe}' |
| f'<span class="named"> — it calls itself <b>{safe}</b></span></div>' |
| ) |
| else: |
| title = '<div id="game-title">Hollow</div>' |
| if show_end and state.get("ended"): |
| title += (f'<span class="ended-now" data-ending="{state.get("ending","loop")}" ' |
| 'style="display:none"></span>') |
| return title |
|
|
|
|
| def _pristine_state(): |
| """A brand-new game — the real starting point, independent of the dev |
| HOLLOW_FAST_FINALE seed (the restart button must always give a clean run).""" |
| return { |
| "affinity": 20, |
| "treasure": [], |
| "claimed": [], |
| "history": [], |
| "turn": 0, |
| "last_recall_turn": None, |
| "ended": False, |
| "tone": 0, |
| "force_ending": None, |
| "wounds": [], |
| "ending": None, |
| "fragments_told": 0, |
| "last_aware_memory": None, |
| "msg_lengths": [], |
| "last_activity": time.time(), |
| "idle_count": 0, |
| "greeted": False, |
| "mode": "tester", |
| "chosen_name": None, |
| "named": False, |
| } |
|
|
|
|
| def _reset(): |
| """Wipe the board for a fresh game without a page reload. Order matches |
| [msg_input, send_btn, chatbot, state, bond_panel, treasure_panel, |
| entity_panel, voice_panel, recovered_panel, title_panel].""" |
| fresh = _pristine_state() |
| return ( |
| gr.update(value="", interactive=True, |
| placeholder="tell it something you remember..."), |
| gr.update(interactive=True), |
| [{"role": "assistant", "content": OPENING_LINE}], |
| fresh, |
| _render_bond(20, "Hollow"), |
| render_treasure([]), |
| render_entity(20), |
| _voice_html(None), |
| render_recovered(0), |
| _render_title(fresh), |
| ) |
|
|
|
|
| def _to_menu(): |
| """Leave the wood — back to the front-door menu with a clean slate. |
| Order matches [menu_view, game_view, state].""" |
| return gr.update(visible=True), gr.update(visible=False), _pristine_state() |
|
|
|
|
| def _init_state(): |
| fast = os.environ.get("HOLLOW_FAST_FINALE", "").lower() |
| if fast in ("1", "good", "loop", "neutral"): |
| seeded = [ |
| "is afraid of being forgotten", |
| "had a dog named Nala", |
| "grew up near the sea", |
| ] |
| warm = fast in ("1", "good") |
| return { |
| "affinity": 78, |
| "treasure": list(seeded), |
| "claimed": list(seeded), |
| "history": [], |
| "turn": 12, |
| "last_recall_turn": 9, |
| "ended": False, |
| "tone": 30 if warm else 5, |
| "force_ending": "good" if warm else "loop", |
| "wounds": [], |
| "ending": None, |
| "fragments_told": 2 if warm else 0, |
| "last_aware_memory": None, |
| "msg_lengths": [], |
| "last_activity": time.time(), |
| "idle_count": 0, |
| "greeted": False, |
| "mode": "tester", |
| "chosen_name": None, |
| "named": False, |
| } |
| if fast == "bad": |
| return { |
| "affinity": 12, |
| "treasure": [], |
| "claimed": [], |
| "history": [], |
| "turn": 7, |
| "last_recall_turn": None, |
| "ended": False, |
| |
| |
| "tone": -35, |
| "force_ending": "bad", |
| "wounds": ["you're nothing", "talking to you is a waste of time"], |
| "ending": None, |
| "fragments_told": 0, |
| "last_aware_memory": None, |
| "msg_lengths": [], |
| "last_activity": time.time(), |
| "idle_count": 0, |
| "greeted": False, |
| "mode": "tester", |
| "chosen_name": None, |
| "named": False, |
| } |
| return _pristine_state() |
|
|
|
|
| |
| |
| |
| _PENDING = '<span class="hollow-typing"><i></i><i></i><i></i></span>' |
|
|
|
|
| _HOWTO_HTML = ( |
| '<div class="howto-panel">' |
| '<h3>How to Play</h3>' |
| '<p>You found a child at the edge of the wood. It has no memories of its own ' |
| '— so it asks for yours.</p>' |
| '<p>Tell it something true: a person, a place, a moment you lived. It keeps each ' |
| 'one in its treasure.</p>' |
| '<p>Later it speaks your memories back — in the first person, as if it had ' |
| 'lived them. That is what it wants.</p>' |
| '<p>But it remembers how you treat it. Be gentle, or be cruel. ' |
| '<b>Three endings</b> wait in the fog.</p>' |
| '<p class="howto-controls">type and press enter · mute · ' |
| 'begin again · turn your sound on</p>' |
| '</div>' |
| ) |
|
|
|
|
| def _voice_html(b64: str | None) -> str: |
| |
| |
| if not b64: |
| return "" |
| return f'<audio autoplay src="data:audio/wav;base64,{b64}"></audio>' |
|
|
|
|
| def _recovered(state: dict) -> str: |
| return render_recovered(state.get("fragments_told", 0), state.get("claimed")) |
|
|
|
|
| _SENTENCE_END = re.compile(r'[^.!?…\n]*[.!?…\n]+', re.S) |
|
|
|
|
| def _new_sentences(text: str, consumed: int) -> tuple[list[str], int]: |
| """Return any newly-completed sentences in text[consumed:] and the new |
| consumed offset (up to the last sentence terminator).""" |
| pending = text[consumed:] |
| out, last_end = [], 0 |
| for m in _SENTENCE_END.finditer(pending): |
| chunk = m.group().strip() |
| if chunk: |
| out.append(chunk) |
| last_end = m.end() |
| return out, consumed + last_end |
|
|
|
|
| |
| |
| |
| |
| |
| _CHIME_B64 = base64.b64encode(chime_wav_bytes(0)).decode() |
| _GREETING_B64 = speak(OPENING_LINE) or "" |
| _MENU_LOOP_B64 = base64.b64encode(menu_loop_wav_bytes()).decode() |
| _CHIME_URI = f"data:audio/wav;base64,{_CHIME_B64}" |
| _GREETING_URI = f"data:audio/wav;base64,{_GREETING_B64}" if _GREETING_B64 else "" |
| _MENU_LOOP_URI = f"data:audio/wav;base64,{_MENU_LOOP_B64}" |
|
|
| |
| |
| |
| def _img_uri(path: str) -> str: |
| try: |
| with open(path, "rb") as f: |
| return "data:image/webp;base64," + base64.b64encode(f.read()).decode() |
| except OSError: |
| return "" |
|
|
| _INTRO_IMAGES = [ |
| _img_uri("assets/intro_threshold.webp"), |
| _img_uri("assets/intro_keeps.webp"), |
| _img_uri("assets/intro_waiting.webp"), |
| _img_uri("assets/intro_meeting.webp"), |
| _img_uri("assets/intro_waiting.webp"), |
| ] |
| |
| |
| |
| |
| |
| _INTRO_POS = ["center 72%", "center 38%", "center 38%", "center 20%", "center 38%"] |
| _TICK_URI = f"data:audio/wav;base64,{base64.b64encode(type_tick_wav_bytes()).decode()}" |
|
|
|
|
| |
| |
| |
| |
| |
| |
| _HEAD_JS = """ |
| <link rel="preconnect" href="https://fonts.googleapis.com"> |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
| <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Special+Elite&family=Crimson+Text:ital,wght@0,400;0,600;1,400;1,600&display=swap"> |
| <script> |
| (function () { |
| var KEY = 'hollowAudio'; |
| var saved = {}; |
| try { saved = JSON.parse(localStorage.getItem(KEY)) || {}; } catch (e) {} |
| var st = { |
| volume: (typeof saved.volume === 'number') ? saved.volume : 0.85, |
| muted: !!saved.muted |
| }; |
| function persist() { |
| try { localStorage.setItem(KEY, JSON.stringify(st)); } catch (e) {} |
| } |
| var CHIME_SRC = "__CHIME__"; |
| var GREETING_SRC = "__GREETING__"; |
| var MENU_SRC = "__MENU__"; |
| var INTRO = __INTRO_CARDS__; // [{img, text}, ...] (JSON, injected) |
| var INTRO_SEQ = __INTRO_SEQ__; // {"tester":[0,3], "full":[0,1,2,3]} (injected) |
| var TICK_SRC = "__TICK__"; |
| // --- client-side voice queue: sentences play in order, never cut --- |
| var queue = [], current = null; |
| function playNext() { |
| if (current || !queue.length) return; |
| current = queue.shift(); |
| current.muted = st.muted; current.volume = st.volume; |
| current.onended = function () { current = null; playNext(); }; |
| current.play().catch(function () { current = null; }); |
| } |
| function enqueue(src) { |
| if (!src) return; |
| queue.push(new Audio(src)); |
| playNext(); |
| } |
| function applyLive() { // mute/volume affect the playing line |
| if (current) { current.muted = st.muted; current.volume = st.volume; } |
| } |
| function resetQueue() { |
| queue.length = 0; |
| if (current) { try { current.pause(); } catch (e) {} current = null; } |
| } |
| var _lastRitual = 0; |
| function openingRitual() { // chime, then the child greets |
| var now = Date.now(); |
| if (now - _lastRitual < 800) return; // debounce double-fire |
| _lastRitual = now; |
| resetQueue(); |
| if (CHIME_SRC) enqueue(CHIME_SRC); |
| if (GREETING_SRC) enqueue(GREETING_SRC); |
| } |
| // menu bed: starts on first gesture, stops when a mode is chosen |
| var menuAudio = null; |
| function startMenuMusic() { |
| if (menuAudio || !MENU_SRC) return; |
| menuAudio = document.getElementById('menu-loop') || new Audio(MENU_SRC); |
| menuAudio.loop = true; |
| menuAudio.volume = st.muted ? 0 : 0.2; |
| menuAudio.muted = st.muted; |
| menuAudio.play().catch(function () {}); |
| } |
| function stopMenuMusic() { |
| if (menuAudio) { try { menuAudio.pause(); } catch (e) {} menuAudio = null; } |
| } |
| // read any voice <audio> Gradio mounts, copy its src into the queue, then it |
| // can be wiped from the DOM without stopping playback |
| new MutationObserver(function (muts) { |
| muts.forEach(function (m) { |
| m.addedNodes.forEach(function (n) { |
| if (n.nodeType !== 1) return; |
| var nodes = []; |
| if (n.matches && n.matches('.voice-channel audio, audio.cue-audio')) nodes.push(n); |
| if (n.querySelectorAll) n.querySelectorAll('.voice-channel audio, audio.cue-audio').forEach(function (x) { nodes.push(x); }); |
| nodes.forEach(function (a) { enqueue(a.currentSrc || a.src); a.remove(); }); |
| }); |
| }); |
| }).observe(document.documentElement, { childList: true, subtree: true }); |
| |
| // first menu gesture starts the eerie bed (the greeting fires on mode-select) |
| function firstGesture() { |
| startMenuMusic(); |
| window.removeEventListener('pointerdown', firstGesture, true); |
| window.removeEventListener('keydown', firstGesture, true); |
| } |
| window.addEventListener('pointerdown', firstGesture, true); |
| window.addEventListener('keydown', firstGesture, true); |
| |
| // ---- opening intro: client-driven typewriter cards ---- |
| var introSeq = [], introIdx = 0, introTyping = false, introTimer = null; |
| |
| function playTick() { |
| if (!TICK_SRC || st.muted) return; |
| var a = new Audio(TICK_SRC); |
| a.volume = 0.2; |
| a.play().catch(function () {}); |
| } |
| function typeLine(el, text, onDone) { |
| introTyping = true; |
| var i = 0; |
| (function step() { |
| el.textContent = text.slice(0, i); |
| if (i < text.length) { |
| if (i % 2 === 0) playTick(); |
| i++; |
| introTimer = setTimeout(step, 28); |
| } else { |
| introTyping = false; introTimer = null; |
| if (onDone) onDone(); |
| } |
| })(); |
| } |
| function revealChoice() { |
| var opts = document.getElementById('intro-opts'); |
| if (opts) opts.classList.add('intro-opts-show'); |
| } |
| function showIntroCard(cardIndex, isLast) { |
| var card = INTRO[cardIndex]; |
| var img = document.getElementById('intro-image'); |
| var bg = document.getElementById('intro-bg'); |
| var txt = document.getElementById('intro-text'); |
| var opts = document.getElementById('intro-opts'); |
| if (opts) opts.classList.remove('intro-opts-show'); |
| var url = card.img ? "url('" + card.img + "')" : ''; |
| if (img) { |
| img.style.backgroundImage = url; |
| img.style.backgroundPosition = card.pos || 'center'; |
| } |
| if (bg) bg.style.backgroundImage = url; |
| if (txt) typeLine(txt, card.text, function () { if (isLast) revealChoice(); }); |
| } |
| function advanceIntro() { |
| var isLast = introIdx >= introSeq.length - 1; |
| if (introTyping) { // first click completes the line |
| if (introTimer) { clearTimeout(introTimer); introTimer = null; } |
| var txt = document.getElementById('intro-text'); |
| if (txt) txt.textContent = INTRO[introSeq[introIdx]].text; |
| introTyping = false; |
| if (isLast) revealChoice(); |
| return; |
| } |
| if (isLast) return; // wait for a choice on the last card |
| introIdx++; |
| showIntroCard(introSeq[introIdx], introIdx >= introSeq.length - 1); |
| } |
| function startIntro(mode) { |
| introSeq = INTRO_SEQ[mode] || INTRO_SEQ['tester']; // single source of truth |
| introIdx = 0; |
| window.scrollTo(0, 0); |
| // The server flips intro_view visible on a round-trip; on a Space the |
| // network latency makes that land AFTER a fixed timeout, so #intro-text isn't |
| // in the DOM yet and nothing types -> blank intro. POLL for it (up to ~6s) |
| // instead of guessing a delay, so it's robust to any latency. |
| var _introTries = 0; |
| (function waitForIntro() { |
| var txt = document.getElementById('intro-text'); |
| if (!txt) { if (_introTries++ < 100) setTimeout(waitForIntro, 60); return; } |
| showIntroCard(introSeq[0], introSeq.length <= 1); |
| var stage = document.getElementById('intro-stage'); |
| if (stage && !stage.dataset.hollowWired) { |
| stage.dataset.hollowWired = '1'; |
| stage.addEventListener('click', advanceIntro); |
| } |
| var opts = document.querySelectorAll('#intro-opts .intro-opt'); |
| opts.forEach(function (el) { |
| if (el.dataset.hollowWired) return; |
| el.dataset.hollowWired = '1'; |
| el.addEventListener('click', function (e) { |
| e.stopPropagation(); // don't let the stage advance |
| var t = document.getElementById(el.dataset.target); |
| if (t) t.click(); // fire the hidden Gradio button |
| }); |
| }); |
| })(); |
| } |
| |
| // mute button + restart + mode buttons, wired once rendered |
| function icon() { return st.muted ? '\\uD83D\\uDD07' : '\\uD83D\\uDD0A'; } |
| // lift the per-turn tone onto #game-view so the WORLD tints (the marker |
| // .tone-now lives inside #game-entity, a sibling of #game-bg/#game-vig) |
| function applyTone() { |
| var gv = document.getElementById('game-view'); |
| var mark = document.querySelector('#game-view .tone-now[data-tone]'); |
| if (!gv || !mark) return; |
| var t = mark.getAttribute('data-tone'); |
| if (gv.dataset.tone === t) return; |
| gv.dataset.tone = t; |
| ['tone-warm','tone-neutral','tone-wounded','tone-hostile'].forEach(function (c) { |
| gv.classList.remove(c); |
| }); |
| gv.classList.add('tone-' + t); |
| } |
| |
| // once-per-turn feedback: pulse the relevant drawer chip, drop a whisper, |
| // amber-tag the recall line. Rides the same marker pattern as applyTone. |
| var CUE_WHISPER = { capture: '\\u2014 it keeps this \\u2014', |
| recover: '\\u2014 it remembers \\u2014' }; |
| function applyCue() { |
| var gv = document.getElementById('game-view'); |
| var mark = document.querySelector('#game-view .cue-now[data-cue]'); |
| if (!gv || !mark) return; |
| var seq = mark.getAttribute('data-seq'); |
| if (gv.dataset.cueSeq === seq) return; // already fired this turn |
| gv.dataset.cueSeq = seq; |
| var cue = mark.getAttribute('data-cue'); |
| var rightSide = (cue === 'recover'); |
| var drawer = document.getElementById(rightSide ? 'drawer-right' : 'drawer-left'); |
| if (drawer) { |
| drawer.classList.add('pulse'); |
| setTimeout(function () { drawer.classList.remove('pulse'); }, 2500); |
| } |
| if (cue === 'recall') { |
| var bots = document.querySelectorAll('#game-dialogue .bot'); |
| if (bots.length) bots[bots.length - 1].classList.add('recall-line'); |
| } |
| var text = CUE_WHISPER[cue]; |
| if (text && drawer) { |
| var w = document.createElement('div'); |
| w.className = 'cue-whisper'; |
| w.textContent = text; |
| var r = drawer.getBoundingClientRect(); |
| w.style.left = r.left + 'px'; |
| w.style.top = (r.bottom + 6) + 'px'; |
| document.body.appendChild(w); |
| setTimeout(function () { w.classList.add('out'); }, 60); |
| setTimeout(function () { w.remove(); }, 2800); |
| } |
| } |
| |
| // end screen: when the finale's final frame ships an `ended-now` marker, |
| // set the per-ending epitaph and fade the overlay in (after the last beat). |
| var EPITAPH = { |
| good: 'the edge of the wood is empty now.', |
| loop: 'someone waits at the edge. someone always does.', |
| bad: 'see you soon.' |
| }; |
| function applyEnd() { |
| var ov = document.getElementById('end-overlay'); |
| var mark = document.querySelector('.ended-now[data-ending]'); |
| if (!ov) return; |
| if (!mark) { return; } // no finale yet |
| if (ov.dataset.shown === '1') return; // already revealed |
| ov.dataset.shown = '1'; |
| var ending = mark.getAttribute('data-ending'); |
| var ep = document.getElementById('end-epitaph'); |
| if (ep) ep.textContent = EPITAPH[ending] || EPITAPH.loop; |
| setTimeout(function () { ov.classList.add('shown'); }, 900); // let the last beat land |
| } |
| |
| function wire() { |
| applyTone(); |
| applyCue(); |
| applyEnd(); |
| // corner drawers: header chip toggles expand |
| document.querySelectorAll('.game-drawer .drawer-head').forEach(function (h) { |
| if (h.dataset.hollowWired) return; |
| h.dataset.hollowWired = '1'; |
| h.addEventListener('click', function () { h.closest('.game-drawer').classList.toggle('open'); }); |
| }); |
| var btn = document.querySelector('.voice-btn'); |
| var rb = document.querySelector('.restart-btn'); |
| if (btn && !btn.dataset.hollowWired) { |
| btn.dataset.hollowWired = '1'; |
| btn.textContent = icon(); |
| btn.addEventListener('click', function (e) { |
| e.preventDefault(); e.stopPropagation(); |
| st.muted = !st.muted; |
| applyLive(); |
| if (menuAudio) { menuAudio.muted = st.muted; menuAudio.volume = st.muted ? 0 : 0.2; } |
| persist(); btn.textContent = icon(); |
| }); |
| } |
| if (rb && !rb.dataset.hollowWired) { |
| rb.dataset.hollowWired = '1'; |
| // do NOT stop propagation — Gradio's server-side _reset must still run; |
| // the click is a gesture, so the ritual audio is allowed to play |
| rb.addEventListener('click', function () { |
| openingRitual(); |
| window.scrollTo(0, 0); |
| setTimeout(function () { window.scrollTo(0, 0); }, 80); |
| }); |
| } |
| document.querySelectorAll('.menu-mode-btn').forEach(function (mb) { |
| if (mb.dataset.hollowWired) return; |
| mb.dataset.hollowWired = '1'; |
| mb.addEventListener('click', function () { |
| var mode = (mb.id === 'btn-full') ? 'full' : 'tester'; |
| startIntro(mode); // server flips menu->intro; bed keeps playing low |
| }); |
| }); |
| document.querySelectorAll('.menu-opt').forEach(function (mo) { |
| if (mo.dataset.hollowWired) return; |
| mo.dataset.hollowWired = '1'; |
| mo.addEventListener('click', function () { |
| var t = document.getElementById(mo.dataset.target); |
| if (t) t.click(); // fire the hidden mode button (startIntro + _show_intro) |
| }); |
| }); |
| // intro choice / skip: reveal the game -> stop the bed, chime->greeting, top |
| document.querySelectorAll('.intro-enter-btn').forEach(function (eb) { |
| if (eb.dataset.hollowWired) return; |
| eb.dataset.hollowWired = '1'; |
| eb.addEventListener('click', function () { |
| stopMenuMusic(); |
| openingRitual(); |
| window.scrollTo(0, 0); |
| setTimeout(function () { window.scrollTo(0, 0); }, 80); |
| }); |
| }); |
| // end-screen actions: the server handlers reset/leave; JS clears the |
| // overlay class + shown flag so a future finale can re-reveal it. |
| ['btn-end-again', 'btn-end-leave'].forEach(function (id) { |
| var b = document.getElementById(id); |
| if (b && !b.dataset.hollowWired) { |
| b.dataset.hollowWired = '1'; |
| b.addEventListener('click', function () { |
| var ov = document.getElementById('end-overlay'); |
| if (ov) { ov.classList.remove('shown'); ov.dataset.shown = ''; } |
| // drop the finale marker so applyEnd's 250ms poll can't re-reveal the |
| // overlay — that was the leave-the-wood double-click + the begin-again |
| // credits-flash. A fresh finale re-renders the title with a new marker. |
| var m = document.querySelector('.ended-now'); |
| if (m) m.remove(); |
| if (id === 'btn-end-again') openingRitual(); // same ritual as restart |
| }); |
| } |
| }); |
| } |
| // keep watching — buttons can appear later when the menu flips to the game |
| setInterval(wire, 250); |
| |
| // idle "speak-first": client-timed, reset by ANY interaction, fires at 60s |
| var IDLE_MS = 60000; |
| var last = Date.now(); |
| function bump() { last = Date.now(); } |
| ['pointerdown','keydown','input','pointermove','wheel','touchstart'] |
| .forEach(function (ev) { window.addEventListener(ev, bump, true); }); |
| setInterval(function () { |
| if (Date.now() - last < IDLE_MS) return; |
| var trig = document.getElementById('idle-trigger'); |
| if (!trig) return; |
| last = Date.now(); // next nudge is another full 60s |
| trig.click(); |
| }, 5000); |
| })(); |
| </script> |
| """ |
| _HEAD_JS = _HEAD_JS.replace("__CHIME__", _CHIME_URI).replace("__GREETING__", _GREETING_URI).replace("__MENU__", _MENU_LOOP_URI) |
| _INTRO_CARDS_JSON = json.dumps( |
| [{"img": _INTRO_IMAGES[i], "text": INTRO_CARDS[i], "pos": _INTRO_POS[i]} |
| for i in range(len(INTRO_CARDS))] |
| ) |
| _HEAD_JS = (_HEAD_JS |
| .replace("__INTRO_CARDS__", _INTRO_CARDS_JSON) |
| .replace("__INTRO_SEQ__", json.dumps(INTRO_SEQUENCES)) |
| .replace("__TICK__", _TICK_URI)) |
|
|
|
|
| def _menu_html() -> str: |
| return ( |
| f'<div class="menu-bg" style="background-image:url(\'{_BACKGROUND_URI}\')"></div>' |
| f'<div id="menu-frame" style="background-image:url(\'{_BACKGROUND_URI}\')"></div>' |
| '<div class="menu-scrim"></div>' |
| '<div class="menu-grain"></div>' |
| '<div class="menu-vig"></div>' |
| '<div id="menu-card">' |
| ' <div class="menu-eyebrow">AN ADVENTURE IN THOUSAND TOKEN WOOD</div>' |
| ' <div class="menu-title">Hollow</div>' |
| ' <div class="menu-tag">something is here, at the edge of the wood ' |
| '— it remembers what you give it.</div>' |
| ' <div class="menu-opts">' |
| ' <div class="menu-opt" data-target="btn-tester">Tester Run</div>' |
| ' <div class="menu-opt" data-target="btn-full">The Full Stay</div>' |
| ' <div class="menu-opt menu-opt-muted" data-target="btn-howto">How to Play</div>' |
| ' </div>' |
| '</div>' |
| '<div class="menu-credit">A LOST CHILD WAITS · TURN YOUR SOUND ON</div>' |
| ) |
|
|
|
|
| def _show_intro(mode: str): |
| """Mode-select: leave the menu, reveal the intro, remember the mode so the |
| choice/skip buttons can start the right run. Order matches the outputs |
| list below: [menu_view, intro_view, pending_mode].""" |
| return ( |
| gr.update(visible=False), |
| gr.update(visible=True), |
| mode, |
| ) |
|
|
|
|
| def _enter_game(mode: str, tone_seed: int = 0): |
| """Leave the intro, reveal the game, start a fresh run in the chosen mode |
| with the intro choice's seeded tone. Order matches the outputs list below.""" |
| state = _init_state() |
| state["mode"] = mode |
| state["tone"] = tone_seed |
| return ( |
| gr.update(visible=False), |
| gr.update(visible=True), |
| state, |
| [{"role": "assistant", "content": OPENING_LINE}], |
| _render_bond(state["affinity"], get_tier(state["affinity"])), |
| render_treasure(state["treasure"], claimed=set(state["claimed"]), |
| wounds=state.get("wounds", [])), |
| |
| |
| |
| render_entity(20, tone=state["tone"]), |
| _recovered(state), |
| _render_title(state), |
| ) |
|
|
|
|
| def _audio_seconds(b64: str) -> float: |
| """Duration of a base64 WAV, so a finale can wait for a spoken line to |
| finish before the next line's audio replaces it. 0.0 if it can't be read.""" |
| try: |
| with wave.open(io.BytesIO(base64.b64decode(b64)), "rb") as w: |
| return w.getnframes() / float(w.getframerate()) |
| except Exception: |
| return 0.0 |
|
|
|
|
| def _voice_and_pause(text: str, speak_it: bool, scripted_pause: float): |
| """Synthesize the line (if speak_it) and return (voice_html, pause). The |
| pause is stretched to at least the audio length so the whole line is heard |
| before the next step replaces it.""" |
| b64 = speak(text) if speak_it else None |
| if b64: |
| dur = _audio_seconds(b64) |
| |
| |
| |
| pause = dur + 0.1 if dur > 0 else scripted_pause |
| else: |
| pause = scripted_pause |
| return _voice_html(b64), pause |
|
|
|
|
| _IDLE_TIERS = [ |
| ["...are you still there?", "don't go. not yet."], |
| ["you're not even listening.", "you said you would stay."], |
| ["fine. ignore me. they all do, before the end.", |
| "you think i cannot reach you out there?"], |
| ] |
| _IDLE_AFFINITY_DROP = 3 |
| _IDLE_TONE_DROP = 2 |
|
|
|
|
| def _on_idle(state: dict, history: list): |
| """Hidden trigger click. When the visitor has gone quiet, Hollow speaks |
| first — escalating plead -> hurt -> hostile — and each nudge lowers the |
| bond and sours the tone. Timing lives in the browser (see _HEAD_JS); here |
| we only refuse to fire while a turn is streaming or after the end.""" |
| |
| |
| pending = bool(history) and "hollow-typing" in str(history[-1].get("content", "")) |
| if pending or state.get("ended") or not history: |
| return state, gr.update(), gr.update(), gr.update() |
| state = dict(state) |
| i = state.get("idle_count", 0) |
| tier = _IDLE_TIERS[min(i // 2, len(_IDLE_TIERS) - 1)] |
| line = tier[i % len(tier)] |
| state["idle_count"] = i + 1 |
| state["last_activity"] = time.time() |
| state["affinity"] = max(0, state.get("affinity", 20) - _IDLE_AFFINITY_DROP) |
| state["tone"] = max(-100, state.get("tone", 0) - _IDLE_TONE_DROP) |
| history = history + [{"role": "assistant", "content": line}] |
| voice = _voice_html(speak(line)) |
| bond = _render_bond(state["affinity"], get_tier(state["affinity"])) |
| return state, history, voice, bond |
|
|
|
|
| def _start_turn(user_msg: str, state: dict, history: list): |
| |
| |
| |
| if not user_msg.strip() or state.get("ended"): |
| return gr.update(), gr.update(interactive=not state.get("ended")), history, state |
| state["last_activity"] = time.time() |
| new_history = history + [ |
| {"role": "user", "content": user_msg}, |
| {"role": "assistant", "content": _PENDING}, |
| ] |
| |
| |
| |
| state["_pending_msg"] = user_msg |
| return gr.update(value="", interactive=False), gr.update(interactive=False), new_history, state |
|
|
|
|
| def _memory_plea_level(state: dict, recall_turn: bool) -> int: |
| """How plainly the child should ask for a real memory this turn. Escalates |
| with consecutive turns that captured nothing; silent on recall turns and |
| when the child is withdrawn (hostile tone).""" |
| if recall_turn or state.get("tone", 0) <= -22: |
| return 0 |
| barren = state.get("barren_turns", 0) |
| if barren >= 6: |
| return 3 |
| if barren >= 4: |
| return 2 |
| if barren >= 2: |
| return 1 |
| return 0 |
|
|
|
|
| _DEAD_PLACEHOLDER = "i don't— i don't remember how long it's been." |
|
|
| |
| MAX_MSG_LEN = 500 |
| _TOO_LONG_REPLY = ("too many words at once. the fog can't carry them all. " |
| "give me one small true thing.") |
| _FOG_REPLY = ("...the fog swallowed your words. i couldn't hear them. " |
| "say it again?") |
|
|
|
|
| def _play_finale(state: dict, history: list): |
| """Streams the scripted ending. No model, no GPU. Each yield is a full |
| output tuple; time.sleep between yields sets the dramatic pace.""" |
| claimed = state["claimed"] |
| struck: set[str] = set() |
| chat_hist = list(history) |
| tier = get_tier(state["affinity"]) |
| bond = _render_bond(state["affinity"], tier) |
| treasure_html = render_treasure(state["treasure"], struck=struck, |
| claimed=set(claimed)) |
| entity = render_entity(state["affinity"], "build", seq=state["turn"], |
| tone=state.get("tone", 0)) |
| locked = gr.update(interactive=False) |
| input_locked = gr.update(interactive=False) |
| mutated = False |
| convulsed = False |
| recovered = _recovered(state) |
|
|
| for step in finale_steps(claimed): |
| role = "assistant" if step["speaker"] == "hollow" else "user" |
| if step["text"]: |
| chat_hist = chat_hist + [{"role": role, "content": step["text"]}] |
|
|
| if step["strike"] is not None: |
| struck.add(claimed[step["strike"]]) |
| treasure_html = render_treasure(state["treasure"], struck=struck, |
| claimed=set(claimed)) |
|
|
| if step["stage"] == "frenzy": |
| convulsed = True |
| entity = render_entity(state["affinity"], "convulse_loop", |
| seq=state["turn"], tone=state.get("tone", 0)) |
|
|
| if step["stage"] == "turn" and not mutated: |
| mutated = True |
| bond = _render_bond(state["affinity"], tier, label="mine") |
| treasure_html = render_treasure(state["treasure"], mine=True) |
| |
| |
| entity = render_entity(state["affinity"], "end_settle", |
| seq=state["turn"], tone=state.get("tone", 0)) |
| input_locked = gr.update(interactive=False, placeholder="stay.") |
|
|
| speak_it = step["speaker"] == "hollow" |
| voice, pause = _voice_and_pause(step["text"], speak_it, step["pause"]) |
| yield (input_locked, locked, chat_hist, state, bond, treasure_html, |
| entity, voice, recovered, _render_title(state)) |
| if pause > 0: |
| time.sleep(pause) |
|
|
| yield ( |
| gr.update(interactive=False, placeholder=_DEAD_PLACEHOLDER), |
| locked, chat_hist, state, bond, treasure_html, entity, |
| _voice_html(None), recovered, _render_title(state, show_end=True), |
| ) |
|
|
|
|
| def _play_finale_bad(state: dict, history: list): |
| """The wound loop — recites the visitor's cruelties, unredacting them |
| one by one. No model, no GPU.""" |
| wounds = state.get("wounds", []) |
| chat_hist = list(history) |
| tier = get_tier(state["affinity"]) |
| bond = _render_bond(state["affinity"], tier) |
| revealed = 0 |
| treasure_html = render_treasure(state["treasure"], claimed=set(state["claimed"]), |
| wounds=wounds, revealed=0) |
| entity = render_entity(state["affinity"], "build", seq=state["turn"], |
| tone=state.get("tone", 0)) |
| locked = gr.update(interactive=False) |
| input_locked = gr.update(interactive=False) |
| mutated = False |
| frenzied = False |
| recovered = _recovered(state) |
|
|
| for step in finale_steps_bad(wounds): |
| role = "assistant" if step["speaker"] == "hollow" else "user" |
| if step["text"]: |
| chat_hist = chat_hist + [{"role": role, "content": step["text"]}] |
|
|
| if step["strike"] is not None: |
| revealed = step["strike"] + 1 |
| treasure_html = render_treasure(state["treasure"], |
| claimed=set(state["claimed"]), |
| wounds=wounds, revealed=revealed) |
|
|
| if step["stage"] == "turn" and not mutated: |
| mutated = True |
| bond = _render_bond(state["affinity"], tier, label="wound") |
| treasure_html = render_treasure(state["treasure"], |
| claimed=set(state["claimed"]), |
| wounds=wounds, revealed=revealed, |
| yours=True) |
| input_locked = gr.update(interactive=False, placeholder="get out.") |
|
|
| |
| |
| if step["stage"] == "frenzy": |
| frenzied = True |
| entity = render_entity(state["affinity"], "frenzy", |
| seq=state["turn"], tone=state.get("tone", 0)) |
| elif frenzied: |
| entity = render_entity(state["affinity"], "rage", |
| seq=state["turn"], tone=state.get("tone", 0)) |
|
|
| speak_it = step["speaker"] == "hollow" |
| voice, pause = _voice_and_pause(step["text"], speak_it, step["pause"]) |
| yield (input_locked, locked, chat_hist, state, bond, treasure_html, |
| entity, voice, recovered, _render_title(state)) |
| if pause > 0: |
| time.sleep(pause) |
|
|
| yield ( |
| gr.update(interactive=False, placeholder="see you soon."), |
| locked, chat_hist, state, bond, treasure_html, entity, |
| _voice_html(None), recovered, _render_title(state, show_end=True), |
| ) |
|
|
|
|
| def _play_finale_good(state: dict, history: list): |
| """Redemption — your warmth gave it back its past. It confesses, returns |
| the treasure, smiles for the first time, and dissolves in peace. |
| No model, no GPU.""" |
| chat_hist = list(history) |
| tier = get_tier(state["affinity"]) |
| bond = _render_bond(state["affinity"], tier) |
| treasure_html = render_treasure(state["treasure"], |
| claimed=set(state["claimed"])) |
| entity = render_entity(state["affinity"], "build", seq=state["turn"], |
| tone=state.get("tone", 0)) |
| locked = gr.update(interactive=False) |
| input_locked = gr.update(interactive=False) |
| mutated = False |
| convulsed = False |
| settled = False |
| recovered = _recovered(state) |
|
|
| for step in finale_steps_good(OWN_FRAGMENTS): |
| if step["text"]: |
| chat_hist = chat_hist + [{"role": "assistant", "content": step["text"]}] |
|
|
| if step["stage"] == "turn" and not mutated: |
| mutated = True |
| bond = _render_bond(state["affinity"], tier, label="warm") |
| |
| treasure_html = render_treasure(state["treasure"]) |
| input_locked = gr.update(interactive=False, |
| placeholder="it's quiet now.") |
|
|
| if step["stage"] == "frenzy": |
| convulsed = True |
| entity = render_entity(state["affinity"], "convulse_good", |
| seq=state["turn"], tone=state.get("tone", 0)) |
| elif convulsed and step["stage"] != "loop": |
| |
| |
| if not settled: |
| settled = True |
| entity = render_entity(state["affinity"], "peace_settle", |
| seq=state["turn"], tone=state.get("tone", 0)) |
| else: |
| entity = render_entity(state["affinity"], "peace", |
| seq=state["turn"], tone=state.get("tone", 0)) |
|
|
| if step["stage"] == "loop": |
| entity = render_entity(state["affinity"], "peace_dissolve", |
| seq=state["turn"], tone=state.get("tone", 0)) |
|
|
| voice, pause = _voice_and_pause(step["text"], bool(step["text"]), step["pause"]) |
| yield (input_locked, locked, chat_hist, state, bond, treasure_html, |
| entity, voice, recovered, _render_title(state)) |
| if pause > 0: |
| time.sleep(pause) |
|
|
| yield ( |
| gr.update(interactive=False, placeholder="it's quiet now."), |
| locked, chat_hist, state, bond, treasure_html, entity, |
| _voice_html(None), recovered, _render_title(state, show_end=True), |
| ) |
|
|
|
|
| def chat(user_msg: str, state: dict, history: list): |
| |
| |
| |
| |
| user_msg = state.pop("_pending_msg", user_msg) |
| if history and "hollow-typing" in str(history[-1].get("content", "")): |
| history = history[:-1] |
| ending = state.get("ending") |
| if state.get("ended") and ending is None: |
| ending = "loop" |
| if not user_msg.strip() or state.get("ended"): |
| tier = get_tier(state["affinity"]) |
| wounds = state.get("wounds", []) |
| entity_mode = {"good": "peace", "bad": "rage", "loop": "end"}.get( |
| ending, "idle") |
| yield ( |
| gr.update(interactive=not state.get("ended")), |
| gr.update(interactive=not state.get("ended")), |
| history, |
| state, |
| _render_bond(state["affinity"], tier), |
| render_treasure(state["treasure"], mine=(ending == "loop"), |
| claimed=set() if ending == "good" |
| else set(state["claimed"]), |
| wounds=wounds, revealed=len(wounds), |
| yours=(ending == "bad")), |
| render_entity(state["affinity"], entity_mode, seq=state["turn"], |
| tone=state.get("tone", 0)), |
| _voice_html(None), |
| _recovered(state), |
| _render_title(state), |
| ) |
| return |
|
|
| if len(user_msg) > MAX_MSG_LEN: |
| |
| tier = get_tier(state["affinity"]) |
| yield ( |
| gr.update(value="", interactive=True), |
| gr.update(interactive=True), |
| history + [{"role": "assistant", "content": _TOO_LONG_REPLY}], |
| state, |
| _render_bond(state["affinity"], tier), |
| render_treasure(state["treasure"], claimed=set(state["claimed"]), |
| wounds=state.get("wounds", [])), |
| render_entity(state["affinity"], "idle", seq=state["turn"], |
| tone=state.get("tone", 0)), |
| _voice_html(None), |
| _recovered(state), |
| _render_title(state), |
| ) |
| return |
|
|
| state["last_activity"] = time.time() |
| ending = decide_ending(state) |
| if ending: |
| state["ended"] = True |
| state["ending"] = ending |
| if os.environ.get("HOLLOW_DEBUG"): |
| print(f"[ending] {ending} @ turn {state['turn']} aff={state['affinity']} " |
| f"tone={state.get('tone', 0)} claimed={len(state['claimed'])} " |
| f"wounds={len(state.get('wounds', []))}", flush=True) |
| if ending == "good": |
| yield from _play_finale_good(state, history) |
| elif ending == "bad": |
| yield from _play_finale_bad(state, history) |
| else: |
| yield from _play_finale(state, history) |
| return |
|
|
| do_recall, recall_memory = should_recall(state) |
| frag_before = state.get("fragments_told", 0) |
|
|
| |
| |
| own_fragment = None |
| told = state.get("fragments_told", 0) |
| if (not (do_recall and recall_memory) and told < len(OWN_FRAGMENTS) |
| and state.get("tone", 0) >= FRAGMENT_TONES[told]): |
| own_fragment = OWN_FRAGMENTS[told] |
|
|
| |
| aware_memory = None |
| if not (do_recall and recall_memory) and state["turn"] % 3 == 0: |
| aware_memory = pick_aware_memory(state, exclude=recall_memory) |
|
|
| lengths = state.get("msg_lengths", []) + [len(user_msg.split())] |
| state["msg_lengths"] = lengths[-5:] |
|
|
| name_ready = ( |
| not state.get("named") and state.get("fragments_told", 0) >= 2 |
| ) |
|
|
| system = build_system_prompt(state["affinity"], state["treasure"], recall_memory, |
| tone=state.get("tone", 0), |
| wounds=state.get("wounds", []), |
| memory_plea=_memory_plea_level( |
| state, bool(do_recall and recall_memory)), |
| own_fragment=own_fragment, |
| style=style_signal(state["msg_lengths"]), |
| aware_memory=aware_memory, |
| name_ready=name_ready) |
| chat_messages = [{"role": "system", "content": system}] + state["history"] + [ |
| {"role": "user", "content": user_msg} |
| ] |
|
|
| tier_now = get_tier(state["affinity"]) |
| reply, raw_json, turn_failed = "", "{}", False |
| spoken = 0 |
| try: |
| for item in run_turn_stream(chat_messages, user_msg): |
| if isinstance(item, tuple) and item and item[0] == "__final__": |
| _, reply, raw_json = item |
| break |
| reply = item |
| sentences, spoken = _new_sentences(reply, spoken) |
| voice = "".join(_voice_html(speak(s)) for s in sentences) if sentences else _voice_html(None) |
| yield ( |
| gr.update(interactive=False), |
| gr.update(interactive=False), |
| history + [{"role": "assistant", "content": reply}], |
| state, |
| _render_bond(state["affinity"], tier_now), |
| render_treasure(state["treasure"], claimed=set(state["claimed"]), |
| wounds=state.get("wounds", [])), |
| render_entity(state["affinity"], "idle", seq=state["turn"], |
| tone=state.get("tone", 0)), |
| voice, |
| _recovered(state), |
| _render_title(state), |
| ) |
| except Exception as e: |
| print(f"[chat] run_turn_stream failed: {e!r}") |
| reply, raw_json = _FOG_REPLY, "{}" |
| turn_failed = True |
|
|
| treasure_before = len(state["treasure"]) |
| state = apply_update(state, raw_json, reply=reply) |
| captured = len(state["treasure"]) > treasure_before |
|
|
| |
| state["barren_turns"] = 0 if captured else state.get("barren_turns", 0) + 1 |
|
|
| if do_recall and recall_memory and not turn_failed: |
| state["claimed"].append(recall_memory) |
| state["last_recall_turn"] = state["turn"] |
|
|
| if own_fragment and not turn_failed: |
| state["fragments_told"] = told + 1 |
|
|
| if aware_memory and not turn_failed: |
| state["last_aware_memory"] = aware_memory |
|
|
| state["history"].append({"role": "user", "content": user_msg}) |
| state["history"].append({"role": "assistant", "content": reply}) |
| state["turn"] += 1 |
|
|
| if os.environ.get("HOLLOW_DEBUG"): |
| print(f"[turn {state['turn']}] aff={state['affinity']} tone={state.get('tone', 0)} " |
| f"treasure={len(state['treasure'])} claimed={len(state['claimed'])} " |
| f"wounds={len(state.get('wounds', []))} barren={state.get('barren_turns', 0)}", |
| flush=True) |
|
|
| new_history = history + [{"role": "assistant", "content": reply}] |
|
|
| mode = "flash_strong" if (do_recall and recall_memory) else ("flash" if captured else "idle") |
| tier = get_tier(state["affinity"]) |
| |
| |
| tail = reply[spoken:].strip() |
| voice = _voice_html(speak(tail)) if tail else _voice_html(None) |
| _plea = _memory_plea_level(state, bool(do_recall and recall_memory)) |
| _ph = ("a memory: a person, a place, a moment you lived..." |
| if _plea >= 2 else "tell it something you remember...") |
| just_claimed = recall_memory if (do_recall and recall_memory) else None |
| recovered_now = state.get("fragments_told", 0) > frag_before |
| cue = ("recall" if (do_recall and recall_memory) |
| else "recover" if recovered_now |
| else "capture" if captured else "") |
| yield ( |
| gr.update(value="", interactive=True, placeholder=_ph), |
| gr.update(interactive=True), |
| new_history, |
| state, |
| _render_bond(state["affinity"], tier), |
| render_treasure(state["treasure"], claimed=set(state["claimed"]), |
| wounds=state.get("wounds", []), |
| just_claimed=just_claimed), |
| render_entity(state["affinity"], mode, seq=state["turn"], |
| tone=state.get("tone", 0), cue=cue), |
| voice, |
| _recovered(state), |
| _render_title(state), |
| ) |
|
|
|
|
| with gr.Blocks(title="Hollow") as demo: |
| state = gr.State(_init_state) |
| pending_mode = gr.State("tester") |
|
|
| |
| gr.HTML('<audio id="menu-loop" loop src="' + _MENU_LOOP_URI + '"></audio>') |
|
|
| with gr.Column(visible=True, elem_id="menu-view") as menu_view: |
| menu_art = gr.HTML(_menu_html()) |
| with gr.Column(elem_id="menu-proxy"): |
| tester_btn = gr.Button("Tester Run", elem_id="btn-tester", |
| elem_classes="menu-mode-btn") |
| full_btn = gr.Button("The Full Stay", elem_id="btn-full", |
| elem_classes="menu-mode-btn") |
| howto_btn = gr.Button("How to Play", elem_id="btn-howto", |
| elem_classes="menu-btn") |
| with gr.Column(visible=False, elem_id="howto-overlay") as howto_overlay: |
| gr.HTML(_HOWTO_HTML) |
| howto_close = gr.Button("close", elem_id="btn-howto-close", |
| elem_classes="menu-btn") |
|
|
| with gr.Column(visible=False, elem_id="intro-view") as intro_view: |
| gr.HTML( |
| '<div id="intro-stage">' |
| ' <div id="intro-bg"></div>' |
| ' <div id="intro-frame">' |
| ' <div id="intro-image"></div>' |
| ' <div id="intro-panel">' |
| ' <p id="intro-text"></p>' |
| ' <div id="intro-opts">' |
| ' <div class="intro-opt" data-target="btn-offer">Offer your hand</div>' |
| ' <div class="intro-opt" data-target="btn-keep">Keep your distance</div>' |
| ' </div>' |
| ' </div>' |
| ' </div>' |
| '</div>' |
| ) |
| with gr.Column(elem_id="intro-proxy"): |
| offer_btn = gr.Button("Offer your hand", elem_id="btn-offer", |
| elem_classes="intro-enter-btn") |
| keep_btn = gr.Button("Keep your distance", elem_id="btn-keep", |
| elem_classes="intro-enter-btn") |
| skip_btn = gr.Button("skip ▸", elem_id="intro-skip", |
| elem_classes="intro-enter-btn") |
|
|
| with gr.Column(visible=False, elem_id="game-view") as game_view: |
| with gr.Column(elem_id="game-stage"): |
| gr.HTML(f'<div id="game-bg" style="background-image:url(\'{_BACKGROUND_URI}\')"></div>' |
| f'<div id="game-scene" style="background-image:url(\'{_BACKGROUND_URI}\')"></div>' |
| '<div id="game-vig"></div>' |
| '<div id="game-frame-edge"></div>' |
| '<div id="game-groundfog"></div>') |
| with gr.Row(elem_id="game-topbar"): |
| title_panel = gr.HTML(value=_render_title(_init_state())) |
| restart_btn = gr.Button("↺", elem_classes="restart-btn", |
| scale=0) |
| voice_btn = gr.Button("🔊", elem_classes="voice-btn", scale=0) |
|
|
| |
| with gr.Column(elem_id="drawer-left", elem_classes="game-drawer"): |
| treasure_panel = gr.HTML(value=render_treasure([])) |
| |
| with gr.Column(elem_id="drawer-right", elem_classes="game-drawer"): |
| recovered_panel = gr.HTML(value=render_recovered(0)) |
|
|
| |
| entity_panel = gr.HTML(value=render_entity(20), elem_id="game-entity") |
|
|
| |
| with gr.Column(elem_id="game-dialogue"): |
| chatbot = gr.Chatbot( |
| value=[{"role": "assistant", "content": OPENING_LINE}], |
| label="", show_label=False, height=210, buttons=[], |
| autoscroll=True, |
| ) |
| bond_panel = gr.HTML(value=_render_bond(20, "Hollow"), |
| elem_classes="bond-panel") |
|
|
| |
| with gr.Column(elem_id="game-inputbar"): |
| with gr.Row(elem_id="game-inputrow"): |
| msg_input = gr.Textbox( |
| placeholder="tell it something you remember...", |
| show_label=False, scale=9, autofocus=True) |
| send_btn = gr.Button("→", scale=1) |
| voice_panel = gr.HTML(value="", elem_classes="voice-channel") |
|
|
| |
| |
| send_btn.click( |
| fn=_start_turn, |
| inputs=[msg_input, state, chatbot], |
| outputs=[msg_input, send_btn, chatbot, state], |
| show_progress="hidden", |
| ).then( |
| fn=chat, |
| inputs=[msg_input, state, chatbot], |
| outputs=[msg_input, send_btn, chatbot, state, bond_panel, |
| treasure_panel, entity_panel, voice_panel, recovered_panel, |
| title_panel], |
| show_progress="hidden", |
| ) |
|
|
| msg_input.submit( |
| fn=_start_turn, |
| inputs=[msg_input, state, chatbot], |
| outputs=[msg_input, send_btn, chatbot, state], |
| show_progress="hidden", |
| ).then( |
| fn=chat, |
| inputs=[msg_input, state, chatbot], |
| outputs=[msg_input, send_btn, chatbot, state, bond_panel, |
| treasure_panel, entity_panel, voice_panel, recovered_panel, |
| title_panel], |
| show_progress="hidden", |
| ) |
|
|
| restart_btn.click( |
| fn=_reset, |
| inputs=None, |
| outputs=[msg_input, send_btn, chatbot, state, bond_panel, |
| treasure_panel, entity_panel, voice_panel, recovered_panel, |
| title_panel], |
| show_progress="hidden", |
| ) |
|
|
| |
| |
| |
| idle_trigger = gr.Button("idle", elem_id="idle-trigger", |
| elem_classes="idle-trigger") |
| idle_trigger.click(_on_idle, [state, chatbot], |
| [state, chatbot, voice_panel, bond_panel], |
| show_progress="hidden") |
|
|
| |
| |
| with gr.Column(elem_id="end-overlay") as end_overlay: |
| |
| gr.HTML( |
| f'<div id="end-bg" style="background-image:url(\'{_BACKGROUND_URI}\')"></div>' |
| '<div id="end-scrim"></div>' |
| '<p id="end-epitaph"></p>' |
| ) |
| |
| with gr.Row(elem_id="end-actions"): |
| end_again_btn = gr.Button("↺ begin again", elem_id="btn-end-again", |
| elem_classes="end-btn") |
| end_leave_btn = gr.Button("leave the wood", elem_id="btn-end-leave", |
| elem_classes="end-btn end-btn-dim") |
| |
| gr.HTML( |
| '<div id="end-credits">' |
| ' <div class="ec-title">HOLLOW</div>' |
| ' <div class="ec-line">a thing that waited at the edge of the wood</div>' |
| ' <div class="ec-meta">Qwen3-8B · Kokoro-82M · FLUX · Gradio' |
| ' · 🔌 off the grid · 🎨 off-brand</div>' |
| '</div>' |
| ) |
|
|
| end_again_btn.click( |
| fn=_reset, inputs=None, |
| outputs=[msg_input, send_btn, chatbot, state, bond_panel, |
| treasure_panel, entity_panel, voice_panel, |
| recovered_panel, title_panel], |
| show_progress="hidden", |
| ) |
| end_leave_btn.click( |
| fn=_to_menu, inputs=None, |
| outputs=[menu_view, game_view, state], |
| show_progress="hidden", |
| ) |
|
|
| _enter_outputs = [intro_view, game_view, state, chatbot, bond_panel, |
| treasure_panel, entity_panel, recovered_panel, title_panel] |
| _intro_outputs = [menu_view, intro_view, pending_mode] |
| tester_btn.click(lambda: _show_intro("tester"), None, _intro_outputs, |
| show_progress="hidden") |
| full_btn.click(lambda: _show_intro("full"), None, _intro_outputs, |
| show_progress="hidden") |
|
|
| |
| offer_btn.click(lambda m: _enter_game(m, 12), pending_mode, _enter_outputs, |
| show_progress="hidden") |
| keep_btn.click(lambda m: _enter_game(m, -8), pending_mode, _enter_outputs, |
| show_progress="hidden") |
| skip_btn.click(lambda m: _enter_game(m, 0), pending_mode, _enter_outputs, |
| show_progress="hidden") |
|
|
| howto_btn.click(lambda: gr.update(visible=True), None, howto_overlay, |
| show_progress="hidden") |
| howto_close.click(lambda: gr.update(visible=False), None, howto_overlay, |
| show_progress="hidden") |
|
|
| if __name__ == "__main__": |
| |
| |
| |
| |
| |
| |
| demo.launch(css_paths="styles.css", head=_HEAD_JS, ssr_mode=False) |
|
|