Переглянути джерело

fix(maps,physics): no more jagged cream spikes; cheap resting chunks

- gen-maps: the frosting's side drips were 1-6 px stairs that rendered as
  dark jagged spikes under the outline; they are now fat rounded drops.
  Crepe ends are rounded so the leaning stack no longer reads as a
  staircase.
- The new tier silhouette exposed a physics cost: a fallen tier resting on
  a carved floor touched ~170 tiny chain edges and planck's continuous
  collision took up to 185 ms per tick. Colliders are simplified a bit
  more (COLLIDER_SIMPLIFY_EPSILON 0.8 -> 1.2 px, invisible with the
  smoothed rendering) and a resting chunk sleeps until it merges (woken
  by nearby terrain changes or a failed merge). Whole-tier fall: ~5 ms
  per tick on average instead of 18-145 ms.

No new dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Idleman 6 днів тому
батько
коміт
7ebd1d8c79

BIN
assets/maps/crepes/terrain.png


BIN
assets/maps/crepes/thumb.png


BIN
assets/maps/gateau-etages/terrain.png


BIN
assets/maps/gateau-etages/thumb.png


+ 1 - 0
readme.md

@@ -189,6 +189,7 @@ type TerrainOp =
 - **État mesuré** (Node, JIT chaud) :
   - un coup ordinaire (op, recherche de détachement, colliders, pas physique) : environ **0,1 ms** en moyenne, ~1 ms au p99 ;
   - un coup unique qui détache un étage entier (~90 000 px) : ~**5 ms** pour tout le tick, dont ~2 ms de recherche, ~1 ms de détachement et ~1,5 ms de colliders.
+  - la chute de cet étage jusqu'à sa réintégration : ~**5 ms** par tick en moyenne, ~45 ms sur le seul tick de l'impact. Deux choses comptent : la tolérance de simplification des colliders (`COLLIDER_SIMPLIFY_EPSILON`, 1,2 px ; à 0,8 px un sol creusé fait des centaines de petits segments et la collision continue de planck coûtait jusqu'à 185 ms par tick), et la mise en sommeil d'un bloc posé (`CHUNK_SLEEP_TIME`) en attendant sa réintégration. Une modification du terrain près d'un bloc endormi le réveille.
 
   Ce cas extrême reste au-dessus de 2 ms. Il faudrait étaler le travail sur plusieurs ticks, ou ajouter un cache de connectivité entre morceaux.
 ---

+ 12 - 7
scripts/gen-maps.ts

@@ -55,12 +55,13 @@ class Canvas {
       this.rect(x - half, top + 6, x + half + 1, top + 12 + len, CREAM);
       this.disc(x, top + 12 + len, half + 1.5, CREAM);
     }
-    for (let y = top; y < top + 60; y++) {
-      const len = Math.max(0, 6 - Math.floor((y - top) / 10));
-      for (let i = 0; i < len; i++) {
-        this.put(x0 - 6 + i, y, CREAM);
-        this.put(x1 + 5 - i, y, CREAM);
-      }
+    // A fat rounded drop down each side, wide enough not to vanish under the outline.
+    for (const [x, len] of [
+      [x0, 26],
+      [x1 - 1, 34],
+    ]) {
+      this.rect(x - 7, top, x + 8, top + len, CREAM);
+      this.disc(x, top + len, 7.5, CREAM);
     }
   }
 }
@@ -231,7 +232,11 @@ function crepes(): Canvas {
     const halfWidth = 330 - Math.round(layer * 1.5) + Math.round(6 * Math.sin(layer * 1.7));
     const left = 860 - halfWidth + shift;
     const right = 860 + halfWidth + shift;
-    c.rect(left, top - CREPE, right, top, SPONGE);
+    // Rounded crêpe ends: the sides read as a stack of pancakes, not a staircase.
+    const r = CREPE / 2;
+    c.rect(left + Math.floor(r), top - CREPE, right - Math.floor(r), top, SPONGE);
+    c.disc(left + r - 0.5, top - r - 0.5, r, SPONGE);
+    c.disc(right - r - 0.5, top - r - 0.5, r, SPONGE);
     if (top - CREPE - SPREAD > 340) c.rect(left + 12, top - CREPE - SPREAD, right - 12, top - CREPE, SPREADS[layer % 3]);
   }
   // Whipped cream on top.

+ 22 - 0
src/server/game.ts

@@ -26,6 +26,8 @@ import {
   CHUNK_REST_ANGULAR,
   CHUNK_REST_SPEED,
   CHUNK_REST_TIME,
+  CHUNK_SLEEP_TIME,
+  CHUNK_WAKE_MARGIN_PX,
   COUGH_INTERVAL_S,
   COUGH_STUMBLE,
   COYOTE_TIME,
@@ -408,6 +410,7 @@ export class Game {
       }
     }
     if (changedRects.length > 0) for (const component of this.finder.find(changedRects)) this.detach(component, changedBy[component.region], result);
+    if (changedRects.length > 0) this.wakeChunksNear(changedRects);
     this.syncTerrain(result);
 
     this.crushedThisStep.clear();
@@ -535,11 +538,27 @@ export class Game {
       }
       const resting = chunk.speed() < CHUNK_REST_SPEED && Math.abs(chunk.body.getAngularVelocity()) < CHUNK_REST_ANGULAR;
       chunk.restTime = resting ? chunk.restTime + TICK_DT : 0;
+      // A chunk settling on a rough floor touches dozens of little edges, and planck's continuous
+      // collision makes each tick very costly (100+ ms for a whole tier). Put it to sleep while it
+      // waits to merge; anything touching it, or the terrain changing nearby, wakes it up.
+      if (chunk.restTime >= CHUNK_SLEEP_TIME && chunk.body.isAwake()) chunk.body.setAwake(false);
       chunk.retryIn = Math.max(0, chunk.retryIn - TICK_DT);
       if (chunk.restTime >= CHUNK_REST_TIME && chunk.retryIn === 0) this.tryMerge(chunk, result);
     }
   }
 
+  /** Wakes the sleeping chunks close to changed terrain, so they fall if their floor went away. */
+  private wakeChunksNear(rects: Rect[]): void {
+    for (const chunk of this.chunks.values()) {
+      if (chunk.body.isAwake()) continue;
+      const c = chunk.body.getWorldCenter();
+      const reach = Math.hypot(chunk.w, chunk.h) / 2 + CHUNK_WAKE_MARGIN_PX;
+      const x = c.x * PPM;
+      const y = c.y * PPM;
+      if (rects.some((r) => x + reach >= r.x0 && x - reach <= r.x1 && y + reach >= r.y0 && y - reach <= r.y1)) chunk.body.setAwake(true);
+    }
+  }
+
   /** Rasterizes a resting chunk back into the bitmap, if the result is anchored. */
   private tryMerge(chunk: FallingChunk, result: StepResult): void {
     const s = chunk.state();
@@ -562,6 +581,9 @@ export class Game {
       }
       this.terrain.markDirty(rect);
       chunk.retryIn = CHUNK_MERGE_RETRY;
+      // Not held by anything anchored: let it move again (it may be resting on a player).
+      chunk.body.setAwake(true);
+      chunk.restTime = 0;
       return;
     }
     // Rotated rasterization leaves a few pixels touching only diagonally: drop them from the patch.

+ 5 - 1
src/shared/constants.ts

@@ -108,7 +108,7 @@ export const MATERIAL_PLATE = 255;
 export const MATERIAL_COUNT = 256;
 
 /** Douglas-Peucker tolerance for terrain colliders (px). */
-export const COLLIDER_SIMPLIFY_EPSILON = 0.8;
+export const COLLIDER_SIMPLIFY_EPSILON = 1.2;
 /** Closed contours smaller than this on both axes get no collider: crumbs players would snag on (px). */
 export const MIN_COLLIDER_LOOP_PX = 5;
 
@@ -121,6 +121,10 @@ export const MIN_CHUNK_PIXELS = 40;
 export const CHUNK_REST_SPEED = 6;
 export const CHUNK_REST_ANGULAR = 0.08;
 export const CHUNK_REST_TIME = 0.5;
+/** A resting chunk is put to sleep in the physics after this long (s), until something wakes it. */
+export const CHUNK_SLEEP_TIME = 0.1;
+/** Terrain changes within this distance (px) of a sleeping chunk wake it up. */
+export const CHUNK_WAKE_MARGIN_PX = 16;
 /** If a resting chunk cannot be merged (nothing anchors it), retry after this delay (s). */
 export const CHUNK_MERGE_RETRY = 0.5;
 /** Max gap (px) filled under a resting chunk so it connects to the ground it lies on. */