Diablo2D — Shadows of Tristram: complete browser ARPG

- Isometric canvas renderer (depth-sorted, FOV/fog, additive lighting)
- 3 classes x 20 skills, 4 acts x 4 floors + boss lairs, torment I-X
- Diablo-style loot: rarities, affix tiers, 14 legendaries, vendor, stash
- Rogue camp with 6 NPCs: Charsi/Akara/Kashya/Cain/Gheed/storage
- NPC quest chain (accept -> hunt -> turn in) with rewards & gating
- Procedural WebAudio SFX + generative music, EN/VI localization
- Saves, settings, waypoints, hardcore mode, PWA manifest
- 93-assertion headless suite + browser E2E via CDP
This commit is contained in:
2026-08-23 06:59:36 +00:00
commit fc1fa2d51e
42 changed files with 11784 additions and 0 deletions
+609
View File
@@ -0,0 +1,609 @@
/* ============================================================
* Diablo2D — ai.js : monster behavior trees + boss scripts
* ============================================================ */
'use strict';
window.D2 = window.D2 || {};
(function (D2) {
let astarBudget = 0; // global per-frame budget for pathfinding
const rnd = Math.random;
function resetFrame() { astarBudget = 6; }
/* ---------------- main entry ---------------- */
function updateMonster(game, m, dt) {
if (m.dead) return;
/* statuses */
if (m.frozen > 0) {
m.frozen -= dt;
m.moving = false;
D2.combat.updateDots(game, m, dt, false);
return;
}
D2.combat.updateDots(game, m, dt, false);
/* regen elite */
if (m.regenPct && m.hp < m.maxHp) {
m.regenAcc += dt;
if (m.regenAcc >= 1) { m.hp = Math.min(m.maxHp, m.hp + m.maxHp * m.regenPct); m.regenAcc -= 1; }
}
const p = game.player;
if (!p || p.dead) { m.moving = false; return; }
const dx = p.x - m.x, dy = p.y - m.y;
const dist = Math.sqrt(dx * dx + dy * dy);
m.distToPlayer = dist;
/* aggro acquisition */
m.aggroCheckT = (m.aggroCheckT || 0) - dt;
if (!m.aggro && m.aggroCheckT <= 0) {
m.aggroCheckT = 0.35;
if (dist < D2.BAL.aggroRange &&
D2.path.hasLOS((x, y) => game.world.transparent(x, y),
m.x | 0, m.y | 0, p.x | 0, p.y | 0)) {
m.aggro = true;
if (m.isBoss && !m.introDone) bossIntro(game, m);
else if (!m.isBoss && rnd() < 0.25) game.sfx('die', { vol: 0.08 });
}
}
/* leash */
if (m.aggro && !m.isBoss) {
const homeDist = Math.hypot(m.x - m.homeX, m.y - m.homeY);
if (homeDist > D2.BAL.deaggroLeash) {
m.aggro = false;
m.hp = Math.min(m.maxHp, m.hp + m.maxHp * 0.3);
m.x = m.homeX; m.y = m.homeY;
}
}
/* cooldowns */
m.cdT -= dt;
m.specialCd -= dt;
if (m.attackAnim > 0) m.attackAnim -= dt;
if (m.hurtFlash > 0) m.hurtFlash -= dt;
if (m.chillT > 0) { m.chillT -= dt; if (m.chillT <= 0) m.chillSlow = 0; }
const speed = m.speed * (1 - (m.chillSlow || 0)) * (m.speedBuff || 1);
/* boss script takes priority */
if (m.isBoss) { bossUpdate(game, m, dt, dist, dx, dy); return; }
switch (m.def.ai) {
case 'melee': case 'swarm': meleeBrain(game, m, dt, dist, dx, dy, speed); break;
case 'flyer': flyerBrain(game, m, dt, dist, dx, dy, speed); break;
case 'ranged': rangedBrain(game, m, dt, dist, dx, dy, speed); break;
case 'caster': casterBrain(game, m, dt, dist, dx, dy, speed); break;
case 'summoner': summonerBrain(game, m, dt, dist, dx, dy, speed); break;
case 'charger': chargerBrain(game, m, dt, dist, dx, dy, speed); break;
default: meleeBrain(game, m, dt, dist, dx, dy, speed);
}
separation(game, m, dt);
}
/* ---------------- shared movement ---------------- */
function steer(game, m, dx, dy, speed, dt, ignoreWalls = false) {
const d = Math.hypot(dx, dy) || 1;
let vx = dx / d, vy = dy / d;
if (!ignoreWalls) {
/* try direct; if blocked ahead use pathfinding fallback */
const probeX = m.x + vx * 0.5, probeY = m.y + vy * 0.5;
if (!game.world.isWalkable(probeX, probeY)) {
if (astarBudget > 0) {
astarBudget--;
m.pathT -= 0.6; // force refresh
const path = D2.path.find(
(x, y) => game.world.isWalkable(x, y),
game.world.w, game.world.h,
m.x | 0, m.y | 0, (m.x + vx * 4) | 0, (m.y + vy * 4) | 0, 900);
m.path = path;
m.pathIdx = 0;
}
if (m.path && m.pathIdx < m.path.length) {
const node = m.path[m.pathIdx];
const ndx = node.x + 0.5 - m.x, ndy = node.y + 0.5 - m.y;
const nd = Math.hypot(ndx, ndy) || 1;
if (nd < 0.4) m.pathIdx++;
else { vx = ndx / nd; vy = ndy / nd; }
} else {
/* slide along wall */
const tx = -vy, ty = vx;
vx = tx; vy = ty;
}
}
}
D2.combat.tryMove(game, m, vx * speed * dt, vy * speed * dt * 0.55);
m.moving = true;
}
function separation(game, m, dt) {
const near = game.queryMonsters(m.x, m.y, m.radius + 0.55);
for (const o of near) {
if (o === m || o.dead) continue;
const dx = m.x - o.x, dy = m.y - o.y;
const d = Math.hypot(dx, dy) || 0.01;
const minD = m.radius + o.radius;
if (d < minD) {
const push = (minD - d) * 2.2;
D2.combat.tryMove(game, m, (dx / d) * push * dt * 8, (dy / d) * push * dt * 8 * 0.55);
}
}
}
function startWindup(m, duration) {
m.state = 'windup';
m.stateT = duration;
m.attackAnim = duration + 0.15;
}
/* ---------------- brains ---------------- */
function meleeBrain(game, m, dt, dist, dx, dy, speed) {
if (!m.aggro) { idleWander(game, m, dt, speed * 0.4); return; }
if (m.state === 'windup') {
m.stateT -= dt;
if (m.stateT <= 0) {
D2.combat.monsterMeleeHit(game, m);
m.state = 'chase';
m.cdT = m.attackCd * (m.cdMult || 1);
game.sfx('swing', { vol: 0.12 });
}
return;
}
if (dist <= m.attackRange + game.player.radius && m.cdT <= 0) {
startWindup(m, 0.32);
return;
}
if (dist > m.attackRange * 0.8) {
steer(game, m, dx, dy, speed, dt);
/* swarm zigzag */
if (m.def.ai === 'swarm' && rnd() < 0.05) {
D2.combat.tryMove(game, m, (rnd() - .5) * 0.3, (rnd() - .5) * 0.3);
}
} else {
m.moving = false;
}
}
function flyerBrain(game, m, dt, dist, dx, dy, speed) {
if (!m.aggro) { idleWander(game, m, dt, speed * 0.6); return; }
if (m.state === 'windup') {
m.stateT -= dt;
if (m.stateT <= 0) {
D2.combat.monsterMeleeHit(game, m);
m.state = 'chase';
m.cdT = m.attackCd * (m.cdMult || 1);
}
return;
}
if (dist <= m.attackRange + game.player.radius && m.cdT <= 0) {
startWindup(m, 0.26);
return;
}
/* flyers ignore walls */
const d = dist || 1;
D2.combat.tryMove(game, m, (dx / d) * speed * dt, (dy / d) * speed * dt * 0.55, true);
m.moving = true;
/* keep inside floor-ish area */
m.x = D2.util.clamp(m.x, 1, game.world.w - 1);
m.y = D2.util.clamp(m.y, 1, game.world.h - 1);
}
function rangedBrain(game, m, dt, dist, dx, dy, speed) {
if (!m.aggro) { idleWander(game, m, dt, speed * 0.4); return; }
const minBand = m.attackRange * 0.45;
if (m.state === 'windup') {
m.stateT -= dt;
if (m.stateT <= 0) {
D2.combat.fireMonsterProjectile(game, m);
m.state = 'chase';
m.cdT = m.attackCd * (m.cdMult || 1);
}
return;
}
if (dist <= m.attackRange && m.cdT <= 0 &&
D2.path.hasLOS((x, y) => game.world.transparent(x, y), m.x | 0, m.y | 0, game.player.x | 0, game.player.y | 0)) {
startWindup(m, 0.4);
return;
}
if (dist < minBand) steer(game, m, -dx, -dy, speed, dt);
else if (dist > m.attackRange) steer(game, m, dx, dy, speed, dt);
else m.moving = false;
}
function casterBrain(game, m, dt, dist, dx, dy, speed) {
if (!m.aggro) { idleWander(game, m, dt, speed * 0.4); return; }
/* blink away when pressured */
if (m.special === 'blinkAway' && dist < 2.6 && m.specialCd <= 0) {
blinkAway(game, m);
m.specialCd = 5;
return;
}
rangedBrain(game, m, dt, dist, dx, dy, speed);
}
function summonerBrain(game, m, dt, dist, dx, dy, speed) {
if (!m.aggro) { idleWander(game, m, dt, speed * 0.4); return; }
if (m.summon && m.specialCd <= 0) {
const minions = game.countMinionsOf(m.uid);
if (minions < m.summon.maxAlive) {
for (let i = 0; i < m.summon.count; i++) {
const ang = rnd() * Math.PI * 2;
const sx = m.x + Math.cos(ang) * 1.2, sy = m.y + Math.sin(ang) * 1.2;
if (D2.combat.canStand(game.world, sx, sy, 0.3)) {
game.spawnMonster(m.summon.species, sx, sy, { ownerUid: m.uid });
game.spawnParticles(sx, sy, { count: 8, color: '#b08aff', speed: 2.5, life: 0.5, size: 2.4, z: 8 });
}
}
game.sfx('buff', { vol: .3 });
m.specialCd = 7;
} else {
m.specialCd = 2;
}
return;
}
casterBrain(game, m, dt, dist, dx, dy, speed);
}
function chargerBrain(game, m, dt, dist, dx, dy, speed) {
/* mid-charge */
if (m.state === 'charging') {
m.stateT -= dt;
D2.combat.tryMove(game, m, m.chargeVX * dt, m.chargeVY * dt * 0.55, true);
m.moving = true;
game.spawnParticles(m.x, m.y, { count: 1, color: '#ff8a3a', speed: 1, life: 0.25, size: 2, z: 6 });
/* contact damage */
if (dist < m.radius + game.player.radius + 0.2 && !m.chargeHit) {
m.chargeHit = true;
D2.combat.monsterHitPlayer(game, m, 1.5, m.elem);
D2.render.addShake(0.25);
}
if (m.stateT <= 0) {
m.state = 'chase';
m.cdT = 1.0;
m.specialCd = 4.5;
}
return;
}
/* begin charge */
if (m.aggro && m.chargeRange && dist > 2.4 && dist < m.chargeRange && m.specialCd <= 0) {
const d = dist || 1;
m.chargeVX = (dx / d) * speed * 3.2;
m.chargeVY = (dy / d) * speed * 3.2 * 0.55;
m.state = 'charging';
m.stateT = 0.55;
m.chargeHit = false;
m.telegraphed = 0.25;
game.sfx('bossroar', { vol: 0.18 });
return;
}
meleeBrain(game, m, dt, dist, dx, dy, speed);
}
function idleWander(game, m, dt, speed) {
m.wanderT = (m.wanderT || 0) - dt;
if (m.wanderT <= 0) {
m.wanderT = 1.5 + rnd() * 2.5;
m.wanderDX = (rnd() - .5) * 2;
m.wanderDY = (rnd() - .5) * 2;
}
if (m.wanderDX || m.wanderDY) {
D2.combat.tryMove(game, m, m.wanderDX * speed * dt, m.wanderDY * speed * dt * 0.55);
m.moving = true;
} else m.moving = false;
}
function blinkAway(game, m) {
for (let tries = 0; tries < 12; tries++) {
const ang = rnd() * Math.PI * 2;
const d = 3.5 + rnd() * 3;
const nx = m.x + Math.cos(ang) * d, ny = m.y + Math.sin(ang) * d;
if (D2.combat.canStand(game.world, nx, ny, m.radius) &&
game.world.tileAt(nx | 0, ny | 0) === D2.world.T.FLOOR) {
game.spawnParticles(m.x, m.y, { count: 10, color: '#b08aff', speed: 2.5, life: 0.4, size: 2.4, z: 8 });
m.x = nx; m.y = ny;
game.spawnParticles(nx, ny, { count: 10, color: '#b08aff', speed: 2.5, life: 0.4, size: 2.4, z: 8 });
game.sfx('teleport', { vol: .3 });
return true;
}
}
return false;
}
/* ---------------- boss brain ---------------- */
function bossIntro(game, m) {
m.introDone = true;
m.aggro = true;
game.sfx('bossroar');
D2.render.addShake(0.5);
game.toast(D2.i18n.t('msg.boss_spawn'), 'bad');
game.showBossBar(m);
if (m.bossDef.intro) game.addFloatText(m.x, m.y - 1, m.bossDef.intro[D2.i18n.getLang()] || m.bossDef.intro.en, '#ff8a6a', 16);
}
function bossUpdate(game, m, dt, dist, dx, dy) {
const p = game.player;
const speed = m.speed * (1 - (m.chillSlow || 0)) * (m.speedBuff || 1);
/* phase 2 enrage */
if (!m.phase2 && m.hp < m.maxHp * 0.5 && m.attacks.includes('enrage')) {
m.phase2 = true;
m.speedBuff = 1.3;
m.cdMult = 0.72;
game.sfx('bossroar');
D2.render.addShake(0.5);
game.toast('⚠ ' + m.name + ' ⚠', 'bad');
game.spawnParticles(m.x, m.y, { count: 30, color: '#ff4a1a', speed: 6, life: 0.7, size: 3, z: 12 });
}
m.bossAtkT = (m.bossAtkT || 1.2) - dt;
/* executing a scripted attack sequence */
if (m.busyT > 0) {
m.busyT -= dt;
if (m.busyAttack) m.busyAttack(game, m, dt);
if (m.busyT <= 0 && m.busyEnd) { const f = m.busyEnd; m.busyEnd = null; f(); }
return;
}
/* pick an attack */
if (m.bossAtkT <= 0 && !p.dead) {
const chosen = pickBossAttack(game, m, dist);
if (chosen) {
m.bossAtkT = (m.isElite ? 1.2 : 1.7) * (m.cdMult || 1) + rnd() * 0.8;
return;
}
m.bossAtkT = 0.5;
}
/* default: chase & melee */
if (dist <= m.attackRange + p.radius && m.cdT <= 0) {
startWindup(m, 0.38);
m.pendingBasic = true;
} else if (m.state === 'windup') {
m.stateT -= dt;
if (m.stateT <= 0) {
D2.combat.monsterMeleeHit(game, m);
m.cdT = m.attackCd * (m.cdMult || 1) * 0.7;
m.state = 'chase';
game.sfx('swing', { vol: 0.2 });
}
return;
} else if (dist > m.attackRange * 0.85) {
steer(game, m, dx, dy, speed, dt);
} else m.moving = false;
}
function pickBossAttack(game, m, dist) {
const p = game.player;
const candidates = [];
for (const a of m.attacks || []) {
switch (a) {
case 'cleaveArc': if (dist < 3.2) candidates.push({ a, w: 3 }); break;
case 'chargeAt': if (dist > 3 && dist < 10) candidates.push({ a, w: 2.5 }); break;
case 'lunge': if (dist > 2 && dist < 5) candidates.push({ a, w: 2 }); break;
case 'boneSpearVolley': if (dist < 11) candidates.push({ a, w: 2.5 }); break;
case 'webVolley': if (dist < 10) candidates.push({ a, w: 2.5 }); break;
case 'summonFallen': if ((m.summonsDone || 0) < 2) candidates.push({ a, w: 1.5 }); break;
case 'summonSkeletons': candidates.push({ a, w: 1.6 }); break;
case 'spawnSpiderlings': candidates.push({ a, w: 1.8 }); break;
case 'curseRing': if (dist < 6) candidates.push({ a, w: 1.8 }); break;
case 'poisonPools': if (dist < 9) candidates.push({ a, w: 2.2 }); break;
case 'fireNovaRing': if (dist < 7) candidates.push({ a, w: 2.4 }); break;
case 'lightningSpiral': candidates.push({ a, w: 2 }); break;
case 'meteorRain': candidates.push({ a, w: 2.2 }); break;
}
}
if (!candidates.length) return null;
const total = candidates.reduce((s, c) => s + c.w, 0);
let roll = rnd() * total;
let chosen = candidates[candidates.length - 1];
for (const c of candidates) { roll -= c.w; if (roll <= 0) { chosen = c; break; } }
execBossAttack(game, m, chosen.a, dist);
return chosen.a;
}
function execBossAttack(game, m, attack, dist) {
const p = game.player;
switch (attack) {
case 'cleaveArc': {
m.attackAnim = 0.5;
game.sfx('bossroar', { vol: 0.25 });
busy(m, 0.5, () => {
const baseAng = Math.atan2(p.y - m.y, p.x - m.x);
game.addEffect({
type: 'swingArc', x: m.x, y: m.y, t: 0, dur: 0.22,
ang: baseAng, arc: Math.PI, range: 3.2, color: '#ff8a5a',
});
const pd = D2.util.dist(p.x, p.y, m.x, m.y);
const ang = Math.atan2(p.y - m.y, p.x - m.x);
if (pd < 3.4 && Math.abs(D2.util.wrapAngle(ang - baseAng)) < Math.PI / 2) {
D2.combat.monsterHitPlayer(game, m, 1.6, null);
D2.render.addShake(0.35);
}
});
break;
}
case 'chargeAt': case 'lunge': {
const d = dist || 1;
const spd = attack === 'chargeAt' ? 14 : 11;
m.chargeVX = (p.x - m.x) / d * spd;
m.chargeVY = (p.y - m.y) / d * spd * 0.55;
m.chargeHit = false;
m.busyT = attack === 'chargeAt' ? 0.6 : 0.4;
m.busyAttack = (g, mm, dt) => {
D2.combat.tryMove(g, mm, mm.chargeVX * dt, mm.chargeVY * dt * 0.55, true);
g.spawnParticles(mm.x, mm.y, { count: 2, color: '#ff6a3a', speed: 2, life: 0.3, size: 2.4, z: 8 });
const dd = D2.util.dist(p.x, p.y, mm.x, mm.y);
if (dd < mm.radius + p.radius + 0.3 && !mm.chargeHit) {
mm.chargeHit = true;
D2.combat.monsterHitPlayer(g, mm, 1.5, null);
D2.render.addShake(0.4);
}
mm.moving = true;
};
game.sfx('bossroar', { vol: 0.2 });
break;
}
case 'summonFallen': case 'summonSkeletons': case 'spawnSpiderlings': {
const speciesMap = { summonFallen: 'fallen', summonSkeletons: 'skeleton', spawnSpiderlings: 'tomb_spider' };
const species = speciesMap[attack];
const n = attack === 'spawnSpiderlings' ? 4 : 3;
m.summonsDone = (m.summonsDone || 0) + 1;
game.sfx('buff');
for (let i = 0; i < n; i++) {
const ang = (i / n) * Math.PI * 2;
const sx = m.x + Math.cos(ang) * 2, sy = m.y + Math.sin(ang) * 2 * 0.6;
if (D2.combat.canStand(game.world, sx, sy, 0.3))
game.spawnMonster(species, sx, sy, { ownerUid: m.uid, aggroed: true });
}
game.spawnParticles(m.x, m.y, { count: 20, color: '#b08aff', speed: 4, life: 0.6, size: 2.6, z: 10 });
busy(m, 0.7);
break;
}
case 'frenzy': case 'enrage': break; // handled in phase logic
case 'boneSpearVolley': {
m.attackAnim = 0.45;
const n = m.phase2 ? 7 : 5;
const base = Math.atan2(p.y - m.y, p.x - m.x);
for (let i = 0; i < n; i++) {
const a = base + (i / (n - 1) - 0.5) * 0.9;
game.projectiles.push(new D2.entities.Projectile({
x: m.x, y: m.y,
vx: Math.cos(a) * 7.5, vy: Math.sin(a) * 7.5 * 0.55,
speed: 7.5, dmg: m.dmg * 0.85, elem: 'phys', fromPlayer: false,
visual: 'bolt', color: '#d8e8b0', life: 2.2,
}));
}
game.sfx('shoot', { vol: .4 });
busy(m, 0.45);
break;
}
case 'webVolley': {
const n = 3;
const base = Math.atan2(p.y - m.y, p.x - m.x);
for (let i = 0; i < n; i++) {
const a = base + (i - 1) * 0.28;
game.projectiles.push(new D2.entities.Projectile({
x: m.x, y: m.y,
vx: Math.cos(a) * 6, vy: Math.sin(a) * 6 * 0.55,
speed: 6, dmg: m.dmg * 0.6, elem: 'phys', fromPlayer: false,
visual: 'orb', color: '#d8f0c8',
slow: 0.6, slowDur: 2.2, life: 2.4,
}));
}
game.sfx('poison', { vol: .4 });
busy(m, 0.5);
break;
}
case 'curseRing': {
game.sfx('nova', { vol: .5 });
game.addEffect({
type: 'telegraph', x: m.x, y: m.y, t: 0, delay: 0.9, radius: 5.5, color: '#b08aff',
onDone: () => {
game.addEffect({ type: 'nova', x: m.x, y: m.y, t: 0, dur: 0.5, radius: 5.5, color: '#b08aff', lightPunch: 6 });
const dd = D2.util.dist(p.x, p.y, m.x, m.y);
if (dd <= 5.5) {
D2.combat.monsterHitPlayer(game, m, 1.1, null);
game.player.chillT = 2; game.player.chillSlow = 0.4;
}
},
});
busy(m, 0.9);
break;
}
case 'poisonPools': {
game.sfx('poison');
for (let i = 0; i < 3; i++) {
const ox = (i - 1) * 1.8 + (rnd() - .5);
const oy = (rnd() - .5) * 2;
game.addEffect({
type: 'groundAoE', x: p.x + ox, y: p.y + oy, t: 0,
delay: 0.55, duration: 3.5, tickT: 0,
radius: 1.6, dmgPct: m.dmg * 0.5, elem: 'pois', fromMonster: true,
color: '#8adf5a', lightPunch: 2,
});
}
busy(m, 0.55);
break;
}
case 'fireNovaRing': {
game.sfx('nova');
const waves = [[2.6, 0], [4.2, 0.45], [5.8, 0.9]];
for (const [radius, delay] of waves) {
game.addEffect({
type: 'telegraph', x: m.x, y: m.y, t: 0, delay: delay + 0.35, radius, color: '#ff6a2a',
onDone: () => {
game.addEffect({ type: 'nova', x: m.x, y: m.y, t: 0, dur: 0.5, radius, color: '#ff6a2a', lightPunch: radius });
const dd = D2.util.dist(p.x, p.y, m.x, m.y);
if (Math.abs(dd - radius) < 1.1 || dd < radius * 0.4) {
D2.combat.monsterHitPlayer(game, m, 1.0, 'fire');
}
game.spawnParticles(m.x, m.y, { count: 18, color: '#ff8a3a', speed: 6, life: 0.5, size: 2.8, z: 10 });
},
});
}
busy(m, 1.3);
break;
}
case 'lightningSpiral': {
game.sfx('lightning');
const shots = m.phase2 ? 30 : 22;
const durTotal = 1.3;
for (let i = 0; i < shots; i++) {
const angle = (i / shots) * Math.PI * 4; // double spiral
game.addEffect({
type: 'timed', t: 0, endAt: 0.15 + (i / shots) * durTotal, fired: false,
fire: (g) => {
const a = angle + g.time * 2;
g.projectiles.push(new D2.entities.Projectile({
x: m.x, y: m.y,
vx: Math.cos(a) * 6.5, vy: Math.sin(a) * 6.5 * 0.55,
speed: 6.5, dmg: m.dmg * 0.55, elem: 'lit', fromPlayer: false,
visual: 'bolt', color: '#ffee6a', life: 1.8,
}));
if (i % 6 === 0) game.sfx('lightning', { vol: .2 });
},
});
}
busy(m, durTotal + 0.2);
break;
}
case 'meteorRain': {
game.sfx('fireball');
const n = m.phase2 ? 6 : 4;
for (let i = 0; i < n; i++) {
game.addEffect({
type: 'telegraph',
x: p.x + (rnd() - .5) * 6, y: p.y + (rnd() - .5) * 4,
t: 0, delay: 0.7 + i * 0.22, radius: 1.7, color: '#ff5a1a',
onDone: (fx) => {
D2.combat.dealRadiusDamage(game, fx.x, fx.y, 1.7, m.dmg * 1.2, { elem: 'fire', fromMonster: true });
game.spawnParticles(fx.x, fx.y, { count: 14, color: '#ff8a3a', speed: 5, life: 0.5, size: 2.8, z: 12, gravity: 10 });
game.sfx('explode', { vol: .4 });
},
});
}
busy(m, 1.6);
break;
}
}
}
function busy(m, t, onEnd) {
m.busyT = t;
m.busyAttack = null;
m.busyEnd = onEnd || null;
}
D2.ai = { updateMonster, resetFrame, blinkAway };
})(window.D2);