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