Bläddra i källkod

fix(characters): Mamie's yarn lays a taut, anchored wool bridge instead of a falling trail

The thread used to be laid pixel by pixel in mid-air behind the ball: every
piece was unanchored, so it broke into 10-20 falling chunks per throw, and
a ball touching Mamie stuck a wool blob on her (buried, then crushed).

- The thread is tied to the ground under the thrower's feet (or the launch
  point when airborne) and drawn in flight from there to the ball.
- On impact, the thread is laid taut from the anchor to the impact point,
  plus a wool blob on what was hit: held by both ends, it is a bridge. A
  ball that hits nothing unravels (yarnLife raised to 2.5 s so a ball
  thrown up lands and sticks).
- The yarn ball does not collide with players (collision filter), and no
  wool is ever laid inside a player.
- Soft chunks (average density below CRUSH_MIN_DENSITY: wool, whipped
  cream) no longer crush players.
- Tests: bridge holds with nothing falling and nobody crushed, the thread
  is tied under her feet, throws at her own feet never bury or kill her.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Idleman 1 vecka sedan
förälder
incheckning
1ebd190593

+ 1 - 1
assets/characters/mamie/character.json

@@ -17,7 +17,7 @@
     "jabAnim": 0.15,
     "yarnSpeed": 560,
     "yarnStart": 8,
-    "yarnLife": 1.3,
+    "yarnLife": 2.5,
     "yarnTrailRadius": 5,
     "yarnBlobRadius": 13,
     "throwAnim": 0.25

+ 2 - 2
readme.md

@@ -179,7 +179,7 @@ type TerrainOp =
 - Quand le bloc s'immobilise (vitesse quasi nulle pendant 0,5 s), le serveur le **rastérise** dans le bitmap et diffuse `merge { id, x, y, w, h, patch }`. Le patch est le résultat pixel par pixel, pour qu'aucun client n'ait à recalculer la rotation. Il comble jusqu'à `CHUNK_GLUE_PX` pixels sous le bloc pour le raccrocher au sol. Si le résultat ne serait pas ancré (bloc posé sur un joueur ou sur un autre bloc), la réintégration est retentée plus tard.
 - Les ops, `detach` et `merge` forment **un seul flux d'événements séquencés** (`shared/terrain/events.ts`) : c'est ce flux, rejoué dans l'ordre, qui garantit un bitmap identique au bit près sur tous les clients (vérifié par les tests).
 - Côté physique, un bloc est approché par des boîtes alignées sur une grille (Box2D ne fait pas collisionner deux chaînes entre elles).
-- Un bloc qui tombe et percute un joueur avec assez d'énergie l'écrase (élimination). Un bloc qui tombe dans la crème disparaît.
+- Un bloc qui tombe et percute un joueur avec assez d'énergie l'écrase (élimination). Les blocs mous (laine, chantilly : densité < `CRUSH_MIN_DENSITY`) n'écrasent pas. Un bloc qui tombe dans la crème disparaît.
 ### Budget de performance
  
 - Une op avec flood-fill et régénération des colliders doit tenir en **2 ms** côté serveur. Si ce n'est pas le cas, on optimise : flood-fill borné, bitsets, cache des composantes.
@@ -312,7 +312,7 @@ Chaque étape doit être **jouable** avant de passer à la suivante.
 - Castor Glouton (déjà jouable depuis M1), Mamie Tricot, Pyromanchot (le trio creuser / construire / exploser).
 - É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.
+  - **Mamie Tricot :** ses aiguilles assomment longtemps. Sa pelote reste reliée par un fil au sol sous ses pieds (le fil est dessiné en vol). Là où elle touche le décor, le fil est posé tendu en Laine, avec une boule collée à l'impact : un pont tenu par ses deux bouts. Une pelote qui ne touche rien se défait. Elle traverse les joueurs, et aucune laine n'est jamais posée dans un joueur. 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`.

+ 17 - 0
src/renderer/entities/RoundViews.ts

@@ -99,10 +99,27 @@ export function paintItem(g: Graphics, kind: EntityKind, lit: boolean, time: num
 export class ItemsView {
   readonly container = new Container();
   private readonly views = new Map<number, Graphics>();
+  /** Yarn threads, redrawn every frame under the items. */
+  private readonly threads = new Graphics();
   private time = 0;
 
+  constructor() {
+    this.container.addChild(this.threads);
+  }
+
   update(items: Map<number, ItemState>, dt: number): void {
     this.time += dt;
+    this.threads.clear();
+    for (const item of items.values()) {
+      if (!item.thread) continue;
+      // A slightly slack wool thread from where it is tied to the flying ball.
+      const [ax, ay] = item.thread;
+      const mx = (ax + item.x) / 2;
+      const my = (ay + item.y) / 2 + Math.min(40, Math.hypot(item.x - ax, item.y - ay) * 0.08);
+      this.threads.moveTo(ax, ay).quadraticCurveTo(mx, my, item.x, item.y).stroke({ width: 6, color: INK });
+      this.threads.moveTo(ax, ay).quadraticCurveTo(mx, my, item.x, item.y).stroke({ width: 3, color: 0x9b6bd9 });
+      this.threads.circle(ax, ay, 4).fill(0x9b6bd9).stroke({ width: 2, color: INK });
+    }
     for (const [id, g] of this.views) {
       if (items.has(id)) continue;
       g.destroy();

+ 66 - 27
src/server/game.ts

@@ -126,9 +126,9 @@ interface Thrown {
   arm: number;
   /** Null for a thrown candle. */
   spec: ProjectileSpec | null;
-  /** Yarn: where the wool thread was last laid. */
-  trailX: number;
-  trailY: number;
+  /** Yarn: where the thread is tied (solid ground under the thrower, or the launch point). */
+  anchorX: number;
+  anchorY: number;
 }
 
 interface Item {
@@ -142,7 +142,13 @@ interface Item {
 
 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;
+const YARN_STEP = 3;
+/** How far below the thrower's feet the thread looks for ground to tie to (px). */
+const YARN_ANCHOR_REACH = 14;
+/** Free space kept between a player's body and any wool laid by a yarn ball (px). */
+const YARN_PLAYER_MARGIN = 3;
+/** Physics collision category of players (projectiles can opt out of hitting them). */
+const CATEGORY_PLAYER = 0x0002;
 
 interface Cherry {
   body: Body;
@@ -349,7 +355,14 @@ export class Game {
   spawnItem(kind: EntityKind, x: number, y: number): number {
     const id = this.nextItemId++;
     const body = this.world.createBody({ type: 'dynamic', position: { x: x / PPM, y: y / PPM }, bullet: true, userData: { item: id } });
-    body.createFixture(new Circle(ITEM_RADIUS / PPM), { density: 0.6, friction: 0.6, restitution: 0.25, userData: { item: id } satisfies FixtureTag });
+    body.createFixture(new Circle(ITEM_RADIUS / PPM), {
+      density: 0.6,
+      friction: 0.6,
+      restitution: 0.25,
+      userData: { item: id } satisfies FixtureTag,
+      // A ball of yarn flies through players: it must not stick to its thrower or wrap anyone.
+      filterMaskBits: kind === 'yarn' ? ~CATEGORY_PLAYER & 0xffff : 0xffff,
+    });
     this.items.set(id, { id, kind, body, age: 0, thrown: null });
     return id;
   }
@@ -424,7 +437,10 @@ export class Game {
   itemStates(): ItemState[] {
     return [...this.items.values()].map((it) => {
       const pos = it.body.getPosition();
-      return { id: it.id, kind: it.kind, x: pos.x * PPM, y: pos.y * PPM, angle: it.body.getAngle(), lit: it.thrown !== null };
+      const state: ItemState = { id: it.id, kind: it.kind, x: pos.x * PPM, y: pos.y * PPM, angle: it.body.getAngle(), lit: it.thrown !== null };
+      // A ball of yarn in flight trails its thread back to the anchor.
+      if (it.kind === 'yarn' && it.thrown) state.thread = [it.thrown.anchorX, it.thrown.anchorY];
+      return state;
     });
   }
 
@@ -550,7 +566,7 @@ export class Game {
     const chunkId = a?.chunk ?? b?.chunk;
     if (playerId === undefined || chunkId === undefined) return;
     const chunk = this.chunks.get(chunkId);
-    if (!chunk || chunk.speed() < CRUSH_MIN_SPEED) return;
+    if (!chunk || !chunk.canCrush || chunk.speed() < CRUSH_MIN_SPEED) return;
     let total = 0;
     for (const n of impulse.normalImpulses) total += n;
     if (total > CRUSH_IMPULSE) this.crushedThisStep.add(playerId);
@@ -560,7 +576,7 @@ export class Game {
 
   private createBodyFixture(body: Body, radius: number, mass: number, id: number): Fixture {
     const tag: FixtureTag = { player: id };
-    return body.createFixture(new Circle(radius / PPM), { density: mass, friction: 0, restitution: 0, userData: tag });
+    return body.createFixture(new Circle(radius / PPM), { density: mass, friction: 0, restitution: 0, userData: tag, filterCategoryBits: CATEGORY_PLAYER });
   }
 
   private resize(p: ServerPlayer, radius: number): void {
@@ -928,7 +944,8 @@ export class Game {
     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 };
+    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 });
     item.body.setAngularVelocity(vx >= 0 ? 12 : -12);
   }
@@ -939,11 +956,12 @@ export class Game {
     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.
+      if (hit) {
+        // The ball sticks to what it hit: the thread is pulled taut from the anchor to here.
         this.removeItem(item);
-        this.queueOp({ kind: 'fill', x: Math.round(x), y: Math.round(y), r: spec.blobRadius, material: spec.material }, undefined, t.owner);
+        this.stretchThread(t, spec, x, y);
+      } else if (t.age >= spec.life) {
+        this.removeItem(item); // nothing to tie the thread to: it unravels
       }
       return;
     }
@@ -953,21 +971,42 @@ export class Game {
     }
   }
 
-  /** 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;
+  /**
+   * Where a thrown yarn ball's thread is tied: the solid ground right under the thrower's feet
+   * (so a bridge thrown from the ground holds by that end), or the launch point if airborne.
+   */
+  private yarnAnchor(owner: number, x: number, y: number): [number, number] {
+    const p = this.players.get(owner);
+    if (!p) return [x, y];
+    const pos = p.body.getPosition();
+    const px = Math.round(pos.x * PPM);
+    const py = Math.round(pos.y * PPM);
+    for (let dy = 0; dy <= p.bodyRadius + YARN_ANCHOR_REACH; dy++) {
+      for (const dx of [0, -6, 6, -12, 12]) if (this.terrain.isSolid(px + dx, py + dy)) return [px + dx, py + dy];
     }
+    return [x, y];
+  }
+
+  /**
+   * Lays the wool: a straight thread of discs from the anchor to the impact point, and a blob
+   * where the ball stuck. Tied to the ground at one end and to what it hit at the other, it
+   * holds as a bridge; otherwise it falls like any unanchored piece (readme §5). No wool is
+   * ever laid inside a player.
+   */
+  private stretchThread(t: Thrown, spec: Extract<ProjectileSpec, { kind: 'yarn' }>, x: number, y: number): void {
+    const bodies = this.alivePlayers().map((p) => {
+      const pos = p.body.getPosition();
+      return { x: pos.x * PPM, y: pos.y * PPM, r: p.bodyRadius };
+    });
+    const clear = (cx: number, cy: number, r: number): boolean => bodies.every((b) => Math.hypot(b.x - cx, b.y - cy) > b.r + r + YARN_PLAYER_MARGIN);
+    const lay = (cx: number, cy: number, r: number): void => {
+      if (clear(cx, cy, r)) this.queueOp({ kind: 'fill', x: Math.round(cx), y: Math.round(cy), r, material: spec.material }, undefined, t.owner);
+    };
+    const dx = x - t.anchorX;
+    const dy = y - t.anchorY;
+    const steps = Math.max(1, Math.ceil(Math.hypot(dx, dy) / YARN_STEP));
+    for (let i = 0; i <= steps; i++) lay(t.anchorX + (dx * i) / steps, t.anchorY + (dy * i) / steps, spec.trailRadius);
+    lay(x, y, spec.blobRadius);
   }
 
   /**

+ 4 - 1
src/server/physics/fallingChunk.ts

@@ -1,5 +1,5 @@
 import { Box, type Body, type World } from 'planck';
-import { CHUNK_BOX_CELL_PX, CHUNK_MAX_BOXES_PER_AXIS, MATERIAL_EMPTY, PIXELS_PER_METER } from '../../shared/constants';
+import { CHUNK_BOX_CELL_PX, CHUNK_MAX_BOXES_PER_AXIS, CRUSH_MIN_DENSITY, MATERIAL_EMPTY, PIXELS_PER_METER } from '../../shared/constants';
 import type { MaterialRegistry } from '../../shared/materials';
 import type { ChunkInfo, ChunkState } from '../../shared/protocol';
 import { rleEncode } from '../../shared/terrain/rle';
@@ -91,6 +91,8 @@ export class FallingChunk {
   restTime = 0;
   /** Delay before trying to merge again after a failed merge. */
   retryIn = 0;
+  /** Soft chunks (wool, whipped cream) bump into players but never crush them. */
+  readonly canCrush: boolean;
 
   constructor(
     world: World,
@@ -123,6 +125,7 @@ export class FallingChunk {
       n += count;
     }
     n = Math.max(1, n);
+    this.canCrush = density / n >= CRUSH_MIN_DENSITY;
     this.body = world.createBody({ type: 'dynamic', position: { x: x / PPM, y: y / PPM }, userData: { chunk: id } });
     const tag: FixtureTag = { chunk: id };
     // Fixtures are created massless and get their density afterwards: planck recomputes the

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

@@ -43,7 +43,7 @@ export interface Blast {
 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. */
+  /** 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 };
 
 /** What a character may read and do. Implemented by the server simulation. */

+ 3 - 2
src/shared/characters/Mamie.ts

@@ -4,8 +4,9 @@ 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.
+ * - Special: throws a ball of yarn whose thread is tied to the ground under her feet. Where the
+ *   ball hits, the thread is laid taut in wool, with a blob stuck to the impact: a bridge that
+ *   holds by both ends. A ball that hits nothing unravels.
  * - Flaw: slow, and wool burns (explosions consume it, see WOOL_BURN_RADIUS_FACTOR).
  */
 export class Mamie extends Character {

+ 2 - 0
src/shared/constants.ts

@@ -115,6 +115,8 @@ export const CHUNK_MAX_BOXES_PER_AXIS = 48;
 export const CRUSH_IMPULSE = 2.2;
 /** Minimum chunk speed (px/s) for a crush to count, so resting blocks never kill. */
 export const CRUSH_MIN_SPEED = 120;
+/** Chunks lighter than this on average (wool 0.4, whipped cream 0.3) are soft: they never crush. */
+export const CRUSH_MIN_DENSITY = 0.6;
 
 /** Physics scale: planck works in meters. */
 export const PIXELS_PER_METER = 32;

+ 2 - 0
src/shared/protocol.ts

@@ -43,6 +43,8 @@ export interface ItemState {
   y: number;
   angle: number;
   lit: boolean;
+  /** Yarn in flight: where its thread is tied. */
+  thread?: [number, number];
 }
 
 export interface CherryState {

+ 44 - 7
tests/characters.test.ts

@@ -39,19 +39,56 @@ describe('roster', () => {
 });
 
 describe('Mamie Tricot', () => {
-  it('her yarn ball spins a wool thread that holds when it reaches a wall', () => {
+  it('her yarn ball lays a taut wool bridge that holds: nothing falls, nobody is hurt', () => {
     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);
+    let detached = 0;
+    let crushed = 0;
+    game.setInput(id, { buttons: Button.Special, aimX: 800, aimY: Math.round(m.y - 40) });
+    for (let i = 0; i < 4 * TICK_RATE; i++) {
+      const r = game.step();
+      if (i === 0) game.setInput(id, { buttons: 0, aimX: 800, aimY: Math.round(m.y - 40) });
+      detached += r.terrain.filter((e) => e.ev.kind === 'detach').length;
+      crushed += r.crushed.length;
+    }
+    expect(countWool(game)).toBeGreaterThan(300);
+    expect(detached).toBe(0);
+    expect(crushed).toBe(0);
+    expect(me(game, id).anim & Anim.Dead).toBe(0);
+  });
+
+  it('the thread follows the ball in flight, tied under her feet', () => {
+    const game = newGame();
+    const { id } = game.addPlayer('Mamie', 'mamie');
+    game.teleport(id, 640, 440);
+    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);
+    const ball = game.itemStates().find((it) => it.kind === 'yarn')!;
+    expect(ball.thread).toBeDefined();
+    const [ax, ay] = ball.thread!;
+    expect(Math.abs(ax - m.x)).toBeLessThan(20);
+    expect(ay).toBeGreaterThan(m.y); // on the ground under her
+    expect(game.terrain.isSolid(Math.round(ax), Math.round(ay))).toBe(true);
+  });
+
+  it('a yarn ball thrown at her own feet never buries or kills her', () => {
+    const game = newGame();
+    const { id } = game.addPlayer('Mamie', 'mamie');
+    game.teleport(id, 640, 440);
+    run(game, 60);
+    for (const [ax, ay] of [[0, 60], [0, -200], [40, 20], [-40, 20]]) {
+      const m = me(game, id);
+      press(game, id, Button.Special, m.x + ax, m.y + ay);
+      run(game, 3 * TICK_RATE);
+      const after = me(game, id);
+      expect(after.anim & Anim.Dead).toBe(0);
+      // Not stuck inside wool: no solid pixel at her centre.
+      expect(game.terrain.isSolid(Math.round(after.x), Math.round(after.y))).toBe(false);
+    }
   });
 
   it('her needles barely push but stun for a long time', () => {