anvilsign in

collin/mahjong · cee1dfb3

Draw the colliders, since measuring them is what makes them hard to see

Collin Richards · 2026-08-21 18:10 UTC · cee1dfb3ca78a11d452aab69eeedc8810986f517 · parent c3d1289b · browse files

modifiedREADME.md+19 −0
⋯ 496 unchanged lines
497497 mapping is what makes the wall a physical object — the thing a thrown tile
498498 bounces off, and the thing the throw gate casts its ray at.
499499
500+### Seeing the colliders
501+
502+Because every boundary is measured off the DOM rather than written down, the
503+only honest way to check a bounce is to draw the boxes in the coordinate space
504+the tiles are drawn in. Add `?colliders` to the address, or call `colliders()`
505+from the console while a hand is running:
506+
507+| drawn | is |
508+| --- | --- |
509+| solid rectangle round the middle | the felt — what actually keeps a tile on the table, out to where each seat's tiles begin |
510+| solid box on a wall stack | a standing stack of two. Half stacks aren't drawn because they aren't colliders: a tile goes over them |
511+| dashed rectangle | the wall square's opening — where a throw is *aimed*. It stops nothing, which is why the pile spills out of it |
512+| dotted rectangle, inset | where the moving tile's own centre may be: the felt less that tile's half width. **This is the line a bounce happens at**, half a tile inside the solid one, which is what makes a bounce look early when it isn't |
513+| box hugging each tile | the rotated rectangle it really collides with |
514+| dotted box round the moving tile | the upright box the felt's straight edges use instead. It comes apart from the rotated one as the tile turns |
515+
516+`colliders(false)` turns it off again. It repaints on toggle, so a settled table
517+doesn't have to be poked first.
518+
500519 ## What is left of the wall
501520
502521 Four walls of eighteen full-size stacks want about 560px a side. No ordinary
⋯ 25 unchanged lines
modifiedsrc/table/physics.ts+21 −0
⋯ 824 unchanged lines
825825 * The renderer squashes the tile by this along its tumbling axis. */
826826 export const faceOn = (b: Body) => Math.cos(b.flip);
827827
828+/**
829+ * A tile's own bounding box, upright. Not what it collides with — that is the
830+ * rotated rectangle — but what the felt's straight edges are worked out from,
831+ * so the overlay draws both and the difference is visible.
832+ */
833+export function aabbOf(b: Body): Rect {
834+ const half = aabbHalf(b);
835+ return { x: b.x - half.x, y: b.y - half.y, w: half.x * 2, h: half.y * 2 };
836+}
837+
838+/**
839+ * Where this tile's *centre* is allowed to be, which is the edge less its own
840+ * half. This is the line a bounce actually happens at, and it sits half a tile
841+ * inside the one you can see — which is why a tile looks like it turned around
842+ * early. For the overlay; the real work is `limitsOf`.
843+ */
844+export function centreLimits(r: Rect, b: Body): Rect {
845+ const l = limitsOf(r, b);
846+ return { x: l.minX, y: l.minY, w: l.maxX - l.minX, h: l.maxY - l.minY };
847+}
848+
828849 /** How far outside the pool a tile is sitting. The tests' invariant. */
829850 export function outside(b: Body, pool: Rect): number {
830851 const half = aabbHalf(b);
⋯ 68 unchanged lines
modifiedsrc/ui/Pool.tsx+81 −1
⋯ 5 unchanged lines
66 import type { TableCtl } from '../game/ctl';
77 import { barge, knock } from '../game/sound';
88 import { SPRITE_COL, SPRITE_COLS } from '../game/tiles';
9-import { faceOn, zScale } from '../table/physics';
9+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 { onCollidersChange, showingColliders } from './colliders';
1213 import { EDGE_SEAT } from './rotation';
1314
1415 /**
⋯ 93 unchanged lines
108109
109110 const observer = new ResizeObserver(resize);
110111 observer.observe(table);
112+ const stopColliders = onCollidersChange(run);
111113 resize();
112114
113115 return () => {
116+ stopColliders();
114117 observer.disconnect();
115118 if (frame) cancelAnimationFrame(frame);
116119 wake.current = () => {};
⋯ 228 unchanged lines
345348 lit: true,
346349 });
347350 }
351+
352+ // Over the top of all of it, or there would be no point to it.
353+ if (showingColliders()) colliders(ctx, pool);
354+}
355+
356+/**
357+ * Every boundary the physics uses, drawn where it actually is.
358+ *
359+ * Solid the felt — the fence that keeps a tile on the table, measured
360+ * out to where each seat's tiles begin (table/geometry.ts).
361+ * Solid each standing stack of two. Half stacks are not drawn because
362+ * they are not colliders: a tile goes over them.
363+ * Dashed the wall square's opening. Only where a throw is *aimed*; it
364+ * stops nothing, which is why the pile spills out of it.
365+ * Dotted, in where the moving tile's own centre is allowed to be — the felt
366+ * less that tile's half width. This is the line a bounce happens
367+ * at, and it is half a tile inside the solid one, which is what
368+ * makes a bounce look early when it is not.
369+ * Per tile the rotated rectangle it really collides with, and, fainter, the
370+ * upright box the felt's straight edges use instead. The two come
371+ * apart as a tile turns, and that difference is worth seeing.
372+ */
373+function colliders(ctx: CanvasRenderingContext2D, pool: TablePool) {
374+ const world = pool.world;
375+ if (!world) return;
376+
377+ const line = (r: Rect) => ctx.strokeRect(r.x + 0.5, r.y + 0.5, r.w - 1, r.h - 1);
378+
379+ ctx.save();
380+ ctx.lineWidth = 1;
381+
382+ // The fence, and the stacks still standing.
383+ ctx.strokeStyle = '#ff2d20';
384+ line(world.bounds);
385+ for (const w of world.walls) line(w);
386+
387+ // Where a throw is aimed. Not a fence — see TableGeometry.pool.
388+ const aim = pool.poolRect;
389+ if (aim) {
390+ ctx.setLineDash([5, 5]);
391+ ctx.strokeStyle = 'rgba(255, 45, 32, 0.45)';
392+ line(aim);
393+ ctx.setLineDash([]);
394+ }
395+
396+ // Whatever is still moving: what it collides with, and what it is held by.
397+ const live = world.bodies.find((b) => !b.resting) ?? pool.newest;
398+
399+ for (const b of world.bodies) {
400+ ctx.strokeStyle = b === live ? 'rgba(255, 45, 32, 0.9)' : 'rgba(255, 45, 32, 0.35)';
401+ ctx.save();
402+ ctx.translate(b.x, b.y);
403+ ctx.rotate(b.angle);
404+ ctx.strokeRect(-b.w / 2, -b.h / 2, b.w, b.h);
405+ ctx.restore();
406+
407+ // The upright box the felt's edges are worked out from. Only for the one in
408+ // flight: sixty of them at once is a red mess, not a picture.
409+ if (b === live && b.angle % Math.PI !== 0) {
410+ ctx.setLineDash([2, 3]);
411+ ctx.strokeStyle = 'rgba(255, 45, 32, 0.5)';
412+ line(aabbOf(b));
413+ ctx.setLineDash([]);
414+ }
415+ }
416+
417+ if (live) {
418+ // The box that tile's centre is held inside, and where the centre is now.
419+ ctx.setLineDash([2, 3]);
420+ ctx.strokeStyle = 'rgba(255, 45, 32, 0.75)';
421+ line(centreLimits(world.bounds, live));
422+ ctx.setLineDash([]);
423+ ctx.fillStyle = '#ff2d20';
424+ ctx.fillRect(live.x - 1.5, live.y - 1.5, 3, 3);
425+ }
426+
427+ ctx.restore();
348428 }
349429
350430 /**
⋯ 204 unchanged lines
addedsrc/ui/colliders.ts+46 −0
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+
17+const FLAG = 'colliders';
18+
19+let on = typeof location !== 'undefined' && new URLSearchParams(location.search).has(FLAG);
20+
21+const listeners = new Set<() => void>();
22+
23+export const showingColliders = () => on;
24+
25+/** Toggle, or set. Returns what it ended up as, so the console says it back. */
26+export function toggleColliders(next = !on): boolean {
27+ on = next;
28+ for (const cb of listeners) cb();
29+ return on;
30+}
31+
32+/** Repaint when it changes — a settled table is not drawing frames otherwise. */
33+export function onCollidersChange(cb: () => void): () => void {
34+ listeners.add(cb);
35+ return () => {
36+ listeners.delete(cb);
37+ };
38+}
39+
40+declare global {
41+ interface Window {
42+ colliders?: (next?: boolean) => boolean;
43+ }
44+}
45+
46+if (typeof window !== 'undefined') window.colliders = toggleColliders;