anvilsign in

collin/mahjong · 407cb68d

Pick a tile up, carry it, and throw it into the middle

Collin Richards · 2026-08-19 10:03 UTC · 407cb68d57ef0befba8cde8b55935db7fc1c7b16 · parent 7c83cfe4 · browse files

modifiedTODO.md+2 −0
⋯ 22 unchanged lines
2323 flags plus a payment hook.
2424 - [ ] make the scar bgger
2525 - [ ] fgure out the br nao
26+- [ ] computer shouldn't be able to take the pung before the throw animation completes
27+- [ ] weirdness around throwing the tiles in the center, it's like attracted to the center, even gets stopped by wall it keeps going
modifiedsrc/App.tsx+18 −1
⋯ 10 unchanged lines
1111 import { FullscreenToggle } from './ui/FullscreenToggle';
1212 import { enterFullscreen, useFullscreen } from './ui/fullscreen';
1313 import { Help } from './ui/Help';
14+import { TablePool } from './table/pool';
15+import { Pool } from './ui/Pool';
1416 import { Seat } from './ui/Seat';
1517 import { Settings } from './ui/Settings';
1618 import './styles.css';
⋯ 12 unchanged lines
2931 // settings screen edits the live game rather than replacing it.
3032 const [game] = useState(() => new Game(prefs.names, prefs.rules));
3133 useSyncExternalStore(game.subscribe, game.getSnapshot);
34+ // The tiles in the middle. Owned here rather than inside <Pool> because the
35+ // seats need it too: a hand has to know whether it can be thrown from, and
36+ // which seats those are changes as the wall is eaten away.
37+ const [pool] = useState(() => new TablePool());
38+ useSyncExternalStore(pool.subscribe, pool.getSnapshot);
3239 const [helpSeat, setHelpSeat] = useState<SeatId | null>(null);
3340 const [settingsOpen, setSettingsOpen] = useState(false);
3441 // Read once at startup; after that the live game is the source of truth.
⋯ 134 unchanged lines
169176 return (
170177 <div className="table">
171178 {SEATS.map((s) => (
172- <Seat key={s} game={game} seat={s} compact={compact} onHelp={() => setHelpSeat(s)} />
179+ <Seat
180+ key={s}
181+ game={game}
182+ seat={s}
183+ compact={compact}
184+ pool={compact ? undefined : pool}
185+ onHelp={() => setHelpSeat(s)}
186+ />
173187 ))}
188+ {/* The tiles in the middle. Not on a phone: the compact layout has no
189+ centre square for them to be thrown into. */}
190+ {!compact && <Pool game={game} pool={pool} />}
174191 <Center
175192 game={game}
176193 prefs={prefs}
⋯ 19 unchanged lines
modifiedsrc/game/sound.ts+20 −0
⋯ 233 unchanged lines
234234 }
235235
236236 /**
237+ * Tile on tile, out in the middle. Not a `SoundEvent`: the engine never emits
238+ * one of these, because it is not something that happens in the game — it is a
239+ * thrown tile landing among the others, and only the physics knows when.
240+ *
241+ * `strength` is 0..1, straight off the impact, so a tile dropped in is a tick
242+ * and one thrown hard is a crack.
243+ */
244+let lastKnock = 0;
245+export function knock(strength: number) {
246+ if (!enabled) return;
247+ const c = audio();
248+ if (!c || !master) return;
249+ // A tile skittering across a pool of sixty can generate a dozen contacts in a
250+ // tenth of a second, and hearing all of them is a rattle, not a table.
251+ if (c.currentTime - lastKnock < 0.045) return;
252+ lastKnock = c.currentTime;
253+ clack(c, 0, 0.15 + strength * 0.6);
254+}
255+
256+/**
237257 * Each cue has its own shape so it is identifiable without looking up — the
238258 * whole reason for the feature is a player noticing across the table.
239259 */
⋯ 69 unchanged lines
modifiedsrc/styles.css+28 −0
⋯ 244 unchanged lines
245245 transform: translateY(calc(var(--tile-w) * -0.2)) scale(1.06);
246246 filter: drop-shadow(0 14px 18px rgba(0, 0, 0, 0.65));
247247 }
248+/* Lifted out of the row and being carried: the canvas is drawing it now, so the
249+ element steps aside. Its slot stays, so the row does not shuffle around under
250+ the tile you are holding and you can still drop it back where it came from. */
251+.hand-cell.carrying .tile {
252+ visibility: hidden;
253+}
248254 .drawn-slot {
249255 margin-left: calc(var(--tile-w) * 0.45);
250256 }
⋯ 237 unchanged lines
488494 min-height: 56px;
489495 }
490496
497+/* ---------- the tiles in the middle ---------- */
498+/* One canvas over the whole table, drawn by ui/Pool.tsx. It has to span the
499+ table and not just the centre: the strips clip their own contents, so a tile
500+ thrown out of a hand would be cut off at the edge of its seat if it were an
501+ element. On the canvas there is nothing to clip it.
502+
503+ Layering: above .wall-ring (0), since the tiles land inside the square and in
504+ front of it; level with .facing, which sits later in the DOM and so keeps the
505+ centre readout on top; below the panels, which raise themselves. */
506+.tile-pool {
507+ position: absolute;
508+ inset: 0;
509+ width: 100%;
510+ height: 100%;
511+ pointer-events: none;
512+ z-index: 1;
513+}
514+
491515 /* ---------- center ---------- */
492516 .center {
493517 position: absolute;
⋯ 182 unchanged lines
676700 font-weight: 600;
677701 }
678702 .result {
703+ /* Over the pile in the middle, not under it. */
704+ z-index: 2;
679705 background: rgba(10, 30, 24, 0.94);
680706 border: 1px solid rgba(217, 180, 92, 0.45);
681707 border-radius: 14px;
⋯ 352 unchanged lines
10341060 it belongs to the table, not to a seat. */
10351061 .table-controls {
10361062 position: absolute;
1063+ /* Over the pile, same as .result. */
1064+ z-index: 2;
10371065 right: 10px;
10381066 bottom: 8px;
10391067 display: flex;
⋯ 835 unchanged lines
modifiedsrc/table/physics.test.ts+35 −20
⋯ 1 unchanged line
22 import { makeRng, STACKS_PER_SIDE } from '../game/tiles';
33 import { isBarrier, SEAT_WALL_SIDE, sideStacks, wallStacks } from '../game/wall';
44 import type { SeatId } from '../game/types';
5-import { addBody, createWorld, settled, step, type Body, type Rect, type Spawn } from './physics';
5+import {
6+ addBody,
7+ createWorld,
8+ outside,
9+ overlapping,
10+ settled,
11+ step,
12+ type Rect,
13+ type Spawn,
14+} from './physics';
615 import { canThrow, segmentHitsRect, type TableGeometry } from './geometry';
716
817 const POOL: Rect = { x: 200, y: 200, w: 220, h: 220 };
⋯ 35 unchanged lines
4453 steps++;
4554 }
4655 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- );
5956 }
6057
6158 describe('physics — the pile', () => {
⋯ 8 unchanged lines
7067 }
7168 runToRest(world);
7269 for (const b of world.bodies) {
73- expect(escape(b, POOL)).toBeLessThan(0.5);
70+ expect(outside(b, POOL)).toBeLessThan(0.5);
7471 expect(b.z).toBe(0);
7572 }
7673 }
⋯ 8 unchanged lines
8582 expect(settled(world)).toBe(true);
8683 });
8784
85+ it('never lets two tiles share the same ground', () => {
86+ // A whole hand's worth thrown into one pool, then checked pair by pair as
87+ // rotated rectangles — not as circles, which would let two tiles at an angle
88+ // sit across each other and call it clear.
89+ const rng = makeRng(4242);
90+ const world = createWorld(POOL);
91+ for (let i = 0; i < 24; i++) {
92+ addBody(world, throwAt(rng));
93+ for (let s = 0; s < 45; s++) step(world);
94+ }
95+ runToRest(world);
96+
97+ for (let i = 0; i < world.bodies.length; i++) {
98+ for (let j = i + 1; j < world.bodies.length; j++) {
99+ // Sub-pixel contact is how a pile rests against itself; see CONTACT_SLOP.
100+ expect(overlapping(world.bodies[i], world.bodies[j])).toBeLessThan(1);
101+ }
102+ }
103+ });
104+
88105 it('is deterministic — the same throws give the same pile', () => {
89106 const pile = () => {
90107 const rng = makeRng(99);
⋯ 29 unchanged lines
120137 });
121138 runToRest(world);
122139 expect(settled(world)).toBe(true);
123- expect(escape(world.bodies[0], POOL)).toBeLessThan(0.5);
140+ expect(outside(world.bodies[0], POOL)).toBeLessThan(0.5);
124141 });
125142
126143 it('makes room for a tile dropped into a settled pile', () => {
⋯ 18 unchanged lines
145162 for (let i = 0; i < 8; i++) at(i);
146163
147164 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.
165+ for (const b of world.bodies) expect(outside(b, POOL)).toBeLessThan(0.5);
166+ // Nothing sharing ground with anything else.
150167 for (let i = 0; i < world.bodies.length; i++) {
151168 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);
169+ expect(overlapping(world.bodies[i], world.bodies[j])).toBeLessThan(1);
155170 }
156171 }
157172 });
⋯ 127 unchanged lines
modifiedsrc/table/physics.ts+233 −107
⋯ 5 unchanged lines
66 *
77 * Pure and deterministic: no DOM, no clock, no randomness. Where the world's
88 * 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.
9+ * `pool.ts`; drawing it is `ui/Pool.tsx`. Anything random — the scatter on a
10+ * placed tile, where a rebuilt pile's tiles sit — is decided by the caller and
11+ * arrives as a spawn impulse, which is what lets the same hand come back to the
12+ * same pile after a refresh.
1313 *
14- * Everything is in CSS pixels, table-local, and seconds.
14+ * Tiles are rectangles and they are solved as rectangles: two of them can lean
15+ * against each other at any angle but never share the same ground. Everything is
16+ * in CSS pixels, table-local, and seconds.
1517 */
1618
1719 /** One fixed step. Small enough that a hard flick can't tunnel a wall. */
⋯ 24 unchanged lines
4244 const SPIN_DAMP = 5;
4345 const SPIN_STOP = 1.2;
4446
47+/**
48+ * Roughly how much longer a tile keeps travelling after it lands, as a time.
49+ * Aiming a throw means solving for where it comes to *rest*, not where it first
50+ * touches down, so `pool.ts` adds this to the flight time.
51+ */
52+export const SLIDE_TIME = 1 / FELT_DAMP;
53+
4554 const BOUND_RESTITUTION = 0.42;
4655 const BOUND_FRICTION = 0.86;
47-const TILE_RESTITUTION = 0.4;
56+const TILE_RESTITUTION = 0.32;
57+/** How much of a glancing blow becomes spin. Tiles are light and skate. */
58+const SPIN_TRANSFER = 0.06;
4859
4960 /** Asleep after this many consecutive steps of going nowhere. */
5061 const SLEEP_SPEED = 7;
⋯ 2 unchanged lines
5364 /**
5465 * Overlap small enough to live with. Without this a crowded pool never sleeps:
5566 * 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.
67+ * contact and shoved back into it on every step, for ever. A fifth of a pixel is
68+ * not something you can see, and it is what lets the pile go quiet.
5969 */
60-const CONTACT_SLOP = 0.5;
70+const CONTACT_SLOP = 0.2;
6171 /**
6272 * A body moving this slowly for this long is put to sleep whatever it thinks it
6373 * is doing. The backstop that guarantees the pile always settles — counted from
⋯ 12 unchanged lines
7686 */
7787 const ENTRY_GRACE = 240;
7888
79-/** Quietest impact worth a sound, as a fraction of a hard landing. */
80-const IMPACT_FLOOR = 0.12;
89+/** How fast a tile drawn at hand size shrinks to the size it is in the pool. */
90+const SCALE_EASE = 6;
8191
8292 export interface Rect {
8393 x: number;
⋯ 2 unchanged lines
8696 h: number;
8797 }
8898
99+/** Anything the collision routines can treat as a rotated rectangle. */
100+interface Box {
101+ x: number;
102+ y: number;
103+ w: number;
104+ h: number;
105+ angle: number;
106+}
107+
89108 export interface Body {
90109 tile: Tile;
91110 /** Who threw it — for the fly-out when it is claimed, not for any marking. */
⋯ 11 unchanged lines
103122 w: number;
104123 h: number;
105124 /**
125+ * Render-only: what to multiply the drawn size by, easing to 1. A tile is
126+ * drawn big in a hand so it can be read, and small in the pool because that is
127+ * the size it is in the wall, so a thrown one has to settle from one to the
128+ * other. Eased here rather than in the renderer so it does not run at a
129+ * different speed on a different frame rate.
130+ */
131+ scale: number;
132+ /**
106133 * Whether it has ever been inside the pool. Until it has, it is free to fly
107134 * over the wall from a seat's edge; once it has, the pool keeps it.
108135 */
⋯ 37 unchanged lines
146173 spin: number;
147174 w: number;
148175 h: number;
176+ /** Drawn this much bigger to start with, easing to its pool size. */
177+ scale?: number;
149178 /** Already settled — a pile being rebuilt after a reload or an undo. */
150179 atRest?: boolean;
151180 }
⋯ 16 unchanged lines
168197 spin: s.atRest ? 0 : s.spin,
169198 w: s.w,
170199 h: s.h,
200+ scale: s.atRest ? 1 : s.scale ?? 1,
171201 entered: !!s.atRest,
172202 resting: !!s.atRest,
173203 still: s.atRest ? SLEEP_STEPS : 0,
⋯ 6 unchanged lines
180210 return body;
181211 }
182212
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;
213+// ---- rotated rectangles ---------------------------------------------------
214+
215+/** Distance from a box's centre to its edge, measured along `ax`. */
216+function reach(b: Box, ax: { x: number; y: number }): number {
217+ const c = Math.cos(b.angle);
218+ const s = Math.sin(b.angle);
219+ return (
220+ Math.abs(c * ax.x + s * ax.y) * (b.w / 2) + Math.abs(-s * ax.x + c * ax.y) * (b.h / 2)
221+ );
222+}
223+
224+/** The rotated rectangle's own bounding box, for the pool's straight edges. */
225+function aabbHalf(b: Box) {
226+ const c = Math.abs(Math.cos(b.angle));
227+ const s = Math.abs(Math.sin(b.angle));
228+ return { x: (b.w / 2) * c + (b.h / 2) * s, y: (b.w / 2) * s + (b.h / 2) * c };
229+}
230+
231+/** Enough to skip a pair without doing the real work. */
232+const circumradius = (b: Box) => Math.hypot(b.w, b.h) / 2;
233+
234+/**
235+ * Separating axis test between two rectangles at any angle. Returns the
236+ * shallowest way out — the direction to push `b` away from `a`, and by how much
237+ * — or null if they are clear of each other.
238+ *
239+ * Four axes is all a pair of rectangles needs: each box's two edge normals.
240+ */
241+function overlapOf(a: Box, b: Box): { nx: number; ny: number; depth: number } | null {
242+ const ca = Math.cos(a.angle);
243+ const sa = Math.sin(a.angle);
244+ const cb = Math.cos(b.angle);
245+ const sb = Math.sin(b.angle);
246+ const axes = [
247+ { x: ca, y: sa },
248+ { x: -sa, y: ca },
249+ { x: cb, y: sb },
250+ { x: -sb, y: cb },
251+ ];
252+
253+ const dx = b.x - a.x;
254+ const dy = b.y - a.y;
255+ let depth = Infinity;
256+ let nx = 0;
257+ let ny = 0;
258+
259+ for (const ax of axes) {
260+ const gap = reach(a, ax) + reach(b, ax) - Math.abs(dx * ax.x + dy * ax.y);
261+ if (gap <= 0) return null;
262+ if (gap < depth) {
263+ depth = gap;
264+ // Always pointing from a towards b, so callers need not guess.
265+ const sign = dx * ax.x + dy * ax.y < 0 ? -1 : 1;
266+ nx = ax.x * sign;
267+ ny = ax.y * sign;
268+ }
269+ }
270+ return { nx, ny, depth };
271+}
272+
273+/** A standing stack, as something the tests above can chew on. */
274+const boxOf = (r: Rect): Box => ({
275+ x: r.x + r.w / 2,
276+ y: r.y + r.h / 2,
277+ w: r.w,
278+ h: r.h,
279+ angle: 0,
280+});
281+
282+// ---- stepping -------------------------------------------------------------
186283
187284 const inside = (r: Rect, x: number, y: number) =>
188285 x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h;
⋯ 24 unchanged lines
213310 const dt = FIXED_DT;
214311
215312 for (const b of world.bodies) {
313+ if (b.scale !== 1) {
314+ b.scale += (1 - b.scale) * Math.min(1, SCALE_EASE * dt);
315+ if (Math.abs(b.scale - 1) < 0.002) b.scale = 1;
316+ }
216317 if (b.resting) continue;
217318 b.age++;
218319 b.awake++;
⋯ 8 unchanged lines
227328 // Below a knock it stops dead rather than shivering out a bounce.
228329 b.vz = landing > 40 ? landing * Z_RESTITUTION : 0;
229330 const strength = Math.min(1, landing / 900);
230- if (strength > IMPACT_FLOOR) impacts.push({ x: b.x, y: b.y, strength });
331+ if (strength > 0.12) impacts.push({ x: b.x, y: b.y, strength });
231332 }
232333 }
233334
⋯ 11 unchanged lines
245346 }
246347
247348 // --- on the felt ---
248- if (b.z <= ON_FELT) {
349+ // Strictly z <= 0, not ON_FELT: friction is between the tile and the cloth,
350+ // so a tile still in the air keeps the speed it was thrown with. ON_FELT is
351+ // only about being low enough to be in another tile's way.
352+ if (b.z <= 0) {
249353 damp(b, dt);
250354 // A tile that fell short skids in rather than sitting outside the pool.
251355 if (!b.entered) pullIn(world, b, dt);
⋯ 8 unchanged lines
260364 }
261365
262366 collideTiles(world, impacts);
367+ // Being shoved by a neighbour can put a tile back over the edge, and a tile
368+ // that then fell asleep out there would stay out there. Only awake ones need
369+ // it: resolvePair never moves a tile that has settled.
370+ for (const b of world.bodies) {
371+ if (b.entered && !b.resting) clampInto(world.bounds, b);
372+ }
263373 // 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.
374+ // things — before, a contact resolved after the decision would undo it and a
375+ // crowded pool would stay awake for ever.
266376 for (const b of world.bodies) if (!b.resting) trySleep(b);
267377 return impacts;
268378 }
269379
380+/**
381+ * The table has been re-measured — the window resized, or the wall was eaten
382+ * further back. The tiles keep where they are, but a pool that shrank must not
383+ * leave any of them stranded outside it, and the ones it moves have to wake up
384+ * so they can settle again.
385+ */
386+export function reshape(world: World, bounds: Rect, walls: Rect[]) {
387+ world.bounds = bounds;
388+ world.walls = walls;
389+ for (const b of world.bodies) {
390+ if (!b.entered) continue;
391+ const x = b.x;
392+ const y = b.y;
393+ clampInto(bounds, b);
394+ if (b.x !== x || b.y !== y) wake(b);
395+ }
396+}
397+
270398 function damp(b: Body, dt: number) {
271399 const k = Math.exp(-FELT_DAMP * dt);
272400 b.vx *= k;
⋯ 7 unchanged lines
280408 b.vy = (b.vy / speed) * next;
281409 }
282410 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- }
411+ if (spin > 0) b.spin = Math.sign(b.spin) * Math.max(0, spin - SPIN_STOP * dt);
287412 }
288413
289414 /** Nudges a tile that landed outside the pool towards the rest of them. */
⋯ 7 unchanged lines
297422 b.vy += (dy / d) * ENTRY_PULL * dt;
298423 }
299424
300-/** Bounce off a standing stack. The stacks are axis-aligned, so the shallower
301- * overlap is the axis to push out along. */
425+/** Bounce off a standing stack, corner to corner if that is how it hits. */
302426 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;
427+ const hit = overlapOf(boxOf(wall), b);
428+ if (!hit || hit.depth <= CONTACT_SLOP) return;
309429
310430 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;
431+ b.x += hit.nx * hit.depth;
432+ b.y += hit.ny * hit.depth;
433+ // Reflect whatever was heading into the wall; keep what was sliding along it.
434+ const into = b.vx * hit.nx + b.vy * hit.ny;
435+ if (into < 0) {
436+ b.vx -= hit.nx * into * (1 + BOUND_RESTITUTION);
437+ b.vy -= hit.ny * into * (1 + BOUND_RESTITUTION);
324438 }
325439 b.spin *= 0.7;
326440 b.resting = false;
327441 b.still = 0;
328442 const strength = Math.min(1, speed / 1100);
329- if (strength > IMPACT_FLOOR) impacts.push({ x: b.x, y: b.y, strength });
443+ if (strength > 0.12) impacts.push({ x: b.x, y: b.y, strength });
330444 }
331445
332446 /** The pool's edge. Once a tile is in, it stays in — this is the invariant the
333447 * tests pin, since a discard that slid off the table would be lost. */
334448 function keepInBounds(world: World, b: Body, impacts: Impact[]) {
335- const r = radiusOf(b);
449+ const half = aabbHalf(b);
336450 const { x, y, w, h } = world.bounds;
337451 // 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;
452+ const minX = w <= half.x * 2 ? x + w / 2 : x + half.x;
453+ const maxX = w <= half.x * 2 ? x + w / 2 : x + w - half.x;
454+ const minY = h <= half.y * 2 ? y + h / 2 : y + half.y;
455+ const maxY = h <= half.y * 2 ? y + h / 2 : y + h - half.y;
342456
343457 let hit = 0;
344458 if (b.x < minX) {
⋯ 19 unchanged lines
364478 b.vx *= BOUND_FRICTION;
365479 }
366480 // 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.
481+ // by its neighbours is clamped every single step, and treating that as a knock
482+ // would keep the whole pool awake for ever.
369483 if (hit > SLEEP_SPEED) {
370484 b.still = 0;
371485 const strength = Math.min(1, hit / 1100);
372- if (strength > IMPACT_FLOOR) impacts.push({ x: b.x, y: b.y, strength });
486+ if (strength > 0.12) impacts.push({ x: b.x, y: b.y, strength });
373487 }
374488 }
375489
376490 /**
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.
491+ * Tile against tile.
381492 *
382493 * 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.
494+ * instead of shouldering through it, and a cheap circle test throws out the pairs
495+ * that are nowhere near before the real rectangle test runs. Pairs where both
496+ * tiles have settled are left alone entirely, which is nearly all of them.
385497 */
386498 function collideTiles(world: World, impacts: Impact[]) {
387499 const bodies = world.bodies;
⋯ 3 unchanged lines
391503 for (let j = i + 1; j < bodies.length; j++) {
392504 const b = bodies[j];
393505 if (b.z > ON_FELT) continue;
394- // A pile that has settled is left alone until something disturbs it.
395506 if (a.resting && b.resting) continue;
507+ if (Math.hypot(b.x - a.x, b.y - a.y) > circumradius(a) + circumradius(b)) continue;
396508 resolvePair(a, b, impacts);
397509 }
398510 }
399511 }
400512
401513 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;
514+ const hit = overlapOf(a, b);
515+ if (!hit) return;
409516 // 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;
517+ const depth = hit.depth - CONTACT_SLOP;
518+ if (depth <= 0) return;
519+ const { nx, ny } = hit;
421520
422521 // Push apart. A tile that has settled is the heavy one: a tile thrown into a
423522 // pool shoulders in, rather than the pool scattering out of its way.
424523 const aShare = a.resting === b.resting ? 0.5 : a.resting ? 0 : 1;
425524 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.
525+ a.x -= nx * depth * aShare;
526+ a.y -= ny * depth * aShare;
527+ b.x += nx * depth * bShare;
528+ b.y += ny * depth * bShare;
529+
530+ // Impulse along the way out, plus a spin kick off what is left over.
432531 //
433532 // Only a genuine approach speed counts, and only that wakes anything. Nudging
434533 // two touching tiles apart is not a collision: a jammed corner of the pool is
⋯ 11 unchanged lines
446545 b.vy += ny * jolt;
447546
448547 const tangent = rvx * -ny + rvy * nx;
449- a.spin -= tangent * 0.004;
450- b.spin += tangent * 0.004;
548+ a.spin -= tangent * SPIN_TRANSFER * 0.06;
549+ b.spin += tangent * SPIN_TRANSFER * 0.06;
451550
452551 const strength = Math.min(1, Math.abs(along) / 900);
453- if (strength > IMPACT_FLOOR) impacts.push({ x: b.x, y: b.y, strength });
552+ if (strength > 0.12) impacts.push({ x: b.x, y: b.y, strength });
454553
455554 // Whatever was asleep has been shoved, so it is awake now.
456555 wake(a);
⋯ 18 unchanged lines
475574
476575 function trySleep(b: Body) {
477576 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;
577+ const slow =
578+ b.entered && b.z <= 0 && b.vz === 0 && speed < SLEEP_SPEED && Math.abs(b.spin) < SLEEP_SPIN;
479579 if (slow && ++b.still >= SLEEP_STEPS) return sleep(b);
480580 if (!slow) b.still = 0;
481581 // Backstop: shuffling about in a crowded pool for this long is settled enough.
⋯ 5 unchanged lines
487587 * time. Used when a pile is rebuilt — a reload, an undo — where the tiles have
488588 * to be somewhere sensible immediately and nothing should appear to move.
489589 */
490-function separate(world: World, body: Body, passes = 12) {
590+function separate(world: World, body: Body, passes = 16) {
491591 // Which way to go when two tiles are exactly on top of each other. Stepping
492592 // by the golden angle fans successive tiles out in every direction, where a
493593 // fixed axis would march them all one way and pack them against a pool edge.
494- const escapeAngle = world.bodies.indexOf(body) * 2.39996;
594+ const escape = world.bodies.indexOf(body) * 2.39996;
495595
496596 for (let p = 0; p < passes; p++) {
497597 let moved = false;
498598 for (const other of world.bodies) {
499599 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;
600+ const hit = overlapOf(other, body);
601+ if (!hit || hit.depth <= CONTACT_SLOP) continue;
602+ if (Math.hypot(body.x - other.x, body.y - other.y) < 0.0001) {
603+ body.x += Math.cos(escape + p) * hit.depth;
604+ body.y += Math.sin(escape + p) * hit.depth;
605+ } else {
606+ body.x += hit.nx * hit.depth;
607+ body.y += hit.ny * hit.depth;
509608 }
510- body.x = other.x + (dx / d) * min;
511- body.y = other.y + (dy / d) * min;
512609 moved = true;
513610 }
514611 clampInto(world.bounds, body);
⋯ 2 unchanged lines
517614 }
518615
519616 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;
617+ const half = aabbHalf(b);
618+ const minX = r.w <= half.x * 2 ? r.x + r.w / 2 : r.x + half.x;
619+ const maxX = r.w <= half.x * 2 ? r.x + r.w / 2 : r.x + r.w - half.x;
620+ const minY = r.h <= half.y * 2 ? r.y + r.h / 2 : r.y + half.y;
621+ const maxY = r.h <= half.y * 2 ? r.y + r.h / 2 : r.y + r.h - half.y;
525622 b.x = Math.min(maxX, Math.max(minX, b.x));
526623 b.y = Math.min(maxY, Math.max(minY, b.y));
527624 }
528625
529626 /** Whether everything has settled — what the render loop idles on. */
530-export const settled = (world: World) => world.bodies.every((b) => b.resting);
627+export const settled = (world: World) =>
628+ world.bodies.every((b) => b.resting && b.scale === 1);
629+
630+/** How far outside the pool a tile is sitting. The tests' invariant. */
631+export function outside(b: Body, pool: Rect): number {
632+ const half = aabbHalf(b);
633+ return Math.max(
634+ 0,
635+ pool.x + half.x - b.x,
636+ b.x - (pool.x + pool.w - half.x),
637+ pool.y + half.y - b.y,
638+ b.y - (pool.y + pool.h - half.y),
639+ );
640+}
641+
642+/** Whether two tiles are sharing ground, which they must never do at rest. */
643+export function overlapping(a: Body, b: Body): number {
644+ const hit = overlapOf(a, b);
645+ return hit ? hit.depth : 0;
646+}
531647
532648 /** Scale a tile is drawn at, given how high it is. Sells the arc on a flat
533649 * canvas; the renderer offsets the shadow by the same height. */
534650 export const zScale = (z: number) => 1 + z * 0.0016;
651+
652+/**
653+ * The upward kick that puts a tile back on the felt after `seconds` in the air.
654+ * How a throw is aimed: pick how long it should take, and the horizontal speed
655+ * follows from the distance.
656+ */
657+export const liftFor = (seconds: number) => (GRAVITY * seconds) / 2;
658+
659+/** How high that kick gets — worth checking against WALL_HEIGHT. */
660+export const peakOf = (vz: number) => (vz * vz) / (2 * GRAVITY);
addedsrc/table/pool.ts+420 −0
1+import type { GameState } from '../game/engine';
2+import { makeRng, type Tile } from '../game/tiles';
3+import type { SeatId } from '../game/types';
4+import { aimAt, canThrow, centreOf, measure, type Point, type TableGeometry } from './geometry';
5+import {
6+ addBody,
7+ advance as advanceWorld,
8+ createWorld,
9+ liftFor,
10+ reshape,
11+ settled,
12+ SLIDE_TIME,
13+ type Body,
14+ type Impact,
15+ type World,
16+} from './physics';
17+
18+const SEATS: SeatId[] = [0, 1, 2, 3];
19+
20+/**
21+ * The tiles in the middle, kept honest against the game state.
22+ *
23+ * This *diffs* rather than listening for events, and that is the whole design.
24+ * A discard is identified by which seat threw it and how many it had thrown
25+ * before — and those keys never shift, because discards are only ever pushed and
26+ * `engine.executeClaim` only ever pops the one on top. So every way the pile can
27+ * change is the same code path: a key that has appeared is a tile to throw in, a
28+ * key that has gone is a tile to take out. Undo, 下一局, a claim, and picking up
29+ * a saved game all fall out of that without the engine knowing this exists.
30+ *
31+ * Nothing here is stored in the game state, so there is no save format to
32+ * change. A pile is rebuilt from the discards it can see, seeded off the hand
33+ * number and each tile's place in it, which is why a refresh mid-hand comes back
34+ * to the pile it had rather than a freshly scattered one.
35+ */
36+
37+/** How long a placed tile is in the air. Long enough to arc over the wall. */
38+const PLACE_FLIGHT = 0.62;
39+/** A thrown tile hops rather than lofts — it is going through a gap, not over. */
40+const THROW_LIFT = 0.12;
41+/** Fastest flick we will honour, px/s. Beyond this it is a slip, not a throw. */
42+export const MAX_THROW_SPEED = 2800;
43+
44+/** A tile lifted out of a hand and carried about. Drawn, but not simulated. */
45+export interface Held {
46+ seat: SeatId;
47+ tile: Tile;
48+ /** Where it came from in that seat's hand, so the caller knows what to play. */
49+ index: number;
50+ x: number;
51+ y: number;
52+ angle: number;
53+ /** The size it was in the hand — it shrinks to pool size once thrown. */
54+ w: number;
55+ h: number;
56+}
57+
58+/** A throw that has been let go of, waiting for the discard it belongs to. */
59+interface Release {
60+ seat: SeatId;
61+ x: number;
62+ y: number;
63+ angle: number;
64+ w: number;
65+ h: number;
66+ vx: number;
67+ vy: number;
68+ spin: number;
69+}
70+
71+export class TablePool {
72+ world: World | null = null;
73+ geo: TableGeometry | null = null;
74+ /** The tile in somebody's fingers, if any. */
75+ held: Held | null = null;
76+ /**
77+ * Poke the render loop. Set by ui/Pool.tsx while it is mounted; a gesture
78+ * calls it so a carried tile follows the finger without waiting on a re-render.
79+ */
80+ wake: () => void = () => {};
81+
82+ private table: HTMLElement | null = null;
83+ /** Bodies by `seat:index` into that seat's discards. */
84+ private entries = new Map<string, Body>();
85+ private release: Release | null = null;
86+ /** What the wall looked like when we last measured, so we know to re-measure. */
87+ private measuredAt = '';
88+ private hand = -1;
89+ /** The tile currently on the table to be claimed, so it can be marked. */
90+ private newestKey: string | null = null;
91+
92+ // Which seats can throw, as a string, so the seats can re-render when it
93+ // changes. Read through useSyncExternalStore, the same way the game is.
94+ private openness = '----';
95+ private listeners = new Set<() => void>();
96+
97+ attach(table: HTMLElement) {
98+ this.table = table;
99+ this.measuredAt = '';
100+ }
101+
102+ detach() {
103+ this.table = null;
104+ this.world = null;
105+ this.geo = null;
106+ this.held = null;
107+ this.release = null;
108+ this.entries.clear();
109+ this.measuredAt = '';
110+ }
111+
112+ subscribe = (cb: () => void) => {
113+ this.listeners.add(cb);
114+ return () => this.listeners.delete(cb);
115+ };
116+
117+ /** Which seats have a clear line in, as a string — cheap to compare. */
118+ getSnapshot = () => this.openness;
119+
120+ /** Force a re-measure — the window resized, or the layout changed under us. */
121+ invalidate() {
122+ this.measuredAt = '';
123+ }
124+
125+ /**
126+ * Whether this seat can flick a tile in, or has to lift it over the wall.
127+ * Unmeasured (or compact, where there is no square) means no.
128+ */
129+ canThrow(seat: SeatId): boolean {
130+ return this.openness[seat] === 'y';
131+ }
132+
133+ /** Where the middle of the table is, for a gesture working out its aim. */
134+ get poolCentre(): Point | null {
135+ return this.geo ? centreOf(this.geo.pool) : null;
136+ }
137+
138+ /** Whether a point is already inside the square — a tile dropped there stays. */
139+ inPool(x: number, y: number): boolean {
140+ const p = this.geo?.pool;
141+ if (!p) return false;
142+ return x >= p.x && x <= p.x + p.w && y >= p.y && y <= p.y + p.h;
143+ }
144+
145+ // ---- the tile in your fingers -----------------------------------------
146+
147+ /** Lifted out of a hand. From here the canvas draws it, not the DOM. */
148+ pickUp(held: Held) {
149+ this.held = held;
150+ }
151+
152+ moveHeld(x: number, y: number, angle?: number) {
153+ if (!this.held) return;
154+ this.held.x = x;
155+ this.held.y = y;
156+ if (angle !== undefined) this.held.angle = angle;
157+ }
158+
159+ /** Put it back in the hand — the throw was not committed to. */
160+ cancelHeld() {
161+ this.held = null;
162+ }
163+
164+ /**
165+ * Let go of it. The body is not made here: the reconciler owns every tile in
166+ * the pool, so this leaves the throw where the next `sync` will find it and
167+ * the caller goes on to call `game.discard`. The held tile stays drawn until
168+ * that happens, so there is no frame with nothing on screen.
169+ */
170+ throwHeld(vx: number, vy: number, spin: number) {
171+ const h = this.held;
172+ if (!h) return;
173+ const speed = Math.hypot(vx, vy);
174+ const scale = speed > MAX_THROW_SPEED ? MAX_THROW_SPEED / speed : 1;
175+ this.release = {
176+ seat: h.seat,
177+ x: h.x,
178+ y: h.y,
179+ angle: h.angle,
180+ w: h.w,
181+ h: h.h,
182+ vx: vx * scale,
183+ vy: vy * scale,
184+ spin,
185+ };
186+ }
187+
188+ // ---- keeping up with the game ----------------------------------------
189+
190+ /** Bring the pile in line with the state. Cheap when nothing has changed. */
191+ sync(state: GameState) {
192+ this.remeasure(state);
193+ const geo = this.geo;
194+ const world = this.world;
195+ if (!geo || !world) return;
196+
197+ // A new hand is a new pile, and nothing should fly anywhere.
198+ const fresh = state.handNumber !== this.hand;
199+ this.hand = state.handNumber;
200+
201+ const want = new Set<string>();
202+ const added: { key: string; seat: SeatId; index: number; tile: Tile }[] = [];
203+ for (const seat of SEATS) {
204+ const discards = state.players[seat].discards;
205+ for (let i = 0; i < discards.length; i++) {
206+ const key = `${seat}:${i}`;
207+ want.add(key);
208+ if (!this.entries.has(key)) added.push({ key, seat, index: i, tile: discards[i] });
209+ }
210+ }
211+
212+ for (const [key, body] of this.entries) {
213+ if (want.has(key)) continue;
214+ const at = world.bodies.indexOf(body);
215+ if (at >= 0) world.bodies.splice(at, 1);
216+ this.entries.delete(key);
217+ }
218+
219+ // One tile at a time is somebody playing, and it gets thrown. Several at
220+ // once is a pile being rebuilt — a save picked up, an undo, a fresh deal —
221+ // and those tiles were thrown long ago, so they are simply already there.
222+ const rebuilding = fresh || added.length > 1;
223+ for (const a of added) {
224+ this.entries.set(a.key, rebuilding ? this.settle(geo, state, a) : this.launch(geo, state, a));
225+ }
226+
227+ // A throw is good for exactly one discard, and the tile it belonged to has
228+ // either just arrived or is never going to.
229+ if (added.length || this.release) {
230+ this.release = null;
231+ this.held = null;
232+ }
233+
234+ const ld = state.lastDiscard;
235+ this.newestKey = ld ? `${ld.from}:${state.players[ld.from].discards.length - 1}` : null;
236+ }
237+
238+ /** Run the physics on. Returns impacts worth a sound. */
239+ advance(seconds: number): Impact[] {
240+ return this.world ? advanceWorld(this.world, seconds) : [];
241+ }
242+
243+ get bodies(): Body[] {
244+ return this.world?.bodies ?? [];
245+ }
246+
247+ /** The tile on the table, if there is one — drawn lit, as a lifted tile is. */
248+ get newest(): Body | null {
249+ return (this.newestKey && this.entries.get(this.newestKey)) || null;
250+ }
251+
252+ /** Nothing is moving, so the render loop can stop until something changes. */
253+ get settled(): boolean {
254+ return !this.held && (!this.world || settled(this.world));
255+ }
256+
257+ // ---- measuring ---------------------------------------------------------
258+
259+ /**
260+ * Re-read the table when the wall's shape has changed or the window has. The
261+ * pile keeps its positions across a re-measure: the wall shrinking does not
262+ * move the tiles already on the felt.
263+ */
264+ private remeasure(state: GameState) {
265+ const table = this.table;
266+ if (!table) return;
267+ const stamp = `${state.drawnFront}/${state.drawnBack}/${Math.round(
268+ table.clientWidth,
269+ )}x${Math.round(table.clientHeight)}`;
270+ if (stamp === this.measuredAt) return;
271+
272+ const geo = measure(table, state);
273+ this.measuredAt = geo ? stamp : '';
274+ this.geo = geo;
275+ if (!geo) {
276+ this.world = null;
277+ this.entries.clear();
278+ this.setOpenness('----');
279+ return;
280+ }
281+ if (!this.world) this.world = createWorld(geo.pool, geo.walls);
282+ else reshape(this.world, geo.pool, geo.walls);
283+ // Only here, never per frame: a hundred-odd ray casts is nothing once a
284+ // draw, and a great deal every sixtieth of a second.
285+ this.setOpenness(SEATS.map((s) => (canThrow(geo, s) ? 'y' : '-')).join(''));
286+ }
287+
288+ private setOpenness(next: string) {
289+ if (next === this.openness) return;
290+ this.openness = next;
291+ for (const cb of this.listeners) cb();
292+ }
293+
294+ // ---- putting tiles in -------------------------------------------------
295+
296+ /** A tile that is simply there already — a rebuilt pile. */
297+ private settle(
298+ geo: TableGeometry,
299+ state: GameState,
300+ a: { seat: SeatId; index: number; tile: Tile },
301+ ): Body {
302+ const rng = this.seeded(state, a);
303+ const { pool } = geo;
304+ // Bunched towards the middle rather than spread evenly to the edges, which
305+ // is what a pool thrown into from four sides actually looks like.
306+ const spread = 0.3;
307+ return addBody(this.world!, {
308+ tile: a.tile,
309+ seat: a.seat,
310+ x: pool.x + pool.w * (0.5 + (rng() - 0.5) * 2 * spread),
311+ y: pool.y + pool.h * (0.5 + (rng() - 0.5) * 2 * spread),
312+ vx: 0,
313+ vy: 0,
314+ z: 0,
315+ vz: 0,
316+ angle: (rng() - 0.5) * Math.PI,
317+ spin: 0,
318+ w: geo.tile.w,
319+ h: geo.tile.h,
320+ atRest: true,
321+ });
322+ }
323+
324+ /** A tile arriving now: thrown if somebody threw it, placed over the wall if
325+ * it was tapped, and flat and quick if a bot's wall happens to be open. */
326+ private launch(
327+ geo: TableGeometry,
328+ state: GameState,
329+ a: { seat: SeatId; index: number; tile: Tile },
330+ ): Body {
331+ const rng = this.seeded(state, a);
332+ const thrown = this.release?.seat === a.seat ? this.release : null;
333+
334+ if (thrown) {
335+ // Which of the two ways a tile goes in, decided by what is in its way.
336+ //
337+ // A clear line to the square means it can be *slid* — flat across the
338+ // cloth, at exactly the speed it was let go at, which is what a flick
339+ // wants to be. A standing wall means it has to be *thrown* instead: lifted
340+ // over the top and dropped in, and then the flick's speed decides how far
341+ // across the pool it lands rather than how fast it skates.
342+ const set = this.inPool(thrown.x, thrown.y);
343+ const slide = set || this.canThrow(a.seat);
344+ let { vx, vy } = thrown;
345+ let vz = 0;
346+
347+ if (!slide) {
348+ const centre = centreOf(geo.pool);
349+ const reach = PLACE_FLIGHT + SLIDE_TIME;
350+ // Hard enough to clear the wall, and never hard enough to slam the far
351+ // side of the square — a lob has to land on the table.
352+ const cap = (Math.hypot(centre.x - thrown.x, centre.y - thrown.y) / reach) * 1.25;
353+ const speed = Math.hypot(vx, vy);
354+ const aim = Math.min(speed, cap) / (speed || 1);
355+ vx *= aim;
356+ vy *= aim;
357+ vz = liftFor(PLACE_FLIGHT);
358+ } else if (!set) {
359+ // A skated tile still leaves the fingers slightly off the cloth.
360+ vz = liftFor(THROW_LIFT);
361+ }
362+
363+ return addBody(this.world!, {
364+ tile: a.tile,
365+ seat: a.seat,
366+ x: thrown.x,
367+ y: thrown.y,
368+ vx,
369+ vy,
370+ z: 0,
371+ vz,
372+ angle: thrown.angle,
373+ spin: thrown.spin,
374+ w: geo.tile.w,
375+ h: geo.tile.h,
376+ // It was drawn at hand size a moment ago; it shrinks on the way down.
377+ scale: thrown.w / geo.tile.w,
378+ });
379+ }
380+
381+ const from = geo.launch[a.seat] ?? centreOf(geo.pool);
382+ const open = this.canThrow(a.seat);
383+ const target = this.aimFor(geo, a.seat, rng);
384+ // Aim for where it comes to *rest*, not where it first touches down: a tile
385+ // keeps sliding after it lands, and SLIDE_TIME is roughly how much further.
386+ const flight = open ? 0.34 + rng() * 0.1 : PLACE_FLIGHT;
387+ const reach = flight + SLIDE_TIME;
388+
389+ return addBody(this.world!, {
390+ tile: a.tile,
391+ seat: a.seat,
392+ x: from.x,
393+ y: from.y,
394+ vx: (target.x - from.x) / reach,
395+ vy: (target.y - from.y) / reach,
396+ z: 0,
397+ vz: liftFor(flight),
398+ angle: (rng() - 0.5) * Math.PI,
399+ spin: (rng() - 0.5) * (open ? 7 : 2.5),
400+ w: geo.tile.w,
401+ h: geo.tile.h,
402+ });
403+ }
404+
405+ /** Where a tile from this seat is headed, with a little scatter. */
406+ private aimFor(geo: TableGeometry, seat: SeatId, rng: () => number): Point {
407+ const base = aimAt(geo, seat) ?? centreOf(geo.pool);
408+ const jitter = Math.min(geo.pool.w, geo.pool.h) * 0.22;
409+ return { x: base.x + (rng() - 0.5) * jitter, y: base.y + (rng() - 0.5) * jitter };
410+ }
411+
412+ /**
413+ * A tile's own random stream. Keyed on the hand and its place in that seat's
414+ * discards, so the same tile scatters the same way every time it is rebuilt —
415+ * a refresh mid-hand puts the pile back exactly as it was.
416+ */
417+ private seeded(state: GameState, a: { seat: SeatId; index: number; tile: Tile }) {
418+ return makeRng(state.handNumber * 7919 + a.seat * 977 + a.index * 31 + a.tile + 1);
419+ }
420+}
modifiedsrc/ui/Center.tsx+4 −1
⋯ 51 unchanged lines
5252 className="facing"
5353 style={compact ? undefined : { transform: `rotate(${SEAT_ROT[facing]}deg)` }}
5454 >
55- {s.lastDiscard && s.phase !== 'handEnd' && (
55+ {/* The tile just thrown is out there in the middle, lit, where everyone
56+ can see it — so saying it again here would only cover the pile. The
57+ phone has no pile, and still needs telling. */}
58+ {compact && s.lastDiscard && s.phase !== 'handEnd' && (
5659 <div className="last-discard">
5760 <div className="ld-label">{s.players[s.lastDiscard.from].name} 打 discarded</div>
5861 <TileView tile={s.lastDiscard.tile} size="hand" highlight />
⋯ 131 unchanged lines
modifiedsrc/ui/Hand.tsx+206 −20
⋯ 1 unchanged line
22 import type { Tile } from '../game/tiles';
33 import { TileView } from './TileView';
44
5+export interface Throw {
6+ /** Where the tile was let go of, in table coordinates. */
7+ x: number;
8+ y: number;
9+ /** How fast, px per second. */
10+ vx: number;
11+ vy: number;
12+ spin: number;
13+}
14+
515 interface Props {
616 tiles: Tile[];
717 /** Somebody else's hand: shown as backs, and not to be poked at. */
⋯ 5 unchanged lines
1323 onSelect: (i: number) => void;
1424 onDiscard: (i: number) => void;
1525 onMove: (from: number, to: number) => void;
26+ /**
27+ * Picking a tile up and carrying it out of the row. Absent means the hand
28+ * cannot be thrown from — the phone layout, where there is no middle of the
29+ * table to throw into — and the row only ever reorders.
30+ */
31+ carry?: {
32+ /**
33+ * Lifted out of the row: from here the canvas draws it, not the DOM. `size`
34+ * is the width the tile had in the hand, so it can be drawn the size it just
35+ * was and shrink to its size in the pool rather than popping.
36+ */
37+ onPickUp: (index: number, x: number, y: number, size: number) => void;
38+ onMoveTo: (x: number, y: number) => void;
39+ /** Let go of over the table. */
40+ onThrow: (index: number, t: Throw) => void;
41+ /** Put back — the throw was never committed to. */
42+ onCancel: () => void;
43+ };
1644 }
1745
1846 /** How far a pointer must travel before a tap becomes a drag. */
1947 const DRAG_SLOP = 12;
48+/** How far out of the row before the tile is genuinely picked up. */
49+const LIFT_OUT = 22;
50+/**
51+ * A release this fast is a throw whatever else is true. Below it, the tile has
52+ * to have been carried well clear of the row to count — so a hesitant drag back
53+ * over your own hand puts the tile back instead of playing it.
54+ */
55+const THROW_SPEED = 220;
56+const COMMIT_OUT = 70;
57+/** Samples kept for reading the speed of a flick. */
58+const SAMPLES = 5;
59+
60+interface Sample {
61+ x: number;
62+ y: number;
63+ t: number;
64+}
2065
2166 /**
2267 * Finds the tile nearest the pointer. Works whatever the seat's rotation:
⋯ 1 unchanged line
2469 * off the first two tiles rather than assuming it runs left-to-right.
2570 */
2671 function nearestIndex(container: HTMLElement, x: number, y: number): number | null {
27- const els = Array.from(container.querySelectorAll<HTMLElement>('[data-idx]'));
28- if (els.length < 2) return null;
29- const centers = els.map((el) => {
30- const r = el.getBoundingClientRect();
31- return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
32- });
33- const horizontal = Math.abs(centers[1].x - centers[0].x) >= Math.abs(centers[1].y - centers[0].y);
72+ const centers = rowCenters(container);
73+ if (!centers) return null;
74+ const { points, horizontal } = centers;
3475 const along = (p: { x: number; y: number }) => (horizontal ? p.x : p.y);
3576 const p = along({ x, y });
3677
3778 let best = 0;
3879 let bestD = Infinity;
39- centers.forEach((c, i) => {
80+ points.forEach((c, i) => {
4081 const d = Math.abs(along(c) - p);
4182 if (d < bestD) {
4283 bestD = d;
⋯ 3 unchanged lines
4687 return best;
4788 }
4889
90+function rowCenters(container: HTMLElement) {
91+ const els = Array.from(container.querySelectorAll<HTMLElement>('[data-idx]'));
92+ if (els.length < 2) return null;
93+ const points = els.map((el) => {
94+ const r = el.getBoundingClientRect();
95+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
96+ });
97+ const horizontal = Math.abs(points[1].x - points[0].x) >= Math.abs(points[1].y - points[0].y);
98+ return { points, horizontal };
99+}
100+
101+/**
102+ * Which way is "out of the row, towards the middle of the table".
103+ *
104+ * Read off the elements rather than from the seat's rotation, the same way
105+ * `nearestIndex` reads the row's direction: the hand and the centre are both on
106+ * screen, so the line between them is the answer for any seat at any angle.
107+ */
108+function outwardOf(container: HTMLElement) {
109+ const table = container.closest<HTMLElement>('.table');
110+ const hand = container.getBoundingClientRect();
111+ const from = { x: hand.left + hand.width / 2, y: hand.top + hand.height / 2 };
112+ if (!table) return { x: 0, y: -1, from, base: { x: 0, y: 0 } };
113+ const t = table.getBoundingClientRect();
114+ const to = { x: t.left + t.width / 2, y: t.top + t.height / 2 };
115+ const dx = to.x - from.x;
116+ const dy = to.y - from.y;
117+ const d = Math.hypot(dx, dy) || 1;
118+ return { x: dx / d, y: dy / d, from, base: { x: t.left, y: t.top } };
119+}
120+
121+/**
122+ * How wide the tile is in the hand. A seat's strip is rotated by a right angle,
123+ * which swaps its bounding box, and a tile is always taller than it is wide — so
124+ * the narrower side is the width whichever edge the player is sitting at.
125+ */
126+function tileWidth(container: HTMLElement, index: number): number {
127+ const el = container.querySelector<HTMLElement>(`[data-idx="${index}"] .tile`);
128+ if (!el) return 0;
129+ const r = el.getBoundingClientRect();
130+ return Math.min(r.width, r.height);
131+}
132+
133+type Mode = 'undecided' | 'sort' | 'carry';
134+
135+interface Drag {
136+ from: number;
137+ cur: number;
138+ startX: number;
139+ startY: number;
140+ mode: Mode;
141+ outward: ReturnType<typeof outwardOf>;
142+ samples: Sample[];
143+}
144+
49145 export function Hand({
50146 tiles,
51147 faceDown,
⋯ 3 unchanged lines
55151 onSelect,
56152 onDiscard,
57153 onMove,
154+ carry,
58155 }: Props) {
59156 const ref = useRef<HTMLDivElement>(null);
60- const drag = useRef<{ from: number; cur: number; x: number; y: number; moved: boolean } | null>(null);
61- const [dragging, setDragging] = useState<number | null>(null);
157+ const drag = useRef<Drag | null>(null);
158+ const [sorting, setSorting] = useState<number | null>(null);
159+ const [carrying, setCarrying] = useState<number | null>(null);
62160
63161 const down = (e: React.PointerEvent, i: number) => {
64162 (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
65- drag.current = { from: i, cur: i, x: e.clientX, y: e.clientY, moved: false };
163+ if (!ref.current) return;
164+ drag.current = {
165+ from: i,
166+ cur: i,
167+ startX: e.clientX,
168+ startY: e.clientY,
169+ mode: 'undecided',
170+ outward: outwardOf(ref.current),
171+ samples: [{ x: e.clientX, y: e.clientY, t: e.timeStamp }],
172+ };
66173 };
67174
68175 const move = (e: React.PointerEvent) => {
69176 const d = drag.current;
70177 if (!d || !ref.current) return;
71- if (!d.moved && Math.hypot(e.clientX - d.x, e.clientY - d.y) < DRAG_SLOP) return;
72- if (!d.moved) {
73- d.moved = true;
74- setDragging(d.cur);
178+
179+ d.samples.push({ x: e.clientX, y: e.clientY, t: e.timeStamp });
180+ if (d.samples.length > SAMPLES) d.samples.shift();
181+
182+ const dx = e.clientX - d.startX;
183+ const dy = e.clientY - d.startY;
184+ const out = dx * d.outward.x + dy * d.outward.y;
185+
186+ if (d.mode === 'undecided') {
187+ if (Math.hypot(dx, dy) < DRAG_SLOP) return;
188+ // Along the row is arranging your hand; out of it is picking the tile up.
189+ // Which axis the movement is mostly on decides, once, and it sticks.
190+ const along = Math.abs(dx * -d.outward.y + dy * d.outward.x);
191+ if (carry && canDiscard && out > along && out > LIFT_OUT) {
192+ d.mode = 'carry';
193+ setCarrying(d.from);
194+ carry.onPickUp(
195+ d.from,
196+ e.clientX - d.outward.base.x,
197+ e.clientY - d.outward.base.y,
198+ tileWidth(ref.current, d.from),
199+ );
200+ return;
201+ }
202+ if (out > along && out > LIFT_OUT) return; // wants to lift, but cannot
203+ d.mode = 'sort';
204+ setSorting(d.cur);
205+ }
206+
207+ if (d.mode === 'carry') {
208+ carry?.onMoveTo(e.clientX - d.outward.base.x, e.clientY - d.outward.base.y);
209+ return;
75210 }
211+
76212 const to = nearestIndex(ref.current, e.clientX, e.clientY);
77213 if (to !== null && to !== d.cur) {
78214 onMove(d.cur, to);
79215 d.cur = to;
80- setDragging(to);
216+ setSorting(to);
81217 }
82218 };
83219
84- const up = () => {
220+ const up = (e: React.PointerEvent) => {
85221 const d = drag.current;
86222 drag.current = null;
87- setDragging(null);
88- if (!d || d.moved || !canDiscard) return;
223+ setSorting(null);
224+ setCarrying(null);
225+ if (!d) return;
226+
227+ if (d.mode === 'carry') {
228+ const v = velocityOf(d.samples, e.timeStamp, e.clientX, e.clientY);
229+ const dx = e.clientX - d.startX;
230+ const dy = e.clientY - d.startY;
231+ const out = dx * d.outward.x + dy * d.outward.y;
232+ const speedOut = v.vx * d.outward.x + v.vy * d.outward.y;
233+ // Thrown if it left the fingers moving towards the table, or if it was
234+ // carried far enough out that letting go there plainly means playing it.
235+ if (speedOut > THROW_SPEED || out > COMMIT_OUT) {
236+ carry?.onThrow(d.from, {
237+ x: e.clientX - d.outward.base.x,
238+ y: e.clientY - d.outward.base.y,
239+ vx: v.vx,
240+ vy: v.vy,
241+ // A flick that curves puts a turn on the tile, as a real one does.
242+ spin: v.spin,
243+ });
244+ } else {
245+ carry?.onCancel();
246+ }
247+ return;
248+ }
249+
250+ if (d.mode === 'sort' || !canDiscard) return;
89251 if (selected === d.from) onDiscard(d.from);
90252 else onSelect(d.from);
91253 };
⋯ 19 unchanged lines
111273 className={
112274 'hand-cell' +
113275 (i === drawnIndex ? ' drawn-slot' : '') +
114- (i === dragging ? ' dragging' : '')
276+ (i === sorting ? ' dragging' : '') +
277+ // Its slot stays, so the row does not shuffle under the tile you are
278+ // holding and you can still put it back where it came from.
279+ (i === carrying ? ' carrying' : '')
115280 }
116281 onPointerDown={(e) => down(e, i)}
117282 >
⋯ 4 unchanged lines
122287 </div>
123288 );
124289 }
290+
291+/**
292+ * How fast the tile was moving when it was let go of, from the last few pointer
293+ * positions rather than just the final one — a single event pair is noisy enough
294+ * to turn a firm throw into a limp one.
295+ */
296+function velocityOf(samples: Sample[], t: number, x: number, y: number) {
297+ const last = { x, y, t };
298+ // Far enough back to be a measurement, near enough to still be the flick.
299+ const first = samples.find((s) => last.t - s.t < 90) ?? samples[0];
300+ const dt = (last.t - first.t) / 1000;
301+ if (!first || dt <= 0.001) return { vx: 0, vy: 0, spin: 0 };
302+
303+ const vx = (last.x - first.x) / dt;
304+ const vy = (last.y - first.y) / dt;
305+ // Spin comes from how much the flick bent between its two halves.
306+ const mid = samples[Math.floor(samples.length / 2)] ?? first;
307+ const bend =
308+ (mid.x - first.x) * (last.y - mid.y) - (mid.y - first.y) * (last.x - mid.x);
309+ return { vx, vy, spin: Math.max(-9, Math.min(9, bend * 0.004)) };
310+}
addedsrc/ui/Pool.tsx+224 −0
1+import { useEffect, useRef, useState } from 'react';
2+import type { Game } from '../game/engine';
3+import { knock } from '../game/sound';
4+import { SPRITE_COL, SPRITE_COLS } from '../game/tiles';
5+import { zScale } from '../table/physics';
6+import type { TablePool } from '../table/pool';
7+
8+/**
9+ * The tiles in the middle of the table, drawn.
10+ *
11+ * A canvas rather than elements, and it spans the whole table rather than just
12+ * the centre. The span is the point: `.slot` and `.seat` both clip their own
13+ * contents, which is exactly why a tile cannot be animated out of a strip as a
14+ * div — on the canvas there is nothing to clip it, so a tile lifted out of a
15+ * hand and thrown crosses the table in one piece.
16+ *
17+ * The loop stops as soon as the pile has settled, and starts again when the
18+ * state changes, a tile is picked up, or the window resizes, so a table nobody
19+ * is touching costs nothing.
20+ */
21+export function Pool({ game, pool }: { game: Game; pool: TablePool }) {
22+ const canvasRef = useRef<HTMLCanvasElement>(null);
23+ const sheet = useSheet();
24+
25+ // What the loop reads. Refreshed on every render, read on every frame.
26+ const live = useRef({ state: game.state, sheet });
27+ live.current = { state: game.state, sheet };
28+
29+ // Poked by the effect below, and by the gesture through pool.wake.
30+ const wake = useRef<() => void>(() => {});
31+
32+ useEffect(() => {
33+ const canvas = canvasRef.current;
34+ const table = canvas?.parentElement;
35+ const ctx = canvas?.getContext('2d');
36+ if (!canvas || !table || !ctx) return;
37+
38+ pool.attach(table);
39+
40+ let frame = 0;
41+ let previous = 0;
42+
43+ const resize = () => {
44+ const dpr = window.devicePixelRatio || 1;
45+ const w = Math.round(table.clientWidth * dpr);
46+ const h = Math.round(table.clientHeight * dpr);
47+ if (canvas.width !== w || canvas.height !== h) {
48+ canvas.width = w;
49+ canvas.height = h;
50+ }
51+ // The square moved, so the colliders and the launch points have too.
52+ pool.invalidate();
53+ run();
54+ };
55+
56+ const tick = (now: number) => {
57+ frame = 0;
58+ // First frame after waking has no previous time to measure against.
59+ const dt = previous ? Math.min(0.05, (now - previous) / 1000) : 0;
60+ previous = now;
61+
62+ pool.sync(live.current.state);
63+ // The loudest contact this frame is the one you would actually hear; the
64+ // rest of a scatter is the same event.
65+ const impacts = pool.advance(dt);
66+ let loudest = 0;
67+ for (const i of impacts) loudest = Math.max(loudest, i.strength);
68+ if (loudest > 0) knock(loudest);
69+ paint(ctx, canvas, pool, live.current.sheet);
70+
71+ // Keep going while anything is moving; otherwise wait to be poked.
72+ if (!pool.settled) frame = requestAnimationFrame(tick);
73+ else previous = 0;
74+ };
75+
76+ const run = () => {
77+ if (!frame) frame = requestAnimationFrame(tick);
78+ };
79+ wake.current = run;
80+ pool.wake = run;
81+
82+ const observer = new ResizeObserver(resize);
83+ observer.observe(table);
84+ resize();
85+
86+ return () => {
87+ observer.disconnect();
88+ if (frame) cancelAnimationFrame(frame);
89+ wake.current = () => {};
90+ pool.wake = () => {};
91+ pool.detach();
92+ };
93+ }, [pool]);
94+
95+ // Any change to the table — a discard, a claim, an undo, a new hand — is a
96+ // reason to look again.
97+ useEffect(() => {
98+ wake.current();
99+ });
100+
101+ return <canvas className="tile-pool" ref={canvasRef} aria-hidden />;
102+}
103+
104+/**
105+ * The tile sheet, as something canvas can draw. The same file the CSS uses for
106+ * the tiles in the hands, so the pool cannot drift from the rest of the table.
107+ */
108+function useSheet(): HTMLImageElement | null {
109+ const [img, setImg] = useState<HTMLImageElement | null>(null);
110+ useEffect(() => {
111+ let alive = true;
112+ const el = new Image();
113+ el.src = '/tiles/tiles.svg';
114+ el.decode()
115+ .then(() => alive && setImg(el))
116+ // Not fatal: the pool falls back to blank tiles rather than emptying out.
117+ .catch(() => {});
118+ return () => {
119+ alive = false;
120+ };
121+ }, []);
122+ return img;
123+}
124+
125+/** How high a tile in somebody's fingers is held above the felt. */
126+const HELD_Z = 52;
127+
128+function paint(
129+ ctx: CanvasRenderingContext2D,
130+ canvas: HTMLCanvasElement,
131+ pool: TablePool,
132+ sheet: HTMLImageElement | null,
133+) {
134+ const dpr = window.devicePixelRatio || 1;
135+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
136+ ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
137+
138+ const bodies = pool.bodies;
139+ const held = pool.held;
140+
141+ // Shadows in one pass of their own, offset before rotation, so the light comes
142+ // from one direction across the whole pool rather than turning with each tile.
143+ for (const b of bodies) shadow(ctx, b.x, b.y, b.z, b.angle, b.w * b.scale, b.h * b.scale);
144+ if (held) shadow(ctx, held.x, held.y, HELD_Z, held.angle, held.w, held.h);
145+
146+ const newest = pool.newest;
147+ for (const b of bodies) {
148+ const s = zScale(b.z) * b.scale;
149+ ctx.save();
150+ ctx.translate(b.x, b.y);
151+ ctx.rotate(b.angle);
152+ face(ctx, b.tile, b.w * s, b.h * s, sheet, b === newest);
153+ ctx.restore();
154+ }
155+
156+ // Whatever is in your fingers is above everything else on the table.
157+ if (held) {
158+ const s = zScale(HELD_Z);
159+ ctx.save();
160+ ctx.translate(held.x, held.y);
161+ ctx.rotate(held.angle);
162+ face(ctx, held.tile, held.w * s, held.h * s, sheet, true);
163+ ctx.restore();
164+ }
165+}
166+
167+/**
168+ * A soft silhouette under a tile. Blurred where the browser will do it — a hard
169+ * black slab reads as a second tile rather than as a shadow — and simply a faint
170+ * one where it will not.
171+ */
172+function shadow(
173+ ctx: CanvasRenderingContext2D,
174+ x: number,
175+ y: number,
176+ z: number,
177+ angle: number,
178+ w: number,
179+ h: number,
180+) {
181+ ctx.save();
182+ ctx.filter = `blur(${(2.5 + z * 0.07).toFixed(1)}px)`;
183+ ctx.fillStyle = `rgba(0, 0, 0, ${(0.34 - Math.min(0.16, z * 0.0014)).toFixed(3)})`;
184+ ctx.translate(x + z * 0.12, y + 3 + z * 0.34);
185+ ctx.rotate(angle);
186+ const s = zScale(z) * 1.03;
187+ tilePath(ctx, w * s, h * s);
188+ ctx.fill();
189+ ctx.restore();
190+}
191+
192+function face(
193+ ctx: CanvasRenderingContext2D,
194+ tile: number,
195+ w: number,
196+ h: number,
197+ sheet: HTMLImageElement | null,
198+ lit: boolean,
199+) {
200+ if (sheet) {
201+ // 43 columns, two rows; the second is the sheet's own highlighted variant,
202+ // which is what a lifted tile in a hand uses. The tile in your fingers and
203+ // the one still to be claimed get it here for the same reason.
204+ const cw = sheet.naturalWidth / SPRITE_COLS;
205+ const ch = sheet.naturalHeight / 2;
206+ ctx.drawImage(sheet, SPRITE_COL[tile] * cw, lit ? ch : 0, cw, ch, -w / 2, -h / 2, w, h);
207+ } else {
208+ tilePath(ctx, w, h);
209+ ctx.fillStyle = '#f6f0e2';
210+ ctx.fill();
211+ }
212+ if (lit) {
213+ // A pool of sixty tiles needs more than a lighter face to pick one out.
214+ tilePath(ctx, w, h);
215+ ctx.strokeStyle = '#d9b45c';
216+ ctx.lineWidth = 2;
217+ ctx.stroke();
218+ }
219+}
220+
221+function tilePath(ctx: CanvasRenderingContext2D, w: number, h: number) {
222+ ctx.beginPath();
223+ ctx.roundRect(-w / 2, -h / 2, w, h, w * 0.1);
224+}
modifiedsrc/ui/Seat.tsx+49 −2
⋯ 1 unchanged line
22 import type { Game } from '../game/engine';
33 import { labelOf, WIND_EN, WIND_NAMES } from '../game/tiles';
44 import type { SeatId } from '../game/types';
5+import type { TablePool } from '../table/pool';
56 import { Bi, CLAIM_LABEL } from './Bi';
67 import { Hand } from './Hand';
78 import { TileView } from './TileView';
⋯ 8 unchanged lines
1617 seat: SeatId;
1718 /** Phone-sized table: nothing is rotated and only seat 0 gets a full strip. */
1819 compact?: boolean;
20+ /** The tiles in the middle. Absent on a phone, which has no middle. */
21+ pool?: TablePool;
1922 onHelp: () => void;
2023 }
2124
22-export function Seat({ game, seat, compact, onHelp }: Props) {
25+export function Seat({ game, seat, compact, pool, onHelp }: Props) {
2326 const s = game.state;
2427 const p = s.players[seat];
2528 const [sel, setSel] = useState<number | null>(null);
⋯ 47 unchanged lines
7376 const drawnIndex = myTurn && s.drawnSeparate && s.drawnTile !== null ? p.hand.length - 1 : null;
7477 const selTile = sel === null ? null : p.hand[sel] ?? null;
7578
79+ // Picking a tile up and throwing it in. Only on your own turn, and only where
80+ // there is a middle of the table to throw into — the phone layout has none, so
81+ // there the row goes back to only ever being rearranged.
82+ const carry =
83+ pool && myTurn
84+ ? {
85+ onPickUp: (i: number, x: number, y: number, size: number) => {
86+ pool.pickUp({
87+ seat,
88+ tile: p.hand[i],
89+ index: i,
90+ x,
91+ y,
92+ // Facing the way it did in the hand, so it does not snap round the
93+ // instant it is lifted.
94+ angle: (ROT[seat] * Math.PI) / 180,
95+ w: size,
96+ h: size * 1.375,
97+ });
98+ pool.wake();
99+ },
100+ onMoveTo: (x: number, y: number) => {
101+ pool.moveHeld(x, y);
102+ pool.wake();
103+ },
104+ onThrow: (i: number, t: { x: number; y: number; vx: number; vy: number; spin: number }) => {
105+ const tile = p.hand[i];
106+ if (tile === undefined) return pool.cancelHeld();
107+ // Where it left the fingers is where the throw starts from.
108+ pool.moveHeld(t.x, t.y);
109+ pool.throwHeld(t.vx, t.vy, t.spin);
110+ game.discard(seat, tile);
111+ setSel(null);
112+ },
113+ onCancel: () => {
114+ pool.cancelHeld();
115+ pool.wake();
116+ },
117+ }
118+ : undefined;
119+
76120 return (
77- <div className={`slot ${SLOT[seat]} ${seat === 0 ? 'mine' : 'opp'}`}>
121+ // `data-seat` is how table/geometry.ts finds this seat's hand to work out
122+ // where it throws from — see measure().
123+ <div className={`slot ${SLOT[seat]} ${seat === 0 ? 'mine' : 'opp'}`} data-seat={seat}>
78124 <div
79125 className={`seat ${myTurn ? 'active' : ''} ${claiming ? 'claiming' : ''}`}
80126 style={compact ? undefined : { transform: `translate(-50%, -50%) rotate(${ROT[seat]}deg)` }}
⋯ 109 unchanged lines
190236 onSelect={setSel}
191237 onDiscard={doDiscard}
192238 onMove={(from, to) => game.reorderHand(seat, from, to)}
239+ carry={carry}
193240 />
194241
195242 <div className="nameplate">
⋯ 33 unchanged lines