anvilsign in

collin/mahjong

1import { useEffect, useRef, useState } from 'react';
2import type { TableCtl } from '../game/ctl';
3import { barge, knock, type Place } from '../game/sound';
4import { aabbOf, centreLimits, faceOn, type Impact, type Rect } from '../table/physics';
5import type { TablePool } from '../table/pool';
6import type { SeatId } from '../game/types';
7import {
8 exposePool,
9 holdOn,
10 isHeld,
11 keyHeld,
12 noteImpacts,
13 onCollidersChange,
14 owedTime,
15 pausingOnHit,
16 recentImpacts,
17 showingColliders,
18} from './colliders';
19import { flickOf, type Sample } from './flick';
20import { EDGE_SEAT } from './rotation';
21import { drawShadow, drawTile, loadArt, qrFace, type Art } from './tileArt';
22
23/**
24 * The tiles in the middle of the table, drawn.
25 *
26 * A canvas rather than elements, and it spans the whole table rather than just
27 * the centre. The span is the point: `.slot` and `.seat` both clip their own
28 * contents, which is exactly why a tile cannot be animated out of a strip as a
29 * div — on the canvas there is nothing to clip it, so a tile lifted out of a
30 * hand and thrown crosses the table in one piece.
31 *
32 * The loop stops as soon as the pile has settled, and starts again when the
33 * state changes, a tile is picked up, or the window resizes, so a table nobody
34 * is touching costs nothing.
35 */
36export function Pool({
37 game,
38 pool,
39 viewSeat = 0,
40 joinUrl,
41}: {
42 game: TableCtl;
43 pool: TablePool;
44 /** The seat drawn at the bottom of this screen — see App. An edge the physics
45 * reports belongs to whoever is drawn there, not to seat 0 by birthright. */
46 viewSeat?: SeatId;
47 /**
48 * Where a phone should point itself to join this table, if there is anywhere.
49 * Undefined puts no tile on the felt at all — no room, no server, nothing to
50 * scan, and nothing pretending otherwise.
51 */
52 joinUrl?: string;
53}) {
54 const canvasRef = useRef<HTMLCanvasElement>(null);
55 const art = useSheet();
56
57 // What the loop reads. Refreshed on every render, read on every frame.
58 const live = useRef({ state: game.state, art, viewSeat, joinUrl });
59 live.current = { state: game.state, art, viewSeat, joinUrl };
60
61 // Poked by the effect below, and by the gesture through pool.wake.
62 const wake = useRef<() => void>(() => {});
63
64 useEffect(() => {
65 const canvas = canvasRef.current;
66 const table = canvas?.parentElement;
67 const ctx = canvas?.getContext('2d');
68 if (!canvas || !table || !ctx) return;
69
70 pool.attach(table);
71 exposePool(pool);
72 const stopDragging = dragging(table as HTMLElement, pool, () => wake.current());
73 // Somebody has pushed a wall along. Where a thrown tile bounces has moved
74 // with it, and the pool re-measures only when it is given a reason to.
75 const wallMoved = () => {
76 pool.invalidate();
77 wake.current();
78 };
79 table.addEventListener('wallmoved', wallMoved);
80
81 let frame = 0;
82 let previous = 0;
83
84 const resize = () => {
85 const dpr = window.devicePixelRatio || 1;
86 const w = Math.round(table.clientWidth * dpr);
87 const h = Math.round(table.clientHeight * dpr);
88 if (canvas.width !== w || canvas.height !== h) {
89 canvas.width = w;
90 canvas.height = h;
91 }
92 // The square moved, so the colliders and the launch points have too.
93 pool.invalidate();
94 run();
95 };
96
97 const tick = (now: number) => {
98 frame = 0;
99 // First frame after waking has no previous time to measure against.
100 const dt = previous ? Math.min(0.05, (now - previous) / 1000) : 0;
101 previous = now;
102
103 pool.sync(live.current.state);
104 // The QR comes and goes with the room, and the tile with it. Asked every
105 // frame rather than watched, because the answer is one boolean and the
106 // pool takes it idempotently.
107 pool.setJoinTile(!!live.current.joinUrl);
108 // A tile in the fingers goes over the table's own buttons — seat 0 throws
109 // from the *right-hand end* of its row, which is directly under the undo
110 // button — so while one is being carried they stop taking the pointer.
111 table.classList.toggle('carrying', !!pool.held);
112 // The loudest contact this frame is the one you would actually hear; the
113 // rest of a scatter is the same event.
114 // Frozen on a contact: the world only moves when it is asked to, a fixed
115 // step at a time, so the moment can be walked through rather than watched.
116 const impacts = isHeld() ? pool.advance(owedTime()) : pool.advance(dt, pausingOnHit());
117 noteImpacts(impacts);
118 if (pausingOnHit() && !isHeld() && impacts.length) holdOn(impacts);
119 let loudest = 0;
120 let loudAt: Impact | null = null;
121 for (const i of impacts) {
122 // A contact quiet enough not to be worth hearing still counts as a tile
123 // arriving in somebody's lap, which is why the two thresholds differ.
124 if (i.strength > AUDIBLE && i.strength > loudest) {
125 loudest = i.strength;
126 loudAt = i;
127 }
128 // Straight into somebody's tiles — the wall square is nobody's, so this
129 // is a hand or a set that has been laid down.
130 if (i.into !== undefined) {
131 shove(pool, table, i.into, i.seat, i.strength, posOf(i.into, live.current.viewSeat));
132 } else if (i.edge) {
133 // An edge is already stated in this screen's own terms, which is what
134 // the sound wants; the seat sitting at it is the round trip back.
135 const pos = EDGE_SEAT[i.edge] as SeatId;
136 const at = ((pos + live.current.viewSeat) % 4) as SeatId;
137 shove(pool, table, at, i.seat, i.strength, pos);
138 }
139 }
140 // Out in the middle a sound has a real position rather than an edge it
141 // belongs to, and the physics knows it to the pixel.
142 if (loudest > 0) knock(loudest, spotOf(pool, loudAt!));
143 paint(ctx, canvas, pool, live.current.art, live.current.joinUrl);
144
145 // Keep going while anything is moving; otherwise wait to be poked. A held
146 // world is never running: it is stepped by hand and repainted each time.
147 if (!isHeld() && !pool.settled) frame = requestAnimationFrame(tick);
148 else previous = 0;
149 };
150
151 const run = () => {
152 if (!frame) frame = requestAnimationFrame(tick);
153 };
154 wake.current = run;
155 pool.wake = run;
156
157 const observer = new ResizeObserver(resize);
158 observer.observe(table);
159 const stopColliders = onCollidersChange(run);
160 const onKey = (e: KeyboardEvent) => {
161 if (keyHeld(e)) e.preventDefault();
162 };
163 window.addEventListener('keydown', onKey);
164 resize();
165
166 return () => {
167 table.removeEventListener('wallmoved', wallMoved);
168 stopDragging();
169 window.removeEventListener('keydown', onKey);
170 stopColliders();
171 observer.disconnect();
172 if (frame) cancelAnimationFrame(frame);
173 wake.current = () => {};
174 pool.wake = () => {};
175 pool.detach();
176 };
177 }, [pool]);
178
179 // Any change to the table — a discard, a claim, an undo, a new hand — is a
180 // reason to look again.
181 useEffect(() => {
182 wake.current();
183 });
184
185 return <canvas className="tile-pool" ref={canvasRef} aria-hidden />;
186}
187
188/**
189 * Picking a discard up and putting it somewhere else.
190 *
191 * A discard's position is not part of the game — the engine neither knows nor
192 * cares where in the middle a tile is lying — so the pile is free to be handled
193 * the way a real one is: take hold of a tile, carry it over the top of the
194 * others, and either set it down or send it skidding off with a flick.
195 *
196 * The listener is on the table and it *captures*, because the tiles are drawn
197 * on a canvas that has no elements to hit. It hit-tests the pile first and only
198 * takes the gesture when a tile is actually under the finger; anything else is
199 * let straight through to whatever it was really aimed at — a tile in a hand,
200 * the settings button, the wall.
201 */
202function dragging(table: HTMLElement, pool: TablePool, wake: () => void) {
203 let samples: Sample[] = [];
204
205 const at = (e: PointerEvent) => {
206 const r = table.getBoundingClientRect();
207 return { x: e.clientX - r.left, y: e.clientY - r.top, t: e.timeStamp };
208 };
209
210 const down = (e: PointerEvent) => {
211 // Only the primary button, and only where there is a tile to take hold of.
212 if (e.button !== 0) return;
213 const p = at(e);
214 if (!pool.grabAt(p.x, p.y)) return;
215 e.stopPropagation();
216 e.preventDefault();
217 samples = [p];
218 try {
219 table.setPointerCapture?.(e.pointerId);
220 } catch {
221 // Uncaptured still works while the pointer stays over the table.
222 }
223 wake();
224 };
225
226 const move = (e: PointerEvent) => {
227 if (!pool.dragging) return;
228 e.stopPropagation();
229 const p = at(e);
230 // Only the tail matters, and only the recent tail: a flick is the last few
231 // milliseconds of a drag, not an average over the whole of it.
232 samples.push(p);
233 if (samples.length > 6) samples.shift();
234 pool.dragTo(p.x, p.y);
235 wake();
236 };
237
238 const up = (e: PointerEvent) => {
239 if (!pool.dragging) return;
240 e.stopPropagation();
241 samples.push(at(e));
242 // Read exactly as a throw out of a hand is: same gesture, same reading,
243 // curve on it and all. See ui/flick.
244 const v = flickOf(samples);
245 pool.releaseGrab(v.vx, v.vy, v.spin);
246 samples = [];
247 wake();
248 };
249
250 table.addEventListener('pointerdown', down, true);
251 table.addEventListener('pointermove', move, true);
252 table.addEventListener('pointerup', up, true);
253 table.addEventListener('pointercancel', up, true);
254 return () => {
255 table.removeEventListener('pointerdown', down, true);
256 table.removeEventListener('pointermove', move, true);
257 table.removeEventListener('pointerup', up, true);
258 table.removeEventListener('pointercancel', up, true);
259 };
260}
261
262/** The sheet and the green back, once, shared by every tile drawn. */
263function useSheet(): Art {
264 const [art, setArt] = useState<Art>({ faces: null, back: null });
265 useEffect(() => {
266 let alive = true;
267 void loadArt().then((a) => alive && setArt(a));
268 return () => {
269 alive = false;
270 };
271 }, []);
272 return art;
273}
274
275/** How high a tile in somebody's fingers is held above the felt. */
276const HELD_Z = 52;
277
278/** Quieter than this and the contact is not worth a clack of its own. */
279const AUDIBLE = 0.12;
280
281/** How long the knocked row rocks for, in seconds. Matches `shoved` in the CSS,
282 * which is the other half of it: the table waits at least this long. */
283const SHAKE = 0.34;
284
285/** A seat, as an edge of the table this screen is drawing. */
286const posOf = (seat: SeatId, viewSeat: SeatId): SeatId => (((seat - viewSeat + 4) % 4) as SeatId);
287
288/**
289 * Where on the felt a contact happened, in the terms the sound wants: how far
290 * left or right of the middle, and how far up the table — the far half of the
291 * pool is over by 對家 and sounds like it. The felt is drawn in screen
292 * coordinates, so this needs no turning: it is already the way you are facing.
293 */
294function spotOf(pool: TablePool, at: Impact): Place {
295 const b = pool.world?.bounds;
296 if (!b) return {};
297 const pan = Math.max(-1, Math.min(1, (at.x - (b.x + b.w / 2)) / (b.w / 2)));
298 // Only the half of the table away from you counts as distance; the near half
299 // is all within reach, and dulling it would just sound like a blanket.
300 const far = Math.max(0, Math.min(1, (b.y + b.h / 2 - at.y) / (b.h / 2)));
301 return { pan: pan * 0.8, far: far * 0.55 };
302}
303
304/**
305 * A tile has ended up in this seat's tiles — knocked into them, or simply come
306 * to a stop off the table on their side. Either way it is theirs to complain
307 * about, and how hard it arrived only decides how loudly.
308 *
309 * Their row takes the knock — straight on the element rather than through
310 * React, because this happens inside the physics loop and re-rendering four
311 * strips to shake one of them would be absurd — and the table says something
312 * about it. Not for the thrower's own edge: you cannot be barged by your own
313 * discard, and everybody's tile passes over their own row on the way out.
314 */
315function shove(
316 pool: TablePool,
317 table: HTMLElement,
318 seat: SeatId,
319 thrower: SeatId | undefined,
320 strength: number,
321 pos: SeatId,
322) {
323 if (seat === thrower) return;
324 // Hold the table while it is being said and while the row is still rocking.
325 // The computer plays on a timer of its own, and a 碰 called over the top of
326 // somebody objecting to the tile lands the objection on nobody.
327 // It comes from their edge: they are the one whose tiles were scattered, and
328 // it is their voice complaining about it.
329 pool.heckle(Math.max(SHAKE, barge(strength, { pos })));
330 const hand = table.querySelector<HTMLElement>(`[data-seat="${seat}"] .hand`);
331 if (!hand || hand.classList.contains('shoved')) return;
332 hand.classList.add('shoved');
333 hand.addEventListener('animationend', () => hand.classList.remove('shoved'), { once: true });
334}
335
336function paint(
337 ctx: CanvasRenderingContext2D,
338 canvas: HTMLCanvasElement,
339 pool: TablePool,
340 art: Art,
341 joinUrl?: string,
342) {
343 const dpr = window.devicePixelRatio || 1;
344 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
345 ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
346
347 const bodies = pool.bodies;
348 const held = pool.held;
349 const departing = pool.departing;
350
351 // Shadows in one pass of their own, offset before rotation, so the light comes
352 // from one direction across the whole pool rather than turning with each tile.
353 for (const b of bodies) {
354 drawShadow(ctx, b.x, b.y, b.z, b.angle, b.w, b.h, b.scale, faceOn(b), b.flipAxis);
355 }
356 if (held) drawShadow(ctx, held.x, held.y, HELD_Z, held.angle, held.w, held.h, 1, 1, 0);
357
358 const newest = pool.newest;
359 // The one tile whose face is painted rather than cut out of the sheet. Asked
360 // for at the size it is actually being drawn, so the code is never resampled
361 // and a phone never has to work at it.
362 const join = pool.joinTile;
363 const joinArt =
364 join && joinUrl ? qrFace(joinUrl, join.w * join.scale * (window.devicePixelRatio || 1)) : null;
365 for (const b of bodies) {
366 drawTile(ctx, art, {
367 x: b.x,
368 y: b.y,
369 z: b.z,
370 angle: b.angle,
371 w: b.w * b.scale,
372 h: b.h * b.scale,
373 facing: faceOn(b),
374 axis: b.flipAxis,
375 code: b.tile,
376 lit: b === newest,
377 image: b === join ? joinArt ?? undefined : undefined,
378 });
379 }
380
381 // On its way to whoever called it — lifted, turning, and fading as it goes.
382 for (const { body: b, fade } of departing) {
383 ctx.save();
384 ctx.globalAlpha = fade;
385 drawTile(ctx, art, {
386 x: b.x,
387 y: b.y,
388 z: b.z,
389 angle: b.angle,
390 w: b.w * b.scale,
391 h: b.h * b.scale,
392 facing: faceOn(b),
393 axis: b.flipAxis,
394 code: b.tile,
395 lit: true,
396 });
397 ctx.restore();
398 }
399
400 // Whatever is in your fingers is above everything else on the table.
401 if (held) {
402 drawTile(ctx, art, {
403 x: held.x,
404 y: held.y,
405 z: HELD_Z,
406 angle: held.angle,
407 w: held.w,
408 h: held.h,
409 facing: 1,
410 axis: 0,
411 code: held.tile,
412 lit: true,
413 });
414 }
415
416 // Over the top of all of it, or there would be no point to it.
417 if (showingColliders()) colliders(ctx, pool);
418}
419
420/**
421 * Every boundary the physics uses, drawn where it actually is.
422 *
423 * Solid the felt — the fence that keeps a tile on the table, measured
424 * out to where each seat's tiles begin (table/geometry.ts).
425 * Solid each standing stack of two. Half stacks are not drawn because
426 * they are not colliders: a tile goes over them.
427 * Dashed the wall square's opening. Only where a throw is *aimed*; it
428 * stops nothing, which is why the pile spills out of it.
429 * Dotted, in where the moving tile's own centre is allowed to be — the felt
430 * less that tile's half width. This is the line a bounce happens
431 * at, and it is half a tile inside the solid one, which is what
432 * makes a bounce look early when it is not.
433 * Per tile the rotated rectangle it really collides with, and, fainter, the
434 * upright box the felt's straight edges use instead. The two come
435 * apart as a tile turns, and that difference is worth seeing.
436 */
437function colliders(ctx: CanvasRenderingContext2D, pool: TablePool) {
438 const world = pool.world;
439 if (!world) return;
440
441 const line = (r: Rect) => ctx.strokeRect(r.x + 0.5, r.y + 0.5, r.w - 1, r.h - 1);
442
443 ctx.save();
444 ctx.lineWidth = 1;
445
446 // The table's own edge, and every tile standing on it.
447 ctx.strokeStyle = '#ff2d20';
448 line(world.bounds);
449 for (const w of world.walls) line(w.rect);
450
451 // Where a throw is aimed. Not a fence — see TableGeometry.pool.
452 const aim = pool.poolRect;
453 if (aim) {
454 ctx.setLineDash([5, 5]);
455 ctx.strokeStyle = 'rgba(255, 45, 32, 0.45)';
456 line(aim);
457 ctx.setLineDash([]);
458 }
459
460 // Whatever is still moving: what it collides with, and what it is held by.
461 const live = world.bodies.find((b) => !b.resting) ?? pool.newest;
462
463 for (const b of world.bodies) {
464 // A tile in the fingers is held over everything and collides with nothing,
465 // so it gets no box: the boxes are what is solid.
466 if (b.carried) continue;
467 ctx.strokeStyle = b === live ? 'rgba(255, 45, 32, 0.9)' : 'rgba(255, 45, 32, 0.35)';
468 ctx.save();
469 ctx.translate(b.x, b.y);
470 ctx.rotate(b.angle);
471 ctx.strokeRect(-b.w / 2, -b.h / 2, b.w, b.h);
472 ctx.restore();
473
474 // The upright box the felt's edges are worked out from. Only for the one in
475 // flight: sixty of them at once is a red mess, not a picture.
476 if (b === live && b.angle % Math.PI !== 0) {
477 ctx.setLineDash([2, 3]);
478 ctx.strokeStyle = 'rgba(255, 45, 32, 0.5)';
479 line(aabbOf(b));
480 ctx.setLineDash([]);
481 }
482 }
483
484 if (live) {
485 // The box that tile's centre is held inside, and where the centre is now.
486 ctx.setLineDash([2, 3]);
487 ctx.strokeStyle = 'rgba(255, 45, 32, 0.75)';
488 line(centreLimits(world.bounds, live));
489 ctx.setLineDash([]);
490 ctx.fillStyle = '#ff2d20';
491 ctx.fillRect(live.x - 1.5, live.y - 1.5, 3, 3);
492 }
493
494 // How the last few throws actually left. Faint line: the flick, as it was
495 // let go of. Bright line: where the tile was actually sent. When those two
496 // disagree the tile leaves on a line nobody threw, which is the other way a
497 // bounce can be a bounce off nothing — see TablePool.throws.
498 ctx.font = '9px ui-monospace, monospace';
499 ctx.textBaseline = 'bottom';
500 for (const t of pool.throws) {
501 const ray = (dx: number, dy: number, len: number) => {
502 const d = Math.hypot(dx, dy) || 1;
503 ctx.beginPath();
504 ctx.moveTo(t.x, t.y);
505 ctx.lineTo(t.x + (dx / d) * len, t.y + (dy / d) * len);
506 ctx.stroke();
507 };
508 ctx.strokeStyle = 'rgba(90, 200, 250, 0.35)';
509 ray(t.fx, t.fy, 90);
510 ctx.strokeStyle = 'rgba(90, 200, 250, 0.9)';
511 ray(t.vx, t.vy, 130);
512 ctx.fillStyle = 'rgba(90, 200, 250, 0.9)';
513 ctx.fillText(t.how, t.x + 6, t.y - 4);
514 ctx.fillRect(t.x - 2, t.y - 2, 4, 4);
515 }
516
517 // Where contact was actually made, fading out. One of these with no box under
518 // it is the interesting case: a bounce off something that is not drawn is a
519 // bounce off something that is not a collider.
520 ctx.font = '9px ui-monospace, monospace';
521 ctx.textBaseline = 'bottom';
522 for (const m of recentImpacts(!isHeld())) {
523 const fade = 1 - m.age / 90;
524 ctx.strokeStyle = `rgba(255, 214, 10, ${fade})`;
525 ctx.fillStyle = `rgba(255, 214, 10, ${fade})`;
526 ctx.beginPath();
527 ctx.moveTo(m.x - 5, m.y);
528 ctx.lineTo(m.x + 5, m.y);
529 ctx.moveTo(m.x, m.y - 5);
530 ctx.lineTo(m.x, m.y + 5);
531 ctx.stroke();
532 if (m.kind) ctx.fillText(m.kind, m.x + 6, m.y - 2);
533 }
534
535 ctx.restore();
536}
537