anvilsign in

collin/mahjong · 9da3ffb8

Nothing stops a tile but another tile

Collin Richards · 2026-08-21 18:25 UTC · 9da3ffb8bf522a0292981624d2b82717a3dcfaf3 · parent cee1dfb3 · browse files

modifiedREADME.md+17 −6
⋯ 483 unchanged lines
484484 had rather than a freshly scattered one — positions are derived from the hand
485485 number and each tile's place in its thrower's discards, never saved.
486486
487+What stops a tile is other tiles. There is no fence: the standing stacks of the
488+wall, the sets people have laid down and the sixteen each of them is holding are
489+all measured as colliders, so the pile is held in by the ring of tiles round it
490+exactly as it is on a real table, and spills through whatever gaps the wall has
491+been eaten into. The table's own edge is a backstop at the screen edge, there
492+only so a tile that got past all of that isn't lost.
493+
487494 It is drawn on a canvas that spans the **whole table**, not just the centre.
488495 That is deliberate: `.slot` and `.seat` both clip their own contents, which is
489496 why a tile can't be animated out of a strip as an element. On the canvas there is
⋯ 16 unchanged lines
506513
507514 | drawn | is |
508515 | --- | --- |
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 |
516+| solid rectangle at the screen edge | the table itself. Nothing meets this in play — it is only there so a tile that got past everybody's tiles is still on the table afterwards |
517+| solid box on every tile | a collider. Every tile on the table is one: the stacks still standing, the sets people have laid down, and the sixteen each of them is holding. Half stacks aren't drawn because they aren't colliders — a tile goes over them |
511518 | 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 |
519+| dotted rectangle, inset | where the moving tile's own centre may be against the table's edge, which is its half width in from it |
520+| box hugging each pool tile | the rotated rectangle it really collides with |
521+| 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 |
515522
516523 `colliders(false)` turns it off again. It repaints on toggle, so a settled table
517-doesn't have to be poked first.
524+doesn't have to be poked first. In a dev build `pool` is the live pool: the
525+overlay draws `pool.world.walls`, the physics' own copy rather than a fresh
526+measurement, so if what's drawn ever disagrees with the DOM it is the world that
527+went stale — which `pool.measuredAt` will show, since the stamp carries every
528+hand's shape.
518529
519530 ## What is left of the wall
520531
⋯ 26 unchanged lines
modifiedsrc/styles.css+6 −0
⋯ 1186 unchanged lines
11871187 /* ---------- shared table controls ---------- */
11881188 /* Bottom-right of the centre square, opposite the log. Unrotated on purpose:
11891189 it belongs to the table, not to a seat. */
1190+/* Out of the way of a throw. The undo button is a wide target lying on the
1191+ playing surface at the bottom-right of the middle, which is exactly where a
1192+ tile leaves seat 0's hand — see the `carrying` class in ui/Pool.tsx. */
1193+.table.carrying .table-controls {
1194+ pointer-events: none;
1195+}
11901196 .table-controls {
11911197 position: absolute;
11921198 /* Over the pile, same as .result. */
⋯ 1104 unchanged lines
modifiedsrc/table/geometry.ts+39 −93
11 import { isBarrier, wallStacks, type WallProgress } from '../game/wall';
22 import type { SeatId } from '../game/types';
3-import type { Rect } from './physics';
3+import type { Barrier, Rect } from './physics';
44
55 /**
66 * Where everything on the table actually is, in pixels.
⋯ 19 unchanged lines
2626 * is laid out. Not a fence — see `felt`. */
2727 pool: Rect;
2828 /**
29- * The whole middle of the table, between the four strips. This is what
30- * actually stops a tile, because the square is not a box: it is eighteen
31- * stacks a side, and once they have been drawn there is nothing there. A tile
32- * is held in by the stacks still standing, and by the edge of the felt beyond
33- * them — so as the wall is eaten away the pool spills out through the gaps,
34- * exactly as it does on a table.
29+ * The table itself, and nothing nearer. Not a fence anybody meets in play: it
30+ * is the backstop that keeps a tile from being lost off the screen entirely,
31+ * and it sits at the edge of the glass where nothing can reach it.
32+ *
33+ * What actually stops a tile is `barriers` — the tiles. The middle is not a
34+ * box, it is a square of eighteen stacks a side with people's hands beyond,
35+ * and as the wall is eaten away the pool spills out through the gaps exactly
36+ * as it does on a real table, until it fetches up against somebody's tiles.
3537 */
3638 felt: Rect;
37- /** Stacks of two still standing. Half stacks are low enough to throw over. */
39+ /**
40+ * Stacks of two still standing. Half stacks are low enough to throw over.
41+ * The throw gate's business — what the physics collides with is `barriers`,
42+ * which includes these.
43+ */
3844 walls: Rect[];
45+ /**
46+ * Everything solid, which is to say every tile on the table: the standing
47+ * stacks, the sets people have laid down, and the sixteen each of them is
48+ * holding. Measured off the elements themselves, like the wall — a tile you
49+ * can see is a tile a thrown one hits.
50+ */
51+ barriers: Barrier[];
3952 /** A tile in the pool is the same tile that was in the wall. */
4053 tile: { w: number; h: number };
4154 /** The middle of each seat's hand, if that seat's hand is on screen. */
⋯ 27 unchanged lines
6982
7083 const stacks = wallStacks(state);
7184 const walls: Rect[] = [];
85+ const barriers: Barrier[] = [];
7286 // A stack element is --ws by --wd (or the other way round on the left and
7387 // right sides), so its narrower side is the tile's width whichever wall it is
7488 // in — the fallback for sizing a pool tile before any hand has been dealt.
⋯ 5 unchanged lines
8094 const r = local(el.getBoundingClientRect());
8195 short = Math.max(short, Math.min(r.w, r.h));
8296 const stack = stacks[i];
83- if (stack && isBarrier(stack)) walls.push(r);
97+ if (stack && isBarrier(stack)) {
98+ walls.push(r);
99+ barriers.push({ rect: r });
100+ }
84101 }
85102 if (short === 0) return null;
86103
⋯ 28 unchanged lines
115132 throwFrom[seat] = er
116133 ? { x: er.left + er.width / 2 - base.left, y: er.top + er.height / 2 - base.top }
117134 : launch[seat];
135+
136+ // Every tile this seat has on the table, one collider each. A row of them
137+ // is adjacent, so a hand comes out as a solid wall without being described
138+ // as one anywhere — the same trick the wall square is measured with.
139+ const slot = table.querySelector<HTMLElement>(`[data-seat="${seat}"]`);
140+ for (const el of slot?.querySelectorAll<HTMLElement>('.hand .tile, .melds .tile') ?? []) {
141+ const t = local(el.getBoundingClientRect());
142+ if (t.w > 0 && t.h > 0) barriers.push({ rect: t, seat });
143+ }
118144 }
119145
120- // The felt is the centre element's own box — the area the four strips leave —
121- // grown into whatever of those strips nobody is using yet.
122- const centre = table.querySelector<HTMLElement>('.center');
123- const box = centre ? local(centre.getBoundingClientRect()) : ringRect;
124- const felt = reaching(box, stripEdges(table, base, local));
146+ // The glass, not the green. Nothing meets this in play — it is only there so
147+ // a tile that got past everybody's tiles is still on the table afterwards.
148+ const felt: Rect = { x: 0, y: 0, w: base.width, h: base.height };
125149
126150 return {
127151 size: { w: base.width, h: base.height },
128152 pool,
129153 felt,
130154 walls,
155+ barriers,
131156 tile: poolTile(table, short),
132157 launch,
133158 throwFrom,
⋯ 20 unchanged lines
154179 const r = el?.getBoundingClientRect();
155180 const w = r && r.width > 0 ? Math.min(r.width, r.height) : fallback;
156181 return { w, h: w * 1.375 };
157-}
158-
159-/**
160- * The inside edge of each strip's contents, where any of them has been read.
161- * Missing sides are ones there was nothing to measure.
162- */
163-export interface StripEdges {
164- left?: number;
165- right?: number;
166- top?: number;
167- bottom?: number;
168-}
169-
170-/**
171- * The felt, out to where each strip's tiles and buttons actually begin.
172- *
173- * `.center` is only what is left over once every strip has been given its full
174- * `--depth`, and a strip is built from its outer edge inwards: the melds sit at
175- * the inside end of it, and until somebody 吃s or 碰s there is nothing there. So
176- * between the middle and the first thing you can see of a seat there is a band
177- * of plain green — most of a hand, for most seats — and a tile that stopped at
178- * `.center`'s edge stopped in the middle of it, against nothing. That band is
179- * table, and this is what hands it back.
180- *
181- * Only ever outwards. The middle is never smaller than the box the layout left.
182- */
183-export function reaching(centre: Rect, edges: StripEdges): Rect {
184- const right = centre.x + centre.w;
185- const bottom = centre.y + centre.h;
186- const x = Math.min(centre.x, edges.left ?? centre.x);
187- const y = Math.min(centre.y, edges.top ?? centre.y);
188- return {
189- x,
190- y,
191- w: Math.max(right, edges.right ?? right) - x,
192- h: Math.max(bottom, edges.bottom ?? bottom) - y,
193- };
194-}
195-
196-/**
197- * Read those edges off the four strips. A child holding nothing — the melds row
198- * before a claim, the button bar with no buttons up — is space nobody is using,
199- * so what counts is the first child with something in it.
200- */
201-function stripEdges(table: HTMLElement, base: DOMRect, local: (r: DOMRect) => Rect): StripEdges {
202- const edges: StripEdges = {};
203- for (const slot of table.querySelectorAll<HTMLElement>('.slot[data-seat]')) {
204- const seat = slot.querySelector<HTMLElement>('.seat');
205- if (!seat) continue;
206-
207- let used: Rect | null = null;
208- for (const child of seat.children) {
209- // The winner's arrow points into the middle from the strip's inside edge
210- // and is gone again next hand; it is not what a seat takes up.
211- if (child.classList.contains('win-arrow')) continue;
212- if (child.childElementCount === 0 && !child.textContent?.trim()) continue;
213- const r = local(child.getBoundingClientRect());
214- if (r.w === 0 || r.h === 0) continue;
215- used = used
216- ? {
217- x: Math.min(used.x, r.x),
218- y: Math.min(used.y, r.y),
219- w: Math.max(used.x + used.w, r.x + r.w) - Math.min(used.x, r.x),
220- h: Math.max(used.y + used.h, r.y + r.h) - Math.min(used.y, r.y),
221- }
222- : r;
223- }
224- if (!used) continue;
225-
226- // Which edge of the table this strip is against. The left and right ones
227- // are the only two that touch the sides, so they are what is asked first.
228- const box = local(slot.getBoundingClientRect());
229- if (box.x < 1) edges.left = Math.max(edges.left ?? 0, used.x + used.w);
230- else if (box.x + box.w > base.width - 1) {
231- edges.right = Math.min(edges.right ?? base.width, used.x);
232- } else if (box.y < 1) edges.top = Math.max(edges.top ?? 0, used.y + used.h);
233- else edges.bottom = Math.min(edges.bottom ?? base.height, used.y);
234- }
235- return edges;
236182 }
237183
238184 export const centreOf = (r: Rect): Point => ({ x: r.x + r.w / 2, y: r.y + r.h / 2 });
⋯ 136 unchanged lines
modifiedsrc/table/physics.test.ts+3 −30
⋯ 24 unchanged lines
2525 type Rect,
2626 type Spawn,
2727 } from './physics';
28-import { canThrow, reaching, segmentHitsRect, type TableGeometry } from './geometry';
28+import { canThrow, segmentHitsRect, type TableGeometry } from './geometry';
2929
3030 const POOL: Rect = { x: 200, y: 200, w: 220, h: 220 };
3131 /** Same aspect as a real tile, roughly pool-tile sized. */
⋯ 362 unchanged lines
394394 it('gets a tile into the pool even when the wall is in the way', () => {
395395 // A standing wall right across the throw, with the tile flicked flat at it:
396396 // it cannot get in on its own, and must not be left outside.
397- const world = createWorld(POOL, [{ x: 150, y: 440, w: 320, h: 36 }]);
397+ const world = createWorld(POOL, [{ rect: { x: 150, y: 440, w: 320, h: 36 } }]);
398398 addBody(world, {
399399 tile: 0,
400400 seat: 0,
⋯ 56 unchanged lines
457457 size: { w: 620, h: 620 },
458458 pool: POOL,
459459 walls,
460+ barriers: walls.map((rect) => ({ rect })),
460461 tile: TILE,
461462 felt: { x: 120, y: 120, w: 380, h: 380 },
462463 launch: { 0: { x: 310, y: 560 } },
⋯ 36 unchanged lines
499500
500501 it('is open when the square has been eaten away entirely', () => {
501502 expect(canThrow(geometry([]), 0)).toBe(true);
502- });
503-});
504-
505-describe('the felt reaching into the strips', () => {
506- // `.center` is what is left once every strip has taken its full depth; the
507- // inside end of a strip is empty until somebody claims something.
508- const CENTRE: Rect = { x: 300, y: 300, w: 400, h: 400 };
509-
510- it('grows out to where a strip that has laid nothing down actually starts', () => {
511- const felt = reaching(CENTRE, { left: 260, right: 740, top: 250, bottom: 750 });
512- expect(felt).toEqual({ x: 260, y: 250, w: 480, h: 500 });
513- });
514-
515- it('leaves a side alone once that seat has tiles down against the middle', () => {
516- // Melds out to the centre's own edge: nothing to give back on that side.
517- const felt = reaching(CENTRE, { left: 300, right: 740 });
518- expect(felt.x).toBe(300);
519- expect(felt.x + felt.w).toBe(740);
520- });
521-
522- it('never shrinks the middle, whatever a strip claims to reach', () => {
523- // A strip whose contents overhang the centre would otherwise invert it.
524- const felt = reaching(CENTRE, { left: 340, top: 360, right: 660, bottom: 640 });
525- expect(felt).toEqual(CENTRE);
526- });
527-
528- it('is the centre itself when there is nothing to measure', () => {
529- expect(reaching(CENTRE, {})).toEqual(CENTRE);
530503 });
531504 });
532505
⋯ 167 unchanged lines
modifiedsrc/table/physics.ts+31 −8
⋯ 205 unchanged lines
206206 strength: number;
207207 /** Set when it was the edge of the table it hit, not a wall or another tile. */
208208 edge?: Edge;
209- /** Whose discard it was, for an edge — you cannot be barged by your own tile. */
209+ /** Whose tiles it ran into, where what it hit belonged to somebody. */
210+ into?: SeatId;
211+ /** Whose discard it was — you cannot be barged by your own tile. */
212+ seat?: SeatId;
213+}
214+
215+/**
216+ * Something solid on the table. Everything that stops a tile is another tile:
217+ * the stacks still standing in the wall, the sets people have laid down, and
218+ * the sixteen everyone is holding. `seat` is whose it is, where it is somebody's
219+ * — a tile thrown into a hand is a thing that hand's owner answers.
220+ */
221+export interface Barrier {
222+ rect: Rect;
210223 seat?: SeatId;
211224 }
212225
⋯ 1 unchanged line
214227 /** Where tiles come to rest — inside the wall square. */
215228 bounds: Rect;
216229 /** Stacks of two still standing. Half and spent stacks are not obstacles. */
217- walls: Rect[];
230+ walls: Barrier[];
218231 bodies: Body[];
219232 /** Left-over time from the last frame, so steps stay a fixed size. */
220233 carry: number;
⋯ 32 unchanged lines
253266 atRest?: boolean;
254267 }
255268
256-export function createWorld(bounds: Rect, walls: Rect[] = []): World {
269+export function createWorld(bounds: Rect, walls: Barrier[] = []): World {
257270 return { bounds, walls, bodies: [], carry: 0, contacts: 0, relaxed: 0 };
258271 }
259272
⋯ 296 unchanged lines
556569 * leave any of them stranded outside it, and the ones it moves have to wake up
557570 * so they can settle again.
558571 */
559-export function reshape(world: World, bounds: Rect, walls: Rect[]) {
572+export function reshape(world: World, bounds: Rect, walls: Barrier[]) {
560573 world.bounds = bounds;
561574 world.walls = walls;
562575 for (const b of world.bodies) {
⋯ 32 unchanged lines
595608 b.vy += (dy / d) * ENTRY_PULL * dt;
596609 }
597610
598-/** Bounce off a standing stack, corner to corner if that is how it hits. */
599-function hitWall(b: Body, wall: Rect, impacts: Impact[]) {
600- const hit = overlapOf(boxOf(wall), b);
611+/** Bounce off whatever is standing there, corner to corner if that is how it
612+ * hits — a stack of the wall, a laid-down set, somebody's hand. */
613+function hitWall(b: Body, wall: Barrier, impacts: Impact[]) {
614+ const hit = overlapOf(boxOf(wall.rect), b);
601615 if (!hit || hit.depth <= CONTACT_SLOP) return;
602616
603617 const speed = Math.hypot(b.vx, b.vy);
⋯ 9 unchanged lines
613627 b.resting = false;
614628 b.still = 0;
615629 const strength = Math.min(1, speed / 1100);
616- if (strength > 0.12) impacts.push({ x: b.x, y: b.y, strength });
630+ if (wall.seat !== undefined) {
631+ // Into somebody's tiles. Reported however gently it arrived — their row is
632+ // theirs, and a discard rolling into it is an event whatever speed it got
633+ // there at. Which of them are loud enough to hear is the caller's business.
634+ if (speed > SLEEP_SPEED) {
635+ impacts.push({ x: b.x, y: b.y, strength, into: wall.seat, seat: b.seat });
636+ }
637+ } else if (strength > 0.12) {
638+ impacts.push({ x: b.x, y: b.y, strength });
639+ }
617640 }
618641
619642 /** The pool's edge. Once a tile is in, it stays in — this is the invariant the
⋯ 300 unchanged lines
modifiedsrc/table/pool.ts+9 −6
⋯ 474 unchanged lines
475475 private remeasure(state: GameState) {
476476 const table = this.table;
477477 if (!table) return;
478- const stamp = `${state.drawnFront}/${state.drawnBack}/${Math.round(
478+ // Every tile on the table is a collider now, so the shape of each hand and
479+ // each set laid down is part of what has to be re-read — not just the wall.
480+ const hands = state.players.map((p) => `${p.hand.length}.${p.melds.length}.${p.flowers.length}`);
481+ const stamp = `${state.drawnFront}/${state.drawnBack}/${hands.join(',')}/${Math.round(
479482 table.clientWidth,
480483 )}x${Math.round(table.clientHeight)}`;
481484 if (stamp === this.measuredAt) return;
⋯ 7 unchanged lines
489492 this.setOpenness('----');
490493 return;
491494 }
492- // Bounded by the felt, not by the square: what keeps a tile in the middle is
493- // the stacks still standing, and once they are gone the pool is free to
494- // spread out through the gap the way a real one does.
495- if (!this.world) this.world = createWorld(geo.felt, geo.walls);
496- else reshape(this.world, geo.felt, geo.walls);
495+ // What keeps a tile in is the tiles: the stacks still standing, the sets
496+ // laid down, and the hands beyond them. `felt` is the table's own edge and
497+ // is only there so a tile that got past all of that is still on the table.
498+ if (!this.world) this.world = createWorld(geo.felt, geo.barriers);
499+ else reshape(this.world, geo.felt, geo.barriers);
497500 // Only here, never per frame: a hundred-odd ray casts is nothing once a
498501 // draw, and a great deal every sixtieth of a second.
499502 this.setOpenness(SEATS.map((s) => (canThrow(geo, s) ? 'y' : '-')).join(''));
⋯ 179 unchanged lines
modifiedsrc/ui/Pool.tsx+12 −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 { onCollidersChange, showingColliders } from './colliders';
12+import { exposePool, onCollidersChange, showingColliders } from './colliders';
1313 import { EDGE_SEAT } from './rotation';
1414
1515 /**
⋯ 39 unchanged lines
5555 if (!canvas || !table || !ctx) return;
5656
5757 pool.attach(table);
58+ exposePool(pool);
5859
5960 let frame = 0;
6061 let previous = 0;
⋯ 19 unchanged lines
8081
8182 pool.sync(live.current.state);
8283 placeReach(reachRef.current, pool);
84+ // A tile in the fingers goes over the table's own buttons — seat 0 throws
85+ // from the *right-hand end* of its row, which is directly under the undo
86+ // button — so while one is being carried they stop taking the pointer.
87+ table.classList.toggle('carrying', !!pool.held);
8388 // The loudest contact this frame is the one you would actually hear; the
8489 // rest of a scatter is the same event.
8590 const impacts = pool.advance(dt);
⋯ 2 unchanged lines
8893 // A contact quiet enough not to be worth hearing still counts as a tile
8994 // arriving in somebody's lap, which is why the two thresholds differ.
9095 if (i.strength > AUDIBLE) loudest = Math.max(loudest, i.strength);
91- if (i.edge) {
96+ // Straight into somebody's tiles — the wall square is nobody's, so this
97+ // is a hand or a set that has been laid down.
98+ if (i.into !== undefined) shove(pool, table, i.into, i.seat, i.strength);
99+ else if (i.edge) {
92100 const at = ((EDGE_SEAT[i.edge] + live.current.viewSeat) % 4) as SeatId;
93101 shove(pool, table, at, i.seat, i.strength);
94102 }
⋯ 284 unchanged lines
379387 ctx.save();
380388 ctx.lineWidth = 1;
381389
382- // The fence, and the stacks still standing.
390+ // The table's own edge, and every tile standing on it.
383391 ctx.strokeStyle = '#ff2d20';
384392 line(world.bounds);
385- for (const w of world.walls) line(w);
393+ for (const w of world.walls) line(w.rect);
386394
387395 // Where a throw is aimed. Not a fence — see TableGeometry.pool.
388396 const aim = pool.poolRect;
⋯ 246 unchanged lines
modifiedsrc/ui/colliders.ts+12 −0
⋯ 39 unchanged lines
4040 declare global {
4141 interface Window {
4242 colliders?: (next?: boolean) => boolean;
43+ /** The live pool, for poking at from the console. Dev builds only. */
44+ pool?: unknown;
4345 }
4446 }
4547
4648 if (typeof window !== 'undefined') window.colliders = toggleColliders;
49+
50+/**
51+ * Hand the console the pool itself, so `pool.world.walls` and `pool.geo` can be
52+ * read against what is drawn. The overlay draws the world's own copy rather than
53+ * a fresh measurement on purpose — if the two ever disagree it is the world that
54+ * is stale, and that is a thing you want to be able to catch.
55+ */
56+export function exposePool(pool: unknown) {
57+ if (import.meta.env.DEV && typeof window !== 'undefined') window.pool = pool;
58+}