/** * 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();