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
+438
View File
@@ -0,0 +1,438 @@
/* ============================================================
* Diablo2D — audio.js : procedural WebAudio engine
* Synthesized SFX + adaptive generative music. Zero assets.
* ============================================================ */
'use strict';
window.D2 = window.D2 || {};
(function (D2) {
let ctx = null;
let masterGain = null, sfxGain = null, musicGain = null;
let noiseBuf = null;
let unlocked = false;
const vol = { master: 0.8, music: 0.55, sfx: 0.9 };
let activeVoices = 0;
const MAX_VOICES = 28;
/* ---------- core plumbing ---------- */
function unlock() {
if (unlocked && ctx && ctx.state === 'running') return;
try {
if (!ctx) {
ctx = new (window.AudioContext || window.webkitAudioContext)();
masterGain = ctx.createGain();
sfxGain = ctx.createGain();
musicGain = ctx.createGain();
sfxGain.connect(masterGain);
musicGain.connect(masterGain);
masterGain.connect(ctx.destination);
applyVolumes();
noiseBuf = makeNoiseBuffer();
}
if (ctx.state === 'suspended') ctx.resume();
unlocked = true;
if (pendingMood) playMusic(pendingMood);
} catch (e) {
console.warn('[audio] init failed', e);
}
}
function makeNoiseBuffer() {
const len = ctx.sampleRate * 1.2;
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
return buf;
}
function setVolumes(m, mu, sx) {
if (m !== undefined) vol.master = m;
if (mu !== undefined) vol.music = mu;
if (sx !== undefined) vol.sfx = sx;
applyVolumes();
}
function applyVolumes() {
if (!masterGain) return;
masterGain.gain.value = vol.master;
musicGain.gain.value = vol.music;
sfxGain.gain.value = vol.sfx;
}
function ready() { return unlocked && ctx && ctx.state === 'running'; }
function canPlay() {
if (!ready()) return false;
if (activeVoices >= MAX_VOICES) return false;
activeVoices++;
setTimeout(() => { activeViewsDec(); }, 4000);
return true;
}
function activeViewsDec() { activeVoices = Math.max(0, activeVoices - 1); }
/* ---------- synth primitives ---------- */
function outNode(dest, pan) {
if (pan !== undefined && ctx.createStereoPanner) {
const p = ctx.createStereoPanner();
p.pan.value = Math.max(-1, Math.min(1, pan));
p.connect(dest);
return p;
}
return dest;
}
/** basic enveloped oscillator */
function tone(o) {
if (!canPlay()) return;
const t0 = ctx.currentTime + (o.delay || 0);
const osc = ctx.createOscillator();
osc.type = o.type || 'sine';
osc.frequency.setValueAtTime(Math.max(20, o.freq), t0);
if (o.freqEnd) osc.frequency.exponentialRampToValueAtTime(Math.max(20, o.freqEnd), t0 + o.dur);
if (o.detune) osc.detune.value = o.detune;
const g = ctx.createGain();
const a = o.attack || 0.005;
const v = (o.vol || 0.5);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(v, t0 + a);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + o.dur);
let node = g;
if (o.filter) {
const f = ctx.createBiquadFilter();
f.type = o.filter.type || 'lowpass';
f.frequency.value = o.filter.freq || 800;
f.Q.value = o.filter.q || 0.8;
g.connect(f); node = f;
}
osc.connect(g);
node.connect(outNode(o.dest || sfxGain, o.pan));
osc.start(t0);
osc.stop(t0 + o.dur + 0.05);
osc.onended = activeViewsDec;
}
/** enveloped filtered noise burst */
function noise(o) {
if (!canPlay()) return;
const t0 = ctx.currentTime + (o.delay || 0);
const src = ctx.createBufferSource();
src.buffer = noiseBuf;
src.loop = true;
src.playbackRate.value = o.rate || 1;
const f = ctx.createBiquadFilter();
f.type = o.filterType || 'bandpass';
f.frequency.setValueAtTime(o.freq || 1000, t0);
if (o.freqEnd) f.frequency.exponentialRampToValueAtTime(Math.max(30, o.freqEnd), t0 + o.dur);
f.Q.value = o.q || 1;
const g = ctx.createGain();
const a = o.attack || 0.004;
const v = (o.vol || 0.5);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(v, t0 + a);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + o.dur);
src.connect(f); f.connect(g);
g.connect(outNode(o.dest || sfxGain, o.pan));
src.start(t0);
src.stop(t0 + o.dur + 0.05);
src.onended = activeViewsDec;
}
const mtof = m => 440 * Math.pow(2, (m - 69) / 12);
/* ---------- sfx recipes ---------- */
const sfxRecipes = {
click() { tone({ type:'square', freq:2200, dur:.04, vol:.10 }); },
hover() { tone({ type:'sine', freq:1400, dur:.03, vol:.05 }); },
error() { tone({ type:'square', freq:160, freqEnd:110, dur:.16, vol:.16 }); },
swing() { noise({ filterType:'bandpass', freq:2600, freqEnd:500, dur:.13, vol:.22, q:1.6 }); },
hit() { noise({ filterType:'lowpass', freq:900, freqEnd:200, dur:.09, vol:.32 });
tone({ type:'sine', freq:150, freqEnd:70, dur:.08, vol:.30 }); },
crit() { noise({ filterType:'lowpass', freq:900, freqEnd:200, dur:.09, vol:.32 });
tone({ type:'sine', freq:150, freqEnd:70, dur:.08, vol:.30 });
tone({ type:'triangle', freq:1800, freqEnd:900, dur:.12, vol:.18, delay:.02 }); },
playerhurt(){ tone({ type:'sawtooth', freq:190, freqEnd:80, dur:.18, vol:.26 });
noise({ filterType:'lowpass', freq:600, freqEnd:150, dur:.14, vol:.25 }); },
shoot() { noise({ filterType:'highpass', freq:1800, freqEnd:3400, dur:.07, vol:.17 }); },
fireball() { noise({ filterType:'lowpass', freq:400, freqEnd:2400, dur:.3, vol:.24 });
tone({ type:'sawtooth', freq:120, freqEnd:60, dur:.28, vol:.12 }); },
explode() { noise({ filterType:'lowpass', freq:2500, freqEnd:60, dur:.55, vol:.45 });
tone({ type:'sine', freq:90, freqEnd:34, dur:.5, vol:.42 }); },
ice() { tone({ type:'sine', freq:2300, dur:.14, vol:.11 });
tone({ type:'sine', freq:3100, dur:.12, vol:.09, delay:.05 });
noise({ filterType:'highpass', freq:5200, dur:.18, vol:.10 }); },
lightning() { noise({ filterType:'bandpass', freq:3000, q:.6, dur:.16, vol:.26 });
tone({ type:'sawtooth', freq:800, freqEnd:200, dur:.12, vol:.10 }); },
poison() { noise({ filterType:'bandpass', freq:500, q:2, freqEnd:220, dur:.3, vol:.18 }); },
coin() { tone({ type:'sine', freq:mtof(98), dur:.09, vol:.14 });
tone({ type:'sine', freq:mtof(105), dur:.14, vol:.14, delay:.06 }); },
pickup() { tone({ type:'triangle', freq:mtof(88), dur:.1, vol:.16 });
tone({ type:'triangle', freq:mtof(95), dur:.16, vol:.16, delay:.07 }); },
legendary() { [76,83,88,95].forEach((m,i)=>tone({type:'sine',freq:mtof(m),dur:.5,vol:.14,delay:i*.09}));
noise({filterType:'highpass',freq:6000,dur:.7,vol:.06,delay:.1}); },
potion() { tone({ type:'sine', freq:520, freqEnd:280, dur:.12, vol:.16 });
tone({ type:'sine', freq:430, freqEnd:210, dur:.14, vol:.16, delay:.11 }); },
levelup() { [64,68,71,76].forEach((m,i)=>tone({type:'triangle',freq:mtof(m),dur:.42,vol:.2,delay:i*.11}));
tone({ type:'sine', freq:mtof(88), dur:.9, vol:.12, delay:.44 }); },
die() { tone({ type:'sawtooth', freq:220, freqEnd:40, dur:.4, vol:.22 });
noise({ filterType:'lowpass', freq:800, freqEnd:100, dur:.35, vol:.2 }); },
bossdie() { noise({ filterType:'lowpass', freq:2500, freqEnd:60, dur:.55, vol:.45 });
tone({ type:'sine', freq:90, freqEnd:34, dur:.5, vol:.42 });
[52,46,40].forEach((m,i)=>tone({type:'sawtooth',freq:mtof(m),dur:1.1,vol:.22,delay:i*.16})); },
door() { tone({ type:'sawtooth', freq:90, freqEnd:130, dur:.5, vol:.12 });
noise({ filterType:'bandpass', freq:300, q:3, dur:.45, vol:.10 }); },
stairs() { tone({ type:'sine', freq:180, freqEnd:60, dur:.7, vol:.2 });
tone({ type:'sine', freq:270, freqEnd:90, dur:.7, vol:.12, delay:.1 }); },
shrine() { [57,64,69,72].forEach((m,i)=>tone({type:'sine',freq:mtof(m),dur:1.4,vol:.12,delay:i*.05,attack:.3}));
noise({filterType:'highpass',freq:7000,dur:1.2,vol:.05}); },
teleport() { tone({ type:'sine', freq:300, freqEnd:2400, dur:.25, vol:.16 });
noise({ filterType:'highpass', freq:1000, freqEnd:5000, dur:.25, vol:.14 }); },
buff() { [60,67,72].forEach((m,i)=>tone({type:'triangle',freq:mtof(m),dur:.7,vol:.14,delay:i*.04,attack:.05})); },
nova() { noise({ filterType:'bandpass', freq:300, freqEnd:3800, dur:.4, vol:.3, q:.7 });
tone({ type:'sawtooth', freq:70, freqEnd:200, dur:.35, vol:.16 }); },
bossroar() { tone({ type:'sawtooth', freq:110, freqEnd:38, dur:1.2, vol:.4 });
tone({ type:'square', freq:74, freqEnd:30, dur:1.2, vol:.22 });
noise({ filterType:'lowpass', freq:900, freqEnd:80, dur:1.1, vol:.3 }); },
buy() { coin(); coin(); },
sell() { coin(); },
questdone() { [67,72,76].forEach((m,i)=>tone({type:'sine',freq:mtof(m),dur:.6,vol:.16,delay:i*.1,attack:.03})); },
};
function sfx(name, opts = {}) {
if (!ready()) return;
const fn = sfxRecipes[name];
if (!fn) return;
try { fn(opts); } catch (e) { /* never crash gameplay over audio */ }
}
/* ---------- generative music ---------- */
// scale = semitone offsets (minor/major/dorian etc.), patterns are step arrays of scale indices or null
const MOODS = {
title: { root: 45, bpm: 58, scale: [0,2,3,5,7,8,10],
bass: [0,null,null,null,null,null,null,null,-2,null,null,null,null,null,null,null],
pad: [[0,3,7],[ -2,2,5 ]], padBeats: 8,
leadProb: .16, leadOct: 2, drums: null, bell: true },
town: { root: 48, bpm: 92, scale: [0,2,4,5,7,9,11],
bass: [0,null,null,4,null,null,2,null,5,null,null,4,null,null,1,null],
pad: [[0,4,7],[3,5,9],[-3,2,4],[0,4,7]], padBeats: 4,
leadProb: .3, leadOct: 2, drums: null, pluck: true },
crypt: { root: 38, bpm: 62, scale: [0,2,3,5,7,8,10],
bass: [0,null,null,null,null,null,0,null,-2,null,null,null,null,null,-2,null],
pad: [[0,3,7],[0,3,8],[1,5,8],[0,3,7]], padBeats: 8,
leadProb: .1, leadOct: 3, drums: null, choir: true },
cave: { root: 40, bpm: 76, scale: [0,2,3,5,7,9,10],
bass: [0,null,null,null,5,null,null,null,3,null,null,null,7,null,null,null],
pad: [[0,3,7],[5,7,10]], padBeats: 8,
leadProb: .12, leadOct: 2,
drums: { kick:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,1], snare:[], hat:[0,0,1,0,0,0,1,0,0,0,1,0,0,0,1,0] },
drip: true },
hell: { root: 33, bpm: 128, scale: [0,1,3,5,6,8,10],
bass: [0,0,null,0,null,0,1,null,0,0,null,0,3,1,0,null],
pad: [[0,1,6],[0,1,6]], padBeats: 8,
leadProb: .2, leadOct: 1,
drums: { kick:[1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0], snare:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,1,0], hat:[1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,1] } },
boss: { root: 31, bpm: 142, scale: [0,1,3,5,7,8,10],
bass: [0,0,3,0,0,5,3,1,0,0,3,0,6,5,3,1],
pad: [[0,1,6],[-1,2,6]], padBeats: 8,
leadProb: .3, leadOct: 2,
drums: { kick:[1,0,0,1,0,0,1,0,1,0,0,1,0,0,1,0], snare:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,1], hat:[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1] } },
};
let pendingMood = null;
let currentMood = null;
let seqTimer = null;
let nextNoteTime = 0;
let step = 0;
let barCount = 0;
function playMusic(mood) {
if (!MOODS[mood]) mood = 'crypt';
pendingMood = mood;
if (!ready()) return;
if (currentMood === mood && seqTimer) return;
stopMusicNow();
currentMood = mood;
step = 0; barCount = 0;
nextNoteTime = ctx.currentTime + 0.08;
seqTimer = setInterval(seqTick, 60);
}
function stopMusic(fadeSec = 0.8) {
pendingMood = null;
if (!ctx || !musicGain) return;
const t0 = ctx.currentTime;
musicGain.gain.cancelScheduledValues(t0);
musicGain.gain.setValueAtTime(musicGain.gain.value, t0);
musicGain.gain.linearRampToValueAtTime(0.0001, t0 + fadeSec);
stopMusicNow(fadeSec);
setTimeout(() => { if (musicGain && !seqTimer) musicGain.gain.value = vol.music; }, fadeSec * 1000 + 60);
}
function stopMusicNow(delaySec = 0) {
if (seqTimer) { clearInterval(seqTimer); seqTimer = null; }
currentMood = null;
}
function seqTick() {
if (!currentMood || !ready()) return;
const M = MOODS[currentMood];
const spb = 60 / M.bpm; // seconds per beat (quarter)
const stepDur = spb / 4; // 16th steps
while (nextNoteTime < ctx.currentTime + 0.18) {
scheduleStep(M, step, nextNoteTime, stepDur);
nextNoteTime += stepDur;
step = (step + 1) % 16;
if (step === 0) barCount++;
}
}
function scheduleStep(M, st, time, stepDur) {
const scale = M.scale;
/* bass line */
const bIdx = M.bass[st];
if (bIdx !== null && bIdx !== undefined) {
const midi = M.root + bIdx + (scale[0] === undefined ? 0 : 0);
musicTone({ type: 'triangle', freq: mtof(midi), dur: stepDur * 2.4, vol: .17, time,
filter: { type: 'lowpass', freq: 320 } });
if (M.drums) musicTone({ type: 'sawtooth', freq: mtof(midi - 12), dur: stepDur * 1.6, vol: .06, time, filter:{type:'lowpass',freq:200} });
}
/* drums */
if (M.drums) {
if (M.drums.kick[st]) musicKick(time);
if (M.drums.snare[st]) musicNoise({ filterType: 'bandpass', freq: 1900, q: .8, dur: .09, vol: .1, time });
if (M.drums.hat[st]) musicNoise({ filterType: 'highpass', freq: 7500, dur: .03, vol: .045, time });
}
/* pad chord each padBeats */
if (M.pad && st === 0) {
const chord = M.pad[Math.floor(barCount / (M.padBeats / 16 || 1)) % M.pad.length] || M.pad[0];
for (const iv of chord) {
const midi = M.root + 12 + iv;
musicPad(midi, M.padBeats * stepDur, time);
}
}
/* lead / bell melody */
if (Math.random() < M.leadProb && st % 2 === 0) {
const deg = scale[Math.floor(Math.random() * scale.length)];
const midi = M.root + 12 * (M.leadOct || 2) + deg;
if (M.bell) musicBell(midi, time);
else if (M.pluck) musicPluck(midi, time);
else if (M.choir) musicChoirNote(midi, time, stepDur * 6);
else musicPluck(midi, time);
}
/* cave drip ambience */
if (M.drip && Math.random() < .05) {
musicTone({ type: 'sine', freq: 1700 + Math.random() * 900, dur: .1, vol: .035, time });
}
}
/* music-routed voice wrappers (respect global volume via musicGain) */
function musicTone(o) {
const saveDest = o.dest; o.dest = musicGain; o.time = o.time;
toneAt(o);
}
function musicNoise(o) { o.dest = musicGain; noiseAt(o); }
/* time-scheduled variants (absolute ctx time) — separate from sfx versions */
function toneAt(o) {
if (!ready()) return;
const t0 = o.time !== undefined ? o.time : ctx.currentTime;
const osc = ctx.createOscillator();
osc.type = o.type || 'sine';
osc.frequency.setValueAtTime(Math.max(20, o.freq), t0);
if (o.freqEnd) osc.frequency.exponentialRampToValueAtTime(Math.max(20, o.freqEnd), t0 + o.dur);
const g = ctx.createGain();
const a = o.attack || 0.01;
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(o.vol || .2, t0 + a);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + o.dur);
let node = g;
if (o.filter) {
const f = ctx.createBiquadFilter();
f.type = o.filter.type; f.frequency.value = o.filter.freq;
g.connect(f); node = f;
}
osc.connect(g); node.connect(o.dest || musicGain);
osc.start(t0); osc.stop(t0 + o.dur + .05);
}
function noiseAt(o) {
if (!ready()) return;
const t0 = o.time !== undefined ? o.time : ctx.currentTime;
const src = ctx.createBufferSource();
src.buffer = noiseBuf; src.loop = true;
const f = ctx.createBiquadFilter();
f.type = o.filterType || 'bandpass';
f.frequency.setValueAtTime(o.freq || 1000, t0);
if (o.freqEnd) f.frequency.exponentialRampToValueAtTime(Math.max(30, o.freqEnd), t0 + o.dur);
f.Q.value = o.q || 1;
const g = ctx.createGain();
g.gain.setValueAtTime(.0001, t0);
g.gain.exponentialRampToValueAtTime(o.vol || .2, t0 + (o.attack || .005));
g.gain.exponentialRampToValueAtTime(.0001, t0 + o.dur);
src.connect(f); f.connect(g); g.connect(o.dest || musicGain);
src.start(t0); src.stop(t0 + o.dur + .05);
}
function musicPad(midi, dur, time) {
[-7, +7].forEach(det => {
const osc = ctx.createOscillator();
osc.type = 'sawtooth';
osc.frequency.value = mtof(midi);
osc.detune.value = det;
const f = ctx.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = 620;
const g = ctx.createGain();
g.gain.setValueAtTime(.0001, time);
g.gain.linearRampToValueAtTime(.028, time + dur * .35);
g.gain.linearRampToValueAtTime(.0001, time + dur);
osc.connect(f); f.connect(g); g.connect(musicGain);
osc.start(time); osc.stop(time + dur + .1);
});
}
function musicBell(midi, time) {
toneAt({ type: 'sine', freq: mtof(midi), dur: 1.6, vol: .07, time, attack: .003 });
toneAt({ type: 'sine', freq: mtof(midi) * 2.01, dur: .9, vol: .03, time, attack: .003 });
}
function musicPluck(midi, time) {
toneAt({ type: 'triangle', freq: mtof(midi), dur: .3, vol: .09, time, attack: .002,
filter: { type: 'lowpass', freq: 2200 } });
}
function musicChoirNote(midi, time, dur) {
[-5, +5].forEach(det => toneAt({ type: 'sawtooth', freq: mtof(midi), detune: det, dur, vol: .022, time, attack: dur * .3,
filter: { type: 'lowpass', freq: 480 } }));
}
function musicKick(time) {
toneAt({ type: 'sine', freq: 130, freqEnd: 38, dur: .16, vol: .3, time, attack: .002 });
}
D2.audio = {
unlock, ready,
setVolumes, get volumes() { return { ...vol }; },
sfx, playMusic, stopMusic,
get currentMood() { return currentMood || pendingMood; },
};
})(window.D2);
+303
View File
@@ -0,0 +1,303 @@
/* ============================================================
* Diablo2D — i18n.js : English / Vietnamese localization
* ============================================================ */
'use strict';
window.D2 = window.D2 || {};
(function (D2) {
const dict = {
en: {
/* generic */
ok: 'OK', cancel: 'Cancel', close: 'Close', yes: 'Yes', no: 'No',
back: 'Back', confirm: 'Confirm', none: 'None', level_short: 'Lv',
/* title */
'title.newgame': 'New Game', 'title.continue': 'Continue', 'title.settings': 'Settings',
'title.help': 'How to Play', 'title.credits': 'Credits',
'title.tagline': 'Shadows of Tristram',
'title.subtitle': 'A dark action-RPG for your browser',
'title.no_save': 'No saved hero found.',
'title.confirm_overwrite': 'Starting a new game will overwrite your saved hero. Continue?',
'title.saved_hero': 'Saved Hero',
'title.delete_save': 'Delete Save',
'title.confirm_delete': 'Permanently delete the saved hero?',
/* classes */
'class.crusader.name': 'Crusader',
'class.crusader.desc': 'A holy warrior clad in heavy armor. Wades into melee with sweeping strikes and unbreakable defenses.',
'class.ranger.name': 'Shadow Huntress',
'class.ranger.desc': 'A swift rogue who strikes from the shadows with bow and blade, bleeding her prey from afar.',
'class.sorceress.name': 'Sorceress',
'class.sorceress.desc': 'Master of fire, frost and storm. Fragile in body, devastating in arcane power.',
'class.select': 'Choose Your Fate',
'class.play': 'Begin Descent',
/* stats */
'stat.strength': 'Strength', 'stat.dexterity': 'Dexterity', 'stat.vitality': 'Vitality', 'stat.energy': 'Energy',
'stat.damage': 'Damage', 'stat.armor': 'Armor', 'stat.crit': 'Crit Chance', 'stat.critdmg': 'Crit Damage',
'stat.speed': 'Move Speed', 'stat.magicfind': 'Magic Find', 'stat.lifelench': 'Life per Kill',
'stat.fire': 'Fire Resist', 'stat.cold': 'Cold Resist', 'stat.lightning': 'Lightning Resist', 'stat.poison': 'Poison Resist',
'stat.points': 'Attribute Points', 'stat.skillpoints': 'Skill Points',
/* ui panels */
'ui.inventory': 'Inventory', 'ui.character': 'Character', 'ui.skills': 'Skills',
'ui.stash': 'Stash', 'ui.vendor': 'Blacksmith', 'ui.healer': 'Healer',
'ui.questlog': 'Quest Log', 'ui.map': 'World Map', 'ui.help': 'How to Play',
'ui.equip.weapon': 'Weapon', 'ui.equip.offhand': 'Off-hand', 'ui.equip.head': 'Head',
'ui.equip.chest': 'Body', 'ui.equip.hands': 'Hands', 'ui.equip.feet': 'Feet',
'ui.equip.amulet': 'Amulet', 'ui.equip.ring1': 'Ring', 'ui.equip.ring2': 'Ring',
'ui.gold': 'Gold', 'ui.sell': 'Sell', 'ui.buy': 'Buy', 'ui.repair_none': '',
'ui.heal_full': 'Full Restore', 'ui.price': 'Price',
'ui.empty_slot': 'Empty', 'ui.drop_confirm': 'This item will be destroyed. Drop it?',
/* hotbar */
'hb.health_potion': 'Health Potion', 'hb.mana_potion': 'Mana Potion',
'hb.inventory': 'Inventory (I)', 'hb.character': 'Character (C)', 'hb.skills': 'Skills (T)',
'hb.quests': 'Quest Log (J)', 'hb.map': 'Map (M)', 'hb.help': 'Help (F1)',
/* messages */
'msg.levelup': 'Level Up! Press C to assign points.',
'msg.skillpoint': 'New skill learned! Press T to view skills.',
'msg.not_enough_mana': 'Not enough mana!',
'msg.skill_cooldown': 'Skill is not ready.',
'msg.inventory_full': 'Inventory is full!',
'msg.potion_none': 'No potions left!',
'msg.boss_spawn': 'A great evil awakens...',
'msg.boss_slain': 'The guardian has fallen!',
'msg.act_clear': 'Act complete! The waypoint stirs...',
'msg.saved': 'Game saved.',
'msg.hardcore_death': 'Your hardcore hero has perished. The record burns.',
'msg.town_portal': 'Returned to town.',
'msg.elite_slain': 'Champion slain!',
'msg.shrine': 'You touch the shrine...',
/* quests */
'quest.main_prefix': 'Act {0}: ',
'quest.descend': 'Descend to depth {0}',
'quest.kill_boss': 'Slay {0}',
'quest.done': 'Complete',
/* death / victory */
'death.title': 'YOU HAVE DIED',
'death.subtitle': 'Even heroes fall... but darkness always offers another chance.',
'death.respawn': 'Awaken in Town', 'death.title_screen': 'Return to Title',
'victory.title': 'THE LORD OF TERROR IS VANQUISHED',
'victory.subtitle': 'Tristram breathes again — yet the depths always call louder.',
'victory.continue': 'Continue in Torment {0}', 'victory.title_screen': 'Return to Title',
'victory.stats': 'Run Statistics',
/* settings */
'set.master': 'Master Volume', 'set.music': 'Music', 'set.sfx': 'Sound Effects',
'set.lang': 'Language', 'set.shake': 'Screen Shake', 'set.dmgnum': 'Damage Numbers',
'set.labels': 'Item Labels', 'set.hardcore': 'Hardcore Mode (new game)',
'set.export': 'Export Save Code', 'set.import': 'Import Save Code', 'set.wipe': 'Erase All Data',
'set.copy_ok': 'Copied!', 'set.import_bad': 'Invalid save code.', 'set.import_ok': 'Save imported!',
'set.confirm_wipe': 'Erase ALL saved data? This cannot be undone.',
/* help */
'help.move': 'Left click — move / attack. Hold to keep moving toward cursor.',
'help.combat': 'Right click & keys 14 — cast skills. Q / E — potions.',
'help.items': 'Hold Alt to reveal ground item labels. Click an item to pick up.',
'help.panels': 'I inventory · C character · T skills · J quests · M map · Esc pause.',
'help.tip1': 'Champions glow with an aura — they hit harder but drop better loot.',
'help.tip2': 'Shrines grant powerful temporary blessings.',
'help.tip3': 'Resistances matter more than armor against elemental bosses.',
'help.keys_title': 'Controls',
/* town npcs */
'npc.charsi.name': 'Charsi',
'npc.akara.name': 'Akara',
'npc.kashya.name': 'Kashya',
'npc.cain.name': 'Deckard Cain',
'npc.gheed.name': 'Gheed',
'npc.stash.name': 'Camp Storage',
'msg.quest_accepted': 'Quest accepted: {0}',
'msg.quest_reward': 'Quest complete! +{0} gold',
'quest.seek_hint': 'Visit camp — new tasks await.',
'ui.rare_item': 'a Rare item',
'npc.charsi.name': 'Charsi',
'npc.akara.name': 'Akara',
'npc.kashya.name': 'Kashya',
'npc.cain.name': 'Deckard Cain',
'npc.gheed.name': 'Gheed',
'npc.stash.name': 'Kho trại',
'msg.quest_accepted': 'Đã nhận nhiệm vụ: {0}',
'msg.quest_reward': 'Nhiệm vụ hoàn thành! +{0} vàng',
'quest.seek_hint': 'Hãy ghé trại — có việc mới đang chờ.',
'ui.rare_item': 'một món Hiếm',
'npc.charsi.greet': 'Steel keeps you alive down there. Browse my wares.',
'npc.akara.greet': 'The Light mend you. What do you need, child?',
'npc.kashya.greet': 'The camp needs a blade like yours. We have work.',
'npc.cain.greet': 'Stay awhile and listen…',
'npc.gheed.greet': 'Psst. Finest goods in the camp — provenance uncertain.',
'npc.stash.greet': 'Your vault awaits.',
'npc.waypoint.greet': 'Where does fate pull you, hero?',
'ui.accept': 'Accept quest',
'ui.turn_in': 'Turn in',
'ui.reward': 'Reward',
'ui.later': 'Not now',
'ui.lore': 'Ask for lore',
'ui.buy_tome': 'Buy Skill Tome (+1 skill pt)',
'ui.respec': 'Respec all points',
'ui.gamble': 'Gamble for goods',
'ui.gamble_hint': 'Rarity unknown until bought…',
'ui.quest_done_return': 'Return to {0}',
'ui.tome_bought': 'Knowledge burns behind your eyes… (+1 skill point)',
'ui.respec_done': 'Your training is undone. Points refunded.',
'ui.gamble_win': 'Fortune favors you… this time.',
'quest.cull': 'Cull the Fallen',
'quest.cull.desc': 'Slay {0} denizens of the Cathedral depths. Kashya counts every kill.',
'quest.boss': 'The {0}',
'quest.boss.desc': 'Descend to the lair and destroy {0}. The camp will not rest until it is done.',
'quest.torment': 'Echoes of Terror',
'quest.torment.desc': 'Malgor is dead, yet his echo festers. Open torment {0} and cull it anew.',
'town.enter': 'Tristram Outpost — Sanctuary',
/* misc */
'floor.depth': 'Depth {0}',
'act.names.0': 'The Ravaged Cathedral',
'act.names.1': 'The Weeping Catacombs',
'act.names.2': 'The Fungal Depths',
'act.names.3': "Hell's Threshold",
torment: 'Torment {0}',
'loading.tips.0': 'Elites drop enchanted loot. Hunt the glowing ones.',
'loading.tips.1': 'Potions refill each time you visit town.',
'loading.tips.2': 'Fire melts flesh; cold freezes bone. Bring both.',
'loading.tips.3': 'Barrels and urns hide coins... and sometimes worse.',
'loading.tips.4': 'Magic Find increases rare and legendary drops.',
'credits.line1': 'Design, code, art & audio — procedurally generated, no external assets.',
'credits.line2': 'Inspired by the classics that defined the genre.',
},
vi: {
ok: 'Đồng ý', cancel: 'Hủy', close: 'Đóng', yes: 'Có', no: 'Không',
back: 'Quay lại', confirm: 'Xác nhận', none: 'Không', level_short: 'Cấp',
'title.newgame': 'Chơi mới', 'title.continue': 'Tiếp tục', 'title.settings': 'Cài đặt',
'title.help': 'Hướng dẫn chơi', 'title.credits': 'Thông tin',
'title.tagline': 'Bóng Đêm Tristram',
'title.subtitle': 'Action-RPG tối giản chạy ngay trên trình duyệt',
'title.no_save': 'Không tìm thấy bản lưu nào.',
'title.confirm_overwrite': 'Bắt đầu game mới sẽ xóa hero đã lưu. Tiếp tục?',
'title.saved_hero': 'Hero đã lưu',
'title.delete_save': 'Xóa bản lưu',
'title.confirm_delete': 'Xóa vĩnh viễn hero đã lưu?',
'class.crusader.name': 'Thập Tự Quân',
'class.crusader.desc': 'Chiến binh thánh thiện trong bộ giáp nặng, quét sạch kẻ thù bằng chém rộng và phòng thủ thép.',
'class.ranger.name': 'Nữ Thợ Săn Bóng Đêm',
'class.ranger.desc': 'Nữ sát thủ nhanh nhẹn, hạ gục con mồi từ xa bằng cung tên độc và bóng tối.',
'class.sorceress.name': 'Pháp Sư',
'class.sorceress.desc': 'Bậc thầy hỏa, băng và lôi. Thân yếu nhưng sức mạnh huyền thuật tàn khốc.',
'class.select': 'Chọn Số Phận Của Bạn',
'class.play': 'Bắt Đầu Hạ Sơn',
'stat.strength': 'Sức mạnh', 'stat.dexterity': 'Nhuệ', 'stat.vitality': 'Sinh lực', 'stat.energy': 'Năng lượng',
'stat.damage': 'Sát thương', 'stat.armor': 'Giáp', 'stat.crit': 'Tỷ lệ chí mạng', 'stat.critdmg': 'ST chí mạng',
'stat.speed': 'Tốc độ chạy', 'stat.magicfind': 'May mắn tìm đồ', 'stat.lifelench': 'Hồi máu mỗi lần hạ',
'stat.fire': 'Kháng Hỏa', 'stat.cold': 'Kháng Băng', 'stat.lightning': 'Kháng Lôi', 'stat.poison': 'Kháng Độc',
'stat.points': 'Điểm thuộc tính', 'stat.skillpoints': 'Điểm kỹ năng',
'ui.inventory': 'Túi đồ', 'ui.character': 'Nhân vật', 'ui.skills': 'Kỹ năng',
'ui.stash': 'Kho đồ', 'ui.vendor': 'Thợ Rèn', 'ui.healer': 'Pháp Sư Trị Liệu',
'ui.questlog': 'Nhật nhiệm vụ', 'ui.map': 'Bản đồ thế giới', 'ui.help': 'Hướng dẫn',
'ui.equip.weapon': 'Vũ khí', 'ui.equip.offhand': 'Tay trái', 'ui.equip.head': 'Đầu',
'ui.equip.chest': 'Thân', 'ui.equip.hands': 'Tay', 'ui.equip.feet': 'Chân',
'ui.equip.amulet': 'Mặt dây', 'ui.equip.ring1': 'Nhẫn', 'ui.equip.ring2': 'Nhẫn',
'ui.gold': 'Vàng', 'ui.sell': 'Bán', 'ui.buy': 'Mua',
'ui.heal_full': 'Hồi phục hoàn toàn', 'ui.price': 'Giá',
'ui.empty_slot': 'Trống', 'ui.drop_confirm': 'Vật phẩm sẽ bị phá hủy. Vứt bỏ?',
'hb.health_potion': 'Bình máu', 'hb.mana_potion': 'Bình mana',
'hb.inventory': 'Túi đồ (I)', 'hb.character': 'Nhân vật (C)', 'hb.skills': 'Kỹ năng (T)',
'hb.quests': 'Nhiệm vụ (J)', 'hb.map': 'Bản đồ (M)', 'hb.help': 'Trợ giúp (F1)',
'msg.levelup': 'Lên cấp! Nhấn C để cộng điểm.',
'msg.skillpoint': 'Kỹ năng mới! Nhấn T để xem kỹ năng.',
'msg.not_enough_mana': 'Không đủ mana!',
'msg.skill_cooldown': 'Kỹ năng chưa hồi xong.',
'msg.inventory_full': 'Túi đồ đầy!',
'msg.potion_none': 'Hết bình hồi máu!',
'msg.boss_spawn': 'Một ma lực khổng lồ thức dậy...',
'msg.boss_slain': 'Ác thần đã bị hạ!',
'msg.act_clear': 'Hoàn thành chương! Điểm dịch chuyển rung lên...',
'msg.saved': 'Đã lưu game.',
'msg.hardcore_death': 'Hero Hardcore của bạn đã chết. Bản lưu bị thiêu rụi.',
'msg.town_portal': 'Đã về trấn.',
'msg.elite_slain': 'Đã hạ tinh anh!',
'msg.shrine': 'Bạn chạm vào đền thờ...',
'quest.main_prefix': 'Chương {0}: ',
'quest.descend': 'Hạ xuống độ sâu {0}',
'quest.kill_boss': 'Hạ gục {0}',
'quest.done': 'Hoàn thành',
'death.title': 'BẠN ĐÃ CHẾT',
'death.subtitle': 'Anh hùng cũng có thể ngã... nhưng bóng tối luôn cho thêm một cơ hội.',
'death.respawn': 'Thức dậy ở trấn', 'death.title_screen': 'Về màn hình chính',
'victory.title': 'CHÚA TỂ KINH HOÀNG ĐÃ BỊ TIÊU DIỆT',
'victory.subtitle': 'Tristram lại được thở — nhưng vực sâu luôn gọi to hơn.',
'victory.continue': 'Tiếp tục ở Torment {0}', 'victory.title_screen': 'Về màn hình chính',
'victory.stats': 'Thống kê chuyến chiến',
'set.master': 'Âm lượng tổng', 'set.music': 'Nhạc nền', 'set.sfx': 'Hiệu ứng âm thanh',
'set.lang': 'Ngôn ngữ', 'set.shake': 'Rung màn hình', 'set.dmgnum': 'Số sát thương',
'set.labels': 'Nhãn đồ trên đất', 'set.hardcore': 'Chế độ Hardcore (chơi mới)',
'set.export': 'Xuất mã lưu', 'set.import': 'Nhập mã lưu', 'set.wipe': 'Xóa toàn bộ dữ liệu',
'set.copy_ok': 'Đã sao chép!', 'set.import_bad': 'Mã lưu không hợp lệ.', 'set.import_ok': 'Đã nhập bản lưu!',
'set.confirm_wipe': 'Xóa TOÀN BỘ dữ liệu đã lưu? Không thể hoàn tác.',
'help.move': 'Chuột trái — di chuyển / tấn công. Giữ để liên tục đi theo con trỏ.',
'help.combat': 'Chuột phải & phím 14 — dùng kỹ năng. Q / E — bình máu, bình mana.',
'help.items': 'Giữ Alt để hiện nhãn đồ trên đất. Nhấp vào món để nhặt.',
'help.panels': 'I túi đồ · C nhân vật · T kỹ năng · J nhiệm vụ · M bản đồ · Esc tạm dừng.',
'help.tip1': 'Tinh anh phát sáng quanh thân — mạnh hơn nhưng rớt đồ tốt hơn.',
'help.tip2': 'Đền thờ ban phước tạm thời rất mạnh.',
'help.tip3': 'Kháng nguyên tố quan trọng hơn giáp khi đấu boss hệ elemental.',
'help.keys_title': 'Điều khiển',
'npc.charsi.greet': 'Thép giữ bạn sống dưới đó. Xem hàng của ta đi.',
'npc.akara.greet': 'Ánh Sáng chữa lành ngươi. Con cần gì nào?',
'npc.kashya.greet': 'Trại cần một lưỡi kiếm như ngươi. Ta có việc đây.',
'npc.cain.greet': 'Ở lại nghe ta kể chuyện này…',
'npc.gheed.greet': 'Này. Hàng tốt nhất trại — nguồn gốc thì… khỏi hỏi.',
'npc.stash.greet': 'Kho của bạn đang chờ.',
'npc.waypoint.greet': 'Định mệnh đang kéo bạn đến đâu, anh hùng?',
'ui.accept': 'Nhận nhiệm vụ',
'ui.turn_in': 'Trả nhiệm vụ',
'ui.reward': 'Thưởng',
'ui.later': 'Để sau',
'ui.lore': 'Hỏi chuyện xưa',
'ui.buy_tome': 'Mua Sách Kỹ năng (+1 điểm)',
'ui.respec': 'Phân bổ lại toàn bộ điểm',
'ui.gamble': 'Đánh bạc đổi hàng',
'ui.gamble_hint': 'Đến khi mua mới biết hiếm hay thường…',
'ui.quest_done_return': 'Quay về gặp {0}',
'ui.tome_bought': 'Tri thức bừng cháy trong đầu con… (+1 điểm kỹ năng)',
'ui.respec_done': 'Toàn bộ điểm đã hoàn trả.',
'ui.gamble_win': 'Thần may mắn đứng về phía ngươi… lần này.',
'quest.cull': 'Thanh tẩy Kẻ ngã ngã',
'quest.cull.desc': 'Hạ {0} sinh vật trong hang động. Kashya đếm từng mạng.',
'quest.boss': '{0}',
'quest.boss.desc': 'Xuống tới hang ổ và tiêu diệt {0}. Trại sẽ không yên khi nó còn sống.',
'quest.torment': 'Ám ảnh Khủng khiếp',
'quest.torment.desc': 'Malgor chết rồi nhưng bóng ma nó còn. Mở torment {0} và thanh tẩy lại.',
'town.enter': 'Trấn Tristram — Nơi Tôn Nghiêm',
'floor.depth': 'Độ sâu {0}',
'act.names.0': 'Nhà thờ hoang tàn',
'act.names.1': 'Hầm mộ than khóc',
'act.names.2': 'Vực nấm sâu thẳm',
'act.names.3': 'Ngưỡng cửa Địa Ngục',
torment: 'Torment {0}',
'loading.tips.0': 'Tinh anh rớt đồ phù phép. Săn những con phát sáng.',
'loading.tips.1': 'Bình hồi máu được nạp lại mỗi khi về trấn.',
'loading.tips.2': 'Lửa thiêu thịt; băng đóng băng xương. Mang cả hai.',
'loading.tips.3': 'Thùng rượu và lư đồng giấu vàng... đôi khi còn giấu cả thứ khác.',
'loading.tips.4': 'May mắn tìm đồ tăng tỷ lệ rớt đồ hiếm và huyền thoại.',
'credits.line1': 'Thiết kế, lập trình, hình ảnh & âm nhạc — sinh ra thủ tục, không dùng tài nguyên ngoài.',
'credits.line2': 'Lấy cảm hứng từ những tựa game kinh điển định hình thể loại.',
},
};
let lang = 'en';
function t(key, ...vars) {
let s = (dict[lang] && dict[lang][key]) ?? dict.en[key] ?? key;
vars.forEach((v, i) => { s = s.replaceAll('{' + i + '}', String(v)); });
return s;
}
function setLang(l) { lang = (dict[l] ? l : 'en'); if (typeof document !== 'undefined') document.documentElement.lang = lang; }
function getLang() { return lang; }
function languages() { return Object.keys(dict); }
/* apply data-i18n attributes inside a root element */
function applyDom(root = document) {
root.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = t(el.getAttribute('data-i18n'));
});
root.querySelectorAll('[data-i18n-title]').forEach(el => {
el.title = t(el.getAttribute('data-i18n-title'));
});
}
D2.i18n = { t, setLang, getLang, languages, applyDom, dict };
})(window.D2);
+124
View File
@@ -0,0 +1,124 @@
/* ============================================================
* Diablo2D — input.js : unified mouse/keyboard state
* Canvas-only listeners; DOM UI handles its own events.
* ============================================================ */
'use strict';
window.D2 = window.D2 || {};
(function (D2) {
const state = {
mx: 0, my: 0, // canvas-space cursor
left: false, right: false, mid: false,
leftPressed: false, // true only on frame of press
rightPressed: false,
wheelDelta: 0,
keys: new Set(), // currently held (KeyboardEvent.code)
pressedKeys: new Set(), // pressed this frame
releasedKeys: new Set(),
anyKeyPress: false,
lastMouseWorld: null,
};
let canvas = null;
const _pressedQueueKeys = new Set();
const _releasedQueueKeys = new Set();
function init(targetCanvas) {
canvas = targetCanvas;
window.addEventListener('keydown', (e) => {
if (e.repeat) return;
state.keys.add(e.code);
_pressedQueueKeys.add(e.code);
state.anyKeyPress = true;
// block browser scroll/space navigation during gameplay
if (['Space', 'ArrowUp', 'ArrowDown', 'Tab'].includes(e.code)) e.preventDefault();
}, { passive: false });
window.addEventListener('keyup', (e) => {
state.keys.delete(e.code);
_releasedQueueKeys.add(e.code);
});
window.addEventListener('blur', () => {
state.keys.clear();
state.left = state.right = false;
});
canvas.addEventListener('mousemove', (e) => {
const r = canvas.getBoundingClientRect();
state.mx = e.clientX - r.left;
state.my = e.clientY - r.top;
/* authoritative resync: if the browser says buttons are up, believe it.
Heals a missed mouseup (released outside the window, alt-tab, etc.) */
if (typeof e.buttons === 'number') {
if (!(e.buttons & 1)) state.left = false;
if (!(e.buttons & 2)) state.right = false;
if (!(e.buttons & 4)) state.mid = false;
}
});
canvas.addEventListener('mousedown', (e) => {
updateMousePos(e);
if (e.button === 0) { state.left = true; state.leftPressed = true; }
if (e.button === 2) { state.right = true; state.rightPressed = true; }
if (e.button === 1) { state.mid = true; e.preventDefault(); }
});
const releaseAll = () => { state.left = false; state.right = false; state.mid = false; };
window.addEventListener('mouseup', (e) => {
if (e.button === 0) state.left = false;
if (e.button === 2) state.right = false;
if (e.button === 1) state.mid = false;
});
/* cursor left the page / OS took the pointer / touch cancelled */
document.documentElement.addEventListener('mouseleave', releaseAll);
window.addEventListener('pointercancel', releaseAll);
document.addEventListener('visibilitychange', () => {
if (document.hidden) releaseAll();
});
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
canvas.addEventListener('wheel', (e) => {
state.wheelDelta += Math.sign(e.deltaY);
e.preventDefault();
}, { passive: false });
window.addEventListener('mousedown', (e) => { // audio unlock gesture
D2.audio && D2.audio.unlock();
}, { once: false, capture: true });
}
function updateMousePos(e) {
const r = canvas.getBoundingClientRect();
state.mx = e.clientX - r.left;
state.my = e.clientY - r.top;
}
/** call at end of each simulation frame */
function endFrame() {
state.leftPressed = false;
state.rightPressed = false;
state.wheelDelta = 0;
state.pressedKeys.clear();
state.releasedKeys.clear();
for (const k of _pressedQueueKeys) state.pressedKeys.add(k);
for (const k of _releasedQueueKeys) state.releasedKeys.add(k);
_pressedQueueKeys.clear();
_releasedQueueKeys.clear();
state.anyKeyPress = false;
}
const isDown = code => state.keys.has(code);
const wasPressed = code => state.pressedKeys.has(code);
const wasReleased = code => state.releasedKeys.has(code);
/* virtual press used by UI hotbar buttons */
function injectPress(code) {
_pressedQueueKeys.add(code);
}
D2.input = { state, init, endFrame, isDown, wasPressed, wasReleased, injectPress };
})(window.D2);
+101
View File
@@ -0,0 +1,101 @@
/* ============================================================
* Diablo2D — save.js : localStorage persistence layer
* ============================================================ */
'use strict';
window.D2 = window.D2 || {};
(function (D2) {
const PREFIX = 'd2d.';
const SAVE_VERSION = 3;
let storageOk = true;
try {
localStorage.setItem(PREFIX + '__probe', '1');
localStorage.removeItem(PREFIX + '__probe');
} catch (e) {
storageOk = false;
}
function get(key, fallback = null) {
if (!storageOk) return fallback;
try {
const raw = localStorage.getItem(PREFIX + key);
if (raw == null) return fallback;
return JSON.parse(raw);
} catch (e) {
console.warn('[save] corrupt entry', key, e);
return fallback;
}
}
function set(key, value) {
if (!storageOk) return false;
try {
localStorage.setItem(PREFIX + key, JSON.stringify(value));
return true;
} catch (e) {
console.error('[save] write failed', e);
return false;
}
}
function remove(key) {
if (!storageOk) return;
try { localStorage.removeItem(PREFIX + key); } catch (e) {}
}
function hasMeta() {
return !!get('meta', null);
}
function writeMeta(meta) {
meta.version = SAVE_VERSION;
meta.savedAt = Date.now();
set('meta', meta);
}
function readMeta() {
return get('meta', null);
}
function wipeAll() {
remove('meta');
remove('char');
remove('world');
remove('settings');
}
/* full snapshot: meta + char + world */
function serializeSnapshot() {
return JSON.stringify({
v: SAVE_VERSION,
meta: readMeta(),
char: get('char'),
world: get('world'),
});
}
function restoreSnapshot(text) {
try {
const obj = JSON.parse(text);
if (!obj || typeof obj !== 'object' || !obj.char) return false;
if (obj.meta) set('meta', obj.meta);
if (obj.char) set('char', obj.char);
if (obj.world) set('world', obj.world);
return true;
} catch (e) {
return false;
}
}
D2.save = {
version: SAVE_VERSION,
get storageOk() { return storageOk; },
KEY: { META: 'meta', CHAR: 'char', WORLD: 'world', SETTINGS: 'settings' },
get, set, remove,
hasMeta, writeMeta, readMeta,
wipeAll,
serializeSnapshot, restoreSnapshot,
};
})(window.D2);
+134
View File
@@ -0,0 +1,134 @@
/* ============================================================
* Diablo2D — util.js : math, RNG, misc helpers
* ============================================================ */
'use strict';
window.D2 = window.D2 || {};
(function (D2) {
const TAU = Math.PI * 2;
const clamp = (v, a, b) => v < a ? a : (v > b ? b : v);
const lerp = (a, b, t) => a + (b - a) * t;
const smoothstep = (a, b, x) => { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };
const dist2 = (ax, ay, bx, by) => { const dx = ax - bx, dy = ay - by; return dx * dx + dy * dy; };
const dist = (ax, ay, bx, by) => Math.sqrt(dist2(ax, ay, bx, by));
const angleTo = (ax, ay, bx, by) => Math.atan2(by - ay, bx - ax);
const wrapAngle = a => { while (a > Math.PI) a -= TAU; while (a < -Math.PI) a += TAU; return a; };
const approachAngle = (cur, target, maxStep) => {
let d = wrapAngle(target - cur);
if (Math.abs(d) <= maxStep) return target;
return cur + Math.sign(d) * maxStep;
};
const easeOutCubic = t => 1 - Math.pow(1 - t, 3);
const easeInQuad = t => t * t;
/* deterministic RNG */
function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
class RNG {
constructor(seed) {
this.seedFn = mulberry32(seed);
this.seed = seed >>> 0;
}
next() { return this.seedFn(); }
range(a, b) { return a + this.seedFn() * (b - a); } // float [a,b)
int(a, b) { return Math.floor(this.range(a, b + 1)); } // int [a,b]
chance(p) { return this.seedFn() < p; }
pick(arr) { return arr[Math.floor(this.seedFn() * arr.length)]; }
weighted(entries) { // [{v,w}] or [[val,weight]]
let total = 0;
for (const e of entries) total += e.w !== undefined ? e.w : e[1];
let roll = this.seedFn() * total;
for (const e of entries) {
roll -= e.w !== undefined ? e.w : e[1];
if (roll <= 0) return e.v !== undefined ? e.v : e[0];
}
const last = entries[entries.length - 1];
return last.v !== undefined ? last.v : last[0];
}
shuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(this.seedFn() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
}
let _uid = 1;
const uid = () => _uid++;
function fmtNum(n) {
n = Math.round(n);
if (Math.abs(n) >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (Math.abs(n) >= 10000) return (n / 1000).toFixed(1) + 'k';
return String(n);
}
function roman(n) {
if (n <= 0) return '0';
const map = [[1000,'M'],[900,'CM'],[500,'D'],[400,'CD'],[100,'C'],[90,'XC'],[50,'L'],[40,'XL'],[10,'X'],[9,'IX'],[5,'V'],[4,'IV'],[1,'I']];
let out = '';
for (const [v, s] of map) while (n >= v) { out += s; n -= v; }
return out;
}
function deepClone(o) { return JSON.parse(JSON.stringify(o)); }
/* color helpers: '#rrggbb' <-> [r,g,b] */
function hexToRgb(hex) {
const h = hex.replace('#', '');
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
}
function rgbToHex(r, g, b) {
const c = v => clamp(Math.round(v), 0, 255).toString(16).padStart(2, '0');
return '#' + c(r) + c(g) + c(b);
}
function lerpColor(c1, c2, t) {
const a = typeof c1 === 'string' ? hexToRgb(c1) : c1;
const b = typeof c2 === 'string' ? hexToRgb(c2) : c2;
return rgbToHex(lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t));
}
function shade(hex, f) { // f<1 darken, f>1 lighten
const [r, g, b] = hexToRgb(hex);
return rgbToHex(r * f, g * f, b * f);
}
function rgba(hex, alpha) {
const [r, g, b] = hexToRgb(hex);
return `rgba(${r},${g},${b},${alpha})`;
}
/* simple event bus */
class Bus {
constructor() { this._l = new Map(); }
on(evt, fn) {
if (!this._l.has(evt)) this._l.set(evt, []);
this._l.get(evt).push(fn);
}
off(evt, fn) {
const l = this._l.get(evt);
if (l) { const i = l.indexOf(fn); if (i >= 0) l.splice(i, 1); }
}
emit(evt, ...args) {
const l = this._l.get(evt);
if (l) for (const fn of [...l]) fn(...args);
}
}
D2.util = {
TAU, clamp, lerp, smoothstep, dist, dist2, angleTo, wrapAngle, approachAngle,
easeOutCubic, easeInQuad,
mulberry32, RNG, uid,
fmtNum, roman, deepClone,
hexToRgb, rgbToHex, lerpColor, shade, rgba,
Bus,
};
})(window.D2);