anvilsign in

collin/mahjong

1import type { GameState } from '../game/engine';
2import { makeRng, type Tile } from '../game/tiles';
3import type { SeatId } from '../game/types';
4import {
5 aimAt,
6 canThrow,
7 canThrowFrom,
8 centreOf,
9 measure,
10 type Point,
11 type TableGeometry,
12} from './geometry';
13import {
14 addBody,
15 advance as advanceWorld,
16 createWorld,
17 liftFor,
18 reshape,
19 settled,
20 stir,
21 SLIDE_TIME,
22 type Body,
23 type Impact,
24 type Rect,
25 type World,
26} from './physics';
27
28const SEATS: SeatId[] = [0, 1, 2, 3];
29
30/**
31 * The tiles in the middle, kept honest against the game state.
32 *
33 * This *diffs* rather than listening for events, and that is the whole design.
34 * A discard is identified by which seat threw it and how many it had thrown
35 * before — and those keys never shift, because discards are only ever pushed and
36 * `engine.executeClaim` only ever pops the one on top. So every way the pile can
37 * change is the same code path: a key that has appeared is a tile to throw in, a
38 * key that has gone is a tile to take out. Undo, 下一局, a claim, and picking up
39 * a saved game all fall out of that without the engine knowing this exists.
40 *
41 * Nothing here is stored in the game state, so there is no save format to
42 * change. A pile is rebuilt from the discards it can see, seeded off the hand
43 * number and each tile's place in it, which is why a refresh mid-hand comes back
44 * to the pile it had rather than a freshly scattered one.
45 */
46
47/** How long a placed tile is in the air. Long enough to arc over the wall. */
48const PLACE_FLIGHT = 0.62;
49/** A flicked tile hops rather than lofts — across the table, not over a wall. */
50const THROW_LIFT = 0.18;
51/** Fastest flick we will honour, px/s. Beyond this it is a slip, not a throw. */
52export const MAX_THROW_SPEED = 3600;
53/** How long a claimed tile takes to leave the table for the seat taking it. */
54const TAKE_FLIGHT = 0.34;
55
56/** A tile on its way off the table, no longer part of the pile. */
57interface Leaving {
58 body: Body;
59 tx: number;
60 ty: number;
61 /** 0 to 1. */
62 t: number;
63}
64
65/**
66 * Whether a tile turns over on its way in.
67 *
68 * Off for now. Like `LOB_WHEN_WALLED` below, the tumble is kept whole — the
69 * flip is a real axis in `physics.ts` and the renderer already squashes the
70 * tile along it — and this is the only switch that reaches it, so putting it
71 * back is one word rather than a rewrite.
72 */
73const TUMBLE: boolean = false;
74
75/**
76 * Whether a wall standing in the way turns a discard into a lob over the top
77 * rather than a flick across the table.
78 *
79 * Off for now, deliberately: every discard slides, because sliding is what a
80 * discard should look like. The lob is kept whole below and this is the only
81 * switch that reaches it — when there is a rule for when a throw is called for
82 * instead, it goes here and nothing else has to move.
83 */
84const LOB_WHEN_WALLED: boolean = false;
85
86/** A tile lifted out of a hand and carried about. Drawn, but not simulated. */
87export interface Held {
88 seat: SeatId;
89 tile: Tile;
90 /** Where it came from in that seat's hand, so the caller knows what to play. */
91 index: number;
92 x: number;
93 y: number;
94 angle: number;
95 /** The size it was in the hand — it shrinks to pool size once thrown. */
96 w: number;
97 h: number;
98}
99
100/** A throw that has been let go of, waiting for the discard it belongs to. */
101interface Release {
102 seat: SeatId;
103 x: number;
104 y: number;
105 angle: number;
106 w: number;
107 h: number;
108 vx: number;
109 vy: number;
110 spin: number;
111}
112
113export class TablePool {
114 world: World | null = null;
115 geo: TableGeometry | null = null;
116 /** The tile in somebody's fingers, if any. */
117 held: Held | null = null;
118 /**
119 * Poke the render loop. Set by ui/Pool.tsx while it is mounted; a gesture
120 * calls it so a carried tile follows the finger without waiting on a re-render.
121 */
122 wake: () => void = () => {};
123
124 private table: HTMLElement | null = null;
125 /** Bodies by `seat:index` into that seat's discards. */
126 private entries = new Map<string, Body>();
127 private release: Release | null = null;
128 /** What the wall looked like when we last measured, so we know to re-measure. */
129 private measuredAt = '';
130 private hand = -1;
131 /** The tile currently on the table to be claimed, so it can be marked. */
132 private newestKey: string | null = null;
133 /** How many melds each seat had last time, to spot who took a tile. */
134 private melds = [0, 0, 0, 0];
135 /** Tiles on their way off the table. Drawn, but out of the physics. */
136 private leaving: Leaving[] = [];
137
138 // Which seats can throw, as a string, so the seats can re-render when it
139 // changes. Read through useSyncExternalStore, the same way the game is.
140 private openness = '----';
141 private listeners = new Set<() => void>();
142
143 attach(table: HTMLElement) {
144 this.table = table;
145 this.measuredAt = '';
146 }
147
148 detach() {
149 this.table = null;
150 this.world = null;
151 this.geo = null;
152 this.held = null;
153 this.release = null;
154 this.entries.clear();
155 this.leaving = [];
156 this.measuredAt = '';
157 }
158
159 subscribe = (cb: () => void) => {
160 this.listeners.add(cb);
161 return () => this.listeners.delete(cb);
162 };
163
164 /** Which seats have a clear line in, as a string — cheap to compare. */
165 getSnapshot = () => this.openness;
166
167 /** Force a re-measure — the window resized, or the layout changed under us. */
168 invalidate() {
169 this.measuredAt = '';
170 }
171
172 /**
173 * Whether this seat can flick a tile in, or has to lift it over the wall.
174 * Unmeasured (or compact, where there is no square) means no.
175 */
176 canThrow(seat: SeatId): boolean {
177 return this.openness[seat] === 'y';
178 }
179
180 /** Where the middle of the table is, for a gesture working out its aim. */
181 get poolCentre(): Point | null {
182 return this.geo ? centreOf(this.geo.pool) : null;
183 }
184
185 /** Whether a point is already inside the square — a tile dropped there stays. */
186 inPool(x: number, y: number): boolean {
187 const p = this.geo?.pool;
188 if (!p) return false;
189 return x >= p.x && x <= p.x + p.w && y >= p.y && y <= p.y + p.h;
190 }
191
192 /** Whether a tile let go of *here* has a clear line in. Not the same question
193 * as `canThrow(seat)`: a carried tile is wherever the player took it. */
194 canThrowFrom(x: number, y: number): boolean {
195 return !!this.geo && canThrowFrom(this.geo, { x, y });
196 }
197
198 /** Where the pool sits on the table, for the patch of it you can reach into. */
199 get poolRect(): Rect | null {
200 return this.geo?.pool ?? null;
201 }
202
203 /**
204 * Push the tiles in the middle about — washing them, the way everybody does
205 * to a pool of discards while waiting. Purely cosmetic: where a discard is
206 * lying is not part of the game.
207 */
208 stir(x: number, y: number, vx: number, vy: number) {
209 if (!this.world || !this.geo) return;
210 // A finger's worth: about a tile across.
211 stir(this.world, x, y, vx, vy, this.geo.tile.w * 0.6);
212 }
213
214 // ---- the tile in your fingers -----------------------------------------
215
216 /** Lifted out of a hand. From here the canvas draws it, not the DOM. */
217 pickUp(held: Held) {
218 this.held = held;
219 }
220
221 moveHeld(x: number, y: number, angle?: number) {
222 if (!this.held) return;
223 this.held.x = x;
224 this.held.y = y;
225 if (angle !== undefined) this.held.angle = angle;
226 }
227
228 /** Put it back in the hand — the throw was not committed to. */
229 cancelHeld() {
230 this.held = null;
231 }
232
233 /**
234 * Let go of it. The body is not made here: the reconciler owns every tile in
235 * the pool, so this leaves the throw where the next `sync` will find it and
236 * the caller goes on to call `game.discard`. The held tile stays drawn until
237 * that happens, so there is no frame with nothing on screen.
238 */
239 throwHeld(vx: number, vy: number, spin: number) {
240 const h = this.held;
241 if (!h) return;
242 const speed = Math.hypot(vx, vy);
243 const scale = speed > MAX_THROW_SPEED ? MAX_THROW_SPEED / speed : 1;
244 this.release = {
245 seat: h.seat,
246 x: h.x,
247 y: h.y,
248 angle: h.angle,
249 w: h.w,
250 h: h.h,
251 vx: vx * scale,
252 vy: vy * scale,
253 spin,
254 };
255 }
256
257 // ---- keeping up with the game ----------------------------------------
258
259 /** Bring the pile in line with the state. Cheap when nothing has changed. */
260 sync(state: GameState) {
261 this.remeasure(state);
262 const geo = this.geo;
263 const world = this.world;
264 if (!geo || !world) return;
265
266 // A new hand is a new pile, and nothing should fly anywhere.
267 const fresh = state.handNumber !== this.hand;
268 this.hand = state.handNumber;
269
270 const want = new Set<string>();
271 const added: { key: string; seat: SeatId; index: number; tile: Tile }[] = [];
272 for (const seat of SEATS) {
273 const discards = state.players[seat].discards;
274 for (let i = 0; i < discards.length; i++) {
275 const key = `${seat}:${i}`;
276 want.add(key);
277 if (!this.entries.has(key)) added.push({ key, seat, index: i, tile: discards[i] });
278 }
279 }
280
281 const taken: Body[] = [];
282 for (const [key, body] of this.entries) {
283 if (want.has(key)) continue;
284 const at = world.bodies.indexOf(body);
285 if (at >= 0) world.bodies.splice(at, 1);
286 this.entries.delete(key);
287 taken.push(body);
288 }
289
290 // Exactly one tile off the table is somebody taking it — a 碰, a 吃, a 槓,
291 // or a discard being taken back. It goes to whoever now has a meld they did
292 // not have a moment ago, and failing that back to the seat that threw it,
293 // which is what an undo is. Several at once is the hand ending, and those
294 // simply go.
295 const claimer = SEATS.find((q) => state.players[q].melds.length > this.melds[q]);
296 this.melds = SEATS.map((q) => state.players[q].melds.length);
297 if (taken.length === 1 && !fresh) {
298 const to = geo.launch[claimer ?? taken[0].seat];
299 if (to) this.leaving.push({ body: taken[0], tx: to.x, ty: to.y, t: 0 });
300 }
301
302 // One tile at a time is somebody playing, and it gets thrown. Several at
303 // once is a pile being rebuilt — a save picked up, an undo, a fresh deal —
304 // and those tiles were thrown long ago, so they are simply already there.
305 const rebuilding = fresh || added.length > 1;
306 for (const a of added) {
307 this.entries.set(a.key, rebuilding ? this.settle(geo, state, a) : this.launch(geo, state, a));
308 }
309
310 // A throw is good for exactly one discard, and the tile it belonged to has
311 // either just arrived or is never going to.
312 if (added.length || this.release) {
313 this.release = null;
314 this.held = null;
315 }
316
317 const ld = state.lastDiscard;
318 this.newestKey = ld ? `${ld.from}:${state.players[ld.from].discards.length - 1}` : null;
319 }
320
321 /** Run the physics on. Returns impacts worth a sound. */
322 advance(seconds: number): Impact[] {
323 this.carryOff(seconds);
324 return this.world ? advanceWorld(this.world, seconds) : [];
325 }
326
327 /** Tiles leaving the table, lifted and carried off to the seat taking them. */
328 private carryOff(seconds: number) {
329 if (this.leaving.length === 0) return;
330 for (const l of this.leaving) {
331 l.t = Math.min(1, l.t + seconds / TAKE_FLIGHT);
332 // Ease out, so it leaves quickly and arrives gently.
333 const k = 1 - (1 - l.t) * (1 - l.t);
334 l.body.x += (l.tx - l.body.x) * k * 0.5;
335 l.body.y += (l.ty - l.body.y) * k * 0.5;
336 // Up off the table on the way, the way a tile picked up would be.
337 l.body.z = Math.sin(l.t * Math.PI) * 40;
338 l.body.angle += seconds * 2.2;
339 }
340 this.leaving = this.leaving.filter((l) => l.t < 1);
341 }
342
343 /** What is on its way off, and how far gone, for the renderer to fade. */
344 get departing(): { body: Body; fade: number }[] {
345 return this.leaving.map((l) => ({ body: l.body, fade: 1 - l.t }));
346 }
347
348 get bodies(): Body[] {
349 return this.world?.bodies ?? [];
350 }
351
352 /** The tile on the table, if there is one — drawn lit, as a lifted tile is. */
353 get newest(): Body | null {
354 return (this.newestKey && this.entries.get(this.newestKey)) || null;
355 }
356
357 /**
358 * A tile is still on its way to the middle. The computer players hold off
359 * while this is true — nobody at a real table reaches across for a tile that
360 * has not landed, and a 碰 called over one still turning over in the air looks
361 * like it was snatched out of flight.
362 */
363 get throwInFlight(): boolean {
364 const b = this.newest;
365 return !!b && !b.resting;
366 }
367
368 /** Nothing is moving, so the render loop can stop until something changes. */
369 get settled(): boolean {
370 return !this.held && this.leaving.length === 0 && (!this.world || settled(this.world));
371 }
372
373 // ---- measuring ---------------------------------------------------------
374
375 /**
376 * Re-read the table when the wall's shape has changed or the window has. The
377 * pile keeps its positions across a re-measure: the wall shrinking does not
378 * move the tiles already on the felt.
379 */
380 private remeasure(state: GameState) {
381 const table = this.table;
382 if (!table) return;
383 const stamp = `${state.drawnFront}/${state.drawnBack}/${Math.round(
384 table.clientWidth,
385 )}x${Math.round(table.clientHeight)}`;
386 if (stamp === this.measuredAt) return;
387
388 const geo = measure(table, state);
389 this.measuredAt = geo ? stamp : '';
390 this.geo = geo;
391 if (!geo) {
392 this.world = null;
393 this.entries.clear();
394 this.setOpenness('----');
395 return;
396 }
397 // Bounded by the felt, not by the square: what keeps a tile in the middle is
398 // the stacks still standing, and once they are gone the pool is free to
399 // spread out through the gap the way a real one does.
400 if (!this.world) this.world = createWorld(geo.felt, geo.walls);
401 else reshape(this.world, geo.felt, geo.walls);
402 // Only here, never per frame: a hundred-odd ray casts is nothing once a
403 // draw, and a great deal every sixtieth of a second.
404 this.setOpenness(SEATS.map((s) => (canThrow(geo, s) ? 'y' : '-')).join(''));
405 }
406
407 private setOpenness(next: string) {
408 if (next === this.openness) return;
409 this.openness = next;
410 for (const cb of this.listeners) cb();
411 }
412
413 // ---- putting tiles in -------------------------------------------------
414
415 /** A tile that is simply there already — a rebuilt pile. */
416 private settle(
417 geo: TableGeometry,
418 state: GameState,
419 a: { seat: SeatId; index: number; tile: Tile },
420 ): Body {
421 const rng = this.seeded(state, a);
422 const { pool } = geo;
423 // Bunched towards the middle rather than spread evenly to the edges, which
424 // is what a pool thrown into from four sides actually looks like.
425 const spread = 0.3;
426 return addBody(this.world!, {
427 tile: a.tile,
428 seat: a.seat,
429 x: pool.x + pool.w * (0.5 + (rng() - 0.5) * 2 * spread),
430 y: pool.y + pool.h * (0.5 + (rng() - 0.5) * 2 * spread),
431 vx: 0,
432 vy: 0,
433 z: 0,
434 vz: 0,
435 angle: (rng() - 0.5) * Math.PI,
436 spin: 0,
437 w: geo.tile.w,
438 h: geo.tile.h,
439 atRest: true,
440 });
441 }
442
443 /** A tile arriving now: thrown if somebody threw it, placed over the wall if
444 * it was tapped, and flat and quick if a bot's wall happens to be open. */
445 private launch(
446 geo: TableGeometry,
447 state: GameState,
448 a: { seat: SeatId; index: number; tile: Tile },
449 ): Body {
450 const rng = this.seeded(state, a);
451 const thrown = this.release?.seat === a.seat ? this.release : null;
452
453 if (thrown) {
454 // Which of the two ways a tile goes in, decided by what is in its way.
455 //
456 // A clear line to the square means it can be *slid* — flat across the
457 // cloth, at exactly the speed it was let go at, which is what a flick
458 // wants to be. A standing wall means it has to be *thrown* instead: lifted
459 // over the top and dropped in, and then the flick's speed decides how far
460 // across the pool it lands rather than how fast it skates.
461 // Sliding is the preferred way in, and the question is asked from where
462 // the tile actually is — a player who carried it out over the square has a
463 // clear line even though the seat they came from does not. Only a tile
464 // with nothing but standing wall in front of it has to be lobbed.
465 const set = this.inPool(thrown.x, thrown.y);
466 const slide = set || !LOB_WHEN_WALLED || this.canThrowFrom(thrown.x, thrown.y);
467 let { vx, vy } = thrown;
468 let vz = 0;
469 const speed = Math.hypot(vx, vy);
470 let flipTurns = 0;
471 let flipOver = 0;
472
473 if (!slide) {
474 // Lofted over a standing wall. It still has to *land* on the table
475 // rather than sail off the far side, so there is a ceiling on how fast
476 // it can cross — but a generous one: a hard throw is meant to reach the
477 // far wall, come off it and barge through the pile, and only a limp one
478 // should drop in the middle and stay there.
479 const centre = centreOf(geo.pool);
480 const reach = PLACE_FLIGHT + SLIDE_TIME;
481 const cap = (Math.hypot(centre.x - thrown.x, centre.y - thrown.y) / reach) * 2.4;
482 const aim = Math.min(speed, cap) / (speed || 1);
483 vx *= aim;
484 vy *= aim;
485 vz = liftFor(PLACE_FLIGHT);
486 // Long enough in the air to turn over properly, and harder means more.
487 flipOver = PLACE_FLIGHT;
488 flipTurns = speed > 1300 ? 2 : 1;
489 } else if (!set) {
490 // A skated tile leaves the fingers off the cloth, and turns over on its
491 // way down — harder means longer in the air and more turns in it.
492 const hop = THROW_LIFT + Math.min(0.22, (speed / MAX_THROW_SPEED) * 0.28);
493 vz = liftFor(hop);
494 flipOver = hop;
495 flipTurns = speed > 2200 ? 2 : speed > 600 ? 1 : 0;
496 }
497 if (!TUMBLE) flipTurns = 0;
498
499 return addBody(this.world!, {
500 tile: a.tile,
501 seat: a.seat,
502 x: thrown.x,
503 y: thrown.y,
504 vx,
505 vy,
506 z: 0,
507 vz,
508 angle: thrown.angle,
509 spin: thrown.spin,
510 flipTurns,
511 flipOver,
512 // It tumbles across its line of travel, the way a thrown tile does.
513 flipAxis: Math.atan2(vy, vx),
514 w: geo.tile.w,
515 h: geo.tile.h,
516 // It was drawn at hand size a moment ago; it shrinks on the way down.
517 scale: thrown.w / geo.tile.w,
518 });
519 }
520
521 // Off the end of the hand, not the middle of it — a tile is thrown from the
522 // hand holding it, which is the one at the right-hand end of the row.
523 const from = geo.throwFrom[a.seat] ?? geo.launch[a.seat] ?? centreOf(geo.pool);
524 const open = !LOB_WHEN_WALLED || this.canThrow(a.seat);
525 const target = this.aimFor(geo, a.seat, rng);
526 // Aim for where it comes to *rest*, not where it first touches down: a tile
527 // keeps sliding after it lands, and SLIDE_TIME is roughly how much further.
528 const flight = open ? 0.34 + rng() * 0.1 : PLACE_FLIGHT;
529 const reach = flight + SLIDE_TIME;
530
531 const vx = (target.x - from.x) / reach;
532 const vy = (target.y - from.y) / reach;
533 return addBody(this.world!, {
534 tile: a.tile,
535 seat: a.seat,
536 x: from.x,
537 y: from.y,
538 vx,
539 vy,
540 z: 0,
541 vz: liftFor(flight),
542 angle: (rng() - 0.5) * Math.PI,
543 spin: (rng() - 0.5) * (open ? 7 : 2.5),
544 // It turns over on its way in, whichever way it is going.
545 flipTurns: TUMBLE ? 1 : 0,
546 flipOver: flight,
547 flipAxis: Math.atan2(vy, vx),
548 w: geo.tile.w,
549 h: geo.tile.h,
550 });
551 }
552
553 /** Where a tile from this seat is headed, with a little scatter. */
554 private aimFor(geo: TableGeometry, seat: SeatId, rng: () => number): Point {
555 const base = aimAt(geo, seat) ?? centreOf(geo.pool);
556 const jitter = Math.min(geo.pool.w, geo.pool.h) * 0.22;
557 return { x: base.x + (rng() - 0.5) * jitter, y: base.y + (rng() - 0.5) * jitter };
558 }
559
560 /**
561 * A tile's own random stream. Keyed on the hand and its place in that seat's
562 * discards, so the same tile scatters the same way every time it is rebuilt —
563 * a refresh mid-hand puts the pile back exactly as it was.
564 */
565 private seeded(state: GameState, a: { seat: SeatId; index: number; tile: Tile }) {
566 return makeRng(state.handNumber * 7919 + a.seat * 977 + a.index * 31 + a.tile + 1);
567 }
568}