Lots of content

This commit is contained in:
2026-11-03 12:41:09 -05:00
parent 6ebe95b208
commit d34388bf2c
126 changed files with 28076 additions and 1027 deletions
+135 -32
View File
@@ -2,6 +2,11 @@
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;
@@ -17,52 +22,130 @@ function chunk(type, data) {
size.writeUInt32BE(data.length); crc.writeUInt32BE(crc32(body));
return Buffer.concat([size, body, crc]);
}
function png(size, pixel) {
const rows = Buffer.alloc(size * (size * 4 + 1));
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
const color = pixel(x, y), offset = y * (size * 4 + 1) + 1 + x * 4;
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(size); header.writeUInt32BE(size, 4); header[8] = 8; header[9] = 6;
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))]);
}
const icons = {
knife: (x,y) => (Math.abs(x + y - 28) < 3 && x > 9 && x < 24) ? [180,185,173,255]
: (Math.abs(x + y - 28) < 2 && x >= 23) ? [119,86,57,255] : null,
axe: (x,y) => Math.abs(x-y) < 2 && x > 7 && x < 27 ? [139,103,65,255]
: x > 5 && x < 20 && y > 4 && y < 14 && x+y < 29 ? [157,164,151,255] : null,
pick: (x,y) => Math.abs(x-y) < 2 && x > 10 && x < 28 ? [139,103,65,255]
: y > 5 && y < 10 && x > 4 && x < 25 ? [157,164,151,255] : null,
spear: (x,y) => Math.abs(x+y-31) < 2 && x > 5 && x < 27 ? [157,124,80,255]
: x > 22 && y < 10 && x-y > 18 ? [185,188,177,255] : null,
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 fs.mkdir(textures, {recursive:true});
const files = ['content.lua','encounters.lua','runtime.lua'];
const colors = new Set(['c7aa83','947853']);
for (const file of files) {
const text = await fs.readFile(path.join(source, 'mods/hearth', file), 'utf8');
for (const match of text.matchAll(/#[0-9a-fA-F]{6}\b/g)) colors.add(match[0].slice(1).toLowerCase());
}
await ensureDirectory(textures);
for (const color of colors) {
const rgb = [0,2,4].map(i=>parseInt(color.slice(i,i+2),16));
const image = png(32, (x,y)=> {
const noise = ((x*73+y*151+x*y*7) % 19)-9;
const grain = (y % 8 === 0) ? -12 : (x % 13 === 0 ? 5 : 0);
return [...rgb.map(c=>Math.max(0,Math.min(255,c+noise+grain))),255];
});
await fs.writeFile(path.join(textures, `hearth_${color}.png`), image);
const image = png(32, (x,y)=>legacyPixel(color,x,y));
await writeAsset(textures, `hearth_${color}.png`, image);
}
for (const [name, shape] of Object.entries(icons)) {
await fs.writeFile(path.join(textures, `hearth_icon_${name}.png`), png(32,(x,y)=>shape(x,y)||[0,0,0,0]));
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 fs.mkdir(models, {recursive:true});
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) {
@@ -75,5 +158,25 @@ export async function generateAssets(destination, source) {
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 fs.writeFile(path.join(models,'hearth_person.obj'), obj.join('\n')+'\n');
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);
}