grid-royale / frontend /static /index.html
LokeshReddy001's picture
suchith's changes (#2)
d7e2b77
Raw
History Blame Contribute Delete
115 kB
<!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>
/* ═══════════════════════════════════════════════════
GRID ROYALE — Cinematic Esports Spectator UI v2
═══════════════════════════════════════════════════ */
: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 ══════════════════════ */
#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 WRAPPER ══════════════════════ */
#game {
display: none; flex-direction: column; height: 100vh;
opacity: 0; transition: opacity 0.5s ease;
}
#game.visible { display: flex; opacity: 1; }
/* ══════════════════════ TOP BAR ══════════════════════ */
.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); }
}
/* Stat cards row */
.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); }
/* Progress bar inside stat pill */
.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;
}
/* Agent dots in top bar */
.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; }
/* Right side */
.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 ══════════════════════ */
.main-layout { display: flex; flex: 1; min-height: 0; }
/* SCENE */
.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; }
/* FPS HUD overlay */
#fpsHud {
position: absolute; inset: 0; z-index: 20;
pointer-events: none;
opacity: 0; transition: opacity 0.5s ease;
}
#fpsHud.active { opacity: 1; }
/* Vignette */
.fps-vignette {
position: absolute; inset: 0;
background: radial-gradient(ellipse 80% 80% at 50% 50%,
transparent 40%, rgba(0,0,0,0.5) 100%);
}
/* Agent color border when in FPS */
.fps-agent-border {
position: absolute; inset: 0;
border: 2px solid transparent;
border-radius: 0;
transition: border-color 0.4s;
pointer-events: none;
}
/* Crosshair */
/* Target-lock reticle for chase cam */
.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%);
}
/* Compass bar */
.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 */
.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 */
.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;
}
/* Nearby threats radar */
.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%);
}
/* Scanline exit button */
.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); }
/* Kill Feed */
#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 */
.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 ══════════════════════ */
.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 header */
.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;
}
/* Leaderboard */
.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 panel */
.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 */
.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 */
.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); }
/* Tabs */
.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 rows */
.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 panel (global) */
.trace-global {
flex: 1; overflow-y: auto; padding: 6px 10px;
}
/* Visual trace chips */
.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 ══════════════════════ */
.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);
}
/* ══════════════════════ GAME OVER ══════════════════════ */
#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); }
/* Loot tooltip popup */
#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>
<!-- ═══════════════ LOBBY ═══════════════ -->
<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>
<!-- ═══════════════ GAME ═══════════════ -->
<div id="game">
<!-- Top Bar -->
<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>
<!-- Progress bar across bottom of top bar -->
<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>
<!-- Main Area -->
<div class="main-layout">
<div class="scene-wrap" id="sceneWrap">
<div id="sceneCanvas"></div>
<!-- FPS HUD -->
<div id="fpsHud">
<div class="fps-vignette"></div>
<div class="fps-agent-border" id="fpsAgentBorder"></div>
<!-- Chase cam: target-lock reticle -->
<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>
<!-- Loot inspect popup -->
<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>
<!-- Controls -->
<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 &nbsp;
<kbd>A</kbd> auto &nbsp;
<kbd>V</kbd> POV &nbsp;
<kbd>Esc</kbd> deselect
</span>
</div>
</div>
<!-- ═══════════════ GAME OVER ═══════════════ -->
<div id="gameOverOverlay">
<div class="gameover-card" id="gameOverCard"></div>
</div>
<!-- ═══════════════ SCRIPT ═══════════════ -->
<script>
const API = '';
let state = null;
let selectedId = null;
let gameStarted = false;
let autoRunning = false;
let activeTab = 'trace';
let fpsMode = false;
let fpsAngle = 0; // compass angle
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 };
}
/* ═══════════ LOBBY PARTICLES ═══════════ */
(function initLobbyParticles() {
// Disabled for minimal, clean design
})();
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();
}
});
// Initialize on load
(async () => {
renderPromptTabs();
await updateAllPrompts();
})();
/* ═══════════ THREE.JS ═══════════ */
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();
// 1. Inner core (white)
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);
// 2. Outer glow shell (agent color)
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);
// 3. Muzzle flash sphere at source
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);
// 4. Impact flash sphere at target
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();
// Green expanding shell
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);
// Torus rising ring
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; // Center of the agent pillar
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();
// ── Procedural star-field background ──
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);
// Stars
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();
}
// Nebula wisps
['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);
// ── Lighting ──
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);
// Arena center up-light (atmospheric)
const centerLight = new THREE.PointLight(0x4040ff, 0.6, 40, 1.5);
centerLight.position.set(0, -0.5, 0); scene.add(centerLight);
// ── Ground — reflective dark metallic plate ──
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);
// ── Horizontal atmospheric haze plane ──
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);
// Shared geometries & materials
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 = {};
// Particles
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);
// Orbit Controls
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();
}
// Raycasting for click-to-select (agents) and tile inspect (loot)
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);
// 1. Check agents first
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;
}
// 2. Check loot crystals / tile glow planes (in objGroup)
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;
}
// 3. Check tile surfaces
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;
}
}
// 4. Empty click — clear selection
hideLootPopup();
selectAgent(null);
});
// Animation loop
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;
// Chase cam & orbit: both use OrbitControls with different target tracking speeds
controls.update();
const lerpSpeed = fpsMode ? 0.1 : 0.05;
if (cameraTarget) controls.target.lerp(cameraTarget, lerpSpeed);
// Particles
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;
// Update active effects
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;
}
}
}
// Agent animations
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);
}
// Handle hit flash
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);
}
// Loot
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||[];
// Static tiles — rebuild only when grid size changes
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;
// Alternating subtle checker tint
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;
// Store grid coords so raycaster can find them
tm.userData.gridX = x; tm.userData.gridY = y; tm.userData.isTile = true;
tileGroup.add(tm);
// Grid edge glow lines
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);
}
}
// Arena energy wall (tall glowing border)
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);
// Wall top edge glow
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);
});
// Arena floor ring glow
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);
// Loot — rarity-tinted crystals
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);
}
// Agents caching & building
const activeAids = new Set(agents.map(a => a.id));
// Clean up or transition dead agents to dying
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);
}
// Update dynamic properties
ac.userData.targetPos.set(ax, 0, az);
ac.userData.shielded = a.shielded;
// Update shield
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);
}
// Update HP bar
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);
// Update selection ring
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);
}
});
// Update FPS camera if in FPS mode
if (fpsMode && selectedId) updateFpsCamera();
}
app = {scene,camera,renderer,controls,build,resize,THREE,agentGroup,tileGroup,clearGroup,
};
}
function renderScene() { if (app) app.build(); }
/* ═══════════ FPS MODE ═══════════ */
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;
// Tighten orbit controls to chase-cam distances
app.controls.minDistance = 2;
app.controls.maxDistance = 10;
app.controls.maxPolarAngle = Math.PI / 1.7; // allow looking from lower angle
// Snap camera to a nice close follow position (behind-right, slightly elevated)
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();
// Restore original orbit distances
if (app) {
app.controls.minDistance = 6;
app.controls.maxDistance = 55;
app.controls.maxPolarAngle = Math.PI / 2.1;
}
buildSidebar();
// Pull camera back to wider view around agent
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;
// Smoothly move the orbit target to follow the agent
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`;
// Tint border with agent color
document.getElementById('fpsAgentBorder').style.borderColor = colorHex+'44';
// Color the reticle ring
const ring = document.getElementById('fpsReticleRing');
if (ring) ring.style.borderColor = colorHex+'80';
// HP
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');
// Remove old blips
radar.querySelectorAll('.fps-radar-blip').forEach(b=>b.remove());
if (!state) return;
const agents = state.agents||[];
const radarRadius = 45; // px
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; // only show nearby
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);
});
}
/* ═══════════ KILL FEED ═══════════ */
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);
}
/* ═══════════ SIDEBAR ═══════════ */
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(); });
});
}
/* ═══════════ HELPERS ═══════════ */
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);
}
// Rebuild 3D scene to show/remove selection ring
if (app) app.build();
}
/* ═══════════ TOP BAR ═══════════ */
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;
// Agent dots
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('');
// Winner
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;
// Update chase-cam HUD
if (fpsMode && selectedId) updateFpsCamera();
}
/* ═══════════ GAME OVER ═══════════ */
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');
}
/* ═══════════ LOOT POPUP ═══════════ */
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();
// Position popup near click, keep inside viewport
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);
}
}
/* ═══════════ API ═══════════ */
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();
// Save scroll positions
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();
// Restore scroll positions
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;
}
}
});
}
/* ═══════════ KEYBOARD ═══════════ */
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 (_) { /* keep lobby */ }
}
bootProbe();
</script>
</body>
</html>