starraid/js/combat.js
JOJO 58c5d9d35b Boss系统重做:多管血条/锁血变身/专属变形动画/签名技能/rush随机装备老虎机
- 8个Boss全部重做:主攻击模式+1~2种随机小攻击+二/三阶段变形动画
- 新增5个雷霆战机参考Boss:太空原种/暗黑突击/激光钢琴/夺权者X/暗黑魔碟
- 多管血条(蓝/红/橙)+锁血转场+入场不掉血(b.y<b.ty-1免疫)
- 签名技能:花弹成长/高机动瞄准弹/21键4模式激光/银钉无人机+巨宽激光压制/锥形弹环+眼激光
- 所有激光发射点跟随Boss移动(bossBeam src机制)
- Boss挑战模式:随机装备3主武Lv3+3副武Lv3+2僚机Lv2,可选老虎机抽奖动画
- rush模式Boss血量x10,击破计时存档
- 武器修正:黎明重炮近炸引信26px/死光伤害x2/椭圆判定bossHit等
2026-08-05 20:18:46 +08:00

333 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){bm.x1=G.boss.x+bm.src.ox;bm.y1=G.boss.y+bm.src.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;
}