Procházet zdrojové kódy

feat(characters): add Tatie Douille (turrets) and Léon le Caméléon (invisible)

Tatie Douille pipes turrets on the cake: the base is a disc of biscuit
laid with a fill op, so a turret falls when the cake under it goes
(principle 2: it goes through the terrain). A turret shoots sugared
almonds at the nearest player it can see (never its builder), at most
two per player. She stands still while building.

Léon le Caméléon turns invisible for the others for 5 s (an anim flag:
clients hide him, he sees himself as a ghost). His long tongue licks a
groove and pulls back what it catches. Attacking or being hit reveals
him.

Small blasts (dragées) get a light effect, without the freeze frame.
Protocol version 11.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Idleman před 4 dny
rodič
revize
52b06970f2

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

@@ -0,0 +1,25 @@
+{
+  "id": "leon",
+  "name": "Léon le Caméléon",
+  "speed": 245,
+  "acceleration": 2200,
+  "jump": 610,
+  "mass": 0.9,
+  "size": 18,
+  "cooldowns": {
+    "attack": 0.6,
+    "special": 6
+  },
+  "params": {
+    "tongueReach": 110,
+    "tongueStep": 12,
+    "tongueCarve": 5,
+    "tonguePower": 25,
+    "tongueRadius": 18,
+    "tongueStun": 0.35,
+    "tonguePull": 620,
+    "tongueLift": 120,
+    "tongueAnim": 0.22,
+    "invisibleTime": 5
+  }
+}

+ 38 - 0
assets/characters/tatie/character.json

@@ -0,0 +1,38 @@
+{
+  "id": "tatie",
+  "name": "Tatie Douille",
+  "speed": 210,
+  "acceleration": 1800,
+  "jump": 570,
+  "mass": 1.1,
+  "size": 20,
+  "cooldowns": {
+    "attack": 0.4,
+    "special": 2.5
+  },
+  "params": {
+    "pinReach": 14,
+    "pinRadius": 18,
+    "pinKnockback": 560,
+    "pinStun": 0.35,
+    "pinCarve": 12,
+    "pinPower": 30,
+    "pinAnim": 0.2,
+    "buildReach": 120,
+    "buildAnim": 0.6,
+    "turretBaseRadius": 13,
+    "turretMaterial": 5,
+    "turretHeight": 18,
+    "turretRange": 420,
+    "turretInterval": 1.3,
+    "turretLife": 25,
+    "turretMax": 2,
+    "turretGroundReach": 160,
+    "drageeSpeed": 760,
+    "drageeFuse": 1.2,
+    "drageeRadius": 10,
+    "drageePower": 25,
+    "drageeKnockback": 420,
+    "drageeStun": 0.25
+  }
+}

+ 5 - 0
readme.md

@@ -167,6 +167,8 @@ Ils sont définis dans `src/shared/characters/` : une classe par personnage qui
 | **Captain Prout** | Coup de cape | Jetpack par petites poussées (3 avant de reposer le pied), laisse un nuage toxique (vision floue + toux) | Chaque poussée s'entend |
 | **Le Chat** | Coup de patte qui griffe | Bond griffes en avant : creuse, éjecte qui il percute, puis pousse blocs, objets et joueurs | Se lèche la patte au hasard |
 | **Magicien Raté** | Baguette | Sort aléatoire : terrain en gelée, échange de position, lapin géant, « pfft » | Tirage pondéré : plus il est en tête, plus ses sorts ratent |
+| **Tatie Douille** | Rouleau à pâtisserie | Pose une tourelle sur le gâteau (socle en biscuit) qui tire des dragées sur le joueur le plus proche, jamais sur elle (2 au plus) | Reste plantée le temps de la poser, et la tourelle tombe avec le gâteau dessous |
+| **Léon le Caméléon** | Grande langue : creuse un sillon et ramène ce qu'elle attrape (joueurs, objets, blocs) | Invisible pour les autres pendant 5 s (il se voit en fantôme) | Attaquer ou être touché le révèle, et la cerise qu'il tient reste visible |
  
 API commune : `onAttack()`, `onSpecial()`, `onTick(dt)`, `onHit()`, et les stats (`speed`, `jump`, `mass`, `size`, `cooldowns`).
  
@@ -382,6 +384,9 @@ Chaque étape doit être **jouable** avant de passer à la suivante.
     - **Cartes :** la Pile de Crêpes est refaite (crêpes épaisses, garnitures molles qu'on peut toutes creuser, une seconde petite pile). Des gaufrettes servent de corniches autour du Roulé, et les choux de la Pièce Montée ont le dessus plat.
     - **Captain Prout :** le flou du nuage passe par le filtre onirique. Il ne rétrécit plus l'écran.
   - **Audio :** tous les sons sont synthétisés en WebAudio (`renderer/audio/`), sans fichier. Chaque matériau joue le son nommé dans `materials.json` (`sfx`). Les sons sont spatialisés autour de la caméra et varient à chaque fois. La musique, « Joyeux anniversaire » à la flûte à bec, accélère au fil de la manche et pendant la montée finale.
+  - **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.
   - **É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.
 ---

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

@@ -32,6 +32,12 @@ export type SoundName =
   | 'lick'
   | 'spell'
   | 'pounce'
+  | 'build'
+  | 'turret'
+  | 'turretDown'
+  | 'tongue'
+  | 'vanish'
+  | 'appear'
   | 'crush'
   | 'thud'
   | 'beep'
@@ -308,6 +314,30 @@ const SOUNDS: Record<SoundName, (s: Synth) => void> = {
     o.frequency.linearRampToValueAtTime(420, s.t0 + 0.28);
     s.noise('highpass', 3000, 3000, 0.05, 0.12, 0.2, 1, 0.01);
   },
+  build: (s) => {
+    // Squeezing the piping bag, then a little "ta-da".
+    s.noise('bandpass', 500, 900, 0, 0.3, 0.4, 3, 0.05);
+    [659, 880].forEach((f, i) => s.tone('triangle', f, f, 0.28 + i * 0.08, 0.12, 0.2));
+  },
+  turret: (s) => {
+    s.tone('sine', 900 * s.vary(), 300, 0, 0.08, 0.35);
+    s.noise('highpass', 2500, 1500, 0, 0.04, 0.25);
+  },
+  turretDown: (s) => {
+    for (let i = 0; i < 3; i++) s.noise('bandpass', 1800 * s.vary(0.3), 900, i * 0.05, 0.06, 0.5, 2);
+    s.tone('square', 500, 120, 0, 0.3, 0.1);
+  },
+  tongue: (s) => {
+    // "Shlurp!": out fast, back in.
+    s.tone('triangle', 300, 1100, 0, 0.1, 0.25, 0.01);
+    s.noise('bandpass', 1500, 600, 0.08, 0.15, 0.4, 4, 0.01);
+  },
+  vanish: (s) => {
+    for (let i = 0; i < 5; i++) s.tone('sine', 1800 - i * 260, 1500 - i * 260, i * 0.05, 0.1, 0.15);
+  },
+  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);
+  },
   crush: (s) => {
     s.noise('lowpass', 900, 100, 0, 0.5, 1, 1, 0.005);
     s.tone('sine', 180, 40, 0, 0.45, 0.8);

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

@@ -536,6 +536,76 @@ export const paintMagicien: Painter = (g, r, pose) => {
   stars(g, r, pose);
 };
 
+/** Tatie Douille: a pastry chef in a pink dress and apron, a tall toque, a piping bag or a rolling pin in hand. */
+export const paintTatie: Painter = (g, r, pose) => {
+  const dress = 0xff8fab;
+  const step = pose.moving && pose.grounded ? Math.sin(pose.walk) : 0;
+  foot(g, -0.35 * r - step * 0.16 * r, 0.93 * r, r, 0x8a3a5a, pose.ink);
+  body(g, 0, 0, r, r, dress, pose);
+  // Apron with a pocket.
+  g.roundRect(-0.15 * r, 0.05 * r, 0.95 * r, 0.8 * r, 0.2 * r).fill(0xfffaf0).stroke({ width: pose.ink * 0.6, color: INK });
+  g.roundRect(0.15 * r, 0.42 * r, 0.4 * r, 0.25 * r, 0.06 * r).stroke({ width: pose.ink * 0.4, color: 0xd9547a });
+  // Face, curls and the toque.
+  g.ellipse(0.25 * r, -0.25 * r, 0.6 * r, 0.48 * r).fill(0xffdcc4);
+  for (let i = 0; i < 4; i++) g.circle(-0.55 * r + i * 0.13 * r, -0.45 * r + (i % 2) * 0.08 * r, 0.14 * r).fill(0xc8643c).stroke({ width: pose.ink * 0.4, color: INK });
+  const puff = Math.sin(pose.time * 3) * 0.03 * r;
+  g.rect(-0.45 * r, -0.95 * r, 0.9 * r, 0.32 * r).fill(0xffffff).stroke({ width: pose.ink * 0.8, color: INK });
+  for (const [cx, cy, cr] of [[-0.35, -1.12, 0.3], [0.0, -1.3, 0.36], [0.35, -1.12, 0.3]]) g.circle(cx * r, cy * r + puff, cr * r).fill(0xffffff).stroke({ width: pose.ink * 0.8, color: INK });
+  g.rect(-0.43 * r, -0.97 * r, 0.86 * r, 0.14 * r).fill(0xffffff);
+  eyes(g, pose, [[0.12 * r, -0.3 * r], [0.48 * r, -0.28 * r]], 0.13 * r);
+  blush(g, 0.66 * r, -0.08 * r, 0.11 * r);
+  g.moveTo(0.3 * r, 0.0).quadraticCurveTo(0.42 * r, 0.08 * r, 0.54 * r, 0.0).stroke({ width: pose.ink * 0.6, color: INK, cap: 'round' });
+  if (pose.attack) {
+    // Rolling pin swung forward.
+    g.roundRect(0.55 * r, -0.35 * r, 0.3 * r, 1.0 * r, 0.12 * r).fill(0xe0b07a).stroke({ width: pose.ink * 0.8, color: INK });
+    for (const y of [-0.5, 0.75]) g.roundRect(0.63 * r, y * r, 0.14 * r, 0.16 * r, 0.05 * r).fill(0x9c6b3a).stroke({ width: pose.ink * 0.5, color: INK });
+  } else {
+    // Piping bag, squeezed while she builds.
+    const squeeze = pose.special ? 0.12 * r : 0;
+    g.poly([0.55 * r, -0.05 * r + squeeze, 1.05 * r, 0.25 * r, 0.6 * r, 0.5 * r - squeeze]).fill(0xfff6e4).stroke({ width: pose.ink * 0.8, color: INK, join: 'round' });
+    g.poly([1.02 * r, 0.18 * r, 1.25 * r, 0.3 * r, 1.04 * r, 0.34 * r]).fill(0xc0c8d0).stroke({ width: pose.ink * 0.5, color: INK });
+    if (pose.special) g.circle(1.3 * r, 0.36 * r, 0.12 * r).fill(0xffffff).stroke({ width: pose.ink * 0.5, color: INK });
+  }
+  foot(g, 0.35 * r + step * 0.16 * r, 0.93 * r, r, 0x8a3a5a, pose.ink);
+  stars(g, r, pose);
+};
+
+/** Léon le Caméléon: green with a spiky crest, a curled tail, one big turret eye, and a very long tongue. */
+export const paintLeon: Painter = (g, r, pose) => {
+  const green = 0x6fcf4a;
+  const dark = 0x3f9a2a;
+  const step = pose.moving && pose.grounded ? Math.sin(pose.walk) : 0;
+  // Curled tail.
+  const curl = Math.sin(pose.time * 2) * 0.05 * r;
+  g.moveTo(-0.8 * r, 0.35 * r).quadraticCurveTo(-1.45 * r, 0.3 * r, -1.35 * r, -0.15 * r + curl).stroke({ width: pose.ink * 3, color: INK, cap: 'round' });
+  g.moveTo(-0.8 * r, 0.35 * r).quadraticCurveTo(-1.45 * r, 0.3 * r, -1.35 * r, -0.15 * r + curl).stroke({ width: pose.ink * 1.8, color: green, cap: 'round' });
+  g.circle(-1.17 * r, -0.12 * r + curl, 0.16 * r).fill(green).stroke({ width: pose.ink * 0.7, color: INK });
+  foot(g, -0.35 * r - step * 0.2 * r, 0.93 * r, r, dark, pose.ink);
+  // Crest.
+  const crest: number[] = [];
+  for (let i = 0; i <= 6; i++) crest.push((-0.7 + i * 0.22) * r, (i % 2 ? -1.12 : -0.82) * r + (i === 0 || i === 6 ? 0.1 * r : 0));
+  g.poly([...crest, 0.62 * r, -0.6 * r, -0.7 * r, -0.6 * r]).fill(0xffd23f).stroke({ width: pose.ink * 0.7, color: INK, join: 'round' });
+  body(g, 0, 0, r, r, green, pose);
+  // Skin spots and a pale belly.
+  for (const [sx, sy, sr] of [[-0.45, -0.3, 0.12], [-0.6, 0.2, 0.09], [-0.2, 0.5, 0.1], [-0.3, -0.62, 0.07]]) g.circle(sx * r, sy * r, sr * r).fill({ color: dark, alpha: 0.55 });
+  g.ellipse(0.25 * r, 0.42 * r, 0.5 * r, 0.32 * r).fill(0xd8f5b0);
+  // One big turret eye in a cone of skin.
+  g.circle(0.38 * r, -0.28 * r, 0.36 * r).fill(green).stroke({ width: pose.ink * 0.8, color: INK });
+  eyes(g, pose, [[0.42 * r, -0.28 * r]], 0.2 * r);
+  // Wide grin, and the tongue when he attacks.
+  g.moveTo(0.35 * r, 0.12 * r).quadraticCurveTo(0.7 * r, 0.22 * r, 0.95 * r, 0.05 * r).stroke({ width: pose.ink * 0.7, color: INK, cap: 'round' });
+  if (pose.attack) {
+    const len = 6.5 * r;
+    const tx = 0.9 * r + pose.lookX * len;
+    const ty = 0.1 * r + pose.lookY * len;
+    g.moveTo(0.9 * r, 0.1 * r).lineTo(tx, ty).stroke({ width: pose.ink * 2.6, color: INK, cap: 'round' });
+    g.moveTo(0.9 * r, 0.1 * r).lineTo(tx, ty).stroke({ width: pose.ink * 1.5, color: 0xff6f91, cap: 'round' });
+    g.circle(tx, ty, 0.28 * r).fill(0xff6f91).stroke({ width: pose.ink * 0.7, color: INK });
+  }
+  foot(g, 0.35 * r + step * 0.2 * r, 0.93 * r, r, dark, pose.ink);
+  stars(g, r, pose);
+};
+
 export const PAINTERS: Record<string, Painter> = {
   castor: paintCastor,
   mamie: paintMamie,
@@ -545,5 +615,7 @@ export const PAINTERS: Record<string, Painter> = {
   prout: paintProut,
   chat: paintChat,
   magicien: paintMagicien,
+  tatie: paintTatie,
+  leon: paintLeon,
   generic: paintGeneric,
 };

+ 5 - 0
src/renderer/config.ts

@@ -52,6 +52,8 @@ export const PLAYER_SPLAT_COLOR = 0xff6f91;
 export const CUSTARD_COLOR = 0xf7d774;
 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;
 export const WHISK_SHAKE = 10;
 export const FREEZE_FRAME_MS = 50;
 export const PARTICLES_PER_100_PX = 6;
@@ -64,6 +66,9 @@ 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;
+/** 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;
 /** Hue shifts (degrees) of the 2nd, 3rd... player on the same character. */
 export const VARIANT_HUES = [150, 240, 60, 300, 100, 200, 330];
 /** Resolution of the hue filter on those players (high enough to stay sharp under the dream filter). */

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

@@ -3,7 +3,17 @@ import { Anim } from '../../shared/characters';
 import type { PlayerInfo, PlayerState } from '../../shared/protocol';
 import { PAINTERS, paintGeneric, type Painter, type Pose } from '../characters/painters';
 import { paintItem } from './RoundViews';
-import { OUTLINE_COLOR, OUTLINE_WIDTH, PLAYER_COLORS, RUN_LEAN, SQUASH_RECOVERY, VARIANT_FILTER_RESOLUTION, VARIANT_HUES } from '../config';
+import {
+  INVISIBLE_FADE,
+  INVISIBLE_SELF_ALPHA,
+  OUTLINE_COLOR,
+  OUTLINE_WIDTH,
+  PLAYER_COLORS,
+  RUN_LEAN,
+  SQUASH_RECOVERY,
+  VARIANT_FILTER_RESOLUTION,
+  VARIANT_HUES,
+} from '../config';
 
 /** Max size the fat ratio is normalized against (castor maxSize is in its JSON; this only drives the cheeks). */
 const FAT_RANGE = 26;
@@ -57,12 +67,16 @@ export class PlayerView {
     this.shadow.clear();
   }
 
-  update(state: PlayerState, aimX: number, aimY: number, dt: number): PlayerEvents {
+  /** `local`: this is the player at this keyboard (who still sees themself when invisible). */
+  update(state: PlayerState, aimX: number, aimY: number, dt: number, local = false): PlayerEvents {
     this.time += dt;
     const size = state.size;
     this.container.position.set(state.x, state.y);
     // In the custard, waiting for the spoon.
     this.container.visible = (state.anim & Anim.Dead) === 0;
+    // Invisible (Léon): gone for the others, a ghost for himself; fades in and out.
+    const alpha = state.anim & Anim.Invisible ? (local ? INVISIBLE_SELF_ALPHA : 0) : 1;
+    this.container.alpha += (alpha - this.container.alpha) * Math.min(1, INVISIBLE_FADE * dt);
     this.held.clear();
     if (state.item) {
       paintItem(this.held, state.item, false, this.time);

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

@@ -1,6 +1,6 @@
 import { Container, Graphics } from 'pixi.js';
-import type { CherryState, EntityKind, ItemState } from '../../shared/protocol';
-import { CUSTARD_COLOR, CUSTARD_DARK, OUTLINE_COLOR, OUTLINE_WIDTH } from '../config';
+import type { CherryState, EntityKind, ItemState, TurretState } from '../../shared/protocol';
+import { CUSTARD_COLOR, CUSTARD_DARK, OUTLINE_COLOR, OUTLINE_WIDTH, PLAYER_COLORS } from '../config';
 
 const INK = OUTLINE_COLOR;
 const W = OUTLINE_WIDTH;
@@ -61,7 +61,11 @@ export class CherryView {
 
 /** Draws one item in local coordinates (origin at its center). */
 export function paintItem(g: Graphics, kind: EntityKind, lit: boolean, time: number): void {
-  if (kind === 'firecracker') {
+  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 });
+  } else 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 });
@@ -178,6 +182,35 @@ export class CloudsView {
   }
 }
 
+/** Tatie's turrets: a choux head with a piping nozzle aimed at its target, and its owner's pennant. */
+export class TurretsView {
+  readonly graphics = new Graphics();
+  private time = 0;
+
+  update(turrets: TurretState[], dt: number): void {
+    this.time += dt;
+    const g = this.graphics;
+    g.clear();
+    for (const t of turrets) {
+      const c = Math.cos(t.angle);
+      const s = Math.sin(t.angle);
+      const px = -s;
+      const py = c;
+      // Nozzle: a silver cone along the aim.
+      g.poly([t.x + px * 5, t.y + py * 5, t.x + c * 22 + px * 2.5, t.y + s * 22 + py * 2.5, t.x + c * 22 - px * 2.5, t.y + s * 22 - py * 2.5, t.x - px * 5, t.y - py * 5])
+        .fill(0xc0c8d0)
+        .stroke({ width: 2, color: INK, join: 'round' });
+      // Choux head with a cream swirl, and the owner's little flag.
+      g.circle(t.x, t.y, 10).fill(0xe0a050).stroke({ width: 2.5, color: INK });
+      g.ellipse(t.x - 3, t.y - 4, 4, 2).fill({ color: 0xffffff, alpha: 0.45 });
+      g.circle(t.x, t.y - 8, 4.5).fill(0xffffff).stroke({ width: 1.5, color: INK });
+      const wave = Math.sin(this.time * 6 + t.id) * 2;
+      g.moveTo(t.x - 6, t.y - 9).lineTo(t.x - 6, t.y - 26).stroke({ width: 1.5, color: INK });
+      g.poly([t.x - 6, t.y - 26, t.x + 6, t.y - 22 + wave, t.x - 6, t.y - 18]).fill(PLAYER_COLORS[(t.owner - 1) % PLAYER_COLORS.length]).stroke({ width: 1.2, color: INK });
+    }
+  }
+}
+
 export class SpoonsView {
   readonly container = new Container();
   private spoons: Spoon[] = [];

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

@@ -22,6 +22,8 @@ export const fr = {
     pompier: 'Q/D : courir · Espace/Z : sauter · Clic gauche : jet d’eau · Clic droit : détremper · F : lancer la bougie · K : abandonner',
     prout: 'Q/D : courir · Espace/Z : sauter · Clic gauche : coup de cape · Clic droit : propulsion · F : lancer la bougie · K : abandonner',
     chat: 'Q/D : courir · Espace/Z : sauter · Clic gauche : coup de patte · Clic droit : bondir griffes en avant · F : lancer la bougie · K : abandonner',
+    tatie: 'Q/D : courir · Espace/Z : sauter · Clic gauche : rouleau à pâtisserie · Clic droit : poser une tourelle · F : lancer la bougie · K : abandonner',
+    leon: 'Q/D : courir · Espace/Z : sauter · Clic gauche : langue · Clic droit : devenir invisible · F : lancer la bougie · K : abandonner',
     magicien: 'Q/D : courir · Espace/Z : sauter · Clic gauche : baguette · Clic droit : lancer un sort · F : lancer la bougie · K : abandonner',
   } as Record<string, string>,
   fps: (n: number) => `${n} FPS`,
@@ -102,6 +104,10 @@ export const fr = {
     lick: ['SLURP…', 'LAP LAP…', 'RONRON…'],
     pounce: ['MRAOU !', 'FFFT !', 'GRIFFE !'],
     backfire: ['AÏE !', 'OUILLE !', 'OUPS !'],
+    build: ['ET HOP !', 'TADAM !', 'VOILÀ !'],
+    turretDown: ['CRAC !', 'PATATRAS !'],
+    pop: ['POC !', 'TOC !', 'PAF !'],
+    vanish: ['POUF !', 'OÙ ÇA ?', 'COUCOU…'],
   },
   spells: {
     jelly: 'Gélifico !',
@@ -188,6 +194,20 @@ export const fr = {
       special: 'Sort au hasard : gelée, échange, lapin géant ou « pfft »',
       flaw: 'Plus il mène, plus ses sorts ratent',
     },
+    tatie: {
+      name: 'Tatie Douille',
+      desc: 'Pâtissière : elle garnit le gâteau de tourelles.',
+      attack: 'Coup de rouleau à pâtisserie',
+      special: 'Pose une tourelle en biscuit qui tire des dragées sur les autres (2 au plus)',
+      flaw: 'Reste plantée le temps de la poser, et la tourelle tombe avec le gâteau dessous',
+    },
+    leon: {
+      name: 'Léon le Caméléon',
+      desc: 'Personne ne le voit venir.',
+      attack: 'Grande langue : creuse un sillon et ramène ce qu’elle attrape',
+      special: 'Devient invisible pour les autres pendant 5 s',
+      flaw: 'Attaquer ou être touché le fait réapparaître, et la cerise flotte au-dessus de lui',
+    },
     random: {
       name: 'Au hasard',
       desc: 'Un gourmand tiré au sort, différent à chaque manche.',

+ 34 - 2
src/renderer/main.ts

@@ -30,12 +30,13 @@ import {
   SHAKE_DECAY,
   SHAKE_MAX,
   SHAKE_PER_PIXEL,
+  SMALL_BLAST_R,
   WHISK_SHAKE,
 } from './config';
 import { GameAudio, type SoundName } from './audio/Audio';
 import { ChunkView } from './entities/ChunkView';
 import { PlayerView } from './entities/PlayerView';
-import { CherryView, CloudsView, CustardView, ItemsView, SpoonsView } from './entities/RoundViews';
+import { CherryView, CloudsView, CustardView, ItemsView, SpoonsView, TurretsView } from './entities/RoundViews';
 import { makeDreamFilter, setDreamPoison, SugarDust } from './fx/Dream';
 import { Particles } from './fx/Particles';
 import { Popups } from './fx/Popups';
@@ -171,11 +172,13 @@ async function start(): Promise<void> {
   const custardView = new CustardView();
   const spoonsView = new SpoonsView();
   const cloudsView = new CloudsView();
+  const turretsView = new TurretsView();
   // 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(
     dust.back,
     chunksLayer,
+    turretsView.graphics,
     itemsView.container,
     playersLayer,
     cherryView.graphics,
@@ -356,6 +359,26 @@ async function start(): Promise<void> {
       case 'pounce':
         popups.spawn(ev.x, ev.y - size - 16, fr.fxWords.pounce, 30);
         break;
+      case 'build':
+        particles.puff(ev.x, ev.y + size, 10, 0xfff6e4);
+        popups.spawn(ev.x, ev.y - size - 16, fr.fxWords.build, 28);
+        break;
+      case 'turret':
+        // ev is the turret's head here, not the builder.
+        particles.puff(ev.x, ev.y, 3, 0xffffff);
+        break;
+      case 'turretDown':
+        particles.burst(ev.x, ev.y, 14, 0xe0a050, 320);
+        popups.spawn(ev.x, ev.y - 20, fr.fxWords.turretDown, 26);
+        break;
+      case 'tongue':
+        break;
+      case 'vanish':
+      case 'appear':
+        // Sparkles everyone sees: a hint of where he went (or came back).
+        particles.burst(ev.x, ev.y, 12, 0x9be15d, 220);
+        if (ev.cue === 'vanish') popups.spawn(ev.x, ev.y - size - 16, fr.fxWords.vanish, 26);
+        break;
       case 'backfire':
         popups.spawn(ev.x, ev.y - size - 20, fr.fxWords.backfire, 34);
         break;
@@ -395,6 +418,14 @@ async function start(): Promise<void> {
         spoonsView.spawn(ev.x, ev.y);
         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);
+          shake = Math.min(SHAKE_MAX, shake + HIT_SHAKE / 3);
+          break;
+        }
         audio.play('explosion', ev.x, ev.y);
         particles.burst(ev.x, ev.y, 40, 0xffb02e, 650);
         particles.burst(ev.x, ev.y, 20, 0xff6f91, 400);
@@ -608,6 +639,7 @@ async function start(): Promise<void> {
     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);
+    turretsView.update(sample.round?.turrets ?? [], dt);
     hud.update(round, rounds, sample.round, states, infos, localId);
     const [aimX, aimY] = screenToWorld(input.mouseX, input.mouseY);
     for (const [id, state] of states) {
@@ -619,7 +651,7 @@ async function start(): Promise<void> {
         views.set(id, view);
         playersLayer.addChild(view.container);
       }
-      const ev = view.update(state, id === localId ? aimX : state.x + state.facing * 100, id === localId ? aimY : state.y, dt);
+      const ev = view.update(state, id === localId ? aimX : state.x + state.facing * 100, id === localId ? aimY : state.y, dt, id === localId);
       if (ev.landed && ev.landingSpeed > LANDING_DUST_MIN_VY) {
         particles.puff(state.x, state.y + state.size, 8);
         audio.play('land', state.x, state.y, Math.min(1, ev.landingSpeed / 1200));

+ 148 - 4
src/server/game.ts

@@ -7,6 +7,7 @@ import {
   type CharacterContext,
   type CharacterStats,
   type ProjectileSpec,
+  type TurretSpec,
 } from '../shared/characters';
 import {
   AIR_CONTROL,
@@ -82,7 +83,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 } from '../shared/protocol';
+import type { ChunkInfo, ChunkState, EntityKind, FxEvent, ItemKind, ItemState, PlayerInfo, PlayerState, RoundState, 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';
@@ -186,6 +187,32 @@ const YARN_PLAYER_MARGIN = 3;
 /** Physics collision category of players (projectiles can opt out of hitting them). */
 const CATEGORY_PLAYER = 0x0002;
 
+/** A turret built by Tatie Douille (see TurretSpec). */
+interface Turret {
+  id: number;
+  owner: number;
+  spec: TurretSpec;
+  /** Head position (px). */
+  x: number;
+  y: number;
+  /** A pixel inside the base: when it is no longer solid, the turret has lost its footing. */
+  baseX: number;
+  baseY: number;
+  angle: number;
+  age: number;
+  reload: number;
+}
+
+/** A turret's base is laid in this tick's ops: its footing is only checked after this delay (s). */
+const TURRET_SETTLE_S = 0.1;
+/** A turret with nothing to shoot at looks again this often (s). */
+const TURRET_SCAN_S = 0.2;
+/** Line of sight: sample spacing (px), and the part next to the head that is not checked (px). */
+const TURRET_SIGHT_STEP = 4;
+const TURRET_SIGHT_SKIP = 14;
+/** Dragées leave from the tip of the nozzle, clear of the turret's own base (px). */
+const TURRET_MUZZLE = 26;
+
 interface Cherry {
   body: Body;
   holder: number;
@@ -264,6 +291,8 @@ export class Game {
   /** Side wind this tick (px/s²). */
   private wind = 0;
   private clouds: { x: number; y: number; r: number; life: number; maxLife: number; owner: number }[] = [];
+  private turrets: Turret[] = [];
+  private nextTurretId = 1;
   /** Recently soaked areas, which sag for a while (Bébé Pompier). */
   private soakZones: { x: number; y: number; r: number; time: number; next: number }[] = [];
   /** Seconds before the next count of the cake left. */
@@ -387,6 +416,7 @@ export class Game {
     if (this.cherry?.holder === id) this.dropCherry(p);
     this.world.destroyBody(p.body);
     this.players.delete(id);
+    this.turrets = this.turrets.filter((t) => t.owner !== id);
   }
 
   /** Moves a player (debug tools and tests). */
@@ -465,6 +495,7 @@ export class Game {
     this.updateCherry();
     this.updateItems();
     this.updateClouds();
+    this.updateTurrets();
     this.updateSoakZones();
     this.updateChunks(result);
     this.syncTerrain(result);
@@ -522,6 +553,7 @@ export class Game {
       finalRush: this.rules !== null && this.timeLeft <= FINAL_RUSH_S,
       wind: this.wind,
       clouds: this.clouds.map((c) => [c.x, c.y, c.r, c.life / c.maxLife]),
+      turrets: this.turrets.map((t): TurretState => ({ id: t.id, x: t.x, y: t.y, angle: t.angle, owner: t.owner })),
       cherry,
     };
   }
@@ -997,6 +1029,7 @@ export class Game {
       shove: (x, y, r, vx, vy, fraction) => this.shove(p, x, y, r, vx, vy, fraction),
       drench: (x, y, r) => this.drench(p, x, y, r),
       cloud: (x, y, r, life) => this.clouds.push({ x, y, r, life, maxLife: life, owner: p.info.id }),
+      buildTurret: (x, y, spec) => this.buildTurret(p, x, y, spec),
       swapWithRandomPlayer: () => this.swapWithRandom(p),
       cue: (cue) => {
         const at = p.body.getPosition();
@@ -1005,6 +1038,117 @@ export class Game {
     };
   }
 
+  /**
+   * Builds a turret on the ground near (x, y): straight down from there, or up to the surface
+   * when (x, y) is inside the cake. Its base is a disc of terrain half sunk in the ground.
+   */
+  private buildTurret(by: ServerPlayer, x: number, y: number, spec: TurretSpec): boolean {
+    const gx = Math.round(x);
+    let gy = Math.round(y);
+    const solid = (py: number): boolean => this.terrain.isSolid(gx, py);
+    if (solid(gy)) {
+      // Inside the cake: up to its surface.
+      let up = 0;
+      while (up < spec.groundReach && solid(gy - 1)) {
+        gy--;
+        up++;
+      }
+      if (solid(gy - 1)) return false;
+    } else {
+      let down = 0;
+      while (down < spec.groundReach && !solid(gy)) {
+        gy++;
+        down++;
+      }
+      if (!solid(gy)) return false;
+    }
+    const headY = gy - spec.height;
+    // No turret inside a player.
+    for (const p of this.alivePlayers()) {
+      const pos = p.body.getPosition();
+      if (Math.hypot(pos.x * PPM - gx, pos.y * PPM - (gy + headY) / 2) < p.bodyRadius + spec.baseRadius) return false;
+    }
+    const mine = this.turrets.filter((t) => t.owner === by.info.id);
+    if (mine.length >= spec.max) this.turrets = this.turrets.filter((t) => t !== mine[0]);
+    this.queueOp({ kind: 'fill', x: gx, y: gy, r: Math.round(spec.baseRadius), material: Math.round(spec.material) }, undefined, by.info.id);
+    this.turrets.push({
+      id: this.nextTurretId++,
+      owner: by.info.id,
+      spec,
+      x: gx,
+      y: headY,
+      baseX: gx,
+      baseY: gy - Math.round(spec.baseRadius / 2),
+      angle: -Math.PI / 2,
+      age: 0,
+      reload: spec.interval / 2,
+    });
+    return true;
+  }
+
+  /**
+   * Turrets fall with the cake they stand on, wear out, and shoot sugared almonds at the
+   * nearest player they can see (never their builder).
+   */
+  private updateTurrets(): void {
+    if (this.turrets.length === 0) return;
+    const gone: Turret[] = [];
+    for (const t of this.turrets) {
+      t.age += TICK_DT;
+      if (t.age > t.spec.life || (t.age > TURRET_SETTLE_S && !this.terrain.isSolid(t.baseX, t.baseY))) {
+        gone.push(t);
+        continue;
+      }
+      t.reload -= TICK_DT;
+      if (t.reload > 0) continue;
+      const target = this.turretTarget(t);
+      if (!target) {
+        t.reload = TURRET_SCAN_S;
+        continue;
+      }
+      t.reload = t.spec.interval;
+      const pos = target.body.getPosition();
+      const dx = pos.x * PPM - t.x;
+      const dy = pos.y * PPM - t.y;
+      const dist = Math.hypot(dx, dy);
+      const speed = t.spec.speed;
+      // Aim a little high: the dragée drops by g·t²/2 on its way (t ≈ dist / speed).
+      const vx = (dx / dist) * speed;
+      const vy = (dy / dist) * speed - (GRAVITY_PX * dist) / speed / 2;
+      t.angle = Math.atan2(vy, vx);
+      const start = TURRET_MUZZLE;
+      this.throwEntity(t.spec.projectile.kind, t.spec.projectile, t.owner, t.x + Math.cos(t.angle) * start, t.y + Math.sin(t.angle) * start, vx, vy);
+      this.fx.push({ kind: 'cue', id: t.owner, cue: 'turret', x: t.x, y: t.y });
+    }
+    for (const t of gone) this.fx.push({ kind: 'cue', id: t.owner, cue: 'turretDown', x: t.x, y: t.y });
+    if (gone.length > 0) this.turrets = this.turrets.filter((t) => !gone.includes(t));
+  }
+
+  /** The nearest living player in range, other than its builder, in plain sight of the turret's head. */
+  private turretTarget(t: Turret): ServerPlayer | null {
+    let best: ServerPlayer | null = null;
+    let bestDist = Infinity;
+    for (const p of this.alivePlayers()) {
+      if (p.info.id === t.owner) continue;
+      const pos = p.body.getPosition();
+      const dx = pos.x * PPM - t.x;
+      const dy = pos.y * PPM - t.y;
+      const dist = Math.hypot(dx, dy);
+      if (dist > t.spec.range || dist >= bestDist) continue;
+      let clear = true;
+      for (let d = TURRET_SIGHT_SKIP; d < dist - p.bodyRadius; d += TURRET_SIGHT_STEP) {
+        if (this.terrain.isSolid(Math.round(t.x + (dx / dist) * d), Math.round(t.y + (dy / dist) * d))) {
+          clear = false;
+          break;
+        }
+      }
+      if (!clear) continue;
+      best = p;
+      bestDist = dist;
+    }
+    return best;
+  }
+
   /** Rank by this round's score: 0 = last, 1 = leader, 0.5 when alone or everyone is tied. */
   private standing(p: ServerPlayer): number {
     const others = [...this.players.values()].filter((o) => o !== p);
@@ -1256,7 +1400,7 @@ 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
+    if (item.kind === 'firecracker' || item.kind === 'yarn' || item.kind === 'dragee') 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';
@@ -1294,7 +1438,7 @@ export class Game {
   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;
+    const fuse = spec?.kind === 'yarn' ? Infinity : spec ? spec.fuse : CANDLE_FUSE_S;
     const [anchorX, anchorY] = spec?.kind === 'yarn' ? this.yarnAnchor(owner, x, y) : [x, y];
     item.thrown = { owner, age: 0, fuse, arm: spec ? PROJECTILE_ARM_S : CANDLE_ARM_S, spec, anchorX, anchorY };
     item.body.setLinearVelocity({ x: vx / PPM, y: vy / PPM });
@@ -1318,7 +1462,7 @@ export class Game {
     }
     if (t.fuse <= 0 || hit) {
       this.removeItem(item);
-      this.explodeAt(x, y, spec?.kind === 'firecracker' ? spec.blast : CANDLE_BLAST, t.owner, null);
+      this.explodeAt(x, y, spec ? spec.blast : CANDLE_BLAST, t.owner, null);
     }
   }
 

+ 43 - 1
src/shared/characters/Character.ts

@@ -37,6 +37,8 @@ export const Anim = {
   Underground: 1 << 7,
   /** Drenched by Bébé Pompier's spray: slower run and jump. */
   Soaked: 1 << 8,
+  /** Léon le Caméléon blending in: hidden from the other players. */
+  Invisible: 1 << 9,
 } as const;
 
 /** A random spell of the Magicien Raté. */
@@ -54,9 +56,34 @@ export interface Blast {
 export type ProjectileSpec =
   /** Explodes on contact (after arming) or when the fuse runs out. */
   | { 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 }
   /** 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 };
 
+/**
+ * A turret built by Tatie Douille. Its base is terrain (a disc of `material` laid on the ground):
+ * when the cake under the base is gone, the turret goes with it.
+ */
+export interface TurretSpec {
+  /** Disc of terrain laid as the base (px), and its material id. */
+  baseRadius: number;
+  material: number;
+  /** The head sits this far above the ground (px). */
+  height: number;
+  /** How far it looks for a target (px), how often it shoots (s), how long it lasts (s). */
+  range: number;
+  interval: number;
+  life: number;
+  /** Turrets per player: building one more removes the oldest. */
+  max: number;
+  /** How far below (or above, if inside the cake) the aim point it looks for the ground (px). */
+  groundReach: number;
+  /** Speed of what it shoots (px/s). */
+  speed: number;
+  projectile: ProjectileSpec;
+}
+
 /** What a character may read and do. Implemented by the server simulation. */
 export interface CharacterContext {
   readonly x: number;
@@ -93,13 +120,28 @@ export interface CharacterContext {
   drench(x: number, y: number, r: number): number;
   /** Leaves a toxic cloud: other players inside are poisoned (blurred vision, coughing). */
   cloud(x: number, y: number, r: number, life: number): void;
+  /** Builds a turret on the ground near (x, y). Returns false when there is no room or no ground. */
+  buildTurret(x: number, y: number, spec: TurretSpec): boolean;
   /** Swaps places with another player at random. Returns false if alone. */
   swapWithRandomPlayer(): boolean;
   /** A visual/sound cue everyone notices (e.g. "PROUT !", a spell). */
   cue(kind: CharacterCue): void;
 }
 
-export type CharacterCue = 'prout' | 'water' | `spell-${Spell}` | 'emerge' | 'lick' | 'pounce' | 'backfire';
+export type CharacterCue =
+  | 'prout'
+  | 'water'
+  | `spell-${Spell}`
+  | 'emerge'
+  | 'lick'
+  | 'pounce'
+  | 'backfire'
+  | 'build'
+  | 'turret'
+  | 'turretDown'
+  | 'tongue'
+  | 'vanish'
+  | 'appear';
 
 /** Base class for all characters. Pure logic: no physics engine, no rendering. */
 export abstract class Character {

+ 63 - 0
src/shared/characters/Leon.ts

@@ -0,0 +1,63 @@
+import { Anim, Character, type CharacterContext } from './Character';
+import { aimDir } from './Mamie';
+
+/**
+ * Léon le Caméléon.
+ * - Attack: shoots his long sticky tongue toward the aim. It licks a groove through the cake,
+ *   and whatever it catches at the tip is pulled back to him: players (who drop the cherry),
+ *   items, falling chunks.
+ * - Special: blends in and turns invisible for the other players for a while (press again to
+ *   show up early).
+ * - Flaw: attacking or being hit shows him at once, and a cherry he holds floats in plain sight.
+ */
+export class Leon extends Character {
+  private tongueTimer = 0;
+  private hidden = 0;
+
+  onAttack(ctx: CharacterContext): void {
+    const p = this.stats.params;
+    this.reveal(ctx);
+    const [dx, dy] = aimDir(ctx);
+    // The groove licked through the cake on the way.
+    for (let d = this.size; d <= this.size + p.tongueReach; d += p.tongueStep) {
+      ctx.emitOp({ kind: 'carve', x: Math.round(ctx.x + dx * d), y: Math.round(ctx.y + dy * d), r: Math.round(p.tongueCarve), power: Math.round(p.tonguePower) });
+    }
+    // Caught at the tip: stuck, then pulled back to him.
+    const tx = ctx.x + dx * (this.size + p.tongueReach);
+    const ty = ctx.y + dy * (this.size + p.tongueReach);
+    ctx.hit(tx, ty, p.tongueRadius, 0, p.tongueStun);
+    ctx.shove(tx, ty, p.tongueRadius, -dx * p.tonguePull, -dy * p.tonguePull - p.tongueLift, 1);
+    ctx.cue('tongue');
+    this.tongueTimer = p.tongueAnim;
+  }
+
+  onSpecial(ctx: CharacterContext): void {
+    if (this.hidden > 0) {
+      this.reveal(ctx);
+      return;
+    }
+    this.hidden = this.stats.params.invisibleTime;
+    ctx.cue('vanish');
+  }
+
+  override onHit(ctx: CharacterContext): void {
+    this.reveal(ctx);
+  }
+
+  override onTick(ctx: CharacterContext, dt: number): void {
+    this.tongueTimer = Math.max(0, this.tongueTimer - dt);
+    if (this.hidden <= 0) return;
+    this.hidden -= dt;
+    if (this.hidden <= 0) this.reveal(ctx);
+  }
+
+  override anim(): number {
+    return (this.tongueTimer > 0 ? Anim.Attack : 0) | (this.hidden > 0 ? Anim.Invisible : 0);
+  }
+
+  private reveal(ctx: CharacterContext): void {
+    if (this.hidden === 0) return;
+    this.hidden = 0;
+    ctx.cue('appear');
+  }
+}

+ 75 - 0
src/shared/characters/Tatie.ts

@@ -0,0 +1,75 @@
+import { Anim, Character, type CharacterContext, type TurretSpec } from './Character';
+import { aimDir } from './Mamie';
+
+/**
+ * Tatie Douille, the pastry chef.
+ * - Attack: rolling pin swipe (it knocks a bit of cake off too).
+ * - Special: pipes a turret on the ground toward the aim. Its base is biscuit laid on the cake;
+ *   the head shoots sugared almonds (dragées) at the nearest player it can see. When the cake
+ *   under it is gone, the turret is gone. Two at most: a third replaces the oldest.
+ * - Flaw: she stands still while piping a turret, and a turret falls with the cake under it.
+ */
+export class Tatie extends Character {
+  private pinTimer = 0;
+  private buildTimer = 0;
+
+  onAttack(ctx: CharacterContext): void {
+    const p = this.stats.params;
+    const [dx, dy] = aimDir(ctx);
+    const reach = this.size + p.pinReach;
+    const x = ctx.x + dx * reach;
+    const y = ctx.y + dy * reach;
+    ctx.hit(x, y, p.pinRadius, p.pinKnockback, p.pinStun);
+    ctx.emitOp({ kind: 'carve', x: Math.round(x), y: Math.round(y), r: Math.round(p.pinCarve), power: Math.round(p.pinPower) });
+    this.pinTimer = p.pinAnim;
+  }
+
+  onSpecial(ctx: CharacterContext): void {
+    const p = this.stats.params;
+    let dx = ctx.input.aimX - ctx.x;
+    let dy = ctx.input.aimY - ctx.y;
+    const dist = Math.hypot(dx, dy);
+    if (dist > p.buildReach) {
+      dx = (dx / dist) * p.buildReach;
+      dy = (dy / dist) * p.buildReach;
+    }
+    if (ctx.buildTurret(ctx.x + dx, ctx.y + dy, this.turret())) {
+      ctx.cue('build');
+      this.buildTimer = p.buildAnim;
+    }
+  }
+
+  override onTick(_ctx: CharacterContext, dt: number): void {
+    this.pinTimer = Math.max(0, this.pinTimer - dt);
+    this.buildTimer = Math.max(0, this.buildTimer - dt);
+  }
+
+  /** Piping a turret takes her whole attention. */
+  override busy(): boolean {
+    return this.buildTimer > 0;
+  }
+
+  override anim(): number {
+    return (this.pinTimer > 0 ? Anim.Attack : 0) | (this.buildTimer > 0 ? Anim.Special : 0);
+  }
+
+  private turret(): TurretSpec {
+    const p = this.stats.params;
+    return {
+      baseRadius: p.turretBaseRadius,
+      material: p.turretMaterial,
+      height: p.turretHeight,
+      range: p.turretRange,
+      interval: p.turretInterval,
+      life: p.turretLife,
+      max: p.turretMax,
+      groundReach: p.turretGroundReach,
+      speed: p.drageeSpeed,
+      projectile: {
+        kind: 'dragee',
+        fuse: p.drageeFuse,
+        blast: { r: p.drageeRadius, power: p.drageePower, knockback: p.drageeKnockback, stun: p.drageeStun },
+      },
+    };
+  }
+}

+ 5 - 1
src/shared/characters/index.ts

@@ -2,12 +2,14 @@ import { Castor } from './Castor';
 import type { Character, CharacterStats } from './Character';
 import { Chat } from './Chat';
 import { Gege } from './Gege';
+import { Leon } from './Leon';
 import { Generic } from './Generic';
 import { Magicien } from './Magicien';
 import { Mamie } from './Mamie';
 import { Pompier } from './Pompier';
 import { Prout } from './Prout';
 import { Pyromanchot } from './Pyromanchot';
+import { Tatie } from './Tatie';
 
 // Menu order (readme §4 table), then the test character.
 const registry: Record<string, new (stats: CharacterStats) => Character> = {
@@ -19,6 +21,8 @@ const registry: Record<string, new (stats: CharacterStats) => Character> = {
   prout: Prout,
   chat: Chat,
   magicien: Magicien,
+  tatie: Tatie,
+  leon: Leon,
   generic: Generic,
 };
 
@@ -39,4 +43,4 @@ export function createCharacter(stats: CharacterStats): Character {
 }
 
 export { Anim, Character, parseCharacterStats } from './Character';
-export type { Blast, CharacterContext, CharacterCue, CharacterStats, ProjectileSpec, Spell } from './Character';
+export type { Blast, CharacterContext, CharacterCue, CharacterStats, ProjectileSpec, Spell, TurretSpec } from './Character';

+ 1 - 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 = 10;
+export const PROTOCOL_VERSION = 11;
 
 export const NET_PORT = 47100;
 export const DISCOVERY_PORT = 47101;

+ 11 - 1
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';
+export type EntityKind = ItemKind | 'firecracker' | 'yarn' | 'dragee';
 
 /** An item lying around, falling, or thrown (lit candle, firecracker, yarn ball). */
 export interface ItemState {
@@ -53,6 +53,15 @@ export interface ItemState {
   thread?: [number, number];
 }
 
+/** A turret (Tatie Douille): its head, where it aims (radians), and whose it is. */
+export interface TurretState {
+  id: number;
+  x: number;
+  y: number;
+  angle: number;
+  owner: number;
+}
+
 export interface CherryState {
   x: number;
   y: number;
@@ -71,6 +80,7 @@ export interface RoundState {
   wind: number;
   /** Toxic clouds: [x, y, radius, life left 0..1]. */
   clouds: [number, number, number, number][];
+  turrets: TurretState[];
   cherry: CherryState | null;
 }
 

+ 123 - 1
tests/characters.test.ts

@@ -38,7 +38,7 @@ function press(game: Game, id: number, buttons: number, x: number, y: number): F
 
 describe('roster', () => {
   it('every character has data that loads', () => {
-    expect(CHARACTER_IDS).toEqual(['castor', 'mamie', 'pyromanchot', 'gege', 'pompier', 'prout', 'chat', 'magicien', 'generic']);
+    expect(CHARACTER_IDS).toEqual(['castor', 'mamie', 'pyromanchot', 'gege', 'pompier', 'prout', 'chat', 'magicien', 'tatie', 'leon', 'generic']);
     for (const id of CHARACTER_IDS) expect(loadCharacterStats(ASSETS, id).id).toBe(id);
   });
 });
@@ -416,3 +416,125 @@ describe('Magicien Raté', () => {
     throw new Error('no rabbit in 60 casts');
   });
 });
+
+describe('Tatie Douille', () => {
+  it('builds a turret on the ground, which shoots the others but never her', () => {
+    const game = newGame();
+    const tatie = game.addPlayer('Tatie', 'tatie');
+    const victim = game.addPlayer('Victime', 'generic');
+    game.teleport(tatie.id, 600, 420); // middle tier top
+    game.teleport(victim.id, 770, 440); // further along the middle tier, in sight
+    run(game, 60);
+    const t = me(game, tatie.id);
+    const fx = press(game, tatie.id, Button.Special, t.x + 60, t.y);
+    expect(fx).toContainEqual(expect.objectContaining({ kind: 'cue', cue: 'build' }));
+    run(game, 2);
+    const [turret] = game.roundState().turrets;
+    expect(turret).toMatchObject({ owner: tatie.id });
+    // Its base is cake: biscuit right under the head.
+    expect(game.terrain.data[(turret.y + 14) * game.terrain.width + turret.x]).toBe(5);
+    let shots = 0;
+    let victimHit = false;
+    for (let i = 0; i < 4 * TICK_RATE; i++) {
+      const step = game.step().fx;
+      shots += step.filter((e) => e.kind === 'cue' && e.cue === 'turret').length;
+      if (me(game, victim.id).anim & Anim.Stunned) victimHit = true;
+    }
+    expect(shots).toBeGreaterThanOrEqual(2);
+    expect(victimHit).toBe(true);
+    // Its own shots never knock it down.
+    expect(game.roundState().turrets).toHaveLength(1);
+    expect(game.stats.get(tatie.id, 'hits')).toBeGreaterThan(0);
+  });
+
+  it('a turret falls with the cake under it, and a third one replaces the oldest', () => {
+    const game = newGame();
+    const tatie = game.addPlayer('Tatie', 'tatie');
+    game.teleport(tatie.id, 600, 420);
+    run(game, 60);
+    // Three turrets along the exposed part of the middle tier (x 520 to 780).
+    for (const x of [560, 650, 720]) {
+      press(game, tatie.id, Button.Special, x, me(game, tatie.id).y);
+      run(game, Math.ceil(2.6 * TICK_RATE));
+    }
+    const turrets = game.roundState().turrets;
+    expect(turrets).toHaveLength(2);
+    expect(turrets[0].x).toBeGreaterThan(600); // the first one (x ≈ 560) was replaced
+    // Blow the ground away under one of them.
+    const target = turrets[0];
+    game.queueOp({ kind: 'carve', x: target.x, y: target.y + 18, r: 30, power: 99 });
+    const fx = run(game, 10);
+    expect(fx).toContainEqual(expect.objectContaining({ kind: 'cue', cue: 'turretDown' }));
+    expect(game.roundState().turrets.map((t) => t.id)).toEqual([turrets[1].id]);
+  });
+
+  it('cannot build in thin air', () => {
+    const game = newGame();
+    const tatie = game.addPlayer('Tatie', 'tatie');
+    game.teleport(tatie.id, 150, 300); // left of the cake: only the custard below
+    run(game, 2);
+    const t = me(game, tatie.id);
+    const fx = press(game, tatie.id, Button.Special, t.x - 100, t.y);
+    expect(fx.some((e) => e.kind === 'cue' && e.cue === 'build')).toBe(false);
+    expect(game.roundState().turrets).toHaveLength(0);
+  });
+});
+
+describe('Léon le Caméléon', () => {
+  it('turns invisible, and shows up again when he attacks', () => {
+    const game = newGame();
+    const leon = game.addPlayer('Léon', 'leon');
+    run(game, 60);
+    const l = me(game, leon.id);
+    const fx = press(game, leon.id, Button.Special, l.x, l.y);
+    expect(fx).toContainEqual(expect.objectContaining({ kind: 'cue', cue: 'vanish' }));
+    expect(me(game, leon.id).anim & Anim.Invisible).toBeTruthy();
+    run(game, TICK_RATE);
+    expect(me(game, leon.id).anim & Anim.Invisible).toBeTruthy();
+    const back = press(game, leon.id, Button.Attack, l.x + 100, l.y);
+    expect(back).toContainEqual(expect.objectContaining({ kind: 'cue', cue: 'appear' }));
+    expect(me(game, leon.id).anim & Anim.Invisible).toBeFalsy();
+  });
+
+  it('invisibility wears off on its own, and a hit reveals him', () => {
+    const game = newGame();
+    const leon = game.addPlayer('Léon', 'leon');
+    const cat = game.addPlayer('Chat', 'chat');
+    game.teleport(leon.id, 600, 420);
+    run(game, 60);
+    const l = me(game, leon.id);
+    press(game, leon.id, Button.Special, l.x, l.y);
+    run(game, Math.ceil(loadCharacterStats(ASSETS, 'leon').params.invisibleTime * TICK_RATE) + 2);
+    expect(me(game, leon.id).anim & Anim.Invisible).toBeFalsy();
+
+    run(game, 6 * TICK_RATE); // special cooldown
+    const l2 = me(game, leon.id);
+    press(game, leon.id, Button.Special, l2.x, l2.y);
+    expect(me(game, leon.id).anim & Anim.Invisible).toBeTruthy();
+    game.teleport(cat.id, l2.x - 40, l2.y);
+    run(game, 1);
+    press(game, cat.id, Button.Attack, l2.x, l2.y);
+    expect(me(game, leon.id).anim & Anim.Invisible).toBeFalsy();
+  });
+
+  it('his tongue pulls a player back to him and licks a groove in the cake', () => {
+    const game = newGame();
+    const leon = game.addPlayer('Léon', 'leon');
+    const victim = game.addPlayer('Victime', 'generic');
+    game.teleport(leon.id, 600, 420);
+    run(game, 60);
+    const l = me(game, leon.id);
+    game.teleport(victim.id, l.x + 125, l.y - 10);
+    run(game, 1);
+    press(game, leon.id, Button.Attack, l.x + 125, l.y - 10);
+    expect(me(game, victim.id).vx).toBeLessThan(-300);
+    expect(me(game, victim.id).anim & Anim.Stunned).toBeTruthy();
+    // Aimed down into the cake: a groove.
+    const solid = (): number => game.terrain.data.reduce((n, m) => n + (m !== 0 ? 1 : 0), 0);
+    run(game, TICK_RATE);
+    const before = solid();
+    const l2 = me(game, leon.id);
+    press(game, leon.id, Button.Attack, l2.x + 60, l2.y + 100);
+    expect(solid()).toBeLessThan(before - 200);
+  });
+});