anvilsign in

collin/mahjong

1/**
2 * The collider overlay: every boundary the physics actually uses, drawn in thin
3 * red over the table.
4 *
5 * Everything the tiles bounce off is *measured* off the DOM rather than written
6 * down anywhere — the felt is where the four strips leave off, the wall is
7 * seventy-odd real elements — so the only honest way to check a bounce is to
8 * draw the boxes in the same coordinate space the tiles are drawn in. A bounce
9 * that looks wrong is either a box in the wrong place or a box you had not
10 * thought about, and this tells you which.
11 *
12 * On with `?colliders` in the address bar, or `colliders()` from the console
13 * while a hand is running — which is the point of it, since the thing you want
14 * to look at is usually already skidding across the table.
15 */
16
17import { FIXED_DT, type Impact } from '../table/physics';
18
19const params = typeof location === 'undefined' ? null : new URLSearchParams(location.search);
20
21let 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 */
35let pauseOnHit = params?.get('pause') !== null && params?.get('pause') !== '0';
36let held = false;
37let pending = 0;
38
39const listeners = new Set<() => void>();
40
41export const showingColliders = () => on;
42
43/** Toggle, or set. Returns what it ended up as, so the console says it back. */
44export function toggleColliders(next = !on): boolean {
45 on = next;
46 if (!on) {
47 held = false;
48 pending = 0;
49 }
50 for (const cb of listeners) cb();
51 return on;
52}
53
54/** Repaint when it changes — a settled table is not drawing frames otherwise. */
55export function onCollidersChange(cb: () => void): () => void {
56 listeners.add(cb);
57 return () => {
58 listeners.delete(cb);
59 };
60}
61
62const 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 */
75const MARKS = 24;
76let marks: { x: number; y: number; kind: string; age: number }[] = [];
77
78export 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. */
92export 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
98export const pausingOnHit = () => on && pauseOnHit;
99export const isHeld = () => held;
100
101/** Arm or disarm the freeze. Turning it off lets go of whatever it is holding. */
102export function pause(next = !pauseOnHit): boolean {
103 pauseOnHit = next;
104 if (!next) resume();
105 return pauseOnHit;
106}
107
108/** Freeze here, and say what on. */
109export 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. */
123export function resume() {
124 held = false;
125 pending = 0;
126 wake();
127}
128
129/** Take `n` fixed steps and stop again. */
130export 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. */
137export 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 */
147export 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
156declare global {
157 interface Window {
158 colliders?: (next?: boolean) => boolean;
159 pause?: (next?: boolean) => boolean;
160 resume?: () => void;
161 stepOn?: (n?: number) => void;
162 /** The live pool, for poking at from the console. Dev builds only. */
163 pool?: unknown;
164 }
165}
166
167if (typeof window !== 'undefined') {
168 window.colliders = toggleColliders;
169 window.pause = pause;
170 window.resume = resume;
171 window.stepOn = stepOn;
172}
173
174/**
175 * Hand the console the pool itself, so `pool.world.walls` and `pool.geo` can be
176 * read against what is drawn. The overlay draws the world's own copy rather than
177 * a fresh measurement on purpose — if the two ever disagree it is the world that
178 * is stale, and that is a thing you want to be able to catch.
179 */
180export function exposePool(pool: unknown) {
181 if (import.meta.env.DEV && typeof window !== 'undefined') window.pool = pool;
182}