anvilsign in

collin/mahjong · 7c83cfe4

Give the middle of the table a floor, walls and gravity

Collin Richards · 2026-08-19 09:40 UTC · 7c83cfe4d56ad03f5976a2d1f6232664dcd9518b · parent 46d10436 · browse files

addedsrc/game/wall.ts+73 −0
1+import { STACKS_PER_SIDE, WALL_SIZE, WALL_STACKS } from './tiles';
2+import type { SeatId } from './types';
3+
4+/**
5+ * What the wall square looks like right now.
6+ *
7+ * The square is eaten from both ends at once — normal draws come off the front,
8+ * kong and flower replacements off the back — so how much of it is left is not
9+ * one number but a per-stack question, and two things want the answer: the
10+ * drawing of it (ui/WallRing) and the physics (table/geometry), since a stack
11+ * still standing is something a thrown tile has to get past.
12+ *
13+ * Deliberately typed against the fields it reads rather than `GameState`, so it
14+ * stays a function of the wall and nothing else.
15+ */
16+export interface WallProgress {
17+ drawnFront: number;
18+ drawnBack: number;
19+ rules: { wallReserve: number };
20+}
21+
22+export interface Stack {
23+ /** Tiles still in this stack: 2 full, 1 half, 0 spent. */
24+ count: 0 | 1 | 2;
25+ /** Part of the 16-tile 底牌 tail that ends the hand. */
26+ dead: boolean;
27+ /** The break point — the stack the next draw comes off. */
28+ next: boolean;
29+}
30+
31+/**
32+ * Position 0 is the break point. Stack `i` holds positions `2i` and `2i+1`, and
33+ * a position is still there if it is past the front and short of the back.
34+ */
35+export function wallStacks(s: WallProgress): Stack[] {
36+ const front = s.drawnFront;
37+ const back = WALL_SIZE - s.drawnBack;
38+ const deadFrom = back - s.rules.wallReserve;
39+
40+ return Array.from({ length: WALL_STACKS }, (_, i) => {
41+ const a = i * 2;
42+ const b = a + 1;
43+ const live = (p: number) => p >= front && p < back;
44+ const count = ((live(a) ? 1 : 0) + (live(b) ? 1 : 0)) as 0 | 1 | 2;
45+ return { count, dead: b >= deadFrom, next: front === a || front === b };
46+ });
47+}
48+
49+/**
50+ * Whether a stack is something a thrown tile has to get past.
51+ *
52+ * A stack of two stands as tall as the tile being thrown at it. One of one is
53+ * low enough to sail over, and a spent one is not there at all — so only a full
54+ * stack is a barrier. This is the rule behind both halves of the throw: the
55+ * colliders the pile bounces off, and whether a seat may flick at all.
56+ */
57+export const isBarrier = (s: Stack) => s.count === 2;
58+
59+/** The four sides in the order WallRing lays them out, clockwise from the top. */
60+export const WALL_SIDES = ['top', 'right', 'bottom', 'left'] as const;
61+export type WallSide = (typeof WALL_SIDES)[number];
62+
63+/**
64+ * Which side of the square sits in front of each seat — the one a seat has to
65+ * throw over. Seats run bottom, right, top, left (see ui/rotation.ts) and the
66+ * sides are drawn top, right, bottom, left, so the two orders are not the same.
67+ */
68+export const SEAT_WALL_SIDE: Record<SeatId, number> = { 0: 2, 1: 1, 2: 0, 3: 3 };
69+
70+/** The stacks making up one side, in the order that side is drawn. */
71+export function sideStacks(stacks: Stack[], side: number): Stack[] {
72+ return stacks.slice(side * STACKS_PER_SIDE, (side + 1) * STACKS_PER_SIDE);
73+}
addedsrc/table/geometry.ts+178 −0
1+import { isBarrier, wallStacks, type WallProgress } from '../game/wall';
2+import type { SeatId } from '../game/types';
3+import type { Rect } from './physics';
4+
5+/**
6+ * Where everything on the table actually is, in pixels.
7+ *
8+ * The wall square's size lives entirely in CSS — `--ring`, `--ws`, `--wd` in
9+ * styles.css work it out from the viewport and the strip depth — so rather than
10+ * restate that arithmetic here and let the two drift apart, this *measures* it.
11+ * `WallRing` renders every stack as a real element tagged with its index, so the
12+ * colliders come from `getBoundingClientRect()` on the things already on screen.
13+ * That is the whole mapping from the HTML to the physics.
14+ *
15+ * Measuring is one layout flush over ~74 elements. It happens when the window
16+ * resizes and when the wall's shape changes, never per frame.
17+ */
18+
19+const SEATS: SeatId[] = [0, 1, 2, 3];
20+
21+export interface TableGeometry {
22+ /** Table size, world units — the world's origin is the table's top left. */
23+ size: { w: number; h: number };
24+ /** Inside the wall square: where discards come to rest. */
25+ pool: Rect;
26+ /** Stacks of two still standing. Half stacks are low enough to throw over. */
27+ walls: Rect[];
28+ /** A tile in the pool is the same tile that was in the wall. */
29+ tile: { w: number; h: number };
30+ /** Where each seat throws from, if that seat's hand is on screen. */
31+ launch: Partial<Record<SeatId, Point>>;
32+}
33+
34+export interface Point {
35+ x: number;
36+ y: number;
37+}
38+
39+/**
40+ * Read the table. Returns null when there is no wall square to measure — the
41+ * compact layout has none, and neither does a table that has not laid out yet.
42+ */
43+export function measure(table: HTMLElement, state: WallProgress): TableGeometry | null {
44+ const ring = table.querySelector<HTMLElement>('.wall-ring');
45+ if (!ring) return null;
46+
47+ const base = table.getBoundingClientRect();
48+ if (base.width === 0 || base.height === 0) return null;
49+ const local = (r: DOMRect): Rect => ({
50+ x: r.left - base.left,
51+ y: r.top - base.top,
52+ w: r.width,
53+ h: r.height,
54+ });
55+
56+ const stacks = wallStacks(state);
57+ const walls: Rect[] = [];
58+ // A stack element is --ws by --wd (or the other way round on the left and
59+ // right sides), which gives us both the tile size and the wall's thickness
60+ // without knowing either number.
61+ let short = 0;
62+ let long = 0;
63+ for (const el of ring.querySelectorAll<HTMLElement>('[data-stack]')) {
64+ const i = Number(el.dataset.stack);
65+ const r = local(el.getBoundingClientRect());
66+ short = Math.max(short, Math.min(r.w, r.h));
67+ long = Math.max(long, Math.max(r.w, r.h));
68+ const stack = stacks[i];
69+ if (stack && isBarrier(stack)) walls.push(r);
70+ }
71+ if (short === 0) return null;
72+
73+ // The pool is the square the wall encloses, less the wall's own thickness.
74+ const ringRect = local(ring.getBoundingClientRect());
75+ const pool: Rect = {
76+ x: ringRect.x + long,
77+ y: ringRect.y + long,
78+ w: Math.max(short, ringRect.w - long * 2),
79+ h: Math.max(short, ringRect.h - long * 2),
80+ };
81+
82+ const launch: Partial<Record<SeatId, Point>> = {};
83+ for (const seat of SEATS) {
84+ const hand = table.querySelector<HTMLElement>(`[data-seat="${seat}"] .hand`);
85+ if (!hand) continue;
86+ const r = hand.getBoundingClientRect();
87+ if (r.width === 0 && r.height === 0) continue;
88+ // A rotated element's bounding box is still centred on the element, so this
89+ // is the right point whichever edge the seat is sitting at.
90+ launch[seat] = { x: r.left + r.width / 2 - base.left, y: r.top + r.height / 2 - base.top };
91+ }
92+
93+ return {
94+ size: { w: base.width, h: base.height },
95+ pool,
96+ walls,
97+ tile: { w: short, h: short * 1.375 },
98+ launch,
99+ };
100+}
101+
102+export const centreOf = (r: Rect): Point => ({ x: r.x + r.w / 2, y: r.y + r.h / 2 });
103+
104+/**
105+ * Whether a seat has a clear line into the middle — the throw gate.
106+ *
107+ * Not a rule bolted on beside the physics; it *is* the physics, asked ahead of
108+ * time. While a standing stack of two is between a seat and the pool, a tile
109+ * flicked from there would hit it, so the seat cannot throw and has to place the
110+ * tile over the wall instead (tap-tap, or 打出). As the hand eats the square,
111+ * gaps open and seats come good one at a time.
112+ *
113+ * Aim is not just dead ahead: a fan of lines spread across the pool means a gap
114+ * off to one side counts, which is how you would actually throw it.
115+ */
116+export function canThrow(geo: TableGeometry, seat: SeatId): boolean {
117+ const from = geo.launch[seat];
118+ if (!from) return false;
119+ return aimPoints(geo).some((to) => clearLine(geo.walls, from, to));
120+}
121+
122+/** Somewhere in the pool this seat could actually get a tile to, or null. */
123+export function aimAt(geo: TableGeometry, seat: SeatId): Point | null {
124+ const from = geo.launch[seat];
125+ if (!from) return null;
126+ const open = aimPoints(geo).filter((to) => clearLine(geo.walls, from, to));
127+ if (open.length === 0) return null;
128+ // The most central thing reachable, so a tile lands among the others rather
129+ // than hugging whichever edge it squeezed past.
130+ const c = centreOf(geo.pool);
131+ return open.reduce((best, p) =>
132+ Math.hypot(p.x - c.x, p.y - c.y) < Math.hypot(best.x - c.x, best.y - c.y) ? p : best,
133+ );
134+}
135+
136+/**
137+ * Landing spots to try for: a grid over the whole pool, not a line through its
138+ * middle.
139+ *
140+ * A throw only has to *get inside* the square — once past the wall the tile
141+ * slides to a stop wherever it ends up. So the near corners matter as much as
142+ * the centre, and they matter more: they sit much closer to the seat, which is
143+ * what makes a gap well off to one side worth aiming through at all. Aiming
144+ * only at the middle would call a seat walled in when it plainly is not.
145+ */
146+function aimPoints(geo: TableGeometry): Point[] {
147+ const { x, y, w, h } = geo.pool;
148+ const steps = [0.08, 0.3, 0.5, 0.7, 0.92];
149+ const out: Point[] = [];
150+ for (const u of steps) for (const v of steps) out.push({ x: x + w * u, y: y + h * v });
151+ return out;
152+}
153+
154+/** Segment against every standing stack, by the slab method. */
155+function clearLine(walls: Rect[], from: Point, to: Point): boolean {
156+ return !walls.some((w) => segmentHitsRect(from, to, w));
157+}
158+
159+export function segmentHitsRect(a: Point, b: Point, r: Rect): boolean {
160+ const dx = b.x - a.x;
161+ const dy = b.y - a.y;
162+ let t0 = 0;
163+ let t1 = 1;
164+
165+ // Each axis clips the span of the segment that could still be inside.
166+ const slab = (p: number, d: number, lo: number, hi: number): boolean => {
167+ if (Math.abs(d) < 1e-9) return p >= lo && p <= hi;
168+ const near = (lo - p) / d;
169+ const far = (hi - p) / d;
170+ t0 = Math.max(t0, Math.min(near, far));
171+ t1 = Math.min(t1, Math.max(near, far));
172+ return t1 >= t0;
173+ };
174+
175+ if (!slab(a.x, dx, r.x, r.x + r.w)) return false;
176+ if (!slab(a.y, dy, r.y, r.y + r.h)) return false;
177+ return t1 >= t0;
178+}
addedsrc/table/physics.test.ts+284 −0
1+import { describe, expect, it } from 'vitest';
2+import { makeRng, STACKS_PER_SIDE } from '../game/tiles';
3+import { isBarrier, SEAT_WALL_SIDE, sideStacks, wallStacks } from '../game/wall';
4+import type { SeatId } from '../game/types';
5+import { addBody, createWorld, settled, step, type Body, type Rect, type Spawn } from './physics';
6+import { canThrow, segmentHitsRect, type TableGeometry } from './geometry';
7+
8+const POOL: Rect = { x: 200, y: 200, w: 220, h: 220 };
9+/** Same aspect as a real tile, roughly pool-tile sized. */
10+const TILE = { w: 26, h: 36 };
11+/** Generous: nothing should still be moving after four seconds. */
12+const SETTLE_CAP = 480;
13+
14+function throwAt(rng: () => number): Spawn {
15+ const centre = { x: POOL.x + POOL.w / 2, y: POOL.y + POOL.h / 2 };
16+ // From below the pool, as seat 0 would, but aimed all over the place and
17+ // hard enough to be a genuine test of the bounds.
18+ const from = { x: 100 + rng() * 400, y: 560 };
19+ const spread = (rng() - 0.5) * 260;
20+ const dx = centre.x + spread - from.x;
21+ const dy = centre.y - from.y;
22+ const d = Math.hypot(dx, dy);
23+ const speed = 900 + rng() * 1800;
24+ return {
25+ tile: Math.floor(rng() * 34),
26+ seat: 0,
27+ x: from.x,
28+ y: from.y,
29+ vx: (dx / d) * speed,
30+ vy: (dy / d) * speed,
31+ z: 12,
32+ vz: 300 + rng() * 500,
33+ angle: rng() * Math.PI * 2,
34+ spin: (rng() - 0.5) * 14,
35+ w: TILE.w,
36+ h: TILE.h,
37+ };
38+}
39+
40+function runToRest(world: ReturnType<typeof createWorld>, cap = SETTLE_CAP): number {
41+ let steps = 0;
42+ while (!settled(world) && steps < cap) {
43+ step(world);
44+ steps++;
45+ }
46+ return steps;
47+}
48+
49+/** How far outside the pool a body sits, allowing for its own radius. */
50+function escape(b: Body, pool: Rect): number {
51+ const r = (b.w + b.h) * 0.22;
52+ return Math.max(
53+ 0,
54+ pool.x + r - b.x,
55+ b.x - (pool.x + pool.w - r),
56+ pool.y + r - b.y,
57+ b.y - (pool.y + pool.h - r),
58+ );
59+}
60+
61+describe('physics — the pile', () => {
62+ it('never lets a tile out of the pool, however hard it is thrown', () => {
63+ const rng = makeRng(20260819);
64+ for (let trial = 0; trial < 40; trial++) {
65+ const world = createWorld(POOL);
66+ // A full hand's worth, thrown one after another into the same pool.
67+ for (let i = 0; i < 16; i++) {
68+ addBody(world, throwAt(rng));
69+ for (let s = 0; s < 40; s++) step(world);
70+ }
71+ runToRest(world);
72+ for (const b of world.bodies) {
73+ expect(escape(b, POOL)).toBeLessThan(0.5);
74+ expect(b.z).toBe(0);
75+ }
76+ }
77+ });
78+
79+ it('settles everything, rather than jittering forever', () => {
80+ const rng = makeRng(7);
81+ const world = createWorld(POOL);
82+ for (let i = 0; i < 30; i++) addBody(world, throwAt(rng));
83+ const steps = runToRest(world);
84+ expect(steps).toBeLessThan(SETTLE_CAP);
85+ expect(settled(world)).toBe(true);
86+ });
87+
88+ it('is deterministic — the same throws give the same pile', () => {
89+ const pile = () => {
90+ const rng = makeRng(99);
91+ const world = createWorld(POOL);
92+ for (let i = 0; i < 12; i++) {
93+ addBody(world, throwAt(rng));
94+ for (let s = 0; s < 30; s++) step(world);
95+ }
96+ runToRest(world);
97+ return world.bodies.map((b) => [b.x, b.y, b.angle]);
98+ };
99+ // This is what lets a refresh mid-hand come back to the pile it had.
100+ expect(pile()).toEqual(pile());
101+ });
102+
103+ it('gets a tile into the pool even when the wall is in the way', () => {
104+ // A standing wall right across the throw, with the tile flicked flat at it:
105+ // it cannot get in on its own, and must not be left outside.
106+ const world = createWorld(POOL, [{ x: 150, y: 440, w: 320, h: 36 }]);
107+ addBody(world, {
108+ tile: 0,
109+ seat: 0,
110+ x: 310,
111+ y: 520,
112+ vx: 0,
113+ vy: -400,
114+ z: 0,
115+ vz: 0,
116+ angle: 0,
117+ spin: 0,
118+ w: TILE.w,
119+ h: TILE.h,
120+ });
121+ runToRest(world);
122+ expect(settled(world)).toBe(true);
123+ expect(escape(world.bodies[0], POOL)).toBeLessThan(0.5);
124+ });
125+
126+ it('makes room for a tile dropped into a settled pile', () => {
127+ // A rebuilt pile — every tile placed at rest on the same spot.
128+ const world = createWorld(POOL);
129+ const at = (n: number) =>
130+ addBody(world, {
131+ tile: n,
132+ seat: 0,
133+ x: 310,
134+ y: 310,
135+ vx: 0,
136+ vy: 0,
137+ z: 0,
138+ vz: 0,
139+ angle: 0,
140+ spin: 0,
141+ w: TILE.w,
142+ h: TILE.h,
143+ atRest: true,
144+ });
145+ for (let i = 0; i < 8; i++) at(i);
146+
147+ expect(settled(world)).toBe(true);
148+ for (const b of world.bodies) expect(escape(b, POOL)).toBeLessThan(0.5);
149+ // Nothing sitting exactly on top of anything else.
150+ for (let i = 0; i < world.bodies.length; i++) {
151+ for (let j = i + 1; j < world.bodies.length; j++) {
152+ const a = world.bodies[i];
153+ const b = world.bodies[j];
154+ expect(Math.hypot(a.x - b.x, a.y - b.y)).toBeGreaterThan(1);
155+ }
156+ }
157+ });
158+});
159+
160+// ---------------------------------------------------------------------------
161+
162+/** A seat-0 view: pool up the screen, a wall across it, hand below. */
163+function geometry(walls: Rect[]): TableGeometry {
164+ return {
165+ size: { w: 620, h: 620 },
166+ pool: POOL,
167+ walls,
168+ tile: TILE,
169+ launch: { 0: { x: 310, y: 560 } },
170+ };
171+}
172+
173+/** The near side of the square, as 18 stacks. */
174+const nearSide = (): Rect[] =>
175+ Array.from({ length: 18 }, (_, i) => ({ x: 180 + i * 14, y: 440, w: 14, h: 36 }));
176+
177+describe('the throw gate', () => {
178+ it('is shut while the wall in front of the seat is standing', () => {
179+ expect(canThrow(geometry(nearSide()), 0)).toBe(false);
180+ });
181+
182+ it('opens once a run of stacks has been drawn', () => {
183+ const walls = nearSide().filter((_, i) => i < 6 || i > 11);
184+ expect(canThrow(geometry(walls), 0)).toBe(true);
185+ });
186+
187+ it('opens on a gap off to one side, not just straight ahead', () => {
188+ // A gap left of centre, with everything dead ahead still standing: the only
189+ // way in is at an angle, towards the near corner of the pool.
190+ const walls = nearSide().filter((_, i) => i < 2 || i > 7);
191+ expect(canThrow(geometry(walls), 0)).toBe(true);
192+ });
193+
194+ it('stays shut for a gap too far round to reach the pool in a line', () => {
195+ // Stacks 0-3 are drawn, but that gap sits off the end of the pool: a tile
196+ // aimed through it lands beside the square, not in it. Openness is about
197+ // getting in, so this is genuinely still shut.
198+ const walls = nearSide().filter((_, i) => i > 3);
199+ expect(canThrow(geometry(walls), 0)).toBe(false);
200+ });
201+
202+ it('is shut for a seat with no hand on screen', () => {
203+ expect(canThrow(geometry([]), 2)).toBe(false);
204+ });
205+
206+ it('is open when the square has been eaten away entirely', () => {
207+ expect(canThrow(geometry([]), 0)).toBe(true);
208+ });
209+});
210+
211+describe('segmentHitsRect', () => {
212+ const r: Rect = { x: 10, y: 10, w: 20, h: 20 };
213+
214+ it('finds a crossing', () => {
215+ expect(segmentHitsRect({ x: 0, y: 20 }, { x: 40, y: 20 }, r)).toBe(true);
216+ });
217+ it('misses a segment that passes by', () => {
218+ expect(segmentHitsRect({ x: 0, y: 40 }, { x: 40, y: 40 }, r)).toBe(false);
219+ });
220+ it('misses a segment that stops short', () => {
221+ expect(segmentHitsRect({ x: 0, y: 20 }, { x: 5, y: 20 }, r)).toBe(false);
222+ });
223+ it('finds a diagonal through a corner', () => {
224+ expect(segmentHitsRect({ x: 0, y: 0 }, { x: 20, y: 20 }, r)).toBe(true);
225+ });
226+});
227+
228+// ---------------------------------------------------------------------------
229+
230+describe('wallStacks', () => {
231+ const at = (drawnFront: number, drawnBack = 0) =>
232+ wallStacks({ drawnFront, drawnBack, rules: { wallReserve: 16 } });
233+
234+ it('starts as 72 full stacks', () => {
235+ const s = at(0);
236+ expect(s).toHaveLength(72);
237+ expect(s.every((x) => x.count === 2)).toBe(true);
238+ expect(s[0].next).toBe(true);
239+ });
240+
241+ it('eats the square from the front, a stack at a time', () => {
242+ // The deal takes 64 tiles plus the dealer's opener.
243+ const s = at(65);
244+ expect(s.slice(0, 32).every((x) => x.count === 0)).toBe(true);
245+ expect(s[32].count).toBe(1);
246+ expect(s[33].count).toBe(2);
247+ expect(s[32].next).toBe(true);
248+ });
249+
250+ it('eats it from the back too, for kong and flower replacements', () => {
251+ const s = at(0, 3);
252+ expect(s[71].count).toBe(0);
253+ expect(s[70].count).toBe(1);
254+ });
255+
256+ it('marks the 16-tile 底牌 tail dead', () => {
257+ const s = at(0);
258+ expect(s[71].dead).toBe(true);
259+ expect(s[64].dead).toBe(true);
260+ expect(s[63].dead).toBe(false);
261+ });
262+
263+ it('counts only a full stack as something to throw over', () => {
264+ // The rule both halves of the throw hang off: a stack of two is as tall as
265+ // the tile coming at it, one of one is not, and a spent one is not there.
266+ const s = at(65);
267+ expect(isBarrier(s[33])).toBe(true); // two left
268+ expect(isBarrier(s[32])).toBe(false); // one left — sail over it
269+ expect(isBarrier(s[0])).toBe(false); // gone
270+ });
271+
272+ it('gives each seat the side of the square in front of it', () => {
273+ // Seats run bottom, right, top, left; sides are drawn top, right, bottom,
274+ // left — so the two orders are genuinely different and worth pinning.
275+ expect(SEAT_WALL_SIDE[0 as SeatId]).toBe(2);
276+ expect(SEAT_WALL_SIDE[2 as SeatId]).toBe(0);
277+ const s = at(65);
278+ // At the deal the top side is gone, so the seat across the table is open
279+ // while the near seat's own wall has not been touched.
280+ expect(sideStacks(s, SEAT_WALL_SIDE[2 as SeatId]).every((x) => x.count === 0)).toBe(true);
281+ expect(sideStacks(s, SEAT_WALL_SIDE[0 as SeatId]).every((x) => x.count === 2)).toBe(true);
282+ expect(sideStacks(s, 0)).toHaveLength(STACKS_PER_SIDE);
283+ });
284+});
addedsrc/table/physics.ts+534 −0
1+import type { Tile } from '../game/tiles';
2+import type { SeatId } from '../game/types';
3+
4+/**
5+ * The tiles in the middle of the table, as things with weight.
6+ *
7+ * Pure and deterministic: no DOM, no clock, no randomness. Where the world's
8+ * measurements come from is `geometry.ts`; what gets thrown into it and when is
9+ * `pool.ts`; drawing it is `ui/Pool.tsx`. Anything random — the spread on a
10+ * placed tile, the scatter a reloaded pile is rebuilt from — is decided by the
11+ * caller and arrives as a spawn impulse, which is what lets the same hand come
12+ * back to the same pile after a refresh.
13+ *
14+ * Everything is in CSS pixels, table-local, and seconds.
15+ */
16+
17+/** One fixed step. Small enough that a hard flick can't tunnel a wall. */
18+export const FIXED_DT = 1 / 120;
19+/**
20+ * Most catch-up a single frame will simulate. A tab that was in the background
21+ * for a minute comes back to a settled pile, not to a spin through 7200 steps.
22+ */
23+const MAX_SUBSTEPS = 8;
24+
25+/** Falls onto the felt in about half a second from a normal throw. */
26+const GRAVITY = 2600;
27+/** Tiles are dense and land flat; they do not bounce much. */
28+const Z_RESTITUTION = 0.34;
29+/** Below this the tile is flat on the felt: sliding, and in the way of others. */
30+const ON_FELT = 8;
31+/**
32+ * How high a standing stack of two reaches. A tile still above this sails over
33+ * the wall — which is exactly what a *placed* tile does, and what a thrown one
34+ * must not need to do.
35+ */
36+export const WALL_HEIGHT = 30;
37+
38+/** Felt friction: an exponential part that kills speed, and a constant part
39+ * that actually brings it to a stop rather than to an asymptote. */
40+const FELT_DAMP = 4.2;
41+const FELT_STOP = 90;
42+const SPIN_DAMP = 5;
43+const SPIN_STOP = 1.2;
44+
45+const BOUND_RESTITUTION = 0.42;
46+const BOUND_FRICTION = 0.86;
47+const TILE_RESTITUTION = 0.4;
48+
49+/** Asleep after this many consecutive steps of going nowhere. */
50+const SLEEP_SPEED = 7;
51+const SLEEP_SPIN = 0.2;
52+const SLEEP_STEPS = 10;
53+/**
54+ * Overlap small enough to live with. Without this a crowded pool never sleeps:
55+ * a tile squeezed between its neighbours and the pool's edge gets pushed out of
56+ * contact and clamped back into it on every step, for ever, and each of those
57+ * counts as being disturbed. Tolerating a fraction of a pixel costs nothing to
58+ * look at and is what lets the pile go quiet.
59+ */
60+const CONTACT_SLOP = 0.5;
61+/**
62+ * A body moving this slowly for this long is put to sleep whatever it thinks it
63+ * is doing. The backstop that guarantees the pile always settles — counted from
64+ * when it last woke, not from when it was thrown.
65+ */
66+const AWAKE_LIMIT = 600;
67+
68+/** Pulls a tile that landed short of the pool in towards the rest of them. */
69+const ENTRY_PULL = 700;
70+/**
71+ * How long a tile gets to find its own way into the pool before it is simply
72+ * put there. A discard is game state made visible — it has to be in the middle
73+ * where it can be seen and counted — so a tile that has got itself wedged
74+ * against a standing stack, or is bouncing between two of them, cannot be left
75+ * out there. Two seconds is far longer than any real throw takes to settle.
76+ */
77+const ENTRY_GRACE = 240;
78+
79+/** Quietest impact worth a sound, as a fraction of a hard landing. */
80+const IMPACT_FLOOR = 0.12;
81+
82+export interface Rect {
83+ x: number;
84+ y: number;
85+ w: number;
86+ h: number;
87+}
88+
89+export interface Body {
90+ tile: Tile;
91+ /** Who threw it — for the fly-out when it is claimed, not for any marking. */
92+ seat: SeatId;
93+ /** Centre on the felt plane. */
94+ x: number;
95+ y: number;
96+ vx: number;
97+ vy: number;
98+ /** Height above the felt, and its rate. Only ever >= 0. */
99+ z: number;
100+ vz: number;
101+ angle: number;
102+ spin: number;
103+ w: number;
104+ h: number;
105+ /**
106+ * Whether it has ever been inside the pool. Until it has, it is free to fly
107+ * over the wall from a seat's edge; once it has, the pool keeps it.
108+ */
109+ entered: boolean;
110+ resting: boolean;
111+ /** Consecutive slow steps, counting towards sleep. */
112+ still: number;
113+ /** Steps lived, for the grace period on getting into the pool. */
114+ age: number;
115+ /** Steps since it last woke, for the backstop on settling. */
116+ awake: number;
117+}
118+
119+/** A collision loud enough to hear. `strength` is 0..1. */
120+export interface Impact {
121+ x: number;
122+ y: number;
123+ strength: number;
124+}
125+
126+export interface World {
127+ /** Where tiles come to rest — inside the wall square. */
128+ bounds: Rect;
129+ /** Stacks of two still standing. Half and spent stacks are not obstacles. */
130+ walls: Rect[];
131+ bodies: Body[];
132+ /** Left-over time from the last frame, so steps stay a fixed size. */
133+ carry: number;
134+}
135+
136+export interface Spawn {
137+ tile: Tile;
138+ seat: SeatId;
139+ x: number;
140+ y: number;
141+ vx: number;
142+ vy: number;
143+ z: number;
144+ vz: number;
145+ angle: number;
146+ spin: number;
147+ w: number;
148+ h: number;
149+ /** Already settled — a pile being rebuilt after a reload or an undo. */
150+ atRest?: boolean;
151+}
152+
153+export function createWorld(bounds: Rect, walls: Rect[] = []): World {
154+ return { bounds, walls, bodies: [], carry: 0 };
155+}
156+
157+export function addBody(world: World, s: Spawn): Body {
158+ const body: Body = {
159+ tile: s.tile,
160+ seat: s.seat,
161+ x: s.x,
162+ y: s.y,
163+ vx: s.atRest ? 0 : s.vx,
164+ vy: s.atRest ? 0 : s.vy,
165+ z: s.atRest ? 0 : s.z,
166+ vz: s.atRest ? 0 : s.vz,
167+ angle: s.angle,
168+ spin: s.atRest ? 0 : s.spin,
169+ w: s.w,
170+ h: s.h,
171+ entered: !!s.atRest,
172+ resting: !!s.atRest,
173+ still: s.atRest ? SLEEP_STEPS : 0,
174+ age: 0,
175+ awake: 0,
176+ };
177+ world.bodies.push(body);
178+ // A tile dropped straight into a settled pile still has to make room.
179+ if (s.atRest) separate(world, body);
180+ return body;
181+}
182+
183+/** Collision radius. A shade under the tile's mean half-extent, so a pool
184+ * overlaps the way a real one does instead of packing like marbles. */
185+const radiusOf = (b: Body) => (b.w + b.h) * 0.22;
186+
187+const inside = (r: Rect, x: number, y: number) =>
188+ x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h;
189+
190+/**
191+ * Run the world forward. `elapsed` is real seconds since the last call; it is
192+ * consumed in fixed steps so the result does not depend on the frame rate.
193+ * Returns the impacts worth a sound.
194+ */
195+export function advance(world: World, elapsed: number): Impact[] {
196+ world.carry += Math.max(0, elapsed);
197+ let steps = Math.floor(world.carry / FIXED_DT);
198+ if (steps > MAX_SUBSTEPS) {
199+ // Drop the backlog rather than working through it.
200+ world.carry = 0;
201+ steps = MAX_SUBSTEPS;
202+ } else {
203+ world.carry -= steps * FIXED_DT;
204+ }
205+
206+ const impacts: Impact[] = [];
207+ for (let i = 0; i < steps; i++) step(world, impacts);
208+ return impacts;
209+}
210+
211+/** One fixed step. Exported for the tests, which drive it directly. */
212+export function step(world: World, impacts: Impact[] = []): Impact[] {
213+ const dt = FIXED_DT;
214+
215+ for (const b of world.bodies) {
216+ if (b.resting) continue;
217+ b.age++;
218+ b.awake++;
219+
220+ // --- through the air ---
221+ if (b.z > 0 || b.vz !== 0) {
222+ b.vz -= GRAVITY * dt;
223+ b.z += b.vz * dt;
224+ if (b.z <= 0) {
225+ const landing = Math.abs(b.vz);
226+ b.z = 0;
227+ // Below a knock it stops dead rather than shivering out a bounce.
228+ b.vz = landing > 40 ? landing * Z_RESTITUTION : 0;
229+ const strength = Math.min(1, landing / 900);
230+ if (strength > IMPACT_FLOOR) impacts.push({ x: b.x, y: b.y, strength });
231+ }
232+ }
233+
234+ b.x += b.vx * dt;
235+ b.y += b.vy * dt;
236+ b.angle += b.spin * dt;
237+
238+ if (inside(world.bounds, b.x, b.y)) b.entered = true;
239+ else if (b.age > ENTRY_GRACE) {
240+ // Out of time going the long way round. Put it in, gently.
241+ b.entered = true;
242+ b.z = 0;
243+ b.vz = 0;
244+ clampInto(world.bounds, b);
245+ }
246+
247+ // --- on the felt ---
248+ if (b.z <= ON_FELT) {
249+ damp(b, dt);
250+ // A tile that fell short skids in rather than sitting outside the pool.
251+ if (!b.entered) pullIn(world, b, dt);
252+ }
253+
254+ // A tile still above the wall is clear of it.
255+ if (b.z < WALL_HEIGHT) {
256+ for (const wall of world.walls) hitWall(b, wall, impacts);
257+ }
258+
259+ if (b.entered) keepInBounds(world, b, impacts);
260+ }
261+
262+ collideTiles(world, impacts);
263+ // Deciding to sleep comes last, so it judges where the step actually left
264+ // things — before, a contact resolved after the decision would undo it and
265+ // a crowded pool would stay awake for ever.
266+ for (const b of world.bodies) if (!b.resting) trySleep(b);
267+ return impacts;
268+}
269+
270+function damp(b: Body, dt: number) {
271+ const k = Math.exp(-FELT_DAMP * dt);
272+ b.vx *= k;
273+ b.vy *= k;
274+ b.spin *= Math.exp(-SPIN_DAMP * dt);
275+
276+ const speed = Math.hypot(b.vx, b.vy);
277+ if (speed > 0) {
278+ const next = Math.max(0, speed - FELT_STOP * dt);
279+ b.vx = (b.vx / speed) * next;
280+ b.vy = (b.vy / speed) * next;
281+ }
282+ const spin = Math.abs(b.spin);
283+ if (spin > 0) {
284+ const next = Math.max(0, spin - SPIN_STOP * dt);
285+ b.spin = Math.sign(b.spin) * next;
286+ }
287+}
288+
289+/** Nudges a tile that landed outside the pool towards the rest of them. */
290+function pullIn(world: World, b: Body, dt: number) {
291+ const cx = world.bounds.x + world.bounds.w / 2;
292+ const cy = world.bounds.y + world.bounds.h / 2;
293+ const dx = cx - b.x;
294+ const dy = cy - b.y;
295+ const d = Math.hypot(dx, dy) || 1;
296+ b.vx += (dx / d) * ENTRY_PULL * dt;
297+ b.vy += (dy / d) * ENTRY_PULL * dt;
298+}
299+
300+/** Bounce off a standing stack. The stacks are axis-aligned, so the shallower
301+ * overlap is the axis to push out along. */
302+function hitWall(b: Body, wall: Rect, impacts: Impact[]) {
303+ const r = radiusOf(b);
304+ const left = b.x + r - wall.x;
305+ const right = wall.x + wall.w - (b.x - r);
306+ const top = b.y + r - wall.y;
307+ const bottom = wall.y + wall.h - (b.y - r);
308+ if (left <= 0 || right <= 0 || top <= 0 || bottom <= 0) return;
309+
310+ const speed = Math.hypot(b.vx, b.vy);
311+ const min = Math.min(left, right, top, bottom);
312+ if (min === left) {
313+ b.x = wall.x - r;
314+ b.vx = -Math.abs(b.vx) * BOUND_RESTITUTION;
315+ } else if (min === right) {
316+ b.x = wall.x + wall.w + r;
317+ b.vx = Math.abs(b.vx) * BOUND_RESTITUTION;
318+ } else if (min === top) {
319+ b.y = wall.y - r;
320+ b.vy = -Math.abs(b.vy) * BOUND_RESTITUTION;
321+ } else {
322+ b.y = wall.y + wall.h + r;
323+ b.vy = Math.abs(b.vy) * BOUND_RESTITUTION;
324+ }
325+ b.spin *= 0.7;
326+ b.resting = false;
327+ b.still = 0;
328+ const strength = Math.min(1, speed / 1100);
329+ if (strength > IMPACT_FLOOR) impacts.push({ x: b.x, y: b.y, strength });
330+}
331+
332+/** The pool's edge. Once a tile is in, it stays in — this is the invariant the
333+ * tests pin, since a discard that slid off the table would be lost. */
334+function keepInBounds(world: World, b: Body, impacts: Impact[]) {
335+ const r = radiusOf(b);
336+ const { x, y, w, h } = world.bounds;
337+ // A pool narrower than a tile would otherwise fight itself; centre instead.
338+ const minX = w <= r * 2 ? x + w / 2 : x + r;
339+ const maxX = w <= r * 2 ? x + w / 2 : x + w - r;
340+ const minY = h <= r * 2 ? y + h / 2 : y + r;
341+ const maxY = h <= r * 2 ? y + h / 2 : y + h - r;
342+
343+ let hit = 0;
344+ if (b.x < minX) {
345+ b.x = minX;
346+ hit = Math.max(hit, Math.abs(b.vx));
347+ b.vx = Math.abs(b.vx) * BOUND_RESTITUTION;
348+ b.vy *= BOUND_FRICTION;
349+ } else if (b.x > maxX) {
350+ b.x = maxX;
351+ hit = Math.max(hit, Math.abs(b.vx));
352+ b.vx = -Math.abs(b.vx) * BOUND_RESTITUTION;
353+ b.vy *= BOUND_FRICTION;
354+ }
355+ if (b.y < minY) {
356+ b.y = minY;
357+ hit = Math.max(hit, Math.abs(b.vy));
358+ b.vy = Math.abs(b.vy) * BOUND_RESTITUTION;
359+ b.vx *= BOUND_FRICTION;
360+ } else if (b.y > maxY) {
361+ b.y = maxY;
362+ hit = Math.max(hit, Math.abs(b.vy));
363+ b.vy = -Math.abs(b.vy) * BOUND_RESTITUTION;
364+ b.vx *= BOUND_FRICTION;
365+ }
366+ // Only a real knock counts as being disturbed. A tile held against the edge
367+ // by its neighbours is clamped every single step, and treating that as a
368+ // knock would keep the whole pool awake for ever.
369+ if (hit > SLEEP_SPEED) {
370+ b.still = 0;
371+ const strength = Math.min(1, hit / 1100);
372+ if (strength > IMPACT_FLOOR) impacts.push({ x: b.x, y: b.y, strength });
373+ }
374+}
375+
376+/**
377+ * Tile against tile, circles rather than rotated rectangles. A mahjong pool is
378+ * a forgiving thing to approximate and tiles are near enough square (1 : 1.375)
379+ * that the difference does not read; rotated-rectangle SAT is a drop-in upgrade
380+ * here if it ever does.
381+ *
382+ * Only tiles flat on the felt take part, so a thrown tile passes over the pile
383+ * instead of shouldering through it, and only awake ones are checked against
384+ * the rest — which is nearly always one tile against a sleeping pile.
385+ */
386+function collideTiles(world: World, impacts: Impact[]) {
387+ const bodies = world.bodies;
388+ for (let i = 0; i < bodies.length; i++) {
389+ const a = bodies[i];
390+ if (a.z > ON_FELT) continue;
391+ for (let j = i + 1; j < bodies.length; j++) {
392+ const b = bodies[j];
393+ if (b.z > ON_FELT) continue;
394+ // A pile that has settled is left alone until something disturbs it.
395+ if (a.resting && b.resting) continue;
396+ resolvePair(a, b, impacts);
397+ }
398+ }
399+}
400+
401+function resolvePair(a: Body, b: Body, impacts: Impact[]) {
402+ const ra = radiusOf(a);
403+ const rb = radiusOf(b);
404+ const min = ra + rb;
405+ let dx = b.x - a.x;
406+ let dy = b.y - a.y;
407+ let d = Math.hypot(dx, dy);
408+ if (d >= min) return;
409+ // Resting in contact is not a collision. See CONTACT_SLOP.
410+ const overlap = min - d - CONTACT_SLOP;
411+ if (overlap <= 0) return;
412+
413+ if (d < 0.0001) {
414+ // Exactly on top of each other: pick an axis so they can get apart.
415+ dx = 1;
416+ dy = 0;
417+ d = 1;
418+ }
419+ const nx = dx / d;
420+ const ny = dy / d;
421+
422+ // Push apart. A tile that has settled is the heavy one: a tile thrown into a
423+ // pool shoulders in, rather than the pool scattering out of its way.
424+ const aShare = a.resting === b.resting ? 0.5 : a.resting ? 0 : 1;
425+ const bShare = 1 - aShare;
426+ a.x -= nx * overlap * aShare;
427+ a.y -= ny * overlap * aShare;
428+ b.x += nx * overlap * bShare;
429+ b.y += ny * overlap * bShare;
430+
431+ // Impulse along the centre line, plus a spin kick off what is left over.
432+ //
433+ // Only a genuine approach speed counts, and only that wakes anything. Nudging
434+ // two touching tiles apart is not a collision: a jammed corner of the pool is
435+ // corrected by a fraction of a pixel every step for as long as it is jammed,
436+ // and treating each of those as a shove would keep the pile awake for ever.
437+ const rvx = b.vx - a.vx;
438+ const rvy = b.vy - a.vy;
439+ const along = rvx * nx + rvy * ny;
440+ if (along >= -SLEEP_SPEED) return;
441+
442+ const jolt = -along * (1 + TILE_RESTITUTION) * 0.5;
443+ a.vx -= nx * jolt;
444+ a.vy -= ny * jolt;
445+ b.vx += nx * jolt;
446+ b.vy += ny * jolt;
447+
448+ const tangent = rvx * -ny + rvy * nx;
449+ a.spin -= tangent * 0.004;
450+ b.spin += tangent * 0.004;
451+
452+ const strength = Math.min(1, Math.abs(along) / 900);
453+ if (strength > IMPACT_FLOOR) impacts.push({ x: b.x, y: b.y, strength });
454+
455+ // Whatever was asleep has been shoved, so it is awake now.
456+ wake(a);
457+ wake(b);
458+}
459+
460+function wake(b: Body) {
461+ if (b.resting) b.awake = 0;
462+ b.resting = false;
463+ b.still = 0;
464+}
465+
466+function sleep(b: Body) {
467+ b.vx = 0;
468+ b.vy = 0;
469+ b.spin = 0;
470+ b.z = 0;
471+ b.vz = 0;
472+ b.resting = true;
473+ b.awake = 0;
474+}
475+
476+function trySleep(b: Body) {
477+ const speed = Math.hypot(b.vx, b.vy);
478+ const slow = b.entered && b.z <= 0 && b.vz === 0 && speed < SLEEP_SPEED && Math.abs(b.spin) < SLEEP_SPIN;
479+ if (slow && ++b.still >= SLEEP_STEPS) return sleep(b);
480+ if (!slow) b.still = 0;
481+ // Backstop: shuffling about in a crowded pool for this long is settled enough.
482+ if (b.entered && b.awake > AWAKE_LIMIT && speed < SLEEP_SPEED * 4) sleep(b);
483+}
484+
485+/**
486+ * Make room for a tile placed straight into a settled pile, without running
487+ * time. Used when a pile is rebuilt — a reload, an undo — where the tiles have
488+ * to be somewhere sensible immediately and nothing should appear to move.
489+ */
490+function separate(world: World, body: Body, passes = 12) {
491+ // Which way to go when two tiles are exactly on top of each other. Stepping
492+ // by the golden angle fans successive tiles out in every direction, where a
493+ // fixed axis would march them all one way and pack them against a pool edge.
494+ const escapeAngle = world.bodies.indexOf(body) * 2.39996;
495+
496+ for (let p = 0; p < passes; p++) {
497+ let moved = false;
498+ for (const other of world.bodies) {
499+ if (other === body) continue;
500+ const min = radiusOf(body) + radiusOf(other);
501+ let dx = body.x - other.x;
502+ let dy = body.y - other.y;
503+ let d = Math.hypot(dx, dy);
504+ if (d >= min) continue;
505+ if (d < 0.0001) {
506+ dx = Math.cos(escapeAngle + p);
507+ dy = Math.sin(escapeAngle + p);
508+ d = 1;
509+ }
510+ body.x = other.x + (dx / d) * min;
511+ body.y = other.y + (dy / d) * min;
512+ moved = true;
513+ }
514+ clampInto(world.bounds, body);
515+ if (!moved) break;
516+ }
517+}
518+
519+function clampInto(r: Rect, b: Body) {
520+ const rad = radiusOf(b);
521+ const minX = r.w <= rad * 2 ? r.x + r.w / 2 : r.x + rad;
522+ const maxX = r.w <= rad * 2 ? r.x + r.w / 2 : r.x + r.w - rad;
523+ const minY = r.h <= rad * 2 ? r.y + r.h / 2 : r.y + rad;
524+ const maxY = r.h <= rad * 2 ? r.y + r.h / 2 : r.y + r.h - rad;
525+ b.x = Math.min(maxX, Math.max(minX, b.x));
526+ b.y = Math.min(maxY, Math.max(minY, b.y));
527+}
528+
529+/** Whether everything has settled — what the render loop idles on. */
530+export const settled = (world: World) => world.bodies.every((b) => b.resting);
531+
532+/** Scale a tile is drawn at, given how high it is. Sells the arc on a flat
533+ * canvas; the renderer offsets the shadow by the same height. */
534+export const zScale = (z: number) => 1 + z * 0.0016;
modifiedsrc/ui/WallRing.tsx+26 −29
1-import { STACKS_PER_SIDE, WALL_SIZE, WALL_STACKS } from '../game/tiles';
1+import { STACKS_PER_SIDE } from '../game/tiles';
2+import { WALL_SIDES, wallStacks } from '../game/wall';
23 import type { GameState } from '../game/engine';
34
45 /**
⋯ 2 unchanged lines
78 * from there, while kong and flower replacements come off the far end (the 底牌
89 * tail), so it is consumed from both directions at once. Spent stacks stay as
910 * faint ghosts so the square keeps its shape all game.
11+ *
12+ * What is left where is `game/wall.ts`, because the physics needs the same
13+ * answer: a stack still standing is what a thrown tile has to clear.
14+ *
15+ * Every stack carries its index, and that is load-bearing — `table/geometry.ts`
16+ * measures these elements to build the colliders, rather than restating the CSS
17+ * that sizes the square.
1018 */
1119 export function WallRing({ state }: { state: GameState }) {
12- const front = state.drawnFront;
13- const back = WALL_SIZE - state.drawnBack;
14- const deadFrom = back - state.rules.wallReserve;
15-
16- const stacks = Array.from({ length: WALL_STACKS }, (_, i) => {
17- const a = i * 2;
18- const b = a + 1;
19- const live = (p: number) => p >= front && p < back;
20- const count = (live(a) ? 1 : 0) + (live(b) ? 1 : 0);
21- return { count, dead: b >= deadFrom, next: front === a || front === b };
22- });
23-
24- const side = (from: number, cls: string) => (
25- <div className={`wall-side ${cls}`} key={cls}>
26- {stacks.slice(from, from + STACKS_PER_SIDE).map((st, i) => (
27- <div
28- key={i}
29- className={`wall-stack s${st.count}${st.dead ? ' dead' : ''}${st.next ? ' next' : ''}`}
30- >
31- <i />
32- </div>
33- ))}
34- </div>
35- );
20+ const stacks = wallStacks(state);
3621
3722 return (
3823 <div className="wall-ring" aria-label="牌牆">
39- {side(0, 'top')}
40- {side(STACKS_PER_SIDE, 'right')}
41- {side(STACKS_PER_SIDE * 2, 'bottom')}
42- {side(STACKS_PER_SIDE * 3, 'left')}
24+ {WALL_SIDES.map((cls, side) => {
25+ const from = side * STACKS_PER_SIDE;
26+ return (
27+ <div className={`wall-side ${cls}`} key={cls}>
28+ {stacks.slice(from, from + STACKS_PER_SIDE).map((st, i) => (
29+ <div
30+ key={i}
31+ data-stack={from + i}
32+ className={`wall-stack s${st.count}${st.dead ? ' dead' : ''}${st.next ? ' next' : ''}`}
33+ >
34+ <i />
35+ </div>
36+ ))}
37+ </div>
38+ );
39+ })}
4340 </div>
4441 );
4542 }