- 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
122 lines
3.9 KiB
JavaScript
122 lines
3.9 KiB
JavaScript
/* ============================================================
|
|
* tools/make_icons.mjs — generate PNG icons with zero deps.
|
|
* Pixel-math art: hellfire sigil on obsidian.
|
|
* node tools/make_icons.mjs
|
|
* ============================================================ */
|
|
import zlib from 'node:zlib';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
/* ---------- minimal PNG encoder (RGBA8, filter 0) ---------- */
|
|
const CRC_TABLE = (() => {
|
|
const t = new Int32Array(256);
|
|
for (let n = 0; n < 256; n++) {
|
|
let c = n;
|
|
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
t[n] = c;
|
|
}
|
|
return t;
|
|
})();
|
|
function crc32(buf) {
|
|
let c = 0xffffffff;
|
|
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
return (c ^ 0xffffffff) >>> 0;
|
|
}
|
|
function chunk(type, data) {
|
|
const len = Buffer.alloc(4);
|
|
len.writeUInt32BE(data.length);
|
|
const td = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
|
const crc = Buffer.alloc(4);
|
|
crc.writeUInt32BE(crc32(td));
|
|
return Buffer.concat([len, td, crc]);
|
|
}
|
|
function encodePNG(w, h, rgba) {
|
|
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
const ihdr = Buffer.alloc(13);
|
|
ihdr.writeUInt32BE(w, 0);
|
|
ihdr.writeUInt32BE(h, 4);
|
|
ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
|
|
const stride = w * 4;
|
|
const raw = Buffer.alloc(h * (stride + 1));
|
|
for (let y = 0; y < h; y++) {
|
|
raw[y * (stride + 1)] = 0;
|
|
rgba.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
|
|
}
|
|
return Buffer.concat([
|
|
sig,
|
|
chunk('IHDR', ihdr),
|
|
chunk('IDAT', zlib.deflateSync(raw, { level: 9 })),
|
|
chunk('IEND', Buffer.alloc(0)),
|
|
]);
|
|
}
|
|
|
|
/* ---------- sigil renderer (SDF-ish per pixel) ---------- */
|
|
function drawIcon(S) {
|
|
const px = Buffer.alloc(S * S * 4);
|
|
const cx = S / 2, cy = S / 2;
|
|
const put = (x, y, r, g, b, a = 255) => {
|
|
const i = (y * S + x) * 4;
|
|
px[i] = r; px[i + 1] = g; px[i + 2] = b; px[i + 3] = a;
|
|
};
|
|
const mix = (x, y, r, g, b, alpha) => {
|
|
const i = (y * S + x) * 4;
|
|
px[i] = px[i] * (1 - alpha) + r * alpha;
|
|
px[i + 1] = px[i + 1] * (1 - alpha) + g * alpha;
|
|
px[i + 2] = px[i + 2] * (1 - alpha) + b * alpha;
|
|
px[i + 3] = 255;
|
|
};
|
|
|
|
for (let y = 0; y < S; y++) {
|
|
for (let x = 0; x < S; x++) {
|
|
const nx = (x - cx) / (S / 2), ny = (y - cy) / (S / 2);
|
|
const rad = Math.hypot(nx, ny);
|
|
|
|
/* obsidian background with vignette */
|
|
let r = 13, g = 10, b = 9;
|
|
const vg = Math.max(0, 1 - rad * 0.85);
|
|
r += 26 * vg; g += 18 * vg; b += 14 * vg;
|
|
|
|
/* outer gold ring */
|
|
const ring = Math.abs(rad - 0.86);
|
|
if (ring < 0.045) {
|
|
const edge = 1 - ring / 0.045;
|
|
mix(x, y, 200, 163, 90, 0.55 + 0.45 * edge);
|
|
}
|
|
|
|
/* hellfire pentagram star (5-point, drawn via angular falloff) */
|
|
const ang = Math.atan2(ny, nx) - Math.PI / 2;
|
|
const spikes = 5;
|
|
const starR = 0.34 + 0.30 * Math.pow(Math.abs(Math.cos(spikes * ang / 2)), 3);
|
|
const d = rad - starR;
|
|
if (d < 0) {
|
|
const glow = Math.min(1, -d * 5);
|
|
const fire = Math.pow(glow, 0.6);
|
|
mix(x, y,
|
|
120 + 120 * fire,
|
|
24 + 60 * fire * (0.6 + 0.4 * Math.sin(ang * spikes)),
|
|
18,
|
|
Math.min(1, 0.35 + glow));
|
|
/* molten core line */
|
|
if (Math.abs(d) < 0.02 && rad > 0.12) mix(x, y, 255, 214, 130, 0.75);
|
|
}
|
|
|
|
/* center ember */
|
|
const ember = Math.hypot(nx * 2.4, ny * 2.4);
|
|
if (ember < 1) {
|
|
const e = 1 - ember;
|
|
mix(x, y, 255, 190 + 40 * e, 110, e * e);
|
|
}
|
|
|
|
put(x, y, px[(y * S + x) * 4], px[(y * S + x) * 4 + 1], px[(y * S + x) * 4 + 2], 255);
|
|
}
|
|
}
|
|
return encodePNG(S, S, px);
|
|
}
|
|
|
|
const outDir = path.join(process.cwd(), 'icons');
|
|
fs.mkdirSync(outDir, { recursive: true });
|
|
for (const size of [180, 192, 512]) {
|
|
fs.writeFileSync(path.join(outDir, `icon-${size}.png`), drawIcon(size));
|
|
console.log(`wrote icons/icon-${size}.png`);
|
|
}
|