gen-maps.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  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. }
  43. // Drips: rounded drops of cream hanging from the frosting.
  44. for (let x = x0 + 24; x < x1 - 24; x += 46 + ((x * 31) % 53)) {
  45. const half = 3 + ((x * 13) % 3);
  46. const len = 12 + ((x * 17) % 26);
  47. this.rect(x - half, top + 6, x + half + 1, top + 12 + len, CREAM);
  48. this.disc(x, top + 12 + len, half + 1.5, CREAM);
  49. }
  50. // A fat rounded drop down each side, wide enough not to vanish under the outline.
  51. for (const [x, len] of [
  52. [x0, 26],
  53. [x1 - 1, 34],
  54. ]) {
  55. this.rect(x - 7, top, x + 8, top + len, CREAM);
  56. this.disc(x, top + len, 7.5, CREAM);
  57. }
  58. }
  59. }
  60. /** Le Gâteau à Étages: three tiers of sponge with chocolate and jelly layers. */
  61. function gateauEtages(): Canvas {
  62. const c = new Canvas();
  63. c.plate();
  64. const tiers = [
  65. { x0: 280, x1: 1768, top: 700, bottom: 960 },
  66. { x0: 520, x1: 1528, top: 470, bottom: 700 },
  67. { x0: 780, x1: 1268, top: 270, bottom: 470 },
  68. ];
  69. for (const t of tiers) {
  70. const h = t.bottom - t.top;
  71. c.rect(t.x0, t.top, t.x1, t.bottom, SPONGE);
  72. // Biscuit base, jelly layer and chocolate layer.
  73. c.rect(t.x0, t.bottom - Math.round(h * 0.12), t.x1, t.bottom, BISCUIT);
  74. c.rect(t.x0, t.top + Math.round(h * 0.55), t.x1, t.top + Math.round(h * 0.62), JELLY);
  75. c.rect(t.x0, t.top + Math.round(h * 0.3), t.x1, t.top + Math.round(h * 0.36), CHOCOLATE);
  76. // Chocolate pillar in the middle, a hard core to dig around.
  77. const mid = Math.round((t.x0 + t.x1) / 2);
  78. c.rect(mid - 20, t.top + 20, mid + 20, t.bottom - Math.round(h * 0.12), CHOCOLATE);
  79. c.frosting(t.x0, t.x1, t.top);
  80. }
  81. return c;
  82. }
  83. /**
  84. * Le Mille-Feuille: thin brittle pastry sheets alternating with thick soft cream, so digging
  85. * down is quick while every pastry sheet makes tunnelling sideways a chore. A slice is missing
  86. * in the middle: two towers on a common base.
  87. */
  88. function milleFeuille(): Canvas {
  89. const c = new Canvas();
  90. c.plate();
  91. const x0 = 260;
  92. const x1 = 1788;
  93. const top = 440;
  94. const bottom = 960;
  95. const PASTRY = 16;
  96. const FILLING = 44;
  97. for (let y = top; y < bottom; y++) {
  98. const layer = (y - top) % (PASTRY + FILLING);
  99. c.rect(x0, y, x1, y + 1, layer < PASTRY ? BISCUIT : CREAM);
  100. }
  101. // Fondant icing with the classic chocolate feathering.
  102. c.rect(x0 - 4, top - 18, x1 + 4, top, CREAM);
  103. for (let x = x0; x < x1; x++) {
  104. 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);
  105. if (x % 60 < 3) c.rect(x, top - 16, x + 1, top - 2, CHOCOLATE);
  106. }
  107. // The missing slice: a trench down to the third pastry sheet.
  108. c.rect(954, top - 18, 1094, top + 2 * (PASTRY + FILLING) + PASTRY, VOID);
  109. return c;
  110. }
  111. /**
  112. * La Pièce Montée: a stepped pyramid of choux on a nougatine base. Each chou is glued with
  113. * caramel to the one under it only, so taking one out drops the whole column above it.
  114. */
  115. function pieceMontee(): Canvas {
  116. const c = new Canvas();
  117. c.plate();
  118. // Nougatine base.
  119. c.rect(470, 905, 1578, 960, CARAMEL);
  120. const R = 40;
  121. const FLAT_TOP = 0.8; // the top is cut flat at this fraction of the radius above the center
  122. const STEP_X = 82; // 2 px gap between neighbours: they never touch sideways
  123. const STEP_Y = 82;
  124. const base = 13;
  125. const cx0 = 1024;
  126. for (let row = 0; 2 * row < base; row++) {
  127. const count = base - 2 * row;
  128. const cy = 905 - R - 2 - row * STEP_Y;
  129. for (let i = 0; i < count; i++) {
  130. const cx = cx0 + (i - (count - 1) / 2) * STEP_X;
  131. // The top of each chou is flat (a disc of glaze): somewhere to stand on every step.
  132. const flat = Math.round(cy - R * FLAT_TOP);
  133. for (let y = flat; y <= cy + R; y++) {
  134. for (let x = Math.floor(cx - R); x <= cx + R; x++) {
  135. const d2 = (x - cx) ** 2 + (y - cy) ** 2;
  136. if (d2 > R * R) continue;
  137. c.put(x, y, d2 <= (R - 14) ** 2 ? CREAM : SPONGE);
  138. }
  139. }
  140. // Caramel glaze on the flat top, and the glue joint with what is below.
  141. const half = Math.sqrt(R * R - (flat - cy) ** 2);
  142. c.rect(Math.round(cx - half + 3), flat, Math.round(cx + half - 2), flat + 5, CARAMEL);
  143. c.disc(cx, cy + R + 1, 11, CARAMEL);
  144. }
  145. }
  146. return c;
  147. }
  148. /**
  149. * Le Roulé: a slice of Swiss roll lying on the plate, a continuous spiral of jam (jelly) through
  150. * the sponge. Cut the sponge strip and everything inside that turn of the spiral comes loose.
  151. */
  152. function roule(): Canvas {
  153. const c = new Canvas();
  154. c.plate();
  155. const cx = 1024;
  156. const R = 330;
  157. const cy = 960 - R - 14;
  158. // Biscuit stand under the roll so it does not just touch the plate at one point.
  159. c.rect(cx - 180, 900, cx + 181, 960, BISCUIT);
  160. const SPACING = 64; // distance between two turns of the spiral
  161. const JAM = 12;
  162. const WAFER = 16;
  163. for (let y = cy - R; y <= cy + R; y++) {
  164. for (let x = cx - R; x <= cx + R; x++) {
  165. const dx = x - cx;
  166. const dy = y - cy;
  167. const r = Math.hypot(dx, dy);
  168. if (r > R) continue;
  169. if (r > R - 10) {
  170. c.put(x, y, CREAM); // thin whipped cream coat
  171. continue;
  172. }
  173. // Archimedean spiral r = SPACING * (θ / 2π): distance to the nearest turn.
  174. const theta = (Math.atan2(dy, dx) + Math.PI * 2) % (Math.PI * 2);
  175. const phase = (r - (SPACING * theta) / (Math.PI * 2) + SPACING * 100) % SPACING;
  176. c.put(x, y, phase < JAM && r > 24 ? JELLY : SPONGE);
  177. }
  178. }
  179. // Wafer sticks planted in the roll: flat ledges to stand on around the round sides.
  180. for (const [side, dy] of [
  181. [-1, -170],
  182. [1, -90],
  183. [-1, 60],
  184. [1, 160],
  185. [-1, 230],
  186. ]) {
  187. const y = cy + dy;
  188. const edge = cx + side * Math.sqrt(R * R - dy * dy);
  189. const inner = Math.round(edge - side * 50);
  190. const outer = Math.round(edge + side * 120);
  191. c.rect(Math.min(inner, outer), y, Math.max(inner, outer), y + WAFER, BISCUIT);
  192. c.disc(outer, y + WAFER / 2 - 0.5, WAFER / 2, BISCUIT);
  193. }
  194. return c;
  195. }
  196. /**
  197. * La Tarte aux Fruits: a brittle shortcrust shell (biscuit) holding custard cream, topped with
  198. * jelly fruit halves. The base cracks at random (modifier).
  199. */
  200. function tarteFruits(): Canvas {
  201. const c = new Canvas();
  202. c.plate();
  203. const x0 = 300;
  204. const x1 = 1748;
  205. const bottom = 960;
  206. // Shell: base and fluted rims.
  207. c.rect(x0, bottom - 70, x1, bottom, BISCUIT);
  208. for (const [rx0, rx1] of [
  209. [x0, x0 + 46],
  210. [x1 - 46, x1],
  211. ]) {
  212. for (let y = 640; y < bottom; y++) {
  213. const flute = Math.round(4 * Math.sin(y / 9));
  214. c.rect(rx0 + (rx0 === x0 ? flute : 0), y, rx1 - (rx0 !== x0 ? flute : 0), y + 1, BISCUIT);
  215. }
  216. }
  217. // Pastry cream.
  218. c.rect(x0 + 46, 740, x1 - 46, bottom - 70, SPONGE);
  219. // Fruit halves: rows of jelly domes on the cream.
  220. for (let i = 0; i < 18; i++) {
  221. const fx = x0 + 100 + i * 75;
  222. const r = 30 + (i % 3) * 6;
  223. 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);
  224. }
  225. // Glaze specks of whipped cream on the rims.
  226. for (const x of [x0 + 23, x1 - 23]) c.disc(x, 632, 16, CREAM);
  227. return c;
  228. }
  229. /**
  230. * La Pile de Crêpes: thick crêpes with soft spreads in between (jam and whipped cream, never
  231. * chocolate: every layer can be dug through), stacked into a big pile that leans a little, and
  232. * a small pile beside it. Side wind (modifier).
  233. */
  234. function crepes(): Canvas {
  235. const c = new Canvas();
  236. c.plate();
  237. const SPREADS = [JELLY, CREAM];
  238. const CREPE = 20;
  239. const SPREAD = 6;
  240. const pile = (cx: number, bottom: number, top: number, halfWidth: number, lean: number): [number, number] => {
  241. let layer = 0;
  242. let y = bottom;
  243. for (; y - CREPE > top; y -= CREPE + SPREAD, layer++) {
  244. const shift = Math.round((bottom - y) * lean);
  245. const half = halfWidth - layer * 3 + Math.round(8 * Math.sin(layer * 1.7));
  246. const left = cx - half + shift;
  247. const right = cx + half + shift;
  248. // Rounded crêpe ends: the sides read as a stack of pancakes, not a staircase.
  249. const r = CREPE / 2;
  250. c.rect(left + r, y - CREPE, right - r, y, SPONGE);
  251. c.disc(left + r - 0.5, y - r - 0.5, r, SPONGE);
  252. c.disc(right - r - 0.5, y - r - 0.5, r, SPONGE);
  253. // The spread oozes almost to the edge.
  254. c.rect(left + 10, y - CREPE - SPREAD, right - 10, y - CREPE, SPREADS[layer % SPREADS.length]);
  255. for (const x of [left + 10, right - 11]) c.disc(x, y - CREPE - SPREAD / 2 - 0.5, SPREAD / 2, SPREADS[layer % SPREADS.length]);
  256. }
  257. return [cx + Math.round((bottom - y) * lean), y - CREPE];
  258. };
  259. const [topX, topY] = pile(820, 960, 380, 330, 0.1);
  260. // Whipped cream and a jam strawberry on top.
  261. c.disc(topX, topY + 4, 46, CREAM);
  262. c.disc(topX + 10, topY - 36, 20, JELLY);
  263. pile(1520, 960, 720, 190, -0.06);
  264. return c;
  265. }
  266. function writePng(dir: string, name: string, w: number, h: number, pixel: (x: number, y: number) => number): void {
  267. const png = new PNG({ width: w, height: h });
  268. for (let y = 0; y < h; y++) {
  269. for (let x = 0; x < w; x++) {
  270. const c = pixel(x, y);
  271. const i = (y * w + x) * 4;
  272. png.data[i] = (c >> 16) & 0xff;
  273. png.data[i + 1] = (c >> 8) & 0xff;
  274. png.data[i + 2] = c & 0xff;
  275. png.data[i + 3] = 0xff;
  276. }
  277. }
  278. writeFileSync(resolve(dir, name), PNG.sync.write(png));
  279. }
  280. const lerp = (a: number, b: number, t: number): number => Math.round(a + (b - a) * t);
  281. const mix = (c1: number, c2: number, t: number): number =>
  282. (lerp(c1 >> 16, c2 >> 16, t) << 16) | (lerp((c1 >> 8) & 0xff, (c2 >> 8) & 0xff, t) << 8) | lerp(c1 & 0xff, c2 & 0xff, t);
  283. // ---------- Background: a cartoon kitchen, drawn with felt-pen outlines ----------
  284. const INK = 0x3b2414;
  285. const TABLE_Y = 900;
  286. function hash(x: number, y: number): number {
  287. let h = Math.imul(x, 374761393) ^ Math.imul(y, 668265263);
  288. h = Math.imul(h ^ (h >>> 13), 1274126177);
  289. return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
  290. }
  291. interface Theme {
  292. stripe: number;
  293. cloth: [number, number, number];
  294. flags: number[];
  295. }
  296. const THEMES: Record<string, Theme> = {
  297. 'gateau-etages': { stripe: 0xffdbe3, cloth: [0xe85d75, 0xf7a8b8, 0xfff3f5], flags: [0xff6f91, 0xffe14d, 0x4dc4ff, 0x9be15d, 0xb18cff] },
  298. 'mille-feuille': { stripe: 0xd9ecff, cloth: [0x4d8fd9, 0xa8cdf7, 0xf3f8ff], flags: [0x4dc4ff, 0xffffff, 0xffe14d, 0x9be15d, 0x4dc4ff] },
  299. 'piece-montee': { stripe: 0xfff0c2, cloth: [0xd9a01e, 0xf7d98a, 0xfffaf0], flags: [0xffe14d, 0xff9a2e, 0xffffff, 0xff6f91, 0xffd27a] },
  300. roule: { stripe: 0xe3f5d9, cloth: [0x5fae4a, 0xb8e0a8, 0xf6fff2], flags: [0x9be15d, 0xff6f91, 0xffffff, 0xffe14d, 0x9be15d] },
  301. 'tarte-fruits': { stripe: 0xefe3ff, cloth: [0x8a5fc9, 0xcdb8f0, 0xfaf6ff], flags: [0xb18cff, 0xff6f91, 0x9be15d, 0xffffff, 0xffe14d] },
  302. crepes: { stripe: 0xffe8d0, cloth: [0xe0862e, 0xf5c79a, 0xfff8f0], flags: [0xff9a2e, 0x5a3a22, 0xffe14d, 0xffffff, 0xff6f91] },
  303. };
  304. /** The background is drawn at BG_SCALE pixels per map pixel, each pixel averaging BG_SUB² samples (antialiasing). */
  305. const BG_SCALE = 2;
  306. const BG_SUB = 3;
  307. const BW = W * BG_SCALE;
  308. const BH = H * BG_SCALE;
  309. function paintBackground(theme: Theme): Uint32Array {
  310. const img = new Uint32Array(BW * BH);
  311. type Inside = (x: number, y: number) => boolean;
  312. /**
  313. * Fills a shape (inside test over a bounding box, in map pixels) with a color function and a
  314. * felt outline, antialiased over what is already painted.
  315. */
  316. const shape = (x0: number, y0: number, x1: number, y1: number, inside: Inside, fill: (x: number, y: number) => number, outline = 4): void => {
  317. const n = BG_SUB * BG_SUB;
  318. 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++) {
  319. 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++) {
  320. let r = 0;
  321. let g = 0;
  322. let b = 0;
  323. let hits = 0;
  324. for (let sy = 0; sy < BG_SUB; sy++) {
  325. for (let sx = 0; sx < BG_SUB; sx++) {
  326. const x = (px + (sx + 0.5) / BG_SUB) / BG_SCALE;
  327. const y = (py + (sy + 0.5) / BG_SUB) / BG_SCALE;
  328. if (!inside(x, y)) continue;
  329. const t = outline - (hash(Math.floor(x) >> 2, Math.floor(y) >> 2) < 0.3 ? 1 : 0);
  330. const edge = outline > 0 && (!inside(x - t, y) || !inside(x + t, y) || !inside(x, y - t) || !inside(x, y + t));
  331. const c = edge ? INK : fill(x, y);
  332. r += c >> 16;
  333. g += (c >> 8) & 0xff;
  334. b += c & 0xff;
  335. hits++;
  336. }
  337. }
  338. if (hits === 0) continue;
  339. const i = py * BW + px;
  340. const cover = hits / n;
  341. const avg = (Math.round(r / hits) << 16) | (Math.round(g / hits) << 8) | Math.round(b / hits);
  342. img[i] = cover === 1 ? avg : mix(img[i], avg, cover);
  343. }
  344. }
  345. };
  346. const rect = (x0: number, y0: number, x1: number, y1: number): Inside => (x, y) => x >= x0 && x < x1 && y >= y0 && y < y1;
  347. const ellipse = (cx: number, cy: number, rx: number, ry: number): Inside => (x, y) => ((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2 <= 1;
  348. const solid = (c: number) => (): number => c;
  349. // Wallpaper: cream with pink stripes and tiny dots, warmer toward the table.
  350. shape(0, 0, W, TABLE_Y, () => true, (x, y) => {
  351. const stripe = x % 96 < 30;
  352. const dot = !stripe && ((x + (Math.floor(y / 40) % 2) * 24) % 48) ** 2 / 9 + ((y % 40) - 20) ** 2 / 9 < 4;
  353. const base = stripe ? theme.stripe : dot ? 0xf7c9a8 : 0xfff3dc;
  354. return mix(base, 0xf6d7b0, Math.max(0, (y - 300) / 900));
  355. }, 0);
  356. // Skirting board.
  357. 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));
  358. // Window with sky, clouds and a sun.
  359. const wx0 = 1480, wy0 = 90, wx1 = 1900, wy1 = 470;
  360. shape(wx0 - 24, wy0 - 24, wx1 + 24, wy1 + 34, rect(wx0 - 24, wy0 - 24, wx1 + 24, wy1 + 34), solid(0xffffff));
  361. shape(wx0, wy0, wx1, wy1, rect(wx0, wy0, wx1, wy1), (x, y) => {
  362. const t = (y - wy0) / (wy1 - wy0);
  363. if ((x - 1820) ** 2 + (y - 160) ** 2 < 42 ** 2) return 0xffe14d;
  364. const cloud = [[1560, 200, 50], [1610, 185, 60], [1665, 205, 45], [1700, 330, 40], [1745, 318, 52], [1790, 335, 38]];
  365. for (const [cx, cy, r] of cloud) if ((x - cx) ** 2 + ((y - cy) * 1.5) ** 2 < r * r) return 0xffffff;
  366. return mix(0x8fd3ff, 0xd9f1ff, t);
  367. }, 4);
  368. 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);
  369. 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);
  370. // Curtains (pink gingham), gently waved.
  371. for (const side of [-1, 1]) {
  372. const cx = side < 0 ? wx0 - 30 : wx1 + 30;
  373. const inside: Inside = (x, y) => {
  374. if (y < wy0 - 50 || y > wy1 + 90) return false;
  375. const width = 70 + Math.sin(y / 30) * 8 + (y - wy0) * 0.06;
  376. return side < 0 ? x > cx - 40 && x < cx - 40 + width : x < cx + 40 && x > cx + 40 - width;
  377. };
  378. shape(cx - 120, wy0 - 60, cx + 120, wy1 + 100, inside, (x, y) => ((Math.floor(x / 14) + Math.floor(y / 14)) % 2 ? 0xff8fab : 0xffe3ea));
  379. }
  380. shape(wx0 - 90, wy0 - 66, wx1 + 90, wy0 - 46, rect(wx0 - 90, wy0 - 66, wx1 + 90, wy0 - 46), solid(0xc8964b), 3);
  381. // Shelf with jars and cups.
  382. const sy = 330;
  383. shape(70, sy, 600, sy + 22, rect(70, sy, 600, sy + 22), (x) => (x % 60 < 3 ? 0x9c6b3a : 0xc8964b));
  384. 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);
  385. const jar = (cx: number, w: number, h: number, color: number, lid: number): void => {
  386. 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)));
  387. 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);
  388. };
  389. jar(160, 70, 110, 0xd93a5b, 0xff6f91);
  390. jar(250, 60, 80, 0xf2c46d, 0x4dc4ff);
  391. jar(335, 80, 130, 0x5a3a22, 0x9be15d);
  392. // Teacup.
  393. shape(420, sy - 60, 520, sy, ellipse(470, sy - 60, 50, 60), (x, y) => (y < sy - 60 ? 0xfff6e4 : (x + y) % 40 < 6 ? 0xff8fab : 0xffffff));
  394. 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);
  395. shape(400, sy - 10, 540, sy, ellipse(470, sy - 4, 70, 9), solid(0xffffff), 3);
  396. // Wall clock.
  397. shape(250, 470, 410, 630, ellipse(330, 550, 78, 78), (x, y) => {
  398. const d = Math.hypot(x - 330, y - 550);
  399. return d > 62 ? 0xff6f91 : 0xfff6e4;
  400. });
  401. shape(326, 495, 334, 552, rect(326, 495, 334, 552), solid(INK), 0);
  402. shape(330, 546, 375, 554, rect(330, 546, 375, 554), solid(INK), 0);
  403. for (let i = 0; i < 12; i++) {
  404. const a = (i / 12) * Math.PI * 2;
  405. const tx = 330 + Math.cos(a) * 52;
  406. const ty = 550 + Math.sin(a) * 52;
  407. shape(tx - 4, ty - 4, tx + 4, ty + 4, ellipse(tx, ty, 4, 4), solid(INK), 0);
  408. }
  409. // Bunting garland across the top.
  410. const sag = (x: number): number => 30 + 70 * Math.sin((x / W) * Math.PI);
  411. shape(0, 0, W, 130, (x, y) => Math.abs(y - sag(x)) < 3, solid(INK), 0);
  412. const flagColors = theme.flags;
  413. for (let i = 0, fx = 20; fx < W; i++, fx += 110) {
  414. const top = sag(fx + 35);
  415. const inside: Inside = (x, y) => {
  416. const t = (y - top) / 70;
  417. return t >= 0 && t <= 1 && Math.abs(x - (fx + 35)) <= 35 * (1 - t);
  418. };
  419. shape(fx - 2, top - 2, fx + 72, top + 74, inside, (x, y) => (hash(x >> 3, y >> 3) < 0.15 ? 0xffffff : flagColors[i % flagColors.length]));
  420. }
  421. // Table: lace edge then a gingham tablecloth with a hint of perspective.
  422. shape(0, TABLE_Y, W, H, rect(-10, TABLE_Y, W + 10, H + 10), (x, y) => {
  423. const depth = (y - TABLE_Y) / (H - TABLE_Y);
  424. const cell = 40 + depth * 30;
  425. const cx = Math.floor((x - W / 2) / cell);
  426. const cy = Math.floor((y - TABLE_Y) / (cell * 0.6));
  427. const a = (cx & 1) === 0;
  428. const b = (cy & 1) === 0;
  429. const base = a && b ? theme.cloth[0] : a || b ? theme.cloth[1] : theme.cloth[2];
  430. return mix(base, 0x7a3a2a, depth * 0.25);
  431. });
  432. 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);
  433. return img;
  434. }
  435. const MAPS: Record<string, () => Canvas> = {
  436. 'gateau-etages': gateauEtages,
  437. 'mille-feuille': milleFeuille,
  438. 'piece-montee': pieceMontee,
  439. roule,
  440. 'tarte-fruits': tarteFruits,
  441. crepes,
  442. };
  443. const TW = 256;
  444. const TH = 128;
  445. for (const [id, build] of Object.entries(MAPS)) {
  446. const dir = resolve(mapsDir, id);
  447. mkdirSync(dir, { recursive: true });
  448. const terrain = build().px;
  449. writePng(dir, 'terrain.png', W, H, (x, y) => terrain[y * W + x]);
  450. const background = paintBackground(THEMES[id]);
  451. writePng(dir, 'background.png', BW, BH, (x, y) => background[y * BW + x]);
  452. writePng(dir, 'thumb.png', TW, TH, (x, y) => {
  453. const c = terrain[Math.floor((y * H) / TH) * W + Math.floor((x * W) / TW)];
  454. return c === VOID ? 0xbfe6ff : c;
  455. });
  456. console.log(`Wrote ${id}: terrain.png, background.png, thumb.png`);
  457. }