/* ============================================================ * 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);