gen-maps.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  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. /**
  134. * Le Roulé: a slice of Swiss roll lying on the plate, a continuous spiral of jam (jelly) through
  135. * the sponge. Cut the sponge strip and everything inside that turn of the spiral comes loose.
  136. */
  137. function roule(): Canvas {
  138. const c = new Canvas();
  139. c.plate();
  140. const cx = 1024;
  141. const R = 330;
  142. const cy = 960 - R - 14;
  143. // Biscuit stand under the roll so it does not just touch the plate at one point.
  144. c.rect(cx - 180, 900, cx + 181, 960, BISCUIT);
  145. const SPACING = 64; // distance between two turns of the spiral
  146. const JAM = 12;
  147. for (let y = cy - R; y <= cy + R; y++) {
  148. for (let x = cx - R; x <= cx + R; x++) {
  149. const dx = x - cx;
  150. const dy = y - cy;
  151. const r = Math.hypot(dx, dy);
  152. if (r > R) continue;
  153. if (r > R - 10) {
  154. c.put(x, y, CREAM); // thin whipped cream coat
  155. continue;
  156. }
  157. // Archimedean spiral r = SPACING * (θ / 2π): distance to the nearest turn.
  158. const theta = (Math.atan2(dy, dx) + Math.PI * 2) % (Math.PI * 2);
  159. const phase = (r - (SPACING * theta) / (Math.PI * 2) + SPACING * 100) % SPACING;
  160. c.put(x, y, phase < JAM && r > 24 ? JELLY : SPONGE);
  161. }
  162. }
  163. return c;
  164. }
  165. /**
  166. * La Tarte aux Fruits: a brittle shortcrust shell (biscuit) holding custard cream, topped with
  167. * jelly fruit halves. The base cracks at random (modifier).
  168. */
  169. function tarteFruits(): Canvas {
  170. const c = new Canvas();
  171. c.plate();
  172. const x0 = 300;
  173. const x1 = 1748;
  174. const bottom = 960;
  175. // Shell: base and fluted rims.
  176. c.rect(x0, bottom - 70, x1, bottom, BISCUIT);
  177. for (const [rx0, rx1] of [
  178. [x0, x0 + 46],
  179. [x1 - 46, x1],
  180. ]) {
  181. for (let y = 640; y < bottom; y++) {
  182. const flute = Math.round(4 * Math.sin(y / 9));
  183. c.rect(rx0 + (rx0 === x0 ? flute : 0), y, rx1 - (rx0 !== x0 ? flute : 0), y + 1, BISCUIT);
  184. }
  185. }
  186. // Pastry cream.
  187. c.rect(x0 + 46, 740, x1 - 46, bottom - 70, SPONGE);
  188. // Fruit halves: rows of jelly domes on the cream.
  189. for (let i = 0; i < 18; i++) {
  190. const fx = x0 + 100 + i * 75;
  191. const r = 30 + (i % 3) * 6;
  192. 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);
  193. }
  194. // Glaze specks of whipped cream on the rims.
  195. for (const x of [x0 + 23, x1 - 23]) c.disc(x, 632, 16, CREAM);
  196. return c;
  197. }
  198. /**
  199. * La Pile de Crêpes: dozens of thin crêpes with spreads in between (chocolate, jam, caramel),
  200. * stacked into a pile that leans. Side wind (modifier).
  201. */
  202. function crepes(): Canvas {
  203. const c = new Canvas();
  204. c.plate();
  205. const SPREADS = [CHOCOLATE, JELLY, CARAMEL];
  206. const CREPE = 9;
  207. const SPREAD = 3;
  208. const lean = 0.22; // horizontal shift per pixel of height
  209. let layer = 0;
  210. for (let top = 960; top > 340; top -= CREPE + SPREAD, layer++) {
  211. const shift = Math.round((960 - top) * lean);
  212. const halfWidth = 330 - Math.round(layer * 1.5) + Math.round(6 * Math.sin(layer * 1.7));
  213. const left = 860 - halfWidth + shift;
  214. const right = 860 + halfWidth + shift;
  215. c.rect(left, top - CREPE, right, top, SPONGE);
  216. if (top - CREPE - SPREAD > 340) c.rect(left + 12, top - CREPE - SPREAD, right - 12, top - CREPE, SPREADS[layer % 3]);
  217. }
  218. // Whipped cream on top.
  219. c.disc(860 + Math.round(620 * lean), 345, 40, CREAM);
  220. return c;
  221. }
  222. function writePng(dir: string, name: string, w: number, h: number, pixel: (x: number, y: number) => number): void {
  223. const png = new PNG({ width: w, height: h });
  224. for (let y = 0; y < h; y++) {
  225. for (let x = 0; x < w; x++) {
  226. const c = pixel(x, y);
  227. const i = (y * w + x) * 4;
  228. png.data[i] = (c >> 16) & 0xff;
  229. png.data[i + 1] = (c >> 8) & 0xff;
  230. png.data[i + 2] = c & 0xff;
  231. png.data[i + 3] = 0xff;
  232. }
  233. }
  234. writeFileSync(resolve(dir, name), PNG.sync.write(png));
  235. }
  236. const lerp = (a: number, b: number, t: number): number => Math.round(a + (b - a) * t);
  237. const mix = (c1: number, c2: number, t: number): number =>
  238. (lerp(c1 >> 16, c2 >> 16, t) << 16) | (lerp((c1 >> 8) & 0xff, (c2 >> 8) & 0xff, t) << 8) | lerp(c1 & 0xff, c2 & 0xff, t);
  239. // ---------- Background: a cartoon kitchen, drawn with felt-pen outlines ----------
  240. const INK = 0x3b2414;
  241. const TABLE_Y = 900;
  242. function hash(x: number, y: number): number {
  243. let h = Math.imul(x, 374761393) ^ Math.imul(y, 668265263);
  244. h = Math.imul(h ^ (h >>> 13), 1274126177);
  245. return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
  246. }
  247. interface Theme {
  248. stripe: number;
  249. cloth: [number, number, number];
  250. flags: number[];
  251. }
  252. const THEMES: Record<string, Theme> = {
  253. 'gateau-etages': { stripe: 0xffdbe3, cloth: [0xe85d75, 0xf7a8b8, 0xfff3f5], flags: [0xff6f91, 0xffe14d, 0x4dc4ff, 0x9be15d, 0xb18cff] },
  254. 'mille-feuille': { stripe: 0xd9ecff, cloth: [0x4d8fd9, 0xa8cdf7, 0xf3f8ff], flags: [0x4dc4ff, 0xffffff, 0xffe14d, 0x9be15d, 0x4dc4ff] },
  255. 'piece-montee': { stripe: 0xfff0c2, cloth: [0xd9a01e, 0xf7d98a, 0xfffaf0], flags: [0xffe14d, 0xff9a2e, 0xffffff, 0xff6f91, 0xffd27a] },
  256. roule: { stripe: 0xe3f5d9, cloth: [0x5fae4a, 0xb8e0a8, 0xf6fff2], flags: [0x9be15d, 0xff6f91, 0xffffff, 0xffe14d, 0x9be15d] },
  257. 'tarte-fruits': { stripe: 0xefe3ff, cloth: [0x8a5fc9, 0xcdb8f0, 0xfaf6ff], flags: [0xb18cff, 0xff6f91, 0x9be15d, 0xffffff, 0xffe14d] },
  258. crepes: { stripe: 0xffe8d0, cloth: [0xe0862e, 0xf5c79a, 0xfff8f0], flags: [0xff9a2e, 0x5a3a22, 0xffe14d, 0xffffff, 0xff6f91] },
  259. };
  260. /** The background is drawn at BG_SCALE pixels per map pixel, each pixel averaging BG_SUB² samples (antialiasing). */
  261. const BG_SCALE = 2;
  262. const BG_SUB = 3;
  263. const BW = W * BG_SCALE;
  264. const BH = H * BG_SCALE;
  265. function paintBackground(theme: Theme): Uint32Array {
  266. const img = new Uint32Array(BW * BH);
  267. type Inside = (x: number, y: number) => boolean;
  268. /**
  269. * Fills a shape (inside test over a bounding box, in map pixels) with a color function and a
  270. * felt outline, antialiased over what is already painted.
  271. */
  272. const shape = (x0: number, y0: number, x1: number, y1: number, inside: Inside, fill: (x: number, y: number) => number, outline = 4): void => {
  273. const n = BG_SUB * BG_SUB;
  274. 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++) {
  275. 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++) {
  276. let r = 0;
  277. let g = 0;
  278. let b = 0;
  279. let hits = 0;
  280. for (let sy = 0; sy < BG_SUB; sy++) {
  281. for (let sx = 0; sx < BG_SUB; sx++) {
  282. const x = (px + (sx + 0.5) / BG_SUB) / BG_SCALE;
  283. const y = (py + (sy + 0.5) / BG_SUB) / BG_SCALE;
  284. if (!inside(x, y)) continue;
  285. const t = outline - (hash(Math.floor(x) >> 2, Math.floor(y) >> 2) < 0.3 ? 1 : 0);
  286. const edge = outline > 0 && (!inside(x - t, y) || !inside(x + t, y) || !inside(x, y - t) || !inside(x, y + t));
  287. const c = edge ? INK : fill(x, y);
  288. r += c >> 16;
  289. g += (c >> 8) & 0xff;
  290. b += c & 0xff;
  291. hits++;
  292. }
  293. }
  294. if (hits === 0) continue;
  295. const i = py * BW + px;
  296. const cover = hits / n;
  297. const avg = (Math.round(r / hits) << 16) | (Math.round(g / hits) << 8) | Math.round(b / hits);
  298. img[i] = cover === 1 ? avg : mix(img[i], avg, cover);
  299. }
  300. }
  301. };
  302. const rect = (x0: number, y0: number, x1: number, y1: number): Inside => (x, y) => x >= x0 && x < x1 && y >= y0 && y < y1;
  303. const ellipse = (cx: number, cy: number, rx: number, ry: number): Inside => (x, y) => ((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2 <= 1;
  304. const solid = (c: number) => (): number => c;
  305. // Wallpaper: cream with pink stripes and tiny dots, warmer toward the table.
  306. shape(0, 0, W, TABLE_Y, () => true, (x, y) => {
  307. const stripe = x % 96 < 30;
  308. const dot = !stripe && ((x + (Math.floor(y / 40) % 2) * 24) % 48) ** 2 / 9 + ((y % 40) - 20) ** 2 / 9 < 4;
  309. const base = stripe ? theme.stripe : dot ? 0xf7c9a8 : 0xfff3dc;
  310. return mix(base, 0xf6d7b0, Math.max(0, (y - 300) / 900));
  311. }, 0);
  312. // Skirting board.
  313. 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));
  314. // Window with sky, clouds and a sun.
  315. const wx0 = 1480, wy0 = 90, wx1 = 1900, wy1 = 470;
  316. shape(wx0 - 24, wy0 - 24, wx1 + 24, wy1 + 34, rect(wx0 - 24, wy0 - 24, wx1 + 24, wy1 + 34), solid(0xffffff));
  317. shape(wx0, wy0, wx1, wy1, rect(wx0, wy0, wx1, wy1), (x, y) => {
  318. const t = (y - wy0) / (wy1 - wy0);
  319. if ((x - 1820) ** 2 + (y - 160) ** 2 < 42 ** 2) return 0xffe14d;
  320. const cloud = [[1560, 200, 50], [1610, 185, 60], [1665, 205, 45], [1700, 330, 40], [1745, 318, 52], [1790, 335, 38]];
  321. for (const [cx, cy, r] of cloud) if ((x - cx) ** 2 + ((y - cy) * 1.5) ** 2 < r * r) return 0xffffff;
  322. return mix(0x8fd3ff, 0xd9f1ff, t);
  323. }, 4);
  324. 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);
  325. 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);
  326. // Curtains (pink gingham), gently waved.
  327. for (const side of [-1, 1]) {
  328. const cx = side < 0 ? wx0 - 30 : wx1 + 30;
  329. const inside: Inside = (x, y) => {
  330. if (y < wy0 - 50 || y > wy1 + 90) return false;
  331. const width = 70 + Math.sin(y / 30) * 8 + (y - wy0) * 0.06;
  332. return side < 0 ? x > cx - 40 && x < cx - 40 + width : x < cx + 40 && x > cx + 40 - width;
  333. };
  334. shape(cx - 120, wy0 - 60, cx + 120, wy1 + 100, inside, (x, y) => ((Math.floor(x / 14) + Math.floor(y / 14)) % 2 ? 0xff8fab : 0xffe3ea));
  335. }
  336. shape(wx0 - 90, wy0 - 66, wx1 + 90, wy0 - 46, rect(wx0 - 90, wy0 - 66, wx1 + 90, wy0 - 46), solid(0xc8964b), 3);
  337. // Shelf with jars and cups.
  338. const sy = 330;
  339. shape(70, sy, 600, sy + 22, rect(70, sy, 600, sy + 22), (x) => (x % 60 < 3 ? 0x9c6b3a : 0xc8964b));
  340. 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);
  341. const jar = (cx: number, w: number, h: number, color: number, lid: number): void => {
  342. 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)));
  343. 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);
  344. };
  345. jar(160, 70, 110, 0xd93a5b, 0xff6f91);
  346. jar(250, 60, 80, 0xf2c46d, 0x4dc4ff);
  347. jar(335, 80, 130, 0x5a3a22, 0x9be15d);
  348. // Teacup.
  349. shape(420, sy - 60, 520, sy, ellipse(470, sy - 60, 50, 60), (x, y) => (y < sy - 60 ? 0xfff6e4 : (x + y) % 40 < 6 ? 0xff8fab : 0xffffff));
  350. 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);
  351. shape(400, sy - 10, 540, sy, ellipse(470, sy - 4, 70, 9), solid(0xffffff), 3);
  352. // Wall clock.
  353. shape(250, 470, 410, 630, ellipse(330, 550, 78, 78), (x, y) => {
  354. const d = Math.hypot(x - 330, y - 550);
  355. return d > 62 ? 0xff6f91 : 0xfff6e4;
  356. });
  357. shape(326, 495, 334, 552, rect(326, 495, 334, 552), solid(INK), 0);
  358. shape(330, 546, 375, 554, rect(330, 546, 375, 554), solid(INK), 0);
  359. for (let i = 0; i < 12; i++) {
  360. const a = (i / 12) * Math.PI * 2;
  361. const tx = 330 + Math.cos(a) * 52;
  362. const ty = 550 + Math.sin(a) * 52;
  363. shape(tx - 4, ty - 4, tx + 4, ty + 4, ellipse(tx, ty, 4, 4), solid(INK), 0);
  364. }
  365. // Bunting garland across the top.
  366. const sag = (x: number): number => 30 + 70 * Math.sin((x / W) * Math.PI);
  367. shape(0, 0, W, 130, (x, y) => Math.abs(y - sag(x)) < 3, solid(INK), 0);
  368. const flagColors = theme.flags;
  369. for (let i = 0, fx = 20; fx < W; i++, fx += 110) {
  370. const top = sag(fx + 35);
  371. const inside: Inside = (x, y) => {
  372. const t = (y - top) / 70;
  373. return t >= 0 && t <= 1 && Math.abs(x - (fx + 35)) <= 35 * (1 - t);
  374. };
  375. shape(fx - 2, top - 2, fx + 72, top + 74, inside, (x, y) => (hash(x >> 3, y >> 3) < 0.15 ? 0xffffff : flagColors[i % flagColors.length]));
  376. }
  377. // Table: lace edge then a gingham tablecloth with a hint of perspective.
  378. shape(0, TABLE_Y, W, H, rect(-10, TABLE_Y, W + 10, H + 10), (x, y) => {
  379. const depth = (y - TABLE_Y) / (H - TABLE_Y);
  380. const cell = 40 + depth * 30;
  381. const cx = Math.floor((x - W / 2) / cell);
  382. const cy = Math.floor((y - TABLE_Y) / (cell * 0.6));
  383. const a = (cx & 1) === 0;
  384. const b = (cy & 1) === 0;
  385. const base = a && b ? theme.cloth[0] : a || b ? theme.cloth[1] : theme.cloth[2];
  386. return mix(base, 0x7a3a2a, depth * 0.25);
  387. });
  388. 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);
  389. return img;
  390. }
  391. const MAPS: Record<string, () => Canvas> = {
  392. 'gateau-etages': gateauEtages,
  393. 'mille-feuille': milleFeuille,
  394. 'piece-montee': pieceMontee,
  395. roule,
  396. 'tarte-fruits': tarteFruits,
  397. crepes,
  398. };
  399. const TW = 256;
  400. const TH = 128;
  401. for (const [id, build] of Object.entries(MAPS)) {
  402. const dir = resolve(mapsDir, id);
  403. mkdirSync(dir, { recursive: true });
  404. const terrain = build().px;
  405. writePng(dir, 'terrain.png', W, H, (x, y) => terrain[y * W + x]);
  406. const background = paintBackground(THEMES[id]);
  407. writePng(dir, 'background.png', BW, BH, (x, y) => background[y * BW + x]);
  408. writePng(dir, 'thumb.png', TW, TH, (x, y) => {
  409. const c = terrain[Math.floor((y * H) / TH) * W + Math.floor((x * W) / TW)];
  410. return c === VOID ? 0xbfe6ff : c;
  411. });
  412. console.log(`Wrote ${id}: terrain.png, background.png, thumb.png`);
  413. }