// Generates the placeholder PNGs (terrain, background, thumbnail) of every built-in map. // Usage: npm run gen:maps import { mkdirSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { PNG } from 'pngjs'; const W = 2048; const H = 1024; const mapsDir = resolve(import.meta.dirname, '../assets/maps'); const VOID = 0x000000; const SPONGE = 0xf2c46d; const CHOCOLATE = 0x5a3a22; const CREAM = 0xffffff; const JELLY = 0xd93a5b; const BISCUIT = 0xc8964b; const CARAMEL = 0xd98a1e; const PLATE = 0x8899aa; /** A terrain image being drawn, one 0xRRGGBB color per pixel (colors map to materials in map.json). */ class Canvas { readonly px = new Uint32Array(W * H).fill(VOID); put(x: number, y: number, c: number): void { if (x >= 0 && y >= 0 && x < W && y < H) this.px[y * W + x] = c; } rect(x0: number, y0: number, x1: number, y1: number, c: number): void { for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) this.put(x, y, c); } disc(cx: number, cy: number, r: number, c: number): void { for (let y = Math.floor(cy - r); y <= cy + r; y++) { for (let x = Math.floor(cx - r); x <= cx + r; x++) if ((x - cx) ** 2 + (y - cy) ** 2 <= r * r) this.put(x, y, c); } } /** The dish everything stands on (the anchor). */ plate(): void { this.rect(180, 960, 1868, 990, PLATE); this.rect(150, 940, 200, 975, PLATE); this.rect(1848, 940, 1898, 975, PLATE); } /** Whipped cream along the top of [x0, x1) at `top`, with drips. */ frosting(x0: number, x1: number, top: number): void { for (let x = x0 - 6; x < x1 + 6; x++) { const wave = Math.round(6 + 4 * Math.sin(x / 23)); for (let y = top - wave; y < top + 12; y++) this.put(x, y, CREAM); const drip = (x * 7919) % 97 < 6 ? 18 + ((x * 31) % 22) : 0; if (x >= x0 && x < x1) for (let y = top + 12; y < top + 12 + drip; y++) this.put(x, y, CREAM); } for (let y = top; y < top + 60; y++) { const len = Math.max(0, 6 - Math.floor((y - top) / 10)); for (let i = 0; i < len; i++) { this.put(x0 - 6 + i, y, CREAM); this.put(x1 + 5 - i, y, CREAM); } } } } /** Le Gâteau à Étages: three tiers of sponge with chocolate and jelly layers. */ function gateauEtages(): Canvas { const c = new Canvas(); c.plate(); const tiers = [ { x0: 280, x1: 1768, top: 700, bottom: 960 }, { x0: 520, x1: 1528, top: 470, bottom: 700 }, { x0: 780, x1: 1268, top: 270, bottom: 470 }, ]; for (const t of tiers) { const h = t.bottom - t.top; c.rect(t.x0, t.top, t.x1, t.bottom, SPONGE); // Biscuit base, jelly layer and chocolate layer. c.rect(t.x0, t.bottom - Math.round(h * 0.12), t.x1, t.bottom, BISCUIT); c.rect(t.x0, t.top + Math.round(h * 0.55), t.x1, t.top + Math.round(h * 0.62), JELLY); c.rect(t.x0, t.top + Math.round(h * 0.3), t.x1, t.top + Math.round(h * 0.36), CHOCOLATE); // Chocolate pillar in the middle, a hard core to dig around. const mid = Math.round((t.x0 + t.x1) / 2); c.rect(mid - 20, t.top + 20, mid + 20, t.bottom - Math.round(h * 0.12), CHOCOLATE); c.frosting(t.x0, t.x1, t.top); } return c; } /** * Le Mille-Feuille: thin brittle pastry sheets alternating with thick soft cream, so digging * down is quick while every pastry sheet makes tunnelling sideways a chore. A slice is missing * in the middle: two towers on a common base. */ function milleFeuille(): Canvas { const c = new Canvas(); c.plate(); const x0 = 260; const x1 = 1788; const top = 440; const bottom = 960; const PASTRY = 16; const FILLING = 44; for (let y = top; y < bottom; y++) { const layer = (y - top) % (PASTRY + FILLING); c.rect(x0, y, x1, y + 1, layer < PASTRY ? BISCUIT : CREAM); } // Fondant icing with the classic chocolate feathering. c.rect(x0 - 4, top - 18, x1 + 4, top, CREAM); for (let x = x0; x < x1; x++) { for (const row of [top - 14, top - 8]) if (Math.abs(((x + row * 3) % 60) - 30) < 2) c.rect(x, row, x + 1, row + 3, CHOCOLATE); if (x % 60 < 3) c.rect(x, top - 16, x + 1, top - 2, CHOCOLATE); } // The missing slice: a trench down to the third pastry sheet. c.rect(954, top - 18, 1094, top + 2 * (PASTRY + FILLING) + PASTRY, VOID); return c; } /** * La Pièce Montée: a stepped pyramid of choux on a nougatine base. Each chou is glued with * caramel to the one under it only, so taking one out drops the whole column above it. */ function pieceMontee(): Canvas { const c = new Canvas(); c.plate(); // Nougatine base. c.rect(470, 905, 1578, 960, CARAMEL); const R = 40; const STEP_X = 82; // 2 px gap between neighbours: they never touch sideways const STEP_Y = 82; const base = 13; const cx0 = 1024; for (let row = 0; 2 * row < base; row++) { const count = base - 2 * row; const cy = 905 - R - 2 - row * STEP_Y; for (let i = 0; i < count; i++) { const cx = cx0 + (i - (count - 1) / 2) * STEP_X; c.disc(cx, cy, R, SPONGE); c.disc(cx, cy, R - 14, CREAM); // Caramel glaze on top and the glue joint with what is below. for (let a = -2.4; a <= -0.7; a += 0.02) c.disc(cx + Math.cos(a) * (R - 4), cy + Math.sin(a) * (R - 4), 5, CARAMEL); c.disc(cx, cy + R + 1, 11, CARAMEL); } } return c; } /** * Le Roulé: a slice of Swiss roll lying on the plate, a continuous spiral of jam (jelly) through * the sponge. Cut the sponge strip and everything inside that turn of the spiral comes loose. */ function roule(): Canvas { const c = new Canvas(); c.plate(); const cx = 1024; const R = 330; const cy = 960 - R - 14; // Biscuit stand under the roll so it does not just touch the plate at one point. c.rect(cx - 180, 900, cx + 181, 960, BISCUIT); const SPACING = 64; // distance between two turns of the spiral const JAM = 12; for (let y = cy - R; y <= cy + R; y++) { for (let x = cx - R; x <= cx + R; x++) { const dx = x - cx; const dy = y - cy; const r = Math.hypot(dx, dy); if (r > R) continue; if (r > R - 10) { c.put(x, y, CREAM); // thin whipped cream coat continue; } // Archimedean spiral r = SPACING * (θ / 2π): distance to the nearest turn. const theta = (Math.atan2(dy, dx) + Math.PI * 2) % (Math.PI * 2); const phase = (r - (SPACING * theta) / (Math.PI * 2) + SPACING * 100) % SPACING; c.put(x, y, phase < JAM && r > 24 ? JELLY : SPONGE); } } return c; } /** * La Tarte aux Fruits: a brittle shortcrust shell (biscuit) holding custard cream, topped with * jelly fruit halves. The base cracks at random (modifier). */ function tarteFruits(): Canvas { const c = new Canvas(); c.plate(); const x0 = 300; const x1 = 1748; const bottom = 960; // Shell: base and fluted rims. c.rect(x0, bottom - 70, x1, bottom, BISCUIT); for (const [rx0, rx1] of [ [x0, x0 + 46], [x1 - 46, x1], ]) { for (let y = 640; y < bottom; y++) { const flute = Math.round(4 * Math.sin(y / 9)); c.rect(rx0 + (rx0 === x0 ? flute : 0), y, rx1 - (rx0 !== x0 ? flute : 0), y + 1, BISCUIT); } } // Pastry cream. c.rect(x0 + 46, 740, x1 - 46, bottom - 70, SPONGE); // Fruit halves: rows of jelly domes on the cream. for (let i = 0; i < 18; i++) { const fx = x0 + 100 + i * 75; const r = 30 + (i % 3) * 6; for (let y = 740 - r; y < 742; y++) for (let x = fx - r; x <= fx + r; x++) if ((x - fx) ** 2 + (y - 742) ** 2 <= r * r) c.put(x, y, JELLY); } // Glaze specks of whipped cream on the rims. for (const x of [x0 + 23, x1 - 23]) c.disc(x, 632, 16, CREAM); return c; } /** * La Pile de Crêpes: dozens of thin crêpes with spreads in between (chocolate, jam, caramel), * stacked into a pile that leans. Side wind (modifier). */ function crepes(): Canvas { const c = new Canvas(); c.plate(); const SPREADS = [CHOCOLATE, JELLY, CARAMEL]; const CREPE = 9; const SPREAD = 3; const lean = 0.22; // horizontal shift per pixel of height let layer = 0; for (let top = 960; top > 340; top -= CREPE + SPREAD, layer++) { const shift = Math.round((960 - top) * lean); const halfWidth = 330 - Math.round(layer * 1.5) + Math.round(6 * Math.sin(layer * 1.7)); const left = 860 - halfWidth + shift; const right = 860 + halfWidth + shift; c.rect(left, top - CREPE, right, top, SPONGE); if (top - CREPE - SPREAD > 340) c.rect(left + 12, top - CREPE - SPREAD, right - 12, top - CREPE, SPREADS[layer % 3]); } // Whipped cream on top. c.disc(860 + Math.round(620 * lean), 345, 40, CREAM); return c; } function writePng(dir: string, name: string, w: number, h: number, pixel: (x: number, y: number) => number): void { const png = new PNG({ width: w, height: h }); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const c = pixel(x, y); const i = (y * w + x) * 4; png.data[i] = (c >> 16) & 0xff; png.data[i + 1] = (c >> 8) & 0xff; png.data[i + 2] = c & 0xff; png.data[i + 3] = 0xff; } } writeFileSync(resolve(dir, name), PNG.sync.write(png)); } const lerp = (a: number, b: number, t: number): number => Math.round(a + (b - a) * t); const mix = (c1: number, c2: number, t: number): number => (lerp(c1 >> 16, c2 >> 16, t) << 16) | (lerp((c1 >> 8) & 0xff, (c2 >> 8) & 0xff, t) << 8) | lerp(c1 & 0xff, c2 & 0xff, t); // ---------- Background: a cartoon kitchen, drawn with felt-pen outlines ---------- const INK = 0x3b2414; const TABLE_Y = 900; function hash(x: number, y: number): number { let h = Math.imul(x, 374761393) ^ Math.imul(y, 668265263); h = Math.imul(h ^ (h >>> 13), 1274126177); return ((h ^ (h >>> 16)) >>> 0) / 4294967296; } interface Theme { stripe: number; cloth: [number, number, number]; flags: number[]; } const THEMES: Record = { 'gateau-etages': { stripe: 0xffdbe3, cloth: [0xe85d75, 0xf7a8b8, 0xfff3f5], flags: [0xff6f91, 0xffe14d, 0x4dc4ff, 0x9be15d, 0xb18cff] }, 'mille-feuille': { stripe: 0xd9ecff, cloth: [0x4d8fd9, 0xa8cdf7, 0xf3f8ff], flags: [0x4dc4ff, 0xffffff, 0xffe14d, 0x9be15d, 0x4dc4ff] }, 'piece-montee': { stripe: 0xfff0c2, cloth: [0xd9a01e, 0xf7d98a, 0xfffaf0], flags: [0xffe14d, 0xff9a2e, 0xffffff, 0xff6f91, 0xffd27a] }, roule: { stripe: 0xe3f5d9, cloth: [0x5fae4a, 0xb8e0a8, 0xf6fff2], flags: [0x9be15d, 0xff6f91, 0xffffff, 0xffe14d, 0x9be15d] }, 'tarte-fruits': { stripe: 0xefe3ff, cloth: [0x8a5fc9, 0xcdb8f0, 0xfaf6ff], flags: [0xb18cff, 0xff6f91, 0x9be15d, 0xffffff, 0xffe14d] }, crepes: { stripe: 0xffe8d0, cloth: [0xe0862e, 0xf5c79a, 0xfff8f0], flags: [0xff9a2e, 0x5a3a22, 0xffe14d, 0xffffff, 0xff6f91] }, }; /** The background is drawn at BG_SCALE pixels per map pixel, each pixel averaging BG_SUB² samples (antialiasing). */ const BG_SCALE = 2; const BG_SUB = 3; const BW = W * BG_SCALE; const BH = H * BG_SCALE; function paintBackground(theme: Theme): Uint32Array { const img = new Uint32Array(BW * BH); type Inside = (x: number, y: number) => boolean; /** * Fills a shape (inside test over a bounding box, in map pixels) with a color function and a * felt outline, antialiased over what is already painted. */ const shape = (x0: number, y0: number, x1: number, y1: number, inside: Inside, fill: (x: number, y: number) => number, outline = 4): void => { const n = BG_SUB * BG_SUB; for (let py = Math.max(0, Math.floor(y0 * BG_SCALE) - BG_SCALE); py < Math.min(BH, Math.ceil(y1 * BG_SCALE) + BG_SCALE); py++) { for (let px = Math.max(0, Math.floor(x0 * BG_SCALE) - BG_SCALE); px < Math.min(BW, Math.ceil(x1 * BG_SCALE) + BG_SCALE); px++) { let r = 0; let g = 0; let b = 0; let hits = 0; for (let sy = 0; sy < BG_SUB; sy++) { for (let sx = 0; sx < BG_SUB; sx++) { const x = (px + (sx + 0.5) / BG_SUB) / BG_SCALE; const y = (py + (sy + 0.5) / BG_SUB) / BG_SCALE; if (!inside(x, y)) continue; const t = outline - (hash(Math.floor(x) >> 2, Math.floor(y) >> 2) < 0.3 ? 1 : 0); const edge = outline > 0 && (!inside(x - t, y) || !inside(x + t, y) || !inside(x, y - t) || !inside(x, y + t)); const c = edge ? INK : fill(x, y); r += c >> 16; g += (c >> 8) & 0xff; b += c & 0xff; hits++; } } if (hits === 0) continue; const i = py * BW + px; const cover = hits / n; const avg = (Math.round(r / hits) << 16) | (Math.round(g / hits) << 8) | Math.round(b / hits); img[i] = cover === 1 ? avg : mix(img[i], avg, cover); } } }; const rect = (x0: number, y0: number, x1: number, y1: number): Inside => (x, y) => x >= x0 && x < x1 && y >= y0 && y < y1; const ellipse = (cx: number, cy: number, rx: number, ry: number): Inside => (x, y) => ((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2 <= 1; const solid = (c: number) => (): number => c; // Wallpaper: cream with pink stripes and tiny dots, warmer toward the table. shape(0, 0, W, TABLE_Y, () => true, (x, y) => { const stripe = x % 96 < 30; const dot = !stripe && ((x + (Math.floor(y / 40) % 2) * 24) % 48) ** 2 / 9 + ((y % 40) - 20) ** 2 / 9 < 4; const base = stripe ? theme.stripe : dot ? 0xf7c9a8 : 0xfff3dc; return mix(base, 0xf6d7b0, Math.max(0, (y - 300) / 900)); }, 0); // Skirting board. shape(0, TABLE_Y - 40, W, TABLE_Y, rect(-10, TABLE_Y - 40, W + 10, TABLE_Y + 10), (x, y) => (y < TABLE_Y - 34 ? 0xffffff : 0xe8c9a0)); // Window with sky, clouds and a sun. const wx0 = 1480, wy0 = 90, wx1 = 1900, wy1 = 470; shape(wx0 - 24, wy0 - 24, wx1 + 24, wy1 + 34, rect(wx0 - 24, wy0 - 24, wx1 + 24, wy1 + 34), solid(0xffffff)); shape(wx0, wy0, wx1, wy1, rect(wx0, wy0, wx1, wy1), (x, y) => { const t = (y - wy0) / (wy1 - wy0); if ((x - 1820) ** 2 + (y - 160) ** 2 < 42 ** 2) return 0xffe14d; const cloud = [[1560, 200, 50], [1610, 185, 60], [1665, 205, 45], [1700, 330, 40], [1745, 318, 52], [1790, 335, 38]]; for (const [cx, cy, r] of cloud) if ((x - cx) ** 2 + ((y - cy) * 1.5) ** 2 < r * r) return 0xffffff; return mix(0x8fd3ff, 0xd9f1ff, t); }, 4); shape(wx0, (wy0 + wy1) / 2 - 7, wx1, (wy0 + wy1) / 2 + 7, rect(wx0 - 5, (wy0 + wy1) / 2 - 7, wx1 + 5, (wy0 + wy1) / 2 + 7), solid(0xffffff), 3); shape((wx0 + wx1) / 2 - 7, wy0, (wx0 + wx1) / 2 + 7, wy1, rect((wx0 + wx1) / 2 - 7, wy0 - 5, (wx0 + wx1) / 2 + 7, wy1 + 5), solid(0xffffff), 3); // Curtains (pink gingham), gently waved. for (const side of [-1, 1]) { const cx = side < 0 ? wx0 - 30 : wx1 + 30; const inside: Inside = (x, y) => { if (y < wy0 - 50 || y > wy1 + 90) return false; const width = 70 + Math.sin(y / 30) * 8 + (y - wy0) * 0.06; return side < 0 ? x > cx - 40 && x < cx - 40 + width : x < cx + 40 && x > cx + 40 - width; }; shape(cx - 120, wy0 - 60, cx + 120, wy1 + 100, inside, (x, y) => ((Math.floor(x / 14) + Math.floor(y / 14)) % 2 ? 0xff8fab : 0xffe3ea)); } shape(wx0 - 90, wy0 - 66, wx1 + 90, wy0 - 46, rect(wx0 - 90, wy0 - 66, wx1 + 90, wy0 - 46), solid(0xc8964b), 3); // Shelf with jars and cups. const sy = 330; shape(70, sy, 600, sy + 22, rect(70, sy, 600, sy + 22), (x) => (x % 60 < 3 ? 0x9c6b3a : 0xc8964b)); for (const [x, y] of [[110, sy + 22], [560, sy + 22]]) shape(x - 12, y, x + 12, y + 40, (px, py) => px >= x - 12 && px < x + 12 && py >= y && py < y + 40 - (px - x + 12), solid(0x9c6b3a), 3); const jar = (cx: number, w: number, h: number, color: number, lid: number): void => { shape(cx - w / 2, sy - h, cx + w / 2, sy, rect(cx - w / 2, sy - h, cx + w / 2, sy + 2), (x, y) => (x - cx + w / 2 < 8 ? mix(color, 0xffffff, 0.4) : y > sy - h * 0.6 ? color : mix(color, 0xffffff, 0.6))); shape(cx - w / 2 - 4, sy - h - 16, cx + w / 2 + 4, sy - h + 2, rect(cx - w / 2 - 4, sy - h - 16, cx + w / 2 + 4, sy - h + 2), solid(lid), 3); }; jar(160, 70, 110, 0xd93a5b, 0xff6f91); jar(250, 60, 80, 0xf2c46d, 0x4dc4ff); jar(335, 80, 130, 0x5a3a22, 0x9be15d); // Teacup. shape(420, sy - 60, 520, sy, ellipse(470, sy - 60, 50, 60), (x, y) => (y < sy - 60 ? 0xfff6e4 : (x + y) % 40 < 6 ? 0xff8fab : 0xffffff)); shape(505, sy - 55, 545, sy - 15, (x, y) => { const d = Math.hypot(x - 515, y - 35 - sy + 70); return d < 22 && d > 12 && x > 515; }, solid(0xffffff), 3); shape(400, sy - 10, 540, sy, ellipse(470, sy - 4, 70, 9), solid(0xffffff), 3); // Wall clock. shape(250, 470, 410, 630, ellipse(330, 550, 78, 78), (x, y) => { const d = Math.hypot(x - 330, y - 550); return d > 62 ? 0xff6f91 : 0xfff6e4; }); shape(326, 495, 334, 552, rect(326, 495, 334, 552), solid(INK), 0); shape(330, 546, 375, 554, rect(330, 546, 375, 554), solid(INK), 0); for (let i = 0; i < 12; i++) { const a = (i / 12) * Math.PI * 2; const tx = 330 + Math.cos(a) * 52; const ty = 550 + Math.sin(a) * 52; shape(tx - 4, ty - 4, tx + 4, ty + 4, ellipse(tx, ty, 4, 4), solid(INK), 0); } // Bunting garland across the top. const sag = (x: number): number => 30 + 70 * Math.sin((x / W) * Math.PI); shape(0, 0, W, 130, (x, y) => Math.abs(y - sag(x)) < 3, solid(INK), 0); const flagColors = theme.flags; for (let i = 0, fx = 20; fx < W; i++, fx += 110) { const top = sag(fx + 35); const inside: Inside = (x, y) => { const t = (y - top) / 70; return t >= 0 && t <= 1 && Math.abs(x - (fx + 35)) <= 35 * (1 - t); }; shape(fx - 2, top - 2, fx + 72, top + 74, inside, (x, y) => (hash(x >> 3, y >> 3) < 0.15 ? 0xffffff : flagColors[i % flagColors.length])); } // Table: lace edge then a gingham tablecloth with a hint of perspective. shape(0, TABLE_Y, W, H, rect(-10, TABLE_Y, W + 10, H + 10), (x, y) => { const depth = (y - TABLE_Y) / (H - TABLE_Y); const cell = 40 + depth * 30; const cx = Math.floor((x - W / 2) / cell); const cy = Math.floor((y - TABLE_Y) / (cell * 0.6)); const a = (cx & 1) === 0; const b = (cy & 1) === 0; const base = a && b ? theme.cloth[0] : a || b ? theme.cloth[1] : theme.cloth[2]; return mix(base, 0x7a3a2a, depth * 0.25); }); shape(0, TABLE_Y - 6, W, TABLE_Y + 30, (x, y) => y >= TABLE_Y - 6 && y < TABLE_Y + 14 + 12 * Math.abs(Math.sin((x / 40) * Math.PI)), (x, y) => (((x % 40) - 20) ** 2 + (y - TABLE_Y - 8) ** 2 < 25 ? 0xf3dfe3 : 0xffffff), 3); return img; } const MAPS: Record Canvas> = { 'gateau-etages': gateauEtages, 'mille-feuille': milleFeuille, 'piece-montee': pieceMontee, roule, 'tarte-fruits': tarteFruits, crepes, }; const TW = 256; const TH = 128; for (const [id, build] of Object.entries(MAPS)) { const dir = resolve(mapsDir, id); mkdirSync(dir, { recursive: true }); const terrain = build().px; writePng(dir, 'terrain.png', W, H, (x, y) => terrain[y * W + x]); const background = paintBackground(THEMES[id]); writePng(dir, 'background.png', BW, BH, (x, y) => background[y * BW + x]); writePng(dir, 'thumb.png', TW, TH, (x, y) => { const c = terrain[Math.floor((y * H) / TH) * W + Math.floor((x * W) / TW)]; return c === VOID ? 0xbfe6ff : c; }); console.log(`Wrote ${id}: terrain.png, background.png, thumb.png`); }