Cialtion commited on
Commit
4360937
·
verified ·
1 Parent(s): 31e5d7b

Update demos/neon_arena/neon_arena.html

Browse files
Files changed (1) hide show
  1. 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>