Files
ashen-realms/tools/asset-gen.mjs
Bastian Wagner 987242541d inventory
2026-08-21 12:52:44 +02:00

181 lines
6.6 KiB
JavaScript

/**
* Derives 9-slice-safe HUD assets from the hand-made art in
* `apps/web/public/assets/hud-elements/`.
*
* `panel-bg.png` carries a gem ornament at the centre of every edge. Stretched
* by `border-image` those ornaments smear, so this script paints them out
* against a plain stretch of the same edge and saves the result as a frame that
* tiles at any panel size. The ornaments are saved separately so the UI can put
* them back at the true centre of each panel.
*
* The generated files are committed, so this only needs re-running when the
* source art changes. It drives Playwright's bundled Chromium for canvas work;
* Playwright is not a direct dependency (it arrives with the Angular test
* builder), so install it explicitly if that ever stops being true.
*
* Run with the dev server up (a file:// canvas is tainted and cannot be read):
* node tools/asset-gen.mjs # defaults to http://localhost:4455
* ASSET_ORIGIN=http://localhost:4200 node tools/asset-gen.mjs
*/
import { chromium } from 'playwright';
import { fileURLToPath } from 'url';
import { writeFile } from 'fs/promises';
import path from 'path';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const outDir = path.join(root, 'apps/web/public/assets/hud-elements');
const ORIGIN = process.env.ASSET_ORIGIN ?? 'http://localhost:4455';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(`${ORIGIN}/assets/hud-elements/panel-bg.png`);
const result = await page.evaluate(async () => {
const load = async (src) => {
const img = new Image();
img.src = src;
await img.decode();
return img;
};
const canvasOf = (w, h) => {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
return c;
};
const img = await load('panel-bg.png');
const src = canvasOf(img.naturalWidth, img.naturalHeight);
src.getContext('2d').drawImage(img, 0, 0);
const sctx = src.getContext('2d');
// ---- 1. trim the transparent margin so the frame meets the image edge ----
const full = sctx.getImageData(0, 0, src.width, src.height).data;
const alphaAt = (x, y) => full[(y * src.width + x) * 4 + 3];
let x0 = 0;
let x1 = src.width - 1;
let y0 = 0;
let y1 = src.height - 1;
const opaqueCol = (x) => {
for (let y = 0; y < src.height; y++) if (alphaAt(x, y) > 40) return true;
return false;
};
const opaqueRow = (y) => {
for (let x = 0; x < src.width; x++) if (alphaAt(x, y) > 40) return true;
return false;
};
while (x0 < x1 && !opaqueCol(x0)) x0++;
while (x1 > x0 && !opaqueCol(x1)) x1--;
while (y0 < y1 && !opaqueRow(y0)) y0++;
while (y1 > y0 && !opaqueRow(y1)) y1--;
const W = x1 - x0 + 1;
const H = y1 - y0 + 1;
const frame = canvasOf(W, H);
const fctx = frame.getContext('2d');
fctx.drawImage(src, x0, y0, W, H, 0, 0, W, H);
// ---- 2. locate the centred ornament on each edge ----
// An edge is a repeating bar away from its ornament, so a line sampled from a
// quiet stretch is a faithful stand-in. Walk out from the centre until the
// line matches that reference again: that is where the ornament ends.
const band = 96; // how deep into the panel an edge ornament reaches
const data = fctx.getImageData(0, 0, W, H).data;
const px = (x, y) => {
const i = (y * W + x) * 4;
return [data[i], data[i + 1], data[i + 2], data[i + 3]];
};
const diffColumns = (ax, bx, from, to) => {
let sum = 0;
for (let y = from; y < to; y++) {
const a = px(ax, y);
const b = px(bx, y);
sum += Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]) + Math.abs(a[3] - b[3]);
}
return sum / (to - from);
};
const diffRows = (ay, by, from, to) => {
let sum = 0;
for (let x = from; x < to; x++) {
const a = px(x, ay);
const b = px(x, by);
sum += Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]) + Math.abs(a[3] - b[3]);
}
return sum / (to - from);
};
const THRESHOLD = 18;
const refX = Math.round(W * 0.26); // quiet stretch of the horizontal edges
const refY = Math.round(H * 0.26); // quiet stretch of the vertical edges
const cx = Math.round(W / 2);
const cy = Math.round(H / 2);
const spanX = (from, to) => {
let a = cx;
let b = cx;
while (a > W * 0.15 && diffColumns(a, refX, from, to) > THRESHOLD) a--;
while (b < W * 0.85 && diffColumns(b, refX, from, to) > THRESHOLD) b++;
return [a, b];
};
const spanY = (from, to) => {
let a = cy;
let b = cy;
while (a > H * 0.15 && diffRows(a, refY, from, to) > THRESHOLD) a--;
while (b < H * 0.85 && diffRows(b, refY, from, to) > THRESHOLD) b++;
return [a, b];
};
const [topA, topB] = spanX(0, band);
const [botA, botB] = spanX(H - band, H);
const [leftA, leftB] = spanY(0, band);
const [rightA, rightB] = spanY(W - band, W);
// ---- 3. keep a copy of the top ornament before painting it out ----
// The bottom edge measures the ornament most tightly (the top edge's texture
// drifts enough to over-report), so use its width for both.
const ornW = Math.max(botB - botA, 96);
const ornH = band;
const ornX = Math.round(cx - ornW / 2);
const ornament = canvasOf(ornW, ornH);
ornament.getContext('2d').drawImage(frame, ornX, 0, ornW, ornH, 0, 0, ornW, ornH);
// ---- 4. paint each ornament out with the plain part of its own edge ----
// `drawImage` composites, so a transparent clean column would leave an opaque
// ornament showing through. Clear the region first, then replace it.
const patchH = (a, b, y) => {
fctx.clearRect(a, y, b - a, band);
for (let x = a; x < b; x++) fctx.drawImage(frame, refX, y, 1, band, x, y, 1, band);
};
const patchV = (a, b, x) => {
fctx.clearRect(x, a, band, b - a);
for (let y = a; y < b; y++) fctx.drawImage(frame, x, refY, band, 1, x, y, band, 1);
};
patchH(topA, topB, 0);
patchH(botA, botB, H - band);
patchV(leftA, leftB, 0);
patchV(rightA, rightB, W - band);
return {
frame: frame.toDataURL('image/png'),
ornament: ornament.toDataURL('image/png'),
meta: {
source: { w: img.naturalWidth, h: img.naturalHeight },
trimmed: { w: W, h: H },
ornaments: { top: [topA, topB], bottom: [botA, botB], left: [leftA, leftB], right: [rightA, rightB] },
ornamentSize: { w: ornW, h: ornH },
},
};
});
const write = async (name, dataUrl) => {
const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1);
await writeFile(path.join(outDir, name), Buffer.from(base64, 'base64'));
};
await write('panel-frame.png', result.frame);
await write('panel-ornament.png', result.ornament);
console.log(JSON.stringify(result.meta, null, 2));
await browser.close();