starraid/js/combat.js
JOJO 3ea4bc5a73 黑石要塞重做v2:俯视五芒装甲星堡,四层石色递进+臂间小堡+同心堡垒+窗火
- 造型:五芒装甲臂(双瓣外壳+板面层次+棱线高光+臂尖绿晶)+臂间小堡次剪影+同心八角堡垒+堡沿窗火闪烁+四瓣穹顶覆盖亚空间核心;去掉俯视图不该有的尖塔
- 攻击:单臂光矛依序绕堡充能与全体臂尖黑石碎片幕交替(40帧/轮);新增shardB碎片弹(深色绿芯、按飞行方向偏转);P2亚空间主炮不变
- 二阶段:装甲瓣沿中线微开+穹顶对角滑开,露出壳下常存绿光
2026-08-05 23:05:02 +08:00

335 lines
16 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(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;} // 花弹:越变越大(有上限)
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.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);
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")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")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;
if(bm.src.arm!==undefined){const ra=G.boss.t*0.0032+bm.src.arm*TAU/5;ox=Math.sin(ra)*32;oy=-Math.cos(ra)*32;} // 黑石要塞:臂尖随整体旋转
bm.x1=G.boss.x+ox;bm.y1=G.boss.y+oy;if(bm.src.followX2)bm.x2=bm.x1;} // 发射点跟随Boss炮台
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;
}