anvilsign in

collin/mahjong

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