Bladeren bron

feat(game): surprises, a living cherry, cartoon hits

Make the game funnier (user's pick from a list of ideas).

- Surprises (server/surprises): twice a round at a random time, with a
  big banner: the house cat's paw sweeps the summit, a child's hand
  grabs a fistful of cake, the table quakes, sugar sprinkles rain down,
  the lights go out (only eyes glow). Everything goes through terrain
  ops or the players; the client draws it on the real clock.
- The cherry is alive: it has eyes, hops away from whoever comes close;
  hot potato: held 20 s by the same player it explodes; knocking it out
  of someone's hands and grabbing it is a steal worth bonus points.
- Cartoon hits: players knocked flying spin and whistle, splat against
  walls; a small falling chunk flattens a player into a pancake instead
  of killing them. Crushes now use the chunk's speed before impact
  (small chunks used to stop dead on the player and never crush).
- Dragées and sprinkles have small bodies so their little blasts reach
  the cake.

Protocol version 12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Idleman 4 dagen geleden
bovenliggende
commit
b5a481ba6c

+ 14 - 1
readme.md

@@ -120,6 +120,18 @@ Toute la logique aléatoire (objets, Magicien Raté, mutateurs) passe par un **R
  
 - La cerise apparaît au sommet de la carte (position définie par la carte). Si elle tombe dans la crème, elle revient sur une corniche du gâteau tirée au hasard (pas forcément en haut).
 - La cerise est lourde : celui qui la tient saute moins haut (`CHERRY_HOLDER_JUMP_MULT`), et Captain Prout n'a qu'une poussée.
+- **La cerise est vivante :** elle a des yeux et regarde le joueur le plus proche. Quand elle est libre et posée, elle fait un bond pour s'éloigner de celui qui approche (`CHERRY_FLEE_*`).
+- **Patate chaude :** tenue `CHERRY_HOT_S` secondes par le même joueur, elle chauffe, tremble, fait tic-tac, puis lui explose dans les mains.
+- **Vol :** faire lâcher la cerise par un coup et la rattraper dans les `CHERRY_STEAL_WINDOW_S` secondes rapporte `CHERRY_STEAL_BONUS` points.
+- **Surprises** (`server/surprises/`) : deux fois par manche, à un moment tiré au hasard, un événement est annoncé en grand :
+  - « Le chat de la maison passe ! » : une patte géante balaie le sommet du gâteau et projette ceux qui s'y trouvent ;
+  - « L'enfant a faim ! » : une ombre grandit, puis une main arrache une poignée de gâteau et lance ceux qui sont dessous ;
+  - « Tremblement de table ! » : les joueurs au sol sautent et le gâteau se fissure ;
+  - « Pluie de vermicelles ! » : des vermicelles tombent et grignotent le gâteau partout ;
+  - « Panne de courant ! » : noir complet, seuls les yeux des joueurs brillent.
+
+  Les poids et les durées sont dans `SURPRISE_WEIGHTS` et `SURPRISE_DURATIONS`.
+- **Coups de dessin animé :** un joueur projeté tourne sur lui-même en sifflant. S'il percute un mur ou le sol, il s'y écrase (« SPLAF ! »).
 - Tant qu'un joueur la tient, il marque **1 point par seconde**. S'il est touché, il la lâche et elle roule (objet physique).
 - **La crème anglaise monte** en continu. La vitesse est définie par la carte et accélère dans les 30 dernières secondes.
 - Un joueur qui tombe dans la crème réapparaît 5 secondes plus tard sur une **cuillère-catapulte** qui le renvoie sur le terrain. La cuillère vise une corniche du gâteau qu'elle peut atteindre (trajectoire vérifiée contre le terrain), même s'il ne reste qu'un petit îlot.
@@ -207,7 +219,7 @@ type TerrainOp =
 - Quand le bloc s'immobilise (vitesse quasi nulle pendant 0,5 s), le serveur le **rastérise** dans le bitmap et diffuse `merge { id, x, y, w, h, patch }`. Le patch est le résultat pixel par pixel, pour qu'aucun client n'ait à recalculer la rotation. Il comble jusqu'à `CHUNK_GLUE_PX` pixels sous le bloc pour le raccrocher au sol. Si le résultat ne serait pas ancré (bloc posé sur un joueur ou sur un autre bloc), la réintégration est retentée plus tard.
 - Les ops, `detach` et `merge` forment **un seul flux d'événements séquencés** (`shared/terrain/events.ts`) : c'est ce flux, rejoué dans l'ordre, qui garantit un bitmap identique au bit près sur tous les clients (vérifié par les tests).
 - Côté physique, un bloc est approché par des boîtes alignées sur une grille (Box2D ne fait pas collisionner deux chaînes entre elles).
-- Un bloc qui tombe et percute un joueur avec assez d'énergie l'écrase (élimination). Les blocs mous (laine, chantilly : densité < `CRUSH_MIN_DENSITY`) n'écrasent pas. Un bloc qui tombe dans la crème disparaît.
+- Un bloc qui tombe et percute un joueur avec assez d'énergie l'écrase. La vitesse prise en compte est celle d'avant le choc. Un gros bloc élimine le joueur, mais un bloc de moins de `CRUSH_KILL_PIXELS` le transforme seulement en crêpe : il reste aplati et immobile pendant `FLAT_S`. Les blocs mous (laine, chantilly : densité < `CRUSH_MIN_DENSITY`) n'écrasent pas. Un bloc qui tombe dans la crème disparaît.
 ### Budget de performance
  
 - Une op avec flood-fill et régénération des colliders doit tenir en **2 ms** côté serveur. Si ce n'est pas le cas, on optimise : flood-fill borné, bitsets, cache des composantes.
@@ -389,6 +401,7 @@ Chaque étape doit être **jouable** avant de passer à la suivante.
   - **Deux personnages en plus :**
     - **Tatie Douille :** sa tourelle est posée sur le terrain. Le socle est un disque de biscuit (`fill`), et quand ce socle disparaît, la tourelle disparaît aussi. La tourelle vise le joueur le plus proche qu'elle voit (ligne de vue vérifiée contre le terrain) et tire en compensant la gravité. Les dragées sont des projectiles comme les pétards, trop faibles pour casser le biscuit. Réglages : `turret*` et `dragee*` dans `character.json`.
     - **Léon le Caméléon :** l'invisibilité est un drapeau d'animation. Le serveur continue d'envoyer sa position et ce sont les clients qui le cachent : c'est suffisant entre amis en LAN.
+  - **Plus drôle :** surprises, cerise vivante (patate chaude, vol), coups de dessin animé et joueur aplati en crêpe (§4, §5).
   - **Écran d'options** (Échap ou ⚙) : volumes, son coupé, secousses de l'écran. Les réglages sont gardés sur le PC (`renderer/settings.ts`).
 - **Reste :** la suite de la DA (sprites dessinés, particules par matériau), l'éditeur de cartes, et l'icône et la signature des exe.
 ---

+ 36 - 0
src/renderer/audio/Audio.ts

@@ -39,6 +39,14 @@ export type SoundName =
   | 'vanish'
   | 'appear'
   | 'crush'
+  | 'surprise'
+  | 'rumble'
+  | 'powerDown'
+  | 'giggle'
+  | 'tick'
+  | 'stolen'
+  | 'splat'
+  | 'whizz'
   | 'thud'
   | 'beep'
   | 'go'
@@ -338,6 +346,34 @@ const SOUNDS: Record<SoundName, (s: Synth) => void> = {
   appear: (s) => {
     for (let i = 0; i < 5; i++) s.tone('sine', 700 + i * 260, 900 + i * 260, i * 0.05, 0.1, 0.15);
   },
+  surprise: (s) => {
+    // "Tadaaa!": a rising brass-like call.
+    [392, 523, 659, 784].forEach((f, i) => s.tone('sawtooth', f, f, i * 0.09, i === 3 ? 0.5 : 0.12, 0.12));
+  },
+  rumble: (s) => {
+    s.noise('lowpass', 220, 90, 0, 2.2, 0.9, 1, 0.2);
+    s.tone('sine', 55, 40, 0, 2, 0.5, 0.2);
+  },
+  powerDown: (s) => {
+    s.tone('sawtooth', 520, 40, 0, 1.1, 0.25, 0.01);
+    s.noise('highpass', 3000, 1000, 0, 0.15, 0.3);
+  },
+  giggle: (s) => {
+    for (let i = 0; i < 4; i++) s.tone('triangle', 1300 + (i % 2) * 260, 1200, i * 0.07, 0.06, 0.18, 0.005);
+  },
+  tick: (s) => s.tone('square', 1800, 1800, 0, 0.03, 0.12, 0.002),
+  stolen: (s) => {
+    [880, 1175, 1568].forEach((f, i) => s.tone('square', f, f, i * 0.06, 0.1, 0.12));
+    s.noise('highpass', 5000, 6000, 0, 0.3, 0.15, 1, 0.02);
+  },
+  splat: (s) => {
+    s.noise('lowpass', 1400, 200, 0, 0.22, 0.9, 1, 0.003);
+    s.tone('sine', 260, 60, 0, 0.2, 0.6);
+  },
+  whizz: (s) => {
+    const o = s.tone('sine', 1500, 400, 0, 0.5, 0.18, 0.02);
+    s.wobble(o, 12, 60, 0, 0.5);
+  },
   crush: (s) => {
     s.noise('lowpass', 900, 100, 0, 0.5, 1, 1, 0.005);
     s.tone('sine', 180, 40, 0, 0.45, 0.8);

+ 17 - 0
src/renderer/config.ts

@@ -54,6 +54,8 @@ export const CUSTARD_DARK = 0xd9a93a;
 export const EXPLOSION_SHAKE = 14;
 /** Explosions smaller than this (turret dragées) get a small effect: no freeze frame, little shake. */
 export const SMALL_BLAST_R = 16;
+/** Sprinkles (blast radius 9) pop without a word; bigger small blasts (dragées) get a "POC !". */
+export const SPRINKLE_POPUP_MIN_R = 9;
 export const WHISK_SHAKE = 10;
 export const FREEZE_FRAME_MS = 50;
 export const PARTICLES_PER_100_PX = 6;
@@ -66,6 +68,21 @@ export const INPUT_SEND_HZ = 60;
 export const SQUASH_RECOVERY = 10;
 /** Max forward lean (radians) of a running character. */
 export const RUN_LEAN = 0.12;
+// Cartoon hits.
+/** A stunned player flying faster than this (px/s) spins, this fast (rad/s). */
+export const SPIN_MIN_SPEED = 450;
+export const SPIN_RATE = 16;
+/** How long a player stays squashed against a wall after a splat (s). */
+export const SPLAT_SHOW_S = 0.45;
+/** A flattened player: height and width multipliers. */
+export const PANCAKE_SQUASH = 0.3;
+export const PANCAKE_STRETCH = 1.7;
+// Surprises.
+export const QUAKE_SHAKE = 7;
+export const BLACKOUT_ALPHA = 0.9;
+export const BLACKOUT_FADE_S = 0.5;
+/** The hot cherry ticks when its heat is above this (0..1). */
+export const CHERRY_TICK_HEAT = 0.5;
 /** Léon invisible: how he sees himself (alpha), and how fast he fades in and out (1/s). */
 export const INVISIBLE_SELF_ALPHA = 0.35;
 export const INVISIBLE_FADE = 8;

+ 44 - 3
src/renderer/entities/PlayerView.ts

@@ -9,7 +9,12 @@ import {
   OUTLINE_COLOR,
   OUTLINE_WIDTH,
   PLAYER_COLORS,
+  PANCAKE_SQUASH,
+  PANCAKE_STRETCH,
   RUN_LEAN,
+  SPIN_MIN_SPEED,
+  SPIN_RATE,
+  SPLAT_SHOW_S,
   SQUASH_RECOVERY,
   VARIANT_FILTER_RESOLUTION,
   VARIANT_HUES,
@@ -42,6 +47,12 @@ export class PlayerView {
   private wasGrounded = true;
   private wasStunned = false;
   private lastVy = 0;
+  /** Cartoon effects: spinning when knocked flying, squashed against a wall, flat as a pancake. */
+  private spin = 0;
+  private splatTime = 0;
+  private splatNx = 0;
+  private splatNy = 0;
+  private flat = false;
 
   constructor(readonly info: PlayerInfo) {
     this.paint = PAINTERS[info.characterId] ?? paintGeneric;
@@ -68,6 +79,13 @@ export class PlayerView {
   }
 
   /** `local`: this is the player at this keyboard (who still sees themself when invisible). */
+  /** Slammed into a wall or the floor: squashed flat against it for a moment. (nx, ny) points away from it. */
+  splat(nx: number, ny: number): void {
+    this.splatTime = SPLAT_SHOW_S;
+    this.splatNx = nx;
+    this.splatNy = ny;
+  }
+
   update(state: PlayerState, aimX: number, aimY: number, dt: number, local = false): PlayerEvents {
     this.time += dt;
     const size = state.size;
@@ -109,7 +127,13 @@ export class PlayerView {
     const dy = aimY - state.y;
     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.splatTime = Math.max(0, this.splatTime - dt);
+    this.flat = (state.anim & Anim.Flat) !== 0;
+    // Knocked flying: the body spins like in a cartoon; otherwise it leans into the run.
+    const speed = Math.hypot(state.vx, state.vy);
+    if (stunned && speed > SPIN_MIN_SPEED && this.splatTime === 0 && !this.flat) this.spin += dt * SPIN_RATE * Math.sign(state.vx || 1);
+    else this.spin = 0;
+    this.body.rotation = this.spin !== 0 ? this.spin : 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, {
@@ -145,9 +169,26 @@ export class PlayerView {
     const sy = 1 - this.squash;
     // Wobble while eating.
     const wobble = flags.special ? 1 + Math.sin(this.time * 28) * 0.04 : 1;
-    this.body.scale.set(facing * scale * sx * wobble, scale * sy / wobble);
+    let kx = sx * wobble;
+    let ky = sy / wobble;
+    this.body.x = 0;
+    if (this.flat) {
+      // A pancake: flat on the ground.
+      kx = PANCAKE_STRETCH;
+      ky = PANCAKE_SQUASH;
+    } else if (this.splatTime > 0) {
+      // Squashed against what it hit, stuck to it.
+      const k = this.splatTime / SPLAT_SHOW_S;
+      const wall = Math.abs(this.splatNx) > Math.abs(this.splatNy);
+      const flatten = 1 - 0.6 * k;
+      const widen = 1 + 0.5 * k;
+      kx = wall ? flatten : widen;
+      ky = wall ? widen : flatten;
+      if (wall) this.body.x = -this.splatNx * size * (1 - flatten);
+    }
+    this.body.scale.set(facing * scale * kx, scale * ky);
     // Keep the feet on the ground while squashed.
-    this.body.y = size * (1 - sy);
+    this.body.y = size * (1 - ky);
     this.body.clear();
     this.paint(this.body, base, {
       time: this.time,

+ 46 - 10
src/renderer/entities/RoundViews.ts

@@ -1,5 +1,5 @@
 import { Container, Graphics } from 'pixi.js';
-import type { CherryState, EntityKind, ItemState, TurretState } from '../../shared/protocol';
+import type { CherryState, EntityKind, ItemState, PlayerState, TurretState } from '../../shared/protocol';
 import { CUSTARD_COLOR, CUSTARD_DARK, OUTLINE_COLOR, OUTLINE_WIDTH, PLAYER_COLORS } from '../config';
 
 const INK = OUTLINE_COLOR;
@@ -43,25 +43,61 @@ export class CherryView {
   readonly graphics = new Graphics();
   private time = 0;
 
-  update(cherry: CherryState | null, dt: number): void {
+  /** The cherry is alive: it looks at the nearest player, and as a hot potato it glows and shakes. */
+  update(cherry: CherryState | null, dt: number, players: Map<number, PlayerState>): void {
     this.time += dt;
     const g = this.graphics;
     g.clear();
     if (!cherry) return;
+    const heat = cherry.heat;
     const bob = cherry.holder ? Math.sin(this.time * 6) * 3 : 0;
-    const x = cherry.x;
-    const y = cherry.y + bob;
-    if (cherry.holder) g.circle(x, y, 22 + Math.sin(this.time * 8) * 2).fill({ color: 0xffe14d, alpha: 0.35 });
+    // About to blow: it trembles harder and harder.
+    const tremble = heat > 0.5 ? (heat - 0.5) * 2 * 4 : 0;
+    const x = cherry.x + Math.sin(this.time * 70) * tremble;
+    const y = cherry.y + bob + Math.cos(this.time * 63) * tremble * 0.6;
+    if (cherry.holder) g.circle(x, y, 22 + Math.sin(this.time * 8) * 2).fill({ color: heat > 0.5 ? 0xff6f2e : 0xffe14d, alpha: 0.35 + heat * 0.2 });
     g.moveTo(x + 2, y - 10).quadraticCurveTo(x + 6, y - 22, x + 12, y - 26).stroke({ width: 3, color: 0x4a7a2a });
     g.ellipse(x + 15, y - 24, 8, 4).fill(0x7ccf5a).stroke({ width: 2, color: INK });
-    g.circle(x, y, 12).fill(0xe0123a).stroke({ width: W, color: INK });
+    g.circle(x, y, 12).fill(mixColor(0xe0123a, 0xff8a1e, heat)).stroke({ width: W, color: INK });
     g.ellipse(x - 4, y - 4, 4, 3).fill({ color: 0xffffff, alpha: 0.75 });
+    // Eyes on the nearest player (the holder if held: looking up at them would be odd, so ahead).
+    let lx = 0;
+    let ly = 0;
+    let best = Infinity;
+    for (const p of players.values()) {
+      if (p.id === cherry.holder) continue;
+      const d = Math.hypot(p.x - cherry.x, p.y - cherry.y);
+      if (d < best) {
+        best = d;
+        lx = (p.x - cherry.x) / (d || 1);
+        ly = (p.y - cherry.y) / (d || 1);
+      }
+    }
+    const scared = best < 140 || heat > 0.6;
+    for (const side of [-1, 1]) {
+      const ex = x + side * 4.5;
+      const ey = y + 1;
+      g.ellipse(ex, ey, 3.6, scared ? 4.8 : 4).fill(0xffffff).stroke({ width: 1.2, color: INK });
+      g.circle(ex + lx * 1.4, ey + ly * 1.6, scared ? 1.3 : 1.9).fill(INK);
+    }
+    if (scared) g.ellipse(x, y + 7, 2.2, 2.6).fill(INK);
+    else g.moveTo(x - 3, y + 6).quadraticCurveTo(x, y + 8.5, x + 3, y + 6).stroke({ width: 1.3, color: INK, cap: 'round' });
   }
 }
 
-/** Draws one item in local coordinates (origin at its center). */
-export function paintItem(g: Graphics, kind: EntityKind, lit: boolean, time: number): void {
-  if (kind === 'dragee') {
+function mixColor(a: number, b: number, t: number): number {
+  const k = Math.max(0, Math.min(1, t));
+  const ch = (s: number): number => Math.round(((a >> s) & 0xff) * (1 - k) + ((b >> s) & 0xff) * k);
+  return (ch(16) << 16) | (ch(8) << 8) | ch(0);
+}
+
+const SPRINKLE_COLORS = [0xff3b6b, 0xffe14d, 0x4dc4ff, 0x9be15d, 0xb18cff, 0xffffff];
+
+/** Draws one item in local coordinates (origin at its center). `seed` varies the look (sprinkle colors). */
+export function paintItem(g: Graphics, kind: EntityKind, lit: boolean, time: number, seed = 0): void {
+  if (kind === 'sprinkle') {
+    g.roundRect(-6, -2, 12, 4, 2).fill(SPRINKLE_COLORS[seed % SPRINKLE_COLORS.length]).stroke({ width: 1.2, color: INK });
+  } else if (kind === 'dragee') {
     // A sugared almond, pastel and shiny.
     g.ellipse(0, 0, 8, 6).fill(0xffb3d1).stroke({ width: 2, color: INK });
     g.ellipse(-2.5, -2, 3, 1.5).fill({ color: 0xffffff, alpha: 0.8 });
@@ -139,7 +175,7 @@ export class ItemsView {
       g.clear();
       // A gentle sparkle so pickups catch the eye.
       if (!item.lit) g.circle(0, 0, 18 + Math.sin(this.time * 5 + id) * 2).fill({ color: 0xffffff, alpha: 0.35 });
-      paintItem(g, item.kind, item.lit, this.time);
+      paintItem(g, item.kind, item.lit, this.time, id);
       g.position.set(item.x, item.y);
       g.rotation = item.angle;
     }

+ 139 - 0
src/renderer/fx/Surprises.ts

@@ -0,0 +1,139 @@
+import { Graphics } from 'pixi.js';
+import { Anim } from '../../shared/characters';
+import { CAT_PAW_RADIUS, HAND_BITE_RADIUS } from '../../shared/constants';
+import type { CherryState, FxEvent, PlayerState, SurpriseId } from '../../shared/protocol';
+import { BLACKOUT_ALPHA, BLACKOUT_FADE_S, OUTLINE_COLOR, QUAKE_SHAKE } from '../config';
+import { fr } from '../i18n/fr';
+
+const INK = OUTLINE_COLOR;
+
+type SurpriseEvent = Extract<FxEvent, { kind: 'surprise' }>;
+
+interface Running {
+  ev: SurpriseEvent;
+  /** performance.now() when it started: the real clock, so a slow frame rate does not stretch it. */
+  start: number;
+  t: number;
+}
+
+/**
+ * Draws the surprise events (server/surprises) in the world: the cat's paw, the child's hand,
+ * the blackout with glowing eyes; and announces them with a big banner.
+ */
+export class SurpriseView {
+  /** Above the players: the paw and the hand pass in front of everything, so does the dark. */
+  readonly graphics = new Graphics();
+  private readonly banner = document.createElement('div');
+  private running: Running[] = [];
+  private bannerTimer: ReturnType<typeof setTimeout> | null = null;
+
+  constructor() {
+    this.banner.id = 'surprise-banner';
+    this.banner.hidden = true;
+    document.body.appendChild(this.banner);
+  }
+
+  start(ev: SurpriseEvent): void {
+    this.running.push({ ev, start: performance.now(), t: 0 });
+    this.banner.textContent = fr.surprises[ev.surprise] ?? ev.surprise;
+    this.banner.hidden = false;
+    // Restart the CSS pop animation.
+    this.banner.classList.remove('pop');
+    void this.banner.offsetWidth;
+    this.banner.classList.add('pop');
+    if (this.bannerTimer) clearTimeout(this.bannerTimer);
+    this.bannerTimer = setTimeout(() => (this.banner.hidden = true), 2600);
+  }
+
+  /** True while a surprise of this kind is going on. */
+  active(id: SurpriseId): boolean {
+    return this.running.some((r) => r.ev.surprise === id);
+  }
+
+  /** Extra screen shake wanted this frame. */
+  get shake(): number {
+    return this.active('quake') ? QUAKE_SHAKE : 0;
+  }
+
+  clear(): void {
+    this.running = [];
+    this.graphics.clear();
+    this.banner.hidden = true;
+  }
+
+  update(width: number, height: number, players: Map<number, PlayerState>, cherry: CherryState | null, localId: number): void {
+    const g = this.graphics;
+    g.clear();
+    const now = performance.now();
+    for (const r of this.running) r.t = (now - r.start) / 1000;
+    this.running = this.running.filter((r) => r.t < r.ev.duration);
+    for (const r of this.running) {
+      const { ev, t } = r;
+      if (ev.surprise === 'cat-paw') this.paw(g, ev, t, width);
+      else if (ev.surprise === 'giant-hand') this.hand(g, ev, t);
+      else if (ev.surprise === 'blackout') this.blackout(g, ev, t, width, height, players, cherry, localId);
+    }
+  }
+
+  /** A huge ginger paw with pink toe beans, its arm reaching in from the side of the screen. */
+  private paw(g: Graphics, ev: SurpriseEvent, t: number, width: number): void {
+    const fromLeft = ev.x < 0;
+    const x1 = fromLeft ? width + CAT_PAW_RADIUS : -CAT_PAW_RADIUS;
+    const x = ev.x + (x1 - ev.x) * Math.min(1, t / ev.duration);
+    const y = ev.y;
+    const r = CAT_PAW_RADIUS * 1.25;
+    const armEnd = fromLeft ? x - 3000 : x + 3000;
+    g.rect(Math.min(x, armEnd), y - r * 0.7, Math.abs(armEnd - x), r * 1.4).fill(0xff9a2e).stroke({ width: 5, color: INK });
+    for (let i = -2; i <= 2; i++) g.moveTo(x - (fromLeft ? 1 : -1) * (r + 40 + i * 70), y - r * 0.6).lineTo(x - (fromLeft ? 1 : -1) * (r + 60 + i * 70), y + r * 0.6).stroke({ width: 6, color: 0xd9741a });
+    g.circle(x, y, r).fill(0xff9a2e).stroke({ width: 5, color: INK });
+    const dir = fromLeft ? 1 : -1;
+    g.ellipse(x + dir * r * 0.2, y + r * 0.15, r * 0.42, r * 0.36).fill(0xffb6c8).stroke({ width: 3, color: INK });
+    for (const [a, s] of [[-0.9, 0.2], [-0.3, 0.22], [0.3, 0.22], [0.9, 0.2]]) {
+      g.circle(x + dir * Math.cos(a) * r * 0.62, y + Math.sin(a) * r * 0.62 - r * 0.15, r * s).fill(0xffb6c8).stroke({ width: 3, color: INK });
+    }
+  }
+
+  /** The child's hand: a shadow grows where it will grab, it comes down, closes, and goes back up. */
+  private hand(g: Graphics, ev: SurpriseEvent, t: number): void {
+    const half = ev.duration / 2;
+    const k = t < half ? t / half : 1 - (t - half) / half;
+    g.ellipse(ev.x, ev.y, HAND_BITE_RADIUS * (0.4 + 0.8 * Math.min(1, t / half)), HAND_BITE_RADIUS * 0.25).fill({ color: INK, alpha: 0.25 });
+    const y = -400 + (ev.y - 60 + 400) * k * k;
+    const closed = t >= half;
+    const palm = 0xffc8a8;
+    const fingers = closed ? 0.5 : 1;
+    for (let i = 0; i < 4; i++) {
+      const fx = ev.x - 54 + i * 36;
+      g.roundRect(fx - 15, y + 30, 30, 70 * fingers, 15).fill(palm).stroke({ width: 5, color: INK });
+    }
+    g.roundRect(ev.x - 75, y - 400, 150, 440, 50).fill(palm).stroke({ width: 5, color: INK });
+    g.roundRect(ev.x + 62, y - 30, 26, 70 * fingers, 13).fill(palm).stroke({ width: 5, color: INK });
+    g.ellipse(ev.x - 20, y - 60, 30, 14).fill({ color: 0xffffff, alpha: 0.25 });
+  }
+
+  /** The lights go out: only the eyes (and the cherry) still glow. */
+  private blackout(
+    g: Graphics,
+    ev: SurpriseEvent,
+    t: number,
+    width: number,
+    height: number,
+    players: Map<number, PlayerState>,
+    cherry: CherryState | null,
+    localId: number,
+  ): void {
+    const fade = Math.min(1, t / BLACKOUT_FADE_S, (ev.duration - t) / BLACKOUT_FADE_S);
+    g.rect(-2000, -2000, width + 4000, height + 4000).fill({ color: 0x0a0820, alpha: BLACKOUT_ALPHA * fade });
+    for (const p of players.values()) {
+      if (p.anim & Anim.Dead || (p.anim & Anim.Invisible && p.id !== localId)) continue;
+      const blink = Math.sin(t * 3 + p.id * 1.7) > 0.96;
+      for (const side of [-1, 1]) {
+        const ex = p.x + p.facing * p.size * 0.3 + side * p.size * 0.2;
+        const ey = p.y - p.size * 0.25;
+        g.ellipse(ex, ey, p.size * 0.22, p.size * 0.22).fill({ color: 0xfff3a0, alpha: 0.25 * fade });
+        g.ellipse(ex, ey, p.size * 0.1, blink ? 1 : p.size * 0.13).fill({ color: 0xfff3a0, alpha: fade });
+      }
+    }
+    if (cherry) g.circle(cherry.x, cherry.y, 22).fill({ color: 0xff3b6b, alpha: 0.3 * fade });
+  }
+}

+ 14 - 0
src/renderer/i18n/fr.ts

@@ -107,8 +107,22 @@ export const fr = {
     build: ['ET HOP !', 'TADAM !', 'VOILÀ !'],
     turretDown: ['CRAC !', 'PATATRAS !'],
     pop: ['POC !', 'TOC !', 'PAF !'],
+    hop: ['HIHI !', 'ATTRAPE-MOI !', 'NIAK NIAK !', 'PAS PAR LÀ !'],
+    hot: ['PATATE CHAUDE !', 'KABOUM CERISE !'],
+    splat: ['SPLAF !', 'FLATCH !', 'SPROTCH !'],
+    whizz: ['WIIIZZ !', 'WOUUUSH !'],
+    pancake: ['CRÊPE !', 'GALETTE !', 'APLATI !'],
     vanish: ['POUF !', 'OÙ ÇA ?', 'COUCOU…'],
   },
+  /** Surprise events, announced in big letters. */
+  surprises: {
+    'cat-paw': 'Le chat de la maison passe !',
+    quake: 'Tremblement de table !',
+    sprinkles: 'Pluie de vermicelles !',
+    'giant-hand': 'L’enfant a faim !',
+    blackout: 'Panne de courant !',
+  } as Record<string, string>,
+  stolen: (bonus: number) => [`VOLÉ ! +${bonus}`],
   spells: {
     jelly: 'Gélifico !',
     swap: 'Échangeus !',

+ 57 - 7
src/renderer/main.ts

@@ -31,6 +31,9 @@ import {
   SHAKE_MAX,
   SHAKE_PER_PIXEL,
   SMALL_BLAST_R,
+  SPIN_MIN_SPEED,
+  SPRINKLE_POPUP_MIN_R,
+  CHERRY_TICK_HEAT,
   WHISK_SHAKE,
 } from './config';
 import { GameAudio, type SoundName } from './audio/Audio';
@@ -38,6 +41,7 @@ import { ChunkView } from './entities/ChunkView';
 import { PlayerView } from './entities/PlayerView';
 import { CherryView, CloudsView, CustardView, ItemsView, SpoonsView, TurretsView } from './entities/RoundViews';
 import { makeDreamFilter, setDreamPoison, SugarDust } from './fx/Dream';
+import { SurpriseView } from './fx/Surprises';
 import { Particles } from './fx/Particles';
 import { Popups } from './fx/Popups';
 import { fr } from './i18n/fr';
@@ -173,6 +177,9 @@ async function start(): Promise<void> {
   const spoonsView = new SpoonsView();
   const cloudsView = new CloudsView();
   const turretsView = new TurretsView();
+  const surpriseView = new SurpriseView();
+  /** Seconds before the hot cherry's next tick. */
+  let tickIn = 0;
   // Map-independent layers always sit above the map ones (background, terrain).
   // The custard covers players who sink into it; the spoon flicks out of it.
   worldRoot.addChild(
@@ -186,6 +193,7 @@ async function start(): Promise<void> {
     custardView.graphics,
     spoonsView.container,
     particles.graphics,
+    surpriseView.graphics,
     dust.front,
     popups.container,
   );
@@ -326,6 +334,7 @@ async function start(): Promise<void> {
       world.view.container.destroy({ children: true, texture: true, textureSource: true });
     }
     world = null;
+    surpriseView.clear();
     pendingEvents = [];
     pendingHash = null;
     for (const id of [...chunkViews.keys()]) removeChunk(id);
@@ -419,10 +428,10 @@ async function start(): Promise<void> {
         break;
       case 'explosion':
         if (ev.r < SMALL_BLAST_R) {
-          // A turret's dragée: a little pop, no freeze frame.
-          audio.play('turret', ev.x, ev.y);
-          particles.burst(ev.x, ev.y, 10, 0xffb3d1, 300);
-          popups.spawn(ev.x, ev.y - 16, fr.fxWords.pop, 22);
+          // A turret's dragée or a sugar sprinkle: a little pop, no freeze frame.
+          audio.play('turret', ev.x, ev.y, 0.6);
+          particles.burst(ev.x, ev.y, 6, 0xffb3d1, 300);
+          if (ev.r > SPRINKLE_POPUP_MIN_R) popups.spawn(ev.x, ev.y - 16, fr.fxWords.pop, 22);
           shake = Math.min(SHAKE_MAX, shake + HIT_SHAKE / 3);
           break;
         }
@@ -446,6 +455,33 @@ async function start(): Promise<void> {
       case 'cue':
         onCue(ev);
         break;
+      case 'surprise':
+        surpriseView.start(ev);
+        audio.play('surprise');
+        if (ev.surprise === 'quake') audio.play('rumble');
+        else if (ev.surprise === 'blackout') audio.play('powerDown');
+        else if (ev.surprise === 'cat-paw') audio.play('pounce');
+        else if (ev.surprise === 'giant-hand') setTimeout(() => audio.play('crush', ev.x, ev.y), (ev.duration / 2) * 1000);
+        break;
+      case 'cherryHop':
+        audio.play('giggle', ev.x, ev.y);
+        popups.spawn(ev.x, ev.y - 26, fr.fxWords.hop, 22);
+        break;
+      case 'cherryBoom':
+        popups.spawn(ev.x, ev.y - 40, fr.fxWords.hot, 40);
+        break;
+      case 'stolen':
+        audio.play('stolen', ev.x, ev.y);
+        popups.spawn(ev.x, ev.y - 50, fr.stolen(ev.bonus), 44);
+        break;
+      case 'splat': {
+        audio.play('splat', ev.x, ev.y);
+        views.get(ev.id)?.splat(ev.nx, ev.ny);
+        popups.spawn(ev.x, ev.y - 30, fr.fxWords.splat, 36);
+        particles.burst(ev.x - ev.nx * 20, ev.y - ev.ny * 20, 10, 0xffffff, 260);
+        shake = Math.min(SHAKE_MAX, shake + HIT_SHAKE);
+        break;
+      }
       case 'whisk':
         audio.play('whisk');
         shake = Math.min(SHAKE_MAX, shake + WHISK_SHAKE);
@@ -569,8 +605,8 @@ async function start(): Promise<void> {
         removeChunk(msg.id);
         break;
       case 'crushed':
-        audio.play('crush', msg.x, msg.y);
-        popups.spawn(msg.x, msg.y, fr.onomatopoeia.crush, 46);
+        audio.play(msg.flat ? 'splat' : 'crush', msg.x, msg.y);
+        popups.spawn(msg.x, msg.y, msg.flat ? fr.fxWords.pancake : fr.onomatopoeia.crush, 46);
         particles.burst(msg.x, msg.y, 24, PLAYER_SPLAT_COLOR, 500);
         shake = Math.min(SHAKE_MAX, shake + CRUSH_SHAKE);
         freezeUntil = performance.now() + FREEZE_FRAME_MS * 2;
@@ -635,7 +671,16 @@ async function start(): Promise<void> {
       if (s) view.update(s);
     }
     itemsView.update(sample.items, dt);
-    cherryView.update(sample.round?.cherry ?? null, dt);
+    cherryView.update(sample.round?.cherry ?? null, dt, sample.players);
+    surpriseView.update(world.terrain.width, world.terrain.height, sample.players, sample.round?.cherry ?? null, localId);
+    shake = Math.max(shake, surpriseView.shake);
+    // The hot potato ticks faster and faster.
+    const heat = sample.round?.cherry?.heat ?? 0;
+    tickIn -= dt;
+    if (heat > CHERRY_TICK_HEAT && tickIn <= 0) {
+      tickIn = 0.15 + (1 - heat) * 0.9;
+      audio.play('tick', sample.round!.cherry!.x, sample.round!.cherry!.y);
+    }
     if (sample.round) custardView.update(sample.round.custardY, world.terrain.width, world.terrain.height, dt, sample.round.finalRush);
     spoonsView.update(dt);
     cloudsView.update(sample.round?.clouds ?? [], dt);
@@ -663,6 +708,11 @@ async function start(): Promise<void> {
       }
       if (ev.stunnedNow) {
         audio.play('hit', state.x, state.y);
+        // Sent flying: whistle away.
+        if (Math.hypot(state.vx, state.vy) > SPIN_MIN_SPEED) {
+          audio.play('whizz', state.x, state.y);
+          popups.spawn(state.x, state.y - state.size - 30, fr.fxWords.whizz, 30);
+        }
         popups.spawn(state.x, state.y - state.size, fr.onomatopoeia.hit, 40);
         particles.burst(state.x, state.y, 10, 0xffe14d, 300);
         shake = Math.min(SHAKE_MAX, shake + HIT_SHAKE);

+ 46 - 0
src/renderer/style.css

@@ -874,3 +874,49 @@ input.invalid {
   border-radius: 14px;
   pointer-events: auto;
 }
+
+/* ---------- Surprise banner ---------- */
+
+#surprise-banner {
+  position: fixed;
+  top: 22%;
+  left: 50%;
+  transform: translateX(-50%) rotate(-3deg);
+  font-size: clamp(30px, 5vw, 64px);
+  font-weight: bold;
+  color: #ffe14d;
+  -webkit-text-stroke: 3px var(--ink);
+  text-shadow: 5px 5px 0 var(--ink);
+  white-space: nowrap;
+  pointer-events: none;
+  z-index: 9;
+}
+
+#surprise-banner[hidden] {
+  display: none;
+}
+
+#surprise-banner.pop {
+  animation: surprise-pop 2.6s ease-out forwards;
+}
+
+@keyframes surprise-pop {
+  0% {
+    transform: translateX(-50%) rotate(-3deg) scale(0.2);
+    opacity: 0;
+  }
+  12% {
+    transform: translateX(-50%) rotate(2deg) scale(1.15);
+    opacity: 1;
+  }
+  20% {
+    transform: translateX(-50%) rotate(-3deg) scale(1);
+  }
+  80% {
+    opacity: 1;
+  }
+  100% {
+    transform: translateX(-50%) rotate(-3deg) scale(1.05) translateY(-20px);
+    opacity: 0;
+  }
+}

+ 194 - 15
src/server/game.ts

@@ -20,6 +20,22 @@ import {
   CANDLE_THROW_SPEED,
   CAKE_CHECK_INTERVAL_S,
   CHERRY_DROP_SPEED,
+  CHERRY_FLEE_COOLDOWN_S,
+  CHERRY_FLEE_HOP,
+  CHERRY_FLEE_RADIUS,
+  CHERRY_FLEE_SPEED,
+  CHERRY_HOT_BLAST,
+  CHERRY_HOT_S,
+  CHERRY_STEAL_BONUS,
+  CHERRY_STEAL_WINDOW_S,
+  CRUSH_KILL_PIXELS,
+  FLAT_S,
+  SMALL_PROJECTILE_RADIUS,
+  SPLAT_SPEED,
+  SPLAT_STUN_S,
+  SURPRISE_WEIGHTS,
+  SURPRISE_WINDOW,
+  SURPRISES_PER_ROUND,
   CHERRY_HOLDER_JUMP_MULT,
   CHERRY_POINTS_PER_S,
   CHERRY_RADIUS,
@@ -88,7 +104,7 @@ import {
 import { Button, EMPTY_INPUT, isDown, type InputState } from '../shared/input';
 import type { MapDefinition } from '../shared/maps/loader';
 import type { MaterialRegistry } from '../shared/materials';
-import type { ChunkInfo, ChunkState, EntityKind, FxEvent, ItemKind, ItemState, PlayerInfo, PlayerState, RoundState, TurretState } from '../shared/protocol';
+import type { ChunkInfo, ChunkState, EntityKind, FxEvent, ItemKind, ItemState, PlayerInfo, PlayerState, RoundState, SurpriseId, TurretState } from '../shared/protocol';
 import { Rng } from '../shared/rng';
 import type { Rect, TerrainBitmap } from '../shared/terrain/bitmap';
 import { DetachFinder, type DetachedComponent } from '../shared/terrain/detach';
@@ -96,6 +112,7 @@ import { applyCells, applyTerrainEvent, rasterizeChunk, type SequencedEvent, typ
 import type { TerrainOp } from '../shared/terrain/ops';
 import { rleEncode } from '../shared/terrain/rle';
 import { MODIFIERS, type Modifier, type ModifierContext } from './modifiers';
+import { SURPRISES, type Surprise, type SurpriseContext } from './surprises';
 import { FallingChunk, type FixtureTag } from './physics/fallingChunk';
 import { TerrainColliders } from './physics/terrainColliders';
 import { MatchStats } from './stats';
@@ -148,6 +165,11 @@ interface ServerPlayer {
   cough: number;
   /** Drenched by Bébé Pompier: seconds left. */
   soaked: number;
+  /** Flattened into a pancake: seconds left. */
+  flat: number;
+  /** Speed (px/s) and velocity at the end of the previous step, to notice a slam into a wall. */
+  lastVx: number;
+  lastVy: number;
   /** Raw buttons of the previous tick (the suicide key works even when stunned). */
   prevRaw: number;
   item: ItemKind | null;
@@ -227,12 +249,21 @@ interface Cherry {
   /** Player who cannot grab it back yet, and for how long. */
   blocked: number;
   blockedFor: number;
+  /** Seconds in its current holder's hands (hot potato). */
+  heldFor: number;
+  /** Seconds before it may hop away again. */
+  fleeIn: number;
+  /** Who knocked it out of whose hands, and how long the steal bonus stays open. */
+  thief: number;
+  victim: number;
+  stealFor: number;
 }
 
 export interface Crush {
   id: number;
   x: number;
   y: number;
+  flat: boolean;
 }
 
 /** What happened during one tick, for the transport to broadcast. */
@@ -282,7 +313,12 @@ export class Game {
   private readonly chunks = new Map<number, FallingChunk>();
   private nextChunkId = 1;
   /** Players hit hard enough by a chunk during the current world step. */
-  private readonly crushedThisStep = new Set<number>();
+  /** Players hit hard enough by a chunk during the current world step, with the size of the biggest one (px). */
+  private readonly crushedThisStep = new Map<number, number>();
+  /** Speed of each falling chunk before the current world step (px/s). */
+  private readonly chunkSpeedBefore = new Map<number, number>();
+  /** Pixel count of each falling chunk (small ones only flatten). */
+  private readonly chunkPixels = new Map<number, number>();
   private nextPlayerId = 1;
   private nextSpawn = 0;
 
@@ -295,6 +331,10 @@ export class Game {
   /** FX produced outside step() bookkeeping (hits, pickups), flushed into the step result. */
   private fx: FxEvent[] = [];
   private readonly modifiers: Modifier[] = [];
+  /** Round times (s since the start) of the surprises still to come, and the one going on. */
+  private surpriseAt: number[] = [];
+  private surprise: { s: Surprise; t: number } | null = null;
+  private readonly surpriseCtx: SurpriseContext;
   private readonly modifierCtx: ModifierContext;
   /** Side wind this tick (px/s²). */
   private wind = 0;
@@ -333,7 +373,39 @@ export class Game {
         const make = MODIFIERS[id];
         if (make) this.modifiers.push(make());
       }
+      // Surprises at random times, away from the very start and the final rush.
+      const [from, beforeEnd] = SURPRISE_WINDOW;
+      const to = this.rules.duration - beforeEnd;
+      if (to > from) for (let i = 0; i < SURPRISES_PER_ROUND; i++) this.surpriseAt.push(this.rng.range(from, to));
+      this.surpriseAt.sort((a, b) => a - b);
     }
+    this.surpriseCtx = {
+      rng: this.rng,
+      terrain: this.terrain,
+      queueOp: (op) => this.queueOp(op),
+      players: () =>
+        this.alivePlayers().map((p) => {
+          const pos = p.body.getPosition();
+          return { id: p.info.id, x: pos.x * PPM, y: pos.y * PPM, r: p.bodyRadius, grounded: p.grounded };
+        }),
+      fling: (id, vx, vy, stun) => {
+        const p = this.players.get(id);
+        if (!p || p.dead > 0) return;
+        p.body.setLinearVelocity({ x: vx / PPM, y: vy / PPM });
+        p.body.setAwake(true);
+        if (stun > 0) this.stunPlayer(p, stun);
+        if (stun > 0 && this.cherry?.holder === id) this.dropCherry(p);
+      },
+      launch: (spec, x, y, vx, vy) => this.throwEntity(spec.kind, spec, 0, x, y, vx, vy),
+      top: (x) => {
+        const cx = Math.round(x);
+        // The plate is not cake: a column whose top is the plate has nothing to grab.
+        for (let y = 0; y < Math.min(this.terrain.height, this.custardY); y++) {
+          if (this.terrain.isSolid(cx, y)) return this.terrain.get(cx, y) === MATERIAL_PLATE ? null : y;
+        }
+        return null;
+      },
+    };
     this.modifierCtx = {
       rng: this.rng,
       terrain: this.terrain,
@@ -411,6 +483,9 @@ export class Game {
       poison: 0,
       cough: 0,
       soaked: 0,
+      flat: 0,
+      lastVx: 0,
+      lastVy: 0,
       prevRaw: 0,
       item: null,
       score: 0,
@@ -452,13 +527,17 @@ export class Game {
   spawnItem(kind: EntityKind, x: number, y: number): number {
     const id = this.nextItemId++;
     const body = this.world.createBody({ type: 'dynamic', position: { x: x / PPM, y: y / PPM }, bullet: true, userData: { item: id } });
-    body.createFixture(new Circle(ITEM_RADIUS / PPM), {
+    // Dragées and sprinkles are small: their little blast must reach the cake they land on.
+    const radius = kind === 'sprinkle' || kind === 'dragee' ? SMALL_PROJECTILE_RADIUS : ITEM_RADIUS;
+    body.createFixture(new Circle(radius / PPM), {
       density: 0.6,
       friction: 0.6,
       restitution: 0.25,
       userData: { item: id } satisfies FixtureTag,
       // A ball of yarn flies through players: it must not stick to its thrower or wrap anyone.
       filterMaskBits: kind === 'yarn' ? ~CATEGORY_PLAYER & 0xffff : 0xffff,
+      // Sprinkles rain down together: they never hit one another.
+      filterGroupIndex: kind === 'sprinkle' ? -1 : 0,
     });
     this.items.set(id, { id, kind, body, age: 0, thrown: null });
     return id;
@@ -492,13 +571,19 @@ export class Game {
     this.syncTerrain(result);
 
     this.crushedThisStep.clear();
+    this.chunkSpeedBefore.clear();
+    for (const c of this.chunks.values()) this.chunkSpeedBefore.set(c.id, c.speed());
     this.world.step(TICK_DT, 8, 3);
-    for (const id of this.crushedThisStep) {
+    for (const [id, pixels] of this.crushedThisStep) {
       const p = this.players.get(id);
       if (!p || p.dead > 0) continue;
       const pos = p.body.getPosition();
-      result.crushed.push({ id, x: pos.x * PPM, y: pos.y * PPM });
-      this.kill(p, 'crushed');
+      // A small chunk only flattens its victim into a pancake for a while.
+      const flat = pixels < CRUSH_KILL_PIXELS;
+      if (flat && p.flat > 0) continue;
+      result.crushed.push({ id, x: pos.x * PPM, y: pos.y * PPM, flat });
+      if (flat) this.flatten(p);
+      else this.kill(p, 'crushed');
     }
     for (const p of this.players.values()) this.postStep(p);
     this.updateRound();
@@ -534,7 +619,8 @@ export class Game {
           (p.dead > 0 ? Anim.Dead : 0) |
           (p.boost > 0 ? Anim.Boosted : 0) |
           (p.poison > 0 ? Anim.Poisoned : 0) |
-          (p.soaked > 0 ? Anim.Soaked : 0),
+          (p.soaked > 0 ? Anim.Soaked : 0) |
+          (p.flat > 0 ? Anim.Flat : 0),
         score: Math.floor(p.score),
         item: p.item,
       };
@@ -555,7 +641,7 @@ export class Game {
     let cherry: RoundState['cherry'] = null;
     if (this.cherry) {
       const pos = this.cherry.body.getPosition();
-      cherry = { x: pos.x * PPM, y: pos.y * PPM, holder: this.cherry.holder };
+      cherry = { x: pos.x * PPM, y: pos.y * PPM, holder: this.cherry.holder, heat: this.cherry.holder ? Math.min(1, this.cherry.heldFor / CHERRY_HOT_S) : 0 };
     }
     return {
       custardY: this.custardY,
@@ -604,6 +690,7 @@ export class Game {
     if (applyCells(this.terrain, 'detach', x0, y0, w, h, c.mask).changed) this.log({ kind: 'detach', id, x: x0, y: y0, w, h, mask, debris }, result);
     if (debris) return;
     this.chunks.set(id, new FallingChunk(this.world, id, c.mask, w, h, x0, y0, this.materials, mask));
+    this.chunkPixels.set(id, c.pixelCount);
     this.stats.add(owner, 'detached');
   }
 
@@ -685,6 +772,7 @@ export class Game {
   private removeChunk(chunk: FallingChunk): void {
     this.world.destroyBody(chunk.body);
     this.chunks.delete(chunk.id);
+    this.chunkPixels.delete(chunk.id);
   }
 
   private onPostSolve(contact: Contact, impulse: ContactImpulse): void {
@@ -694,10 +782,11 @@ export class Game {
     const chunkId = a?.chunk ?? b?.chunk;
     if (playerId === undefined || chunkId === undefined) return;
     const chunk = this.chunks.get(chunkId);
-    if (!chunk || !chunk.canCrush || chunk.speed() < CRUSH_MIN_SPEED) return;
+    // Speed before the impact: a small chunk stops dead on the player it lands on.
+    if (!chunk || !chunk.canCrush || Math.max(chunk.speed(), this.chunkSpeedBefore.get(chunkId) ?? 0) < CRUSH_MIN_SPEED) return;
     let total = 0;
     for (const n of impulse.normalImpulses) total += n;
-    if (total > CRUSH_IMPULSE) this.crushedThisStep.add(playerId);
+    if (total > CRUSH_IMPULSE) this.crushedThisStep.set(playerId, Math.max(this.crushedThisStep.get(playerId) ?? 0, this.chunkPixels.get(chunkId) ?? Infinity));
   }
 
   // ---------------------------------------------------------------- players
@@ -731,6 +820,7 @@ export class Game {
       return;
     }
     p.stun = Math.max(0, p.stun - TICK_DT);
+    p.flat = Math.max(0, p.flat - TICK_DT);
     p.stunLock = Math.max(0, p.stunLock - TICK_DT);
     p.soaked = Math.max(0, p.soaked - TICK_DT);
     p.boost = Math.max(0, p.boost - TICK_DT);
@@ -809,6 +899,10 @@ export class Game {
     if (p.dead > 0) return;
     p.grounded = false;
     let wall = 0;
+    // The contact facing the previous velocity the most: a slam into it is a cartoon splat.
+    const lastSpeed = Math.hypot(p.lastVx, p.lastVy);
+    let slam: { nx: number; ny: number } | null = null;
+    let slamDot = 0;
     for (let edge = p.body.getContactList(); edge; edge = edge.next) {
       const contact = edge.contact;
       if (!contact.isTouching()) continue;
@@ -818,11 +912,26 @@ export class Game {
       const flip = contact.getFixtureA().getBody() === p.body ? -1 : 1;
       const nx = normal.x * flip;
       const ny = normal.y * flip;
+      const dot = (p.lastVx * nx + p.lastVy * ny) / (lastSpeed || 1);
+      if (dot < slamDot) {
+        slamDot = dot;
+        slam = { nx, ny };
+      }
       if (ny < GROUND_NORMAL_Y) p.grounded = true;
       // A wall on the left pushes the player right (+x), and the other way round.
       else if (Math.abs(nx) > WALL_NORMAL_X) wall = nx > 0 ? -1 : 1;
     }
     p.sinceGrounded = p.grounded ? 0 : p.sinceGrounded + TICK_DT;
+    const vel = p.body.getLinearVelocity();
+    // Only someone sent flying by a hit (stunned) splats: a long fall onto the floor is just a landing.
+    if (slam && p.stun > 0 && lastSpeed > SPLAT_SPEED && slamDot < -0.6 && Math.hypot(vel.x, vel.y) * PPM < lastSpeed * 0.5) {
+      const pos = p.body.getPosition();
+      this.fx.push({ kind: 'splat', id: p.info.id, x: pos.x * PPM, y: pos.y * PPM, nx: slam.nx, ny: slam.ny });
+      // Stuck to the wall a moment longer.
+      p.stun = Math.max(p.stun, SPLAT_STUN_S);
+    }
+    p.lastVx = vel.x * PPM;
+    p.lastVy = vel.y * PPM;
     if (!p.grounded && wall !== 0) {
       p.wallSide = wall;
       p.sinceWall = 0;
@@ -1005,8 +1114,15 @@ export class Game {
       target.body.setLinearVelocity({ x: ((nx / len) * kb) / PPM, y: ((ny / len) * kb) / PPM });
       this.stunPlayer(target, stun);
       target.character.onHit(this.contextFor(target, target.input));
-      // A hit holder lets the cherry go (readme §4).
-      if (this.cherry?.holder === target.info.id) this.dropCherry(target);
+      // A hit holder lets the cherry go (readme §4); the attacker may steal it.
+      if (this.cherry?.holder === target.info.id) {
+        this.dropCherry(target);
+        if (attacker) {
+          this.cherry.thief = attacker.info.id;
+          this.cherry.victim = target.info.id;
+          this.cherry.stealFor = CHERRY_STEAL_WINDOW_S;
+        }
+      }
       this.stats.add(target.info.id, 'hitTaken');
       if (attacker) this.stats.add(attacker.info.id, 'hits');
       count++;
@@ -1024,6 +1140,15 @@ export class Game {
     p.stunLock = seconds + STUN_GUARD_S;
   }
 
+  /** Flattened into a pancake by a small chunk: stuck for FLAT_S (whatever the stun guard says). */
+  private flatten(p: ServerPlayer): void {
+    if (this.cherry?.holder === p.info.id) this.dropCherry(p);
+    this.stats.add(p.info.id, 'hitTaken');
+    p.flat = FLAT_S;
+    p.stun = FLAT_S;
+    p.stunLock = FLAT_S + STUN_GUARD_S;
+  }
+
   /** Bébé Pompier's spray: drenched players run and jump less for a while. */
   private drench(by: ServerPlayer, x: number, y: number, r: number): number {
     let count = 0;
@@ -1293,6 +1418,7 @@ export class Game {
   private updateRound(): void {
     if (!this.rules) return;
     for (const m of this.modifiers) m.tick(this.modifierCtx, TICK_DT);
+    this.updateSurprise();
     if (this.wind !== 0) {
       // Loose things drift too.
       const push = (body: Body): void => {
@@ -1313,6 +1439,26 @@ export class Game {
     this.custardY = Math.max(0, this.custardY - speed * TICK_DT);
   }
 
+  /** Starts a surprise now (also used by tests and debug tools). */
+  startSurprise(id: SurpriseId): void {
+    const s = SURPRISES[id](this.surpriseCtx);
+    this.surprise = { s, t: 0 };
+    this.fx.push({ kind: 'surprise', surprise: id, x: s.x, y: s.y, duration: s.duration });
+  }
+
+  private updateSurprise(): void {
+    const elapsed = (this.rules?.duration ?? 0) - this.timeLeft;
+    if (!this.surprise && this.surpriseAt.length > 0 && elapsed >= this.surpriseAt[0]) {
+      this.surpriseAt.shift();
+      this.startSurprise(this.rng.weighted(SURPRISE_WEIGHTS as Record<SurpriseId, number>));
+    }
+    const active = this.surprise;
+    if (!active) return;
+    active.s.tick(this.surpriseCtx, active.t, TICK_DT);
+    active.t += TICK_DT;
+    if (active.t >= active.s.duration) this.surprise = null;
+  }
+
   /** Solid pixels above the custard, plate excluded: what is left to stand on and fight over. */
   private cakeLeft(): number {
     const { width, data } = this.terrain;
@@ -1330,13 +1476,15 @@ export class Game {
     const [x, y] = this.map.cherrySpawn;
     const body = this.world.createBody({ type: 'dynamic', position: { x: x / PPM, y: y / PPM }, bullet: true });
     body.createFixture(new Circle(CHERRY_RADIUS / PPM), { density: 0.6, friction: 0.5, restitution: 0.35, userData: { cherry: true } satisfies FixtureTag });
-    this.cherry = { body, holder: 0, blocked: 0, blockedFor: 0 };
+    this.cherry = { body, holder: 0, blocked: 0, blockedFor: 0, heldFor: 0, fleeIn: 0, thief: 0, victim: 0, stealFor: 0 };
   }
 
   private updateCherry(): void {
     const cherry = this.cherry;
     if (!cherry) return;
     cherry.blockedFor = Math.max(0, cherry.blockedFor - TICK_DT);
+    cherry.stealFor = Math.max(0, cherry.stealFor - TICK_DT);
+    cherry.fleeIn = Math.max(0, cherry.fleeIn - TICK_DT);
     const holder = cherry.holder ? this.players.get(cherry.holder) : undefined;
     if (holder) {
       // Held: it rides above the holder's head and scores 1 point per second.
@@ -1344,6 +1492,14 @@ export class Game {
       cherry.body.setPosition({ x: pos.x, y: pos.y - (holder.character.size + CHERRY_HOLD_OFFSET) / PPM });
       holder.score += CHERRY_POINTS_PER_S * TICK_DT;
       this.stats.add(holder.info.id, 'cherry', TICK_DT);
+      // Hot potato: held too long, it blows up in their hands.
+      cherry.heldFor += TICK_DT;
+      if (cherry.heldFor >= CHERRY_HOT_S) {
+        const at = cherry.body.getPosition();
+        this.fx.push({ kind: 'cherryBoom', id: holder.info.id, x: at.x * PPM, y: at.y * PPM });
+        this.dropCherry(holder);
+        this.explodeAt(pos.x * PPM, pos.y * PPM, CHERRY_HOT_BLAST, 0, null);
+      }
       return;
     }
     cherry.holder = 0;
@@ -1358,15 +1514,38 @@ export class Game {
       cherry.body.setLinearVelocity({ x: 0, y: 0 });
       return;
     }
+    let nearest: ServerPlayer | null = null;
+    let nearestDist = Infinity;
     for (const p of this.alivePlayers()) {
       if (p.stun > 0 || (cherry.blocked === p.info.id && cherry.blockedFor > 0)) continue;
       const pp = p.body.getPosition();
-      if (Math.hypot(pp.x - pos.x, pp.y - pos.y) * PPM > p.bodyRadius + CHERRY_RADIUS + 4) continue;
+      const dist = Math.hypot(pp.x - pos.x, pp.y - pos.y) * PPM;
+      if (dist < nearestDist) {
+        nearest = p;
+        nearestDist = dist;
+      }
+      if (dist > p.bodyRadius + CHERRY_RADIUS + 4) continue;
       cherry.holder = p.info.id;
+      cherry.heldFor = 0;
       cherry.body.setActive(false);
       this.fx.push({ kind: 'pickup', id: p.info.id, item: 'cherry', x: pos.x * PPM, y: pos.y * PPM });
+      // Knocked out of someone's hands and grabbed right away: stolen!
+      if (cherry.stealFor > 0 && cherry.thief === p.info.id) {
+        p.score += CHERRY_STEAL_BONUS;
+        this.fx.push({ kind: 'stolen', id: p.info.id, from: cherry.victim, bonus: CHERRY_STEAL_BONUS, x: pos.x * PPM, y: pos.y * PPM });
+      }
+      cherry.stealFor = 0;
       return;
     }
+    // Alive: resting, it hops away from whoever comes close (it can still be cornered).
+    const v = cherry.body.getLinearVelocity();
+    if (nearest && nearestDist < CHERRY_FLEE_RADIUS && cherry.fleeIn === 0 && Math.hypot(v.x, v.y) * PPM < 40) {
+      const away = pos.x >= nearest.body.getPosition().x ? 1 : -1;
+      cherry.body.setLinearVelocity({ x: (away * CHERRY_FLEE_SPEED) / PPM, y: -CHERRY_FLEE_HOP / PPM });
+      cherry.body.setAwake(true);
+      cherry.fleeIn = CHERRY_FLEE_COOLDOWN_S;
+      this.fx.push({ kind: 'cherryHop', x: pos.x * PPM, y: pos.y * PPM });
+    }
   }
 
   /** The holder lets the cherry go: it pops out and rolls (readme §4). */
@@ -1435,7 +1614,7 @@ export class Game {
   }
 
   private pickUp(p: ServerPlayer, item: Item, x: number, y: number): void {
-    if (item.kind === 'firecracker' || item.kind === 'yarn' || item.kind === 'dragee') return; // projectiles are not pickups
+    if (item.kind === 'firecracker' || item.kind === 'yarn' || item.kind === 'dragee' || item.kind === 'sprinkle') return; // projectiles are not pickups
     this.removeItem(item);
     this.fx.push({ kind: 'pickup', id: p.info.id, item: item.kind, x, y });
     if (item.kind === 'candle') p.item = 'candle';

+ 172 - 0
src/server/surprises/index.ts

@@ -0,0 +1,172 @@
+import type { ProjectileSpec } from '../../shared/characters';
+import {
+  CAT_PAW_KNOCKBACK,
+  CAT_PAW_LIFT,
+  CAT_PAW_POWER,
+  CAT_PAW_RADIUS,
+  CAT_PAW_STUN,
+  HAND_BITE_POWER,
+  HAND_BITE_RADIUS,
+  HAND_GRAB_RADIUS,
+  HAND_TOSS_SPEED,
+  QUAKE_CRACK_POWER,
+  QUAKE_CRACK_RADIUS,
+  QUAKE_CRACKS,
+  QUAKE_JOLT,
+  QUAKE_PERIOD_S,
+  SPRINKLE_BLAST,
+  SPRINKLE_PERIOD_S,
+  SPRINKLE_SPEED,
+  SURPRISE_DURATIONS,
+} from '../../shared/constants';
+import type { SurpriseId } from '../../shared/protocol';
+import type { Rng } from '../../shared/rng';
+import type { TerrainBitmap } from '../../shared/terrain/bitmap';
+import type { TerrainOp } from '../../shared/terrain/ops';
+
+// Surprise events (readme §4): once or twice a round, something from the kitchen turns the
+// game upside down. Every one goes through the terrain or the players; the client draws it.
+
+/** What a surprise may do. Implemented by the server Game. */
+export interface SurpriseContext {
+  readonly rng: Rng;
+  readonly terrain: TerrainBitmap;
+  queueOp(op: TerrainOp): void;
+  /** Living players: id, position and radius. */
+  players(): { id: number; x: number; y: number; r: number; grounded: boolean }[];
+  /** Sets a player's velocity (px/s) and stuns them (0 = no stun). */
+  fling(id: number, vx: number, vy: number, stun: number): void;
+  launch(spec: ProjectileSpec, x: number, y: number, vx: number, vy: number): void;
+  /** Highest solid pixel of column x (the top of the cake), or null. */
+  top(x: number): number | null;
+}
+
+export interface Surprise {
+  readonly id: SurpriseId;
+  /** Where it happens (meaning depends on the surprise, see the client). */
+  readonly x: number;
+  readonly y: number;
+  readonly duration: number;
+  /** `t`: seconds since it started. */
+  tick(ctx: SurpriseContext, t: number, dt: number): void;
+}
+
+/** A random column with cake in it, and its top (or the middle of the map if none). */
+function randomTop(ctx: SurpriseContext): [number, number] {
+  for (let tries = 0; tries < 60; tries++) {
+    const x = ctx.rng.int(60, ctx.terrain.width - 60);
+    const y = ctx.top(x);
+    if (y !== null) return [x, y];
+  }
+  return [ctx.terrain.width / 2, ctx.terrain.height / 2];
+}
+
+/** "Le chat de la maison passe !": a giant paw sweeps across the summit of the cake. */
+function catPaw(ctx: SurpriseContext): Surprise {
+  const fromLeft = ctx.rng.next() < 0.5;
+  // It scrapes the summit: lower, it would saw through the tiers and send the whole cake down.
+  let top = ctx.terrain.height;
+  for (let x = 0; x < ctx.terrain.width; x += 8) top = Math.min(top, ctx.top(x) ?? top);
+  const y = top + CAT_PAW_RADIUS * 0.6;
+  const x0 = fromLeft ? -CAT_PAW_RADIUS : ctx.terrain.width + CAT_PAW_RADIUS;
+  const x1 = fromLeft ? ctx.terrain.width + CAT_PAW_RADIUS : -CAT_PAW_RADIUS;
+  const dir = fromLeft ? 1 : -1;
+  const duration = SURPRISE_DURATIONS['cat-paw'];
+  const hit = new Set<number>();
+  return {
+    id: 'cat-paw',
+    x: x0,
+    y,
+    duration,
+    tick(c, t) {
+      const x = x0 + (x1 - x0) * Math.min(1, t / duration);
+      c.queueOp({ kind: 'carve', x: Math.round(x), y: Math.round(y), r: CAT_PAW_RADIUS, power: CAT_PAW_POWER });
+      for (const p of c.players()) {
+        if (hit.has(p.id) || Math.hypot(p.x - x, p.y - y) > CAT_PAW_RADIUS + p.r) continue;
+        hit.add(p.id);
+        c.fling(p.id, dir * CAT_PAW_KNOCKBACK, -CAT_PAW_LIFT, CAT_PAW_STUN);
+      }
+    },
+  };
+}
+
+/** "Tremblement de table !": the whole cake jumps, players hop around, cracks open. */
+function quake(): Surprise {
+  let next = 0;
+  return {
+    id: 'quake',
+    x: 0,
+    y: 0,
+    duration: SURPRISE_DURATIONS.quake,
+    tick(c, t) {
+      if (t < next) return;
+      next += QUAKE_PERIOD_S;
+      for (const p of c.players()) if (p.grounded) c.fling(p.id, c.rng.range(-1, 1) * QUAKE_JOLT * 0.6, -QUAKE_JOLT, 0);
+      const { width, height } = c.terrain;
+      for (let n = 0; n < QUAKE_CRACKS; n++) {
+        for (let tries = 0; tries < 30; tries++) {
+          const x = c.rng.int(0, width - 1);
+          const y = c.rng.int(0, height - 1);
+          if (!c.terrain.isSolid(x, y)) continue;
+          c.queueOp({ kind: 'carve', x, y, r: QUAKE_CRACK_RADIUS, power: QUAKE_CRACK_POWER });
+          break;
+        }
+      }
+    },
+  };
+}
+
+/** "Pluie de vermicelles !": sugar sprinkles rain down and nibble the cake everywhere. */
+function sprinkles(): Surprise {
+  let next = 0;
+  const spec: ProjectileSpec = { kind: 'sprinkle', fuse: 8, blast: SPRINKLE_BLAST };
+  return {
+    id: 'sprinkles',
+    x: 0,
+    y: 0,
+    duration: SURPRISE_DURATIONS.sprinkles,
+    tick(c, t) {
+      while (next <= t) {
+        next += SPRINKLE_PERIOD_S;
+        const x = c.rng.int(20, c.terrain.width - 20);
+        c.launch(spec, x, -20, c.rng.range(-60, 60), SPRINKLE_SPEED);
+      }
+    },
+  };
+}
+
+/** "L'enfant a faim !": a giant hand comes down, grabs a fistful of cake and whoever is there. */
+function giantHand(ctx: SurpriseContext): Surprise {
+  const [x, y] = randomTop(ctx);
+  const duration = SURPRISE_DURATIONS['giant-hand'];
+  let done = false;
+  return {
+    id: 'giant-hand',
+    x,
+    y,
+    duration,
+    tick(c, t) {
+      // The shadow grows for the first half (time to run), then the hand closes.
+      if (done || t < duration / 2) return;
+      done = true;
+      c.queueOp({ kind: 'carve', x: Math.round(x), y: Math.round(y + HAND_BITE_RADIUS * 0.4), r: HAND_BITE_RADIUS, power: HAND_BITE_POWER });
+      for (const p of c.players()) {
+        if (Math.hypot(p.x - x, p.y - y) > HAND_GRAB_RADIUS) continue;
+        c.fling(p.id, c.rng.range(-0.4, 0.4) * HAND_TOSS_SPEED, -HAND_TOSS_SPEED, 0.8);
+      }
+    },
+  };
+}
+
+/** "Panne de courant !": the lights go out (drawn by the clients; only eyes glow). */
+function blackout(): Surprise {
+  return { id: 'blackout', x: 0, y: 0, duration: SURPRISE_DURATIONS.blackout, tick() {} };
+}
+
+export const SURPRISES: Record<SurpriseId, (ctx: SurpriseContext) => Surprise> = {
+  'cat-paw': catPaw,
+  quake,
+  sprinkles,
+  'giant-hand': giantHand,
+  blackout,
+};

+ 4 - 0
src/shared/characters/Character.ts

@@ -39,6 +39,8 @@ export const Anim = {
   Soaked: 1 << 8,
   /** Léon le Caméléon blending in: hidden from the other players. */
   Invisible: 1 << 9,
+  /** Flattened into a pancake by a small falling chunk. */
+  Flat: 1 << 10,
 } as const;
 
 /** A random spell of the Magicien Raté. */
@@ -58,6 +60,8 @@ export type ProjectileSpec =
   | { kind: 'firecracker'; fuse: number; blast: Blast }
   /** A sugar pellet shot by a turret: same behaviour as a firecracker, drawn differently. */
   | { kind: 'dragee'; fuse: number; blast: Blast }
+  /** A sugar sprinkle from the "pluie de vermicelles" surprise. */
+  | { kind: 'sprinkle'; fuse: number; blast: Blast }
   /** Trails a thread tied under the thrower; where it hits, the thread is laid taut and a wool blob sticks. */
   | { kind: 'yarn'; life: number; trailRadius: number; blobRadius: number; material: number };
 

+ 48 - 1
src/shared/constants.ts

@@ -2,7 +2,7 @@
 // (characters, maps, materials) whenever they belong to a specific entity.
 
 /** Bumped on every incompatible protocol change. */
-export const PROTOCOL_VERSION = 11;
+export const PROTOCOL_VERSION = 12;
 
 export const NET_PORT = 47100;
 export const DISCOVERY_PORT = 47101;
@@ -185,6 +185,53 @@ export const WALL_JUMP_PUSH = 170;
 export const WALL_SLIDE_SPEED = 90;
 /** The cherry is heavy: its holder jumps this much (and Captain Prout gets fewer thrusts). */
 export const CHERRY_HOLDER_JUMP_MULT = 0.8;
+// The cherry is alive (readme §4). Tune by playing.
+/** Free and resting, it hops away from a player closer than this (px), at most once per cooldown (s). */
+export const CHERRY_FLEE_RADIUS = 90;
+export const CHERRY_FLEE_COOLDOWN_S = 1.2;
+export const CHERRY_FLEE_SPEED = 220;
+export const CHERRY_FLEE_HOP = 380;
+/** Hot potato: held this long by the same player, it explodes in their hands (s). */
+export const CHERRY_HOT_S = 20;
+export const CHERRY_HOT_BLAST = { r: 40, power: 30, knockback: 700, stun: 0.6 };
+/** Knocking the cherry out of someone's hands and grabbing it within this delay (s) steals it: bonus points. */
+export const CHERRY_STEAL_WINDOW_S = 2.5;
+export const CHERRY_STEAL_BONUS = 3;
+
+// Cartoon hits. Tune by playing.
+/** A player flying faster than this (px/s) who slams into a wall or the floor goes "SPLAF". */
+export const SPLAT_SPEED = 650;
+export const SPLAT_STUN_S = 0.4;
+/** A crushing chunk smaller than this (px) only flattens its victim into a pancake for FLAT_S. */
+export const CRUSH_KILL_PIXELS = 6000;
+export const FLAT_S = 2;
+
+// Surprise events (server/surprises). Tune by playing.
+/** Surprises per round, at random times between SURPRISE_WINDOW[0] s and SURPRISE_WINDOW[1] s before the end. */
+export const SURPRISES_PER_ROUND = 2;
+export const SURPRISE_WINDOW: [number, number] = [25, 20];
+export const SURPRISE_WEIGHTS = { 'cat-paw': 3, quake: 2, sprinkles: 2, 'giant-hand': 3, blackout: 2 } as const;
+export const SURPRISE_DURATIONS = { 'cat-paw': 1.8, quake: 2.5, sprinkles: 4, 'giant-hand': 1.8, blackout: 6 } as const;
+export const CAT_PAW_RADIUS = 46;
+export const CAT_PAW_POWER = 50;
+export const CAT_PAW_KNOCKBACK = 900;
+export const CAT_PAW_LIFT = 350;
+export const CAT_PAW_STUN = 0.5;
+export const QUAKE_PERIOD_S = 0.3;
+export const QUAKE_JOLT = 420;
+export const QUAKE_CRACKS = 4;
+export const QUAKE_CRACK_RADIUS = 9;
+export const QUAKE_CRACK_POWER = 35;
+export const SPRINKLE_PERIOD_S = 0.06;
+export const SPRINKLE_SPEED = 300;
+export const SPRINKLE_BLAST = { r: 9, power: 25, knockback: 160, stun: 0 };
+/** Body radius of dragées and sprinkles (px). */
+export const SMALL_PROJECTILE_RADIUS = 5;
+export const HAND_BITE_RADIUS = 85;
+export const HAND_BITE_POWER = 55;
+export const HAND_GRAB_RADIUS = 110;
+export const HAND_TOSS_SPEED = 1000;
+
 /** Contact normal Y below which a contact counts as ground (y points down). */
 export const GROUND_NORMAL_Y = -0.5;
 /** A growing body's physics fixture is rebuilt once its radius drifts this far (px). */

+ 18 - 2
src/shared/protocol.ts

@@ -39,7 +39,7 @@ export interface PlayerState {
 
 export type ItemKind = 'candle' | 'smarties' | 'whisk';
 /** Things in flight: pickable items, and projectiles thrown by characters. */
-export type EntityKind = ItemKind | 'firecracker' | 'yarn' | 'dragee';
+export type EntityKind = ItemKind | 'firecracker' | 'yarn' | 'dragee' | 'sprinkle';
 
 /** An item lying around, falling, or thrown (lit candle, firecracker, yarn ball). */
 export interface ItemState {
@@ -67,8 +67,13 @@ export interface CherryState {
   y: number;
   /** Player holding it, 0 when it rolls free. */
   holder: number;
+  /** Hot potato: 0 when just picked up, 1 when it explodes in the holder's hands. */
+  heat: number;
 }
 
+/** Surprise events (server/surprises). */
+export type SurpriseId = 'cat-paw' | 'quake' | 'sprinkles' | 'giant-hand' | 'blackout';
+
 /** Round-wide state carried by every snapshot. Null fields are inactive (warm-up). */
 export interface RoundState {
   custardY: number;
@@ -92,6 +97,16 @@ export type FxEvent =
   | { kind: 'pickup'; id: number; item: ItemKind | 'cherry'; x: number; y: number }
   | { kind: 'cherryDrop'; id: number; x: number; y: number }
   | { kind: 'whisk'; id: number }
+  /** A surprise starts: (x, y) depends on it (where the paw comes from, where the hand grabs). */
+  | { kind: 'surprise'; surprise: SurpriseId; x: number; y: number; duration: number }
+  /** The free cherry hops away from someone. */
+  | { kind: 'cherryHop'; x: number; y: number }
+  /** Held too long, the cherry exploded in its holder's hands. */
+  | { kind: 'cherryBoom'; id: number; x: number; y: number }
+  /** `id` knocked the cherry out of `from`'s hands and grabbed it: bonus points. */
+  | { kind: 'stolen'; id: number; from: number; bonus: number; x: number; y: number }
+  /** A player slammed into a wall or the floor; (nx, ny) points away from it. */
+  | { kind: 'splat'; id: number; x: number; y: number; nx: number; ny: number }
   /** A character's signature cue (a fart, a water jet, a spell...), at its position. */
   | { kind: 'cue'; id: number; cue: CharacterCue; x: number; y: number };
 
@@ -226,7 +241,8 @@ export type ServerMessage =
   | { t: 'terrain'; seq: number; ev: TerrainEvent }
   /** A chunk left the game without merging (fell off the map). */
   | { t: 'chunkRemoved'; id: number }
-  | { t: 'crushed'; id: number; x: number; y: number }
+  /** `flat`: only flattened into a pancake (small chunk), not out of the round. */
+  | { t: 'crushed'; id: number; x: number; y: number; flat: boolean }
   | { t: 'snapshot'; tick: number; players: PlayerState[]; chunks: ChunkState[]; items: ItemState[]; round: RoundState }
   | { t: 'fx'; ev: FxEvent }
   | {

+ 188 - 0
tests/fun.test.ts

@@ -0,0 +1,188 @@
+import { describe, expect, it } from 'vitest';
+import { loadCharacterStats, loadMap } from '../src/server/assets';
+import { Game } from '../src/server/game';
+import { Anim } from '../src/shared/characters';
+import { CHERRY_HOT_S, CHERRY_STEAL_BONUS, CRUSH_KILL_PIXELS, PIXELS_PER_METER, SURPRISE_DURATIONS, TICK_RATE } from '../src/shared/constants';
+import { Button } from '../src/shared/input';
+import type { FxEvent, SurpriseId } from '../src/shared/protocol';
+import { ASSETS, realMaterials } from './helpers';
+
+const materials = realMaterials();
+const PPM = PIXELS_PER_METER;
+
+function newRound(seed = 1, duration = 180): Game {
+  const { definition, terrain } = loadMap(ASSETS, 'gateau-etages', materials);
+  return new Game({ map: definition, terrain, materials, seed, rules: { duration }, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
+}
+
+function run(game: Game, ticks: number): FxEvent[] {
+  const fx: FxEvent[] = [];
+  for (let i = 0; i < ticks; i++) fx.push(...game.step().fx);
+  return fx;
+}
+
+const me = (game: Game, id: number) => game.snapshot().find((p) => p.id === id)!;
+const solid = (game: Game): number => game.terrain.data.reduce((n, m) => n + (m !== 0 ? 1 : 0), 0);
+
+interface CherryHandle {
+  body: { setPosition(p: { x: number; y: number }): void; setLinearVelocity(v: { x: number; y: number }): void };
+}
+const cherryOf = (game: Game): CherryHandle => (game as unknown as { cherry: CherryHandle }).cherry;
+
+describe('surprises', () => {
+  it('two of them happen during a round, at random times', () => {
+    // A short round keeps the test fast: surprises come between 25 s and 20 s before the end.
+    const game = newRound(3, 60);
+    const fx = run(game, 60 * TICK_RATE);
+    expect(fx.filter((e) => e.kind === 'surprise')).toHaveLength(2);
+  });
+
+  it('each one runs to its end without trouble, and the terrain stays consistent', () => {
+    for (const id of Object.keys(SURPRISE_DURATIONS) as SurpriseId[]) {
+      const game = newRound();
+      game.addPlayer('Témoin', 'generic');
+      run(game, 30);
+      game.startSurprise(id);
+      run(game, Math.ceil((SURPRISE_DURATIONS[id] + 1) * TICK_RATE));
+      expect(game.roundOver, id).toBe(false);
+    }
+  });
+
+  it('the cat paw scrapes the summit of the cake and sends whoever is up there flying', () => {
+    const game = newRound();
+    const { id } = game.addPlayer('Balayé', 'generic');
+    game.teleport(id, 1024, 240); // top tier, the summit
+    run(game, 60);
+    const before = solid(game);
+    game.startSurprise('cat-paw');
+    let flung = false;
+    for (let i = 0; i < SURPRISE_DURATIONS['cat-paw'] * TICK_RATE + 5; i++) {
+      run(game, 1);
+      if (Math.abs(me(game, id).vx) > 500) flung = true;
+    }
+    expect(flung).toBe(true);
+    expect(solid(game)).toBeLessThan(before);
+  });
+
+  it('the giant hand grabs a fistful of cake and tosses whoever stands there', () => {
+    const game = newRound(7);
+    const { id } = game.addPlayer('Attrapé', 'generic');
+    run(game, 10);
+    const before = solid(game);
+    const fx = run(game, 1).concat(((): FxEvent[] => {
+      game.startSurprise('giant-hand');
+      return run(game, 1);
+    })());
+    const hand = fx.find((e) => e.kind === 'surprise')!;
+    expect(hand).toMatchObject({ surprise: 'giant-hand' });
+    if (hand.kind !== 'surprise') return;
+    // Stand right where the hand comes down.
+    game.teleport(id, hand.x, hand.y - 20);
+    let tossed = false;
+    for (let i = 0; i < SURPRISE_DURATIONS['giant-hand'] * TICK_RATE; i++) {
+      run(game, 1);
+      if (me(game, id).vy < -600) tossed = true;
+    }
+    expect(tossed).toBe(true);
+    expect(solid(game)).toBeLessThan(before - 5000);
+  });
+
+  it('sugar sprinkles rain down and nibble the cake', () => {
+    const game = newRound();
+    run(game, 10);
+    const before = solid(game);
+    game.startSurprise('sprinkles');
+    const fx = run(game, (SURPRISE_DURATIONS.sprinkles + 2) * TICK_RATE);
+    expect(fx.filter((e) => e.kind === 'explosion').length).toBeGreaterThan(20);
+    expect(solid(game)).toBeLessThan(before);
+  });
+});
+
+describe('the living cherry', () => {
+  it('hops away from a player who comes close', () => {
+    const game = newRound();
+    const { id } = game.addPlayer('Chasseur', 'generic');
+    run(game, 2 * TICK_RATE); // the cherry lands on the top tier
+    const c = game.roundState().cherry!;
+    game.teleport(id, c.x - 70, c.y - 10);
+    const fx = run(game, 10);
+    expect(fx.some((e) => e.kind === 'cherryHop')).toBe(true);
+  });
+
+  it('hot potato: held too long, it explodes in the holder’s hands', () => {
+    const game = newRound();
+    const { id } = game.addPlayer('Gourmand', 'generic');
+    run(game, 2 * TICK_RATE);
+    const p = me(game, id);
+    cherryOf(game).body.setPosition({ x: p.x / PPM, y: p.y / PPM });
+    run(game, 2);
+    expect(game.roundState().cherry!.holder).toBe(id);
+    const fx = run(game, Math.ceil(CHERRY_HOT_S * TICK_RATE));
+    expect(fx).toContainEqual(expect.objectContaining({ kind: 'cherryBoom', id }));
+    expect(game.roundState().cherry!.holder).not.toBe(id);
+  });
+
+  it('knocking it out of someone’s hands and grabbing it is a steal: bonus points', () => {
+    const game = newRound();
+    const holder = game.addPlayer('Volé', 'generic');
+    const thief = game.addPlayer('Voleur', 'castor');
+    game.teleport(holder.id, 600, 420);
+    game.teleport(thief.id, 560, 420);
+    run(game, 60);
+    const h = me(game, holder.id);
+    cherryOf(game).body.setPosition({ x: h.x / PPM, y: h.y / PPM });
+    run(game, 2);
+    expect(game.roundState().cherry!.holder).toBe(holder.id);
+    // Bite: the cherry pops out; put it in the thief's mouth right away.
+    const t = me(game, thief.id);
+    game.setInput(thief.id, { buttons: Button.Attack, aimX: Math.round(h.x), aimY: Math.round(h.y) });
+    run(game, 1);
+    game.setInput(thief.id, { buttons: 0, aimX: 0, aimY: 0 });
+    expect(game.roundState().cherry!.holder).toBe(0);
+    cherryOf(game).body.setPosition({ x: t.x / PPM, y: t.y / PPM });
+    cherryOf(game).body.setLinearVelocity({ x: 0, y: 0 });
+    const fx = run(game, 2);
+    expect(fx).toContainEqual(expect.objectContaining({ kind: 'stolen', id: thief.id, from: holder.id, bonus: CHERRY_STEAL_BONUS }));
+    expect(me(game, thief.id).score).toBeGreaterThanOrEqual(CHERRY_STEAL_BONUS);
+  });
+});
+
+describe('cartoon hits', () => {
+  it('a small falling chunk flattens a player into a pancake instead of killing them', () => {
+    const game = newRound();
+    const { id } = game.addPlayer('Crêpe', 'generic');
+    game.teleport(id, 400, 680); // bottom tier, open sky above
+    run(game, 30);
+    // A small block of biscuit (under CRUSH_KILL_PIXELS) dropped from high above.
+    const { width, data } = game.terrain;
+    const side = Math.floor(Math.sqrt(CRUSH_KILL_PIXELS / 2));
+    // Built hanging in the air: it is not anchored, so the next detach search drops it.
+    for (let y = 300; y < 300 + side; y++) for (let x = 400 - side / 2; x < 400 + side / 2; x++) data[y * width + Math.round(x)] = 5;
+    game.terrain.markDirty({ x0: 300, y0: 280, x1: 500, y1: 300 + side + 10 });
+    game.queueOp({ kind: 'carve', x: 400, y: 300 + side, r: 2, power: 99 }); // triggers the detach search there
+    let crushedFlat = false;
+    for (let i = 0; i < 2 * TICK_RATE && !crushedFlat; i++) {
+      const res = game.step();
+      if (res.crushed.some((c) => c.id === id && c.flat)) crushedFlat = true;
+    }
+    expect(crushedFlat).toBe(true);
+    const p = me(game, id);
+    expect(p.anim & Anim.Flat).toBeTruthy();
+    expect(p.anim & Anim.Dead).toBe(0);
+  });
+
+  it('a player knocked into a wall goes SPLAF', () => {
+    const game = newRound();
+    const cat = game.addPlayer('Chat', 'chat');
+    const victim = game.addPlayer('Victime', 'generic');
+    game.teleport(cat.id, 420, 680); // bottom tier, middle tier's wall at x = 520
+    game.teleport(victim.id, 470, 680);
+    run(game, 30);
+    const c = me(game, cat.id);
+    game.setInput(cat.id, { buttons: Button.Special, aimX: 600, aimY: Math.round(c.y) });
+    run(game, 1);
+    game.setInput(cat.id, { buttons: 0, aimX: 600, aimY: Math.round(c.y) });
+    const fx = run(game, 30);
+    expect(fx).toContainEqual(expect.objectContaining({ kind: 'splat', id: victim.id }));
+  });
+});

+ 1 - 1
tests/net.test.ts

@@ -173,7 +173,7 @@ describe('LAN server (M3)', () => {
       for (const h of c.hashChecks) expect(h.mine).toBe(h.server);
     }
     expect(a.terrain.hash()).toBe(b.terrain.hash());
-  });
+  }, 15_000); // 600 ticks of real networking: slow when the whole suite runs in parallel
 
   it('a player leaving is announced and an empty server goes back to the lobby', async () => {
     const s = await startServer();

+ 4 - 2
tests/rules.test.ts

@@ -77,12 +77,14 @@ describe('custard and the spoon', () => {
 
   it('the spoon finds the last little island of cake', () => {
     const game = newRound();
-    // Wipe the cake, plate excluded, except for a 120 px block on the far right.
+    // Wipe the cake, except for the plate and a 120 px block on the far right.
     const { width, height, data } = game.terrain;
     for (let y = 0; y < height; y++) {
       for (let x = 0; x < width; x++) {
         const i = y * width + x;
-        if (data[i] !== 255 && !(x >= 1600 && x < 1720 && y >= 820)) data[i] = 0;
+        // (The raised ends of the plate go too: they would be ledges as well.)
+        const plate = data[i] === 255 && x >= 220 && x < 1830;
+        if (!plate && !(x >= 1600 && x < 1720 && y >= 820 && data[i] !== 255)) data[i] = 0;
       }
     }
     game.terrain.markDirty({ x0: 0, y0: 0, x1: width, y1: height });