- 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
135 lines
4.6 KiB
JavaScript
135 lines
4.6 KiB
JavaScript
/* ============================================================
|
|
* 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);
|