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