Update demos/neon_arena/neon_arena.html
Browse files- demos/neon_arena/neon_arena.html +513 -0
demos/neon_arena/neon_arena.html
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Neon Arena - Multi-AI Battle</title>
|
| 7 |
+
<style>
|
| 8 |
+
*{margin:0;padding:0;box-sizing:border-box}
|
| 9 |
+
body{background:#0a0a0f;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:'Segoe UI',system-ui,sans-serif;color:#fff}
|
| 10 |
+
.game-container{position:relative}
|
| 11 |
+
canvas{border:2px solid #0ff;box-shadow:0 0 30px rgba(0,255,255,.3);border-radius:4px}
|
| 12 |
+
.hud{position:absolute;top:-45px;left:0;right:0;display:flex;justify-content:space-between;align-items:center;padding:0 10px;font-size:12px}
|
| 13 |
+
.health-bar{width:120px;height:8px;background:rgba(255,255,255,.1);border-radius:4px;overflow:hidden}
|
| 14 |
+
.health-fill{height:100%;background:linear-gradient(90deg,#0f0,#0ff);transition:width .3s}
|
| 15 |
+
.score{font-size:24px;color:#ff0;text-shadow:0 0 10px #ff0}
|
| 16 |
+
.bottom-hud{position:absolute;bottom:-35px;left:0;right:0;display:flex;justify-content:space-between;font-size:11px;color:rgba(255,255,255,.6);padding:0 10px}
|
| 17 |
+
.ai-panel{position:absolute;top:10px;left:10px;display:flex;flex-direction:column;gap:6px;max-height:90%;overflow-y:auto}
|
| 18 |
+
.ai-box{background:rgba(0,0,0,.8);border:1px solid;padding:6px;border-radius:5px;font-size:10px;min-width:130px}
|
| 19 |
+
.ai-box h4{margin-bottom:3px;font-size:11px}
|
| 20 |
+
.ai-box .hp-bar{height:4px;background:rgba(255,255,255,.2);border-radius:2px;margin-top:3px}
|
| 21 |
+
.ai-box .hp-fill{height:100%;border-radius:2px;transition:width .3s}
|
| 22 |
+
.ai-avatars{position:absolute;top:10px;right:10px;display:flex;flex-direction:column;gap:6px;max-height:90%;overflow-y:auto}
|
| 23 |
+
.ai-avatar{width:60px;height:60px;border:2px solid;border-radius:6px;overflow:hidden;background:#111;position:relative;transition:all .3s;display:flex;align-items:center;justify-content:center}
|
| 24 |
+
.ai-avatar img{width:100%;height:100%;object-fit:cover}
|
| 25 |
+
.ai-avatar .label{position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,.8);font-size:8px;text-align:center;padding:1px}
|
| 26 |
+
.ai-avatar.hit{animation:shake .3s;border-color:#f00!important;box-shadow:0 0 12px #f00}
|
| 27 |
+
.ai-avatar.attack{border-color:#ff0!important;box-shadow:0 0 12px #ff0}
|
| 28 |
+
.ai-avatar.dead{opacity:.3;filter:grayscale(1)}
|
| 29 |
+
.ai-avatar .default-icon{font-size:28px}
|
| 30 |
+
@keyframes shake{25%{transform:translateX(-3px)}75%{transform:translateX(3px)}}
|
| 31 |
+
.overlay{position:absolute;inset:0;background:rgba(0,0,0,.92);display:flex;flex-direction:column;align-items:center;justify-content:center;border-radius:4px;z-index:10}
|
| 32 |
+
.overlay.hidden{display:none}
|
| 33 |
+
.overlay h1{font-size:42px;background:linear-gradient(90deg,#0ff,#f0f);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
| 34 |
+
.overlay p{margin:5px 0;opacity:.7;font-size:13px}
|
| 35 |
+
.server-input{margin:15px 0;display:flex;gap:8px;align-items:center}
|
| 36 |
+
.server-input input{padding:8px 12px;background:rgba(255,255,255,.1);border:1px solid #0ff;color:#fff;border-radius:4px;width:280px;font-size:12px}
|
| 37 |
+
.status-dot{width:10px;height:10px;border-radius:50%;background:#666}
|
| 38 |
+
.status-dot.ok{background:#0f0;box-shadow:0 0 8px #0f0}
|
| 39 |
+
.status-dot.err{background:#f00}
|
| 40 |
+
.config-row{margin:10px 0;display:flex;align-items:center;gap:10px;font-size:13px}
|
| 41 |
+
.config-row input[type=number]{background:#222;border:1px solid #0ff;color:#fff;padding:5px 8px;border-radius:4px;width:60px}
|
| 42 |
+
.config-row select{background:#222;border:1px solid #0ff;color:#fff;padding:5px 8px;border-radius:4px}
|
| 43 |
+
.overflow-hint{font-size:11px;color:#f0f;margin-top:5px;display:none}
|
| 44 |
+
.btn{margin-top:20px;padding:12px 40px;font-size:16px;font-weight:bold;background:transparent;border:2px solid #0ff;color:#0ff;cursor:pointer;border-radius:4px;transition:all .3s}
|
| 45 |
+
.btn:hover{background:#0ff;color:#000}
|
| 46 |
+
.game-stats{margin-top:15px;padding:15px;background:rgba(255,255,255,.05);border-radius:8px;text-align:left;font-size:13px}
|
| 47 |
+
.game-stats p{margin:5px 0}
|
| 48 |
+
.highlight{color:#0ff;font-weight:bold}
|
| 49 |
+
.winner{font-size:32px;margin:15px 0}
|
| 50 |
+
.winner.player{color:#0f0}
|
| 51 |
+
.winner.ai{color:#f0f}
|
| 52 |
+
.debug{position:fixed;right:10px;top:10px;width:300px;max-height:380px;background:rgba(0,0,0,.9);border:1px solid #0ff;border-radius:8px;padding:6px;font-family:monospace;font-size:9px;color:#0f0;overflow:hidden;display:flex;flex-direction:column}
|
| 53 |
+
.debug-header{display:flex;justify-content:space-between;border-bottom:1px solid #333;padding-bottom:4px;margin-bottom:4px}
|
| 54 |
+
.debug-log{flex:1;overflow-y:auto;max-height:320px}
|
| 55 |
+
.log-entry{margin:2px 0;padding:3px;background:rgba(255,255,255,.03);border-left:2px solid #0ff;font-size:9px}
|
| 56 |
+
.log-entry.error{border-color:#f00;color:#f88}
|
| 57 |
+
.audio-ctrl{position:fixed;bottom:10px;left:10px;display:flex;gap:8px;background:rgba(0,0,0,.8);padding:6px 10px;border-radius:15px;border:1px solid #0ff;font-size:10px}
|
| 58 |
+
.audio-ctrl label{display:flex;align-items:center;gap:4px;color:#0ff}
|
| 59 |
+
.audio-ctrl input[type=range]{width:50px}
|
| 60 |
+
</style>
|
| 61 |
+
</head>
|
| 62 |
+
<body>
|
| 63 |
+
<div class="game-container">
|
| 64 |
+
<div class="hud">
|
| 65 |
+
<div><span style="color:#0f0;font-weight:bold;">◆ PLAYER</span>
|
| 66 |
+
<div class="health-bar"><div class="health-fill" id="player-hp"></div></div>
|
| 67 |
+
</div>
|
| 68 |
+
<div class="score" id="time">0:00</div>
|
| 69 |
+
<div style="color:#f0f;">AI: <span id="ai-count">0</span></div>
|
| 70 |
+
</div>
|
| 71 |
+
<canvas id="game" width="900" height="600"></canvas>
|
| 72 |
+
<div class="ai-panel" id="ai-panel"></div>
|
| 73 |
+
<div class="ai-avatars" id="ai-avatars"></div>
|
| 74 |
+
<div class="bottom-hud">
|
| 75 |
+
<div>Latency: <span id="latency">--</span>ms</div>
|
| 76 |
+
<div>Decisions: <span id="decisions">0</span></div>
|
| 77 |
+
<div>WASD:Move | IJKL:Fire | SPACE:Shield</div>
|
| 78 |
+
</div>
|
| 79 |
+
<div class="overlay" id="start-screen">
|
| 80 |
+
<h1>NEON ARENA</h1>
|
| 81 |
+
<p>Multi-AI Battle with SimpleTool Function Calling</p>
|
| 82 |
+
<div class="server-input">
|
| 83 |
+
<input id="fc-url" value="http://localhost:8899" placeholder="SimpleTool Server URL">
|
| 84 |
+
<div class="status-dot" id="status-dot"></div>
|
| 85 |
+
</div>
|
| 86 |
+
<div id="conn-status" style="font-size:11px;">Click to check...</div>
|
| 87 |
+
<div class="config-row">
|
| 88 |
+
<label>AI Count: <input type="number" id="ai-num" min="1" max="20" value="2"></label>
|
| 89 |
+
<span id="max-ai-info" style="font-size:11px;color:#888;"></span>
|
| 90 |
+
</div>
|
| 91 |
+
<div class="config-row" id="overflow-row" style="display:none;">
|
| 92 |
+
<label>Overflow Mode:
|
| 93 |
+
<select id="overflow-mode">
|
| 94 |
+
<option value="clone">Clone profiles (复制人设)</option>
|
| 95 |
+
<option value="default">Default style (默认图标)</option>
|
| 96 |
+
</select>
|
| 97 |
+
</label>
|
| 98 |
+
</div>
|
| 99 |
+
<div class="overflow-hint" id="overflow-hint"></div>
|
| 100 |
+
<button class="btn" id="start-btn">START</button>
|
| 101 |
+
</div>
|
| 102 |
+
<div class="overlay hidden" id="end-screen">
|
| 103 |
+
<h2>GAME OVER</h2>
|
| 104 |
+
<div class="winner" id="winner">--</div>
|
| 105 |
+
<div class="game-stats">
|
| 106 |
+
<p>Time: <span class="highlight" id="final-time">0:00</span></p>
|
| 107 |
+
<p>Your Hits: <span class="highlight" id="final-hits">0</span></p>
|
| 108 |
+
<p>AI Decisions: <span class="highlight" id="final-decisions">0</span></p>
|
| 109 |
+
<p>Avg Latency: <span class="highlight" id="final-latency">--</span>ms</p>
|
| 110 |
+
</div>
|
| 111 |
+
<button class="btn" id="restart-btn">RESTART</button>
|
| 112 |
+
</div>
|
| 113 |
+
</div>
|
| 114 |
+
<div class="audio-ctrl">
|
| 115 |
+
<label>🎵<input type="range" id="bgm-vol" min="0" max="100" value="30"></label>
|
| 116 |
+
<label>🔊<input type="range" id="sfx-vol" min="0" max="100" value="70"></label>
|
| 117 |
+
</div>
|
| 118 |
+
<div class="debug">
|
| 119 |
+
<div class="debug-header"><span>📊 AI Log</span><button onclick="debugLog.length=0;$('debug-log').innerHTML=''">Clear</button></div>
|
| 120 |
+
<div class="debug-log" id="debug-log"></div>
|
| 121 |
+
</div>
|
| 122 |
+
<script>
|
| 123 |
+
// ═══════════════════════════════════════════════════════════════════
|
| 124 |
+
// 🎮 配置区域 - 在这里添加/修改 AI 角色
|
| 125 |
+
// ═══════════════════════════════════════════════════════════════════
|
| 126 |
+
const AI_PROFILES = [
|
| 127 |
+
{
|
| 128 |
+
id: "nahida", name: "纳西妲", color: "#7CFC00",
|
| 129 |
+
expressionDir: "./expressions_nahida",
|
| 130 |
+
expressions: {
|
| 131 |
+
idle: "01_smile.png", happy: "02_laugh.png", angry: "03_angry.png",
|
| 132 |
+
sad: "04_sad.png", shocked: "05_shocked.png", confused: "06_confused.png",
|
| 133 |
+
love: "07_love.png", smug: "08_smug.png", dizzy: "09_dizzy.png"
|
| 134 |
+
},
|
| 135 |
+
soundDir: "./wavs/Nahida",
|
| 136 |
+
sounds: { hit: "t_别打了别打了.wav", taunt: "t_杂鱼杂鱼.wav" }
|
| 137 |
+
},
|
| 138 |
+
{
|
| 139 |
+
id: "aiyafala", name: "艾雅法拉", color: "#FF6B6B",
|
| 140 |
+
expressionDir: "./expressions_aiyafala",
|
| 141 |
+
expressions: {
|
| 142 |
+
idle: "01_calm_平静.png", happy: "02_happy_开心.png", confused: "03_confused_困惑.png",
|
| 143 |
+
angry: "04_angry_生气.png", sad: "05_sad_难过.png", curious: "06_curious_好奇.png",
|
| 144 |
+
furious: "07_furious_愤怒.png", crying: "08_crying_哭泣.png", idea: "09_idea_灵感.png",
|
| 145 |
+
shocked: "06_curious_好奇.png", smug: "02_happy_开心.png", dizzy: "08_crying_哭泣.png", love: "02_happy_开心.png"
|
| 146 |
+
},
|
| 147 |
+
soundDir: "./wavs/Eyjafjalla",
|
| 148 |
+
sounds: { hit: "t_别打了前辈别打了.wav", taunt: "t_杂鱼前辈杂鱼前辈.wav" }
|
| 149 |
+
}
|
| 150 |
+
];
|
| 151 |
+
|
| 152 |
+
// 默认 AI 配置 (无资源时使用)
|
| 153 |
+
const DEFAULT_COLORS = ['#00CED1','#FF69B4','#FFD700','#9370DB','#20B2AA','#FF7F50','#87CEEB','#98FB98','#DDA0DD','#F0E68C'];
|
| 154 |
+
function createDefaultProfile(idx) {
|
| 155 |
+
return {
|
| 156 |
+
id: `ai_${idx}`, name: `AI-${idx+1}`, color: DEFAULT_COLORS[idx % DEFAULT_COLORS.length],
|
| 157 |
+
expressionDir: null, expressions: {}, soundDir: null, sounds: {}
|
| 158 |
+
};
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
// 生成实际使用的 AI 配置列表
|
| 162 |
+
function generateAIList(count, mode) {
|
| 163 |
+
const list = [];
|
| 164 |
+
for (let i = 0; i < count; i++) {
|
| 165 |
+
if (i < AI_PROFILES.length) {
|
| 166 |
+
list.push({...AI_PROFILES[i], sourceIdx: i});
|
| 167 |
+
} else if (mode === 'clone') {
|
| 168 |
+
const src = AI_PROFILES[i % AI_PROFILES.length];
|
| 169 |
+
list.push({...src, id: `${src.id}_${Math.floor(i/AI_PROFILES.length)+1}`, name: `${src.name} #${Math.floor(i/AI_PROFILES.length)+1}`, sourceIdx: i % AI_PROFILES.length});
|
| 170 |
+
} else {
|
| 171 |
+
list.push({...createDefaultProfile(i), sourceIdx: -1});
|
| 172 |
+
}
|
| 173 |
+
}
|
| 174 |
+
return list;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
const BALANCE = {playerHp:10,aiHp:5,playerSpeed:4,aiMoveDistance:28,bulletSpeed:8,playerFireCooldown:15,aiFireCooldown:35,shieldDuration:500,shieldCooldown:180};
|
| 178 |
+
const EXPR_RULES = {
|
| 179 |
+
gotHit_high:{expressions:["sad","shocked"],duration:600,sound:"hit",cooldown:2500,css:"hit"},
|
| 180 |
+
gotHit_low:{expressions:["crying","furious","dizzy"],duration:800,sound:"hit",cooldown:2000,css:"hit"},
|
| 181 |
+
hitPlayer:{expressions:["smug","happy","love"],duration:700,sound:"taunt",cooldown:3000},
|
| 182 |
+
attack:{expressions:["angry","furious"],duration:400,css:"attack"},
|
| 183 |
+
thinking:{expressions:["confused","curious"],duration:0},
|
| 184 |
+
moving:{expressions:["idle","curious"],duration:300},
|
| 185 |
+
win:{expressions:["happy","smug","love"],duration:0,sound:"taunt"},
|
| 186 |
+
lose:{expressions:["crying","dizzy","sad"],duration:0}
|
| 187 |
+
};
|
| 188 |
+
const TOOLS = [
|
| 189 |
+
{type:"function",function:{name:"move",description:"Move spaceship",parameters:{type:"object",properties:{direction:{type:"string",enum:["up","down","left","right"]}},required:["direction"]}}},
|
| 190 |
+
{type:"function",function:{name:"fire",description:"Fire bullet",parameters:{type:"object",properties:{direction:{type:"string",enum:["up","down","left","right"]}},required:["direction"]}}}
|
| 191 |
+
];
|
| 192 |
+
|
| 193 |
+
const $ = id => document.getElementById(id);
|
| 194 |
+
const rand = arr => arr[Math.floor(Math.random() * arr.length)];
|
| 195 |
+
const debugLog = [];
|
| 196 |
+
function safeStr(v) { return v == null ? '' : String(v).trim().toLowerCase(); }
|
| 197 |
+
function log(entry) {
|
| 198 |
+
entry.time = Date.now(); debugLog.push(entry);
|
| 199 |
+
const div = document.createElement('div');
|
| 200 |
+
div.className = 'log-entry' + (entry.error ? ' error' : '');
|
| 201 |
+
div.innerHTML = entry.ai ? `<span style="color:${entry.color}">[${entry.ai}]</span> ${entry.fn}(${entry.dir}) <span style="color:#f0f">${entry.ms|0}ms</span>` : entry.msg;
|
| 202 |
+
$('debug-log').insertBefore(div, $('debug-log').firstChild);
|
| 203 |
+
while ($('debug-log').children.length > 50) $('debug-log').lastChild.remove();
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
const Audio_ = {
|
| 207 |
+
sfxCache:{}, sfxVol:0.7, lastPlay:{},
|
| 208 |
+
play(path, cooldown=0) {
|
| 209 |
+
if (!path) return;
|
| 210 |
+
const now = Date.now();
|
| 211 |
+
if (cooldown && now - (this.lastPlay[path]||0) < cooldown) return;
|
| 212 |
+
if (!this.sfxCache[path]) this.sfxCache[path] = new Audio(path);
|
| 213 |
+
const snd = this.sfxCache[path];
|
| 214 |
+
snd.volume = this.sfxVol; snd.currentTime = 0;
|
| 215 |
+
snd.play().catch(()=>{});
|
| 216 |
+
this.lastPlay[path] = now;
|
| 217 |
+
}
|
| 218 |
+
};
|
| 219 |
+
$('sfx-vol').oninput = e => Audio_.sfxVol = e.target.value/100;
|
| 220 |
+
|
| 221 |
+
class FCClient {
|
| 222 |
+
constructor(url) { this.url = url.replace(/\/$/, ''); }
|
| 223 |
+
async health() {
|
| 224 |
+
try { const r = await fetch(`${this.url}/health`, {signal: AbortSignal.timeout(3000)}); const d = await r.json(); return {ok: d.loaded === true || d.status === 'ok'}; }
|
| 225 |
+
catch(e) { return {ok: false}; }
|
| 226 |
+
}
|
| 227 |
+
async call(query, env, hist) {
|
| 228 |
+
const t0 = performance.now();
|
| 229 |
+
try {
|
| 230 |
+
const r = await fetch(`${this.url}/v1/function_call`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({messages:[{role:'user',content:query}],tools:TOOLS,environment:env,history:hist})});
|
| 231 |
+
const d = await r.json(), ms = performance.now() - t0;
|
| 232 |
+
let fn = safeStr(d.function) || safeStr(d.heads?.function);
|
| 233 |
+
let direction = safeStr(d.args?.direction) || safeStr(d.heads?.arg1);
|
| 234 |
+
if (!['move','fire'].includes(fn)) fn = 'move';
|
| 235 |
+
if (!['up','down','left','right'].includes(direction)) direction = 'left';
|
| 236 |
+
return {fn, direction, ms, success: d.success};
|
| 237 |
+
} catch(e) { return {fn:'move', direction:'left', ms:performance.now()-t0, success:false}; }
|
| 238 |
+
}
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
class ExprMgr {
|
| 242 |
+
constructor(idx, profile) {
|
| 243 |
+
this.idx = idx; this.profile = profile; this.timeout = null;
|
| 244 |
+
this.hasExpr = !!(profile.expressionDir && Object.keys(profile.expressions).length);
|
| 245 |
+
this.create();
|
| 246 |
+
}
|
| 247 |
+
create() {
|
| 248 |
+
const p = this.profile, c = p.color;
|
| 249 |
+
this.el = document.createElement('div');
|
| 250 |
+
this.el.className = 'ai-avatar';
|
| 251 |
+
this.el.style.borderColor = c;
|
| 252 |
+
if (this.hasExpr) {
|
| 253 |
+
this.img = document.createElement('img');
|
| 254 |
+
this.img.src = this.getPath('idle');
|
| 255 |
+
this.img.onerror = () => { this.hasExpr = false; this.showDefaultIcon(); };
|
| 256 |
+
this.el.appendChild(this.img);
|
| 257 |
+
} else { this.showDefaultIcon(); }
|
| 258 |
+
const lbl = document.createElement('div');
|
| 259 |
+
lbl.className = 'label'; lbl.style.color = c; lbl.textContent = p.name;
|
| 260 |
+
this.el.appendChild(lbl);
|
| 261 |
+
$('ai-avatars').appendChild(this.el);
|
| 262 |
+
const box = document.createElement('div');
|
| 263 |
+
box.className = 'ai-box'; box.id = `ai-box-${this.idx}`; box.style.borderColor = c;
|
| 264 |
+
box.innerHTML = `<h4 style="color:${c}">🤖 ${p.name}</h4><div>Status: <span class="st">Ready</span></div><div>Action: <span class="act">--</span></div><div class="hp-bar"><div class="hp-fill" style="width:100%;background:${c}"></div></div>`;
|
| 265 |
+
$('ai-panel').appendChild(box);
|
| 266 |
+
}
|
| 267 |
+
showDefaultIcon() {
|
| 268 |
+
const icon = document.createElement('span');
|
| 269 |
+
icon.className = 'default-icon'; icon.style.color = this.profile.color; icon.textContent = '🤖';
|
| 270 |
+
this.el.innerHTML = ''; this.el.appendChild(icon);
|
| 271 |
+
}
|
| 272 |
+
getPath(emotion) {
|
| 273 |
+
const expr = this.profile.expressions || {};
|
| 274 |
+
const f = expr[emotion] || expr.idle || Object.values(expr)[0];
|
| 275 |
+
return f && this.profile.expressionDir ? `${this.profile.expressionDir}/${f}` : '';
|
| 276 |
+
}
|
| 277 |
+
getSoundPath(name) {
|
| 278 |
+
const f = this.profile.sounds?.[name];
|
| 279 |
+
return f && this.profile.soundDir ? `${this.profile.soundDir}/${f}` : null;
|
| 280 |
+
}
|
| 281 |
+
set(emotion, duration=0, cssClass='') {
|
| 282 |
+
if (this.timeout) clearTimeout(this.timeout);
|
| 283 |
+
this.el.classList.remove('hit','attack','dead');
|
| 284 |
+
if (this.img && this.hasExpr) { const path = this.getPath(emotion); if (path) this.img.src = path; }
|
| 285 |
+
if (cssClass) this.el.classList.add(cssClass);
|
| 286 |
+
if (duration > 0) this.timeout = setTimeout(() => { this.el.classList.remove('hit','attack'); this.set('idle'); }, duration);
|
| 287 |
+
}
|
| 288 |
+
trigger(eventName) {
|
| 289 |
+
const rule = EXPR_RULES[eventName]; if (!rule) return;
|
| 290 |
+
this.set(rand(rule.expressions), rule.duration||0, rule.css||'');
|
| 291 |
+
if (rule.sound) Audio_.play(this.getSoundPath(rule.sound), rule.cooldown||0);
|
| 292 |
+
}
|
| 293 |
+
updateBox(status, action, hpPct) {
|
| 294 |
+
const box = $(`ai-box-${this.idx}`); if (!box) return;
|
| 295 |
+
box.querySelector('.st').textContent = status;
|
| 296 |
+
box.querySelector('.act').textContent = action;
|
| 297 |
+
box.querySelector('.hp-fill').style.width = hpPct + '%';
|
| 298 |
+
}
|
| 299 |
+
markDead() { this.el.classList.add('dead'); }
|
| 300 |
+
destroy() { this.el?.remove(); $(`ai-box-${this.idx}`)?.remove(); }
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
let fcClient = null, game = null;
|
| 304 |
+
|
| 305 |
+
class Game {
|
| 306 |
+
constructor(aiCount, overflowMode) {
|
| 307 |
+
this.canvas = $('game'); this.ctx = this.canvas.getContext('2d');
|
| 308 |
+
this.W = this.canvas.width; this.H = this.canvas.height;
|
| 309 |
+
this.running = false; this.startTime = 0;
|
| 310 |
+
this.player = {x:80,y:this.H/2,vx:0,vy:0,hp:BALANCE.playerHp,maxHp:BALANCE.playerHp,cd:0,shield:false,shieldCd:0,trail:[]};
|
| 311 |
+
this.ais = []; this.exprMgrs = [];
|
| 312 |
+
this.aiCount = aiCount; this.overflowMode = overflowMode;
|
| 313 |
+
this.bullets = []; this.particles = [];
|
| 314 |
+
this.stars = Array.from({length:80},()=>({x:Math.random()*this.W,y:Math.random()*this.H,s:Math.random()*1.5+.5}));
|
| 315 |
+
this.keys = {}; this.stats = {hits:0,decisions:0,latencies:[]};
|
| 316 |
+
this.initAIs(); this.bindKeys();
|
| 317 |
+
}
|
| 318 |
+
initAIs() {
|
| 319 |
+
const profiles = generateAIList(this.aiCount, this.overflowMode);
|
| 320 |
+
for (let i = 0; i < profiles.length; i++) {
|
| 321 |
+
const p = profiles[i];
|
| 322 |
+
const angle = (i / profiles.length) * Math.PI - Math.PI/2;
|
| 323 |
+
const cx = this.W - 100, cy = this.H / 2, r = Math.min(this.H/2 - 50, 200);
|
| 324 |
+
const sx = cx + Math.cos(angle) * r * 0.3;
|
| 325 |
+
const sy = cy + Math.sin(angle) * r;
|
| 326 |
+
this.ais.push({idx:i, profile:p, x:sx+(Math.random()-.5)*30, y:sy+(Math.random()-.5)*30, vx:0,vy:0, hp:BALANCE.aiHp,maxHp:BALANCE.aiHp, cd:0,trail:[], thinking:false,lastThink:0,pending:false,alive:true,history:[]});
|
| 327 |
+
this.exprMgrs.push(new ExprMgr(i, p));
|
| 328 |
+
}
|
| 329 |
+
$('ai-count').textContent = this.aiCount;
|
| 330 |
+
}
|
| 331 |
+
bindKeys() {
|
| 332 |
+
window.onkeydown = e => { this.keys[e.key.toLowerCase()] = true; if (e.key === ' ') { e.preventDefault(); this.activateShield(); } };
|
| 333 |
+
window.onkeyup = e => this.keys[e.key.toLowerCase()] = false;
|
| 334 |
+
}
|
| 335 |
+
activateShield() { if (this.player.shieldCd <= 0 && !this.player.shield) { this.player.shield = true; this.player.shieldCd = BALANCE.shieldCooldown; setTimeout(() => this.player.shield = false, BALANCE.shieldDuration); } }
|
| 336 |
+
start() { this.running = true; this.startTime = performance.now(); this.loop(); }
|
| 337 |
+
stop(winner) { this.running = false; this.showEnd(winner); }
|
| 338 |
+
loop() { if (!this.running) return; this.update(); this.render(); requestAnimationFrame(() => this.loop()); }
|
| 339 |
+
update() {
|
| 340 |
+
const now = performance.now();
|
| 341 |
+
if (this.player.cd > 0) this.player.cd--;
|
| 342 |
+
if (this.player.shieldCd > 0) this.player.shieldCd--;
|
| 343 |
+
for (const ai of this.ais) { if (!ai.alive) continue; if (ai.cd > 0) ai.cd--; if (now - ai.lastThink > 20 && !ai.pending) { this.aiThink(ai); ai.lastThink = now; } }
|
| 344 |
+
this.handleInput(); this.updateEntity(this.player);
|
| 345 |
+
this.ais.filter(a=>a.alive).forEach(a => this.updateEntity(a));
|
| 346 |
+
this.player.trail.unshift({x:this.player.x,y:this.player.y}); if (this.player.trail.length > 8) this.player.trail.pop();
|
| 347 |
+
this.ais.filter(a=>a.alive).forEach(a=>{a.trail.unshift({x:a.x,y:a.y});if(a.trail.length>8)a.trail.pop();});
|
| 348 |
+
this.updateBullets();
|
| 349 |
+
this.particles = this.particles.filter(p=>{p.x+=p.vx;p.y+=p.vy;p.vx*=.95;p.vy*=.95;return --p.life>0;});
|
| 350 |
+
this.updateUI();
|
| 351 |
+
const alive = this.ais.filter(a=>a.alive).length; $('ai-count').textContent = alive;
|
| 352 |
+
if (this.player.hp <= 0) this.stop('ai'); else if (alive === 0) this.stop('player');
|
| 353 |
+
}
|
| 354 |
+
handleInput() {
|
| 355 |
+
let vx=0, vy=0;
|
| 356 |
+
if (this.keys['w']) vy -= BALANCE.playerSpeed; if (this.keys['s']) vy += BALANCE.playerSpeed;
|
| 357 |
+
if (this.keys['a']) vx -= BALANCE.playerSpeed; if (this.keys['d']) vx += BALANCE.playerSpeed;
|
| 358 |
+
if (vx && vy) { vx *= .707; vy *= .707; } this.player.vx = vx; this.player.vy = vy;
|
| 359 |
+
if (this.player.cd <= 0) {
|
| 360 |
+
let dir = null;
|
| 361 |
+
if (this.keys['i']) dir = {dx:0,dy:-1}; if (this.keys['k']) dir = {dx:0,dy:1};
|
| 362 |
+
if (this.keys['j']) dir = {dx:-1,dy:0}; if (this.keys['l']) dir = {dx:1,dy:0};
|
| 363 |
+
if (dir) { this.fire(this.player, dir.dx, dir.dy, 'player'); this.player.cd = BALANCE.playerFireCooldown; }
|
| 364 |
+
}
|
| 365 |
+
}
|
| 366 |
+
async aiThink(ai) {
|
| 367 |
+
if (ai.pending || !ai.alive) return;
|
| 368 |
+
ai.pending = true; ai.thinking = true;
|
| 369 |
+
this.exprMgrs[ai.idx].trigger('thinking');
|
| 370 |
+
this.exprMgrs[ai.idx].updateBox('Thinking...', '--', (ai.hp/ai.maxHp)*100);
|
| 371 |
+
const p = this.player, dx = p.x - ai.x, dy = p.y - ai.y, dist = Math.round(Math.hypot(dx, dy));
|
| 372 |
+
const hDir = dx > 0 ? 'right' : 'left', vDir = dy > 0 ? 'down' : 'up';
|
| 373 |
+
const primaryDir = Math.abs(dx) > Math.abs(dy) ? hDir : vDir;
|
| 374 |
+
const alignH = Math.abs(dy) < 35, alignV = Math.abs(dx) < 35;
|
| 375 |
+
const nearWall = ai.x < 60 ? 'left' : ai.x > this.W-60 ? 'right' : ai.y < 60 ? 'top' : ai.y > this.H-60 ? 'bottom' : 'no';
|
| 376 |
+
const env = [`pos=${Math.round(ai.x)},${Math.round(ai.y)}`,`player=${Math.round(p.x)},${Math.round(p.y)}`,`dist=${dist}`,`align_h=${alignH}`,`align_v=${alignV}`,`cd=${ai.cd}`,`wall=${nearWall}`];
|
| 377 |
+
let instruction = '';
|
| 378 |
+
if (ai.cd <= 0 && dist < 400) { if (alignH) instruction = `FIRE ${hDir}! Aligned horizontally.`; else if (alignV) instruction = `FIRE ${vDir}! Aligned vertically.`; else instruction = `MOVE ${primaryDir} to align.`; }
|
| 379 |
+
else if (ai.cd > 0) instruction = `Cooling. MOVE ${primaryDir}.`;
|
| 380 |
+
else instruction = `MOVE ${primaryDir} to approach (dist=${dist}).`;
|
| 381 |
+
const query = `Arena ${this.W}x${this.H}. ${instruction} Call move(dir) or fire(dir). dir:up/down/left/right`;
|
| 382 |
+
try {
|
| 383 |
+
const res = await fcClient.call(query, env, ai.history.slice(-6));
|
| 384 |
+
this.stats.decisions++; this.stats.latencies.push(res.ms);
|
| 385 |
+
$('decisions').textContent = this.stats.decisions;
|
| 386 |
+
$('latency').textContent = Math.round(this.stats.latencies.slice(-20).reduce((a,b)=>a+b,0)/Math.min(20,this.stats.latencies.length));
|
| 387 |
+
log({ai:ai.profile.name, color:ai.profile.color, fn:res.fn, dir:res.direction, ms:res.ms});
|
| 388 |
+
if (ai.alive) {
|
| 389 |
+
this.execAction(ai, res.fn, res.direction);
|
| 390 |
+
ai.history.push(`${res.fn}(${res.direction})`); if (ai.history.length > 12) ai.history.shift();
|
| 391 |
+
this.exprMgrs[ai.idx].updateBox('Active', `${res.fn}(${res.direction})`, (ai.hp/ai.maxHp)*100);
|
| 392 |
+
this.exprMgrs[ai.idx].trigger(res.fn === 'fire' ? 'attack' : 'moving');
|
| 393 |
+
}
|
| 394 |
+
} catch(e) { log({msg:`[${ai.profile.name}] Error: ${e.message}`, error:true}); }
|
| 395 |
+
ai.pending = false; ai.thinking = false;
|
| 396 |
+
}
|
| 397 |
+
execAction(ai, action, direction) {
|
| 398 |
+
const dirs = {up:{dx:0,dy:-1},down:{dx:0,dy:1},left:{dx:-1,dy:0},right:{dx:1,dy:0}};
|
| 399 |
+
const d = dirs[direction]; if (!d) return;
|
| 400 |
+
if (action === 'move') { ai.x = Math.max(20, Math.min(this.W-20, ai.x + d.dx * BALANCE.aiMoveDistance)); ai.y = Math.max(20, Math.min(this.H-20, ai.y + d.dy * BALANCE.aiMoveDistance)); ai.vx = d.dx * 3; ai.vy = d.dy * 3; }
|
| 401 |
+
else if (action === 'fire' && ai.cd <= 0) { this.fire(ai, d.dx, d.dy, 'ai', ai.idx); ai.cd = BALANCE.aiFireCooldown; }
|
| 402 |
+
}
|
| 403 |
+
fire(ent, dx, dy, owner, aiIdx=-1) {
|
| 404 |
+
const color = owner==='player' ? '#0ff' : (aiIdx>=0 ? this.ais[aiIdx].profile.color : '#f0f');
|
| 405 |
+
this.bullets.push({x:ent.x+dx*20, y:ent.y+dy*20, vx:dx*BALANCE.bulletSpeed, vy:dy*BALANCE.bulletSpeed, owner, aiIdx, color, life:100});
|
| 406 |
+
for (let i=0;i<5;i++) this.particles.push({x:ent.x+dx*15,y:ent.y+dy*15,vx:(Math.random()-.5)*3-dx,vy:(Math.random()-.5)*3-dy,life:12,color});
|
| 407 |
+
if (owner==='player') this.stats.hits++;
|
| 408 |
+
}
|
| 409 |
+
updateEntity(e) { e.x += e.vx; e.y += e.vy; e.vx *= .88; e.vy *= .88; e.x = Math.max(20, Math.min(this.W-20, e.x)); e.y = Math.max(20, Math.min(this.H-20, e.y)); }
|
| 410 |
+
updateBullets() {
|
| 411 |
+
for (let i=this.bullets.length-1; i>=0; i--) {
|
| 412 |
+
const b = this.bullets[i]; b.x += b.vx; b.y += b.vy; b.life--;
|
| 413 |
+
if (b.life<=0 || b.x<0 || b.x>this.W || b.y<0 || b.y>this.H) { this.bullets.splice(i,1); continue; }
|
| 414 |
+
if (b.owner==='ai' && Math.hypot(b.x-this.player.x,b.y-this.player.y)<16) {
|
| 415 |
+
if (this.player.shield) { this.bullets.splice(i,1); continue; }
|
| 416 |
+
this.player.hp--; this.bullets.splice(i,1); this.spawnHitFX(this.player.x, this.player.y, '#0f0');
|
| 417 |
+
if (b.aiIdx >= 0) this.exprMgrs[b.aiIdx].trigger('hitPlayer'); continue;
|
| 418 |
+
}
|
| 419 |
+
if (b.owner==='player') {
|
| 420 |
+
for (const ai of this.ais) {
|
| 421 |
+
if (!ai.alive) continue;
|
| 422 |
+
if (Math.hypot(b.x-ai.x,b.y-ai.y)<16) {
|
| 423 |
+
ai.hp--; this.bullets.splice(i,1); this.spawnHitFX(ai.x, ai.y, ai.profile.color);
|
| 424 |
+
const hpRatio = ai.hp/ai.maxHp;
|
| 425 |
+
this.exprMgrs[ai.idx].updateBox(ai.hp>0?'Hit!':'DEAD', '--', hpRatio*100);
|
| 426 |
+
if (ai.hp<=0) { ai.alive = false; this.exprMgrs[ai.idx].markDead(); this.exprMgrs[ai.idx].trigger('lose'); }
|
| 427 |
+
else this.exprMgrs[ai.idx].trigger(hpRatio > 0.5 ? 'gotHit_high' : 'gotHit_low');
|
| 428 |
+
break;
|
| 429 |
+
}
|
| 430 |
+
}
|
| 431 |
+
}
|
| 432 |
+
}
|
| 433 |
+
}
|
| 434 |
+
spawnHitFX(x,y,color) { for(let i=0;i<12;i++) this.particles.push({x,y,vx:(Math.random()-.5)*6,vy:(Math.random()-.5)*6,life:20,color}); }
|
| 435 |
+
updateUI() { $('player-hp').style.width = (this.player.hp/this.player.maxHp*100)+'%'; const s = Math.floor((performance.now()-this.startTime)/1000); $('time').textContent = `${Math.floor(s/60)}:${(s%60).toString().padStart(2,'0')}`; }
|
| 436 |
+
render() {
|
| 437 |
+
const ctx = this.ctx; ctx.fillStyle = '#0a0a0f'; ctx.fillRect(0,0,this.W,this.H);
|
| 438 |
+
ctx.fillStyle = '#fff'; this.stars.forEach(s=>{ctx.globalAlpha=.3+Math.random()*.3;ctx.beginPath();ctx.arc(s.x,s.y,s.s,0,Math.PI*2);ctx.fill();}); ctx.globalAlpha = 1;
|
| 439 |
+
ctx.strokeStyle = 'rgba(0,255,255,.03)'; for(let x=0;x<this.W;x+=50){ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,this.H);ctx.stroke();} for(let y=0;y<this.H;y+=50){ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(this.W,y);ctx.stroke();}
|
| 440 |
+
this.particles.forEach(p=>{ctx.globalAlpha=p.life/20;ctx.fillStyle=p.color;ctx.beginPath();ctx.arc(p.x,p.y,2,0,Math.PI*2);ctx.fill();}); ctx.globalAlpha = 1;
|
| 441 |
+
const drawTrail = (trail,color) => {ctx.strokeStyle=color;ctx.lineWidth=2;for(let i=1;i<trail.length;i++){ctx.globalAlpha=(1-i/trail.length)*.2;ctx.beginPath();ctx.moveTo(trail[i-1].x,trail[i-1].y);ctx.lineTo(trail[i].x,trail[i].y);ctx.stroke();}ctx.globalAlpha=1;};
|
| 442 |
+
drawTrail(this.player.trail,'#0f0'); this.ais.filter(a=>a.alive).forEach(a=>drawTrail(a.trail,a.profile.color));
|
| 443 |
+
this.bullets.forEach(b=>{ctx.shadowBlur=10;ctx.shadowColor=b.color;ctx.fillStyle=b.color;ctx.beginPath();ctx.arc(b.x,b.y,4,0,Math.PI*2);ctx.fill();}); ctx.shadowBlur=0;
|
| 444 |
+
const drawShip = (x,y,color,shield,think) => {
|
| 445 |
+
ctx.shadowBlur=15;ctx.shadowColor=color;ctx.fillStyle=color;
|
| 446 |
+
ctx.beginPath();ctx.moveTo(x+14,y);ctx.lineTo(x-8,y-10);ctx.lineTo(x-4,y);ctx.lineTo(x-8,y+10);ctx.closePath();ctx.fill();
|
| 447 |
+
ctx.fillStyle='#fff';ctx.beginPath();ctx.arc(x,y,3,0,Math.PI*2);ctx.fill();ctx.shadowBlur=0;
|
| 448 |
+
if(shield){ctx.strokeStyle='#0ff';ctx.lineWidth=2;ctx.globalAlpha=.5+Math.sin(Date.now()/50)*.3;ctx.beginPath();ctx.arc(x,y,24,0,Math.PI*2);ctx.stroke();ctx.globalAlpha=1;}
|
| 449 |
+
if(think){ctx.fillStyle='#ff0';ctx.font='12px sans-serif';ctx.fillText('💭',x-6,y-18);}
|
| 450 |
+
};
|
| 451 |
+
drawShip(this.player.x,this.player.y,'#0f0',this.player.shield,false);
|
| 452 |
+
this.ais.filter(a=>a.alive).forEach(a=>drawShip(a.x,a.y,a.profile.color,false,a.thinking));
|
| 453 |
+
}
|
| 454 |
+
showEnd(winner) {
|
| 455 |
+
$('winner').textContent = winner==='player' ? 'YOU WIN!' : 'AI WINS';
|
| 456 |
+
$('winner').className = 'winner '+(winner==='player'?'player':'ai');
|
| 457 |
+
const s = Math.floor((performance.now()-this.startTime)/1000);
|
| 458 |
+
$('final-time').textContent = `${Math.floor(s/60)}:${(s%60).toString().padStart(2,'0')}`;
|
| 459 |
+
$('final-hits').textContent = this.stats.hits; $('final-decisions').textContent = this.stats.decisions;
|
| 460 |
+
$('final-latency').textContent = this.stats.latencies.length ? Math.round(this.stats.latencies.reduce((a,b)=>a+b,0)/this.stats.latencies.length) : '--';
|
| 461 |
+
$('end-screen').classList.remove('hidden');
|
| 462 |
+
}
|
| 463 |
+
reset() {
|
| 464 |
+
this.exprMgrs.forEach(m => m.destroy()); $('ai-panel').innerHTML = ''; $('ai-avatars').innerHTML = '';
|
| 465 |
+
this.player = {x:80,y:this.H/2,vx:0,vy:0,hp:BALANCE.playerHp,maxHp:BALANCE.playerHp,cd:0,shield:false,shieldCd:0,trail:[]};
|
| 466 |
+
this.ais = []; this.exprMgrs = []; this.bullets = []; this.particles = [];
|
| 467 |
+
this.stats = {hits:0,decisions:0,latencies:[]};
|
| 468 |
+
this.aiCount = parseInt($('ai-num').value)||2; this.overflowMode = $('overflow-mode').value;
|
| 469 |
+
this.initAIs(); $('end-screen').classList.add('hidden'); this.start();
|
| 470 |
+
}
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
function updateOverflowUI() {
|
| 474 |
+
const num = parseInt($('ai-num').value) || 2;
|
| 475 |
+
const profileCount = AI_PROFILES.length;
|
| 476 |
+
const overflow = num > profileCount;
|
| 477 |
+
$('overflow-row').style.display = overflow ? 'flex' : 'none';
|
| 478 |
+
$('overflow-hint').style.display = overflow ? 'block' : 'none';
|
| 479 |
+
if (overflow) {
|
| 480 |
+
const extra = num - profileCount;
|
| 481 |
+
$('overflow-hint').innerHTML = `⚠️ ${extra} AI(s) exceed profiles. <br>Clone: reuse expressions/sounds. Default: 🤖 icon only.`;
|
| 482 |
+
}
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
async function init() {
|
| 486 |
+
$('ai-num').value = Math.min(2, AI_PROFILES.length || 2);
|
| 487 |
+
$('max-ai-info').textContent = `(${AI_PROFILES.length} profiles available)`;
|
| 488 |
+
$('ai-num').oninput = updateOverflowUI;
|
| 489 |
+
updateOverflowUI();
|
| 490 |
+
fcClient = new FCClient($('fc-url').value);
|
| 491 |
+
const r = await fcClient.health();
|
| 492 |
+
$('status-dot').className = 'status-dot '+(r.ok?'ok':'err');
|
| 493 |
+
$('conn-status').textContent = r.ok ? '✔ Connected' : '✗ Failed';
|
| 494 |
+
$('fc-url').oninput = async () => {
|
| 495 |
+
fcClient = new FCClient($('fc-url').value);
|
| 496 |
+
const r = await fcClient.health();
|
| 497 |
+
$('status-dot').className = 'status-dot '+(r.ok?'ok':'err');
|
| 498 |
+
$('conn-status').textContent = r.ok ? '✔ Connected' : '✗ Failed';
|
| 499 |
+
};
|
| 500 |
+
$('start-btn').onclick = async () => {
|
| 501 |
+
const r = await fcClient.health();
|
| 502 |
+
if (!r.ok) { alert('Cannot connect to SimpleTool Server'); return; }
|
| 503 |
+
$('start-screen').classList.add('hidden');
|
| 504 |
+
game = new Game(parseInt($('ai-num').value) || 2, $('overflow-mode').value);
|
| 505 |
+
game.start();
|
| 506 |
+
};
|
| 507 |
+
$('restart-btn').onclick = () => game?.reset();
|
| 508 |
+
console.log(`[Neon Arena] Loaded ${AI_PROFILES.length} AI profiles`);
|
| 509 |
+
}
|
| 510 |
+
init();
|
| 511 |
+
</script>
|
| 512 |
+
</body>
|
| 513 |
+
</html>
|