|
|
@@ -1,12 +1,12 @@
|
|
|
-// Generates the placeholder PNGs of the reference map "gateau-etages".
|
|
|
-// Usage: npm run gen:test-map
|
|
|
+// 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 outDir = resolve(import.meta.dirname, '../assets/maps/gateau-etages');
|
|
|
+const mapsDir = resolve(import.meta.dirname, '../assets/maps');
|
|
|
|
|
|
const VOID = 0x000000;
|
|
|
const SPONGE = 0xf2c46d;
|
|
|
@@ -14,60 +14,135 @@ const CHOCOLATE = 0x5a3a22;
|
|
|
const CREAM = 0xffffff;
|
|
|
const JELLY = 0xd93a5b;
|
|
|
const BISCUIT = 0xc8964b;
|
|
|
+const CARAMEL = 0xd98a1e;
|
|
|
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);
|
|
|
-};
|
|
|
+/** 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;
|
|
|
+ }
|
|
|
|
|
|
-// 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);
|
|
|
+ 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);
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
-interface Tier {
|
|
|
- x0: number;
|
|
|
- x1: number;
|
|
|
- top: number;
|
|
|
- bottom: number;
|
|
|
+ /** 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);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
-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);
|
|
|
+
|
|
|
+/** 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);
|
|
|
}
|
|
|
- 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);
|
|
|
+ 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;
|
|
|
}
|
|
|
|
|
|
-function writePng(name: string, w: number, h: number, pixel: (x: number, y: number) => number): void {
|
|
|
+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++) {
|
|
|
@@ -79,7 +154,7 @@ function writePng(name: string, w: number, h: number, pixel: (x: number, y: numb
|
|
|
png.data[i + 3] = 0xff;
|
|
|
}
|
|
|
}
|
|
|
- writeFileSync(resolve(outDir, name), PNG.sync.write(png));
|
|
|
+ writeFileSync(resolve(dir, name), PNG.sync.write(png));
|
|
|
}
|
|
|
|
|
|
const lerp = (a: number, b: number, t: number): number => Math.round(a + (b - a) * t);
|
|
|
@@ -97,7 +172,19 @@ function hash(x: number, y: number): number {
|
|
|
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
|
|
|
}
|
|
|
|
|
|
-function paintBackground(): Uint32Array {
|
|
|
+interface Theme {
|
|
|
+ stripe: number;
|
|
|
+ cloth: [number, number, number];
|
|
|
+ flags: number[];
|
|
|
+}
|
|
|
+
|
|
|
+const THEMES: Record<string, Theme> = {
|
|
|
+ '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] },
|
|
|
+};
|
|
|
+
|
|
|
+function paintBackground(theme: Theme): 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. */
|
|
|
@@ -119,7 +206,7 @@ function paintBackground(): Uint32Array {
|
|
|
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;
|
|
|
+ const base = stripe ? theme.stripe : dot ? 0xf7c9a8 : 0xfff3dc;
|
|
|
return mix(base, 0xf6d7b0, Math.max(0, (y - 300) / 900));
|
|
|
}, 0);
|
|
|
// Skirting board.
|
|
|
@@ -182,7 +269,7 @@ function paintBackground(): Uint32Array {
|
|
|
// 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];
|
|
|
+ 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) => {
|
|
|
@@ -200,21 +287,31 @@ function paintBackground(): Uint32Array {
|
|
|
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;
|
|
|
+ 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;
|
|
|
}
|
|
|
|
|
|
-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 MAPS: Record<string, () => Canvas> = {
|
|
|
+ 'gateau-etages': gateauEtages,
|
|
|
+ 'mille-feuille': milleFeuille,
|
|
|
+ 'piece-montee': pieceMontee,
|
|
|
+};
|
|
|
+
|
|
|
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}`);
|
|
|
+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', W, H, (x, y) => background[y * W + 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`);
|
|
|
+}
|