- 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
842 lines
34 KiB
JavaScript
842 lines
34 KiB
JavaScript
/* ============================================================
|
|
* 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);
|