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 const departing = pool.departing;
141
142 // Shadows in one pass of their own, offset before rotation, so the light comes
143 // from one direction across the whole pool rather than turning with each tile.
144 for (const b of bodies) shadow(ctx, b.x, b.y, b.z, b.angle, b.w, b.h, b.scale);
145 if (held) shadow(ctx, held.x, held.y, HELD_Z, held.angle, held.w, held.h, 1);
146
147 const newest = pool.newest;
148 for (const b of bodies) {
149 const s = zScale(b.z) * b.scale;
150 ctx.save();
151 ctx.translate(b.x, b.y);
152 ctx.rotate(b.angle);
153 face(ctx, b.tile, b.w * s, b.h * s, sheet, b === newest);
154 ctx.restore();
155 }
156
157 // On its way to whoever called it — lifted, turning, and fading as it goes.
158 for (const { body: b, fade } of departing) {
159 const s = zScale(b.z) * b.scale;
160 ctx.save();
161 ctx.globalAlpha = fade;
162 ctx.translate(b.x, b.y);
163 ctx.rotate(b.angle);
164 face(ctx, b.tile, b.w * s, b.h * s, sheet, true);
165 ctx.restore();
166 }
167
168 // Whatever is in your fingers is above everything else on the table.
169 if (held) {
170 const s = zScale(HELD_Z);
171 ctx.save();
172 ctx.translate(held.x, held.y);
173 ctx.rotate(held.angle);
174 face(ctx, held.tile, held.w * s, held.h * s, sheet, true);
175 ctx.restore();
176 }
177}
178
179/**
180 * A soft silhouette under a tile.
181 *
182 * The blur is baked into a sprite once per tile size rather than applied per
183 * draw. `ctx.filter` is the obvious way to write this and the wrong one: every
184 * filtered fill makes the browser allocate and blur its own layer, so sixty
185 * tiles times sixty frames is sixty times sixty blurs a second, which is enough
186 * to make the tab stop answering. One blur, then `drawImage`.
187 *
188 * Height is sold by scaling that sprite up — a shadow that spreads and fades as
189 * the tile rises — which a fixed blur radius could not have done anyway.
190 */
191const shadows = new Map<string, HTMLCanvasElement>();
192
193function shadowSprite(w: number, h: number): HTMLCanvasElement {
194 const key = `${Math.round(w)}x${Math.round(h)}`;
195 const had = shadows.get(key);
196 if (had) return had;
197 // Only ever a handful — one per tile size on screen — but a window being
198 // dragged to resize walks through a lot of them.
199 if (shadows.size > 8) shadows.clear();
200
201 const pad = Math.ceil(Math.max(8, w * 0.5));
202 const c = document.createElement('canvas');
203 c.width = Math.ceil(w) + pad * 2;
204 c.height = Math.ceil(h) + pad * 2;
205 const g = c.getContext('2d');
206 if (g) {
207 g.filter = `blur(${Math.max(1.5, w * 0.11).toFixed(1)}px)`;
208 g.fillStyle = '#000';
209 g.translate(c.width / 2, c.height / 2);
210 g.beginPath();
211 g.roundRect(-w / 2, -h / 2, w, h, w * 0.1);
212 g.fill();
213 }
214 shadows.set(key, c);
215 return c;
216}
217
218function shadow(
219 ctx: CanvasRenderingContext2D,
220 x: number,
221 y: number,
222 z: number,
223 angle: number,
224 w: number,
225 h: number,
226 scale: number,
227) {
228 // Keyed on the tile's own size, never on the eased one — a scale that changes
229 // every frame would mean a fresh blur every frame, which is the thing this
230 // exists to avoid.
231 const sprite = shadowSprite(w, h);
232 const spread = zScale(z) * scale * (1 + z * 0.004);
233 ctx.save();
234 ctx.globalAlpha = 0.36 - Math.min(0.18, z * 0.0016);
235 ctx.translate(x + z * 0.12, y + 3 + z * 0.34);
236 ctx.rotate(angle);
237 ctx.drawImage(
238 sprite,
239 (-sprite.width / 2) * spread,
240 (-sprite.height / 2) * spread,
241 sprite.width * spread,
242 sprite.height * spread,
243 );
244 ctx.restore();
245}
246
247function face(
248 ctx: CanvasRenderingContext2D,
249 tile: number,
250 w: number,
251 h: number,
252 sheet: HTMLImageElement | null,
253 lit: boolean,
254) {
255 if (sheet) {
256 // 43 columns, two rows; the second is the sheet's own highlighted variant,
257 // which is what a lifted tile in a hand uses. The tile in your fingers and
258 // the one still to be claimed get it here for the same reason.
259 const cw = sheet.naturalWidth / SPRITE_COLS;
260 const ch = sheet.naturalHeight / 2;
261 ctx.drawImage(sheet, SPRITE_COL[tile] * cw, lit ? ch : 0, cw, ch, -w / 2, -h / 2, w, h);
262 } else {
263 tilePath(ctx, w, h);
264 ctx.fillStyle = '#f6f0e2';
265 ctx.fill();
266 }
267 if (lit) {
268 // A pool of sixty tiles needs more than a lighter face to pick one out.
269 tilePath(ctx, w, h);
270 ctx.strokeStyle = '#d9b45c';
271 ctx.lineWidth = 2;
272 ctx.stroke();
273 }
274}
275
276function tilePath(ctx: CanvasRenderingContext2D, w: number, h: number) {
277 ctx.beginPath();
278 ctx.roundRect(-w / 2, -h / 2, w, h, w * 0.1);
279}