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 (

ARENA PRO

); } 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 }) => (
{label}
{val}
); 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
解鎖英雄
{rw.val.name}
; if(rw.type==='gadget') return
解鎖指令
{rw.val.name}
; }; return (
{step < 5 ? '點擊升級!' : `${currName} 神祕核心!`}
{step < 5 ? (
) : (
{isDouble &&
DOUBLE DROP!!
}
{renderRw(reward)}
{isDouble &&
{renderRw(reward2)}
}
{done &&
點擊任意處返回
}
)}
); }