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