inventory
This commit is contained in:
180
tools/asset-gen.mjs
Normal file
180
tools/asset-gen.mjs
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 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();
|
||||
Reference in New Issue
Block a user