Client-side accumulating history — generation never knows (streaming law untouched; every mark parents to a segment group and sinks out with its ExitAnim): - SCARS: hard marble-static knocks (contact events ≥ 4.5 u/s) leave dark scuff marks at the hit point, sized by impact speed, capped 30 per segment, shared geometry/material, excluded from the fit box. - ICE SCUFFS: hard braking on icy stations drops pale translucent streak marks (rate-limited 0.5s per racer, deterministic — sim state only). - TIP-OVERS: a hard knock within 0.8u of trackside furniture (fans, banners, bumper mushrooms) tips it ~74 degrees away from the shove, eased over ~0.5s — and it STAYS fallen until the segment sinks. A fallen fan keeps spinning: the wreck is alive. - Telemetry: race JSON gains standing-mark count; scar bookkeeping cleans with segment disposal. NO audio (user rule). 210 green, check clean, goldens untouched (pure presentation).
- commit
- bedb2e352b57bcfa78ab89fab4243c18275117a6
- parent
- 11ae0ef5c39a001d0a8f858fc6050b1687f6b006
- author
- Dami <dami.etoile@gmail.com>
| M | progress.md | 7 | +- |
| M | src/ | 125 | ++++++++++++++++++++++++++++++ |
@@ -65,8 +65,11 @@ cross composite, quantitative metrics CI).
6565 boundaries — no observed failure (fuzz clean), but the claim is not6666 airtight. Tighten commitSlope's neighbor bands if a fuzz ever finds6767 it.68-- **Destructible decor**: physics now exists to collide with; dormant69- until something can hit decor (island-top pieces are off the channel).68+- **World memory (M25) residual**: scars/scuffs/furniture tip-overs69+ shipped (contact-event driven, per-segment lifetime). Open: a browser70+ soak to tune SCAR_SPEED (4.5) and mark sizes; island-top destructibles71+ stay dormant (still off the channel — trackside furniture was the72+ honest first target).7073 - **Bumper density tuning**: after watching feedback (pins currently7174 only on wide island decks).7275 - **Braid residual (V2/V3/M23)**: lane mods, wanderers, open forks,@@ -459,6 +459,8 @@ interface Racer {
459459 /** Progress rank this step (index into the s-sorted pack) — written in460460 * the sort pass; riders read their leader through it. */461461 rank: number;462+ /** Last ice-scuff sim time (M25 world memory rate limit). */463+ lastScuffT: number;462464 /** Seeded directed relationship (M20b) — resolved once at pack build. */463465 rival: Rival | null;464466 /** Post-contact temper window remaining (seconds; 0 = cool). */@@ -576,6 +578,18 @@ class Session {
576578 speed: number;577579 idx: number;578580 }[] = [];581+ /** M25 world memory: shared scar geometry/material (never disposed per582+ * mark) + per-segment mark counts + furniture mid-tip animations. */583+ private scarGeo: THREE.SphereGeometry | null = null;584+ private scarMat: THREE.MeshStandardMaterial | null = null;585+ private scuffMat: THREE.MeshStandardMaterial | null = null;586+ private readonly scarCount = new Map<number, number>();587+ private readonly tipping: {588+ obj: THREE.Object3D;589+ axis: THREE.Vector3;590+ target: number;591+ t: number;592+ }[] = [];579593 /** Grudge-gated boost ignitions (M20b declustering telemetry). */580594 private rivalIgnitions = 0;581595 /** Rival HUD token flag (M20b, ?rival=1) — default off until review. */@@ -811,6 +825,7 @@ class Session {
811825 rival: null,812826 temperClock: 0,813827 blockBlend: 0,828+ lastScuffT: 0,814829 });815830 // Racer peers (M15): real bodies on a wrapped grid start —816831 // stations × 3 lanes, then stacked tiers above (a huge pack rains@@ -975,6 +990,7 @@ class Session {
975990 rival: null,976991 temperClock: 0,977992 blockBlend: 0,993+ lastScuffT: 0,978994 });979995 }980996 if (this.peerMesh?.instanceColor) {@@ -1017,6 +1033,8 @@ class Session {
10171033 rivalIgnitions: number;10181034 maxTrain: number;10191035 trains: number;1036+ /** World-memory marks currently standing (M25). */1037+ marks: number;10201038 racers: {10211039 idx: number;10221040 s: number;@@ -1063,6 +1081,7 @@ class Session {
10631081 rivalIgnitions: this.rivalIgnitions,10641082 maxTrain,10651083 trains,1084+ marks: [...this.scarCount.values()].reduce((a, b) => a + b, 0),10661085 racers: top.map((r) => ({10671086 idx: r.idx,10681087 s: Number(r.s.toFixed(1)),@@ -1487,6 +1506,7 @@ class Session {
14871506 this.altRails.delete(seg.index);14881507 this.braidSegs.delete(seg.index);14891508 this.braidContOf.delete(seg.index);1509+ this.scarCount.delete(seg.index);14901510 this.fanField.removeSegment(seg.index);14911511 // Sink away rather than pop out; the group leaves the scene when the14921512 // exit wave finishes. (Occupancy claims release much later — the@@ -2044,6 +2064,23 @@ class Session {
20442064 r.boost.boosting,20452065 r.draft,20462066 );2067+ // M25 ice scuffs: hard braking on an icy surface leaves marks —2068+ // rate-limited per racer, deterministic (sim state only).2069+ if (2070+ r.dbg.mode === "brake" &&2071+ rail.mods[r.nearest.idx]?.icy === true &&2072+ r.simT - r.lastScuffT > 0.52073+ ) {2074+ r.lastScuffT = r.simT;2075+ this.pendingScars.push({2076+ x: r.pos[0],2077+ y: r.pos[1],2078+ z: r.pos[2],2079+ speed: 0, // scuff, not knock2080+ idx: r.idx,2081+ });2082+ if (this.pendingScars.length > 64) this.pendingScars.shift();2083+ }20472084 const j = wantJump(20482085 rail,20492086 r.nearest.idx,@@ -2889,6 +2926,40 @@ class Session {
28892926 }28902927 }28912928 2929+ /** M25: a hard knock tips nearby trackside furniture — fans, banners2930+ * and bumper mushrooms fall over and STAY fallen until their segment2931+ * sinks out. The tip axis is horizontal, perpendicular to the shove2932+ * (away from the hit), eased in finishFrame. */2933+ private tipFurnitureNear(2934+ seg: BuiltSegment,2935+ x: number,2936+ y: number,2937+ z: number,2938+ ): void {2939+ const KNOCKABLE = /^(fan-|banner|mushroom_red$)/;2940+ for (const group of [seg.group, seg.postGroup]) {2941+ if (!group) continue;2942+ for (const obj of group.children) {2943+ if (!KNOCKABLE.test(obj.name) || obj.userData.tipped) continue;2944+ const p = obj.getWorldPosition(this.trailPoint);2945+ const dx = p.x - x;2946+ const dz = p.z - z;2947+ const d2 = dx * dx + (p.y - y) * (p.y - y) + dz * dz;2948+ if (d2 > 0.8 * 0.8) continue;2949+ obj.userData.tipped = true;2950+ // Axis ⟂ the horizontal shove direction: the piece falls AWAY.2951+ const hl = Math.hypot(dx, dz) || 1;2952+ this.tipping.push({2953+ obj,2954+ axis: new THREE.Vector3(dz / hl, 0, -dx / hl),2955+ target: -(Math.PI / 2) * 0.82,2956+ t: 0,2957+ });2958+ break;2959+ }2960+ }2961+ }2962+28922963 private finishFrame(dt: number): void {28932964 // Trail follows the CAMERA SUBJECT's ball center (the hero by28942965 // default; the leader when leader-cam is elected) — a trail on an@@ -2913,6 +2984,60 @@ class Session {
29132984 }29142985 }29152986 2987+ // M25 WORLD MEMORY: drain the step's marks. Every mark parents to2988+ // its racer's current segment group — it sinks out with the segment2989+ // (ExitAnim) and its GPU state is shared (geometry/material live for2990+ // the session). Generation never knows: this is presentation history.2991+ if (this.pendingScars.length > 0) {2992+ this.scarGeo ??= new THREE.SphereGeometry(1, 8, 6);2993+ this.scarMat ??= new THREE.MeshStandardMaterial({2994+ color: 0x2b2b31,2995+ roughness: 0.95,2996+ });2997+ this.scuffMat ??= new THREE.MeshStandardMaterial({2998+ color: 0xbfd4e6,2999+ roughness: 0.9,3000+ transparent: true,3001+ opacity: 0.55,3002+ });3003+ for (const scar of this.pendingScars) {3004+ const r = this.racers[scar.idx];3005+ const segIdx = r ? r.nearest.seg : this.currentIndex;3006+ const seg = this.built.get(segIdx);3007+ if (!seg) continue;3008+ const count = this.scarCount.get(segIdx) ?? 0;3009+ if (count >= 30) continue;3010+ const knock = scar.speed > 0;3011+ const mesh = new THREE.Mesh(3012+ this.scarGeo,3013+ knock ? this.scarMat : this.scuffMat,3014+ );3015+ const s = knock ? Math.min(0.16, 0.07 + scar.speed * 0.012) : 0.06;3016+ mesh.scale.set(s, s * 0.35, s);3017+ // Segment groups may carry a presentation frame — parent in the3018+ // group's LOCAL space.3019+ seg.group.worldToLocal(3020+ mesh.position.set(scar.x, scar.y, scar.z),3021+ );3022+ mesh.castShadow = false;3023+ mesh.userData.voidDecor = true; // skip the fit box3024+ seg.group.add(mesh);3025+ this.scarCount.set(segIdx, count + 1);3026+ // A hard knock near trackside furniture TIPS it (fans, banners,3027+ // bumper mushrooms) — the wreck stays until the segment sinks.3028+ if (knock) this.tipFurnitureNear(seg, scar.x, scar.y, scar.z);3029+ }3030+ this.pendingScars.length = 0;3031+ }3032+ // Furniture mid-tip animations ease to their fallen pose.3033+ for (let i = this.tipping.length - 1; i >= 0; i--) {3034+ const tip = this.tipping[i];3035+ tip.t = Math.min(1, tip.t + dt * 2.2);3036+ const k = 1 - (1 - tip.t) * (1 - tip.t); // ease-out3037+ tip.obj.setRotationFromAxisAngle(tip.axis, tip.target * k);3038+ if (tip.t >= 1) this.tipping.splice(i, 1);3039+ }3040+29163041 // Stream animations: assembly waves ahead, sink-out behind.29173042 for (const [idx, anim] of [...this.spawning]) {29183043 if (anim.update(dt)) this.spawning.delete(idx);