anvilsign in

collin/mahjong

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