183 lines
10 KiB
JavaScript
Executable File
183 lines
10 KiB
JavaScript
Executable File
// Original deterministic assets, generated without libraries or downloaded art.
|
|
import { deflateSync } from 'node:zlib';
|
|
import * as fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { ensureDirectory, relativeName, safePath } from './files.mjs';
|
|
import { BODY_PROFILES, playerB3D, soundOGG, SOUND_PROFILES, TOOL_NAMES, toolOBJ } from './media.mjs';
|
|
import { ANIMAL_NAMES, BOTANY_CATALOGUE, animalOBJ, contactSheet, legacyPixel, worldArt } from './world-art.mjs';
|
|
import { FLORA_NAMES, TREE_SEASONS, SPECIES_ATLAS_NAMES, floraOBJ, treeAtlas } from './flora-art.mjs';
|
|
import { CHARACTER_ART_MANIFEST, CHARACTER_OPTIONS, characterArt, characterMesh, compositeCharacter, portraitSheet } from './character-art.mjs';
|
|
|
|
function crc32(data) {
|
|
let crc = 0xffffffff;
|
|
for (const byte of data) {
|
|
crc ^= byte;
|
|
for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
|
|
}
|
|
return (crc ^ 0xffffffff) >>> 0;
|
|
}
|
|
function chunk(type, data) {
|
|
const body = Buffer.concat([Buffer.from(type), data]);
|
|
const size = Buffer.alloc(4), crc = Buffer.alloc(4);
|
|
size.writeUInt32BE(data.length); crc.writeUInt32BE(crc32(body));
|
|
return Buffer.concat([size, body, crc]);
|
|
}
|
|
export function png(width, pixel, height = width) {
|
|
if (!Number.isInteger(width) || !Number.isInteger(height) || width<1 || height<1 ||
|
|
width>2048 || height>2048 || width*height>1024*1024 || typeof pixel!=='function')
|
|
throw new Error('Invalid bounded PNG dimensions or pixel callback.');
|
|
const rows = Buffer.alloc(height * (width * 4 + 1));
|
|
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
|
|
const color = pixel(x, y), offset = y * (width * 4 + 1) + 1 + x * 4;
|
|
if (!color || color.length!==4 || !Array.from(color).every(n=>Number.isInteger(n) && n>=0 && n<=255))
|
|
throw new Error('Invalid RGBA pixel.');
|
|
rows.set(color, offset);
|
|
}
|
|
const header = Buffer.alloc(13);
|
|
header.writeUInt32BE(width); header.writeUInt32BE(height, 4); header[8] = 8; header[9] = 6;
|
|
return Buffer.concat([Buffer.from([137,80,78,71,13,10,26,10]), chunk('IHDR', header),
|
|
chunk('IDAT', deflateSync(rows)), chunk('IEND', Buffer.alloc(0))]);
|
|
}
|
|
function inside(x, y, polygon) {
|
|
let hit = false;
|
|
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
const [a,b] = polygon[i], [c,d] = polygon[j];
|
|
if ((b > y) !== (d > y) && x < (c-a) * (y-b) / (d-b) + a) hit = !hit;
|
|
}
|
|
return hit;
|
|
}
|
|
const blades = {
|
|
knife: [[12,17],[24,3],[23,12],[16,21]],
|
|
axe: [[5,7],[14,3],[22,9],[17,17],[12,16],[11,11],[6,12]],
|
|
pick: [[4,10],[9,5],[18,5],[27,11],[20,9],[13,9],[8,11]],
|
|
spear: [[21,11],[28,2],[27,12],[23,15]],
|
|
};
|
|
function icon(name, x, y) {
|
|
const diagonal = name === 'knife' || name === 'spear';
|
|
const shaft = diagonal ? [[4,26],[6,28],[25,9],[23,7]] : [[11,10],[14,9],[27,27],[24,29]];
|
|
let color = null;
|
|
if (inside(x+.5,y+.5,shaft)) color = (x+y)%5 === 0 ? [88,57,39] : [157,112,65];
|
|
if (inside(x+.5,y+.5,blades[name])) color = (x+y)%4 === 0 ? [227,233,220] : [159,181,180];
|
|
if (color && ((diagonal && x<12) || (!diagonal && y>21)) && (x+y)%3 === 0) color = [211,183,128];
|
|
return color ? [...color,255] : [0,0,0,0];
|
|
}
|
|
|
|
// Only mod Lua is an input. In particular never traverse repository scripts,
|
|
// generated assets, tests or symlink/junction targets. Bounds cap accidental input.
|
|
async function sourceColors(source) {
|
|
const colors = new Set(['c7aa83','947853']);
|
|
let entries = 0;
|
|
async function visit(directory, depth = 0) {
|
|
if (depth > 32) throw new Error('Mod directory nesting limit exceeded.');
|
|
await safePath(directory);
|
|
for (const name of (await fs.readdir(directory)).sort()) {
|
|
relativeName(name);
|
|
if (++entries > 10000) throw new Error('Mod source entry limit exceeded.');
|
|
const filename = path.join(directory, name), stat = await fs.lstat(filename);
|
|
if (stat.isSymbolicLink()) throw new Error('Refusing symbolic link or junction in mod assets input.');
|
|
if (stat.isDirectory()) await visit(filename, depth+1);
|
|
else if (!stat.isFile()) throw new Error('Mod assets input must contain regular files.');
|
|
else if (name.endsWith('.lua')) {
|
|
if (stat.size > 2*1024*1024) throw new Error('Mod Lua input exceeds size limit.');
|
|
const text = await fs.readFile(await safePath(filename), 'utf8');
|
|
for (const match of text.matchAll(/#[0-9a-fA-F]{6}\b/g)) colors.add(match[0].slice(1).toLowerCase());
|
|
if (colors.size > 4096) throw new Error('Mod texture color limit exceeded.');
|
|
}
|
|
}
|
|
}
|
|
await visit(path.join(source, 'mods'));
|
|
return [...colors].sort();
|
|
}
|
|
|
|
async function writeAsset(directory, name, bytes) {
|
|
// Packaging supplies a private staging tree in a trusted parent; guard output
|
|
// links too, so direct invocation cannot overwrite a linked external asset.
|
|
const filename = await safePath(path.join(directory, relativeName(name)));
|
|
await fs.writeFile(filename, bytes, {mode: 0o600});
|
|
}
|
|
|
|
// Per-image provenance is included in the hash-verified package, not just in a
|
|
// source comment. No inference from filename/common-name or unsafe Lua execution.
|
|
export const BOTANY_ART_MANIFEST = Object.freeze(BOTANY_CATALOGUE.flatMap(({id,latin,group,morphology})=>
|
|
[false,true].map(dormant=>Object.freeze({id,botanicalLatin:latin,group,morphology,
|
|
state:dormant?'dormant':'active',filename:`hearth_botany_${id}${dormant?'_dormant':''}.png`,
|
|
width:32,height:32,license:'MIT',author:'The Last Hearth original procedural art'}))));
|
|
|
|
export async function generateAssets(destination, source) {
|
|
const colors = await sourceColors(source);
|
|
const textures = path.join(destination, 'mods/hearth/textures');
|
|
await ensureDirectory(textures);
|
|
for (const color of colors) {
|
|
const image = png(32, (x,y)=>legacyPixel(color,x,y));
|
|
await writeAsset(textures, `hearth_${color}.png`, image);
|
|
}
|
|
const art = worldArt();
|
|
await writeAsset(destination, 'botany-art.json', JSON.stringify({format:1,images:BOTANY_ART_MANIFEST},null,2)+'\n');
|
|
for (const season of TREE_SEASONS) {
|
|
const trees = treeAtlas(art, season), suffix = season==='summer'?'':`_${season}`;
|
|
await writeAsset(textures, `hearth_tree_atlas${suffix}.png`, png(trees.width,
|
|
(x,y)=>trees.data.subarray((y*trees.width+x)*4,(y*trees.width+x+1)*4), trees.height));
|
|
}
|
|
for (const name of SPECIES_ATLAS_NAMES) {
|
|
const [species, , season='summer'] = name.split('_'), trees=treeAtlas(art,season,species);
|
|
await writeAsset(textures, `hearth_${name}.png`, png(trees.width,
|
|
(x,y)=>trees.data.subarray((y*trees.width+x)*4,(y*trees.width+x+1)*4), trees.height));
|
|
}
|
|
for (const [name, image] of art) await writeAsset(textures, name,
|
|
png(image.width, (x,y)=>image.data.subarray((y*image.width+x)*4,(y*image.width+x+1)*4), image.height));
|
|
// The client uses this engine-defined filename, not the journal's named tile.
|
|
const crosshair = art.get('hearth_crosshair.png');
|
|
await writeAsset(textures, 'crosshair.png', png(crosshair.width,
|
|
(x,y)=>crosshair.data.subarray((y*crosshair.width+x)*4,(y*crosshair.width+x+1)*4), crosshair.height));
|
|
await writeAsset(textures, 'hearth_art_contact_sheet.png', artContactSheetPNG(art));
|
|
for (const name of TOOL_NAMES) {
|
|
await writeAsset(textures, `hearth_icon_${name}.png`, png(32,(x,y)=>icon(name,x,y)));
|
|
}
|
|
const encode = image => png(image.width,
|
|
(x,y)=>image.data.subarray((y*image.width+x)*4,(y*image.width+x+1)*4),image.height);
|
|
for (const [name,image] of characterArt()) await writeAsset(textures,name,encode(image));
|
|
await writeAsset(textures,'hearth_person.png',encode(compositeCharacter()));
|
|
await writeAsset(textures,'hearth_character_contact_sheet.png',encode(portraitSheet()));
|
|
await writeAsset(destination,'character-art.json',JSON.stringify({format:1,options:CHARACTER_OPTIONS,
|
|
meshes:BODY_PROFILES.map(body=>({body,filename:characterMesh(body)})),images:CHARACTER_ART_MANIFEST},null,2)+'\n');
|
|
// A low-poly original player silhouette; stable engine mesh instead of a billboard.
|
|
// Kept for older callers; M1 uses hearth_person.b3d and hearth_person.png.
|
|
const models = path.join(destination, 'mods/hearth/models');
|
|
await ensureDirectory(models);
|
|
for (const name of FLORA_NAMES) await writeAsset(models, `hearth_${name}.obj`, floraOBJ(name));
|
|
for (const name of ANIMAL_NAMES) await writeAsset(models, `hearth_${name}.obj`, animalOBJ(name));
|
|
let vertices = 0;
|
|
const obj = ['# Original Last Hearth block character', 'vt 0 0','vt 1 0','vt 1 1','vt 0 1'];
|
|
function box(x0,y0,z0,x1,y1,z1) {
|
|
for(const v of [[x0,y0,z0],[x1,y0,z0],[x1,y1,z0],[x0,y1,z0],
|
|
[x0,y0,z1],[x1,y0,z1],[x1,y1,z1],[x0,y1,z1]]) obj.push(`v ${v.join(' ')}`);
|
|
for(const f of [[1,4,3,2],[5,6,7,8],[1,2,6,5],[4,8,7,3],[1,5,8,4],[2,3,7,6]])
|
|
obj.push(`f ${f.map((n,i)=>`${vertices+n}/${i+1}`).join(' ')}`);
|
|
vertices += 8;
|
|
}
|
|
box(-2.3,8,-1.3,2.3,14,1.3); box(-2,14,-2,2,18,2);
|
|
box(-4,8,-1,-2.3,14,1); box(2.3,8,-1,4,14,1);
|
|
box(-2.2,0,-1.2,-0.2,8,1.2); box(0.2,0,-1.2,2.2,8,1.2);
|
|
await writeAsset(models, 'hearth_person.obj', obj.join('\n')+'\n');
|
|
for (const body of BODY_PROFILES) await writeAsset(models,characterMesh(body),playerB3D(body));
|
|
for (const name of TOOL_NAMES) await writeAsset(models, `hearth_${name}.obj`, toolOBJ(name));
|
|
const sounds = path.join(destination, 'mods/hearth/sounds');
|
|
await ensureDirectory(sounds);
|
|
for (const name of Object.keys(SOUND_PROFILES).sort()) {
|
|
await writeAsset(sounds, name+'.ogg', await soundOGG(name));
|
|
// Retire only our former generated WAVs when regenerating an existing tree.
|
|
const legacy = await safePath(path.join(sounds, name+'.wav'));
|
|
try {
|
|
if (!(await fs.lstat(legacy)).isFile()) throw new Error('Legacy sound must be a regular file.');
|
|
await fs.unlink(legacy);
|
|
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
}
|
|
}
|
|
|
|
// Pure export for review tooling; generateAssets writes it via the same guarded
|
|
// output path as runtime textures. It never writes into the source worktree.
|
|
export function artContactSheetPNG(art = worldArt()) {
|
|
const image = contactSheet(art);
|
|
return png(image.width, (x,y)=>image.data.subarray((y*image.width+x)*4,(y*image.width+x+1)*4), image.height);
|
|
}
|