/* ============================================================ * Diablo2D — combat.js : damage pipeline & skill implementation * ============================================================ */ 'use strict'; window.D2 = window.D2 || {}; (function (D2) { const BAL = () => D2.BAL; const rnd = Math.random; const ri = (a, b) => a + Math.floor(rnd() * (b - a + 1)); /* ================= player damage ================= */ function mainStatPct(player) { const s = player.stats; const cls = player.classId; if (cls === 'crusader') return s.str * BAL().meleeDmgPerStr; if (cls === 'ranger') return s.dex * BAL().rangedDmgPerDex; return s.ene * BAL().castDmgPerEne; } function weaponDamageRange(player) { const w = player.equip.weapon; const s = player.stats; let min = 2, max = 4; if (w && w.dmgMax) { min = w.dmgMin; max = w.dmgMax; } if (s.flatDmg) { min += s.flatDmg; max += s.flatDmg; } const mult = 1 + mainStatPct(player) / 100 + (s.dmgPct || 0) / 100; return { min: Math.max(1, Math.round(min * mult)), max: Math.max(2, Math.round(max * mult)) }; } function rollPlayerHit(player, dmgPct, opts = {}) { const r = weaponDamageRange(player); let dmg = (ri(r.min, r.max)) * (dmgPct / 100); const s = player.stats; const elem = opts.elem || 'phys'; if (elem === 'fire' && s.fireDmg) dmg += s.fireDmg; else if (elem === 'cold' && s.coldDmg) dmg += s.coldDmg; else if (elem === 'lit' && s.litDmg) dmg += s.litDmg; else if (elem === 'pois' && s.poisDmg) dmg += s.poisDmg; else if (elem === 'phys') { dmg += ((s.fireDmg || 0) + (s.coldDmg || 0) + (s.litDmg || 0) + (s.poisDmg || 0)) * 0.5; } /* elite slayer */ if (opts.target && opts.target.isElite && s.eliteDmgPct) dmg *= 1 + s.eliteDmgPct / 100; /* last stand */ const hpPct = player.hp / player.maxHp; if (s.lowLifeDmg && hpPct < 0.35) dmg *= 1 + s.lowLifeDmg / 100; /* crit */ let crit = false; if (!opts.noCrit) { const cc = (s.critChance || 0) / 100 + BAL().baseCrit; if (opts.guaranteedCrit || rnd() < Math.min(cc, BAL().critCap)) { crit = true; dmg *= 1 + BAL().baseCritDmg - 1 + (s.critDmgPct || 0) / 100; } } return { dmg: Math.max(1, Math.round(dmg)), crit }; } /* ================= damage application ================= */ function applyToMonster(game, m, hit, opts = {}) { if (m.dead) return 0; let dmg = hit.dmg; /* monster armor: implicit via level, mild */ const armor = BAL().mArmor(m.level) * (m.isBoss ? 1.6 : 1); const dr = armor / (armor + 40 + 8 * (game.player ? game.player.level : 10)); dmg *= (1 - dr * 0.5); /* resists */ const elem = opts.elem || 'phys'; if (elem !== 'phys' && m.resists && m.resists[elem]) dmg *= (1 - m.resists[elem]); dmg = Math.max(1, Math.round(dmg)); m.hp -= dmg; m.hurtFlash = BAL().hitFlashTime; /* floating number */ game.addFloatText(m.x, m.y, (hit.crit ? '' : '') + dmg, hit.crit ? '#ffd24a' : elemColor(opts.elem), hit.crit ? 16 : 12); if (hit.crit) { game.sfx('crit', { vol: .5 }); D2.render.addShake(0.12); } else { game.sfx(opts.silentHit ? null : 'hit', { vol: .35 }); } /* blood particles */ game.spawnParticles(m.x, m.y, { count: hit.crit ? 10 : 6, color: elemBlood(opts.elem), speed: 3.5, life: 0.5, size: 2.6, z: 8, gravity: 14, }); /* knockback */ if (opts.knockback && !m.isBoss) { const ang = Math.atan2(m.y - (opts.fromY ?? m.y - 1), m.x - (opts.fromX ?? m.x)); const kb = opts.knockback; tryMove(game, m, Math.cos(ang) * kb, Math.sin(ang) * kb, true); } /* on-hit procs from player */ if (opts.src === 'player') { const s = game.player.stats; /* lifesteal legendary */ if (s.lifestealPct) healPlayer(game, dmg * s.lifestealPct); /* chain on hit */ if (s.chainOnHitPct && rnd() < s.chainOnHitPct) chainZap(game, m, 2, hit.dmg * 0.4); /* venom strike */ if (s.venomOnHit) applyDot(game, m, { elem: 'pois', dps: dmg * 0.25, dur: 3 }); /* bleed passive */ if (s.bleedOnHitPct && !opts.noBleed) { applyDot(game, m, { elem: 'phys', dps: weaponDamageRange(game.player).max * s.bleedOnHitPct / 100 / 3, dur: 3, bleed: true }); } /* chill attacker flag on melee (handled in ai) */ } if (m.hp <= 0) killMonster(game, m, { skillId: opts.skillId }); else { /* aggro on damage */ m.aggro = true; if (m.state === 'idle') m.state = 'chase'; } return dmg; } function elemColor(elem) { return { fire: '#ff8a4a', cold: '#8ad8ff', lit: '#ffee6a', pois: '#9adf3a', holy: '#ffe8b0', phys: '#f2ece0' }[elem] || '#f2ece0'; } function elemBlood(elem) { return { fire: '#ff6a2a', cold: '#a8e0ff', lit: '#fff0a0', pois: '#8adf5a', holy: '#ffe8b0', phys: '#a8231a' }[elem] || '#a8231a'; } function healPlayer(game, amount) { const p = game.player; if (p.dead) return; const before = p.hp; p.hp = Math.min(p.maxHp, p.hp + amount); if (p.hp - before >= 1) { game.addFloatText(p.x, p.y - 0.3, '+' + Math.round(p.hp - before), '#7ade8a', 11); } } /* chain lightning proc between monsters */ function chainZap(game, from, jumps, dmg) { let cur = from; const hitSet = new Set([from.uid]); for (let i = 0; i < jumps; i++) { const next = game.nearestMonster(cur.x, cur.y, 5.5, m => !m.dead && !hitSet.has(m.uid)); if (!next) break; hitSet.add(next.uid); /* zap visual */ for (let k = 0; k < 6; k++) { game.spawnParticles(D2.util.lerp(cur.x, next.x, k / 6), D2.util.lerp(cur.y, next.y, k / 6), { count: 1, color: '#ffee6a', speed: 0.5, life: 0.18, size: 2.2, z: 10, }); } applyToMonster(game, next, { dmg: Math.round(dmg), crit: false }, { elem: 'lit', src: 'player', silentHit: true }); cur = next; dmg *= 0.75; } } /* ================= monster → player ================= */ function monsterHitPlayer(game, m, mult = 1, elem = null) { const p = game.player; if (p.dead) return; const s = p.stats; /* dodge */ const dodge = Math.min(0.5, (s.dodgePct || 0) / 100 + p.level * BAL().dodgePerDex); if (rnd() < dodge) { game.addFloatText(p.x, p.y, 'dodge', '#9ab0c8', 11); return; } /* block */ if (s.blockChance && rnd() * 100 < s.blockChance) { game.addFloatText(p.x, p.y, 'block', '#c8a35a', 12); game.sfx('hit', { vol: .25 }); return; } let dmg = m.dmg * mult; const e = elem || m.elem; if (e && e !== 'phys') { const resKey = { fire: 'resFire', cold: 'resCold', lit: 'resLit', pois: 'resPois' }[e]; const res = Math.min(BAL().resistCap, (s[resKey] || 0) / 100); dmg *= (1 - res); } /* armor DR */ const armor = s.armor; const dr = armor / (armor + BAL().armorK * m.level); dmg *= (1 - Math.min(BAL().armorDrCap, dr)); /* last stand DR */ if (s.lowLifeDR && p.hp / p.maxHp < 0.35) dmg *= 1 - s.lowLifeDR / 100; /* smoke veil dodge already; flat reduction none */ dmg = Math.max(1, Math.round(dmg)); p.hp -= dmg; p.hurtFlash = BAL().hitFlashTime; game.addFloatText(p.x, p.y, '-' + dmg, '#ff6a5a', 13); game.sfx('playerhurt', { vol: .5 }); D2.render.addShake(0.18); game.spawnParticles(p.x, p.y, { count: 5, color: '#a8231a', speed: 3, life: 0.4, size: 2.4, z: 8, gravity: 14 }); /* chill attacker response (frostweave) */ if (s.chillAttacker && !m.chilledByPlayer) { m.chillT = 2; m.chillSlow = 0.35; m.chilledByPlayer = true; setTimeoutSafe(() => { m.chilledByPlayer = false; }, 2500); } /* vampiric monster heals */ if (m.lifestealPct) m.hp = Math.min(m.maxHp, m.hp + dmg * m.lifestealPct); if (p.hp <= 0) game.onPlayerDeath(); } function setTimeoutSafe(fn, ms) { if (typeof setTimeout === 'function') setTimeout(fn, ms); } /* ================= kill & rewards ================= */ function killMonster(game, m, opts = {}) { if (m.dead) return; m.dead = true; m.dying = true; const p = game.player; /* death effects */ if (m.deathNova) { game.addEffect({ type: 'nova', x: m.x, y: m.y, t: 0, dur: 0.5, radius: m.deathNova.radius, color: m.deathNova.elem === 'cold' ? '#8ad8ff' : '#ff8a3a', onDone: () => dealRadiusDamage(game, m.x, m.y, m.deathNova.radius, m.dmg * 1.2, { elem: m.deathNova.elem, fromMonster: true, chill: m.deathNova.chill }), }); } if (m.corpsePoison) { game.addGroundDecal(m.x, m.y, 1.4, 'rgba(120,220,80,.25)', 3); game.addEffect({ type: 'nova', x: m.x, y: m.y, t: 0, dur: 0.4, radius: 1.6, color: '#8adf5a', onDone: () => dealRadiusDamage(game, m.x, m.y, 1.6, m.dmg, { elem: 'pois', fromMonster: true }), }); } if (m.explodeOnDeath) { const ex = m.explodeOnDeath; game.addEffect({ type: 'nova', x: m.x, y: m.y, t: 0, dur: 0.45, radius: ex.radius, color: ex.elem === 'pois' ? '#8adf5a' : '#ff8a3a', onDone: () => dealRadiusDamage(game, m.x, m.y, ex.radius, m.dmg * ex.dmgMult, { elem: ex.elem, fromMonster: true }), }); } /* corpse decal + gib particles */ game.addGroundDecal(m.x, m.y, m.radius * 1.3, 'rgba(90,16,10,.5)', BAL().corpseTime); game.spawnParticles(m.x, m.y, { count: m.isBoss ? 40 : 12, color: '#7a1a10', speed: m.isBoss ? 7 : 4.5, life: 0.8, size: 3, z: 10, gravity: 16, }); game.sfx(m.isBoss ? 'bossdie' : 'die', { vol: m.isBoss ? 0.8 : 0.45 }); /* rewards */ const xpGain = Math.round(m.xpReward * (1 + (p.buffMod('xpGain') || 0))); game.gainXp(xpGain, m); if (game.onQuestKill) game.onQuestKill(m); p.kills++; if (m.isElite) { p.eliteKills++; game.toast(D2.i18n.t('msg.elite_slain'), 'gold'); } if (m.isBoss) { p.bossKills++; game.onBossKilled(m); } /* life on kill */ const s = p.stats; if (s.lifeOnKill) healPlayer(game, s.lifeOnKill); if (s.execHeal && (m.isElite || m.isBoss)) healPlayer(game, p.maxHp * 0.15); if (s.goldPerKill) game.giveGold(Math.round(s.goldPerKill)); /* legendary on-kill effects */ if (s.fireNovaOnKill) { game.addEffect({ type: 'nova', x: m.x, y: m.y, t: 0, dur: 0.4, radius: 2.2, color: '#ff8a3a', onDone: () => dealRadiusDamage(game, m.x, m.y, 2.2, weaponDamageRange(p).max * 1.2, { elem: 'fire', src: 'player' }), }); } if (s.corpseBoom && rnd() < 0.2) { dealRadiusDamage(game, m.x, m.y, 2.0, weaponDamageRange(p).max, { elem: 'phys', src: 'player' }); game.spawnParticles(m.x, m.y, { count: 16, color: '#ff8a3a', speed: 5, life: 0.5, size: 2.6, z: 8 }); } /* loot */ if (D2.loot) D2.loot.dropFor(game, m); if (m.isBoss) { D2.render.addShake(0.6); game.addLight(m.x, m.y, 8, '#ff8a3a', 0.9, 1.2); } game.onMonsterRemoved(m); } /* AoE damage helper */ function dealRadiusDamage(game, x, y, radius, dmg, opts = {}) { if (opts.fromMonster) { const p = game.player; if (p && !p.dead) { const d = D2.util.dist(p.x, p.y, x, y); if (d <= radius + p.radius) monsterHitPlayer(game, { dmg: dmg, level: opts.level || game.player.level, elem: opts.elem }, 1, opts.elem); } } else { const hits = game.queryMonsters(x, y, radius + 0.4); for (const m of hits) { applyToMonster(game, m, rollPlayerHit(game.player, dmg, { elem: opts.elem, noCrit: true, target: m }), { elem: opts.elem, src: 'player' }); } } } /* ================= dots & statuses ================= */ function applyDot(game, target, dot) { target.dots = target.dots || []; const existing = target.dots.find(d => d.elem === dot.elem); if (existing) { existing.dps = Math.max(existing.dps, dot.dps); existing.t = Math.max(existing.t, dot.dur); } else target.dots.push({ elem: dot.elem, dps: dot.dps, t: dot.dur, bleed: dot.bleed }); } function updateDots(game, target, dt, isPlayer) { if (!target.dots || !target.dots.length) return; for (let i = target.dots.length - 1; i >= 0; i--) { const d = target.dots[i]; d.t -= dt; const tick = d.dps * dt; if (isPlayer) { target.hp -= tick; if (target.hp <= 0) game.onPlayerDeath(); } else { target.hp -= tick; if (rnd() < dt * 3) game.addFloatText(target.x, target.y, Math.round(d.dps), elemColor(d.elem), 10); if (target.hp <= 0) killMonster(game, target, {}); } if (d.t <= 0) target.dots.splice(i, 1); } } /* ================= movement helper ================= */ function tryMove(game, e, dx, dy, ignoreProps = false) { const w = game.world; const nx = e.x + dx, ny = e.y + dy; const r = e.radius * 0.9; /* axis-separated collision */ if (canStand(w, nx, e.y, r, ignoreProps)) e.x = nx; if (canStand(w, e.x, ny, r, ignoreProps)) e.y = ny; } function canStand(world, x, y, r, ignoreProps) { for (const [ox, oy] of [[r, 0], [-r, 0], [0, r], [0, -r], [r * .7, r * .7], [-r * .7, r * .7], [r * .7, -r * .7], [-r * .7, -r * .7]]) { const tx = Math.floor(x + ox), ty = Math.floor(y + oy); if (world.tileAt(tx, ty) !== D2.world.T.FLOOR) return false; if (!ignoreProps && world.propMap.has(tx + ',' + ty)) return false; } return true; } /* ================= player skills ================= */ function playerBasicAttack(game, aimX, aimY) { const p = game.player; if (p.attackCd > 0 || p.dead || p.channel) return; const cls = D2.Skills.CLASSES[p.classId]; const basic = cls.basic; if (basic.castType === 'meleeArc') { doMeleeArc(game, p, aimX, aimY, basic.params.dmgPct, basic.params.arcDeg, basic.params.range); } else { const ang = Math.atan2(aimY - p.y, aimX - p.x); fireProjectiles(game, p, ang, basic.params, 100); if (p.stats.doubleShot) { const ang2 = ang + 0.12; fireProjectiles(game, p, ang2, { ...basic.params, count: 1, spreadDeg: 0 }, 60); } game.sfx('shoot', { vol: .3 }); } p.attackAnim = 0.28; p.attackCd = 1 / ((p.equip.weapon ? p.equip.weapon.aps : 1.3) * (1 + (p.stats.attackSpeedPct || 0) / 100)); } function doMeleeArc(game, p, aimX, aimY, dmgPct, arcDeg, range, opts = {}) { const baseAng = Math.atan2(aimY - p.y, aimX - p.x); const halfArc = (arcDeg * Math.PI / 180) / 2; const hits = game.queryMonsters(p.x, p.y, range + 0.5); let any = false; for (const m of hits) { const ang = Math.atan2(m.y - p.y, m.x - p.x); if (Math.abs(D2.util.wrapAngle(ang - baseAng)) <= halfArc && D2.util.dist(p.x, p.y, m.x, m.y) <= range + m.radius) { const hit = rollPlayerHit(p, dmgPct, { elem: opts.elem, target: m, guaranteedCrit: p.guaranteedCritT > 0 }); applyToMonster(game, m, hit, { elem: opts.elem, src: 'player', fromX: p.x, fromY: p.y, knockback: opts.knockback, skillId: opts.skillId }); if (opts.stun && !m.isBoss) { m.frozen = Math.max(m.frozen, opts.stun); } any = true; } } p.guaranteedCritT = 0; game.sfx(any ? 'hit' : 'swing', { vol: .4 }); /* swing arc visual */ game.addEffect({ type: 'swingArc', x: p.x, y: p.y, t: 0, dur: 0.18, ang: baseAng, arc: arcDeg * Math.PI / 180, range, color: opts.elem ? elemColor(opts.elem) : '#e8d8b0', }); /* smash barrels & urns in the swing */ if (game.world && game.world.props) { for (const pr of [...game.world.props]) { if (pr.type !== 'barrel' && pr.type !== 'urn') continue; const cx = pr.x + 0.5, cy = pr.y + 0.5; const d = D2.util.dist(p.x, p.y, cx, cy); if (d < range + 0.5) { const ang = Math.atan2(cy - p.y, cx - p.x); if (Math.abs(D2.util.wrapAngle(ang - baseAng)) < halfArc + 0.3) { breakProp(game, pr); } } } } return any; } function fireProjectiles(game, p, baseAng, params, dmgPct) { const n = params.count || 1; const spread = (params.spreadDeg || 0) * Math.PI / 180; const elem = params.elem || (p.classId === 'sorceress' ? 'lit' : 'phys'); for (let i = 0; i < n; i++) { const off = n > 1 ? (i / (n - 1) - 0.5) * spread : 0; const a = baseAng + off; const hit = rollPlayerHit(p, dmgPct, { elem, noCrit: false }); const pr = new D2.entities.Projectile({ x: p.x + Math.cos(a) * 0.4, y: p.y + Math.sin(a) * 0.4, vx: Math.cos(a) * (params.speed || 10), vy: Math.sin(a) * (params.speed || 10) * 0.55, speed: params.speed || 10, dmg: hit.dmg, crit: hit.crit, elem, fromPlayer: true, pierce: !!params.pierce, maxHits: params.pierce ? 99 : 1, visual: elem === 'phys' ? (p.classId === 'ranger' ? 'arrow' : 'knife') : 'orb', color: elemColor(elem), size: params.size || 1, slow: params.slow || 0, slowDur: params.slowDur || 0, bleed: !!params.bleed, freeze: params.freeze || 0, life: 1.6, }); game.projectiles.push(pr); } } /* main skill cast dispatcher */ function castSkill(game, player, skill, rank, aimX, aimY) { if (player.dead || player.channel) return false; const params = skill.params(rank); const manaCost = skill.manaCost(rank); if (player.mana < manaCost) { game.toast(D2.i18n.t('msg.not_enough_mana'), 'bad'); game.sfx('error'); return false; } const cdLeft = game.skillCooldownLeft(skill.id); if (cdLeft > 0) { game.sfx('error'); return false; } player.mana -= manaCost; const cdr = Math.min(BAL().cooldownReductionCap, (player.stats.cdrPct || 0) / 100); game.setSkillCooldown(skill.id, skill.cooldown * (1 - cdr)); const elem = params.elem || (skill.branch === 'pyro' ? 'fire' : skill.branch === 'cryo' ? 'cold' : skill.branch === 'storm' ? 'lit' : null); switch (skill.castType) { case 'meleeArc': doMeleeArc(game, player, aimX, aimY, params.dmgPct, params.arcDeg, params.range, { elem, knockback: params.knockback, stun: params.stun, skillId: skill.id }); if (player.stats.cleaveEcho) { setTimeoutSafe(() => { if (!game.player.dead) doMeleeArc(game, game.player, aimX, aimY, params.dmgPct * 0.5, params.arcDeg, params.range + 0.3, { elem, skillId: skill.id, noCrit: true }); }, 140); } game.sfx('swing', { vol: .5 }); break; case 'whirlwind': player.channel = { type: 'whirlwind', t: params.duration, tick: 0, params, skillId: skill.id, elem, }; game.sfx('nova', { vol: .4 }); break; case 'dash': { const ang = Math.atan2(aimY - player.y, aimX - player.x); player.channel = { type: 'dash', t: params.distance / params.speed, tick: 0, vx: Math.cos(ang) * params.speed, vy: Math.sin(ang) * params.speed * 0.55, params, skillId: skill.id, hitSet: new Set(), elem, }; game.sfx('nova', { vol: .35 }); break; } case 'cone': doMeleeArc(game, player, aimX, aimY, params.dmgPct, params.arcDeg, params.range, { elem: 'phys', knockback: params.knockback, stun: params.stun, skillId: skill.id }); D2.render.addShake(0.3); game.sfx('explode', { vol: .4 }); break; case 'projSpread': { const ang = Math.atan2(aimY - player.y, aimX - player.x); fireProjectiles(game, player, ang, params, params.dmgPct); game.sfx(elem === 'fire' ? 'fireball' : elem === 'cold' ? 'ice' : elem === 'lit' ? 'lightning' : 'shoot', { vol: .45 }); break; } case 'novaProj': { const n = params.count; for (let i = 0; i < n; i++) { const a = (i / n) * Math.PI * 2; fireProjectiles(game, player, a, { count: 1, spreadDeg: 0, dmgPct: params.dmgPct, speed: params.speed, elem: 'phys' }, params.dmgPct); } game.sfx('swing', { vol: .5 }); break; } case 'nova': { game.addEffect({ type: 'nova', x: player.x, y: player.y, t: 0, dur: 0.45, radius: params.radius, color: elemColor(elem), lightPunch: params.radius, }); const hits = game.queryMonsters(player.x, player.y, params.radius + 0.5); for (const m of hits) { const hit = rollPlayerHit(player, params.dmgPct, { elem, target: m, guaranteedCrit: player.guaranteedCritT > 0 }); applyToMonster(game, m, hit, { elem, src: 'player', fromX: player.x, fromY: player.y, knockback: 1.2, skillId: skill.id }); if (params.freeze && !m.isBoss) m.frozen = Math.max(m.frozen, params.freeze); if (params.ignite) applyDot(game, m, { elem: 'fire', dps: hit.dmg * 0.3, dur: 2.5 }); } player.guaranteedCritT = 0; game.sfx(elem === 'cold' ? 'ice' : 'nova', { vol: .6 }); game.addLight(player.x, player.y, params.radius + 2, elemColor(elem), 0.8, 0.5); break; } case 'groundAoE': { const clamped = clampAim(game, player, aimX, aimY, 8); game.addEffect({ type: 'groundAoE', x: clamped.x, y: clamped.y, t: 0, delay: params.delay, duration: params.duration, tickT: 0, radius: params.radius, dmgPct: params.dmgPct, elem, slow: params.slow, slowDur: params.slowDur, skillId: skill.id, color: elemColor(elem), lightPunch: params.radius, }); game.sfx(elem === 'cold' ? 'ice' : 'fireball', { vol: .5 }); break; } case 'volleyArea': { const clamped = clampAim(game, player, aimX, aimY, 9); for (let w = 0; w < params.waves; w++) { game.addEffect({ type: 'telegraph', x: clamped.x + (rnd() - .5) * params.radius, y: clamped.y + (rnd() - .5) * params.radius * 0.6, t: 0, delay: 0.25 + w * params.waveDelay, radius: 1.3, color: '#d8cba8', onDone: (fx) => { dealRadiusDamage(game, fx.x, fx.y, 1.3, params.dmgPct, { elem: 'phys', src: 'player' }); game.spawnParticles(fx.x, fx.y, { count: 5, color: '#d8cba8', speed: 3, life: 0.3, size: 2, z: 12, gravity: 18 }); }, }); } game.sfx('shoot', { vol: .5 }); break; } case 'meteor': { const clamped = clampAim(game, player, aimX, aimY, 9); game.addEffect({ type: 'telegraph', x: clamped.x, y: clamped.y, t: 0, delay: params.delay, radius: params.radius, color: '#ff5a1a', onDone: (fx) => { dealRadiusDamage(game, fx.x, fx.y, params.radius, params.dmgPct, { elem: 'fire', src: 'player' }); game.spawnParticles(fx.x, fx.y, { count: 26, color: '#ff8a3a', speed: 7, life: 0.6, size: 3, z: 14, gravity: 10 }); game.addGroundDecal(fx.x, fx.y, params.radius * 0.8, 'rgba(40,16,8,.55)', 6); D2.render.addShake(0.5); game.sfx('explode', { vol: .8 }); game.addLight(fx.x, fx.y, params.radius + 3, '#ff8a3a', 1, 0.6); }, }); game.sfx('fireball', { vol: .6 }); break; } case 'chainLightning': { let cur = null; /* first target nearest to aim */ const aimHits = game.queryMonsters(aimX, aimY, 2.5); cur = aimHits.length ? aimHits[0] : game.nearestMonster(player.x, player.y, params.range, m => !m.dead); const hitSet = new Set(); let dmgBase = params.dmgPct; for (let j = 0; j < params.jumps && cur; j++) { hitSet.add(cur.uid); const hit = rollPlayerHit(player, dmgBase, { elem: 'lit', target: cur }); applyToMonster(game, cur, hit, { elem: 'lit', src: 'player', skillId: skill.id }); /* visual zap */ for (let k = 0; k < 8; k++) { game.spawnParticles( D2.util.lerp(j === 0 ? player.x : prevX, cur.x, k / 8), D2.util.lerp(j === 0 ? player.y : prevY, cur.y, k / 8), { count: 1, color: '#ffee6a', speed: 0.4, life: 0.22, size: 2.4, z: 12 }); } var prevX = cur.x, prevY = cur.y; game.addLight(cur.x, cur.y, 2.5, '#ffee6a', 0.7, 0.25); cur = game.nearestMonster(cur.x, cur.y, params.range * 0.6, m => !m.dead && !hitSet.has(m.uid)); dmgBase *= 0.8; } if (!hitSet.size) game.sfx('error'); else game.sfx('lightning', { vol: .6 }); break; } case 'blink': { const clamped = clampAim(game, player, aimX, aimY, params.range); game.spawnParticles(player.x, player.y, { count: 12, color: '#b08aff', speed: 3, life: 0.4, size: 2.4, z: 10 }); if (canStand(game.world, clamped.x, clamped.y, player.radius)) { player.x = clamped.x; player.y = clamped.y; } else { /* step back toward player until valid */ for (let s = 1; s <= 6; s++) { const bx = D2.util.lerp(player.x, clamped.x, 1 - s / 6); const by = D2.util.lerp(player.y, clamped.y, 1 - s / 6); if (canStand(game.world, bx, by, player.radius)) { player.x = bx; player.y = by; break; } } } game.spawnParticles(player.x, player.y, { count: 14, color: '#b08aff', speed: 3.5, life: 0.45, size: 2.6, z: 10 }); game.sfx('teleport'); if (params.guaranteedCritDur) { player.guaranteedCritT = params.guaranteedCritDur; player.guaranteedCritBonus = params.bonusCritDmg; } break; } case 'buffSelf': { player.buffs.push({ id: skill.id, name: skill.name, icon: params.icon, t: params.dur, dur: params.dur, mods: params.mods, }); if (skill.id === 'smokeveil') player.smokeveilT = params.dur; game.sfx('buff'); game.toast((skill.name[D2.i18n.getLang()] || skill.name.en) + '!'); D2.player.recompute(game.player); break; } default: console.warn('unknown castType', skill.castType); return false; } return true; } function clampAim(game, player, aimX, aimY, maxRange) { const d = D2.util.dist(player.x, player.y, aimX, aimY); if (d <= maxRange) { if (canStand(game.world, aimX, aimY, 0.3)) return { x: aimX, y: aimY }; } const ang = Math.atan2(aimY - player.y, aimX - player.x); const dd = Math.min(d, maxRange); for (let s = dd; s > 0.5; s -= 0.4) { const x = player.x + Math.cos(ang) * s, y = player.y + Math.sin(ang) * s; if (canStand(game.world, x, y, 0.3)) return { x, y }; } return { x: player.x, y: player.y }; } /* channel & dash per-frame update */ function updateChannels(game, player, dt) { const ch = player.channel; if (!ch) return; if (ch.type === 'whirlwind') { ch.t -= dt; ch.tick -= dt; player.attackAnim = 0.2; if (ch.tick <= 0) { ch.tick = 0.18; const hits = game.queryMonsters(player.x, player.y, ch.params.range + 0.4); for (const m of hits) { const hit = rollPlayerHit(player, ch.params.dmgPct, { elem: ch.elem, target: m, noCrit: rnd() > 0.2 }); applyToMonster(game, m, hit, { elem: ch.elem, src: 'player', skillId: ch.skillId, silentHit: rnd() > 0.4 }); } game.spawnParticles(player.x, player.y, { count: 3, color: '#c8e8ff', speed: 4, life: 0.25, size: 2, z: 8 }); if (hits.length) game.sfx('swing', { vol: .25 }); } if (ch.t <= 0) player.channel = null; } else if (ch.type === 'dash') { ch.t -= dt; tryMove(game, player, ch.vx * dt, ch.vy * dt, true); game.spawnParticles(player.x, player.y, { count: 2, color: '#ffb04a', speed: 1.5, life: 0.3, size: 2.2, z: 6 }); const hits = game.queryMonsters(player.x, player.y, player.radius + 0.6); for (const m of hits) { if (ch.hitSet.has(m.uid)) continue; ch.hitSet.add(m.uid); const hit = rollPlayerHit(player, ch.params.dmgPct, { elem: ch.elem, target: m }); applyToMonster(game, m, hit, { elem: ch.elem, src: 'player', fromX: player.x, fromY: player.y, skillId: ch.skillId }); if (ch.params.stun && !m.isBoss) m.frozen = Math.max(m.frozen, ch.params.stun); } if (ch.t <= 0) player.channel = null; } } /* ================= monster attacks ================= */ function monsterMeleeHit(game, m) { const d = D2.util.dist(m.x, m.y, game.player.x, game.player.y); if (d <= m.attackRange + game.player.radius + 0.25) { monsterHitPlayer(game, m, 1, m.elem); if (m.chillOnHit) { game.player.chillT = 1.5; game.player.chillSlow = 0.25; } } } function fireMonsterProjectile(game, m, override = {}) { const proj = m.proj; if (!proj) return; const p = game.player; const ang = Math.atan2(p.y - m.y, p.x - m.x) + (rnd() - .5) * 0.08; game.projectiles.push(new D2.entities.Projectile({ x: m.x, y: m.y, vx: Math.cos(ang) * proj.speed, vy: Math.sin(ang) * proj.speed * 0.55, speed: proj.speed, dmg: m.dmg * (override.dmgMult || 0.9), elem: proj.elem || m.elem || 'phys', fromPlayer: false, visual: proj.kind === 'arrow' || proj.kind === 'firearrow' ? 'arrow' : 'bolt', color: proj.color || '#fff', slow: proj.slow || 0, slowDur: proj.slowDur || 0, aoe: proj.aoe || 0, pool: proj.pool || false, life: 2.4, })); game.sfx(proj.elem === 'fire' ? 'fireball' : proj.elem === 'pois' ? 'poison' : 'shoot', { vol: .3 }); } /* projectile hit resolution (called by game loop) */ function projectileHit(game, pr, target) { if (pr.fromPlayer) { const hit = { dmg: pr.dmg, crit: pr.crit }; applyToMonster(game, target, hit, { elem: pr.elem, src: 'player', knockback: 0.3 * pr.size, }); if (pr.slow) { target.chillT = Math.max(target.chillT || 0, pr.slowDur); target.chillSlow = pr.slow; } if (pr.freeze && !target.isBoss) target.frozen = Math.max(target.frozen, pr.freeze); } else { monsterHitPlayer(game, { dmg: pr.dmg, level: pr.level || game.player.level, elem: pr.elem }, 1, pr.elem); if (pr.aoe) { dealRadiusDamage(game, pr.x, pr.y, pr.aoe, pr.dmg * 0.6, { elem: pr.elem, fromMonster: true }); game.spawnParticles(pr.x, pr.y, { count: 10, color: pr.color, speed: 4, life: 0.4, size: 2.6, z: 8 }); } if (pr.pool) { game.addGroundDecal(pr.x, pr.y, 1.3, 'rgba(140,220,80,.3)', 3.5); game.addEffect({ type: 'groundAoE', x: pr.x, y: pr.y, t: 0, delay: 0.1, duration: 3, tickT: 0, radius: 1.3, dmgPct: pr.dmg, elem: pr.elem, fromMonster: true, color: '#8adf5a', }); } } } /* ground AoE ticking (both player & monster sourced) */ function tickGroundAoe(game, fx, dt) { fx.tickT -= dt; if (fx.tickT <= 0) { fx.tickT = 0.5; if (fx.fromMonster) { const p = game.player; if (p && !p.dead && D2.util.dist(p.x, p.y, fx.x, fx.y) <= fx.radius) { monsterHitPlayer(game, { dmg: fx.dmgPct, level: game.player.level, elem: fx.elem }, 0.5, fx.elem); } } else { const hits = game.queryMonsters(fx.x, fx.y, fx.radius); for (const m of hits) { const hit = rollPlayerHit(game.player, fx.dmgPct * 0.5, { elem: fx.elem, noCrit: true, target: m }); applyToMonster(game, m, hit, { elem: fx.elem, src: 'player', skillId: fx.skillId, silentHit: true }); if (fx.slow) { m.chillT = Math.max(m.chillT || 0, fx.slowDur || 1); m.chillSlow = fx.slow; } } if (hits.length) game.spawnParticles(fx.x + (rnd() - .5) * fx.radius, fx.y + (rnd() - .5) * fx.radius * 0.6, { count: 3, color: fx.color, speed: 2, life: 0.4, size: 2.2, z: 10, }); } } } /* ================= destructibles & shrines ================= */ function breakProp(game, prop) { game.world.removeProp(prop); game.sfx('explode', { vol: .3 }); const col = prop.type === 'urn' ? '#b8a888' : '#8a6a42'; game.spawnParticles(prop.x + 0.5, prop.y + 0.5, { count: 10, color: col, speed: 4, life: 0.5, size: 2.6, z: 8, gravity: 16 }); if (D2.loot) D2.loot.rollPropLoot(game, prop.x + 0.5, prop.y + 0.5, prop.type); } function activateShrine(game, prop) { prop.used = true; const BAL = D2.BAL; const P = BAL.shrineBuffPower; const defs = { dmg: { id: 'shrine_dmg', mods: { dmgPct: P.dmg * 100 } }, speed: { id: 'shrine_spd', mods: { moveSpeedPct: P.speed * 100 } }, armor: { id: 'shrine_arm', mods: { armorPct: P.armor * 100 } }, xp: { id: 'shrine_xp', mods: { xpGain: P.xp * 100 } }, regen: { id: 'shrine_reg', mods: { regenHpFlat: P.regen } }, }; const d = defs[prop.buff] || defs.dmg; const names = { shrine_dmg: { en: 'Rage of the Fallen', vi: 'Thịnh Nộ Kẻ Sa Đọa' }, shrine_spd: { en: 'Winds of the Deep', vi: 'Gió Vực Sâu' }, shrine_arm: { en: 'Bulwark of Stone', vi: 'Tường Đá Bất Diệt' }, shrine_xp: { en: 'Wisdom of Ages', vi: 'Trí Tuệ Nghìn Năm' }, shrine_reg: { en: 'Fountain of Life', vi: 'Suối Nguồn Sống' }, }; game.player.buffs.push({ id: d.id, name: names[d.id], icon: 'renewal', t: BAL.shrineDuration, dur: BAL.shrineDuration, mods: d.mods, }); D2.player.recompute(game.player); game.sfx('shrine'); game.toast(D2.i18n.t('msg.shrine')); game.spawnParticles(prop.x + .5, prop.y + .5, { count: 20, color: '#ffe86a', speed: 3.5, life: 0.8, size: 2.4, z: 12 }); game.addLight(prop.x, prop.y, 4, '#ffe86a', 0.8, 1); } D2.combat = { weaponDamageRange, rollPlayerHit, applyToMonster, dealRadiusDamage, killMonster, monsterHitPlayer, monsterMeleeHit, fireMonsterProjectile, projectileHit, applyDot, updateDots, tickGroundAoe, playerBasicAttack, castSkill, updateChannels, doMeleeArc, fireProjectiles, tryMove, canStand, breakProp, activateShrine, healPlayer, elemColor, chainZap, }; })(window.D2);