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(pool, 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/** How long the knocked row rocks for, in seconds. Matches `shoved` in the CSS,
204 * which is the other half of it: the table waits at least this long. */
205const SHAKE = 0.34;
206
207/**
208 * A tile has ended up in this seat's tiles — knocked into them, or simply come
209 * to a stop off the table on their side. Either way it is theirs to complain
210 * about, and how hard it arrived only decides how loudly.
211 *
212 * Their row takes the knock — straight on the element rather than through
213 * React, because this happens inside the physics loop and re-rendering four
214 * strips to shake one of them would be absurd — and the table says something
215 * about it. Not for the thrower's own edge: you cannot be barged by your own
216 * discard, and everybody's tile passes over their own row on the way out.
217 */
218function shove(
219 pool: TablePool,
220 table: HTMLElement,
221 seat: SeatId,
222 thrower: SeatId | undefined,
223 strength: number,
224) {
225 if (seat === thrower) return;
226 // Hold the table while it is being said and while the row is still rocking.
227 // The computer plays on a timer of its own, and a 碰 called over the top of
228 // somebody objecting to the tile lands the objection on nobody.
229 pool.heckle(Math.max(SHAKE, barge(strength)));
230 const hand = table.querySelector<HTMLElement>(`[data-seat="${seat}"] .hand`);
231 if (!hand || hand.classList.contains('shoved')) return;
232 hand.classList.add('shoved');
233 hand.addEventListener('animationend', () => hand.classList.remove('shoved'), { once: true });
234}
235
236/**
237 * Park the reachable patch over the pool. Driven off the measurement rather
238 * than restated in CSS, for the same reason the colliders are: the wall square's
239 * size is worked out in one place and this follows it.
240 */
241function placeReach(el: HTMLDivElement | null, pool: TablePool) {
242 if (!el) return;
243 const r = pool.poolRect;
244 if (!r) {
245 el.style.display = 'none';
246 el.dataset.box = '';
247 return;
248 }
249 const box = [r.x, r.y, r.w, r.h].map(Math.round);
250 const stamp = box.join(',');
251 if (el.dataset.box === stamp) return;
252 el.dataset.box = stamp;
253 el.style.display = '';
254 el.style.left = `${box[0]}px`;
255 el.style.top = `${box[1]}px`;
256 el.style.width = `${box[2]}px`;
257 el.style.height = `${box[3]}px`;
258}
259
260function paint(
261 ctx: CanvasRenderingContext2D,
262 canvas: HTMLCanvasElement,
263 pool: TablePool,
264 art: Art,
265) {
266 const dpr = window.devicePixelRatio || 1;
267 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
268 ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
269
270 const bodies = pool.bodies;
271 const held = pool.held;
272 const departing = pool.departing;
273
274 // Shadows in one pass of their own, offset before rotation, so the light comes
275 // from one direction across the whole pool rather than turning with each tile.
276 for (const b of bodies) {
277 shadow(ctx, b.x, b.y, b.z, b.angle, b.w, b.h, b.scale, faceOn(b), b.flipAxis);
278 }
279 if (held) shadow(ctx, held.x, held.y, HELD_Z, held.angle, held.w, held.h, 1, 1, 0);
280
281 const newest = pool.newest;
282 for (const b of bodies) {
283 tile(ctx, art, {
284 x: b.x,
285 y: b.y,
286 z: b.z,
287 angle: b.angle,
288 w: b.w * b.scale,
289 h: b.h * b.scale,
290 facing: faceOn(b),
291 axis: b.flipAxis,
292 code: b.tile,
293 lit: b === newest,
294 });
295 }
296
297 // On its way to whoever called it — lifted, turning, and fading as it goes.
298 for (const { body: b, fade } of departing) {
299 ctx.save();
300 ctx.globalAlpha = fade;
301 tile(ctx, art, {
302 x: b.x,
303 y: b.y,
304 z: b.z,
305 angle: b.angle,
306 w: b.w * b.scale,
307 h: b.h * b.scale,
308 facing: faceOn(b),
309 axis: b.flipAxis,
310 code: b.tile,
311 lit: true,
312 });
313 ctx.restore();
314 }
315
316 // Whatever is in your fingers is above everything else on the table.
317 if (held) {
318 tile(ctx, art, {
319 x: held.x,
320 y: held.y,
321 z: HELD_Z,
322 angle: held.angle,
323 w: held.w,
324 h: held.h,
325 facing: 1,
326 axis: 0,
327 code: held.tile,
328 lit: true,
329 });
330 }
331}
332
333/**
334 * How thick a tile is, as a fraction of its width. A real one is about
335 * three-quarters as thick as it is wide; this is the part of that you can see
336 * from almost overhead, which is a good deal less.
337 */
338const THICKNESS = 0.3;
339/** The ivory of the tile's own body, seen along its sides. */
340const EDGE_LIGHT = '#e6dcc2';
341const EDGE_DARK = '#b9ad8d';
342
343interface Drawn {
344 x: number;
345 y: number;
346 z: number;
347 angle: number;
348 w: number;
349 h: number;
350 /** cos of the turn: 1 flat on its back, 0 edge on, negative face down. */
351 facing: number;
352 axis: number;
353 code: number;
354 lit: boolean;
355}
356
357/**
358 * One tile, with the thickness of one.
359 *
360 * Drawn as two passes: the body of the tile, offset down the screen towards the
361 * viewer, and then its face on top of that. The sliver of body left showing
362 * along the bottom edge is what stops a tile reading as a printed card.
363 *
364 * Turning over makes that matter more, not less. Face on you see the face and a
365 * hair of the side; edge on there is no face left to see and the tile is a bar
366 * of ivory as thick as it really is — which is why the squash uses the
367 * rectangle's true projected width, `w·|cos| + thickness·|sin|`, rather than
368 * pinching to nothing and back.
369 */
370function tile(ctx: CanvasRenderingContext2D, art: Art, d: Drawn) {
371 const s = zScale(d.z);
372 const w = d.w * s;
373 const h = d.h * s;
374 const thick = w * THICKNESS;
375 const c = Math.abs(d.facing);
376 const sin = Math.sqrt(Math.max(0, 1 - d.facing * d.facing));
377 // What the whole slab covers across the axis it is turning about.
378 const projected = c + (thick / w) * sin;
379 // How much of the body shows past the face: none of it face on beyond its own
380 // thickness, all of it edge on. Higher tiles show a touch more of their side.
381 const lift = thick * (0.45 + 0.55 * sin) + d.z * 0.05;
382
383 // --- the body of the tile, seen from just off square
384 ctx.save();
385 ctx.translate(d.x, d.y + lift);
386 squash(ctx, projected, d.axis);
387 ctx.rotate(d.angle);
388 const grad = ctx.createLinearGradient(0, -h / 2, 0, h / 2);
389 grad.addColorStop(0, EDGE_DARK);
390 grad.addColorStop(1, EDGE_LIGHT);
391 ctx.fillStyle = grad;
392 tilePath(ctx, w, h);
393 ctx.fill();
394 ctx.restore();
395
396 // --- and its face on top
397 ctx.save();
398 ctx.translate(d.x, d.y);
399 squash(ctx, Math.max(0.015, c), d.axis);
400 ctx.rotate(d.angle);
401 face(ctx, d.code, w, h, art, d.lit, d.facing < 0);
402 ctx.restore();
403}
404
405/**
406 * A soft silhouette under a tile.
407 *
408 * The blur is baked into a sprite once per tile size rather than applied per
409 * draw. `ctx.filter` is the obvious way to write this and the wrong one: every
410 * filtered fill makes the browser allocate and blur its own layer, so sixty
411 * tiles times sixty frames is sixty times sixty blurs a second, which is enough
412 * to make the tab stop answering. One blur, then `drawImage`.
413 *
414 * Height is sold by scaling that sprite up — a shadow that spreads and fades as
415 * the tile rises — which a fixed blur radius could not have done anyway.
416 */
417const shadows = new Map<string, HTMLCanvasElement>();
418
419function shadowSprite(w: number, h: number): HTMLCanvasElement {
420 const key = `${Math.round(w)}x${Math.round(h)}`;
421 const had = shadows.get(key);
422 if (had) return had;
423 // Only ever a handful — one per tile size on screen — but a window being
424 // dragged to resize walks through a lot of them.
425 if (shadows.size > 8) shadows.clear();
426
427 const pad = Math.ceil(Math.max(8, w * 0.5));
428 const c = document.createElement('canvas');
429 c.width = Math.ceil(w) + pad * 2;
430 c.height = Math.ceil(h) + pad * 2;
431 const g = c.getContext('2d');
432 if (g) {
433 g.filter = `blur(${Math.max(1.5, w * 0.11).toFixed(1)}px)`;
434 g.fillStyle = '#000';
435 g.translate(c.width / 2, c.height / 2);
436 g.beginPath();
437 g.roundRect(-w / 2, -h / 2, w, h, w * 0.1);
438 g.fill();
439 }
440 shadows.set(key, c);
441 return c;
442}
443
444/**
445 * Foreshortening along the axis a tile is turning about.
446 *
447 * There is no third dimension here, so an out-of-plane turn is drawn as a
448 * squash. Applied in the *table's* frame, before the tile's own flat rotation,
449 * because the axis it turns about is fixed by the direction it was thrown, not
450 * by which way the tile happens to be facing.
451 */
452function squash(ctx: CanvasRenderingContext2D, across: number, axis: number) {
453 if (across > 0.999) return;
454 ctx.rotate(axis);
455 ctx.scale(across, 1);
456 ctx.rotate(-axis);
457}
458
459function shadow(
460 ctx: CanvasRenderingContext2D,
461 x: number,
462 y: number,
463 z: number,
464 angle: number,
465 w: number,
466 h: number,
467 scale: number,
468 facing: number,
469 axis: number,
470) {
471 // Keyed on the tile's own size, never on the eased one — a scale that changes
472 // every frame would mean a fresh blur every frame, which is the thing this
473 // exists to avoid.
474 const sprite = shadowSprite(w, h);
475 const spread = zScale(z) * scale * (1 + z * 0.004);
476 ctx.save();
477 ctx.globalAlpha = 0.36 - Math.min(0.18, z * 0.0016);
478 ctx.translate(x + z * 0.12, y + 3 + z * 0.34);
479 squash(ctx, Math.abs(facing) + THICKNESS * Math.sqrt(Math.max(0, 1 - facing * facing)), axis);
480 ctx.rotate(angle);
481 ctx.drawImage(
482 sprite,
483 (-sprite.width / 2) * spread,
484 (-sprite.height / 2) * spread,
485 sprite.width * spread,
486 sprite.height * spread,
487 );
488 ctx.restore();
489}
490
491function face(
492 ctx: CanvasRenderingContext2D,
493 tile: number,
494 w: number,
495 h: number,
496 art: Art,
497 lit: boolean,
498 backwards: boolean,
499) {
500 // Part-way through a turn you are looking at the back of it: the same green
501 // tile back the wall is built from. A tile always comes to rest face up, so
502 // this is only ever seen in the air.
503 if (backwards) {
504 if (art.back) ctx.drawImage(art.back, -w / 2, -h / 2, w, h);
505 else {
506 tilePath(ctx, w, h);
507 ctx.fillStyle = '#1c7a4b';
508 ctx.fill();
509 }
510 return;
511 }
512
513 if (art.faces) {
514 // 43 columns, two rows; the second is the sheet's own highlighted variant,
515 // which is what a lifted tile in a hand uses. The tile in your fingers and
516 // the one still to be claimed get it here for the same reason.
517 const cw = art.faces.naturalWidth / SPRITE_COLS;
518 const ch = art.faces.naturalHeight / 2;
519 ctx.drawImage(art.faces, SPRITE_COL[tile] * cw, lit ? ch : 0, cw, ch, -w / 2, -h / 2, w, h);
520 } else {
521 tilePath(ctx, w, h);
522 ctx.fillStyle = '#f6f0e2';
523 ctx.fill();
524 }
525 if (lit) {
526 // A pool of sixty tiles needs more than a lighter face to pick one out.
527 tilePath(ctx, w, h);
528 ctx.strokeStyle = '#d9b45c';
529 ctx.lineWidth = 2;
530 ctx.stroke();
531 }
532}
533
534function tilePath(ctx: CanvasRenderingContext2D, w: number, h: number) {
535 ctx.beginPath();
536 ctx.roundRect(-w / 2, -h / 2, w, h, w * 0.1);
537}