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
+609
View File
@@ -0,0 +1,609 @@
/* ============================================================
* Diablo2D — ai.js : monster behavior trees + boss scripts
* ============================================================ */
'use strict';
window.D2 = window.D2 || {};
(function (D2) {
let astarBudget = 0; // global per-frame budget for pathfinding
const rnd = Math.random;
function resetFrame() { astarBudget = 6; }
/* ---------------- main entry ---------------- */
function updateMonster(game, m, dt) {
if (m.dead) return;
/* statuses */
if (m.frozen > 0) {
m.frozen -= dt;
m.moving = false;
D2.combat.updateDots(game, m, dt, false);
return;
}
D2.combat.updateDots(game, m, dt, false);
/* regen elite */
if (m.regenPct && m.hp < m.maxHp) {
m.regenAcc += dt;
if (m.regenAcc >= 1) { m.hp = Math.min(m.maxHp, m.hp + m.maxHp * m.regenPct); m.regenAcc -= 1; }
}
const p = game.player;
if (!p || p.dead) { m.moving = false; return; }
const dx = p.x - m.x, dy = p.y - m.y;
const dist = Math.sqrt(dx * dx + dy * dy);
m.distToPlayer = dist;
/* aggro acquisition */
m.aggroCheckT = (m.aggroCheckT || 0) - dt;
if (!m.aggro && m.aggroCheckT <= 0) {
m.aggroCheckT = 0.35;
if (dist < D2.BAL.aggroRange &&
D2.path.hasLOS((x, y) => game.world.transparent(x, y),
m.x | 0, m.y | 0, p.x | 0, p.y | 0)) {
m.aggro = true;
if (m.isBoss && !m.introDone) bossIntro(game, m);
else if (!m.isBoss && rnd() < 0.25) game.sfx('die', { vol: 0.08 });
}
}
/* leash */
if (m.aggro && !m.isBoss) {
const homeDist = Math.hypot(m.x - m.homeX, m.y - m.homeY);
if (homeDist > D2.BAL.deaggroLeash) {
m.aggro = false;
m.hp = Math.min(m.maxHp, m.hp + m.maxHp * 0.3);
m.x = m.homeX; m.y = m.homeY;
}
}
/* cooldowns */
m.cdT -= dt;
m.specialCd -= dt;
if (m.attackAnim > 0) m.attackAnim -= dt;
if (m.hurtFlash > 0) m.hurtFlash -= dt;
if (m.chillT > 0) { m.chillT -= dt; if (m.chillT <= 0) m.chillSlow = 0; }
const speed = m.speed * (1 - (m.chillSlow || 0)) * (m.speedBuff || 1);
/* boss script takes priority */
if (m.isBoss) { bossUpdate(game, m, dt, dist, dx, dy); return; }
switch (m.def.ai) {
case 'melee': case 'swarm': meleeBrain(game, m, dt, dist, dx, dy, speed); break;
case 'flyer': flyerBrain(game, m, dt, dist, dx, dy, speed); break;
case 'ranged': rangedBrain(game, m, dt, dist, dx, dy, speed); break;
case 'caster': casterBrain(game, m, dt, dist, dx, dy, speed); break;
case 'summoner': summonerBrain(game, m, dt, dist, dx, dy, speed); break;
case 'charger': chargerBrain(game, m, dt, dist, dx, dy, speed); break;
default: meleeBrain(game, m, dt, dist, dx, dy, speed);
}
separation(game, m, dt);
}
/* ---------------- shared movement ---------------- */
function steer(game, m, dx, dy, speed, dt, ignoreWalls = false) {
const d = Math.hypot(dx, dy) || 1;
let vx = dx / d, vy = dy / d;
if (!ignoreWalls) {
/* try direct; if blocked ahead use pathfinding fallback */
const probeX = m.x + vx * 0.5, probeY = m.y + vy * 0.5;
if (!game.world.isWalkable(probeX, probeY)) {
if (astarBudget > 0) {
astarBudget--;
m.pathT -= 0.6; // force refresh
const path = D2.path.find(
(x, y) => game.world.isWalkable(x, y),
game.world.w, game.world.h,
m.x | 0, m.y | 0, (m.x + vx * 4) | 0, (m.y + vy * 4) | 0, 900);
m.path = path;
m.pathIdx = 0;
}
if (m.path && m.pathIdx < m.path.length) {
const node = m.path[m.pathIdx];
const ndx = node.x + 0.5 - m.x, ndy = node.y + 0.5 - m.y;
const nd = Math.hypot(ndx, ndy) || 1;
if (nd < 0.4) m.pathIdx++;
else { vx = ndx / nd; vy = ndy / nd; }
} else {
/* slide along wall */
const tx = -vy, ty = vx;
vx = tx; vy = ty;
}
}
}
D2.combat.tryMove(game, m, vx * speed * dt, vy * speed * dt * 0.55);
m.moving = true;
}
function separation(game, m, dt) {
const near = game.queryMonsters(m.x, m.y, m.radius + 0.55);
for (const o of near) {
if (o === m || o.dead) continue;
const dx = m.x - o.x, dy = m.y - o.y;
const d = Math.hypot(dx, dy) || 0.01;
const minD = m.radius + o.radius;
if (d < minD) {
const push = (minD - d) * 2.2;
D2.combat.tryMove(game, m, (dx / d) * push * dt * 8, (dy / d) * push * dt * 8 * 0.55);
}
}
}
function startWindup(m, duration) {
m.state = 'windup';
m.stateT = duration;
m.attackAnim = duration + 0.15;
}
/* ---------------- brains ---------------- */
function meleeBrain(game, m, dt, dist, dx, dy, speed) {
if (!m.aggro) { idleWander(game, m, dt, speed * 0.4); return; }
if (m.state === 'windup') {
m.stateT -= dt;
if (m.stateT <= 0) {
D2.combat.monsterMeleeHit(game, m);
m.state = 'chase';
m.cdT = m.attackCd * (m.cdMult || 1);
game.sfx('swing', { vol: 0.12 });
}
return;
}
if (dist <= m.attackRange + game.player.radius && m.cdT <= 0) {
startWindup(m, 0.32);
return;
}
if (dist > m.attackRange * 0.8) {
steer(game, m, dx, dy, speed, dt);
/* swarm zigzag */
if (m.def.ai === 'swarm' && rnd() < 0.05) {
D2.combat.tryMove(game, m, (rnd() - .5) * 0.3, (rnd() - .5) * 0.3);
}
} else {
m.moving = false;
}
}
function flyerBrain(game, m, dt, dist, dx, dy, speed) {
if (!m.aggro) { idleWander(game, m, dt, speed * 0.6); return; }
if (m.state === 'windup') {
m.stateT -= dt;
if (m.stateT <= 0) {
D2.combat.monsterMeleeHit(game, m);
m.state = 'chase';
m.cdT = m.attackCd * (m.cdMult || 1);
}
return;
}
if (dist <= m.attackRange + game.player.radius && m.cdT <= 0) {
startWindup(m, 0.26);
return;
}
/* flyers ignore walls */
const d = dist || 1;
D2.combat.tryMove(game, m, (dx / d) * speed * dt, (dy / d) * speed * dt * 0.55, true);
m.moving = true;
/* keep inside floor-ish area */
m.x = D2.util.clamp(m.x, 1, game.world.w - 1);
m.y = D2.util.clamp(m.y, 1, game.world.h - 1);
}
function rangedBrain(game, m, dt, dist, dx, dy, speed) {
if (!m.aggro) { idleWander(game, m, dt, speed * 0.4); return; }
const minBand = m.attackRange * 0.45;
if (m.state === 'windup') {
m.stateT -= dt;
if (m.stateT <= 0) {
D2.combat.fireMonsterProjectile(game, m);
m.state = 'chase';
m.cdT = m.attackCd * (m.cdMult || 1);
}
return;
}
if (dist <= m.attackRange && m.cdT <= 0 &&
D2.path.hasLOS((x, y) => game.world.transparent(x, y), m.x | 0, m.y | 0, game.player.x | 0, game.player.y | 0)) {
startWindup(m, 0.4);
return;
}
if (dist < minBand) steer(game, m, -dx, -dy, speed, dt);
else if (dist > m.attackRange) steer(game, m, dx, dy, speed, dt);
else m.moving = false;
}
function casterBrain(game, m, dt, dist, dx, dy, speed) {
if (!m.aggro) { idleWander(game, m, dt, speed * 0.4); return; }
/* blink away when pressured */
if (m.special === 'blinkAway' && dist < 2.6 && m.specialCd <= 0) {
blinkAway(game, m);
m.specialCd = 5;
return;
}
rangedBrain(game, m, dt, dist, dx, dy, speed);
}
function summonerBrain(game, m, dt, dist, dx, dy, speed) {
if (!m.aggro) { idleWander(game, m, dt, speed * 0.4); return; }
if (m.summon && m.specialCd <= 0) {
const minions = game.countMinionsOf(m.uid);
if (minions < m.summon.maxAlive) {
for (let i = 0; i < m.summon.count; i++) {
const ang = rnd() * Math.PI * 2;
const sx = m.x + Math.cos(ang) * 1.2, sy = m.y + Math.sin(ang) * 1.2;
if (D2.combat.canStand(game.world, sx, sy, 0.3)) {
game.spawnMonster(m.summon.species, sx, sy, { ownerUid: m.uid });
game.spawnParticles(sx, sy, { count: 8, color: '#b08aff', speed: 2.5, life: 0.5, size: 2.4, z: 8 });
}
}
game.sfx('buff', { vol: .3 });
m.specialCd = 7;
} else {
m.specialCd = 2;
}
return;
}
casterBrain(game, m, dt, dist, dx, dy, speed);
}
function chargerBrain(game, m, dt, dist, dx, dy, speed) {
/* mid-charge */
if (m.state === 'charging') {
m.stateT -= dt;
D2.combat.tryMove(game, m, m.chargeVX * dt, m.chargeVY * dt * 0.55, true);
m.moving = true;
game.spawnParticles(m.x, m.y, { count: 1, color: '#ff8a3a', speed: 1, life: 0.25, size: 2, z: 6 });
/* contact damage */
if (dist < m.radius + game.player.radius + 0.2 && !m.chargeHit) {
m.chargeHit = true;
D2.combat.monsterHitPlayer(game, m, 1.5, m.elem);
D2.render.addShake(0.25);
}
if (m.stateT <= 0) {
m.state = 'chase';
m.cdT = 1.0;
m.specialCd = 4.5;
}
return;
}
/* begin charge */
if (m.aggro && m.chargeRange && dist > 2.4 && dist < m.chargeRange && m.specialCd <= 0) {
const d = dist || 1;
m.chargeVX = (dx / d) * speed * 3.2;
m.chargeVY = (dy / d) * speed * 3.2 * 0.55;
m.state = 'charging';
m.stateT = 0.55;
m.chargeHit = false;
m.telegraphed = 0.25;
game.sfx('bossroar', { vol: 0.18 });
return;
}
meleeBrain(game, m, dt, dist, dx, dy, speed);
}
function idleWander(game, m, dt, speed) {
m.wanderT = (m.wanderT || 0) - dt;
if (m.wanderT <= 0) {
m.wanderT = 1.5 + rnd() * 2.5;
m.wanderDX = (rnd() - .5) * 2;
m.wanderDY = (rnd() - .5) * 2;
}
if (m.wanderDX || m.wanderDY) {
D2.combat.tryMove(game, m, m.wanderDX * speed * dt, m.wanderDY * speed * dt * 0.55);
m.moving = true;
} else m.moving = false;
}
function blinkAway(game, m) {
for (let tries = 0; tries < 12; tries++) {
const ang = rnd() * Math.PI * 2;
const d = 3.5 + rnd() * 3;
const nx = m.x + Math.cos(ang) * d, ny = m.y + Math.sin(ang) * d;
if (D2.combat.canStand(game.world, nx, ny, m.radius) &&
game.world.tileAt(nx | 0, ny | 0) === D2.world.T.FLOOR) {
game.spawnParticles(m.x, m.y, { count: 10, color: '#b08aff', speed: 2.5, life: 0.4, size: 2.4, z: 8 });
m.x = nx; m.y = ny;
game.spawnParticles(nx, ny, { count: 10, color: '#b08aff', speed: 2.5, life: 0.4, size: 2.4, z: 8 });
game.sfx('teleport', { vol: .3 });
return true;
}
}
return false;
}
/* ---------------- boss brain ---------------- */
function bossIntro(game, m) {
m.introDone = true;
m.aggro = true;
game.sfx('bossroar');
D2.render.addShake(0.5);
game.toast(D2.i18n.t('msg.boss_spawn'), 'bad');
game.showBossBar(m);
if (m.bossDef.intro) game.addFloatText(m.x, m.y - 1, m.bossDef.intro[D2.i18n.getLang()] || m.bossDef.intro.en, '#ff8a6a', 16);
}
function bossUpdate(game, m, dt, dist, dx, dy) {
const p = game.player;
const speed = m.speed * (1 - (m.chillSlow || 0)) * (m.speedBuff || 1);
/* phase 2 enrage */
if (!m.phase2 && m.hp < m.maxHp * 0.5 && m.attacks.includes('enrage')) {
m.phase2 = true;
m.speedBuff = 1.3;
m.cdMult = 0.72;
game.sfx('bossroar');
D2.render.addShake(0.5);
game.toast('⚠ ' + m.name + ' ⚠', 'bad');
game.spawnParticles(m.x, m.y, { count: 30, color: '#ff4a1a', speed: 6, life: 0.7, size: 3, z: 12 });
}
m.bossAtkT = (m.bossAtkT || 1.2) - dt;
/* executing a scripted attack sequence */
if (m.busyT > 0) {
m.busyT -= dt;
if (m.busyAttack) m.busyAttack(game, m, dt);
if (m.busyT <= 0 && m.busyEnd) { const f = m.busyEnd; m.busyEnd = null; f(); }
return;
}
/* pick an attack */
if (m.bossAtkT <= 0 && !p.dead) {
const chosen = pickBossAttack(game, m, dist);
if (chosen) {
m.bossAtkT = (m.isElite ? 1.2 : 1.7) * (m.cdMult || 1) + rnd() * 0.8;
return;
}
m.bossAtkT = 0.5;
}
/* default: chase & melee */
if (dist <= m.attackRange + p.radius && m.cdT <= 0) {
startWindup(m, 0.38);
m.pendingBasic = true;
} else if (m.state === 'windup') {
m.stateT -= dt;
if (m.stateT <= 0) {
D2.combat.monsterMeleeHit(game, m);
m.cdT = m.attackCd * (m.cdMult || 1) * 0.7;
m.state = 'chase';
game.sfx('swing', { vol: 0.2 });
}
return;
} else if (dist > m.attackRange * 0.85) {
steer(game, m, dx, dy, speed, dt);
} else m.moving = false;
}
function pickBossAttack(game, m, dist) {
const p = game.player;
const candidates = [];
for (const a of m.attacks || []) {
switch (a) {
case 'cleaveArc': if (dist < 3.2) candidates.push({ a, w: 3 }); break;
case 'chargeAt': if (dist > 3 && dist < 10) candidates.push({ a, w: 2.5 }); break;
case 'lunge': if (dist > 2 && dist < 5) candidates.push({ a, w: 2 }); break;
case 'boneSpearVolley': if (dist < 11) candidates.push({ a, w: 2.5 }); break;
case 'webVolley': if (dist < 10) candidates.push({ a, w: 2.5 }); break;
case 'summonFallen': if ((m.summonsDone || 0) < 2) candidates.push({ a, w: 1.5 }); break;
case 'summonSkeletons': candidates.push({ a, w: 1.6 }); break;
case 'spawnSpiderlings': candidates.push({ a, w: 1.8 }); break;
case 'curseRing': if (dist < 6) candidates.push({ a, w: 1.8 }); break;
case 'poisonPools': if (dist < 9) candidates.push({ a, w: 2.2 }); break;
case 'fireNovaRing': if (dist < 7) candidates.push({ a, w: 2.4 }); break;
case 'lightningSpiral': candidates.push({ a, w: 2 }); break;
case 'meteorRain': candidates.push({ a, w: 2.2 }); break;
}
}
if (!candidates.length) return null;
const total = candidates.reduce((s, c) => s + c.w, 0);
let roll = rnd() * total;
let chosen = candidates[candidates.length - 1];
for (const c of candidates) { roll -= c.w; if (roll <= 0) { chosen = c; break; } }
execBossAttack(game, m, chosen.a, dist);
return chosen.a;
}
function execBossAttack(game, m, attack, dist) {
const p = game.player;
switch (attack) {
case 'cleaveArc': {
m.attackAnim = 0.5;
game.sfx('bossroar', { vol: 0.25 });
busy(m, 0.5, () => {
const baseAng = Math.atan2(p.y - m.y, p.x - m.x);
game.addEffect({
type: 'swingArc', x: m.x, y: m.y, t: 0, dur: 0.22,
ang: baseAng, arc: Math.PI, range: 3.2, color: '#ff8a5a',
});
const pd = D2.util.dist(p.x, p.y, m.x, m.y);
const ang = Math.atan2(p.y - m.y, p.x - m.x);
if (pd < 3.4 && Math.abs(D2.util.wrapAngle(ang - baseAng)) < Math.PI / 2) {
D2.combat.monsterHitPlayer(game, m, 1.6, null);
D2.render.addShake(0.35);
}
});
break;
}
case 'chargeAt': case 'lunge': {
const d = dist || 1;
const spd = attack === 'chargeAt' ? 14 : 11;
m.chargeVX = (p.x - m.x) / d * spd;
m.chargeVY = (p.y - m.y) / d * spd * 0.55;
m.chargeHit = false;
m.busyT = attack === 'chargeAt' ? 0.6 : 0.4;
m.busyAttack = (g, mm, dt) => {
D2.combat.tryMove(g, mm, mm.chargeVX * dt, mm.chargeVY * dt * 0.55, true);
g.spawnParticles(mm.x, mm.y, { count: 2, color: '#ff6a3a', speed: 2, life: 0.3, size: 2.4, z: 8 });
const dd = D2.util.dist(p.x, p.y, mm.x, mm.y);
if (dd < mm.radius + p.radius + 0.3 && !mm.chargeHit) {
mm.chargeHit = true;
D2.combat.monsterHitPlayer(g, mm, 1.5, null);
D2.render.addShake(0.4);
}
mm.moving = true;
};
game.sfx('bossroar', { vol: 0.2 });
break;
}
case 'summonFallen': case 'summonSkeletons': case 'spawnSpiderlings': {
const speciesMap = { summonFallen: 'fallen', summonSkeletons: 'skeleton', spawnSpiderlings: 'tomb_spider' };
const species = speciesMap[attack];
const n = attack === 'spawnSpiderlings' ? 4 : 3;
m.summonsDone = (m.summonsDone || 0) + 1;
game.sfx('buff');
for (let i = 0; i < n; i++) {
const ang = (i / n) * Math.PI * 2;
const sx = m.x + Math.cos(ang) * 2, sy = m.y + Math.sin(ang) * 2 * 0.6;
if (D2.combat.canStand(game.world, sx, sy, 0.3))
game.spawnMonster(species, sx, sy, { ownerUid: m.uid, aggroed: true });
}
game.spawnParticles(m.x, m.y, { count: 20, color: '#b08aff', speed: 4, life: 0.6, size: 2.6, z: 10 });
busy(m, 0.7);
break;
}
case 'frenzy': case 'enrage': break; // handled in phase logic
case 'boneSpearVolley': {
m.attackAnim = 0.45;
const n = m.phase2 ? 7 : 5;
const base = Math.atan2(p.y - m.y, p.x - m.x);
for (let i = 0; i < n; i++) {
const a = base + (i / (n - 1) - 0.5) * 0.9;
game.projectiles.push(new D2.entities.Projectile({
x: m.x, y: m.y,
vx: Math.cos(a) * 7.5, vy: Math.sin(a) * 7.5 * 0.55,
speed: 7.5, dmg: m.dmg * 0.85, elem: 'phys', fromPlayer: false,
visual: 'bolt', color: '#d8e8b0', life: 2.2,
}));
}
game.sfx('shoot', { vol: .4 });
busy(m, 0.45);
break;
}
case 'webVolley': {
const n = 3;
const base = Math.atan2(p.y - m.y, p.x - m.x);
for (let i = 0; i < n; i++) {
const a = base + (i - 1) * 0.28;
game.projectiles.push(new D2.entities.Projectile({
x: m.x, y: m.y,
vx: Math.cos(a) * 6, vy: Math.sin(a) * 6 * 0.55,
speed: 6, dmg: m.dmg * 0.6, elem: 'phys', fromPlayer: false,
visual: 'orb', color: '#d8f0c8',
slow: 0.6, slowDur: 2.2, life: 2.4,
}));
}
game.sfx('poison', { vol: .4 });
busy(m, 0.5);
break;
}
case 'curseRing': {
game.sfx('nova', { vol: .5 });
game.addEffect({
type: 'telegraph', x: m.x, y: m.y, t: 0, delay: 0.9, radius: 5.5, color: '#b08aff',
onDone: () => {
game.addEffect({ type: 'nova', x: m.x, y: m.y, t: 0, dur: 0.5, radius: 5.5, color: '#b08aff', lightPunch: 6 });
const dd = D2.util.dist(p.x, p.y, m.x, m.y);
if (dd <= 5.5) {
D2.combat.monsterHitPlayer(game, m, 1.1, null);
game.player.chillT = 2; game.player.chillSlow = 0.4;
}
},
});
busy(m, 0.9);
break;
}
case 'poisonPools': {
game.sfx('poison');
for (let i = 0; i < 3; i++) {
const ox = (i - 1) * 1.8 + (rnd() - .5);
const oy = (rnd() - .5) * 2;
game.addEffect({
type: 'groundAoE', x: p.x + ox, y: p.y + oy, t: 0,
delay: 0.55, duration: 3.5, tickT: 0,
radius: 1.6, dmgPct: m.dmg * 0.5, elem: 'pois', fromMonster: true,
color: '#8adf5a', lightPunch: 2,
});
}
busy(m, 0.55);
break;
}
case 'fireNovaRing': {
game.sfx('nova');
const waves = [[2.6, 0], [4.2, 0.45], [5.8, 0.9]];
for (const [radius, delay] of waves) {
game.addEffect({
type: 'telegraph', x: m.x, y: m.y, t: 0, delay: delay + 0.35, radius, color: '#ff6a2a',
onDone: () => {
game.addEffect({ type: 'nova', x: m.x, y: m.y, t: 0, dur: 0.5, radius, color: '#ff6a2a', lightPunch: radius });
const dd = D2.util.dist(p.x, p.y, m.x, m.y);
if (Math.abs(dd - radius) < 1.1 || dd < radius * 0.4) {
D2.combat.monsterHitPlayer(game, m, 1.0, 'fire');
}
game.spawnParticles(m.x, m.y, { count: 18, color: '#ff8a3a', speed: 6, life: 0.5, size: 2.8, z: 10 });
},
});
}
busy(m, 1.3);
break;
}
case 'lightningSpiral': {
game.sfx('lightning');
const shots = m.phase2 ? 30 : 22;
const durTotal = 1.3;
for (let i = 0; i < shots; i++) {
const angle = (i / shots) * Math.PI * 4; // double spiral
game.addEffect({
type: 'timed', t: 0, endAt: 0.15 + (i / shots) * durTotal, fired: false,
fire: (g) => {
const a = angle + g.time * 2;
g.projectiles.push(new D2.entities.Projectile({
x: m.x, y: m.y,
vx: Math.cos(a) * 6.5, vy: Math.sin(a) * 6.5 * 0.55,
speed: 6.5, dmg: m.dmg * 0.55, elem: 'lit', fromPlayer: false,
visual: 'bolt', color: '#ffee6a', life: 1.8,
}));
if (i % 6 === 0) game.sfx('lightning', { vol: .2 });
},
});
}
busy(m, durTotal + 0.2);
break;
}
case 'meteorRain': {
game.sfx('fireball');
const n = m.phase2 ? 6 : 4;
for (let i = 0; i < n; i++) {
game.addEffect({
type: 'telegraph',
x: p.x + (rnd() - .5) * 6, y: p.y + (rnd() - .5) * 4,
t: 0, delay: 0.7 + i * 0.22, radius: 1.7, color: '#ff5a1a',
onDone: (fx) => {
D2.combat.dealRadiusDamage(game, fx.x, fx.y, 1.7, m.dmg * 1.2, { elem: 'fire', fromMonster: true });
game.spawnParticles(fx.x, fx.y, { count: 14, color: '#ff8a3a', speed: 5, life: 0.5, size: 2.8, z: 12, gravity: 10 });
game.sfx('explode', { vol: .4 });
},
});
}
busy(m, 1.6);
break;
}
}
}
function busy(m, t, onEnd) {
m.busyT = t;
m.busyAttack = null;
m.busyEnd = onEnd || null;
}
D2.ai = { updateMonster, resetFrame, blinkAway };
})(window.D2);
+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);
+178
View File
@@ -0,0 +1,178 @@
/* ============================================================
* Diablo2D — entities.js : entity classes & spatial index
* ============================================================ */
'use strict';
window.D2 = window.D2 || {};
(function (D2) {
let nextUid = 1;
/* ---------------- Player ---------------- */
class Player {
constructor(classId) {
this.uid = nextUid++;
this.kind = 'player';
this.classId = classId;
this.x = 0; this.y = 0;
this.radius = 0.34;
this.dead = false;
/* progression */
this.level = 1;
this.xp = 0;
this.gold = 60;
this.statPoints = 0;
this.skillPoints = 0;
this.attributes = { str: 0, dex: 0, vit: 0, ene: 0 }; // allocated points
this.skills = {}; // skillId -> rank
this.hotbar = ['basic', null, null, null, null]; // slot0=basic(LMB), 1=RMB, 2..4 keys
this.potions = { hp: 4, mp: 3 };
this.inventory = []; // items
this.equip = { weapon: null, offhand: null, head: null, chest: null, hands: null, feet: null, amulet: null, ring1: null, ring2: null };
/* derived (recomputed) */
this.stats = {};
this.maxHp = 50; this.hp = 50;
this.maxMana = 20; this.mana = 20;
/* movement / anim */
this.moving = false;
this.walkPhase = 0;
this.attackAnim = 0;
this.hurtFlash = 0;
this.attackCd = 0;
this.channel = null; // whirlwind/dash state
this.guaranteedCritT = 0;
this.smokeveilT = 0;
this.buffs = []; // {id,name,icon,t,dur,mods}
this.dots = [];
/* run meta */
this.kills = 0;
this.eliteKills = 0;
this.bossKills = 0;
this.deaths = 0;
}
}
/* ---------------- Monster ---------------- */
class Monster {
constructor(statBlock, x, y) {
Object.assign(this, statBlock);
this.uid = nextUid++;
this.kind = 'monster';
this.x = x; this.y = y;
this.homeX = x; this.homeY = y;
this.dead = false;
this.dying = false;
/* ai state */
this.state = 'idle'; // idle | chase | windup | recover | charging | fleeing
this.aggro = false;
this.stateT = 0;
this.attackT = 0; // windup progress
this.attackAnim = 0;
this.hurtFlash = 0;
this.cdT = 0; // attack cooldown
this.specialCd = 0;
this.chargeVX = 0; this.chargeVY = 0;
this.path = null; this.pathIdx = 0; this.pathT = 0;
this.moving = false; this.speedFactor = 1;
/* statuses */
this.frozen = 0;
this.chillT = 0; this.chillSlow = 0;
this.dots = [];
this.regenAcc = 0;
}
}
/* ---------------- Projectile ---------------- */
const projectiles = [];
class Projectile {
constructor(o) {
Object.assign(this, {
x: 0, y: 0, vx: 0, vy: 0,
speed: 10, dmg: 5, elem: null,
fromPlayer: true,
pierce: false,
maxHits: 1,
hits: new Set(),
life: 2.2,
visual: 'bolt',
color: '#fff',
size: 1,
slow: 0, slowDur: 0,
bleed: false,
aoe: 0,
pool: false,
freeze: 0,
knockback: 0,
guaranteedCrit: false,
ownerSkillId: null,
z: 0,
}, o);
this.uid = nextUid++;
this.dead = false;
}
}
/* ---------------- Pickup ---------------- */
class Pickup {
constructor(kind, x, y, extra = {}) {
this.uid = nextUid++;
this.kind = kind; // gold | potion | item
this.x = x; this.y = y;
this.amount = extra.amount || 0;
this.item = extra.item || null;
this.potionType = extra.potionType || null;
this.t = 0; // age (for pop animation)
this.magnet = false;
}
}
/* ---------------- Spatial hash for monsters ---------------- */
class Grid {
constructor(cell = 2.5) { this.cell = cell; this.map = new Map(); }
key(x, y) { return ((x / this.cell) | 0) + ',' + ((y / this.cell) | 0); }
rebuild(entities) {
this.map.clear();
for (const e of entities) {
if (e.dead) continue;
const k = this.key(e.x, e.y);
let arr = this.map.get(k);
if (!arr) { arr = []; this.map.set(k, arr); }
arr.push(e);
}
}
query(x, y, r, out = []) {
out.length = 0;
const c = this.cell;
const x0 = ((x - r) / c) | 0, x1 = ((x + r) / c) | 0;
const y0 = ((y - r) / c) | 0, y1 = ((y + r) / c) | 0;
for (let gy = y0; gy <= y1; gy++) {
for (let gx = x0; gx <= x1; gx++) {
const arr = this.map.get(gx + ',' + gy);
if (arr) for (const e of arr) {
const dx = e.x - x, dy = e.y - y;
if (dx * dx + dy * dy <= r * r) out.push(e);
}
}
}
return out;
}
nearest(x, y, maxR, filter) {
let best = null, bd = maxR * maxR;
const cand = this.query(x, y, maxR, []);
for (const e of cand) {
if (filter && !filter(e)) continue;
const d = (e.x - x) ** 2 + (e.y - y) ** 2;
if (d < bd) { bd = d; best = e; }
}
return best;
}
}
D2.entities = { Player, Monster, Projectile, Pickup, Grid };
})(window.D2);
+45
View File
@@ -0,0 +1,45 @@
/* ============================================================
* Diablo2D — fov.js : raycast visibility & fog of war
* ============================================================ */
'use strict';
window.D2 = window.D2 || {};
(function (D2) {
const RAY_COUNT = 160;
/**
* Update world.visible / world.explored from (cx, cy) within radius.
* world must expose: w, h, transparent(x,y), visible(Uint8), explored(Uint8)
*/
function compute(world, cx, cy, radius) {
const { w, h } = world;
world.visible.fill(0);
const r2 = radius * radius;
const mark = (x, y) => {
if (x < 0 || y < 0 || x >= w || y >= h) return;
const i = y * w + x;
world.visible[i] = 1;
world.explored[i] = 1;
};
mark(cx, cy);
for (let i = 0; i < RAY_COUNT; i++) {
const ang = (i / RAY_COUNT) * Math.PI * 2;
const dx = Math.cos(ang), dy = Math.sin(ang);
let x = cx + 0.5, y = cy + 0.5;
for (let d = 0; d < radius; d += 0.5) {
x += dx * 0.5; y += dy * 0.5;
const tx = x | 0, ty = y | 0;
if (tx < 0 || ty < 0 || tx >= w || ty >= h) break;
const ddx = x - cx - 0.5, ddy = y - cy - 0.5;
if (ddx * ddx + ddy * ddy > r2) break;
mark(tx, ty);
if (!world.transparent(tx, ty)) break;
}
}
}
D2.fov = { compute };
})(window.D2);
+1081
View File
File diff suppressed because it is too large Load Diff
+149
View File
@@ -0,0 +1,149 @@
/* ============================================================
* Diablo2D — loot.js : drops, gold, pickups
* ============================================================ */
'use strict';
window.D2 = window.D2 || {};
(function (D2) {
const rnd = Math.random;
function goldFindMult(player) {
return 1 + ((player.stats && player.stats.goldFind) || 0) / 100;
}
function magicFindOf(player) {
return (player.stats && player.stats.magicFind) || 0;
}
/** main drop routine when a monster dies */
function dropFor(game, m) {
const p = game.player;
const DC = D2.BAL.dropChances;
const x = m.x, y = m.y;
/* --- gold --- */
let goldChance = DC.gold + (m.isElite ? 0.4 : 0) + (m.isBoss ? 1 : 0);
if (m.isBoss) goldChance = 1;
if (rnd() < goldChance) {
let amount = D2.BAL.goldDropBase(m.level) * goldFindMult(p);
if (m.isElite) amount *= 2.6;
if (m.isBoss) amount *= 7;
amount = Math.max(1, Math.round(amount));
const piles = m.isBoss ? 4 : amount > 40 ? 2 : 1;
for (let i = 0; i < piles; i++) {
game.pickups.push(new D2.entities.Pickup('gold',
x + (rnd() - .5) * 0.8, y + (rnd() - .5) * 0.8,
{ amount: Math.max(1, Math.round(amount / piles)) }));
}
}
/* --- potions --- */
const potChance = (DC.potionHp + DC.potionMp) * (m.isElite ? 1.8 : 1);
if (rnd() < potChance || m.isBoss) {
const type = rnd() < 0.62 ? 'hp' : 'mp';
game.pickups.push(new D2.entities.Pickup('potion',
x + (rnd() - .5) * .6, y + (rnd() - .5) * .6, { potionType: type }));
}
if (m.isBoss) {
game.pickups.push(new D2.entities.Pickup('potion', x + .5, y, { potionType: 'hp' }));
game.pickups.push(new D2.entities.Pickup('potion', x - .5, y, { potionType: 'mp' }));
}
/* --- gear --- */
let gearChance = DC.gear + (m.isElite ? DC.gearEliteBonus : 0);
if (m.isBoss) gearChance = DC.gearBoss;
const rolls = m.isBoss ? 4 : 1;
for (let i = 0; i < rolls; i++) {
if (rnd() >= gearChance && !(m.isBoss && i === 0)) continue;
const opts = {
magicFind: magicFindOf(p),
elite: m.isElite,
boss: m.isBoss,
slot: rnd() < 0.55 ? undefined :
D2.Items.SLOTS[(rnd() * D2.Items.SLOTS.length) | 0],
};
if (m.isBoss && i === 0) {
/* guaranteed flagship drop: legendary 40%, else rare */
opts.rarity = rnd() < 0.4 ? 'legendary' : 'rare';
}
const item = D2.Items.rollItem(Math.max(1, m.level), opts);
if (item) {
game.pickups.push(new D2.entities.Pickup('item',
x + (rnd() - .5) * 1.2, y + (rnd() - .5) * 1.2, { item }));
if (item.rarity === 'legendary') {
game.sfx('legendary');
game.toast('✦ ' + item.name, 'gold');
game.addLight(x, y, 3, '#ef8f34', 0.9, 2.5);
} else if (item.rarity === 'rare') {
game.sfx('pickup');
}
}
}
}
/** barrels / urns / chests */
function rollPropLoot(game, x, y, propType) {
if (propType === 'shrine' || String(propType).startsWith('npc_')) return;
if (propType === 'chest') {
/* chests are generous */
const n = 1 + (rnd() < 0.4 ? 1 : 0);
for (let i = 0; i < n; i++) {
const item = D2.Items.rollItem(Math.max(1, game.monsterLevel()),
{ magicFind: magicFindOf(game.player) + 15 });
if (item) game.pickups.push(new D2.entities.Pickup('item', x + (rnd() - .5), y + (rnd() - .5) * .6, { item }));
}
const g = Math.round(D2.BAL.goldDropBase(game.monsterLevel()) * 2 * goldFindMult(game.player));
game.pickups.push(new D2.entities.Pickup('gold', x, y + .3, { amount: g }));
game.sfx('coin');
return;
}
/* barrels & urns */
if (rnd() < 0.45) {
const amount = Math.max(1, Math.round(D2.BAL.goldDropBase(Math.max(1, game.monsterLevel() - 2)) * 0.5 * goldFindMult(game.player)));
game.pickups.push(new D2.entities.Pickup('gold', x, y, { amount }));
} else if (rnd() < 0.14) {
game.pickups.push(new D2.entities.Pickup('potion', x, y, {
potionType: rnd() < 0.65 ? 'hp' : 'mp'
}));
} else if (rnd() < 0.05) {
const item = D2.Items.rollItem(Math.max(1, game.monsterLevel() - 1));
if (item) game.pickups.push(new D2.entities.Pickup('item', x, y, { item }));
}
}
/** collect a pickup; returns true if consumed */
function collect(game, pk) {
const p = game.player;
switch (pk.kind) {
case 'gold': {
p.gold += pk.amount;
game.sfx('coin', { vol: .35 });
return true;
}
case 'potion': {
if (p.potions[pk.potionType] >= D2.BAL.potionStackMax) return false;
p.potions[pk.potionType]++;
game.sfx('pickup', { vol: .3 });
return true;
}
case 'item': {
if (p.inventory.length >= 40) {
game.toast(D2.i18n.t('msg.inventory_full'), 'bad');
return false;
}
p.inventory.push(pk.item);
game.sfx(pk.item.rarity === 'legendary' ? 'legendary' : 'pickup', { vol: .45 });
if (pk.item.rarity === 'magic' || pk.item.rarity === 'rare' || pk.item.rarity === 'legendary') {
game.toast(pk.item.name, pk.item.rarity === 'legendary' ? 'gold' : '');
}
if (D2.ui) D2.ui.refreshInventory();
return true;
}
}
return false;
}
D2.loot = { dropFor, rollPropLoot, collect };
})(window.D2);
+128
View File
@@ -0,0 +1,128 @@
/* ============================================================
* Diablo2D — path.js : A* pathfinding + line of sight
* ============================================================ */
'use strict';
window.D2 = window.D2 || {};
(function (D2) {
const DIRS = [
[1, 0, 1], [-1, 0, 1], [0, 1, 1], [0, -1, 1],
[1, 1, 1.414], [1, -1, 1.414], [-1, 1, 1.414], [-1, -1, 1.414],
];
/**
* A* over world tiles. walkable(x,y) -> bool.
* Returns array of {x,y} tile coords (excluding start), or null.
*/
function find(walkable, w, h, sx, sy, tx, ty, maxNodes = 4000) {
if (!walkable(tx, ty)) return null;
if (sx === tx && sy === ty) return [];
const open = new MinHeap();
const gScore = new Float32Array(w * h).fill(Infinity);
const cameFrom = new Int32Array(w * h).fill(-1);
const closed = new Uint8Array(w * h);
const idx = (x, y) => y * w + x;
gScore[idx(sx, sy)] = 0;
open.push(idx(sx, sy), octile(sx, sy, tx, ty));
let nodes = 0;
while (open.size > 0 && nodes < maxNodes) {
const cur = open.pop();
const cx = cur % w, cy = (cur / w) | 0;
if (closed[cur]) continue;
closed[cur] = 1;
nodes++;
if (cx === tx && cy === ty) {
const path = [];
let n = cur;
while (n !== idx(sx, sy) && n >= 0) {
path.push({ x: n % w, y: (n / w) | 0 });
n = cameFrom[n];
}
path.reverse();
return path;
}
for (const [dx, dy, cost] of DIRS) {
const nx = cx + dx, ny = cy + dy;
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
if (!walkable(nx, ny)) continue;
// no corner cutting
if (dx !== 0 && dy !== 0) {
if (!walkable(cx + dx, cy) || !walkable(cx, cy + dy)) continue;
}
const ni = idx(nx, ny);
if (closed[ni]) continue;
const tentative = gScore[cur] + cost;
if (tentative < gScore[ni]) {
gScore[ni] = tentative;
cameFrom[ni] = cur;
open.push(ni, tentative + octile(nx, ny, tx, ty) * 1.02);
}
}
}
return null;
}
function octile(x0, y0, x1, y1) {
const dx = Math.abs(x1 - x0), dy = Math.abs(y1 - y0);
return (dx + dy) + (1.414 - 2) * Math.min(dx, dy);
}
/** lightweight binary min-heap keyed by f-score */
class MinHeap {
constructor() { this.items = []; this.f = []; this.size = 0; }
push(idx, f) {
let i = this.size++;
this.items[i] = idx; this.f[i] = f;
while (i > 0) {
const p = (i - 1) >> 1;
if (this.f[p] <= this.f[i]) break;
this.swap(p, i); i = p;
}
}
pop() {
const top = this.items[0];
this.size--;
if (this.size > 0) {
this.items[0] = this.items[this.size];
this.f[0] = this.f[this.size];
let i = 0;
for (;;) {
const l = 2 * i + 1, r = l + 1;
let m = i;
if (l < this.size && this.f[l] < this.f[m]) m = l;
if (r < this.size && this.f[r] < this.f[m]) m = r;
if (m === i) break;
this.swap(m, i); i = m;
}
}
return top;
}
swap(a, b) {
[this.items[a], this.items[b]] = [this.items[b], this.items[a]];
[this.f[a], this.f[b]] = [this.f[b], this.f[a]];
}
}
/** Bresenham line-of-sight through walkable/transparent tiles */
function hasLOS(transparent, x0, y0, x1, y1) {
let dx = Math.abs(x1 - x0), dy = Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1;
let err = dx - dy;
let x = x0, y = y0;
for (;;) {
if (x === x1 && y === y1) return true;
const e2 = 2 * err;
if (e2 > -dy) { err -= dy; x += sx; }
if (e2 < dx) { err += dx; y += sy; }
if (x === x1 && y === y1) return true;
if (!transparent(x, y)) return false;
}
}
D2.path = { find, hasLOS, MinHeap };
})(window.D2);
+327
View File
@@ -0,0 +1,327 @@
/* ============================================================
* Diablo2D — player.js : stats aggregation, leveling, skills UI logic
* ============================================================ */
'use strict';
window.D2 = window.D2 || {};
(function (D2) {
/* special legendary flags -> stat keys */
const SPECIAL_MAP = {
cleaveEcho: 'cleaveEcho',
doubleShot: 'doubleShot',
fireNovaOnKill: 'fireNovaOnKill',
chillAttacker: 'chillAttacker',
allSkills: null, // handled via skillBonusLevels
execHeal: 'execHeal',
stormDash: 'dashCdrPct', // value applied below
lifesteal: 'lifestealPct',
eliteSlayer: 'eliteDmgPct',
chainOnHit: 'chainOnHitPct',
corpseBoom: 'corpseBoom',
venomStrike: 'venomOnHit',
voidstep: 'blinkCdrPct',
};
function createPlayer(classId) {
const p = new D2.entities.Player(classId);
/* baseMods are applied inside recompute(); attributes hold allocated points only */
/* starter gear */
p.equip.weapon = D2.Items.startingWeapon(classId);
p.equip.chest = D2.Items.startingArmor();
/* auto-assign starting actives to hotbar */
const actives = D2.Skills.skillsFor(classId).filter(s => s.type === 'active' && s.tier === 0);
let slot = 1;
for (const a of actives) {
if (slot >= 5) break;
p.skills[a.id] = 0; // rank 0 = known-but-untrained? no: grant rank 1 free
p.skills[a.id] = 1;
p.hotbar[slot] = a.id;
slot++;
}
recompute(p);
p.hp = p.maxHp;
p.mana = p.maxMana;
return p;
}
/** full stat aggregation */
function recompute(p) {
const oldMaxHp = p.maxHp, oldMaxMana = p.maxMana;
const BAL = D2.BAL;
const s = {};
const add = (k, v) => { s[k] = (s[k] || 0) + v; };
/* class base */
const cls = D2.Skills.CLASSES[p.classId];
for (const [k, v] of Object.entries(cls.baseMods)) add(k, v);
add('armor', D2.BAL.baseArmor);
/* allocated attributes */
for (const [k, v] of Object.entries(p.attributes)) if (v) add(k, v);
/* per-level growth (small) */
add('str', Math.floor(p.level / 4));
add('dex', Math.floor(p.level / 4));
add('vit', Math.floor(p.level / 3));
add('ene', Math.floor(p.level / 4));
/* equipment */
let skillBonusLevels = 0;
for (const slotKey of Object.keys(p.equip)) {
const it = p.equip[slotKey];
if (!it) continue;
if (it.stats) for (const [k, v] of Object.entries(it.stats)) add(k, v);
if (it.armor) add('armor', it.armor);
if (it.flatDmg) add('flatDmg', it.flatDmg);
if (it.special === 'allSkills') skillBonusLevels = 1;
const flag = SPECIAL_MAP[it.special];
if (flag === 'lifestealPct') s.lifestealPct = 0.06;
else if (flag === 'chainOnHitPct') s.chainOnHitPct = 0.15;
else if (flag === 'eliteDmgPct') s.eliteDmgPct = 25;
else if (flag === 'dashCdrPct') s.dashCdrPct = 30;
else if (flag === 'blinkCdrPct') s.blinkCdrPct = 30;
else if (flag) s[flag] = true;
}
/* passives from learned skills */
for (const [skillId, rank] of Object.entries(p.skills)) {
if (!rank || rank <= 0) continue;
const sk = D2.Skills.findSkill(p.classId, skillId);
if (!sk || sk.type !== 'passive') continue;
const mods = sk.mods(rank);
for (const [k, v] of Object.entries(mods)) add(k, v);
}
/* active buffs */
for (const b of p.buffs) {
if (b.mods) for (const [k, v] of Object.entries(b.mods)) add(k, v);
}
/* shrine/regen derived */
p.stats = s;
/* derived attributes */
p.maxHp = Math.round(BAL.baseHp + s.vit * BAL.hpPerVit + (p.level - 1) * BAL.hpPerLevel + (s.hpFlat || 0));
p.maxMana = Math.round(BAL.baseMana + s.ene * BAL.manaPerEne + (p.level - 1) * BAL.manaPerLevel + (s.manaFlat || 0));
/* keep ratio when max grows */
if (oldMaxHp > 0 && p.maxHp > oldMaxHp) p.hp += p.maxHp - oldMaxHp;
if (oldMaxMana > 0 && p.maxMana > oldMaxMana) p.mana += p.maxMana - oldMaxMana;
p.hp = D2.util.clamp(p.hp, 0, p.maxHp);
p.mana = D2.util.clamp(p.mana, 0, p.maxMana);
p.moveSpeed = Math.min(BAL.speedCap,
BAL.baseSpeed * (1 + (s.moveSpeedPct || 0) / 100));
p.skillBonusLevels = skillBonusLevels;
}
function buffMod(p, key) {
let v = 0;
for (const b of p.buffs) if (b.mods && b.mods[key]) v += b.mods[key];
return v;
}
PlayerProtoInit();
function PlayerProtoInit() {
D2.entities.Player.prototype.buffMod = function (key) { return buffMod(this, key); };
}
/* ---------------- XP & levels ---------------- */
function gainXp(game, amount, source) {
const p = game.player;
amount = Math.round(amount * (1 + buffMod(p, 'xpGain') / 100));
p.xp += amount;
game.addFloatText(
source ? source.x : p.x,
source ? source.y - 0.5 : p.y - 0.5,
'+' + amount + ' xp', '#c8a35a', 11);
while (p.level < D2.BAL.maxLevel && p.xp >= D2.BAL.xpForLevel(p.level)) {
levelUp(game);
}
if (D2.ui) { D2.ui.refreshHud(); }
}
function levelUp(game) {
const p = game.player;
p.level++;
p.statPoints += D2.BAL.statPointsPerLevel;
p.skillPoints += D2.BAL.skillPointsPerLevel;
recompute(p);
p.hp = p.maxHp;
p.mana = p.maxMana;
game.sfx('levelup');
game.toast(D2.i18n.t('msg.levelup'), 'gold');
game.addFloatText(p.x, p.y, 'LEVEL ' + p.level + '!', '#ffe86a', 18);
game.spawnParticles(p.x, p.y, { count: 26, color: '#ffe86a', speed: 4.5, life: 0.9, size: 2.8, z: 14 });
game.addLight(p.x, p.y, 5, '#ffe86a', 0.9, 1);
if (D2.ui) { D2.ui.refreshHud(); D2.ui.refreshCharacter(); D2.ui.refreshSkills(); }
}
function allocateStat(p, key) {
if (p.statPoints <= 0) return false;
if (!(key in p.attributes)) return false;
p.attributes[key]++;
p.statPoints--;
recompute(p);
return true;
}
/* ---------------- skills ---------------- */
function getSkillRank(p, skillId) {
const r = p.skills[skillId] || 0;
if (r > 0 && p.skillBonusLevels) {
const sk = D2.Skills.findSkill(p.classId, skillId);
if (sk && sk.type === 'active') return Math.min(sk.maxRank, r + p.skillBonusLevels);
}
return r;
}
function canLearnSkill(p, skill) {
if ((p.skills[skill.id] || 0) >= skill.maxRank) return 'maxed';
if (p.level < skill.unlockLevel) return 'level';
if (p.skillPoints <= 0) return 'points';
return true;
}
function learnSkill(game, skill) {
const p = game.player;
const ok = canLearnSkill(p, skill);
if (ok !== true) {
if (game && game.sfx) game.sfx('error');
return false;
}
p.skills[skill.id] = (p.skills[skill.id] || 0) + 1;
p.skillPoints--;
/* auto-assign to hotbar on first rank */
if (p.skills[skill.id] === 1 && !p.hotbar.includes(skill.id)) {
const empty = p.hotbar.findIndex((v, i) => i > 0 && v == null);
if (empty >= 0) p.hotbar[empty] = skill.id;
}
if (game && game.sfx) game.sfx('buff');
recompute(p);
if (D2.ui) { D2.ui.refreshSkills(); D2.ui.refreshHotbar(); }
return true;
}
/* ---------------- potions ---------------- */
let beltCd = 0;
function usePotion(game, type) {
const p = game.player;
if (beltCd > 0) return false;
if (p.potions[type] <= 0) {
game.toast(D2.i18n.t('msg.potion_none'), 'bad');
if (game && game.sfx) game.sfx('error');
return false;
}
const BAL = D2.BAL;
if (type === 'hp') {
if (p.hp >= p.maxHp) return false;
const heal = p.maxHp * BAL.potionHealPct + BAL.potionHealFlat(p.level);
D2.combat.healPlayer(game, heal);
} else {
if (p.mana >= p.maxMana) return false;
const restore = p.maxMana * BAL.potionManaPct + BAL.potionHealFlat(p.level) * 0.6;
p.mana = Math.min(p.maxMana, p.mana + restore);
game.addFloatText(p.x, p.y - .3, '+' + Math.round(restore), '#6f9fe8', 11);
}
p.potions[type]--;
beltCd = 0.8;
game.sfx('potion');
if (D2.ui) D2.ui.refreshHotbar();
return true;
}
function tickBelt(dt) { if (beltCd > 0) beltCd -= dt; }
/* ---------------- equip/unequip ---------------- */
function resolvePlayer(g) { return g && g.player ? g.player : g; }
function equipItem(game, item, fromInventoryIdx) {
const p = resolvePlayer(game);
let slotKey = item.slot;
if (item.slot === 'ring') {
if (!p.equip.ring1) slotKey = 'ring1';
else if (!p.equip.ring2) slotKey = 'ring2';
else slotKey = 'ring1';
}
const prev = p.equip[slotKey];
p.equip[slotKey] = item;
if (fromInventoryIdx != null) {
p.inventory.splice(fromInventoryIdx, 1);
}
if (prev) p.inventory.push(prev);
recompute(p);
if (game && game.sfx) game.sfx('pickup');
if (D2.ui) { D2.ui.refreshInventory(); D2.ui.refreshCharacter(); D2.ui.refreshHotbar(); }
return true;
}
function unequipSlot(game, slotKey) {
const p = resolvePlayer(game);
const it = p.equip[slotKey];
if (!it) return false;
if (p.inventory.length >= 40) {
if (game && game.toast) game.toast(D2.i18n.t('msg.inventory_full'), 'bad');
return false;
}
p.equip[slotKey] = null;
p.inventory.push(it);
recompute(p);
if (game && game.sfx) game.sfx('click');
if (D2.ui) { D2.ui.refreshInventory(); D2.ui.refreshCharacter(); }
return true;
}
function sellItem(game, invIdxOrItem, priceOverride) {
const p = resolvePlayer(game);
let item, idx = invIdxOrItem;
if (typeof invIdxOrItem === 'object') { item = invIdxOrItem; idx = p.inventory.indexOf(item); }
else item = p.inventory[idx];
if (!item) return false;
const val = priceOverride != null ? priceOverride : Math.round(item.value * D2.BAL.sellRatio);
if (idx >= 0) p.inventory.splice(idx, 1);
else {
/* equipped? */
for (const k of Object.keys(p.equip)) if (p.equip[k] === item) p.equip[k] = null;
}
p.gold += val;
if (game && game.sfx) game.sfx('sell');
recompute(p);
if (D2.ui) { D2.ui.refreshVendor(); D2.ui.refreshInventory(); D2.ui.refreshCharacter(); }
return val;
}
function buyItem(game, item) {
const p = resolvePlayer(game);
if (p.gold < item.value) {
if (game && game.toast) game.toast(D2.i18n.t('msg.not_enough_mana').replace(/mana/i, 'gold'), 'bad');
if (game && game.sfx) game.sfx('error');
return false;
}
if (p.inventory.length >= 40) {
if (game && game.toast) game.toast(D2.i18n.t('msg.inventory_full'), 'bad');
return false;
}
p.gold -= item.value;
p.inventory.push(item);
if (game && game.sfx) game.sfx('buy');
if (D2.ui) { D2.ui.refreshVendor(); D2.ui.refreshInventory(); }
return true;
}
D2.player = {
createPlayer, recompute, gainXp, allocateStat,
getSkillRank, canLearnSkill, learnSkill,
usePotion, tickBelt,
equipItem, unequipSlot, sellItem, buyItem,
buffMod,
};
})(window.D2);
+513
View File
@@ -0,0 +1,513 @@
/* ============================================================
* Diablo2D — world.js : procedural dungeon & town generation
* ============================================================ */
'use strict';
window.D2 = window.D2 || {};
(function (D2) {
const T = { VOID: 0, FLOOR: 1, WALL: 2 };
const THEMES = {
cathedral: {
gen: 'rooms', floorsPerRoom: 0.9,
floorCols: ['#4a3f38', '#52453c', '#453b34', '#57493e'],
wallTop: '#2c2622', wallFace: '#3a322c',
accent: '#7a5a3a', torch: '#ff9a4a', fog: '#0a0806', ambient: 0.16,
music: 'crypt',
},
catacombs: {
gen: 'rooms',
floorCols: ['#3a4048', '#414750', '#363c44', '#464c55'],
wallTop: '#23282e', wallFace: '#30363d',
accent: '#4a6a8a', torch: '#7ab8ff', fog: '#07090c', ambient: 0.12,
music: 'crypt',
},
caves: {
gen: 'caves',
floorCols: ['#3e4a34', '#46523a', '#38442e', '#4d5940'],
wallTop: '#242c20', wallFace: '#323e2a',
accent: '#5a8a3a', torch: '#b8e86a', fog: '#080a06', ambient: 0.14,
music: 'cave',
},
hell: {
gen: 'caves',
floorCols: ['#48302a', '#50362e', '#422a24', '#583a30'],
wallTop: '#281512', wallFace: '#38201a',
accent: '#c83a1a', torch: '#ff5a2a', fog: '#100604', ambient: 0.18,
music: 'hell',
},
town: {
gen: 'town',
floorCols: ['#55503e', '#5c5644', '#4e4938', '#635d49'],
wallTop: '#33291e', wallFace: '#453829',
accent: '#8a7a4a', torch: '#ffb84a', fog: '#0c0a08', ambient: 0.32,
music: 'town',
},
};
/* ---------------- helpers ---------------- */
function idx(w, x, y) { return y * w + x; }
function inBounds(w, h, x, y) { return x >= 0 && y >= 0 && x < w && y < h; }
/* ---------------- rooms & corridors generator ---------------- */
function genRooms(rng, w, h, roomAttempts) {
const tiles = new Uint8Array(w * h);
const rooms = [];
for (let i = 0; i < roomAttempts && rooms.length < 14; i++) {
const rw = rng.int(6, 13), rh = rng.int(5, 10);
const rx = rng.int(2, w - rw - 3), ry = rng.int(2, h - rh - 3);
const room = { x: rx, y: ry, w: rw, h: rh, cx: (rx + rw / 2) | 0, cy: (ry + rh / 2) | 0 };
if (rooms.some(r => overlap(room, r, 2))) continue;
rooms.push(room);
carveRect(tiles, w, room);
}
/* L corridors between consecutive rooms */
for (let i = 1; i < rooms.length; i++) {
const a = rooms[i - 1], b = rooms[i];
carveL(tiles, w, a.cx, a.cy, b.cx, b.cy);
}
/* a couple of loops */
for (let k = 0; k < 3 && rooms.length > 4; k++) {
const a = rng.pick(rooms), b = rng.pick(rooms);
if (a !== b) carveL(tiles, w, a.cx, a.cy, b.cx, b.cy);
}
return { tiles, rooms };
}
function overlap(a, b, pad) {
return a.x - pad < b.x + b.w && a.x + a.w + pad > b.x &&
a.y - pad < b.y + b.h && a.y + a.h + pad > b.y;
}
function carveRect(tiles, w, r) {
for (let y = r.y; y < r.y + r.h; y++)
for (let x = r.x; x < r.x + r.w; x++)
tiles[idx(w, x, y)] = T.FLOOR;
}
function carveL(tiles, w, x0, y0, x1, y1) {
let x = x0, y = y0;
const horizFirst = Math.random() < 0.5;
const step = () => { tiles[idx(w, x, y)] = T.FLOOR; };
step();
if (horizFirst) {
while (x !== x1) { x += Math.sign(x1 - x); step(); }
while (y !== y1) { y += Math.sign(y1 - y); step(); }
} else {
while (y !== y1) { y += Math.sign(y1 - y); step(); }
while (x !== x1) { x += Math.sign(x1 - x); step(); }
}
}
/* ---------------- cellular cave generator ---------------- */
function genCaves(rng, w, h) {
const tiles = new Uint8Array(w * h);
const wallP = 0.44;
for (let y = 0; y < h; y++)
for (let x = 0; x < w; x++)
tiles[idx(w, x, y)] = (x === 0 || y === 0 || x === w - 1 || y === h - 1 || rng.next() < wallP) ? T.WALL : T.FLOOR;
for (let pass = 0; pass < 4; pass++) {
const next = Uint8Array.from(tiles);
for (let y = 1; y < h - 1; y++) {
for (let x = 1; x < w - 1; x++) {
let walls = 0;
for (let dy = -1; dy <= 1; dy++)
for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
if (tiles[idx(w, x + dx, y + dy)] !== T.FLOOR) walls++;
}
next[idx(w, x, y)] = walls >= 5 ? T.WALL : T.FLOOR;
}
}
tiles.set(next);
}
/* keep only the largest connected region */
const seen = new Uint8Array(w * h);
let best = null;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i0 = idx(w, x, y);
if (tiles[i0] !== T.FLOOR || seen[i0]) continue;
const region = [];
const stack = [[x, y]];
seen[i0] = 1;
while (stack.length) {
const [cx, cy] = stack.pop();
region.push([cx, cy]);
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) {
const nx = cx + dx, ny = cy + dy;
if (!inBounds(w, h, nx, ny)) continue;
const ni = idx(w, nx, ny);
if (!seen[ni] && tiles[ni] === T.FLOOR) { seen[ni] = 1; stack.push([nx, ny]); }
}
}
if (!best || region.length > best.length) best = region;
}
}
const keep = new Set(best.map(([x, y]) => idx(w, x, y)));
for (let i = 0; i < tiles.length; i++)
if (tiles[i] === T.FLOOR && !keep.has(i)) tiles[i] = T.VOID;
/* pseudo-rooms = flood-fill pockets for prop/spawn logic */
const rooms = [];
const grid = {};
for (const [x, y] of best) grid[x + ',' + y] = true;
// sample room anchors on a coarse grid where floor exists
for (let gy = 6; gy < h - 6; gy += 9) {
for (let gx = 6; gx < w - 6; gx += 9) {
if (grid[gx + ',' + gy]) rooms.push({ x: gx - 3, y: gy - 3, w: 7, h: 7, cx: gx, cy: gy });
}
}
return { tiles, rooms };
}
/* ---------------- walls around floors ---------------- */
function buildWalls(tiles, w, h) {
const out = Uint8Array.from(tiles);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (tiles[idx(w, x, y)] !== T.VOID) continue;
let touchesFloor = false;
for (let dy = -1; dy <= 1 && !touchesFloor; dy++)
for (let dx = -1; dx <= 1; dx++) {
const nx = x + dx, ny = y + dy;
if (inBounds(w, h, nx, ny) && tiles[idx(w, nx, ny)] === T.FLOOR) { touchesFloor = true; break; }
}
if (touchesFloor) out[idx(w, x, y)] = T.WALL;
}
}
return out;
}
/* ---------------- floor assembly ---------------- */
/**
* opts: { act, floorIdx, seed, bossLair, torment }
* Returns the World object used by simulation & rendering.
*/
function generateFloor(opts) {
const act = opts.act | 0;
const floorIdx = opts.floorIdx | 0;
const themeId = D2.BAL.acts[act].theme;
const theme = THEMES[themeId];
const rng = new D2.util.RNG(opts.seed >>> 0);
const isBoss = !!opts.bossLair;
const w = isBoss ? 52 : 62, h = isBoss ? 38 : 62;
let tiles, rooms;
if (isBoss) {
tiles = new Uint8Array(w * h);
const m = 4;
const room = { x: m, y: m, w: w - m * 2, h: h - m * 2 };
carveRect(tiles, w, room);
rooms = [room];
/* pillars for cover */
for (let py = room.y + 4; py < room.y + room.h - 3; py += 6) {
for (let px = room.x + 5; px < room.x + room.w - 4; px += 8) {
tiles[idx(w, px, py)] = T.WALL;
tiles[idx(w, px + 1, py)] = T.WALL;
}
}
} else if (theme.gen === 'rooms') {
({ tiles, rooms } = genRooms(rng, w, h, 90));
} else {
({ tiles, rooms } = genCaves(rng, w, h));
}
const finalTiles = buildWalls(tiles, w, h);
/* variants for art */
const variant = new Uint8Array(w * h);
for (let i = 0; i < variant.length; i++) variant[i] = (rng.next() * 4) | 0;
const world = {
w, h,
tiles: finalTiles,
variant,
theme, themeId, act, floorIdx,
isBossLair: isBoss,
seed: opts.seed >>> 0,
torment: opts.torment | 0,
rooms,
props: [],
propMap: new Map(), // "x,y" -> blocking prop
spawns: [],
visible: new Uint8Array(w * h),
explored: new Uint8Array(w * h),
torches: [], // light sources {x,y,color,r,flicker}
spawnX: 0, spawnY: 0,
stairsDown: null, stairsUp: null,
mlvl: D2.BAL.monsterLevel(act, floorIdx, opts.torment | 0),
tileAt(x, y) { return inBounds(w, h, x, y) ? finalTiles[idx(w, x, y)] : T.VOID; },
isWalkable(x, y) {
if (this.tileAt(Math.floor(x), Math.floor(y)) !== T.FLOOR) return false;
return !this.propMap.has(Math.floor(x) + ',' + Math.floor(y));
},
transparent(x, y) { return this.tileAt(x, y) === T.FLOOR; },
randomFloorIn(room) {
for (let tries = 0; tries < 30; tries++) {
const x = rng.int(room.x, room.x + room.w - 1);
const y = rng.int(room.y, room.y + room.h - 1);
if (finalTiles[idx(w, x, y)] === T.FLOOR && !this.propMap.has(x + ',' + y)) return { x, y };
}
return null;
},
addProp(p) {
p.uid = D2.util.uid();
this.props.push(p);
if (p.blocking) this.propMap.set(p.x + ',' + p.y, p);
if (p.type === 'torch') this.torches.push({ x: p.x, y: p.y });
return p;
},
removeProp(p) {
const i = this.props.indexOf(p);
if (i >= 0) this.props.splice(i, 1);
this.propMap.delete(p.x + ',' + p.y);
},
};
/* ---- spawn point = first room center ---- */
const startRoom = rooms[0];
world.spawnX = startRoom.cx; world.spawnY = startRoom.cy;
/* ---- stairs ---- */
if (!isBoss) {
const lastRoom = farthestRoom(world, rooms, startRoom);
const sp = world.randomFloorIn(lastRoom) || { x: lastRoom.cx, y: lastRoom.cy };
world.stairsDown = { x: sp.x, y: sp.y };
world.addProp({ type: 'stairs_down', x: sp.x, y: sp.y, blocking: false });
} else {
world.stairsUp = { x: startRoom.cx, y: startRoom.cy };
world.addProp({ type: 'stairs_up', x: startRoom.cx, y: startRoom.cy, blocking: false });
}
populateProps(world, rng, theme);
populateMonsters(world, rng, act, isBoss);
ensureRouteToStairs(world);
return world;
}
/* Remove blocking props along one spawn→stairs route so caves
with 1-wide tunnels can never be sealed by barrels. */
function ensureRouteToStairs(world) {
if (!world.stairsDown || world.isTown) return;
const tilesPath = D2.path.find(
(x, y) => world.transparent(x, y),
world.w, world.h,
world.spawnX, world.spawnY,
world.stairsDown.x, world.stairsDown.y, 30000);
if (!tilesPath) return;
for (const n of tilesPath) {
const pr = world.propMap.get(n.x + ',' + n.y);
if (pr && pr.blocking) world.removeProp(pr);
}
}
function farthestRoom(world, rooms, from) {
let best = rooms[rooms.length - 1], bestD = -1;
for (const r of rooms) {
const d = (r.cx - from.cx) ** 2 + (r.cy - from.cy) ** 2;
if (d > bestD) { bestD = d; best = r; }
}
return best;
}
/* ---------------- props ---------------- */
function populateProps(world, rng, theme) {
const { w, h } = world;
const at = (x, y) => world.tileAt(x, y) === T.FLOOR && !world.propMap.has(x + ',' + y);
/* torches along walls */
const torchSpots = [];
for (let y = 1; y < h - 1; y++)
for (let x = 1; x < w - 1; x++)
if (world.tiles[idx(w, x, y)] === T.WALL &&
world.tileAt(x, y + 1) === T.FLOOR)
torchSpots.push([x, y]);
rng.shuffle(torchSpots);
const torchCount = Math.min(18, torchSpots.length);
for (let i = 0; i < torchCount; i++) {
const [x, y] = torchSpots[i];
world.addProp({ type: 'torch', x, y: y, blocking: false, light: true });
}
if (world.isBossLair) {
/* a chest behind the boss area */
return;
}
for (const room of world.rooms.slice(1)) {
/* barrels & urns */
const clusterN = rng.int(0, 3);
for (let c = 0; c < clusterN; c++) {
const bx = rng.int(room.x + 1, room.x + room.w - 2);
const by = rng.int(room.y + 1, room.y + room.h - 2);
const n = rng.int(1, 3);
for (let i = 0; i < n; i++) {
const x = Math.min(room.x + room.w - 1, bx + i % 2), y = by;
if (at(x, y)) world.addProp({
type: rng.chance(0.5) ? 'barrel' : 'urn',
x, y, blocking: true, hp: 1,
});
}
}
/* chest */
if (rng.chance(0.3)) {
const spot = world.randomFloorIn(room);
if (spot) world.addProp({ type: 'chest', x: spot.x, y: spot.y, blocking: true, opened: false });
}
/* shrine (rare) */
if (rng.chance(0.05)) {
const spot = world.randomFloorIn(room);
if (spot) world.addProp({
type: 'shrine', x: spot.x, y: spot.y, blocking: false, used: false,
buff: rng.pick(['dmg', 'speed', 'armor', 'xp', 'regen']),
});
}
}
}
/* ---------------- monsters ---------------- */
function populateMonsters(world, rng, act, isBoss) {
if (isBoss) {
const bd = D2.Monsters.BOSS_MAP[D2.BAL.acts[act].boss];
world.spawns.push({ boss: bd.id, x: world.w / 2, y: world.h * 0.68 });
/* honor guard */
const guard = D2.Monsters.rollSpeciesForAct(act);
for (let i = 0; i < 4; i++) {
world.spawns.push({ speciesId: guard.id, x: world.w / 2 + (rng.range(-5, 5)), y: world.h * 0.55 + rng.range(-2, 2), elite: false });
}
return;
}
const density = 0.55 + world.act * 0.12 + world.floorIdx * 0.05;
for (const room of world.rooms.slice(1)) {
if (world.stairsDown && room === nearestRoomToStairs(world)) continue;
const packs = Math.max(1, Math.round((room.w * room.h) / 55 * density));
for (let p = 0; p < packs; p++) {
const species = D2.Monsters.rollSpeciesForAct(act);
const packSize = species.ai === 'swarm' ? rng.int(3, 5) : rng.int(2, 4);
const packElite = rng.chance(0.13);
const base = world.randomFloorIn(room);
if (!base) continue;
for (let i = 0; i < packSize; i++) {
const sx = Math.max(1, Math.min(world.w - 2, base.x + ((rng.next() * 5) | 0) - 2));
const sy = Math.max(1, Math.min(world.h - 2, base.y + ((rng.next() * 5) | 0) - 2));
if (world.tileAt(sx, sy) === T.FLOOR)
world.spawns.push({ speciesId: species.id, x: sx, y: sy, elite: packElite });
}
}
}
}
function nearestRoomToStairs(world) {
if (!world.rooms.length || !world.stairsDown) return null;
let best = null, bd = Infinity;
for (const r of world.rooms) {
const d = (r.cx - world.stairsDown.x) ** 2 + (r.cy - world.stairsDown.y) ** 2;
if (d < bd) { bd = d; best = r; }
}
return best;
}
/* ---------------- town ---------------- */
function generateTown(seed) {
const w = 56, h = 40;
const rng = new D2.util.RNG((seed ^ 0x7A6E) >>> 0);
const theme = THEMES.town;
const tiles = new Uint8Array(w * h).fill(T.VOID);
const border = 2;
for (let y = border; y < h - border; y++)
for (let x = border; x < w - border; x++)
tiles[idx(w, x, y)] = T.FLOOR;
const world = {
w, h, tiles, variant: new Uint8Array(w * h),
theme, themeId: 'town', act: -1, floorIdx: -1,
isBossLair: false, isTown: true, seed: seed >>> 0, torment: 0,
rooms: [], props: [], propMap: new Map(),
spawns: [], visible: new Uint8Array(w * h), explored: new Uint8Array(w * h),
torches: [], spawnX: (w / 2) | 0, spawnY: (h * 0.62) | 0,
stairsDown: null, stairsUp: null, mlvl: 1,
tileAt(x, y) { return inBounds(w, h, x, y) ? tiles[idx(w, x, y)] : T.VOID; },
isWalkable(x, y) {
if (this.tileAt(Math.floor(x), Math.floor(y)) !== T.FLOOR) return false;
return !this.propMap.has(Math.floor(x) + ',' + Math.floor(y));
},
transparent(x, y) { return this.tileAt(x, y) === T.FLOOR; },
randomFloorIn(room) { return null; },
addProp(p) {
p.uid = D2.util.uid();
this.props.push(p);
if (p.blocking) this.propMap.set(p.x + ',' + p.y, p);
if (p.type === 'torch') this.torches.push({ x: p.x, y: p.y });
return p;
},
removeProp(p) {
const i = this.props.indexOf(p);
if (i >= 0) this.props.splice(i, 1);
this.propMap.delete(p.x + ',' + p.y);
},
};
/* Rogue-camp buildings along the top (charsi/akara/stash/kashya/cain/gheed) */
const bw = 7, bh = 6;
const buildings = [
{ x: 3, npc: 'charsi' }, // blacksmith
{ x: 11, npc: 'akara' }, // healer & magic
{ x: 19, npc: 'stash' }, // camp storage
{ x: 27, npc: 'kashya' }, // rogue captain — hunt quests
{ x: 35, npc: 'cain' }, // elder lore — main quests
{ x: 43, npc: 'gheed' }, // gambler
].map(b => ({ ...b, w: bw, h: bh, y: 4 }));
for (const b of buildings) {
for (let y = b.y; y < b.y + b.h; y++)
for (let x = b.x; x < b.x + b.w; x++)
tiles[idx(w, x, y)] = T.WALL;
/* doorway at bottom center */
const doorX = b.x + (b.w >> 1);
tiles[idx(w, doorX, b.y + b.h - 1)] = T.FLOOR;
tiles[idx(w, doorX, b.y + b.h - 2)] = T.FLOOR;
world.addProp({ type: 'npc_' + b.npc, x: doorX, y: b.y + b.h - 4, blocking: true });
world.addProp({ type: 'torch', x: b.x - 1, y: b.y + b.h, blocking: false, light: true });
world.addProp({ type: 'torch', x: b.x + b.w, y: b.y + b.h, blocking: false, light: true });
}
/* waypoint stone center */
world.addProp({ type: 'waypoint', x: (w / 2) | 0, y: (h * 0.38) | 0, blocking: true });
/* decorative props */
for (let i = 0; i < 10; i++) {
const x = rng.int(border + 1, w - border - 2), y = rng.int(h * 0.55 | 0, h - border - 2);
if (world.tileAt(x, y) === T.FLOOR && !world.propMap.has(x + ',' + y))
world.addProp({ type: rng.chance(0.5) ? 'barrel' : 'bones', x, y, blocking: rng.chance(0.5), hp: 1 });
}
/* fence posts around perimeter */
for (let x = border; x < w - border; x += 3) {
world.addProp({ type: 'bones', x, y: h - border, blocking: false });
world.addProp({ type: 'bones', x, y: border - 1 >= 0 ? border : border, blocking: false });
}
/* explored everywhere in town */
world.explored.fill(1);
return world;
}
D2.world = { T, THEMES, generateFloor, generateTown };
})(window.D2);