Explorar o código

feat(renderer): sharp terrain and backgrounds, characters with volume

- Render at the screen's pixel density (capped at MAX_RESOLUTION) instead
  of letting the browser upscale a 1x canvas.
- Terrain chunks are drawn supersampled from a signed distance field
  (Felzenszwalb EDT around each chunk): smooth, antialiased edges and
  felt outline, soft drop shadow, crisp material textures. The bitmap
  and the deterministic ops are untouched.
- Map backgrounds are generated at 2x with 3x3 antialiasing; the client
  reads their resolution from their size.
- Character toolkit: shaded bodies (own shadow, highlight, glint, crayon
  hatching), expressive eyes (brows by action, blinks, spiral when
  stunned), blush, contact shadow and run lean, plus new details per
  character (Gege's miner helmet, Mamie's knitted shawl and pearls, the
  cat's collar, the wizard's moon hat...). Menu portraits at 3x.

No new dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Idleman hai 1 semana
pai
achega
919403eae2

BIN=BIN
assets/maps/crepes/background.png


BIN=BIN
assets/maps/gateau-etages/background.png


BIN=BIN
assets/maps/mille-feuille/background.png


BIN=BIN
assets/maps/piece-montee/background.png


BIN=BIN
assets/maps/roule/background.png


BIN=BIN
assets/maps/tarte-fruits/background.png


+ 4 - 1
readme.md

@@ -258,6 +258,8 @@ Une carte invalide est **rejetée avec un message explicite**. On ne la charge j
 - **Effets :** secousse d'écran proportionnelle à l'énergie, freeze-frame de 50 ms sur les gros impacts, nombreuses particules.
 - **Effets :** secousse d'écran proportionnelle à l'énergie, freeze-frame de 50 ms sur les gros impacts, nombreuses particules.
 - **Son :** mastication, « splotch », « crrrac » de biscuit, prouts variés. Musique : chanson d'anniversaire à la flûte à bec qui accélère avec la montée de la crème.
 - **Son :** mastication, « splotch », « crrrac » de biscuit, prouts variés. Musique : chanson d'anniversaire à la flûte à bec qui accélère avec la montée de la crème.
 - Placeholders autorisés pour le développement : formes géométriques colorées. La logique ne dépend jamais des assets finaux.
 - Placeholders autorisés pour le développement : formes géométriques colorées. La logique ne dépend jamais des assets finaux.
+- **Netteté (rendu client) :** le renderer suit la densité de l'écran (`devicePixelRatio`, plafonnée à `MAX_RESOLUTION`). Le terrain est dessiné en sur-échantillonnage (`TERRAIN_SUPERSAMPLE` texels par pixel) à partir d'un champ de distance signée calculé autour de chaque morceau : les bords sont des courbes lissées et anticrénelées, jamais l'escalier du bitmap. Le bitmap et les ops restent au pixel près (seul l'affichage change). Les fonds sont générés en 2× et anticrénelés par `npm run gen:maps` ; le client déduit leur résolution de leur taille.
+- **Personnages :** une boîte à outils commune (`renderer/characters/painters.ts`) leur donne du volume (ombre propre, reflet, éclat, hachures au crayon), des yeux expressifs (sourcils selon l'action, clignements, spirales quand assommé), des joues roses, une ombre de contact au sol et une inclinaison dans la course.
 ---
 ---
  
  
 ## 8. Conventions de code
 ## 8. Conventions de code
@@ -340,7 +342,8 @@ Chaque étape doit être **jouable** avant de passer à la suivante.
     - **Le Chat :** sa poussée déplace blocs, objets et joueurs assommés à pleine force (les autres un peu). Il s'arrête au hasard pour se lécher la patte et ignore alors les commandes.
     - **Le Chat :** sa poussée déplace blocs, objets et joueurs assommés à pleine force (les autres un peu). Il s'arrête au hasard pour se lécher la patte et ignore alors les commandes.
     - **Magicien Raté :** gelée (génoise, biscuit et chantilly deviennent gelée), échange de place avec un joueur au hasard, lapin géant en génoise qui tombe du ciel, ou « pfft ». Le poids du « pfft » augmente avec son classement dans la manche.
     - **Magicien Raté :** gelée (génoise, biscuit et chantilly deviennent gelée), échange de place avec un joueur au hasard, lapin géant en génoise qui tombe du ciel, ou « pfft ». Le poids du « pfft » augmente avec son classement dans la manche.
     - Les signatures (prout, jet, sortie de terre, sorts, léchouille) passent par un événement `cue` commun, prêt pour l'audio.
     - Les signatures (prout, jet, sortie de terre, sorts, léchouille) passent par un événement `cue` commun, prêt pour l'audio.
-- **Reste :** la DA finale, l'audio, l'éditeur de cartes et l'installeur Windows.
+  - **DA, première passe finale :** terrain et fonds nets à toutes les résolutions, personnages en volume et plus expressifs (§7).
+- **Reste :** la suite de la DA (sprites dessinés, particules par matériau), l'audio, l'éditeur de cartes et l'installeur Windows.
 ---
 ---
  
  
 ## 10. Pour Claude : comment travailler sur ce projet
 ## 10. Pour Claude : comment travailler sur ce projet

+ 38 - 9
scripts/gen-maps.ts

@@ -279,17 +279,46 @@ const THEMES: Record<string, Theme> = {
   crepes: { stripe: 0xffe8d0, cloth: [0xe0862e, 0xf5c79a, 0xfff8f0], flags: [0xff9a2e, 0x5a3a22, 0xffe14d, 0xffffff, 0xff6f91] },
   crepes: { stripe: 0xffe8d0, cloth: [0xe0862e, 0xf5c79a, 0xfff8f0], flags: [0xff9a2e, 0x5a3a22, 0xffe14d, 0xffffff, 0xff6f91] },
 };
 };
 
 
+/** The background is drawn at BG_SCALE pixels per map pixel, each pixel averaging BG_SUB² samples (antialiasing). */
+const BG_SCALE = 2;
+const BG_SUB = 3;
+const BW = W * BG_SCALE;
+const BH = H * BG_SCALE;
+
 function paintBackground(theme: Theme): Uint32Array {
 function paintBackground(theme: Theme): Uint32Array {
-  const img = new Uint32Array(W * H);
+  const img = new Uint32Array(BW * BH);
   type Inside = (x: number, y: number) => boolean;
   type Inside = (x: number, y: number) => boolean;
-  /** Fills a shape (inside test over a bounding box) with a color function and a felt outline. */
+  /**
+   * Fills a shape (inside test over a bounding box, in map pixels) with a color function and a
+   * felt outline, antialiased over what is already painted.
+   */
   const shape = (x0: number, y0: number, x1: number, y1: number, inside: Inside, fill: (x: number, y: number) => number, outline = 4): void => {
   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 n = BG_SUB * BG_SUB;
+    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++) {
+      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++) {
+        let r = 0;
+        let g = 0;
+        let b = 0;
+        let hits = 0;
+        for (let sy = 0; sy < BG_SUB; sy++) {
+          for (let sx = 0; sx < BG_SUB; sx++) {
+            const x = (px + (sx + 0.5) / BG_SUB) / BG_SCALE;
+            const y = (py + (sy + 0.5) / BG_SUB) / BG_SCALE;
+            if (!inside(x, y)) continue;
+            const t = outline - (hash(Math.floor(x) >> 2, Math.floor(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));
+            const c = edge ? INK : fill(x, y);
+            r += c >> 16;
+            g += (c >> 8) & 0xff;
+            b += c & 0xff;
+            hits++;
+          }
+        }
+        if (hits === 0) continue;
+        const i = py * BW + px;
+        const cover = hits / n;
+        const avg = (Math.round(r / hits) << 16) | (Math.round(g / hits) << 8) | Math.round(b / hits);
+        img[i] = cover === 1 ? avg : mix(img[i], avg, cover);
       }
       }
     }
     }
   };
   };
@@ -406,7 +435,7 @@ for (const [id, build] of Object.entries(MAPS)) {
   const terrain = build().px;
   const terrain = build().px;
   writePng(dir, 'terrain.png', W, H, (x, y) => terrain[y * W + x]);
   writePng(dir, 'terrain.png', W, H, (x, y) => terrain[y * W + x]);
   const background = paintBackground(THEMES[id]);
   const background = paintBackground(THEMES[id]);
-  writePng(dir, 'background.png', W, H, (x, y) => background[y * W + x]);
+  writePng(dir, 'background.png', BW, BH, (x, y) => background[y * BW + x]);
   writePng(dir, 'thumb.png', TW, TH, (x, y) => {
   writePng(dir, 'thumb.png', TW, TH, (x, y) => {
     const c = terrain[Math.floor((y * H) / TH) * W + Math.floor((x * W) / TW)];
     const c = terrain[Math.floor((y * H) / TH) * W + Math.floor((x * W) / TW)];
     return c === VOID ? 0xbfe6ff : c;
     return c === VOID ? 0xbfe6ff : c;

+ 157 - 35
src/renderer/characters/painters.ts

@@ -44,23 +44,99 @@ function ellipsePoly(cx: number, cy: number, rx: number, ry: number, angle: numb
   return pts;
   return pts;
 }
 }
 
 
+function darken(color: number, k: number): number {
+  return (Math.round((color >> 16) * k) << 16) | (Math.round(((color >> 8) & 0xff) * k) << 8) | Math.round((color & 0xff) * k);
+}
+
+/**
+ * A round body part with volume: own shadow on the back and underside, a lit face, a soft
+ * highlight and a glint up front, crayon hatching in the shadow, then the felt outline.
+ * Light comes from the top-front (facing right, up).
+ */
+function body(g: Graphics, cx: number, cy: number, rx: number, ry: number, color: number, pose: Pose, angle = 0): void {
+  const shadow = darken(color, 0.72);
+  g.poly(ellipsePoly(cx, cy, rx, ry, angle, 28)).fill(shadow);
+  g.poly(ellipsePoly(cx + rx * 0.07, cy - ry * 0.09, rx * 0.9, ry * 0.88, angle, 28)).fill(color);
+  // Crayon hatching in the shadow (back-bottom).
+  for (let i = 0; i < 6; i++) {
+    const a = 1.95 + i * 0.2;
+    const x0 = cx + Math.cos(a) * rx * 0.62;
+    const y0 = cy + Math.sin(a) * ry * 0.62;
+    g.moveTo(x0, y0)
+      .lineTo(x0 - rx * 0.16, y0 + ry * 0.2)
+      .stroke({ width: pose.ink * 0.45, color: darken(color, 0.55), alpha: 0.45, cap: 'round' });
+  }
+  g.poly(ellipsePoly(cx - rx * 0.28, cy - ry * 0.5, rx * 0.34, ry * 0.17, -0.45, 16)).fill({ color: 0xffffff, alpha: 0.32 });
+  g.circle(cx + rx * 0.5, cy - ry * 0.55, Math.min(rx, ry) * 0.08).fill({ color: 0xffffff, alpha: 0.85 });
+  g.poly(ellipsePoly(cx, cy, rx, ry, angle, 28)).stroke({ width: pose.ink, color: INK, join: 'round' });
+}
+
+/** Upper half of an ellipse (helmets, hats), as a closed polygon. */
+function dome(cx: number, cy: number, rx: number, ry: number): number[] {
+  const pts: number[] = [];
+  for (let i = 0; i <= 16; i++) {
+    const a = Math.PI + (i / 16) * Math.PI;
+    pts.push(cx + Math.cos(a) * rx, cy + Math.sin(a) * ry);
+  }
+  return pts;
+}
+
+/** Pink cheek. */
+function blush(g: Graphics, x: number, y: number, r: number): void {
+  g.ellipse(x, y, r, r * 0.65).fill({ color: 0xff5c8a, alpha: 0.35 });
+}
+
+/** Blinks every few seconds, out of step between players. */
+function blinking(pose: Pose): boolean {
+  const period = 3.1 + ((pose.color >> 4) & 7) * 0.23;
+  return (pose.time + (pose.color & 0xff) / 97) % period < 0.13;
+}
+
+/**
+ * Big expressive eyes: white with a shaded lid, a pupil following the aim with two glints,
+ * eyebrows giving the mood (angry when attacking, raised during the special), crosses when
+ * stunned, a blink now and then.
+ */
 function eyes(g: Graphics, pose: Pose, spots: [number, number][], size: number): void {
 function eyes(g: Graphics, pose: Pose, spots: [number, number][], size: number): void {
-  for (const [ex, ey] of spots) {
+  spots.forEach(([ex, ey], i) => {
     if (pose.stunned) {
     if (pose.stunned) {
-      const d = size * 0.6;
-      g.moveTo(ex - d, ey - d).lineTo(ex + d, ey + d).moveTo(ex + d, ey - d).lineTo(ex - d, ey + d).stroke({ width: pose.ink, color: INK, cap: 'round' });
-      continue;
+      // Spinning spiral eyes.
+      const turn = pose.time * 10 * (i ? -1 : 1);
+      g.moveTo(ex, ey);
+      for (let k = 1; k <= 14; k++) {
+        const a = turn + k * 0.75;
+        const rr = (k / 14) * size;
+        g.lineTo(ex + Math.cos(a) * rr, ey + Math.sin(a) * rr);
+      }
+      g.stroke({ width: pose.ink * 0.7, color: INK, cap: 'round' });
+      return;
     }
     }
-    g.ellipse(ex, ey, size, size * 1.2).fill(0xffffff).stroke({ width: pose.ink * 0.8, color: INK });
-    const px = ex + pose.lookX * size * 0.4;
-    const py = ey + pose.lookY * size * 0.45;
-    g.circle(px, py, size * 0.5).fill(INK);
-    g.circle(px - size * 0.18, py - size * 0.2, size * 0.17).fill(0xffffff);
-  }
+    if (blinking(pose)) {
+      g.moveTo(ex - size, ey).quadraticCurveTo(ex, ey + size * 0.6, ex + size, ey).stroke({ width: pose.ink * 0.9, color: INK, cap: 'round' });
+    } else {
+      g.ellipse(ex, ey, size, size * 1.22).fill(0xffffff);
+      g.ellipse(ex, ey - size * 0.75, size * 0.9, size * 0.45).fill({ color: 0x9fb4d0, alpha: 0.35 });
+      g.ellipse(ex, ey, size, size * 1.22).stroke({ width: pose.ink * 0.8, color: INK });
+      const px = ex + pose.lookX * size * 0.42;
+      const py = ey + pose.lookY * size * 0.5;
+      g.circle(px, py, size * 0.55).fill(INK);
+      g.circle(px - size * 0.2, py - size * 0.22, size * 0.2).fill(0xffffff);
+      g.circle(px + size * 0.18, py + size * 0.2, size * 0.08).fill(0xffffff);
+    }
+    // Eyebrow: the inner end (toward the other eye) drops when angry, both rise when excited.
+    const inner = spots.length > 1 ? (i === 0 ? 1 : -1) : 1;
+    const by = ey - size * 1.65 - (pose.special ? size * 0.35 : 0);
+    const tilt = pose.attack ? size * 0.55 : pose.special ? -size * 0.15 : -size * 0.1;
+    g.moveTo(ex - inner * size * 0.85, by - (pose.attack ? size * 0.1 : 0))
+      .lineTo(ex + inner * size * 0.75, by + tilt)
+      .stroke({ width: pose.ink * 0.9, color: INK, cap: 'round' });
+  });
 }
 }
 
 
 function foot(g: Graphics, x: number, y: number, r: number, color: number, ink: number): void {
 function foot(g: Graphics, x: number, y: number, r: number, color: number, ink: number): void {
-  g.ellipse(x, y, r * 0.24, r * 0.13).fill(color).stroke({ width: ink, color: INK });
+  g.ellipse(x, y, r * 0.26, r * 0.14).fill(color);
+  g.ellipse(x + r * 0.04, y - r * 0.04, r * 0.14, r * 0.05).fill({ color: 0xffffff, alpha: 0.3 });
+  g.ellipse(x, y, r * 0.26, r * 0.14).stroke({ width: ink, color: INK });
 }
 }
 
 
 function stars(g: Graphics, r: number, pose: Pose): void {
 function stars(g: Graphics, r: number, pose: Pose): void {
@@ -99,8 +175,10 @@ export const paintCastor: Painter = (g, r, pose) => {
   foot(g, -0.38 * r - step * 0.18 * r, 0.93 * r - (step < 0 ? lift : 0), r, dark, pose.ink);
   foot(g, -0.38 * r - step * 0.18 * r, 0.93 * r - (step < 0 ? lift : 0), r, dark, pose.ink);
 
 
   // Body + belly.
   // Body + belly.
-  g.circle(0, 0, r).fill(fur).stroke({ width: pose.ink, color: INK });
+  body(g, 0, 0, r, r, fur, pose);
   g.ellipse(0.22 * r, 0.32 * r, 0.52 * r, 0.5 * r).fill(0xe0b07a);
   g.ellipse(0.22 * r, 0.32 * r, 0.52 * r, 0.5 * r).fill(0xe0b07a);
+  g.ellipse(0.3 * r, 0.22 * r, 0.3 * r, 0.22 * r).fill({ color: 0xffffff, alpha: 0.2 });
+  for (let i = 0; i < 4; i++) g.moveTo(0.02 * r + i * 0.14 * r, 0.62 * r).lineTo(0.08 * r + i * 0.14 * r, 0.7 * r).stroke({ width: pose.ink * 0.4, color: 0xb88550 });
   // Fur tufts.
   // Fur tufts.
   g.moveTo(-0.55 * r, -0.2 * r).lineTo(-0.42 * r, -0.12 * r).moveTo(-0.6 * r, 0.05 * r).lineTo(-0.46 * r, 0.1 * r).stroke({ width: pose.ink * 0.5, color: dark });
   g.moveTo(-0.55 * r, -0.2 * r).lineTo(-0.42 * r, -0.12 * r).moveTo(-0.6 * r, 0.05 * r).lineTo(-0.46 * r, 0.1 * r).stroke({ width: pose.ink * 0.5, color: dark });
 
 
@@ -158,9 +236,8 @@ export const paintGeneric: Painter = (g, r, pose) => {
   g.moveTo(0, -r).quadraticCurveTo(0.05 * r, -1.25 * r, sway, -1.4 * r).stroke({ width: pose.ink, color: INK });
   g.moveTo(0, -r).quadraticCurveTo(0.05 * r, -1.25 * r, sway, -1.4 * r).stroke({ width: pose.ink, color: INK });
   g.poly(ellipsePoly(sway + 0.18 * r, -1.42 * r, 0.2 * r, 0.1 * r, -0.4)).fill(0x7ccf5a).stroke({ width: pose.ink * 0.7, color: INK });
   g.poly(ellipsePoly(sway + 0.18 * r, -1.42 * r, 0.2 * r, 0.1 * r, -0.4)).fill(0x7ccf5a).stroke({ width: pose.ink * 0.7, color: INK });
 
 
-  g.circle(0, 0, r).fill(pose.color).stroke({ width: pose.ink, color: INK });
-  g.ellipse(-0.35 * r, -0.45 * r, 0.25 * r, 0.15 * r).fill({ color: 0xffffff, alpha: 0.55 });
-  g.circle(0.55 * r, 0.25 * r, 0.14 * r).fill({ color: 0xff3b6b, alpha: 0.35 });
+  body(g, 0, 0, r, r, pose.color, pose);
+  blush(g, 0.6 * r, 0.22 * r, 0.16 * r);
 
 
   eyes(
   eyes(
     g,
     g,
@@ -178,10 +255,6 @@ export const paintGeneric: Painter = (g, r, pose) => {
   stars(g, r, pose);
   stars(g, r, pose);
 };
 };
 
 
-function darken(color: number, k: number): number {
-  return (Math.round((color >> 16) * k) << 16) | (Math.round(((color >> 8) & 0xff) * k) << 8) | Math.round((color & 0xff) * k);
-}
-
 /** Mamie Tricot: lilac dress, pink shawl, grey bun skewered with knitting needles, round glasses. */
 /** Mamie Tricot: lilac dress, pink shawl, grey bun skewered with knitting needles, round glasses. */
 export const paintMamie: Painter = (g, r, pose) => {
 export const paintMamie: Painter = (g, r, pose) => {
   const step = pose.moving && pose.grounded ? Math.sin(pose.walk) : 0;
   const step = pose.moving && pose.grounded ? Math.sin(pose.walk) : 0;
@@ -194,14 +267,23 @@ export const paintMamie: Painter = (g, r, pose) => {
   g.circle(-0.32 * r, -1.08 * r, 0.3 * r).fill(0xe8e8f0).stroke({ width: pose.ink, color: INK });
   g.circle(-0.32 * r, -1.08 * r, 0.3 * r).fill(0xe8e8f0).stroke({ width: pose.ink, color: INK });
 
 
   // Dress, face, hair cap.
   // Dress, face, hair cap.
-  g.circle(0, 0, r).fill(0xb18cff).stroke({ width: pose.ink, color: INK });
+  body(g, 0, 0, r, r, 0xb18cff, pose);
   g.ellipse(0.18 * r, -0.2 * r, 0.68 * r, 0.6 * r).fill(0xffd9b8);
   g.ellipse(0.18 * r, -0.2 * r, 0.68 * r, 0.6 * r).fill(0xffd9b8);
   g.ellipse(-0.1 * r, -0.72 * r, 0.85 * r, 0.38 * r).fill(0xe8e8f0).stroke({ width: pose.ink * 0.7, color: INK });
   g.ellipse(-0.1 * r, -0.72 * r, 0.85 * r, 0.38 * r).fill(0xe8e8f0).stroke({ width: pose.ink * 0.7, color: INK });
   for (let i = 0; i < 4; i++) g.moveTo(-0.7 * r + i * 0.3 * r, -0.8 * r).quadraticCurveTo(-0.6 * r + i * 0.3 * r, -0.6 * r, -0.5 * r + i * 0.3 * r, -0.75 * r).stroke({ width: pose.ink * 0.4, color: 0xa0a8b0 });
   for (let i = 0; i < 4; i++) g.moveTo(-0.7 * r + i * 0.3 * r, -0.8 * r).quadraticCurveTo(-0.6 * r + i * 0.3 * r, -0.6 * r, -0.5 * r + i * 0.3 * r, -0.75 * r).stroke({ width: pose.ink * 0.4, color: 0xa0a8b0 });
 
 
   // Shawl draped over the shoulders.
   // Shawl draped over the shoulders.
   g.poly([-0.95 * r, 0.05 * r, 0.9 * r, 0.25 * r, 0.3 * r, 0.75 * r, -0.6 * r, 0.6 * r]).fill(0xff8fab).stroke({ width: pose.ink * 0.8, color: INK });
   g.poly([-0.95 * r, 0.05 * r, 0.9 * r, 0.25 * r, 0.3 * r, 0.75 * r, -0.6 * r, 0.6 * r]).fill(0xff8fab).stroke({ width: pose.ink * 0.8, color: INK });
-  for (let i = 0; i < 3; i++) g.circle(-0.4 * r + i * 0.35 * r, 0.42 * r, 0.06 * r).fill(0xffffff);
+  // Knitted stitches.
+  for (let row = 0; row < 2; row++) {
+    for (let i = 0; i < 5; i++) {
+      const kx = -0.6 * r + i * 0.28 * r + row * 0.14 * r;
+      const ky = 0.28 * r + row * 0.18 * r;
+      g.moveTo(kx - 0.06 * r, ky - 0.05 * r).lineTo(kx, ky + 0.04 * r).lineTo(kx + 0.06 * r, ky - 0.05 * r).stroke({ width: pose.ink * 0.4, color: 0xd9547a });
+    }
+  }
+  // Pearl necklace.
+  for (let i = 0; i < 6; i++) g.circle(-0.05 * r + i * 0.13 * r, 0.12 * r + Math.sin(i * 0.6) * 0.05 * r, 0.055 * r).fill(0xfffaf0).stroke({ width: pose.ink * 0.3, color: INK });
 
 
   eyes(
   eyes(
     g,
     g,
@@ -213,9 +295,12 @@ export const paintMamie: Painter = (g, r, pose) => {
     0.12 * r,
     0.12 * r,
   );
   );
   // Round glasses.
   // Round glasses.
-  for (const ex of [0.2, 0.56]) g.circle(ex * r, -0.21 * r, 0.19 * r).stroke({ width: pose.ink * 0.6, color: INK });
+  for (const ex of [0.2, 0.56]) {
+    g.circle(ex * r, -0.21 * r, 0.19 * r).fill({ color: 0xbfe6ff, alpha: 0.2 }).stroke({ width: pose.ink * 0.6, color: INK });
+    g.moveTo(ex * r + 0.02 * r, -0.33 * r).lineTo(ex * r + 0.11 * r, -0.26 * r).stroke({ width: pose.ink * 0.4, color: 0xffffff, alpha: 0.9 });
+  }
   g.moveTo(0.39 * r, -0.22 * r).lineTo(0.37 * r, -0.22 * r).stroke({ width: pose.ink * 0.6, color: INK });
   g.moveTo(0.39 * r, -0.22 * r).lineTo(0.37 * r, -0.22 * r).stroke({ width: pose.ink * 0.6, color: INK });
-  g.circle(0.68 * r, 0.02 * r, 0.11 * r).fill({ color: 0xff3b6b, alpha: 0.4 });
+  blush(g, 0.68 * r, 0.02 * r, 0.12 * r);
   if (pose.attack) {
   if (pose.attack) {
     g.circle(0.42 * r, 0.1 * r, 0.08 * r).fill(0x5a1e1e);
     g.circle(0.42 * r, 0.1 * r, 0.08 * r).fill(0x5a1e1e);
     // Needles poking forward.
     // Needles poking forward.
@@ -258,8 +343,10 @@ export const paintPyromanchot: Painter = (g, r, pose) => {
     g.circle(-0.85 * r, -1.28 * r, 0.06 * r).fill(0xffffff);
     g.circle(-0.85 * r, -1.28 * r, 0.06 * r).fill(0xffffff);
   }
   }
 
 
-  g.ellipse(0, 0, r * (1 - waddle * 0.3), r * 1.05).fill(black).stroke({ width: pose.ink, color: INK });
-  g.ellipse(0.25 * r, 0.18 * r, 0.6 * r, 0.75 * r).fill(0xffffff);
+  body(g, 0, 0, r * (1 - waddle * 0.3), r * 1.05, black, pose);
+  g.ellipse(0.25 * r, 0.18 * r, 0.6 * r, 0.75 * r).fill(0xf2f4f8);
+  g.ellipse(0.32 * r, 0.05 * r, 0.42 * r, 0.55 * r).fill(0xffffff);
+  blush(g, 0.62 * r, 0.02 * r, 0.12 * r);
   // Flippers.
   // Flippers.
   const raise = pose.attack ? -0.6 : 0;
   const raise = pose.attack ? -0.6 : 0;
   g.poly(ellipsePoly(0.75 * r, (0.15 + raise * 0.4) * r, 0.18 * r, 0.42 * r, 0.5 + raise)).fill(black).stroke({ width: pose.ink * 0.8, color: INK });
   g.poly(ellipsePoly(0.75 * r, (0.15 + raise * 0.4) * r, 0.18 * r, 0.42 * r, 0.5 + raise)).fill(black).stroke({ width: pose.ink * 0.8, color: INK });
@@ -270,6 +357,10 @@ export const paintPyromanchot: Painter = (g, r, pose) => {
   }
   }
   // Headband.
   // Headband.
   g.rect(-0.95 * r, -0.68 * r, 1.85 * r, 0.2 * r).fill(0xe0123a).stroke({ width: pose.ink * 0.6, color: INK });
   g.rect(-0.95 * r, -0.68 * r, 1.85 * r, 0.2 * r).fill(0xe0123a).stroke({ width: pose.ink * 0.6, color: INK });
+  g.moveTo(-0.95 * r, -0.62 * r).lineTo(0.9 * r, -0.62 * r).stroke({ width: pose.ink * 0.35, color: 0xff6f6f });
+  // Knot flapping behind.
+  const flap = Math.sin(pose.time * 9) * 0.08 * r;
+  g.poly([-0.9 * r, -0.6 * r, -1.35 * r, -0.75 * r + flap, -1.25 * r, -0.5 * r + flap]).fill(0xe0123a).stroke({ width: pose.ink * 0.6, color: INK });
   eyes(
   eyes(
     g,
     g,
     pose,
     pose,
@@ -294,20 +385,30 @@ export const paintGege: Painter = (g, r, pose) => {
     g.ellipse(0, 0.55 * r, 1.1 * r, 0.5 * r).fill(0xc8964b).stroke({ width: pose.ink, color: INK });
     g.ellipse(0, 0.55 * r, 1.1 * r, 0.5 * r).fill(0xc8964b).stroke({ width: pose.ink, color: INK });
     for (let i = 0; i < 5; i++) g.circle(-0.7 * r + i * 0.35 * r, 0.3 * r + (i % 2) * 0.1 * r, 0.12 * r).fill(0xf2c46d);
     for (let i = 0; i < 5; i++) g.circle(-0.7 * r + i * 0.35 * r, 0.3 * r + (i % 2) * 0.1 * r, 0.12 * r).fill(0xf2c46d);
     eyes(g, pose, [[0.1 * r, 0.05 * r], [0.45 * r, 0.07 * r]], 0.17 * r);
     eyes(g, pose, [[0.1 * r, 0.05 * r], [0.45 * r, 0.07 * r]], 0.17 * r);
+    // The lamp of his helmet glows out of the crumbs.
+    g.circle(0.3 * r, -0.28 * r, 0.11 * r).fill(0xfff7b0).stroke({ width: pose.ink * 0.5, color: INK });
     return;
     return;
   }
   }
   const wiggle = pose.moving ? Math.sin(pose.walk) * 0.12 * r : Math.sin(pose.time * 3) * 0.05 * r;
   const wiggle = pose.moving ? Math.sin(pose.walk) * 0.12 * r : Math.sin(pose.time * 3) * 0.05 * r;
   // Tail segments behind.
   // Tail segments behind.
   for (let i = 3; i >= 1; i--) {
   for (let i = 3; i >= 1; i--) {
     const sr = r * (0.75 - i * 0.12);
     const sr = r * (0.75 - i * 0.12);
-    g.circle(-i * 0.55 * r, 0.35 * r + (i % 2 ? wiggle : -wiggle), sr).fill(i % 2 ? pink : dark).stroke({ width: pose.ink, color: INK });
+    const sy = 0.35 * r + (i % 2 ? wiggle : -wiggle);
+    body(g, -i * 0.55 * r, sy, sr, sr, i % 2 ? pink : dark, pose);
+    g.moveTo(-i * 0.55 * r - sr * 0.2, sy - sr * 0.9).quadraticCurveTo(-i * 0.55 * r - sr * 0.45, sy, -i * 0.55 * r - sr * 0.2, sy + sr * 0.9).stroke({ width: pose.ink * 0.45, color: darken(pink, 0.7) });
   }
   }
   const lunge = pose.attack ? 0.25 * r : 0;
   const lunge = pose.attack ? 0.25 * r : 0;
-  g.circle(lunge, 0, r).fill(pink).stroke({ width: pose.ink, color: INK });
+  body(g, lunge, 0, r, r, pink, pose);
   for (let i = -1; i <= 1; i++) g.moveTo(lunge - 0.6 * r, i * 0.25 * r).lineTo(lunge - 0.85 * r, i * 0.3 * r).stroke({ width: pose.ink * 0.5, color: dark });
   for (let i = -1; i <= 1; i++) g.moveTo(lunge - 0.6 * r, i * 0.25 * r).lineTo(lunge - 0.85 * r, i * 0.3 * r).stroke({ width: pose.ink * 0.5, color: dark });
-  g.rect(lunge - 0.95 * r, -0.62 * r, 1.9 * r, 0.18 * r).fill(0x4dc4ff).stroke({ width: pose.ink * 0.6, color: INK });
+  // Miner's helmet with a lamp.
+  g.poly(dome(lunge - 0.05 * r, -0.55 * r, 0.85 * r, 0.5 * r)).fill(0xffd23f).stroke({ width: pose.ink, color: INK });
+  g.rect(lunge - 0.98 * r, -0.6 * r, 1.9 * r, 0.14 * r).fill(0xe0a800).stroke({ width: pose.ink * 0.6, color: INK });
+  g.roundRect(lunge + 0.38 * r, -0.98 * r, 0.3 * r, 0.24 * r, 0.06 * r).fill(0x8899aa).stroke({ width: pose.ink * 0.6, color: INK });
+  g.circle(lunge + 0.68 * r, -0.86 * r, 0.11 * r).fill(0xfff7b0).stroke({ width: pose.ink * 0.5, color: INK });
+  g.poly([lunge + 0.75 * r, -0.92 * r, lunge + 1.9 * r, -1.25 * r, lunge + 1.9 * r, -0.45 * r, lunge + 0.75 * r, -0.8 * r]).fill({ color: 0xfff7b0, alpha: 0.28 });
   eyes(g, pose, [[lunge + 0.25 * r, -0.2 * r], [lunge + 0.6 * r, -0.18 * r]], 0.18 * r);
   eyes(g, pose, [[lunge + 0.25 * r, -0.2 * r], [lunge + 0.6 * r, -0.18 * r]], 0.18 * r);
   g.moveTo(lunge + 0.3 * r, 0.3 * r).quadraticCurveTo(lunge + 0.5 * r, 0.45 * r, lunge + 0.7 * r, 0.28 * r).stroke({ width: pose.ink * 0.7, color: INK, cap: 'round' });
   g.moveTo(lunge + 0.3 * r, 0.3 * r).quadraticCurveTo(lunge + 0.5 * r, 0.45 * r, lunge + 0.7 * r, 0.28 * r).stroke({ width: pose.ink * 0.7, color: INK, cap: 'round' });
+  blush(g, lunge + 0.72 * r, 0.1 * r, 0.13 * r);
   stars(g, r, pose);
   stars(g, r, pose);
 };
 };
 
 
@@ -315,12 +416,19 @@ export const paintGege: Painter = (g, r, pose) => {
 export const paintPompier: Painter = (g, r, pose) => {
 export const paintPompier: Painter = (g, r, pose) => {
   const step = pose.moving && pose.grounded ? Math.sin(pose.walk) : 0;
   const step = pose.moving && pose.grounded ? Math.sin(pose.walk) : 0;
   foot(g, -0.35 * r - step * 0.18 * r, 0.93 * r, r, 0x5a3a22, pose.ink);
   foot(g, -0.35 * r - step * 0.18 * r, 0.93 * r, r, 0x5a3a22, pose.ink);
-  g.circle(0, 0, r).fill(0xffd27a).stroke({ width: pose.ink, color: INK });
+  body(g, 0, 0, r, r, 0xffd27a, pose);
+  // Reflective stripes on the coat.
+  g.rect(-0.85 * r, 0.42 * r, 1.7 * r, 0.12 * r).fill(0xd8e0e8).stroke({ width: pose.ink * 0.4, color: INK });
   g.ellipse(0.2 * r, -0.15 * r, 0.62 * r, 0.55 * r).fill(0xffdcc4);
   g.ellipse(0.2 * r, -0.15 * r, 0.62 * r, 0.55 * r).fill(0xffdcc4);
+  blush(g, 0.66 * r, 0.02 * r, 0.12 * r);
+  blush(g, 0.0 * r, 0.02 * r, 0.1 * r);
   // Helmet with a badge.
   // Helmet with a badge.
   g.poly(ellipsePoly(0, -0.62 * r, 1.0 * r, 0.42 * r, 0)).fill(0xe0123a).stroke({ width: pose.ink, color: INK });
   g.poly(ellipsePoly(0, -0.62 * r, 1.0 * r, 0.42 * r, 0)).fill(0xe0123a).stroke({ width: pose.ink, color: INK });
   g.rect(-1.1 * r, -0.5 * r, 2.2 * r, 0.14 * r).fill(0xc00f30).stroke({ width: pose.ink * 0.6, color: INK });
   g.rect(-1.1 * r, -0.5 * r, 2.2 * r, 0.14 * r).fill(0xc00f30).stroke({ width: pose.ink * 0.6, color: INK });
   g.circle(0.15 * r, -0.72 * r, 0.14 * r).fill(0xffe14d).stroke({ width: pose.ink * 0.5, color: INK });
   g.circle(0.15 * r, -0.72 * r, 0.14 * r).fill(0xffe14d).stroke({ width: pose.ink * 0.5, color: INK });
+  g.ellipse(-0.35 * r, -0.82 * r, 0.25 * r, 0.1 * r).fill({ color: 0xffffff, alpha: 0.4 });
+  // A curl of hair escaping the helmet.
+  g.moveTo(0.55 * r, -0.42 * r).quadraticCurveTo(0.75 * r, -0.62 * r, 0.62 * r, -0.72 * r).stroke({ width: pose.ink * 0.6, color: 0x8a5a2a, cap: 'round' });
   eyes(g, pose, [[0.15 * r, -0.2 * r], [0.5 * r, -0.18 * r]], 0.15 * r);
   eyes(g, pose, [[0.15 * r, -0.2 * r], [0.5 * r, -0.18 * r]], 0.15 * r);
   // Pacifier.
   // Pacifier.
   g.circle(0.48 * r, 0.22 * r, 0.16 * r).fill(0x4dc4ff).stroke({ width: pose.ink * 0.6, color: INK });
   g.circle(0.48 * r, 0.22 * r, 0.16 * r).fill(0x4dc4ff).stroke({ width: pose.ink * 0.6, color: INK });
@@ -343,10 +451,14 @@ export const paintProut: Painter = (g, r, pose) => {
   const flap = Math.sin(pose.time * 8) * 0.15 * r + (pose.special ? 0.3 * r : 0);
   const flap = Math.sin(pose.time * 8) * 0.15 * r + (pose.special ? 0.3 * r : 0);
   // Cape behind, flapping.
   // Cape behind, flapping.
   g.poly([-0.3 * r, -0.6 * r, -1.35 * r - flap, 0.5 * r, -1.1 * r - flap, 0.9 * r, 0.1 * r, 0.6 * r]).fill(0x8a3fc9).stroke({ width: pose.ink, color: INK });
   g.poly([-0.3 * r, -0.6 * r, -1.35 * r - flap, 0.5 * r, -1.1 * r - flap, 0.9 * r, 0.1 * r, 0.6 * r]).fill(0x8a3fc9).stroke({ width: pose.ink, color: INK });
+  g.poly([-0.3 * r, -0.5 * r, -1.0 * r - flap * 0.7, 0.45 * r, -0.85 * r - flap * 0.7, 0.75 * r]).fill({ color: 0x5e2290, alpha: 0.6 });
   foot(g, -0.35 * r - step * 0.18 * r, 0.93 * r, r, 0x2a2a3a, pose.ink);
   foot(g, -0.35 * r - step * 0.18 * r, 0.93 * r, r, 0x2a2a3a, pose.ink);
-  g.circle(0, 0, r).fill(0x9be15d).stroke({ width: pose.ink, color: INK });
+  body(g, 0, 0, r, r, 0x9be15d, pose);
   // Mask.
   // Mask.
-  g.rect(-0.1 * r, -0.42 * r, 1.0 * r, 0.32 * r).fill(0x2a2a3a);
+  g.roundRect(-0.15 * r, -0.44 * r, 1.07 * r, 0.36 * r, 0.12 * r).fill(0x8a3fc9).stroke({ width: pose.ink * 0.6, color: INK });
+  g.poly([-0.15 * r, -0.36 * r, -0.45 * r, -0.5 * r - Math.sin(pose.time * 8) * 0.05 * r, -0.42 * r, -0.25 * r]).fill(0x8a3fc9).stroke({ width: pose.ink * 0.5, color: INK });
+  // Belt.
+  g.rect(-0.95 * r, 0.62 * r, 1.9 * r, 0.14 * r).fill(0xffe14d).stroke({ width: pose.ink * 0.5, color: INK });
   eyes(g, pose, [[0.25 * r, -0.26 * r], [0.62 * r, -0.24 * r]], 0.13 * r);
   eyes(g, pose, [[0.25 * r, -0.26 * r], [0.62 * r, -0.24 * r]], 0.13 * r);
   // Emblem.
   // Emblem.
   g.circle(0.15 * r, 0.35 * r, 0.3 * r).fill(0xffe14d).stroke({ width: pose.ink * 0.7, color: INK });
   g.circle(0.15 * r, 0.35 * r, 0.3 * r).fill(0xffe14d).stroke({ width: pose.ink * 0.7, color: INK });
@@ -369,9 +481,14 @@ export const paintChat: Painter = (g, r, pose) => {
     g.poly([ex * r - 0.25 * r, -0.65 * r, ex * r, -1.2 * r, ex * r + 0.28 * r, -0.62 * r]).fill(ginger).stroke({ width: pose.ink, color: INK });
     g.poly([ex * r - 0.25 * r, -0.65 * r, ex * r, -1.2 * r, ex * r + 0.28 * r, -0.62 * r]).fill(ginger).stroke({ width: pose.ink, color: INK });
     g.poly([ex * r - 0.12 * r, -0.7 * r, ex * r, -1.0 * r, ex * r + 0.14 * r, -0.68 * r]).fill(0xffb6c8);
     g.poly([ex * r - 0.12 * r, -0.7 * r, ex * r, -1.0 * r, ex * r + 0.14 * r, -0.68 * r]).fill(0xffb6c8);
   }
   }
-  g.circle(0, 0, r).fill(ginger).stroke({ width: pose.ink, color: INK });
+  body(g, 0, 0, r, r, ginger, pose);
   for (let i = 0; i < 3; i++) g.moveTo(-0.6 * r + i * 0.25 * r, -0.85 * r).lineTo(-0.5 * r + i * 0.25 * r, -0.6 * r).stroke({ width: pose.ink * 0.6, color: dark });
   for (let i = 0; i < 3; i++) g.moveTo(-0.6 * r + i * 0.25 * r, -0.85 * r).lineTo(-0.5 * r + i * 0.25 * r, -0.6 * r).stroke({ width: pose.ink * 0.6, color: dark });
   g.ellipse(0.25 * r, 0.3 * r, 0.45 * r, 0.4 * r).fill(0xfff3e0);
   g.ellipse(0.25 * r, 0.3 * r, 0.45 * r, 0.4 * r).fill(0xfff3e0);
+  for (let i = 0; i < 3; i++) g.moveTo(-0.92 * r, -0.1 * r + i * 0.25 * r).quadraticCurveTo(-0.7 * r, -0.05 * r + i * 0.25 * r, -0.55 * r, -0.12 * r + i * 0.25 * r).stroke({ width: pose.ink * 0.7, color: dark, cap: 'round' });
+  // Collar and its bell.
+  g.moveTo(-0.6 * r, 0.62 * r).quadraticCurveTo(0.1 * r, 0.82 * r, 0.75 * r, 0.55 * r).stroke({ width: pose.ink * 1.6, color: 0xe0123a, cap: 'round' });
+  g.circle(0.2 * r, 0.8 * r, 0.12 * r).fill(0xffd23f).stroke({ width: pose.ink * 0.5, color: INK });
+  blush(g, 0.72 * r, 0.12 * r, 0.11 * r);
   if (pose.busy) {
   if (pose.busy) {
     // Eyes closed, paw up at the mouth.
     // Eyes closed, paw up at the mouth.
     g.moveTo(0.1 * r, -0.2 * r).lineTo(0.35 * r, -0.2 * r).moveTo(0.48 * r, -0.18 * r).lineTo(0.72 * r, -0.18 * r).stroke({ width: pose.ink * 0.8, color: INK });
     g.moveTo(0.1 * r, -0.2 * r).lineTo(0.35 * r, -0.2 * r).moveTo(0.48 * r, -0.18 * r).lineTo(0.72 * r, -0.18 * r).stroke({ width: pose.ink * 0.8, color: INK });
@@ -391,16 +508,21 @@ export const paintChat: Painter = (g, r, pose) => {
 export const paintMagicien: Painter = (g, r, pose) => {
 export const paintMagicien: Painter = (g, r, pose) => {
   const step = pose.moving && pose.grounded ? Math.sin(pose.walk) : 0;
   const step = pose.moving && pose.grounded ? Math.sin(pose.walk) : 0;
   foot(g, -0.35 * r - step * 0.18 * r, 0.93 * r, r, 0x2a2a6a, pose.ink);
   foot(g, -0.35 * r - step * 0.18 * r, 0.93 * r, r, 0x2a2a6a, pose.ink);
-  g.circle(0, 0, r).fill(0x3d5adb).stroke({ width: pose.ink, color: INK });
+  body(g, 0, 0, r, r, 0x3d5adb, pose);
   for (const [sx, sy] of [[-0.5, 0.2], [-0.2, 0.6], [-0.6, -0.3]]) g.circle(sx * r, sy * r, 0.07 * r).fill(0xffe14d);
   for (const [sx, sy] of [[-0.5, 0.2], [-0.2, 0.6], [-0.6, -0.3]]) g.circle(sx * r, sy * r, 0.07 * r).fill(0xffe14d);
   g.ellipse(0.25 * r, -0.2 * r, 0.55 * r, 0.45 * r).fill(0xffdcc4);
   g.ellipse(0.25 * r, -0.2 * r, 0.55 * r, 0.45 * r).fill(0xffdcc4);
   // Hat.
   // Hat.
   const tilt = Math.sin(pose.time * 2) * 0.05 * r;
   const tilt = Math.sin(pose.time * 2) * 0.05 * r;
   g.poly([-0.75 * r, -0.55 * r, 0.75 * r, -0.55 * r, 0.1 * r + tilt, -1.75 * r]).fill(0x2a2a6a).stroke({ width: pose.ink, color: INK });
   g.poly([-0.75 * r, -0.55 * r, 0.75 * r, -0.55 * r, 0.1 * r + tilt, -1.75 * r]).fill(0x2a2a6a).stroke({ width: pose.ink, color: INK });
-  g.circle(0.05 * r + tilt, -1.0 * r, 0.12 * r).fill(0xffe14d);
+  g.circle(0.05 * r + tilt, -1.0 * r, 0.14 * r).fill(0xffe14d);
+  g.circle(0.11 * r + tilt, -1.04 * r, 0.12 * r).fill(0x2a2a6a);
+  for (const [hx, hy] of [[-0.3, -0.7], [0.3, -1.3], [0.4, -0.75]]) g.circle(hx * r + tilt * 0.5, hy * r, 0.05 * r).fill(0xffffff);
+  g.poly([-0.82 * r, -0.5 * r, 0.82 * r, -0.5 * r, 0.75 * r, -0.62 * r, -0.75 * r, -0.62 * r]).fill(0x1e1e55).stroke({ width: pose.ink * 0.6, color: INK });
   eyes(g, pose, [[0.15 * r, -0.28 * r], [0.48 * r, -0.26 * r]], 0.13 * r);
   eyes(g, pose, [[0.15 * r, -0.28 * r], [0.48 * r, -0.26 * r]], 0.13 * r);
   // Beard.
   // Beard.
-  g.poly([0.0 * r, 0.0 * r, 0.75 * r, 0.0 * r, 0.42 * r, 0.85 * r]).fill(0xffffff).stroke({ width: pose.ink * 0.8, color: INK });
+  g.poly([-0.05 * r, -0.02 * r, 0.8 * r, -0.02 * r, 0.65 * r, 0.45 * r, 0.42 * r, 0.9 * r, 0.2 * r, 0.45 * r]).fill(0xffffff).stroke({ width: pose.ink * 0.8, color: INK });
+  g.moveTo(0.2 * r, 0.15 * r).lineTo(0.32 * r, 0.5 * r).moveTo(0.5 * r, 0.12 * r).lineTo(0.45 * r, 0.55 * r).stroke({ width: pose.ink * 0.4, color: 0xc8d0dc });
+  g.ellipse(0.38 * r, 0.02 * r, 0.12 * r, 0.07 * r).fill(0xffb39a).stroke({ width: pose.ink * 0.4, color: INK });
   // Wand.
   // Wand.
   const raise = pose.special || pose.attack ? -0.6 : 0;
   const raise = pose.special || pose.attack ? -0.6 : 0;
   g.moveTo(0.7 * r, 0.25 * r).lineTo(1.35 * r, (0.05 + raise) * r).stroke({ width: pose.ink * 1.1, color: 0x5a3a22, cap: 'round' });
   g.moveTo(0.7 * r, 0.25 * r).lineTo(1.35 * r, (0.05 + raise) * r).stroke({ width: pose.ink * 1.1, color: 0x5a3a22, cap: 'round' });

+ 10 - 0
src/renderer/config.ts

@@ -16,6 +16,14 @@ export const CAVITY_DARKEN = 0.64;
 export const DROP_SHADOW_DX = 5;
 export const DROP_SHADOW_DX = 5;
 export const DROP_SHADOW_DY = 7;
 export const DROP_SHADOW_DY = 7;
 export const DROP_SHADOW_ALPHA = 70;
 export const DROP_SHADOW_ALPHA = 70;
+/** Width (px) of the drop shadow's soft edge. */
+export const DROP_SHADOW_SOFTNESS = 2.5;
+/** Terrain texels per world pixel (power of two): edges and textures stay smooth when zoomed in. */
+export const TERRAIN_SUPERSAMPLE = 2;
+/** Width (px) of the antialiased terrain edges. */
+export const TERRAIN_AA = 0.7;
+/** Highest renderer resolution (device pixels per CSS pixel), to keep 60 FPS on 4K screens. */
+export const MAX_RESOLUTION = 2;
 
 
 export const SHAKE_PER_PIXEL = 0.004;
 export const SHAKE_PER_PIXEL = 0.004;
 export const SHAKE_MAX = 12;
 export const SHAKE_MAX = 12;
@@ -39,4 +47,6 @@ export const POPUP_MIN_PIXELS = 250;
 export const POPUP_COOLDOWN = 0.45;
 export const POPUP_COOLDOWN = 0.45;
 export const INPUT_SEND_HZ = 60;
 export const INPUT_SEND_HZ = 60;
 export const SQUASH_RECOVERY = 10;
 export const SQUASH_RECOVERY = 10;
+/** Max forward lean (radians) of a running character. */
+export const RUN_LEAN = 0.12;
 export const PLAYER_COLORS = [0xff6f91, 0x4dc4ff, 0x9be15d, 0xffc94d, 0xb18cff, 0xff9a4d, 0x4de0c4, 0xf15bb5];
 export const PLAYER_COLORS = [0xff6f91, 0x4dc4ff, 0x9be15d, 0xffc94d, 0xb18cff, 0xff9a4d, 0x4de0c4, 0xf15bb5];

+ 9 - 2
src/renderer/entities/PlayerView.ts

@@ -3,7 +3,7 @@ import { Anim } from '../../shared/characters';
 import type { PlayerInfo, PlayerState } from '../../shared/protocol';
 import type { PlayerInfo, PlayerState } from '../../shared/protocol';
 import { PAINTERS, paintGeneric, type Painter, type Pose } from '../characters/painters';
 import { PAINTERS, paintGeneric, type Painter, type Pose } from '../characters/painters';
 import { paintItem } from './RoundViews';
 import { paintItem } from './RoundViews';
-import { OUTLINE_COLOR, OUTLINE_WIDTH, PLAYER_COLORS, SQUASH_RECOVERY } from '../config';
+import { OUTLINE_COLOR, OUTLINE_WIDTH, PLAYER_COLORS, RUN_LEAN, SQUASH_RECOVERY } from '../config';
 
 
 /** Max size the fat ratio is normalized against (castor maxSize is in its JSON; this only drives the cheeks). */
 /** Max size the fat ratio is normalized against (castor maxSize is in its JSON; this only drives the cheeks). */
 const FAT_RANGE = 26;
 const FAT_RANGE = 26;
@@ -18,6 +18,8 @@ export interface PlayerEvents {
 export class PlayerView {
 export class PlayerView {
   readonly container = new Container();
   readonly container = new Container();
   private readonly body = new Graphics();
   private readonly body = new Graphics();
+  /** Soft contact shadow on the ground. */
+  private readonly shadow = new Graphics();
   /** Carried item (candle) held up next to the character. */
   /** Carried item (candle) held up next to the character. */
   private readonly held = new Graphics();
   private readonly held = new Graphics();
   private readonly name: Text;
   private readonly name: Text;
@@ -39,12 +41,13 @@ export class PlayerView {
     });
     });
     this.name.anchor.set(0.5, 1);
     this.name.anchor.set(0.5, 1);
     this.held.scale.set(0.8);
     this.held.scale.set(0.8);
-    this.container.addChild(this.body, this.held, this.name);
+    this.container.addChild(this.shadow, this.body, this.held, this.name);
   }
   }
 
 
   /** Draws a static pose (menu portraits). */
   /** Draws a static pose (menu portraits). */
   drawIdle(): void {
   drawIdle(): void {
     this.draw(this.info.radius, 1, 0.6, -0.2, { moving: false, grounded: true, attack: false, special: false, stunned: false, busy: false, underground: false });
     this.draw(this.info.radius, 1, 0.6, -0.2, { moving: false, grounded: true, attack: false, special: false, stunned: false, busy: false, underground: false });
+    this.shadow.clear();
   }
   }
 
 
   update(state: PlayerState, aimX: number, aimY: number, dt: number): PlayerEvents {
   update(state: PlayerState, aimX: number, aimY: number, dt: number): PlayerEvents {
@@ -81,6 +84,10 @@ export class PlayerView {
     const dx = (aimX - state.x) * state.facing;
     const dx = (aimX - state.x) * state.facing;
     const dy = aimY - state.y;
     const dy = aimY - state.y;
     const len = Math.hypot(dx, dy) || 1;
     const len = Math.hypot(dx, dy) || 1;
+    // Leans into the run.
+    this.body.rotation = Math.max(-RUN_LEAN, Math.min(RUN_LEAN, (state.vx / 600) * RUN_LEAN));
+    this.shadow.clear();
+    if (state.grounded) this.shadow.ellipse(0, size * 0.98, size * 0.85, size * 0.18).fill({ color: OUTLINE_COLOR, alpha: 0.18 });
     this.draw(size, state.facing, dx / len, dy / len, {
     this.draw(size, state.facing, dx / len, dy / len, {
       moving,
       moving,
       grounded: state.grounded,
       grounded: state.grounded,

+ 13 - 3
src/renderer/main.ts

@@ -17,6 +17,7 @@ import {
   EXPLOSION_SHAKE,
   EXPLOSION_SHAKE,
   FREEZE_FRAME_MS,
   FREEZE_FRAME_MS,
   HIT_SHAKE,
   HIT_SHAKE,
+  MAX_RESOLUTION,
   POISON_BLUR,
   POISON_BLUR,
   INPUT_SEND_HZ,
   INPUT_SEND_HZ,
   LANDING_DUST_MIN_VY,
   LANDING_DUST_MIN_VY,
@@ -69,7 +70,9 @@ async function loadWorld(mapId: string, materials: MaterialRegistry): Promise<Wo
   const def = parseMapDefinition(files.definition, mapId);
   const def = parseMapDefinition(files.definition, mapId);
   const terrain = buildTerrain(def, files.terrain, materials);
   const terrain = buildTerrain(def, files.terrain, materials);
   const bitmap = await createImageBitmap(new Blob([files.background as BlobPart], { type: 'image/png' }));
   const bitmap = await createImageBitmap(new Blob([files.background as BlobPart], { type: 'image/png' }));
-  const background = new Sprite(new Texture({ source: new ImageSource({ resource: bitmap }) }));
+  // Backgrounds may be drawn at a higher resolution than the map (gen-maps draws them at 2×).
+  const resolution = Math.max(1, bitmap.width / terrain.width);
+  const background = new Sprite(new Texture({ source: new ImageSource({ resource: bitmap, resolution, scaleMode: 'linear' }) }));
   return { materials, terrain, view: new TerrainView(terrain, materials), background, lastSeq: 0 };
   return { materials, terrain, view: new TerrainView(terrain, materials), background, lastSeq: 0 };
 }
 }
 
 
@@ -94,7 +97,7 @@ function makePortraits(app: Application): Map<string, HTMLCanvasElement> {
   PLAYABLE_CHARACTER_IDS.forEach((characterId, i) => {
   PLAYABLE_CHARACTER_IDS.forEach((characterId, i) => {
     const view = new PlayerView({ id: i + 1, name: '', characterId, radius: PORTRAIT_RADIUS });
     const view = new PlayerView({ id: i + 1, name: '', characterId, radius: PORTRAIT_RADIUS });
     view.drawIdle();
     view.drawIdle();
-    out.set(characterId, app.renderer.extract.canvas({ target: view.container, resolution: 2 }) as HTMLCanvasElement);
+    out.set(characterId, app.renderer.extract.canvas({ target: view.container, resolution: 3 }) as HTMLCanvasElement);
     view.destroy();
     view.destroy();
   });
   });
   return out;
   return out;
@@ -118,7 +121,14 @@ async function chooseGame(app: Application): Promise<{ choice: MenuChoice; addre
 async function start(): Promise<void> {
 async function start(): Promise<void> {
   const app = new Application();
   const app = new Application();
   const host = document.getElementById('game')!;
   const host = document.getElementById('game')!;
-  await app.init({ resizeTo: window, background: 0xfff1d6, antialias: true });
+  await app.init({
+    resizeTo: window,
+    background: 0xfff1d6,
+    antialias: true,
+    // Render at the screen's real pixel density, or everything is upscaled (and blurry) by the browser.
+    resolution: Math.min(MAX_RESOLUTION, window.devicePixelRatio || 1),
+    autoDensity: true,
+  });
   host.appendChild(app.canvas);
   host.appendChild(app.canvas);
 
 
   const { choice, address, port } = await chooseGame(app);
   const { choice, address, port } = await chooseGame(app);

+ 218 - 125
src/renderer/terrain/TerrainView.ts

@@ -1,5 +1,5 @@
 import { CanvasSource, Container, Sprite, Texture } from 'pixi.js';
 import { CanvasSource, Container, Sprite, Texture } from 'pixi.js';
-import { CHUNK_SIZE, MATERIAL_EMPTY, OUTLINE_PX } from '../../shared/constants';
+import { CHUNK_SIZE, MATERIAL_EMPTY, OUTLINE_PX, RENDER_MARGIN } from '../../shared/constants';
 import { parseHexColor, type MaterialRegistry } from '../../shared/materials';
 import { parseHexColor, type MaterialRegistry } from '../../shared/materials';
 import type { TerrainBitmap } from '../../shared/terrain/bitmap';
 import type { TerrainBitmap } from '../../shared/terrain/bitmap';
 import type { TerrainEvent } from '../../shared/terrain/events';
 import type { TerrainEvent } from '../../shared/terrain/events';
@@ -10,13 +10,17 @@ import {
   DROP_SHADOW_ALPHA,
   DROP_SHADOW_ALPHA,
   DROP_SHADOW_DX,
   DROP_SHADOW_DX,
   DROP_SHADOW_DY,
   DROP_SHADOW_DY,
+  DROP_SHADOW_SOFTNESS,
   OUTLINE_COLOR,
   OUTLINE_COLOR,
   RIM_LIGHT_DEPTH,
   RIM_LIGHT_DEPTH,
   RIM_LIGHT_WHITE,
   RIM_LIGHT_WHITE,
+  TERRAIN_AA,
+  TERRAIN_SUPERSAMPLE,
   UNDERSIDE_DARKEN,
   UNDERSIDE_DARKEN,
   UNDERSIDE_DEPTH,
   UNDERSIDE_DEPTH,
 } from '../config';
 } from '../config';
-import { hash2, makeThicknessTile, makeTile, tileIndex } from './textures';
+import { sampleBilinear, signedDistance } from './distance';
+import { hash2, makeThicknessTile, makeTile, scaledTileIndex, tileIndex } from './textures';
 
 
 interface ChunkSprite {
 interface ChunkSprite {
   ctx: CanvasRenderingContext2D;
   ctx: CanvasRenderingContext2D;
@@ -25,23 +29,30 @@ interface ChunkSprite {
   sprite: Sprite;
   sprite: Sprite;
 }
 }
 
 
-/** Probes for the felt-pen outline, sorted by distance so the scan can stop at the local thickness. */
-const OUTLINE_PROBES: [dx: number, dy: number, d: number][] = [];
-for (let dy = -OUTLINE_PX; dy <= OUTLINE_PX; dy++) {
-  for (let dx = -OUTLINE_PX; dx <= OUTLINE_PX; dx++) {
-    const d = Math.hypot(dx, dy);
-    if (d > 0 && d <= OUTLINE_PX + 0.5) OUTLINE_PROBES.push([dx, dy, d]);
-  }
-}
-OUTLINE_PROBES.sort((a, b) => a[2] - b[2]);
-
+const S = TERRAIN_SUPERSAMPLE;
+/** Texture side of one terrain chunk. */
+const TEX = CHUNK_SIZE * S;
+/** The distance field window around a chunk: enough for the outline, the shading and the drop shadow. */
+const M = RENDER_MARGIN;
+const WIN = CHUNK_SIZE + 2 * M;
 /** Column scan margin: how far above/below the chunk the lighting looks. */
 /** Column scan margin: how far above/below the chunk the lighting looks. */
 const SCAN = Math.max(RIM_LIGHT_DEPTH, UNDERSIDE_DEPTH, CAVITY_SHADOW_DEPTH) + OUTLINE_PX + 1;
 const SCAN = Math.max(RIM_LIGHT_DEPTH, UNDERSIDE_DEPTH, CAVITY_SHADOW_DEPTH) + OUTLINE_PX + 1;
+const OR = OUTLINE_COLOR >> 16;
+const OG = (OUTLINE_COLOR >> 8) & 0xff;
+const OB = OUTLINE_COLOR & 0xff;
+
+/** 0..1 coverage of a signed distance (positive inside), antialiased over TERRAIN_AA pixels. */
+function coverage(sd: number): number {
+  const c = sd / TERRAIN_AA + 0.5;
+  return c <= 0 ? 0 : c >= 1 ? 1 : c;
+}
 
 
 /**
 /**
- * Renders the terrain bitmap as one canvas-backed sprite per 64 px chunk:
- * textured materials, an irregular felt-pen outline, rim light on top faces,
- * darker undersides, a drop shadow, and holes that show the inside of the cake.
+ * Renders the terrain bitmap as one canvas-backed sprite per 64 px chunk, supersampled
+ * (TERRAIN_SUPERSAMPLE texels per pixel). Edges come from a signed distance field, so they are
+ * smooth and antialiased whatever the zoom: textured materials, an irregular felt-pen outline,
+ * rim light on top faces, darker undersides, a soft drop shadow, and holes that show the inside
+ * of the cake.
  */
  */
 export class TerrainView {
 export class TerrainView {
   readonly container = new Container();
   readonly container = new Container();
@@ -54,6 +65,13 @@ export class TerrainView {
   private readonly solidUp = new Int16Array(CHUNK_SIZE);
   private readonly solidUp = new Int16Array(CHUNK_SIZE);
   private readonly solidDown = new Int16Array(CHUNK_SIZE);
   private readonly solidDown = new Int16Array(CHUNK_SIZE);
   private readonly emptyUp = new Int16Array(CHUNK_SIZE);
   private readonly emptyUp = new Int16Array(CHUNK_SIZE);
+  /** Signed distance of the current terrain, and coverage of the original one, around the chunk being drawn. */
+  private readonly sd = new Float32Array(WIN * WIN);
+  private readonly orig = new Float32Array(WIN * WIN);
+  // Per-pixel shading of the chunk being drawn: brightness, tint toward white, cavity darkening.
+  private readonly shadeK = new Float32Array(CHUNK_SIZE * CHUNK_SIZE);
+  private readonly shadeW = new Float32Array(CHUNK_SIZE * CHUNK_SIZE);
+  private readonly cavityK = new Float32Array(CHUNK_SIZE * CHUNK_SIZE);
 
 
   constructor(
   constructor(
     private readonly bitmap: TerrainBitmap,
     private readonly bitmap: TerrainBitmap,
@@ -61,7 +79,7 @@ export class TerrainView {
   ) {
   ) {
     this.original = bitmap.data.slice();
     this.original = bitmap.data.slice();
     materials.byId.forEach((m, id) => {
     materials.byId.forEach((m, id) => {
-      if (m && id !== MATERIAL_EMPTY) this.tiles[id] = makeTile(parseHexColor(m.color), m.texture);
+      if (m && id !== MATERIAL_EMPTY) this.tiles[id] = makeTile(parseHexColor(m.color), m.texture, S);
     });
     });
   }
   }
 
 
@@ -89,48 +107,64 @@ export class TerrainView {
   /**
   /**
    * Draws a falling chunk's mask with the same textures (sampled at its original map
    * Draws a falling chunk's mask with the same textures (sampled at its original map
    * position, so it looks unchanged at the moment it breaks off) and a felt outline.
    * position, so it looks unchanged at the moment it breaks off) and a felt outline.
+   * The texture is 1 px larger than the mask on each side (see ChunkView's pivot).
    */
    */
   makeChunkTexture(mask: Uint8Array, w: number, h: number, ox: number, oy: number): Texture {
   makeChunkTexture(mask: Uint8Array, w: number, h: number, ox: number, oy: number): Texture {
     const pad = 1;
     const pad = 1;
+    const m = OUTLINE_PX + 2;
+    const ww = w + 2 * m;
+    const wh = h + 2 * m;
+    const sd = new Float32Array(ww * wh);
+    signedDistance(
+      (i) => {
+        const x = (i % ww) - m;
+        const y = ((i / ww) | 0) - m;
+        return x >= 0 && y >= 0 && x < w && y < h && mask[y * w + x] !== MATERIAL_EMPTY;
+      },
+      ww,
+      wh,
+      sd,
+      m,
+    );
+    const cw = (w + pad * 2) * S;
+    const ch = (h + pad * 2) * S;
     const canvas = document.createElement('canvas');
     const canvas = document.createElement('canvas');
-    canvas.width = w + pad * 2;
-    canvas.height = h + pad * 2;
+    canvas.width = cw;
+    canvas.height = ch;
     const ctx = canvas.getContext('2d')!;
     const ctx = canvas.getContext('2d')!;
-    const image = ctx.createImageData(canvas.width, canvas.height);
+    const image = ctx.createImageData(cw, ch);
     const out = image.data;
     const out = image.data;
-    const solid = (x: number, y: number): boolean => x >= 0 && y >= 0 && x < w && y < h && mask[y * w + x] !== MATERIAL_EMPTY;
-    const or = OUTLINE_COLOR >> 16;
-    const og = (OUTLINE_COLOR >> 8) & 0xff;
-    const ob = OUTLINE_COLOR & 0xff;
-    for (let y = 0; y < h; y++) {
-      for (let x = 0; x < w; x++) {
-        const m = mask[y * w + x];
-        if (m === MATERIAL_EMPTY) continue;
-        const o = ((y + pad) * canvas.width + (x + pad)) * 4;
-        const t = this.thickness[tileIndex(ox + x, oy + y)];
-        let edge = false;
-        for (const [dx, dy, d] of OUTLINE_PROBES) {
-          if (d > t) break;
-          if (!solid(x + dx, y + dy)) {
-            edge = true;
-            break;
-          }
-        }
-        out[o + 3] = 255;
-        if (edge) {
-          out[o] = or;
-          out[o + 1] = og;
-          out[o + 2] = ob;
-          continue;
+    for (let ty = 0; ty < ch; ty++) {
+      for (let tx = 0; tx < cw; tx++) {
+        // Local mask coordinates of this texel's center.
+        const lx = (tx + 0.5) / S - pad;
+        const ly = (ty + 0.5) / S - pad;
+        const d = sampleBilinear(sd, ww, wh, lx + m, ly + m);
+        const a = coverage(d);
+        if (a === 0) continue;
+        const px = Math.max(0, Math.min(w - 1, Math.floor(lx)));
+        const py = Math.max(0, Math.min(h - 1, Math.floor(ly)));
+        const t = this.thickness[tileIndex(ox + px, oy + py)];
+        const ink = 1 - coverage(d - t);
+        let r = OR;
+        let g = OG;
+        let b = OB;
+        const mat = mask[py * w + px];
+        if (ink < 1 && mat !== MATERIAL_EMPTY) {
+          const c = this.tiles[mat]![scaledTileIndex((ox + px) * S + (tx % S), (oy + py) * S + (ty % S), S)];
+          r = (c >> 16) * (1 - ink) + OR * ink;
+          g = ((c >> 8) & 0xff) * (1 - ink) + OG * ink;
+          b = (c & 0xff) * (1 - ink) + OB * ink;
         }
         }
-        const c = this.tiles[m]![tileIndex(ox + x, oy + y)];
-        out[o] = c >> 16;
-        out[o + 1] = (c >> 8) & 0xff;
-        out[o + 2] = c & 0xff;
+        const o = (ty * cw + tx) * 4;
+        out[o] = r;
+        out[o + 1] = g;
+        out[o + 2] = b;
+        out[o + 3] = a * 255;
       }
       }
     }
     }
     ctx.putImageData(image, 0, 0);
     ctx.putImageData(image, 0, 0);
-    return new Texture({ source: new CanvasSource({ resource: canvas, scaleMode: 'linear' }) });
+    return new Texture({ source: new CanvasSource({ resource: canvas, resolution: S, scaleMode: 'linear' }) });
   }
   }
 
 
   /** Redraws every chunk flagged dirty in the bitmap. */
   /** Redraws every chunk flagged dirty in the bitmap. */
@@ -140,8 +174,7 @@ export class TerrainView {
 
 
   private redraw(index: number): void {
   private redraw(index: number): void {
     const { x0, y0, x1, y1 } = this.bitmap.chunkRect(index);
     const { x0, y0, x1, y1 } = this.bitmap.chunkRect(index);
-    const bm = this.bitmap;
-    const { width, height, data } = bm;
+    const { width, height, data } = this.bitmap;
     const original = this.original;
     const original = this.original;
 
 
     let any = false;
     let any = false;
@@ -164,91 +197,151 @@ export class TerrainView {
     chunk ??= this.createChunk(index, x0, y0);
     chunk ??= this.createChunk(index, x0, y0);
     chunk.sprite.visible = true;
     chunk.sprite.visible = true;
 
 
-    const out = chunk.image.data;
-    out.fill(0);
-    const or = OUTLINE_COLOR >> 16;
-    const og = (OUTLINE_COLOR >> 8) & 0xff;
-    const ob = OUTLINE_COLOR & 0xff;
-    const { solidUp, solidDown, emptyUp } = this;
+    // Distance fields over the window around the chunk (clamped to the map: no outline on the map border).
+    const wx0 = x0 - M;
+    const wy0 = y0 - M;
+    const at = (i: number): number => {
+      const x = Math.max(0, Math.min(width - 1, wx0 + (i % WIN)));
+      const y = Math.max(0, Math.min(height - 1, wy0 + ((i / WIN) | 0)));
+      return y * width + x;
+    };
+    signedDistance((i) => data[at(i)] !== MATERIAL_EMPTY, WIN, WIN, this.sd, M);
+    for (let i = 0; i < WIN * WIN; i++) this.orig[i] = original[at(i)] !== MATERIAL_EMPTY ? 1 : 0;
 
 
+    // Per-pixel lighting from column scans: rim light on top faces, darker undersides, cavity shade.
+    const { solidUp, solidDown, emptyUp, shadeK, shadeW, cavityK } = this;
+    const cw = x1 - x0;
     for (let x = x0; x < x1; x++) {
     for (let x = x0; x < x1; x++) {
       this.scanColumn(x, y0, y1);
       this.scanColumn(x, y0, y1);
       for (let y = y0; y < y1; y++) {
       for (let y = y0; y < y1; y++) {
-        const i = y * width + x;
-        const o = ((y - y0) * CHUNK_SIZE + (x - x0)) * 4;
-        const mat = data[i];
+        const p = (y - y0) * CHUNK_SIZE + (x - x0);
         const t = this.thickness[tileIndex(x, y)];
         const t = this.thickness[tileIndex(x, y)];
+        const up = solidUp[y - y0];
+        const down = solidDown[y - y0];
+        let k = 1;
+        let white = 0;
+        if (up <= t + RIM_LIGHT_DEPTH) white = Math.max(0, RIM_LIGHT_WHITE * (1 - (up - t) / (RIM_LIGHT_DEPTH + 1)));
+        else if (down <= t + UNDERSIDE_DEPTH) k = UNDERSIDE_DARKEN;
+        shadeK[p] = k;
+        shadeW[p] = Math.min(RIM_LIGHT_WHITE, white);
+        let ck = CAVITY_DARKEN;
+        const e = emptyUp[y - y0];
+        if (e < CAVITY_SHADOW_DEPTH) ck *= 0.6 + (0.4 * e) / CAVITY_SHADOW_DEPTH;
+        cavityK[p] = ck;
+      }
+    }
 
 
-        if (mat !== MATERIAL_EMPTY) {
-          let edge = false;
-          for (const [dx, dy, d] of OUTLINE_PROBES) {
-            if (d > t) break;
-            const nx = x + dx;
-            const ny = y + dy;
-            if (nx >= 0 && ny >= 0 && nx < width && ny < height && data[ny * width + nx] === MATERIAL_EMPTY) {
-              edge = true;
-              break;
-            }
-          }
-          out[o + 3] = 255;
-          if (edge) {
-            // Felt pen: slightly uneven ink.
-            const ink = 1 + (hash2(x >> 1, y >> 1, 99) - 0.5) * 0.35;
-            out[o] = or * ink;
-            out[o + 1] = og * ink;
-            out[o + 2] = ob * ink;
-            continue;
-          }
-          const c = this.tiles[mat]![tileIndex(x, y)];
-          let r = c >> 16;
-          let g = (c >> 8) & 0xff;
-          let b = c & 0xff;
-          const up = solidUp[y - y0];
-          const down = solidDown[y - y0];
-          if (up <= t + RIM_LIGHT_DEPTH) {
-            // Rim light on top faces, strongest right under the outline.
-            const k = RIM_LIGHT_WHITE * (1 - (up - t) / (RIM_LIGHT_DEPTH + 1));
-            r += (255 - r) * k;
-            g += (255 - g) * k;
-            b += (255 - b) * k;
-          } else if (down <= t + UNDERSIDE_DEPTH) {
-            r *= UNDERSIDE_DARKEN;
-            g *= UNDERSIDE_DARKEN;
-            b *= UNDERSIDE_DARKEN;
+    const out = chunk.image.data;
+    out.fill(0);
+    const sd = this.sd;
+    const origCov = this.orig;
+    const shadowA = DROP_SHADOW_ALPHA / 255;
+    const texW = cw * S;
+    const texH = (y1 - y0) * S;
+    for (let ty = 0; ty < texH; ty++) {
+      const py = ty / S;
+      const y = y0 + Math.floor(py);
+      // Window coordinates of the texel center.
+      const v = py + 0.5 / S + M;
+      for (let tx = 0; tx < texW; tx++) {
+        const px = tx / S;
+        const x = x0 + Math.floor(px);
+        const u = px + 0.5 / S + M;
+        const p = (y - y0) * CHUNK_SIZE + (x - x0);
+        const o = (ty * TEX + tx) * 4;
+        const i = y * width + x;
+
+        // Background layer: the inside of the cake where it was dug, and the drop shadow.
+        let bgR = 0;
+        let bgG = 0;
+        let bgB = 0;
+        let bgA = 0;
+        const cav = Math.min(1, Math.max(0, (sampleBilinear(origCov, WIN, WIN, u, v) - 0.5) / TERRAIN_AA + 0.5));
+        const sh = shadowA * Math.min(1, Math.max(0, sampleBilinear(sd, WIN, WIN, u - DROP_SHADOW_DX, v - DROP_SHADOW_DY) / DROP_SHADOW_SOFTNESS + 0.5));
+        if (cav > 0) {
+          let om = original[i];
+          if (om === MATERIAL_EMPTY) om = this.nearbyMaterial(original, x, y);
+          if (om !== MATERIAL_EMPTY) {
+            const c = this.tiles[om]![scaledTileIndex(x0 * S + tx, y0 * S + ty, S)];
+            const k = cavityK[p] * (1 - sh * 0.55);
+            bgR = (c >> 16) * k;
+            bgG = ((c >> 8) & 0xff) * k * 0.95;
+            bgB = (c & 0xff) * k * 0.9;
+            bgA = cav;
           }
           }
-          out[o] = r;
-          out[o + 1] = g;
-          out[o + 2] = b;
-          continue;
+        }
+        if (sh > 0 && bgA < 1) {
+          const a = sh * (1 - bgA);
+          const na = bgA + a;
+          bgR = (bgR * bgA + OR * a) / na;
+          bgG = (bgG * bgA + OG * a) / na;
+          bgB = (bgB * bgA + OB * a) / na;
+          bgA = na;
         }
         }
 
 
-        const shadowX = x - DROP_SHADOW_DX;
-        const shadowY = y - DROP_SHADOW_DY;
-        const inShadow = shadowX >= 0 && shadowY >= 0 && data[shadowY * width + shadowX] !== MATERIAL_EMPTY;
-        const orig = original[i];
-        if (orig !== MATERIAL_EMPTY) {
-          // Cavity: the inside of the cake, darker, shaded under the ceiling.
-          const c = this.tiles[orig]![tileIndex(x, y)];
-          let k = CAVITY_DARKEN;
-          const e = emptyUp[y - y0];
-          if (e < CAVITY_SHADOW_DEPTH) k *= 0.6 + (0.4 * e) / CAVITY_SHADOW_DEPTH;
-          if (inShadow) k *= 0.85;
-          out[o] = (c >> 16) * k;
-          out[o + 1] = ((c >> 8) & 0xff) * k * 0.95;
-          out[o + 2] = (c & 0xff) * k * 0.9;
-          out[o + 3] = 255;
-        } else if (inShadow) {
-          out[o] = or;
-          out[o + 1] = og;
-          out[o + 2] = ob;
-          out[o + 3] = DROP_SHADOW_ALPHA;
+        // Terrain layer: textured fill inside a felt-pen outline.
+        const d = sampleBilinear(sd, WIN, WIN, u, v);
+        const fa = coverage(d);
+        let fr = 0;
+        let fg = 0;
+        let fb = 0;
+        if (fa > 0) {
+          const t = this.thickness[tileIndex(x, y)];
+          const ink = 1 - coverage(d - t);
+          // Felt pen: slightly uneven ink.
+          const inkK = 1 + (hash2(x >> 1, y >> 1, 99) - 0.5) * 0.35;
+          fr = OR * inkK;
+          fg = OG * inkK;
+          fb = OB * inkK;
+          const mat = data[i];
+          if (ink < 1 && mat !== MATERIAL_EMPTY) {
+            const c = this.tiles[mat]![scaledTileIndex(x0 * S + tx, y0 * S + ty, S)];
+            const k = shadeK[p];
+            const w = shadeW[p];
+            const r = (c >> 16) * k;
+            const g = ((c >> 8) & 0xff) * k;
+            const b = (c & 0xff) * k;
+            fr = (r + (255 - r) * w) * (1 - ink) + fr * ink;
+            fg = (g + (255 - g) * w) * (1 - ink) + fg * ink;
+            fb = (b + (255 - b) * w) * (1 - ink) + fb * ink;
+          }
         }
         }
+
+        // Terrain over background.
+        const a = fa + bgA * (1 - fa);
+        if (a <= 0) continue;
+        out[o] = (fr * fa + bgR * bgA * (1 - fa)) / a;
+        out[o + 1] = (fg * fa + bgG * bgA * (1 - fa)) / a;
+        out[o + 2] = (fb * fa + bgB * bgA * (1 - fa)) / a;
+        out[o + 3] = a * 255;
       }
       }
     }
     }
     chunk.ctx.putImageData(chunk.image, 0, 0);
     chunk.ctx.putImageData(chunk.image, 0, 0);
     chunk.texture.source.update();
     chunk.texture.source.update();
   }
   }
 
 
+  /** A material of the original map next to (x, y), for antialiased cavity edges. */
+  private nearbyMaterial(original: Uint8Array, x: number, y: number): number {
+    const { width, height } = this.bitmap;
+    for (const [dx, dy] of [
+      [1, 0],
+      [-1, 0],
+      [0, 1],
+      [0, -1],
+      [1, 1],
+      [-1, -1],
+      [1, -1],
+      [-1, 1],
+    ]) {
+      const nx = x + dx;
+      const ny = y + dy;
+      if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue;
+      const m = original[ny * width + nx];
+      if (m !== MATERIAL_EMPTY) return m;
+    }
+    return MATERIAL_EMPTY;
+  }
+
   /** Fills the per-column distance tables for column x over rows [y0, y1). */
   /** Fills the per-column distance tables for column x over rows [y0, y1). */
   private scanColumn(x: number, y0: number, y1: number): void {
   private scanColumn(x: number, y0: number, y1: number): void {
     const { width, height, data } = this.bitmap;
     const { width, height, data } = this.bitmap;
@@ -277,19 +370,19 @@ export class TerrainView {
 
 
   private receivesShadow(x0: number, y0: number, x1: number, y1: number): boolean {
   private receivesShadow(x0: number, y0: number, x1: number, y1: number): boolean {
     const bm = this.bitmap;
     const bm = this.bitmap;
-    for (let y = y0 - DROP_SHADOW_DY; y < y1 - DROP_SHADOW_DY; y++) {
-      for (let x = x0 - DROP_SHADOW_DX; x < x1 - DROP_SHADOW_DX; x++) if (bm.isSolid(x, y)) return true;
+    for (let y = y0 - DROP_SHADOW_DY - 2; y < y1 - DROP_SHADOW_DY + 2; y++) {
+      for (let x = x0 - DROP_SHADOW_DX - 2; x < x1 - DROP_SHADOW_DX + 2; x++) if (bm.isSolid(x, y)) return true;
     }
     }
     return false;
     return false;
   }
   }
 
 
   private createChunk(index: number, x0: number, y0: number): ChunkSprite {
   private createChunk(index: number, x0: number, y0: number): ChunkSprite {
     const canvas = document.createElement('canvas');
     const canvas = document.createElement('canvas');
-    canvas.width = CHUNK_SIZE;
-    canvas.height = CHUNK_SIZE;
+    canvas.width = TEX;
+    canvas.height = TEX;
     const ctx = canvas.getContext('2d')!;
     const ctx = canvas.getContext('2d')!;
-    const image = ctx.createImageData(CHUNK_SIZE, CHUNK_SIZE);
-    const texture = new Texture({ source: new CanvasSource({ resource: canvas, scaleMode: 'nearest' }) });
+    const image = ctx.createImageData(TEX, TEX);
+    const texture = new Texture({ source: new CanvasSource({ resource: canvas, resolution: S, scaleMode: 'linear' }) });
     const sprite = new Sprite(texture);
     const sprite = new Sprite(texture);
     sprite.position.set(x0, y0);
     sprite.position.set(x0, y0);
     this.container.addChild(sprite);
     this.container.addChild(sprite);

+ 108 - 0
src/renderer/terrain/distance.ts

@@ -0,0 +1,108 @@
+// Signed distance fields over a window of a solid/empty grid: the terrain is drawn from them,
+// so its edges are smooth curves instead of the staircase of the material bitmap.
+
+const INF = 1e20;
+
+/** Scratch buffers for the 1D transform, grown on demand. */
+let f = new Float64Array(0);
+let d = new Float64Array(0);
+let z = new Float64Array(0);
+let v = new Int32Array(0);
+
+function ensure(n: number): void {
+  if (f.length >= n) return;
+  f = new Float64Array(n);
+  d = new Float64Array(n);
+  z = new Float64Array(n + 1);
+  v = new Int32Array(n);
+}
+
+/** Squared Euclidean distance transform of `grid` along one line (Felzenszwalb & Huttenlocher). */
+function edt1d(grid: Float64Array, offset: number, stride: number, n: number): void {
+  for (let q = 0; q < n; q++) f[q] = grid[offset + q * stride];
+  let k = 0;
+  v[0] = 0;
+  z[0] = -INF;
+  z[1] = INF;
+  for (let q = 1; q < n; q++) {
+    let s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
+    while (s <= z[k]) {
+      k--;
+      s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
+    }
+    k++;
+    v[k] = q;
+    z[k] = s;
+    z[k + 1] = INF;
+  }
+  k = 0;
+  for (let q = 0; q < n; q++) {
+    while (z[k + 1] < q) k++;
+    const dq = q - v[k];
+    d[q] = dq * dq + f[v[k]];
+  }
+  for (let q = 0; q < n; q++) grid[offset + q * stride] = d[q];
+}
+
+function edt2d(grid: Float64Array, w: number, h: number): void {
+  ensure(Math.max(w, h));
+  for (let x = 0; x < w; x++) edt1d(grid, x, w, h);
+  for (let y = 0; y < h; y++) edt1d(grid, y * w, 1, w);
+}
+
+let inside = new Float64Array(0);
+let outside = new Float64Array(0);
+
+/**
+ * Signed distance (in pixels, positive inside solid) from each pixel center of a w×h window to
+ * the solid/empty boundary, which runs halfway between a solid pixel and an empty one.
+ * Values are clamped to ±`clamp`.
+ */
+export function signedDistance(solid: (i: number) => boolean, w: number, h: number, out: Float32Array, clamp: number): void {
+  const n = w * h;
+  if (inside.length < n) {
+    inside = new Float64Array(n);
+    outside = new Float64Array(n);
+  }
+  for (let i = 0; i < n; i++) {
+    const s = solid(i);
+    inside[i] = s ? INF : 0;
+    outside[i] = s ? 0 : INF;
+  }
+  edt2d(inside, w, h);
+  edt2d(outside, w, h);
+  for (let i = 0; i < n; i++) {
+    const sd = inside[i] > 0 ? Math.sqrt(inside[i]) - 0.5 : 0.5 - Math.sqrt(outside[i]);
+    out[i] = sd > clamp ? clamp : sd < -clamp ? -clamp : sd;
+  }
+}
+
+/** Bilinear sample of a w×h grid of pixel-center values at window coordinates (u, v) (pixel units). */
+export function sampleBilinear(grid: Float32Array, w: number, h: number, u: number, v: number): number {
+  const x = u - 0.5;
+  const y = v - 0.5;
+  let ix = Math.floor(x);
+  let iy = Math.floor(y);
+  let fx = x - ix;
+  let fy = y - iy;
+  if (ix < 0) {
+    ix = 0;
+    fx = 0;
+  } else if (ix >= w - 1) {
+    ix = w - 2;
+    fx = 1;
+  }
+  if (iy < 0) {
+    iy = 0;
+    fy = 0;
+  } else if (iy >= h - 1) {
+    iy = h - 2;
+    fy = 1;
+  }
+  const i = iy * w + ix;
+  const a = grid[i];
+  const b = grid[i + 1];
+  const c = grid[i + w];
+  const e = grid[i + w + 1];
+  return a + (b - a) * fx + (c - a) * fy + (a - b - c + e) * fx * fy;
+}

+ 27 - 17
src/renderer/terrain/textures.ts

@@ -62,7 +62,7 @@ type Shader = (x: number, y: number) => [k: number, white: number];
 
 
 const shaders: Record<string, Shader> = {
 const shaders: Record<string, Shader> = {
   sponge: (x, y) => {
   sponge: (x, y) => {
-    const grain = (hash2(x, y, 3) - 0.5) * 0.08;
+    const grain = (hash2(Math.floor(x * 2), Math.floor(y * 2), 3) - 0.5) * 0.08;
     const soft = (valueNoise(x, y, 8, 4) - 0.5) * 0.12;
     const soft = (valueNoise(x, y, 8, 4) - 0.5) * 0.12;
     const { d } = worley(x, y, 16, 5);
     const { d } = worley(x, y, 16, 5);
     if (d < 0.22) return [0.68 + d, 0]; // air bubble
     if (d < 0.22) return [0.68 + d, 0]; // air bubble
@@ -77,7 +77,7 @@ const shaders: Record<string, Shader> = {
   chocolate: (x, y) => {
   chocolate: (x, y) => {
     const u = x % 32;
     const u = x % 32;
     const v = y % 32;
     const v = y % 32;
-    const n = (hash2(x, y, 7) - 0.5) * 0.04;
+    const n = (hash2(Math.floor(x * 2), Math.floor(y * 2), 7) - 0.5) * 0.04;
     if (u < 3 || v < 3) return [1.35 + n, 0.05]; // bevel highlight
     if (u < 3 || v < 3) return [1.35 + n, 0.05]; // bevel highlight
     if (u > 28 || v > 28) return [0.7 + n, 0]; // bevel shadow
     if (u > 28 || v > 28) return [0.7 + n, 0]; // bevel shadow
     const gloss = (u + v) % 64 > 40 && (u + v) % 64 < 46 ? 0.12 : 0;
     const gloss = (u + v) % 64 > 40 && (u + v) % 64 < 46 ? 0.12 : 0;
@@ -96,7 +96,7 @@ const shaders: Record<string, Shader> = {
     return [0.82 + deep * 0.25, stripe];
     return [0.82 + deep * 0.25, stripe];
   },
   },
   biscuit: (x, y) => {
   biscuit: (x, y) => {
-    const grain = (hash2(x, y, 17) - 0.5) * 0.16;
+    const grain = (hash2(Math.floor(x * 2), Math.floor(y * 2), 17) - 0.5) * 0.16;
     const row = Math.floor(y / 32);
     const row = Math.floor(y / 32);
     const hx = (x + (row % 2) * 16) % 32;
     const hx = (x + (row % 2) * 16) % 32;
     const hy = y % 32;
     const hy = y % 32;
@@ -114,42 +114,52 @@ const shaders: Record<string, Shader> = {
   wool: (x, y) => {
   wool: (x, y) => {
     const u = x % 10;
     const u = x % 10;
     const v = (y + (u < 5 ? u : 9 - u)) % 10;
     const v = (y + (u < 5 ? u : 9 - u)) % 10;
-    return [v < 2 ? 0.75 : 1 + (hash2(x, y, 21) - 0.5) * 0.1, 0];
+    return [v < 2 ? 0.75 : 1 + (hash2(Math.floor(x * 2), Math.floor(y * 2), 21) - 0.5) * 0.1, 0];
   },
   },
   plate: (x, y) => {
   plate: (x, y) => {
     const band = y % 14;
     const band = y % 14;
     if (band < 2) return [1.25, 0.3];
     if (band < 2) return [1.25, 0.3];
-    if (band === 13) return [0.85, 0];
+    if (band >= 13) return [0.85, 0];
     return [1.05 + (valueNoise(x, y, 4, 23) - 0.5) * 0.06, 0.1];
     return [1.05 + (valueNoise(x, y, 4, 23) - 0.5) * 0.06, 0.1];
   },
   },
 };
 };
 
 
-const fallback: Shader = (x, y) => [0.95 + hash2(x, y, 1) * 0.1, 0];
+const fallback: Shader = (x, y) => [0.95 + hash2(Math.floor(x * 2), Math.floor(y * 2), 1) * 0.1, 0];
 
 
-/** Packed 0xRRGGBB tile for a material. */
-export function makeTile(color: number, texture: string): Uint32Array {
+/**
+ * Packed 0xRRGGBB tile for a material, with `scale` texels per world pixel (a power of two):
+ * the terrain is drawn supersampled, so patterns stay crisp when the camera zooms in.
+ */
+export function makeTile(color: number, texture: string, scale = 1): Uint32Array {
   const shade = shaders[texture] ?? fallback;
   const shade = shaders[texture] ?? fallback;
   const r = color >> 16;
   const r = color >> 16;
   const g = (color >> 8) & 0xff;
   const g = (color >> 8) & 0xff;
   const b = color & 0xff;
   const b = color & 0xff;
-  const tile = new Uint32Array(TILE * TILE);
-  for (let y = 0; y < TILE; y++) {
-    for (let x = 0; x < TILE; x++) {
-      const [k, white] = shade(x, y);
+  const size = TILE * scale;
+  const tile = new Uint32Array(size * size);
+  for (let y = 0; y < size; y++) {
+    for (let x = 0; x < size; x++) {
+      const [k, white] = shade(x / scale, y / scale);
       const c = (v: number): number => Math.max(0, Math.min(255, Math.round(v * k * (1 - white) + 255 * white)));
       const c = (v: number): number => Math.max(0, Math.min(255, Math.round(v * k * (1 - white) + 255 * white)));
-      tile[y * TILE + x] = (c(r) << 16) | (c(g) << 8) | c(b);
+      tile[y * size + x] = (c(r) << 16) | (c(g) << 8) | c(b);
     }
     }
   }
   }
   return tile;
   return tile;
 }
 }
 
 
-/** Felt-pen outline thickness at a world pixel: 2..max, varying slowly along the line. */
-export function makeThicknessTile(max: number): Uint8Array {
-  const tile = new Uint8Array(TILE * TILE);
+/** Felt-pen outline thickness at a world pixel: 2..max, varying slowly and smoothly along the line. */
+export function makeThicknessTile(max: number): Float32Array {
+  const tile = new Float32Array(TILE * TILE);
   for (let y = 0; y < TILE; y++) {
   for (let y = 0; y < TILE; y++) {
-    for (let x = 0; x < TILE; x++) tile[y * TILE + x] = 2 + Math.round(valueNoise(x, y, 8, 31) * (max - 2));
+    for (let x = 0; x < TILE; x++) tile[y * TILE + x] = 2 + valueNoise(x, y, 8, 31) * (max - 2);
   }
   }
   return tile;
   return tile;
 }
 }
 
 
 export const tileIndex = (x: number, y: number): number => (y & MASK) * TILE + (x & MASK);
 export const tileIndex = (x: number, y: number): number => (y & MASK) * TILE + (x & MASK);
+
+/** Index into a tile made with `scale` texels per pixel, at texel coordinates (x, y). */
+export const scaledTileIndex = (x: number, y: number, scale: number): number => {
+  const m = TILE * scale - 1;
+  return (y & m) * TILE * scale + (x & m);
+};