starraid/star-raid/js/ui.js

621 lines
29 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use strict";
/* ================= 界面流转 ================= */
const OVLS=["o-auth","o-home","o-title","o-setup","o-hangar","o-cards","o-pause","o-over","o-codex","o-demo","o-rush","o-slot","o-unsaved","o-rushmode","o-rushpick"];
function show(id){for(const o of OVLS)$(o).classList.toggle("hide",o!==id);}
const SETUP={mode:"endless",ship:"falcon",diff:0}; // 出击默认无尽模式
function bestLine(){
const b=SAVE.best;
$("best-line").textContent=`无尽纪录 ${Math.max(b.e0,b.e1)} · 闯关进度 ${b.stage}/6`;
}
$("b-go").onclick=()=>{AU.init();show("o-setup");refreshRunSave();};
$("b-back1").onclick=()=>show("o-title");
$("b-back2").onclick=()=>show("o-title");
$("b-hangar").onclick=()=>{AU.init();buildShop();show("o-hangar");};
$("b-codex").onclick=()=>{AU.init();buildCodex();show("o-codex");};
$("b-rush").onclick=()=>{AU.init();buildRush();show("o-rush");};
$("b-back6").onclick=()=>{UI="title";show("o-title");};
/* 基地右上角静音开关SFX tone/noise 自查 SAVE.mute音乐走 AU.applyMute 闸门persist 触发云同步)
图标为手绘像素画:开声=青喇叭+绿音波 / 静音=灰喇叭+红叉 */
const SPK_ON=[ // 音波三根竖线:从左到右由短到长
".....SS.........",
"....SSS.........",
"..SSSSS........W",
"SSSSSSS......W.W",
"SSSSSSSS...W.W.W",
"SSSSSSSSS..W.W.W",
"SSSSSSSS...W.W.W",
"SSSSSSS......W.W",
"..SSSSS........W",
"....SSS.........",
".....SS........."];
const SPK_OFF=[
".....SS.........",
"....SSS.........",
"..SSSSS.........",
"SSSSSSS...X...X.",
"SSSSSSSS...X.X..",
"SSSSSSSSS...X...",
"SSSSSSSS...X.X..",
"SSSSSSS...X...X.",
"..SSSSS.........",
"....SSS.........",
".....SS........."];
function updateMuteBtn(){
const c=$("b-mute-icon");if(!c)return;
const g=c.getContext("2d");g.clearRect(0,0,c.width,c.height);
const art=SAVE.mute?SPK_OFF:SPK_ON;
for(let y=0;y<art.length;y++)for(let x=0;x<art[y].length;x++){
const ch=art[y][x];if(ch===".")continue;
g.fillStyle=SAVE.mute?(ch==="X"?"#ff5566":"#7f8cb8"):(ch==="W"?"#5cff9d":"#41e6ff");
g.fillRect(x*2+2,y*2+2,2,2);
}
}
$("b-mute").onclick=()=>{
SAVE.mute=!SAVE.mute;persist();
AU.init();if(AU.ctx&&AU.ctx.state==="suspended")AU.ctx.resume();
AU.applyMute();updateMuteBtn();
if(!SAVE.mute)SFX.pick(); // 解除静音给个反馈音(静音时 tone 自查抑制)
};
updateMuteBtn();
$("b-back4").onclick=()=>{UI="title";bestLine();show("o-title");};
$("b-back5").onclick=exitDemoSim;
/* 图鉴测试模式隐藏入口连点图鉴标题“军”字10次开关。
纯内存开关——不动 SAVE.codexpersist/结算上报都读它,动了会污染后端),刷新页面即失效 */
let codexTestAll=false,cxClicks=0,cxLastClick=0;
$("cx-secret").onclick=()=>{
const now=Date.now();
if(now-cxLastClick>2000)cxClicks=0; // 间隔超2秒重新计数
cxLastClick=now;
if(++cxClicks<10)return;
cxClicks=0;codexTestAll=!codexTestAll;
SFX.pick();buildCodex();
};
/* 自选武装测试模式隐藏入口连点“自”字10次开关与图鉴“军”字同一开关 */
let rpClicks=0,rpLastClick=0;
$("rp-secret").onclick=()=>{
const now=Date.now();
if(now-rpLastClick>2000)rpClicks=0; // 间隔超2秒重新计数
rpLastClick=now;
if(++rpClicks<10)return;
rpClicks=0;codexTestAll=!codexTestAll;
SFX.pick();
$("rp-secret").style.color=codexTestAll?"var(--gold)":"";
if(!$("o-rushpick").classList.contains("hide"))openRushPick(rushPickIdx); // 面板开着时立即刷新生效
};
/* 开始出击:新开无尽对局(不分难度)前,若云端有未完成对局,先弹结算确认 */
$("b-start").onclick=async()=>{
if(SETUP.mode==="endless"&&typeof NET!=="undefined"&&NET.user){
if(NET.runSave===undefined)await NET.fetchRunSave();
if(NET.runSave){showUnsaved(NET.runSave);return;}
}
startGame();
};
/* ================= 无尽模式:断点续玩(波次检查点) =================
每波开始拍快照上传startWave 调用);中途退出=从当前波开头重打,
该波内的得分/击杀不保留build与层数完整保留 */
function takeRunSnapshot(){
return {v:1,mode:"endless",diff:G.diff,ship:G.ship,
wave:G.wave,score:Math.round(G.score),coins:Math.round(G.coins),kills:G.kills,
level:G.level,xp:Math.round(G.xp),xpNeed:G.xpNeed,luck:G.luck,
weapons:Object.assign({},G.weapons),loadout:G.loadout.slice(),
subs:Object.assign({},G.subs),subLoadout:G.subLoadout.slice(),
drones:Object.assign({},G.drones),droneLoadout:G.droneLoadout.slice(),
u:Object.assign({},G.u),
lives:G.lives,maxHp:G.maxHp,hp:Math.round(G.hp),
shield:G.shield,od:Math.round(G.od),
bossN:G.bossN,bossKills:G.bossKills||0,time:G.time};
}
/* startWave 开头调用:仅无尽模式实战(演示/主页模拟/未登录跳过) */
function maybeSaveRun(){
if(!G||G.mode!=="endless"||G.demoSim)return;
if(typeof NET==="undefined"||!NET.user)return;
NET.saveRunSnapshot(takeRunSnapshot());
}
/* 出击页:拉取云端存档并渲染“继续对局”卡片 */
async function refreshRunSave(){
if(typeof NET==="undefined"||!NET.user){buildResumeSlot();return;}
await NET.fetchRunSave();
buildResumeSlot();
}
function buildResumeSlot(){
const box=$("resume-slot");if(!box)return;box.innerHTML="";
const s=typeof NET!=="undefined"?NET.runSave:null;
if(!s||!NET.user)return;
const d=document.createElement("div");d.className="panel";
d.style.cssText="border-color:var(--gold);margin-bottom:8px;padding:8px 10px";
d.innerHTML=`<div class="h3" style="color:var(--gold)">⏸ 未完成的无尽对局</div>
<div class="small" style="margin:2px 0 6px">WAVE ${s.wave} · ${s.diff?"困难":"普通"} · 得分 ${s.score} · ${SHIPS[s.ship]?SHIPS[s.ship].name:"猎鹰"}</div>`;
const b=document.createElement("button");
b.className="btn primary";b.style.minWidth="min(56vw,200px)";
b.textContent="▶ 继续对局";
b.onclick=()=>{SFX.pick();resumeSavedRun(s);};
d.appendChild(b);box.appendChild(d);
}
/* 从波次检查点重建对局:该波从头重打 */
function resumeSavedRun(s){
AU.init();if(AU.ctx&&AU.ctx.state==="suspended")AU.ctx.resume();
newRun("endless",s.ship,s.diff);
G.score=s.score;G.coins=s.coins;G.kills=s.kills;
G.level=s.level;G.xp=s.xp;G.xpNeed=s.xpNeed;G.luck=s.luck;
G.weapons=Object.assign({},s.weapons);G.loadout=s.loadout.slice();
G.subs=Object.assign({},s.subs);G.subLoadout=s.subLoadout.slice();
G.drones=Object.assign({},s.drones);G.droneLoadout=s.droneLoadout.slice();
G.u=Object.assign({fire:0,crit:0,shield:0,nano:0,mag:0,bounty:0,odcore:0,ghost:0},s.u);
G.lives=s.lives;G.maxHp=s.maxHp;G.hp=Math.min(s.hp,s.maxHp);
G.shield=Math.min(s.shield,shieldCap());G.od=s.od;
G.bossN=s.bossN;G.bossKills=s.bossKills;G.time=s.time;
G.wave=s.wave-1; // startWave 会 ++ 回这一波整波重打Boss波则重抽BossbossN未预增不偏移
UI="play";show("");acc=0;
MUS.start(false);
banner("继续对局 — WAVE "+s.wave,"#41e6ff",130);
startWave();
}
/* 结算未完成对局:星币入账+本地纪录+排行榜上报,返回删除云档的 Promise */
function settleSavedRun(s){
SAVE.coins+=s.coins;
SAVE.best["e"+s.diff]=Math.max(SAVE.best["e"+s.diff]||0,s.score);
persist();
if(typeof NET!=="undefined"&&NET.user){
NET.submitRun({mode:"endless",diff:s.diff,score:s.score,wave:s.wave,stage:1,
timeSec:Math.round((s.time||0)/60),bossKills:s.bossKills||0,win:false,
codex:Object.keys(SAVE.codex)});
return NET.deleteRunSave();
}
return Promise.resolve();
}
/* “将直接结算未完成的对局”确认弹窗 */
function showUnsaved(s){
$("unsaved-info").innerHTML=
`<span>抵达波次</span><b>WAVE ${s.wave}</b>
<span>难度</span><b>${s.diff?"困难":"普通"}</b>
<span>当前得分</span><b>${s.score}</b>
<span>持有星币</span><b>✦ ${s.coins}</b>`;
show("o-unsaved");
}
$("b-settle-go").onclick=async()=>{ // 结算旧档(等删除完成再开新局,防队列外竞态已由 NET 队列兜底)
const s=typeof NET!=="undefined"?NET.runSave:null;
if(s)await settleSavedRun(s);
startGame();
};
$("b-unsaved-resume").onclick=()=>{
const s=typeof NET!=="undefined"?NET.runSave:null;
if(s)resumeSavedRun(s);else show("o-setup");
};
$("b-unsaved-cancel").onclick=()=>show("o-setup");
if("ontouchstart" in window){const pb=$("tb-pause");pb.style.display="block";pb.onclick=()=>togglePause();}
$("b-resume").onclick=()=>togglePause();
$("b-restart").onclick=()=>{show("");restartSameRun();};
/* 暂时退出:无尽模式且已有云端断点存档时【不结算】——星币/分数留在波次快照里,回基地可续玩;其余情况照旧结算 */
$("b-quit").onclick=()=>{
const keep=G&&G.mode==="endless"&&!G.demoSim&&typeof NET!=="undefined"&&NET.user&&NET.runSave;
if(!keep)bank();
G=null;MUS.stop();UI="title";bestLine();show("o-title");
};
/* 再来一局:按刚结束那一局自己的 mode/ship/diff 重开(修复 SETUP 漂移导致进错模式,如 rush/闯关死后进了无尽) */
function restartSameRun(){
if(!G){startGame();return;}
if(G.mode==="rush"){startRush(G.rushIdx);return;}
SETUP.mode=G.mode;SETUP.ship=G.ship;SETUP.diff=G.diff;
startGame();
}
$("b-again").onclick=()=>restartSameRun();
$("b-home").onclick=()=>{G=null;UI="title";bestLine();show("o-title");};
$("mode-pick").addEventListener("click",e=>{
const c=e.target.closest(".pcard");if(!c)return;
SETUP.mode=c.dataset.m;
for(const el of $("mode-pick").children)el.classList.toggle("sel",el===c);
});
$("diff-pick").addEventListener("click",e=>{
const b=e.target.closest("button");if(!b)return;
SETUP.diff=+b.dataset.d;
for(const el of $("diff-pick").children)el.classList.toggle("primary",el===b);
});
/* 机体选择/购买(机库内):购买后整店刷新(星币/按钮态变化),选择只刷新机体卡 */
function buildShips(){
const box=$("ship-pick");box.innerHTML="";
for(const k in SHIPS){
const s=SHIPS[k],owned=SAVE.ships.includes(k);
const d=document.createElement("div");
d.className="pcard"+(owned?"":" lock")+(SETUP.ship===k?" sel":"");
const c=document.createElement("canvas");c.width=52;c.height=64;
const g=c.getContext("2d");g.imageSmoothingEnabled=false;
const spr=SPR[s.spr];g.drawImage(spr,(52-spr.width*3)/2,(64-spr.height*3)/2,spr.width*3,spr.height*3);
d.appendChild(c);
d.insertAdjacentHTML("beforeend",`<div class="nm">${s.name}</div><div class="ds">${s.trait}</div>`+(owned?"":`<div class="cost">✦ ${s.cost} 解锁</div>`));
d.onclick=()=>{
if(!SAVE.ships.includes(k)){
if(SAVE.coins>=s.cost){SAVE.coins-=s.cost;SAVE.ships.push(k);persist();SFX.level();buildShop();}
else SFX.hurt();
return;
}
SETUP.ship=k;persist();buildShips(); // persist 触发云端同步选中机体
};
box.appendChild(d);
}
}
/* ================= 机库商店 ================= */
const META=[
{key:"weapon",name:"主武器校准",ds:"出击时主武器等级 +1",max:2,costs:[300,800]},
{key:"armor",name:"强化装甲",ds:"出击时生命上限 +1",max:2,costs:[400,1000]},
{key:"hpup",name:"结构强化",ds:"出击时结构值 +15",max:2,costs:[250,700]},
{key:"luck",name:"幸运币",ds:"高品质强化概率提升",max:2,costs:[350,900]},
{key:"wallet",name:"收藏家协议",ds:"星币获取 +15%",max:3,costs:[300,800,2000]},
{key:"mag2",name:"引力透镜",ds:"拾取范围 +15%",max:2,costs:[200,600]},
{key:"odstart",name:"超频预载",ds:"出击时超频已充能 50%",max:1,costs:[500]}
];
function buildShop(){
$("wallet").textContent=SAVE.coins;
buildShips();
const box=$("shop");box.innerHTML="";
for(const m of META){
const lv=SAVE.m[m.key]||0,maxed=lv>=m.max,cost=maxed?0:m.costs[lv];
const d=document.createElement("div");d.className="sitem";
d.innerHTML=`<div class="inf"><div class="nm">${m.name}</div><div class="ds">${m.ds}</div>
<div class="pips">${"■".repeat(lv)}${"□".repeat(m.max-lv)}</div></div>
<button ${maxed||SAVE.coins<cost?"disabled":""}>${maxed?"MAX":"✦ "+cost}</button>`;
if(!maxed)d.querySelector("button").onclick=()=>{
if(SAVE.coins<cost)return;
SAVE.coins-=cost;SAVE.m[m.key]=lv+1;persist();SFX.coin();buildShop();
};
box.appendChild(d);
}
}
/* ================= 开局 / 暂停 / 结算 ================= */
function startGame(){
AU.init();if(AU.ctx&&AU.ctx.state==="suspended")AU.ctx.resume();
newRun(SETUP.mode,SETUP.ship,SETUP.diff);
UI="play";show("");acc=0;
MUS.start(false);
banner(SETUP.mode==="stage"?"STAGE 1 — "+STAGE_NAMES[0]:"无尽模式 — 生存到底!","#41e6ff",130);
startWave();
}
function togglePause(){
if(!G)return;
if(UI==="play"){UI="pause";buildLoadoutUI();show("o-pause");}
else if(UI==="pause"){UI="play";show("");}
}
/* ================= 换装面板 ================= */
function buildLoadoutUI(){
const box=$("loadout");if(!box||!G)return;box.innerHTML="";
const mk=(title,dict,owned,active,max)=>{
const sec=document.createElement("div");
sec.innerHTML=`<div class="h3" style="margin:6px 0 2px">${title}${active.length}/${max})· 点击切换装备</div>`;
const row=document.createElement("div");
for(const k in owned){
const d=document.createElement("span");
const on=active.includes(k);
d.className="litem"+(on?" on":"");
d.textContent=`${dict[k].name} Lv${owned[k]}`;
d.onclick=()=>{
const i=active.indexOf(k);
if(i>=0){if(active===G.loadout&&active.length===1){SFX.hurt();return;}active.splice(i,1);}
else{if(active.length>=max){SFX.hurt();return;}active.push(k);}
SFX.pick();buildLoadoutUI();
};
row.appendChild(d);
}
if(Object.keys(owned).length===0)row.innerHTML='<span class="small">尚未获得</span>';
sec.appendChild(row);box.appendChild(sec);
};
mk("主武器",WEAPONS,G.weapons,G.loadout,5);
mk("副武器",SUBS,G.subs,G.subLoadout,5);
mk("僚机",DRONES,G.drones,G.droneLoadout,3);
}
/* ================= 图鉴 ================= */
function iconCanvas(img,size,sil){
const c=document.createElement("canvas");c.width=c.height=size;
const g=c.getContext("2d");g.imageSmoothingEnabled=false;
const sc=Math.min(size/img.width,size/img.height);
g.drawImage(img,(size-img.width*sc)/2,(size-img.height*sc)/2,img.width*sc,img.height*sc);
if(sil){g.globalCompositeOperation="source-atop";g.fillStyle="#232f52";g.fillRect(0,0,size,size);g.globalCompositeOperation="source-over";}
return c;
}
function buildCodex(){
const box=$("codex");box.innerHTML="";
const total=MAINS.length+SUB_KEYS.length+DRONE_KEYS.length+PAS_KEYS.length;
$("codex-t").textContent=total;
$("codex-n").textContent=codexCount(); // 计数永远显示真实收集数,测试模式不影响
$("codex-test").style.display=codexTestAll?"":"none";
$("cx-secret").style.color=codexTestAll?"var(--gold)":""; // 开启后“军”字变金
const mk=(title,keys,dict,kind)=>{
const h=document.createElement("div");h.className="cx-sec";h.textContent=title;box.appendChild(h);
const grid=document.createElement("div");grid.className="cx-grid";
for(const k of keys){
const def=dict[k],owned=!!SAVE.codex[k]||codexTestAll; // 测试模式:全部可见可演示
const d=document.createElement("div");
d.className="cx-item"+(owned?"":" lock"); // 未收集:剪影+???,不可演示
const img=def.icon?ICONS[def.icon]:SPR[def.spr];
d.appendChild(iconCanvas(img,28,!owned));
d.insertAdjacentHTML("beforeend",`<div class="nm">${owned?def.name:"???"}</div>`);
if(owned)d.onclick=()=>{SFX.pick();startDemoSim(kind,k);};
grid.appendChild(d);
}
box.appendChild(grid);
};
mk("主武器",MAINS,WEAPONS,"m");
mk("副武器",SUB_KEYS,SUBS,"s");
mk("僚机",DRONE_KEYS,DRONES,"d");
mk("被动强化",PAS_KEYS,PASSIVES,"p");
}
document.addEventListener("visibilitychange",()=>{if(document.hidden&&UI==="play")togglePause();});
function bank(){
if(!G||G.banked)return;G.banked=true;
SAVE.coins+=G.coins;
if(G.mode!=="rush"){const bk=(G.mode==="endless"?"e":"s")+G.diff;
SAVE.best[bk]=Math.max(SAVE.best[bk]||0,G.score);}
persist();
}
/* ---- Boss 挑战模式 ---- */
function buildRush(){
G=null; // 清掉上一局残留rush胜利后G仍在防背景卡残影
SAVE.rush=SAVE.rush||{};
const ra=$("rush-anim"); // 老虎机开关(持久化)
if(ra){ra.checked=!!SAVE.rushAnim;ra.onchange=()=>{SAVE.rushAnim=ra.checked?1:0;persist();};}
// 按难度分级分组展示BOSSES[i].tier0简单/1中等/2困难/3地狱
const TIERS=[["简单","#5cff9d"],["中等","#ffcf5c"],["困难","#ff9a3d"],["地狱","#ff3355"]];
const el=$("rush-pick");el.innerHTML="";
for(let ti=0;ti<4;ti++){
const grp=document.createElement("div");grp.className="rush-grp";
grp.innerHTML=`<div class="h3" style="color:${TIERS[ti][1]}">— ${TIERS[ti][0]} —</div>`;
const row=document.createElement("div");row.className="rush-row";
BOSSES.forEach((b,i)=>{
if(b.tier!==ti)return;
const best=SAVE.rush[i];
const d=document.createElement("div");d.className="pcard";
d.innerHTML=`<div class="nm" style="margin-top:2px;color:${b.col}">${b.name}${best?" ✓":""}</div>
<div class="ds">${b.sub}<br>${best?("最快 "+Math.floor(best/60)+":"+("0"+best%60).slice(-2)):"未击破"}</div>`;
d.onclick=()=>startRush(i);
row.appendChild(d);
});
if(row.children.length){grp.appendChild(row);el.appendChild(grp);}
}
}
function rollRushBuild(){ // 随机武装3主武Lv4 + 3副武Lv4 + 2僚机Lv3
const sh=a=>{const x=a.slice();for(let i=x.length-1;i>0;i--){const j=rndi(0,i),t2=x[i];x[i]=x[j];x[j]=t2;}return x;};
return {m:sh(Object.keys(WEAPONS)).slice(0,3),s:sh(Object.keys(SUBS)).slice(0,3),d:sh(Object.keys(DRONES)).slice(0,2)};
}
function startRush(i){ // 点Boss卡片弹窗选择 随机出击/自选武器
AU.init();if(AU.ctx&&AU.ctx.state==="suspended")AU.ctx.resume();
openRushMode(i);
}
/* ---- 出击方式弹窗:随机 / 自选 ---- */
let rushPickIdx=0;
function openRushMode(i){
rushPickIdx=i;
const b=BOSSES[i],tierName=["简单","中等","困难","地狱"][b.tier]||"";
const nm=$("rushmode-name");nm.textContent=b.name+(tierName?" · "+tierName:"");nm.style.color=b.col;
$("b-rushmode-random").onclick=()=>{
show("o-rush");
const build=rollRushBuild();
if(SAVE.rushAnim)playSlot(build,()=>startRushGo(i,build)); // 老虎机动画抽取
else startRushGo(i,build); // 直接随机
};
$("b-rushmode-pick").onclick=()=>openRushPick(i);
$("b-rushmode-cancel").onclick=()=>show("o-rush");
show("o-rushmode");
}
/* ---- 自选武装面板:从图鉴已解锁中挑 3主+3副+2僚等级与随机同级 Lv4/4/3 ---- */
const rushPick={m:[null,null,null],s:[null,null,null],d:[null,null,null]}; // 槽位null=未选
function openRushPick(i){
rushPickIdx=i;
const b=BOSSES[i];
$("rushpick-title").textContent=b.name+" — 从已解锁图鉴中选择";
for(const k of ["m","s","d"])rushPick[k].fill(null);
const mk=(label,keys,dict)=>{
const box=$("rushpick-"+label);box.innerHTML="";
for(const k of keys){
const def=dict[k],owned=!!SAVE.codex[k]||codexTestAll; // 测试模式:全部可选
const d=document.createElement("div");
d.className="cx-item"+(owned?"":" lock");
const img=def.icon?ICONS[def.icon]:SPR[def.spr];
d.appendChild(iconCanvas(img,28,!owned));
d.insertAdjacentHTML("beforeend",`<div class="nm">${owned?def.name:"???"}</div>`);
d.dataset.key=k;
if(owned)d.onclick=()=>toggleRushPick(d,label);
box.appendChild(d);
}
};
mk("m",MAINS,WEAPONS);
mk("s",SUB_KEYS,SUBS);
mk("d",DRONE_KEYS,DRONES);
updRushPickCaps();
show("o-rushpick");
}
function toggleRushPick(el,label){
const cap=label==="d"?2:3,slots=rushPick[label];
const i=slots.indexOf(el.dataset.key);
if(i>=0){slots[i]=null;el.classList.remove("sel");} // 取消已选
else{
if(slots.filter(Boolean).length>=cap)return; // 槽位已满:忽略点击
const empty=slots.indexOf(null);if(empty<0)return;
slots[empty]=el.dataset.key;el.classList.add("sel");
SFX.pick();
}
updRushPickCaps();
}
function updRushPickCaps(){
const cap={m:3,s:3,d:2};
for(const k of ["m","s","d"]){
const n=rushPick[k].filter(Boolean).length;
const c=$("rushpick-cnt-"+k);if(c)c.textContent=n+"/"+cap[k];
$("rushpick-"+k).classList.toggle("full",n>=cap[k]);
}
}
function rushPickGo(){ // 未选满的槽位:从已解锁池随机补齐(图鉴全空时兜底全池)
const owned=kk=>!!SAVE.codex[kk]||codexTestAll;
const pools={m:MAINS,s:SUB_KEYS,d:DRONE_KEYS};
const build={m:[],s:[],d:[]};
for(const k of ["m","s","d"]){
const cap=k==="d"?2:3;
const ownedPool=pools[k].filter(owned);
const src=ownedPool.length?ownedPool:pools[k]; // 兜底
build[k]=rushPick[k].filter(Boolean).slice(0,cap);
const rest=src.filter(x=>!build[k].includes(x));
while(build[k].length<cap&&rest.length)build[k].push(rest.splice(rndi(0,rest.length-1),1)[0]);
}
startRushGo(rushPickIdx,build);
}
function startRushGo(i,build){
newRun("rush","falcon",0);
G.weapons={};build.m.forEach(k=>G.weapons[k]=4);G.loadout=build.m.slice();
G.subs={};build.s.forEach(k=>G.subs[k]=4);G.subLoadout=build.s.slice();
G.drones={};build.d.forEach(k=>G.drones[k]=3);G.droneLoadout=build.d.slice();
G.xpNeed=1e9; // 挑战模式无升级打断
G.rushIdx=i;G.rushT0=0;
G.stage=i; // 排行榜统计rush 局 stage 记录 Boss 索引(0~10)
G.lives=1;G.stat.maxLives=1; // BOSS 挑战一命制:掉落/拾取心均以 maxLives 为上限,中途无法再加命
UI="play";show("");acc=0;
MUS.start(true);
banner("BOSS 挑战 — "+BOSSES[i].name,"#ff5566",130);
G.rushT0=G.time;
startBoss();
}
$("b-rushpick-go").onclick=()=>{show("o-rush");rushPickGo();};
$("b-rushpick-back").onclick=()=>show("o-rush");
/* ---- 老虎机抽奖动画 ---- */
let slotTimers=[];
function playSlot(build,cb){
UI="slot";show("o-slot");
const row=$("slot-row");row.innerHTML="";
$("slot-hint").textContent=" ";
const items=[
...build.m.map(k=>({cat:"主武器",pool:WEAPONS,key:k})),
...build.s.map(k=>({cat:"副武器",pool:SUBS,key:k})),
...build.d.map(k=>({cat:"僚机",pool:DRONES,key:k}))];
let done=0;
items.forEach((it,idx)=>{
const d=document.createElement("div");d.className="slotbox";
d.innerHTML=`<div class="sc">${it.cat}</div><div class="sn">???</div>`;
row.appendChild(d);
const sn=d.querySelector(".sn"),names=Object.values(it.pool).map(x=>x.name);
const iv=setInterval(()=>{sn.textContent=names[rndi(0,names.length-1)];},60);
slotTimers.push(iv);
setTimeout(()=>{clearInterval(iv);
sn.textContent=it.pool[it.key].name;sn.classList.add("hit");
SFX.graze();done++;
if(done>=items.length){$("slot-hint").textContent="— 出击 —";setTimeout(cb,700);}
},700+idx*280);
});
}
/* 结算上报服务端每局一次含rush胜利图鉴演示除外 */
function submitRun(win){
if(!G||G.submitted||G.demoSim)return;
G.submitted=true;
if(typeof NET==="undefined"||!NET.user)return;
NET.submitRun({mode:G.mode,diff:G.diff,score:G.score,wave:G.wave,stage:G.stage,
timeSec:Math.round(G.time/60),bossKills:G.bossKills||0,win:!!win,
codex:Object.keys(SAVE.codex)}); // 注意:不上报 localStorage 历史最高分SAVE.best 是浏览器级共享,会串号)
}
function endRun(win){
if(UI==="over")return;
bank();MUS.stop();
submitRun(win);
if(G.mode==="endless"&&typeof NET!=="undefined"&&NET.user)NET.deleteRunSave(); // 对局真正结束:清除云端断点存档
UI="over";
$("over-title").textContent=win?"★ 任务完成 ★":"任务失败";
$("over-title").style.color=win?"#ffcf5c":"#ff5566";
const mins=Math.floor(G.time/3600),secs=Math.floor(G.time/60)%60;
$("over-stats").innerHTML=
`<span>最终得分</span><b>${G.score}</b>
<span>${G.mode==="endless"?"抵达波次":G.mode==="rush"?"挑战目标":"抵达关卡"}</span><b>${G.mode==="endless"?G.wave:G.mode==="rush"?BOSSES[G.rushIdx].name:G.stage+"-"+G.wave}</b>
<span>击坠数</span><b>${G.kills}</b>
<span>强化等级</span><b>Lv.${G.level}</b>
<span>存活时间</span><b>${mins}${secs}秒</b>
<span>持有军械</span><b>${Object.keys(G.weapons).length}主/${Object.keys(G.subs).length}副/${Object.keys(G.drones).length}机</b>`;
$("over-coins").textContent=G.coins;
setTimeout(()=>show("o-over"),win?400:900);
}
/* ================= 三选一卡片 ================= */
function offerCards(bossRare){
if(!G||G.p.dead>0&&!bossRare)return;
G.bossCard=!!bossRare;
const opts=rollCards(bossRare);
G.cardOpts=opts;
const box=$("cards");box.innerHTML="";
opts.forEach((c,i)=>{
let nm,ds,rare,img;
if(c.kind==="wlvl"){const w=WEAPONS[c.key],lv=G.weapons[c.key];nm=w.name;rare=w.rare;ds=`主武器强化 Lv.${lv} → Lv.${lv+1}`;img=w.icon?ICONS[w.icon]:SPR[w.spr];}
else if(c.kind==="weapon"){const w=WEAPONS[c.key];nm="主武器·"+w.name;rare=w.rare;ds=w.ds;img=w.icon?ICONS[w.icon]:SPR[w.spr];}
else if(c.kind==="slvl"){const u=SUBS[c.key],lv=G.subs[c.key];nm=u.name;rare=u.rare;ds=`副武器强化 Lv.${lv} → Lv.${lv+1}`;img=u.icon?ICONS[u.icon]:SPR[u.spr];}
else if(c.kind==="sub"){const u=SUBS[c.key];nm="副武器·"+u.name;rare=u.rare;ds=typeof u.ds==="function"?u.ds(0):u.ds;img=u.icon?ICONS[u.icon]:SPR[u.spr];}
else if(c.kind==="dlvl"){const u=DRONES[c.key],lv=G.drones[c.key];nm=u.name;rare=u.rare;ds=`僚机强化 Lv.${lv} → Lv.${lv+1}`;img=SPR[u.spr];}
else if(c.kind==="drone"){const u=DRONES[c.key];nm="僚机·"+u.name;rare=u.rare;ds=typeof u.ds==="function"?u.ds(0):u.ds;img=SPR[u.spr];}
else if(c.kind==="upg"){const u=PASSIVES[c.key];nm=u.name;rare=u.rare;ds=u.ds(G.u[c.key]);img=u.icon?ICONS[u.icon]:SPR[u.spr];}
else{rare=0;
if(c.key==="heal"){nm="紧急修复";ds="生命 +1";img=SPR.heart;}
else if(c.key==="coins"){nm="星币补给";ds="星币 +50";img=SPR.coin;}
else{nm="超频充能";ds="超频立即充满";img=SPR.odc;}}
const d=document.createElement("div");
d.className="ucard r"+rare;
const cv2=document.createElement("canvas");cv2.width=40;cv2.height=40;
const g=cv2.getContext("2d");g.imageSmoothingEnabled=false;
const sc=Math.min(40/img.width,40/img.height);
g.drawImage(img,(40-img.width*sc)/2,(40-img.height*sc)/2,img.width*sc,img.height*sc);
d.appendChild(cv2);
d.insertAdjacentHTML("beforeend",
`<div class="key">${i+1}</div><div class="nm">${nm}</div>
<div class="rtag">${["普通","稀有","史诗"][rare]}</div><div class="ds">${ds}</div>`);
d.onclick=()=>pickCard(i);
box.appendChild(d);
});
UI="cards";show("o-cards");SFX.level();
}
function pickCard(i){
if(UI!=="cards"||!G.cardOpts||!G.cardOpts[i])return;
const c=G.cardOpts[i];G.cardOpts=null;
if(c.kind==="wlvl")G.weapons[c.key]++;
else if(c.kind==="weapon"){G.weapons[c.key]=1;if(G.loadout.length<5)G.loadout.push(c.key);banner("获得 "+WEAPONS[c.key].name,"#b98cff",80);}
else if(c.kind==="slvl")G.subs[c.key]++;
else if(c.kind==="sub"){G.subs[c.key]=1;if(G.subLoadout.length<5)G.subLoadout.push(c.key);banner("获得 "+SUBS[c.key].name,"#b98cff",80);}
else if(c.kind==="dlvl")G.drones[c.key]++;
else if(c.kind==="drone"){G.drones[c.key]=1;if(G.droneLoadout.length<3)G.droneLoadout.push(c.key);banner("获得 "+DRONES[c.key].name,"#b98cff",80);}
else if(c.kind==="upg"){
G.u[c.key]++;
if(c.key==="nano"){G.maxHp+=25;G.hp=Math.min(G.maxHp,G.hp+40);}
if(c.key==="shield")G.shield=Math.min(shieldCap(),G.shield+1);
}else{
if(c.key==="heal")G.lives=Math.min(G.stat.maxLives,G.lives+1);
if(c.key==="coins")addCoins(50);
if(c.key==="od")G.od=100;
}
if(c.key&&c.kind!=="fill"){SAVE.codex[c.key]=true;persist();} // 图鉴收录
SFX.pick();
if(G.bossCard){G.bossCard=false;UI="play";show("");}
else{G.cardQ=(G.cardQ||1)-1;
if(G.cardQ>0)offerCards(false);
else{UI="play";show("");}}
}
/* ================= 图鉴:全屏实战模拟 =================
复用真实战斗引擎:只装备选定军械,杂兵+射手+精英持续来袭 */
function startDemoSim(kind,key){
const def=kind==="m"?WEAPONS[key]:kind==="s"?SUBS[key]:kind==="p"?PASSIVES[key]:DRONES[key];
newRun("stage","falcon",0);
G.p.inv=0; // 图鉴演示:取消出生保护闪烁
const lv=3;
G.demoSim={kind,key};
if(kind==="m"){G.weapons={};G.weapons[key]=lv;G.loadout=[key];}
else if(kind==="s"){G.weapons={vulcan:1};G.loadout=["vulcan"];G.subs={};G.subs[key]=lv;G.subLoadout=[key];}
else if(kind==="d"){G.weapons={};G.loadout=[];G.drones={};G.drones[key]=lv;G.droneLoadout=[key];}
else{G.weapons={vulcan:2};G.loadout=["vulcan"];G.u[key]=3;
if(key==="nano"){G.maxHp+=75;G.hp=G.maxHp;}
if(key==="shield")G.shield=shieldCap();}
$("demo-name").textContent=def.name;
const ds=typeof def.ds==="function"?def.ds(lv):def.ds;
$("demo-ds").textContent=ds; // 只显示介绍本身单行不折行nowrap 在 index.html 上)
UI="demoSim";show("o-demo");acc=0;
banner(def.name,"#41e6ff",90);
}
function exitDemoSim(){G=null;UI="codex";buildCodex();show("o-codex");}
function demoSpawn(){
const D=G.demoSim;
D.t=(D.t||0)-1;
if(D.t>0||G.enemies.length>=10)return;
D.t=Math.max(30,52-Math.floor(G.time/900)); // 渐进增密
const r=Math.random();
if(r<0.5)spawnEnemy(pick(["scout","scout","weaver","kami"]),rnd(20,W-20),-12,false); // 杂兵为主
else if(r<0.84)spawnEnemy(pick(["gunship","splitter","sniper","mine"]),rnd(20,W-20),-12,false); // 射手少数
else spawnEnemy(pick(["gunship","weaver","splitter"]),rnd(20,W-20),-12,true); // 精英
}
bestLine();