| <!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8"/> |
| <meta name="viewport" content="width=device-width, initial-scale=1"/> |
| <title>Grid Royale — Battle Arena</title> |
| <link rel="preconnect" href="https://fonts.googleapis.com"> |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
| <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@300;400;500;600&family=Instrument+Serif:ital@0;1&display=swap" rel="stylesheet"> |
| <script type="importmap"> |
| { |
| "imports": { |
| "three": "https://unpkg.com/three@0.160.0/build/three.module.js" |
| } |
| } |
| </script> |
| <style> |
| |
| |
| |
| :root { |
| --bg: #07070d; |
| --surface: #0f0f1a; |
| --surface2: #16162a; |
| --surface3: #1e1e35; |
| --border: #232340; |
| --border2: #2d2d52; |
| --text: #e8e6f0; |
| --text-dim: #6b6b90; |
| --text-muted: #35354a; |
| --accent: #ff4655; |
| --accent2: #ff6b7a; |
| --accent-glow: rgba(255,70,85,0.35); |
| --gold: #ffd700; |
| --gold-dim: rgba(255,215,0,0.15); |
| --cyan: #00e5cc; |
| --cyan-dim: rgba(0,229,204,0.15); |
| --blue: #4a9eff; |
| --blue-dim: rgba(74,158,255,0.15); |
| --purple: #b44aff; |
| } |
| |
| * { box-sizing: border-box; margin: 0; padding: 0; } |
| html, body { height: 100%; overflow: hidden; } |
| body { |
| background: var(--bg); |
| color: var(--text); |
| font-family: 'Space Grotesk', system-ui, sans-serif; |
| -webkit-font-smoothing: antialiased; |
| } |
| ::-webkit-scrollbar { width: 3px; } |
| ::-webkit-scrollbar-track { background: transparent; } |
| ::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; } |
| |
| |
| #lobby { |
| position: fixed; inset: 0; z-index: 200; |
| display: flex; align-items: center; justify-content: center; |
| gap: 0; |
| background: var(--bg); |
| transition: opacity 0.8s cubic-bezier(0.4,0,0.2,1), transform 0.8s cubic-bezier(0.4,0,0.2,1); |
| } |
| #lobby.hidden { opacity: 0; transform: scale(0.96); pointer-events: none; } |
| #lobbyParticles { display: none; } |
| |
| .lobby-inner { |
| position: relative; z-index: 1; |
| display: flex; flex-direction: column; align-items: center; gap: 2.0rem; |
| } |
| .lobby-brand { |
| text-align: center; |
| } |
| .lobby-title { |
| font-family: 'Space Grotesk', sans-serif; |
| font-size: clamp(36px, 6vw, 56px); |
| font-weight: 500; |
| letter-spacing: -1px; line-height: 1.0; |
| color: var(--text); |
| } |
| .lobby-title em { |
| font-style: normal; |
| color: var(--text-dim); |
| } |
| .lobby-eyebrow { |
| margin-top: 10px; |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 10px; text-transform: uppercase; letter-spacing: 0.4em; |
| color: var(--text-dim); |
| } |
| .lobby-eyebrow span { color: var(--purple); } |
| |
| .lobby-panel { |
| width: 380px; |
| background: var(--surface); |
| border: 1px solid var(--border); |
| border-radius: 12px; padding: 2rem 1.75rem; |
| } |
| .lobby-section-label { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 9px; text-transform: uppercase; letter-spacing: 0.25em; |
| color: var(--text-muted); margin-bottom: 16px; |
| display: flex; align-items: center; gap: 8px; |
| } |
| .lobby-section-label::after { |
| content: ''; flex: 1; height: 1px; background: var(--border); |
| } |
| .lobby-row { |
| display: flex; justify-content: space-between; align-items: center; |
| gap: 12px; margin-bottom: 14px; |
| } |
| .lobby-row label { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; |
| color: var(--text-dim); min-width: 72px; |
| } |
| .lobby-row .range-wrap { flex: 1; display: flex; align-items: center; gap: 10px; } |
| .lobby-row input[type=range] { |
| flex: 1; -webkit-appearance: none; appearance: none; |
| background: transparent; height: 20px; cursor: pointer; |
| } |
| .lobby-row input[type=range]::-webkit-slider-runnable-track { |
| height: 2px; |
| background: var(--border2); |
| border-radius: 1px; |
| } |
| .lobby-row input[type=range]::-webkit-slider-thumb { |
| -webkit-appearance: none; width: 12px; height: 12px; |
| border-radius: 50%; background: var(--text); border: none; |
| cursor: pointer; margin-top: -5px; |
| transition: transform 0.2s; |
| } |
| .lobby-row input[type=range]:hover::-webkit-slider-thumb { |
| transform: scale(1.15); |
| } |
| .range-val { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 15px; font-weight: 600; color: var(--text); |
| width: 28px; text-align: right; |
| } |
| |
| .lobby-prompt-wrap { |
| margin-top: 10px; |
| margin-bottom: 12px; |
| width: 100%; |
| } |
| #systemPromptInput { |
| width: 100%; |
| height: 120px; |
| background: var(--surface); |
| border: 1px solid var(--border); |
| border-radius: 8px; |
| color: var(--text); |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 10px; |
| line-height: 1.4; |
| padding: 8px 10px; |
| resize: vertical; |
| outline: none; |
| transition: border-color 0.2s, box-shadow 0.2s; |
| } |
| #systemPromptInput:focus { |
| border-color: var(--text-muted); |
| } |
| |
| .lobby-prompt-tabs { |
| display: flex; |
| gap: 8px; |
| margin-top: 10px; |
| margin-bottom: 2px; |
| flex-wrap: wrap; |
| width: 100%; |
| } |
| .lobby-tab { |
| padding: 6px 12px; |
| border-radius: 6px; |
| background: var(--surface2); |
| border: 1px solid var(--border); |
| color: var(--text-dim); |
| font-family: 'Space Grotesk', sans-serif; |
| font-size: 11px; |
| font-weight: 600; |
| cursor: pointer; |
| transition: all 0.2s ease; |
| user-select: none; |
| } |
| .lobby-tab:hover { |
| border-color: var(--text-muted); |
| color: var(--text); |
| } |
| .lobby-tab.active { |
| background: var(--surface2); |
| border-color: var(--text); |
| color: var(--text); |
| } |
| |
| .lobby-agent-edit-row { |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| margin-top: 10px; |
| margin-bottom: 8px; |
| width: 100%; |
| background: var(--surface2); |
| border: 1px solid var(--border); |
| border-radius: 8px; |
| padding: 8px 12px; |
| transition: border-color 0.2s, box-shadow 0.2s; |
| } |
| .lobby-agent-edit-row:focus-within { |
| border-color: var(--text-muted); |
| } |
| .lobby-agent-color-dot { |
| width: 10px; |
| height: 10px; |
| border-radius: 50%; |
| background: var(--accent); |
| flex-shrink: 0; |
| transition: all 0.2s ease; |
| } |
| .lobby-agent-name-input { |
| flex: 1; |
| background: transparent; |
| border: none; |
| outline: none; |
| color: var(--text); |
| font-family: 'Space Grotesk', sans-serif; |
| font-size: 13px; |
| font-weight: 600; |
| padding: 0; |
| } |
| .lobby-agent-name-input::placeholder { |
| color: var(--text-dim); |
| } |
| |
| .lobby-deploy { |
| width: 100%; margin-top: 24px; |
| padding: 14px; border: 1px solid var(--border); border-radius: 8px; |
| background: var(--surface2); |
| color: var(--text); |
| font-family: 'Space Grotesk', sans-serif; |
| font-size: 12px; font-weight: 600; |
| text-transform: uppercase; letter-spacing: 0.15em; |
| cursor: pointer; |
| transition: all 0.2s ease; |
| } |
| .lobby-deploy:hover { |
| background: var(--text); |
| color: var(--bg); |
| border-color: var(--text); |
| } |
| .lobby-deploy:disabled { opacity: 0.4; cursor: not-allowed; } |
| |
| .ctrl-btn.warming { |
| border-color: var(--accent); |
| color: var(--accent); |
| box-shadow: 0 0 10px var(--accent-glow); |
| animation: warmPulse 1.0s infinite alternate; |
| } |
| .ctrl-btn.warmed { |
| border-color: var(--cyan); |
| color: var(--cyan); |
| box-shadow: 0 0 10px rgba(0,229,255,0.2); |
| } |
| @keyframes warmPulse { |
| 0% { opacity: 0.6; } |
| 100% { opacity: 1; } |
| } |
| |
| |
| #game { |
| display: none; flex-direction: column; height: 100vh; |
| opacity: 0; transition: opacity 0.5s ease; |
| } |
| #game.visible { display: flex; opacity: 1; } |
| |
| |
| .top-bar { |
| display: grid; |
| grid-template-columns: auto 1fr auto; |
| align-items: center; |
| padding: 0 16px; height: 56px; |
| background: rgba(10,10,20,0.95); |
| backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); |
| border-bottom: 1px solid var(--border); |
| flex-shrink: 0; z-index: 10; |
| gap: 16px; |
| } |
| .top-brand { |
| display: flex; align-items: center; gap: 10px; |
| } |
| .top-brand-logo { |
| font-family: 'Instrument Serif', serif; |
| font-size: 18px; color: var(--accent); |
| font-style: italic; letter-spacing: -0.5px; |
| line-height: 1; |
| } |
| .top-brand-sep { width: 1px; height: 20px; background: var(--border); } |
| .top-brand-turn { |
| display: flex; flex-direction: column; gap: 2px; |
| } |
| .top-brand-turn-label { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 9px; text-transform: uppercase; letter-spacing: 0.15em; |
| color: var(--text-muted); |
| } |
| .top-brand-turn-val { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 13px; font-weight: 600; color: var(--text); |
| } |
| .top-brand-turn-val.flash { animation: numFlash 0.35s ease; } |
| @keyframes numFlash { |
| 0% { color: var(--accent); } |
| 100% { color: var(--text); } |
| } |
| |
| |
| .top-stats { |
| display: flex; align-items: center; justify-content: center; |
| gap: 6px; |
| } |
| .stat-pill { |
| display: flex; align-items: center; gap: 6px; |
| padding: 5px 12px; border-radius: 8px; |
| background: var(--surface); border: 1px solid var(--border); |
| transition: all 0.2s; |
| cursor: default; |
| } |
| .stat-pill:hover { border-color: var(--border2); background: var(--surface2); } |
| .stat-pill-icon { font-size: 13px; line-height: 1; } |
| .stat-pill-body { display: flex; flex-direction: column; gap: 0; } |
| .stat-pill-label { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 8px; text-transform: uppercase; letter-spacing: 0.12em; |
| color: var(--text-muted); line-height: 1; |
| } |
| .stat-pill-val { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 13px; font-weight: 600; line-height: 1.2; |
| } |
| .stat-pill-val.cyan { color: var(--cyan); } |
| .stat-pill-val.gold { color: var(--gold); } |
| .stat-pill-val.accent { color: var(--accent); } |
| .stat-pill-val.purple { color: var(--purple); } |
| |
| |
| .turn-progress-bar { |
| position: absolute; bottom: 0; left: 16px; right: 16px; height: 2px; |
| } |
| .turn-progress-fill { |
| height: 100%; border-radius: 1px; |
| background: linear-gradient(90deg, var(--purple), var(--accent)); |
| transition: width 0.4s ease; |
| } |
| |
| |
| .top-dots { display: flex; align-items: center; gap: 5px; } |
| .agent-dot { |
| width: 9px; height: 9px; border-radius: 50%; |
| transition: all 0.4s ease; |
| position: relative; |
| } |
| .agent-dot.alive { box-shadow: 0 0 8px currentColor; } |
| .agent-dot.dead { opacity: 0.2; transform: scale(0.55); } |
| .agent-dot.selected { ring: 2px white; box-shadow: 0 0 0 2px #fff, 0 0 12px currentColor; } |
| |
| |
| .top-right { |
| display: flex; align-items: center; gap: 10px; |
| } |
| .camera-mode-badge { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 9px; text-transform: uppercase; letter-spacing: 0.15em; |
| color: var(--text-dim); |
| background: rgba(255,255,255,0.04); |
| border: 1px solid var(--border); |
| padding: 4px 10px; border-radius: 5px; |
| cursor: pointer; transition: all 0.15s ease; |
| } |
| .camera-mode-badge:hover:not(:disabled) { |
| color: var(--purple); border-color: rgba(180,74,255,0.5); |
| background: rgba(180,74,255,0.08); |
| } |
| .camera-mode-badge.active { |
| color: var(--purple); border-color: rgba(180,74,255,0.5); |
| background: rgba(180,74,255,0.15); |
| box-shadow: 0 0 12px rgba(180,74,255,0.25); |
| } |
| .camera-mode-badge:disabled { |
| opacity: 0.4; cursor: not-allowed; |
| } |
| .winner-announce { |
| font-family: 'Space Grotesk', sans-serif; |
| font-size: 12px; font-weight: 700; color: var(--gold); |
| animation: winGlow 1s ease-in-out infinite alternate; |
| display: none; |
| } |
| @keyframes winGlow { |
| from { text-shadow: 0 0 8px rgba(255,215,0,0.3); } |
| to { text-shadow: 0 0 25px rgba(255,215,0,0.7); } |
| } |
| |
| |
| .main-layout { display: flex; flex: 1; min-height: 0; } |
| |
| |
| .scene-wrap { |
| flex: 1; position: relative; background: var(--bg); min-height: 0; |
| overflow: hidden; |
| } |
| #sceneCanvas { position: absolute; inset: 0; } |
| #sceneCanvas canvas { display: block; width: 100% !important; height: 100% !important; } |
| |
| |
| #fpsHud { |
| position: absolute; inset: 0; z-index: 20; |
| pointer-events: none; |
| opacity: 0; transition: opacity 0.5s ease; |
| } |
| #fpsHud.active { opacity: 1; } |
| |
| |
| .fps-vignette { |
| position: absolute; inset: 0; |
| background: radial-gradient(ellipse 80% 80% at 50% 50%, |
| transparent 40%, rgba(0,0,0,0.5) 100%); |
| } |
| |
| |
| .fps-agent-border { |
| position: absolute; inset: 0; |
| border: 2px solid transparent; |
| border-radius: 0; |
| transition: border-color 0.4s; |
| pointer-events: none; |
| } |
| |
| |
| |
| .fps-reticle { |
| position: absolute; top: 50%; left: 50%; |
| transform: translate(-50%, -50%); |
| width: 64px; height: 64px; |
| pointer-events: none; |
| } |
| .fps-reticle-ring { |
| position: absolute; inset: 0; |
| border-radius: 50%; |
| border: 1.5px solid rgba(255,255,255,0.2); |
| animation: reticleBreath 2.5s ease-in-out infinite alternate; |
| } |
| @keyframes reticleBreath { |
| from { transform: scale(1); opacity: 0.25; } |
| to { transform: scale(1.1); opacity: 0.5; } |
| } |
| .fps-reticle-corner { |
| position: absolute; width: 12px; height: 12px; |
| border-color: rgba(255,255,255,0.7); border-style: solid; |
| } |
| .fps-reticle-corner.tl { top: 0; left: 0; border-width: 2px 0 0 2px; border-radius: 2px 0 0 0; } |
| .fps-reticle-corner.tr { top: 0; right: 0; border-width: 2px 2px 0 0; border-radius: 0 2px 0 0; } |
| .fps-reticle-corner.bl { bottom: 0; left: 0; border-width: 0 0 2px 2px; border-radius: 0 0 0 2px; } |
| .fps-reticle-corner.br { bottom: 0; right: 0; border-width: 0 2px 2px 0; border-radius: 0 0 2px 0; } |
| .fps-reticle-dot { |
| position: absolute; width: 4px; height: 4px; |
| background: rgba(255,255,255,0.8); border-radius: 50%; |
| left: 50%; top: 50%; transform: translate(-50%,-50%); |
| } |
| |
| |
| .fps-compass { |
| position: absolute; top: 12px; left: 50%; |
| transform: translateX(-50%); |
| display: flex; align-items: center; gap: 4px; |
| background: rgba(0,0,0,0.5); |
| backdrop-filter: blur(8px); |
| padding: 4px 12px; border-radius: 8px; |
| border: 1px solid rgba(255,255,255,0.1); |
| } |
| .fps-compass-letter { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 11px; font-weight: 600; |
| color: rgba(255,255,255,0.4); |
| width: 22px; text-align: center; |
| } |
| .fps-compass-letter.active { color: var(--accent); } |
| |
| |
| .fps-agent-info { |
| position: absolute; bottom: 80px; left: 24px; |
| display: flex; flex-direction: column; gap: 4px; |
| } |
| .fps-agent-name { |
| font-family: 'Space Grotesk', sans-serif; |
| font-size: 15px; font-weight: 700; |
| } |
| .fps-agent-pos { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 11px; color: rgba(255,255,255,0.5); |
| } |
| |
| |
| .fps-hp-bar-wrap { |
| position: absolute; bottom: 80px; right: 24px; |
| display: flex; flex-direction: column; align-items: flex-end; gap: 3px; |
| } |
| .fps-hp-label { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 9px; text-transform: uppercase; letter-spacing: 0.15em; |
| color: rgba(255,255,255,0.4); |
| } |
| .fps-hp-val { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 20px; font-weight: 700; |
| } |
| .fps-hp-bar { |
| width: 120px; height: 4px; background: rgba(255,255,255,0.1); |
| border-radius: 2px; overflow: hidden; |
| } |
| .fps-hp-bar-fill { |
| height: 100%; border-radius: 2px; |
| transition: width 0.4s ease, background 0.4s; |
| } |
| |
| |
| .fps-radar { |
| position: absolute; bottom: 70px; left: 50%; |
| transform: translateX(-50%); |
| width: 90px; height: 90px; |
| background: rgba(0,0,0,0.55); |
| backdrop-filter: blur(8px); |
| border: 1px solid rgba(255,255,255,0.08); |
| border-radius: 50%; |
| overflow: hidden; |
| } |
| .fps-radar-sweep { |
| position: absolute; inset: 0; |
| background: conic-gradient(from 0deg, rgba(0,229,204,0.15) 0deg, transparent 60deg); |
| animation: radarSweep 3s linear infinite; |
| } |
| @keyframes radarSweep { to { transform: rotate(360deg); } } |
| .fps-radar-rings { |
| position: absolute; inset: 25%; border-radius: 50%; |
| border: 1px solid rgba(0,229,204,0.1); |
| } |
| .fps-radar-center { |
| position: absolute; width: 5px; height: 5px; |
| background: var(--cyan); border-radius: 50%; |
| left: 50%; top: 50%; transform: translate(-50%, -50%); |
| box-shadow: 0 0 8px var(--cyan); |
| } |
| .fps-radar-blip { |
| position: absolute; width: 5px; height: 5px; |
| border-radius: 50%; |
| transform: translate(-50%, -50%); |
| } |
| |
| |
| .fps-exit-btn { |
| position: absolute; top: 12px; right: 12px; |
| pointer-events: auto; |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; |
| color: rgba(255,255,255,0.5); |
| background: rgba(0,0,0,0.5); |
| border: 1px solid rgba(255,255,255,0.1); |
| padding: 5px 10px; border-radius: 6px; |
| cursor: pointer; transition: all 0.2s; |
| } |
| .fps-exit-btn:hover { color: #fff; border-color: rgba(255,255,255,0.3); } |
| |
| |
| #killFeed { |
| position: absolute; top: 16px; left: 16px; z-index: 18; |
| display: flex; flex-direction: column; gap: 5px; |
| pointer-events: none; max-width: 280px; |
| } |
| .kill-toast { |
| display: flex; align-items: center; gap: 8px; |
| padding: 7px 12px; |
| background: rgba(7,7,13,0.9); |
| backdrop-filter: blur(12px); |
| border: 1px solid rgba(35,35,64,0.6); |
| border-radius: 8px; |
| font-size: 11px; font-weight: 500; |
| animation: toastIn 0.25s ease forwards; |
| opacity: 0; transform: translateX(-16px); |
| } |
| .kill-toast.fade-out { animation: toastOut 0.35s ease forwards; } |
| @keyframes toastIn { to { opacity: 1; transform: translateX(0); } } |
| @keyframes toastOut { from { opacity: 1; transform: translateX(0); } to { opacity: 0; transform: translateX(-20px) scale(0.94); } } |
| .kill-toast.kill { border-left: 3px solid var(--accent); } |
| .kill-toast.loot { border-left: 3px solid var(--gold); } |
| .kill-toast.ability { border-left: 3px solid var(--blue); } |
| .kill-toast.damage { border-left: 3px solid #ff9100; } |
| |
| |
| .loading-overlay { |
| position: absolute; inset: 0; z-index: 15; |
| display: flex; align-items: center; justify-content: center; |
| background: rgba(7,7,13,0.8); |
| backdrop-filter: blur(6px); |
| opacity: 0; pointer-events: none; |
| transition: opacity 0.25s ease; |
| } |
| .loading-overlay.active { opacity: 1; pointer-events: auto; } |
| .loading-spinner { display: flex; flex-direction: column; align-items: center; gap: 14px; } |
| .loading-spinner .ring { |
| width: 32px; height: 32px; |
| border: 2px solid var(--border2); |
| border-top-color: var(--accent); |
| border-radius: 50%; |
| animation: spin 0.75s linear infinite; |
| } |
| @keyframes spin { to { transform: rotate(360deg); } } |
| .loading-spinner span { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 11px; color: var(--text-dim); |
| animation: loadPulse 1.4s ease-in-out infinite; |
| } |
| @keyframes loadPulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } } |
| |
| |
| .sidebar { |
| width: 300px; flex-shrink: 0; |
| display: flex; flex-direction: column; |
| background: rgba(10,10,20,0.97); |
| border-left: 1px solid var(--border); |
| overflow-y: auto; overflow-x: hidden; |
| } |
| |
| |
| .section-head { |
| display: flex; align-items: center; justify-content: space-between; |
| padding: 10px 14px; |
| border-bottom: 1px solid var(--border); |
| flex-shrink: 0; |
| } |
| .section-head-label { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 9px; text-transform: uppercase; letter-spacing: 0.2em; |
| color: var(--text-muted); display: flex; align-items: center; gap: 5px; |
| } |
| .section-head-label::before { |
| content: ''; width: 2px; height: 10px; |
| background: var(--accent); border-radius: 1px; |
| } |
| |
| |
| .lb-container { padding: 8px 10px; flex-shrink: 0; } |
| .lb-row { |
| display: flex; align-items: center; gap: 9px; |
| padding: 8px 8px; border-radius: 10px; |
| cursor: pointer; position: relative; |
| transition: all 0.2s ease; |
| margin-bottom: 2px; |
| } |
| .lb-row:hover { background: rgba(30,30,53,0.7); } |
| .lb-row.selected { |
| background: rgba(255,70,85,0.06); |
| box-shadow: inset 0 0 0 1px rgba(255,70,85,0.18); |
| } |
| .lb-row.dead { opacity: 0.3; } |
| .lb-rank { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 9px; color: var(--text-muted); |
| width: 14px; text-align: center; flex-shrink: 0; |
| } |
| .lb-avatar-wrap { position: relative; flex-shrink: 0; } |
| .lb-avatar { |
| width: 30px; height: 30px; border-radius: 50%; |
| display: flex; align-items: center; justify-content: center; |
| font-weight: 700; color: #000; font-size: 11px; |
| transition: box-shadow 0.3s; |
| } |
| .lb-avatar-ring { |
| position: absolute; inset: -2px; border-radius: 50%; |
| border: 2px solid transparent; |
| transition: border-color 0.3s, opacity 0.3s; |
| } |
| .lb-row.selected .lb-avatar-ring { opacity: 1 !important; } |
| .lb-avatar-status { |
| position: absolute; bottom: 0; right: 0; |
| width: 8px; height: 8px; border-radius: 50%; |
| border: 1.5px solid var(--bg); |
| } |
| .lb-info { flex: 1; min-width: 0; } |
| .lb-name { |
| font-weight: 600; font-size: 12px; |
| overflow: hidden; text-overflow: ellipsis; white-space: nowrap; |
| } |
| .lb-meta { |
| display: flex; align-items: center; gap: 6px; margin-top: 3px; |
| } |
| .lb-kill-badge { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 9px; color: var(--accent); |
| background: rgba(255,70,85,0.1); |
| padding: 1px 4px; border-radius: 3px; |
| } |
| .lb-hp-wrap { flex: 1; } |
| .lb-hp-bar { |
| width: 100%; height: 2px; background: var(--border); |
| border-radius: 1px; overflow: hidden; |
| } |
| .lb-hp-fill { |
| height: 100%; border-radius: 1px; |
| transition: width 0.4s ease, background 0.4s; |
| } |
| .lb-hp-pct { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 8px; color: var(--text-muted); margin-top: 1px; |
| } |
| .lb-score { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 12px; font-weight: 600; color: var(--gold); |
| min-width: 32px; text-align: right; flex-shrink: 0; |
| } |
| |
| |
| .detail-back { |
| display: flex; align-items: center; gap: 7px; |
| padding: 8px 14px 6px; |
| cursor: pointer; |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 9px; text-transform: uppercase; letter-spacing: 0.1em; |
| color: var(--text-dim); border-bottom: 1px solid var(--border); |
| transition: color 0.2s; |
| flex-shrink: 0; |
| } |
| .detail-back:hover { color: var(--text); } |
| |
| .detail-hero { |
| position: relative; overflow: hidden; flex-shrink: 0; |
| padding: 16px 16px 12px; |
| } |
| .detail-hero-bg { |
| position: absolute; inset: 0; |
| opacity: 0.06; |
| background-size: 30px 30px; |
| background-image: |
| linear-gradient(rgba(255,255,255,0.05) 1px, transparent 1px), |
| linear-gradient(90deg, rgba(255,255,255,0.05) 1px, transparent 1px); |
| } |
| .detail-hero-content { position: relative; z-index: 1; } |
| .detail-avatar-lg { |
| width: 48px; height: 48px; border-radius: 50%; |
| display: flex; align-items: center; justify-content: center; |
| font-weight: 700; font-size: 18px; color: #000; |
| margin-bottom: 10px; |
| box-shadow: 0 0 0 3px rgba(0,0,0,0.5), 0 0 24px rgba(0,0,0,0.5); |
| } |
| .detail-name-lg { |
| font-size: 18px; font-weight: 700; line-height: 1; |
| margin-bottom: 3px; |
| } |
| .detail-location { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 10px; color: var(--text-dim); |
| } |
| .detail-pov-btn { |
| position: absolute; top: 14px; right: 14px; |
| padding: 5px 10px; border-radius: 7px; border: none; cursor: pointer; |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 9px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.1em; |
| transition: all 0.2s; |
| } |
| .detail-pov-btn.off { |
| background: var(--surface3); color: var(--text-dim); |
| border: 1px solid var(--border2); |
| } |
| .detail-pov-btn.off:hover { color: var(--purple); border-color: var(--purple); } |
| .detail-pov-btn.on { |
| background: rgba(180,74,255,0.2); color: var(--purple); |
| border: 1px solid rgba(180,74,255,0.5); |
| box-shadow: 0 0 12px rgba(180,74,255,0.3); |
| } |
| |
| |
| .stats-grid { |
| display: grid; grid-template-columns: 1fr 1fr 1fr; |
| gap: 5px; padding: 10px 12px; |
| border-bottom: 1px solid var(--border); |
| flex-shrink: 0; |
| } |
| .stat-box { |
| background: var(--surface); border-radius: 8px; |
| padding: 8px 10px; border: 1px solid var(--border); |
| text-align: center; |
| } |
| .stat-box-label { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 8px; text-transform: uppercase; letter-spacing: 0.1em; |
| color: var(--text-muted); margin-bottom: 4px; |
| } |
| .stat-box-val { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 16px; font-weight: 700; |
| } |
| .stat-box-sub { |
| width: 100%; height: 3px; background: var(--border); |
| border-radius: 1px; overflow: hidden; margin-top: 5px; |
| } |
| .stat-box-sub-fill { |
| height: 100%; border-radius: 1px; |
| transition: width 0.4s ease; |
| } |
| |
| |
| .abilities-section { |
| padding: 8px 12px; |
| border-bottom: 1px solid var(--border); |
| flex-shrink: 0; |
| } |
| .abilities-row { |
| display: flex; gap: 5px; margin-top: 8px; |
| } |
| .ability-chip { |
| flex: 1; padding: 6px 8px; border-radius: 7px; |
| border: 1px solid var(--border); |
| background: var(--surface); |
| display: flex; flex-direction: column; align-items: center; gap: 3px; |
| text-align: center; |
| } |
| .ability-chip.ready { border-color: rgba(0,229,204,0.3); background: rgba(0,229,204,0.05); } |
| .ability-chip.depleted { opacity: 0.4; } |
| .ability-chip-name { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 9px; text-transform: uppercase; letter-spacing: 0.05em; |
| color: var(--text-dim); |
| } |
| .ability-chip-uses { |
| font-size: 11px; font-weight: 700; |
| color: var(--cyan); |
| } |
| .ability-chip.depleted .ability-chip-uses { color: var(--text-muted); } |
| |
| |
| .detail-tabs { |
| display: flex; border-bottom: 1px solid var(--border); |
| flex-shrink: 0; |
| } |
| .detail-tab { |
| flex: 1; padding: 8px 4px; text-align: center; |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 9px; text-transform: uppercase; letter-spacing: 0.1em; |
| color: var(--text-muted); cursor: pointer; |
| border-bottom: 2px solid transparent; |
| transition: all 0.15s; |
| } |
| .detail-tab:hover { color: var(--text-dim); background: rgba(30,30,53,0.4); } |
| .detail-tab.active { color: var(--accent); border-bottom-color: var(--accent); } |
| |
| .detail-tab-content { flex: 1; overflow-y: auto; padding: 8px 10px; } |
| |
| |
| .trace-row { |
| padding: 6px 8px; margin: 3px 0; |
| border-radius: 5px; border-left: 2px solid var(--border); |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 10px; line-height: 1.5; |
| } |
| .trace-label { |
| font-size: 9px; text-transform: uppercase; |
| letter-spacing: 0.08em; font-weight: 600; margin-bottom: 2px; |
| } |
| .trace-content { |
| word-break: break-word; white-space: pre-wrap; |
| max-height: 80px; overflow-y: auto; color: var(--text-dim); |
| } |
| .empty-state { |
| padding: 24px; text-align: center; |
| font-size: 11px; color: var(--text-muted); font-style: italic; |
| } |
| |
| |
| .trace-global { |
| flex: 1; overflow-y: auto; padding: 6px 10px; |
| } |
| |
| |
| .trace-chip { |
| display: inline-flex; align-items: center; gap: 3px; |
| padding: 1px 6px; border-radius: 8px; |
| font-size: 9px; font-weight: 700; letter-spacing: 0.04em; |
| font-family: 'JetBrains Mono', monospace; |
| } |
| .trace-chip.dmg { background: rgba(255,70,85,0.15); color: #ff7585; } |
| .trace-chip.score { background: rgba(251,191,36,0.15); color: #fbbf24; } |
| .trace-chip.pos { background: rgba(255,255,255,0.06); color: var(--text-dim); } |
| .trace-row.fail { border-left-color: #ff4655 !important; opacity: 0.72; } |
| .trace-row.fail .trace-label::before { content: '⚠ '; color: #ff4655; } |
| .trace-head { |
| display: flex; align-items: center; gap: 6px; |
| margin-bottom: 3px; |
| } |
| .trace-icon { font-size: 13px; line-height: 1; } |
| .trace-arrow { font-size: 15px; line-height: 1; font-weight: 700; } |
| .trace-tool { font-size: 9px; text-transform: uppercase; letter-spacing: 0.1em; font-weight: 700; } |
| .trace-round { font-size: 9px; color: var(--text-muted); font-weight: 400; } |
| .trace-turn { font-size: 9px; color: var(--text-muted); margin-left: auto; } |
| .trace-result { font-size: 9.5px; color: var(--text-dim); margin-top: 2px; } |
| |
| |
| .controls-bar { |
| display: flex; align-items: center; gap: 8px; |
| padding: 8px 16px; |
| background: rgba(10,10,20,0.97); |
| border-top: 1px solid var(--border); |
| flex-shrink: 0; z-index: 10; |
| } |
| .ctrl-btn { |
| padding: 9px 18px; border: 1px solid var(--border2); |
| border-radius: 8px; cursor: pointer; |
| font-family: 'Space Grotesk', sans-serif; |
| font-size: 12px; font-weight: 600; letter-spacing: 0.05em; |
| transition: all 0.2s ease; |
| background: var(--surface2); color: var(--text); |
| } |
| .ctrl-btn:hover { |
| border-color: var(--accent); |
| background: rgba(255,70,85,0.08); |
| transform: translateY(-1px); |
| } |
| .ctrl-btn:active { transform: translateY(0); } |
| .ctrl-btn:disabled { opacity: 0.3; cursor: not-allowed; transform: none; box-shadow: none; } |
| .ctrl-btn.primary { |
| background: linear-gradient(135deg, var(--accent), #e6354a); |
| color: #fff; border-color: var(--accent); |
| box-shadow: 0 2px 14px var(--accent-glow); |
| } |
| .ctrl-btn.primary:hover { box-shadow: 0 4px 22px var(--accent-glow); } |
| .ctrl-btn.active { |
| background: var(--accent); color: #fff; border-color: var(--accent); |
| } |
| |
| .speed-select { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 10px; background: var(--surface); |
| border: 1px solid var(--border); color: var(--text); |
| padding: 5px 9px; border-radius: 7px; |
| cursor: pointer; outline: none; |
| } |
| .speed-select:focus { border-color: var(--accent); } |
| |
| .ctrl-sep { width: 1px; height: 24px; background: var(--border); } |
| |
| .controls-hint { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 9px; color: var(--text-muted); margin-left: auto; |
| display: flex; gap: 8px; align-items: center; |
| } |
| .controls-hint kbd { |
| background: var(--surface2); border: 1px solid var(--border); |
| padding: 1px 5px; border-radius: 3px; font-size: 9px; color: var(--text-dim); |
| } |
| #stepTiming { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 10px; color: var(--text-muted); |
| } |
| |
| |
| #gameOverOverlay { |
| position: fixed; inset: 0; z-index: 300; |
| display: none; align-items: center; justify-content: center; |
| background: rgba(7,7,13,0.88); |
| backdrop-filter: blur(16px); |
| animation: overlayIn 0.5s ease; |
| } |
| #gameOverOverlay.active { display: flex; } |
| @keyframes overlayIn { from { opacity: 0; } to { opacity: 1; } } |
| .gameover-card { |
| text-align: center; padding: 2.5rem 2rem; |
| background: var(--surface); |
| border: 1px solid var(--border2); |
| border-radius: 20px; |
| box-shadow: 0 24px 80px rgba(0,0,0,0.7); |
| max-width: 440px; width: 90%; |
| animation: cardBounce 0.6s cubic-bezier(0.34,1.56,0.64,1); |
| } |
| @keyframes cardBounce { |
| from { transform: scale(0.82) translateY(40px); opacity: 0; } |
| to { transform: scale(1) translateY(0); opacity: 1; } |
| } |
| .go-trophy { font-size: 44px; margin-bottom: 10px; } |
| .go-title { |
| font-family: 'Space Grotesk', sans-serif; |
| font-size: 26px; font-weight: 700; color: var(--gold); |
| text-shadow: 0 0 40px rgba(255,215,0,0.35); margin-bottom: 4px; |
| } |
| .go-subtitle { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 10px; color: var(--text-dim); |
| text-transform: uppercase; letter-spacing: 0.15em; margin-bottom: 20px; |
| } |
| .go-leaderboard { text-align: left; margin-bottom: 20px; } |
| .go-lb-row { |
| display: flex; align-items: center; gap: 10px; |
| padding: 7px 12px; border-radius: 6px; |
| font-size: 13px; border-bottom: 1px solid var(--border); |
| } |
| .go-lb-row:first-child { |
| background: var(--gold-dim); border: 1px solid rgba(255,215,0,0.15); |
| } |
| .go-lb-row.dead { opacity: 0.35; } |
| .go-rank { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-dim); width: 20px; } |
| .go-avatar { width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; color: #000; font-size: 10px; } |
| .go-name { flex: 1; font-weight: 600; } |
| .go-score { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--gold); } |
| .go-kills { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text-dim); } |
| .go-play-again { |
| padding: 13px 32px; border: none; border-radius: 10px; |
| background: linear-gradient(135deg, var(--accent), #e6354a); |
| color: #fff; font-family: 'Space Grotesk', sans-serif; |
| font-size: 13px; font-weight: 700; |
| text-transform: uppercase; letter-spacing: 0.15em; |
| cursor: pointer; transition: all 0.3s; |
| box-shadow: 0 4px 24px var(--accent-glow); |
| } |
| .go-play-again:hover { transform: translateY(-2px); box-shadow: 0 8px 36px var(--accent-glow); } |
| |
| |
| #lootPopup { |
| position: absolute; z-index: 25; |
| width: 210px; |
| background: rgba(8,8,20,0.95); |
| backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); |
| border: 1px solid var(--border2); |
| border-radius: 12px; |
| box-shadow: 0 12px 40px rgba(0,0,0,0.6); |
| padding: 0; |
| pointer-events: auto; |
| opacity: 0; transform: translateY(6px) scale(0.97); |
| transition: opacity 0.2s ease, transform 0.2s ease; |
| display: none; |
| } |
| #lootPopup.active { display: block; opacity: 1; transform: translateY(0) scale(1); } |
| .loot-popup-header { |
| padding: 10px 14px 8px; |
| border-bottom: 1px solid var(--border); |
| display: flex; align-items: center; justify-content: space-between; |
| } |
| .loot-popup-title { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 9px; text-transform: uppercase; letter-spacing: 0.15em; |
| color: var(--text-muted); |
| } |
| .loot-popup-close { |
| font-size: 11px; color: var(--text-muted); cursor: pointer; |
| line-height: 1; transition: color 0.15s; |
| } |
| .loot-popup-close:hover { color: var(--text); } |
| .loot-popup-coord { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 11px; font-weight: 600; color: var(--text-dim); |
| padding: 4px 14px 6px; |
| border-bottom: 1px solid var(--border); |
| } |
| .loot-item-row { |
| display: flex; align-items: center; gap: 10px; |
| padding: 8px 14px; |
| border-bottom: 1px solid rgba(35,35,64,0.5); |
| transition: background 0.15s; |
| } |
| .loot-item-row:last-child { border-bottom: none; } |
| .loot-item-row:hover { background: rgba(30,30,60,0.5); } |
| .loot-item-icon { |
| width: 28px; height: 28px; border-radius: 7px; |
| display: flex; align-items: center; justify-content: center; |
| font-size: 14px; flex-shrink: 0; |
| } |
| .loot-item-icon.common { background: rgba(255,215,0,0.12); } |
| .loot-item-icon.rare { background: rgba(74,158,255,0.12); } |
| .loot-item-icon.epic { background: rgba(180,74,255,0.12); } |
| .loot-item-details { flex: 1; } |
| .loot-item-name { |
| font-size: 12px; font-weight: 600; color: var(--text); |
| line-height: 1.2; margin-bottom: 2px; |
| } |
| .loot-item-rarity { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 9px; text-transform: uppercase; letter-spacing: 0.1em; |
| } |
| .loot-item-rarity.common { color: var(--gold); } |
| .loot-item-rarity.rare { color: var(--blue); } |
| .loot-item-rarity.epic { color: var(--purple); } |
| .loot-item-source { |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 8px; color: var(--text-muted); margin-top: 1px; |
| } |
| </style> |
| </head> |
| <body> |
|
|
| |
| <div id="lobby"> |
| <canvas id="lobbyParticles"></canvas> |
| <div class="lobby-inner"> |
| <div class="lobby-brand"> |
| <h1 class="lobby-title">Grid <em>Royale</em></h1> |
| </div> |
| <div class="lobby-panel"> |
| <div class="lobby-section-label">Arena Config</div> |
| <div class="lobby-row"> |
| <label>Grid</label> |
| <div class="range-wrap"> |
| <input type="range" id="gridSize" min="10" max="30" value="20" step="1"> |
| <span class="range-val" id="gridSizeVal">20</span> |
| </div> |
| </div> |
| <div class="lobby-row"> |
| <label>Agents</label> |
| <div class="range-wrap"> |
| <input type="range" id="numAgents" min="2" max="8" value="4" step="1"> |
| <span class="range-val" id="numAgentsVal">4</span> |
| </div> |
| </div> |
| <div class="lobby-row"> |
| <label>Chests</label> |
| <div class="range-wrap"> |
| <input type="range" id="numChests" min="5" max="50" value="15" step="1"> |
| <span class="range-val" id="numChestsVal">15</span> |
| </div> |
| </div> |
| <div class="lobby-row"> |
| <label>Max Turns</label> |
| <div class="range-wrap"> |
| <input type="range" id="maxTurns" min="30" max="250" value="100" step="10"> |
| <span class="range-val" id="maxTurnsVal">100</span> |
| </div> |
| </div> |
| <div class="lobby-section-label" style="margin-top: 16px;">Agent Customization</div> |
| <div class="lobby-prompt-tabs" id="agentPromptTabs"></div> |
| <div class="lobby-agent-edit-row"> |
| <span class="lobby-agent-color-dot" id="lobbyAgentColorDot"></span> |
| <input type="text" id="lobbyAgentNameInput" class="lobby-agent-name-input" placeholder="Agent Name"> |
| </div> |
| <div class="lobby-prompt-wrap"> |
| <textarea id="systemPromptInput" placeholder="Configure the agent system prompt..."></textarea> |
| </div> |
| <button class="lobby-deploy" id="startBtn">⚔ Deploy Agents</button> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div id="game"> |
| |
| <div class="top-bar"> |
| <div class="top-brand"> |
| <span class="top-brand-logo">Grid Royale</span> |
| <div class="top-brand-sep"></div> |
| <div class="top-brand-turn"> |
| <span class="top-brand-turn-label">Turn</span> |
| <span class="top-brand-turn-val" id="turnBadge">0 / 50</span> |
| </div> |
| <div class="top-brand-sep"></div> |
| |
| <div style="width:80px;height:4px;background:var(--border);border-radius:2px;overflow:hidden;align-self:center"> |
| <div class="turn-progress-fill" id="turnProgress" style="width:0%"></div> |
| </div> |
| </div> |
|
|
| <div class="top-stats"> |
| <div class="stat-pill"> |
| <span class="stat-pill-icon">⚡</span> |
| <div class="stat-pill-body"> |
| <span class="stat-pill-label">Alive</span> |
| <span class="stat-pill-val cyan" id="statAlive">0</span> |
| </div> |
| </div> |
| <div class="stat-pill"> |
| <span class="stat-pill-icon">💀</span> |
| <div class="stat-pill-body"> |
| <span class="stat-pill-label">Kills</span> |
| <span class="stat-pill-val accent" id="statKills">0</span> |
| </div> |
| </div> |
| <div class="stat-pill"> |
| <span class="stat-pill-icon">📦</span> |
| <div class="stat-pill-body"> |
| <span class="stat-pill-label">Loot</span> |
| <span class="stat-pill-val gold" id="statLoot">0</span> |
| </div> |
| </div> |
|
|
| <div class="top-dots" id="agentDots"></div> |
| </div> |
|
|
| <div class="top-right"> |
| <button class="camera-mode-badge" id="newGameBtn" onclick="returnToLobby()" title="Open lobby to start a new game">⚙ NEW GAME</button> |
| <button class="camera-mode-badge" id="cameraModeBadge" onclick="togglePov()" title="Toggle chase cam (V) — select an agent first" disabled>📹 OVERVIEW</button> |
| <span class="winner-announce" id="winnerAnnounce"></span> |
| </div> |
| </div> |
|
|
| |
| <div class="main-layout"> |
| <div class="scene-wrap" id="sceneWrap"> |
| <div id="sceneCanvas"></div> |
|
|
| |
| <div id="fpsHud"> |
| <div class="fps-vignette"></div> |
| <div class="fps-agent-border" id="fpsAgentBorder"></div> |
|
|
| |
| <div class="fps-reticle" id="fpsReticle"> |
| <div class="fps-reticle-ring" id="fpsReticleRing"></div> |
| <div class="fps-reticle-corner tl"></div> |
| <div class="fps-reticle-corner tr"></div> |
| <div class="fps-reticle-corner bl"></div> |
| <div class="fps-reticle-corner br"></div> |
| <div class="fps-reticle-dot"></div> |
| </div> |
|
|
| <div class="fps-agent-info"> |
| <div class="fps-agent-name" id="fpsAgentName"></div> |
| <div class="fps-agent-pos" id="fpsAgentPos"></div> |
| </div> |
|
|
| <div class="fps-hp-bar-wrap"> |
| <div class="fps-hp-label">HP</div> |
| <div class="fps-hp-val" id="fpsHpVal">100</div> |
| <div class="fps-hp-bar"> |
| <div class="fps-hp-bar-fill" id="fpsHpFill" style="width:100%;background:var(--cyan)"></div> |
| </div> |
| </div> |
|
|
| <div class="fps-radar" id="fpsRadar"> |
| <div class="fps-radar-sweep"></div> |
| <div class="fps-radar-rings"></div> |
| <div class="fps-radar-center"></div> |
| </div> |
|
|
| <button class="fps-exit-btn" onclick="exitFps()">✕ Exit POV</button> |
| </div> |
|
|
| <div id="killFeed"></div> |
| |
| <div id="lootPopup"> |
| <div class="loot-popup-header"> |
| <span class="loot-popup-title">Loot Cache</span> |
| <span class="loot-popup-close" onclick="hideLootPopup()">✕</span> |
| </div> |
| <div class="loot-popup-coord" id="lootPopupCoord"></div> |
| <div id="lootPopupItems"></div> |
| </div> |
| <div class="loading-overlay" id="loadingOverlay"> |
| <div class="loading-spinner"> |
| <div class="ring"></div> |
| <span id="loadingText">Agents are thinking...</span> |
| </div> |
| </div> |
| </div> |
| <div class="sidebar" id="sidebar"></div> |
| </div> |
|
|
| |
| <div class="controls-bar"> |
| <button class="ctrl-btn primary" id="stepBtn">▶ STEP</button> |
| <button class="ctrl-btn" id="autoBtn">⏩ AUTO</button> |
| <button class="ctrl-btn" id="warmBtn" title="Pre-warm/load serverless inference models to prevent cold start latency">⚡ Load Model</button> |
| <div class="ctrl-sep"></div> |
| <select class="speed-select" id="speedSelect"> |
| <option value="0">Instant</option> |
| <option value="100" selected>100ms</option> |
| <option value="300">300ms</option> |
| <option value="500">500ms</option> |
| <option value="1000">1s</option> |
| </select> |
| <span class="controls-hint"> |
| <kbd>Space</kbd> step |
| <kbd>A</kbd> auto |
| <kbd>V</kbd> POV |
| <kbd>Esc</kbd> deselect |
| </span> |
| </div> |
| </div> |
|
|
| |
| <div id="gameOverOverlay"> |
| <div class="gameover-card" id="gameOverCard"></div> |
| </div> |
|
|
| |
| <script> |
| const API = ''; |
| let state = null; |
| let selectedId = null; |
| let gameStarted = false; |
| let autoRunning = false; |
| let activeTab = 'trace'; |
| let fpsMode = false; |
| let fpsAngle = 0; |
| let nameMaterialCache = {}; |
| |
| const AGENT_COLORS = { |
| agent_0: '#ff4655', agent_1: '#00e5cc', agent_2: '#4a9eff', |
| agent_3: '#ffd700', agent_4: '#b44aff', agent_5: '#ff6b9d', |
| agent_6: '#00e5ff', agent_7: '#ff9100', |
| }; |
| |
| const TOOL_VIS = { |
| observe: { icon: '👁', color: '#6ec5ff', label: 'OBSERVE' }, |
| think: { icon: '💭', color: '#a78bfa', label: 'THINK' }, |
| move: { icon: '➡', color: '#7dd3fc', label: 'MOVE' }, |
| attack: { icon: '⚔', color: '#ff4655', label: 'ATTACK' }, |
| dash: { icon: '💨', color: '#fbbf24', label: 'DASH' }, |
| shield: { icon: '🛡', color: '#34d399', label: 'SHIELD' }, |
| heal: { icon: '❤', color: '#f472b6', label: 'HEAL' }, |
| activate_ability: { icon: '✨', color: '#c084fc', label: 'ABILITY' }, |
| }; |
| const DEFAULT_VIS = { icon: '•', color: '#888', label: 'TOOL' }; |
| function ARROW_FOR(dx, dy) { |
| dx = Math.sign(dx||0); dy = Math.sign(dy||0); |
| if (dx===0 && dy<0) return '↑'; if (dx===0 && dy>0) return '↓'; |
| if (dx<0 && dy===0) return '←'; if (dx>0 && dy===0) return '→'; |
| if (dx<0 && dy<0) return '↖'; if (dx>0 && dy<0) return '↗'; |
| if (dx<0 && dy>0) return '↙'; if (dx>0 && dy>0) return '↘'; |
| return '·'; |
| } |
| function pickVis(tool, args) { |
| if (tool === 'activate_ability' && args && args.ability && TOOL_VIS[args.ability]) |
| return TOOL_VIS[args.ability]; |
| return TOOL_VIS[tool] || DEFAULT_VIS; |
| } |
| function outcomeChips(result) { |
| const r = String(result || ''); |
| const chips = []; |
| let fail = false; |
| const score = r.match(/picked up (\d+) points?/i); |
| if (score) chips.push(`<span class="trace-chip score">+${score[1]} pts</span>`); |
| const loot = r.match(/picked up ability '([a-z_]+)'/i); |
| if (loot) chips.push(`<span class="trace-chip score">+${esc(loot[1])}</span>`); |
| const dmg = r.match(/for (\d+) damage/i); |
| if (dmg) chips.push(`<span class="trace-chip dmg">−${dmg[1]} HP</span>`); |
| const heal = r.match(/restored (\d+) HP/i); |
| if (heal) chips.push(`<span class="trace-chip score">+${heal[1]} HP</span>`); |
| const moved = r.match(/Moved to \((\d+),\s*(\d+)\)/i); |
| if (moved) chips.push(`<span class="trace-chip pos">(${moved[1]},${moved[2]})</span>`); |
| if (/eliminated|kill/i.test(r)) chips.push(`<span class="trace-chip dmg">☠ KILL</span>`); |
| if (/shield (activated|broke)/i.test(r)) chips.push(`<span class="trace-chip score">🛡</span>`); |
| if (/^(Invalid|cannot|don't have|can only|You cannot|Ability|No agent)/i.test(r) || /cooldown|no uses left/i.test(r)) fail = true; |
| return { chips: chips.join(' '), fail }; |
| } |
| |
| |
| (function initLobbyParticles() { |
| |
| })(); |
| |
| const BOT_NAMES = ["Vex", "Nyx", "Kael", "Zara", "Cypher", "Helix", "Apex", "Blaze"]; |
| let agentNames = [...BOT_NAMES]; |
| let agentSystemPrompts = {}; |
| let activePromptAgent = 'agent_0'; |
| let promptManuallyEdited = {}; |
| |
| function saveCurrentPrompt() { |
| const promptInput = document.getElementById('systemPromptInput'); |
| if (promptInput && activePromptAgent) { |
| agentSystemPrompts[activePromptAgent] = promptInput.value; |
| } |
| } |
| |
| function loadCurrentPrompt() { |
| const promptInput = document.getElementById('systemPromptInput'); |
| if (promptInput && activePromptAgent) { |
| promptInput.value = agentSystemPrompts[activePromptAgent] || ''; |
| } |
| } |
| |
| function renderPromptTabs() { |
| const numAgents = parseInt(document.getElementById('numAgents').value); |
| const tabsContainer = document.getElementById('agentPromptTabs'); |
| if (!tabsContainer) return; |
| |
| tabsContainer.innerHTML = ''; |
| |
| const activeIdx = parseInt(activePromptAgent.split('_')[1]); |
| if (activeIdx >= numAgents) { |
| activePromptAgent = 'agent_0'; |
| } |
| |
| for (let i = 0; i < numAgents; i++) { |
| const aid = `agent_${i}`; |
| const name = agentNames[i] || `Agent ${i}`; |
| const color = AGENT_COLORS[aid] || '#e8e6e3'; |
| |
| const tab = document.createElement('div'); |
| tab.className = 'lobby-tab'; |
| if (aid === activePromptAgent) { |
| tab.classList.add('active'); |
| tab.style.setProperty('--tab-color', color); |
| tab.style.setProperty('--tab-glow', `${color}4D`); |
| |
| const promptInput = document.getElementById('systemPromptInput'); |
| if (promptInput) { |
| promptInput.style.setProperty('--active-tab-color', color); |
| promptInput.style.setProperty('--active-tab-glow', `${color}66`); |
| } |
| |
| const colorDot = document.getElementById('lobbyAgentColorDot'); |
| if (colorDot) { |
| colorDot.style.background = color; |
| colorDot.style.boxShadow = `0 0 10px ${color}`; |
| } |
| |
| const nameInput = document.getElementById('lobbyAgentNameInput'); |
| if (nameInput) { |
| nameInput.value = name; |
| } |
| } |
| tab.textContent = name; |
| tab.addEventListener('click', () => { |
| saveCurrentPrompt(); |
| activePromptAgent = aid; |
| renderPromptTabs(); |
| loadCurrentPrompt(); |
| }); |
| tabsContainer.appendChild(tab); |
| } |
| } |
| |
| async function updateAllPrompts() { |
| const gridSize = document.getElementById('gridSize').value; |
| try { |
| const data = await api('GET', `/api/game/default_prompt?grid_size=${gridSize}`); |
| if (data && data.default_prompt) { |
| const maxAgents = 8; |
| for (let i = 0; i < maxAgents; i++) { |
| const aid = `agent_${i}`; |
| if (!promptManuallyEdited[aid]) { |
| agentSystemPrompts[aid] = data.default_prompt; |
| } |
| } |
| loadCurrentPrompt(); |
| } |
| } catch (err) { |
| console.error("Failed to fetch default prompts", err); |
| } |
| } |
| |
| ['gridSize','numAgents','numChests','maxTurns'].forEach(id => { |
| const el = document.getElementById(id); |
| const val = document.getElementById(id+'Val'); |
| el.addEventListener('input', () => { |
| val.textContent = el.value; |
| if (id === 'gridSize') { |
| updateAllPrompts(); |
| } else if (id === 'numAgents') { |
| renderPromptTabs(); |
| } |
| }); |
| }); |
| |
| document.getElementById('lobbyAgentNameInput').addEventListener('input', (e) => { |
| if (activePromptAgent) { |
| const activeIdx = parseInt(activePromptAgent.split('_')[1]); |
| const newName = e.target.value.trim() || `Agent ${activeIdx}`; |
| agentNames[activeIdx] = newName; |
| |
| const activeTab = document.querySelector('#agentPromptTabs .lobby-tab.active'); |
| if (activeTab) { |
| activeTab.textContent = newName; |
| } |
| } |
| }); |
| |
| document.getElementById('systemPromptInput').addEventListener('input', () => { |
| if (activePromptAgent) { |
| promptManuallyEdited[activePromptAgent] = true; |
| saveCurrentPrompt(); |
| } |
| }); |
| |
| |
| (async () => { |
| renderPromptTabs(); |
| await updateAllPrompts(); |
| })(); |
| |
| |
| const TILE = 1.6; |
| let app = null; |
| let cameraTarget = null; |
| let fpsCameraPos = null; |
| let fpsLookTarget = null; |
| let agentMeshes = {}; |
| let activeFX = []; |
| let agentSelMats = {}; |
| let lootMaterials = null; |
| let traceHistory = {}; |
| |
| function spawnLaserEffect(fromPos, toPos, colorHex) { |
| if (!app) return; |
| const THREE = app.THREE || window.__gr3d.THREE; |
| if (!THREE) return; |
| const col = parseInt(colorHex.slice(1), 16); |
| const dir = new THREE.Vector3().subVectors(toPos, fromPos); |
| const len = dir.length(); |
| |
| const group = new THREE.Group(); |
| |
| |
| const coreGeo = new THREE.CylinderGeometry(0.02, 0.02, len, 6); |
| coreGeo.translate(0, len/2, 0); |
| const coreMat = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.95 }); |
| const coreMesh = new THREE.Mesh(coreGeo, coreMat); |
| group.add(coreMesh); |
| |
| |
| const glowGeo = new THREE.CylinderGeometry(0.08, 0.04, len, 8); |
| glowGeo.translate(0, len/2, 0); |
| const glowMat = new THREE.MeshBasicMaterial({ color: col, transparent: true, opacity: 0.5, blending: THREE.AdditiveBlending }); |
| const glowMesh = new THREE.Mesh(glowGeo, glowMat); |
| group.add(glowMesh); |
| |
| |
| const flashSourceGeo = new THREE.SphereGeometry(0.2, 8, 8); |
| const flashSourceMat = new THREE.MeshBasicMaterial({ color: col, transparent: true, opacity: 0.8, blending: THREE.AdditiveBlending }); |
| const flashSource = new THREE.Mesh(flashSourceGeo, flashSourceMat); |
| flashSource.position.set(0, 0, 0); |
| group.add(flashSource); |
| |
| |
| const flashTargetGeo = new THREE.SphereGeometry(0.25, 8, 8); |
| const flashTargetMat = new THREE.MeshBasicMaterial({ color: col, transparent: true, opacity: 0.8, blending: THREE.AdditiveBlending }); |
| const flashTarget = new THREE.Mesh(flashTargetGeo, flashTargetMat); |
| flashTarget.position.set(0, len, 0); |
| group.add(flashTarget); |
| |
| group.position.copy(fromPos); |
| group.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.clone().normalize()); |
| app.scene.add(group); |
| |
| activeFX.push({ |
| mesh: group, |
| duration: 0.3, |
| maxDuration: 0.3, |
| type: 'laser' |
| }); |
| } |
| |
| function spawnHealEffect(pos, colorHex) { |
| if (!app) return; |
| const THREE = app.THREE || window.__gr3d.THREE; |
| if (!THREE) return; |
| const col = parseInt(colorHex.slice(1), 16); |
| |
| const group = new THREE.Group(); |
| |
| |
| const geo = new THREE.SphereGeometry(0.55, 16, 16); |
| const mat = new THREE.MeshBasicMaterial({ |
| color: col, |
| transparent: true, |
| opacity: 0.4, |
| blending: THREE.AdditiveBlending, |
| side: THREE.DoubleSide |
| }); |
| const mesh = new THREE.Mesh(geo, mat); |
| group.add(mesh); |
| |
| const wireGeo = new THREE.SphereGeometry(0.56, 8, 8); |
| const wireMat = new THREE.MeshBasicMaterial({ |
| color: col, |
| transparent: true, |
| opacity: 0.5, |
| wireframe: true, |
| blending: THREE.AdditiveBlending |
| }); |
| const wireMesh = new THREE.Mesh(wireGeo, wireMat); |
| group.add(wireMesh); |
| |
| |
| const ringGeo = new THREE.TorusGeometry(0.4, 0.04, 8, 24); |
| const ringMat = new THREE.MeshBasicMaterial({ |
| color: col, |
| transparent: true, |
| opacity: 0.8, |
| blending: THREE.AdditiveBlending |
| }); |
| const ringMesh = new THREE.Mesh(ringGeo, ringMat); |
| ringMesh.rotation.x = -Math.PI / 2; |
| ringMesh.position.y = -0.5; |
| group.add(ringMesh); |
| |
| group.position.copy(pos); |
| group.position.y += 0.6; |
| app.scene.add(group); |
| |
| activeFX.push({ |
| mesh: group, |
| duration: 0.6, |
| maxDuration: 0.6, |
| type: 'heal', |
| pos: pos.clone() |
| }); |
| } |
| |
| function spawnShieldBlockEffect(pos, colorHex) { |
| if (!app) return; |
| const THREE = app.THREE || window.__gr3d.THREE; |
| if (!THREE) return; |
| const col = parseInt(colorHex.slice(1), 16); |
| const shGeo = new THREE.SphereGeometry(0.65, 12, 12); |
| const mat = new THREE.MeshBasicMaterial({ |
| color: col, |
| transparent: true, |
| opacity: 0.8, |
| wireframe: true, |
| blending: THREE.AdditiveBlending |
| }); |
| const mesh = new THREE.Mesh(shGeo, mat); |
| mesh.position.copy(pos); |
| mesh.position.y += 0.5; |
| app.scene.add(mesh); |
| |
| activeFX.push({ |
| mesh: mesh, |
| duration: 0.35, |
| maxDuration: 0.35, |
| type: 'shield_block' |
| }); |
| } |
| |
| function initLootMaterials(THREE) { |
| if (lootMaterials) return; |
| const RARITY_COLORS = { common: 0xffd700, rare: 0x4a9eff, epic: 0xb44aff }; |
| const RARITY_EMISSIVE = { common: 0xffaa00, rare: 0x3377ff, epic: 0x8800cc }; |
| lootMaterials = {}; |
| ['common', 'rare', 'epic'].forEach(rarity => { |
| const col = RARITY_COLORS[rarity]; |
| const emCol = RARITY_EMISSIVE[rarity]; |
| lootMaterials[rarity] = { |
| crystal: new THREE.MeshStandardMaterial({ |
| color: col, emissive: emCol, emissiveIntensity: rarity==='epic'?1.2:rarity==='rare'?0.9:0.7, |
| roughness: 0.15, metalness: 0.7, |
| }), |
| beam: new THREE.MeshBasicMaterial({ |
| color: col, transparent: true, opacity: rarity==='epic'?0.25:0.15, blending: THREE.AdditiveBlending |
| }), |
| ring: new THREE.MeshBasicMaterial({ |
| color: col, transparent: true, opacity: 0.25, side: THREE.DoubleSide, blending: THREE.AdditiveBlending |
| }), |
| glow: new THREE.MeshBasicMaterial({ |
| color: col, transparent: true, opacity: 0.12, blending: THREE.AdditiveBlending, side: THREE.DoubleSide |
| }) |
| }; |
| }); |
| } |
| |
| async function loadThree() { |
| if (window.__gr3d && window.__gr3d.THREE) return; |
| window.__gr3d = {}; |
| const THREE_mod = await import('three'); |
| const { OrbitControls } = await import('https://unpkg.com/three@0.160.0/examples/jsm/controls/OrbitControls.js'); |
| window.__gr3d.THREE = THREE_mod; |
| window.__gr3d.OrbitControls = OrbitControls; |
| } |
| |
| function initScene() { |
| if (app) return; |
| const THREE = window.__gr3d.THREE; |
| const OrbitControls = window.__gr3d.OrbitControls; |
| const wrap = document.getElementById('sceneCanvas'); |
| if (!wrap) return; |
| |
| const scene = new THREE.Scene(); |
| |
| |
| const skyCanvas = document.createElement('canvas'); |
| skyCanvas.width = 1024; skyCanvas.height = 512; |
| const skyCtx = skyCanvas.getContext('2d'); |
| const skyGrad = skyCtx.createLinearGradient(0, 0, 0, 512); |
| skyGrad.addColorStop(0, '#020210'); |
| skyGrad.addColorStop(0.4, '#060620'); |
| skyGrad.addColorStop(1, '#0a0a1e'); |
| skyCtx.fillStyle = skyGrad; |
| skyCtx.fillRect(0, 0, 1024, 512); |
| |
| for (let i = 0; i < 1400; i++) { |
| const sx = Math.random()*1024, sy = Math.random()*512; |
| const sr = Math.random()*1.2+0.2; |
| const sa = Math.random()*0.8+0.2; |
| skyCtx.beginPath(); |
| skyCtx.arc(sx, sy, sr, 0, Math.PI*2); |
| const hue = Math.random() > 0.85 ? (Math.random() > 0.5 ? '#aac0ff' : '#ffccaa') : '#ffffff'; |
| skyCtx.fillStyle = hue; |
| skyCtx.globalAlpha = sa; |
| skyCtx.fill(); |
| } |
| |
| ['rgba(80,30,120,0.08)','rgba(20,60,120,0.06)','rgba(100,20,60,0.06)'].forEach((c,i) => { |
| const ng = skyCtx.createRadialGradient(300+i*200, 200+i*50, 20, 300+i*200, 200+i*50, 250); |
| ng.addColorStop(0, c); ng.addColorStop(1, 'transparent'); |
| skyCtx.globalAlpha = 1; |
| skyCtx.fillStyle = ng; |
| skyCtx.fillRect(0, 0, 1024, 512); |
| }); |
| const skyTex = new THREE.CanvasTexture(skyCanvas); |
| scene.background = skyTex; |
| scene.fog = new THREE.Fog(0x05050f, 28, 80); |
| |
| const w = wrap.clientWidth || 800; |
| const h = wrap.clientHeight || 500; |
| const camera = new THREE.PerspectiveCamera(45, w/h, 0.1, 500); |
| const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); |
| renderer.setPixelRatio(Math.min(devicePixelRatio||1, 2)); |
| renderer.setSize(w, h); |
| renderer.shadowMap.enabled = true; |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; |
| renderer.toneMapping = THREE.ACESFilmicToneMapping; |
| renderer.toneMappingExposure = 1.2; |
| wrap.appendChild(renderer.domElement); |
| |
| |
| scene.add(new THREE.AmbientLight(0x202040, 0.5)); |
| const hemi = new THREE.HemisphereLight(0x4466bb, 0x080612, 0.7); |
| scene.add(hemi); |
| const sun = new THREE.DirectionalLight(0xffd0e8, 1.4); |
| sun.position.set(18, 40, 12); |
| sun.castShadow = true; |
| sun.shadow.mapSize.set(2048, 2048); |
| sun.shadow.camera.left = -40; sun.shadow.camera.right = 40; |
| sun.shadow.camera.top = 40; sun.shadow.camera.bottom = -40; |
| sun.shadow.bias = -0.001; scene.add(sun); |
| const fill = new THREE.DirectionalLight(0x2030c0, 0.4); |
| fill.position.set(-18, 22, -12); scene.add(fill); |
| const rim = new THREE.DirectionalLight(0x800060, 0.3); |
| rim.position.set(2, 4, -22); scene.add(rim); |
| |
| const centerLight = new THREE.PointLight(0x4040ff, 0.6, 40, 1.5); |
| centerLight.position.set(0, -0.5, 0); scene.add(centerLight); |
| |
| |
| const groundGeo = new THREE.PlaneGeometry(300, 300, 60, 60); |
| const groundMat = new THREE.MeshStandardMaterial({ |
| color: 0x04040e, |
| roughness: 0.3, |
| metalness: 0.7, |
| envMapIntensity: 0.8, |
| }); |
| const ground = new THREE.Mesh(groundGeo, groundMat); |
| ground.rotation.x = -Math.PI/2; ground.position.y = -0.68; ground.receiveShadow = true; |
| scene.add(ground); |
| |
| |
| const hazeMat = new THREE.MeshBasicMaterial({ |
| color: 0x1a1a40, transparent: true, opacity: 0.08, |
| blending: THREE.AdditiveBlending, side: THREE.DoubleSide, depthWrite: false, |
| }); |
| const hazePlane = new THREE.Mesh(new THREE.PlaneGeometry(200, 200), hazeMat); |
| hazePlane.rotation.x = -Math.PI/2; hazePlane.position.y = 0.01; |
| scene.add(hazePlane); |
| |
| const tileGroup = new THREE.Group(); scene.add(tileGroup); |
| const objGroup = new THREE.Group(); scene.add(objGroup); |
| const agentGroup = new THREE.Group(); scene.add(agentGroup); |
| |
| |
| const crystalGeo = new THREE.OctahedronGeometry(0.18, 0); |
| const beamGeo = new THREE.CylinderGeometry(0.03, 0.12, 3, 8); |
| const ringGeo = new THREE.RingGeometry(0.25, 0.35, 16); |
| const pillarGeo = new THREE.CylinderGeometry(0.12, 0.16, 0.9, 8); |
| const topGeo = new THREE.OctahedronGeometry(0.1, 0); |
| const shieldGeo = new THREE.SphereGeometry(0.55, 16, 16); |
| const shieldWireGeo= new THREE.SphereGeometry(0.56, 8, 8); |
| const ringGeoAgent = new THREE.RingGeometry(0.32, 0.38, 24); |
| const hpBgGeo = new THREE.BoxGeometry(0.5, 0.05, 0.01); |
| const hpFgGeo = new THREE.BoxGeometry(0.5, 0.05, 0.01); |
| |
| const crystalMat = new THREE.MeshStandardMaterial({ color:0xffd700, emissive:0xffaa00, emissiveIntensity:0.8, roughness:0.2, metalness:0.6 }); |
| const beamMat = new THREE.MeshBasicMaterial({ color:0xffd700, transparent:true, opacity:0.15, blending:THREE.AdditiveBlending }); |
| const glowRingMat = new THREE.MeshBasicMaterial({ color:0xffd700, transparent:true, opacity:0.2, side:THREE.DoubleSide, blending:THREE.AdditiveBlending }); |
| const shieldMat = new THREE.MeshStandardMaterial({ color:0x4a9eff, emissive:0x4a9eff, emissiveIntensity:0.3, transparent:true, opacity:0.12, side:THREE.DoubleSide }); |
| const shieldWireMat = new THREE.MeshBasicMaterial({ color:0x4a9eff, transparent:true, opacity:0.15, wireframe:true }); |
| const hpBgMat = new THREE.MeshBasicMaterial({ color:0x07070d }); |
| const hpGreenMat = new THREE.MeshBasicMaterial({ color:0x00e5cc }); |
| const hpYellowMat = new THREE.MeshBasicMaterial({ color:0xffd700 }); |
| const hpRedMat = new THREE.MeshBasicMaterial({ color:0xff4655 }); |
| const agentPillarMats = {}, agentTopMats = {}, agentRingMats = {}; |
| |
| |
| const particleCount = 250; |
| const pg = new THREE.BufferGeometry(); |
| const pPos = new Float32Array(particleCount*3); |
| const pSeed = new Float32Array(particleCount); |
| for (let i = 0; i < particleCount; i++) { |
| pPos[i*3] = (Math.random()-0.5)*50; |
| pPos[i*3+1] = Math.random()*18+1; |
| pPos[i*3+2] = (Math.random()-0.5)*50; |
| pSeed[i] = Math.random()*Math.PI*2; |
| } |
| pg.setAttribute('position', new THREE.BufferAttribute(pPos, 3)); |
| pg.setAttribute('seed', new THREE.BufferAttribute(pSeed, 1)); |
| const particlesMesh = new THREE.Points(pg, new THREE.PointsMaterial({ |
| color:0x8855ff, size:0.05, transparent:true, opacity:0.2, |
| blending:THREE.AdditiveBlending, depthWrite:false, |
| })); |
| scene.add(particlesMesh); |
| |
| |
| const controls = new OrbitControls(camera, renderer.domElement); |
| controls.enableDamping = true; controls.dampingFactor = 0.06; |
| controls.minDistance = 6; controls.maxDistance = 55; |
| controls.maxPolarAngle = Math.PI/2.1; controls.minPolarAngle = 0.15; |
| camera.position.set(22, 24, 22); controls.update(); |
| |
| function resize() { |
| const ww = wrap.clientWidth || 800, hh = wrap.clientHeight || 500; |
| renderer.setSize(ww, hh); |
| camera.aspect = ww/hh; camera.updateProjectionMatrix(); |
| } |
| |
| |
| const raycaster = new THREE.Raycaster(); |
| const mouse = new THREE.Vector2(); |
| let mouseDownPos = null; |
| renderer.domElement.addEventListener('mousedown', e => { mouseDownPos = {x:e.clientX,y:e.clientY}; }); |
| renderer.domElement.addEventListener('click', e => { |
| if (mouseDownPos) { |
| if (Math.abs(e.clientX-mouseDownPos.x) > 5 || Math.abs(e.clientY-mouseDownPos.y) > 5) return; |
| } |
| if (fpsMode) return; |
| const rect = renderer.domElement.getBoundingClientRect(); |
| mouse.x = ((e.clientX-rect.left)/rect.width)*2-1; |
| mouse.y = -((e.clientY-rect.top)/rect.height)*2+1; |
| raycaster.setFromCamera(mouse, camera); |
| |
| |
| const agentMeshes = []; |
| agentGroup.traverse(o => { if (o.isMesh && o.userData.aid) agentMeshes.push(o); }); |
| const agentHits = raycaster.intersectObjects(agentMeshes); |
| if (agentHits.length) { |
| const aid = agentHits[0].object.userData.aid; |
| hideLootPopup(); |
| selectAgent(aid === selectedId ? null : aid); |
| return; |
| } |
| |
| |
| const lootMeshes = []; |
| objGroup.traverse(o => { if (o.isMesh && (o.userData.isCrystal||o.userData.isTileGlow)) lootMeshes.push(o); }); |
| const lootHits = raycaster.intersectObjects(lootMeshes); |
| if (lootHits.length) { |
| const ud = lootHits[0].object.userData; |
| hideLootPopup(); |
| selectAgent(null); |
| selectTile(ud.gridX, ud.gridY, e.clientX, e.clientY); |
| return; |
| } |
| |
| |
| const tileMeshes = []; |
| tileGroup.traverse(o => { if (o.isMesh && o.userData.isTile) tileMeshes.push(o); }); |
| const tileHits = raycaster.intersectObjects(tileMeshes); |
| if (tileHits.length) { |
| const ud = tileHits[0].object.userData; |
| const tileData = state && state.tiles && state.tiles[`${ud.gridX},${ud.gridY}`]; |
| if (tileData && tileData.loot && tileData.loot.length) { |
| hideLootPopup(); |
| selectAgent(null); |
| selectTile(ud.gridX, ud.gridY, e.clientX, e.clientY); |
| return; |
| } |
| } |
| |
| |
| hideLootPopup(); |
| selectAgent(null); |
| }); |
| |
| |
| const clock = new THREE.Clock(); |
| let orbitEyePos = new THREE.Vector3(22, 24, 22); |
| let orbitEyeTarget = new THREE.Vector3(0, 0, 0); |
| |
| function animate() { |
| requestAnimationFrame(animate); |
| const dt = clock.getDelta(); |
| const t = clock.elapsedTime; |
| |
| |
| controls.update(); |
| const lerpSpeed = fpsMode ? 0.1 : 0.05; |
| if (cameraTarget) controls.target.lerp(cameraTarget, lerpSpeed); |
| |
| |
| const pp = particlesMesh.geometry.attributes.position; |
| const ps = particlesMesh.geometry.attributes.seed; |
| for (let i = 0; i < particleCount; i++) { |
| const s = ps.array[i]; |
| pp.array[i*3+1] += Math.sin(t*0.4+s)*0.002; |
| pp.array[i*3] += Math.cos(t*0.2+s)*0.002; |
| } |
| pp.needsUpdate = true; |
| |
| |
| for (let i = activeFX.length - 1; i >= 0; i--) { |
| const fx = activeFX[i]; |
| fx.duration -= dt; |
| if (fx.duration <= 0) { |
| scene.remove(fx.mesh); |
| if (fx.mesh.traverse) { |
| fx.mesh.traverse(child => { |
| if (child.geometry) child.geometry.dispose(); |
| if (child.material) { |
| if (Array.isArray(child.material)) { |
| child.material.forEach(m => m.dispose()); |
| } else { |
| child.material.dispose(); |
| } |
| } |
| }); |
| } else { |
| if (fx.mesh.geometry) fx.mesh.geometry.dispose(); |
| if (fx.mesh.material) fx.mesh.material.dispose(); |
| } |
| activeFX.splice(i, 1); |
| } else { |
| const pct = fx.duration / fx.maxDuration; |
| if (fx.type === 'laser') { |
| const core = fx.mesh.children[0]; |
| const glow = fx.mesh.children[1]; |
| if (core) core.scale.set(pct, 1, pct); |
| if (glow) glow.scale.set(pct, 1, pct); |
| const flashSrc = fx.mesh.children[2]; |
| const flashTgt = fx.mesh.children[3]; |
| if (flashSrc) flashSrc.scale.setScalar(1 + (1 - pct) * 2.5); |
| if (flashTgt) flashTgt.scale.setScalar(1 + (1 - pct) * 3.0); |
| fx.mesh.children.forEach(c => { |
| if (c.material) { |
| const baseOpacity = (c === core) ? 0.95 : ((c === glow) ? 0.5 : 0.8); |
| c.material.opacity = pct * baseOpacity; |
| } |
| }); |
| } else if (fx.type === 'heal') { |
| const scale = 1 + (1 - pct) * 1.8; |
| fx.mesh.scale.setScalar(scale); |
| const ring = fx.mesh.children[2]; |
| if (ring) { |
| ring.position.y = -0.5 + (1 - pct) * 2.0; |
| } |
| fx.mesh.children.forEach(c => { |
| if (c.material) c.material.opacity = pct * (c === ring ? 0.8 : 0.4); |
| }); |
| } else if (fx.type === 'shield_block') { |
| const scale = 1 + (1 - pct) * 3; |
| fx.mesh.scale.setScalar(scale); |
| fx.mesh.material.opacity = pct; |
| } |
| } |
| } |
| |
| |
| for (let i = agentGroup.children.length - 1; i >= 0; i--) { |
| const ac = agentGroup.children[i]; |
| const seed = ac.userData.phase||0; |
| |
| if (ac.userData.dying) { |
| ac.userData.deathTimer -= dt * 2.0; |
| if (ac.userData.deathTimer <= 0) { |
| agentGroup.remove(ac); |
| delete agentMeshes[ac.userData.aid]; |
| continue; |
| } else { |
| ac.scale.setScalar(ac.userData.deathTimer); |
| ac.traverse(child => { |
| if (child.material) { |
| if (!child.userData.materialCloned) { |
| child.material = child.material.clone(); |
| child.userData.materialCloned = true; |
| } |
| child.material.transparent = true; |
| child.material.opacity = Math.min(child.material.opacity || 1.0, ac.userData.deathTimer); |
| } |
| }); |
| } |
| } else { |
| if (ac.userData.targetPos) { |
| ac.position.lerp(ac.userData.targetPos, 0.12); |
| } |
| |
| |
| if (ac.userData.flashTimer > 0) { |
| ac.userData.flashTimer -= dt; |
| const pillar = ac.getObjectByName('pillar'); |
| if (pillar) { |
| pillar.material.emissive.setHex(0xff0000); |
| pillar.material.emissiveIntensity = 2.5; |
| pillar.position.x = Math.sin(t * 50) * 0.06; |
| } |
| } else { |
| const pillar = ac.getObjectByName('pillar'); |
| if (pillar) { |
| const colHex = AGENT_COLORS[ac.userData.aid] || '#ffffff'; |
| pillar.material.emissive.setHex(parseInt(colHex.slice(1), 16)); |
| pillar.material.emissiveIntensity = ac.userData.shielded ? 0.9 : 0.5; |
| pillar.position.x = 0; |
| } |
| } |
| } |
| |
| const topGem = ac.getObjectByName('topGem'); |
| if (topGem) { topGem.position.y = 1.05+Math.sin(t*2+seed)*0.06; topGem.rotation.y += dt*0.8; } |
| const pillar = ac.getObjectByName('pillar'); |
| if (pillar) pillar.position.y = 0.5+Math.sin(t*2+seed)*0.03; |
| ac.children.forEach(c => { |
| if (c.userData.isShield) { |
| c.material.opacity = 0.12+Math.sin(t*3+seed)*0.06; |
| c.scale.setScalar(1+Math.sin(t*2)*0.03); |
| } |
| }); |
| const hpBar = ac.getObjectByName('hpBar'); |
| if (hpBar) hpBar.quaternion.copy(camera.quaternion); |
| } |
| |
| |
| objGroup.children.forEach(o => { |
| if (o.userData.baseY !== undefined) { |
| o.position.y = o.userData.baseY+Math.sin(t*1.5+(o.userData.phase||0))*0.08; |
| if (o.userData.rotates) o.rotation.y += dt*1.2; |
| } |
| if (o.userData.isBeam) o.material.opacity = 0.15+Math.sin(t*2.5+(o.userData.phase||0))*0.1; |
| }); |
| |
| renderer.render(scene, camera); |
| } |
| animate(); |
| |
| function clearGroup(g, disposeResources=false) { |
| while (g.children.length) { |
| const c = g.children.pop(); |
| if (disposeResources) { |
| c.traverse && c.traverse(o => { |
| if (o.geometry) o.geometry.dispose(); |
| if (o.material) { |
| if (Array.isArray(o.material)) o.material.forEach(m=>m.dispose()); |
| else { if (o.material.map) o.material.map.dispose(); o.material.dispose(); } |
| } |
| }); |
| } |
| } |
| } |
| |
| function makeLabel(text, color) { |
| if (!nameMaterialCache[text]) { |
| const cv = document.createElement('canvas'); cv.width=256; cv.height=64; |
| const ctx = cv.getContext('2d'); ctx.clearRect(0,0,256,64); |
| ctx.fillStyle='rgba(7,7,13,0.75)'; |
| const tw = ctx.measureText(text).width||60; |
| const pw = Math.max(tw+24,70), px=(256-pw)/2; |
| ctx.beginPath(); ctx.roundRect(px,8,pw,32,8); ctx.fill(); |
| ctx.fillStyle=color; |
| ctx.font='bold 18px "Space Grotesk",sans-serif'; |
| ctx.textAlign='center'; ctx.textBaseline='middle'; |
| ctx.fillText(text,128,25); |
| const tex = new THREE.CanvasTexture(cv); tex.minFilter=THREE.LinearFilter; |
| nameMaterialCache[text] = new THREE.SpriteMaterial({map:tex,transparent:true,depthTest:false}); |
| } |
| const sprite = new THREE.Sprite(nameMaterialCache[text]); |
| sprite.scale.set(2.2,0.55,1); return sprite; |
| } |
| |
| function createHpBarMesh(hp) { |
| const hpGroup = new THREE.Group(); hpGroup.name='hpBar'; |
| hpGroup.add(new THREE.Mesh(hpBgGeo, hpBgMat)); |
| const pct = Math.max(0,Math.min(100,hp))/100; |
| const hpMat = pct>0.6?hpGreenMat:pct>0.3?hpYellowMat:hpRedMat; |
| const fg = new THREE.Mesh(hpFgGeo, hpMat); |
| fg.name='hpFill'; fg.scale.x=pct; |
| fg.position.x = -0.25*(1-pct); fg.position.z=0.001; |
| hpGroup.add(fg); return hpGroup; |
| } |
| |
| function build() { |
| if (!state) return; |
| const size = state.grid_size, half = (size-1)/2; |
| const tiles = state.tiles||{}, agents = state.agents||[]; |
| |
| |
| if (tileGroup.children.length===0 || tileGroup.userData.gridSize!==size) { |
| clearGroup(tileGroup, true); |
| tileGroup.userData.gridSize = size; |
| for (let x=0; x<size; x++) { |
| for (let y=0; y<size; y++) { |
| const wx=(x-half)*TILE, wz=(y-half)*TILE; |
| const hv = Math.sin(x*0.5)*Math.cos(y*0.7)*0.03; |
| const isEdge = x===0||y===0||x===size-1||y===size-1; |
| const tileH = 0.18+hv; |
| |
| const checker = (x+y)%2===0; |
| const tileMat = new THREE.MeshStandardMaterial({ |
| color: isEdge ? 0x0c0c22 : (checker ? 0x111128 : 0x0e0e22), |
| roughness: 0.55, metalness: 0.45, |
| emissive: isEdge ? 0x080820 : (checker ? 0x090918 : 0x07071a), |
| emissiveIntensity: 0.4, |
| }); |
| const tm = new THREE.Mesh(new THREE.BoxGeometry(TILE*0.96,tileH,TILE*0.96), tileMat); |
| tm.position.set(wx,-tileH/2,wz); tm.receiveShadow=true; |
| |
| tm.userData.gridX = x; tm.userData.gridY = y; tm.userData.isTile = true; |
| tileGroup.add(tm); |
| |
| const edgeColor = isEdge ? 0x2535aa : 0x1a2060; |
| const lineMat = new THREE.MeshBasicMaterial({ |
| color: edgeColor, transparent:true, |
| opacity: isEdge ? 0.7 : 0.25, blending: THREE.AdditiveBlending, |
| }); |
| const lx = new THREE.Mesh(new THREE.BoxGeometry(TILE*0.97,0.003,0.015),lineMat); |
| lx.position.set(wx,0.003,wz-TILE*0.48); tileGroup.add(lx); |
| const lz = new THREE.Mesh(new THREE.BoxGeometry(0.015,0.003,TILE*0.97),lineMat); |
| lz.position.set(wx-TILE*0.48,0.003,wz); tileGroup.add(lz); |
| } |
| } |
| |
| const eo = (size*TILE)/2+0.5; |
| const wallH = 3.0; |
| [[0,wallH/2,eo,0],[0,wallH/2,-eo,0],[eo,wallH/2,0,Math.PI/2],[-eo,wallH/2,0,Math.PI/2]].forEach(([x,y,z,ry])=>{ |
| const wallMat = new THREE.MeshBasicMaterial({color:0x3355ff,transparent:true,opacity:0.06,blending:THREE.AdditiveBlending,side:THREE.DoubleSide}); |
| const b=new THREE.Mesh(new THREE.PlaneGeometry(size*TILE,wallH),wallMat); b.position.set(x,y,z); b.rotation.y=ry; tileGroup.add(b); |
| |
| const edgeMat = new THREE.MeshBasicMaterial({color:0x5588ff,transparent:true,opacity:0.35,blending:THREE.AdditiveBlending}); |
| const eg = new THREE.Mesh(new THREE.BoxGeometry(size*TILE+0.1,0.04,0.04),edgeMat); |
| eg.position.set(x,wallH,z); eg.rotation.y=ry; tileGroup.add(eg); |
| }); |
| |
| const floorRingMat = new THREE.MeshBasicMaterial({color:0x2244cc,transparent:true,opacity:0.15,blending:THREE.AdditiveBlending,side:THREE.DoubleSide}); |
| const floorRing = new THREE.Mesh(new THREE.RingGeometry(size*TILE/2-0.3, size*TILE/2+0.1, 64), floorRingMat); |
| floorRing.rotation.x=-Math.PI/2; floorRing.position.y=0.005; tileGroup.add(floorRing); |
| } |
| |
| clearGroup(objGroup, false); |
| |
| |
| initLootMaterials(THREE); |
| for (let x=0; x<size; x++) for (let y=0; y<size; y++) { |
| const tile = tiles[`${x},${y}`]||{}; |
| if (!tile.loot || !tile.loot.length) continue; |
| const wx=(x-half)*TILE, wz=(y-half)*TILE; |
| const rarity = tile.loot[0]?.rarity || 'common'; |
| const mats = lootMaterials[rarity] || lootMaterials.common; |
| |
| const crystal = new THREE.Mesh(crystalGeo, mats.crystal); |
| crystal.position.set(wx,0.35,wz); |
| crystal.userData = {baseY:0.35, phase:Math.random()*Math.PI*2, rotates:true, |
| isCrystal:true, gridX:x, gridY:y}; |
| crystal.castShadow=true; objGroup.add(crystal); |
| |
| const beam = new THREE.Mesh(beamGeo, mats.beam); |
| beam.position.set(wx,1.5,wz); beam.userData={isBeam:true,phase:Math.random()*Math.PI*2}; objGroup.add(beam); |
| |
| const gr = new THREE.Mesh(ringGeo, mats.ring); |
| gr.rotation.x=-Math.PI/2; gr.position.set(wx,0.01,wz); objGroup.add(gr); |
| |
| const tilGlow = new THREE.Mesh(new THREE.PlaneGeometry(TILE*0.9,TILE*0.9), mats.glow); |
| tilGlow.rotation.x=-Math.PI/2; tilGlow.position.set(wx,0.002,wz); |
| tilGlow.userData = {isTileGlow:true, gridX:x, gridY:y}; |
| objGroup.add(tilGlow); |
| } |
| |
| |
| const activeAids = new Set(agents.map(a => a.id)); |
| |
| |
| for (const aid of Object.keys(agentMeshes)) { |
| if (!activeAids.has(aid)) { |
| const ac = agentMeshes[aid]; |
| if (ac && !ac.userData.dying) { |
| ac.userData.dying = true; |
| ac.userData.deathTimer = 1.0; |
| } |
| } |
| } |
| |
| agents.forEach(a => { |
| const ax=(a.x-half)*TILE, az=(a.y-half)*TILE; |
| const colorHex = AGENT_COLORS[a.id]||'#ffffff'; |
| const col = parseInt(colorHex.slice(1),16); |
| |
| let ac = agentMeshes[a.id]; |
| if (!a.alive) { |
| if (ac && !ac.userData.dying) { |
| ac.userData.dying = true; |
| ac.userData.deathTimer = 1.0; |
| } |
| return; |
| } |
| |
| if (!ac) { |
| ac = new THREE.Group(); |
| ac.position.set(ax, 0, az); |
| ac.userData = { |
| aid: a.id, |
| phase: Math.random()*Math.PI*2, |
| targetPos: new THREE.Vector3(ax, 0, az), |
| flashTimer: 0 |
| }; |
| agentGroup.add(ac); |
| agentMeshes[a.id] = ac; |
| |
| const pl = new THREE.PointLight(col,0.7,4,2); pl.position.set(0,0.3,0); ac.add(pl); |
| |
| if (!agentRingMats[a.id]) agentRingMats[a.id]=new THREE.MeshBasicMaterial({color:col,side:THREE.DoubleSide,transparent:true,opacity:0.5,blending:THREE.AdditiveBlending}); |
| const ring = new THREE.Mesh(ringGeoAgent,agentRingMats[a.id]); |
| ring.rotation.x=-Math.PI/2; ring.position.set(0,0.02,0); ac.add(ring); |
| |
| if (!agentPillarMats[a.id]) agentPillarMats[a.id]=new THREE.MeshStandardMaterial({color:col,emissive:col,emissiveIntensity:0.5,roughness:0.25,metalness:0.4}); |
| const pillar = new THREE.Mesh(pillarGeo,agentPillarMats[a.id]); |
| pillar.name='pillar'; pillar.position.set(0,0.5,0); pillar.castShadow=true; pillar.userData.aid=a.id; ac.add(pillar); |
| |
| if (!agentTopMats[a.id]) agentTopMats[a.id]=new THREE.MeshStandardMaterial({color:0xffffff,emissive:col,emissiveIntensity:0.8,roughness:0.1,metalness:0.5}); |
| const tg = new THREE.Mesh(topGeo,agentTopMats[a.id]); |
| tg.name='topGem'; tg.position.set(0,1.05,0); tg.userData.rotates=true; ac.add(tg); |
| |
| const label = makeLabel(a.name, colorHex); label.name='nameLabel'; label.position.set(0,1.55,0); ac.add(label); |
| } |
| |
| |
| ac.userData.targetPos.set(ax, 0, az); |
| ac.userData.shielded = a.shielded; |
| |
| |
| for (let i = ac.children.length - 1; i >= 0; i--) { |
| if (ac.children[i].userData.isShield) ac.remove(ac.children[i]); |
| } |
| if (a.shielded) { |
| const sh = new THREE.Mesh(shieldGeo,shieldMat); sh.position.set(0,0.6,0); sh.userData.isShield=true; ac.add(sh); |
| const sw = new THREE.Mesh(shieldWireGeo,shieldWireMat); sw.position.set(0,0.6,0); sw.userData.isShield=true; ac.add(sw); |
| } |
| |
| |
| const oldHp = ac.getObjectByName('hpBar'); |
| if (oldHp) ac.remove(oldHp); |
| const hpBar = createHpBarMesh(a.hp); hpBar.position.set(0,1.35,0); ac.add(hpBar); |
| |
| |
| const oldSel = ac.getObjectByName('selRing'); |
| if (oldSel) ac.remove(oldSel); |
| if (a.id === selectedId) { |
| if (!agentSelMats[a.id]) { |
| agentSelMats[a.id] = new THREE.MeshBasicMaterial({color:col,side:THREE.DoubleSide,transparent:true,opacity:0.8,blending:THREE.AdditiveBlending}); |
| } |
| const selRing = new THREE.Mesh( |
| new THREE.RingGeometry(0.45,0.52,24), |
| agentSelMats[a.id] |
| ); |
| selRing.name = 'selRing'; |
| selRing.rotation.x=-Math.PI/2; selRing.position.set(0,0.03,0); ac.add(selRing); |
| } |
| }); |
| |
| |
| if (fpsMode && selectedId) updateFpsCamera(); |
| } |
| |
| app = {scene,camera,renderer,controls,build,resize,THREE,agentGroup,tileGroup,clearGroup, |
| }; |
| } |
| |
| function renderScene() { if (app) app.build(); } |
| |
| |
| function enterFps() { |
| if (!selectedId || !state || !app) return; |
| const agent = (state.agents||[]).find(a=>a.id===selectedId); |
| if (!agent || !agent.alive) return; |
| fpsMode = true; |
| |
| const half = (state.grid_size-1)/2; |
| const ax = (agent.x - half)*TILE, az = (agent.y - half)*TILE; |
| |
| |
| app.controls.minDistance = 2; |
| app.controls.maxDistance = 10; |
| app.controls.maxPolarAngle = Math.PI / 1.7; |
| |
| |
| const offset = new app.THREE.Vector3(4, 3.5, 4); |
| app.camera.position.set(ax + offset.x, offset.y, az + offset.z); |
| cameraTarget = new app.THREE.Vector3(ax, 0.7, az); |
| app.controls.target.copy(cameraTarget); |
| app.controls.update(); |
| |
| document.getElementById('fpsHud').classList.add('active'); |
| updateCameraBadge(); |
| updateFpsHud(agent); |
| buildSidebar(); |
| } |
| |
| function updateCameraBadge() { |
| const badge = document.getElementById('cameraModeBadge'); |
| if (!badge) return; |
| if (fpsMode) { |
| badge.textContent = '📹 EXIT CHASE'; |
| badge.classList.add('active'); |
| badge.disabled = false; |
| badge.title = 'Exit chase cam (V)'; |
| } else if (selectedId) { |
| badge.textContent = '📹 CHASE CAM'; |
| badge.classList.remove('active'); |
| badge.disabled = false; |
| badge.title = 'Enter chase cam (V)'; |
| } else { |
| badge.textContent = '📹 OVERVIEW'; |
| badge.classList.remove('active'); |
| badge.disabled = true; |
| badge.title = 'Select an agent first'; |
| } |
| } |
| |
| function exitFps() { |
| fpsMode = false; |
| fpsCameraPos = null; |
| fpsLookTarget = null; |
| document.getElementById('fpsHud').classList.remove('active'); |
| updateCameraBadge(); |
| |
| if (app) { |
| app.controls.minDistance = 6; |
| app.controls.maxDistance = 55; |
| app.controls.maxPolarAngle = Math.PI / 2.1; |
| } |
| buildSidebar(); |
| |
| if (app && state && selectedId) { |
| const agent = (state.agents||[]).find(a=>a.id===selectedId); |
| if (agent) { |
| const half=(state.grid_size-1)/2; |
| const ax=(agent.x-half)*TILE, az=(agent.y-half)*TILE; |
| cameraTarget = new app.THREE.Vector3(ax, 0, az); |
| } |
| } else if (app) { |
| cameraTarget = new app.THREE.Vector3(0, 0, 0); |
| } |
| } |
| |
| function updateFpsCamera() { |
| if (!fpsMode || !selectedId || !state || !app) return; |
| const agent = (state.agents||[]).find(a=>a.id===selectedId); |
| if (!agent || !agent.alive) { exitFps(); return; } |
| const half=(state.grid_size-1)/2; |
| const ax=(agent.x-half)*TILE, az=(agent.y-half)*TILE; |
| |
| |
| cameraTarget = new app.THREE.Vector3(ax, 0.7, az); |
| |
| updateFpsHud(agent); |
| } |
| |
| function updateFpsHud(agent) { |
| const colorHex = AGENT_COLORS[agent.id]||'#ffffff'; |
| document.getElementById('fpsAgentName').textContent = agent.name; |
| document.getElementById('fpsAgentName').style.color = colorHex; |
| document.getElementById('fpsAgentPos').textContent = `(${agent.x}, ${agent.y}) · ${agent.hp} HP`; |
| |
| document.getElementById('fpsAgentBorder').style.borderColor = colorHex+'44'; |
| |
| const ring = document.getElementById('fpsReticleRing'); |
| if (ring) ring.style.borderColor = colorHex+'80'; |
| |
| const hp = agent.hp; |
| const hpColor = hp>60?'var(--cyan)':hp>30?'var(--gold)':'var(--accent)'; |
| document.getElementById('fpsHpVal').textContent = hp; |
| document.getElementById('fpsHpVal').style.color = hpColor; |
| document.getElementById('fpsHpFill').style.width = hp+'%'; |
| document.getElementById('fpsHpFill').style.background = hpColor; |
| const half=(state.grid_size-1)/2; |
| updateRadar(agent, half); |
| } |
| |
| function updateRadar(selfAgent, half) { |
| const radar = document.getElementById('fpsRadar'); |
| |
| radar.querySelectorAll('.fps-radar-blip').forEach(b=>b.remove()); |
| if (!state) return; |
| const agents = state.agents||[]; |
| const radarRadius = 45; |
| agents.forEach(a => { |
| if (!a.alive || a.id === selfAgent.id) return; |
| const dx = (a.x - selfAgent.x)*TILE; |
| const dz = (a.y - selfAgent.y)*TILE; |
| const dist = Math.sqrt(dx*dx+dz*dz); |
| if (dist > 8) return; |
| const scale = radarRadius / 8; |
| const bx = 50 + dx*scale; |
| const by = 50 + dz*scale; |
| const blip = document.createElement('div'); |
| blip.className = 'fps-radar-blip'; |
| blip.style.left = Math.max(5,Math.min(95,bx))+'%'; |
| blip.style.top = Math.max(5,Math.min(95,by))+'%'; |
| blip.style.background = AGENT_COLORS[a.id]||'#ff4655'; |
| blip.style.boxShadow = `0 0 4px ${AGENT_COLORS[a.id]||'#ff4655'}`; |
| radar.appendChild(blip); |
| }); |
| } |
| |
| |
| function parseEvents() { |
| if (!state || !state.turn_log || !state.turn_log.agents) return; |
| const agents = state.agents||[]; |
| const log = state.turn_log.agents; |
| const half = (state.grid_size - 1) / 2; |
| const THREE = app ? (app.THREE || window.__gr3d.THREE) : null; |
| |
| for (const aid of Object.keys(log)) { |
| const agent = agents.find(a=>a.id===aid); |
| if (!agent) continue; |
| const name = agent.name; |
| const color = AGENT_COLORS[aid]||'#888'; |
| const entries = log[aid]||[]; |
| const fromPos = THREE ? new THREE.Vector3((agent.x - half) * TILE, 0.5, (agent.y - half) * TILE) : null; |
| |
| for (const entry of entries) { |
| if (entry.phase!=='exec') continue; |
| const tool = entry.tool||'', result=entry.result||'', args=entry.args||{}; |
| if (tool==='activate_ability') { |
| const ability=(args.ability||'').toLowerCase(); |
| if (ability==='attack') { |
| const tx = args.x !== undefined ? args.x : args.target_x; |
| const ty = args.y !== undefined ? args.y : args.target_y; |
| |
| if (tx !== undefined && ty !== undefined && THREE && fromPos) { |
| const toPos = new THREE.Vector3((tx - half) * TILE, 0.5, (ty - half) * TILE); |
| spawnLaserEffect(fromPos, toPos, color); |
| |
| const victim = agents.find(a => a.x === tx && a.y === ty && a.alive); |
| if (victim) { |
| if (result.toLowerCase().includes('blocked') || result.toLowerCase().includes('shield')) { |
| spawnShieldBlockEffect(toPos, '#4a9eff'); |
| } else { |
| const victimGroup = agentMeshes[victim.id]; |
| if (victimGroup) { |
| victimGroup.userData.flashTimer = 0.35; |
| } |
| } |
| } |
| } |
| |
| if (result.toLowerCase().includes('eliminated')) { |
| const m=result.match(/Attacked\s+(\w+)/i); |
| addKillToast('kill',`💀 ${name} eliminated ${m?m[1]:'someone'}`,color); |
| } else if (result.toLowerCase().includes('damage')) { |
| const dm=result.match(/(\d+)\s*damage/i); |
| addKillToast('damage',`⚔ ${name} attacked${dm?' for '+dm[1]+' dmg':''}`,color); |
| } |
| } else if (ability==='dash') { |
| addKillToast('ability',`⚡ ${name} used Dash`,color); |
| } else if (ability==='shield') { |
| addKillToast('ability',`🛡 ${name} activated Shield`,color); |
| } else if (ability==='heal') { |
| const hm=result.match(/Healed for (\d+)/i); |
| addKillToast('ability',`💚 ${name} healed${hm?' +'+hm[1]+' HP':''}`,color); |
| if (THREE && fromPos) { |
| spawnHealEffect(fromPos, '#00e5cc'); |
| } |
| } |
| } else if (tool==='move') { |
| if (result.toLowerCase().includes('loot')||result.toLowerCase().includes('picked up')) { |
| const am=result.match(/ability '(\w+)'/i); |
| const pm=result.match(/picked up (\d+) points/i); |
| if (am) addKillToast('loot',`📦 ${name} found ${am[1]}`,color); |
| else if (pm) addKillToast('loot',`📦 ${name} looted ${pm[1]} pts`,color); |
| } |
| } |
| } |
| } |
| } |
| |
| function addKillToast(type,text,color) { |
| const feed = document.getElementById('killFeed'); |
| if (!feed) return; |
| const toast = document.createElement('div'); |
| toast.className=`kill-toast ${type}`; |
| toast.innerHTML=`<span style="color:${color}">${text}</span>`; |
| feed.appendChild(toast); |
| while (feed.children.length>5) feed.removeChild(feed.firstChild); |
| setTimeout(()=>{ |
| toast.classList.add('fade-out'); |
| setTimeout(()=>toast.remove(), 350); |
| }, 4000); |
| } |
| |
| |
| function buildSidebar() { |
| if (!state) return; |
| const agents = state.agents||[]; |
| const sidebar = document.getElementById('sidebar'); |
| const selected = agents.find(a=>a.id===selectedId); |
| sidebar.innerHTML = selected ? buildDetailPanel(selected) : buildLeaderboard(agents)+buildTracePanel(); |
| bindSidebarEvents(); |
| } |
| |
| function buildLeaderboard(agents) { |
| const sorted = [...agents].sort((a,b)=>b.score-a.score); |
| let html = `<div class="section-head"><span class="section-head-label">Standings</span></div><div class="lb-container">`; |
| sorted.forEach((a,i) => { |
| const color = AGENT_COLORS[a.id]||'#888'; |
| const hpPct = Math.max(0,Math.min(100,a.hp)); |
| const hpColor = hpPct>60?'#00e5cc':hpPct>30?'#ffd700':'#ff4655'; |
| const totalKills = (state.agents||[]).reduce((s,ag)=>s+ag.kills,0)||1; |
| html += ` |
| <div class="lb-row ${a.alive?'':'dead'} ${a.id===selectedId?'selected':''}" data-aid="${a.id}"> |
| <span class="lb-rank">${i+1}</span> |
| <div class="lb-avatar-wrap"> |
| <div class="lb-avatar" style="background:${color}">${a.name.charAt(0).toUpperCase()}</div> |
| <div class="lb-avatar-ring" style="border-color:${color}80;opacity:${a.alive?1:0}"></div> |
| <div class="lb-avatar-status" style="background:${a.alive?'#00e5cc':'#3d3d4a'}"></div> |
| </div> |
| <div class="lb-info"> |
| <div class="lb-name" style="color:${a.alive?color:'var(--text-dim)'}">${a.name}</div> |
| <div class="lb-meta"> |
| <span class="lb-kill-badge">⚔ ${a.kills}</span> |
| <div class="lb-hp-wrap"> |
| <div class="lb-hp-bar"><div class="lb-hp-fill" style="width:${hpPct}%;background:${hpColor}"></div></div> |
| <div class="lb-hp-pct">${a.hp} HP</div> |
| </div> |
| </div> |
| </div> |
| <span class="lb-score">${a.score}</span> |
| </div>`; |
| }); |
| html += '</div>'; |
| return html; |
| } |
| |
| function buildDetailPanel(a) { |
| const color = AGENT_COLORS[a.id]||'#888'; |
| const hpPct = Math.max(0,Math.min(100,a.hp)); |
| const hpColor = hpPct>60?'#00e5cc':hpPct>30?'#ffd700':'#ff4655'; |
| const abilities = a.abilities||[]; |
| |
| let html = ` |
| <div class="detail-back" onclick="selectAgent(null)">← Back to standings</div> |
| <div class="detail-hero" style="background:linear-gradient(135deg,${color}10,transparent)"> |
| <div class="detail-hero-bg"></div> |
| <div class="detail-hero-content"> |
| <div class="detail-avatar-lg" style="background:${color};box-shadow:0 0 30px ${color}50">${a.name.charAt(0).toUpperCase()}</div> |
| <div class="detail-name-lg" style="color:${color}">${a.name}</div> |
| <div class="detail-location">📍 (${a.x}, ${a.y}) · ${a.alive?'<span style="color:#00e5cc">ALIVE</span>':'<span style="color:#ff4655">DEAD</span>'} ${a.shielded?'· 🛡 SHIELDED':''}</div> |
| </div> |
| <button class="detail-pov-btn ${fpsMode?'on':'off'}" id="povToggleBtn" onclick="togglePov()"> |
| ${fpsMode?'📹 EXIT CHASE':'📹 CHASE CAM'} |
| </button> |
| </div> |
| <div class="stats-grid"> |
| <div class="stat-box"> |
| <div class="stat-box-label">HP</div> |
| <div class="stat-box-val" style="color:${hpColor}">${a.hp}</div> |
| <div class="stat-box-sub"><div class="stat-box-sub-fill" style="width:${hpPct}%;background:${hpColor}"></div></div> |
| </div> |
| <div class="stat-box"> |
| <div class="stat-box-label">Score</div> |
| <div class="stat-box-val" style="color:var(--gold)">${a.score}</div> |
| </div> |
| <div class="stat-box"> |
| <div class="stat-box-label">Kills</div> |
| <div class="stat-box-val" style="color:var(--accent)">${a.kills}</div> |
| </div> |
| </div>`; |
| |
| if (abilities.length) { |
| html += `<div class="abilities-section"> |
| <div class="section-head-label" style="padding:0;margin-bottom:0">Abilities</div> |
| <div class="abilities-row">`; |
| abilities.forEach(ab => { |
| const ready = ab.uses_remaining===null||ab.uses_remaining>0; |
| const depleted = ab.uses_remaining===0; |
| html += `<div class="ability-chip ${ready?'ready':''} ${depleted?'depleted':''}"> |
| <div class="ability-chip-name">${ab.name}</div> |
| <div class="ability-chip-uses">${ab.uses_remaining===null?'∞':ab.uses_remaining}</div> |
| </div>`; |
| }); |
| html += `</div></div>`; |
| } |
| |
| html += ` |
| <div class="detail-tabs"> |
| <div class="detail-tab ${activeTab==='trace'?'active':''}" data-tab="trace">Trace</div> |
| <div class="detail-tab ${activeTab==='messages'?'active':''}" data-tab="messages">Messages</div> |
| <div class="detail-tab ${activeTab==='tools'?'active':''}" data-tab="tools">Tools</div> |
| </div> |
| <div class="detail-tab-content">${buildTabContent(a.id, activeTab)}</div>`; |
| |
| return html; |
| } |
| |
| function buildTabContent(aid, tab) { |
| if (tab==='trace') return buildTraceForAgent(aid); |
| if (tab==='messages') return buildMessages(aid); |
| if (tab==='tools') return buildTools(); |
| return ''; |
| } |
| |
| function buildTraceForAgent(aid) { |
| const entries = traceHistory[aid] || []; |
| if (!entries.length) return '<div class="empty-state">No trace data yet</div>'; |
| return entries.map(e => { |
| if (e.phase !== 'exec') return ''; |
| const vis = pickVis(e.tool, e.args); |
| const oc = outcomeChips(e.result); |
| let extra = ''; |
| if (e.tool === 'move' || (e.tool === 'activate_ability' && e.args && e.args.ability === 'dash')) { |
| const dx = e.args?.dx ?? 0, dy = e.args?.dy ?? 0; |
| extra = `<span class="trace-arrow" style="color:${vis.color}">${ARROW_FOR(dx,dy)}</span>`; |
| } else if (e.tool === 'activate_ability' && e.args && e.args.ability === 'attack' && (e.args.x!==undefined)) { |
| extra = `<span class="trace-chip pos">→ (${e.args.x},${e.args.y})</span>`; |
| } else if (e.tool === 'think') { |
| const txt = String(e.args?.content || '').slice(0, 80); |
| extra = txt ? `<span style="font-size:9.5px;color:var(--text-dim);font-style:italic;font-weight:400">${esc(txt)}${e.args?.content?.length>80?'…':''}</span>` : ''; |
| } |
| const result = String(e.result || '').slice(0, 120); |
| return `<div class="trace-row ${oc.fail?'fail':''}" style="border-left-color:${vis.color}"> |
| <div class="trace-head"> |
| <span class="trace-icon">${vis.icon}</span> |
| <span class="trace-tool" style="color:${vis.color}">${vis.label}</span> |
| ${extra} |
| ${oc.chips} |
| <span class="trace-turn">t${e.turn}·r${e.round}</span> |
| </div> |
| ${result?`<div class="trace-result">${esc(result)}</div>`:''} |
| </div>`; |
| }).join(''); |
| } |
| |
| function buildMessages(aid) { |
| const agent = (state.agents||[]).find(a=>a.id===aid); |
| if (!agent) return '<div class="empty-state">No data</div>'; |
| const msgs = agent.messages||[]; |
| if (!msgs.length) return '<div class="empty-state">No messages yet</div>'; |
| const roleConfig = { |
| system:{color:'#5a8a5a',label:'SYSTEM'},user:{color:'#ff4655',label:'USER'}, |
| assistant:{color:'#e8e6f0',label:'ASSISTANT'},tool:{color:'#6b6b90',label:'TOOL'}, |
| }; |
| return msgs.map(m => { |
| const rc = roleConfig[m.role]||{color:'#888',label:m.role}; |
| let content = m.content||''; |
| if (m.role==='assistant'&&m.tool_calls) content=(m.tool_calls||[]).map(tc=>`${tc.function.name}(${JSON.stringify(tc.function.arguments||{})})`).join('\n'); |
| return `<div class="trace-row" style="border-left-color:${rc.color}"> |
| <div class="trace-label" style="color:${rc.color}">${rc.label}</div> |
| <div class="trace-content">${esc(content.slice(0,400))}</div> |
| </div>`; |
| }).join(''); |
| } |
| |
| function buildTools() { |
| const schemas = state.tool_schemas; |
| if (!schemas||!schemas.length) return '<div class="empty-state">No tools defined</div>'; |
| return schemas.map(t => { |
| const name=t.function?.name||t.name||'?', desc=t.function?.description||t.description||''; |
| const props=t.function?.parameters?.properties||{}, req=t.function?.parameters?.required||[]; |
| const propHtml = Object.keys(props).map(k=>{ |
| const p=props[k]; const isReq=req.includes(k); |
| return `<div style="padding:2px 0;font-size:10px"><span style="color:var(--text)">${esc(k)}</span><span style="color:var(--text-dim)">: ${p.type||'any'}${isReq?' <span style="color:var(--accent)">*</span>':''}</span>${p.description?`<div style="color:var(--text-muted);font-size:9px">${esc(p.description)}</div>`:''}</div>`; |
| }).join(''); |
| return `<div class="trace-row" style="border-left-color:var(--accent)"> |
| <div class="trace-label" style="color:var(--accent)">${esc(name)}</div> |
| <div class="trace-content" style="color:var(--text-dim)">${esc(desc)}</div> |
| ${propHtml?`<div style="margin-top:4px;border-top:1px solid var(--border);padding-top:4px">${propHtml}</div>`:''} |
| </div>`; |
| }).join(''); |
| } |
| |
| function buildTracePanel() { |
| const log = state.turn_log; |
| if (!log||!log.agents) return `<div class="section-head"><span class="section-head-label">Turn Trace</span></div><div class="empty-state">No turn data yet</div>`; |
| const agents = state.agents||[]; |
| let html = `<div class="section-head"><span class="section-head-label">Turn Trace</span></div><div class="trace-global">`; |
| for (const aid of Object.keys(log.agents)) { |
| const agent = agents.find(a=>a.id===aid); |
| const name = agent?agent.name:aid, color=AGENT_COLORS[aid]||'#888'; |
| const entries = log.agents[aid]||[]; |
| entries.forEach(e => { |
| if (e.phase!=='exec') return; |
| const vis = pickVis(e.tool, e.args); |
| const oc = outcomeChips(e.result); |
| let extra = ''; |
| if (e.tool === 'move' || (e.tool === 'activate_ability' && e.args && e.args.ability === 'dash')) { |
| extra = `<span class="trace-arrow" style="color:${vis.color}">${ARROW_FOR(e.args?.dx, e.args?.dy)}</span>`; |
| } |
| html+=`<div class="trace-row ${oc.fail?'fail':''}" style="border-left-color:${color}"> |
| <div class="trace-head"> |
| <span class="trace-icon">${vis.icon}</span> |
| <span style="color:${color};font-weight:700;font-size:10px">${esc(name)}</span> |
| <span class="trace-tool" style="color:${vis.color}">${vis.label}</span> |
| ${extra} |
| ${oc.chips} |
| </div> |
| </div>`; |
| }); |
| } |
| html += '</div>'; |
| return html; |
| } |
| |
| function bindSidebarEvents() { |
| document.querySelectorAll('.lb-row[data-aid]').forEach(el=>{ |
| el.addEventListener('click', ()=>selectAgent(el.dataset.aid)); |
| }); |
| document.querySelectorAll('.detail-tab[data-tab]').forEach(el=>{ |
| el.addEventListener('click', ()=>{ activeTab=el.dataset.tab; buildSidebar(); }); |
| }); |
| } |
| |
| |
| function esc(s) { const d=document.createElement('div'); d.textContent=s; return d.innerHTML; } |
| function hexToRgb(hex) { |
| const r=parseInt(hex.slice(1,3),16), g=parseInt(hex.slice(3,5),16), b=parseInt(hex.slice(5,7),16); |
| return `${r},${g},${b}`; |
| } |
| |
| function togglePov() { |
| if (fpsMode) exitFps(); |
| else enterFps(); |
| } |
| |
| function selectAgent(id) { |
| if (fpsMode && id !== selectedId) exitFps(); |
| selectedId = id; |
| activeTab = 'trace'; |
| buildSidebar(); |
| updateCameraBadge(); |
| if (id && state) { |
| const agent = (state.agents||[]).find(a=>a.id===id); |
| if (agent && app) { |
| const half=(state.grid_size-1)/2; |
| const ax=(agent.x-half)*TILE, az=(agent.y-half)*TILE; |
| cameraTarget = new app.THREE.Vector3(ax,0,az); |
| } |
| } else { |
| if (app) cameraTarget = new app.THREE.Vector3(0,0,0); |
| } |
| |
| if (app) app.build(); |
| } |
| |
| |
| function updateTopBar() { |
| if (!state) return; |
| const agents = state.agents||[]; |
| const alive = agents.filter(a=>a.alive).length; |
| const turn = state.turn||0, maxTurns=state.max_turns||50; |
| const totalKills = agents.reduce((s,a)=>s+a.kills,0); |
| const lootCount = Object.values(state.tiles||{}).filter(t=>t.loot).length; |
| const winner = state.winner||(alive===1?agents.find(a=>a.alive)?.name:null); |
| const gameOver = state.game_over||alive<=1; |
| |
| const tb = document.getElementById('turnBadge'); |
| tb.textContent = `${turn} / ${maxTurns}`; |
| tb.classList.add('flash'); setTimeout(()=>tb.classList.remove('flash'), 350); |
| |
| document.getElementById('turnProgress').style.width = `${(turn/maxTurns)*100}%`; |
| document.getElementById('statAlive').textContent = alive; |
| document.getElementById('statKills').textContent = totalKills; |
| document.getElementById('statLoot').textContent = lootCount; |
| |
| |
| |
| document.getElementById('agentDots').innerHTML = agents.map(a=>{ |
| const isSel = a.id===selectedId; |
| return `<div class="agent-dot ${a.alive?'alive':'dead'} ${isSel?'selected':''}" style="background:${AGENT_COLORS[a.id]||'#888'};color:${AGENT_COLORS[a.id]||'#888'}" title="${a.name}"></div>`; |
| }).join(''); |
| |
| |
| const winEl = document.getElementById('winnerAnnounce'); |
| if (winner) { winEl.textContent=`🏆 ${winner.toUpperCase()} WINS`; winEl.style.display='inline'; } |
| else if (gameOver) { winEl.textContent='GAME OVER'; winEl.style.color='var(--accent)'; winEl.style.display='inline'; } |
| else winEl.style.display='none'; |
| |
| document.getElementById('stepBtn').disabled = gameOver; |
| document.getElementById('autoBtn').disabled = gameOver; |
| |
| |
| if (fpsMode && selectedId) updateFpsCamera(); |
| } |
| |
| |
| function showGameOver() { |
| const agents = state.agents||[]; |
| const alive = agents.filter(a=>a.alive).length; |
| const winner = state.winner||(alive>=1?agents.find(a=>a.alive)?.name:null); |
| const sorted = [...agents].sort((a,b)=>b.score-a.score); |
| const card = document.getElementById('gameOverCard'); |
| card.innerHTML = ` |
| <div class="go-trophy">🏆</div> |
| <div class="go-title">${winner?winner.toUpperCase()+' WINS':'GAME OVER'}</div> |
| <div class="go-subtitle">Final Standings</div> |
| <div class="go-leaderboard"> |
| ${sorted.map((a,i)=>` |
| <div class="go-lb-row ${a.alive?'':'dead'}"> |
| <span class="go-rank">#${i+1}</span> |
| <span class="go-avatar" style="background:${AGENT_COLORS[a.id]||'#888'}">${a.name.charAt(0).toUpperCase()}</span> |
| <span class="go-name" style="color:${AGENT_COLORS[a.id]||'#888'}">${a.name}</span> |
| <span class="go-kills">⚔${a.kills}</span> |
| <span class="go-score">${a.score}</span> |
| </div>`).join('')} |
| </div> |
| <button class="go-play-again" onclick="playAgain()">↻ PLAY AGAIN</button>`; |
| document.getElementById('gameOverOverlay').classList.add('active'); |
| } |
| |
| |
| let selectedTile = null; |
| |
| function selectTile(gx, gy, screenX, screenY) { |
| if (!state || !state.tiles) return; |
| const tileData = state.tiles[`${gx},${gy}`]; |
| if (!tileData || !tileData.loot || !tileData.loot.length) return; |
| selectedTile = {gx, gy}; |
| |
| const popup = document.getElementById('lootPopup'); |
| const scene = document.getElementById('sceneWrap'); |
| const sceneRect = scene.getBoundingClientRect(); |
| |
| let px = screenX - sceneRect.left + 12; |
| let py = screenY - sceneRect.top + 12; |
| if (px + 220 > sceneRect.width) px = screenX - sceneRect.left - 230; |
| if (py + 160 > sceneRect.height) py = screenY - sceneRect.top - 160; |
| popup.style.left = px + 'px'; |
| popup.style.top = py + 'px'; |
| |
| document.getElementById('lootPopupCoord').textContent = `Grid (${gx}, ${gy})`; |
| document.getElementById('lootPopupItems').innerHTML = buildLootPanel(tileData.loot); |
| popup.classList.add('active'); |
| } |
| |
| function hideLootPopup() { |
| selectedTile = null; |
| const popup = document.getElementById('lootPopup'); |
| if (popup) { |
| popup.classList.remove('active'); |
| setTimeout(() => { popup.style.display = 'none'; popup.style.display = ''; }, 200); |
| } |
| } |
| |
| function buildLootPanel(items) { |
| const ICONS = { points: '💰', attack: '⚔️', dash: '⚡', shield: '🛡️', heal: '💚' }; |
| return items.map(item => { |
| const icon = item.type === 'ability' ? (ICONS[item.ability_name] || '✨') : ICONS.points; |
| const name = item.type === 'ability' ? item.ability_name.charAt(0).toUpperCase() + item.ability_name.slice(1) + ' Ability' : `${item.points} Points`; |
| const source = item.source === 'chest' ? 'from chest' : `dropped by ${item.source}`; |
| return ` |
| <div class="loot-item-row"> |
| <div class="loot-item-icon ${item.rarity}">${icon}</div> |
| <div class="loot-item-details"> |
| <div class="loot-item-name">${name}</div> |
| <div class="loot-item-rarity ${item.rarity}">${item.rarity}</div> |
| <div class="loot-item-source">${source}</div> |
| </div> |
| </div>`; |
| }).join(''); |
| } |
| |
| function playAgain() { |
| if (fpsMode) exitFps(); |
| document.getElementById('gameOverOverlay').classList.remove('active'); |
| document.getElementById('game').classList.remove('visible'); |
| document.getElementById('game').style.display='none'; |
| document.getElementById('lobby').classList.remove('hidden'); |
| document.getElementById('startBtn').disabled=false; |
| document.getElementById('startBtn').textContent='⚔ Deploy Agents'; |
| state=null; selectedId=null; gameStarted=false; autoRunning=false; |
| agentMeshes = {}; |
| activeFX = []; |
| traceHistory = {}; |
| if (app) { |
| app.clearGroup(app.agentGroup, true); |
| app.clearGroup(app.tileGroup, true); |
| } |
| } |
| |
| |
| async function api(method, path, body) { |
| const opts={method,headers:{'Content-Type':'application/json'}}; |
| if (body) opts.body=JSON.stringify(body); |
| const r = await fetch(API+path, opts); |
| return r.json(); |
| } |
| |
| async function startGame() { |
| const gridSize=parseInt(document.getElementById('gridSize').value); |
| const numAgents=parseInt(document.getElementById('numAgents').value); |
| const numChests=parseInt(document.getElementById('numChests').value); |
| const maxTurns=parseInt(document.getElementById('maxTurns').value); |
| |
| saveCurrentPrompt(); |
| const systemPrompts = {}; |
| for (let i = 0; i < numAgents; i++) { |
| const aid = `agent_${i}`; |
| systemPrompts[aid] = agentSystemPrompts[aid] || ''; |
| } |
| |
| document.getElementById('startBtn').disabled=true; |
| document.getElementById('startBtn').textContent='Deploying...'; |
| state = await api('POST','/api/game/start',{ |
| grid_size:gridSize, |
| max_agents:numAgents, |
| num_chests:numChests, |
| max_turns:maxTurns, |
| agent_names:agentNames.slice(0, numAgents), |
| system_prompts:systemPrompts |
| }); |
| state = await api('GET','/api/game/state'); |
| gameStarted=true; |
| nameMaterialCache={}; |
| traceHistory={}; |
| if (app) app.clearGroup(app.tileGroup, true); |
| document.getElementById('lobby').classList.add('hidden'); |
| const game=document.getElementById('game'); game.classList.add('visible'); game.style.display='flex'; |
| showLoading('Initializing arena...'); |
| try { |
| await loadThree(); |
| if (!app) initScene(); |
| requestAnimationFrame(()=>{ if(app) app.resize(); renderScene(); }); |
| updateUI(); |
| } finally { hideLoading(); } |
| } |
| |
| function showLoading(label) { |
| document.getElementById('loadingText').textContent=label||'Agents are thinking...'; |
| document.getElementById('loadingOverlay').classList.add('active'); |
| document.getElementById('stepBtn').disabled=true; |
| document.getElementById('autoBtn').disabled=true; |
| } |
| |
| function hideLoading() { |
| document.getElementById('loadingOverlay').classList.remove('active'); |
| if (state&&!state.game_over) { |
| const alive=(state.agents||[]).filter(a=>a.alive).length; |
| if (alive>1) { document.getElementById('stepBtn').disabled=false; document.getElementById('autoBtn').disabled=false; } |
| } |
| } |
| |
| async function step() { |
| showLoading('Agents are thinking...'); |
| try { |
| state = await api('POST','/api/game/step'); |
| renderScene(); parseEvents(); updateUI(); checkGameOver(); |
| } finally { hideLoading(); } |
| } |
| |
| async function auto() { |
| if (autoRunning) { autoRunning=false; return; } |
| autoRunning=true; |
| const autoBtn=document.getElementById('autoBtn'), stepBtn=document.getElementById('stepBtn'); |
| autoBtn.textContent='⏹ STOP'; autoBtn.classList.add('active'); stepBtn.disabled=true; |
| try { |
| while (autoRunning) { |
| const delay=parseInt(document.getElementById('speedSelect').value)||100; |
| let data; |
| try { data=await api('POST','/api/game/step'); } catch(e) { break; } |
| state=data; renderScene(); parseEvents(); updateUI(); |
| autoBtn.disabled=false; autoBtn.textContent='⏹ STOP'; stepBtn.disabled=true; |
| if (state.game_over||(state.agents||[]).filter(a=>a.alive).length<=1) { checkGameOver(); break; } |
| if (delay>0) await new Promise(r=>setTimeout(r,delay)); |
| } |
| } finally { |
| autoRunning=false; |
| autoBtn.textContent='⏩ AUTO'; autoBtn.classList.remove('active'); autoBtn.disabled=false; |
| const alive=state?(state.agents||[]).filter(a=>a.alive).length:0; |
| stepBtn.disabled=!state||state.game_over||alive<=1; |
| updateUI(); |
| } |
| } |
| |
| function checkGameOver() { |
| if (!state) return; |
| const alive=(state.agents||[]).filter(a=>a.alive).length; |
| if (state.game_over||alive<=1) setTimeout(()=>showGameOver(),600); |
| } |
| |
| function accumulateTrace() { |
| if (!state || !state.turn_log || !state.turn_log.agents) return; |
| const log = state.turn_log.agents; |
| for (const aid of Object.keys(log)) { |
| if (!traceHistory[aid]) traceHistory[aid] = []; |
| const entries = log[aid] || []; |
| entries.forEach(e => { |
| const targetTurn = e.turn !== undefined ? e.turn : state.turn; |
| const exists = traceHistory[aid].some(x => x.phase === e.phase && x.turn === targetTurn && x.round === e.round && x.tool === e.tool); |
| if (!exists) { |
| const copy = { ...e, turn: targetTurn }; |
| traceHistory[aid].push(copy); |
| } |
| }); |
| } |
| } |
| |
| function updateUI() { |
| if (!state) return; |
| accumulateTrace(); |
| |
| |
| const scrollPositions = {}; |
| const scrollableSelectors = ['.lb-container', '.detail-tab-content', '#sidebar', '.trace-global']; |
| scrollableSelectors.forEach(sel => { |
| const el = document.querySelector(sel); |
| if (el) { |
| const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 25; |
| scrollPositions[sel] = { |
| top: el.scrollTop, |
| isAtBottom: isAtBottom |
| }; |
| } |
| }); |
| |
| updateTopBar(); |
| buildSidebar(); |
| |
| |
| Object.keys(scrollPositions).forEach(sel => { |
| const el = document.querySelector(sel); |
| if (el) { |
| const pos = scrollPositions[sel]; |
| if (pos.isAtBottom) { |
| el.scrollTop = el.scrollHeight; |
| } else { |
| el.scrollTop = pos.top; |
| } |
| } |
| }); |
| } |
| |
| |
| document.getElementById('startBtn').addEventListener('click', startGame); |
| |
| document.getElementById('warmBtn').addEventListener('click', async () => { |
| const btn = document.getElementById('warmBtn'); |
| if (btn.disabled) return; |
| |
| btn.disabled = true; |
| btn.classList.add('warming'); |
| btn.classList.remove('warmed'); |
| btn.textContent = '⚡ Loading...'; |
| |
| try { |
| const res = await fetch('/api/model/warm', { method: 'POST' }); |
| const data = await res.json(); |
| if (data.status === 'success') { |
| btn.classList.remove('warming'); |
| btn.classList.add('warmed'); |
| btn.textContent = '⚡ Loaded ✅'; |
| } else { |
| btn.classList.remove('warming'); |
| btn.textContent = '⚡ Load Model'; |
| btn.disabled = false; |
| console.error('Failed to load model:', data.detail || data.message); |
| } |
| } catch (err) { |
| btn.classList.remove('warming'); |
| btn.textContent = '⚡ Load Model'; |
| btn.disabled = false; |
| console.error('Failed to load model:', err); |
| } |
| }); |
| document.getElementById('stepBtn').addEventListener('click', step); |
| document.getElementById('autoBtn').addEventListener('click', auto); |
| |
| document.addEventListener('keydown', e => { |
| if (e.target.tagName==='INPUT'||e.target.tagName==='SELECT') return; |
| if (e.key===' '||e.code==='Space') { |
| e.preventDefault(); |
| if (gameStarted&&!autoRunning) { const btn=document.getElementById('stepBtn'); if (!btn.disabled) step(); } |
| else if (!gameStarted) startGame(); |
| } |
| if ((e.key==='a'||e.key==='A')&&gameStarted) { e.preventDefault(); auto(); } |
| if ((e.key==='v'||e.key==='V')&&gameStarted&&selectedId) { e.preventDefault(); togglePov(); } |
| if (e.key==='Escape') { |
| if (fpsMode) exitFps(); |
| else if (selectedId) selectAgent(null); |
| } |
| }); |
| |
| window.addEventListener('resize', ()=>{ if (app) app.resize(); }); |
| |
| function returnToLobby() { |
| if (autoRunning) auto(); |
| gameStarted = false; selectedId = null; state = null; |
| document.getElementById('lobby').classList.remove('hidden'); |
| const g = document.getElementById('game'); g.classList.remove('visible'); g.style.display = 'none'; |
| } |
| |
| async function bootProbe() { |
| try { |
| const r = await fetch('/api/game/state'); |
| if (!r.ok) return; |
| const s = await r.json(); |
| if (!s || !s.agents || !s.agents.length) return; |
| state = s; |
| gameStarted = true; |
| nameMaterialCache = {}; |
| traceHistory = {}; |
| document.getElementById('lobby').classList.add('hidden'); |
| const game = document.getElementById('game'); |
| game.classList.add('visible'); game.style.display = 'flex'; |
| showLoading('Joining live arena...'); |
| try { |
| await loadThree(); |
| if (!app) initScene(); |
| requestAnimationFrame(() => { if (app) app.resize(); renderScene(); }); |
| updateUI(); |
| } finally { hideLoading(); } |
| } catch (_) { } |
| } |
| bootProbe(); |
| </script> |
| </body> |
| </html> |
|
|