anvilsign in

collin/mahjong

1import type { GameState } from '../game/engine';
2import { makeRng, type Tile } from '../game/tiles';
3import type { SeatId } from '../game/types';
4import { aimAt, canThrow, centreOf, measure, type Point, type TableGeometry } from './geometry';
5import {
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
18const 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. */
38const PLACE_FLIGHT = 0.62;
39/** A thrown tile hops rather than lofts — it is going through a gap, not over. */
40const THROW_LIFT = 0.12;
41/** Fastest flick we will honour, px/s. Beyond this it is a slip, not a throw. */
42export const MAX_THROW_SPEED = 2800;
43/** How long a claimed tile takes to leave the table for the seat taking it. */
44const TAKE_FLIGHT = 0.34;
45
46/** A tile on its way off the table, no longer part of the pile. */
47interface Leaving {
48 body: Body;
49 tx: number;
50 ty: number;
51 /** 0 to 1. */
52 t: number;
53}
54
55/** A tile lifted out of a hand and carried about. Drawn, but not simulated. */
56export interface Held {
57 seat: SeatId;
58 tile: Tile;
59 /** Where it came from in that seat's hand, so the caller knows what to play. */
60 index: number;
61 x: number;
62 y: number;
63 angle: number;
64 /** The size it was in the hand — it shrinks to pool size once thrown. */
65 w: number;
66 h: number;
67}
68
69/** A throw that has been let go of, waiting for the discard it belongs to. */
70interface Release {
71 seat: SeatId;
72 x: number;
73 y: number;
74 angle: number;
75 w: number;
76 h: number;
77 vx: number;
78 vy: number;
79 spin: number;
80}
81
82export class TablePool {
83 world: World | null = null;
84 geo: TableGeometry | null = null;
85 /** The tile in somebody's fingers, if any. */
86 held: Held | null = null;
87 /**
88 * Poke the render loop. Set by ui/Pool.tsx while it is mounted; a gesture
89 * calls it so a carried tile follows the finger without waiting on a re-render.
90 */
91 wake: () => void = () => {};
92
93 private table: HTMLElement | null = null;
94 /** Bodies by `seat:index` into that seat's discards. */
95 private entries = new Map<string, Body>();
96 private release: Release | null = null;
97 /** What the wall looked like when we last measured, so we know to re-measure. */
98 private measuredAt = '';
99 private hand = -1;
100 /** The tile currently on the table to be claimed, so it can be marked. */
101 private newestKey: string | null = null;
102 /** How many melds each seat had last time, to spot who took a tile. */
103 private melds = [0, 0, 0, 0];
104 /** Tiles on their way off the table. Drawn, but out of the physics. */
105 private leaving: Leaving[] = [];
106
107 // Which seats can throw, as a string, so the seats can re-render when it
108 // changes. Read through useSyncExternalStore, the same way the game is.
109 private openness = '----';
110 private listeners = new Set<() => void>();
111
112 attach(table: HTMLElement) {
113 this.table = table;
114 this.measuredAt = '';
115 }
116
117 detach() {
118 this.table = null;
119 this.world = null;
120 this.geo = null;
121 this.held = null;
122 this.release = null;
123 this.entries.clear();
124 this.leaving = [];
125 this.measuredAt = '';
126 }
127
128 subscribe = (cb: () => void) => {
129 this.listeners.add(cb);
130 return () => this.listeners.delete(cb);
131 };
132
133 /** Which seats have a clear line in, as a string — cheap to compare. */
134 getSnapshot = () => this.openness;
135
136 /** Force a re-measure — the window resized, or the layout changed under us. */
137 invalidate() {
138 this.measuredAt = '';
139 }
140
141 /**
142 * Whether this seat can flick a tile in, or has to lift it over the wall.
143 * Unmeasured (or compact, where there is no square) means no.
144 */
145 canThrow(seat: SeatId): boolean {
146 return this.openness[seat] === 'y';
147 }
148
149 /** Where the middle of the table is, for a gesture working out its aim. */
150 get poolCentre(): Point | null {
151 return this.geo ? centreOf(this.geo.pool) : null;
152 }
153
154 /** Whether a point is already inside the square — a tile dropped there stays. */
155 inPool(x: number, y: number): boolean {
156 const p = this.geo?.pool;
157 if (!p) return false;
158 return x >= p.x && x <= p.x + p.w && y >= p.y && y <= p.y + p.h;
159 }
160
161 // ---- the tile in your fingers -----------------------------------------
162
163 /** Lifted out of a hand. From here the canvas draws it, not the DOM. */
164 pickUp(held: Held) {
165 this.held = held;
166 }
167
168 moveHeld(x: number, y: number, angle?: number) {
169 if (!this.held) return;
170 this.held.x = x;
171 this.held.y = y;
172 if (angle !== undefined) this.held.angle = angle;
173 }
174
175 /** Put it back in the hand — the throw was not committed to. */
176 cancelHeld() {
177 this.held = null;
178 }
179
180 /**
181 * Let go of it. The body is not made here: the reconciler owns every tile in
182 * the pool, so this leaves the throw where the next `sync` will find it and
183 * the caller goes on to call `game.discard`. The held tile stays drawn until
184 * that happens, so there is no frame with nothing on screen.
185 */
186 throwHeld(vx: number, vy: number, spin: number) {
187 const h = this.held;
188 if (!h) return;
189 const speed = Math.hypot(vx, vy);
190 const scale = speed > MAX_THROW_SPEED ? MAX_THROW_SPEED / speed : 1;
191 this.release = {
192 seat: h.seat,
193 x: h.x,
194 y: h.y,
195 angle: h.angle,
196 w: h.w,
197 h: h.h,
198 vx: vx * scale,
199 vy: vy * scale,
200 spin,
201 };
202 }
203
204 // ---- keeping up with the game ----------------------------------------
205
206 /** Bring the pile in line with the state. Cheap when nothing has changed. */
207 sync(state: GameState) {
208 this.remeasure(state);
209 const geo = this.geo;
210 const world = this.world;
211 if (!geo || !world) return;
212
213 // A new hand is a new pile, and nothing should fly anywhere.
214 const fresh = state.handNumber !== this.hand;
215 this.hand = state.handNumber;
216
217 const want = new Set<string>();
218 const added: { key: string; seat: SeatId; index: number; tile: Tile }[] = [];
219 for (const seat of SEATS) {
220 const discards = state.players[seat].discards;
221 for (let i = 0; i < discards.length; i++) {
222 const key = `${seat}:${i}`;
223 want.add(key);
224 if (!this.entries.has(key)) added.push({ key, seat, index: i, tile: discards[i] });
225 }
226 }
227
228 const taken: Body[] = [];
229 for (const [key, body] of this.entries) {
230 if (want.has(key)) continue;
231 const at = world.bodies.indexOf(body);
232 if (at >= 0) world.bodies.splice(at, 1);
233 this.entries.delete(key);
234 taken.push(body);
235 }
236
237 // Exactly one tile off the table is somebody taking it — a 碰, a 吃, a 槓,
238 // or a discard being taken back. It goes to whoever now has a meld they did
239 // not have a moment ago, and failing that back to the seat that threw it,
240 // which is what an undo is. Several at once is the hand ending, and those
241 // simply go.
242 const claimer = SEATS.find((q) => state.players[q].melds.length > this.melds[q]);
243 this.melds = SEATS.map((q) => state.players[q].melds.length);
244 if (taken.length === 1 && !fresh) {
245 const to = geo.launch[claimer ?? taken[0].seat];
246 if (to) this.leaving.push({ body: taken[0], tx: to.x, ty: to.y, t: 0 });
247 }
248
249 // One tile at a time is somebody playing, and it gets thrown. Several at
250 // once is a pile being rebuilt — a save picked up, an undo, a fresh deal —
251 // and those tiles were thrown long ago, so they are simply already there.
252 const rebuilding = fresh || added.length > 1;
253 for (const a of added) {
254 this.entries.set(a.key, rebuilding ? this.settle(geo, state, a) : this.launch(geo, state, a));
255 }
256
257 // A throw is good for exactly one discard, and the tile it belonged to has
258 // either just arrived or is never going to.
259 if (added.length || this.release) {
260 this.release = null;
261 this.held = null;
262 }
263
264 const ld = state.lastDiscard;
265 this.newestKey = ld ? `${ld.from}:${state.players[ld.from].discards.length - 1}` : null;
266 }
267
268 /** Run the physics on. Returns impacts worth a sound. */
269 advance(seconds: number): Impact[] {
270 this.carryOff(seconds);
271 return this.world ? advanceWorld(this.world, seconds) : [];
272 }
273
274 /** Tiles leaving the table, lifted and carried off to the seat taking them. */
275 private carryOff(seconds: number) {
276 if (this.leaving.length === 0) return;
277 for (const l of this.leaving) {
278 l.t = Math.min(1, l.t + seconds / TAKE_FLIGHT);
279 // Ease out, so it leaves quickly and arrives gently.
280 const k = 1 - (1 - l.t) * (1 - l.t);
281 l.body.x += (l.tx - l.body.x) * k * 0.5;
282 l.body.y += (l.ty - l.body.y) * k * 0.5;
283 // Up off the table on the way, the way a tile picked up would be.
284 l.body.z = Math.sin(l.t * Math.PI) * 40;
285 l.body.angle += seconds * 2.2;
286 }
287 this.leaving = this.leaving.filter((l) => l.t < 1);
288 }
289
290 /** What is on its way off, and how far gone, for the renderer to fade. */
291 get departing(): { body: Body; fade: number }[] {
292 return this.leaving.map((l) => ({ body: l.body, fade: 1 - l.t }));
293 }
294
295 get bodies(): Body[] {
296 return this.world?.bodies ?? [];
297 }
298
299 /** The tile on the table, if there is one — drawn lit, as a lifted tile is. */
300 get newest(): Body | null {
301 return (this.newestKey && this.entries.get(this.newestKey)) || null;
302 }
303
304 /** Nothing is moving, so the render loop can stop until something changes. */
305 get settled(): boolean {
306 return !this.held && this.leaving.length === 0 && (!this.world || settled(this.world));
307 }
308
309 // ---- measuring ---------------------------------------------------------
310
311 /**
312 * Re-read the table when the wall's shape has changed or the window has. The
313 * pile keeps its positions across a re-measure: the wall shrinking does not
314 * move the tiles already on the felt.
315 */
316 private remeasure(state: GameState) {
317 const table = this.table;
318 if (!table) return;
319 const stamp = `${state.drawnFront}/${state.drawnBack}/${Math.round(
320 table.clientWidth,
321 )}x${Math.round(table.clientHeight)}`;
322 if (stamp === this.measuredAt) return;
323
324 const geo = measure(table, state);
325 this.measuredAt = geo ? stamp : '';
326 this.geo = geo;
327 if (!geo) {
328 this.world = null;
329 this.entries.clear();
330 this.setOpenness('----');
331 return;
332 }
333 if (!this.world) this.world = createWorld(geo.pool, geo.walls);
334 else reshape(this.world, geo.pool, geo.walls);
335 // Only here, never per frame: a hundred-odd ray casts is nothing once a
336 // draw, and a great deal every sixtieth of a second.
337 this.setOpenness(SEATS.map((s) => (canThrow(geo, s) ? 'y' : '-')).join(''));
338 }
339
340 private setOpenness(next: string) {
341 if (next === this.openness) return;
342 this.openness = next;
343 for (const cb of this.listeners) cb();
344 }
345
346 // ---- putting tiles in -------------------------------------------------
347
348 /** A tile that is simply there already — a rebuilt pile. */
349 private settle(
350 geo: TableGeometry,
351 state: GameState,
352 a: { seat: SeatId; index: number; tile: Tile },
353 ): Body {
354 const rng = this.seeded(state, a);
355 const { pool } = geo;
356 // Bunched towards the middle rather than spread evenly to the edges, which
357 // is what a pool thrown into from four sides actually looks like.
358 const spread = 0.3;
359 return addBody(this.world!, {
360 tile: a.tile,
361 seat: a.seat,
362 x: pool.x + pool.w * (0.5 + (rng() - 0.5) * 2 * spread),
363 y: pool.y + pool.h * (0.5 + (rng() - 0.5) * 2 * spread),
364 vx: 0,
365 vy: 0,
366 z: 0,
367 vz: 0,
368 angle: (rng() - 0.5) * Math.PI,
369 spin: 0,
370 w: geo.tile.w,
371 h: geo.tile.h,
372 atRest: true,
373 });
374 }
375
376 /** A tile arriving now: thrown if somebody threw it, placed over the wall if
377 * it was tapped, and flat and quick if a bot's wall happens to be open. */
378 private launch(
379 geo: TableGeometry,
380 state: GameState,
381 a: { seat: SeatId; index: number; tile: Tile },
382 ): Body {
383 const rng = this.seeded(state, a);
384 const thrown = this.release?.seat === a.seat ? this.release : null;
385
386 if (thrown) {
387 // Which of the two ways a tile goes in, decided by what is in its way.
388 //
389 // A clear line to the square means it can be *slid* — flat across the
390 // cloth, at exactly the speed it was let go at, which is what a flick
391 // wants to be. A standing wall means it has to be *thrown* instead: lifted
392 // over the top and dropped in, and then the flick's speed decides how far
393 // across the pool it lands rather than how fast it skates.
394 const set = this.inPool(thrown.x, thrown.y);
395 const slide = set || this.canThrow(a.seat);
396 let { vx, vy } = thrown;
397 let vz = 0;
398
399 if (!slide) {
400 const centre = centreOf(geo.pool);
401 const reach = PLACE_FLIGHT + SLIDE_TIME;
402 // Hard enough to clear the wall, and never hard enough to slam the far
403 // side of the square — a lob has to land on the table.
404 const cap = (Math.hypot(centre.x - thrown.x, centre.y - thrown.y) / reach) * 1.25;
405 const speed = Math.hypot(vx, vy);
406 const aim = Math.min(speed, cap) / (speed || 1);
407 vx *= aim;
408 vy *= aim;
409 vz = liftFor(PLACE_FLIGHT);
410 } else if (!set) {
411 // A skated tile still leaves the fingers slightly off the cloth.
412 vz = liftFor(THROW_LIFT);
413 }
414
415 return addBody(this.world!, {
416 tile: a.tile,
417 seat: a.seat,
418 x: thrown.x,
419 y: thrown.y,
420 vx,
421 vy,
422 z: 0,
423 vz,
424 angle: thrown.angle,
425 spin: thrown.spin,
426 w: geo.tile.w,
427 h: geo.tile.h,
428 // It was drawn at hand size a moment ago; it shrinks on the way down.
429 scale: thrown.w / geo.tile.w,
430 });
431 }
432
433 const from = geo.launch[a.seat] ?? centreOf(geo.pool);
434 const open = this.canThrow(a.seat);
435 const target = this.aimFor(geo, a.seat, rng);
436 // Aim for where it comes to *rest*, not where it first touches down: a tile
437 // keeps sliding after it lands, and SLIDE_TIME is roughly how much further.
438 const flight = open ? 0.34 + rng() * 0.1 : PLACE_FLIGHT;
439 const reach = flight + SLIDE_TIME;
440
441 return addBody(this.world!, {
442 tile: a.tile,
443 seat: a.seat,
444 x: from.x,
445 y: from.y,
446 vx: (target.x - from.x) / reach,
447 vy: (target.y - from.y) / reach,
448 z: 0,
449 vz: liftFor(flight),
450 angle: (rng() - 0.5) * Math.PI,
451 spin: (rng() - 0.5) * (open ? 7 : 2.5),
452 w: geo.tile.w,
453 h: geo.tile.h,
454 });
455 }
456
457 /** Where a tile from this seat is headed, with a little scatter. */
458 private aimFor(geo: TableGeometry, seat: SeatId, rng: () => number): Point {
459 const base = aimAt(geo, seat) ?? centreOf(geo.pool);
460 const jitter = Math.min(geo.pool.w, geo.pool.h) * 0.22;
461 return { x: base.x + (rng() - 0.5) * jitter, y: base.y + (rng() - 0.5) * jitter };
462 }
463
464 /**
465 * A tile's own random stream. Keyed on the hand and its place in that seat's
466 * discards, so the same tile scatters the same way every time it is rebuilt —
467 * a refresh mid-hand puts the pile back exactly as it was.
468 */
469 private seeded(state: GameState, a: { seat: SeatId; index: number; tile: Tile }) {
470 return makeRng(state.handNumber * 7919 + a.seat * 977 + a.index * 31 + a.tile + 1);
471 }
472}