anvilsign in

collin/mahjong

1import { useEffect, useRef, useState } from 'react';
2// Imported rather than named by path, so the canvas draws the very files the
3// CSS tiles use wherever the bundle ends up being served from.
4import backUrl from '../assets/back.png';
5import tilesUrl from '../assets/tiles.svg';
6import type { TableCtl } from '../game/ctl';
7import { barge, knock } from '../game/sound';
8import { SPRITE_COL, SPRITE_COLS } from '../game/tiles';
9import { aabbOf, centreLimits, faceOn, zScale, type Rect } from '../table/physics';
10import type { TablePool } from '../table/pool';
11import type { SeatId } from '../game/types';
12import { exposePool, onCollidersChange, showingColliders } from './colliders';
13import { EDGE_SEAT } from './rotation';
14
15/**
16 * The tiles in the middle of the table, drawn.
17 *
18 * A canvas rather than elements, and it spans the whole table rather than just
19 * the centre. The span is the point: `.slot` and `.seat` both clip their own
20 * contents, which is exactly why a tile cannot be animated out of a strip as a
21 * div — on the canvas there is nothing to clip it, so a tile lifted out of a
22 * hand and thrown crosses the table in one piece.
23 *
24 * The loop stops as soon as the pile has settled, and starts again when the
25 * state changes, a tile is picked up, or the window resizes, so a table nobody
26 * is touching costs nothing.
27 */
28export function Pool({
29 game,
30 pool,
31 viewSeat = 0,
32}: {
33 game: TableCtl;
34 pool: TablePool;
35 /** The seat drawn at the bottom of this screen — see App. An edge the physics
36 * reports belongs to whoever is drawn there, not to seat 0 by birthright. */
37 viewSeat?: SeatId;
38}) {
39 const canvasRef = useRef<HTMLCanvasElement>(null);
40 const reachRef = useRef<HTMLDivElement>(null);
41 const art = useSheet();
42 const wash = useWash(pool);
43
44 // What the loop reads. Refreshed on every render, read on every frame.
45 const live = useRef({ state: game.state, art, viewSeat });
46 live.current = { state: game.state, art, viewSeat };
47
48 // Poked by the effect below, and by the gesture through pool.wake.
49 const wake = useRef<() => void>(() => {});
50
51 useEffect(() => {
52 const canvas = canvasRef.current;
53 const table = canvas?.parentElement;
54 const ctx = canvas?.getContext('2d');
55 if (!canvas || !table || !ctx) return;
56
57 pool.attach(table);
58 exposePool(pool);
59
60 let frame = 0;
61 let previous = 0;
62
63 const resize = () => {
64 const dpr = window.devicePixelRatio || 1;
65 const w = Math.round(table.clientWidth * dpr);
66 const h = Math.round(table.clientHeight * dpr);
67 if (canvas.width !== w || canvas.height !== h) {
68 canvas.width = w;
69 canvas.height = h;
70 }
71 // The square moved, so the colliders and the launch points have too.
72 pool.invalidate();
73 run();
74 };
75
76 const tick = (now: number) => {
77 frame = 0;
78 // First frame after waking has no previous time to measure against.
79 const dt = previous ? Math.min(0.05, (now - previous) / 1000) : 0;
80 previous = now;
81
82 pool.sync(live.current.state);
83 placeReach(reachRef.current, pool);
84 // A tile in the fingers goes over the table's own buttons — seat 0 throws
85 // from the *right-hand end* of its row, which is directly under the undo
86 // button — so while one is being carried they stop taking the pointer.
87 table.classList.toggle('carrying', !!pool.held);
88 // The loudest contact this frame is the one you would actually hear; the
89 // rest of a scatter is the same event.
90 const impacts = pool.advance(dt);
91 let loudest = 0;
92 for (const i of impacts) {
93 // A contact quiet enough not to be worth hearing still counts as a tile
94 // arriving in somebody's lap, which is why the two thresholds differ.
95 if (i.strength > AUDIBLE) loudest = Math.max(loudest, i.strength);
96 // Straight into somebody's tiles — the wall square is nobody's, so this
97 // is a hand or a set that has been laid down.
98 if (i.into !== undefined) shove(pool, table, i.into, i.seat, i.strength);
99 else if (i.edge) {
100 const at = ((EDGE_SEAT[i.edge] + live.current.viewSeat) % 4) as SeatId;
101 shove(pool, table, at, i.seat, i.strength);
102 }
103 }
104 if (loudest > 0) knock(loudest);
105 paint(ctx, canvas, pool, live.current.art);
106
107 // Keep going while anything is moving; otherwise wait to be poked.
108 if (!pool.settled) frame = requestAnimationFrame(tick);
109 else previous = 0;
110 };
111
112 const run = () => {
113 if (!frame) frame = requestAnimationFrame(tick);
114 };
115 wake.current = run;
116 pool.wake = run;
117
118 const observer = new ResizeObserver(resize);
119 observer.observe(table);
120 const stopColliders = onCollidersChange(run);
121 resize();
122
123 return () => {
124 stopColliders();
125 observer.disconnect();
126 if (frame) cancelAnimationFrame(frame);
127 wake.current = () => {};
128 pool.wake = () => {};
129 pool.detach();
130 };
131 }, [pool]);
132
133 // Any change to the table — a discard, a claim, an undo, a new hand — is a
134 // reason to look again.
135 useEffect(() => {
136 wake.current();
137 });
138
139 return (
140 <>
141 <canvas className="tile-pool" ref={canvasRef} aria-hidden />
142 {/* The patch of table you can reach into. Sized to the pool from the
143 measurement rather than from CSS, and only that patch, so it cannot
144 swallow clicks meant for the strips around it. */}
145 <div className="pool-reach" ref={reachRef} onPointerDown={wash.down} onPointerMove={wash.move} onPointerUp={wash.up} onPointerCancel={wash.up} />
146 </>
147 );
148}
149
150/**
151 * Washing the tiles about with a finger.
152 *
153 * A discard's position is not part of the game — the engine neither knows nor
154 * cares where in the middle a tile is lying — so this is free to be pure fidget,
155 * the thing everybody does to the pool while waiting for their turn.
156 */
157function useWash(pool: TablePool) {
158 const last = useRef<{ x: number; y: number; t: number } | null>(null);
159
160 const at = (e: React.PointerEvent) => {
161 const table = (e.currentTarget as HTMLElement).parentElement;
162 const r = table?.getBoundingClientRect();
163 return { x: e.clientX - (r?.left ?? 0), y: e.clientY - (r?.top ?? 0), t: e.timeStamp };
164 };
165
166 return {
167 down: (e: React.PointerEvent) => {
168 try {
169 (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
170 } catch {
171 // Uncaptured still works while the pointer stays over the pool.
172 }
173 last.current = at(e);
174 },
175 move: (e: React.PointerEvent) => {
176 const prev = last.current;
177 if (!prev) return;
178 const now = at(e);
179 const dt = (now.t - prev.t) / 1000;
180 last.current = now;
181 if (dt <= 0) return;
182 pool.stir(now.x, now.y, (now.x - prev.x) / dt, (now.y - prev.y) / dt);
183 pool.wake();
184 },
185 up: () => {
186 last.current = null;
187 },
188 };
189}
190
191interface Art {
192 faces: HTMLImageElement | null;
193 back: HTMLImageElement | null;
194}
195
196/**
197 * The tile art, as something canvas can draw: the face sheet, and the green
198 * back a tile shows while it is turning over. Both are the same files the CSS
199 * uses for the tiles in the hands and the wall, so the pool cannot drift from
200 * the rest of the table.
201 */
202function useSheet(): Art {
203 const [art, setArt] = useState<Art>({ faces: null, back: null });
204 useEffect(() => {
205 let alive = true;
206 const load = (src: string) => {
207 const el = new Image();
208 el.src = src;
209 // Not fatal: the pool falls back to blank tiles rather than emptying out.
210 return el.decode().then(
211 () => el,
212 () => null,
213 );
214 };
215 void Promise.all([load(tilesUrl), load(backUrl)]).then(
216 ([faces, back]) => alive && setArt({ faces, back }),
217 );
218 return () => {
219 alive = false;
220 };
221 }, []);
222 return art;
223}
224
225/** How high a tile in somebody's fingers is held above the felt. */
226const HELD_Z = 52;
227
228/** Quieter than this and the contact is not worth a clack of its own. */
229const AUDIBLE = 0.12;
230
231/** How long the knocked row rocks for, in seconds. Matches `shoved` in the CSS,
232 * which is the other half of it: the table waits at least this long. */
233const SHAKE = 0.34;
234
235/**
236 * A tile has ended up in this seat's tiles — knocked into them, or simply come
237 * to a stop off the table on their side. Either way it is theirs to complain
238 * about, and how hard it arrived only decides how loudly.
239 *
240 * Their row takes the knock — straight on the element rather than through
241 * React, because this happens inside the physics loop and re-rendering four
242 * strips to shake one of them would be absurd — and the table says something
243 * about it. Not for the thrower's own edge: you cannot be barged by your own
244 * discard, and everybody's tile passes over their own row on the way out.
245 */
246function shove(
247 pool: TablePool,
248 table: HTMLElement,
249 seat: SeatId,
250 thrower: SeatId | undefined,
251 strength: number,
252) {
253 if (seat === thrower) return;
254 // Hold the table while it is being said and while the row is still rocking.
255 // The computer plays on a timer of its own, and a 碰 called over the top of
256 // somebody objecting to the tile lands the objection on nobody.
257 pool.heckle(Math.max(SHAKE, barge(strength)));
258 const hand = table.querySelector<HTMLElement>(`[data-seat="${seat}"] .hand`);
259 if (!hand || hand.classList.contains('shoved')) return;
260 hand.classList.add('shoved');
261 hand.addEventListener('animationend', () => hand.classList.remove('shoved'), { once: true });
262}
263
264/**
265 * Park the reachable patch over the pool. Driven off the measurement rather
266 * than restated in CSS, for the same reason the colliders are: the wall square's
267 * size is worked out in one place and this follows it.
268 */
269function placeReach(el: HTMLDivElement | null, pool: TablePool) {
270 if (!el) return;
271 const r = pool.poolRect;
272 if (!r) {
273 el.style.display = 'none';
274 el.dataset.box = '';
275 return;
276 }
277 const box = [r.x, r.y, r.w, r.h].map(Math.round);
278 const stamp = box.join(',');
279 if (el.dataset.box === stamp) return;
280 el.dataset.box = stamp;
281 el.style.display = '';
282 el.style.left = `${box[0]}px`;
283 el.style.top = `${box[1]}px`;
284 el.style.width = `${box[2]}px`;
285 el.style.height = `${box[3]}px`;
286}
287
288function paint(
289 ctx: CanvasRenderingContext2D,
290 canvas: HTMLCanvasElement,
291 pool: TablePool,
292 art: Art,
293) {
294 const dpr = window.devicePixelRatio || 1;
295 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
296 ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
297
298 const bodies = pool.bodies;
299 const held = pool.held;
300 const departing = pool.departing;
301
302 // Shadows in one pass of their own, offset before rotation, so the light comes
303 // from one direction across the whole pool rather than turning with each tile.
304 for (const b of bodies) {
305 shadow(ctx, b.x, b.y, b.z, b.angle, b.w, b.h, b.scale, faceOn(b), b.flipAxis);
306 }
307 if (held) shadow(ctx, held.x, held.y, HELD_Z, held.angle, held.w, held.h, 1, 1, 0);
308
309 const newest = pool.newest;
310 for (const b of bodies) {
311 tile(ctx, art, {
312 x: b.x,
313 y: b.y,
314 z: b.z,
315 angle: b.angle,
316 w: b.w * b.scale,
317 h: b.h * b.scale,
318 facing: faceOn(b),
319 axis: b.flipAxis,
320 code: b.tile,
321 lit: b === newest,
322 });
323 }
324
325 // On its way to whoever called it — lifted, turning, and fading as it goes.
326 for (const { body: b, fade } of departing) {
327 ctx.save();
328 ctx.globalAlpha = fade;
329 tile(ctx, art, {
330 x: b.x,
331 y: b.y,
332 z: b.z,
333 angle: b.angle,
334 w: b.w * b.scale,
335 h: b.h * b.scale,
336 facing: faceOn(b),
337 axis: b.flipAxis,
338 code: b.tile,
339 lit: true,
340 });
341 ctx.restore();
342 }
343
344 // Whatever is in your fingers is above everything else on the table.
345 if (held) {
346 tile(ctx, art, {
347 x: held.x,
348 y: held.y,
349 z: HELD_Z,
350 angle: held.angle,
351 w: held.w,
352 h: held.h,
353 facing: 1,
354 axis: 0,
355 code: held.tile,
356 lit: true,
357 });
358 }
359
360 // Over the top of all of it, or there would be no point to it.
361 if (showingColliders()) colliders(ctx, pool);
362}
363
364/**
365 * Every boundary the physics uses, drawn where it actually is.
366 *
367 * Solid the felt — the fence that keeps a tile on the table, measured
368 * out to where each seat's tiles begin (table/geometry.ts).
369 * Solid each standing stack of two. Half stacks are not drawn because
370 * they are not colliders: a tile goes over them.
371 * Dashed the wall square's opening. Only where a throw is *aimed*; it
372 * stops nothing, which is why the pile spills out of it.
373 * Dotted, in where the moving tile's own centre is allowed to be — the felt
374 * less that tile's half width. This is the line a bounce happens
375 * at, and it is half a tile inside the solid one, which is what
376 * makes a bounce look early when it is not.
377 * Per tile the rotated rectangle it really collides with, and, fainter, the
378 * upright box the felt's straight edges use instead. The two come
379 * apart as a tile turns, and that difference is worth seeing.
380 */
381function colliders(ctx: CanvasRenderingContext2D, pool: TablePool) {
382 const world = pool.world;
383 if (!world) return;
384
385 const line = (r: Rect) => ctx.strokeRect(r.x + 0.5, r.y + 0.5, r.w - 1, r.h - 1);
386
387 ctx.save();
388 ctx.lineWidth = 1;
389
390 // The table's own edge, and every tile standing on it.
391 ctx.strokeStyle = '#ff2d20';
392 line(world.bounds);
393 for (const w of world.walls) line(w.rect);
394
395 // Where a throw is aimed. Not a fence — see TableGeometry.pool.
396 const aim = pool.poolRect;
397 if (aim) {
398 ctx.setLineDash([5, 5]);
399 ctx.strokeStyle = 'rgba(255, 45, 32, 0.45)';
400 line(aim);
401 ctx.setLineDash([]);
402 }
403
404 // Whatever is still moving: what it collides with, and what it is held by.
405 const live = world.bodies.find((b) => !b.resting) ?? pool.newest;
406
407 for (const b of world.bodies) {
408 ctx.strokeStyle = b === live ? 'rgba(255, 45, 32, 0.9)' : 'rgba(255, 45, 32, 0.35)';
409 ctx.save();
410 ctx.translate(b.x, b.y);
411 ctx.rotate(b.angle);
412 ctx.strokeRect(-b.w / 2, -b.h / 2, b.w, b.h);
413 ctx.restore();
414
415 // The upright box the felt's edges are worked out from. Only for the one in
416 // flight: sixty of them at once is a red mess, not a picture.
417 if (b === live && b.angle % Math.PI !== 0) {
418 ctx.setLineDash([2, 3]);
419 ctx.strokeStyle = 'rgba(255, 45, 32, 0.5)';
420 line(aabbOf(b));
421 ctx.setLineDash([]);
422 }
423 }
424
425 if (live) {
426 // The box that tile's centre is held inside, and where the centre is now.
427 ctx.setLineDash([2, 3]);
428 ctx.strokeStyle = 'rgba(255, 45, 32, 0.75)';
429 line(centreLimits(world.bounds, live));
430 ctx.setLineDash([]);
431 ctx.fillStyle = '#ff2d20';
432 ctx.fillRect(live.x - 1.5, live.y - 1.5, 3, 3);
433 }
434
435 ctx.restore();
436}
437
438/**
439 * How thick a tile is, as a fraction of its width. A real one is about
440 * three-quarters as thick as it is wide; this is the part of that you can see
441 * from almost overhead, which is a good deal less.
442 */
443const THICKNESS = 0.3;
444/** The ivory of the tile's own body, seen along its sides. */
445const EDGE_LIGHT = '#e6dcc2';
446const EDGE_DARK = '#b9ad8d';
447
448interface Drawn {
449 x: number;
450 y: number;
451 z: number;
452 angle: number;
453 w: number;
454 h: number;
455 /** cos of the turn: 1 flat on its back, 0 edge on, negative face down. */
456 facing: number;
457 axis: number;
458 code: number;
459 lit: boolean;
460}
461
462/**
463 * One tile, with the thickness of one.
464 *
465 * Drawn as two passes: the body of the tile, offset down the screen towards the
466 * viewer, and then its face on top of that. The sliver of body left showing
467 * along the bottom edge is what stops a tile reading as a printed card.
468 *
469 * Turning over makes that matter more, not less. Face on you see the face and a
470 * hair of the side; edge on there is no face left to see and the tile is a bar
471 * of ivory as thick as it really is — which is why the squash uses the
472 * rectangle's true projected width, `w·|cos| + thickness·|sin|`, rather than
473 * pinching to nothing and back.
474 */
475function tile(ctx: CanvasRenderingContext2D, art: Art, d: Drawn) {
476 const s = zScale(d.z);
477 const w = d.w * s;
478 const h = d.h * s;
479 const thick = w * THICKNESS;
480 const c = Math.abs(d.facing);
481 const sin = Math.sqrt(Math.max(0, 1 - d.facing * d.facing));
482 // What the whole slab covers across the axis it is turning about.
483 const projected = c + (thick / w) * sin;
484 // How much of the body shows past the face: none of it face on beyond its own
485 // thickness, all of it edge on. Higher tiles show a touch more of their side.
486 const lift = thick * (0.45 + 0.55 * sin) + d.z * 0.05;
487
488 // --- the body of the tile, seen from just off square
489 ctx.save();
490 ctx.translate(d.x, d.y + lift);
491 squash(ctx, projected, d.axis);
492 ctx.rotate(d.angle);
493 const grad = ctx.createLinearGradient(0, -h / 2, 0, h / 2);
494 grad.addColorStop(0, EDGE_DARK);
495 grad.addColorStop(1, EDGE_LIGHT);
496 ctx.fillStyle = grad;
497 tilePath(ctx, w, h);
498 ctx.fill();
499 ctx.restore();
500
501 // --- and its face on top
502 ctx.save();
503 ctx.translate(d.x, d.y);
504 squash(ctx, Math.max(0.015, c), d.axis);
505 ctx.rotate(d.angle);
506 face(ctx, d.code, w, h, art, d.lit, d.facing < 0);
507 ctx.restore();
508}
509
510/**
511 * A soft silhouette under a tile.
512 *
513 * The blur is baked into a sprite once per tile size rather than applied per
514 * draw. `ctx.filter` is the obvious way to write this and the wrong one: every
515 * filtered fill makes the browser allocate and blur its own layer, so sixty
516 * tiles times sixty frames is sixty times sixty blurs a second, which is enough
517 * to make the tab stop answering. One blur, then `drawImage`.
518 *
519 * Height is sold by scaling that sprite up — a shadow that spreads and fades as
520 * the tile rises — which a fixed blur radius could not have done anyway.
521 */
522const shadows = new Map<string, HTMLCanvasElement>();
523
524function shadowSprite(w: number, h: number): HTMLCanvasElement {
525 const key = `${Math.round(w)}x${Math.round(h)}`;
526 const had = shadows.get(key);
527 if (had) return had;
528 // Only ever a handful — one per tile size on screen — but a window being
529 // dragged to resize walks through a lot of them.
530 if (shadows.size > 8) shadows.clear();
531
532 const pad = Math.ceil(Math.max(8, w * 0.5));
533 const c = document.createElement('canvas');
534 c.width = Math.ceil(w) + pad * 2;
535 c.height = Math.ceil(h) + pad * 2;
536 const g = c.getContext('2d');
537 if (g) {
538 g.filter = `blur(${Math.max(1.5, w * 0.11).toFixed(1)}px)`;
539 g.fillStyle = '#000';
540 g.translate(c.width / 2, c.height / 2);
541 g.beginPath();
542 g.roundRect(-w / 2, -h / 2, w, h, w * 0.1);
543 g.fill();
544 }
545 shadows.set(key, c);
546 return c;
547}
548
549/**
550 * Foreshortening along the axis a tile is turning about.
551 *
552 * There is no third dimension here, so an out-of-plane turn is drawn as a
553 * squash. Applied in the *table's* frame, before the tile's own flat rotation,
554 * because the axis it turns about is fixed by the direction it was thrown, not
555 * by which way the tile happens to be facing.
556 */
557function squash(ctx: CanvasRenderingContext2D, across: number, axis: number) {
558 if (across > 0.999) return;
559 ctx.rotate(axis);
560 ctx.scale(across, 1);
561 ctx.rotate(-axis);
562}
563
564function shadow(
565 ctx: CanvasRenderingContext2D,
566 x: number,
567 y: number,
568 z: number,
569 angle: number,
570 w: number,
571 h: number,
572 scale: number,
573 facing: number,
574 axis: number,
575) {
576 // Keyed on the tile's own size, never on the eased one — a scale that changes
577 // every frame would mean a fresh blur every frame, which is the thing this
578 // exists to avoid.
579 const sprite = shadowSprite(w, h);
580 const spread = zScale(z) * scale * (1 + z * 0.004);
581 ctx.save();
582 ctx.globalAlpha = 0.36 - Math.min(0.18, z * 0.0016);
583 ctx.translate(x + z * 0.12, y + 3 + z * 0.34);
584 squash(ctx, Math.abs(facing) + THICKNESS * Math.sqrt(Math.max(0, 1 - facing * facing)), axis);
585 ctx.rotate(angle);
586 ctx.drawImage(
587 sprite,
588 (-sprite.width / 2) * spread,
589 (-sprite.height / 2) * spread,
590 sprite.width * spread,
591 sprite.height * spread,
592 );
593 ctx.restore();
594}
595
596function face(
597 ctx: CanvasRenderingContext2D,
598 tile: number,
599 w: number,
600 h: number,
601 art: Art,
602 lit: boolean,
603 backwards: boolean,
604) {
605 // Part-way through a turn you are looking at the back of it: the same green
606 // tile back the wall is built from. A tile always comes to rest face up, so
607 // this is only ever seen in the air.
608 if (backwards) {
609 if (art.back) ctx.drawImage(art.back, -w / 2, -h / 2, w, h);
610 else {
611 tilePath(ctx, w, h);
612 ctx.fillStyle = '#1c7a4b';
613 ctx.fill();
614 }
615 return;
616 }
617
618 if (art.faces) {
619 // 43 columns, two rows; the second is the sheet's own highlighted variant,
620 // which is what a lifted tile in a hand uses. The tile in your fingers and
621 // the one still to be claimed get it here for the same reason.
622 const cw = art.faces.naturalWidth / SPRITE_COLS;
623 const ch = art.faces.naturalHeight / 2;
624 ctx.drawImage(art.faces, SPRITE_COL[tile] * cw, lit ? ch : 0, cw, ch, -w / 2, -h / 2, w, h);
625 } else {
626 tilePath(ctx, w, h);
627 ctx.fillStyle = '#f6f0e2';
628 ctx.fill();
629 }
630 if (lit) {
631 // A pool of sixty tiles needs more than a lighter face to pick one out.
632 tilePath(ctx, w, h);
633 ctx.strokeStyle = '#d9b45c';
634 ctx.lineWidth = 2;
635 ctx.stroke();
636 }
637}
638
639function tilePath(ctx: CanvasRenderingContext2D, w: number, h: number) {
640 ctx.beginPath();
641 ctx.roundRect(-w / 2, -h / 2, w, h, w * 0.1);
642}