| """ |
| app.py — Omniscient Reader: Scenario Simulator |
| ================================================ |
| Main Gradio application entry point. |
| |
| Connects the Dokkaebi AI (Qwen 2.5 14B via Modal), the game engine, |
| scenario definitions, and the custom dark-theme game UI. |
| |
| Part of the ORV (Omniscient Reader's Viewpoint) Scenario Simulator. |
| Build Small Hackathon 2026 — Track 2: Thousand Token Wood. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import os |
| from urllib.parse import quote |
| import gradio as gr |
|
|
| from game_engine import GameState, process_turn |
| from scenarios import SCENARIOS, get_scenario_announcement_text, get_next_scenario |
| from prompts import build_prompt, build_intro_prompt |
| from modal_client import call_dokkaebi, call_dokkaebi_cinematic |
|
|
| |
| |
| |
|
|
| _ASSET_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets") |
| |
| _CACHE_BUST = int(os.path.getmtime(_ASSET_DIR)) |
|
|
| SCENE_IMAGES = { |
| "Subway": "seoul_subway.png", |
| "Seoul Street": "seoul_street.png", |
| "Destroyed Office": "destroyed_office.png", |
| "Han River Bridge": "han_river_bridge.png", |
| "Reader Noticed": "reader_noticed.png", |
| } |
|
|
| def _asset_path(filename: str) -> str: |
| return os.path.join(_ASSET_DIR, filename) |
|
|
| def _asset_url(filename: str) -> str: |
| """Return a Gradio file URL that works on Windows + HF Spaces. |
| |
| Gradio 6.x serves allowed files at /gradio_api/file=<abs_path>. |
| We encode path segments so spaces become %20. |
| """ |
| abs_path = os.path.abspath(os.path.join(_ASSET_DIR, filename)) |
| fwd = abs_path.replace("\\", "/") |
| parts = fwd.split("/") |
| encoded = "/".join(quote(p, safe='') if p else p for p in parts) |
| encoded = encoded.replace("%3A", ":", 1) |
| return f"/gradio_api/file={encoded}?v={_CACHE_BUST}" |
|
|
|
|
| |
| |
| |
|
|
| def _read_file(path: str) -> str: |
| with open(os.path.join(os.path.dirname(__file__), path), "r", encoding="utf-8") as f: |
| return f.read() |
|
|
| _STYLE_CSS = _read_file("style.css") |
| _ANIMATIONS_CSS = _read_file("animations.css") |
| _GAME_JS = _read_file("game.js") |
|
|
|
|
| |
| |
| |
|
|
|
|
| def render_start_screen() -> str: |
| """Dramatic ORV manhwa-accurate start screen.""" |
| return f""" |
| <style> |
| #start-screen {{ |
| background-image: url('{_asset_url('title_banner.png')}') !important; |
| background-size: cover !important; |
| background-position: center center !important; |
| background-repeat: no-repeat !important; |
| background-color: #030305 !important; |
| }} |
| </style> |
| <div class="start-screen" id="start-screen" style=" |
| position: relative; |
| min-height: 100vh; |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| justify-content: center; |
| overflow: hidden;"> |
| |
| <!-- Multi-layer dark overlay for readability --> |
| <div style="position:absolute;inset:0; |
| background: linear-gradient( |
| 180deg, |
| rgba(3,3,5,0.50) 0%, |
| rgba(3,3,5,0.60) 35%, |
| rgba(3,3,5,0.82) 70%, |
| rgba(3,3,5,0.96) 100% |
| );"></div> |
| |
| <!-- Subtle vignette edges --> |
| <div style="position:absolute;inset:0; |
| background: radial-gradient(ellipse at 50% 50%, transparent 40%, rgba(3,3,5,0.7) 100%); |
| pointer-events:none;"></div> |
| |
| <!-- Top crimson accent line --> |
| <div style="position:absolute;top:0;left:0;right:0;height:2px; |
| background:linear-gradient(90deg,transparent,#7a1a22,#904858,#7a1a22,transparent);"></div> |
| |
| <div class="start-content" style="position:relative;z-index:2;text-align:center;padding:0 40px;max-width:760px;"> |
| |
| <!-- Label above title --> |
| <div style=" |
| font-family:'Cinzel',serif; |
| font-size:10px; |
| color:#904858; |
| letter-spacing:6px; |
| text-transform:uppercase; |
| margin-bottom:18px; |
| opacity:0.85; |
| ">[ System Notification — Star Stream Access Granted ]</div> |
| |
| <!-- Main Title --> |
| <div style=" |
| font-family:'Cinzel Decorative',serif; |
| font-size:clamp(2.2em,5.5vw,4em); |
| font-weight:900; |
| color:#d8cbb0; |
| letter-spacing:0.18em; |
| text-transform:uppercase; |
| text-shadow: |
| 0 0 60px rgba(144,72,88,0.35), |
| 0 0 120px rgba(122,26,34,0.15), |
| 0 3px 12px rgba(0,0,0,0.95); |
| margin-bottom:6px; |
| line-height:1.1; |
| ">Omniscient Reader</div> |
| |
| <!-- Subtitle --> |
| <div style=" |
| font-family:'Cinzel',serif; |
| font-size:0.95em; |
| color:#904858; |
| letter-spacing:0.55em; |
| text-transform:uppercase; |
| opacity:0.75; |
| margin-bottom:28px; |
| ">Scenario Simulator</div> |
| |
| <!-- Divider --> |
| <div style="width:360px;margin:0 auto 28px;height:1px; |
| background:linear-gradient(90deg,transparent,#5a1828,#904858,#5a1828,transparent);"></div> |
| |
| <!-- Tagline --> |
| <div style=" |
| font-family:'IM Fell English',Georgia,serif; |
| font-size:1em; |
| font-style:italic; |
| color:#a09078; |
| line-height:2.2; |
| margin-bottom:36px; |
| text-shadow:0 2px 6px rgba(0,0,0,0.9); |
| "> |
| You have read the novel 3,149 times.<br> |
| The Dokkaebi knows you know everything.<br> |
| <span style="color:#c06878;">Reality has been redesigned — specifically for you.</span> |
| </div> |
| |
| <!-- Portraits row --> |
| <div style="display:flex;justify-content:center;gap:20px;margin-bottom:36px;"> |
| |
| <!-- Kim Dokja --> |
| <div style="text-align:center;"> |
| <div style=" |
| width:88px;height:114px; |
| border:1px solid #5a1828; |
| position:relative;overflow:hidden; |
| box-shadow:0 8px 32px rgba(0,0,0,0.9),0 0 0 1px rgba(144,72,88,0.15); |
| "> |
| <div style="position:absolute;top:0;left:0;width:12px;height:12px;border-top:2px solid #904858;border-left:2px solid #904858;z-index:2;"></div> |
| <div style="position:absolute;bottom:0;right:0;width:12px;height:12px;border-bottom:2px solid #904858;border-right:2px solid #904858;z-index:2;"></div> |
| <img src="{_asset_url('dokja_portrait.png')}" alt="Kim Dokja" |
| style="width:100%;height:100%;object-fit:cover;object-position:top; |
| filter:contrast(1.15) brightness(0.8) saturate(0.8);"> |
| <div style="position:absolute;inset:0;background:linear-gradient(to top,rgba(6,5,8,0.6) 0%,transparent 50%);"></div> |
| </div> |
| <div style="font-family:'Cinzel',serif;color:#904858;font-size:8px;margin-top:7px;letter-spacing:2.5px;text-transform:uppercase;">Incarnation</div> |
| </div> |
| |
| <!-- Dokkaebi --> |
| <div style="text-align:center;"> |
| <div style=" |
| width:88px;height:114px; |
| border:1px solid #5a1828; |
| position:relative;overflow:hidden; |
| box-shadow:0 8px 32px rgba(0,0,0,0.9),0 0 0 1px rgba(144,72,88,0.15); |
| "> |
| <div style="position:absolute;top:0;left:0;width:12px;height:12px;border-top:2px solid #904858;border-left:2px solid #904858;z-index:2;"></div> |
| <div style="position:absolute;bottom:0;right:0;width:12px;height:12px;border-bottom:2px solid #904858;border-right:2px solid #904858;z-index:2;"></div> |
| <img src="{_asset_url('dokkaebi_character.png')}" alt="Dokkaebi" |
| style="width:100%;height:100%;object-fit:cover; |
| filter:contrast(1.15) brightness(0.8) saturate(0.75);"> |
| <div style="position:absolute;inset:0;background:linear-gradient(to top,rgba(6,5,8,0.6) 0%,transparent 50%);"></div> |
| </div> |
| <div style="font-family:'Cinzel',serif;color:#904858;font-size:8px;margin-top:7px;letter-spacing:2.5px;text-transform:uppercase;">Dokkaebi</div> |
| </div> |
| |
| <!-- Yoo Sangah --> |
| <div style="text-align:center;"> |
| <div style=" |
| width:88px;height:114px; |
| border:1px solid #5a1828; |
| position:relative;overflow:hidden; |
| box-shadow:0 8px 32px rgba(0,0,0,0.9),0 0 0 1px rgba(144,72,88,0.15); |
| "> |
| <div style="position:absolute;top:0;left:0;width:12px;height:12px;border-top:2px solid #904858;border-left:2px solid #904858;z-index:2;"></div> |
| <div style="position:absolute;bottom:0;right:0;width:12px;height:12px;border-bottom:2px solid #904858;border-right:2px solid #904858;z-index:2;"></div> |
| <img src="{_asset_url('yoo_sangah_portrait.png')}" alt="Jung Heewon" |
| style="width:100%;height:100%;object-fit:cover;object-position:top; |
| filter:contrast(1.15) brightness(0.8) saturate(0.75);"> |
| <div style="position:absolute;inset:0;background:linear-gradient(to top,rgba(6,5,8,0.6) 0%,transparent 50%);"></div> |
| </div> |
| <div style="font-family:'Cinzel',serif;color:#904858;font-size:8px;margin-top:7px;letter-spacing:2.5px;text-transform:uppercase;">Jung Heewon</div> |
| </div> |
| |
| <!-- Jung Heewon --> |
| <div style="text-align:center;"> |
| <div style=" |
| width:88px;height:114px; |
| border:1px solid #5a1828; |
| position:relative;overflow:hidden; |
| box-shadow:0 8px 32px rgba(0,0,0,0.9),0 0 0 1px rgba(144,72,88,0.15); |
| "> |
| <div style="position:absolute;top:0;left:0;width:12px;height:12px;border-top:2px solid #904858;border-left:2px solid #904858;z-index:2;"></div> |
| <div style="position:absolute;bottom:0;right:0;width:12px;height:12px;border-bottom:2px solid #904858;border-right:2px solid #904858;z-index:2;"></div> |
| <img src="{_asset_url('jung_heewon_portrait.png')}" alt="Lee Jihye" |
| style="width:100%;height:100%;object-fit:cover;object-position:top; |
| filter:contrast(1.15) brightness(0.8) saturate(0.75);"> |
| <div style="position:absolute;inset:0;background:linear-gradient(to top,rgba(6,5,8,0.6) 0%,transparent 50%);"></div> |
| </div> |
| <div style="font-family:'Cinzel',serif;color:#904858;font-size:8px;margin-top:7px;letter-spacing:2.5px;text-transform:uppercase;">Lee Jihye</div> |
| </div> |
| |
| </div> |
| |
| <!-- Divider --> |
| <div style="width:260px;margin:0 auto 24px;height:1px; |
| background:linear-gradient(90deg,transparent,#5a1828,#904858,#5a1828,transparent);"></div> |
| |
| <!-- Attribution --> |
| <div style="font-family:'Cinzel',serif;color:#3a2832;font-size:9px;letter-spacing:3px;text-transform:uppercase;margin-bottom:28px;"> |
| Powered by Llama 3.1 · Build Small Hackathon 2026 |
| </div> |
| |
| <!-- Spacer so background fills screen (real button is position:fixed) --> |
| <div style="height:120px;"></div> |
| |
| </div> |
| </div> |
| """ |
|
|
|
|
| def render_scenario_header(state: dict) -> str: |
| """Top bar — Elder Scrolls quest log style.""" |
| scenario_num = state.get("scenario_num", 1) |
| scenario = SCENARIOS.get(scenario_num, SCENARIOS[1]) |
| name = scenario["name"] |
| subtitle = scenario.get("subtitle", "") |
| location = scenario.get("location", "") |
| difficulty = scenario.get("difficulty", "?") |
| turn = state.get("turn", 1) |
| max_turns = scenario.get("turns", 8) |
|
|
| diff_colors = {"F": "#90c060", "E": "#904858", "D": "#9b6a30", "C": "#c97040", |
| "B": "#c04030", "A": "#8b1a1a", "S": "#f0c840", "SS": "#cc3333"} |
| diff_color = diff_colors.get(difficulty, "#904858") |
|
|
| return f""" |
| <div class="scenario-header-inner"> |
| <div class="scenario-header-left"> |
| <span style="font-family:'Cinzel',serif;color:#6a3040;font-size:11px;letter-spacing:1px;">Main Scenario {scenario_num}</span> |
| </div> |
| <div class="scenario-header-center" style="text-align:center;"> |
| <div class="scenario-header-text" style="font-family:'Cinzel Decorative',serif;font-size:15px;font-weight:700;color:#f0c840;letter-spacing:4px;text-shadow:0 0 20px rgba(240,200,64,0.45);">âš” {name} âš”</div> |
| <div style="font-family:'Cinzel',serif;font-size:10px;color:#8a7050;letter-spacing:2px;margin-top:2px;">{subtitle} — {location}</div> |
| </div> |
| <div class="scenario-header-right" style="text-align:right;"> |
| <span style="font-family:'Cinzel',serif;color:{diff_color};font-size:11px;letter-spacing:1px;">Rank {difficulty}</span> |
| <span style="font-family:'Cinzel',serif;color:#5a4a35;font-size:11px;margin-left:14px;letter-spacing:1px;">Turn {turn} / {max_turns}</span> |
| </div> |
| </div> |
| """ |
|
|
|
|
| def render_player_panel(state: dict) -> str: |
| """Left panel — Elder Scrolls character sheet style.""" |
| hp = state.get("hp", 100) |
| max_hp = state.get("max_hp", 100) |
| hp_pct = max(0, min(100, (hp / max_hp) * 100)) if max_hp > 0 else 0 |
| coins = state.get("coins", 0) |
| titles = state.get("titles", []) |
| skills = state.get("skills", {}) |
| sponsor = state.get("sponsor") |
| debuffs = state.get("active_debuffs", []) |
| phase = state.get("current_phase", "Exploration") |
|
|
| titles_html = "".join( |
| f'<span class="title-badge">{t}</span>' for t in titles |
| ) if titles else f'<span style="color:#5a4a35;font-family:\'Cinzel\',serif;font-size:10px;font-style:italic;">No titles earned yet</span>' |
|
|
| if isinstance(skills, dict): |
| skills_parts = [] |
| for name, d in skills.items(): |
| cd = d.get("cooldown", 0) |
| opacity = "0.35" if cd > 0 else "1" |
| cd_txt = f" · CD:{cd}" if cd > 0 else "" |
| skills_parts.append( |
| f'<div class="skill-name" style="opacity:{opacity};margin-bottom:3px;">' |
| f"âš” {name}{cd_txt}" |
| f'</div>' |
| ) |
| skills_html = "".join(skills_parts) or f'<span style="color:#5a4a35;font-size:10px;font-style:italic;">None</span>' |
| else: |
| skills_html = "" |
|
|
| debuffs_html = "" |
| if debuffs: |
| debuff_badges = "".join( |
| f'<div style="background:rgba(139,26,26,0.15);border:1px solid #5a0808;' |
| f'color:#cc3333;padding:3px 8px;margin:2px 0;' |
| f'font-family:\'Cinzel\',serif;font-size:9px;letter-spacing:1px;">âš {d}</div>' |
| for d in debuffs |
| ) |
| debuffs_html = f""" |
| <div> |
| <div style="font-family:'Cinzel',serif;font-size:9px;letter-spacing:2px;color:#8b1a1a;text-transform:uppercase;margin-bottom:4px;">Active Afflictions</div> |
| {debuff_badges} |
| </div>""" |
|
|
| phase_colors = {"Combat": "#cc3333", "Safe Zone": "#90c060", "Exploration": "#904858"} |
| phase_color = phase_colors.get(phase, "#904858") |
| phase_icons = {"Combat": "⚔", "Safe Zone": "✦", "Exploration": "◈"} |
| phase_icon = phase_icons.get(phase, "â—ˆ") |
|
|
| sponsor_html = f""" |
| <div style="margin-top:6px;font-family:'Cinzel',serif;color:#904858;font-size:10px;letter-spacing:1px;text-shadow:0 0 8px rgba(201,168,76,0.3);"> |
| ✦ Sponsor: {sponsor} |
| </div> |
| """ if sponsor else "" |
|
|
| return f""" |
| <div style="display:flex;flex-direction:column;gap:12px;width:100%;"> |
| |
| <!-- Portrait with ornate corners --> |
| <div class="player-portrait"> |
| <img src="{_asset_url('dokja_portrait.png')}" alt="Kim Dokja"> |
| </div> |
| |
| <!-- Name + Phase --> |
| <div style="text-align:center;"> |
| <div style="font-family:'Cinzel Decorative',serif;color:#f0c840;font-size:15px;font-weight:700;text-transform:uppercase;letter-spacing:3px;text-shadow:0 0 16px rgba(240,200,64,0.4);"> |
| Kim Dokja |
| </div> |
| <div style="font-family:'Cinzel',serif;font-size:10px;color:#8a7050;letter-spacing:2px;margin-top:4px;"> |
| Incarnation · <span style="color:{phase_color};">{phase_icon} {phase}</span> |
| </div> |
| </div> |
| |
| <!-- Divider --> |
| <div style="height:1px;background:linear-gradient(90deg,transparent,#6a3040,transparent);"></div> |
| |
| <!-- HP Bar --> |
| <div class="stat-bar stat-bar-hp"> |
| <div class="stat-bar-label"><span>Vitality</span><span>{hp}/{max_hp}</span></div> |
| <div class="stat-bar-track"><div class="stat-bar-fill" style="width:{hp_pct}%"></div></div> |
| </div> |
| |
| <!-- Coins --> |
| <div style="font-family:'Cinzel',serif;color:#904858;font-size:13px;text-shadow:0 0 8px rgba(201,168,76,0.4);letter-spacing:1px;"> |
| â—ˆ {coins:,} Coins |
| </div> |
| |
| {debuffs_html} |
| |
| <!-- Divider --> |
| <div style="height:1px;background:linear-gradient(90deg,transparent,#3a1828,transparent);"></div> |
| |
| <!-- Titles --> |
| <div> |
| <div style="font-family:'Cinzel',serif;font-size:9px;letter-spacing:2px;color:#6a3040;text-transform:uppercase;margin-bottom:6px;">Titles Earned</div> |
| <div>{titles_html}</div> |
| </div> |
| |
| <!-- Skills --> |
| <div> |
| <div style="font-family:'Cinzel',serif;font-size:9px;letter-spacing:2px;color:#6a3040;text-transform:uppercase;margin-bottom:6px;">Combat Skills</div> |
| <div style="font-family:'Cinzel',serif;font-size:11px;color:#b8a07a;">{skills_html}</div> |
| </div> |
| |
| {sponsor_html} |
| </div> |
| """ |
|
|
|
|
|
|
|
|
| def render_meters(state: dict) -> str: |
| """Render meters in Elder Scrolls style.""" |
| meta = state.get("meta_exposure", 0) |
| stability = state.get("prob_stability", 100) |
| entertainment = state.get("entertainment", 0) |
|
|
| meta_class = "" |
| if meta >= 75: |
| meta_class = "meter-danger" |
| elif meta >= 50: |
| meta_class = "meter-pulse" |
|
|
| return f""" |
| <div class="meters-container"> |
| <!-- Divider --> |
| <div style="height:1px;background:linear-gradient(90deg,transparent,#3a1828,transparent);margin-bottom:8px;"></div> |
| |
| <!-- Meta Exposure --> |
| <div class="stat-bar stat-bar-meta {meta_class}"> |
| <div class="stat-bar-label"><span>âš Meta Exposure</span><span>{meta}%</span></div> |
| <div class="stat-bar-track"><div class="stat-bar-fill" style="width:{meta}%"></div></div> |
| </div> |
| |
| <!-- Probability Stability --> |
| <div class="stat-bar stat-bar-stability"> |
| <div class="stat-bar-label"><span>â—ˆ Probability</span><span>{stability}%</span></div> |
| <div class="stat-bar-track"><div class="stat-bar-fill" style="width:{stability}%"></div></div> |
| </div> |
| |
| <!-- Entertainment --> |
| <div style="text-align:center;margin-top:8px;font-family:'Cinzel',serif;font-size:10px;color:#5a4a35;letter-spacing:2px;"> |
| ✦ Entertainment {entertainment} |
| </div> |
| </div> |
| """ |
|
|
|
|
| def render_narrative(narrative: str, dokkaebi_comment: str, |
| reality_subversion: str = None) -> str: |
| """Main game area narrative — tome reading style.""" |
| subversion_html = "" |
| if reality_subversion: |
| subversion_html = f""" |
| <div style="margin:12px 24px;padding:12px 16px; |
| background:rgba(90,42,138,0.12);border-left:2px solid #5a2a8a; |
| font-family:'Cinzel',serif;color:#9b59b6;font-size:11px;letter-spacing:1px;"> |
| ⚠Probability Distortion — {reality_subversion} |
| </div> |
| """ |
|
|
| return f""" |
| <div class="narrative-container system-window" |
| style="overflow-y:auto;max-height:280px;min-height:100px;flex-shrink:0;" |
| > |
| <div class="narrative-text">{narrative}</div> |
| {subversion_html} |
| <div class="dokkaebi-text">{dokkaebi_comment}</div> |
| </div> |
| """ |
|
|
|
|
| def render_scene(state: dict) -> str: |
| """Scene background image — cinematic dark vignette.""" |
| scenario_num = state.get("scenario_num", 1) |
| scenario = SCENARIOS.get(scenario_num, SCENARIOS[1]) |
| scene_img = scenario.get("scene_image", "seoul_subway.png") |
|
|
| return f""" |
| <div style="width:100%;height:160px;overflow:hidden;margin-bottom:0; |
| background:url('{_asset_url(scene_img)}') center/cover no-repeat; |
| border-bottom:1px solid rgba(106,48,64,0.4); |
| position:relative;flex-shrink:0;" |
| > |
| <div style="position:absolute;inset:0;background:linear-gradient(to top,rgba(6,5,8,0.95) 0%,rgba(6,5,8,0.3) 50%,transparent 100%);"></div> |
| <div style="position:absolute;inset:0;background:linear-gradient(to right,rgba(6,5,8,0.4) 0%,transparent 30%,transparent 70%,rgba(6,5,8,0.4) 100%);"></div> |
| </div> |
| """ |
|
|
|
|
| def render_suggestions(suggestions: list) -> str: |
| """Three action suggestions — Elder Scrolls decision scroll.""" |
| if not suggestions or len(suggestions) < 3: |
| suggestions = ["Look around carefully", "Search for something useful", "Do something unexpected"] |
|
|
| buttons = "" |
| for i, sug in enumerate(suggestions): |
| roman = ["I", "II", "III", "IV", "V"][i] if i < 5 else str(i+1) |
| buttons += f""" |
| <button class="suggestion-btn" data-action="{sug}" |
| style="display:flex;align-items:flex-start;gap:10px;width:100%;text-align:left;margin:0 0 6px 0;padding:10px 14px; |
| background:rgba(18,12,20,0.7);border:1px solid #5a3040;border-left:2px solid #904050;color:#d0b0a0;cursor:pointer;transition:all 0.2s; |
| font-family:'IM Fell English',serif;font-style:italic;font-size:13px;line-height:1.5;" |
| onmouseover="this.style.borderColor='#c06070'; this.style.backgroundColor='rgba(40,18,28,0.8)'; this.style.color='#e0c0b0';" |
| onmouseout="this.style.borderColor='#5a3040'; this.style.backgroundColor='rgba(18,12,20,0.7)'; this.style.color='#d0b0a0';" |
| > |
| <span style="color:#904050;min-width:18px;font-size:10px;margin-top:1px;font-family:'Cinzel',serif;">{roman}.</span> |
| <span>{sug}</span> |
| </button> |
| """ |
|
|
| return f'''<div style="width:100%;"> |
| <div class="suggestion-section-label">— Choose Your Action —</div> |
| <div style="padding:6px 14px 10px;display:flex;flex-direction:column;gap:4px;">{buttons}</div> |
| </div>''' |
|
|
|
|
| def render_star_stream(state: dict, new_reactions: list = None) -> str: |
| """Right panel — Star Stream as an ancient chronicle scroll.""" |
| history = state.get("star_stream_history", []) |
| display_reactions = history[-15:] |
|
|
| messages_html = "" |
| for r in display_reactions: |
| modifier = r.get("modifier", "Unknown Constellation") |
| reaction = r.get("reaction", "...") |
| coins = r.get("coins", 0) |
|
|
| mood = "neutral" |
| if any(w in modifier for w in ["Demonic", "Abyssal", "Dark", "Death"]): mood = "negative" |
| if any(w in modifier for w in ["Judge", "Salvation", "Light", "Star"]): mood = "positive" |
|
|
| coin_html = f'<div class="chat-donation" style="font-family:\'Cinzel\',serif;font-size:10px;letter-spacing:1px;">+ {coins} Coins</div>' if coins > 0 else "" |
|
|
| messages_html += f""" |
| <div class="chat-message" data-mood="{mood}"> |
| <span class="constellation-name">{modifier}</span> |
| <span style="color:#8a7050;font-family:\'IM Fell English\',serif;font-style:italic;font-size:12px;line-height:1.5;">{reaction}</span> |
| {coin_html} |
| </div> |
| """ |
|
|
| if not messages_html: |
| messages_html = """ |
| <div class="chat-message" data-mood="neutral" style="opacity:0.5;"> |
| <span style="font-family:'IM Fell English',serif;font-style:italic;font-size:12px;color:#5a4a35;">The Constellations observe in silence...</span> |
| </div> |
| """ |
|
|
| return f""" |
| <div style="width:100%;display:flex;flex-direction:column;height:100%;"> |
| <div style="font-family:'Cinzel Decorative',serif;color:#904858;font-size:11px;letter-spacing:3px;text-transform:uppercase;margin-bottom:10px;text-shadow:0 0 12px rgba(201,168,76,0.4);"> |
| ✦ Star Stream |
| </div> |
| <div style="height:1px;background:linear-gradient(90deg,#6a3040,transparent);margin-bottom:12px;"></div> |
| <div class="constellation-chat" id="star-stream-chat"> |
| {messages_html} |
| </div> |
| </div> |
| """ |
|
|
|
|
| def render_overlay(overlay_type: str = "", data: dict = None) -> str: |
| """Fullscreen overlays — Elder Scrolls stone tablet style.""" |
| if not overlay_type: |
| return '<div class="fullscreen-overlay" style="display:none;"></div>' |
|
|
| if overlay_type == "scenario_announcement": |
| scenario = data or {} |
| announcement = get_scenario_announcement_text(scenario) |
| intro = scenario.get("intro_narrative", "") |
| return f""" |
| <div class="fullscreen-overlay scenario-flash" id="game-overlay" data-auto-dismiss="6000"> |
| <div class="scenario-announcement fade-in-up" style="max-width:640px;"> |
| <div style="font-family:'Cinzel',serif;color:#6a3040;font-size:11px;letter-spacing:3px;margin-bottom:16px;">MAIN SCENARIO #{scenario.get('number', '?')}</div> |
| <div style="font-family:'Cinzel Decorative',serif;color:#f0c840;font-size:1.8em;font-weight:700;letter-spacing:4px;text-shadow:0 0 30px rgba(240,200,64,0.4);margin-bottom:20px;">SCENARIO BEGINS</div> |
| <div style="height:1px;background:linear-gradient(90deg,transparent,#904858,transparent);margin-bottom:20px;"></div> |
| <pre style="font-family:'IM Fell English',serif;color:#d4c090;text-align:left;white-space:pre-wrap;max-width:500px;margin:0 auto 20px;font-style:italic;font-size:13px;line-height:1.8;">{announcement}</pre> |
| <div style="font-family:'IM Fell English',serif;font-style:italic;color:#b8a07a;font-size:14px;line-height:1.8;max-width:560px;margin:0 auto 24px;">{intro}</div> |
| <div style="font-family:'Cinzel',serif;font-size:10px;color:#5a4a35;letter-spacing:3px;">✦ Click to Continue ✦</div> |
| </div> |
| </div> |
| """ |
|
|
| elif overlay_type == "ranking": |
| rank = data.get("rank", "C") if data else "C" |
| rank_colors = {"S": "#f0c840", "A": "#904858", "B": "#7a8fbb", "C": "#8a7050"} |
| color = rank_colors.get(rank, "#8a7050") |
| bg_img = _asset_url("rank_s_bg.png") if rank == "S" else "" |
| bg_style = f"background:url('{bg_img}') center/cover no-repeat,#060402;" if bg_img else "background:#060402;" |
| return f""" |
| <div class="fullscreen-overlay" id="game-overlay" data-auto-dismiss="4000" style="{bg_style}"> |
| <div class="ranking-display rank-{rank.lower()}" style="text-align:center;"> |
| <div style="font-family:'Cinzel',serif;color:#6a3040;font-size:12px;letter-spacing:4px;margin-bottom:16px;">SCENARIO COMPLETE</div> |
| <div style="height:1px;width:200px;margin:0 auto 20px;background:linear-gradient(90deg,transparent,#904858,transparent);"></div> |
| <div style="font-size:7em;font-family:'Cinzel Decorative',serif;font-weight:900;color:{color};text-shadow:0 0 60px {color},0 0 120px {color}40;">{rank}</div> |
| <div style="font-family:'Cinzel',serif;font-size:14px;letter-spacing:4px;color:#904858;margin-top:16px;">RANK {rank} — BATTLE CONCLUDED</div> |
| </div> |
| </div> |
| """ |
|
|
| elif overlay_type == "reader_noticed": |
| return f""" |
| <div class="fullscreen-overlay reader-noticed glitch-effect static-noise" id="game-overlay"> |
| <div style="text-align:center;max-width:600px;padding:40px;border:1px solid #8b1a1a;background:linear-gradient(160deg,#1a0808,#0e0404);position:relative;"> |
| <div style="position:absolute;top:-1px;left:-1px;width:24px;height:24px;border-top:2px solid #cc3333;border-left:2px solid #cc3333;"></div> |
| <div style="position:absolute;bottom:-1px;right:-1px;width:24px;height:24px;border-bottom:2px solid #cc3333;border-right:2px solid #cc3333;"></div> |
| <div class="glitch-text" style="font-family:'Cinzel',serif;font-size:11px;letter-spacing:3px;color:#8b1a1a;margin-bottom:20px;">PROBABILITY DISTORTION DETECTED</div> |
| <div class="glitch-text" style="font-family:'Cinzel Decorative',serif;font-size:2.4em;font-weight:900;color:#e8d5a3;margin:16px 0;text-shadow:0 0 30px rgba(139,26,26,0.6);">THE READER<br>HAS BEEN NOTICED</div> |
| <div style="height:1px;background:linear-gradient(90deg,transparent,#8b1a1a,transparent);margin:20px 0;"></div> |
| <div style="font-family:'Cinzel',serif;font-size:12px;color:#cc3333;letter-spacing:3px;">⚠Hidden Scenario Unlocked — Difficulty: Impossible</div> |
| </div> |
| </div> |
| """ |
|
|
| elif overlay_type == "game_over": |
| reason = data.get("reason", "You have died.") if data else "You have died." |
| return f""" |
| <div class="fullscreen-overlay" id="game-overlay"> |
| <div style="text-align:center;max-width:520px;padding:48px;border:1px solid #5a0808;background:linear-gradient(160deg,#150808,#0a0404);position:relative;"> |
| <div style="position:absolute;top:-1px;left:-1px;width:22px;height:22px;border-top:2px solid #8b1a1a;border-left:2px solid #8b1a1a;"></div> |
| <div style="position:absolute;bottom:-1px;right:-1px;width:22px;height:22px;border-bottom:2px solid #8b1a1a;border-right:2px solid #8b1a1a;"></div> |
| <div style="font-family:'Cinzel Decorative',serif;font-size:2em;color:#8b1a1a;font-weight:900;text-shadow:0 0 30px rgba(139,26,26,0.5);margin-bottom:20px;">INCARNATION ELIMINATED</div> |
| <div style="height:1px;background:linear-gradient(90deg,transparent,#5a0808,transparent);margin-bottom:20px;"></div> |
| <div style="font-family:'IM Fell English',serif;font-style:italic;color:#b8a07a;font-size:15px;line-height:1.8;margin-bottom:20px;">{reason}</div> |
| <div style="font-family:'Cinzel',serif;font-size:10px;color:#5a4a35;letter-spacing:2px;">The Constellations have lost interest.</div> |
| </div> |
| </div> |
| """ |
|
|
| return '<div class="fullscreen-overlay" style="display:none;"></div>' |
|
|
|
|
| def render_state_data(state: dict) -> str: |
| """Hidden element that passes state JSON to JavaScript for effects.""" |
| return f'<div id="game-state-data" style="display:none;" data-state=\'{json.dumps(state)}\'></div>' |
|
|
| def buy_item_action(item_selection: str, state: dict): |
| if not item_selection or not state: |
| return state, render_player_panel(state) |
| item_map = { |
| "Minor HP Potion (15c)": ("Minor HP Potion", 15), |
| "HP Potion (30c)": ("HP Potion", 30), |
| "Major HP Potion (60c)": ("Major HP Potion", 60), |
| "Star Stream Flare (100c)": ("Star Stream Flare", 100), |
| "Unbroken Faith (150c)": ("Unbroken Faith", 150), |
| "Probability Nullifier (200c)": ("Probability Nullifier", 200), |
| "Random Attribute Box (250c)": ("Random Attribute Box", 250) |
| } |
| if item_selection not in item_map: |
| return state, render_player_panel(state) |
| |
| item_name, cost = item_map[item_selection] |
| gs = GameState.from_dict(state) |
| if gs.buy_item(item_name, cost): |
| return gs.to_dict(), render_player_panel(gs.to_dict()) |
| return state, render_player_panel(state) |
|
|
| def use_skill_action(skill_selection: str, current_input: str, state: dict): |
| if not skill_selection or not state: |
| return state, current_input, gr.update() |
| |
| gs = GameState.from_dict(state) |
| skill_data = gs.state["skills"].get(skill_selection) |
| if skill_data and skill_data.get("cooldown", 0) <= 0: |
| skill_data["cooldown"] = skill_data.get("max_cooldown", 3) |
| new_text = f"[Activating Skill: {skill_selection}] - " + (current_input or "") |
| available_skills = [s for s, d in gs.state["skills"].items() if d.get("cooldown", 0) <= 0] |
| return gs.to_dict(), new_text, gr.update(choices=available_skills) |
| |
| return state, current_input, gr.update() |
|
|
|
|
| |
| |
| |
|
|
|
|
| def start_game(): |
| """Initialize game state and trigger Scenario 1 intro.""" |
| gs = GameState() |
| state = gs.to_dict() |
| scenario = SCENARIOS[1] |
|
|
| |
| system_prompt = build_intro_prompt(state, scenario) |
| ai_response = call_dokkaebi(system_prompt, "The scenario begins. Introduce yourself and set the scene.") |
|
|
| |
| narrative = ai_response.get("narrative", scenario["intro_narrative"]) |
| dokkaebi_comment = ai_response.get("dokkaebi_comment", "Incarnation. Your scenario begins now.") |
| suggestions = ai_response.get("suggestions", [ |
| "Look around the subway car", |
| "Try to calm the panicking passengers", |
| "Search for an exit immediately" |
| ]) |
| constellation_reactions = ai_response.get("constellation_reactions", []) |
|
|
| |
| if constellation_reactions: |
| for r in constellation_reactions[-5:]: |
| state["star_stream_history"].append(r) |
|
|
| available_skills = [s for s, d in state.get("skills", {}).items() if d.get("cooldown", 0) <= 0] |
|
|
| return ( |
| state, |
| gr.update(visible=False), |
| gr.update(visible=False), |
| gr.update(visible=True), |
| gr.update(visible=True), |
| gr.update(visible=True), |
| render_scenario_header(state), |
| render_player_panel(state), |
| render_meters(state), |
| render_scene(state), |
| render_narrative(narrative, dokkaebi_comment), |
| render_suggestions(suggestions), |
| render_star_stream(state, constellation_reactions), |
| render_overlay("scenario_announcement", scenario), |
| render_state_data(state), |
| "", |
| gr.update(choices=available_skills), |
| ) |
|
|
|
|
| def take_action(stance: str, player_action: str, state: dict): |
| """Process a player action and update the game.""" |
| if not state: |
| |
| return [state] + [gr.update()] * 9 + ["", gr.update()] |
|
|
| if not player_action or not player_action.strip(): |
| |
| return ( |
| state, |
| render_scenario_header(state), |
| render_player_panel(state), |
| render_meters(state), |
| render_scene(state), |
| gr.update(), |
| gr.update(), |
| render_star_stream(state), |
| render_overlay(), |
| render_state_data(state), |
| "", |
| gr.update() |
| ) |
|
|
| player_action = player_action.strip() |
| if stance and stance != "Neutral": |
| action_for_prompt = f"[{stance} Stance] {player_action}" |
| else: |
| action_for_prompt = player_action |
|
|
| |
| scenario_num = state.get("scenario_num", 1) |
| scenario = SCENARIOS.get(scenario_num, SCENARIOS[1]) |
|
|
| |
| system_prompt = build_prompt(state, action_for_prompt, scenario) |
| |
| ai_response = call_dokkaebi(system_prompt, action_for_prompt) |
|
|
| |
| prev_hp = state.get("hp", 100) |
| prev_meta = state.get("meta_exposure", 0) |
| updated_state = process_turn(state, action_for_prompt, ai_response, stance) |
|
|
| |
| overlay_html = render_overlay() |
|
|
| |
| if updated_state.get("meta_exposure", 0) >= 100 and prev_meta < 100: |
| overlay_html = render_overlay("reader_noticed") |
| |
| elif ai_response.get("scenario_complete"): |
| rank = ai_response.get("scenario_rank", "C") |
| overlay_html = render_overlay("ranking", {"rank": rank}) |
| |
| next_num = updated_state.get("scenario_num", scenario_num) |
| if next_num != scenario_num and next_num in SCENARIOS: |
| |
| pass |
| |
| elif updated_state.get("game_over"): |
| overlay_html = render_overlay("game_over", { |
| "reason": updated_state.get("game_over_reason", "You have died.") |
| }) |
| |
| elif ai_response.get("big_moment"): |
| |
| pass |
|
|
| |
| narrative = ai_response.get("narrative", "Something happens...") |
| dokkaebi_comment = ai_response.get("dokkaebi_comment", "...") |
| reality_subversion = ai_response.get("reality_subversion") |
| suggestions = ai_response.get("suggestions", []) |
| constellation_reactions = ai_response.get("constellation_reactions", []) |
|
|
| |
| took_damage = updated_state.get("hp", 100) < prev_hp |
| meta_increased = updated_state.get("meta_exposure", 0) > prev_meta |
|
|
| |
| js_triggers = { |
| "damage": took_damage, |
| "meta_up": meta_increased, |
| "coins_gained": ai_response.get("entertainment_score", 0) > 5, |
| "big_moment": ai_response.get("big_moment", False), |
| } |
| trigger_data = json.dumps(js_triggers) |
|
|
| |
| state_with_triggers = ( |
| f'<div id="game-state-data" style="display:none;" ' |
| f'data-state=\'{json.dumps(updated_state)}\' ' |
| f'data-triggers=\'{trigger_data}\'></div>' |
| ) |
|
|
| available_skills = [s for s, d in updated_state.get("skills", {}).items() if d.get("cooldown", 0) <= 0] |
|
|
| return ( |
| updated_state, |
| render_scenario_header(updated_state), |
| render_player_panel(updated_state), |
| render_meters(updated_state), |
| render_scene(updated_state), |
| render_narrative(narrative, dokkaebi_comment, reality_subversion), |
| render_suggestions(suggestions), |
| render_star_stream(updated_state, constellation_reactions), |
| overlay_html, |
| state_with_triggers, |
| "", |
| gr.update(choices=available_skills), |
| ) |
|
|
|
|
| |
| |
| |
|
|
| |
| _ALL_CSS = _STYLE_CSS + "\n" + _ANIMATIONS_CSS |
|
|
| |
| _HEAD_HTML = f""" |
| <script>{_GAME_JS}</script> |
| """ |
|
|
| |
| _CINEMATIC_HTML = f""" |
| <div id="orv-cinematic-overlay"> |
| |
| <!-- Video layer --> |
| <video id="orv-intro-video" muted playsinline preload="auto" |
| src="{_asset_url('intro_video.mp4')}"> |
| </video> |
| |
| <!-- Dark vignette over video --> |
| <div id="orv-cinematic-vignette"></div> |
| |
| <!-- Skip button (top-right) --> |
| <button id="orv-skip-btn" onclick="window._skipCinematic()"> |
| SKIP <span style="opacity:0.5;margin-left:4px;">▶▶</span> |
| </button> |
| |
| <!-- Progress bar (bottom) --> |
| <div id="orv-cinematic-progress"> |
| <div id="orv-cinematic-progress-bar"></div> |
| </div> |
| |
| <!-- Loading text (bottom-center) --> |
| <div id="orv-cinematic-text"> |
| Entering the Star Stream |
| </div> |
| |
| </div> |
| """ |
|
|
|
|
|
|
| |
| _INIT_JS = """ |
| function() { |
| var _gameReady = false; |
| var _cinematicDone = false; |
| |
| // ── STYLE THE START BUTTON WITH CINZEL FONT ── |
| function styleStartButton() { |
| var btn = document.querySelector('#gradio-start-btn button'); |
| if (!btn) { setTimeout(styleStartButton, 100); return; } |
| |
| var applyFont = function(el) { |
| el.style.setProperty('font-family', "'Cinzel', serif", 'important'); |
| el.style.setProperty('font-size', '14px', 'important'); |
| el.style.setProperty('font-weight', '700', 'important'); |
| el.style.setProperty('letter-spacing', '5px', 'important'); |
| el.style.setProperty('text-transform', 'uppercase', 'important'); |
| }; |
| applyFont(btn); |
| btn.querySelectorAll('*').forEach(applyFont); |
| btn.style.setProperty('color', '#c06878', 'important'); |
| btn.style.setProperty('background', 'linear-gradient(160deg,#1a0c12 0%,#2a101a 50%,#150a10 100%)', 'important'); |
| btn.style.setProperty('border', '1px solid #904858', 'important'); |
| btn.style.setProperty('border-radius', '0', 'important'); |
| btn.style.setProperty('padding', '18px 64px', 'important'); |
| btn.style.setProperty('box-shadow', '0 0 30px rgba(144,72,88,0.3)', 'important'); |
| |
| btn.addEventListener('mouseenter', function() { |
| this.style.setProperty('letter-spacing', '7px', 'important'); |
| this.style.setProperty('color', '#e8c8d0', 'important'); |
| }); |
| btn.addEventListener('mouseleave', function() { |
| this.style.setProperty('letter-spacing', '5px', 'important'); |
| this.style.setProperty('color', '#c06878', 'important'); |
| }); |
| |
| // ── CLICK: START CINEMATIC ── |
| btn.addEventListener('click', function() { |
| startCinematic(); |
| }); |
| } |
| styleStartButton(); |
| |
| // ── CINEMATIC FLOW ── |
| function startCinematic() { |
| var overlay = document.getElementById('orv-cinematic-overlay'); |
| var video = document.getElementById('orv-intro-video'); |
| var progressBar = document.getElementById('orv-cinematic-progress-bar'); |
| var skipBtn = document.getElementById('orv-skip-btn'); |
| |
| if (!overlay) return; |
| |
| // Show the overlay |
| overlay.classList.add('active'); |
| |
| // Try to play video |
| if (video && video.src && !video.error) { |
| video.currentTime = 0; |
| var playPromise = video.play(); |
| if (playPromise) { |
| playPromise.catch(function() { |
| // Video failed — just show the overlay as loading screen |
| }); |
| } |
| |
| // Update progress bar |
| video.addEventListener('timeupdate', function() { |
| if (video.duration) { |
| var pct = (video.currentTime / video.duration) * 100; |
| if (progressBar) progressBar.style.width = pct + '%'; |
| } |
| }); |
| |
| // Video ended naturally |
| video.addEventListener('ended', function() { |
| if (progressBar) progressBar.style.width = '100%'; |
| finishCinematic(); |
| }); |
| |
| // Show skip button after 2 seconds |
| setTimeout(function() { |
| if (skipBtn) { |
| skipBtn.classList.add('visible'); |
| } |
| }, 2000); |
| } else { |
| // No video available — just show loading text, auto-dismiss when game ready |
| if (skipBtn) skipBtn.style.display = 'none'; |
| } |
| } |
| |
| // ── SKIP / FINISH ── |
| window._skipCinematic = function() { |
| var video = document.getElementById('orv-intro-video'); |
| if (video) { video.pause(); } |
| finishCinematic(); |
| }; |
| |
| function finishCinematic() { |
| if (_cinematicDone) return; |
| _cinematicDone = true; |
| |
| var overlay = document.getElementById('orv-cinematic-overlay'); |
| if (!overlay) return; |
| |
| if (_gameReady) { |
| // Game already loaded — fade out immediately |
| fadeOutOverlay(overlay); |
| } else { |
| // Game still loading — show "loading" state, wait |
| var text = document.getElementById('orv-cinematic-text'); |
| if (text) text.textContent = 'Loading Scenario...'; |
| var skipBtn = document.getElementById('orv-skip-btn'); |
| if (skipBtn) skipBtn.classList.remove('visible'); |
| |
| // Poll until game is ready |
| var poll = setInterval(function() { |
| if (_gameReady) { |
| clearInterval(poll); |
| fadeOutOverlay(overlay); |
| } |
| }, 200); |
| } |
| } |
| |
| function fadeOutOverlay(overlay) { |
| overlay.style.transition = 'opacity 1s ease'; |
| overlay.style.opacity = '0'; |
| setTimeout(function() { |
| overlay.classList.remove('active'); |
| overlay.style.opacity = ''; |
| overlay.style.transition = ''; |
| }, 1000); |
| } |
| |
| // ── DETECT WHEN GAME IS LOADED ── |
| var observer = new MutationObserver(function() { |
| var header = document.querySelector('.game-header'); |
| if (header && getComputedStyle(header).display !== 'none') { |
| _gameReady = true; |
| // If cinematic already done (skipped/ended), fade out now |
| if (_cinematicDone) { |
| var overlay = document.getElementById('orv-cinematic-overlay'); |
| if (overlay && overlay.classList.contains('active')) { |
| fadeOutOverlay(overlay); |
| } |
| } |
| } |
| }); |
| observer.observe(document.body, { attributes: true, subtree: true, attributeFilter: ['style', 'class'] }); |
| } |
| """ |
|
|
|
|
| with gr.Blocks( |
| title="Omniscient Reader — Scenario Simulator", |
| elem_classes="game-container", |
| ) as demo: |
|
|
|
|
| |
| game_state = gr.State(value=None) |
|
|
| |
| start_screen_html = gr.HTML( |
| render_start_screen(), |
| elem_classes="start-screen-container", |
| ) |
| start_btn = gr.Button( |
| "⚡ Begin the Scenario", |
| elem_id="gradio-start-btn", |
| elem_classes="start-btn-visible", |
| variant="primary", |
| ) |
|
|
| |
| cinematic_overlay = gr.HTML(_CINEMATIC_HTML, elem_id="cinematic-overlay-wrapper") |
|
|
| |
| with gr.Row(elem_classes="game-header", visible=False) as game_header: |
| scenario_header = gr.HTML(render_scenario_header({"scenario_num": 1, "turn": 1})) |
|
|
| |
| with gr.Row(elem_classes="game-main", visible=False) as game_main: |
|
|
| |
| with gr.Column(scale=1, min_width=260, elem_classes="player-panel"): |
| player_panel = gr.HTML(render_player_panel(GameState().to_dict())) |
| meters_panel = gr.HTML(render_meters(GameState().to_dict())) |
| with gr.Accordion("Dokkaebi Bag", open=False, elem_classes=["system-window", "shop-accordion"]): |
| shop_item = gr.Dropdown( |
| choices=[ |
| "Minor HP Potion (15c)", |
| "HP Potion (30c)", |
| "Major HP Potion (60c)", |
| "Star Stream Flare (100c)", |
| "Unbroken Faith (150c)", |
| "Probability Nullifier (200c)", |
| "Random Attribute Box (250c)" |
| ], |
| label="Select Item", |
| show_label=False |
| ) |
| shop_buy_btn = gr.Button("💰 Buy Item", elem_classes="suggestion-btn") |
|
|
| |
| with gr.Column(scale=3, min_width=400, elem_classes="game-area"): |
| scene_display = gr.HTML('<div class="scene-placeholder"></div>') |
| narrative_display = gr.HTML( |
| '<div class="system-window"><div class="narrative-text">' |
| 'Waiting for scenario to begin...</div></div>' |
| ) |
| suggestions_display = gr.HTML(render_suggestions([])) |
|
|
| |
| with gr.Column(scale=1, min_width=280, elem_classes="constellation-panel"): |
| star_stream_display = gr.HTML( |
| render_star_stream(GameState().to_dict()) |
| ) |
|
|
| |
| with gr.Row(elem_classes="input-bar", visible=False) as input_bar: |
| with gr.Column(scale=1, min_width=150): |
| active_skills_dd = gr.Dropdown(choices=[], label="", show_label=False, elem_classes="game-input", container=False) |
| skill_btn = gr.Button("Use Skill", elem_classes="suggestion-btn") |
| with gr.Column(scale=4): |
| stance_radio = gr.Radio(["Neutral", "Aggressive", "Deceptive", "Empathetic", "Observant"], label="", show_label=False, value="Neutral", container=False) |
| player_input = gr.Textbox( |
| placeholder="What do you do, Incarnation?", |
| label="", |
| elem_classes="game-input", |
| max_lines=2, |
| ) |
| submit_btn = gr.Button( |
| "âš¡ Act", |
| elem_classes="act-button", |
| variant="primary", |
| scale=1, |
| ) |
|
|
| |
| overlay_display = gr.HTML( |
| render_overlay(), |
| elem_classes="overlay-container", |
| ) |
|
|
| |
| state_data = gr.HTML( |
| '<div id="game-state-data" style="display:none;"></div>', |
| visible=False, |
| ) |
|
|
| |
|
|
| |
| start_btn.click( |
| fn=start_game, |
| inputs=[], |
| outputs=[ |
| game_state, |
| start_screen_html, |
| start_btn, |
| game_header, |
| game_main, |
| input_bar, |
| scenario_header, |
| player_panel, |
| meters_panel, |
| scene_display, |
| narrative_display, |
| suggestions_display, |
| star_stream_display, |
| overlay_display, |
| state_data, |
| player_input, |
| active_skills_dd, |
| ], |
| ) |
|
|
| |
| submit_btn.click( |
| fn=take_action, |
| inputs=[stance_radio, player_input, game_state], |
| outputs=[ |
| game_state, |
| scenario_header, |
| player_panel, |
| meters_panel, |
| scene_display, |
| narrative_display, |
| suggestions_display, |
| star_stream_display, |
| overlay_display, |
| state_data, |
| player_input, |
| active_skills_dd, |
| ], |
| ) |
|
|
| |
| player_input.submit( |
| fn=take_action, |
| inputs=[stance_radio, player_input, game_state], |
| outputs=[ |
| game_state, |
| scenario_header, |
| player_panel, |
| meters_panel, |
| scene_display, |
| narrative_display, |
| suggestions_display, |
| star_stream_display, |
| overlay_display, |
| state_data, |
| player_input, |
| active_skills_dd, |
| ], |
| ) |
|
|
| |
| shop_buy_btn.click( |
| fn=buy_item_action, |
| inputs=[shop_item, game_state], |
| outputs=[game_state, player_panel] |
| ) |
|
|
| |
| skill_btn.click( |
| fn=use_skill_action, |
| inputs=[active_skills_dd, player_input, game_state], |
| outputs=[game_state, player_input, active_skills_dd] |
| ) |
|
|
|
|
| |
| |
| |
| |
| |
| demo.css = _ALL_CSS |
| demo.head = _HEAD_HTML |
|
|
| if __name__ == "__main__": |
| import time |
|
|
| |
| |
| _abs_assets = os.path.abspath(_ASSET_DIR) |
| _fwd_assets = _abs_assets.replace("\\", "/") |
| demo.launch( |
| server_name="0.0.0.0", |
| server_port=7860, |
| allowed_paths=[_abs_assets, _fwd_assets, os.path.dirname(_abs_assets)], |
| show_error=True, |
| prevent_thread_lock=True, |
| css=_ALL_CSS, |
| head=_HEAD_HTML, |
| js=_INIT_JS, |
| ) |
|
|
| print("ORV Scenario Simulator running at http://localhost:7860") |
|
|
| try: |
| while True: |
| time.sleep(1) |
| except KeyboardInterrupt: |
| pass |
|
|
|
|