anvilsign in

collin/mahjong

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