/* ============================================================ * 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);