Diablo2D — Shadows of Tristram: complete browser ARPG

- Isometric canvas renderer (depth-sorted, FOV/fog, additive lighting)
- 3 classes x 20 skills, 4 acts x 4 floors + boss lairs, torment I-X
- Diablo-style loot: rarities, affix tiers, 14 legendaries, vendor, stash
- Rogue camp with 6 NPCs: Charsi/Akara/Kashya/Cain/Gheed/storage
- NPC quest chain (accept -> hunt -> turn in) with rewards & gating
- Procedural WebAudio SFX + generative music, EN/VI localization
- Saves, settings, waypoints, hardcore mode, PWA manifest
- 93-assertion headless suite + browser E2E via CDP
This commit is contained in:
2026-08-23 06:59:36 +00:00
commit fc1fa2d51e
42 changed files with 11784 additions and 0 deletions
+841
View File
@@ -0,0 +1,841 @@
/* ============================================================
* 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);