anvilsign in

collin/mahjong · 90552988

A throwing sandbox, a freeze on contact, and the bug both of them found

Collin Richards · 2026-08-21 18:47 UTC · 90552988c9f7d40ad86211d1460d6d5576b13d60 · parent 9da3ffb8 · browse files

modifiedREADME.md+15 −0
⋯ 519 unchanged lines
520520 | box hugging each pool tile | the rotated rectangle it really collides with |
521521 | dotted box round the moving tile | the upright box the straight edges use instead. It comes apart from the rotated one as the tile turns |
522522
523+| yellow cross | where contact was *actually* made, fading over a second or so. One of these sitting in open felt with no box under it is a bounce off something that isn't a collider at all — a different bug from a box in the wrong place |
524+
525+Colliders mode is also a **throwing sandbox**: a flick throws for real but is not a
526+discard, so the tile stays in your hand, the turn never passes and the computer
527+players hold still. Throw the same tile at the same corner as many times as you
528+like. Sixty stay on the table before the oldest is swept off; `pool.clearLoose()`
529+sweeps them all. Tapping a tile to discard does nothing in this mode — throwing
530+is the thing being tested.
531+
532+Add `pause=1` (`?colliders&pause=1`, or `pause()` from the console) to **stop
533+dead on contact**. The physics halts on the fixed step that resolved it and
534+drops the rest of the frame, so what's on screen is where the tile touched
535+rather than where it had got to by the end of the frame. Space carries on, →
536+takes one step, ↑ takes ten; the console says what it stopped on.
537+
523538 `colliders(false)` turns it off again. It repaints on toggle, so a settled table
524539 doesn't have to be poked first. In a dev build `pool` is the live pool: the
525540 overlay draws `pool.world.walls`, the physics' own copy rather than a fresh
⋯ 32 unchanged lines
modifiedsrc/App.tsx+7 −1
⋯ 10 unchanged lines
1111 import { NetSession } from './net/session';
1212 import { Bi, BiText } from './ui/Bi';
1313 import { Center } from './ui/Center';
14+import { showingColliders } from './ui/colliders';
1415 import { useCompact } from './ui/compact';
1516 import { Controller } from './ui/Controller';
1617 import { FullscreenToggle } from './ui/FullscreenToggle';
⋯ 215 unchanged lines
232233 // whichever device is the room's host runs its own AutoPlay.
233234 // The computer waits for a thrown tile to land — and for whoever it landed on
234235 // to finish saying so — before it reaches for it.
235- useEffect(() => new AutoPlay(game, () => pool.busy).start(), [game, pool]);
236+ // `showingColliders` holds the computer still as well: the throwing sandbox
237+ // is no use if the table plays on around it.
238+ useEffect(
239+ () => new AutoPlay(game, () => pool.busy || showingColliders()).start(),
240+ [game, pool],
241+ );
236242
237243 // ---- routing ------------------------------------------------------------
238244 // A phone at the party table holds a hand, not the table.
⋯ 232 unchanged lines
modifiedsrc/table/physics.ts+32 −3
⋯ 181 unchanged lines
182182 * longer an edge to read off its position.
183183 */
184184 over?: Edge;
185+ /**
186+ * Whether it has got clear of the hand it was thrown from. A tile leaves from
187+ * *inside* its own row — `throwFrom` is the middle of the last cell — so on
188+ * the frame it is let go it is overlapping its thrower's own tiles, and
189+ * without this every throw would ricochet off the hand that made it. Your own
190+ * tiles are not solid to you until you are out of them.
191+ */
192+ leftSeat: boolean;
185193 resting: boolean;
186194 /** Consecutive slow steps, counting towards sleep. */
187195 still: number;
⋯ 104 unchanged lines
292300 h: s.h,
293301 scale: s.atRest ? 1 : s.scale ?? 1,
294302 entered: !!s.atRest,
303+ // A rebuilt pile is already lying in the middle, not in anybody's hand.
304+ leftSeat: !!s.atRest,
295305 resting: !!s.atRest,
296306 still: s.atRest ? SLEEP_STEPS : 0,
297307 age: 0,
⋯ 131 unchanged lines
429439 * consumed in fixed steps so the result does not depend on the frame rate.
430440 * Returns the impacts worth a sound.
431441 */
432-export function advance(world: World, elapsed: number): Impact[] {
442+export function advance(world: World, elapsed: number, stopOnImpact = false): Impact[] {
433443 world.carry += Math.max(0, elapsed);
434444 let steps = Math.floor(world.carry / FIXED_DT);
435445 if (steps > MAX_SUBSTEPS) {
⋯ 5 unchanged lines
441451 }
442452
443453 const impacts: Impact[] = [];
444- for (let i = 0; i < steps; i++) step(world, impacts);
454+ for (let i = 0; i < steps; i++) {
455+ step(world, impacts);
456+ // Freeze on the step that resolved the contact, and drop what is left of
457+ // the frame — the debugger wants the tile where it touched, not where it
458+ // would have been fifteen milliseconds later. See ui/colliders.ts.
459+ if (stopOnImpact && impacts.length) {
460+ world.carry = 0;
461+ break;
462+ }
463+ }
445464 return impacts;
446465 }
447466
⋯ 86 unchanged lines
534553
535554 // A tile still above the wall is clear of it.
536555 if (b.z < WALL_HEIGHT) {
537- for (const wall of world.walls) hitWall(b, wall, impacts);
556+ let inOwnHand = false;
557+ for (const wall of world.walls) {
558+ if (wall.seat === b.seat && !b.leftSeat) {
559+ // Still inside the row it was thrown from: note it, but pass through.
560+ const over = overlapOf(boxOf(wall.rect), b);
561+ if (over && over.depth > CONTACT_SLOP) inOwnHand = true;
562+ continue;
563+ }
564+ hitWall(b, wall, impacts);
565+ }
566+ if (!inOwnHand) b.leftSeat = true;
538567 }
539568
540569 // The pool's edge is the inside face of the wall, and it is only as tall as
⋯ 402 unchanged lines
modifiedsrc/table/pool.ts+149 −82
⋯ 87 unchanged lines
8888 */
8989 const LOB_WHEN_WALLED: boolean = true;
9090
91+/** How many sandbox throws stay on the table before the oldest is swept off.
92+ * A wall's worth: enough to test a crowded pool, not enough to bury it. */
93+const LOOSE_LIMIT = 60;
94+
9195 /** A tile lifted out of a hand and carried about. Drawn, but not simulated. */
9296 export interface Held {
9397 seat: SeatId;
⋯ 48 unchanged lines
142146 private melds = [0, 0, 0, 0];
143147 /** Tiles on their way off the table. Drawn, but out of the physics. */
144148 private leaving: Leaving[] = [];
149+ /**
150+ * Sandbox throws: real tiles in the real world, but belonging to no discard,
151+ * so the reconciler never looks at them — nothing in `entries` means nothing
152+ * to cull. See `throwLoose`.
153+ */
154+ private loose: Body[] = [];
145155 /** When the table will have stopped talking. See `heckle`. */
146156 private quietAt = 0;
147157
⋯ 11 unchanged lines
159169 this.table = null;
160170 this.world = null;
161171 this.geo = null;
172+ this.loose = [];
162173 this.held = null;
163174 this.release = null;
164175 this.entries.clear();
⋯ 101 unchanged lines
266277 }
267278
268279 /**
280+ * A throw that is not a discard.
281+ *
282+ * The tile stays in the hand, the game does not move, and the body goes
283+ * straight into the world rather than waiting for a discard to arrive and
284+ * claim it. For throwing the same tile a hundred times with the colliders
285+ * drawn, which is the only way to tell a bounce that is wrong from a bounce
286+ * that only looks wrong.
287+ */
288+ throwLoose(vx: number, vy: number, spin: number) {
289+ const h = this.held;
290+ const geo = this.geo;
291+ const world = this.world;
292+ if (!h || !geo || !world) return;
293+ const speed = Math.hypot(vx, vy);
294+ const scale = speed > MAX_THROW_SPEED ? MAX_THROW_SPEED / speed : 1;
295+ this.loose.push(
296+ this.fling(geo, h.tile, {
297+ seat: h.seat,
298+ x: h.x,
299+ y: h.y,
300+ angle: h.angle,
301+ w: h.w,
302+ h: h.h,
303+ vx: vx * scale,
304+ vy: vy * scale,
305+ spin,
306+ }),
307+ );
308+ // Oldest off the table first, or an afternoon of this buries the felt and
309+ // every throw lands on a pile instead of on the thing being tested.
310+ while (this.loose.length > LOOSE_LIMIT) this.drop(this.loose.shift()!);
311+ this.held = null;
312+ }
313+
314+ /** Sweep the sandbox's tiles away and start again. */
315+ clearLoose() {
316+ for (const b of this.loose) this.drop(b);
317+ this.loose = [];
318+ }
319+
320+ private drop(body: Body) {
321+ const at = this.world?.bodies.indexOf(body) ?? -1;
322+ if (at >= 0) this.world!.bodies.splice(at, 1);
323+ }
324+
325+ /**
269326 * A throw that happened on somebody's phone, arriving over the wire in their
270327 * own frame: `u` along their edge, `vy` into the table. It is stood at their
271328 * side of this screen and turned into the table's coordinates, so the tile
⋯ 124 unchanged lines
396453 }
397454
398455 /** Run the physics on. Returns impacts worth a sound. */
399- advance(seconds: number): Impact[] {
456+ advance(seconds: number, stopOnImpact = false): Impact[] {
400457 this.carryOff(seconds);
401- return this.world ? advanceWorld(this.world, seconds) : [];
458+ return this.world ? advanceWorld(this.world, seconds, stopOnImpact) : [];
402459 }
403460
404461 /** Tiles leaving the table, lifted and carried off to the seat taking them. */
⋯ 133 unchanged lines
538595 });
539596 }
540597
598+ /**
599+ * A tile let go of by a hand: what a flick actually becomes.
600+ *
601+ * Its own method because a throw is not always a discard — the sandbox
602+ * behind ?colliders throws the same tile over and over without the game
603+ * moving at all, and it has to be the *same* throw or there would be no
604+ * point testing with it.
605+ */
606+ private fling(geo: TableGeometry, tile: Tile, thrown: Release): Body {
607+ // Which of the two ways a tile goes in, decided by what is in its way.
608+ //
609+ // A clear line to the square means it can be *slid* — flat across the
610+ // cloth, at exactly the speed it was let go at, which is what a flick
611+ // wants to be. A standing wall means it has to be *thrown* instead: lifted
612+ // over the top and dropped in, and then the flick's speed decides how far
613+ // across the pool it lands rather than how fast it skates.
614+ // Sliding is the preferred way in, and the question is asked from where
615+ // the tile actually is — a player who carried it out over the square has a
616+ // clear line even though the seat they came from does not. Only a tile
617+ // with nothing but standing wall in front of it has to be lobbed.
618+ const set = this.inPool(thrown.x, thrown.y);
619+ const slide = set || !LOB_WHEN_WALLED || this.canThrowFrom(thrown.x, thrown.y);
620+ let { vx, vy } = thrown;
621+ let vz = 0;
622+ const speed = Math.hypot(vx, vy);
623+ let flipTurns = 0;
624+ let flipOver = 0;
625+
626+ if (!slide) {
627+ // Lofted over a standing wall, and it has to come *down* inside the
628+ // square: a lob solved for where it would come to rest touches down
629+ // short, on the wrong side of the wall, and stops dead against it.
630+ //
631+ // So the flick picks a landing spot along its own line, between just
632+ // inside the near edge of the square and just short of the far one. A
633+ // limp one drops in over the near wall, a hard one carries to the far
634+ // side and barges through whatever is lying there, and neither of them
635+ // can leave the tile outside.
636+ const dir = speed > 0 ? { x: vx / speed, y: vy / speed } : { x: 0, y: -1 };
637+ const span = spanThrough(geo.pool, thrown, dir);
638+ const centre = centreOf(geo.pool);
639+ let carry = speed * PLACE_FLIGHT;
640+ if (span) {
641+ const near = span.near + geo.tile.h;
642+ carry = Math.min(Math.max(near, carry), Math.max(near, span.far - geo.tile.h));
643+ } else {
644+ // Flicked somewhere that is not the square at all — put it in anyway.
645+ carry = Math.hypot(centre.x - thrown.x, centre.y - thrown.y);
646+ dir.x = (centre.x - thrown.x) / (carry || 1);
647+ dir.y = (centre.y - thrown.y) / (carry || 1);
648+ }
649+ vx = (dir.x * carry) / PLACE_FLIGHT;
650+ vy = (dir.y * carry) / PLACE_FLIGHT;
651+ vz = liftFor(PLACE_FLIGHT);
652+ // Long enough in the air to turn over properly, and harder means more.
653+ flipOver = PLACE_FLIGHT;
654+ flipTurns = speed > 1300 ? 2 : 1;
655+ } else if (!set) {
656+ // A skated tile leaves the fingers off the cloth, and turns over on its
657+ // way down — harder means longer in the air and more turns in it.
658+ const hop = THROW_LIFT + Math.min(0.22, (speed / MAX_THROW_SPEED) * 0.28);
659+ vz = liftFor(hop);
660+ flipOver = hop;
661+ flipTurns = speed > 2200 ? 2 : speed > 600 ? 1 : 0;
662+ }
663+ if (!TUMBLE) flipTurns = 0;
664+
665+ return addBody(this.world!, {
666+ tile: tile,
667+ seat: thrown.seat,
668+ x: thrown.x,
669+ y: thrown.y,
670+ vx,
671+ vy,
672+ z: 0,
673+ vz,
674+ angle: thrown.angle,
675+ spin: thrown.spin,
676+ flipTurns,
677+ flipOver,
678+ // It tumbles across its line of travel, the way a thrown tile does.
679+ flipAxis: Math.atan2(vy, vx),
680+ w: geo.tile.w,
681+ h: geo.tile.h,
682+ // It was drawn at hand size a moment ago; it shrinks on the way down.
683+ scale: thrown.w / geo.tile.w,
684+ });
685+ }
686+
541687 /** A tile arriving now: thrown if somebody threw it, placed over the wall if
542688 * it was tapped, and flat and quick if a bot's wall happens to be open. */
543689 private launch(
⋯ 4 unchanged lines
548694 const rng = this.seeded(state, a);
549695 const thrown = this.release?.seat === a.seat ? this.release : null;
550696
551- if (thrown) {
552- // Which of the two ways a tile goes in, decided by what is in its way.
553- //
554- // A clear line to the square means it can be *slid* — flat across the
555- // cloth, at exactly the speed it was let go at, which is what a flick
556- // wants to be. A standing wall means it has to be *thrown* instead: lifted
557- // over the top and dropped in, and then the flick's speed decides how far
558- // across the pool it lands rather than how fast it skates.
559- // Sliding is the preferred way in, and the question is asked from where
560- // the tile actually is — a player who carried it out over the square has a
561- // clear line even though the seat they came from does not. Only a tile
562- // with nothing but standing wall in front of it has to be lobbed.
563- const set = this.inPool(thrown.x, thrown.y);
564- const slide = set || !LOB_WHEN_WALLED || this.canThrowFrom(thrown.x, thrown.y);
565- let { vx, vy } = thrown;
566- let vz = 0;
567- const speed = Math.hypot(vx, vy);
568- let flipTurns = 0;
569- let flipOver = 0;
570-
571- if (!slide) {
572- // Lofted over a standing wall, and it has to come *down* inside the
573- // square: a lob solved for where it would come to rest touches down
574- // short, on the wrong side of the wall, and stops dead against it.
575- //
576- // So the flick picks a landing spot along its own line, between just
577- // inside the near edge of the square and just short of the far one. A
578- // limp one drops in over the near wall, a hard one carries to the far
579- // side and barges through whatever is lying there, and neither of them
580- // can leave the tile outside.
581- const dir = speed > 0 ? { x: vx / speed, y: vy / speed } : { x: 0, y: -1 };
582- const span = spanThrough(geo.pool, thrown, dir);
583- const centre = centreOf(geo.pool);
584- let carry = speed * PLACE_FLIGHT;
585- if (span) {
586- const near = span.near + geo.tile.h;
587- carry = Math.min(Math.max(near, carry), Math.max(near, span.far - geo.tile.h));
588- } else {
589- // Flicked somewhere that is not the square at all — put it in anyway.
590- carry = Math.hypot(centre.x - thrown.x, centre.y - thrown.y);
591- dir.x = (centre.x - thrown.x) / (carry || 1);
592- dir.y = (centre.y - thrown.y) / (carry || 1);
593- }
594- vx = (dir.x * carry) / PLACE_FLIGHT;
595- vy = (dir.y * carry) / PLACE_FLIGHT;
596- vz = liftFor(PLACE_FLIGHT);
597- // Long enough in the air to turn over properly, and harder means more.
598- flipOver = PLACE_FLIGHT;
599- flipTurns = speed > 1300 ? 2 : 1;
600- } else if (!set) {
601- // A skated tile leaves the fingers off the cloth, and turns over on its
602- // way down — harder means longer in the air and more turns in it.
603- const hop = THROW_LIFT + Math.min(0.22, (speed / MAX_THROW_SPEED) * 0.28);
604- vz = liftFor(hop);
605- flipOver = hop;
606- flipTurns = speed > 2200 ? 2 : speed > 600 ? 1 : 0;
607- }
608- if (!TUMBLE) flipTurns = 0;
609-
610- return addBody(this.world!, {
611- tile: a.tile,
612- seat: a.seat,
613- x: thrown.x,
614- y: thrown.y,
615- vx,
616- vy,
617- z: 0,
618- vz,
619- angle: thrown.angle,
620- spin: thrown.spin,
621- flipTurns,
622- flipOver,
623- // It tumbles across its line of travel, the way a thrown tile does.
624- flipAxis: Math.atan2(vy, vx),
625- w: geo.tile.w,
626- h: geo.tile.h,
627- // It was drawn at hand size a moment ago; it shrinks on the way down.
628- scale: thrown.w / geo.tile.w,
629- });
630- }
697+ if (thrown) return this.fling(geo, a.tile, thrown);
631698
632699 // Off the end of the hand, not the middle of it — a tile is thrown from the
633700 // hand holding it, which is the one at the right-hand end of the row.
⋯ 48 unchanged lines
modifiedsrc/ui/Pool.tsx+43 −4
⋯ 8 unchanged lines
99 import { aabbOf, centreLimits, faceOn, zScale, type Rect } from '../table/physics';
1010 import type { TablePool } from '../table/pool';
1111 import type { SeatId } from '../game/types';
12-import { exposePool, onCollidersChange, showingColliders } from './colliders';
12+import {
13+ exposePool,
14+ holdOn,
15+ isHeld,
16+ keyHeld,
17+ noteImpacts,
18+ onCollidersChange,
19+ owedTime,
20+ pausingOnHit,
21+ recentImpacts,
22+ showingColliders,
23+} from './colliders';
1324 import { EDGE_SEAT } from './rotation';
1425
1526 /**
⋯ 71 unchanged lines
8798 table.classList.toggle('carrying', !!pool.held);
8899 // The loudest contact this frame is the one you would actually hear; the
89100 // rest of a scatter is the same event.
90- const impacts = pool.advance(dt);
101+ // Frozen on a contact: the world only moves when it is asked to, a fixed
102+ // step at a time, so the moment can be walked through rather than watched.
103+ const impacts = isHeld() ? pool.advance(owedTime()) : pool.advance(dt, pausingOnHit());
104+ noteImpacts(impacts);
105+ if (pausingOnHit() && !isHeld() && impacts.length) holdOn(impacts);
91106 let loudest = 0;
92107 for (const i of impacts) {
93108 // A contact quiet enough not to be worth hearing still counts as a tile
⋯ 10 unchanged lines
104119 if (loudest > 0) knock(loudest);
105120 paint(ctx, canvas, pool, live.current.art);
106121
107- // Keep going while anything is moving; otherwise wait to be poked.
108- if (!pool.settled) frame = requestAnimationFrame(tick);
122+ // Keep going while anything is moving; otherwise wait to be poked. A held
123+ // world is never running: it is stepped by hand and repainted each time.
124+ if (!isHeld() && !pool.settled) frame = requestAnimationFrame(tick);
109125 else previous = 0;
110126 };
111127
⋯ 6 unchanged lines
118134 const observer = new ResizeObserver(resize);
119135 observer.observe(table);
120136 const stopColliders = onCollidersChange(run);
137+ const onKey = (e: KeyboardEvent) => {
138+ if (keyHeld(e)) e.preventDefault();
139+ };
140+ window.addEventListener('keydown', onKey);
121141 resize();
122142
123143 return () => {
144+ window.removeEventListener('keydown', onKey);
124145 stopColliders();
125146 observer.disconnect();
126147 if (frame) cancelAnimationFrame(frame);
⋯ 305 unchanged lines
432453 ctx.fillRect(live.x - 1.5, live.y - 1.5, 3, 3);
433454 }
434455
456+ // Where contact was actually made, fading out. One of these with no box under
457+ // it is the interesting case: a bounce off something that is not drawn is a
458+ // bounce off something that is not a collider.
459+ ctx.font = '9px ui-monospace, monospace';
460+ ctx.textBaseline = 'bottom';
461+ for (const m of recentImpacts(!isHeld())) {
462+ const fade = 1 - m.age / 90;
463+ ctx.strokeStyle = `rgba(255, 214, 10, ${fade})`;
464+ ctx.fillStyle = `rgba(255, 214, 10, ${fade})`;
465+ ctx.beginPath();
466+ ctx.moveTo(m.x - 5, m.y);
467+ ctx.lineTo(m.x + 5, m.y);
468+ ctx.moveTo(m.x, m.y - 5);
469+ ctx.lineTo(m.x, m.y + 5);
470+ ctx.stroke();
471+ if (m.kind) ctx.fillText(m.kind, m.x + 6, m.y - 2);
472+ }
473+
435474 ctx.restore();
436475 }
437476
⋯ 205 unchanged lines
modifiedsrc/ui/Seat.tsx+14 −0
⋯ 3 unchanged lines
44 import type { SeatId } from '../game/types';
55 import type { TablePool } from '../table/pool';
66 import { Bi, BiEm, CLAIM_LABEL } from './Bi';
7+import { showingColliders } from './colliders';
78 import { useT } from './lang';
89 import { Hand } from './Hand';
910 import { SEAT_ROT, SEAT_SLOT } from './rotation';
⋯ 89 unchanged lines
99100 }, [p.discards.length]);
100101
101102 const doDiscard = (i: number) => {
103+ // Tapping plays the tile for real, which would end the sandbox's turn and
104+ // set the computer going. Throwing is the thing being tested; tapping just
105+ // puts the tile down again.
106+ if (showingColliders()) return setSel(null);
102107 game.discard(seat, p.hand[i]);
103108 setSel(null);
104109 };
⋯ 33 unchanged lines
138143 if (tile === undefined) return pool.cancelHeld();
139144 // Where it left the fingers is where the throw starts from.
140145 pool.moveHeld(t.x, t.y);
146+ // With the colliders drawn the table is a sandbox: the throw is the
147+ // real throw, but it is not a discard. The tile stays in the hand
148+ // and the game does not move, so the same flick can be made again
149+ // and again against the boxes on screen.
150+ if (showingColliders()) {
151+ pool.throwLoose(t.vx, t.vy, t.spin);
152+ setSel(null);
153+ return;
154+ }
141155 pool.throwHeld(t.vx, t.vy, t.spin);
142156 game.discard(seat, tile);
143157 setSel(null);
⋯ 200 unchanged lines
modifiedsrc/ui/colliders.ts+127 −3
⋯ 13 unchanged lines
1414 * to look at is usually already skidding across the table.
1515 */
1616
17-const FLAG = 'colliders';
17+import { FIXED_DT, type Impact } from '../table/physics';
1818
19-let on = typeof location !== 'undefined' && new URLSearchParams(location.search).has(FLAG);
19+const params = typeof location === 'undefined' ? null : new URLSearchParams(location.search);
2020
21+let on = !!params?.has('colliders');
22+
23+/**
24+ * Stop dead on contact. `?colliders&pause=1`, or `pause()` from the console.
25+ *
26+ * A bounce is four or five milliseconds long and the interesting part is the
27+ * one frame where the two rectangles are touching, which is exactly the frame
28+ * you cannot see. This holds the world there — the physics stops on the fixed
29+ * step that resolved the contact and drops the rest of the frame, so what is on
30+ * screen is where the tile actually touched rather than where it had got to by
31+ * the time the frame ended.
32+ *
33+ * Space carries on, → takes one step, ↑ takes ten.
34+ */
35+let pauseOnHit = params?.get('pause') !== null && params?.get('pause') !== '0';
36+let held = false;
37+let pending = 0;
38+
2139 const listeners = new Set<() => void>();
2240
2341 export const showingColliders = () => on;
⋯ 1 unchanged line
2543 /** Toggle, or set. Returns what it ended up as, so the console says it back. */
2644 export function toggleColliders(next = !on): boolean {
2745 on = next;
46+ if (!on) {
47+ held = false;
48+ pending = 0;
49+ }
2850 for (const cb of listeners) cb();
2951 return on;
3052 }
⋯ 6 unchanged lines
3759 };
3860 }
3961
62+const wake = () => {
63+ for (const cb of listeners) cb();
64+};
65+
66+/**
67+ * Where contacts have just happened.
68+ *
69+ * The overlay draws the boxes the physics holds, so anything it collides with
70+ * is on screen by construction — which makes the useful question the other one:
71+ * *did* it collide, and where? A cross sitting in open felt with no box under it
72+ * is a bounce off something that is not a collider at all, and that is a
73+ * different bug from a box in the wrong place.
74+ */
75+const MARKS = 24;
76+let marks: { x: number; y: number; kind: string; age: number }[] = [];
77+
78+export function noteImpacts(impacts: Impact[]) {
79+ if (!on || !impacts.length) return;
80+ for (const i of impacts) {
81+ marks.push({
82+ x: i.x,
83+ y: i.y,
84+ kind: i.into !== undefined ? `seat ${i.into}` : i.edge ? i.edge : '',
85+ age: 0,
86+ });
87+ }
88+ if (marks.length > MARKS) marks = marks.slice(-MARKS);
89+}
90+
91+/** Ages them a frame and hands back what is still worth drawing. */
92+export function recentImpacts(step: boolean) {
93+ if (step) for (const m of marks) m.age++;
94+ marks = marks.filter((m) => m.age < 90);
95+ return marks;
96+}
97+
98+export const pausingOnHit = () => on && pauseOnHit;
99+export const isHeld = () => held;
100+
101+/** Arm or disarm the freeze. Turning it off lets go of whatever it is holding. */
102+export function pause(next = !pauseOnHit): boolean {
103+ pauseOnHit = next;
104+ if (!next) resume();
105+ return pauseOnHit;
106+}
107+
108+/** Freeze here, and say what on. */
109+export function holdOn(impacts: Impact[]) {
110+ held = true;
111+ const what = impacts
112+ .map((i) => {
113+ const where = `(${Math.round(i.x)}, ${Math.round(i.y)})`;
114+ if (i.into !== undefined) return `seat ${i.into}'s tiles at ${where}`;
115+ if (i.edge) return `the ${i.edge} edge at ${where}`;
116+ return `something at ${where}`;
117+ })
118+ .join(', ');
119+ console.log(`[colliders] held on ${what} — space to carry on, → one step, ↑ ten`);
120+}
121+
122+/** Let it run again. */
123+export function resume() {
124+ held = false;
125+ pending = 0;
126+ wake();
127+}
128+
129+/** Take `n` fixed steps and stop again. */
130+export function stepOn(n = 1) {
131+ if (!held) return;
132+ pending += n;
133+ wake();
134+}
135+
136+/** How much the frozen loop owes, in seconds, taken as it is asked for. */
137+export function owedTime(): number {
138+ const n = pending;
139+ pending = 0;
140+ return n * FIXED_DT;
141+}
142+
143+/**
144+ * Space to carry on, → for one step, ↑ for ten. Only while the overlay is up,
145+ * so this cannot get in the way of an actual game.
146+ */
147+export function keyHeld(e: KeyboardEvent): boolean {
148+ if (!on || !held) return false;
149+ if (e.key === ' ') resume();
150+ else if (e.key === 'ArrowRight') stepOn(1);
151+ else if (e.key === 'ArrowUp') stepOn(10);
152+ else return false;
153+ return true;
154+}
155+
40156 declare global {
41157 interface Window {
42158 colliders?: (next?: boolean) => boolean;
159+ pause?: (next?: boolean) => boolean;
160+ resume?: () => void;
161+ stepOn?: (n?: number) => void;
43162 /** The live pool, for poking at from the console. Dev builds only. */
44163 pool?: unknown;
45164 }
46165 }
47166
48-if (typeof window !== 'undefined') window.colliders = toggleColliders;
167+if (typeof window !== 'undefined') {
168+ window.colliders = toggleColliders;
169+ window.pause = pause;
170+ window.resume = resume;
171+ window.stepOn = stepOn;
172+}
49173
50174 /**
51175 * Hand the console the pool itself, so `pool.world.walls` and `pool.geo` can be
⋯ 7 unchanged lines