starraid/star-raid/js/combat.js
JOJO 9edc366cf9 图鉴测试模式+巡飞弹新武器+存档云同步+机库合并+默认无尽
①图鉴隐藏测试模式:连点标题'军'字10次开关,纯内存标志位全部军械可见可演示,不动SAVE.codex(persist/submitRun读它,动了会污染后端),刷新失效
②24件主武器说明文案全部替换(用户提供梗文案);演示页介绍单行显示、去掉类别前缀、删'实战模拟·自动驾驶'提示行
③新主武器'巡飞弹'(loiter):慢攻速追踪导弹(制导引擎新增maxSpd上限5保持中速),命中留火场(zones新kind:fire纯粒子火舌,无圆形区域)+分裂4枚小炸弹快爆;新精灵loms;CODEX_KEYS同步63
④存档云同步:星币/机体/选中机体/永久强化/本地纪录上服务端(player_state表+POST /api/state+vState白名单清洗);login/me/register响应带state;客户端persist()钩子防抖600ms自动上传;服务端整体替换本地防串号,登出全清;老账号首登自动上传本地存档迁移
⑤机体选择/购买从出击准备并入机库强化(buildShips);出击默认无尽模式
服务端已重启生效;/api/state全链路在独立测试库curl验证通过(合法推送/回读/非法拦截清洗/401)
2026-08-06 12:15:17 +08:00

340 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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";
/* ================= 战斗结算 ================= */
let _uid=1;
function banner(t,col,time){G.banner={t,col:col||"#41e6ff"};G.bannerT=time||100;}
function addScore(base,x,y){
let s=base*BAL.scoreCombo(G.combo)*(G.mutator==="FRENZY"?1.3:1);
s=Math.round(s);
G.score+=s;
if(base>=400&&x!==undefined)G.texts.push({x,y,t:"+"+s,col:"#ffcf5c",life:50});
}
function addCoins(n){G.coins+=Math.round(n*pCoinMul()*BAL.coinGlobal);}
function spawnPart(x,y,col,n){
for(let i=0;i<(n||1);i++)G.parts.push({x,y,vx:rnd(-2,2),vy:rnd(-2,2),life:rndi(14,30),col,sz:rndi(1,3)});
}
function dropPick(x,y,kind){
G.picks.push({x:clamp(x,8,W-8),y:clamp(y,-20,H-40),kind,vy:0.7,t:0,spr:{gem:"gem",coin:"coin",heart:"heart",bomb:"bombp",odc:"odc"}[kind]});
}
function hurtEnemy(e,dmg,canCrit){
if(!e||e.dead)return;
let crit=false;
if(canCrit&&Math.random()<pCrit()){dmg*=BAL.player.critM;crit=true;}
e.hp-=dmg;e.flash=2;e.hpt=150;
if(crit)G.texts.push({x:e.x,y:e.y-8,t:Math.round(dmg)+"!",col:"#ffe95c",life:30});
spawnPart(e.x,e.y,crit?"#ffe95c":"#fff",1);
// 寄生电浆(副武器):附着水蛭,吸血回复结构
if(canCrit&&G.subLoadout.includes("leech")&&!e.leech&&Math.random()<0.14+0.06*G.subs.leech){
e.leech={t:200,dps:1.2+0.5*G.subs.leech};
for(let i=0;i<3;i++)G.parts.push({x:e.x+rnd(-4,4),y:e.y+rnd(-4,4),vx:0,vy:0,life:14,col:"#b05ce8",sz:1,suck:{x:e.x,y:e.y}});
}
if(e.hp<=0)killEnemy(e);
}
function killEnemy(e){
e.dead=true;G.kills++;
SFX.boom(e.elite||e.r>7);
G.shake=Math.max(G.shake,e.elite?6:2);
const cols=e.elite?["#ffd23d","#fff","#ff9a3d"]:["#ff9a3d","#ff5566","#fff"];
for(let i=0;i<(e.elite?20:8);i++)spawnPart(e.x,e.y,pick(cols),e.elite?3:2);
addScore(e.score,e.x,e.y);
G.combo++;G.comboT=180;
G.od=Math.min(100,G.od+BAL.odGain.kill*(1+0.3*G.u.odcore));
for(let i=0;i<e.gems;i++)dropPick(e.x+rnd(-8,8),e.y+rnd(-6,6),"gem");
if(Math.random()<e.coin){const n=e.elite?rndi(5,8):rndi(1,2);for(let i=0;i<n;i++)dropPick(e.x+rnd(-10,10),e.y,"coin");}
if(e.elite){dropPick(e.x,e.y,"odc");if(Math.random()<0.5)dropPick(e.x+8,e.y,"coin");}
const r=Math.random();
if(!e.neutral){
if(r<0.025&&G.lives<G.stat.maxLives)dropPick(e.x,e.y,"heart");
else if(r<0.085)dropPick(e.x,e.y,"odc");
}
if(e.death==="ring"&&!e.neutral)for(let i=0;i<8;i++)ebul(e.x,e.y,i/8*TAU,1.6,"orbR");
if(e.death==="split")for(let i=0;i<2;i++)spawnEnemy("mini",e.x+(i?8:-8),e.y,false);
}
function hitPlayer(dmg){
const p=G.p;
if(p.inv>0||p.dead>0)return;
if(G.demoSim){spawnPart(p.x,p.y,"#41e6ff",3);SFX.graze();return;} // 演示模式无敌
if(Math.random()<pDodge()){G.texts.push({x:p.x,y:p.y-10,t:"MISS",col:"#9fd8ff",life:40});return;}
if(G.shield>0){G.shield--;G.shieldT=0;p.inv=70;SFX.hurt();G.shake=6;banner("护盾损毁!","#43e8ff",60);return;}
G.hp-=dmg;G.combo=0;
SFX.hurt();G.shake=7;G.flash=8;G.hitstop=5;p.inv=55;
for(let i=0;i<8;i++)spawnPart(p.x,p.y,pick(["#ff5566","#fff","#ff9a3d"]),2);
G.texts.push({x:p.x,y:p.y-10,t:"-"+Math.round(dmg),col:"#ff5566",life:35});
if(G.hp<=0){
G.lives--;G.hp=0;
SFX.boom(true);G.shake=12;G.flash=14;
for(let i=0;i<20;i++)spawnPart(p.x,p.y,pick(["#ff5566","#fff","#ff9a3d"]),3);
if(G.lives<=0){p.dead=99999;endRun(false);}
else{p.dead=70;G.hp=G.maxHp;banner("机体损毁 — 重新部署","#ff5566",80);}
}
}
function shieldCap(){return G.stat.shieldMax+(G.u.shield>0?1:0)+(G.u.shield>=3?1:0);}
/* ================= 子弹更新 ================= */
function explodeOnce(b){if(b.boom)return;b.boom=true;explodeBullet(b);}
function strongestEnemy(x,y,r){ // 斩首巡航弹Boss/精英优先
if(G.boss)return G.boss;
let best=null,bh=-1;const rr=r*r;
for(const e of G.enemies){if(e.dead)continue;
if(dist2(x,y,e.x,e.y)<rr){const v=e.elite?1e6+e.hp:e.hp;if(v>bh){bh=v;best=e;}}}
return best||nearestEnemy(x,y,r);
}
function updateBullets(){
for(const b of G.pb){
// 附着状态:钻头研磨 / 粘着炸弹
if(b.stuck){
const se=b.stuck;
if(se.dead){b.dead=true;if(b.t==="tb")explodeOnce(b);continue;}
b.x=se.x+b.ox;b.y=se.y+b.oy;
if(b.t==="tb"){b.fuse--;if(b.fuse<=0){b.dead=true;explodeOnce(b);}}
continue;
}
if(b.homing||b.softH){const t=b.eliteHunt?strongestEnemy(b.x,b.y,460):nearestEnemy(b.x,b.y,b.softH?400:220);
if(t){const a=Math.atan2(t.y-b.y,t.x-b.x),c=Math.atan2(b.vy,b.vx);
let d=a-c;while(d>Math.PI)d-=TAU;while(d<-Math.PI)d+=TAU;
if(b.softH){ // 星云奥秘:缓慢转向,不加速
const na=c+clamp(d,-0.05,0.05),s=Math.hypot(b.vx,b.vy);
b.vx=Math.cos(na)*s;b.vy=Math.sin(na)*s;
}else{const na=c+clamp(d,-0.16,0.16),s=Math.min(b.maxSpd||6,Math.hypot(b.vx,b.vy)+0.08);
b.vx=Math.cos(na)*s;b.vy=Math.sin(na)*s;}}}
if(b.back){b.bt--;if(b.bt<=0&&b.vy<0){b.vy*=-1;b.vx*=-0.4;}}
if(b.curve){const c=Math.atan2(b.vy,b.vx)+b.curve,s=Math.hypot(b.vx,b.vy)||0.1;
b.vx=Math.cos(c)*s;b.vy=Math.sin(c)*s;}
if(b.pull)for(const e of G.enemies)if(!e.dead){
const d2=dist2(b.x,b.y,e.x,e.y);
if(d2<b.pull*b.pull&&d2>1){const d=Math.sqrt(d2);e.x+=(b.x-e.x)/d*0.45;e.y+=(b.y-e.y)/d*0.45;}}
// 空爆弹:先向左右平移,再折向直飞,沿途不断爆炸
if(b.t==="am"&&!b.dead){
if(b.phase===1){b.pt++;
if(b.pt>=20){b.phase=2;b.vx=0;b.vy=-4.4;}}
else if(G.time%6===0){
const ex=b.x+rnd(-8,8),ey=b.y+rnd(-4,8);
areaDamage(ex,ey,b.zone||15,b.dmg*0.35);
explodeFx(ex,ey,"airburst",1);
if(G.time%12===0)SFX.boom(false);
}
}
if(b.trailCol&&G.time%2===0)G.parts.push({x:b.x,y:b.y,vx:rnd(-0.2,0.2),vy:0.4,life:12,col:b.trailCol,sz:1});
if(b.t==="nb"&&G.time%4===0)G.parts.push({x:b.x+rnd(-4,4),y:b.y+rnd(-4,4),vx:rnd(-0.3,0.3),vy:rnd(-0.3,0.3),life:14,col:pick(["#b98cff","#e0b3ff"]),sz:1});
// 电弧手里剑:一路前飞不停留,全程周期性放电
if(b.t==="shk"&&!b.dead){
b.pt2=(b.pt2||0)-1;
if(b.pt2<=0){b.pt2=b.pulse||20;
const tg=nearEnemies(b.x,b.y,55,3);
for(const t2 of tg){zapVisual(b.x,b.y,t2.x,t2.y,pick(["#7ce9ff","#ffffff"]));hurtT(t2,b.zapDmg,true);}
if(tg.length)SFX.graze();}
}
if(b.t==="dw"&&!b.dead){ // 黎明重炮近炸引信半径26无需直接命中
let pz=!!nearestEnemy(b.x,b.y,26);
if(!pz&&bossHit(b.x,b.y,26))pz=true;
if(pz){b.dead=true;explodeOnce(b);}}
if(b.t==="c"&&!b.dead&&b.y<H*0.5){ // 月牙斩:进入上半屏后弧线向中线聚拢
const ca=Math.atan2(b.vy,b.vx),ta=Math.atan2(-60-b.y,(W/2)-b.x);
let d4=ta-ca;while(d4>Math.PI)d4-=TAU;while(d4<-Math.PI)d4+=TAU;
const na=ca+clamp(d4,-0.035,0.035),ss=Math.hypot(b.vx,b.vy);
b.vx=Math.cos(na)*ss;b.vy=Math.sin(na)*ss;}
if(!b.back&&b.bt!==undefined){b.bt--;if(b.bt<=0&&!b.dead){b.dead=true;explodeOnce(b);}}
if(b.t==="mt"&&b.ty2!==undefined&&b.y<=b.ty2&&!b.dead){b.dead=true;explodeOnce(b);}
if(b.t==="s"&&!b.dead){ // 分裂弹:飞行一段距离后自动分裂
b.age=(b.age||0)+1;
if(b.age>=(b.splitAt||26)){b.dead=true;
for(let i=0;i<b.split;i++){const a=-Math.PI/2+(i-(b.split-1)/2)*0.35;
pbul(b.x,b.y,Math.cos(a)*4.5,Math.sin(a)*4.5,b.dmg*0.4,{r:2});}
spawnPart(b.x,b.y,"#5cff9d",4);}
}
b.x+=b.vx;b.y+=b.vy;b.life--;
if(b.life<=0&&!b.dead){b.dead=true;if(b.t==="bb"||b.t==="sg"||b.t==="ho"||b.t==="dw"||b.t==="ph"||b.t==="nb")explodeOnce(b);}
if(b.y<-16||b.y>H+16||b.x<-16||b.x>W+16)b.dead=true;
}
// 敌弹:热诱弹偏转(每枚热诱弹只锁定一颗敌弹)+ 时间力场减速
for(const f of G.flares)if(f.lock&&(f.lock.dead||dist2(f.x,f.y,f.lock.x,f.lock.y)>3600))f.lock=null;
for(const b of G.eb){
let esf=1;
for(const z of G.zones)if(z.slow&&dist2(b.x,b.y,z.x,z.y)<z.r*z.r){esf=0.45;break;}
let fl=null,fd=46*46;
for(const f of G.flares){if(f.lock&&f.lock!==b)continue;const d3=dist2(b.x,b.y,f.x,f.y);if(d3<fd){fd=d3;fl=f;}}
if(fl)fl.lock=b;
if(fl){const a=Math.atan2(fl.y-b.y,fl.x-b.x),c=Math.atan2(b.vy,b.vx);
let d4=a-c;while(d4>Math.PI)d4-=TAU;while(d4<-Math.PI)d4+=TAU;
const na=c+clamp(d4,-0.2,0.2),ss=Math.hypot(b.vx,b.vy);
b.vx=Math.cos(na)*ss;b.vy=Math.sin(na)*ss;
if(fd<36){b.dead=true;fl.dead=true;spawnPart(b.x,b.y,"#ffd23d",2);SFX.graze();}}
if(b.grow){b.scale=Math.min(b.maxS||2.6,(b.scale||1)+b.grow);b.r=2.4*b.scale;} // 花弹:越变越大(有上限)
if(b.rift){if(b.rift>0){b.rift--;b.vx=0;b.vy=0; // 亚空间裂隙:悬停预瞄
if(b.rift<=0){const ra2=Math.atan2(G.p.y-b.y,G.p.x-b.x);b.vx=Math.cos(ra2)*3.9;b.vy=Math.sin(ra2)*3.9;b.spr="dartS";b.scale=1;b.r=2.5;}}} // 倒计时尽化长钉射出
b.x+=b.vx*esf;b.y+=b.vy*esf;
if(b.y<-10||b.y>H+10||b.x<-10||b.x>W+10)b.dead=true;}
// 热诱弹
for(const f of G.flares){f.x+=f.vx||0;f.y+=(f.vy!==undefined?f.vy:0.35);f.life--;
if(G.time%6===0)G.parts.push({x:f.x+rnd(-3,3),y:f.y+rnd(-3,3),vx:0,vy:-0.3,life:10,col:pick(["#ffd23d","#ff9a3d"]),sz:1});}
G.flares=G.flares.filter(f=>f.life>0&&!f.dead&&f.y>-10&&f.y<H+10&&f.x>-10&&f.x<W+10);
G.pb=G.pb.filter(b=>!b.dead);
G.eb=G.eb.filter(b=>!b.dead);
// 区域效果(电浆/引力井)
for(const z of G.zones){z.life--;
if(z.vy)z.y+=z.vy; // 酸雾下飘
if(z.cd>0)z.cd--;
if(z.kind==="gas"&&G.p.dead<=0&&G.p.inv<=0&&dist2(G.p.x,G.p.y,z.x,z.y)<(z.r+G.stat.hitR)*(z.r+G.stat.hitR)&&!(z.cd>0)){z.cd=45;hitPlayer(12);} // 酸雾持续伤害
if(z.pull)for(const e of G.enemies)if(!e.dead){
const d2=dist2(z.x,z.y,e.x,e.y);
if(d2<(z.r+44)*(z.r+44)&&d2>1){const d=Math.sqrt(d2);e.x+=(z.x-e.x)/d*0.5;e.y+=(z.y-e.y)/d*0.5;}}
if(z.dps&&G.time%6===0){
for(const e of G.enemies)if(!e.dead&&dist2(z.x,z.y,e.x,e.y)<(z.r+e.r)*(z.r+e.r))hurtEnemy(e,z.dps*0.1,false);
if(G.boss&&bossHit(z.x,z.y,z.r))hurtBoss(z.dps*0.1);}
}
G.zones=G.zones.filter(z=>z.life>0&&z.y<H+40); // 酸雾飘出屏幕才清理
for(const z of G.zaps)z.life--;
G.zaps=G.zaps.filter(z=>z.life>0);
for(const n of G.novas){n.life--;n.r+=(n.maxr-n.r)*0.18;}
G.novas=G.novas.filter(n=>n.life>0);
// 圣光十字:逐渐变长 + 原地旋转90°周期性结算伤害
for(const hc of G.holies){hc.t++;hc.life--;
if(hc.t%9===0)crossBeams(hc.x,hc.y,hc.len*Math.min(1,hc.t/12),hc.dmg*0.4,(hc.t/36)*Math.PI/2);}
G.holies=G.holies.filter(h=>h.life>0);
// 黎明重炮余震:延迟二次爆炸
for(const sk of G.shocks){sk.t--;if(sk.t<=0&&!sk.done){sk.done=true;
areaDamage(sk.x,sk.y,sk.r,sk.dmg);explodeFx(sk.x,sk.y,"boom",0.7);G.shake=Math.max(G.shake,3);SFX.boom(false);}}
G.shocks=G.shocks.filter(s=>!s.done);
}
/* ================= 碰撞 ================= */
function collide(){
const p=G.p;
// 玩家子弹 vs 敌
for(const b of G.pb){
if(b.dead)continue;
for(const e of G.enemies){
if(e.dead)continue;
const rr=e.r+b.r+1;
if(dist2(b.x,b.y,e.x,e.y)<rr*rr){
// 粘着炸弹:命中即附着,不走普通结算
if(b.t==="tb"&&!b.stuck){
b.stuck=e;b.ox=b.x-e.x;b.oy=b.y-e.y;
SFX.hit();break;
}
if(b.pierce){if(!b.hits)b.hits={};const u2=e.uid||(e.uid=_uid++);
if(b.hits[u2]!==undefined)continue;
b.hits[u2]=1;}
else b.dead=true;
if(b.slow)e.slow=Math.max(e.slow||0,b.slow);
if(b.freeze){e.frozen=Math.max(e.frozen||0,b.freeze);e.slow=0;explodeFx(b.x,b.y,"ice",0.5);}
if(b.poison)e.dot={t:b.poison,dps:Math.max(0.6,b.dmg*0.12)};
if(b.aoe){for(const o of G.enemies)if(!o.dead&&dist2(b.x,b.y,o.x,o.y)<b.aoe*b.aoe)hurtEnemy(o,b.dmg*(o===e?1:0.5),true);
explodeFx(b.x,b.y,"boom",0.45);
if(b.aoe>=20){G.shake=Math.max(G.shake,3);}SFX.hit();}
else hurtEnemy(e,b.dmg,true);
if(b.t==="vl"){ // 电能机炮:命中后电弧连锁跳跃
let vcn=0;const vcm=b.chain||2;
for(const o of G.enemies){if(o.dead||o===e||vcn>=vcm)continue;
if(dist2(e.x,e.y,o.x,o.y)<2304){vcn++;
zapVisual(e.x,e.y,o.x,o.y,pick(["#7ce9ff","#b06cff"]));
hurtEnemy(o,b.dmg*0.5,true);}}
if(vcn)SFX.graze();}
if(b.t==="rg"&&!b.zoneUsed){b.zoneUsed=true;
G.zones.push({x:b.x,y:b.y,r:b.zone,dps:b.dmg*0.09,life:220,col:"#43e8ff",kind:"plasma"});
areaDamage(b.x,b.y,18,b.dmg*0.5);
explodeFx(b.x,b.y,"plasma",0.8);}
SFX.hit();
if(b.dead){if(b.t==="sg"||b.t==="ho"||b.t==="bb"||b.t==="am"||b.t==="tb"||b.t==="tb2"||b.t==="dw"||b.t==="ph"||b.t==="nb"||b.t==="ex"||b.t==="lm"||b.t==="lb")explodeOnce(b);break;}
}
}
if(!b.dead&&G.boss){
const B=G.boss;
if(bossHit(b.x,b.y,b.r)){
if(b.pierce){if(!b.hits)b.hits={};if(!b.hits.boss){b.hits.boss=1;hurtBoss(b.dmg);}}
else{b.dead=true;
if(b.t==="sg"||b.t==="ho"||b.t==="bb"||b.t==="am"||b.t==="tb"||b.t==="tb2"||b.t==="dw"||b.t==="ph"||b.t==="nb"||b.t==="ex"||b.t==="lm"||b.t==="lb")explodeOnce(b);
else hurtBoss(b.dmg);}
SFX.hit();
}
}
}
// 敌弹 vs 玩家(含擦弹)
if(p.dead<=0)for(const b of G.eb){
const d2=dist2(b.x,b.y,p.x,p.y);
const hr=G.stat.hitR+b.r;
if(d2<hr*hr){b.dead=true;hitPlayer(b.dmg);}
else if(!b.grazed&&d2<(BAL.player.grazeR+b.r)*(BAL.player.grazeR+b.r)){
b.grazed=true;G.score+=5;
G.od=Math.min(100,G.od+BAL.odGain.graze*(1+0.3*G.u.odcore));
SFX.graze();spawnPart(p.x+rnd(-6,6),p.y+rnd(-6,6),"#41e6ff",1);
}
}
// Boss光束预警后伤害点-线段距离判定,每束有命中冷却)
for(const bm of G.bbeams){
if(bm.src&&G.boss&&bm.src.b===G.boss){let ox=bm.src.ox||0,oy=bm.src.oy||0;
bm.x1=G.boss.x+ox;bm.y1=G.boss.y+oy;if(bm.src.followX2)bm.x2=bm.x1;
if(bm.src.sweep)bm.x2=bm.x1+Math.sin(G.time*0.014+(bm.src.swPh||0))*(bm.src.amp||55);} // 黑石要塞:亚空间主炮缓慢扫动
if(bm.tele>0){bm.tele--;continue;}
bm.life--;
if(bm.cd>0)bm.cd--;
if(p.dead<=0&&bm.cd<=0){
const dx=bm.x2-bm.x1,dy=bm.y2-bm.y1,L2=dx*dx+dy*dy||1;
const tt=clamp(((p.x-bm.x1)*dx+(p.y-bm.y1)*dy)/L2,0,1);
const cx=bm.x1+dx*tt,cy=bm.y1+dy*tt,br=bm.w/2+G.stat.hitR;
if(dist2(p.x,p.y,cx,cy)<br*br){bm.cd=30;hitPlayer(bm.dmg);}
}
}
G.bbeams=G.bbeams.filter(bm=>bm.life>0||bm.tele>0);
// 冲撞Boss撞击玩家
if(G.boss&&G.boss.dashSt&&G.boss.dashSt.ph==="go"&&p.dead<=0&&p.inv<=0){
if(bossHit(p.x,p.y,G.stat.hitR+2))hitPlayer(30);
}
// 敌机撞击(反伤棘甲)
if(p.dead<=0&&p.inv<=0)for(const e of G.enemies){
if(e.dead)continue;
const rr=e.r+G.stat.hitR+2;
if(dist2(e.x,e.y,p.x,p.y)<rr*rr){hurtEnemy(e,50,false);
if(G.subLoadout.includes("thorns"))hurtEnemy(e,15*G.subs.thorns*pDmgMul(),true);
hitPlayer(30);break;}
}
// 拾取
for(const k of G.picks){
k.t++;k.y+=k.vy;
const md=pMagnet(),d2=dist2(k.x,k.y,p.x,p.y);
if(p.dead<=0&&d2<md*md){const d=Math.sqrt(d2)||1;k.x+=(p.x-k.x)/d*3.4;k.y+=(p.y-k.y)/d*3.4;}
if(p.dead<=0&&d2<100){
k.dead=true;
if(k.kind==="gem"){addXp(G.mutator==="ARMOR"?1.3:1);SFX.pick();}
else if(k.kind==="coin"){addCoins(1);SFX.coin();}
else if(k.kind==="heart"){if(G.hp<G.maxHp){G.hp=Math.min(G.maxHp,G.hp+35);banner("结构修复 +35","#5cff9d",60);}else if(G.lives<G.stat.maxLives){G.lives++;banner("备用机体 +1","#5cff9d",60);}else addScore(500,p.x,p.y);SFX.pick();}
else if(k.kind==="odc"){G.od=Math.min(100,G.od+25);SFX.pick();}
}
if(k.y>H+10)k.dead=true;
}
G.picks=G.picks.filter(k=>!k.dead);
}
/* ================= 经验与三选一 ================= */
function addXp(n){
if(G.demoSim)return; // 演示模式不升级不抽卡
G.xp+=n;
while(G.xp>=G.xpNeed){G.xp-=G.xpNeed;G.level++;G.xpNeed=BAL.xpNeed(G.level);G.cardQ=(G.cardQ||0)+1;}
if(G.cardQ>0&&UI==="play")offerCards(false);
}
function buildPool(){
const pool=[];
for(const k of MAINS){
if(!G.weapons[k])pool.push({kind:"weapon",key:k,rare:WEAPONS[k].rare});
else if(G.weapons[k]<5)pool.push({kind:"wlvl",key:k,rare:WEAPONS[k].rare});}
for(const k of SUB_KEYS){
if(!G.subs[k])pool.push({kind:"sub",key:k,rare:SUBS[k].rare});
else if(G.subs[k]<SUBS[k].max)pool.push({kind:"slvl",key:k,rare:SUBS[k].rare});}
for(const k of DRONE_KEYS){
if(!G.drones[k])pool.push({kind:"drone",key:k,rare:DRONES[k].rare});
else if(G.drones[k]<DRONES[k].max)pool.push({kind:"dlvl",key:k,rare:DRONES[k].rare});}
for(const k of PAS_KEYS)if(G.u[k]<PASSIVES[k].max)pool.push({kind:"upg",key:k,rare:PASSIVES[k].rare});
return pool;
}
function rollCards(bossRare){
const pool=buildPool(),out=[];
for(let c=0;c<3;c++){
let w=bossRare?[0,68,32]:[BAL.rarity[0],BAL.rarity[1]+G.luck*2,BAL.rarity[2]+G.luck*1.5];
for(let tries=0;tries<8;tries++){
const tot=w[0]+w[1]+w[2];let r=rnd(tot);
const rare=r<w[0]?0:r<w[0]+w[1]?1:2;
const cand=pool.filter(p=>p.rare===rare&&!out.includes(p));
if(cand.length){out.push(pick(cand));break;}
if(tries===7){const rest=pool.filter(p=>!out.includes(p));if(rest.length)out.push(pick(rest));}
}
}
while(out.length<3)out.push({kind:"fill",key:["heal","coins","od"][out.length%3],rare:0});
return out;
}