/* ============================================================
* Diablo2D — game.js : central state machine & simulation loop
* ============================================================ */
'use strict';
window.D2 = window.D2 || {};
(function (D2) {
const G = {
state: 'boot', // boot|title|classselect|playing|paused|dead|victory|settings|help
world: null,
player: null,
monsters: [],
projectiles: [],
pickups: [],
particles: [],
floatTexts: [],
lights: [],
effects: [],
groundDecals: [],
time: 0,
settings: {
masterVol: 0.8, musicVol: 0.55, sfxVol: 0.9,
lang: 'en', screenShake: true, dmgNumbers: true, labels: 'alt',
hardcore: false,
},
progress: { act: 0, floorIdx: 0, torment: 0, bossesKilled: [], quests: {}, qCount: {} },
waypointsUnlocked: [true, false, false, false],
stash: [],
vendorStock: [],
quests: [],
skillCooldowns: {},
hoverEntity: null,
moveMarker: null,
mouseWorld: { x: 0, y: 0 },
_grid: new D2.entities.Grid(2.5),
_fovTile: '',
_repathT: 0,
_autosaveT: 0,
bossRef: null,
};
/* ================= helpers used across modules ================= */
G.sfx = function (name, opts) { if (name) D2.audio.sfx(name, opts); };
G.toast = function (text, cls) {
if (D2.ui) D2.ui.toast(text, cls);
else console.log('[toast]', text);
};
G.addFloatText = function (x, y, text, color, size) {
if (!G.settings.dmgNumbers && /^\d/.test(String(text))) return;
if (G.floatTexts.length > D2.BAL.maxFloatingText) G.floatTexts.shift();
G.floatTexts.push({ x, y, text, color: color || '#fff', size: size || 12, life: 0.9, maxLife: 0.9 });
};
G.spawnParticles = function (x, y, o = {}) {
const n = Math.min(o.count || 6, D2.BAL.maxParticles - G.particles.length);
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2;
const sp = (o.speed || 3) * (0.4 + Math.random() * 0.8);
G.particles.push({
x, y, z: o.z || 6,
vx: Math.cos(a) * sp, vy: Math.sin(a) * sp * 0.55,
vz: 2 + Math.random() * 3,
gravity: o.gravity != null ? o.gravity : 10,
color: o.color || '#fff',
size: (o.size || 2.2) * (0.6 + Math.random() * 0.7),
life: (o.life || 0.5) * (0.7 + Math.random() * 0.5),
maxLife: 1, alpha: o.alpha,
});
const pt = G.particles[G.particles.length - 1];
pt.maxLife = pt.life;
}
};
G.addLight = function (x, y, radius, color, intensity, dur) {
G.lights.push({ x, y, radius, color, intensity, t: dur || 0.4, maxT: dur || 0.4 });
if (G.lights.length > 40) G.lights.shift();
};
G.addEffect = function (fx) {
fx.t = fx.t || 0;
G.effects.push(fx);
if (G.effects.length > 120) G.effects.shift();
};
G.addGroundDecal = function (x, y, r, color, life) {
G.groundDecals.push({ x, y, r, color, life, maxLife: life, alpha: 1 });
if (G.groundDecals.length > 140) G.groundDecals.shift();
};
G.queryMonsters = function (x, y, r) {
return G._grid.query(x, y, r, []);
};
G.nearestMonster = function (x, y, r, filter) {
return G._grid.nearest(x, y, r, filter);
};
G.countMinionsOf = function (ownerUid) {
let c = 0;
for (const m of G.monsters) if (!m.dead && m.ownerUid === ownerUid) c++;
return c;
};
G.onMonsterRemoved = function () {};
G.spawnMonster = function (speciesId, x, y, opts = {}) {
const stats = D2.Monsters.buildMonster(speciesId, G.monsterLevel(), { elite: false });
const m = new D2.entities.Monster(stats, x, y);
if (opts.ownerUid) m.ownerUid = opts.ownerUid;
if (opts.aggroed) { m.aggro = true; }
G.monsters.push(m);
return m;
};
G.monsterLevel = function () {
if (!G.world || G.world.isTown) return Math.max(1, G.player ? G.player.level : 1);
return G.world.mlvl;
};
G.skillCooldownLeft = function (id) { return G.skillCooldowns[id] || 0; };
G.setSkillCooldown = function (id, dur) { G.skillCooldowns[id] = dur; };
G.showBossBar = function (m) { G.bossRef = m; if (D2.ui) D2.ui.showBossBar(m.name); };
G.hideBossBar = function () { G.bossRef = null; if (D2.ui) D2.ui.hideBossBar(); };
G.giveGold = function (n) { G.player.gold += n; if (D2.ui) D2.ui.refreshHud(); };
G.gainXp = function (amount, src) { D2.player.gainXp(G, amount, src); };
/* ================= run lifecycle ================= */
G.startNewGame = function (classId) {
D2.save.wipeAll();
G.progress = { act: 0, floorIdx: 0, torment: 0, bossesKilled: [], quests: {}, qCount: {} };
G.waypointsUnlocked = [true, false, false, false];
G.stash = [];
G.player = D2.player.createPlayer(classId);
G.enterTown(true);
G.state = 'playing';
D2.save.writeMeta({ classId, name: classId, level: 1, hardcore: G.settings.hardcore, torment: 0 });
G.saveGame();
};
G.continueGame = function () {
if (!G.loadGame()) { G.toast('No save found', 'bad'); return false; }
G.state = 'playing';
return true;
};
G.enterTown = function (silent) {
G.world = D2.world.generateTown((Math.random() * 2 ** 31) | 0);
G.monsters = []; G.projectiles = []; G.pickups = [];
G.effects = []; G.groundDecals = [];
G.bossRef = null; if (D2.ui) D2.ui.hideBossBar();
if (G.player) {
G.player.x = G.world.spawnX + 0.5;
G.player.y = G.world.spawnY + 0.5;
G.player.channel = null;
/* refill potions in town */
G.player.potions.hp = Math.max(G.player.potions.hp, 6);
G.player.potions.mp = Math.max(G.player.potions.mp, 4);
}
D2.audio.playMusic('town');
if (!silent) G.toast(D2.i18n.t('town.enter'));
G.refreshVendorStock();
G.saveGame();
if (D2.ui) D2.ui.refreshHud();
};
G.refreshVendorStock = function () {
const mlvl = Math.max(1, (G.player ? G.player.level : 1) + G.progress.torment * 8);
G.vendorStock = D2.Items.vendorStock(mlvl, 9 + ((Math.random() * 5) | 0));
};
G.enterFloor = function (act, floorIdx, opts = {}) {
const pr = G.progress;
pr.act = act; pr.floorIdx = floorIdx;
const seed = (hashStr(G.saveSeedStr()) + act * 7919 + floorIdx * 104729 + pr.torment * 31) >>> 0;
const isBoss = floorIdx >= D2.BAL.floorsPerAct - 1;
G.world = D2.world.generateFloor({ act, floorIdx, seed, bossLair: isBoss, torment: pr.torment });
G.monsters = []; G.projectiles = []; G.pickups = [];
G.effects = []; G.groundDecals = [];
G.player.x = G.world.spawnX + 0.5;
G.player.y = G.world.spawnY + 0.5;
G._fovTile = '';
/* instantiate spawns */
for (const s of G.world.spawns) {
if (s.boss) {
const bd = D2.Monsters.BOSS_MAP[s.boss];
const stats = D2.Monsters.buildMonster(bd.base, G.world.mlvl, { bossDef: bd });
const m = new D2.entities.Monster(stats, s.x, s.y);
m.isBoss = true; m.bossDef = bd;
m.name = bd.name[D2.i18n.getLang()] || bd.name.en;
m.shape = bd.shape; m.palette = bd.palette;
m.radius = D2.Monsters.SPECIES_MAP[bd.base].radius * bd.size;
m.maxHp = Math.round(stats.maxHp); m.hp = m.maxHp;
m.attacks = bd.attacks.slice();
m.speed = bd.speed;
G.monsters.push(m);
} else {
const stats = D2.Monsters.buildMonster(s.speciesId, G.world.mlvl, { elite: s.elite });
G.monsters.push(new D2.entities.Monster(stats, s.x + 0.5, s.y + 0.5));
}
}
D2.audio.playMusic(isBoss ? 'boss' : G.world.theme.music);
G.updateQuests();
G.saveGame();
if (D2.ui) D2.ui.refreshHud();
};
G.descend = function () {
const pr = G.progress;
const nextFloor = pr.floorIdx + 1;
if (nextFloor >= D2.BAL.floorsPerAct) {
G.enterFloor(pr.act, D2.BAL.floorsPerAct - 1, { boss: true });
} else {
G.enterFloor(pr.act, nextFloor);
}
G.sfx('stairs');
};
G.climbToTown = function () {
G.enterTown();
G.sfx('stairs');
};
G.useWaypoint = function (dest) {
/* dest: {town:true} | {act, floor} */
if (dest.town) { G.enterTown(); }
else G.enterFloor(dest.act, dest.floor);
G.sfx('teleport');
};
/* ---------- quests ---------- */
/* ================= quest engine (Diablo-style NPC chain) ================= */
G.questState = function (id) { return (G.progress.quests || {})[id] || 'none'; };
G.qCount = id => (G.progress.qCount || {})[id] || 0;
G.questObjective = function (q) {
const t = D2.BAL.acts[q.act];
if (q.type === 'boss') {
const bd = D2.Monsters.BOSS_MAP[t.boss];
return { label: D2.i18n.t(q.title, bd ? bd.name[D2.i18n.getLang()] || bd.name.en : t.boss),
desc: D2.i18n.t(q.desc, bd ? bd.name[D2.i18n.getLang()] || bd.name.en : t.boss),
done: (G.progress.bossesKilled || []).some(k => k.startsWith(t.boss + '_' + (q.torment ? G.progress.torment : Math.max(0, G.progress.torment)))) };
}
const n = D2.BAL.questKillBase(q.act);
return { label: D2.i18n.t(q.title), desc: D2.i18n.t(q.desc, n),
count: G.qCount(q.id), target: q.target,
done: G.qCount(q.id) >= q.target };
};
G.availableQuests = function () {
return D2.BAL.QUESTS.filter(q => {
if (G.questState(q.id) !== 'none') return false;
if (q.requiresVictory && !(G.progress.bossesKilled || []).some(k => k.startsWith('terrorlord'))) return false;
if (q.requires && G.questState(q.requires) !== 'claimed') return false;
if (!q.requiresVictory && !q.torment && q.act !== G.progress.act) {
/* allow earlier unfinished acts too */
if (q.act > G.progress.act) return false;
}
return true;
});
};
G.activeQuests = function () {
return D2.BAL.QUESTS.filter(q => G.questState(q.id) === 'active');
};
G.acceptQuest = function (id) {
if (G.questState(id) !== 'none') return false;
G.progress.quests[id] = 'active';
G.progress.qCount[id] = 0;
const q = D2.BAL.questById(id);
G.toast(D2.i18n.t('msg.quest_accepted', D2.i18n.t(q.title)), 'gold');
G.sfx('questdone');
G.updateQuests();
if (D2.ui) D2.ui.renderQuestTracker();
return true;
};
G.turnInQuest = function (id) {
const q = D2.BAL.questById(id);
const obj = G.questObjective(q);
if (G.questState(id) !== 'active' || !obj.done) return false;
const r = q.reward;
G.player.gold += r.gold || 0;
if (r.xp) G.gainXp(r.xp);
if (r.itemLevelBonus) {
const it = D2.Items.rollItem(D2.BAL.monsterLevel(G.progress.act, 3, G.progress.torment) + r.itemLevelBonus,
{ rarity: 'rare' });
if (G.player.inventory.length < 40) G.player.inventory.push(it);
}
G.progress.quests[id] = 'claimed';
G.sfx('questdone');
G.toast(D2.i18n.t('msg.quest_reward', D2.util.fmtNum(r.gold || 0)), 'gold');
G.saveGame();
if (D2.ui) D2.ui.renderQuestTracker();
return true;
};
G.onQuestKill = function (m) {
let changed = false;
for (const q of G.activeQuests()) {
if (q.type !== 'kills') continue;
G.progress.qCount[q.id] = (G.progress.qCount[q.id] || 0) + 1;
const obj = G.questObjective(q);
changed = true;
if (obj.done && obj.count === obj.target) {
G.toast(D2.i18n.t('ui.quest_done_return',
D2.i18n.t('npc.' + q.giver + '.name')), 'gold');
G.sfx('questdone');
}
}
if (changed && D2.ui) D2.ui.renderQuestTracker();
};
/* what each camp NPC offers — consumed by the dialog UI */
G.npcOptions = function (type) {
const opts = [];
const L = D2.i18n.getLang();
const opt = (label, fn, hl) => opts.push({ label, fn, highlight: hl });
if (type === 'charsi') {
opt('⚒ ' + D2.i18n.t('ui.vendor'), () => { D2.ui.closePanel('dialog'); D2.ui.openPanel('vendor'); });
} else if (type === 'akara') {
const cost = D2.BAL.healerCost(G.player.level);
opt('✚ ' + D2.i18n.t('ui.heal_full') + ` (${cost} 🜚)`, () => {
if (G.player.gold >= cost) {
G.player.gold -= cost;
G.player.hp = G.player.maxHp; G.player.mana = G.player.maxMana;
G.sfx('shrine'); ui_refresh(); G.saveGame();
D2.ui.closePanel('dialog');
} else { G.sfx('error'); D2.ui.toast('Not enough gold', 'bad'); }
});
const tc = D2.BAL.tomeCost(G.player.level);
opt(`📖 ${D2.i18n.t('ui.buy_tome')} (${tc} 🜚)`, () => {
if (G.player.gold >= tc) {
G.player.gold -= tc; G.player.skillPoints++;
G.sfx('buff'); D2.ui.toast(D2.i18n.t('ui.tome_bought'));
ui_refresh(); G.saveGame();
} else { G.sfx('error'); D2.ui.toast('Not enough gold', 'bad'); }
});
const rc = D2.BAL.respecCost(G.player.level);
opt(`♻ ${D2.i18n.t('ui.respec')} (${rc} 🜚)`, () => {
if (G.player.gold >= rc) {
G.player.gold -= rc;
const p = G.player;
p.statPoints += (p.attributes.str || 0) + (p.attributes.dex || 0) +
(p.attributes.vit || 0) + (p.attributes.ene || 0);
p.attributes = { str: 0, dex: 0, vit: 0, ene: 0 };
p.skillPoints += Object.values(p.skills).reduce((a, b) => a + b, 0);
p.skills = {}; p.hotbar = ['basic', null, null, null, null];
D2.player.recompute(p); p.hp = p.maxHp; p.mana = p.maxMana;
G.sfx('shrine'); D2.ui.toast(D2.i18n.t('ui.respec_done'));
ui_refresh(); D2.ui.refreshHotbar(); G.saveGame();
} else { G.sfx('error'); D2.ui.toast('Not enough gold', 'bad'); }
});
} else if (type === 'kashya' || type === 'cain') {
for (const q of D2.BAL.QUESTS.filter(x =>
x.giver === type && G.availableQuests().includes(x))) {
const obj = G.questObjective(q);
opt(`❗ ${obj.label}`, () => offerQuest(q), true);
}
for (const q of D2.BAL.QUESTS.filter(x =>
x.giver === type && G.questState(x.id) === 'active')) {
const obj = G.questObjective(q);
if (obj.done) {
const rw = q.reward;
opt(`✔ ${D2.i18n.t('ui.turn_in')}: ${obj.label} (+${rw.gold}🜚 +${rw.xp}xp)`,
() => { G.turnInQuest(q.id); reopenDialog(type); }, true);
}
}
if (type === 'cain') opt('📖 ' + D2.i18n.t('ui.lore'), () => {
D2.ui.toast(D2.i18n.getLang() === 'vi'
? '"Tristram từng là một cảng bình yên… cho đến khi tiếng chuông nhà thờ vang lên ngược."'
: '"Tristram was a quiet port once… until the cathedral bell rang backwards."', '');
});
} else if (type === 'gheed') {
const gc = D2.BAL.gambleCost(G.player.level);
opt(`🎲 ${D2.i18n.t('ui.gamble')} (${gc} 🜚)`, () => {
if (G.player.gold < gc) { G.sfx('error'); D2.ui.toast('Not enough gold', 'bad'); return; }
G.player.gold -= gc;
const roll = Math.random();
const rarity = roll < 0.55 ? 'magic' : roll < 0.9 ? 'rare' : 'legendary';
const it = D2.Items.rollItem(D2.BAL.monsterLevel(G.progress.act, 1, G.progress.torment) + 2,
{ rarity });
if (G.player.inventory.length < 40) {
G.player.inventory.push(it);
G.sfx(rarity === 'legendary' ? 'legendary' : 'gold');
D2.ui.toast(`${D2.i18n.t('ui.gamble_win')} — ${it.name}`,
rarity === 'legendary' ? 'gold' : '');
ui_refresh();
} else { G.sfx('error'); D2.ui.toast(D2.i18n.t('msg.inventory_full'), 'bad'); G.player.gold += gc; }
}, true);
} else if (type === 'stash') {
opt('📦 ' + D2.i18n.t('ui.stash'), () => { D2.ui.closePanel('dialog'); D2.ui.openPanel('stash'); });
}
function ui_refresh() {
if (!D2.ui) return;
D2.ui.refreshHud(); D2.ui.refreshInventory(); D2.ui.refreshCharacter();
}
function offerQuest(q) {
const obj = G.questObjective(q);
const rw = q.reward;
const body = document.querySelector('#panel-dialog .fpanel-body');
if (body) {
body.innerHTML =
`
${obj.label}
` +
`${obj.desc}
` +
`${D2.i18n.t('ui.reward')}: ${rw.gold} 🜚 · ${rw.xp} XP` +
`${rw.itemLevelBonus ? ` · ${D2.i18n.t('ui.rare_item')}` : ''}
`;
const row = document.createElement('div');
row.className = 'dialog-options';
const acc = document.createElement('button');
acc.className = 'btn btn-primary';
acc.textContent = '✔ ' + D2.i18n.t('ui.accept');
acc.addEventListener('click', () => {
G.acceptQuest(q.id);
D2.ui.closePanel('dialog');
reopenDialog(type);
});
const later = document.createElement('button');
later.className = 'btn';
later.textContent = D2.i18n.t('ui.later');
later.addEventListener('click', () => { D2.ui.closePanel('dialog'); reopenDialog(type); });
row.appendChild(acc); row.appendChild(later);
body.appendChild(row);
}
}
function reopenDialog(t) {
if (D2.ui) { D2.ui.closeAllPanels(); D2.ui.openNpcDialog(t); }
}
return opts;
};
G.updateQuests = function () {
const pr = G.progress;
const q = [];
/* NPC chain first */
for (const qa of G.activeQuests()) {
const obj = G.questObjective(qa);
q.push({
header: D2.i18n.t('npc.' + qa.giver + '.name'),
text: obj.label + (qa.type === 'kills'
? ` — ${Math.min(obj.count, obj.target)}/${obj.target}` : (obj.done ? ' ✔' : '')),
done: obj.done,
});
}
/* fallback hint when the chain is quiet */
if (!q.length) {
const avail = G.availableQuests()[0];
if (avail) {
q.push({
header: D2.i18n.t('npc.' + avail.giver + '.name'),
text: D2.i18n.t('quest.seek_hint'),
done: false,
});
} else {
const actName = D2.i18n.t(D2.BAL.acts[pr.act].name);
if (pr.floorIdx >= D2.BAL.floorsPerAct - 1) {
const bossId = D2.BAL.acts[pr.act].boss;
const bd = D2.Monsters.BOSS_MAP[bossId];
const killed = pr.bossesKilled.includes(bossId + '_' + pr.torment);
q.push({ header: actName, text: D2.i18n.t('quest.kill_boss', bd ? bd.name[D2.i18n.getLang()] : bossId), done: killed });
} else {
q.push({ header: actName, text: D2.i18n.t('quest.descend', pr.floorIdx + 2), done: false });
}
}
}
G.quests = q;
if (D2.ui) D2.ui.renderQuestTracker();
};
G.onBossKilled = function (m) {
G.toast(D2.i18n.t('msg.boss_slain'), 'gold');
G.hideBossBar();
const pr = G.progress;
pr.bossesKilled.push(m.bossDef.id + '_' + pr.torment);
if (pr.act >= D2.BAL.acts.length - 1 && !G.victoryShown) {
G.victoryShown = true;
setTimeout(() => {
G.state = 'victory';
if (D2.ui) D2.ui.showVictory();
}, 1600);
} else {
/* unlock next act waypoint */
if (pr.act + 1 < D2.BAL.acts.length) G.waypointsUnlocked[pr.act + 1] = true;
G.toast(D2.i18n.t('msg.act_clear'), 'gold');
G.updateQuests();
}
G.saveGame();
};
G.onPlayerDeath = function () {
const p = G.player;
if (p.dead) return;
p.dead = true;
p.deaths++;
G.sfx('bossdie');
D2.render.addShake(0.7);
G.spawnParticles(p.x, p.y, { count: 30, color: '#a8231a', speed: 5, life: 1, size: 3, z: 10 });
if (G.settings.hardcore) {
D2.save.wipeAll();
G.toast(D2.i18n.t('msg.hardcore_death'), 'bad');
setTimeout(() => { G.state = 'title'; if (D2.ui) D2.ui.showTitle(); }, 1800);
return;
}
G.state = 'dead';
setTimeout(() => { if (D2.ui) D2.ui.showDeath(); }, 900);
};
G.respawnInTown = function () {
const p = G.player;
p.dead = false;
const penalty = Math.round(p.gold * D2.BAL.deathGoldPenalty);
p.gold = Math.max(0, p.gold - penalty);
p.hp = p.maxHp;
p.mana = p.maxMana;
p.buffs = [];
D2.player.recompute(p);
G.state = 'playing';
G.enterTown(true);
if (penalty > 0) G.toast('-' + penalty + ' gold');
};
/* ================= save / load ================= */
G.saveSeedStr = function () {
return (D2.save.readMeta() || {}).seedStr || 'tristram';
};
function hashStr(s) {
let h = 2166136261;
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
return h >>> 0;
}
G.saveGame = function () {
if (!G.player) return;
const p = G.player;
D2.save.writeMeta({
classId: p.classId, level: p.level, name: p.classId,
hardcore: G.settings.hardcore, torment: G.progress.torment,
seedStr: G.saveSeedStr(),
});
D2.save.set(D2.save.KEY.CHAR, {
classId: p.classId, level: p.level, xp: p.xp, gold: p.gold,
attributes: p.attributes, statPoints: p.statPoints, skillPoints: p.skillPoints,
skills: p.skills, hotbar: p.hotbar, potions: p.potions,
inventory: p.inventory, equip: p.equip,
kills: p.kills, eliteKills: p.eliteKills, bossKills: p.bossKills, deaths: p.deaths,
});
D2.save.set(D2.save.KEY.WORLD, {
progress: G.progress, waypoints: G.waypointsUnlocked,
stash: G.stash, settings: G.settings,
inTown: !!(G.world && G.world.isTown),
});
};
G.loadGame = function () {
const meta = D2.save.readMeta();
const charData = D2.save.get(D2.save.KEY.CHAR);
const worldData = D2.save.get(D2.save.KEY.WORLD);
if (!meta || !charData) return false;
if (worldData && worldData.settings) Object.assign(G.settings, worldData.settings);
applySettings();
const p = D2.player.createPlayer(charData.classId);
p.level = charData.level; p.xp = charData.xp; p.gold = charData.gold;
p.attributes = charData.attributes || p.attributes;
p.statPoints = charData.statPoints || 0;
p.skillPoints = charData.skillPoints || 0;
p.skills = charData.skills || {};
p.hotbar = charData.hotbar || p.hotbar;
p.potions = charData.potions || p.potions;
p.inventory = charData.inventory || [];
p.equip = Object.assign(p.equip, charData.equip);
p.kills = charData.kills || 0;
p.eliteKills = charData.eliteKills || 0;
p.bossKills = charData.bossKills || 0;
p.deaths = charData.deaths || 0;
D2.player.recompute(p);
p.hp = p.maxHp; p.mana = p.maxMana;
G.player = p;
if (worldData) {
G.progress = worldData.progress || G.progress;
G.waypointsUnlocked = worldData.waypoints || G.waypointsUnlocked;
G.stash = worldData.stash || [];
}
G.victoryShown = G.progress.bossesKilled.some(k => k.startsWith('terrorlord'));
G.enterTown(true);
G.updateQuests();
return true;
};
function applySettings() {
D2.i18n.setLang(G.settings.lang);
D2.audio.setVolumes(G.settings.masterVol, G.settings.musicVol, G.settings.sfxVol);
document.documentElement.dataset.lang = G.settings.lang;
if (typeof document !== 'undefined') {
const el = document.documentElement;
el.style.setProperty('--dmg-numbers', G.settings.dmgNumbers ? '1' : '0');
}
}
G.applySettings = applySettings;
/* ================= input during gameplay ================= */
function canvasMouseWorld() {
const w = D2.render.screenToWorld(D2.input.state.mx, D2.input.state.my);
G.mouseWorld = w;
return w;
}
function handleInput(dt) {
const inp = D2.input;
const p = G.player;
if (!p || p.dead) return;
const mw = canvasMouseWorld();
/* hover entity — tight grab: only when the cursor is genuinely ON
the creature's body, so floor clicks next to it still move */
G.hoverEntity = (() => {
let best = null, bd = Infinity;
for (const m of G.monsters) {
if (m.dead) continue;
const d = D2.util.dist(m.x, m.y, mw.x, mw.y);
const grab = Math.max(0.45, m.radius * 0.85 + 0.18);
if (d < grab && d < bd) { bd = d; best = m; }
}
return best;
})();
/* --- left mouse: move / attack / interact --- */
if (inp.state.leftPressed || (inp.state.left && !uiBlocking())) {
if (G.hoverEntity) {
p.attackMoveTarget = { x: G.hoverEntity.x, y: G.hoverEntity.y, entity: G.hoverEntity };
p.moveTarget = null;
p.chaseT = 0;
p.abandonT = 0;
p._bestD = undefined; p._noProgT = 0;
} else {
/* prop interaction takes precedence when close */
const prop = propAt(mw.x, mw.y);
if (prop && D2.util.dist(p.x, p.y, prop.x + .5, prop.y + .5) <= D2.BAL.interactRange + 0.6) {
interactProp(prop);
} else {
p.moveTarget = { x: mw.x, y: mw.y };
p.attackMoveTarget = null;
p.chaseT = 0;
p.abandonT = 0;
p.path = null;
p._bestD = undefined; p._noProgT = 0;
G.moveMarker = { x: mw.x, y: mw.y, t: 0.5 };
}
}
}
/* --- right mouse: skill slot 1 --- */
if (inp.state.rightPressed) {
castHotbarSlot(1, mw);
}
/* --- number keys: skill slots 2-4 --- */
for (let i = 2; i <= 4; i++) {
if (inp.wasPressed('Digit' + i)) castHotbarSlot(i, mw);
}
/* potions */
if (inp.wasPressed('KeyQ')) D2.player.usePotion(G, 'hp');
if (inp.wasPressed('KeyE')) D2.player.usePotion(G, 'mp');
/* interact key */
if (inp.wasPressed('KeyF') || inp.wasPressed('Space')) {
const near = nearestInteractive();
if (near) interactProp(near);
}
/* panel toggles handled in ui.tickGlobalKeys (works also while paused menus) */
/* continuous held-cast for slot1? no; basic attack while holding LMB near enemy */
if (inp.state.left && p.attackMoveTarget) {
const t = p.attackMoveTarget.entity;
const d = t ? D2.util.dist(p.x, p.y, t.x, t.y) : Infinity;
p.chaseT = (p.chaseT || 0) + dt;
/* leash: dead / too far / chased too long → stop auto-running */
if (!t || t.dead || d > 16 || p.chaseT > 5) {
p.attackMoveTarget = null;
p.chaseT = 0;
if (!inp.state.leftPressed) p.moveTarget = null;
} else {
const isRanged = p.classId !== 'crusader';
const reach = isRanged ? 6.5 : 1.5;
if (d > reach) {
p.moveTarget = { x: t.x, y: t.y };
} else {
p.moveTarget = null;
if (p.attackCd <= 0) {
D2.combat.playerBasicAttack(G, t.x, t.y);
}
}
}
} else if (!inp.state.left) {
p.chaseT = 0;
}
}
function uiBlocking() {
return D2.ui && D2.ui.anyPanelOpen();
}
function castHotbarSlot(slot, mw) {
const p = G.player;
const skillId = p.hotbar[slot];
if (!skillId) return;
if (skillId === 'basic') {
D2.combat.playerBasicAttack(G, mw.x, mw.y);
return;
}
const sk = D2.Skills.findSkill(p.classId, skillId);
if (!sk) return;
const rank = D2.player.getSkillRank(p, skillId);
if (rank <= 0) { G.toast(D2.i18n.t('msg.skill_cooldown'), ''); return; }
/* special CDR from legendaries */
let cd = sk.cooldown;
if (sk.castType === 'dash' && p.stats.dashCdrPct) cd *= 1 - p.stats.dashCdrPct / 100;
if (sk.castType === 'blink' && p.stats.blinkCdrPct) cd *= 1 - p.stats.blinkCdrPct / 100;
const savedCd = sk.cooldown;
try {
sk.cooldown = cd;
D2.combat.castSkill(G, p, sk, rank, mw.x, mw.y);
} finally {
sk.cooldown = savedCd;
}
if (D2.ui) D2.ui.refreshHotbar();
}
/* ---------------- props interaction ---------------- */
function propAt(wx, wy) {
for (const pr of G.world.props) {
/* dead props must not swallow movement clicks */
if (pr.type === 'chest' && pr.opened) continue;
if (pr.type === 'shrine' && pr.used) continue;
if (Math.abs(pr.x + 0.5 - wx) < 0.5 && Math.abs(pr.y + 0.5 - wy) < 0.6) return pr;
}
return null;
}
function nearestInteractive() {
const p = G.player;
let best = null, bd = Infinity;
for (const pr of G.world.props) {
const interactive = ['stairs_down', 'stairs_up', 'chest', 'shrine', 'waypoint'].includes(pr.type) ||
String(pr.type).startsWith('npc_');
if (!interactive) continue;
if (pr.type === 'shrine' && pr.used) continue;
if (pr.type === 'chest' && pr.opened) continue;
const d = D2.util.dist(p.x, p.y, pr.x + 0.5, pr.y + 0.5);
if (d < D2.BAL.interactRange + 0.5 && d < bd) { bd = d; best = pr; }
}
return best;
}
function interactProp(prop) {
const p = G.player;
switch (prop.type) {
case 'stairs_down':
G.descend();
break;
case 'stairs_up':
G.climbToTown();
break;
case 'chest':
prop.opened = true;
G.world.propMap.delete(prop.x + ',' + prop.y);
G.sfx('door');
D2.loot.rollPropLoot(G, prop.x + 0.5, prop.y + 0.5, 'chest');
break;
case 'shrine':
if (!prop.used) D2.combat.activateShrine(G, prop);
break;
case 'waypoint':
if (D2.ui) D2.ui.openPanel('worldmap');
break;
default:
if (String(prop.type).startsWith('npc_')) {
if (D2.ui) D2.ui.openNpcDialog(prop.type.replace('npc_', ''));
}
}
}
/* ================= simulation update ================= */
G.update = function (dt) {
if (G.state !== 'playing') return;
G.time += dt;
D2.ai.resetFrame();
handleInput(dt);
D2.player.tickBelt(dt);
const p = G.player;
/* --- player movement along target --- */
updatePlayerMovement(dt);
/* channels (whirlwind/dash) */
D2.combat.updateChannels(G, p, dt);
/* cooldowns & timers */
if (p.attackCd > 0) p.attackCd -= dt;
if (p.attackAnim > 0) p.attackAnim -= dt;
if (p.hurtFlash > 0) p.hurtFlash -= dt;
if (p.guaranteedCritT > 0) p.guaranteedCritT -= dt;
if (p.smokeveilT > 0) { p.smokeveilT -= dt; if (p.smokeveilT <= 0) D2.player.recompute(p); }
for (const k of Object.keys(G.skillCooldowns)) {
G.skillCooldowns[k] -= dt;
if (G.skillCooldowns[k] <= 0) delete G.skillCooldowns[k];
}
/* buffs */
let buffExpired = false;
for (let i = p.buffs.length - 1; i >= 0; i--) {
p.buffs[i].t -= dt;
if (p.buffs[i].t <= 0) { p.buffs.splice(i, 1); buffExpired = true; }
}
if (buffExpired) D2.player.recompute(p);
if (p.chillT > 0) { p.chillT -= dt; if (p.chillT <= 0) p.chillSlow = 0; }
/* regen */
const s = p.stats;
let regen = D2.BAL.hpRegenPerSec + (s.hpRegen || 0) + (s.regenHpFlat || 0) +
(s.regenHpPct ? p.maxHp * s.regenHpPct / 100 : 0);
p.hp = Math.min(p.maxHp, p.hp + regen * dt);
p.mana = Math.min(p.maxMana, p.mana +
(D2.BAL.manaRegenPerSec + (s.manaRegen || 0) + (s.manaRegenFlat || 0)) * dt);
D2.combat.updateDots(G, p, dt, true);
/* --- FOV --- */
const ftKey = (p.x | 0) + ',' + (p.y | 0);
if (ftKey !== G._fovTile) {
G._fovTile = ftKey;
D2.fov.compute(G.world, p.x | 0, p.y | 0, 13);
}
/* --- monsters --- */
G._grid.rebuild(G.monsters);
for (const m of G.monsters) {
if (m.dead) continue;
const d2p = (m.x - p.x) ** 2 + (m.y - p.y) ** 2;
if (d2p < 26 * 26) {
D2.ai.updateMonster(G, m, dt);
}
if (m.hurtFlash > 0) m.hurtFlash -= dt;
}
G.monsters = G.monsters.filter(m => !m.dead);
/* --- projectiles --- */
for (const pr of G.projectiles) {
if (pr.dead) continue;
pr.x += pr.vx * dt; pr.y += pr.vy * dt;
pr.life -= dt;
if (pr.life <= 0) { pr.dead = true; continue; }
/* wall collision */
if (G.world.tileAt(pr.x | 0, pr.y | 0) !== D2.world.T.FLOOR) {
pr.dead = true;
G.spawnParticles(pr.x, pr.y, { count: 4, color: pr.color, speed: 2, life: 0.25, size: 1.8, z: 8 });
continue;
}
if (pr.fromPlayer) {
const hits = G.queryMonsters(pr.x, pr.y, 0.42 * pr.size);
for (const m of hits) {
if (m.dead || pr.hits.has(m.uid)) continue;
pr.hits.add(m.uid);
D2.combat.projectileHit(G, pr, m);
if (!pr.pierce || pr.hits.size >= 3) { pr.dead = true; break; }
}
} else {
if (!p.dead && D2.util.dist(pr.x, pr.y, p.x, p.y) < p.radius + 0.22) {
D2.combat.projectileHit(G, pr, p);
pr.dead = true;
}
}
}
G.projectiles = G.projectiles.filter(pr => !pr.dead);
/* --- effects --- */
for (const fx of G.effects) {
fx.t += dt;
switch (fx.type) {
case 'telegraph':
if (fx.t >= fx.delay && !fx.fired) {
fx.fired = true;
if (fx.onDone) fx.onDone(fx);
}
break;
case 'groundAoE': {
if (fx.t >= fx.delay) D2.combat.tickGroundAoe(G, fx, dt);
if (fx.t >= fx.delay + fx.duration) fx.done = true;
break;
}
case 'nova':
case 'swingArc':
if (fx.t >= fx.dur && !fx.done) {
fx.done = true;
if (fx.onDone) fx.onDone(fx);
}
break;
case 'timed':
if (!fx.fired && G.time >= fx.endAt) {
fx.fired = true;
fx.fire(G);
}
break;
}
}
G.effects = G.effects.filter(fx => !fx.done && !(fx.type === 'timed' && fx.fired));
/* --- pickups --- */
for (let i = G.pickups.length - 1; i >= 0; i--) {
const pk = G.pickups[i];
pk.t += dt;
const d = D2.util.dist(pk.x, pk.y, p.x, p.y);
if (pk.t > 0.35 && d < 0.85) {
if (D2.loot.collect(G, pk)) G.pickups.splice(i, 1);
}
}
/* --- particles --- */
for (let i = G.particles.length - 1; i >= 0; i--) {
const pt = G.particles[i];
pt.life -= dt;
if (pt.life <= 0) { G.particles.splice(i, 1); continue; }
pt.x += pt.vx * dt; pt.y += pt.vy * dt;
pt.z += pt.vz * dt;
pt.vz -= pt.gravity * dt;
if (pt.z < 0) { pt.z = 0; pt.vz *= -0.4; }
}
/* --- floating texts --- */
for (let i = G.floatTexts.length - 1; i >= 0; i--) {
G.floatTexts[i].life -= dt;
if (G.floatTexts[i].life <= 0) G.floatTexts.splice(i, 1);
}
/* --- lights --- */
for (let i = G.lights.length - 1; i >= 0; i--) {
G.lights[i].t -= dt;
if (G.lights[i].t <= 0) G.lights.splice(i, 1);
}
/* --- ground decals fade --- */
for (let i = G.groundDecals.length - 1; i >= 0; i--) {
G.groundDecals[i].life -= dt;
if (G.groundDecals[i].life <= 0) G.groundDecals.splice(i, 1);
}
/* --- boss bar refresh --- */
if (G.bossRef) {
if (G.bossRef.dead) G.hideBossBar();
else if (D2.ui) D2.ui.updateBossBar(G.bossRef.hp / G.bossRef.maxHp);
}
/* --- autosave cadence (in-town or every 90s) --- */
G._autosaveT += dt;
if (G._autosaveT > 90) { G._autosaveT = 0; G.saveGame(); }
};
function updatePlayerMovement(dt) {
const p = G.player;
if (!p.moveTarget || p.channel) {
if (!p.channel) p.moving = false;
return;
}
const speed = p.moveSpeed * (1 - (p.chillSlow || 0));
const dx = p.moveTarget.x - p.x, dy = p.moveTarget.y - p.y;
const d = Math.hypot(dx, dy);
if (d < 0.15) {
p.moveTarget = null;
p.moving = false;
p.path = null;
return;
}
/* direct if LOS and no path yet, else follow/recompute A* path */
let vx = dx / d, vy = dy / d;
const clear = D2.path.hasLOS(
(x, y) => G.world.transparent(x, y),
p.x | 0, p.y | 0, p.moveTarget.x | 0, p.moveTarget.y | 0);
if (clear && !p.path) {
p.path = null;
} else {
G._repathT -= dt;
if (!p.path || G._repathT <= 0 || p.pathIdx >= (p.path ? p.path.length : 0)) {
G._repathT = 0.45;
p.path = D2.path.find(
(x, y) => G.world.isWalkable(x, y),
G.world.w, G.world.h,
p.x | 0, p.y | 0, p.moveTarget.x | 0, p.moveTarget.y | 0, 6000);
p.pathIdx = 0;
}
if (p.path && p.pathIdx < p.path.length) {
const node = p.path[p.pathIdx];
const nx = node.x + 0.5, ny = node.y + 0.5;
const ndx = nx - p.x, ndy = ny - p.y;
const nd = Math.hypot(ndx, ndy) || 1;
if (nd < 0.3) p.pathIdx++;
else { vx = ndx / nd; vy = ndy / nd; }
} else if (!p.path && !clear) {
/* target is unreachable (wall / sealed pocket) — give up quickly
instead of grinding against the geometry forever */
p.abandonT = (p.abandonT || 0) + dt;
if (p.abandonT > 0.35) {
p.moveTarget = null;
p.path = null;
p.abandonT = 0;
p.moving = false;
return;
}
}
}
const stepX = vx * speed * dt, stepY = vy * speed * dt * 0.55;
const bx = p.x, by = p.y;
D2.combat.tryMove(G, p, stepX, stepY);
p.walkPhase += dt * speed * 3.2;
p.moving = true;
/* stuck detection → one nudge, then abandon the target */
if (Math.abs(p.x - bx) < 1e-7 && Math.abs(p.y - by) < 1e-7) {
p.stuckT = (p.stuckT || 0) + dt;
if (p.stuckT > 0.22 && p.stuckT < 0.45) {
const ang = Math.atan2(p.moveTarget.y - p.y, p.moveTarget.x - p.x) + Math.PI / 2;
D2.combat.tryMove(G, p, Math.cos(ang) * speed * dt * 8, Math.sin(ang) * speed * dt * 8 * 0.55);
} else if (p.stuckT > 0.6) {
p.stuckT = 0;
p.abandonT = 0;
p.path = null;
p.moveTarget = null;
p.moving = false;
}
} else { p.stuckT = 0; p.abandonT = 0; }
/* no-progress watchdog: sliding along a wall still changes position,
so also require the DISTANCE to the target to actually shrink */
const dNow = D2.util.dist(p.x, p.y, p.moveTarget.x, p.moveTarget.y);
if (p._bestD === undefined || dNow < p._bestD - 0.05) {
p._bestD = dNow;
p._noProgT = 0;
} else if ((p._noProgT = (p._noProgT || 0) + dt) > 1.2) {
p.moveTarget = null;
p.path = null;
p._bestD = undefined;
p._noProgT = 0;
p.moving = false;
return;
}
/* arrive check */
if (D2.util.dist(p.x, p.y, p.moveTarget.x, p.moveTarget.y) < 0.18) {
p.moveTarget = null;
p.moving = false;
}
}
/* expose */
D2.game = G;
})(window.D2);