Files
EpicNext-Cms/src/lib/services/pet-icon.ts
T
openhands df38dccbf1
Local Build and Deploy / deploy (push) Failing after 46s
style: format code biome
2026-07-13 21:57:41 +02:00

383 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { cropRgba, decodePng } from "@/lib/services/imager/png-decode";
import { encodePng, parseNitroBundle } from "@/lib/services/swf/nitro-builder";
// Pet .nitro can live in the CMS import target or the bundled asset set.
const PET_DIRS = [
path.join(
/*turbopackIgnore: true*/ process.cwd(),
"public/nitro-assets/bundled/pet",
),
path.join(
/*turbopackIgnore: true*/ process.cwd(),
"public/Gamedata/bundled/pet",
),
];
interface Rect {
x: number;
y: number;
w: number;
h: number;
}
interface Asset {
x?: number;
y?: number;
source?: string;
}
interface AnimLayer {
frameSequences?: Record<string, { frames?: Record<string, { id?: number }> }>;
}
interface Visualization {
size?: number;
layerCount?: number;
animations?: Record<string, { layers?: Record<string, AnimLayer> }>;
}
interface NitroJson {
name?: string;
assets?: Record<string, Asset>;
spritesheet?: { frames?: Record<string, { frame: Rect; rotated?: boolean }> };
visualizations?: Visualization[];
}
interface LayerSpec {
letter: string;
frameId: number;
}
// A pet is rendered as several stacked layers (a,b,c,d = body parts; sd = shadow).
// Directions present on pets are a subset of 0..7; 2 reads best as a portrait.
const DIR_PREFERENCE = [2, 0, 4, 3, 1, 7, 6, 5];
// Generated PNG icons are tiny; cache them in-process keyed by lib.
const iconCache = new Map<string, Buffer | null>();
function findNitro(lib: string): string | null {
for (const dir of PET_DIRS) {
const p = path.join(/*turbopackIgnore: true*/ dir, `${lib}.nitro`);
if (existsSync(/*turbopackIgnore: true*/ p)) return p;
}
return null;
}
/**
* Which layers (and at which frame id) make up the pet's IDLE pose, read from
* the size-64 visualization's animation 0. Layers with an explicitly empty
* frameSequences are NOT shown when idle (they belong to other animations —
* e.g. dragon wings/fire) and must be skipped, otherwise the composite turns
* into a junk pile. Falls back to layers a–d at frame 0 when no visualization.
*/
function idleLayerSpecs(json: NitroJson): LayerSpec[] {
const viz = json.visualizations?.find((v) => v.size === 64);
if (!viz?.layerCount) {
return ["a", "b", "c", "d"].map((letter) => ({ letter, frameId: 0 }));
}
const anim0 = viz.animations?.["0"];
const raw: {
letter: string;
alPresent: boolean;
hasSeq: boolean;
frameId: number;
}[] = [];
let anyWithSeq = false;
for (let i = 0; i < viz.layerCount && i < 4; i++) {
const letter = String.fromCharCode(97 + i); // a, b, c, d
const al = anim0?.layers?.[String(i)];
const seqKeys = al ? Object.keys(al.frameSequences ?? {}) : [];
const hasSeq = seqKeys.length > 0;
if (hasSeq) anyWithSeq = true;
const frameId = hasSeq
? Number(al?.frameSequences?.[seqKeys[0]]?.frames?.["0"]?.id ?? 0) || 0
: 0;
raw.push({ letter, alPresent: !!al, hasSeq, frameId });
}
// No idle animation defined anywhere → render every layer's static frame 0.
if (!anyWithSeq) return raw.map((r) => ({ letter: r.letter, frameId: 0 }));
// Idle animation exists → include only layers it drives (or layers with no
// entry); skip layers explicitly muted in idle (empty frameSequences).
return raw
.filter((r) => r.hasSeq || !r.alPresent)
.map((r) => ({ letter: r.letter, frameId: r.frameId }));
}
/** Resolve a layer asset to its spritesheet rect + placement offset. */
function resolveSprite(
json: NitroJson,
lib: string,
layer: string,
dir: number,
frameId: number,
): { rect: Rect; ox: number; oy: number } | null {
const assetName = `${lib}_64_${layer}_${dir}_${frameId}`;
const asset = json.assets?.[assetName];
if (!asset) return null;
// `source` aliases another asset's pixels; offset stays on this asset.
const pixelAsset = asset.source ?? assetName;
const frameKey = `${lib}_${pixelAsset}`;
const f = json.spritesheet?.frames?.[frameKey];
if (!f || f.rotated || f.frame.w <= 0 || f.frame.h <= 0) return null;
return { rect: f.frame, ox: asset.x ?? 0, oy: asset.y ?? 0 };
}
/** Alpha-over `src` (w×h RGBA) onto `dst` at (dx,dy) within a dstW-wide canvas. */
function blit(
dst: Buffer,
dstW: number,
src: Buffer,
w: number,
h: number,
dx: number,
dy: number,
): void {
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const s = (y * w + x) * 4;
const sa = src[s + 3];
if (sa === 0) continue;
const d = ((dy + y) * dstW + (dx + x)) * 4;
if (sa === 255) {
src.copy(dst, d, s, s + 4);
continue;
}
const ia = 255 - sa;
dst[d] = (src[s] * sa + dst[d] * ia) / 255;
dst[d + 1] = (src[s + 1] * sa + dst[d + 1] * ia) / 255;
dst[d + 2] = (src[s + 2] * sa + dst[d + 2] * ia) / 255;
dst[d + 3] = sa + (dst[d + 3] * ia) / 255;
}
}
}
// Real per-breed pet colour is applied at runtime (the .nitro stores white
// palettes), so recolorable regions are baked as a saturated magenta/red
// placeholder. We tint those placeholder pixels to a sensible default colour
// per breed (an estimate, not the exact in-game value). Naturally-coloured
// pixels (white dog, brown horse, green plant) are left untouched.
const DEFAULT_PET_COLOR: Record<string, [number, number, number]> = {
lion: [216, 166, 72],
haloompa: [216, 166, 72],
pig: [240, 168, 178],
pigletbaby: [240, 168, 178],
bunnylove: [240, 168, 190],
dog: [196, 160, 120],
terrier: [196, 160, 120],
terrierbaby: [196, 160, 120],
puppybaby: [196, 160, 120],
bunnydepressed: [196, 160, 120],
gnome: [196, 160, 120],
fools: [196, 160, 120],
cat: [156, 152, 158],
kittenbaby: [156, 152, 158],
monkey: [150, 108, 74],
demonmonkey: [150, 108, 74],
bear: [150, 112, 80],
bearbaby: [150, 112, 80],
horse: [162, 122, 86],
rhino: [150, 150, 158],
elephants: [150, 150, 158],
spider: [96, 84, 96],
bunnyevil: [120, 120, 128],
pigeonevil: [120, 120, 130],
pigeongood: [176, 176, 182],
bunnyeaster: [220, 214, 214],
cow: [206, 186, 162],
chicken: [238, 226, 176],
frog: [120, 184, 92],
dragon: [120, 172, 110],
croco: [128, 176, 108],
turtle: [130, 172, 120],
monster: [128, 176, 100],
monsterplant: [120, 170, 96],
pterosaur: [128, 176, 108],
velociraptor: [128, 176, 108],
};
const FALLBACK_PET_COLOR: [number, number, number] = [190, 158, 120];
function recolorMask(
rgba: Buffer,
[tr, tg, tb]: [number, number, number],
): void {
for (let i = 0; i < rgba.length; i += 4) {
if (rgba[i + 3] === 0) continue;
const r = rgba[i];
const g = rgba[i + 1];
const b = rgba[i + 2];
// Placeholder mask: highly saturated toward magenta/red (green far below
// red/blue). Tint to the breed colour, preserving the mask's shading.
if (Math.max(r, b) - g > 60) {
const l = Math.max(r, g, b) / 255;
rgba[i] = Math.round(tr * l);
rgba[i + 1] = Math.round(tg * l);
rgba[i + 2] = Math.round(tb * l);
}
}
}
/**
* Keep only the largest connected (8-neighbour) opaque blob — the pet body —
* and drop detached fragments (mis-posed heads/hats, stray specks from layers
* that don't align at this frame), then tight-crop to it. Turns "body + floating
* junk" into a clean centered icon.
*/
function keepLargestBlob(
rgba: Buffer,
w: number,
h: number,
): { rgba: Buffer; w: number; h: number } {
const n = w * h;
const label = new Int32Array(n).fill(-1);
const opaque = (i: number) => rgba[i * 4 + 3] > 30;
const comps: { id: number; size: number }[] = [];
for (let start = 0; start < n; start++) {
if (label[start] !== -1 || !opaque(start)) continue;
const id = comps.length;
let size = 0;
const stack = [start];
label[start] = id;
while (stack.length) {
const p = stack.pop() as number;
size++;
const x = p % w;
const y = (p / w) | 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const nx = x + dx;
const ny = y + dy;
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
const np = ny * w + nx;
if (label[np] === -1 && opaque(np)) {
label[np] = id;
stack.push(np);
}
}
}
}
comps.push({ id, size });
}
if (comps.length === 0) return { rgba, w, h };
let main = comps[0];
for (const c of comps) if (c.size > main.size) main = c;
// Tight-crop bbox of the main blob.
let minX = w;
let minY = h;
let maxX = -1;
let maxY = -1;
for (let p = 0; p < n; p++) {
if (label[p] !== main.id) continue;
const x = p % w;
const y = (p / w) | 0;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
const nw = maxX - minX + 1;
const nh = maxY - minY + 1;
const out = Buffer.alloc(nw * nh * 4);
for (let y = 0; y < nh; y++) {
for (let x = 0; x < nw; x++) {
const sp = (minY + y) * w + (minX + x);
if (label[sp] === main.id) {
const o = (y * nw + x) * 4;
const si = sp * 4;
rgba.copy(out, o, si, si + 4);
}
}
}
return { rgba: out, w: nw, h: nh };
}
/** Composite all non-shadow body layers of one direction's base pose into a PNG. */
function composite(
json: NitroJson,
sheet: { width: number; rgba: Buffer },
): Buffer | null {
const lib = (json.name ?? "").toString();
if (!lib) return null;
type Sprite = { rect: Rect; ox: number; oy: number };
const bestDir = (specs: LayerSpec[]): Sprite[] => {
let best: Sprite[] = [];
for (const dir of DIR_PREFERENCE) {
const resolved = specs
.map((s) => resolveSprite(json, lib, s.letter, dir, s.frameId))
.filter((s): s is Sprite => s !== null);
if (resolved.length > best.length) {
best = resolved;
if (dir === DIR_PREFERENCE[0]) break; // front pose is good enough
}
}
return best;
};
// Prefer the idle-pose layers; if nothing resolves, fall back to every
// layer's static frame 0 (covers pets whose frame ids don't line up).
let sprites = bestDir(idleLayerSpecs(json));
if (sprites.length === 0) {
sprites = bestDir(
["a", "b", "c", "d"].map((letter) => ({ letter, frameId: 0 })),
);
}
if (sprites.length === 0) return null;
// Union bounding box in pet-local coords.
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const s of sprites) {
minX = Math.min(minX, s.ox);
minY = Math.min(minY, s.oy);
maxX = Math.max(maxX, s.ox + s.rect.w);
maxY = Math.max(maxY, s.oy + s.rect.h);
}
const cw = maxX - minX;
const ch = maxY - minY;
if (cw <= 0 || ch <= 0 || cw > 256 || ch > 256) return null;
const canvas = Buffer.alloc(cw * ch * 4); // transparent
for (const s of sprites) {
const px = cropRgba(
sheet.rgba,
sheet.width,
s.rect.x,
s.rect.y,
s.rect.w,
s.rect.h,
);
blit(canvas, cw, px, s.rect.w, s.rect.h, s.ox - minX, s.oy - minY);
}
recolorMask(canvas, DEFAULT_PET_COLOR[lib] ?? FALLBACK_PET_COLOR);
const blob = keepLargestBlob(canvas, cw, ch);
return encodePng(blob.w, blob.h, blob.rgba);
}
/**
* Build a composited PNG icon for a pet from its `.nitro` spritesheet, or null
* if the pet has no bundle / no usable frames. Cached in-process.
*/
export function getPetIconPng(lib: string): Buffer | null {
if (!/^[A-Za-z0-9_]+$/.test(lib)) return null;
const cached = iconCache.get(lib);
if (cached !== undefined) return cached;
const nitroPath = findNitro(lib);
if (!nitroPath) {
iconCache.set(lib, null);
return null;
}
try {
const { json, png } = parseNitroBundle(
readFileSync(/*turbopackIgnore: true*/ nitroPath),
);
const sheet = decodePng(png);
const icon = composite(json as NitroJson, sheet);
iconCache.set(lib, icon);
return icon;
} catch {
iconCache.set(lib, null);
return null;
}
}