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
44/** A tile lifted out of a hand and carried about. Drawn, but not simulated. */
45export 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. */
59interface 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
71export 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}