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 loudest = Math.max(loudest, i.strength);
74 if (i.edge && i.strength > BARGE) shove(table, EDGE_SEAT[i.edge], i.seat, i.strength);
75 }
76 if (loudest > 0) knock(loudest);
77 paint(ctx, canvas, pool, live.current.art);
78
79 // Keep going while anything is moving; otherwise wait to be poked.
80 if (!pool.settled) frame = requestAnimationFrame(tick);
81 else previous = 0;
82 };
83
84 const run = () => {
85 if (!frame) frame = requestAnimationFrame(tick);
86 };
87 wake.current = run;
88 pool.wake = run;
89
90 const observer = new ResizeObserver(resize);
91 observer.observe(table);
92 resize();
93
94 return () => {
95 observer.disconnect();
96 if (frame) cancelAnimationFrame(frame);
97 wake.current = () => {};
98 pool.wake = () => {};
99 pool.detach();
100 };
101 }, [pool]);
102
103 // Any change to the table — a discard, a claim, an undo, a new hand — is a
104 // reason to look again.
105 useEffect(() => {
106 wake.current();
107 });
108
109 return (
110 <>
111 <canvas className="tile-pool" ref={canvasRef} aria-hidden />
112 {/* The patch of table you can reach into. Sized to the pool from the
113 measurement rather than from CSS, and only that patch, so it cannot
114 swallow clicks meant for the strips around it. */}
115 <div className="pool-reach" ref={reachRef} onPointerDown={wash.down} onPointerMove={wash.move} onPointerUp={wash.up} onPointerCancel={wash.up} />
116 </>
117 );
118}
119
120/**
121 * Washing the tiles about with a finger.
122 *
123 * A discard's position is not part of the game — the engine neither knows nor
124 * cares where in the middle a tile is lying — so this is free to be pure fidget,
125 * the thing everybody does to the pool while waiting for their turn.
126 */
127function useWash(pool: TablePool) {
128 const last = useRef<{ x: number; y: number; t: number } | null>(null);
129
130 const at = (e: React.PointerEvent) => {
131 const table = (e.currentTarget as HTMLElement).parentElement;
132 const r = table?.getBoundingClientRect();
133 return { x: e.clientX - (r?.left ?? 0), y: e.clientY - (r?.top ?? 0), t: e.timeStamp };
134 };
135
136 return {
137 down: (e: React.PointerEvent) => {
138 try {
139 (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
140 } catch {
141 // Uncaptured still works while the pointer stays over the pool.
142 }
143 last.current = at(e);
144 },
145 move: (e: React.PointerEvent) => {
146 const prev = last.current;
147 if (!prev) return;
148 const now = at(e);
149 const dt = (now.t - prev.t) / 1000;
150 last.current = now;
151 if (dt <= 0) return;
152 pool.stir(now.x, now.y, (now.x - prev.x) / dt, (now.y - prev.y) / dt);
153 pool.wake();
154 },
155 up: () => {
156 last.current = null;
157 },
158 };
159}
160
161interface Art {
162 faces: HTMLImageElement | null;
163 back: HTMLImageElement | null;
164}
165
166/**
167 * The tile art, as something canvas can draw: the face sheet, and the green
168 * back a tile shows while it is turning over. Both are the same files the CSS
169 * uses for the tiles in the hands and the wall, so the pool cannot drift from
170 * the rest of the table.
171 */
172function useSheet(): Art {
173 const [art, setArt] = useState<Art>({ faces: null, back: null });
174 useEffect(() => {
175 let alive = true;
176 const load = (src: string) => {
177 const el = new Image();
178 el.src = src;
179 // Not fatal: the pool falls back to blank tiles rather than emptying out.
180 return el.decode().then(
181 () => el,
182 () => null,
183 );
184 };
185 void Promise.all([load('/tiles/tiles.svg'), load('/tiles/back.png')]).then(
186 ([faces, back]) => alive && setArt({ faces, back }),
187 );
188 return () => {
189 alive = false;
190 };
191 }, []);
192 return art;
193}
194
195/** How high a tile in somebody's fingers is held above the felt. */
196const HELD_Z = 52;
197
198/**
199 * Hard enough to be a throw at somebody rather than a tile coming to rest
200 * against the near side. A tile that merely rolls up to the edge is not an
201 * incident; one still doing a couple of hundred pixels a second is.
202 */
203const BARGE = 0.3;
204
205/**
206 * A tile has come off the pool and into this seat's tiles.
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}