snapquest / index.html
ashish-doing's picture
fix: FastAPI owns root, mount Gradio at /gradio, fix API path in index.html
a3ed0e7
Raw
History Blame Contribute Delete
54.9 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SNAPQUEST ⚔</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;900&family=Cinzel+Decorative:wght@400;700&family=IM+Fell+English:ital@0;1&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<style>
:root {
--obsidian:#08090d; --parchment:#12100a; --gold:#c9a84c; --gold-dim:#6b5520;
--gold-bright:#f0d060; --teal:#3aafa9; --teal-dim:#1a4a47; --crimson:#cc2936;
--crimson-dim:#4a0a0e; --ghost:#e8e4d9; --muted:#7a7060; --panel:#0d0e14;
--panel2:#11120a; --border:#1e1c14; --border-gold:#2a2210;
}
*{box-sizing:border-box;margin:0;padding:0;}
html,body{background:var(--obsidian);}
body{color:var(--ghost);font-family:'Share Tech Mono',monospace;min-height:100vh;overflow-x:hidden;}
.cinzel{font-family:'Cinzel',serif;} .cinzel-d{font-family:'Cinzel Decorative',serif;}
.fell{font-family:'IM Fell English',Georgia,serif;font-style:italic;}
.mono{font-family:'Share Tech Mono',monospace;}
/* CRT scanline overlay (subtle, perf-safe) */
.scanlines{position:fixed;inset:0;pointer-events:none;z-index:9000;
background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(201,168,76,.02) 2px,rgba(201,168,76,.02) 4px);
mix-blend-mode:overlay;}
.vignette{position:fixed;inset:0;pointer-events:none;z-index:9001;
background:radial-gradient(ellipse at center, transparent 60%, rgba(0,0,0,.55) 100%);}
/* Screens */
.screen{display:none;min-height:100vh;} .screen.active{display:flex;flex-direction:column;}
/* ── SCREEN 1 ── */
#s1 .hero{position:relative;background:radial-gradient(ellipse at 50% 0%,#18090a,var(--obsidian) 70%);
border-bottom:1px solid var(--border-gold);padding:48px 24px 36px;text-align:center;overflow:hidden;}
.rune-l,.rune-r{position:absolute;top:14px;font-family:'Cinzel Decorative',serif;font-size:32px;color:#1e1808;opacity:.35;}
.rune-l{left:20px;} .rune-r{right:20px;}
.hero-title{font-family:'Cinzel Decorative',serif;font-size:clamp(32px,6vw,58px);font-weight:700;
letter-spacing:.18em;color:var(--gold);text-shadow:0 0 40px rgba(201,168,76,.5),0 2px 0 #000;margin-bottom:6px;}
.hero-sub{font-family:'Cinzel',serif;color:var(--gold-dim);font-size:11px;letter-spacing:.4em;margin-bottom:22px;}
.divider{height:1px;background:linear-gradient(90deg,transparent,var(--gold-dim),var(--gold),var(--gold-dim),transparent);margin:0;}
.hero-desc{font-family:'IM Fell English',serif;font-style:italic;color:var(--muted);font-size:14px;line-height:1.85;
max-width:520px;margin:18px auto 24px;}
.badges{display:flex;gap:10px;justify-content:center;flex-wrap:wrap;}
.badge{border:1px solid var(--border-gold);padding:4px 12px;font-size:10px;color:var(--gold-dim);letter-spacing:.1em;}
.badge-red{border-color:var(--crimson-dim);color:var(--crimson);}
/* Upload section */
.upload-section{max-width:820px;margin:0 auto;padding:36px 24px;width:100%;}
.section-label{font-family:'Cinzel',serif;color:var(--gold-dim);font-size:10px;letter-spacing:.35em;margin-bottom:16px;}
.slot-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px;margin-bottom:22px;}
.slot{position:relative;border:1px solid var(--border);background:var(--parchment);
min-height:160px;display:flex;flex-direction:column;align-items:center;justify-content:center;
cursor:pointer;transition:border-color .2s,box-shadow .2s;text-align:center;padding:14px;overflow:hidden;}
.slot:hover{border-color:var(--gold-dim);box-shadow:0 0 14px rgba(201,168,76,.1);}
.slot.loaded{border-color:var(--teal-dim);align-items:flex-start;justify-content:flex-start;}
.slot.analyzing{border-color:var(--gold-dim);animation:slotPulse 1.2s ease-in-out infinite;}
@keyframes slotPulse{0%,100%{box-shadow:0 0 6px rgba(201,168,76,.1);}50%{box-shadow:0 0 22px rgba(201,168,76,.35);}}
.slot .slot-label{position:absolute;top:7px;left:10px;font-size:9px;letter-spacing:.25em;color:#3a2e14;font-family:'Cinzel',serif;z-index:2;}
.slot .slot-icon{font-size:28px;color:#2a2020;margin-bottom:8px;}
.slot .slot-name{font-family:'Cinzel',serif;color:#3a2e14;font-size:10px;letter-spacing:.2em;}
.slot .slot-opt{color:#1e1a10;font-size:10px;margin-top:4px;}
.slot img{width:100%;height:84px;object-fit:cover;border:1px solid var(--border);margin-bottom:7px;margin-top:20px;}
.slot .analyzed{color:var(--teal);font-size:9px;letter-spacing:.2em;margin-bottom:5px;display:flex;align-items:center;gap:6px;}
.slot .analyzing-label{color:var(--gold);font-size:9px;letter-spacing:.2em;margin-bottom:5px;}
.tags{line-height:2;} .tag{display:inline-block;border:1px solid var(--border-gold);padding:2px 8px;font-size:10px;color:var(--gold-dim);margin:2px;}
.boss-tag{border-color:var(--crimson-dim);color:var(--crimson);}
.boss-preview{margin-top:8px;border:1px solid var(--crimson-dim);padding:4px 8px;background:#0a0205;
color:var(--crimson);font-size:10px;letter-spacing:.15em;animation:bossGlow 2s ease-in-out infinite;}
@keyframes bossGlow{0%,100%{box-shadow:0 0 0 rgba(204,41,54,0);}50%{box-shadow:0 0 14px rgba(204,41,54,.35);}}
.remove-photo{position:absolute;top:6px;right:6px;z-index:3;width:20px;height:20px;
display:flex;align-items:center;justify-content:center;border:1px solid var(--crimson-dim);
background:#0a0205;color:var(--crimson);font-size:12px;cursor:pointer;line-height:1;}
.remove-photo:hover{border-color:var(--crimson);color:#ff8888;}
.corner{position:absolute;width:14px;height:14px;}
.tl{top:0;left:0;border-top:2px solid var(--gold-dim);border-left:2px solid var(--gold-dim);}
.tr{top:0;right:0;border-top:2px solid var(--gold-dim);border-right:2px solid var(--gold-dim);}
.bl{bottom:0;left:0;border-bottom:2px solid var(--gold-dim);border-left:2px solid var(--gold-dim);}
.br{bottom:0;right:0;border-bottom:2px solid var(--gold-dim);border-right:2px solid var(--gold-dim);}
/* How it works */
.how{border:1px solid var(--border-gold);background:var(--panel2);padding:18px 22px;margin-bottom:26px;position:relative;}
.how-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:20px;text-align:center;margin-top:14px;}
.how-icon{font-size:26px;margin-bottom:8px;}
.how-text{font-family:'IM Fell English',serif;font-style:italic;color:var(--muted);font-size:12px;line-height:1.6;}
/* Buttons */
.btn{font-family:'Cinzel',serif;font-size:13px;letter-spacing:.2em;padding:14px 24px;
cursor:pointer;border:1px solid var(--gold-dim);color:var(--gold);
background:linear-gradient(135deg,#120d04,#1c1508);text-transform:uppercase;
transition:all .2s;width:100%;display:block;text-align:center;}
.btn:hover{border-color:var(--gold);color:var(--gold-bright);box-shadow:0 0 20px rgba(201,168,76,.15);}
.btn:active{transform:scale(.98);}
.btn:disabled,.btn-disabled{font-family:'Cinzel',serif;font-size:13px;letter-spacing:.2em;
padding:14px 24px;border:1px solid #222;color:#333;background:#09090d;
text-transform:uppercase;cursor:not-allowed;width:100%;display:block;text-align:center;}
.btn-sub{text-align:center;color:var(--gold-dim);font-size:10px;letter-spacing:.15em;margin-top:8px;}
/* ── SCREEN 2 — CLASS SELECT ── */
#s2 .topbar{background:var(--panel);border-bottom:1px solid var(--border-gold);
padding:14px 24px;display:flex;align-items:center;justify-content:space-between;}
.topbar-title{font-family:'Cinzel Decorative',serif;color:var(--gold);font-size:18px;letter-spacing:.15em;text-shadow:0 0 12px rgba(201,168,76,.4);}
.back-btn{font-family:'Cinzel',serif;font-size:11px;letter-spacing:.15em;padding:6px 16px;
cursor:pointer;border:1px solid var(--gold-dim);color:var(--gold);background:var(--panel);
text-transform:uppercase;transition:all .2s;}
.back-btn:hover{border-color:var(--gold);}
.class-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin-bottom:28px;}
.class-card{border:1px solid var(--border);background:var(--panel);padding:18px 14px;
cursor:pointer;transition:all .2s;position:relative;}
.class-card:hover{border-color:var(--border-gold);transform:translateY(-2px);}
.class-card.selected{border-width:2px;}
@keyframes goldPulse{0%,100%{box-shadow:0 0 8px rgba(201,168,76,.15);}50%{box-shadow:0 0 24px rgba(201,168,76,.45);}}
.class-card.selected{animation:goldPulse 3s ease-in-out infinite;}
.class-icon{font-size:34px;text-align:center;margin-bottom:8px;filter:drop-shadow(0 0 8px rgba(201,168,76,.25));}
.class-name{font-size:12px;letter-spacing:.2em;text-align:center;margin-bottom:3px;}
.class-tag{font-family:'IM Fell English',serif;font-style:italic;color:#5a5040;font-size:11px;text-align:center;margin-bottom:12px;}
.stat-grid{display:grid;grid-template-columns:1fr 1fr;gap:3px;margin-bottom:12px;}
.stat-box{text-align:center;background:var(--parchment);padding:5px 4px;border:1px solid var(--border);}
.stat-val{font-size:13px;font-weight:bold;} .stat-key{color:#3a2e14;font-size:9px;letter-spacing:.1em;}
.perk{color:var(--muted);font-size:11px;padding:4px 0;border-bottom:1px solid var(--border);}
.playstyle{border:1px solid var(--border);padding:6px 8px;color:#3a2020;font-size:10px;font-style:italic;margin-top:10px;}
.chosen-badge{font-family:'Cinzel',serif;font-size:9px;letter-spacing:.2em;text-align:center;margin-top:10px;}
/* ── SCREEN 3 — DUNGEON HUD ── */
#s3{flex-direction:column;}
.hud{background:var(--panel);border-bottom:1px solid var(--border-gold);
padding:8px 16px;display:grid;grid-template-columns:auto 1fr auto auto auto;
gap:14px;align-items:center;flex-shrink:0;}
.hud-title{font-family:'Cinzel Decorative',serif;color:var(--gold);font-size:15px;letter-spacing:.15em;text-shadow:0 0 10px rgba(201,168,76,.4);}
.minimap{display:flex;align-items:center;gap:0;overflow-x:auto;}
.mm-room{width:24px;height:24px;border:1px solid var(--border);display:flex;align-items:center;justify-content:center;font-size:11px;background:var(--panel);flex-shrink:0;}
.mm-line{width:10px;height:1px;background:#1e1c14;align-self:center;flex-shrink:0;}
.bar-wrap{min-width:130px;}
.bar-label{display:flex;justify-content:space-between;margin-bottom:3px;font-size:10px;}
.bar-track{height:6px;background:#1a0a0a;border:1px solid #2a1414;overflow:hidden;}
.bar-fill{height:100%;transition:width .4s;}
.game-grid{flex:1;display:grid;grid-template-columns:250px 1fr 220px;overflow:hidden;min-height:0;}
.sidebar{background:var(--panel);overflow:hidden;display:flex;flex-direction:column;}
.sidebar-l{border-right:1px solid var(--border);}
.sidebar-r{border-left:1px solid var(--border);}
.sidebar-header{padding:13px;border-bottom:1px solid var(--border-gold);flex-shrink:0;}
.sidebar-body{flex:1;overflow-y:auto;padding:12px;}
.class-mini-icon{font-size:28px;text-align:center;margin-bottom:4px;}
.center-col{display:flex;flex-direction:column;overflow:hidden;min-width:0;}
.scene-header{border-bottom:1px solid var(--border);padding:10px 16px;background:var(--parchment);
flex-shrink:0;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;}
.scene-name{font-family:'Cinzel',serif;color:var(--ghost);font-size:13px;}
.scene-loc{font-family:'Cinzel',serif;color:var(--gold-dim);font-size:9px;letter-spacing:.3em;}
.monster-tags{display:flex;gap:6px;flex-wrap:wrap;}
@keyframes runeGlow{0%,100%{box-shadow:inset 0 0 10px rgba(204,41,54,.1);}50%{box-shadow:inset 0 0 30px rgba(204,41,54,.35),0 0 18px rgba(204,41,54,.2);}}
.boss-panel{border:2px solid var(--crimson);background:#0a0208;padding:12px 16px;
margin:0;flex-shrink:0;position:relative;animation:runeGlow 2s ease-in-out infinite;}
.boss-panel.defeated{border-color:var(--teal);background:#030e0a;animation:none;border-width:1px;}
.chronicle{flex:1;overflow-y:auto;padding:14px 16px;font-size:12px;line-height:1.7;
background:radial-gradient(ellipse at 50% 0%,#0e0c08,var(--obsidian));}
.cl-dm{color:var(--ghost);line-height:1.75;}
.cl-player{color:#7ab8f5;}
.cl-combat{color:var(--crimson);font-weight:bold;}
.cl-loot{color:var(--gold);}
.cl-victory{color:#6bcf7f;font-weight:bold;}
.cl-system{color:var(--muted);}
.cl-div{color:#1e1c14;}
.actions{border-top:1px solid var(--border-gold);padding:12px 14px;background:var(--panel);flex-shrink:0;}
.advance-btn{width:100%;font-family:'Cinzel',serif;font-size:13px;letter-spacing:.2em;padding:12px;
cursor:pointer;border:1px solid var(--gold-dim);color:var(--gold);
background:linear-gradient(90deg,#120d04,#1c1508);text-transform:uppercase;margin-bottom:10px;}
@keyframes goldPulse2{0%,100%{box-shadow:0 0 8px rgba(201,168,76,.2);}50%{box-shadow:0 0 24px rgba(201,168,76,.5);}}
.advance-btn{animation:goldPulse2 2s ease-in-out infinite;}
.advance-btn:hover{color:var(--gold-bright);}
.choice-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:10px;}
.choice-btn{font-family:'Share Tech Mono',monospace;font-size:11px;padding:10px 8px;
cursor:pointer;border:1px solid var(--border-gold);color:var(--muted);
background:var(--panel2);text-align:left;transition:all .15s;line-height:1.4;}
.choice-btn:hover{border-color:var(--gold-dim);color:var(--ghost);}
.choice-btn.boss-mode{border-color:var(--crimson-dim);color:#aa4444;}
.choice-btn.boss-mode:hover{border-color:var(--crimson);color:#ff8888;}
.choice-btn:disabled{opacity:.4;cursor:not-allowed;}
.input-row{display:flex;gap:8px;}
.action-input{flex:1;background:var(--parchment);border:1px solid var(--border-gold);
color:var(--ghost);font-family:'Share Tech Mono',monospace;font-size:12px;padding:9px 12px;outline:none;}
.action-input:focus{border-color:var(--gold-dim);}
.act-btn{font-family:'Cinzel',serif;font-size:11px;letter-spacing:.15em;padding:9px 14px;
cursor:pointer;border:1px solid var(--gold-dim);color:var(--gold);background:var(--panel);
white-space:nowrap;transition:all .2s;}
.act-btn:hover{border-color:var(--gold);}
.act-btn:disabled{opacity:.4;cursor:not-allowed;}
.inv-item{display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--border);font-size:11px;}
.inv-empty{font-family:'IM Fell English',serif;font-style:italic;color:var(--muted);font-size:12px;}
.atm-label{font-family:'Cinzel',serif;color:var(--gold-dim);font-size:9px;letter-spacing:.25em;margin-bottom:10px;}
.atm-text{font-family:'IM Fell English',serif;font-style:italic;color:var(--muted);font-size:12px;line-height:1.8;}
.perk-item{color:var(--muted);font-size:10px;padding:3px 0;border-bottom:1px solid var(--border);}
.thinking{display:flex;align-items:center;gap:8px;color:var(--gold-dim);font-size:11px;letter-spacing:.15em;padding:6px 0;}
.thinking .dot{width:5px;height:5px;background:var(--gold-dim);border-radius:50%;animation:dotPulse 1.2s ease-in-out infinite;}
.thinking .dot:nth-child(2){animation-delay:.2s;} .thinking .dot:nth-child(3){animation-delay:.4s;}
@keyframes dotPulse{0%,100%{opacity:.2;}50%{opacity:1;}}
/* Overlays */
@keyframes fadeIn{from{opacity:0;transform:translateY(6px);}to{opacity:1;transform:none;}}
.overlay{position:fixed;inset:0;background:rgba(4,3,6,.93);z-index:9999;
display:flex;flex-direction:column;align-items:center;justify-content:center;
font-family:'Share Tech Mono',monospace;animation:fadeIn .3s ease;padding:20px;}
.loot-title{font-family:'Cinzel Decorative',serif;color:var(--gold);font-size:22px;letter-spacing:.3em;
margin-bottom:6px;text-shadow:0 0 20px rgba(201,168,76,.6);text-align:center;}
.loot-sub{font-family:'IM Fell English',serif;font-style:italic;color:var(--muted);margin-bottom:28px;text-align:center;}
.loot-cards{display:flex;gap:16px;flex-wrap:wrap;justify-content:center;margin-bottom:32px;}
.loot-card{border:1px solid;background:var(--panel2);padding:14px 18px;min-width:130px;text-align:center;animation:fadeIn .4s ease;}
.loot-icon{font-size:30px;margin-bottom:7px;}
.loot-tier{font-size:9px;letter-spacing:.2em;margin-bottom:4px;}
.loot-name{font-family:'Cinzel',serif;color:var(--ghost);font-size:13px;margin-bottom:4px;}
.loot-stat{color:var(--muted);font-size:11px;}
.victory-overlay{position:fixed;inset:0;background:rgba(4,3,6,.95);z-index:9998;
display:flex;flex-direction:column;align-items:center;justify-content:center;
text-align:center;animation:fadeIn .5s ease;padding:20px;}
.victory-title{font-family:'Cinzel Decorative',serif;color:var(--gold);font-size:clamp(24px,4vw,38px);
letter-spacing:.2em;margin-bottom:12px;text-shadow:0 0 40px rgba(201,168,76,.6);}
.victory-stat{font-family:'Cinzel',serif;color:var(--gold);font-size:22px;}
.victory-stat-label{color:var(--muted);font-size:10px;letter-spacing:.15em;}
/* Loading */
@keyframes loadPulse{0%,100%{opacity:.3;}50%{opacity:1;}}
.loading-screen{position:fixed;inset:0;background:rgba(8,9,13,.92);z-index:9997;
display:flex;align-items:center;justify-content:center;flex-direction:column;text-align:center;
backdrop-filter:blur(2px);}
.loading-title{font-family:'Cinzel Decorative',serif;color:var(--gold);font-size:26px;
letter-spacing:.2em;margin-bottom:14px;animation:loadPulse 1.2s infinite;}
.loading-sub{font-family:'IM Fell English',serif;font-style:italic;color:var(--muted);font-size:13px;}
#loading-screen{display:none;}
/* Toast / error banner */
.toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);
background:#0a0205;border:1px solid var(--crimson);color:#ff9090;
padding:10px 20px;font-size:12px;letter-spacing:.05em;z-index:10001;
max-width:90vw;text-align:center;animation:fadeIn .3s ease;display:none;}
/* Responsive */
@media(max-width:900px){.game-grid{grid-template-columns:1fr!important;}.sidebar-l,.sidebar-r{display:none!important;}
.class-grid{grid-template-columns:1fr 1fr!important;}.slot-grid{grid-template-columns:1fr!important;}
.hud{grid-template-columns:auto 1fr auto!important;}
.bar-wrap:last-child, .hud > div:nth-child(5){display:none;}
}
</style>
</head>
<body>
<div class="scanlines"></div>
<div class="vignette"></div>
<!-- Loading overlay -->
<div id="loading-screen" class="loading-screen">
<div style="position:relative;padding:40px;">
<div class="corner tl"></div><div class="corner tr"></div>
<div class="corner bl"></div><div class="corner br"></div>
<div class="loading-title" id="loading-msg">⚗ CONSULTING THE ORACLE</div>
<div class="loading-sub" id="loading-sub">MiniCPM-V is reading your world...</div>
</div>
</div>
<!-- Toast -->
<div class="toast" id="toast"></div>
<!-- ═══════════════════════════════════════════════════════ SCREEN 1 — UPLOAD -->
<div class="screen active" id="s1">
<div class="hero">
<div class="rune-l">ᚱ ᛗ ᚢ</div>
<div class="rune-r">ᚦ ᛞ ᚾ</div>
<div class="hero-title">SNAPQUEST</div>
<div class="hero-sub">YOUR CHAMBER · YOUR DUNGEON · YOUR FATE</div>
<div class="divider" style="max-width:480px;margin:0 auto 0;"></div>
<div class="hero-desc">
Photograph any real space. MiniCPM-V 4.6 reads every object within it.<br>
Your <span style="color:var(--gold);font-style:normal;">bookshelf</span> becomes the Archive of Ancient Tomes.
Your <span style="color:var(--crimson);font-style:normal;">lamp</span> becomes the final guardian.
</div>
<div class="badges">
<span class="badge">1.3B PARAMS</span>
<span class="badge">MODAL A10G GPU</span>
<span class="badge">MULTI-ROOM</span>
<span class="badge badge-red">☠ BOSS FIGHTS</span>
<span class="badge" style="border-color:var(--teal-dim);color:var(--teal);">VOICE I/O</span>
</div>
</div>
<div class="upload-section">
<div class="section-label">◈ UPLOAD YOUR CHAMBERS ( 1 REQUIRED · 3 FOR FULL DUNGEON )</div>
<div class="slot-grid" id="slot-grid">
<!-- slots rendered by JS -->
</div>
<div class="how" id="how-box">
<div class="corner tl"></div><div class="corner tr"></div>
<div class="corner bl"></div><div class="corner br"></div>
<div class="cinzel" style="color:var(--gold-dim);font-size:9px;letter-spacing:.3em;">THE RITUAL OF SIGHT</div>
<div class="how-grid">
<div><div class="how-icon">📸</div><div class="how-text">MiniCPM-V reads every object in your photograph</div></div>
<div><div class="how-icon"></div><div class="how-text">Each object transforms into a dungeon element or creature</div></div>
<div><div class="how-icon"></div><div class="how-text fell" style="color:var(--crimson);">Last photo's dominant object becomes your final guardian</div></div>
</div>
</div>
<div id="s1-btn-area"></div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════ SCREEN 2 — CLASS -->
<div class="screen" id="s2">
<div class="topbar">
<div class="topbar-title">⚔ SNAPQUEST</div>
<button class="back-btn" onclick="showScreen('s1')">← BACK</button>
</div>
<div style="max-width:1100px;margin:0 auto;padding:28px 20px 48px;width:100%;">
<div style="text-align:center;margin-bottom:8px;">
<div class="cinzel" style="color:var(--gold);font-size:20px;letter-spacing:.2em;margin-bottom:4px;">CHOOSE YOUR CLASS</div>
<div class="cinzel" style="color:var(--gold-dim);font-size:10px;letter-spacing:.3em;">YOUR CHOICE SHAPES HOW THE DUNGEON REVEALS ITS SECRETS</div>
</div>
<div class="divider" style="max-width:400px;margin:16px auto;"></div>
<div class="cinzel fell" id="s2-summary" style="text-align:center;color:var(--gold-dim);font-size:11px;margin-bottom:20px;"></div>
<div class="class-grid" id="class-grid"><!-- rendered by JS --></div>
<div id="s2-btn-area"></div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════ SCREEN 3 — DUNGEON -->
<div class="screen" id="s3">
<!-- TOP HUD -->
<div class="hud">
<div class="hud-title">⚔ SQ</div>
<div class="minimap" id="minimap"></div>
<div style="text-align:center;min-width:70px;">
<div class="cinzel" id="hud-diff" style="font-size:9px;letter-spacing:.2em;"></div>
<div style="color:var(--muted);font-size:10px;" id="hud-room"></div>
</div>
<div class="bar-wrap">
<div class="bar-label"><span style="color:#ff6060;">VITALITY</span><span id="hud-hp"></span></div>
<div class="bar-track"><div class="bar-fill" id="hp-bar"></div></div>
</div>
<div style="min-width:100px;text-align:right;">
<div class="cinzel" style="color:var(--gold-dim);font-size:10px;letter-spacing:.15em;" id="hud-level"></div>
<div style="color:var(--muted);font-size:10px;" id="hud-xp"></div>
</div>
</div>
<div class="game-grid">
<!-- LEFT -->
<div class="sidebar sidebar-l">
<div class="sidebar-header">
<div class="class-mini-icon" id="cls-icon"></div>
<div class="cinzel" style="letter-spacing:.2em;text-align:center;font-size:12px;" id="cls-name"></div>
<div class="fell" style="color:var(--muted);font-size:10px;text-align:center;" id="cls-tag"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:3px;margin-top:10px;" id="cls-stats"></div>
</div>
<div class="sidebar-body">
<div class="cinzel atm-label" style="margin-bottom:10px;">SATCHEL · <span id="inv-count">0</span> ITEMS</div>
<div id="inventory"></div>
</div>
</div>
<!-- CENTER -->
<div class="center-col">
<div class="scene-header">
<div><span class="scene-loc">LOCATION · </span><span class="scene-name" id="scene-name"></span></div>
<div class="monster-tags" id="monster-tags"></div>
</div>
<div id="boss-panel"></div>
<div class="chronicle" id="chronicle"></div>
<div class="actions">
<div id="advance-area"></div>
<div class="choice-grid" id="choices"></div>
<div class="input-row">
<input class="action-input" id="action-input" type="text"
placeholder='"attack", "examine the lamp", "use scroll"...'
onkeydown="if(event.key==='Enter') doAction(this.value)">
<button class="act-btn" id="act-btn" onclick="doAction(document.getElementById('action-input').value)">⚔ ACT</button>
</div>
</div>
</div>
<!-- RIGHT -->
<div class="sidebar sidebar-r">
<div class="sidebar-body">
<div class="cinzel atm-label">ATMOSPHERE</div>
<div class="atm-text" id="atmosphere"></div>
<div class="divider" style="margin:14px 0;"></div>
<div class="cinzel atm-label">ROOM STATUS</div>
<div style="font-size:11px;line-height:1.9;color:var(--muted);" id="room-status"></div>
<div class="divider" style="margin:14px 0;"></div>
<div class="cinzel atm-label">CLASS PERKS</div>
<div id="cls-perks"></div>
<div class="divider" style="margin:14px 0;"></div>
<div class="cinzel atm-label">VOICE COMMAND</div>
<div style="color:var(--muted);font-size:10px;margin-bottom:8px;">Record audio below to speak your action</div>
<button class="btn" id="voice-btn" onclick="startVoice()" style="font-size:11px;padding:10px;">🎙 RECORD VOICE</button>
<div id="voice-status" style="color:var(--muted);font-size:10px;margin-top:6px;"></div>
</div>
</div>
</div>
</div>
<!-- Loot overlay -->
<div id="loot-overlay" style="display:none;" class="overlay">
<div style="position:relative;padding:40px 20px;text-align:center;max-width:700px;width:100%;">
<div class="corner tl"></div><div class="corner tr"></div>
<div class="corner bl"></div><div class="corner br"></div>
<div class="loot-title">✦ SPOILS OF BATTLE ✦</div>
<div class="loot-sub">The dungeon yields its secrets.</div>
<div class="loot-cards" id="loot-cards"></div>
<button class="btn" style="max-width:240px;margin:0 auto;" onclick="closeLoot()">CLAIM YOUR SPOILS</button>
</div>
</div>
<!-- Victory overlay -->
<div id="victory-overlay" style="display:none;" class="victory-overlay">
<div style="position:relative;padding:48px 32px;max-width:560px;text-align:center;">
<div class="corner tl"></div><div class="corner tr"></div>
<div class="corner bl"></div><div class="corner br"></div>
<div class="victory-title">★ DUNGEON CLEARED ★</div>
<div class="divider" style="max-width:360px;margin:0 auto 20px;"></div>
<div class="fell" style="color:var(--muted);font-size:14px;line-height:1.8;margin-bottom:28px;" id="victory-text"></div>
<div style="display:flex;gap:28px;justify-content:center;margin-bottom:32px;flex-wrap:wrap;">
<div><div class="victory-stat" id="v-level"></div><div class="victory-stat-label">FINAL LEVEL</div></div>
<div><div class="victory-stat" id="v-xp"></div><div class="victory-stat-label">TOTAL XP</div></div>
<div><div class="victory-stat" id="v-items"></div><div class="victory-stat-label">ITEMS</div></div>
</div>
<button class="btn" style="max-width:280px;margin:0 auto;" onclick="document.getElementById('victory-overlay').style.display='none'">VIEW THE AFTERMATH</button>
</div>
</div>
<script>
// ════════════════════════════════════════════════════════════════════════════
// CONFIG & STATE
// ════════════════════════════════════════════════════════════════════════════
// IMPORTANT: relative path. Gradio is mounted at /gradio in app.py, so its
// own gradio_api lives at /gradio/gradio_api/run/<endpoint>. This works
// regardless of the Space's domain (HF Spaces, localhost, custom domain).
const API_BASE = '/gradio/gradio_api/run';
const SID = 'sq_' + Math.random().toString(36).slice(2);
const GS = {
photos: [{},{},{}], // slot objects
selectedClass: null,
gameState: null,
chronicle: [],
busy: false,
};
const CLASS_DATA = {
Swordsman:{icon:'⚔️',tagline:'The Iron Vanguard',stats:{HP:130,ATK:18,DEF:14,SPD:10},
color:'#3aafa9',perks:['Shield Bash — stun enemy 1 turn','Rallying Cry — +20 HP when below 30%','Armor Expertise — DEF items +50%']},
Archer:{icon:'🏹',tagline:'The Silent Wind',stats:{HP:100,ATK:22,DEF:8,SPD:18},
color:'#c9a84c',perks:['Piercing Shot — ignore 50% enemy DEF','Eagle Eye — first strike crits each room','Quiver Master — ranged never misses']},
Healer:{icon:'💚',tagline:'The Warden of Light',stats:{HP:115,ATK:10,DEF:16,SPD:12},
color:'#6bcf7f',perks:['Mend — restore 25 HP/turn (passive)','Holy Shield — 3-turn immunity 1/room','Life Tap — kill enemy → gain 20 HP']},
Rogue:{icon:'🗡️',tagline:'The Shadow Knife',stats:{HP:95,ATK:20,DEF:10,SPD:22},
color:'#c084fc',perks:['Backstab — first action 3× damage','Vanish — 30% dodge chance','Loot Sense — +1 item per room']},
Mage:{icon:'🔮',tagline:'The Arcane Eye',stats:{HP:90,ATK:28,DEF:6,SPD:14},
color:'#60a5fa',perks:['Arcane Burst — 40% chance double damage','Object Reading — detect hidden items','Mana Shield — DEF scales with ATK']},
};
const LOOT_TIERS = {
common: {color:'#9ca3af',glow:'none',label:'COMMON'},
uncommon: {color:'#4ade80',glow:'0 0 8px rgba(74,222,128,.5)',label:'UNCOMMON'},
rare: {color:'#60a5fa',glow:'0 0 12px rgba(96,165,250,.6)',label:'RARE'},
epic: {color:'#c084fc',glow:'0 0 16px rgba(192,132,252,.7)',label:'EPIC'},
legendary:{color:'#fbbf24',glow:'0 0 24px rgba(251,191,36,.8)',label:'LEGENDARY'},
};
// ════════════════════════════════════════════════════════════════════════════
// API CALLS — Gradio /gradio/gradio_api/run/<endpoint> endpoints
// ════════════════════════════════════════════════════════════════════════════
async function callAPI(endpoint, data) {
const res = await fetch(`${API_BASE}/${endpoint}`, {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({data}),
});
if (!res.ok) {
const text = await res.text().catch(()=>'');
throw new Error(`HTTP ${res.status} on /${endpoint}: ${text.slice(0,200)}`);
}
const json = await res.json();
const raw = (json && json.data) ? json.data[0] : json;
return typeof raw === 'string' ? JSON.parse(raw) : raw;
}
function showToast(msg) {
const t = document.getElementById('toast');
t.textContent = msg;
t.style.display = 'block';
clearTimeout(t._hideTimer);
t._hideTimer = setTimeout(()=>{ t.style.display='none'; }, 4000);
}
// ════════════════════════════════════════════════════════════════════════════
// SCREEN ROUTING
// ════════════════════════════════════════════════════════════════════════════
function showScreen(id) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById(id).classList.add('active');
window.scrollTo(0,0);
}
function showLoading(msg, sub) {
document.getElementById('loading-msg').textContent = msg || '⚗ CONSULTING THE ORACLE';
document.getElementById('loading-sub').textContent = sub || '';
document.getElementById('loading-screen').style.display = 'flex';
}
function hideLoading() { document.getElementById('loading-screen').style.display = 'none'; }
// ════════════════════════════════════════════════════════════════════════════
// SCREEN 1 — UPLOAD
// ════════════════════════════════════════════════════════════════════════════
const ROOM_LABELS = ['ENTRY HALL','INNER CHAMBER','BOSS LAIR ☠'];
const ROOM_ICONS = ['⚔','🏰','☠'];
function renderSlots() {
const grid = document.getElementById('slot-grid');
grid.innerHTML = '';
GS.photos.forEach((p, i) => {
const has = !!p.path && p.path !== 'pending';
const pending = p.path === 'pending';
let inner = '';
if (pending) {
inner = `
<div class="slot-label">${String(i+1).padStart(2,'0')} · ${ROOM_LABELS[i]}</div>
<img src="${p.dataUrl||''}" alt="room ${i+1}" style="opacity:.5;">
<div class="analyzing-label">⚗ ANALYZING...</div>`;
} else if (has) {
const tags = (p.objects||[]).map(o => `<span class="tag">${e(o)}</span>`).join('');
const boss = (i===2 && p.objects && p.objects[0])
? `<div class="boss-preview">☠ BOSS · ${e(p.objects[0].toUpperCase())}</div>` : '';
inner = `
<div class="slot-label">${String(i+1).padStart(2,'0')} · ${ROOM_LABELS[i]}</div>
<img src="${p.dataUrl||''}" alt="room ${i+1}">
<div class="analyzed">✓ ANALYZED</div>
<div class="tags">${tags}</div>${boss}`;
} else {
inner = `
<div class="slot-label">${String(i+1).padStart(2,'0')} · ${ROOM_LABELS[i]}</div>
<div class="slot-icon">${ROOM_ICONS[i]}</div>
<div class="slot-name">${ROOM_LABELS[i]}</div>
<div class="slot-opt">${i===0?'Required':'Optional'}</div>`;
}
const removeBtn = (has || pending) ? `<div class="remove-photo" onclick="event.stopPropagation();removeSlot(${i})">✕</div>` : '';
const div = document.createElement('div');
div.className = 'slot' + (has ? ' loaded' : '') + (pending ? ' analyzing' : '');
div.innerHTML = `
${inner}${removeBtn}
<div class="corner tl"></div><div class="corner tr"></div>
<div class="corner bl"></div><div class="corner br"></div>
<input type="file" id="file-${i}" accept="image/*" style="display:none" onchange="handleFile(${i},this)">`;
div.onclick = (ev) => {
if (ev.target.closest('.remove-photo')) return;
if (pending) return;
document.getElementById(`file-${i}`).click();
};
grid.appendChild(div);
});
renderS1Button();
}
function removeSlot(i) {
GS.photos[i] = {};
renderSlots();
}
function renderS1Button() {
const n = GS.photos.filter(p=>p.path && p.path !== 'pending').length;
const pending = GS.photos.some(p=>p.path === 'pending');
const area = document.getElementById('s1-btn-area');
const labels = {1:'1-room dungeon',2:'2-room dungeon',3:'3-room dungeon'};
if (pending) {
area.innerHTML = `<div class="btn-disabled">⚗ READING YOUR WORLD...</div>`;
} else if (n >= 1) {
area.innerHTML = `
<button class="btn" onclick="goToClassSelect()">⚔&nbsp;&nbsp;CHOOSE YOUR CLASS&nbsp;&nbsp;→</button>
<div class="btn-sub">${labels[n]||''} ready to be conquered</div>`;
} else {
area.innerHTML = `<div class="btn-disabled">UPLOAD AT LEAST ONE PHOTO TO BEGIN</div>`;
}
}
async function handleFile(slot, inp) {
if (!inp.files || !inp.files[0]) return;
const file = inp.files[0];
const reader = new FileReader();
reader.onload = async (ev) => {
const dataUrl = ev.target.result;
GS.photos[slot] = {dataUrl, path:'pending', objects:[], name:file.name};
renderSlots();
try {
const res = await callAPI('upload', [SID, slot, dataUrl, file.name]);
if (res && res.ok) {
const summary = res.photos[slot];
GS.photos[slot] = {dataUrl, path:'ready', objects:summary.objects||[], name:file.name};
} else {
GS.photos[slot] = {dataUrl, path:'ready', objects:['mysterious object'], name:file.name};
if (res && res.error) showToast('Vision read fallback used: ' + res.error);
}
} catch(err) {
GS.photos[slot] = {dataUrl, path:'ready', objects:['mysterious object'], name:file.name};
showToast('Upload error (fallback used): ' + err.message);
}
renderSlots();
};
reader.readAsDataURL(file);
}
function goToClassSelect() {
renderClassSelect();
showScreen('s2');
}
// ════════════════════════════════════════════════════════════════════════════
// SCREEN 2 — CLASS SELECT
// ════════════════════════════════════════════════════════════════════════════
function renderClassSelect() {
const n = GS.photos.filter(p=>p.path && p.path !== 'pending').length;
const summaries = {1:'1 photo → 1-room dungeon (straight to boss)',
2:'2 photos → 2-room dungeon (entry + boss)',
3:'3 photos → 3-room dungeon (entry, chamber, boss)'};
document.getElementById('s2-summary').textContent = summaries[n]||'';
const grid = document.getElementById('class-grid');
grid.innerHTML = '';
Object.entries(CLASS_DATA).forEach(([cls, d]) => {
const isSel = cls === GS.selectedClass;
const stats = Object.entries(d.stats).map(([k,v])=>
`<div class="stat-box"><div class="stat-val" style="color:${d.color}">${v}</div><div class="stat-key">${k}</div></div>`
).join('');
const perks = d.perks.map(p=>`<div class="perk">▸ ${e(p)}</div>`).join('');
const badge = isSel ? `<div class="chosen-badge" style="color:${d.color};">✓ CHOSEN</div>` : '';
const card = document.createElement('div');
card.className = 'class-card' + (isSel ? ' selected' : '');
card.style.borderColor = isSel ? d.color : '';
card.style.borderWidth = isSel ? '2px' : '';
card.innerHTML = `
<div class="class-icon">${d.icon}</div>
<div class="class-name" style="color:${d.color}">${cls.toUpperCase()}</div>
<div class="class-tag">${e(d.tagline)}</div>
<div class="divider" style="margin-bottom:10px;"></div>
<div class="stat-grid">${stats}</div>
<div>${perks}</div>
<div class="playstyle">"${e(d.perks[0])}"</div>
${badge}`;
card.onclick = () => { GS.selectedClass = cls; renderClassSelect(); };
grid.appendChild(card);
});
const area = document.getElementById('s2-btn-area');
if (GS.selectedClass) {
area.innerHTML = `<button class="btn" onclick="startDungeon()">⚔&nbsp;&nbsp;ENTER THE DUNGEON&nbsp;&nbsp;→</button>`;
} else {
area.innerHTML = `<div class="btn-disabled">CHOOSE A CLASS BEFORE YOU DESCEND</div>`;
}
}
async function startDungeon() {
if (!GS.selectedClass || GS.busy) return;
GS.busy = true;
showLoading('⚔ FORGING YOUR DUNGEON...', 'Building rooms from your photos...');
try {
const res = await callAPI('start', [SID, GS.selectedClass]);
hideLoading();
if (!res || !res.ok) {
showToast('Failed to start: ' + (res && res.error ? res.error : 'unknown error'));
GS.busy = false;
return;
}
GS.gameState = res.state;
GS.chronicle = [];
addChronicle('system', `═══ You descend into ${res.state.room.scene_name} ═══`);
addChronicle('dm', res.state.room.scene_description || '');
if (res.state.room.is_boss && res.state.boss) {
addChronicle('combat', `☠ ${res.state.boss.name} stirs in the darkness...`);
}
renderDungeon();
showScreen('s3');
} catch(err) {
hideLoading();
showToast('Failed to start: ' + err.message);
}
GS.busy = false;
}
// ════════════════════════════════════════════════════════════════════════════
// SCREEN 3 — DUNGEON
// ════════════════════════════════════════════════════════════════════════════
function renderDungeon() {
const gs = GS.gameState;
if (!gs) return;
const cd = CLASS_DATA[gs.character_class] || CLASS_DATA.Swordsman;
const room = gs.room;
const boss = gs.boss;
// HUD
const hpPct = Math.max(0,Math.min(100, gs.hp/gs.max_hp*100));
const hpColor = hpPct<30?'#cc2936':hpPct<60?'#c9841c':'#3aafa9';
document.getElementById('hud-hp').textContent = `${gs.hp}/${gs.max_hp}`;
document.getElementById('hud-hp').style.color = hpColor;
document.getElementById('hp-bar').style.width = hpPct+'%';
document.getElementById('hp-bar').style.background = hpColor;
document.getElementById('hud-diff').textContent = (room.difficulty||'').toUpperCase();
document.getElementById('hud-diff').style.color = {easy:'var(--teal)',medium:'#c9841c',hard:'var(--crimson)'}[room.difficulty]||'var(--teal)';
document.getElementById('hud-room').textContent = `ROOM ${gs.room_index+1}/${gs.total_rooms}`;
document.getElementById('hud-level').textContent = 'LVL ' + gs.level;
document.getElementById('hud-xp').textContent = (gs.xp%100)+'/100 XP';
// Minimap
const mm = document.getElementById('minimap');
mm.innerHTML = '';
gs.rooms_summary.forEach((r,i) => {
let c,sym,bc;
if(i===gs.room_index){c='#c9a84c';sym='▶';bc='2px solid #c9a84c';}
else if(r.cleared){c='#3aafa9';sym='✓';bc='1px solid #3aafa9';}
else if(r.is_boss){c='#cc2936';sym='☠';bc='1px dashed #cc2936';}
else{c='#1e1c14';sym=String(i+1);bc='1px solid #1e1c14';}
if(i>0) { const l=document.createElement('div'); l.className='mm-line'; mm.appendChild(l); }
const d=document.createElement('div');
d.className='mm-room'; d.style.border=bc; d.style.color=c;
d.title=r.scene_name; d.textContent=sym; mm.appendChild(d);
});
// Class sidebar
document.getElementById('cls-icon').textContent = cd.icon;
document.getElementById('cls-name').textContent = gs.character_class.toUpperCase();
document.getElementById('cls-name').style.color = cd.color;
document.getElementById('cls-tag').textContent = cd.tagline;
document.getElementById('cls-stats').innerHTML = Object.entries(cd.stats).map(([k,v])=>
`<div class="stat-box"><div class="stat-val" style="color:${cd.color}">${v}</div><div class="stat-key">${k}</div></div>`
).join('');
document.getElementById('cls-perks').innerHTML = cd.perks.map(p=>`<div class="perk-item">▸ ${e(p)}</div>`).join('');
// Inventory
document.getElementById('inv-count').textContent = gs.inventory.length;
const inv = document.getElementById('inventory');
if (!gs.inventory.length) {
inv.innerHTML = '<div class="inv-empty">Your satchel is empty.</div>';
} else {
inv.innerHTML = gs.inventory.map(item => {
if (typeof item === 'object' && item.tier) {
const td = LOOT_TIERS[item.tier]||LOOT_TIERS.common;
return `<div class="inv-item">
<span style="font-size:15px;min-width:20px;">${item.icon||'📦'}</span>
<div style="flex:1;min-width:0;">
<div style="color:${td.color};text-shadow:${td.glow};font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${e(item.name)}</div>
<div style="color:var(--muted);font-size:10px;">${e(item.stat||'')}</div>
</div>
<div style="font-size:9px;color:${td.color};opacity:.6;">${td.label}</div>
</div>`;
}
return `<div class="inv-item" style="color:var(--muted);">· ${e(String(item))}</div>`;
}).join('');
}
// Scene header
document.getElementById('scene-name').textContent = room.scene_name;
document.getElementById('monster-tags').innerHTML = (room.objects_found||[]).map((obj,i)=>
room.is_boss && i===0
? `<span class="tag boss-tag">☠ ${e(obj.toUpperCase())}</span>`
: `<span class="tag">${e(obj)}</span>`
).join('');
// Boss panel
const bpEl = document.getElementById('boss-panel');
if (room.is_boss && boss) {
const bPct = Math.max(0,Math.min(100,boss.hp/boss.max_hp*100));
if (boss.alive) {
bpEl.className = 'boss-panel';
bpEl.innerHTML = `
<div class="corner tl"></div><div class="corner tr"></div>
<div class="corner bl"></div><div class="corner br"></div>
<div style="color:var(--crimson);font-size:9px;letter-spacing:.3em;margin-bottom:4px;">☠ BOSS ENCOUNTER</div>
<div class="cinzel" style="color:#ff8888;font-size:15px;margin-bottom:8px;">${e(boss.name)}</div>
<div class="bar-track" style="margin-bottom:4px;">
<div class="bar-fill" style="width:${bPct}%;background:linear-gradient(90deg,#4a0a0e,#cc2936);"></div>
</div>
<div style="color:var(--crimson);font-size:11px;">${boss.hp} / ${boss.max_hp} HP</div>`;
} else {
bpEl.className = 'boss-panel defeated';
bpEl.innerHTML = `<div class="cinzel" style="color:var(--teal);letter-spacing:.15em;">✓ ${e(boss.name)} SLAIN</div>`;
}
} else {
bpEl.innerHTML = '';
bpEl.className = '';
}
// Advance button
const advEl = document.getElementById('advance-area');
if (room.cleared && (gs.room_index+1) < gs.total_rooms) {
advEl.innerHTML = `<button class="advance-btn" onclick="doAction('go deeper')">▶&nbsp;&nbsp;DESCEND DEEPER&nbsp;&nbsp;→</button>`;
} else {
advEl.innerHTML = '';
}
// Choices
const isBossActive = room.is_boss && boss && boss.alive;
document.getElementById('choices').innerHTML = (gs.choices||[]).slice(0,3).map((c,i)=>
`<button class="choice-btn${isBossActive?' boss-mode':''}" onclick="doAction(${JSON.stringify(c)})">
<span style="color:var(--gold-dim);margin-right:6px;">${['①','②','③'][i]}</span>${e(c)}
</button>`
).join('');
// Atmosphere
document.getElementById('atmosphere').textContent = room.atmosphere||room.scene_description||'The dungeon watches.';
const diffC = {easy:'var(--teal)',medium:'#c9841c',hard:'var(--crimson)'}[room.difficulty]||'var(--teal)';
document.getElementById('room-status').innerHTML = `
Difficulty:&nbsp;<span style="color:${diffC}">${(room.difficulty||'').toUpperCase()}</span><br>
Enemies:&nbsp;<span style="color:${room.enemy_alive?'var(--crimson)':'var(--teal)'};">${room.enemy_alive?'Active':'Defeated'}</span><br>
Room:&nbsp;${gs.room_index+1} of ${gs.total_rooms}`;
// Scroll chronicle
renderChronicle();
// Victory check
if (room.is_boss && boss && !boss.alive && gs.room_index === gs.total_rooms-1) {
showVictory(gs, boss);
}
}
function renderChronicle() {
const ch = document.getElementById('chronicle');
ch.innerHTML = GS.chronicle.map(({type,text}) => `<div class="cl-${type}">${e(text)}</div>`).join('');
ch.scrollTop = ch.scrollHeight;
}
function addChronicle(type, text) {
if (!text || !text.trim()) return;
text.split('\n').forEach(line => {
if (!line.trim()) { GS.chronicle.push({type:'system',text:'\u00A0'}); return; }
let t = type;
if (line.startsWith('▷')) t='player';
else if (line.includes('⚔')||line.includes('💀')) t='combat';
else if (line.includes('✅')||line.includes('🏆')) t='victory';
else if (line.includes('💰')||line.includes('✨')) t='loot';
else if (line.startsWith('═')||line.startsWith('─')) t='div';
else if (line.startsWith('[')) t='system';
GS.chronicle.push({type:t, text:line});
});
}
function setBusy(busy) {
GS.busy = busy;
const actBtn = document.getElementById('act-btn');
const input = document.getElementById('action-input');
if (actBtn) actBtn.disabled = busy;
if (input) input.disabled = busy;
document.querySelectorAll('.choice-btn').forEach(b => b.disabled = busy);
const advBtn = document.querySelector('.advance-btn');
if (advBtn) advBtn.disabled = busy;
}
async function doAction(text) {
text = (text||'').trim();
if (!text || GS.busy) return;
document.getElementById('action-input').value = '';
addChronicle('player', `▷ ${text}`);
GS.chronicle.push({type:'thinking', text:'__THINKING__'});
renderChronicleWithThinking();
setBusy(true);
try {
const res = await callAPI('action', [SID, text]);
GS.chronicle = GS.chronicle.filter(c => c.type !== 'thinking');
if (!res || !res.ok) {
addChronicle('system', `[error] ${res && res.error ? res.error : 'unknown error'}`);
setBusy(false);
renderChronicle();
return;
}
GS.gameState = res.state;
if (res.story) addChronicle('dm', res.story);
setBusy(false);
if (res.loot && res.loot.length) showLoot(res.loot);
if (res.audio_b64) playAudio(res.audio_b64);
renderDungeon();
} catch(err) {
GS.chronicle = GS.chronicle.filter(c => c.type !== 'thinking');
addChronicle('system', `[error] ${err.message}`);
setBusy(false);
renderChronicle();
}
}
function renderChronicleWithThinking() {
const ch = document.getElementById('chronicle');
ch.innerHTML = GS.chronicle.map(({type,text}) => {
if (type === 'thinking') {
return `<div class="thinking">THE DUNGEON STIRS <span class="dot"></span><span class="dot"></span><span class="dot"></span></div>`;
}
return `<div class="cl-${type}">${e(text)}</div>`;
}).join('');
ch.scrollTop = ch.scrollHeight;
}
// ════════════════════════════════════════════════════════════════════════════
// LOOT / VICTORY / VOICE
// ════════════════════════════════════════════════════════════════════════════
function showLoot(items) {
document.getElementById('loot-cards').innerHTML = items.map(item => {
const td = LOOT_TIERS[item.tier]||LOOT_TIERS.common;
return `<div class="loot-card" style="border-color:${td.color};box-shadow:${td.glow};">
<div class="loot-icon">${item.icon||'📦'}</div>
<div class="loot-tier" style="color:${td.color};">${td.label}</div>
<div class="loot-name">${e(item.name)}</div>
<div class="loot-stat">${e(item.stat||'')}</div>
</div>`;
}).join('');
document.getElementById('loot-overlay').style.display = 'flex';
}
function closeLoot() { document.getElementById('loot-overlay').style.display = 'none'; }
function showVictory(gs, boss) {
document.getElementById('v-level').textContent = gs.level;
document.getElementById('v-xp').textContent = gs.xp;
document.getElementById('v-items').textContent = gs.inventory.length;
document.getElementById('victory-text').textContent =
`You have slain ${boss.name} and emerged victorious. The dungeon remembers your name.`;
setTimeout(()=>{ document.getElementById('victory-overlay').style.display = 'flex'; }, 600);
}
function playAudio(b64) {
try {
const blob = b64ToBlob(b64, 'audio/mpeg');
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audio.play().catch(()=>{});
} catch(e) {}
}
function b64ToBlob(b64, type) {
const bin = atob(b64);
const arr = new Uint8Array(bin.length);
for(let i=0;i<bin.length;i++) arr[i]=bin.charCodeAt(i);
return new Blob([arr],{type});
}
// Voice recording
let mediaRec=null, chunks=[];
async function startVoice() {
const btn = document.getElementById('voice-btn');
if (mediaRec && mediaRec.state==='recording') {
mediaRec.stop();
btn.textContent = '🎙 RECORD VOICE';
document.getElementById('voice-status').textContent = 'Processing...';
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({audio:true});
chunks = [];
mediaRec = new MediaRecorder(stream);
mediaRec.ondataavailable = ev => chunks.push(ev.data);
mediaRec.onstop = async () => {
stream.getTracks().forEach(t => t.stop());
const blob = new Blob(chunks, {type:'audio/webm'});
const reader = new FileReader();
reader.onload = async ev => {
const b64 = ev.target.result.split(',')[1];
document.getElementById('voice-status').textContent = 'Sending to Whisper...';
setBusy(true);
try {
const res = await callAPI('voice', [SID, b64]);
if (res.transcribed) {
document.getElementById('voice-status').textContent = `"${res.transcribed}"`;
if (res.ok) {
GS.gameState = res.state;
if (res.story) addChronicle('dm', res.story);
if (res.loot && res.loot.length) showLoot(res.loot);
if (res.audio_b64) playAudio(res.audio_b64);
renderDungeon();
}
} else {
document.getElementById('voice-status').textContent = 'Could not transcribe, try again.';
}
} catch(err) {
document.getElementById('voice-status').textContent = 'Voice error: '+err.message;
}
setBusy(false);
};
reader.readAsDataURL(blob);
};
mediaRec.start();
btn.textContent = '⏹ STOP RECORDING';
document.getElementById('voice-status').textContent = '● Recording...';
} catch(err) {
document.getElementById('voice-status').textContent = 'Mic error: '+err.message;
}
}
// ════════════════════════════════════════════════════════════════════════════
// UTILS
// ════════════════════════════════════════════════════════════════════════════
function e(str) {
return String(str||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ════════════════════════════════════════════════════════════════════════════
// INIT
// ════════════════════════════════════════════════════════════════════════════
renderSlots();
</script>
</body>
</html>