import React, { useState, useEffect, useRef } from 'react';
import { Crown, Trophy, Coins, Users, Play, Shield, Globe, Wifi, X, Lock, Zap, Star, Map as MapIcon, Edit, Save, LogIn, LayoutGrid, Flame, PackageOpen, ChevronUp, Target, Swords, ClipboardList, CheckCircle, RefreshCcw, LogOut, Cloud, MessageSquare, Activity, ShieldPlus, BookOpen, Keyboard, Touchpad, Hourglass, ArrowRight, BatteryCharging, HelpCircle, BarChart3, Volume2, VolumeX, Hand, Brush } from 'lucide-react';
import { initializeApp } from 'firebase/app';
import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from 'firebase/auth';
import { getFirestore, doc, setDoc, getDoc, onSnapshot, updateDoc, deleteField } from 'firebase/firestore';
// ==========================================
// 1. 全系統無版權設定與極限壓縮資料庫
// ==========================================
const MAP_W = 40, MAP_H = 30, TS = 50;
const MODES_RAW = "solo_showdown,👑 孤膽生存,10人死鬥,0,1,10|gun_game,🔫 武器大師,擊殺進化,1,1,10|gem_grab,💎 礦源爭奪,收集晶石,1,0,6|bounty,⚔️ 殲滅戰,擊殺得星,1,0,6|heist,🏦 金庫攻防,摧毀金庫,1,0,6|soccer_ball,⚽ 競技足球,踢進球門,1,0,6|hot_zone,📍 據點搶奪,佔據點,1,0,6|knockout,☠️ 淘汰賽,殲滅敵隊,0,0,6|wipeout,🩸 擊殺戰,率先10殺,1,0,6|basket_brawl,🏀 競技籃球,投籃框,1,0,6|coin_rush,💰 金幣狂潮,收集30金幣,1,0,6|capture_flag,🚩 搶旗戰,奪旗幟,1,0,6|vip,👑 VIP刺殺,保護VIP,1,0,6|king_hill,⛰️ 山丘之王,佔據點,1,0,6|juggernaut,👹 巨獸狩獵,1v4,0,0,5|boss_raid,🐲 首領降臨,1v4,0,0,5|one_shot,🎯 一擊必殺,1HP,1,0,6|vampire_survival,🧛 吸血生存,吸血存活,1,1,10|boss_fight,🐲 首領戰,打Boss,1,0,5|payload,🚂 推車護送,推車,1,0,6|paint_map,🎨 領地塗鴉,塗色,1,0,6|prop_hunt,📦 躲貓貓,躲藏,0,0,6|gravity_shift,🕳️ 重力反轉,重力變換,1,0,6|shadow_realm,🌑 暗影迷霧,視野受限,1,1,10|dodge_laser,☄️ 極限閃避,閃雷射,0,1,10|pinball,🎱 奪命彈珠,彈珠撞人,1,0,6|clone_war,🎭 複製人戰,生成複製人,1,0,6|ice_rink,❄️ 冰河時期,無摩擦,1,0,6|lava_rising,🌋 熔岩上升,岩漿吞噬,0,1,10|troll_def,🛡️ 核心保衛,保衛核心,1,0,6|time_stop,⏱️ 時間暫停,時間暫停,1,0,6|bomb_walk,💣 連環爆破,走過爆炸,1,1,10";
const MODES = MODES_RAW.split('|').map(s=>{let p=s.split(',');return{id:p[0],name:p[1],desc:p[2],canRespawn:p[3]==='1',isFFA:p[4]==='1',maxP:parseInt(p[5]||6)}});
const PASSIVES = { fighter:{n:'鋼鐵意志',sc:2.5,u:'%'}, sniper:{n:'鷹眼',sc:3,u:'%'}, tank:{n:'復甦',sc:6,u:'%'}, thrower:{n:'爆破專家',sc:2,u:'%'}, support:{n:'恩典光環',sc:10,u:'HP'}, assassin:{n:'致命打擊',sc:4,u:'%'}, summoner:{n:'召喚大師',sc:5,u:'%'}, magic:{n:'秘法充能',sc:4,u:'%'}, ninja:{n:'影步',sc:3,u:'%'}, elemental:{n:'元素共鳴',sc:2,u:'%'}, engineer:{n:'防禦矩陣',sc:2.5,u:'%'}, berserker:{n:'嗜血',sc:3,u:'%'}, ultimate:{n:'創世神域',sc:3,u:'%',n2:'時光沙漏',sc2:5,u2:'%'} };
const EMOTES = ['👍','👎','😂','😡','🔥','💀','😭','🎉','👀','😎','🙏','💯','💩','🤡','👑'];
const MECHS = ['heal','dash','shield','push','slow','tp','summon','poison','speed','stealth','pull','vision','drain','stun','boom','silence','reflect','jump'];
const PFXS = ['烈火','冰晶','雷霆','暗黑','神聖','狂風','大地','時空','鮮血','虛空','幻影','奧術','星辰','深海','劇毒','機甲','幽冥','星爆','量子','混沌'];
const SFXS = ['護符','核心','法典','印記','沙漏','寶石','水晶','魔刃','面具','披風','魔環','聖杯','之眼','引擎','反應爐','靈核'];
const GEARS = [ { id:'heal',name:'急救包',cd:600,fx:'heal'}, { id:'dash',name:'衝刺',cd:300,fx:'dash'}, { id:'invincible',name:'無敵護盾',cd:900,fx:'shield'}, { id:'push_back',name:'震退波',cd:400,fx:'push'} ];
for(let i=4; i<200; i++) GEARS.push({ id:`g_${i}`, name:`${PFXS[i%PFXS.length]}${SFXS[i%SFXS.length]}`, desc:'戰術指令', cd:600+(i%5)*100, fx:MECHS[i%MECHS.length] });
function genH() {
let l = [], A = Object.keys(PASSIVES).filter(k=>k!=='ultimate');
const ATK = ['projectile','burst','shotgun','melee','throw','homing','chain_lightning','laser_beam','throw_puddle','melee_dash'];
const SUP = ['shockwave','laser_beam','throw','heal_aura','multi_dash','blackhole','summon','chain_lightning','bullet_hell','cone'];
const baseClasses = Array(30).fill(0).map((_,i)=>({
arch: A[i%A.length], aT: ATK[i%ATK.length], sT: SUP[i%SUP.length],
hp: 2000 + (i*150)%4000, spd: 3.2 + (i*0.05)%1.8, dmg: 300 + (i*40)%900, rng: 250 + (i*20)%550, rld: 800 + (i*40)%1000
}));
for (let i=0; i<149; i++) {
let c = baseClasses[i%30];
l.push({
id: `hero_${i}`, name: i===0?'先鋒武士':(i===1?'神槍手':`${PFXS[i%PFXS.length]}${SFXS[i%SFXS.length]}`), arch: c.arch, hex: `#${(Math.random()*0xffffff<<0).toString(16).padStart(6,'0')}`, cost: i<2 ? 0 : (200+Math.pow(i,1.35)*60)|0, sp: 'tough',
hp: c.hp + (i*13), spd: c.spd + (i%5)*0.02,
atk: { dmg: c.dmg + (i*2), rng: c.rng + (i%10)*5, rld: c.rld - (i%20)*5, type: c.aT, pel: (i%3)+1, spr: 0.5, aoe: 50 },
sup: { dmg: c.dmg*2 + i, rng: c.rng*1.2, type: c.sT, pel: (i%5)+1, pierce: i%2===0, breakWall: i%3===0, aoe: 150 },
hcType: ['spread_x2','pierce','range_boost','mega_dmg','aoe_expand','shield_wall','heal_burst','speed_demon','summon_beast'][i%9]
});
}
l[0].atk.type='shotgun'; l[0].atk.pel=5; l[0].sup.type='shotgun'; l[0].sup.pel=9;
l[1].atk.type='burst'; l[1].atk.pel=6; l[1].sup.type='burst'; l[1].sup.pel=12;
l.push({id:'hero_149',name:'阿爾法(終極)',arch:'ultimate',hp:12000,spd:5.5,hex:'#000000',cost:99999,atk:{dmg:1500,rng:700,rld:800,type:'omni_slash',spr:0.3,pel:5,pierce:true},sup:{dmg:2000,rng:900,type:'bullet_hell',pel:36,pierce:true,breakWall:true},sp:'vampire',hcType:'pierce'});
return l;
}
const HEROES = genH();
let audioCtx = null;
const playSfx = (type) => {
if (typeof window==='undefined') return;
if (!audioCtx) { try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch(e){return;} }
if (audioCtx.state === 'suspended') audioCtx.resume();
const o = audioCtx.createOscillator(), g = audioCtx.createGain(), n = audioCtx.currentTime;
o.connect(g); g.connect(audioCtx.destination);
if (type === 'shoot') { o.type='square'; o.frequency.setValueAtTime(600,n); o.frequency.exponentialRampToValueAtTime(100,n+0.1); g.gain.setValueAtTime(0.05,n); g.gain.exponentialRampToValueAtTime(0.001,n+0.1); o.start(n); o.stop(n+0.1); }
else if (type === 'hit') { o.type='sawtooth'; o.frequency.setValueAtTime(150,n); o.frequency.exponentialRampToValueAtTime(50,n+0.1); g.gain.setValueAtTime(0.1,n); g.gain.exponentialRampToValueAtTime(0.001,n+0.1); o.start(n); o.stop(n+0.1); }
else if (type === 'kill') { o.type='sine'; o.frequency.setValueAtTime(800,n); o.frequency.setValueAtTime(1200,n+0.05); g.gain.setValueAtTime(0.1,n); g.gain.linearRampToValueAtTime(0,n+0.15); o.start(n); o.stop(n+0.15); }
else if (type === 'win') { o.type='triangle'; o.frequency.setValueAtTime(440,n); o.frequency.setValueAtTime(554,n+0.2); o.frequency.setValueAtTime(659,n+0.4); g.gain.setValueAtTime(0.1,n); g.gain.linearRampToValueAtTime(0,n+0.8); o.start(n); o.stop(n+0.8); }
else if (type === 'lose') { o.type='sawtooth'; o.frequency.setValueAtTime(300,n); o.frequency.linearRampToValueAtTime(100,n+0.8); g.gain.setValueAtTime(0.1,n); g.gain.linearRampToValueAtTime(0,n+0.8); o.start(n); o.stop(n+0.8); }
};
const Toast = ({ msg, type='error', onClose }) => {
if (!msg) return null;
useEffect(() => { const t = setTimeout(onClose, 3500); return () => clearTimeout(t); }, [msg, onClose]);
return (
{type==='error'?:} {String(msg)}
);
};
let fbApp, fbAuth, fbDb;
const appId = typeof __app_id !== 'undefined' ? String(__app_id) : 'arena-pro-v7-final';
if (typeof __firebase_config !== 'undefined') { try { fbApp = initializeApp(JSON.parse(__firebase_config)); fbAuth = getAuth(fbApp); fbDb = getFirestore(fbApp); } catch (e) {} }
const getRRef = (id) => doc(fbDb, 'artifacts', appId, 'public', 'data', 'rooms', id);
const getDailyQuests = () => ({ date: new Date().toLocaleDateString(), list: [ { id: 'q1', type: 'win', target: 3, progress: 0, reward: 150, desc: '贏得 3 場遊戲' }, { id: 'q2', type: 'kill', target: 15, progress: 0, reward: 100, desc: '擊殺 15 名敵人' }, { id: 'q3', type: 'dmg', target: 30000, progress: 0, reward: 100, desc: '造成傷害' } ] });
const defaultInfo = { trophies: 0, coins: 500, pp: 0, unlocked: ['hero_0'], myHeroId: 'hero_0', team: 0, customMaps: [], heroRanks: { hero_0: 0 }, winStreak: 0, unlockedGears: ['heal', 'dash', 'invincible', 'push_back'], equips: { hero_0: 'heal' }, quests: getDailyQuests() };
const sanitizeInfoForDb = (inf) => { const res = JSON.parse(JSON.stringify(inf)); if(res.customMaps && Array.isArray(res.customMaps)) res.customMaps.forEach(m => { if(Array.isArray(m.grid)) m.grid = JSON.stringify(m.grid); }); return res; };
const parseInfoFromDb = (data) => { if(data.customMaps && Array.isArray(data.customMaps)) data.customMaps.forEach(m => { if(typeof m.grid === 'string') try{ m.grid=JSON.parse(m.grid); }catch(e){} }); return data; };
export default function App() {
const [user, setUser] = useState(null); const [isDataLoaded, setIsDataLoaded] = useState(false);
const [gameState, setGameState] = useState('start'); const [room, setRoom] = useState(null);
const [info, setInfo] = useState(defaultInfo); const [matchResult, setMatchResult] = useState(null);
const [toastMsg, setToastMsg] = useState({text:'', type:'error'}); const [localMatch, setLocalMatch] = useState(null);
const showToast = (text, type='error') => setToastMsg({text: String(text), type: String(type)});
useEffect(() => {
if (!fbAuth) { setIsDataLoaded(true); return; }
const initAuth = async () => { if (typeof __initial_auth_token !== 'undefined' && __initial_auth_token) await signInWithCustomToken(fbAuth, __initial_auth_token); else await signInAnonymously(fbAuth); };
initAuth();
const unsub = onAuthStateChanged(fbAuth, async u => {
setUser(u);
if (u) {
try {
const docRef = doc(fbDb, 'artifacts', appId, 'users', u.uid, 'userdata', 'progress');
const snap = await getDoc(docRef);
if (snap.exists()) {
let data = parseInfoFromDb(snap.data());
if (!data.quests || data.quests.date !== new Date().toLocaleDateString()) data.quests = getDailyQuests();
if (!data.unlockedGears || !Array.isArray(data.unlockedGears)) data.unlockedGears = defaultInfo.unlockedGears;
if (!data.equips) data.equips = { hero_0: 'heal' };
if (data.pp === undefined) data.pp = 0;
setInfo({ ...defaultInfo, ...data });
} else { await setDoc(docRef, sanitizeInfoForDb(defaultInfo)); }
} catch(e) {}
setIsDataLoaded(true);
}
});
return () => unsub();
}, []);
useEffect(() => { if (isDataLoaded && user && fbDb && info.quests) setDoc(doc(fbDb, 'artifacts', appId, 'users', user.uid, 'userdata', 'progress'), sanitizeInfoForDb(info)).catch(()=>{}); }, [info, isDataLoaded, user]);
if (!isDataLoaded) return LOADING ENGINE...
;
return (
<>
setToastMsg({text:'', type:''})} />
{gameState === 'start' && {playSfx('ui'); setGameState('hub');}} />}
{gameState === 'hub' && }
{gameState === 'map_maker' && }
{gameState === 'lobby' && }
{gameState === 'vs_screen' && }
{gameState === 'arena' && { setMatchResult(res); setGameState('mvp'); }} localMatch={localMatch} showToast={showToast} />}
{gameState === 'mvp' && }
{gameState === 'mystery_box' && setGameState(room ? 'lobby' : 'hub')} />}
>
);
}
// 沉浸式全動態開頭畫面
function StartScreen({ onStart }) {
const canvasRef = useRef(null);
useEffect(() => {
const c = canvasRef.current; const ctx = c.getContext('2d', {alpha:false});
let w = window.innerWidth, h = window.innerHeight; c.width = w; c.height = h;
let animId;
const ents = Array(8).fill(0).map(()=>({x:Math.random()*w, y:Math.random()*h, c:Math.random()>0.5?'#3b82f6':'#ef4444', a:Math.random()*Math.PI*2, t:0}));
const projs = [];
const dC = (x,y,r,col) => { ctx.fillStyle=col; ctx.beginPath(); ctx.arc(x,y,r,0,7); ctx.fill(); };
const loop = () => {
ctx.fillStyle='#0f172a'; ctx.fillRect(0,0,w,h);
ctx.strokeStyle='#1e293b'; ctx.lineWidth=2;
for(let i=0;i {
e.x += Math.cos(e.a)*2.5; e.y += Math.sin(e.a)*2.5;
if(e.x<0||e.x>w||e.y<0||e.y>h || Math.random()<0.01) e.a = Math.random()*6;
if(Math.random()<0.02) projs.push({x:e.x, y:e.y, vx:Math.cos(e.a)*15, vy:Math.sin(e.a)*15, c:e.c, l:30});
ctx.fillStyle='rgba(0,0,0,0.5)'; ctx.beginPath(); ctx.ellipse(e.x, e.y+15, 20, 10, 0, 0, 7); ctx.fill();
dC(e.x, e.y, 18, '#1e293b'); dC(e.x, e.y-6, 14, e.c); ctx.strokeStyle='#000'; ctx.stroke();
ctx.fillStyle='#000'; ctx.fillRect(e.x-15, e.y-30, 30, 5); ctx.fillStyle=e.c; ctx.fillRect(e.x-15, e.y-30, 25, 5);
});
for(let i=projs.length-1; i>=0; i--) {
let p = projs[i]; p.x+=p.vx; p.y+=p.vy; p.l--;
dC(p.x, p.y, 6, p.c);
ctx.globalAlpha=0.3; ctx.beginPath(); ctx.moveTo(p.x,p.y); ctx.lineTo(p.x-p.vx*3, p.y-p.vy*3); ctx.lineWidth=6; ctx.strokeStyle=p.c; ctx.stroke(); ctx.globalAlpha=1;
if(p.l<=0) projs.splice(i,1);
}
animId = requestAnimationFrame(loop);
};
loop(); return () => cancelAnimationFrame(animId);
}, []);
return (
);
}
function Hub({ user, setGameState, info, setInfo, setRoom, showToast }) {
const [showHelp, setShowHelp] = useState(false); const [showGears, setShowGears] = useState(false);
const myHero = HEROES.find(b => b.id === info.myHeroId) || HEROES[0];
const totalTrophies = Object.values(info.heroRanks || {}).reduce((a,b)=>a+b, 0);
const hTrophies = (info.heroRanks && info.heroRanks[myHero.id]) || 0;
const passiveTier = Math.min(10, Math.max(0, Math.floor(hTrophies / 100)));
const passiveData = PASSIVES[myHero.arch] || PASSIVES.fighter;
const curGearId = info.equips?.[myHero.id] || 'heal'; const curGear = GEARS.find(g=>g.id === curGearId) || GEARS[0];
const StatBar = ({ label, val, max, color }) => (
);
return (
{showHelp && (
操作說明與系統特點
電腦版 (PC): WASD 移動。滑鼠點擊螢幕發射 (無自瞄)。E 鍵指令,F 鍵極限狀態,滑鼠右鍵大絕。
手機版 (Mobile): 左手拖曳移動。右側有紅色普攻與黃色大絕搖桿。
💡 拖曳搖桿 = 手動瞄準,顯示真實形狀預判框。 極短按不拖曳 = 閃爍0.15秒鎖定框後自動射擊!
單機模式: 點擊「大廳」,選擇自動隨機迷宮,按下 START 瞬間秒開 AI 戰場,零延遲!
地圖編輯器防呆: 支援「🤚 滑動視角 / 🖌️ 繪製」切換。有死胡同或未放滿重生點會跳出紅色警告!
)}
{showGears && (
戰術指令庫 ({Number(info.unlockedGears?.length||0)} / 200)
{GEARS.map(g => {
const isUnl = info.unlockedGears?.includes(g.id); const isSel = curGear.id === g.id;
return (
);
})}
)}
ARENA PRO
{totalTrophies}
{info.coins}
英雄陣容 (150 位)
{HEROES.map((b, i) => {
const isUnl = info.unlocked.includes(b.id); const isSel = info.myHeroId === b.id;
const prevB = HEROES[i - 1]; const canUnl = !prevB || info.unlocked.includes(prevB.id);
return (
);
})}
{String(myHero.hcType)}
{String(myHero.name)}
{/* 天賦面板 */}
永久天賦 T{Number(passiveTier)}
{String(passiveData.n)}
{String(passiveData.d)} +{Number(passiveTier * passiveData.sc)}{String(passiveData.u)}
能力值分析
);
}
function Lobby({ user, room, setRoom, setGameState, info, setInfo, showToast, setLocalMatch }) {
const [rData, setRData] = useState({ mode: 'gem_grab', players: {}, customMap: null, status: 'waiting' });
const isHost = !room || room.isHost;
useEffect(() => {
if (rData.status === 'starting' && room) setGameState('vs_screen');
}, [rData.status, setGameState, room]);
useEffect(() => {
if (!room) {
const b = HEROES.find(x => x.id === info.myHeroId) || HEROES[0];
const tr = (info.heroRanks && info.heroRanks[b.id]) || 0;
const pt = Math.min(10, Math.max(0, Math.floor(tr / 100)));
setRData(p=>({ ...p, players: { 'local': { name: '你', hero: b.id, team: info.team, gear: info.equips?.[b.id]||'heal', isReady:true, tier: pt } } }));
return;
}
if (!user || !fbDb) return;
const unsub = onSnapshot(getRRef(room.id), snap => {
if(snap.exists()) { const d = snap.data(); if (d.status === 'closed') { showToast('房間已關閉', 'error'); setGameState('hub'); setRoom(null); } else setRData(d); }
else { showToast('房間解散', 'error'); setGameState('hub'); setRoom(null); }
}); return () => unsub();
}, [room, user]);
const updateRoom = async (u) => {
playSfx('ui');
if (room && user && isHost) await updateDoc(getRRef(room.id), u);
else if(!room) setRData(p => {
if(u.customMap !== undefined) { const m = info.customMaps?.find(x=>x.id === u.customMap); if(m) return {...p, ...u, mode: m.mode}; }
if(u.mode !== undefined && p.customMap) return {...p, ...u, customMap: null};
return {...p, ...u};
});
};
const toggleTeam = async () => {
playSfx('ui'); const myUid = room ? user.uid : 'local'; const newTeam = rData.players[myUid]?.team === 0 ? 1 : 0; setInfo({...info, team: newTeam});
if (room) await updateDoc(getRRef(room.id), { [`players.${myUid}.team`]: newTeam }); else setRData(p => ({...p, players: {...p.players, local: {...p.players.local, team: newTeam}}}));
};
const toggleReady = async () => { playSfx('ui'); if(!room) return; await updateDoc(getRRef(room.id), { [`players.${user.uid}.isReady`]: !rData.players[user.uid]?.isReady }); };
const leaveRoom = async () => { playSfx('ui'); if (room && isHost) await updateDoc(getRRef(room.id), { status: 'closed' }); else if (room) await updateDoc(getRRef(room.id), { [`players.${user.uid}`]: deleteField() }); setRoom(null); setGameState('hub'); };
const startBrawl = () => {
playSfx('win');
const myId = room ? user?.uid : 'local';
const othersReady = Object.entries(rData.players).filter(([k,p]) => k !== myId).every(([k,p]) => p.isReady || p.isBot);
if (room && Object.keys(rData.players).length > 1 && !othersReady && !confirm('有玩家未準備,強制開始會將未準備的玩家視為AI,確定開始嗎?')) return;
const newP = { ...rData.players }; const mDef = MODES.find(m=>m.id === rData.mode) || MODES[0];
const maxP = mDef.maxP || 6;
if (mDef.isFFA) {
for (let i=Object.keys(newP).length; i{if(p.team===0)t0C++;else t1C++;});
const isJug = ['juggernaut','boss_raid'].includes(rData.mode);
const max0 = isJug ? 1 : Math.floor(maxP/2); const max1 = isJug ? 4 : Math.ceil(maxP/2);
for(let i=t0C; i { await updateDoc(getRRef(room.id), { status: 'starting', players: newP, state: {}, lastActive: Date.now() }); }, 0);
} else {
setLocalMatch({ mode: rData.mode, customMap: rData.customMap, players: newP });
setGameState('vs_screen');
}
};
const createRoom = async () => {
playSfx('ui'); if (!user || !fbDb) return showToast('未連接 Firebase 服務', 'error'); const rId = Math.floor(1000+Math.random()*9000).toString();
const b = HEROES.find(x => x.id === info.myHeroId) || HEROES[0];
await setDoc(getRRef(rId), { host: user.uid, mode: rData.mode, status: 'waiting', customMap: rData.customMap, players: { [user.uid]: { name: '主機', hero: b.id, team: info.team, isReady: true, gear: info.equips?.[b.id]||'heal', tier: Math.min(10, Math.floor(((info.heroRanks&&info.heroRanks[b.id])||0)/100)) } }, inputs: {}, state: {}, lastActive: Date.now() });
setRoom({ id: rId, isHost: true });
};
return (
{room &&
房號: {String(room.id)}
}
遊戲模式 (42種)
{MODES.map(m => (
))}
選擇地圖
{(info.customMaps||[]).map(m => ())}
{!MODES.find(m=>m.id===rData.mode)?.isFFA &&
}
{MODES.find(m=>m.id===rData.mode)?.isFFA ? (
<>
競技參戰者
{Object.entries(rData.players||{}).map(([k,v]) =>
{String(v.name)} ({String(HEROES.find(b=>b.id===v.hero)?.name)}) {(v.isReady||k===(room?room.host:'local'))&&}
)}
>
) : (
<>
藍隊陣營 {['juggernaut','boss_raid'].includes(rData.mode) && '(魔王)'}
{Object.entries(rData.players||{}).filter(([,v]) => v.team===0).map(([k,v]) =>
{String(v.name)} ({String(HEROES.find(b=>b.id===v.hero)?.name)}) {(v.isReady||k===(room?room.host:'local'))&&}
)}
紅隊陣營 {['juggernaut','boss_raid'].includes(rData.mode) && '(狩獵者)'}
{Object.entries(rData.players||{}).filter(([,v]) => v.team===1).map(([k,v]) =>
{String(v.name)} ({String(HEROES.find(b=>b.id===v.hero)?.name)}) {(v.isReady||k===(room?room.host:'local'))&&}
)}
>
)}
{(!room) ? (
) : (
isHost ? (
) :
( rData.status === 'playing' ?
:
)
)}
);
}
function MapMaker({ info, setInfo, setGameState, showToast }) {
const [grid, setGrid] = useState(()=>Array(MAP_H).fill().map(()=>Array(MAP_W).fill(0)));
const [brush, setBrush] = useState(1); const [mode, setMode] = useState('gem_grab'); const [mapName, setMapName] = useState('自訂地圖'); const [mirror, setMirror] = useState('none');
const [isPan, setIsPan] = useState(false); const isP = useRef(false);
const curTiles = (() => {
let base = [ {id:0, c:'#d97736', n:'空地', sym:0}, {id:1, c:'#334155', n:'牆壁', sym:1}, {id:2, c:'#15803d', n:'草叢', sym:2}, {id:3, c:'#3b82f6', n:'水池', sym:3}, {id:4, c:'#93c5fd', n:'藍重生', sym:5}, {id:5, c:'#fca5a5', n:'紅重生', sym:4} ];
if(mode === 'gem_grab') base.push({id:6, c:'#eab308', n:'礦源', sym:6});
if(['bounty', 'wipeout', 'knockout', 'coin_rush', 'star_collector', 'ffa', 'one_shot', 'vampire_survival', 'sniper_only', 'melee_only', 'zombie', 'juggernaut', 'gun_game', 'solo_showdown', 'meteor_shower', 'stealth_assassins', 'lava_floor', 'time_attack', 'golden_snitch', 'hot_potato'].includes(mode)) base.push({id:6, c:'#eab308', n:'中心物', sym:6});
if(['brawl_ball', 'basket_brawl'].includes(mode)) base.push({id:7, c:'#bfdbfe', n:'藍目標', sym:8}, {id:8, c:'#fecaca', n:'紅目標', sym:7});
if(['heist', 'tower_defense', 'zombie_survival', 'troll_def'].includes(mode)) base.push({id:9, c:'#1d4ed8', n:'藍金庫', sym:10}, {id:10, c:'#b91c1c', n:'紅金庫', sym:9});
if(['hot_zone', 'king_hill', 'tug_of_war', 'paint_map', 'prop_hunt'].includes(mode)) base.push({id:11, c:'#fcd34d', n:'據點', sym:11});
if(mode === 'capture_flag') base.push({id:12, c:'#3b82f6', n:'藍旗', sym:13}, {id:13, c:'#ef4444', n:'紅旗', sym:12});
if(['boss_fight', 'boss_raid', 'vip', 'payload'].includes(mode)) base.push({id:14, c:'#f43f5e', n:'目標物', sym:14});
if(MODES.find(m=>m.id===mode)?.isFFA) base.push({id:15, c:'#eab308', n:'FFA重生', sym:15});
if(mode === 'portal_kombat') base.push({id:16, c:'#a855f7', n:'蟲洞', sym:16});
return base;
})();
const paint = (r, c) => {
if(r<=0||r>=MAP_H-1||c<=0||c>=MAP_W-1) return; const n = [...grid]; const getS = (id) => curTiles.find(t=>t.id===id)?.sym ?? id;
const ap = (rr, cc, bId) => { if(rr>0&&rr0&&cc {
if(!isP.current || isPan) return;
const rect = e.currentTarget.getBoundingClientRect();
let cx = e.clientX, cy = e.clientY;
if (e.touches && e.touches.length > 0) { cx = e.touches[0].clientX; cy = e.touches[0].clientY; }
if (cx===undefined || cy===undefined) return;
const c = Math.floor(((cx - rect.left) / rect.width) * MAP_W);
const r = Math.floor(((cy - rect.top) / rect.height) * MAP_H);
paint(r, c);
};
const clearMap = () => { setGrid(Array(MAP_H).fill().map((_,r)=>Array(MAP_W).fill().map((_,c)=>{ if(r===0||r===MAP_H-1||c===0||c===MAP_W-1) return 1; return 0; }))); };
useEffect(() => { if (grid[0][0] === 0) clearMap(); }, []);
const saveMap = () => {
playSfx('ui');
let rSp=0, bSp=0, ffaSp=0, gem=0, safeB=0, safeR=0, zone=0, flagB=0, flagR=0;
let startNode = null; let keyNodes = [];
grid.forEach((row, r) => row.forEach((v, c) => {
if(v===4) bSp++; if(v===5) rSp++; if(v===15) ffaSp++;
if(v===6) gem++; if(v===9) safeB++; if(v===10) safeR++;
if(v===11) zone++; if(v===12) flagB++; if(v===13) flagR++;
if([4,5,6,7,8,9,10,11,12,13,14,15,16].includes(v)) { keyNodes.push({r,c}); if(!startNode) startNode = {r,c}; }
}));
const isFFA = MODES.find(m=>m.id===mode)?.isFFA;
if(!isFFA && (bSp===0 || rSp===0)) return showToast('驗證失敗:紅藍雙方至少各需一個「重生點」', 'error');
if(isFFA && ffaSp < 2) return showToast('驗證失敗:FFA模式需包含至少兩個「FFA重生點」', 'error');
if(mode==='gem_grab' && gem===0) return showToast('驗證失敗:必須放置「礦源」', 'error');
if(['heist', 'tower_defense', 'troll_def'].includes(mode) && (safeB===0 || safeR===0)) return showToast('驗證失敗:必須放置雙方的「金庫」', 'error');
if(['hot_zone', 'king_hill'].includes(mode) && zone===0) return showToast('驗證失敗:必須放置「據點」', 'error');
if(mode==='capture_flag' && (flagB===0 || flagR===0)) return showToast('驗證失敗:必須放置雙方「旗幟」', 'error');
if (keyNodes.length > 0 && startNode) {
let visited = new Set(); let queue = [startNode]; visited.add(`${startNode.r},${startNode.c}`);
while(queue.length > 0) {
let {r, c} = queue.shift(); let dirs = [[-1,0], [1,0], [0,-1], [0,1]];
for (let [dr, dc] of dirs) { let nr = r + dr, nc = c + dc; if (nr >= 0 && nr < MAP_H && nc >= 0 && nc < MAP_W) { if (grid[nr][nc] !== 1 && !visited.has(`${nr},${nc}`)) { visited.add(`${nr},${nc}`); queue.push({r: nr, c: nc}); } } }
}
for (let node of keyNodes) { if (!visited.has(`${node.r},${node.c}`)) return showToast('驗證失敗:地圖有死胡同!', 'error'); }
}
const maps = info.customMaps || []; if(maps.length >= 5) return showToast('最多儲存 5 張自訂地圖', 'error');
setInfo({...info, customMaps: [...maps, { id: Date.now().toString(), name: mapName, mode, grid }]}); showToast('驗證通過!', 'success'); setGameState('hub');
};
return (
地圖編輯器
setMapName(e.target.value)} className="w-full bg-slate-950 p-3 rounded-xl text-white font-bold outline-none focus:border-cyan-500 border border-slate-700" />
{curTiles.map(t=>(
{if(!isPan){isP.current=true; handlePaint(e);}}} onPointerMove={handlePaint} onPointerUp={()=>isP.current=false} onPointerLeave={()=>isP.current=false} onPointerCancel={()=>isP.current=false}>
{grid.map((row, r) => row.map((v, c) =>
t.id===v)?.c || '#000'}} />))}
);
}
function VSScreen({ info, setGameState, room, localMatch }) {
const [rData, setRData] = useState(null);
useEffect(() => {
if (!room) { setRData(localMatch); setTimeout(() => setGameState('arena'), 3000); }
else { const unsub = onSnapshot(getRRef(room.id), snap => { if (snap.exists()) { setRData(snap.data()); if (!room.isHost && snap.data().status === 'playing') setGameState('arena'); } }); if (room.isHost) { setTimeout(() => { updateDoc(getRRef(room.id), { status: 'playing' }).then(() => setGameState('arena')); }, 3000); } return () => unsub(); }
}, []);
if (!rData) return
BATTLE...
;
return (
BATTLE!
{Object.values(rData.players).filter(p=>p.team===0).map((p,i) =>
{String(p.name)} (T{Number(p.tier)})
)}
VS
{Object.values(rData.players).filter(p=>p.team===1||rData.mode==='solo_showdown').map((p,i) =>
{String(p.name)} (T{Number(p.tier)})
)}
);
}
// ==========================================
// 戰鬥場景 (Arena)
// ==========================================
function Arena({ user, room, info, setGameState, setRoom, onEnd, localMatch, showToast }) {
const canvasRef = useRef(null); const engineLock = useRef(false); const stateRef = useRef({ user, room, info, setGameState, setRoom, onEnd, localMatch });
const [showEmotes, setShowEmotes] = useState(false); const hasExited = useRef(false);
useEffect(() => { stateRef.current = { user, room, info, setGameState, setRoom, onEnd, localMatch }; }, [user, room, info, setGameState, setRoom, onEnd, localMatch]);
useEffect(() => {
if (engineLock.current) return; engineLock.current = true;
let active = true; let isMapReady = false;
const canvas = canvasRef.current; const ctx = canvas.getContext('2d', { alpha: false });
const W = MAP_W * TS, H = MAP_H * TS;
const sRoom = stateRef.current.room; const isOnline = !!sRoom; let isHost = !sRoom || sRoom.isHost;
const myUid = isOnline ? stateRef.current.user?.uid : 'local';
let roomRef = null; if (isOnline) roomRef = getRRef(sRoom.id);
const input = { dx:0, dy:0, ax:0, ay:0, fire:false, sup:false, gad:false, hcSkill:false, emote:null, last:0, autoAim: false, autoSup: false };
const keys = {}; const mouse = { x:0, y:0, l:false, r:false }; const cam = { x:W/2, y:H/2 };
const joys = { m:{act:false,x:0,y:0,ox:0,oy:0,id:null}, a:{act:false,x:0,y:0,ox:0,oy:0,id:null}, s:{act:false,x:0,y:0,ox:0,oy:0,id:null} };
const dist = (x1,y1,x2,y2) => Math.hypot(x2-x1, y2-y1);
const getUI = () => { const w = window.innerWidth, h = window.innerHeight; return { ax: w-120, ay: h-120, ar: 50, sx: w-260, sy: h-120, sr: 40, gx: w-120, gy: h-240, gr: 30, hx: w-260, hy: h-220, hr: 30 }; };
const handleTouchS = e => {
const ui = getUI();
for(let t of e.changedTouches) {
if(t.clientX < window.innerWidth/2) { joys.m.act=true; joys.m.id=t.identifier; joys.m.ox=joys.m.x=t.clientX; joys.m.oy=joys.m.y=t.clientY; }
else if(dist(t.clientX, t.clientY, ui.sx, ui.sy) < ui.sr*2) { joys.s.act=true; joys.s.id=t.identifier; joys.s.ox=ui.sx; joys.s.oy=ui.sy; joys.s.x=t.clientX; joys.s.y=t.clientY; }
else if(dist(t.clientX, t.clientY, ui.ax, ui.ay) < ui.ar*2) { joys.a.act=true; joys.a.id=t.identifier; joys.a.ox=ui.ax; joys.a.oy=ui.ay; joys.a.x=t.clientX; joys.a.y=t.clientY; }
else if(dist(t.clientX, t.clientY, ui.gx, ui.gy) < ui.gr*2) { input.gad = true; }
else if(dist(t.clientX, t.clientY, ui.hx, ui.hy) < ui.hr*2) { input.hcSkill = true; }
}
};
const handleTouchM = e => { const moveJoy = (j,t) => { if(t.identifier===j.id){ j.x=t.clientX; j.y=t.clientY; const d=dist(j.x,j.y,j.ox,j.oy); if(d>40){j.x=j.ox+(j.x-j.ox)/d*40; j.y=j.oy+(j.y-j.oy)/d*40;} } }; for(let t of e.changedTouches) { moveJoy(joys.m,t); moveJoy(joys.a,t); moveJoy(joys.s,t); } };
const handleTouchE = e => {
for(let t of e.changedTouches) {
if(t.identifier===joys.m.id) { joys.m.act=false; joys.m.id=null; }
if(t.identifier===joys.a.id) {
input.fire=true; input.autoAim = dist(joys.a.x, joys.a.y, joys.a.ox, joys.a.oy) < 5;
if(!input.autoAim){ input.ax = joys.a.x-joys.a.ox; input.ay = joys.a.y-joys.a.oy; }
joys.a.act=false; joys.a.id=null;
}
if(t.identifier===joys.s.id) {
input.sup=true; input.autoSup = dist(joys.s.x, joys.s.y, joys.s.ox, joys.s.oy) < 5;
if(!input.autoSup){ input.sx = joys.s.x-joys.s.ox; input.sy = joys.s.y-joys.s.oy; }
joys.s.act=false; joys.s.id=null;
}
}
};
const handleKD = e => { keys[e.code] = true; if(e.code==='Space') {input.sup=true; input.autoSup=false; input.sx=mouse.x+cam.x-(engine.ents[myUid]?.x||0); input.sy=mouse.y+cam.y-(engine.ents[myUid]?.y||0);} if(e.code==='KeyE') input.gad=true; if(e.code==='KeyF') input.hcSkill=true; };
const handleKU = e => keys[e.code] = false;
const handleMM = e => { mouse.x = e.clientX; mouse.y = e.clientY; };
const handleMD = e => { if(e.button===0) mouse.l=true; if(e.button===2) mouse.r=true; };
const handleMU = e => {
if(e.button===0){input.fire=true; input.autoAim=false; mouse.l=false; input.ax=mouse.x+cam.x-(engine.ents[myUid]?.x||0); input.ay=mouse.y+cam.y-(engine.ents[myUid]?.y||0); }
if(e.button===2){input.sup=true; input.autoSup=false; mouse.r=false; input.sx=mouse.x+cam.x-(engine.ents[myUid]?.x||0); input.sy=mouse.y+cam.y-(engine.ents[myUid]?.y||0); }
};
window.sendEmote = (emo) => { input.emote = emo; setShowEmotes(false); };
const preventDefaultWrapper = (fn) => (e) => { if (e.target === canvas) { e.preventDefault(); } fn(e); };
canvas.addEventListener('touchstart', preventDefaultWrapper(handleTouchS), {passive:false}); canvas.addEventListener('touchmove', preventDefaultWrapper(handleTouchM), {passive:false}); canvas.addEventListener('touchend', preventDefaultWrapper(handleTouchE), {passive:false}); canvas.addEventListener('touchcancel', preventDefaultWrapper(handleTouchE));
window.addEventListener('keydown', handleKD); window.addEventListener('keyup', handleKU); window.addEventListener('mousemove', handleMM); window.addEventListener('mousedown', handleMD); window.addEventListener('mouseup', handleMU); canvas.addEventListener('contextmenu', e=>e.preventDefault());
let weather = { type: ['none', 'rain', 'snow', 'dust'][Math.floor(Math.random()*4)], particles: [] };
if (weather.type !== 'none') { for(let i=0;i<100;i++) weather.particles.push({x:Math.random()*window.innerWidth, y:Math.random()*window.innerHeight, s:Math.random()*2+1}); }
let engine = { mode: 'gem_grab', grid: [], time: 180*60, cd: null, ents: {}, bullets: [], gems: [], stars: [], ball: null, safes: [], items: [], zones: [], flags: [], stats: { t0:0, t1:0, myK:0, myD:0 }, floatingTexts: [], killFeed: [], particles: [], objLimit: 300 };
let cState = { ents: {} }, sInputs = {}; let lastSyncTime = 0, packetId = 0; let unsubSnap = null; let intervalRef = null;
const getG = (x,y) => { const c=Math.floor(x/TS), r=Math.floor(y/TS); if (!engine.grid || r<0 || r>=MAP_H || c<0 || c>=MAP_W || !engine.grid[r]) return 1; return engine.grid[r][c]; };
const addText = (x,y,txt,col,scl=1) => { engine.floatingTexts.push({x:x+(Math.random()-0.5)*20, y:y-20, txt, col, life: 40, scl}); };
const addKill = (killer, victim, isAlly) => { engine.killFeed.push({txt: `${killer} ⚔️ ${victim}`, col: isAlly?'#22d3ee':'#f87171', life: 180}); };
const addParticles = (x,y,col,cnt) => { for(let i=0;i
{
let sx=team===0?TS*3:W-TS*3, sy=H/2+(Math.random()-0.5)*300;
const isFFA = MODES.find(m=>m.id===engine.mode)?.isFFA;
if(forceX !== undefined) { sx=forceX; sy=forceY; } else {
const sTiles=[];
if (engine.grid && engine.grid.length > 0) {
for(let r=0;r0) { const st = sTiles[Math.floor(Math.random()*sTiles.length)]; sx=st.x; sy=st.y; } else { sx=Math.max(TS, Math.min(W-TS, sx)); sy=Math.max(TS, Math.min(H-TS, sy)); }
}
let initMaxHp = bType.hp;
if (bType.arch === 'fighter' || bType.arch === 'ultimate') initMaxHp *= (1 + tier * (PASSIVES.fighter.sc/100));
let initHp = initMaxHp; if(gearStr === 'shield') initHp += 600;
if (engine.mode === 'one_shot') { initHp=1; initMaxHp=1; }
if (['juggernaut','boss_raid'].includes(engine.mode) && team===0 && id!=='local' && !id.startsWith('bot_1')) { initHp*=10; initMaxHp*=10; }
const gDef = GEARS.find(g=>g.id === gearStr) || GEARS[0];
let maxGCD = gDef.cd || 600;
if (bType.arch === 'ultimate') maxGCD *= (1 - tier * (PASSIVES.ultimate.sc2/100));
engine.ents[id] = { id, isBot:isB, team, name:n, b:JSON.parse(JSON.stringify(bType)), x:sx, y:sy, rad: (['juggernaut','boss_raid'].includes(engine.mode))&&team===0?30:18, hp:initHp, maxHp:initMaxHp, spd:bType.spd, color:bType.hex, ammo:3, rld:0, respawn:0, supC:0, shT:0, val: ['bounty','wipeout','ffa','gun_game'].includes(engine.mode)?2:0, hcC:0, hcT:0, gear:gearStr, maxGadCD: maxGCD, gadCD: 0, tier, isMinion:false, evadeT:0, evadeDx:0, evadeDy:0, emote:null, emoteT:0, revealT:0, hidden:false, stunT: 0, slowT: 0, outOfCombat: 0, la: 0, qS: null };
};
const startEngineProcess = async () => {
engine.bullets = []; engine.gems = []; engine.stars = []; engine.ball = null; engine.safes = []; engine.items = []; engine.zones = []; engine.flags = [];
engine.stats = { t0:0, t1:0, myK:0, myD:0 }; engine.floatingTexts = []; engine.killFeed = []; engine.particles = []; engine.cd = null;
let rData = null; if(isOnline) { try { const snap = await getDoc(roomRef); if(snap.exists()) rData = snap.data(); } catch(e){} }
else { rData = stateRef.current.localMatch; }
engine.mode = rData ? rData.mode : 'gem_grab';
let customMapGrid = null;
if (rData && rData.customMap !== null) {
const cMapInfo = stateRef.current.info.customMaps?.find(m=>m.id===rData.customMap);
if (cMapInfo) customMapGrid = JSON.parse(JSON.stringify(cMapInfo.grid));
}
if (customMapGrid) {
engine.grid = customMapGrid;
} else {
engine.grid = Array(MAP_H).fill().map((_,r)=>Array(MAP_W).fill().map((_,c)=>{
if(r===0||r===MAP_H-1||c===0||c===MAP_W-1) return 1;
if(engine.mode==='gem_grab' && r===MAP_H/2 && c===MAP_W/2) return 6;
if(['brawl_ball','basket_brawl'].includes(engine.mode) && r>10&&r<20 && (c===4||c===MAP_W-5)) return 6;
if(['hot_zone', 'king_hill', 'tug_of_war', 'paint_map', 'prop_hunt'].includes(engine.mode) && r>10&&r<20 && c>15&&c<25) return 11;
if(engine.mode==='capture_flag' && r===MAP_H/2 && (c===4||c===MAP_W-5)) return c===4?12:13;
if(['boss_fight', 'vip', 'juggernaut', 'boss_raid'].includes(engine.mode) && r===MAP_H/2 && c===MAP_W/2) return 6;
if(engine.mode==='portal_kombat' && Math.random()<0.02) return 16;
const isCenter = r>10 && r<20 && c>15 && c<25;
const isSpawn = (c<5 || c>MAP_W-6);
if (!isCenter && !isSpawn) { const rand = Math.random(); if (rand < 0.15) return 1; if (rand < 0.3) return 2; }
return 0;
}));
}
for(let r=0;r spawnEnt(uid, p.isBot, p.team, p.name, HEROES.find(b=>b.id===p.hero)||HEROES[0], p.gear||'damage', p.tier||0));
unsubSnap = onSnapshot(roomRef, snap => { if(snap.exists()) { const d = snap.data(); if(d.state && d.state.ents && !isHost) { cState = d.state; Object.values(cState.ents).forEach(e => { e.ox=engine.ents[e.id]?.x||e.x; e.oy=engine.ents[e.id]?.y||e.y; e.lerp=0; }); Object.values(engine.ents).forEach(e=>{if(!cState.ents[e.id] && !e.isMinion) delete engine.ents[e.id];}); engine.time=d.state.time; engine.stats=d.state.stats; engine.bullets=d.state.bullets; engine.gems=d.state.gems; engine.stars=d.state.stars; engine.ball=d.state.ball; engine.safes=d.state.safes; engine.items=d.state.items; engine.zones=d.state.zones; engine.flags=d.state.flags; engine.grid=d.state.grid; Object.values(cState.ents).forEach(e=>{if(!engine.ents[e.id]) engine.ents[e.id]=e; else{Object.assign(engine.ents[e.id], e); engine.ents[e.id].x=e.ox; engine.ents[e.id].y=e.oy;}}); } if(d.inputs && isHost) { Object.keys(d.inputs).forEach(uid => { if(uid!==myUid) { if(!sInputs[uid] || sInputs[uid].pid !== d.inputs[uid].pid) { sInputs[uid] = d.inputs[uid]; } } }); } if (d.status === 'finished' && !isHost) { active=false; if(!hasExited.current) stateRef.current.onEnd(d.result); } } });
} else if (!isOnline) {
if (rData && rData.players) Object.entries(rData.players).forEach(([uid, p]) => spawnEnt(uid, p.isBot, p.team, p.name, HEROES.find(b=>b.id===p.hero)||HEROES[0], p.gear||'damage', p.tier||0));
}
if (engine.mode==='vip') {
const t0 = Object.values(engine.ents).filter(e=>e.team===0); if(t0.length>0) t0[Math.floor(Math.random()*t0.length)].val=10;
const t1 = Object.values(engine.ents).filter(e=>e.team===1); if(t1.length>0) t1[Math.floor(Math.random()*t1.length)].val=10;
}
if (engine.mode==='hot_potato') { const all = Object.values(engine.ents).filter(e=>!e.isMinion); if(all.length>0) all[Math.floor(Math.random()*all.length)].val=100; }
if (isOnline && isHost) {
intervalRef = setInterval(async () => {
if (!active) return clearInterval(intervalRef);
try { const snap = await getDoc(roomRef); if (snap.exists()) { const d = snap.data();
if (d.status === 'playing' && Date.now() - (d.lastActive || Date.now()) > 6000) {
if (!isHost) { const uids = Object.keys(d.players).sort(); if (uids[0] === myUid) { isHost = true; updateDoc(roomRef, { host: myUid, lastActive: Date.now() }); } }
}
}} catch(e){}
}, 3000);
}
};
startEngineProcess();
const rsCol = (e) => {
e.x = Math.max(e.rad, Math.min(W-e.rad, e.x)); e.y = Math.max(e.rad, Math.min(H-e.rad, e.y));
const c=Math.floor(e.x/TS), r=Math.floor(e.y/TS);
for(let i=r-1;i<=r+1;i++)for(let j=c-1;j<=c+1;j++) if(getG(j*TS, i*TS)===1||getG(j*TS, i*TS)===3){
const tx=Math.max(j*TS,Math.min(e.x,j*TS+TS)), ty=Math.max(i*TS,Math.min(e.y,i*TS+TS)), dx=e.x-tx, dy=e.y-ty, d=Math.max(0.01, dist(0,0,dx,dy));
if(d Math.abs(pushY)) e.x += pushX; else e.y += pushY; }
}
};
const softCol = (e) => {
Object.values(engine.ents).forEach(e2 => {
if(e.id !== e2.id && e2.hp>0 && !e.isMinion && !e2.isMinion) {
const d = Math.max(0.01, dist(e.x,e.y,e2.x,e2.y)); const sumR = e.rad + e2.rad;
if(d < sumR) { const push = (sumR-d)*0.1; e.x -= (e2.x-e.x)/d*push; e.y -= (e2.y-e.y)/d*push; }
}
});
};
const checkLoS = (x1, y1, x2, y2) => {
const steps = Math.max(1, Math.ceil(dist(x1,y1,x2,y2) / (TS/2))); const dx=(x2-x1)/steps, dy=(y2-y1)/steps;
for(let i=1; i {
if (engine.mode === 'gem_grab') {
if (ent.val >= 10) return {x: ent.team===0?TS*2:W-TS*2, y: H/2};
let closestGem = null, d = 9999; engine.gems.forEach(g => { let dt = dist(ent.x,ent.y,g.x,g.y); if(dt s.team !== ent.team);
if (['brawl_ball', 'basket_brawl', 'pinball'].includes(engine.mode)) {
if (engine.ball) { if (engine.ball.owner === ent.id) return {x: ent.team===0?W-TS*2:TS*2, y: H/2}; return engine.ball; }
}
if (['hot_zone', 'king_hill', 'paint_map', 'tug_of_war', 'prop_hunt'].includes(engine.mode)) { if (engine.zones.length > 0) return engine.zones[0]; }
if (['coin_rush', 'star_collector'].includes(engine.mode)) {
let closestCoin = null, d = 9999; engine.gems.forEach(g => { let dt = dist(ent.x,ent.y,g.x,g.y); if(dtf.team===ent.team); const enFlag = engine.flags.find(f=>f.team!==ent.team);
if (enFlag && enFlag.owner === ent.id) return {x: myFlag.ox, y: myFlag.oy};
if (enFlag && !enFlag.owner) return enFlag;
}
if (engine.mode === 'vip') { const ev = Object.values(engine.ents).find(e=>e.team!==ent.team && e.val===10); if(ev) return ev; }
if (engine.mode === 'boss_fight') return engine.ents['boss_ent'];
if (engine.mode === 'payload') return engine.items.find(i=>i.type==='payload');
if (engine.mode === 'golden_snitch') return engine.items.find(i=>i.type==='snitch');
if (engine.mode === 'hot_potato' && ent.val!==100) return {x: W/2, y:H/2};
if (['dodge_laser', 'meteor_shower'].includes(engine.mode)) return {x: W/2 + (Math.random()-0.5)*W*0.8, y: H/2 + (Math.random()-0.5)*H*0.8};
return null;
};
const getAutoAimAngle = (ent, isSup, sInp) => {
let tg=null, minD=9999;
let rng = isSup ? (ent.b.sup?.rng||500) : (ent.b.atk?.rng||400);
if (engine.mode==='sniper_only') rng *= 2; if (engine.mode==='melee_only') rng = Math.min(rng, 150);
Object.values(engine.ents).forEach(e=>{
if(e.team!==ent.team && e.hp>0 && !e.hidden && !e.isMinion && e.shT<=0 && checkLoS(ent.x,ent.y,e.x,e.y)) { const d=dist(ent.x,ent.y,e.x,e.y); if(d {
if(!isMapReady) return;
let dx=0, dy=0, ax=0, ay=0;
if(keys['KeyW']) dy-=1; if(keys['KeyS']) dy+=1; if(keys['KeyA']) dx-=1; if(keys['KeyD']) dx+=1;
if(joys.m.act) { dx=joys.m.x-joys.m.ox; dy=joys.m.y-joys.m.oy; }
const dm = dist(0,0,dx,dy); if(dm>0.01){ dx/=dm; dy/=dm; } else { dx=0; dy=0; }
const doF = input.fire, doS = input.sup, doG = input.gad, doHCSkill = input.hcSkill, doEmote = input.emote;
const cAutoAim = input.autoAim, cAutoSup = input.autoSup;
if(joys.a.act) { ax=joys.a.x-joys.a.ox; ay=joys.a.y-joys.a.oy; const am=dist(0,0,ax,ay); if(am>0.01){ax/=am;ay/=am;}else{ax=0;ay=0;} }
else if(joys.s.act) { ax=joys.s.x-joys.s.ox; ay=joys.s.y-joys.s.oy; const am=dist(0,0,ax,ay); if(am>0.01){ax/=am;ay/=am;}else{ax=0;ay=0;} }
else { ax=input.ax||0; ay=input.ay||0; }
const myEntForUI = engine.ents[myUid];
const isDeadUI = !myEntForUI || myEntForUI.hp <= 0 || myEntForUI.respawn > 0;
if (isOnline && !isHost) {
if (!isDeadUI && (Date.now() - input.last > 66 || doF || doS || doG || doHCSkill || doEmote)) {
packetId++; updateDoc(roomRef, { [`inputs.${myUid}`]: {dx, dy, ax, ay, f:doF, s:doS, g:doG, hcSkill:doHCSkill, emote:doEmote, autoAim:cAutoAim, autoSup:cAutoSup, pid: packetId } }).catch(()=>{});
input.last=Date.now(); input.fire=input.sup=input.gad=input.hcSkill=false; input.emote=null; input.autoAim=false; input.autoSup=false;
}
Object.values(cState.ents).forEach(e => { if(e.lerp<1) { e.lerp+=0.15; e.x=e.ox+(e.tx-e.ox)*e.lerp; e.y=e.oy+(e.ty-e.oy)*e.lerp; } });
Object.values(engine.ents).forEach(e => {
if(e.lastHp === undefined) e.lastHp = e.hp;
if(e.hp < e.lastHp) addText(e.x, e.y, `-${Math.floor(e.lastHp - e.hp)}`, e.hp<=0?'#fde047':'#fca5a5', e.hp<=0?1.5:1);
if(e.hp > e.lastHp && e.hp < e.maxHp) { addText(e.x, e.y, `+${Math.floor(e.hp - e.lastHp)}`, '#4ade80'); addParticles(e.x, e.y, '#4ade80', 2); }
e.lastHp = e.hp;
});
engine.floatingTexts.forEach(t => t.y -= 1.5);
for(let i=engine.floatingTexts.length-1; i>=0; i--){ engine.floatingTexts[i].life--; if(engine.floatingTexts[i].life<=0) engine.floatingTexts.splice(i,1); }
for(let i=engine.killFeed.length-1; i>=0; i--){ engine.killFeed[i].life--; if(engine.killFeed[i].life<=0) engine.killFeed.splice(i,1); }
for(let i=engine.particles.length-1; i>=0; i--){ let p=engine.particles[i]; p.x+=p.vx; p.y+=p.vy; p.vx*=0.9; p.vy*=0.9; p.life--; if(p.life<=0) engine.particles.splice(i,1); }
if (['showdown', 'lava_rising'].includes(engine.mode)) engine.gasRadius = Math.max(200, engine.gasRadius - 0.5);
if(weather.type !== 'none') { weather.particles.forEach(p => { p.y += p.s*2; if(weather.type==='wind') p.x+=p.s*3; if(p.y>window.innerHeight) p.y=0; if(p.x>window.innerWidth) p.x=0; }); }
return;
}
engine.time--;
if (['showdown', 'lava_rising'].includes(engine.mode)) { engine.gasRadius = Math.max(200, engine.gasRadius - 0.5); if(engine.time%60===0) Object.values(engine.ents).forEach(e=>{ if(e.hp>0 && dist(e.x,e.y,W/2,H/2) > engine.gasRadius) { aDmg(e, 1000, null); if(e.hp<=0) e.val=0;} }); }
if (engine.mode === 'vampire_survival' && engine.time%60===0) { Object.values(engine.ents).forEach(e=>{ if(e.hp>0) { e.hp-=50; addText(e.x,e.y,'-50','#f87171'); if(e.hp<=0) e.val=0;} }); }
if (engine.mode === 'meteor_shower' && engine.time%30===0) { const tX=Math.random()*W, tY=Math.random()*H; engine.bullets.push({o:'sys',t:99,type:'throw',x:tX,y:0,vx:0,vy:0,dmg:3000,rng:999,d:0,aoe:200,p:false,h:[],sx:tX,sy:0,tx:tX,ty:tY,pr:0,life:60,fx:'meteor'}); }
if (engine.mode === 'dodge_laser' && engine.time%40===0) { const tX=Math.random()*W, tY=Math.random()*H; engine.bullets.push({o:'sys',t:99,type:'line',x:tX,y:0,vx:0,vy:15,dmg:9999,rng:H,d:0,aoe:50,p:true,h:[],sx:tX,sy:0,tx:tX,ty:H,pr:0,life:100,fx:'light_beam'}); }
if (engine.mode === 'time_stop' && engine.time%600===0) { Object.values(engine.ents).forEach(e => { if(Math.random()<0.8) e.stunT=180; }); addText(W/2, H/2, 'ZA WARUDO!', '#fde047', 3); }
if (engine.mode === 'clone_war' && engine.time%900===0) { Object.values(engine.ents).forEach(e => { if(e.hp>0 && !e.isMinion) { const mId=`c_${e.id}_${Date.now()}`; engine.ents[mId] = {...e, id:mId, isBot:true, name:`${e.name}複製`, hp:1500, maxHp:1500, isMinion:true, val:0}; } }); }
if (engine.mode==='gem_grab' && engine.time%300===0) engine.items.forEach(i=>{if(i.type==='mine') engine.gems.push({x:i.x,y:i.y,vx:(Math.random()-0.5)*10,vy:(Math.random()-0.5)*10});});
if ((engine.mode==='coin_rush'||engine.mode==='star_collector') && Math.random()<0.03) engine.gems.push({x:Math.random()*W, y:Math.random()*H, vx:0, vy:0});
if (weather.type !== 'none') { weather.particles.forEach(p => { p.y += p.s*2; if(weather.type==='wind') p.x+=p.s*3; if(p.y>window.innerHeight) p.y=0; if(p.x>window.innerWidth) p.x=0; }); }
Object.values(engine.ents).forEach(ent => {
if(ent.respawn > 0) {
ent.respawn--;
if(ent.respawn<=0) {
ent.hp=ent.maxHp+(ent.gear==='shield'?600:0); ent.shT=180; ent.val=(['bounty','wipeout','ffa','gun_game'].includes(engine.mode))?2: (ent.val===10?10:0);
ent.x=ent.team===0?TS*3:W-TS*3; ent.y=H/2+(Math.random()-0.5)*200;
ent.hidden=false; ent.revealT=0; ent.ammo=3; ent.outOfCombat=0;
}
return;
}
const rldSpeed = (ent.b.sp==='berserk'&&ent.hp0 && ent.b.hcType==='rapid_fire' ? 32 : 16);
if(ent.rld > 0) { ent.rld -= rldSpeed; if(ent.rld<=0 && ent.ammo<3) { ent.ammo++; if(ent.ammo<3) ent.rld=ent.b.atk.rld; } }
if(ent.shT > 0) ent.shT--; if(ent.hcT > 0) ent.hcT--; if(ent.emoteT > 0) ent.emoteT--;
if(ent.stunT > 0) ent.stunT--; if(ent.slowT > 0) ent.slowT--;
ent.outOfCombat++;
if (engine.mode !== 'blood_rush' && ent.outOfCombat > 180 && ent.hp < ent.maxHp && engine.time%60===0) {
let healRate = 0.1; if (ent.b.arch === 'tank') healRate += ent.tier * (PASSIVES.tank.sc/100);
const healAmt = ent.maxHp * (ent.gear==='health'?healRate+0.05:healRate);
ent.hp = Math.min(ent.maxHp, ent.hp + healAmt); addText(ent.x, ent.y, `+${Math.floor(healAmt)}`, '#4ade80');
addParticles(ent.x, ent.y, '#4ade80', 2);
}
if (ent.b.arch === 'support' && engine.time%60===0 && ent.tier > 0) {
Object.values(engine.ents).forEach(e2 => { if(e2.team===ent.team && e2.id!==ent.id && dist(e2.x,e2.y,ent.x,ent.y)<200) { e2.hp=Math.min(e2.maxHp, e2.hp+(ent.tier*PASSIVES.support.sc)); addText(e2.x,e2.y,`+${ent.tier*PASSIVES.support.sc}`,'#4ade80'); } });
}
if (engine.mode === 'paint_map' && engine.time%5===0 && !ent.isMinion) {
const c=Math.floor(ent.x/TS), r=Math.floor(ent.y/TS);
if (getG(ent.x,ent.y)===0) { if(!engine.paintGrid) engine.paintGrid={}; engine.paintGrid[`${r},${c}`] = ent.team; }
}
if (engine.mode === 'bomb_walk' && engine.time%15===0 && !ent.isMinion) {
engine.items.push({type:'trap', x:ent.x, y:ent.y, team:ent.team, owner:ent.id, life: 120});
}
if (engine.mode === 'hot_potato' && ent.val===100) {
addParticles(ent.x, ent.y, '#ef4444', 1);
if (engine.time%300===0) { aDmg(ent, 9999, null); }
Object.values(engine.ents).forEach(e2=>{if(e2.id!==ent.id&&e2.hp>0&&dist(e2.x,e2.y,ent.x,ent.y) 0) ent.revealT--;
ent.hidden = false;
if((inBush || engine.mode==='stealth_assassins') && ent.revealT <= 0) {
ent.hidden = true;
for(let eid in engine.ents) { const e2 = engine.ents[eid]; if(e2.hp>0 && e2.team!==ent.team && dist(e2.x,e2.y,ent.x,ent.y) < 120) { ent.hidden = false; break; } }
if(engine.ball && engine.ball.owner === ent.id) ent.hidden = false;
if(engine.mode==='capture_flag' && engine.flags.find(f=>f.owner===ent.id)) ent.hidden = false;
}
let cx=0, cy=0, cf=false, cs=false, cg=false, chcSkill=false, ca=0, cEmote=null, isAutoF=false, isAutoS=false;
if(ent.stunT > 0) { cx=0; cy=0; cf=false; cs=false; cg=false; }
else if(ent.id===myUid) {
cx=dx; cy=dy; cf=doF; cs=doS; cg=doG; chcSkill=doHCSkill; cEmote=doEmote;
if(engine.mode==='gravity_shift' && engine.time%600 < 300) { cx+=(W/2-ent.x)*0.005; cy+=(H/2-ent.y)*0.005; } else if(engine.mode==='gravity_shift') { cx-=(W/2-ent.x)*0.005; cy-=(H/2-ent.y)*0.005; }
isAutoF = (!joys.a.act && !keys['Space'] && Object.keys(keys).length===0 && cAutoAim);
isAutoS = (!joys.s.act && !keys['Space'] && Object.keys(keys).length===0 && cAutoSup);
if(isAutoF) ca = getAutoAimAngle(ent, false, null);
else if(isAutoS) ca = getAutoAimAngle(ent, true, null);
else ca = Math.atan2(ay,ax);
input.fire=input.sup=input.gad=input.hcSkill=false; input.emote=null; input.autoAim=false; input.autoSup=false;
}
else if(ent.isBot) {
let tg=null, minD=9999;
let rng = ent.b.atk?.rng||400; if(engine.mode==='sniper_only') rng*=2; if(engine.mode==='melee_only') rng=Math.min(rng,150);
Object.values(engine.ents).forEach(e=>{ if(e.team!==ent.team && e.hp>0 && !e.hidden && checkLoS(ent.x,ent.y,e.x,e.y)) { const d=dist(e.x,e.y,ent.x,ent.y); if(d 300) { ca = Math.atan2(mTg.y-ent.y, mTg.x-ent.x); cx = Math.cos(ca); cy = Math.sin(ca); }
else if(tg) {
ca=Math.atan2(tg.y-ent.y, tg.x-ent.x);
if(minD > rng*0.8){ cx=Math.cos(ca); cy=Math.sin(ca); }
else if(minD < rng*0.4){ cx=-Math.cos(ca); cy=-Math.sin(ca); }
else if (engine.mode==='hot_potato' && ent.val===100) { cx=Math.cos(ca); cy=Math.sin(ca); }
}
}
if (ent.evadeT > 0) { ent.evadeT--; cx = ent.evadeDx; cy = ent.evadeDy; }
else {
if((cx!==0 || cy!==0) && getG(ent.x + cx*TS*0.8, ent.y + cy*TS*0.8) === 1) {
ent.evadeT = 40 + Math.random()*30;
const sign = Math.random() < 0.5 ? 1 : -1;
ent.evadeDx = -cy * sign; ent.evadeDy = cx * sign; // 90度轉彎
if(getG(ent.x + ent.evadeDx*TS*0.8, ent.y + ent.evadeDy*TS*0.8) === 1) {
ent.evadeDx = -ent.evadeDx; ent.evadeDy = -ent.evadeDy; // 換邊轉
if(getG(ent.x + ent.evadeDx*TS*0.8, ent.y + ent.evadeDy*TS*0.8) === 1) { ent.evadeDx = -cx; ent.evadeDy = -cy; } // 兩邊都牆則後退
}
cx = ent.evadeDx; cy = ent.evadeDy;
}
}
if (ent.gadCD<=0 && engine.mode !== 'dodge_laser') {
const gt = ent.gear;
if(gt && gt.includes('heal') && ent.hp300 && tg && Math.random()<0.05) cg=true;
if(gt && gt.includes('summon') && tg && Math.random()<0.05) cg=true;
if(gt && gt.includes('ammo') && ent.ammo===0 && tg) cg=true;
}
if(ent.hcC>=2500 && tg && Math.random()<0.05 && engine.mode!=='dodge_laser') chcSkill=true;
if(ent.supC>=1000&&tg&&minD<(ent.b.sup?.rng||500)&&Math.random()<0.1 && engine.mode!=='dodge_laser')cs=true;
else if(tg && minD0 && engine.mode!=='dodge_laser')cf=true;
}
else if(sInputs[ent.id]) {
const sInp=sInputs[ent.id]; cx=sInp.dx; cy=sInp.dy; cf=sInp.f; cs=sInp.s; cg=sInp.g; chcSkill=sInp.hcSkill; cEmote=sInp.emote;
isAutoF = sInp.autoAim && cf; isAutoS = sInp.autoSup && cs;
if(isAutoF) ca = getAutoAimAngle(ent, false, sInp);
else if(isAutoS) ca = getAutoAimAngle(ent, true, sInp);
else ca = Math.atan2(sInp.ay, sInp.ax);
sInputs[ent.id]={};
}
if(cEmote) { ent.emote = cEmote; ent.emoteT = 120; }
if(cf || cs || chcSkill || cg) { ent.revealT = 120; ent.outOfCombat = 0; }
if(ent.hp < ent.maxHp && (cf || cs)) ent.outOfCombat = 0;
if(!ent.qS) ent.qS = null;
if(chcSkill && ent.hcC >= 2500) {
ent.hcC = 0; ent.hcT = 300; playSfx('shoot');
for(let i=0; i<12; i++) {
const ba = (Math.PI*2/12)*i;
engine.bullets.push({o:ent.id,t:ent.team,type:'line',x:ent.x,y:ent.y,vx:Math.cos(ba)*20,vy:Math.sin(ba)*20,dmg:1000,rng:600,d:0,aoe:50,p:true,h:[],sx:ent.x,sy:ent.y,tx:ent.x+Math.cos(ba)*600,ty:ent.y+Math.sin(ba)*600,pr:0,life:60,fx:'heavy_bullet'});
}
if (ent.b.hcType === 'summon_beast') {
const minId = `m_${ent.id}_hc_${Date.now()}`;
engine.ents[minId] = { id: minId, isBot: true, team: ent.team, name: '狂暴巨獸', x: ent.x+50, y: ent.y, rad: 25, hp: 8000, maxHp: 8000, spd: 4.5, color: '#ef4444', ammo: 1, rld: 0, respawn: Infinity, supC:0, shT:0, val:0, hcC:0, hcT:0, gear:'damage', b: { hp:8000, spd:4.5, hex:ent.color, atk: { dmg: 800, rng: 200, rld: 800, type: 'melee', pel: 1, spr: 0, fx:'slash' } }, isMinion: true, owner: ent.id, evadeT:0, evadeDx:0, evadeDy:0, revealT:0, hidden:false, stunT:0, slowT:0, gadCD:0, outOfCombat:0 };
}
if(ent.b.sup?.breakWall) { const c=Math.floor(ent.x/TS), r=Math.floor(ent.y/TS); for(let i=r-3;i<=r+3;i++)for(let j=c-3;j<=c+3;j++) if(engine.grid[i]&&engine.grid[i][j]===1) engine.grid[i][j]=0; }
}
if(cs && ent.supC>=1000 && engine.mode !== 'dodge_laser') {
if(isAutoS && !ent.isBot) ent.qS = { t:8, a:ca, isSup:true };
else { fireAtk(ent, ca, true); ent.supC=0; playSfx('shoot'); }
}
else if(cf && ent.ammo>0 && !ent.isMinion && engine.mode !== 'dodge_laser') {
if(isAutoF && !ent.isBot) ent.qS = { t:8, a:ca, isSup:false };
else { fireAtk(ent, ca, false); ent.ammo--; ent.rld=ent.b.atk.rld; playSfx('shoot'); }
}
else if(cf && ent.isMinion && ent.rld<=0 && ent.b.atk) {
fireAtk(ent, ca, false); ent.rld=ent.b.atk.rld;
}
if(ent.qS) {
ent.qS.t--;
if(ent.qS.t <= 0) {
if(ent.qS.isSup) { fireAtk(ent, ent.qS.a, true); ent.supC=0; playSfx('shoot'); }
else { fireAtk(ent, ent.qS.a, false); ent.ammo--; ent.rld=ent.b.atk.rld; playSfx('shoot'); }
ent.qS = null;
}
}
let curSpd = ent.spd;
if(ent.slowT > 0) curSpd *= 0.5;
if(ent.gear === 'speed' && inBush) curSpd *= 1.15;
if(ent.hcT > 0) curSpd *= (ent.b.hcType==='speed_demon' ? 1.5 : 1.25);
if(ent.b.arch==='ninja') curSpd *= (1+ent.tier*(PASSIVES.ninja.sc/100));
if(cf && ent.ammo>0) curSpd *= 0.7;
if (engine.mode === 'ice_rink') {
ent.vx = (ent.vx||0)*0.98 + (cx*curSpd)*0.05; ent.vy = (ent.vy||0)*0.98 + (cy*curSpd)*0.05;
ent.x += ent.vx; ent.y += ent.vy;
if(ent.vx!==0||ent.vy!==0) ent.la = Math.atan2(ent.vy, ent.vx);
} else {
if(cx!==0||cy!==0) { const m=dist(0,0,cx,cy); ent.x+=(cx/m)*curSpd; ent.y+=(cy/m)*curSpd; ent.la = Math.atan2(cy, cx); if(inBush && engine.time%5===0) addParticles(ent.x,ent.y,'#22c55e',1); }
}
rsCol(ent); softCol(ent);
let hasBall = (['brawl_ball','basket_brawl'].includes(engine.mode)) && engine.ball && engine.ball.owner===ent.id;
if(hasBall) {
engine.ball.x = ent.x; engine.ball.y = ent.y;
if(cf) { engine.ball.owner=null; engine.ball.vx=Math.cos(ca)*25; engine.ball.vy=Math.sin(ca)*25; if(engine.mode==='basket_brawl') engine.ball.vz=15; ent.ammo--; playSfx('shoot'); }
} else {
if(cg && ent.gadCD<=0 && engine.mode !== 'dodge_laser') {
const gDef = GEARS.find(g=>g.id===ent.gear) || GEARS[0];
let baseCD = gDef.cd || 600;
if (ent.b.arch === 'ultimate') baseCD *= (1 - (ent.tier * (PASSIVES.ultimate.sc2/100)));
ent.maxGadCD = baseCD; ent.gadCD = baseCD;
const gt = gDef.fx || 'heal';
playSfx('ui');
if(gt.includes('heal')||gt.includes('cleanse')){ ent.hp=Math.min(ent.maxHp+(ent.gear==='shield'?600:0),ent.hp+1500); addText(ent.x,ent.y,'+1500','#4ade80'); }
if(gt.includes('dash')||gt.includes('jump')){ ent.x+=Math.cos(ca)*250; ent.y+=Math.sin(ca)*250; rsCol(ent); addParticles(ent.x,ent.y,ent.color,10); }
if(gt.includes('shield')) ent.shT=120;
if(gt.includes('push')) Object.values(engine.ents).forEach(e2=>{if(e2.hp>0&&e2.team!==ent.team&&dist(e2.x,e2.y,ent.x,ent.y)<200){e2.x+=Math.cos(Math.atan2(e2.y-ent.y,e2.x-ent.x))*150;e2.y+=Math.sin(Math.atan2(e2.y-ent.y,e2.x-ent.x))*150;aDmg(e2,500,ent);rsCol(e2);}});
if(gt.includes('vision')) Object.values(engine.ents).forEach(e2=>{if(e2.team!==ent.team) e2.revealT = 240;});
if(gt.includes('slow')) Object.values(engine.ents).forEach(e2=>{if(e2.hp>0&&e2.team!==ent.team&&dist(e2.x,e2.y,ent.x,ent.y)<250) e2.slowT = 180;});
if(gt.includes('rage')){ ent.hp-=500; ent.hcT=180; addText(ent.x,ent.y,'-500','#fca5a5'); }
if(gt.includes('tp')){ ent.x=ent.team===0?TS*3:W-TS*3; ent.y=H/2; }
if(gt.includes('summon')){ const tId = `t_${ent.id}_${Date.now()}`; engine.ents[tId] = { id: tId, isBot: true, team: ent.team, name: '砲台', x: ent.x, y: ent.y, rad: 15, hp: 2000, maxHp: 2000, spd: 0, color: '#94a3b8', ammo: 3, rld: 0, respawn: Infinity, supC:0, shT:0, val:0, hcC:0, hcT:0, gear:'damage', b: { hp:2000, spd:0, hex:'#94a3b8', atk: { dmg: 300, rng: 400, rld: 1000, type: 'line', pel: 1, spr: 0, fx:'bullet' } }, isMinion: true, owner: ent.id, evadeT:0, evadeDx:0, evadeDy:0, revealT:0, hidden:false, stunT:0, slowT:0, gadCD:0, outOfCombat:0 }; }
if(gt.includes('mine')) engine.items.push({type:'trap', x:ent.x, y:ent.y, team:ent.team, owner:ent.id});
if(gt.includes('speed')) ent.hcT = 180;
if(gt.includes('stealth')) { ent.revealT=-180; ent.hidden=true; }
if(gt.includes('pull')) { let tg=null, maxD=0; Object.values(engine.ents).forEach(e2=>{if(e2.team!==ent.team&&e2.hp>0){let d=dist(e2.x,e2.y,ent.x,ent.y); if(d<400&&d>maxD&&checkLoS(ent.x,ent.y,e2.x,e2.y)){maxD=d;tg=e2;}}}); if(tg){tg.x=ent.x+Math.cos(ca)*50; tg.y=ent.y+Math.sin(ca)*50;} }
if(gt.includes('drain')) Object.values(engine.ents).forEach(e2=>{if(e2.team!==ent.team&&dist(e2.x,e2.y,ent.x,ent.y)<300){e2.ammo=0; ent.ammo=3;}});
if(gt.includes('stun') || gt.includes('silence')) Object.values(engine.ents).forEach(e2=>{if(e2.team!==ent.team&&dist(e2.x,e2.y,ent.x,ent.y)<300) e2.stunT = 120;});
if(gt.includes('boom')) { aDmg(ent, 500, ent); Object.values(engine.ents).forEach(e2=>{if(e2.team!==ent.team&&dist(e2.x,e2.y,ent.x,ent.y)<200) aDmg(e2,1500,ent);}); addParticles(ent.x,ent.y,'#ef4444',30); }
if(gt.includes('shoot')) for(let i=0;i<8;i++){ const ba=(Math.PI*2/8)*i; engine.bullets.push({o:ent.id,t:ent.team,type:'line',x:ent.x,y:ent.y,vx:Math.cos(ba)*15,vy:Math.sin(ba)*15,dmg:500,rng:400,d:0,aoe:50,p:false,h:[],sx:ent.x,sy:ent.y,tx:ent.x+Math.cos(ba)*400,ty:ent.y+Math.sin(ba)*400,pr:0,life:40,fx:'bullet'}); }
}
}
});
function fireAtk(ent, a, isSup) {
let atk = isSup ? ent.b.sup : ent.b.atk; if(!atk) return;
if(atk.breakWall && isSup) { const c=Math.floor(ent.x/TS), r=Math.floor(ent.y/TS); for(let i=r-2;i<=r+2;i++)for(let j=c-2;j<=c+2;j++) if(engine.grid[i]&&engine.grid[i][j]===1) engine.grid[i][j]=0; }
if(['summon','summon_mech','summon_clones'].includes(atk.type)) {
const cnt = atk.count || 1;
for(let c=0; c 0 && ent.b.hcType === 'summon_beast';
let mHp = (atk.hp || 3000) * (isHc?2:1); if(ent.b.arch==='summoner') mHp *= (1+ent.tier*(PASSIVES.summoner.sc/100));
engine.ents[minId] = { id: minId, isBot: true, team: ent.team, name: '召喚怪', x: ent.x+Math.cos(a+(c*0.5))*50, y: ent.y+Math.sin(a+(c*0.5))*50, rad: isHc?25:15, hp: mHp, maxHp: mHp, spd: isHc?4.5:3.5, color: ent.color, ammo: 1, rld: 0, respawn: Infinity, supC:0, shT:0, val:0, hcC:0, hcT:0, gear:'damage', b: { hp:mHp, spd:isHc?4.5:3.5, hex:ent.color, atk: { dmg: isHc?800:400, rng: 150, rld: 800, type: 'melee', pel: 1, spr: 0, fx:'slash' } }, isMinion: true, owner: ent.id, evadeT:0, evadeDx:0, evadeDy:0, revealT:0, hidden:false, stunT:0, slowT:0, gadCD:0, outOfCombat:0 };
}
return;
}
let curDmg = atk.dmg; let curRng = atk.rng; let curPel = atk.pel || 1; let curPierce = atk.pierce; let curAoe = atk.aoe || 50;
if(ent.gear === 'damage' && ent.hp < ent.maxHp * 0.5) curDmg *= 1.15;
if(ent.b.arch==='magic') ent.supC += 20 * (1+ent.tier*(PASSIVES.magic.sc/100));
if(ent.b.arch==='elemental' && Math.random() < ent.tier*(PASSIVES.elemental.sc/100)) atk.stun = true;
if(ent.b.arch==='sniper') curRng *= (1+ent.tier*(PASSIVES.sniper.sc/100));
if(ent.b.arch==='thrower') curAoe *= (1+ent.tier*(PASSIVES.thrower.sc/100));
if(engine.mode==='sniper_only') curRng *= 2;
if(engine.mode==='melee_only') curRng = Math.min(curRng, 150);
if(ent.hcT > 0) {
curDmg *= 1.25;
if(isSup) {
if(ent.b.hcType === 'pierce') curPierce = true;
if(ent.b.hcType === 'spread_x2') curPel *= 2;
if(ent.b.hcType === 'range_boost') curRng *= 1.5;
if(ent.b.hcType === 'mega_dmg') curDmg *= 2;
if(ent.b.hcType === 'aoe_expand') curAoe *= 1.5;
}
}
const spr=atk.spr||0, sA=a-spr/2, st=curPel>1?spr/(curPel-1):0;
if(['dash','melee_dash'].includes(atk.type)){ent.x+=Math.cos(a)*150;ent.y+=Math.sin(a)*150;rsCol(ent);}
else if(['heal_aura','tsunami_heal'].includes(atk.type)) Object.values(engine.ents).forEach(e=>{if(e.team===ent.team&&dist(e.x,e.y,ent.x,ent.y) engine.objLimit) engine.bullets.shift();
engine.floatingTexts.forEach(t => t.y -= 1.5);
for(let i=engine.floatingTexts.length-1; i>=0; i--){ engine.floatingTexts[i].life--; if(engine.floatingTexts[i].life<=0) engine.floatingTexts.splice(i,1); }
for(let i=engine.killFeed.length-1; i>=0; i--){ engine.killFeed[i].life--; if(engine.killFeed[i].life<=0) engine.killFeed.splice(i,1); }
for(let i=engine.particles.length-1; i>=0; i--){ let p=engine.particles[i]; p.x+=p.vx; p.y+=p.vy; p.vx*=0.9; p.vy*=0.9; p.life--; if(p.life<=0) engine.particles.splice(i,1); }
for (let i=engine.bullets.length-1; i>=0; i--) {
const b = engine.bullets[i]; b.life--; if(b.life<=0){engine.bullets.splice(i,1);continue;}
if(['throw','throw_puddle','meteor','shockwave'].includes(b.type)) { b.pr+=0.04; if(b.pr>=1){ Object.values(engine.ents).forEach(e=>{if(e.hp>0&&e.team!==b.t&&dist(e.x,e.y,b.tx,b.ty)b.rng || (!b.ignoreWall && getG(b.x,b.y)===1)){ addParticles(b.x,b.y,'#94a3b8',3); hitW=true; break; }
}
if(hitW){ engine.bullets.splice(i,1); continue; }
let hit=false;
for(let eid in engine.ents){const e=engine.ents[eid]; if(e.hp>0&&e.team!==b.t&&!b.h.includes(eid)&&dist(e.x,e.y,b.x,b.y)0) { b.bounce--; b.h.push(eid); let nTg=null, nD=999; Object.values(engine.ents).forEach(e2=>{if(e2.hp>0&&e2.team!==b.t&&!b.h.includes(e2.id)){let dt=dist(e2.x,e2.y,e.x,e.y);if(dt<300&&dt0&&b.t!==99&&dist(engine.ents['boss_ent'].x,engine.ents['boss_ent'].y,b.x,b.y)<50){ aDmg(engine.ents['boss_ent'], b.dmg, engine.ents[b.o]); hit=true; break; }
if(hit)engine.bullets.splice(i,1);
}
function aDmg(tg, dmg, att) {
if(tg.shT>0) return;
if(tg.gear==='reflect' && tg.gadU>0) { tg.gadU--; if(att) aDmg(att, dmg, tg); return; }
let fd = dmg;
if(tg.hcT>0 && tg.b.hcType==='shield_wall') fd*=0.5; else if(tg.hcT>0) fd*=0.75;
if(tg.b.sp==='tough'&&tg.hpb.id === attEnt.b.id); let nxtB = HEROES[(curIdx+1)%HEROES.length];
attEnt.b = JSON.parse(JSON.stringify(nxtB)); attEnt.name = nxtB.name; attEnt.color = nxtB.hex; attEnt.hp = nxtB.hp; attEnt.maxHp = nxtB.hp;
addText(attEnt.x, attEnt.y, 'EVOLVE!', '#facc15', 2);
}
if (engine.mode === 'time_attack' && attEnt && !attEnt.isMinion) attEnt.timeLife = Math.min(15, (attEnt.timeLife||15) + 5);
}
if(tg.hp<=0 && tg.isMinion && tg.id!=='boss_ent') delete engine.ents[tg.id];
if(att && !att.isMinion) {
if(att.id===myUid){engine.stats.myD+=fd; if(tg.hp<=0 && tg.id!=='boss_ent')engine.stats.myK++;}
att.supC=Math.min(1000,att.supC+fd); att.hcC=Math.min(2500,att.hcC+fd);
if(att.b.sp==='vampire') { att.hp=Math.min(att.maxHp+(att.gear==='shield'?600:0),att.hp+fd*0.2); addText(att.x,att.y,`+${Math.floor(fd*0.2)}`,'#4ade80'); }
if(engine.mode==='boss_fight' && tg.id==='boss_ent') { if(att.team===0)engine.stats.t0+=fd; else engine.stats.t1+=fd; }
}
}
for(let i=engine.items.length-1; i>=0; i--) {
let it = engine.items[i];
if(it.type==='trap') {
let triggered=false; Object.values(engine.ents).forEach(e=>{if(e.hp>0&&e.team!==it.team&&dist(e.x,e.y,it.x,it.y)<60){ aDmg(e,1500,engine.ents[it.owner]); addParticles(it.x,it.y,'#ef4444',20); triggered=true; }});
if(triggered) engine.items.splice(i,1);
}
if(it.type==='payload') {
let t0C=0, t1C=0; Object.values(engine.ents).forEach(e=>{if(e.hp>0&&!e.isMinion&&dist(e.x,e.y,it.x,it.y)<150){if(e.team===0)t0C++;else t1C++;}});
if(t0C>t1C) it.x += 1; else if(t1C>t0C) it.x -= 1;
it.x = Math.max(TS*2, Math.min(W-TS*2, it.x));
engine.stats.t0 = Math.floor((it.x / W)*100); engine.stats.t1 = 100 - engine.stats.t0;
if(it.x >= W-TS*3) endGame(0); if(it.x <= TS*3) endGame(1);
}
if(it.type==='snitch') {
it.x+=it.vx; it.y+=it.vy; if(it.xW-TS)it.vx*=-1; if(it.yH-TS)it.vy*=-1;
Object.values(engine.ents).forEach(e=>{if(e.hp>0&&!e.isMinion&&dist(e.x,e.y,it.x,it.y)<40){ if(e.team===0)engine.stats.t0+=10; else engine.stats.t1+=10; engine.items.splice(i,1); }});
}
if(it.type==='portal' && engine.time%30===0) {
Object.values(engine.ents).forEach(e=>{if(e.hp>0&&!e.isMinion&&dist(e.x,e.y,it.x,it.y)<30){ const ports=engine.items.filter(p=>p.type==='portal'&&p!==it); if(ports.length>0){const rp=ports[Math.floor(Math.random()*ports.length)]; e.x=rp.x; e.y=rp.y; addParticles(e.x,e.y,'#a855f7',10);} }});
engine.bullets.forEach(b=>{if(dist(b.x,b.y,it.x,it.y)<30){ const ports=engine.items.filter(p=>p.type==='portal'&&p!==it); if(ports.length>0){const rp=ports[Math.floor(Math.random()*ports.length)]; b.x=rp.x; b.y=rp.y; b.sx=b.x; b.sy=b.y; b.tx=b.x+Math.cos(Math.atan2(b.vy,b.vx))*b.rng; b.ty=b.y+Math.sin(Math.atan2(b.vy,b.vx))*b.rng; addParticles(b.x,b.y,'#a855f7',10);} }});
}
}
Object.values(engine.ents).forEach(e => {
if(e.hp<=0 && e.respawn===0 && !e.isMinion) {
e.respawn = (MODES.find(m=>m.id===engine.mode)?.canRespawn) ? 180 : Infinity;
if(['bounty','wipeout','ffa','gun_game','shadow_realm','time_attack','bomb_walk'].includes(engine.mode)) { if(e.team===0) engine.stats.t1+=e.val; else engine.stats.t0+=e.val; engine.stars.push({x:e.x, y:e.y, center:false}); }
if(engine.mode==='vip' && e.val===10) { if(e.team===0) engine.stats.t1+=1; else engine.stats.t0+=1; }
if((engine.mode==='brawl_ball' || engine.mode==='basket_brawl') && engine.ball && engine.ball.owner===e.id) engine.ball.owner=null;
if(e.val && engine.mode==='gem_grab') { for(let i=0;if.owner===e.id); if(mf) mf.owner=null; }
if(['bounty','wipeout','ffa','gun_game','shadow_realm','time_attack','bomb_walk'].includes(engine.mode)) e.val = 2;
}
});
if (engine.mode==='gem_grab') {
engine.stats.t0 = engine.stats.t1 = 0; Object.values(engine.ents).forEach(e=>{if(e.hp>0 && !e.isMinion){if(e.team===0)engine.stats.t0+=e.val||0; else engine.stats.t1+=e.val||0;}});
for(let i=engine.gems.length-1; i>=0; i--) { let g=engine.gems[i]; g.x+=g.vx; g.y+=g.vy; g.vx*=0.9; g.vy*=0.9; if(getG(g.x,g.y)===1){g.vx*=-1;g.vy*=-1;}
for(let eid in engine.ents){const e=engine.ents[eid]; if(e.hp>0&&!e.isMinion&&dist(e.x,e.y,g.x,g.y)=10 || engine.stats.t1>=10) { if(!engine.cd) engine.cd=15*60; engine.cd--; if(engine.cd<=0) endGame(engine.stats.t0>engine.stats.t1?0:1); } else engine.cd=null;
}
else if (['bounty', 'wipeout', 'shadow_realm'].includes(engine.mode)) {
for(let i=engine.stars.length-1; i>=0; i--) {
let s = engine.stars[i];
for(let eid in engine.ents){const e=engine.ents[eid]; if(e.hp>0&&!e.isMinion&&dist(e.x,e.y,s.x,s.y)=10 || engine.stats.t1>=10)) endGame(engine.stats.t0>=10?0:1);
}
else if (['brawl_ball', 'basket_brawl', 'pinball'].includes(engine.mode)) {
if(engine.ball && !engine.ball.owner) {
engine.ball.x+=engine.ball.vx; engine.ball.y+=engine.ball.vy; engine.ball.vx*=0.95; engine.ball.vy*=0.95;
if(engine.mode==='basket_brawl' && engine.ball.z>0) { engine.ball.z+=engine.ball.vz; engine.ball.vz-=1; if(engine.ball.z<=0){engine.ball.z=0; engine.ball.vz*=-0.6;} }
if(getG(engine.ball.x,engine.ball.y)===1){engine.ball.vx*=-1;engine.ball.vy*=-1;}
engine.ball.x = Math.max(10, Math.min(W-10, engine.ball.x)); engine.ball.y = Math.max(10, Math.min(H-10, engine.ball.y));
if(engine.mode==='pinball' && dist(0,0,engine.ball.vx,engine.ball.vy)>5) {
Object.values(engine.ents).forEach(e=>{if(e.hp>0&&!e.isMinion&&dist(e.x,e.y,engine.ball.x,engine.ball.y) W-TS && engine.ball.z<50) { engine.stats.t0++; resetBall(); }
if(engine.ball.z<=10 && dist(0,0,engine.ball.vx,engine.ball.vy)<5) { for(let eid in engine.ents){const e=engine.ents[eid];if(e.hp>0&&!e.isMinion&&dist(e.x,e.y,engine.ball.x,engine.ball.y)=2) endGame(0); if(engine.stats.t1>=2) endGame(1);
}
else if (['heist', 'tower_defense', 'zombie_survival', 'troll_def'].includes(engine.mode)) { if(engine.safes[0].hp<=0) endGame(1); if(engine.safes[1]?.hp<=0) endGame(0); }
else if (['hot_zone', 'king_hill', 'tug_of_war', 'paint_map', 'prop_hunt'].includes(engine.mode)) {
engine.zones.forEach(z => {
if(z.type==='heal') { z.life--; if(z.life<=0) return; Object.values(engine.ents).forEach(e => { if(e.hp>0&&e.team===z.team&&dist(e.x,e.y,z.x,z.y) { if(e.hp>0&&!e.isMinion&&dist(e.x,e.y,z.x,z.y)t1 && z.p0<100) z.p0+=0.1; else if(t1>t0 && z.p1<100) z.p1+=0.1;
engine.stats.t0 = Math.floor(z.p0); engine.stats.t1 = Math.floor(z.p1);
}
});
engine.zones = engine.zones.filter(z => z.type!=='heal' || z.life>0);
if(engine.stats.t0>=100) endGame(0); if(engine.stats.t1>=100) endGame(1);
}
else if (['coin_rush', 'star_collector'].includes(engine.mode)) {
for(let i=engine.gems.length-1; i>=0; i--) {
let g = engine.gems[i]; for(let eid in engine.ents){const e=engine.ents[eid]; if(e.hp>0&&!e.isMinion&&dist(e.x,e.y,g.x,g.y)=30) endGame(0); if(engine.stats.t1>=30) endGame(1);
}
else if (engine.mode==='paint_map') {
let p0=0, p1=0;
if(engine.paintGrid) Object.values(engine.paintGrid).forEach(v=>{if(v===0)p0++;else p1++;});
engine.stats.t0 = p0; engine.stats.t1 = p1;
}
else if (['knockout', 'zombie', 'juggernaut', 'boss_raid'].includes(engine.mode)) {
let t0Alive=0, t1Alive=0; Object.values(engine.ents).forEach(e=>{if(e.hp>0&&!e.isMinion){if(e.team===0)t0Alive++;else t1Alive++;}});
if(t0Alive===0) endGame(1); else if(t1Alive===0) endGame(0);
}
else if (engine.mode==='capture_flag') {
engine.flags.forEach(f => {
if(f.owner) {
const oE = engine.ents[f.owner]; if(oE) { f.x=oE.x; f.y=oE.y; }
if(oE && getG(oE.x, oE.y) === (oE.team===0?4:5)) { if(oE.team===0)engine.stats.t0++; else engine.stats.t1++; f.owner=null; f.x=f.ox; f.y=f.oy; }
} else {
Object.values(engine.ents).forEach(e=>{if(e.hp>0&&!e.isMinion&&e.team!==f.team&&dist(e.x,e.y,f.x,f.y)<20){ f.owner=e.id; }});
}
});
if(engine.stats.t0>=3) endGame(0); if(engine.stats.t1>=3) endGame(1);
}
else if (engine.mode==='vip') {
if (engine.stats.t0 >= 1) endGame(1); if (engine.stats.t1 >= 1) endGame(0);
}
else if (['ffa', 'gun_game', 'time_attack', 'bomb_walk'].includes(engine.mode)) {
for(let i=engine.stars.length-1; i>=0; i--) {
let s = engine.stars[i];
for(let eid in engine.ents){const e=engine.ents[eid]; if(e.hp>0&&!e.isMinion&&dist(e.x,e.y,s.x,s.y){if(e.val>maxK){maxK=e.val;winT=e.team;}});
if (maxK >= (engine.mode==='gun_game'?10:5)) endGame(winT);
}
else if (engine.mode==='boss_fight') {
if (engine.ents['boss_ent'] && engine.ents['boss_ent'].hp <= 0) endGame(engine.stats.t0 > engine.stats.t1 ? 0 : 1);
}
if (engine.time <= 0) {
if (engine.mode==='paint_map') { if(engine.stats.t0 > engine.stats.t1) endGame(0); else if(engine.stats.t1 > engine.stats.t0) endGame(1); else endGame(-1); }
else { if(engine.stats.t0 > engine.stats.t1) endGame(0); else if(engine.stats.t1 > engine.stats.t0) endGame(1); else endGame(-1); }
}
if (isOnline && isHost && Date.now() - lastSyncTime > 100) {
lastSyncTime = Date.now();
const packEnts = {};
Object.values(engine.ents).forEach(e => {
packEnts[e.id] = { x:Math.round(e.x), y:Math.round(e.y), hp:Math.round(e.hp), ammo:e.ammo, respawn:e.respawn, supC:Math.round(e.supC), hcC:Math.round(e.hcC), hcT:e.hcT, shT:e.shT, val:e.val, isBot:e.isBot, name:e.name, team:e.team, b:e.b, gear:e.gear, isMinion:e.isMinion, owner:e.owner, emote:e.emote, emoteT:e.emoteT, hidden:e.hidden, gadCD:e.gadCD, tier:e.tier };
});
updateDoc(roomRef, { state: { time:engine.time, stats:engine.stats, bullets:engine.bullets, gems:engine.gems, stars:engine.stars, ball:engine.ball, safes:engine.safes, items:engine.items, zones:engine.zones, flags:engine.flags, grid:engine.grid, ents:packEnts }, lastActive: Date.now() }).catch(()=>{});
}
};
const resetBall = () => { if(engine.ball) { engine.ball.x=W/2; engine.ball.y=H/2; engine.ball.vx=0; engine.ball.vy=0; engine.ball.z=0; engine.ball.vz=0; engine.ball.owner=null; } Object.values(engine.ents).forEach(e=>{ if(!e.isMinion) {e.x=e.team===0?TS*3:W-TS*3;e.y=H/2+(Math.random()-0.5)*200;}}); };
const endGame = async (winTeam) => {
const myE = engine.ents[myUid]; const isWin = winTeam === (myE?myE.team:0); const res = { result: isWin?'Victory':'Defeat', stats: engine.stats };
if (isWin) playSfx('win'); else playSfx('lose');
if (isOnline && isHost) await updateDoc(roomRef, { status: 'finished', result: res }); if (!isOnline) { if(!hasExited.current) stateRef.current.onEnd(res); }
};
const draw = () => {
if(!isMapReady) return;
const dpr = window.devicePixelRatio || 1; canvas.width = window.innerWidth * dpr; canvas.height = window.innerHeight * dpr;
canvas.style.width = `${window.innerWidth}px`; canvas.style.height = `${window.innerHeight}px`; ctx.scale(dpr, dpr);
const myE = engine.ents[myUid]; const myTeam = myE ? myE.team : 0;
const isDeadUI = !myE || myE.hp <= 0 || myE.respawn > 0;
let camTarget = myE;
if (isDeadUI) {
const aliveTeam = Object.values(engine.ents).find(e => e.team === myTeam && e.hp > 0 && !e.isMinion);
if (aliveTeam) camTarget = aliveTeam; else camTarget = {x:W/2, y:H/2};
}
if(camTarget) { cam.x += (camTarget.x - window.innerWidth/2 - cam.x)*0.1; cam.y += (camTarget.y - window.innerHeight/2 - cam.y)*0.1; }
ctx.fillStyle = engine.time < 30*60 ? '#0f172a' : '#1e1b4b';
ctx.fillRect(0,0,window.innerWidth,window.innerHeight);
ctx.save();
ctx.translate(-cam.x, -cam.y);
ctx.fillStyle = ['showdown','lava_rising'].includes(engine.mode)?'#92400e':(engine.mode==='ice_rink'?'#e0f2fe':'#d97736'); ctx.fillRect(0,0,W,H);
if(engine.mode==='paint_map' && engine.paintGrid) {
for(let k in engine.paintGrid) { const [r,c] = k.split(','); ctx.fillStyle=engine.paintGrid[k]===0?'rgba(59,130,246,0.3)':'rgba(239,68,68,0.3)'; ctx.fillRect(c*TS, r*TS, TS, TS); }
}
if(['showdown','lava_rising'].includes(engine.mode)) { ctx.fillStyle=engine.mode==='lava_rising'?'rgba(239,68,68,0.5)':'rgba(34,197,94,0.3)'; ctx.fillRect(0,0,W,H); ctx.globalCompositeOperation = 'destination-out'; ctx.beginPath(); ctx.arc(W/2, H/2, engine.gasRadius, 0, Math.PI*2); ctx.fill(); ctx.globalCompositeOperation = 'source-over'; }
if(['hot_zone', 'king_hill', 'tug_of_war', 'paint_map', 'prop_hunt'].includes(engine.mode)) engine.zones.forEach(z => {
if(z.type==='heal') { ctx.fillStyle='rgba(74,222,128,0.2)'; ctx.beginPath(); ctx.arc(z.x,z.y,z.rad,0,Math.PI*2); ctx.fill(); return; }
ctx.fillStyle='rgba(252,211,77,0.2)'; ctx.beginPath(); ctx.arc(z.x, z.y, z.rad, 0, Math.PI*2); ctx.fill();
ctx.strokeStyle='#3b82f6'; ctx.lineWidth=10; ctx.beginPath(); ctx.arc(z.x, z.y, z.rad-5, -Math.PI/2, -Math.PI/2 + (z.p0/100)*Math.PI*2); ctx.stroke();
ctx.strokeStyle='#ef4444'; ctx.lineWidth=10; ctx.beginPath(); ctx.arc(z.x, z.y, z.rad-15, -Math.PI/2, -Math.PI/2 + (z.p1/100)*Math.PI*2); ctx.stroke();
});
ctx.shadowColor = 'rgba(0,0,0,0.5)'; ctx.shadowBlur = 10; ctx.shadowOffsetY = 5;
for(let r=0;r{ if(i.type==='mine') { ctx.fillStyle='#1e293b'; ctx.fillRect(i.x-20, i.y-20, 40, 40); ctx.fillStyle='#a855f7'; ctx.beginPath(); ctx.arc(i.x,i.y,10,0,Math.PI*2); ctx.fill(); }});
engine.gems.forEach(g => { ctx.fillStyle=g.type==='heal_coin'?'#4ade80':(['coin_rush','bomb_walk'].includes(engine.mode)?'#eab308':'#a855f7'); ctx.beginPath(); ctx.arc(g.x,g.y,8,0,Math.PI*2); ctx.fill(); ctx.fillStyle='#fff'; ctx.globalAlpha=0.5; ctx.beginPath(); ctx.arc(g.x-2,g.y-2,3,0,Math.PI*2); ctx.fill(); ctx.globalAlpha=1; });
}
else if(['bounty', 'wipeout', 'ffa', 'gun_game', 'shadow_realm'].includes(engine.mode)) { engine.stars.forEach(s => { ctx.fillStyle=s.center?'#3b82f6':'#eab308'; ctx.beginPath(); ctx.arc(s.x,s.y,10,0,Math.PI*2); ctx.fill(); }); }
else if(['brawl_ball', 'basket_brawl', 'pinball'].includes(engine.mode)) { if(engine.ball && !engine.ball.owner){ ctx.fillStyle='rgba(0,0,0,0.5)'; ctx.beginPath(); ctx.arc(engine.ball.x,engine.ball.y,10,0,Math.PI*2); ctx.fill(); ctx.fillStyle=engine.mode==='basket_brawl'?'#f97316':'#fff'; ctx.beginPath(); ctx.arc(engine.ball.x,engine.ball.y-(engine.ball.z||0),engine.mode==='pinball'?25:12,0,Math.PI*2); ctx.fill(); ctx.strokeStyle='#000'; ctx.stroke(); } }
else if(['heist', 'tower_defense', 'zombie_survival', 'troll_def'].includes(engine.mode)) { engine.safes.forEach(s => { ctx.fillStyle=s.team===myTeam?'#3b82f6':'#ef4444'; ctx.fillRect(s.x-30,s.y-30,60,60); ctx.fillStyle='#fff'; ctx.font='bold 16px Arial'; ctx.fillText(Math.floor(s.hp/s.max*100)+'%', s.x-15, s.y); }); }
else if(engine.mode==='capture_flag') { engine.flags.forEach(f => { if(!f.owner) { ctx.fillStyle=f.team===myTeam?'#3b82f6':'#ef4444'; ctx.fillRect(f.x-10,f.y-10,20,20); ctx.fillStyle='#fff'; ctx.font='bold 14px Arial'; ctx.fillText('🚩', f.x-5, f.y+5); } }); }
engine.items.forEach(it=>{
if(it.type==='trap' && (it.team===myTeam || !myE)) { ctx.fillStyle=it.team===myTeam?'#60a5fa':'#f87171'; ctx.globalAlpha=0.4; ctx.beginPath(); ctx.arc(it.x,it.y,20,0,Math.PI*2); ctx.fill(); ctx.globalAlpha=1; }
if(it.type==='payload') { ctx.fillStyle='#facc15'; ctx.fillRect(it.x-25, it.y-25, 50, 50); ctx.fillStyle='#000'; ctx.font='20px Arial'; ctx.fillText('🚂', it.x-10, it.y+5); }
if(it.type==='snitch') { ctx.fillStyle='#fde047'; ctx.beginPath(); ctx.arc(it.x,it.y, 8, 0, Math.PI*2); ctx.fill(); ctx.shadowColor='#facc15'; ctx.shadowBlur=20; ctx.stroke(); ctx.shadowColor='transparent'; }
});
engine.bullets.forEach(b => {
if (['throw','meteor','throw_puddle'].includes(b.type)) {
ctx.fillStyle = 'rgba(239,68,68,0.2)'; ctx.beginPath(); ctx.arc(b.tx, b.ty, b.aoe || 50, 0, Math.PI*2); ctx.fill();
ctx.strokeStyle = 'rgba(239,68,68,0.8)'; ctx.lineWidth = 2; ctx.stroke();
}
});
engine.particles.forEach(p => { ctx.fillStyle=p.col; ctx.globalAlpha=Math.max(0, p.life/50); ctx.beginPath(); ctx.arc(p.x,p.y,p.sz,0,Math.PI*2); ctx.fill(); ctx.globalAlpha=1; });
engine.bullets.forEach(b => {
ctx.shadowColor=b.t===myTeam?'#60a5fa':'#f87171'; ctx.shadowBlur=10;
const rx=['throw','meteor','throw_puddle'].includes(b.type)?b.sx+(b.tx-b.sx)*b.pr:b.x, ry=['throw','meteor','throw_puddle'].includes(b.type)?b.sy+(b.ty-b.sy)*b.pr-Math.sin(b.pr*Math.PI)*150:b.y;
if(['throw','meteor','throw_puddle'].includes(b.type)) {
ctx.fillStyle='rgba(0,0,0,0.3)'; ctx.beginPath(); ctx.ellipse(b.sx+(b.tx-b.sx)*b.pr, b.sy+(b.ty-b.sy)*b.pr, 10, 5, 0, 0, Math.PI*2); ctx.fill();
}
ctx.fillStyle=['throw','throw_puddle'].includes(b.type)?'#22c55e':(b.t===myTeam?'#60a5fa':'#f87171'); ctx.beginPath(); ctx.arc(rx,ry,['throw','meteor'].includes(b.type)?12:(b.fx==='heavy_bullet'?12:6),0,Math.PI*2); ctx.fill();
if(!['throw','meteor','throw_puddle'].includes(b.type)) { ctx.globalAlpha=0.3; ctx.beginPath(); ctx.moveTo(rx,ry); ctx.lineTo(rx-b.vx*3, ry-b.vy*3); ctx.lineWidth=6; ctx.strokeStyle=ctx.fillStyle; ctx.stroke(); ctx.globalAlpha=1; }
ctx.shadowColor='transparent';
});
if (['boss_fight', 'boss_raid'].includes(engine.mode) && engine.ents['boss_ent'] && engine.ents['boss_ent'].hp>0) {
const boss = engine.ents['boss_ent'];
ctx.fillStyle='#ef4444'; ctx.beginPath(); ctx.arc(boss.x, boss.y, boss.rad, 0, Math.PI*2); ctx.fill();
ctx.fillStyle='#000'; ctx.fillRect(boss.x-30, boss.y-boss.rad-20, 60, 8); ctx.fillStyle='#ef4444'; ctx.fillRect(boss.x-30, boss.y-boss.rad-20, 60*(boss.hp/boss.maxHp), 8);
ctx.fillStyle='#fff'; ctx.font='bold 12px Arial'; ctx.textAlign='center'; ctx.fillText('遠古巨龍', boss.x, boss.y-boss.rad-25);
}
Object.values(engine.ents).forEach(e => {
if(e.hp<=0 || e.respawn>0 || (e.hidden && e.team !== myTeam) || e.id==='boss_ent') return;
const isA = e.team === myTeam, cm = isA?'#3b82f6':'#ef4444';
ctx.globalAlpha = e.hidden ? 0.4 : 1.0;
if (engine.time < 30*60 || engine.mode==='shadow_realm') {
ctx.save(); ctx.globalCompositeOperation = 'lighter';
const grd = ctx.createRadialGradient(e.x, e.y, 0, e.x, e.y, engine.mode==='shadow_realm'?250:150);
grd.addColorStop(0, 'rgba(255,255,255,0.2)'); grd.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = grd; ctx.beginPath(); ctx.arc(e.x, e.y, engine.mode==='shadow_realm'?250:150, 0, Math.PI*2); ctx.fill(); ctx.restore();
}
if (e.supC >= 1000 && !e.isMinion) {
ctx.save(); ctx.translate(e.x, e.y); ctx.rotate(Date.now()/300);
ctx.strokeStyle = isA ? '#60a5fa' : '#facc15'; ctx.lineWidth = 3; ctx.setLineDash([8, 8]);
ctx.beginPath(); ctx.arc(0, 0, e.rad + 10, 0, Math.PI*2); ctx.stroke();
ctx.restore();
}
if(e.hcT > 0) { ctx.fillStyle = `rgba(168,85,247,${0.3 + Math.sin(Date.now()/50)*0.2})`; ctx.beginPath(); ctx.ellipse(e.x, e.y+e.rad, e.rad*1.8, e.rad*0.9, 0, 0, Math.PI*2); ctx.fill(); }
if(engine.mode==='prop_hunt' && e.team===0) { ctx.fillStyle='#78350f'; ctx.fillRect(e.x-e.rad, e.y-e.rad, e.rad*2, e.rad*2); }
else {
ctx.fillStyle = 'rgba(0,0,0,0.3)'; ctx.beginPath(); ctx.ellipse(e.x, e.y+e.rad*0.8, e.rad*1.2, e.rad*0.6, 0, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = isA?'rgba(59,130,246,0.2)':'rgba(239,68,68,0.2)'; ctx.beginPath(); ctx.ellipse(e.x, e.y+e.rad*0.8, e.rad*1.2, e.rad*0.6, 0, 0, Math.PI*2); ctx.fill(); ctx.strokeStyle = cm; ctx.lineWidth=2; ctx.stroke();
if(e.shT > 0) { ctx.strokeStyle='#eab308'; ctx.lineWidth=3; ctx.beginPath(); ctx.arc(e.x, e.y, e.rad+5, 0, Math.PI*2); ctx.stroke(); }
if(e.shT < 0) { ctx.strokeStyle='#ef4444'; ctx.lineWidth=2; ctx.setLineDash([5,5]); ctx.beginPath(); ctx.arc(e.x, e.y, e.rad+8, 0, Math.PI*2); ctx.stroke(); ctx.setLineDash([]); }
ctx.fillStyle='#1e293b'; ctx.beginPath(); ctx.arc(e.x, e.y, e.rad, 0, Math.PI*2); ctx.fill();
ctx.fillStyle=e.hcT>0 ? '#d8b4fe' : e.color; ctx.beginPath(); ctx.arc(e.x, e.y-8, e.rad*0.9, 0, Math.PI*2); ctx.fill(); ctx.strokeStyle=e.hcT>0?'#9333ea':'#000'; ctx.lineWidth=e.hcT>0?2:1; ctx.stroke();
}
if(e.id==='local' && engine.mode==='vip' && e.val===10) { ctx.fillStyle='#facc15'; ctx.font='20px Arial'; ctx.fillText('👑', e.x, e.y-e.rad-45); }
if(engine.mode==='hot_potato' && e.val===100) { ctx.fillStyle='#ef4444'; ctx.font='20px Arial'; ctx.fillText('💣', e.x, e.y-e.rad-45); }
const hudY = e.y - e.rad - 20;
ctx.fillStyle=isA?'#93c5fd':'#fca5a5'; ctx.font='bold 11px Arial'; ctx.textAlign='center'; ctx.fillText(`${e.name}${e.tier?` T${e.tier}`:''}`, e.x, hudY - 5);
ctx.fillStyle='#000'; ctx.fillRect(e.x-20, hudY, 40, 6);
ctx.fillStyle=(e.hp e.maxHp) { ctx.fillStyle='rgba(255,255,255,0.8)'; ctx.fillRect(e.x-20, hudY, 40*((e.hp-e.maxHp)/600), 6); }
if (!e.isMinion) {
for(let i=0; i<3; i++) {
ctx.fillStyle='rgba(0,0,0,0.5)'; ctx.fillRect(e.x-20 + i*14, hudY+8, 12, 4);
if(i 0 && e.emote) { ctx.fillStyle='#fff'; ctx.beginPath(); ctx.arc(e.x+20, e.y-e.rad-30, 15, 0, Math.PI*2); ctx.fill(); ctx.fillStyle='#000'; ctx.font='20px Arial'; ctx.fillText(e.emote, e.x+10, e.y-e.rad-23); }
if(['gem_grab', 'coin_rush', 'star_collector', 'time_attack', 'bomb_walk'].includes(engine.mode) && e.val>0) { ctx.fillStyle='#a855f7'; ctx.font='bold 14px Arial'; ctx.fillText(`💎${e.val}`, e.x, hudY - 18); }
if(['bounty', 'wipeout', 'ffa', 'gun_game'].includes(engine.mode) && !e.isMinion) { ctx.fillStyle='#eab308'; ctx.font='bold 14px Arial'; ctx.fillText(`⭐${e.val||2}`, e.x, hudY - 18); }
if(['brawl_ball', 'basket_brawl', 'pinball'].includes(engine.mode) && engine.ball?.owner===e.id) { ctx.fillStyle='#fff'; ctx.beginPath(); ctx.arc(e.x, hudY - 22, 8, 0, Math.PI*2); ctx.fill(); ctx.stroke(); }
if(engine.mode==='capture_flag' && engine.flags.find(f=>f.owner===e.id)) { ctx.fillStyle=engine.flags.find(f=>f.owner===e.id).team===0?'#3b82f6':'#ef4444'; ctx.font='bold 14px Arial'; ctx.fillText(`🚩`, e.x, hudY - 18); }
if(engine.mode==='time_attack' && e.timeLife) { ctx.fillStyle='#facc15'; ctx.font='bold 14px Arial'; ctx.fillText(`⏳${e.timeLife}s`, e.x, hudY - 18); }
ctx.globalAlpha = 1.0;
});
engine.floatingTexts.forEach(t => { ctx.fillStyle = t.col; ctx.font = `bold ${18*t.scl}px Arial`; ctx.globalAlpha = Math.max(0, t.life/40); ctx.fillText(t.txt, t.x, t.y); ctx.globalAlpha = 1.0; });
if(myE && !isDeadUI && (mouse.l||mouse.r||joys.a.act||joys.s.act||myE.qS)) {
let aA = 0, aT = null, isS = false, pR = 1, shw = false;
if (myE.qS && myE.qS.t > 0) {
shw = true; isS = myE.qS.isSup; aA = myE.qS.a; aT = isS ? myE.b.sup : myE.b.atk;
let minD = aT?.rng||400;
Object.values(engine.ents).forEach(e=>{ if(e.team!==myE.team && e.hp>0 && !e.hidden && checkLoS(myE.x,myE.y,e.x,e.y)) { const d=dist(myE.x,myE.y,e.x,e.y); if(d0 && isS && myE.b.hcType==='range_boost') drng*=1.5; if(engine.mode==='sniper_only') drng*=2; if(engine.mode==='melee_only') drng=Math.min(drng, 150);
let tX = myE.x + Math.cos(aA)*drng*pR, tY = myE.y + Math.sin(aA)*drng*pR;
let aoe = aT.aoe || 50, spr = aT.spr || 0.8, shape = 'line', wid = 20;
if (['throw','meteor','throw_puddle','summon','summon_mech','summon_clones'].includes(aT.type)) shape = 'circle_target';
else if (['heal_aura','tsunami_heal','blackhole','omni_slash','bullet_hell'].includes(aT.type)) shape = 'circle_self';
else if (['shotgun','cone','shockwave'].includes(aT.type) || (aT.spr > 0 && aT.pel > 1)) shape = 'cone';
else if (aT.type === 'laser_beam') wid = 40;
else if (aT.type === 'melee' || aT.type === 'melee_dash') wid = 60;
let hitE = false;
Object.values(engine.ents).forEach(e=>{
if(e.hp>0 && e.team!==myE.team && !e.hidden){
let dx=e.x-myE.x, dy=e.y-myE.y, d=dist(myE.x,myE.y,e.x,e.y), ea=Math.atan2(dy,dx);
if (shape === 'circle_target') { if(dist(e.x,e.y,tX,tY) <= aoe + e.rad) hitE = true; }
else if (shape === 'circle_self') { if(d <= drng + e.rad) hitE = true; }
else if (shape === 'cone') {
if(d <= drng + e.rad) { let ad = Math.abs(ea-aA); if(ad>Math.PI) ad=2*Math.PI-ad; if(ad <= spr/2 + 0.1) hitE=true; }
} else {
let along = dx*Math.cos(aA) + dy*Math.sin(aA), perp = Math.abs(-dx*Math.sin(aA) + dy*Math.cos(aA));
if(along >= 0 && along <= drng + e.rad && perp <= wid/2 + e.rad) hitE = true;
}
}
});
ctx.fillStyle = hitE ? (isS?'rgba(250,204,21,0.6)':'rgba(239,68,68,0.4)') : (isS?'rgba(255,255,255,0.3)':'rgba(255,255,255,0.2)');
ctx.strokeStyle = hitE ? (isS?'rgba(250,204,21,0.8)':'rgba(239,68,68,0.8)') : (isS?'rgba(255,255,255,0.6)':'rgba(255,255,255,0.5)');
ctx.lineWidth = 2; ctx.beginPath();
if (shape === 'circle_target') {
ctx.setLineDash([5,5]); ctx.moveTo(myE.x, myE.y); ctx.lineTo(tX, tY); ctx.stroke(); ctx.setLineDash([]);
ctx.beginPath(); ctx.arc(tX, tY, aoe, 0, Math.PI*2); ctx.fill(); ctx.stroke();
} else if (shape === 'circle_self') {
ctx.arc(myE.x, myE.y, drng, 0, Math.PI*2); ctx.fill(); ctx.stroke();
} else if (shape === 'cone') {
ctx.moveTo(myE.x, myE.y); ctx.arc(myE.x, myE.y, drng, aA - spr/2, aA + spr/2); ctx.lineTo(myE.x, myE.y); ctx.fill(); ctx.stroke();
} else {
ctx.save(); ctx.translate(myE.x, myE.y); ctx.rotate(aA);
ctx.fillRect(0, -wid/2, drng, wid); ctx.strokeRect(0, -wid/2, drng, wid);
ctx.restore();
}
}
}
ctx.restore();
if (isDeadUI) {
ctx.fillStyle = 'rgba(0,0,0,0.6)'; ctx.fillRect(0,0,window.innerWidth,window.innerHeight);
ctx.fillStyle = '#fff'; ctx.font = 'bold 64px Arial'; ctx.textAlign = 'center';
ctx.fillText(`復活倒數: ${Math.ceil((myE?.respawn||0)/60)}`, window.innerWidth/2, window.innerHeight/2);
ctx.fillStyle = '#94a3b8'; ctx.font = 'bold 24px Arial'; ctx.fillText('觀戰中...', window.innerWidth/2, window.innerHeight/2 + 50);
}
ctx.fillStyle='rgba(0,0,0,0.7)'; ctx.fillRect(window.innerWidth/2-150, 0, 300, 40); ctx.font='bold 24px Arial'; ctx.textAlign='center';
if (engine.mode==='gem_grab') { ctx.fillStyle='#3b82f6'; ctx.fillText(`💎 ${engine.stats.t0}`, window.innerWidth/2-70, 28); ctx.fillStyle='#ef4444'; ctx.fillText(`${engine.stats.t1} 💎`, window.innerWidth/2+70, 28); if (engine.cd !== null) { ctx.fillStyle='#facc15'; ctx.font='bold 60px Arial'; ctx.fillText(Math.ceil(engine.cd/60), window.innerWidth/2, 120); } }
else if (['bounty', 'wipeout', 'ffa', 'gun_game'].includes(engine.mode)) { ctx.fillStyle='#3b82f6'; ctx.fillText(`⭐ ${engine.stats.t0}`, window.innerWidth/2-70, 28); ctx.fillStyle='#ef4444'; ctx.fillText(`${engine.stats.t1} ⭐`, window.innerWidth/2+70, 28); }
else if (['brawl_ball', 'basket_brawl', 'pinball'].includes(engine.mode)) { ctx.fillStyle='#3b82f6'; ctx.fillText(`⚽ ${engine.stats.t0}`, window.innerWidth/2-70, 28); ctx.fillStyle='#ef4444'; ctx.fillText(`${engine.stats.t1} ⚽`, window.innerWidth/2+70, 28); }
else if (['heist', 'tower_defense', 'zombie_survival', 'troll_def'].includes(engine.mode)) { ctx.fillStyle='#3b82f6'; ctx.fillText(`🏦`, window.innerWidth/2-70, 28); ctx.fillStyle='#ef4444'; ctx.fillText(`🏦`, window.innerWidth/2+70, 28); }
else if (['hot_zone', 'king_hill', 'tug_of_war', 'paint_map', 'prop_hunt'].includes(engine.mode)) { ctx.fillStyle='#3b82f6'; ctx.fillText(`${engine.stats.t0}%`, window.innerWidth/2-70, 28); ctx.fillStyle='#ef4444'; ctx.fillText(`${engine.stats.t1}%`, window.innerWidth/2+70, 28); }
else if (['coin_rush', 'star_collector'].includes(engine.mode)) { ctx.fillStyle='#3b82f6'; ctx.fillText(`💰 ${engine.stats.t0}`, window.innerWidth/2-70, 28); ctx.fillStyle='#ef4444'; ctx.fillText(`${engine.stats.t1} 💰`, window.innerWidth/2+70, 28); }
else if (engine.mode==='capture_flag') { ctx.fillStyle='#3b82f6'; ctx.fillText(`🚩 ${engine.stats.t0}`, window.innerWidth/2-70, 28); ctx.fillStyle='#ef4444'; ctx.fillText(`${engine.stats.t1} 🚩`, window.innerWidth/2+70, 28); }
else if (engine.mode==='zombie') { ctx.fillStyle='#3b82f6'; ctx.fillText(`🧟`, window.innerWidth/2-70, 28); ctx.fillStyle='#ef4444'; ctx.fillText(`🧟`, window.innerWidth/2+70, 28); }
else if (['boss_fight', 'juggernaut', 'boss_raid'].includes(engine.mode)) { ctx.fillStyle='#3b82f6'; ctx.fillText(`🛡️ ${Math.floor(engine.stats.t0)}`, window.innerWidth/2-90, 28); ctx.fillStyle='#ef4444'; ctx.fillText(`${Math.floor(engine.stats.t1)} 🛡️`, window.innerWidth/2+90, 28); }
ctx.fillStyle=(engine.time<600)?'#f87171':'#fff'; ctx.font='16px Arial'; ctx.fillText(Math.floor(engine.time/60), window.innerWidth/2, 25);
engine.killFeed.forEach((k, idx) => { ctx.fillStyle = k.col; ctx.font = 'bold 14px Arial'; ctx.textAlign='right'; ctx.globalAlpha = Math.max(0, k.life/180); ctx.fillText(k.txt, window.innerWidth - 20, 30 + idx*20); ctx.globalAlpha = 1.0; });
if(isOnline) { ctx.fillStyle=ping<100?'#4ade80':ping<200?'#eab308':'#f87171'; ctx.textAlign='left'; ctx.font='bold 12px Arial'; ctx.fillText(`Ping: ${ping}ms`, 10, 20); }
if (!isDeadUI) {
if(joys.m.act){ ctx.fillStyle='rgba(255,255,255,0.2)'; ctx.beginPath(); ctx.arc(joys.m.ox, joys.m.oy, 50, 0, Math.PI*2); ctx.fill(); ctx.fillStyle='rgba(255,255,255,0.5)'; ctx.beginPath(); ctx.arc(joys.m.x, joys.m.y, 20, 0, Math.PI*2); ctx.fill(); }
const ui = getUI();
ctx.fillStyle='rgba(239,68,68,0.2)'; ctx.beginPath(); ctx.arc(ui.ax, ui.ay, ui.ar, 0, Math.PI*2); ctx.fill(); ctx.strokeStyle='rgba(239,68,68,0.5)'; ctx.lineWidth=2; ctx.stroke();
if(joys.a.act){ ctx.fillStyle='rgba(255,255,255,0.8)'; ctx.beginPath(); ctx.arc(joys.a.x, joys.a.y, 20, 0, Math.PI*2); ctx.fill(); }
ctx.fillStyle = (myE && myE.supC>=1000) ? 'rgba(250,204,21,0.5)' : 'rgba(250,204,21,0.1)'; ctx.beginPath(); ctx.arc(ui.sx, ui.sy, ui.sr, 0, Math.PI*2); ctx.fill(); ctx.strokeStyle='#facc15'; ctx.lineWidth=2; ctx.stroke();
if(joys.s.act){ ctx.fillStyle='#fff'; ctx.beginPath(); ctx.arc(joys.s.x, joys.s.y, 20, 0, Math.PI*2); ctx.fill(); }
const gadIsReady = myE && myE.gadCD<=0;
ctx.fillStyle = gadIsReady ? 'rgba(34,197,94,0.6)' : 'rgba(34,197,94,0.2)'; ctx.beginPath(); ctx.arc(ui.gx, ui.gy, ui.gr, 0, Math.PI*2); ctx.fill(); ctx.strokeStyle='#4ade80'; ctx.stroke();
if(myE && myE.gadCD>0) { ctx.fillStyle='rgba(0,0,0,0.5)'; ctx.beginPath(); ctx.moveTo(ui.gx, ui.gy); ctx.arc(ui.gx, ui.gy, ui.gr, -Math.PI/2 + (1-myE.gadCD/myE.maxGadCD)*Math.PI*2, Math.PI*1.5); ctx.fill(); }
const gDef = GEARS.find(g=>g.id===myE?.gear) || GEARS[0];
ctx.fillStyle='#fff'; ctx.font='bold 12px Arial'; ctx.textAlign='center'; ctx.fillText(gDef.name.slice(0,2), ui.gx, ui.gy+4);
const hcRatio = myE ? Math.min(1, myE.hcC / 2500) : 0;
ctx.fillStyle = myE?.hcT>0 ? 'rgba(239,68,68,0.8)' : (hcRatio >= 1 ? 'rgba(168,85,247,0.8)' : 'rgba(168,85,247,0.2)');
ctx.beginPath(); ctx.arc(ui.hx, ui.hy, ui.hr, 0, Math.PI*2); ctx.fill(); ctx.strokeStyle=myE?.hcT>0?'#ef4444':'#a855f7'; ctx.stroke();
if (myE?.hcT > 0) { ctx.fillStyle='rgba(0,0,0,0.5)'; ctx.beginPath(); ctx.moveTo(ui.hx, ui.hy); ctx.arc(ui.hx, ui.hy, ui.hr, -Math.PI/2 + (1-myE.hcT/300)*Math.PI*2, Math.PI*1.5); ctx.fill(); }
else if (hcRatio < 1) { ctx.fillStyle='rgba(0,0,0,0.5)'; ctx.beginPath(); ctx.moveTo(ui.hx, ui.hy); ctx.arc(ui.hx, ui.hy, ui.hr, -Math.PI/2 + hcRatio*Math.PI*2, Math.PI*1.5); ctx.fill(); }
ctx.fillStyle='#fff'; ctx.font='bold 14px Arial'; ctx.fillText('大絕', ui.hx, ui.hy+5);
}
};
let lastTime = performance.now(); let animId;
const loop = (time) => {
if(!active) return; animId = requestAnimationFrame(loop);
const dt = time - lastTime; if (dt < 16.5) return; lastTime = time - (dt % 16.5);
update(); draw();
};
animId = requestAnimationFrame(loop);
return () => {
active = false; engineLock.current = false; cancelAnimationFrame(animId);
if(unsubSnap) unsubSnap(); if(intervalRef) clearInterval(intervalRef);
const c = document.getElementById('arena-container');
if (c) { c.removeEventListener('touchstart', handleTouchS); c.removeEventListener('touchmove', handleTouchM); c.removeEventListener('touchend', handleTouchE); c.removeEventListener('touchcancel', handleTouchE); }
window.removeEventListener('keydown', handleKD); window.removeEventListener('keyup', handleKU); window.removeEventListener('mousemove', handleMM); window.removeEventListener('mousedown', handleMD); window.removeEventListener('mouseup', handleMU);
};
}, []);
const handleLeave = async () => {
pSfx('ui');
if (confirm('確定要退出嗎?(退出不計敗場)')) {
hasExited.current = true;
const sRoom = stateRef.current.room;
if (sRoom && sRoom.isHost) await updateDoc(getRRef(sRoom.id), { status: 'closed' });
else if (sRoom) await updateDoc(getRRef(sRoom.id), { [`players.${stateRef.current.user.uid}`]: deleteField() });
stateRef.current.setRoom(null); stateRef.current.setGameState('hub');
}
};
return (
{showEmotes && (
{EMOTES.map(emo => )}
)}
);
}
function MVPScreen({ result, info, setInfo, setGameState, setRoom, room }) {
const isWin = result?.result === 'Victory';
const claim = async () => {
pSfx('ui');
const baseTrophies = isWin ? 8 : -2; const streakBonus = isWin ? Math.min((info.winStreak || 0), 5) : 0;
const totalGain = isWin ? baseTrophies + streakBonus : baseTrophies;
const newStreak = isWin ? (info.winStreak || 0) + 1 : 0;
const curBrawlerTrophies = (info.heroRanks && info.heroRanks[info.myHeroId]) || 0;
const newBrawlerTrophies = Math.max(0, curBrawlerTrophies + totalGain);
const newQuestsList = info.quests ? [...info.quests.list] : [];
if (newQuestsList.length > 0) {
newQuestsList.forEach(q => {
if (!q.claimed) {
if (q.type === 'win' && isWin) q.progress++;
if (q.type === 'kill') q.progress += (result?.stats?.myK || 0);
if (q.type === 'dmg') q.progress += (result?.stats?.myD || 0);
q.progress = Math.min(q.progress, q.target);
}
});
}
setInfo(p => ({ ...p, coins: p.coins + (isWin ? 30 : 10), winStreak: newStreak, heroRanks: { ...(p.heroRanks || {}), [info.myHeroId]: newBrawlerTrophies }, quests: { ...p.quests, list: newQuestsList } }));
if (room && fbDb && room.isHost) { await updateDoc(getRRef(room.id), { status: 'waiting' }); }
if (isWin) setGameState('mystery_box'); else setGameState(room ? 'lobby' : 'hub');
};
return (
{isWin ? 'VICTORY' : 'DEFEAT'}
角色獎盃 {isWin?'+8':'-2'}
{(isWin && (info.winStreak || 0) > 0) &&
連勝加成 +{Math.min((info.winStreak || 0), 5)}
}
金幣 +{isWin?30:10}
擊殺數{result?.stats?.myK||0}
總輸出{Math.floor(result?.stats?.myD||0)}
);
}
function MysteryBoxScreen({ info, setInfo, onComplete }) {
const [step, setStep] = useState(0); const [rarityLevel, setRarityLevel] = useState(0);
const [reward, setReward] = useState(null); const [isDouble, setIsDouble] = useState(false); const [reward2, setReward2] = useState(null);
const [done, setDone] = useState(false);
const RARITIES = [ { name: '稀有', color: '#38bdf8', chance: 50 }, { name: '超稀有', color: '#a855f7', chance: 28 }, { name: '史詩', color: '#fb923c', chance: 15 }, { name: '神話', color: '#f43f5e', chance: 5 }, { name: '傳奇', color: '#facc15', chance: 2 } ];
const generateReward = (rLvl) => {
const r = Math.random();
if (rLvl >= 3 && r < 0.3) {
const locked = HEROES.filter(b => !info.unlocked.includes(b.id));
if (locked.length > 0) return { type: 'brawler', val: locked[Math.floor(Math.random() * locked.length)] };
else return { type: 'coins', val: 5000, msg: '圖鑑已滿!補償金幣' };
}
if (rLvl >= 2 && r < 0.6) {
const lockedG = GEARS.filter(g => !info.unlockedGears?.includes(g.id));
if (lockedG.length > 0) return { type: 'gadget', val: lockedG[Math.floor(Math.random() * lockedG.length)] };
else return { type: 'pp', val: 1000, msg: '指令全滿!補償能量' };
}
if (r < 0.5) return { type: 'coins', val: 50 * (rLvl + 1) + Math.floor(Math.random()*50) };
return { type: 'pp', val: 20 * (rLvl + 1) + Math.floor(Math.random()*20) };
};
useEffect(() => {
const rand = Math.random() * 100; let sum = 0, finalR = 0;
for(let i=0; i {
pSfx('ui');
if (done) { onComplete(); return; }
if (step < 4 && step < rarityLevel) setStep(step + 1);
else if (step < 5) {
setStep(5); pSfx('win');
let newInfo = { ...info };
const applyRw = (rw) => {
if (rw.type === 'coins') newInfo.coins += rw.val;
if (rw.type === 'pp') newInfo.pp += rw.val;
if (rw.type === 'brawler') newInfo.unlocked = [...newInfo.unlocked, rw.val.id];
if (rw.type === 'gadget') newInfo.unlockedGears = [...(newInfo.unlockedGears||[]), rw.val.id];
};
applyRw(reward); if(isDouble) applyRw(reward2);
setInfo(newInfo); setTimeout(()=>setDone(true), 1500);
}
};
const currColor = step < 5 ? RARITIES[Math.min(step, rarityLevel)].color : RARITIES[rarityLevel].color;
const currName = step < 5 ? RARITIES[Math.min(step, rarityLevel)].name : RARITIES[rarityLevel].name;
const renderRw = (rw) => {
if(!rw) return null;
if(rw.type==='coins') return +{rw.val}{rw.msg && {rw.msg}
} ;
if(rw.type==='pp') return +{rw.val}{rw.msg && {rw.msg}
} ;
if(rw.type==='brawler') return ;
if(rw.type==='gadget') return
;
};
return (
{step < 5 ? '點擊升級!' : `${currName} 神祕核心!`}
{step < 5 ? (
) : (
{isDouble &&
DOUBLE DROP!!
}
{renderRw(reward)}
{isDouble &&
{renderRw(reward2)}
}
{done &&
點擊任意處返回
}
)}
);
}