Kaynağa Gözat

feat(game): wall jumps for everyone, a heavy cherry that comes back anywhere

Playtest feedback: flyers reach the cherry far more easily, and the
characters who can neither fly, dig fast nor jump high spend the round
trying to get up.

- Wall jump and wall slide for every character: kick off a wall, hold
  toward it to come back and climb. Tested: every character climbs the
  230 px wall of a tier.
- The cherry is heavy: its holder jumps lower, and Captain Prout gets a
  single thrust while holding it.
- A cherry that falls in the custard comes back on a random ledge of
  the cake instead of always at the top.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Idleman 4 gün önce
ebeveyn
işleme
68aececba8

+ 2 - 1
assets/characters/prout/character.json

@@ -23,6 +23,7 @@
     "cloudRadius": 46,
     "cloudLife": 3.5,
     "puffAnim": 0.25,
-    "airPuffs": 3
+    "airPuffs": 3,
+    "cherryAirPuffs": 1
   }
 }

+ 3 - 1
readme.md

@@ -118,10 +118,12 @@ Toute la logique aléatoire (objets, Magicien Raté, mutateurs) passe par un **R
  
 ### Déroulement d'une manche
  
-- La cerise apparaît au sommet de la carte (position définie par la carte).
+- La cerise apparaît au sommet de la carte (position définie par la carte). Si elle tombe dans la crème, elle revient sur une corniche du gâteau tirée au hasard (pas forcément en haut).
+- La cerise est lourde : celui qui la tient saute moins haut (`CHERRY_HOLDER_JUMP_MULT`), et Captain Prout n'a qu'une poussée.
 - Tant qu'un joueur la tient, il marque **1 point par seconde**. S'il est touché, il la lâche et elle roule (objet physique).
 - **La crème anglaise monte** en continu. La vitesse est définie par la carte et accélère dans les 30 dernières secondes.
 - Un joueur qui tombe dans la crème réapparaît 5 secondes plus tard sur une **cuillère-catapulte** qui le renvoie sur le terrain. La cuillère vise une corniche du gâteau qu'elle peut atteindre (trajectoire vérifiée contre le terrain), même s'il ne reste qu'un petit îlot.
+- **Saut contre un mur**, pour tous les personnages : en l'air contre une paroi, sauter fait rebondir dessus. En gardant la direction du mur, on y revient et on grimpe. On y glisse aussi plus lentement (`WALL_*`). Aucun personnage ne reste coincé sous un étage.
 - Un joueur coincé peut **abandonner** (touche K) : il plonge dans la crème et revient par la cuillère.
 - La manche s'arrête plus tôt s'il ne reste presque plus de gâteau au-dessus de la crème (`MIN_CAKE_PIXELS`).
 - Des **objets tombent du ciel** à intervalles aléatoires :

+ 1 - 1
src/renderer/i18n/fr.ts

@@ -79,7 +79,7 @@ export const fr = {
     sfx: 'Bruitages',
     mute: 'Couper le son',
     shake: 'Secousses de l’écran',
-    keys: 'Échap : options · K : abandonner quand tu es coincé (retour par la cuillère)',
+    keys: 'Échap : options · K : abandonner quand tu es coincé (retour par la cuillère) · Contre un mur, saute pour rebondir dessus et grimper (garde la direction du mur)',
     stop: 'Arrêter la partie',
     stopConfirm: 'Arrêter la partie pour tout le monde et revenir à l’échauffement ?',
     quit: 'Retour au menu',

+ 42 - 7
src/server/game.ts

@@ -20,6 +20,7 @@ import {
   CANDLE_THROW_SPEED,
   CAKE_CHECK_INTERVAL_S,
   CHERRY_DROP_SPEED,
+  CHERRY_HOLDER_JUMP_MULT,
   CHERRY_POINTS_PER_S,
   CHERRY_RADIUS,
   CHERRY_REGRAB_DELAY,
@@ -72,6 +73,10 @@ import {
   STUN_GUARD_KNOCKBACK,
   STUN_GUARD_S,
   TICK_DT,
+  WALL_JUMP_PUSH,
+  WALL_JUMP_UP,
+  WALL_NORMAL_X,
+  WALL_SLIDE_SPEED,
   WHISK_CRACK_POWER,
   WHISK_CRACK_RADIUS,
   WHISK_CRACKS,
@@ -123,6 +128,9 @@ interface ServerPlayer {
   facing: number;
   grounded: boolean;
   sinceGrounded: number;
+  /** Wall touched in the air: -1 on the left, 1 on the right, 0 none; and how long ago. */
+  wallSide: number;
+  sinceWall: number;
   jumpBuffer: number;
   attackCooldown: number;
   specialCooldown: number;
@@ -390,6 +398,8 @@ export class Game {
       facing: 1,
       grounded: false,
       sinceGrounded: Infinity,
+      wallSide: 0,
+      sinceWall: Infinity,
       jumpBuffer: 0,
       attackCooldown: 0,
       specialCooldown: 0,
@@ -748,10 +758,22 @@ export class Game {
     }
 
     p.jumpBuffer = pressed & Button.Jump ? JUMP_BUFFER_TIME : Math.max(0, p.jumpBuffer - TICK_DT);
+    const jumpSpeed =
+      stats.jump * Math.sqrt(mobility) * (p.soaked > 0 ? SOAKED_JUMP_MULT : 1) * (this.cherry?.holder === p.info.id ? CHERRY_HOLDER_JUMP_MULT : 1);
+    const towardWall = !locked && p.wallSide !== 0 && isDown(input, p.wallSide > 0 ? Button.Right : Button.Left);
     if (p.jumpBuffer > 0 && p.sinceGrounded <= COYOTE_TIME) {
-      vy = -stats.jump * Math.sqrt(mobility) * (p.soaked > 0 ? SOAKED_JUMP_MULT : 1);
+      vy = -jumpSpeed;
       p.jumpBuffer = 0;
       p.sinceGrounded = Infinity;
+    } else if (p.jumpBuffer > 0 && p.sinceWall <= COYOTE_TIME && p.wallSide !== 0) {
+      // Wall jump: kick off the wall, up and a little away.
+      vy = -jumpSpeed * WALL_JUMP_UP;
+      vx = -p.wallSide * WALL_JUMP_PUSH;
+      p.jumpBuffer = 0;
+      p.sinceWall = Infinity;
+    } else if (towardWall && p.sinceWall === 0 && vy > WALL_SLIDE_SPEED) {
+      // Holding toward a wall slows the fall.
+      vy = WALL_SLIDE_SPEED;
     } else if (released & Button.Jump && vy < 0) {
       vy *= JUMP_CUT;
     }
@@ -786,19 +808,28 @@ export class Game {
   private postStep(p: ServerPlayer): void {
     if (p.dead > 0) return;
     p.grounded = false;
+    let wall = 0;
     for (let edge = p.body.getContactList(); edge; edge = edge.next) {
       const contact = edge.contact;
       if (!contact.isTouching()) continue;
       const normal = contact.getWorldManifold(null)?.normal;
       if (!normal) continue;
       // The manifold normal points from fixture A to fixture B; flip it so it points toward the player.
-      const ny = contact.getFixtureA().getBody() === p.body ? -normal.y : normal.y;
-      if (ny < GROUND_NORMAL_Y) {
-        p.grounded = true;
-        break;
-      }
+      const flip = contact.getFixtureA().getBody() === p.body ? -1 : 1;
+      const nx = normal.x * flip;
+      const ny = normal.y * flip;
+      if (ny < GROUND_NORMAL_Y) p.grounded = true;
+      // A wall on the left pushes the player right (+x), and the other way round.
+      else if (Math.abs(nx) > WALL_NORMAL_X) wall = nx > 0 ? -1 : 1;
     }
     p.sinceGrounded = p.grounded ? 0 : p.sinceGrounded + TICK_DT;
+    if (!p.grounded && wall !== 0) {
+      p.wallSide = wall;
+      p.sinceWall = 0;
+    } else {
+      p.sinceWall += TICK_DT;
+      if (p.grounded) p.wallSide = 0;
+    }
     // Landing ends the spoon flight; brushing an edge on the way up does not.
     if (p.grounded && p.body.getLinearVelocity().y >= 0) p.flight = 0;
     const y = p.body.getPosition().y * PPM;
@@ -1015,6 +1046,7 @@ export class Game {
       vx: vel.x * PPM,
       vy: vel.y * PPM,
       grounded: p.grounded,
+      carryingCherry: this.cherry?.holder === p.info.id,
       facing: p.facing,
       input,
       emitOp: (op, onApplied) => this.queueOp(op, onApplied, p.info.id),
@@ -1318,8 +1350,11 @@ export class Game {
     const pos = cherry.body.getPosition();
     if (pos.y * PPM > this.custardY + CUSTARD_SINK_PX || pos.y * PPM > this.terrain.height + OUT_OF_MAP_MARGIN) {
       this.fx.push({ kind: 'splash', id: 0, x: pos.x * PPM, y: this.custardY });
-      const [x, y] = this.map.cherrySpawn;
+      // Back on the cake, anywhere it can rest (not always at the top: diggers get their chance).
+      const spots = this.landingSpots(CHERRY_RADIUS, Math.min(this.custardY, this.terrain.height) - CHERRY_RADIUS - 4);
+      const [x, y] = spots.length > 0 ? spots[this.rng.int(0, spots.length - 1)] : this.map.cherrySpawn;
       cherry.body.setPosition({ x: x / PPM, y: y / PPM });
+      this.fx.push({ kind: 'pickup', id: 0, item: 'cherry', x, y }); // shows everyone where it is now
       cherry.body.setLinearVelocity({ x: 0, y: 0 });
       return;
     }

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

@@ -91,6 +91,8 @@ export interface CharacterContext {
   readonly vx: number;
   readonly vy: number;
   readonly grounded: boolean;
+  /** Holding the cherry (it weighs the holder down). */
+  readonly carryingCherry: boolean;
   /** -1 left, 1 right */
   readonly facing: number;
   readonly input: InputState;

+ 2 - 1
src/shared/characters/Prout.ts

@@ -28,7 +28,8 @@ export class Prout extends Character {
 
   onSpecial(ctx: CharacterContext): void {
     const p = this.stats.params;
-    if (this.puffsUsed >= p.airPuffs) return;
+    // The cherry is heavy: fewer thrusts while he holds it.
+    if (this.puffsUsed >= (ctx.carryingCherry ? p.cherryAirPuffs : p.airPuffs)) return;
     this.puffsUsed++;
     const [dx] = aimDir(ctx);
     ctx.setVelocity(ctx.vx + dx * p.puffSide, Math.min(ctx.vy, 0) - p.puffLift);

+ 11 - 0
src/shared/constants.ts

@@ -174,6 +174,17 @@ export const JUMP_BUFFER_TIME = 0.1;
 /** Vertical speed multiplier applied when jump is released while rising. */
 export const JUMP_CUT = 0.5;
 export const AIR_CONTROL = 0.6;
+/**
+ * Wall jump, for everyone (no character is stuck under a tier): touching a wall in the air,
+ * jump kicks off it (WALL_JUMP_UP × the jump, WALL_JUMP_PUSH px/s away); holding toward the wall
+ * brings you back to kick again, and slows the fall to WALL_SLIDE_SPEED px/s.
+ */
+export const WALL_NORMAL_X = 0.7;
+export const WALL_JUMP_UP = 1;
+export const WALL_JUMP_PUSH = 170;
+export const WALL_SLIDE_SPEED = 90;
+/** The cherry is heavy: its holder jumps this much (and Captain Prout gets fewer thrusts). */
+export const CHERRY_HOLDER_JUMP_MULT = 0.8;
 /** Contact normal Y below which a contact counts as ground (y points down). */
 export const GROUND_NORMAL_Y = -0.5;
 /** A growing body's physics fixture is rebuilt once its radius drifts this far (px). */

+ 66 - 1
tests/rules.test.ts

@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
 import { loadCharacterStats, loadMap } from '../src/server/assets';
 import { Game } from '../src/server/game';
 import { MatchStats } from '../src/server/stats';
-import { Anim } from '../src/shared/characters';
+import { Anim, PLAYABLE_CHARACTER_IDS } from '../src/shared/characters';
 import { CHERRY_REGRAB_DELAY, FINAL_RUSH_S, MIN_CAKE_PIXELS, RESPAWN_DELAY_S, SMARTIES_DURATION_S, STUN_GUARD_S, TICK_RATE } from '../src/shared/constants';
 import { Button } from '../src/shared/input';
 import type { FxEvent } from '../src/shared/protocol';
@@ -283,3 +283,68 @@ describe('trophies', () => {
     expect(t.find((x) => x.id === 'pyromaniac')).toBeUndefined();
   });
 });
+
+describe('getting up', () => {
+  /** Holds toward the wall and taps jump, like a player climbing; returns the highest y reached. */
+  function climb(game: Game, id: number, ticks: number): number {
+    let best = Infinity;
+    for (let i = 0; i < ticks; i++) {
+      const jump = i % 12 < 6 ? Button.Jump : 0;
+      game.setInput(id, { buttons: Button.Right | jump, aimX: 2000, aimY: 0 });
+      game.step();
+      best = Math.min(best, me(game, id).y);
+    }
+    return best;
+  }
+
+  it('every character can climb a tier wall by wall-jumping', () => {
+    for (const character of PLAYABLE_CHARACTER_IDS) {
+      const game = newRound();
+      const { id } = game.addPlayer('Grimpeur', character);
+      game.teleport(id, 470, 670); // bottom tier, against the middle tier's wall (x = 520, 230 px high)
+      run(game, 30);
+      expect(climb(game, id, 6 * TICK_RATE), character).toBeLessThan(470);
+    }
+  });
+
+  it('the cherry is heavy: its holder jumps lower', () => {
+    const jumpHeight = (holding: boolean): number => {
+      const game = newRound();
+      const { id } = game.addPlayer('Sauteur', 'generic');
+      game.teleport(id, 400, 680);
+      run(game, 30);
+      if (holding) {
+        const cherry = (game as unknown as { cherry: { body: { setPosition(p: { x: number; y: number }): void } } }).cherry;
+        const p = me(game, id);
+        cherry.body.setPosition({ x: p.x / 32, y: p.y / 32 });
+        run(game, 2);
+        expect(game.roundState().cherry!.holder).toBe(id);
+      }
+      const y0 = me(game, id).y;
+      game.setInput(id, { buttons: Button.Jump, aimX: 0, aimY: 0 });
+      let top = y0;
+      for (let i = 0; i < TICK_RATE; i++) {
+        game.step();
+        top = Math.min(top, me(game, id).y);
+      }
+      return y0 - top;
+    };
+    expect(jumpHeight(true)).toBeLessThan(jumpHeight(false) * 0.75);
+  });
+
+  it('a cherry that falls in the custard comes back on the cake, not always at the top', () => {
+    const tops = new Set<number>();
+    for (const seed of [1, 2, 3, 4]) {
+      const game = newRound(180, seed);
+      const cherry = (game as unknown as { cherry: { body: { setPosition(p: { x: number; y: number }): void } } }).cherry;
+      cherry.body.setPosition({ x: 100 / 32, y: 1010 / 32 });
+      run(game, 2);
+      const c = game.roundState().cherry!;
+      expect(c.y).toBeLessThan(game.custardLevel);
+      run(game, TICK_RATE); // it lands on something
+      expect(game.roundState().cherry!.y).toBeLessThan(game.custardLevel - 10);
+      tops.add(Math.round(c.y / 100));
+    }
+    expect(tops.size).toBeGreaterThan(1);
+  });
+});