anvilsign in

collin/mahjong

1import { useEffect, useRef, useState } from 'react';
2import type { Game } from '../game/engine';
3import { knock } from '../game/sound';
4import { SPRITE_COL, SPRITE_COLS } from '../game/tiles';
5import { zScale } from '../table/physics';
6import type { TablePool } from '../table/pool';
7
8/**
9 * The tiles in the middle of the table, drawn.
10 *
11 * A canvas rather than elements, and it spans the whole table rather than just
12 * the centre. The span is the point: `.slot` and `.seat` both clip their own
13 * contents, which is exactly why a tile cannot be animated out of a strip as a
14 * div — on the canvas there is nothing to clip it, so a tile lifted out of a
15 * hand and thrown crosses the table in one piece.
16 *
17 * The loop stops as soon as the pile has settled, and starts again when the
18 * state changes, a tile is picked up, or the window resizes, so a table nobody
19 * is touching costs nothing.
20 */
21export function Pool({ game, pool }: { game: Game; pool: TablePool }) {
22 const canvasRef = useRef<HTMLCanvasElement>(null);
23 const sheet = useSheet();
24
25 // What the loop reads. Refreshed on every render, read on every frame.
26 const live = useRef({ state: game.state, sheet });
27 live.current = { state: game.state, sheet };
28
29 // Poked by the effect below, and by the gesture through pool.wake.
30 const wake = useRef<() => void>(() => {});
31
32 useEffect(() => {
33 const canvas = canvasRef.current;
34 const table = canvas?.parentElement;
35 const ctx = canvas?.getContext('2d');
36 if (!canvas || !table || !ctx) return;
37
38 pool.attach(table);
39
40 let frame = 0;
41 let previous = 0;
42
43 const resize = () => {
44 const dpr = window.devicePixelRatio || 1;
45 const w = Math.round(table.clientWidth * dpr);
46 const h = Math.round(table.clientHeight * dpr);
47 if (canvas.width !== w || canvas.height !== h) {
48 canvas.width = w;
49 canvas.height = h;
50 }
51 // The square moved, so the colliders and the launch points have too.
52 pool.invalidate();
53 run();
54 };
55
56 const tick = (now: number) => {
57 frame = 0;
58 // First frame after waking has no previous time to measure against.
59 const dt = previous ? Math.min(0.05, (now - previous) / 1000) : 0;
60 previous = now;
61
62 pool.sync(live.current.state);
63 // The loudest contact this frame is the one you would actually hear; the
64 // rest of a scatter is the same event.
65 const impacts = pool.advance(dt);
66 let loudest = 0;
67 for (const i of impacts) loudest = Math.max(loudest, i.strength);
68 if (loudest > 0) knock(loudest);
69 paint(ctx, canvas, pool, live.current.sheet);
70
71 // Keep going while anything is moving; otherwise wait to be poked.
72 if (!pool.settled) frame = requestAnimationFrame(tick);
73 else previous = 0;
74 };
75
76 const run = () => {
77 if (!frame) frame = requestAnimationFrame(tick);
78 };
79 wake.current = run;
80 pool.wake = run;
81
82 const observer = new ResizeObserver(resize);
83 observer.observe(table);
84 resize();
85
86 return () => {
87 observer.disconnect();
88 if (frame) cancelAnimationFrame(frame);
89 wake.current = () => {};
90 pool.wake = () => {};
91 pool.detach();
92 };
93 }, [pool]);
94
95 // Any change to the table — a discard, a claim, an undo, a new hand — is a
96 // reason to look again.
97 useEffect(() => {
98 wake.current();
99 });
100
101 return <canvas className="tile-pool" ref={canvasRef} aria-hidden />;
102}
103
104/**
105 * The tile sheet, as something canvas can draw. The same file the CSS uses for
106 * the tiles in the hands, so the pool cannot drift from the rest of the table.
107 */
108function useSheet(): HTMLImageElement | null {
109 const [img, setImg] = useState<HTMLImageElement | null>(null);
110 useEffect(() => {
111 let alive = true;
112 const el = new Image();
113 el.src = '/tiles/tiles.svg';
114 el.decode()
115 .then(() => alive && setImg(el))
116 // Not fatal: the pool falls back to blank tiles rather than emptying out.
117 .catch(() => {});
118 return () => {
119 alive = false;
120 };
121 }, []);
122 return img;
123}
124
125/** How high a tile in somebody's fingers is held above the felt. */
126const HELD_Z = 52;
127
128function paint(
129 ctx: CanvasRenderingContext2D,
130 canvas: HTMLCanvasElement,
131 pool: TablePool,
132 sheet: HTMLImageElement | null,
133) {
134 const dpr = window.devicePixelRatio || 1;
135 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
136 ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
137
138 const bodies = pool.bodies;
139 const held = pool.held;
140
141 // Shadows in one pass of their own, offset before rotation, so the light comes
142 // from one direction across the whole pool rather than turning with each tile.
143 for (const b of bodies) shadow(ctx, b.x, b.y, b.z, b.angle, b.w * b.scale, b.h * b.scale);
144 if (held) shadow(ctx, held.x, held.y, HELD_Z, held.angle, held.w, held.h);
145
146 const newest = pool.newest;
147 for (const b of bodies) {
148 const s = zScale(b.z) * b.scale;
149 ctx.save();
150 ctx.translate(b.x, b.y);
151 ctx.rotate(b.angle);
152 face(ctx, b.tile, b.w * s, b.h * s, sheet, b === newest);
153 ctx.restore();
154 }
155
156 // Whatever is in your fingers is above everything else on the table.
157 if (held) {
158 const s = zScale(HELD_Z);
159 ctx.save();
160 ctx.translate(held.x, held.y);
161 ctx.rotate(held.angle);
162 face(ctx, held.tile, held.w * s, held.h * s, sheet, true);
163 ctx.restore();
164 }
165}
166
167/**
168 * A soft silhouette under a tile. Blurred where the browser will do it — a hard
169 * black slab reads as a second tile rather than as a shadow — and simply a faint
170 * one where it will not.
171 */
172function shadow(
173 ctx: CanvasRenderingContext2D,
174 x: number,
175 y: number,
176 z: number,
177 angle: number,
178 w: number,
179 h: number,
180) {
181 ctx.save();
182 ctx.filter = `blur(${(2.5 + z * 0.07).toFixed(1)}px)`;
183 ctx.fillStyle = `rgba(0, 0, 0, ${(0.34 - Math.min(0.16, z * 0.0014)).toFixed(3)})`;
184 ctx.translate(x + z * 0.12, y + 3 + z * 0.34);
185 ctx.rotate(angle);
186 const s = zScale(z) * 1.03;
187 tilePath(ctx, w * s, h * s);
188 ctx.fill();
189 ctx.restore();
190}
191
192function face(
193 ctx: CanvasRenderingContext2D,
194 tile: number,
195 w: number,
196 h: number,
197 sheet: HTMLImageElement | null,
198 lit: boolean,
199) {
200 if (sheet) {
201 // 43 columns, two rows; the second is the sheet's own highlighted variant,
202 // which is what a lifted tile in a hand uses. The tile in your fingers and
203 // the one still to be claimed get it here for the same reason.
204 const cw = sheet.naturalWidth / SPRITE_COLS;
205 const ch = sheet.naturalHeight / 2;
206 ctx.drawImage(sheet, SPRITE_COL[tile] * cw, lit ? ch : 0, cw, ch, -w / 2, -h / 2, w, h);
207 } else {
208 tilePath(ctx, w, h);
209 ctx.fillStyle = '#f6f0e2';
210 ctx.fill();
211 }
212 if (lit) {
213 // A pool of sixty tiles needs more than a lighter face to pick one out.
214 tilePath(ctx, w, h);
215 ctx.strokeStyle = '#d9b45c';
216 ctx.lineWidth = 2;
217 ctx.stroke();
218 }
219}
220
221function tilePath(ctx: CanvasRenderingContext2D, w: number, h: number) {
222 ctx.beginPath();
223 ctx.roundRect(-w / 2, -h / 2, w, h, w * 0.1);
224}