anvilsign in

collin/mahjong

1import type { NetThrow } from '../game/ctl';
2import type { GameState } from '../game/engine';
3import { makeRng, type Tile } from '../game/tiles';
4import type { SeatId } from '../game/types';
5import { SEAT_ROT } from '../ui/rotation';
6import {
7 aimAt,
8 canThrow,
9 canThrowFrom,
10 centreOf,
11 measure,
12 spanThrough,
13 type Point,
14 type TableGeometry,
15} from './geometry';
16import {
17 addBody,
18 advance as advanceWorld,
19 createWorld,
20 liftFor,
21 reshape,
22 settled,
23 stir,
24 SLIDE_TIME,
25 type Body,
26 type Impact,
27 type Rect,
28 type World,
29} from './physics';
30
31const SEATS: SeatId[] = [0, 1, 2, 3];
32
33/**
34 * The tiles in the middle, kept honest against the game state.
35 *
36 * This *diffs* rather than listening for events, and that is the whole design.
37 * A discard is identified by which seat threw it and how many it had thrown
38 * before — and those keys never shift, because discards are only ever pushed and
39 * `engine.executeClaim` only ever pops the one on top. So every way the pile can
40 * change is the same code path: a key that has appeared is a tile to throw in, a
41 * key that has gone is a tile to take out. Undo, 下一局, a claim, and picking up
42 * a saved game all fall out of that without the engine knowing this exists.
43 *
44 * Nothing here is stored in the game state, so there is no save format to
45 * change. A pile is rebuilt from the discards it can see, seeded off the hand
46 * number and each tile's place in it, which is why a refresh mid-hand comes back
47 * to the pile it had rather than a freshly scattered one.
48 */
49
50/** How long a placed tile is in the air. Long enough to arc over the wall. */
51const PLACE_FLIGHT = 0.62;
52/** A flicked tile hops rather than lofts — across the table, not over a wall. */
53const THROW_LIFT = 0.18;
54/** Fastest flick we will honour, px/s. Beyond this it is a slip, not a throw. */
55export const MAX_THROW_SPEED = 3600;
56/** How long a claimed tile takes to leave the table for the seat taking it. */
57const TAKE_FLIGHT = 0.34;
58
59/** A tile on its way off the table, no longer part of the pile. */
60interface Leaving {
61 body: Body;
62 tx: number;
63 ty: number;
64 /** 0 to 1. */
65 t: number;
66}
67
68/**
69 * Whether a tile turns over on its way in.
70 *
71 * Off for now. Like `LOB_WHEN_WALLED` below, the tumble is kept whole — the
72 * flip is a real axis in `physics.ts` and the renderer already squashes the
73 * tile along it — and this is the only switch that reaches it, so putting it
74 * back is one word rather than a rewrite.
75 */
76const TUMBLE: boolean = false;
77
78/**
79 * Whether a wall standing in the way turns a discard into a lob over the top
80 * rather than a flick across the table.
81 *
82 * Back on now that the square is a square again. It was off while the wall ran
83 * along the edges of the middle, where there was nothing much to get over and
84 * everything read better slid; a square standing in the middle with the pool
85 * inside it is the other case, and a tile slid flat at it simply bounces off
86 * and ends up lying outside — which is not where a discard goes. As the square
87 * is eaten the gaps open and the slide comes back on its own, seat by seat.
88 */
89const LOB_WHEN_WALLED: boolean = true;
90
91/** How many sandbox throws stay on the table before the oldest is swept off.
92 * A wall's worth: enough to test a crowded pool, not enough to bury it. */
93const LOOSE_LIMIT = 60;
94
95/** A tile lifted out of a hand and carried about. Drawn, but not simulated. */
96export interface Held {
97 seat: SeatId;
98 tile: Tile;
99 /** Where it came from in that seat's hand, so the caller knows what to play. */
100 index: number;
101 x: number;
102 y: number;
103 angle: number;
104 /** The size it was in the hand — it shrinks to pool size once thrown. */
105 w: number;
106 h: number;
107}
108
109/** A throw that has been let go of, waiting for the discard it belongs to. */
110interface Release {
111 seat: SeatId;
112 x: number;
113 y: number;
114 angle: number;
115 w: number;
116 h: number;
117 vx: number;
118 vy: number;
119 spin: number;
120}
121
122export class TablePool {
123 world: World | null = null;
124 geo: TableGeometry | null = null;
125 /** The tile in somebody's fingers, if any. */
126 held: Held | null = null;
127 /**
128 * Poke the render loop. Set by ui/Pool.tsx while it is mounted; a gesture
129 * calls it so a carried tile follows the finger without waiting on a re-render.
130 */
131 wake: () => void = () => {};
132
133 private table: HTMLElement | null = null;
134 /** Bodies by `seat:index` into that seat's discards. */
135 private entries = new Map<string, Body>();
136 private release: Release | null = null;
137 /** When the throw was let go of. Online its discard takes a round-trip to
138 * come back, so a release has to be able to outlive a few frames. */
139 private releasedAt = 0;
140 /** What the wall looked like when we last measured, so we know to re-measure. */
141 private measuredAt = '';
142 private hand = -1;
143 /** The tile currently on the table to be claimed, so it can be marked. */
144 private newestKey: string | null = null;
145 /** How many melds each seat had last time, to spot who took a tile. */
146 private melds = [0, 0, 0, 0];
147 /** Tiles on their way off the table. Drawn, but out of the physics. */
148 private leaving: Leaving[] = [];
149 /**
150 * Sandbox throws: real tiles in the real world, but belonging to no discard,
151 * so the reconciler never looks at them — nothing in `entries` means nothing
152 * to cull. See `throwLoose`.
153 */
154 private loose: Body[] = [];
155 /** When the table will have stopped talking. See `heckle`. */
156 private quietAt = 0;
157
158 // Which seats can throw, as a string, so the seats can re-render when it
159 // changes. Read through useSyncExternalStore, the same way the game is.
160 private openness = '----';
161 private listeners = new Set<() => void>();
162
163 attach(table: HTMLElement) {
164 this.table = table;
165 this.measuredAt = '';
166 }
167
168 detach() {
169 this.table = null;
170 this.world = null;
171 this.geo = null;
172 this.loose = [];
173 this.held = null;
174 this.release = null;
175 this.entries.clear();
176 this.leaving = [];
177 this.measuredAt = '';
178 }
179
180 subscribe = (cb: () => void) => {
181 this.listeners.add(cb);
182 return () => this.listeners.delete(cb);
183 };
184
185 /** Which seats have a clear line in, as a string — cheap to compare. */
186 getSnapshot = () => this.openness;
187
188 /** Force a re-measure — the window resized, or the layout changed under us. */
189 invalidate() {
190 this.measuredAt = '';
191 }
192
193 /**
194 * Whether this seat can flick a tile in, or has to lift it over the wall.
195 * Unmeasured (or compact, where there is no square) means no.
196 */
197 canThrow(seat: SeatId): boolean {
198 return this.openness[seat] === 'y';
199 }
200
201 /** Where the middle of the table is, for a gesture working out its aim. */
202 get poolCentre(): Point | null {
203 return this.geo ? centreOf(this.geo.pool) : null;
204 }
205
206 /** Whether a point is already inside the square — a tile dropped there stays. */
207 inPool(x: number, y: number): boolean {
208 const p = this.geo?.pool;
209 if (!p) return false;
210 return x >= p.x && x <= p.x + p.w && y >= p.y && y <= p.y + p.h;
211 }
212
213 /** Whether a tile let go of *here* has a clear line in. Not the same question
214 * as `canThrow(seat)`: a carried tile is wherever the player took it. */
215 canThrowFrom(x: number, y: number): boolean {
216 return !!this.geo && canThrowFrom(this.geo, { x, y });
217 }
218
219 /** Where the pool sits on the table, for the patch of it you can reach into. */
220 get poolRect(): Rect | null {
221 return this.geo?.pool ?? null;
222 }
223
224 /**
225 * Push the tiles in the middle about — washing them, the way everybody does
226 * to a pool of discards while waiting. Purely cosmetic: where a discard is
227 * lying is not part of the game.
228 */
229 stir(x: number, y: number, vx: number, vy: number) {
230 if (!this.world || !this.geo) return;
231 // A finger's worth: about a tile across.
232 stir(this.world, x, y, vx, vy, this.geo.tile.w * 0.6);
233 }
234
235 // ---- the tile in your fingers -----------------------------------------
236
237 /** Lifted out of a hand. From here the canvas draws it, not the DOM. */
238 pickUp(held: Held) {
239 this.held = held;
240 }
241
242 moveHeld(x: number, y: number, angle?: number) {
243 if (!this.held) return;
244 this.held.x = x;
245 this.held.y = y;
246 if (angle !== undefined) this.held.angle = angle;
247 }
248
249 /** Put it back in the hand — the throw was not committed to. */
250 cancelHeld() {
251 this.held = null;
252 }
253
254 /**
255 * Let go of it. The body is not made here: the reconciler owns every tile in
256 * the pool, so this leaves the throw where the next `sync` will find it and
257 * the caller goes on to call `game.discard`. The held tile stays drawn until
258 * that happens, so there is no frame with nothing on screen.
259 */
260 throwHeld(vx: number, vy: number, spin: number) {
261 const h = this.held;
262 if (!h) return;
263 const speed = Math.hypot(vx, vy);
264 const scale = speed > MAX_THROW_SPEED ? MAX_THROW_SPEED / speed : 1;
265 this.release = {
266 seat: h.seat,
267 x: h.x,
268 y: h.y,
269 angle: h.angle,
270 w: h.w,
271 h: h.h,
272 vx: vx * scale,
273 vy: vy * scale,
274 spin,
275 };
276 this.releasedAt = Date.now();
277 }
278
279 /**
280 * A throw that is not a discard.
281 *
282 * The tile stays in the hand, the game does not move, and the body goes
283 * straight into the world rather than waiting for a discard to arrive and
284 * claim it. For throwing the same tile a hundred times with the colliders
285 * drawn, which is the only way to tell a bounce that is wrong from a bounce
286 * that only looks wrong.
287 */
288 throwLoose(vx: number, vy: number, spin: number) {
289 const h = this.held;
290 const geo = this.geo;
291 const world = this.world;
292 if (!h || !geo || !world) return;
293 const speed = Math.hypot(vx, vy);
294 const scale = speed > MAX_THROW_SPEED ? MAX_THROW_SPEED / speed : 1;
295 this.loose.push(
296 this.fling(geo, h.tile, {
297 seat: h.seat,
298 x: h.x,
299 y: h.y,
300 angle: h.angle,
301 w: h.w,
302 h: h.h,
303 vx: vx * scale,
304 vy: vy * scale,
305 spin,
306 }),
307 );
308 // Oldest off the table first, or an afternoon of this buries the felt and
309 // every throw lands on a pile instead of on the thing being tested.
310 while (this.loose.length > LOOSE_LIMIT) this.drop(this.loose.shift()!);
311 this.held = null;
312 }
313
314 /** Sweep the sandbox's tiles away and start again. */
315 clearLoose() {
316 for (const b of this.loose) this.drop(b);
317 this.loose = [];
318 }
319
320 private drop(body: Body) {
321 const at = this.world?.bodies.indexOf(body) ?? -1;
322 if (at >= 0) this.world!.bodies.splice(at, 1);
323 }
324
325 /**
326 * A throw that happened on somebody's phone, arriving over the wire in their
327 * own frame: `u` along their edge, `vy` into the table. It is stood at their
328 * side of this screen and turned into the table's coordinates, so the tile
329 * flies in from off their edge — from where they are sitting, as a thrown
330 * tile should. The discard it belongs to arrives through the state as usual;
331 * this only decides how it enters.
332 */
333 throwFromNet(seat: SeatId, t: NetThrow) {
334 const geo = this.geo;
335 if (!geo) return;
336 const f = geo.felt;
337 // Just beyond the felt, where the thrower's strip is.
338 const m = geo.tile.h * 1.4;
339 const u = Math.min(1, Math.max(0, t.u));
340 let x: number;
341 let y: number;
342 let vx: number;
343 let vy: number;
344 // Each seat's frame: x runs to the player's right along their edge, y into
345 // the table. The screen rotation for each seat is what `ROT` says it is.
346 switch (seat) {
347 case 0:
348 x = f.x + u * f.w;
349 y = f.y + f.h + m;
350 vx = t.vx;
351 vy = -t.vy;
352 break;
353 case 1:
354 x = f.x + f.w + m;
355 y = f.y + (1 - u) * f.h;
356 vx = -t.vy;
357 vy = -t.vx;
358 break;
359 case 2:
360 x = f.x + (1 - u) * f.w;
361 y = f.y - m;
362 vx = -t.vx;
363 vy = t.vy;
364 break;
365 default:
366 x = f.x - m;
367 y = f.y + u * f.h;
368 vx = t.vy;
369 vy = t.vx;
370 break;
371 }
372 const speed = Math.hypot(vx, vy);
373 const scale = speed > MAX_THROW_SPEED ? MAX_THROW_SPEED / speed : 1;
374 this.release = {
375 seat,
376 x,
377 y,
378 // Facing its thrower, the way a tile lifted from their hand would be.
379 angle: (SEAT_ROT[seat] * Math.PI) / 180,
380 w: geo.tile.w,
381 h: geo.tile.h,
382 vx: vx * scale,
383 vy: vy * scale,
384 spin: t.spin,
385 };
386 this.releasedAt = Date.now();
387 }
388
389 // ---- keeping up with the game ----------------------------------------
390
391 /** Bring the pile in line with the state. Cheap when nothing has changed. */
392 sync(state: GameState) {
393 this.remeasure(state);
394 const geo = this.geo;
395 const world = this.world;
396 if (!geo || !world) return;
397
398 // A new hand is a new pile, and nothing should fly anywhere.
399 const fresh = state.handNumber !== this.hand;
400 this.hand = state.handNumber;
401
402 const want = new Set<string>();
403 const added: { key: string; seat: SeatId; index: number; tile: Tile }[] = [];
404 for (const seat of SEATS) {
405 const discards = state.players[seat].discards;
406 for (let i = 0; i < discards.length; i++) {
407 const key = `${seat}:${i}`;
408 want.add(key);
409 if (!this.entries.has(key)) added.push({ key, seat, index: i, tile: discards[i] });
410 }
411 }
412
413 const taken: Body[] = [];
414 for (const [key, body] of this.entries) {
415 if (want.has(key)) continue;
416 const at = world.bodies.indexOf(body);
417 if (at >= 0) world.bodies.splice(at, 1);
418 this.entries.delete(key);
419 taken.push(body);
420 }
421
422 // Exactly one tile off the table is somebody taking it — a 碰, a 吃, a 槓,
423 // or a discard being taken back. It goes to whoever now has a meld they did
424 // not have a moment ago, and failing that back to the seat that threw it,
425 // which is what an undo is. Several at once is the hand ending, and those
426 // simply go.
427 const claimer = SEATS.find((q) => state.players[q].melds.length > this.melds[q]);
428 this.melds = SEATS.map((q) => state.players[q].melds.length);
429 if (taken.length === 1 && !fresh) {
430 const to = geo.launch[claimer ?? taken[0].seat];
431 if (to) this.leaving.push({ body: taken[0], tx: to.x, ty: to.y, t: 0 });
432 }
433
434 // One tile at a time is somebody playing, and it gets thrown. Several at
435 // once is a pile being rebuilt — a save picked up, an undo, a fresh deal —
436 // and those tiles were thrown long ago, so they are simply already there.
437 const rebuilding = fresh || added.length > 1;
438 for (const a of added) {
439 this.entries.set(a.key, rebuilding ? this.settle(geo, state, a) : this.launch(geo, state, a));
440 }
441
442 // A throw is good for exactly one discard. At a local table the discard is
443 // in the state before the next frame, so the tile has either just arrived
444 // or is never going to; online it is out on a round-trip, so the release
445 // waits — but not forever, in case the host turned the move down.
446 if (added.length || (this.release && Date.now() - this.releasedAt > 2500)) {
447 this.release = null;
448 this.held = null;
449 }
450
451 const ld = state.lastDiscard;
452 this.newestKey = ld ? `${ld.from}:${state.players[ld.from].discards.length - 1}` : null;
453 }
454
455 /** Run the physics on. Returns impacts worth a sound. */
456 advance(seconds: number, stopOnImpact = false): Impact[] {
457 this.carryOff(seconds);
458 return this.world ? advanceWorld(this.world, seconds, stopOnImpact) : [];
459 }
460
461 /** Tiles leaving the table, lifted and carried off to the seat taking them. */
462 private carryOff(seconds: number) {
463 if (this.leaving.length === 0) return;
464 for (const l of this.leaving) {
465 l.t = Math.min(1, l.t + seconds / TAKE_FLIGHT);
466 // Ease out, so it leaves quickly and arrives gently.
467 const k = 1 - (1 - l.t) * (1 - l.t);
468 l.body.x += (l.tx - l.body.x) * k * 0.5;
469 l.body.y += (l.ty - l.body.y) * k * 0.5;
470 // Up off the table on the way, the way a tile picked up would be.
471 l.body.z = Math.sin(l.t * Math.PI) * 40;
472 l.body.angle += seconds * 2.2;
473 }
474 this.leaving = this.leaving.filter((l) => l.t < 1);
475 }
476
477 /** What is on its way off, and how far gone, for the renderer to fade. */
478 get departing(): { body: Body; fade: number }[] {
479 return this.leaving.map((l) => ({ body: l.body, fade: 1 - l.t }));
480 }
481
482 get bodies(): Body[] {
483 return this.world?.bodies ?? [];
484 }
485
486 /** The tile on the table, if there is one — drawn lit, as a lifted tile is. */
487 get newest(): Body | null {
488 return (this.newestKey && this.entries.get(this.newestKey)) || null;
489 }
490
491 /**
492 * A tile is still on its way to the middle. The computer players hold off
493 * while this is true — nobody at a real table reaches across for a tile that
494 * has not landed, and a 碰 called over one still turning over in the air looks
495 * like it was snatched out of flight.
496 */
497 get throwInFlight(): boolean {
498 const b = this.newest;
499 return !!b && !b.resting;
500 }
501
502 /**
503 * Somebody is being told off for where they put that tile. Held for as long
504 * as the line takes to say, told to us by whoever is saying it.
505 */
506 heckle(seconds: number) {
507 this.quietAt = Math.max(this.quietAt, Date.now() + seconds * 1000);
508 }
509
510 /**
511 * Whether the table is mid-something and a computer player should wait: a
512 * tile still in the air, or a player still complaining about the last one.
513 * Reaching for a tile over the top of somebody objecting to it lands the
514 * objection on nobody.
515 */
516 get busy(): boolean {
517 return this.throwInFlight || Date.now() < this.quietAt;
518 }
519
520 /** Nothing is moving, so the render loop can stop until something changes. */
521 get settled(): boolean {
522 return !this.held && this.leaving.length === 0 && (!this.world || settled(this.world));
523 }
524
525 // ---- measuring ---------------------------------------------------------
526
527 /**
528 * Re-read the table when the wall's shape has changed or the window has. The
529 * pile keeps its positions across a re-measure: the wall shrinking does not
530 * move the tiles already on the felt.
531 */
532 private remeasure(state: GameState) {
533 const table = this.table;
534 if (!table) return;
535 // Every tile on the table is a collider now, so the shape of each hand and
536 // each set laid down is part of what has to be re-read — not just the wall.
537 const hands = state.players.map((p) => `${p.hand.length}.${p.melds.length}.${p.flowers.length}`);
538 const stamp = `${state.drawnFront}/${state.drawnBack}/${hands.join(',')}/${Math.round(
539 table.clientWidth,
540 )}x${Math.round(table.clientHeight)}`;
541 if (stamp === this.measuredAt) return;
542
543 const geo = measure(table, state);
544 this.measuredAt = geo ? stamp : '';
545 this.geo = geo;
546 if (!geo) {
547 this.world = null;
548 this.entries.clear();
549 this.setOpenness('----');
550 return;
551 }
552 // What keeps a tile in is the tiles: the stacks still standing, the sets
553 // laid down, and the hands beyond them. `felt` is the table's own edge and
554 // is only there so a tile that got past all of that is still on the table.
555 if (!this.world) this.world = createWorld(geo.felt, geo.barriers);
556 else reshape(this.world, geo.felt, geo.barriers);
557 // Only here, never per frame: a hundred-odd ray casts is nothing once a
558 // draw, and a great deal every sixtieth of a second.
559 this.setOpenness(SEATS.map((s) => (canThrow(geo, s) ? 'y' : '-')).join(''));
560 }
561
562 private setOpenness(next: string) {
563 if (next === this.openness) return;
564 this.openness = next;
565 for (const cb of this.listeners) cb();
566 }
567
568 // ---- putting tiles in -------------------------------------------------
569
570 /** A tile that is simply there already — a rebuilt pile. */
571 private settle(
572 geo: TableGeometry,
573 state: GameState,
574 a: { seat: SeatId; index: number; tile: Tile },
575 ): Body {
576 const rng = this.seeded(state, a);
577 const { pool } = geo;
578 // Bunched towards the middle rather than spread evenly to the edges, which
579 // is what a pool thrown into from four sides actually looks like.
580 const spread = 0.3;
581 return addBody(this.world!, {
582 tile: a.tile,
583 seat: a.seat,
584 x: pool.x + pool.w * (0.5 + (rng() - 0.5) * 2 * spread),
585 y: pool.y + pool.h * (0.5 + (rng() - 0.5) * 2 * spread),
586 vx: 0,
587 vy: 0,
588 z: 0,
589 vz: 0,
590 angle: (rng() - 0.5) * Math.PI,
591 spin: 0,
592 w: geo.tile.w,
593 h: geo.tile.h,
594 atRest: true,
595 });
596 }
597
598 /**
599 * A tile let go of by a hand: what a flick actually becomes.
600 *
601 * Its own method because a throw is not always a discard — the sandbox
602 * behind ?colliders throws the same tile over and over without the game
603 * moving at all, and it has to be the *same* throw or there would be no
604 * point testing with it.
605 */
606 private fling(geo: TableGeometry, tile: Tile, thrown: Release): Body {
607 // Which of the two ways a tile goes in, decided by what is in its way.
608 //
609 // A clear line to the square means it can be *slid* — flat across the
610 // cloth, at exactly the speed it was let go at, which is what a flick
611 // wants to be. A standing wall means it has to be *thrown* instead: lifted
612 // over the top and dropped in, and then the flick's speed decides how far
613 // across the pool it lands rather than how fast it skates.
614 // Sliding is the preferred way in, and the question is asked from where
615 // the tile actually is — a player who carried it out over the square has a
616 // clear line even though the seat they came from does not. Only a tile
617 // with nothing but standing wall in front of it has to be lobbed.
618 const set = this.inPool(thrown.x, thrown.y);
619 const slide = set || !LOB_WHEN_WALLED || this.canThrowFrom(thrown.x, thrown.y);
620 let { vx, vy } = thrown;
621 let vz = 0;
622 const speed = Math.hypot(vx, vy);
623 let flipTurns = 0;
624 let flipOver = 0;
625
626 if (!slide) {
627 // Lofted over a standing wall, and it has to come *down* inside the
628 // square: a lob solved for where it would come to rest touches down
629 // short, on the wrong side of the wall, and stops dead against it.
630 //
631 // So the flick picks a landing spot along its own line, between just
632 // inside the near edge of the square and just short of the far one. A
633 // limp one drops in over the near wall, a hard one carries to the far
634 // side and barges through whatever is lying there, and neither of them
635 // can leave the tile outside.
636 const dir = speed > 0 ? { x: vx / speed, y: vy / speed } : { x: 0, y: -1 };
637 const span = spanThrough(geo.pool, thrown, dir);
638 const centre = centreOf(geo.pool);
639 let carry = speed * PLACE_FLIGHT;
640 if (span) {
641 const near = span.near + geo.tile.h;
642 carry = Math.min(Math.max(near, carry), Math.max(near, span.far - geo.tile.h));
643 } else {
644 // Flicked somewhere that is not the square at all — put it in anyway.
645 carry = Math.hypot(centre.x - thrown.x, centre.y - thrown.y);
646 dir.x = (centre.x - thrown.x) / (carry || 1);
647 dir.y = (centre.y - thrown.y) / (carry || 1);
648 }
649 vx = (dir.x * carry) / PLACE_FLIGHT;
650 vy = (dir.y * carry) / PLACE_FLIGHT;
651 vz = liftFor(PLACE_FLIGHT);
652 // Long enough in the air to turn over properly, and harder means more.
653 flipOver = PLACE_FLIGHT;
654 flipTurns = speed > 1300 ? 2 : 1;
655 } else if (!set) {
656 // A skated tile leaves the fingers off the cloth, and turns over on its
657 // way down — harder means longer in the air and more turns in it.
658 const hop = THROW_LIFT + Math.min(0.22, (speed / MAX_THROW_SPEED) * 0.28);
659 vz = liftFor(hop);
660 flipOver = hop;
661 flipTurns = speed > 2200 ? 2 : speed > 600 ? 1 : 0;
662 }
663 if (!TUMBLE) flipTurns = 0;
664
665 return addBody(this.world!, {
666 tile: tile,
667 seat: thrown.seat,
668 x: thrown.x,
669 y: thrown.y,
670 vx,
671 vy,
672 z: 0,
673 vz,
674 angle: thrown.angle,
675 spin: thrown.spin,
676 flipTurns,
677 flipOver,
678 // It tumbles across its line of travel, the way a thrown tile does.
679 flipAxis: Math.atan2(vy, vx),
680 w: geo.tile.w,
681 h: geo.tile.h,
682 // It was drawn at hand size a moment ago; it shrinks on the way down.
683 scale: thrown.w / geo.tile.w,
684 });
685 }
686
687 /** A tile arriving now: thrown if somebody threw it, placed over the wall if
688 * it was tapped, and flat and quick if a bot's wall happens to be open. */
689 private launch(
690 geo: TableGeometry,
691 state: GameState,
692 a: { seat: SeatId; index: number; tile: Tile },
693 ): Body {
694 const rng = this.seeded(state, a);
695 const thrown = this.release?.seat === a.seat ? this.release : null;
696
697 if (thrown) return this.fling(geo, a.tile, thrown);
698
699 // Off the end of the hand, not the middle of it — a tile is thrown from the
700 // hand holding it, which is the one at the right-hand end of the row.
701 const from = geo.throwFrom[a.seat] ?? geo.launch[a.seat] ?? centreOf(geo.pool);
702 const open = !LOB_WHEN_WALLED || this.canThrow(a.seat);
703 const target = this.aimFor(geo, a.seat, rng);
704 const flight = open ? 0.34 + rng() * 0.1 : PLACE_FLIGHT;
705 // A slide is aimed at where it comes to *rest*, since it keeps going after
706 // it lands and SLIDE_TIME is roughly how much further. A lob is aimed at
707 // where it touches *down*, because it has to come down on the inside of the
708 // wall — solving for the resting place lands it short, against the outside.
709 const reach = open ? flight + SLIDE_TIME : flight;
710
711 const vx = (target.x - from.x) / reach;
712 const vy = (target.y - from.y) / reach;
713 return addBody(this.world!, {
714 tile: a.tile,
715 seat: a.seat,
716 x: from.x,
717 y: from.y,
718 vx,
719 vy,
720 z: 0,
721 vz: liftFor(flight),
722 angle: (rng() - 0.5) * Math.PI,
723 spin: (rng() - 0.5) * (open ? 7 : 2.5),
724 // It turns over on its way in, whichever way it is going.
725 flipTurns: TUMBLE ? 1 : 0,
726 flipOver: flight,
727 flipAxis: Math.atan2(vy, vx),
728 w: geo.tile.w,
729 h: geo.tile.h,
730 });
731 }
732
733 /** Where a tile from this seat is headed, with a little scatter. */
734 private aimFor(geo: TableGeometry, seat: SeatId, rng: () => number): Point {
735 const base = aimAt(geo, seat) ?? centreOf(geo.pool);
736 const jitter = Math.min(geo.pool.w, geo.pool.h) * 0.22;
737 return { x: base.x + (rng() - 0.5) * jitter, y: base.y + (rng() - 0.5) * jitter };
738 }
739
740 /**
741 * A tile's own random stream. Keyed on the hand and its place in that seat's
742 * discards, so the same tile scatters the same way every time it is rebuilt —
743 * a refresh mid-hand puts the pile back exactly as it was.
744 */
745 private seeded(state: GameState, a: { seat: SeatId; index: number; tile: Tile }) {
746 return makeRng(state.handNumber * 7919 + a.seat * 977 + a.index * 31 + a.tile + 1);
747 }
748}