// Generates the placeholder PNGs of the reference map "gateau-etages". // Usage: npm run gen:test-map import { mkdirSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { PNG } from 'pngjs'; const W = 2048; const H = 1024; const outDir = resolve(import.meta.dirname, '../assets/maps/gateau-etages'); const VOID = 0x000000; const SPONGE = 0xf2c46d; const CHOCOLATE = 0x5a3a22; const CREAM = 0xffffff; const JELLY = 0xd93a5b; const BISCUIT = 0xc8964b; const PLATE = 0x8899aa; const terrain = new Uint32Array(W * H).fill(VOID); const put = (x: number, y: number, c: number): void => { if (x >= 0 && y >= 0 && x < W && y < H) terrain[y * W + x] = c; }; const 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++) put(x, y, c); }; // Plate: a wide dish with a raised rim. rect(180, 960, 1868, 990, PLATE); rect(150, 940, 200, 975, PLATE); rect(1848, 940, 1898, 975, PLATE); interface Tier { x0: number; x1: number; top: number; bottom: number; } const tiers: Tier[] = [ { 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; rect(t.x0, t.top, t.x1, t.bottom, SPONGE); // Biscuit base, jelly layer and chocolate layer. rect(t.x0, t.bottom - Math.round(h * 0.12), t.x1, t.bottom, BISCUIT); rect(t.x0, t.top + Math.round(h * 0.55), t.x1, t.top + Math.round(h * 0.62), JELLY); 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); rect(mid - 20, t.top + 20, mid + 20, t.bottom - Math.round(h * 0.12), CHOCOLATE); // Whipped cream frosting with drips. for (let x = t.x0 - 6; x < t.x1 + 6; x++) { const wave = Math.round(6 + 4 * Math.sin(x / 23)); for (let y = t.top - wave; y < t.top + 12; y++) put(x, y, CREAM); const drip = (x * 7919) % 97 < 6 ? 18 + ((x * 31) % 22) : 0; if (x >= t.x0 && x < t.x1) for (let y = t.top + 12; y < t.top + 12 + drip; y++) put(x, y, CREAM); } for (let y = t.top; y < t.top + 60; y++) { const len = Math.max(0, 6 - Math.floor((y - t.top) / 10)); for (let i = 0; i < len; i++) { put(t.x0 - 6 + i, y, CREAM); put(t.x1 + 5 - i, y, CREAM); } } } function writePng(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(outDir, 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; } function paintBackground(): Uint32Array { const img = new Uint32Array(W * H); type Inside = (x: number, y: number) => boolean; /** Fills a shape (inside test over a bounding box) with a color function and a felt outline. */ const shape = (x0: number, y0: number, x1: number, y1: number, inside: Inside, fill: (x: number, y: number) => number, outline = 4): void => { for (let y = Math.max(0, Math.floor(y0) - 1); y < Math.min(H, Math.ceil(y1) + 1); y++) { for (let x = Math.max(0, Math.floor(x0) - 1); x < Math.min(W, Math.ceil(x1) + 1); x++) { if (!inside(x, y)) continue; const t = outline - (hash(x >> 2, 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)); img[y * W + x] = edge ? INK : fill(x, y); } } }; 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 ? 0xffdbe3 : 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 = [0xff6f91, 0xffe14d, 0x4dc4ff, 0x9be15d, 0xb18cff]; 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 ? 0xe85d75 : a || b ? 0xf7a8b8 : 0xfff3f5; 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; } mkdirSync(outDir, { recursive: true }); writePng('terrain.png', W, H, (x, y) => terrain[y * W + x]); const background = paintBackground(); writePng('background.png', W, H, (x, y) => background[y * W + x]); const TW = 256; const TH = 128; writePng('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 terrain.png, background.png and thumb.png to ${outDir}`);