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:
+609
@@ -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);
|
||||
Reference in New Issue
Block a user