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.scale, b.h * b.scale);
145 if (held) shadow(ctx, held.x, held.y, HELD_Z, held.angle, held.w, held.h);
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. Blurred where the browser will do it — a hard
181 * black slab reads as a second tile rather than as a shadow — and simply a faint
182 * one where it will not.
183 */
184function shadow(
185 ctx: CanvasRenderingContext2D,
186 x: number,
187 y: number,
188 z: number,
189 angle: number,
190 w: number,
191 h: number,
192) {
193 ctx.save();
194 ctx.filter = `blur(${(2.5 + z * 0.07).toFixed(1)}px)`;
195 ctx.fillStyle = `rgba(0, 0, 0, ${(0.34 - Math.min(0.16, z * 0.0014)).toFixed(3)})`;
196 ctx.translate(x + z * 0.12, y + 3 + z * 0.34);
197 ctx.rotate(angle);
198 const s = zScale(z) * 1.03;
199 tilePath(ctx, w * s, h * s);
200 ctx.fill();
201 ctx.restore();
202}
203
204function face(
205 ctx: CanvasRenderingContext2D,
206 tile: number,
207 w: number,
208 h: number,
209 sheet: HTMLImageElement | null,
210 lit: boolean,
211) {
212 if (sheet) {
213 // 43 columns, two rows; the second is the sheet's own highlighted variant,
214 // which is what a lifted tile in a hand uses. The tile in your fingers and
215 // the one still to be claimed get it here for the same reason.
216 const cw = sheet.naturalWidth / SPRITE_COLS;
217 const ch = sheet.naturalHeight / 2;
218 ctx.drawImage(sheet, SPRITE_COL[tile] * cw, lit ? ch : 0, cw, ch, -w / 2, -h / 2, w, h);
219 } else {
220 tilePath(ctx, w, h);
221 ctx.fillStyle = '#f6f0e2';
222 ctx.fill();
223 }
224 if (lit) {
225 // A pool of sixty tiles needs more than a lighter face to pick one out.
226 tilePath(ctx, w, h);
227 ctx.strokeStyle = '#d9b45c';
228 ctx.lineWidth = 2;
229 ctx.stroke();
230 }
231}
232
233function tilePath(ctx: CanvasRenderingContext2D, w: number, h: number) {
234 ctx.beginPath();
235 ctx.roundRect(-w / 2, -h / 2, w, h, w * 0.1);
236}