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 { faceOn, 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 reachRef = useRef<HTMLDivElement>(null);
24 const art = useSheet();
25 const wash = useWash(pool);
26
27 // What the loop reads. Refreshed on every render, read on every frame.
28 const live = useRef({ state: game.state, art });
29 live.current = { state: game.state, art };
30
31 // Poked by the effect below, and by the gesture through pool.wake.
32 const wake = useRef<() => void>(() => {});
33
34 useEffect(() => {
35 const canvas = canvasRef.current;
36 const table = canvas?.parentElement;
37 const ctx = canvas?.getContext('2d');
38 if (!canvas || !table || !ctx) return;
39
40 pool.attach(table);
41
42 let frame = 0;
43 let previous = 0;
44
45 const resize = () => {
46 const dpr = window.devicePixelRatio || 1;
47 const w = Math.round(table.clientWidth * dpr);
48 const h = Math.round(table.clientHeight * dpr);
49 if (canvas.width !== w || canvas.height !== h) {
50 canvas.width = w;
51 canvas.height = h;
52 }
53 // The square moved, so the colliders and the launch points have too.
54 pool.invalidate();
55 run();
56 };
57
58 const tick = (now: number) => {
59 frame = 0;
60 // First frame after waking has no previous time to measure against.
61 const dt = previous ? Math.min(0.05, (now - previous) / 1000) : 0;
62 previous = now;
63
64 pool.sync(live.current.state);
65 placeReach(reachRef.current, pool);
66 // The loudest contact this frame is the one you would actually hear; the
67 // rest of a scatter is the same event.
68 const impacts = pool.advance(dt);
69 let loudest = 0;
70 for (const i of impacts) loudest = Math.max(loudest, i.strength);
71 if (loudest > 0) knock(loudest);
72 paint(ctx, canvas, pool, live.current.art);
73
74 // Keep going while anything is moving; otherwise wait to be poked.
75 if (!pool.settled) frame = requestAnimationFrame(tick);
76 else previous = 0;
77 };
78
79 const run = () => {
80 if (!frame) frame = requestAnimationFrame(tick);
81 };
82 wake.current = run;
83 pool.wake = run;
84
85 const observer = new ResizeObserver(resize);
86 observer.observe(table);
87 resize();
88
89 return () => {
90 observer.disconnect();
91 if (frame) cancelAnimationFrame(frame);
92 wake.current = () => {};
93 pool.wake = () => {};
94 pool.detach();
95 };
96 }, [pool]);
97
98 // Any change to the table — a discard, a claim, an undo, a new hand — is a
99 // reason to look again.
100 useEffect(() => {
101 wake.current();
102 });
103
104 return (
105 <>
106 <canvas className="tile-pool" ref={canvasRef} aria-hidden />
107 {/* The patch of table you can reach into. Sized to the pool from the
108 measurement rather than from CSS, and only that patch, so it cannot
109 swallow clicks meant for the strips around it. */}
110 <div className="pool-reach" ref={reachRef} onPointerDown={wash.down} onPointerMove={wash.move} onPointerUp={wash.up} onPointerCancel={wash.up} />
111 </>
112 );
113}
114
115/**
116 * Washing the tiles about with a finger.
117 *
118 * A discard's position is not part of the game — the engine neither knows nor
119 * cares where in the middle a tile is lying — so this is free to be pure fidget,
120 * the thing everybody does to the pool while waiting for their turn.
121 */
122function useWash(pool: TablePool) {
123 const last = useRef<{ x: number; y: number; t: number } | null>(null);
124
125 const at = (e: React.PointerEvent) => {
126 const table = (e.currentTarget as HTMLElement).parentElement;
127 const r = table?.getBoundingClientRect();
128 return { x: e.clientX - (r?.left ?? 0), y: e.clientY - (r?.top ?? 0), t: e.timeStamp };
129 };
130
131 return {
132 down: (e: React.PointerEvent) => {
133 try {
134 (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
135 } catch {
136 // Uncaptured still works while the pointer stays over the pool.
137 }
138 last.current = at(e);
139 },
140 move: (e: React.PointerEvent) => {
141 const prev = last.current;
142 if (!prev) return;
143 const now = at(e);
144 const dt = (now.t - prev.t) / 1000;
145 last.current = now;
146 if (dt <= 0) return;
147 pool.stir(now.x, now.y, (now.x - prev.x) / dt, (now.y - prev.y) / dt);
148 pool.wake();
149 },
150 up: () => {
151 last.current = null;
152 },
153 };
154}
155
156interface Art {
157 faces: HTMLImageElement | null;
158 back: HTMLImageElement | null;
159}
160
161/**
162 * The tile art, as something canvas can draw: the face sheet, and the green
163 * back a tile shows while it is turning over. Both are the same files the CSS
164 * uses for the tiles in the hands and the wall, so the pool cannot drift from
165 * the rest of the table.
166 */
167function useSheet(): Art {
168 const [art, setArt] = useState<Art>({ faces: null, back: null });
169 useEffect(() => {
170 let alive = true;
171 const load = (src: string) => {
172 const el = new Image();
173 el.src = src;
174 // Not fatal: the pool falls back to blank tiles rather than emptying out.
175 return el.decode().then(
176 () => el,
177 () => null,
178 );
179 };
180 void Promise.all([load('/tiles/tiles.svg'), load('/tiles/back.png')]).then(
181 ([faces, back]) => alive && setArt({ faces, back }),
182 );
183 return () => {
184 alive = false;
185 };
186 }, []);
187 return art;
188}
189
190/** How high a tile in somebody's fingers is held above the felt. */
191const HELD_Z = 52;
192
193/**
194 * Park the reachable patch over the pool. Driven off the measurement rather
195 * than restated in CSS, for the same reason the colliders are: the wall square's
196 * size is worked out in one place and this follows it.
197 */
198function placeReach(el: HTMLDivElement | null, pool: TablePool) {
199 if (!el) return;
200 const r = pool.poolRect;
201 if (!r) {
202 el.style.display = 'none';
203 el.dataset.box = '';
204 return;
205 }
206 const box = [r.x, r.y, r.w, r.h].map(Math.round);
207 const stamp = box.join(',');
208 if (el.dataset.box === stamp) return;
209 el.dataset.box = stamp;
210 el.style.display = '';
211 el.style.left = `${box[0]}px`;
212 el.style.top = `${box[1]}px`;
213 el.style.width = `${box[2]}px`;
214 el.style.height = `${box[3]}px`;
215}
216
217function paint(
218 ctx: CanvasRenderingContext2D,
219 canvas: HTMLCanvasElement,
220 pool: TablePool,
221 art: Art,
222) {
223 const dpr = window.devicePixelRatio || 1;
224 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
225 ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
226
227 const bodies = pool.bodies;
228 const held = pool.held;
229 const departing = pool.departing;
230
231 // Shadows in one pass of their own, offset before rotation, so the light comes
232 // from one direction across the whole pool rather than turning with each tile.
233 for (const b of bodies) {
234 shadow(ctx, b.x, b.y, b.z, b.angle, b.w, b.h, b.scale, faceOn(b), b.flipAxis);
235 }
236 if (held) shadow(ctx, held.x, held.y, HELD_Z, held.angle, held.w, held.h, 1, 1, 0);
237
238 const newest = pool.newest;
239 for (const b of bodies) {
240 tile(ctx, art, {
241 x: b.x,
242 y: b.y,
243 z: b.z,
244 angle: b.angle,
245 w: b.w * b.scale,
246 h: b.h * b.scale,
247 facing: faceOn(b),
248 axis: b.flipAxis,
249 code: b.tile,
250 lit: b === newest,
251 });
252 }
253
254 // On its way to whoever called it — lifted, turning, and fading as it goes.
255 for (const { body: b, fade } of departing) {
256 ctx.save();
257 ctx.globalAlpha = fade;
258 tile(ctx, art, {
259 x: b.x,
260 y: b.y,
261 z: b.z,
262 angle: b.angle,
263 w: b.w * b.scale,
264 h: b.h * b.scale,
265 facing: faceOn(b),
266 axis: b.flipAxis,
267 code: b.tile,
268 lit: true,
269 });
270 ctx.restore();
271 }
272
273 // Whatever is in your fingers is above everything else on the table.
274 if (held) {
275 tile(ctx, art, {
276 x: held.x,
277 y: held.y,
278 z: HELD_Z,
279 angle: held.angle,
280 w: held.w,
281 h: held.h,
282 facing: 1,
283 axis: 0,
284 code: held.tile,
285 lit: true,
286 });
287 }
288}
289
290/**
291 * How thick a tile is, as a fraction of its width. A real one is about
292 * three-quarters as thick as it is wide; this is the part of that you can see
293 * from almost overhead, which is a good deal less.
294 */
295const THICKNESS = 0.3;
296/** The ivory of the tile's own body, seen along its sides. */
297const EDGE_LIGHT = '#e6dcc2';
298const EDGE_DARK = '#b9ad8d';
299
300interface Drawn {
301 x: number;
302 y: number;
303 z: number;
304 angle: number;
305 w: number;
306 h: number;
307 /** cos of the turn: 1 flat on its back, 0 edge on, negative face down. */
308 facing: number;
309 axis: number;
310 code: number;
311 lit: boolean;
312}
313
314/**
315 * One tile, with the thickness of one.
316 *
317 * Drawn as two passes: the body of the tile, offset down the screen towards the
318 * viewer, and then its face on top of that. The sliver of body left showing
319 * along the bottom edge is what stops a tile reading as a printed card.
320 *
321 * Turning over makes that matter more, not less. Face on you see the face and a
322 * hair of the side; edge on there is no face left to see and the tile is a bar
323 * of ivory as thick as it really is — which is why the squash uses the
324 * rectangle's true projected width, `w·|cos| + thickness·|sin|`, rather than
325 * pinching to nothing and back.
326 */
327function tile(ctx: CanvasRenderingContext2D, art: Art, d: Drawn) {
328 const s = zScale(d.z);
329 const w = d.w * s;
330 const h = d.h * s;
331 const thick = w * THICKNESS;
332 const c = Math.abs(d.facing);
333 const sin = Math.sqrt(Math.max(0, 1 - d.facing * d.facing));
334 // What the whole slab covers across the axis it is turning about.
335 const projected = c + (thick / w) * sin;
336 // How much of the body shows past the face: none of it face on beyond its own
337 // thickness, all of it edge on. Higher tiles show a touch more of their side.
338 const lift = thick * (0.45 + 0.55 * sin) + d.z * 0.05;
339
340 // --- the body of the tile, seen from just off square
341 ctx.save();
342 ctx.translate(d.x, d.y + lift);
343 squash(ctx, projected, d.axis);
344 ctx.rotate(d.angle);
345 const grad = ctx.createLinearGradient(0, -h / 2, 0, h / 2);
346 grad.addColorStop(0, EDGE_DARK);
347 grad.addColorStop(1, EDGE_LIGHT);
348 ctx.fillStyle = grad;
349 tilePath(ctx, w, h);
350 ctx.fill();
351 ctx.restore();
352
353 // --- and its face on top
354 ctx.save();
355 ctx.translate(d.x, d.y);
356 squash(ctx, Math.max(0.015, c), d.axis);
357 ctx.rotate(d.angle);
358 face(ctx, d.code, w, h, art, d.lit, d.facing < 0);
359 ctx.restore();
360}
361
362/**
363 * A soft silhouette under a tile.
364 *
365 * The blur is baked into a sprite once per tile size rather than applied per
366 * draw. `ctx.filter` is the obvious way to write this and the wrong one: every
367 * filtered fill makes the browser allocate and blur its own layer, so sixty
368 * tiles times sixty frames is sixty times sixty blurs a second, which is enough
369 * to make the tab stop answering. One blur, then `drawImage`.
370 *
371 * Height is sold by scaling that sprite up — a shadow that spreads and fades as
372 * the tile rises — which a fixed blur radius could not have done anyway.
373 */
374const shadows = new Map<string, HTMLCanvasElement>();
375
376function shadowSprite(w: number, h: number): HTMLCanvasElement {
377 const key = `${Math.round(w)}x${Math.round(h)}`;
378 const had = shadows.get(key);
379 if (had) return had;
380 // Only ever a handful — one per tile size on screen — but a window being
381 // dragged to resize walks through a lot of them.
382 if (shadows.size > 8) shadows.clear();
383
384 const pad = Math.ceil(Math.max(8, w * 0.5));
385 const c = document.createElement('canvas');
386 c.width = Math.ceil(w) + pad * 2;
387 c.height = Math.ceil(h) + pad * 2;
388 const g = c.getContext('2d');
389 if (g) {
390 g.filter = `blur(${Math.max(1.5, w * 0.11).toFixed(1)}px)`;
391 g.fillStyle = '#000';
392 g.translate(c.width / 2, c.height / 2);
393 g.beginPath();
394 g.roundRect(-w / 2, -h / 2, w, h, w * 0.1);
395 g.fill();
396 }
397 shadows.set(key, c);
398 return c;
399}
400
401/**
402 * Foreshortening along the axis a tile is turning about.
403 *
404 * There is no third dimension here, so an out-of-plane turn is drawn as a
405 * squash. Applied in the *table's* frame, before the tile's own flat rotation,
406 * because the axis it turns about is fixed by the direction it was thrown, not
407 * by which way the tile happens to be facing.
408 */
409function squash(ctx: CanvasRenderingContext2D, across: number, axis: number) {
410 if (across > 0.999) return;
411 ctx.rotate(axis);
412 ctx.scale(across, 1);
413 ctx.rotate(-axis);
414}
415
416function shadow(
417 ctx: CanvasRenderingContext2D,
418 x: number,
419 y: number,
420 z: number,
421 angle: number,
422 w: number,
423 h: number,
424 scale: number,
425 facing: number,
426 axis: number,
427) {
428 // Keyed on the tile's own size, never on the eased one — a scale that changes
429 // every frame would mean a fresh blur every frame, which is the thing this
430 // exists to avoid.
431 const sprite = shadowSprite(w, h);
432 const spread = zScale(z) * scale * (1 + z * 0.004);
433 ctx.save();
434 ctx.globalAlpha = 0.36 - Math.min(0.18, z * 0.0016);
435 ctx.translate(x + z * 0.12, y + 3 + z * 0.34);
436 squash(ctx, Math.abs(facing) + THICKNESS * Math.sqrt(Math.max(0, 1 - facing * facing)), axis);
437 ctx.rotate(angle);
438 ctx.drawImage(
439 sprite,
440 (-sprite.width / 2) * spread,
441 (-sprite.height / 2) * spread,
442 sprite.width * spread,
443 sprite.height * spread,
444 );
445 ctx.restore();
446}
447
448function face(
449 ctx: CanvasRenderingContext2D,
450 tile: number,
451 w: number,
452 h: number,
453 art: Art,
454 lit: boolean,
455 backwards: boolean,
456) {
457 // Part-way through a turn you are looking at the back of it: the same green
458 // tile back the wall is built from. A tile always comes to rest face up, so
459 // this is only ever seen in the air.
460 if (backwards) {
461 if (art.back) ctx.drawImage(art.back, -w / 2, -h / 2, w, h);
462 else {
463 tilePath(ctx, w, h);
464 ctx.fillStyle = '#1c7a4b';
465 ctx.fill();
466 }
467 return;
468 }
469
470 if (art.faces) {
471 // 43 columns, two rows; the second is the sheet's own highlighted variant,
472 // which is what a lifted tile in a hand uses. The tile in your fingers and
473 // the one still to be claimed get it here for the same reason.
474 const cw = art.faces.naturalWidth / SPRITE_COLS;
475 const ch = art.faces.naturalHeight / 2;
476 ctx.drawImage(art.faces, SPRITE_COL[tile] * cw, lit ? ch : 0, cw, ch, -w / 2, -h / 2, w, h);
477 } else {
478 tilePath(ctx, w, h);
479 ctx.fillStyle = '#f6f0e2';
480 ctx.fill();
481 }
482 if (lit) {
483 // A pool of sixty tiles needs more than a lighter face to pick one out.
484 tilePath(ctx, w, h);
485 ctx.strokeStyle = '#d9b45c';
486 ctx.lineWidth = 2;
487 ctx.stroke();
488 }
489}
490
491function tilePath(ctx: CanvasRenderingContext2D, w: number, h: number) {
492 ctx.beginPath();
493 ctx.roundRect(-w / 2, -h / 2, w, h, w * 0.1);
494}