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 { aabbOf, centreLimits, faceOn, zScale, type Rect } from '../table/physics';
10import type { TablePool } from '../table/pool';
11import type { SeatId } from '../game/types';
12import {
13 exposePool,
14 holdOn,
15 isHeld,
16 keyHeld,
17 noteImpacts,
18 onCollidersChange,
19 owedTime,
20 pausingOnHit,
21 recentImpacts,
22 showingColliders,
23} from './colliders';
24import { EDGE_SEAT } from './rotation';
25
26/**
27 * The tiles in the middle of the table, drawn.
28 *
29 * A canvas rather than elements, and it spans the whole table rather than just
30 * the centre. The span is the point: `.slot` and `.seat` both clip their own
31 * contents, which is exactly why a tile cannot be animated out of a strip as a
32 * div — on the canvas there is nothing to clip it, so a tile lifted out of a
33 * hand and thrown crosses the table in one piece.
34 *
35 * The loop stops as soon as the pile has settled, and starts again when the
36 * state changes, a tile is picked up, or the window resizes, so a table nobody
37 * is touching costs nothing.
38 */
39export function Pool({
40 game,
41 pool,
42 viewSeat = 0,
43}: {
44 game: TableCtl;
45 pool: TablePool;
46 /** The seat drawn at the bottom of this screen — see App. An edge the physics
47 * reports belongs to whoever is drawn there, not to seat 0 by birthright. */
48 viewSeat?: SeatId;
49}) {
50 const canvasRef = useRef<HTMLCanvasElement>(null);
51 const art = useSheet();
52
53 // What the loop reads. Refreshed on every render, read on every frame.
54 const live = useRef({ state: game.state, art, viewSeat });
55 live.current = { state: game.state, art, viewSeat };
56
57 // Poked by the effect below, and by the gesture through pool.wake.
58 const wake = useRef<() => void>(() => {});
59
60 useEffect(() => {
61 const canvas = canvasRef.current;
62 const table = canvas?.parentElement;
63 const ctx = canvas?.getContext('2d');
64 if (!canvas || !table || !ctx) return;
65
66 pool.attach(table);
67 exposePool(pool);
68 const stopDragging = dragging(table as HTMLElement, pool, () => wake.current());
69
70 let frame = 0;
71 let previous = 0;
72
73 const resize = () => {
74 const dpr = window.devicePixelRatio || 1;
75 const w = Math.round(table.clientWidth * dpr);
76 const h = Math.round(table.clientHeight * dpr);
77 if (canvas.width !== w || canvas.height !== h) {
78 canvas.width = w;
79 canvas.height = h;
80 }
81 // The square moved, so the colliders and the launch points have too.
82 pool.invalidate();
83 run();
84 };
85
86 const tick = (now: number) => {
87 frame = 0;
88 // First frame after waking has no previous time to measure against.
89 const dt = previous ? Math.min(0.05, (now - previous) / 1000) : 0;
90 previous = now;
91
92 pool.sync(live.current.state);
93 // A tile in the fingers goes over the table's own buttons — seat 0 throws
94 // from the *right-hand end* of its row, which is directly under the undo
95 // button — so while one is being carried they stop taking the pointer.
96 table.classList.toggle('carrying', !!pool.held);
97 // The loudest contact this frame is the one you would actually hear; the
98 // rest of a scatter is the same event.
99 // Frozen on a contact: the world only moves when it is asked to, a fixed
100 // step at a time, so the moment can be walked through rather than watched.
101 const impacts = isHeld() ? pool.advance(owedTime()) : pool.advance(dt, pausingOnHit());
102 noteImpacts(impacts);
103 if (pausingOnHit() && !isHeld() && impacts.length) holdOn(impacts);
104 let loudest = 0;
105 for (const i of impacts) {
106 // A contact quiet enough not to be worth hearing still counts as a tile
107 // arriving in somebody's lap, which is why the two thresholds differ.
108 if (i.strength > AUDIBLE) loudest = Math.max(loudest, i.strength);
109 // Straight into somebody's tiles — the wall square is nobody's, so this
110 // is a hand or a set that has been laid down.
111 if (i.into !== undefined) shove(pool, table, i.into, i.seat, i.strength);
112 else if (i.edge) {
113 const at = ((EDGE_SEAT[i.edge] + live.current.viewSeat) % 4) as SeatId;
114 shove(pool, table, at, i.seat, i.strength);
115 }
116 }
117 if (loudest > 0) knock(loudest);
118 paint(ctx, canvas, pool, live.current.art);
119
120 // Keep going while anything is moving; otherwise wait to be poked. A held
121 // world is never running: it is stepped by hand and repainted each time.
122 if (!isHeld() && !pool.settled) frame = requestAnimationFrame(tick);
123 else previous = 0;
124 };
125
126 const run = () => {
127 if (!frame) frame = requestAnimationFrame(tick);
128 };
129 wake.current = run;
130 pool.wake = run;
131
132 const observer = new ResizeObserver(resize);
133 observer.observe(table);
134 const stopColliders = onCollidersChange(run);
135 const onKey = (e: KeyboardEvent) => {
136 if (keyHeld(e)) e.preventDefault();
137 };
138 window.addEventListener('keydown', onKey);
139 resize();
140
141 return () => {
142 stopDragging();
143 window.removeEventListener('keydown', onKey);
144 stopColliders();
145 observer.disconnect();
146 if (frame) cancelAnimationFrame(frame);
147 wake.current = () => {};
148 pool.wake = () => {};
149 pool.detach();
150 };
151 }, [pool]);
152
153 // Any change to the table — a discard, a claim, an undo, a new hand — is a
154 // reason to look again.
155 useEffect(() => {
156 wake.current();
157 });
158
159 return <canvas className="tile-pool" ref={canvasRef} aria-hidden />;
160}
161
162/**
163 * Picking a discard up and putting it somewhere else.
164 *
165 * A discard's position is not part of the game — the engine neither knows nor
166 * cares where in the middle a tile is lying — so the pile is free to be handled
167 * the way a real one is: take hold of a tile, carry it over the top of the
168 * others, and either set it down or send it skidding off with a flick.
169 *
170 * The listener is on the table and it *captures*, because the tiles are drawn
171 * on a canvas that has no elements to hit. It hit-tests the pile first and only
172 * takes the gesture when a tile is actually under the finger; anything else is
173 * let straight through to whatever it was really aimed at — a tile in a hand,
174 * the settings button, the wall.
175 */
176function dragging(table: HTMLElement, pool: TablePool, wake: () => void) {
177 let samples: { x: number; y: number; t: number }[] = [];
178
179 const at = (e: PointerEvent) => {
180 const r = table.getBoundingClientRect();
181 return { x: e.clientX - r.left, y: e.clientY - r.top, t: e.timeStamp };
182 };
183
184 const down = (e: PointerEvent) => {
185 // Only the primary button, and only where there is a tile to take hold of.
186 if (e.button !== 0) return;
187 const p = at(e);
188 if (!pool.grabAt(p.x, p.y)) return;
189 e.stopPropagation();
190 e.preventDefault();
191 samples = [p];
192 try {
193 table.setPointerCapture?.(e.pointerId);
194 } catch {
195 // Uncaptured still works while the pointer stays over the table.
196 }
197 wake();
198 };
199
200 const move = (e: PointerEvent) => {
201 if (!pool.dragging) return;
202 e.stopPropagation();
203 const p = at(e);
204 // Only the tail matters, and only the recent tail: a flick is the last few
205 // milliseconds of a drag, not an average over the whole of it.
206 samples.push(p);
207 if (samples.length > 6) samples.shift();
208 pool.dragTo(p.x, p.y);
209 wake();
210 };
211
212 const up = (e: PointerEvent) => {
213 if (!pool.dragging) return;
214 e.stopPropagation();
215 const p = at(e);
216 samples.push(p);
217 const first = samples[0];
218 const dt = (p.t - first.t) / 1000;
219 const vx = dt > 0 ? (p.x - first.x) / dt : 0;
220 const vy = dt > 0 ? (p.y - first.y) / dt : 0;
221 // A flick that curves puts a turn on the tile, as one out of a hand does.
222 const spin = dt > 0 ? ((p.x - first.x) * 0.004) / dt / 100 : 0;
223 pool.releaseGrab(vx, vy, spin);
224 samples = [];
225 wake();
226 };
227
228 table.addEventListener('pointerdown', down, true);
229 table.addEventListener('pointermove', move, true);
230 table.addEventListener('pointerup', up, true);
231 table.addEventListener('pointercancel', up, true);
232 return () => {
233 table.removeEventListener('pointerdown', down, true);
234 table.removeEventListener('pointermove', move, true);
235 table.removeEventListener('pointerup', up, true);
236 table.removeEventListener('pointercancel', up, true);
237 };
238}
239
240interface Art {
241 faces: HTMLImageElement | null;
242 back: HTMLImageElement | null;
243}
244
245/**
246 * The tile art, as something canvas can draw: the face sheet, and the green
247 * back a tile shows while it is turning over. Both are the same files the CSS
248 * uses for the tiles in the hands and the wall, so the pool cannot drift from
249 * the rest of the table.
250 */
251function useSheet(): Art {
252 const [art, setArt] = useState<Art>({ faces: null, back: null });
253 useEffect(() => {
254 let alive = true;
255 const load = (src: string) => {
256 const el = new Image();
257 el.src = src;
258 // Not fatal: the pool falls back to blank tiles rather than emptying out.
259 return el.decode().then(
260 () => el,
261 () => null,
262 );
263 };
264 void Promise.all([load(tilesUrl), load(backUrl)]).then(
265 ([faces, back]) => alive && setArt({ faces, back }),
266 );
267 return () => {
268 alive = false;
269 };
270 }, []);
271 return art;
272}
273
274/** How high a tile in somebody's fingers is held above the felt. */
275const HELD_Z = 52;
276
277/** Quieter than this and the contact is not worth a clack of its own. */
278const AUDIBLE = 0.12;
279
280/** How long the knocked row rocks for, in seconds. Matches `shoved` in the CSS,
281 * which is the other half of it: the table waits at least this long. */
282const SHAKE = 0.34;
283
284/**
285 * A tile has ended up in this seat's tiles — knocked into them, or simply come
286 * to a stop off the table on their side. Either way it is theirs to complain
287 * about, and how hard it arrived only decides how loudly.
288 *
289 * Their row takes the knock — straight on the element rather than through
290 * React, because this happens inside the physics loop and re-rendering four
291 * strips to shake one of them would be absurd — and the table says something
292 * about it. Not for the thrower's own edge: you cannot be barged by your own
293 * discard, and everybody's tile passes over their own row on the way out.
294 */
295function shove(
296 pool: TablePool,
297 table: HTMLElement,
298 seat: SeatId,
299 thrower: SeatId | undefined,
300 strength: number,
301) {
302 if (seat === thrower) return;
303 // Hold the table while it is being said and while the row is still rocking.
304 // The computer plays on a timer of its own, and a 碰 called over the top of
305 // somebody objecting to the tile lands the objection on nobody.
306 pool.heckle(Math.max(SHAKE, barge(strength)));
307 const hand = table.querySelector<HTMLElement>(`[data-seat="${seat}"] .hand`);
308 if (!hand || hand.classList.contains('shoved')) return;
309 hand.classList.add('shoved');
310 hand.addEventListener('animationend', () => hand.classList.remove('shoved'), { once: true });
311}
312
313function paint(
314 ctx: CanvasRenderingContext2D,
315 canvas: HTMLCanvasElement,
316 pool: TablePool,
317 art: Art,
318) {
319 const dpr = window.devicePixelRatio || 1;
320 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
321 ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
322
323 const bodies = pool.bodies;
324 const held = pool.held;
325 const departing = pool.departing;
326
327 // Shadows in one pass of their own, offset before rotation, so the light comes
328 // from one direction across the whole pool rather than turning with each tile.
329 for (const b of bodies) {
330 shadow(ctx, b.x, b.y, b.z, b.angle, b.w, b.h, b.scale, faceOn(b), b.flipAxis);
331 }
332 if (held) shadow(ctx, held.x, held.y, HELD_Z, held.angle, held.w, held.h, 1, 1, 0);
333
334 const newest = pool.newest;
335 for (const b of bodies) {
336 tile(ctx, art, {
337 x: b.x,
338 y: b.y,
339 z: b.z,
340 angle: b.angle,
341 w: b.w * b.scale,
342 h: b.h * b.scale,
343 facing: faceOn(b),
344 axis: b.flipAxis,
345 code: b.tile,
346 lit: b === newest,
347 });
348 }
349
350 // On its way to whoever called it — lifted, turning, and fading as it goes.
351 for (const { body: b, fade } of departing) {
352 ctx.save();
353 ctx.globalAlpha = fade;
354 tile(ctx, art, {
355 x: b.x,
356 y: b.y,
357 z: b.z,
358 angle: b.angle,
359 w: b.w * b.scale,
360 h: b.h * b.scale,
361 facing: faceOn(b),
362 axis: b.flipAxis,
363 code: b.tile,
364 lit: true,
365 });
366 ctx.restore();
367 }
368
369 // Whatever is in your fingers is above everything else on the table.
370 if (held) {
371 tile(ctx, art, {
372 x: held.x,
373 y: held.y,
374 z: HELD_Z,
375 angle: held.angle,
376 w: held.w,
377 h: held.h,
378 facing: 1,
379 axis: 0,
380 code: held.tile,
381 lit: true,
382 });
383 }
384
385 // Over the top of all of it, or there would be no point to it.
386 if (showingColliders()) colliders(ctx, pool);
387}
388
389/**
390 * Every boundary the physics uses, drawn where it actually is.
391 *
392 * Solid the felt — the fence that keeps a tile on the table, measured
393 * out to where each seat's tiles begin (table/geometry.ts).
394 * Solid each standing stack of two. Half stacks are not drawn because
395 * they are not colliders: a tile goes over them.
396 * Dashed the wall square's opening. Only where a throw is *aimed*; it
397 * stops nothing, which is why the pile spills out of it.
398 * Dotted, in where the moving tile's own centre is allowed to be — the felt
399 * less that tile's half width. This is the line a bounce happens
400 * at, and it is half a tile inside the solid one, which is what
401 * makes a bounce look early when it is not.
402 * Per tile the rotated rectangle it really collides with, and, fainter, the
403 * upright box the felt's straight edges use instead. The two come
404 * apart as a tile turns, and that difference is worth seeing.
405 */
406function colliders(ctx: CanvasRenderingContext2D, pool: TablePool) {
407 const world = pool.world;
408 if (!world) return;
409
410 const line = (r: Rect) => ctx.strokeRect(r.x + 0.5, r.y + 0.5, r.w - 1, r.h - 1);
411
412 ctx.save();
413 ctx.lineWidth = 1;
414
415 // The table's own edge, and every tile standing on it.
416 ctx.strokeStyle = '#ff2d20';
417 line(world.bounds);
418 for (const w of world.walls) line(w.rect);
419
420 // Where a throw is aimed. Not a fence — see TableGeometry.pool.
421 const aim = pool.poolRect;
422 if (aim) {
423 ctx.setLineDash([5, 5]);
424 ctx.strokeStyle = 'rgba(255, 45, 32, 0.45)';
425 line(aim);
426 ctx.setLineDash([]);
427 }
428
429 // Whatever is still moving: what it collides with, and what it is held by.
430 const live = world.bodies.find((b) => !b.resting) ?? pool.newest;
431
432 for (const b of world.bodies) {
433 // A tile in the fingers is held over everything and collides with nothing,
434 // so it gets no box: the boxes are what is solid.
435 if (b.carried) continue;
436 ctx.strokeStyle = b === live ? 'rgba(255, 45, 32, 0.9)' : 'rgba(255, 45, 32, 0.35)';
437 ctx.save();
438 ctx.translate(b.x, b.y);
439 ctx.rotate(b.angle);
440 ctx.strokeRect(-b.w / 2, -b.h / 2, b.w, b.h);
441 ctx.restore();
442
443 // The upright box the felt's edges are worked out from. Only for the one in
444 // flight: sixty of them at once is a red mess, not a picture.
445 if (b === live && b.angle % Math.PI !== 0) {
446 ctx.setLineDash([2, 3]);
447 ctx.strokeStyle = 'rgba(255, 45, 32, 0.5)';
448 line(aabbOf(b));
449 ctx.setLineDash([]);
450 }
451 }
452
453 if (live) {
454 // The box that tile's centre is held inside, and where the centre is now.
455 ctx.setLineDash([2, 3]);
456 ctx.strokeStyle = 'rgba(255, 45, 32, 0.75)';
457 line(centreLimits(world.bounds, live));
458 ctx.setLineDash([]);
459 ctx.fillStyle = '#ff2d20';
460 ctx.fillRect(live.x - 1.5, live.y - 1.5, 3, 3);
461 }
462
463 // How the last few throws actually left. Faint line: the flick, as it was
464 // let go of. Bright line: where the tile was actually sent. When those two
465 // disagree the tile leaves on a line nobody threw, which is the other way a
466 // bounce can be a bounce off nothing — see TablePool.throws.
467 ctx.font = '9px ui-monospace, monospace';
468 ctx.textBaseline = 'bottom';
469 for (const t of pool.throws) {
470 const ray = (dx: number, dy: number, len: number) => {
471 const d = Math.hypot(dx, dy) || 1;
472 ctx.beginPath();
473 ctx.moveTo(t.x, t.y);
474 ctx.lineTo(t.x + (dx / d) * len, t.y + (dy / d) * len);
475 ctx.stroke();
476 };
477 ctx.strokeStyle = 'rgba(90, 200, 250, 0.35)';
478 ray(t.fx, t.fy, 90);
479 ctx.strokeStyle = 'rgba(90, 200, 250, 0.9)';
480 ray(t.vx, t.vy, 130);
481 ctx.fillStyle = 'rgba(90, 200, 250, 0.9)';
482 ctx.fillText(t.how, t.x + 6, t.y - 4);
483 ctx.fillRect(t.x - 2, t.y - 2, 4, 4);
484 }
485
486 // Where contact was actually made, fading out. One of these with no box under
487 // it is the interesting case: a bounce off something that is not drawn is a
488 // bounce off something that is not a collider.
489 ctx.font = '9px ui-monospace, monospace';
490 ctx.textBaseline = 'bottom';
491 for (const m of recentImpacts(!isHeld())) {
492 const fade = 1 - m.age / 90;
493 ctx.strokeStyle = `rgba(255, 214, 10, ${fade})`;
494 ctx.fillStyle = `rgba(255, 214, 10, ${fade})`;
495 ctx.beginPath();
496 ctx.moveTo(m.x - 5, m.y);
497 ctx.lineTo(m.x + 5, m.y);
498 ctx.moveTo(m.x, m.y - 5);
499 ctx.lineTo(m.x, m.y + 5);
500 ctx.stroke();
501 if (m.kind) ctx.fillText(m.kind, m.x + 6, m.y - 2);
502 }
503
504 ctx.restore();
505}
506
507/**
508 * How thick a tile is, as a fraction of its width. A real one is about
509 * three-quarters as thick as it is wide; this is the part of that you can see
510 * from almost overhead, which is a good deal less.
511 */
512const THICKNESS = 0.3;
513/** The ivory of the tile's own body, seen along its sides. */
514const EDGE_LIGHT = '#e6dcc2';
515const EDGE_DARK = '#b9ad8d';
516/**
517 * The line round the outside of a tile.
518 *
519 * The art already has one — a single black pixel all the way round each 128px
520 * cell — but a tile in the middle is drawn about a quarter of that size, and
521 * `drawImage` box-filters that one pixel down to a quarter of its strength. On
522 * the two sides the art shades for itself, left and bottom, there is a painted
523 * bevel underneath and you cannot tell. On the other two there is nothing but
524 * the outline, so white met green with no edge at all and the tile read as a
525 * flat shape rather than something lying on cloth. Drawn as a stroke instead of
526 * relying on the bitmap, it holds at any size.
527 */
528const EDGE_LINE = 'rgba(52, 43, 29, 0.55)';
529
530interface Drawn {
531 x: number;
532 y: number;
533 z: number;
534 angle: number;
535 w: number;
536 h: number;
537 /** cos of the turn: 1 flat on its back, 0 edge on, negative face down. */
538 facing: number;
539 axis: number;
540 code: number;
541 lit: boolean;
542}
543
544/**
545 * One tile, with the thickness of one.
546 *
547 * Drawn as two passes: the body of the tile, offset down the screen towards the
548 * viewer, and then its face on top of that. The sliver of body left showing
549 * along the bottom edge is what stops a tile reading as a printed card.
550 *
551 * Turning over makes that matter more, not less. Face on you see the face and a
552 * hair of the side; edge on there is no face left to see and the tile is a bar
553 * of ivory as thick as it really is — which is why the squash uses the
554 * rectangle's true projected width, `w·|cos| + thickness·|sin|`, rather than
555 * pinching to nothing and back.
556 */
557function tile(ctx: CanvasRenderingContext2D, art: Art, d: Drawn) {
558 const s = zScale(d.z);
559 const w = d.w * s;
560 const h = d.h * s;
561 const thick = w * THICKNESS;
562 const c = Math.abs(d.facing);
563 const sin = Math.sqrt(Math.max(0, 1 - d.facing * d.facing));
564 // What the whole slab covers across the axis it is turning about.
565 const projected = c + (thick / w) * sin;
566 // How much of the body shows past the face: none of it face on beyond its own
567 // thickness, all of it edge on. Higher tiles show a touch more of their side.
568 const lift = thick * (0.45 + 0.55 * sin) + d.z * 0.05;
569
570 // --- the body of the tile, seen from just off square
571 ctx.save();
572 ctx.translate(d.x, d.y + lift);
573 squash(ctx, projected, d.axis);
574 ctx.rotate(d.angle);
575 const grad = ctx.createLinearGradient(0, -h / 2, 0, h / 2);
576 grad.addColorStop(0, EDGE_DARK);
577 grad.addColorStop(1, EDGE_LIGHT);
578 ctx.fillStyle = grad;
579 tilePath(ctx, w, h);
580 ctx.fill();
581 ctx.strokeStyle = EDGE_LINE;
582 ctx.lineWidth = 1;
583 ctx.stroke();
584 ctx.restore();
585
586 // --- and its face on top
587 ctx.save();
588 ctx.translate(d.x, d.y);
589 squash(ctx, Math.max(0.015, c), d.axis);
590 ctx.rotate(d.angle);
591 face(ctx, d.code, w, h, art, d.lit, d.facing < 0);
592 ctx.restore();
593}
594
595/**
596 * A soft silhouette under a tile.
597 *
598 * The blur is baked into a sprite once per tile size rather than applied per
599 * draw. `ctx.filter` is the obvious way to write this and the wrong one: every
600 * filtered fill makes the browser allocate and blur its own layer, so sixty
601 * tiles times sixty frames is sixty times sixty blurs a second, which is enough
602 * to make the tab stop answering. One blur, then `drawImage`.
603 *
604 * Height is sold by scaling that sprite up — a shadow that spreads and fades as
605 * the tile rises — which a fixed blur radius could not have done anyway.
606 */
607const shadows = new Map<string, HTMLCanvasElement>();
608
609function shadowSprite(w: number, h: number): HTMLCanvasElement {
610 const key = `${Math.round(w)}x${Math.round(h)}`;
611 const had = shadows.get(key);
612 if (had) return had;
613 // Only ever a handful — one per tile size on screen — but a window being
614 // dragged to resize walks through a lot of them.
615 if (shadows.size > 8) shadows.clear();
616
617 const pad = Math.ceil(Math.max(8, w * 0.5));
618 const c = document.createElement('canvas');
619 c.width = Math.ceil(w) + pad * 2;
620 c.height = Math.ceil(h) + pad * 2;
621 const g = c.getContext('2d');
622 if (g) {
623 g.filter = `blur(${Math.max(1.5, w * 0.11).toFixed(1)}px)`;
624 g.fillStyle = '#000';
625 g.translate(c.width / 2, c.height / 2);
626 g.beginPath();
627 g.roundRect(-w / 2, -h / 2, w, h, w * 0.1);
628 g.fill();
629 }
630 shadows.set(key, c);
631 return c;
632}
633
634/**
635 * Foreshortening along the axis a tile is turning about.
636 *
637 * There is no third dimension here, so an out-of-plane turn is drawn as a
638 * squash. Applied in the *table's* frame, before the tile's own flat rotation,
639 * because the axis it turns about is fixed by the direction it was thrown, not
640 * by which way the tile happens to be facing.
641 */
642function squash(ctx: CanvasRenderingContext2D, across: number, axis: number) {
643 if (across > 0.999) return;
644 ctx.rotate(axis);
645 ctx.scale(across, 1);
646 ctx.rotate(-axis);
647}
648
649function shadow(
650 ctx: CanvasRenderingContext2D,
651 x: number,
652 y: number,
653 z: number,
654 angle: number,
655 w: number,
656 h: number,
657 scale: number,
658 facing: number,
659 axis: number,
660) {
661 // Keyed on the tile's own size, never on the eased one — a scale that changes
662 // every frame would mean a fresh blur every frame, which is the thing this
663 // exists to avoid.
664 const sprite = shadowSprite(w, h);
665 const spread = zScale(z) * scale * (1 + z * 0.004);
666 ctx.save();
667 ctx.globalAlpha = 0.36 - Math.min(0.18, z * 0.0016);
668 ctx.translate(x + z * 0.12, y + 3 + z * 0.34);
669 squash(ctx, Math.abs(facing) + THICKNESS * Math.sqrt(Math.max(0, 1 - facing * facing)), axis);
670 ctx.rotate(angle);
671 ctx.drawImage(
672 sprite,
673 (-sprite.width / 2) * spread,
674 (-sprite.height / 2) * spread,
675 sprite.width * spread,
676 sprite.height * spread,
677 );
678 ctx.restore();
679}
680
681function face(
682 ctx: CanvasRenderingContext2D,
683 tile: number,
684 w: number,
685 h: number,
686 art: Art,
687 lit: boolean,
688 backwards: boolean,
689) {
690 // Part-way through a turn you are looking at the back of it: the same green
691 // tile back the wall is built from. A tile always comes to rest face up, so
692 // this is only ever seen in the air.
693 if (backwards) {
694 if (art.back) ctx.drawImage(art.back, -w / 2, -h / 2, w, h);
695 else {
696 tilePath(ctx, w, h);
697 ctx.fillStyle = '#1c7a4b';
698 ctx.fill();
699 }
700 // A green back on green cloth needs the edge more than a white face does.
701 tilePath(ctx, w, h);
702 ctx.strokeStyle = EDGE_LINE;
703 ctx.lineWidth = 1;
704 ctx.stroke();
705 return;
706 }
707
708 if (art.faces) {
709 // 43 columns, two rows; the second is the sheet's own highlighted variant,
710 // which is what a lifted tile in a hand uses. The tile in your fingers and
711 // the one still to be claimed get it here for the same reason.
712 const cw = art.faces.naturalWidth / SPRITE_COLS;
713 const ch = art.faces.naturalHeight / 2;
714 ctx.drawImage(art.faces, SPRITE_COL[tile] * cw, lit ? ch : 0, cw, ch, -w / 2, -h / 2, w, h);
715 } else {
716 tilePath(ctx, w, h);
717 ctx.fillStyle = '#f6f0e2';
718 ctx.fill();
719 }
720 // The edge the shrunken art no longer carries for itself.
721 tilePath(ctx, w, h);
722 ctx.strokeStyle = EDGE_LINE;
723 ctx.lineWidth = 1;
724 ctx.stroke();
725 if (lit) {
726 // A pool of sixty tiles needs more than a lighter face to pick one out.
727 tilePath(ctx, w, h);
728 ctx.strokeStyle = '#d9b45c';
729 ctx.lineWidth = 2;
730 ctx.stroke();
731 }
732}
733
734function tilePath(ctx: CanvasRenderingContext2D, w: number, h: number) {
735 ctx.beginPath();
736 ctx.roundRect(-w / 2, -h / 2, w, h, w * 0.1);
737}