gen-maps.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. // Generates the placeholder PNGs (terrain, background, thumbnail) of every built-in map.
  2. // Usage: npm run gen:maps
  3. import { mkdirSync, writeFileSync } from 'node:fs';
  4. import { resolve } from 'node:path';
  5. import { PNG } from 'pngjs';
  6. const W = 2048;
  7. const H = 1024;
  8. const mapsDir = resolve(import.meta.dirname, '../assets/maps');
  9. const VOID = 0x000000;
  10. const SPONGE = 0xf2c46d;
  11. const CHOCOLATE = 0x5a3a22;
  12. const CREAM = 0xffffff;
  13. const JELLY = 0xd93a5b;
  14. const BISCUIT = 0xc8964b;
  15. const CARAMEL = 0xd98a1e;
  16. const PLATE = 0x8899aa;
  17. /** A terrain image being drawn, one 0xRRGGBB color per pixel (colors map to materials in map.json). */
  18. class Canvas {
  19. readonly px = new Uint32Array(W * H).fill(VOID);
  20. put(x: number, y: number, c: number): void {
  21. if (x >= 0 && y >= 0 && x < W && y < H) this.px[y * W + x] = c;
  22. }
  23. rect(x0: number, y0: number, x1: number, y1: number, c: number): void {
  24. for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) this.put(x, y, c);
  25. }
  26. disc(cx: number, cy: number, r: number, c: number): void {
  27. for (let y = Math.floor(cy - r); y <= cy + r; y++) {
  28. 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);
  29. }
  30. }
  31. /** The dish everything stands on (the anchor). */
  32. plate(): void {
  33. this.rect(180, 960, 1868, 990, PLATE);
  34. this.rect(150, 940, 200, 975, PLATE);
  35. this.rect(1848, 940, 1898, 975, PLATE);
  36. }
  37. /** Whipped cream along the top of [x0, x1) at `top`, with drips. */
  38. frosting(x0: number, x1: number, top: number): void {
  39. for (let x = x0 - 6; x < x1 + 6; x++) {
  40. const wave = Math.round(6 + 4 * Math.sin(x / 23));
  41. for (let y = top - wave; y < top + 12; y++) this.put(x, y, CREAM);
  42. const drip = (x * 7919) % 97 < 6 ? 18 + ((x * 31) % 22) : 0;
  43. if (x >= x0 && x < x1) for (let y = top + 12; y < top + 12 + drip; y++) this.put(x, y, CREAM);
  44. }
  45. for (let y = top; y < top + 60; y++) {
  46. const len = Math.max(0, 6 - Math.floor((y - top) / 10));
  47. for (let i = 0; i < len; i++) {
  48. this.put(x0 - 6 + i, y, CREAM);
  49. this.put(x1 + 5 - i, y, CREAM);
  50. }
  51. }
  52. }
  53. }
  54. /** Le Gâteau à Étages: three tiers of sponge with chocolate and jelly layers. */
  55. function gateauEtages(): Canvas {
  56. const c = new Canvas();
  57. c.plate();
  58. const tiers = [
  59. { x0: 280, x1: 1768, top: 700, bottom: 960 },
  60. { x0: 520, x1: 1528, top: 470, bottom: 700 },
  61. { x0: 780, x1: 1268, top: 270, bottom: 470 },
  62. ];
  63. for (const t of tiers) {
  64. const h = t.bottom - t.top;
  65. c.rect(t.x0, t.top, t.x1, t.bottom, SPONGE);
  66. // Biscuit base, jelly layer and chocolate layer.
  67. c.rect(t.x0, t.bottom - Math.round(h * 0.12), t.x1, t.bottom, BISCUIT);
  68. c.rect(t.x0, t.top + Math.round(h * 0.55), t.x1, t.top + Math.round(h * 0.62), JELLY);
  69. c.rect(t.x0, t.top + Math.round(h * 0.3), t.x1, t.top + Math.round(h * 0.36), CHOCOLATE);
  70. // Chocolate pillar in the middle, a hard core to dig around.
  71. const mid = Math.round((t.x0 + t.x1) / 2);
  72. c.rect(mid - 20, t.top + 20, mid + 20, t.bottom - Math.round(h * 0.12), CHOCOLATE);
  73. c.frosting(t.x0, t.x1, t.top);
  74. }
  75. return c;
  76. }
  77. /**
  78. * Le Mille-Feuille: thin brittle pastry sheets alternating with thick soft cream, so digging
  79. * down is quick while every pastry sheet makes tunnelling sideways a chore. A slice is missing
  80. * in the middle: two towers on a common base.
  81. */
  82. function milleFeuille(): Canvas {
  83. const c = new Canvas();
  84. c.plate();
  85. const x0 = 260;
  86. const x1 = 1788;
  87. const top = 440;
  88. const bottom = 960;
  89. const PASTRY = 16;
  90. const FILLING = 44;
  91. for (let y = top; y < bottom; y++) {
  92. const layer = (y - top) % (PASTRY + FILLING);
  93. c.rect(x0, y, x1, y + 1, layer < PASTRY ? BISCUIT : CREAM);
  94. }
  95. // Fondant icing with the classic chocolate feathering.
  96. c.rect(x0 - 4, top - 18, x1 + 4, top, CREAM);
  97. for (let x = x0; x < x1; x++) {
  98. 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);
  99. if (x % 60 < 3) c.rect(x, top - 16, x + 1, top - 2, CHOCOLATE);
  100. }
  101. // The missing slice: a trench down to the third pastry sheet.
  102. c.rect(954, top - 18, 1094, top + 2 * (PASTRY + FILLING) + PASTRY, VOID);
  103. return c;
  104. }
  105. /**
  106. * La Pièce Montée: a stepped pyramid of choux on a nougatine base. Each chou is glued with
  107. * caramel to the one under it only, so taking one out drops the whole column above it.
  108. */
  109. function pieceMontee(): Canvas {
  110. const c = new Canvas();
  111. c.plate();
  112. // Nougatine base.
  113. c.rect(470, 905, 1578, 960, CARAMEL);
  114. const R = 40;
  115. const STEP_X = 82; // 2 px gap between neighbours: they never touch sideways
  116. const STEP_Y = 82;
  117. const base = 13;
  118. const cx0 = 1024;
  119. for (let row = 0; 2 * row < base; row++) {
  120. const count = base - 2 * row;
  121. const cy = 905 - R - 2 - row * STEP_Y;
  122. for (let i = 0; i < count; i++) {
  123. const cx = cx0 + (i - (count - 1) / 2) * STEP_X;
  124. c.disc(cx, cy, R, SPONGE);
  125. c.disc(cx, cy, R - 14, CREAM);
  126. // Caramel glaze on top and the glue joint with what is below.
  127. 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);
  128. c.disc(cx, cy + R + 1, 11, CARAMEL);
  129. }
  130. }
  131. return c;
  132. }
  133. function writePng(dir: string, name: string, w: number, h: number, pixel: (x: number, y: number) => number): void {
  134. const png = new PNG({ width: w, height: h });
  135. for (let y = 0; y < h; y++) {
  136. for (let x = 0; x < w; x++) {
  137. const c = pixel(x, y);
  138. const i = (y * w + x) * 4;
  139. png.data[i] = (c >> 16) & 0xff;
  140. png.data[i + 1] = (c >> 8) & 0xff;
  141. png.data[i + 2] = c & 0xff;
  142. png.data[i + 3] = 0xff;
  143. }
  144. }
  145. writeFileSync(resolve(dir, name), PNG.sync.write(png));
  146. }
  147. const lerp = (a: number, b: number, t: number): number => Math.round(a + (b - a) * t);
  148. const mix = (c1: number, c2: number, t: number): number =>
  149. (lerp(c1 >> 16, c2 >> 16, t) << 16) | (lerp((c1 >> 8) & 0xff, (c2 >> 8) & 0xff, t) << 8) | lerp(c1 & 0xff, c2 & 0xff, t);
  150. // ---------- Background: a cartoon kitchen, drawn with felt-pen outlines ----------
  151. const INK = 0x3b2414;
  152. const TABLE_Y = 900;
  153. function hash(x: number, y: number): number {
  154. let h = Math.imul(x, 374761393) ^ Math.imul(y, 668265263);
  155. h = Math.imul(h ^ (h >>> 13), 1274126177);
  156. return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
  157. }
  158. interface Theme {
  159. stripe: number;
  160. cloth: [number, number, number];
  161. flags: number[];
  162. }
  163. const THEMES: Record<string, Theme> = {
  164. 'gateau-etages': { stripe: 0xffdbe3, cloth: [0xe85d75, 0xf7a8b8, 0xfff3f5], flags: [0xff6f91, 0xffe14d, 0x4dc4ff, 0x9be15d, 0xb18cff] },
  165. 'mille-feuille': { stripe: 0xd9ecff, cloth: [0x4d8fd9, 0xa8cdf7, 0xf3f8ff], flags: [0x4dc4ff, 0xffffff, 0xffe14d, 0x9be15d, 0x4dc4ff] },
  166. 'piece-montee': { stripe: 0xfff0c2, cloth: [0xd9a01e, 0xf7d98a, 0xfffaf0], flags: [0xffe14d, 0xff9a2e, 0xffffff, 0xff6f91, 0xffd27a] },
  167. };
  168. function paintBackground(theme: Theme): Uint32Array {
  169. const img = new Uint32Array(W * H);
  170. type Inside = (x: number, y: number) => boolean;
  171. /** Fills a shape (inside test over a bounding box) with a color function and a felt outline. */
  172. const shape = (x0: number, y0: number, x1: number, y1: number, inside: Inside, fill: (x: number, y: number) => number, outline = 4): void => {
  173. for (let y = Math.max(0, Math.floor(y0) - 1); y < Math.min(H, Math.ceil(y1) + 1); y++) {
  174. for (let x = Math.max(0, Math.floor(x0) - 1); x < Math.min(W, Math.ceil(x1) + 1); x++) {
  175. if (!inside(x, y)) continue;
  176. const t = outline - (hash(x >> 2, y >> 2) < 0.3 ? 1 : 0);
  177. const edge = outline > 0 && (!inside(x - t, y) || !inside(x + t, y) || !inside(x, y - t) || !inside(x, y + t));
  178. img[y * W + x] = edge ? INK : fill(x, y);
  179. }
  180. }
  181. };
  182. const rect = (x0: number, y0: number, x1: number, y1: number): Inside => (x, y) => x >= x0 && x < x1 && y >= y0 && y < y1;
  183. const ellipse = (cx: number, cy: number, rx: number, ry: number): Inside => (x, y) => ((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2 <= 1;
  184. const solid = (c: number) => (): number => c;
  185. // Wallpaper: cream with pink stripes and tiny dots, warmer toward the table.
  186. shape(0, 0, W, TABLE_Y, () => true, (x, y) => {
  187. const stripe = x % 96 < 30;
  188. const dot = !stripe && ((x + (Math.floor(y / 40) % 2) * 24) % 48) ** 2 / 9 + ((y % 40) - 20) ** 2 / 9 < 4;
  189. const base = stripe ? theme.stripe : dot ? 0xf7c9a8 : 0xfff3dc;
  190. return mix(base, 0xf6d7b0, Math.max(0, (y - 300) / 900));
  191. }, 0);
  192. // Skirting board.
  193. 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));
  194. // Window with sky, clouds and a sun.
  195. const wx0 = 1480, wy0 = 90, wx1 = 1900, wy1 = 470;
  196. shape(wx0 - 24, wy0 - 24, wx1 + 24, wy1 + 34, rect(wx0 - 24, wy0 - 24, wx1 + 24, wy1 + 34), solid(0xffffff));
  197. shape(wx0, wy0, wx1, wy1, rect(wx0, wy0, wx1, wy1), (x, y) => {
  198. const t = (y - wy0) / (wy1 - wy0);
  199. if ((x - 1820) ** 2 + (y - 160) ** 2 < 42 ** 2) return 0xffe14d;
  200. const cloud = [[1560, 200, 50], [1610, 185, 60], [1665, 205, 45], [1700, 330, 40], [1745, 318, 52], [1790, 335, 38]];
  201. for (const [cx, cy, r] of cloud) if ((x - cx) ** 2 + ((y - cy) * 1.5) ** 2 < r * r) return 0xffffff;
  202. return mix(0x8fd3ff, 0xd9f1ff, t);
  203. }, 4);
  204. 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);
  205. 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);
  206. // Curtains (pink gingham), gently waved.
  207. for (const side of [-1, 1]) {
  208. const cx = side < 0 ? wx0 - 30 : wx1 + 30;
  209. const inside: Inside = (x, y) => {
  210. if (y < wy0 - 50 || y > wy1 + 90) return false;
  211. const width = 70 + Math.sin(y / 30) * 8 + (y - wy0) * 0.06;
  212. return side < 0 ? x > cx - 40 && x < cx - 40 + width : x < cx + 40 && x > cx + 40 - width;
  213. };
  214. shape(cx - 120, wy0 - 60, cx + 120, wy1 + 100, inside, (x, y) => ((Math.floor(x / 14) + Math.floor(y / 14)) % 2 ? 0xff8fab : 0xffe3ea));
  215. }
  216. shape(wx0 - 90, wy0 - 66, wx1 + 90, wy0 - 46, rect(wx0 - 90, wy0 - 66, wx1 + 90, wy0 - 46), solid(0xc8964b), 3);
  217. // Shelf with jars and cups.
  218. const sy = 330;
  219. shape(70, sy, 600, sy + 22, rect(70, sy, 600, sy + 22), (x) => (x % 60 < 3 ? 0x9c6b3a : 0xc8964b));
  220. 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);
  221. const jar = (cx: number, w: number, h: number, color: number, lid: number): void => {
  222. 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)));
  223. 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);
  224. };
  225. jar(160, 70, 110, 0xd93a5b, 0xff6f91);
  226. jar(250, 60, 80, 0xf2c46d, 0x4dc4ff);
  227. jar(335, 80, 130, 0x5a3a22, 0x9be15d);
  228. // Teacup.
  229. shape(420, sy - 60, 520, sy, ellipse(470, sy - 60, 50, 60), (x, y) => (y < sy - 60 ? 0xfff6e4 : (x + y) % 40 < 6 ? 0xff8fab : 0xffffff));
  230. 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);
  231. shape(400, sy - 10, 540, sy, ellipse(470, sy - 4, 70, 9), solid(0xffffff), 3);
  232. // Wall clock.
  233. shape(250, 470, 410, 630, ellipse(330, 550, 78, 78), (x, y) => {
  234. const d = Math.hypot(x - 330, y - 550);
  235. return d > 62 ? 0xff6f91 : 0xfff6e4;
  236. });
  237. shape(326, 495, 334, 552, rect(326, 495, 334, 552), solid(INK), 0);
  238. shape(330, 546, 375, 554, rect(330, 546, 375, 554), solid(INK), 0);
  239. for (let i = 0; i < 12; i++) {
  240. const a = (i / 12) * Math.PI * 2;
  241. const tx = 330 + Math.cos(a) * 52;
  242. const ty = 550 + Math.sin(a) * 52;
  243. shape(tx - 4, ty - 4, tx + 4, ty + 4, ellipse(tx, ty, 4, 4), solid(INK), 0);
  244. }
  245. // Bunting garland across the top.
  246. const sag = (x: number): number => 30 + 70 * Math.sin((x / W) * Math.PI);
  247. shape(0, 0, W, 130, (x, y) => Math.abs(y - sag(x)) < 3, solid(INK), 0);
  248. const flagColors = theme.flags;
  249. for (let i = 0, fx = 20; fx < W; i++, fx += 110) {
  250. const top = sag(fx + 35);
  251. const inside: Inside = (x, y) => {
  252. const t = (y - top) / 70;
  253. return t >= 0 && t <= 1 && Math.abs(x - (fx + 35)) <= 35 * (1 - t);
  254. };
  255. shape(fx - 2, top - 2, fx + 72, top + 74, inside, (x, y) => (hash(x >> 3, y >> 3) < 0.15 ? 0xffffff : flagColors[i % flagColors.length]));
  256. }
  257. // Table: lace edge then a gingham tablecloth with a hint of perspective.
  258. shape(0, TABLE_Y, W, H, rect(-10, TABLE_Y, W + 10, H + 10), (x, y) => {
  259. const depth = (y - TABLE_Y) / (H - TABLE_Y);
  260. const cell = 40 + depth * 30;
  261. const cx = Math.floor((x - W / 2) / cell);
  262. const cy = Math.floor((y - TABLE_Y) / (cell * 0.6));
  263. const a = (cx & 1) === 0;
  264. const b = (cy & 1) === 0;
  265. const base = a && b ? theme.cloth[0] : a || b ? theme.cloth[1] : theme.cloth[2];
  266. return mix(base, 0x7a3a2a, depth * 0.25);
  267. });
  268. 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);
  269. return img;
  270. }
  271. const MAPS: Record<string, () => Canvas> = {
  272. 'gateau-etages': gateauEtages,
  273. 'mille-feuille': milleFeuille,
  274. 'piece-montee': pieceMontee,
  275. };
  276. const TW = 256;
  277. const TH = 128;
  278. for (const [id, build] of Object.entries(MAPS)) {
  279. const dir = resolve(mapsDir, id);
  280. mkdirSync(dir, { recursive: true });
  281. const terrain = build().px;
  282. writePng(dir, 'terrain.png', W, H, (x, y) => terrain[y * W + x]);
  283. const background = paintBackground(THEMES[id]);
  284. writePng(dir, 'background.png', W, H, (x, y) => background[y * W + x]);
  285. writePng(dir, 'thumb.png', TW, TH, (x, y) => {
  286. const c = terrain[Math.floor((y * H) / TH) * W + Math.floor((x * W) / TW)];
  287. return c === VOID ? 0xbfe6ff : c;
  288. });
  289. console.log(`Wrote ${id}: terrain.png, background.png, thumb.png`);
  290. }