Spaces:
Runtime error
Runtime error
| <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()">⚔ CHOOSE YOUR CLASS →</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()">⚔ ENTER THE DUNGEON →</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')">▶ DESCEND DEEPER →</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: <span style="color:${diffC}">${(room.difficulty||'').toUpperCase()}</span><br> | |
| Enemies: <span style="color:${room.enemy_alive?'var(--crimson)':'var(--teal)'};">${room.enemy_alive?'Active':'Defeated'}</span><br> | |
| Room: ${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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); | |
| } | |
| // ════════════════════════════════════════════════════════════════════════════ | |
| // INIT | |
| // ════════════════════════════════════════════════════════════════════════════ | |
| renderSlots(); | |
| </script> | |
| </body> | |
| </html> |