/**
* Photograph the surface library.
*
* node scripts/brand-assets/textures.mjs
*
* `/simulate/assets` on lumbridgecorp.com is a page whose whole argument is that
* the art in this repository is code — eight texture kinds drawn on a 2D canvas
* from seeded value noise, thirty-five surface roles that pair one of those with
* a colour and a roughness, and no binary art anywhere, enforced by a CI job.
* It made that argument in about four thousand pixels of prose with nothing on
* it to look at, which is a strange way to talk about the appearance of things.
*
* So this writes the library out: one tile per texture kind, drawn by
* `TextureBin`, multiplied by the colour of the first role that carries it, and
* one neutral tile beside it so a reader can see for themselves that the map has
* no hue of its own. The role table goes with them as data, so the site can set
* it in its own type rather than baking labels into an image.
*
* ### Why this one runs against Vite dev and the others run against `dist/`
*
* `shots.mjs` and `films.mjs` photograph the *application*, and the application
* a visitor gets is the built bundle, so that is what they point a camera at.
* This photographs a *module*. `TextureBin` is not reachable from the built
* bundle — it exposes no names — and making it reachable would mean a third Vite
* entry, which would ship a texture-sheet page to tera.lumbridgecorp.com so that
* a script could take a screenshot of it. Vite dev serves and transforms the
* source on request, so the page imports the same files a reader opens on the
* repository. That is a shorter chain of custody than the bundle, not a longer
* one, and the manifest records the commit either way.
*
* ### It needs no GPU
*
* Nothing here draws with WebGL. The textures are Canvas2D and the encoder is
* the browser's own WebP, so this runs in a couple of seconds on the software
* path and there is nothing to check a renderer string for. Chrome is still the
* right tool: `toDataURL("image/webp")` is the encoder, exactly as in
* `shots.mjs`, because there is no `sharp` on this box.
*
* ### Flags
*
* --site
where lumbridge-v4 is (default ../../lumbridge-v4)
* --manifest-only rewrite the generated TS without drawing anything
*/
import { chromium } from "playwright";
import { createServer } from "vite";
import { mkdir, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { execFileSync } from "node:child_process";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..", "..");
const args = process.argv.slice(2);
function flag(name, fallback = null) {
const i = args.indexOf(name);
return i === -1 ? fallback : args[i + 1];
}
const SITE = flag("--site", join(ROOT, "..", "lumbridge-v4"));
const MANIFEST_ONLY = args.includes("--manifest-only");
const PUBLIC_DIR = join(SITE, "apps", "web", "public", "textures");
const DATA_DIR = join(SITE, "apps", "web", "src", "data");
/**
* A sentence per texture kind, written here rather than on the website.
*
* Same rule the shot captions follow: the description lives next to the thing
* that produces it, so a texture that is rewritten cannot leave a paragraph
* behind on a marketing page describing the grain it used to have. The website
* renders these; it does not author them.
*
* Every claim below is checkable against `DRAW` in `src/assets/textures.ts`.
*/
const NOTES = {
carpetLoop:
"Two octaves of value noise for the pile, then a loop pattern punched over it on a half-offset grid — the same trick a real loop carpet uses to hide its seams.",
woodPlank:
"Planks of a random width, each with its own grain running the length of it, and a hairline gap where two meet.",
polishedConcrete:
"Broad, slow noise for the pour, a fine speckle for the aggregate, and a few pale trowel sweeps that stop it reading as a gradient.",
ceilingTile:
"A mineral-fibre face: dense fine noise, pinholes punched through it, and a chamfer drawn around the edge of the tile.",
plasterPaint:
"Almost nothing, on purpose. Roller texture at the threshold of visibility is what keeps a painted wall from reading as a flat fill under raking light.",
fabricWeave:
"A warp and a weft drawn as alternating lines, which is enough structure to catch a highlight without becoming a pattern anybody notices.",
tileGrid:
"A hard grid of grout lines over lightly varied tiles, so no two squares in a floor are quite the same value.",
whiteboard:
"A near-white gloss with a faint vertical wipe and a few ghosted horizontal strokes — a board that has been used and cleaned, not a board out of a box.",
};
function sha() {
try {
return execFileSync("git", ["rev-parse", "--short", "HEAD"], { cwd: ROOT })
.toString()
.trim();
} catch {
return "unknown";
}
}
function dirty() {
try {
return (
execFileSync("git", ["status", "--porcelain"], { cwd: ROOT }).toString().trim() !== ""
);
} catch {
return true;
}
}
async function draw() {
// `server.middlewareMode` is off: the page is fetched over HTTP like any
// other, so Vite's HTML transform runs and the inline module script is
// rewritten with resolved import URLs.
const server = await createServer({
root: ROOT,
logLevel: "warn",
server: { port: 5313, strictPort: true },
});
await server.listen();
const browser = await chromium.launch({ channel: "chrome" });
try {
const page = await browser.newPage();
const errors = [];
page.on("pageerror", (error) => errors.push(String(error)));
await page.goto("http://localhost:5313/scripts/brand-assets/textures.html", {
waitUntil: "networkidle",
});
// The tiles are drawn during module evaluation, so this is the whole wait.
// `waitForFunction`, not `waitForSelector`: the page paints nothing, so its
// body has no height, and Playwright's visibility rule counts a zero-height
// element as hidden however present it is.
await page.waitForFunction(() => document.body.dataset.ready === "1", null, {
timeout: 30000,
});
if (errors.length) throw new Error(`page errors: ${errors.join("; ")}`);
const library = await page.evaluate(() => window.__library());
if (!library.textures.length) throw new Error("no textures came back");
return library;
} finally {
await browser.close();
await server.close();
}
}
function manifest(library) {
const rows = library.textures
.map(
(t) => ` {
kind: ${JSON.stringify(t.kind)},
src: ${JSON.stringify(`/textures/${t.kind}.webp`)},
neutralSrc: ${JSON.stringify(`/textures/${t.kind}-neutral.webp`)},
size: ${t.size},
leadRole: ${JSON.stringify(t.leadRole)},
leadColor: ${JSON.stringify(t.leadColor)},
carriedBy: ${JSON.stringify(t.carriedBy)},
note: ${JSON.stringify(NOTES[t.kind] ?? "")},
},`,
)
.join("\n");
const surfaces = library.surfaces
.map(
(s) =>
` { role: ${JSON.stringify(s.role)}, color: ${JSON.stringify(s.color)}, texture: ${JSON.stringify(s.texture)}, roughness: ${s.roughness}, metalness: ${s.metalness} },`,
)
.join("\n");
return `/**
* Generated. Do not edit — \`scripts/brand-assets/textures.mjs\` in the tera repo
* rewrites this file wholesale, and the descriptions below live next to the code
* that draws each texture so the two cannot drift apart.
*
* Every tile named here was drawn by tera's own \`TextureBin\` at the commit
* recorded below, and every row in \`SURFACES\` is what \`MaterialRegistry\` hands
* the office for that role. Nothing is an illustration and nothing is retouched.
*
* To change a description: edit \`NOTES\` in that script and run it with
* \`--manifest-only\`, which redraws nothing.
*/
/** A union rather than \`string\`, so a page naming a texture that is gone fails typecheck. */
export type TextureKind =
${library.textures.map((t) => ` | ${JSON.stringify(t.kind)}`).join("\n")};
export interface TextureTile {
kind: TextureKind;
/** The map multiplied by \`leadColor\` — what the office actually renders. */
src: string;
/** The map alone. Near-white by construction: the hue arrives from the palette. */
neutralSrc: string;
/** Pixels per side, which is also metres-per-repeat divided into resolution. */
size: number;
/** The first role in palette order that carries this texture. */
leadRole: string;
leadColor: string;
/** Every role that carries it. */
carriedBy: string[];
note: string;
}
/** One row per surface role, as \`MaterialRegistry\` builds it at \`high\` quality. */
export interface Surface {
role: string;
color: string;
texture: string | null;
roughness: number | null;
metalness: number | null;
}
/** The tera commit these were drawn from. */
export const TEXTURES_COMMIT = ${JSON.stringify(sha())};
/** True if that commit is not the whole story — the tree had uncommitted work. */
export const TEXTURES_DIRTY = ${dirty()};
export const TEXTURES: TextureTile[] = [
${rows}
];
export const SURFACES: Surface[] = [
${surfaces}
];
/** How many metres one repeat covers. From \`TEXTURE_TILE_METRES\` in tera. */
export const TEXTURE_TILE_METRES = 2;
`;
}
const dataUrl = (value) => Buffer.from(value.split(",")[1], "base64");
async function main() {
if (MANIFEST_ONLY) {
// Redrawing is the only way to know the role table, so a manifest-only run
// still needs the page — it just does not write any images.
const library = await draw();
await writeFile(join(DATA_DIR, "textures.generated.ts"), manifest(library));
console.log(`manifest only → ${join(DATA_DIR, "textures.generated.ts")}`);
return;
}
const library = await draw();
await mkdir(PUBLIC_DIR, { recursive: true });
let bytes = 0;
for (const tile of library.textures) {
const tinted = dataUrl(tile.tinted);
const neutral = dataUrl(tile.neutral);
await writeFile(join(PUBLIC_DIR, `${tile.kind}.webp`), tinted);
await writeFile(join(PUBLIC_DIR, `${tile.kind}-neutral.webp`), neutral);
bytes += tinted.length + neutral.length;
console.log(
`${tile.kind.padEnd(18)} ${tile.size}px ${tile.leadColor} ${tile.carriedBy.length} role(s) ${Math.round((tinted.length + neutral.length) / 1024)} kB`,
);
}
await writeFile(join(DATA_DIR, "textures.generated.ts"), manifest(library));
console.log(
`\n${library.textures.length} textures, ${library.surfaces.length} surface roles, ${Math.round(bytes / 1024)} kB total → ${SITE}`,
);
if (dirty()) console.log("warning: tera's tree is dirty; the manifest names a dirty sha");
}
await main();