Quellcode durchsuchen

feat(characters): Mamie Tricot, Pyromanchot and the character selection screen (M5)

- Mamie Tricot: needles (short jab, long stun, light scratch on the cake);
  yarn ball that spins a wool thread behind it and sticks a wool blob where
  it lands, making bridges and nets when anchored. Slow; explosions burn
  wool within WOOL_BURN_RADIUS_FACTOR times their radius.
- Pyromanchot: firecrackers that explode on contact, one in three
  backfiring in his flippers; belly slide ending in an explosion that
  spares him.
- Character API: launch(projectile) and explode(blast); the server shares
  one projectile system for the candle, firecrackers and yarn, and one
  explosion routine (carve, burn wool, knockback).
- Painted Mamie (bun with needles, glasses, shawl) and Pyromanchot (headband,
  fizzing fuse, flat belly-slide pose); firecracker and yarn sprites.
- Selection screen: portraits plus a sheet with attack, special, flaw and
  speed/jump/weight bars read from character.json; compact title menu.
- Tests: roster, yarn thread holding, needle stun, wool burning, firecracker
  and backfire, belly slide.

No new dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Idleman vor 1 Woche
Ursprung
Commit
4939e09d8a

+ 25 - 0
assets/characters/mamie/character.json

@@ -0,0 +1,25 @@
+{
+  "id": "mamie",
+  "name": "Mamie Tricot",
+  "speed": 175,
+  "acceleration": 1500,
+  "jump": 540,
+  "mass": 1.1,
+  "size": 20,
+  "cooldowns": { "attack": 0.32, "special": 1.1 },
+  "params": {
+    "needleReach": 14,
+    "needleRadius": 14,
+    "needleKnockback": 260,
+    "needleStun": 0.7,
+    "needleCarveRadius": 7,
+    "needlePower": 15,
+    "jabAnim": 0.15,
+    "yarnSpeed": 560,
+    "yarnStart": 8,
+    "yarnLife": 1.3,
+    "yarnTrailRadius": 5,
+    "yarnBlobRadius": 13,
+    "throwAnim": 0.25
+  }
+}

+ 27 - 0
assets/characters/pyromanchot/character.json

@@ -0,0 +1,27 @@
+{
+  "id": "pyromanchot",
+  "name": "Pyromanchot",
+  "speed": 250,
+  "acceleration": 2100,
+  "jump": 600,
+  "mass": 1,
+  "size": 19,
+  "cooldowns": { "attack": 0.45, "special": 2.4 },
+  "params": {
+    "crackerSpeed": 620,
+    "crackerLift": 120,
+    "crackerFuse": 0.75,
+    "crackerRadius": 26,
+    "crackerPower": 40,
+    "crackerKnockback": 520,
+    "crackerStun": 0.4,
+    "backfireEvery": 3,
+    "throwAnim": 0.2,
+    "slideSpeed": 620,
+    "slideTime": 0.55,
+    "slideBlastRadius": 42,
+    "slideBlastPower": 45,
+    "slideKnockback": 680,
+    "slideStun": 0.5
+  }
+}

+ 6 - 1
readme.md

@@ -303,7 +303,12 @@ Chaque étape doit être **jouable** avant de passer à la suivante.
 ### M5 : Trois personnages
  
 - Castor Glouton (déjà jouable depuis M1), Mamie Tricot, Pyromanchot (le trio creuser / construire / exploser).
-- Écran de sélection des personnages (une version minimale existe dans l'écran titre).
+- Écran de sélection des personnages.
+- **État :** implémenté.
+  - **Mamie Tricot :** ses aiguilles assomment longtemps. Sa pelote tisse un fil de Laine qui s'accroche où elle touche : ancré aux deux bouts, c'est un pont ou un filet, sinon il tombe. Toute explosion brûle la laine dans `WOOL_BURN_RADIUS_FACTOR` fois son rayon.
+  - **Pyromanchot :** ses pétards explosent au contact, sauf un sur trois qui lui explose dessus. Sa glissade sur le ventre se termine par une explosion qui l'épargne.
+  - **Objets lancés :** bougie, pétards et pelotes partagent un même système de projectiles côté serveur.
+  - **Écran de sélection :** une fiche par personnage (attaque, spécial, défaut) avec des barres Vitesse / Saut / Poids lues dans `character.json`.
 ### M6 : Plusieurs cartes
  
 - Loader validé, rotation configurable dans le lobby, choix par le dernier, `modifiers`.

+ 3 - 1
src/main/index.ts

@@ -3,7 +3,8 @@ import { app, BrowserWindow, ipcMain, utilityProcess, type UtilityProcess, type
 import { readFileSync } from 'node:fs';
 import type { HostResult, MapFiles } from '../preload/api';
 import { DISCOVERY_INTERVAL_MS } from '../shared/constants';
-import { readMapFiles, readMapImage, resolveAssetsDir } from '../server/assets';
+import { loadCharacterStats, readMapFiles, readMapImage, resolveAssetsDir } from '../server/assets';
+import { CHARACTER_IDS } from '../shared/characters';
 import { DiscoveryListener } from './discovery';
 
 const assetsDir = app.isPackaged ? join(process.resourcesPath, 'assets') : join(app.getAppPath(), 'assets');
@@ -76,6 +77,7 @@ ipcMain.handle('lan:host', (_event, name: string) => hostServer(name));
 ipcMain.handle('lan:discover-start', (event) => startDiscovery(event.sender));
 ipcMain.handle('lan:discover-stop', () => stopDiscovery());
 ipcMain.handle('assets:materials', () => JSON.parse(readFileSync(join(resolveAssetsDir(), 'materials', 'materials.json'), 'utf8')));
+ipcMain.handle('assets:characters', () => CHARACTER_IDS.map((id) => loadCharacterStats(resolveAssetsDir(), id)));
 ipcMain.handle('assets:map', (_event, mapId: string): MapFiles => {
   const dir = resolveAssetsDir();
   const { definition, terrain } = readMapFiles(dir, mapId);

+ 2 - 0
src/preload/api.ts

@@ -36,6 +36,8 @@ export interface LanApi {
 
 export interface AssetsApi {
   materials(): Promise<unknown>;
+  /** Raw character.json of every playable character. */
+  characters(): Promise<unknown[]>;
   map(mapId: string): Promise<MapFiles>;
 }
 

+ 1 - 0
src/preload/index.ts

@@ -16,6 +16,7 @@ const lan: LanApi = {
 
 const assets: AssetsApi = {
   materials: () => ipcRenderer.invoke('assets:materials'),
+  characters: () => ipcRenderer.invoke('assets:characters'),
   map: (mapId) => ipcRenderer.invoke('assets:map', mapId),
 };
 

+ 104 - 0
src/renderer/characters/painters.ts

@@ -178,7 +178,111 @@ 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. */
+export const paintMamie: Painter = (g, r, pose) => {
+  const step = pose.moving && pose.grounded ? Math.sin(pose.walk) : 0;
+  foot(g, -0.35 * r - step * 0.15 * r, 0.93 * r, r, 0x8a5fc9, pose.ink);
+
+  // Bun with crossed needles (behind the head).
+  g.moveTo(-0.75 * r, -1.35 * r).lineTo(0.05 * r, -0.85 * r).moveTo(-0.65 * r, -0.8 * r).lineTo(0.0 * r, -1.4 * r).stroke({ width: pose.ink * 0.8, color: 0xc0c8d0 });
+  g.circle(-0.75 * r, -1.35 * r, 0.07 * r).fill(0xff6f91);
+  g.circle(0.0 * r, -1.4 * r, 0.07 * r).fill(0x4dc4ff);
+  g.circle(-0.32 * r, -1.08 * r, 0.3 * r).fill(0xe8e8f0).stroke({ width: pose.ink, color: INK });
+
+  // Dress, face, hair cap.
+  g.circle(0, 0, r).fill(0xb18cff).stroke({ width: pose.ink, color: INK });
+  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 });
+  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.
+  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);
+
+  eyes(
+    g,
+    pose,
+    [
+      [0.2 * r, -0.22 * r],
+      [0.56 * r, -0.2 * r],
+    ],
+    0.12 * r,
+  );
+  // 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 });
+  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 });
+  if (pose.attack) {
+    g.circle(0.42 * r, 0.1 * r, 0.08 * r).fill(0x5a1e1e);
+    // Needles poking forward.
+    g.moveTo(0.75 * r, 0.25 * r).lineTo(1.55 * r, 0.1 * r).moveTo(0.75 * r, 0.32 * r).lineTo(1.5 * r, 0.3 * r).stroke({ width: pose.ink * 0.7, color: 0xc0c8d0 });
+  } else {
+    g.moveTo(0.3 * r, 0.08 * r).quadraticCurveTo(0.45 * r, 0.16 * r, 0.6 * r, 0.06 * r).stroke({ width: pose.ink * 0.6, color: INK, cap: 'round' });
+  }
+  if (pose.special) {
+    // Arm flung forward, a fresh ball of yarn.
+    g.circle(1.05 * r, -0.1 * r, 0.25 * r).fill(0x9b6bd9).stroke({ width: pose.ink * 0.7, color: INK });
+    g.moveTo(0.9 * r, -0.2 * r).quadraticCurveTo(1.05 * r, 0, 1.2 * r, -0.2 * r).stroke({ width: pose.ink * 0.4, color: INK });
+  }
+  foot(g, 0.35 * r + step * 0.15 * r, 0.93 * r, r, 0x8a5fc9, pose.ink);
+  stars(g, r, pose);
+};
+
+/** Pyromanchot: a penguin with a red headband and a fizzing fuse; lies flat during the belly slide. */
+export const paintPyromanchot: Painter = (g, r, pose) => {
+  const black = 0x2a2a3a;
+  const orange = 0xff9a2e;
+  const spark = Math.sin(pose.time * 30) > 0;
+  if (pose.special) {
+    // Belly slide: flat, beak first, speed lines behind.
+    for (let i = 0; i < 3; i++) g.moveTo(-1.9 * r, (0.05 + i * 0.25) * r).lineTo(-1.35 * r, (0.05 + i * 0.25) * r).stroke({ width: pose.ink * 0.6, color: INK });
+    g.ellipse(0, 0.35 * r, 1.25 * r, 0.65 * r).fill(black).stroke({ width: pose.ink, color: INK });
+    g.ellipse(0.05 * r, 0.62 * r, 1.0 * r, 0.32 * r).fill(0xffffff);
+    g.poly([1.15 * r, 0.1 * r, 1.6 * r, 0.25 * r, 1.15 * r, 0.42 * r]).fill(orange).stroke({ width: pose.ink * 0.7, color: INK });
+    eyes(g, { ...pose, lookX: 1, lookY: 0 }, [[0.85 * r, 0.05 * r]], 0.15 * r);
+    g.rect(-0.2 * r, -0.28 * r, 0.9 * r, 0.16 * r).fill(0xe0123a);
+    return;
+  }
+  const step = pose.moving && pose.grounded ? Math.sin(pose.walk) : 0;
+  const waddle = pose.moving ? Math.sin(pose.walk) * 0.08 : 0;
+  foot(g, -0.3 * r - step * 0.12 * r, 0.95 * r, r, orange, pose.ink);
+
+  // Fuse sticking out of the headband, fizzing.
+  g.moveTo(-0.6 * r, -0.62 * r).quadraticCurveTo(-1.0 * r, -0.9 * r, -0.85 * r, -1.25 * r).stroke({ width: pose.ink * 0.6, color: INK });
+  if (spark) {
+    g.circle(-0.85 * r, -1.28 * r, 0.12 * r).fill(0xffe14d);
+    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);
+  // Flippers.
+  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 });
+  if (pose.attack) {
+    // A firecracker in the flipper.
+    g.roundRect(0.85 * r, -0.65 * r, 0.18 * r, 0.45 * r, 0.05 * r).fill(0xe0123a).stroke({ width: pose.ink * 0.6, color: INK });
+    if (spark) g.circle(0.94 * r, -0.72 * r, 0.09 * r).fill(0xffe14d);
+  }
+  // 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 });
+  eyes(
+    g,
+    pose,
+    [
+      [0.28 * r, -0.3 * r],
+      [0.6 * r, -0.28 * r],
+    ],
+    0.15 * r,
+  );
+  g.poly([0.85 * r, -0.12 * r, 1.35 * r, 0.0 * r, 0.85 * r, 0.12 * r]).fill(orange).stroke({ width: pose.ink * 0.7, color: INK });
+  foot(g, 0.3 * r + step * 0.12 * r, 0.95 * r, r, orange, pose.ink);
+  stars(g, r, pose);
+};
+
 export const PAINTERS: Record<string, Painter> = {
   castor: paintCastor,
+  mamie: paintMamie,
+  pyromanchot: paintPyromanchot,
   generic: paintGeneric,
 };

+ 14 - 3
src/renderer/entities/RoundViews.ts

@@ -1,5 +1,5 @@
 import { Container, Graphics } from 'pixi.js';
-import type { CherryState, ItemKind, ItemState } from '../../shared/protocol';
+import type { CherryState, EntityKind, ItemState } from '../../shared/protocol';
 import { CUSTARD_COLOR, CUSTARD_DARK, OUTLINE_COLOR, OUTLINE_WIDTH } from '../config';
 
 const INK = OUTLINE_COLOR;
@@ -60,8 +60,19 @@ export class CherryView {
 }
 
 /** Draws one item in local coordinates (origin at its center). */
-export function paintItem(g: Graphics, kind: ItemKind, lit: boolean, time: number): void {
-  if (kind === 'candle') {
+export function paintItem(g: Graphics, kind: EntityKind, lit: boolean, time: number): void {
+  if (kind === 'firecracker') {
+    g.roundRect(-4, -11, 8, 20, 2).fill(0xe0123a).stroke({ width: 2, color: INK });
+    g.rect(-4, -3, 8, 4).fill(0xffe14d);
+    g.moveTo(0, -11).quadraticCurveTo(4, -15, 2, -19).stroke({ width: 1.5, color: INK });
+    if (Math.sin(time * 45) > -0.3) {
+      g.circle(2, -20, 3.5).fill(0xffe14d);
+      g.circle(2, -20, 1.6).fill(0xffffff);
+    }
+  } else if (kind === 'yarn') {
+    g.circle(0, 0, 10).fill(0x9b6bd9).stroke({ width: 2.5, color: INK });
+    g.moveTo(-8, -4).quadraticCurveTo(0, -10, 8, -3).moveTo(-9, 2).quadraticCurveTo(0, -4, 9, 3).moveTo(-6, 7).quadraticCurveTo(0, 2, 6, 8).stroke({ width: 1.5, color: 0x6e44b0 });
+  } else if (kind === 'candle') {
     g.roundRect(-5, -14, 10, 26, 3).fill(0xffffff).stroke({ width: 2.5, color: INK });
     for (let i = 0; i < 3; i++) g.rect(-5, -12 + i * 9, 10, 4).fill(0xff6f91);
     g.moveTo(0, -14).lineTo(0, -19).stroke({ width: 2, color: INK });

+ 29 - 3
src/renderer/i18n/fr.ts

@@ -14,6 +14,8 @@ export const fr = {
   rejectedCharacter: 'Ce personnage n’existe pas sur ce serveur.',
   loadError: (msg: string) => `Impossible de charger la partie : ${msg}`,
   help: {
+    mamie: 'Q/D : courir · Espace/Z : sauter · Clic gauche : aiguilles · Clic droit : lancer une pelote · F : lancer la bougie',
+    pyromanchot: 'Q/D : courir · Espace/Z : sauter · Clic gauche : pétard · Clic droit : glissade explosive · F : lancer la bougie',
     generic: 'Q/D : courir · Espace/Z : sauter · Clic gauche : croquer le décor · F : lancer la bougie',
     castor: 'Q/D : courir · Espace/Z : sauter · Clic gauche : coup de dents · Clic droit (maintenu) : manger · F : lancer la bougie',
   } as Record<string, string>,
@@ -77,12 +79,36 @@ export const fr = {
   } as Record<string, { name: string; desc: (v: number) => string }>,
   defaultName: 'Gourmand',
   characters: {
-    generic: { name: 'Le Testeur', desc: 'Court, saute, croque. Rien de plus, rien de moins.' },
     castor: {
       name: 'Castor Glouton',
-      desc: 'Mange le décor en avançant. Mais plus il mange, plus il grossit… jusqu’à passer à travers le sol.',
+      desc: 'Creuse en mangeant tout ce qui passe.',
+      attack: 'Coup de dents',
+      special: 'Mange le décor en avançant (maintenu)',
+      flaw: 'Grossit en mangeant, jusqu’à passer à travers le sol',
     },
-  } as Record<string, { name: string; desc: string }>,
+    mamie: {
+      name: 'Mamie Tricot',
+      desc: 'Construit des ponts et des filets en laine.',
+      attack: 'Aiguilles : pousse peu, assomme longtemps',
+      special: 'Pelote : un fil de laine qui s’accroche où il touche',
+      flaw: 'Lente, et ses constructions brûlent',
+    },
+    pyromanchot: {
+      name: 'Pyromanchot',
+      desc: 'Un manchot qui adore les feux d’artifice.',
+      attack: 'Pétards qui explosent au contact',
+      special: 'Glissade sur le ventre, explosion à l’arrivée',
+      flaw: '1 pétard sur 3 lui explose dessus',
+    },
+    generic: {
+      name: 'Le Testeur',
+      desc: 'Court, saute, croque. Rien de plus.',
+      attack: 'Croque le décor',
+      special: '—',
+      flaw: 'Aucun charisme',
+    },
+  } as Record<string, { name: string; desc: string; attack: string; special: string; flaw: string }>,
+  stats: { speed: 'Vitesse', jump: 'Saut', mass: 'Poids' },
   /** Comic-book sound effects shown on impacts. */
   onomatopoeia: {
     crunch: ['CROC !', 'CRUNCH !', 'SCRONCH !', 'CRAC !'],

+ 3 - 2
src/renderer/main.ts

@@ -1,6 +1,6 @@
 import 'pixi.js/unsafe-eval'; // Pixi without eval, required by our Content-Security-Policy
 import { Application, CanvasSource, Container, ImageSource, Sprite, Texture } from 'pixi.js';
-import { Anim, CHARACTER_IDS } from '../shared/characters';
+import { Anim, CHARACTER_IDS, parseCharacterStats } from '../shared/characters';
 import { DEFAULT_CHARACTER_ID, INTERPOLATION_DELAY_MS, PROTOCOL_VERSION } from '../shared/constants';
 import { buildTerrain, parseMapDefinition } from '../shared/maps/loader';
 import { parseHexColor, parseMaterials, type MaterialRegistry } from '../shared/materials';
@@ -102,8 +102,9 @@ function makePortraits(app: Application): Map<string, HTMLCanvasElement> {
 /** Title menu then game browser, until the player picks a game to host or join. */
 async function chooseGame(app: Application): Promise<{ choice: MenuChoice; address: string; port: number }> {
   const portraits = makePortraits(app);
+  const stats = new Map((await window.assets.characters()).map((raw) => parseCharacterStats(raw)).map((st) => [st.id, st]));
   for (;;) {
-    const choice = await showMenu(CHARACTER_IDS, DEFAULT_CHARACTER_ID, portraits);
+    const choice = await showMenu(CHARACTER_IDS, DEFAULT_CHARACTER_ID, portraits, stats);
     const target = await showBrowser();
     if (target.mode === 'back') continue;
     if (target.mode === 'join') return { choice, address: target.address, port: target.port };

+ 99 - 0
src/renderer/style.css

@@ -85,6 +85,9 @@ body {
 
 .menu-card {
   width: min(760px, calc(100vw - 32px));
+  max-height: calc(100vh - 32px);
+  overflow-y: auto;
+  box-sizing: border-box;
   background: var(--paper);
   border: 4px solid var(--ink);
   border-radius: 28px 22px 30px 20px;
@@ -575,3 +578,99 @@ input.invalid {
   font-size: 14px;
   opacity: 0.8;
 }
+
+/* ---------- Character sheet ---------- */
+
+.characters .character {
+  width: 150px;
+  padding: 8px 8px 10px;
+}
+
+.sheet {
+  display: flex;
+  gap: 18px;
+  justify-content: center;
+  align-items: center;
+  flex-wrap: wrap;
+  margin: 14px auto 0;
+  padding: 10px 16px;
+  max-width: 640px;
+  min-height: 110px;
+  background: #fff;
+  border: 3px dashed var(--ink);
+  border-radius: 16px 12px 18px 11px;
+  text-align: left;
+}
+
+.sheet .abilities {
+  flex: 1 1 300px;
+}
+
+.sheet p {
+  margin: 3px 0;
+  font-size: 15px;
+}
+
+.sheet .desc {
+  font-style: italic;
+  margin-bottom: 6px;
+}
+
+.sheet .flaw {
+  color: #b02f4c;
+}
+
+.sheet .stats {
+  flex: 0 0 auto;
+}
+
+.stat {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 10px;
+  font-size: 14px;
+  margin: 4px 0;
+}
+
+.pips {
+  display: inline-flex;
+  gap: 4px;
+}
+
+.pips i {
+  width: 14px;
+  height: 14px;
+  border-radius: 50%;
+  border: 2px solid var(--ink);
+  background: #fff;
+}
+
+.pips i.on {
+  background: var(--pink);
+}
+
+/* Compact title menu: four characters and their sheet must fit in 720 px. */
+.menu-card {
+  padding: 14px 24px 16px;
+}
+
+.menu-card h1 {
+  font-size: clamp(30px, 5vw, 46px);
+}
+
+.subtitle {
+  margin: 2px 0 10px;
+}
+
+.menu-card h2 {
+  margin: 10px 0 8px;
+}
+
+.portrait {
+  height: 92px;
+}
+
+#menu-play {
+  margin-top: 12px;
+}

+ 72 - 6
src/renderer/ui/menu.ts

@@ -1,3 +1,4 @@
+import type { CharacterStats } from '../../shared/characters';
 import { fr } from '../i18n/fr';
 
 export interface MenuChoice {
@@ -24,8 +25,36 @@ function save(key: string, value: string): void {
   }
 }
 
-/** Title screen: nickname + character choice. `previews` maps character ids to a rendered portrait. */
-export function showMenu(characterIds: string[], defaultId: string, previews: Map<string, HTMLCanvasElement>): Promise<MenuChoice> {
+const PIPS = 5;
+
+/** A row of PIPS dots, `value` relative to the best character for that stat. */
+function statBar(label: string, value: number, best: number): HTMLDivElement {
+  const row = document.createElement('div');
+  row.className = 'stat';
+  const name = document.createElement('span');
+  name.textContent = label;
+  const pips = document.createElement('span');
+  pips.className = 'pips';
+  const filled = Math.max(1, Math.round((value / best) * PIPS));
+  for (let i = 0; i < PIPS; i++) {
+    const pip = document.createElement('i');
+    if (i < filled) pip.className = 'on';
+    pips.appendChild(pip);
+  }
+  row.append(name, pips);
+  return row;
+}
+
+/**
+ * Title screen: nickname + character selection (readme §9, M5). `previews` maps character ids
+ * to a rendered portrait, `stats` gives the stat bars of the selected character's sheet.
+ */
+export function showMenu(
+  characterIds: string[],
+  defaultId: string,
+  previews: Map<string, HTMLCanvasElement>,
+  stats: Map<string, CharacterStats>,
+): Promise<MenuChoice> {
   const root = document.createElement('div');
   root.id = 'menu';
   root.innerHTML = `
@@ -37,6 +66,7 @@ export function showMenu(characterIds: string[], defaultId: string, previews: Ma
       </label>
       <h2>${fr.chooseCharacter}</h2>
       <div class="characters"></div>
+      <div class="sheet"></div>
       <button id="menu-play">${fr.play}</button>
     </div>`;
   document.body.appendChild(root);
@@ -49,7 +79,7 @@ export function showMenu(characterIds: string[], defaultId: string, previews: Ma
   const list = root.querySelector<HTMLDivElement>('.characters')!;
   const cards = new Map<string, HTMLButtonElement>();
   for (const id of characterIds) {
-    const text = fr.characters[id] ?? { name: id, desc: '' };
+    const text = fr.characters[id] ?? { name: id, desc: '', attack: '', special: '', flaw: '' };
     const card = document.createElement('button');
     card.className = 'character';
     card.type = 'button';
@@ -60,18 +90,54 @@ export function showMenu(characterIds: string[], defaultId: string, previews: Ma
     }
     const name = document.createElement('strong');
     name.textContent = text.name;
-    const desc = document.createElement('span');
-    desc.textContent = text.desc;
-    card.append(name, desc);
+    card.append(name);
     card.addEventListener('click', () => select(id));
     card.addEventListener('dblclick', () => play());
     list.appendChild(card);
     cards.set(id, card);
   }
 
+  const sheet = root.querySelector<HTMLDivElement>('.sheet')!;
+  const all = [...stats.values()];
+  const best = (key: 'speed' | 'jump' | 'mass'): number => Math.max(...all.map((st) => st[key]), 1);
+  function renderSheet(id: string): void {
+    const text = fr.characters[id];
+    const st = stats.get(id);
+    sheet.replaceChildren();
+    if (!text) return;
+    const left = document.createElement('div');
+    left.className = 'abilities';
+    const desc = document.createElement('p');
+    desc.className = 'desc';
+    desc.textContent = text.desc;
+    left.appendChild(desc);
+    for (const [icon, line, cls] of [
+      ['⚔️', text.attack, ''],
+      ['✨', text.special, ''],
+      ['⚠️', text.flaw, 'flaw'],
+    ]) {
+      const p = document.createElement('p');
+      p.className = cls;
+      p.textContent = `${icon} ${line}`;
+      left.appendChild(p);
+    }
+    sheet.appendChild(left);
+    if (st) {
+      const right = document.createElement('div');
+      right.className = 'stats';
+      right.append(
+        statBar(fr.stats.speed, st.speed, best('speed')),
+        statBar(fr.stats.jump, st.jump, best('jump')),
+        statBar(fr.stats.mass, st.mass, best('mass')),
+      );
+      sheet.appendChild(right);
+    }
+  }
+
   function select(id: string): void {
     selected = id;
     for (const [cid, card] of cards) card.classList.toggle('selected', cid === id);
+    renderSheet(id);
   }
   select(selected);
 

+ 100 - 24
src/server/game.ts

@@ -1,5 +1,13 @@
 import { Circle, World, type Body, type Contact, type ContactImpulse, type Fixture } from 'planck';
-import { Anim, createCharacter, type Character, type CharacterContext, type CharacterStats } from '../shared/characters';
+import {
+  Anim,
+  createCharacter,
+  type Blast,
+  type Character,
+  type CharacterContext,
+  type CharacterStats,
+  type ProjectileSpec,
+} from '../shared/characters';
 import {
   AIR_CONTROL,
   CANDLE_ARM_S,
@@ -29,9 +37,11 @@ import {
   ITEM_RADIUS,
   JUMP_BUFFER_TIME,
   JUMP_CUT,
+  MATERIAL_WOOL,
   MIN_CHUNK_PIXELS,
   OUT_OF_MAP_MARGIN,
   PIXELS_PER_METER,
+  PROJECTILE_ARM_S,
   RESIZE_THRESHOLD_PX,
   RESPAWN_DELAY_S,
   SMARTIES_DURATION_S,
@@ -43,11 +53,12 @@ import {
   WHISK_CRACKS,
   WHISK_JOLT,
   WHISK_STUN_S,
+  WOOL_BURN_RADIUS_FACTOR,
 } from '../shared/constants';
 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, FxEvent, ItemKind, ItemState, PlayerInfo, PlayerState, RoundState } from '../shared/protocol';
+import type { ChunkInfo, ChunkState, EntityKind, FxEvent, ItemKind, ItemState, PlayerInfo, PlayerState, RoundState } from '../shared/protocol';
 import { Rng } from '../shared/rng';
 import type { TerrainBitmap } from '../shared/terrain/bitmap';
 import { DetachFinder, type DetachedComponent } from '../shared/terrain/detach';
@@ -104,15 +115,33 @@ interface PendingOp {
   onApplied?: (removed: number) => void;
 }
 
+/** A lit candle, or a character's projectile. */
+interface Thrown {
+  owner: number;
+  age: number;
+  /** Candle and firecracker: seconds before it blows up on its own. */
+  fuse: number;
+  arm: number;
+  /** Null for a thrown candle. */
+  spec: ProjectileSpec | null;
+  /** Yarn: where the wool thread was last laid. */
+  trailX: number;
+  trailY: number;
+}
+
 interface Item {
   id: number;
-  kind: ItemKind;
+  kind: EntityKind;
   body: Body;
   age: number;
-  /** Set once a candle is thrown. */
-  thrown: { owner: number; fuse: number } | null;
+  /** Set once thrown: then it is a projectile, no longer something to pick up. */
+  thrown: Thrown | null;
 }
 
+const CANDLE_BLAST: Blast = { r: CANDLE_BLAST_RADIUS, power: CANDLE_BLAST_POWER, knockback: CANDLE_KNOCKBACK, stun: CANDLE_STUN_S };
+/** Spacing of the wool discs along a yarn thread (px). */
+const YARN_STEP = 4;
+
 interface Cherry {
   body: Body;
   holder: number;
@@ -300,7 +329,7 @@ export class Game {
   }
 
   /** Spawns an item now (tests and debug). */
-  spawnItem(kind: ItemKind, x: number, y: number): number {
+  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), { density: 0.6, friction: 0.6, restitution: 0.25, userData: { item: id } satisfies FixtureTag });
@@ -695,6 +724,8 @@ export class Game {
       setVelocity: (vx, vy) => p.body.setLinearVelocity({ x: vx / PPM, y: vy / PPM }),
       setSize: (radius) => this.resize(p, radius),
       hit: (x, y, r, knockback, stun) => this.hitArea(p, x, y, r, knockback, stun),
+      launch: (spec, x, y, vx, vy) => this.throwEntity(spec.kind, spec, p.info.id, x, y, vx, vy),
+      explode: (x, y, blast, spareSelf) => this.explodeAt(x, y, blast, p.info.id, spareSelf ? p : null),
     };
   }
 
@@ -799,9 +830,7 @@ export class Game {
         continue;
       }
       if (item.thrown) {
-        item.thrown.fuse -= TICK_DT;
-        const armed = CANDLE_FUSE_S - item.thrown.fuse >= CANDLE_ARM_S;
-        if (item.thrown.fuse <= 0 || (armed && this.touchesAnything(item.body))) this.explode(item, x, y);
+        this.updateThrown(item, item.thrown, x, y);
         continue;
       }
       item.age += TICK_DT;
@@ -820,11 +849,12 @@ export class Game {
   }
 
   private pickUp(p: ServerPlayer, item: Item, x: number, y: number): void {
+    if (item.kind === 'firecracker' || item.kind === 'yarn') 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';
     else if (item.kind === 'smarties') p.boost = SMARTIES_DURATION_S;
-    else this.whisk(p);
+    else if (item.kind === 'whisk') this.whisk(p);
   }
 
   private removeItem(item: Item): void {
@@ -848,24 +878,70 @@ export class Game {
     dx /= len;
     dy /= len;
     const start = p.bodyRadius + ITEM_RADIUS + 2;
-    const id = this.spawnItem('candle', px + dx * start, py + dy * start);
-    const item = this.items.get(id)!;
-    item.thrown = { owner: p.info.id, fuse: CANDLE_FUSE_S };
     const v = p.body.getLinearVelocity();
-    item.body.setLinearVelocity({ x: v.x + (dx * CANDLE_THROW_SPEED) / PPM, y: v.y + (dy * CANDLE_THROW_SPEED) / PPM });
-    item.body.setAngularVelocity(dx >= 0 ? 12 : -12);
+    this.throwEntity('candle', null, p.info.id, px + dx * start, py + dy * start, v.x * PPM + dx * CANDLE_THROW_SPEED, v.y * PPM + dy * CANDLE_THROW_SPEED);
     this.stats.add(p.info.id, 'candles');
   }
 
-  /** Candle explosion: carves the cake (chocolate resists) and blows everyone away, thrower included. */
-  private explode(item: Item, x: number, y: number): void {
-    const owner = item.thrown?.owner ?? 0;
-    this.removeItem(item);
-    this.queueOp({ kind: 'carve', x: Math.round(x), y: Math.round(y), r: CANDLE_BLAST_RADIUS, power: CANDLE_BLAST_POWER }, undefined, owner);
-    const attacker = this.players.get(owner);
-    const hits = this.hitArea(null, x, y, CANDLE_BLAST_RADIUS + 10, CANDLE_KNOCKBACK, CANDLE_STUN_S);
-    if (attacker) this.stats.add(owner, 'hits', hits);
-    this.fx.push({ kind: 'explosion', x, y, r: CANDLE_BLAST_RADIUS });
+  /** Spawns a projectile entity already in flight. */
+  private throwEntity(kind: EntityKind, spec: ProjectileSpec | null, owner: number, x: number, y: number, vx: number, vy: number): void {
+    const id = this.spawnItem(kind, x, y);
+    const item = this.items.get(id)!;
+    const fuse = spec?.kind === 'firecracker' ? spec.fuse : spec?.kind === 'yarn' ? Infinity : CANDLE_FUSE_S;
+    item.thrown = { owner, age: 0, fuse, arm: spec ? PROJECTILE_ARM_S : CANDLE_ARM_S, spec, trailX: x, trailY: y };
+    item.body.setLinearVelocity({ x: vx / PPM, y: vy / PPM });
+    item.body.setAngularVelocity(vx >= 0 ? 12 : -12);
+  }
+
+  private updateThrown(item: Item, t: Thrown, x: number, y: number): void {
+    t.age += TICK_DT;
+    t.fuse -= TICK_DT;
+    const hit = t.age >= t.arm && this.touchesAnything(item.body);
+    const spec = t.spec;
+    if (spec?.kind === 'yarn') {
+      this.layThread(item, t, spec, x, y);
+      if (hit || t.age >= spec.life) {
+        // The ball sticks where it lands (or gives up mid-air): a blob of wool.
+        this.removeItem(item);
+        this.queueOp({ kind: 'fill', x: Math.round(x), y: Math.round(y), r: spec.blobRadius, material: spec.material }, undefined, t.owner);
+      }
+      return;
+    }
+    if (t.fuse <= 0 || hit) {
+      this.removeItem(item);
+      this.explodeAt(x, y, spec?.kind === 'firecracker' ? spec.blast : CANDLE_BLAST, t.owner, null);
+    }
+  }
+
+  /** Lays wool discs from the last thread point up to just behind the ball, so the ball never sits in its own wool. */
+  private layThread(item: Item, t: Thrown, spec: Extract<ProjectileSpec, { kind: 'yarn' }>, x: number, y: number): void {
+    const v = item.body.getLinearVelocity();
+    const speed = Math.hypot(v.x, v.y);
+    if (speed === 0) return;
+    const back = ITEM_RADIUS + spec.trailRadius + 3;
+    const tx = x - (v.x / speed) * back;
+    const ty = y - (v.y / speed) * back;
+    let dist = Math.hypot(tx - t.trailX, ty - t.trailY);
+    while (dist >= YARN_STEP) {
+      t.trailX += ((tx - t.trailX) / dist) * YARN_STEP;
+      t.trailY += ((ty - t.trailY) / dist) * YARN_STEP;
+      this.queueOp({ kind: 'fill', x: Math.round(t.trailX), y: Math.round(t.trailY), r: spec.trailRadius, material: spec.material }, undefined, t.owner);
+      dist -= YARN_STEP;
+    }
+  }
+
+  /**
+   * Every explosion: carves the cake (chocolate resists), burns the wool around it
+   * (Mamie's constructions burn, readme §4) and blows players away, `spare` excepted.
+   */
+  private explodeAt(x: number, y: number, blast: Blast, owner: number, spare: ServerPlayer | null): void {
+    const cx = Math.round(x);
+    const cy = Math.round(y);
+    this.queueOp({ kind: 'carve', x: cx, y: cy, r: Math.round(blast.r), power: Math.round(blast.power) }, undefined, owner);
+    this.queueOp({ kind: 'convert', x: cx, y: cy, r: Math.round(blast.r * WOOL_BURN_RADIUS_FACTOR), from: MATERIAL_WOOL, to: 0 }, undefined, owner);
+    const hits = this.hitArea(spare, x, y, blast.r + 10, blast.knockback, blast.stun);
+    this.stats.add(owner, 'hits', hits);
+    this.fx.push({ kind: 'explosion', x, y, r: blast.r });
   }
 
   /** Electric whisk: jolts everyone else and shakes jelly loose, so weakened blocks come off (readme §4). */

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

@@ -31,6 +31,21 @@ export const Anim = {
   Boosted: 1 << 4,
 } as const;
 
+/** An explosion: carves the cake (power vs hardness), burns wool around it, blows players away. */
+export interface Blast {
+  r: number;
+  power: number;
+  knockback: number;
+  stun: number;
+}
+
+/** Projectiles a character can throw. Behaviour lives in the server; tuning comes from character.json. */
+export type ProjectileSpec =
+  /** Explodes on contact (after arming) or when the fuse runs out. */
+  | { kind: 'firecracker'; fuse: number; blast: Blast }
+  /** Spins a wool thread along its path and sticks a wool blob where it lands. */
+  | { kind: 'yarn'; life: number; trailRadius: number; blobRadius: number; material: number };
+
 /** What a character may read and do. Implemented by the server simulation. */
 export interface CharacterContext {
   readonly x: number;
@@ -48,6 +63,10 @@ export interface CharacterContext {
   setSize(radius: number): void;
   /** Knocks back and stuns every other player whose body touches the disc (x, y, r). Returns how many were hit. */
   hit(x: number, y: number, r: number, knockback: number, stun: number): number;
+  /** Throws a projectile from (x, y) with velocity (vx, vy) in px/s. */
+  launch(spec: ProjectileSpec, x: number, y: number, vx: number, vy: number): void;
+  /** Explodes at (x, y). `spareSelf`: the character is not blown away by its own blast. */
+  explode(x: number, y: number, blast: Blast, spareSelf: boolean): void;
 }
 
 /** Base class for all characters. Pure logic: no physics engine, no rendering. */

+ 56 - 0
src/shared/characters/Mamie.ts

@@ -0,0 +1,56 @@
+import { MATERIAL_WOOL } from '../constants';
+import { Anim, Character, type CharacterContext } from './Character';
+
+/**
+ * Mamie Tricot.
+ * - Attack: knitting needles, a short jab that barely pushes but stuns for a while.
+ * - Special: throws a ball of yarn that spins a wool thread along its path and sticks a
+ *   wool blob where it lands: bridges and nets, if both ends hold on to something.
+ * - Flaw: slow, and wool burns (explosions consume it, see WOOL_BURN_RADIUS_FACTOR).
+ */
+export class Mamie extends Character {
+  private jabTimer = 0;
+  private throwTimer = 0;
+
+  onAttack(ctx: CharacterContext): void {
+    const p = this.stats.params;
+    const [dx, dy] = aimDir(ctx);
+    const reach = this.size + p.needleReach;
+    const nx = ctx.x + dx * reach;
+    const ny = ctx.y + dy * reach;
+    ctx.hit(nx, ny, p.needleRadius, p.needleKnockback, p.needleStun);
+    // The needles scratch the cake a little: everything goes through the ground (readme §1).
+    ctx.emitOp({ kind: 'carve', x: Math.round(nx), y: Math.round(ny), r: Math.round(p.needleCarveRadius), power: Math.round(p.needlePower) });
+    this.jabTimer = p.jabAnim;
+  }
+
+  onSpecial(ctx: CharacterContext): void {
+    const p = this.stats.params;
+    const [dx, dy] = aimDir(ctx);
+    const start = this.size + p.yarnStart;
+    ctx.launch(
+      { kind: 'yarn', life: p.yarnLife, trailRadius: Math.round(p.yarnTrailRadius), blobRadius: Math.round(p.yarnBlobRadius), material: MATERIAL_WOOL },
+      ctx.x + dx * start,
+      ctx.y + dy * start,
+      dx * p.yarnSpeed + ctx.vx * 0.5,
+      dy * p.yarnSpeed + ctx.vy * 0.5,
+    );
+    this.throwTimer = p.throwAnim;
+  }
+
+  override onTick(_ctx: CharacterContext, dt: number): void {
+    this.jabTimer = Math.max(0, this.jabTimer - dt);
+    this.throwTimer = Math.max(0, this.throwTimer - dt);
+  }
+
+  override anim(): number {
+    return (this.jabTimer > 0 ? Anim.Attack : 0) | (this.throwTimer > 0 ? Anim.Special : 0);
+  }
+}
+
+export function aimDir(ctx: CharacterContext): [number, number] {
+  const dx = ctx.input.aimX - ctx.x;
+  const dy = ctx.input.aimY - ctx.y;
+  const len = Math.hypot(dx, dy);
+  return len < 1 ? [ctx.facing, 0] : [dx / len, dy / len];
+}

+ 66 - 0
src/shared/characters/Pyromanchot.ts

@@ -0,0 +1,66 @@
+import { Anim, Character, type Blast, type CharacterContext } from './Character';
+import { aimDir } from './Mamie';
+
+/**
+ * Pyromanchot, the penguin who loves fireworks.
+ * - Attack: throws a firecracker that explodes on contact.
+ * - Special: belly slide, ending in an explosion where he stops (he is spared).
+ * - Flaw: one firecracker in three goes off in his flippers.
+ */
+export class Pyromanchot extends Character {
+  private thrown = 0;
+  private slideTime = 0;
+  private slideDir = 1;
+  private throwTimer = 0;
+
+  onAttack(ctx: CharacterContext): void {
+    const p = this.stats.params;
+    this.thrown++;
+    this.throwTimer = p.throwAnim;
+    const blast = this.crackerBlast();
+    if (this.thrown % Math.max(1, Math.round(p.backfireEvery)) === 0) {
+      // Backfire: it blows up right here, and he is not spared.
+      ctx.explode(ctx.x + ctx.facing * this.size * 0.6, ctx.y, blast, false);
+      return;
+    }
+    const [dx, dy] = aimDir(ctx);
+    const start = this.size + 10;
+    ctx.launch(
+      { kind: 'firecracker', fuse: p.crackerFuse, blast },
+      ctx.x + dx * start,
+      ctx.y + dy * start,
+      dx * p.crackerSpeed + ctx.vx * 0.5,
+      dy * p.crackerSpeed + ctx.vy * 0.5 - p.crackerLift,
+    );
+  }
+
+  onSpecial(ctx: CharacterContext): void {
+    if (this.slideTime > 0) return;
+    this.slideTime = this.stats.params.slideTime;
+    this.slideDir = ctx.facing;
+  }
+
+  override onTick(ctx: CharacterContext, dt: number): void {
+    const p = this.stats.params;
+    this.throwTimer = Math.max(0, this.throwTimer - dt);
+    if (this.slideTime <= 0) return;
+    this.slideTime -= dt;
+    // Belly slide: fast and flat, gravity still applies.
+    ctx.setVelocity(this.slideDir * p.slideSpeed, Math.max(ctx.vy, 0));
+    // Blocked by a wall, or the slide is over: boom.
+    const blocked = this.slideTime < p.slideTime - 0.1 && Math.abs(ctx.vx) < p.slideSpeed * 0.3;
+    if (this.slideTime <= 0 || blocked) {
+      this.slideTime = 0;
+      ctx.explode(ctx.x + this.slideDir * this.size, ctx.y, { r: p.slideBlastRadius, power: p.slideBlastPower, knockback: p.slideKnockback, stun: p.slideStun }, true);
+    }
+  }
+
+  override anim(): number {
+    return (this.throwTimer > 0 ? Anim.Attack : 0) | (this.slideTime > 0 ? Anim.Special : 0);
+  }
+
+  private crackerBlast(): Blast {
+    const p = this.stats.params;
+    return { r: p.crackerRadius, power: p.crackerPower, knockback: p.crackerKnockback, stun: p.crackerStun };
+  }
+}

+ 7 - 2
src/shared/characters/index.ts

@@ -1,10 +1,15 @@
 import { Castor } from './Castor';
 import type { Character, CharacterStats } from './Character';
 import { Generic } from './Generic';
+import { Mamie } from './Mamie';
+import { Pyromanchot } from './Pyromanchot';
 
+// Menu order: the three M5 characters, then the test character.
 const registry: Record<string, new (stats: CharacterStats) => Character> = {
-  generic: Generic,
   castor: Castor,
+  mamie: Mamie,
+  pyromanchot: Pyromanchot,
+  generic: Generic,
 };
 
 /** Ids of playable characters, in menu order. */
@@ -21,4 +26,4 @@ export function createCharacter(stats: CharacterStats): Character {
 }
 
 export { Anim, Character, parseCharacterStats } from './Character';
-export type { CharacterContext, CharacterStats } from './Character';
+export type { Blast, CharacterContext, CharacterStats, ProjectileSpec } from './Character';

+ 6 - 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 = 5;
+export const PROTOCOL_VERSION = 6;
 
 export const NET_PORT = 47100;
 export const DISCOVERY_PORT = 47101;
@@ -56,6 +56,11 @@ export const WHISK_CRACK_RADIUS = 11;
 export const WHISK_CRACK_POWER = 35;
 export const WHISK_JOLT = 320;
 export const WHISK_STUN_S = 0.35;
+/** Explosions burn wool (material 7) within this multiple of their radius (readme §4, Mamie Tricot). */
+export const WOOL_BURN_RADIUS_FACTOR = 2.2;
+export const MATERIAL_WOOL = 7;
+/** Projectiles can only hit things after this delay, so they do not blow up in the thrower's hands (s). */
+export const PROJECTILE_ARM_S = 0.08;
 
 export const TICK_RATE = 60;
 export const TICK_DT = 1 / TICK_RATE;

+ 4 - 2
src/shared/protocol.ts

@@ -32,11 +32,13 @@ 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';
 
-/** An item lying around, falling, or (candle) thrown and lit. */
+/** An item lying around, falling, or thrown (lit candle, firecracker, yarn ball). */
 export interface ItemState {
   id: number;
-  kind: ItemKind;
+  kind: EntityKind;
   x: number;
   y: number;
   angle: number;

+ 140 - 0
tests/characters.test.ts

@@ -0,0 +1,140 @@
+import { describe, expect, it } from 'vitest';
+import { loadCharacterStats, loadMap } from '../src/server/assets';
+import { Game } from '../src/server/game';
+import { Anim, CHARACTER_IDS } from '../src/shared/characters';
+import { MATERIAL_WOOL, TICK_RATE } from '../src/shared/constants';
+import { Button } from '../src/shared/input';
+import type { FxEvent } from '../src/shared/protocol';
+import { ASSETS, realMaterials } from './helpers';
+
+const materials = realMaterials();
+
+function newGame(): Game {
+  const { definition, terrain } = loadMap(ASSETS, 'gateau-etages', materials);
+  return new Game({ map: definition, terrain, materials, seed: 5, 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 countWool = (game: Game): number => game.terrain.data.reduce((n, m) => n + (m === MATERIAL_WOOL ? 1 : 0), 0);
+
+/** Presses `buttons` aimed at (x, y) for one tick, then releases. */
+function press(game: Game, id: number, buttons: number, x: number, y: number): FxEvent[] {
+  game.setInput(id, { buttons, aimX: Math.round(x), aimY: Math.round(y) });
+  const fx = run(game, 1);
+  game.setInput(id, { buttons: 0, aimX: Math.round(x), aimY: Math.round(y) });
+  return fx;
+}
+
+describe('roster', () => {
+  it('every character has data that loads', () => {
+    expect(CHARACTER_IDS).toEqual(['castor', 'mamie', 'pyromanchot', 'generic']);
+    for (const id of CHARACTER_IDS) expect(loadCharacterStats(ASSETS, id).id).toBe(id);
+  });
+});
+
+describe('Mamie Tricot', () => {
+  it('her yarn ball spins a wool thread that holds when it reaches a wall', () => {
+    const game = newGame();
+    const { id } = game.addPlayer('Mamie', 'mamie');
+    game.teleport(id, 640, 440); // middle tier, left of the top tier wall (x ≈ 780)
+    run(game, 60);
+    const m = me(game, id);
+    press(game, id, Button.Special, 800, m.y - 40);
+    run(game, 2 * TICK_RATE);
+    const wool = countWool(game);
+    expect(wool).toBeGreaterThan(150);
+    // Let anything unanchored fall and merge or vanish: most of the thread must still be in the bitmap.
+    run(game, 4 * TICK_RATE);
+    expect(countWool(game)).toBeGreaterThan(wool * 0.5);
+  });
+
+  it('her needles barely push but stun for a long time', () => {
+    const game = newGame();
+    const mamie = game.addPlayer('Mamie', 'mamie');
+    const victim = game.addPlayer('Victime', 'generic');
+    run(game, 90);
+    const m = me(game, mamie.id);
+    game.teleport(victim.id, m.x + 40, m.y);
+    press(game, mamie.id, Button.Attack, m.x + 40, m.y);
+    const v = me(game, victim.id);
+    expect(v.anim & Anim.Stunned).toBeTruthy();
+    expect(Math.hypot(v.vx, v.vy)).toBeLessThan(400);
+    run(game, Math.round(0.6 * TICK_RATE));
+    expect(me(game, victim.id).anim & Anim.Stunned).toBeTruthy();
+  });
+
+  it('her wool burns: an explosion nearby consumes it', () => {
+    const game = newGame();
+    const mamie = game.addPlayer('Mamie', 'mamie');
+    const pyro = game.addPlayer('Pyro', 'pyromanchot');
+    game.teleport(mamie.id, 640, 440);
+    run(game, 60);
+    const m = me(game, mamie.id);
+    press(game, mamie.id, Button.Special, 800, m.y - 40);
+    run(game, 2 * TICK_RATE);
+    const before = countWool(game);
+    expect(before).toBeGreaterThan(0);
+    // A firecracker thrown into the wool.
+    game.teleport(pyro.id, 700, 300);
+    run(game, 2);
+    const fx = press(game, pyro.id, Button.Attack, 760, m.y - 30).concat(run(game, TICK_RATE));
+    expect(fx.some((e) => e.kind === 'explosion')).toBe(true);
+    run(game, 2);
+    expect(countWool(game)).toBeLessThan(before);
+  });
+});
+
+describe('Pyromanchot', () => {
+  it('throws firecrackers that explode away from him, and every third one backfires', () => {
+    const game = newGame();
+    const { id } = game.addPlayer('Pyro', 'pyromanchot');
+    run(game, 90);
+    const explosions: { x: number; y: number }[] = [];
+    for (let shot = 0; shot < 3; shot++) {
+      const p = me(game, id);
+      const fx = press(game, id, Button.Attack, p.x + 300, p.y - 100).concat(run(game, TICK_RATE));
+      for (const e of fx) if (e.kind === 'explosion') explosions.push({ x: e.x - p.x, y: e.y - p.y });
+      run(game, Math.round(0.5 * TICK_RATE));
+    }
+    expect(explosions).toHaveLength(3);
+    // The first two flew off; the third went off right next to him.
+    expect(Math.hypot(explosions[0].x, explosions[0].y)).toBeGreaterThan(60);
+    expect(Math.hypot(explosions[1].x, explosions[1].y)).toBeGreaterThan(60);
+    expect(Math.hypot(explosions[2].x, explosions[2].y)).toBeLessThan(40);
+  });
+
+  it('a backfire stuns him', () => {
+    const game = newGame();
+    const { id } = game.addPlayer('Pyro', 'pyromanchot');
+    run(game, 90);
+    for (let shot = 0; shot < 3; shot++) {
+      const p = me(game, id);
+      press(game, id, Button.Attack, p.x + 300, p.y - 100);
+      if (shot < 2) run(game, TICK_RATE);
+    }
+    expect(me(game, id).anim & Anim.Stunned).toBeTruthy();
+  });
+
+  it('belly slide: fast, then an explosion at the end that spares him', () => {
+    const game = newGame();
+    const pyro = game.addPlayer('Pyro', 'pyromanchot');
+    const victim = game.addPlayer('Victime', 'generic');
+    game.teleport(pyro.id, 900, 680); // bottom tier, lots of room to the right
+    game.teleport(victim.id, 1500, 680);
+    run(game, 60);
+    const start = me(game, pyro.id);
+    press(game, pyro.id, Button.Special, start.x + 500, start.y);
+    expect(me(game, pyro.id).anim & Anim.Special).toBeTruthy();
+    const fx = run(game, TICK_RATE);
+    const end = me(game, pyro.id);
+    expect(end.x - start.x).toBeGreaterThan(200);
+    expect(fx.some((e) => e.kind === 'explosion')).toBe(true);
+    expect(end.anim & Anim.Stunned).toBe(0);
+  });
+});