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 // How the last few throws actually left. Faint line: the flick, as it was
457 // let go of. Bright line: where the tile was actually sent. When those two
458 // disagree the tile leaves on a line nobody threw, which is the other way a
459 // bounce can be a bounce off nothing — see TablePool.throws.
460 ctx.font = '9px ui-monospace, monospace';
461 ctx.textBaseline = 'bottom';
462 for (const t of pool.throws) {
463 const ray = (dx: number, dy: number, len: number) => {
464 const d = Math.hypot(dx, dy) || 1;
465 ctx.beginPath();
466 ctx.moveTo(t.x, t.y);
467 ctx.lineTo(t.x + (dx / d) * len, t.y + (dy / d) * len);
468 ctx.stroke();
469 };
470 ctx.strokeStyle = 'rgba(90, 200, 250, 0.35)';
471 ray(t.fx, t.fy, 90);
472 ctx.strokeStyle = 'rgba(90, 200, 250, 0.9)';
473 ray(t.vx, t.vy, 130);
474 ctx.fillStyle = 'rgba(90, 200, 250, 0.9)';
475 ctx.fillText(t.how, t.x + 6, t.y - 4);
476 ctx.fillRect(t.x - 2, t.y - 2, 4, 4);
477 }
478
479 // Where contact was actually made, fading out. One of these with no box under
480 // it is the interesting case: a bounce off something that is not drawn is a
481 // bounce off something that is not a collider.
482 ctx.font = '9px ui-monospace, monospace';
483 ctx.textBaseline = 'bottom';
484 for (const m of recentImpacts(!isHeld())) {
485 const fade = 1 - m.age / 90;
486 ctx.strokeStyle = `rgba(255, 214, 10, ${fade})`;
487 ctx.fillStyle = `rgba(255, 214, 10, ${fade})`;
488 ctx.beginPath();
489 ctx.moveTo(m.x - 5, m.y);
490 ctx.lineTo(m.x + 5, m.y);
491 ctx.moveTo(m.x, m.y - 5);
492 ctx.lineTo(m.x, m.y + 5);
493 ctx.stroke();
494 if (m.kind) ctx.fillText(m.kind, m.x + 6, m.y - 2);
495 }
496
497 ctx.restore();
498}
499
500/**
501 * How thick a tile is, as a fraction of its width. A real one is about
502 * three-quarters as thick as it is wide; this is the part of that you can see
503 * from almost overhead, which is a good deal less.
504 */
505const THICKNESS = 0.3;
506/** The ivory of the tile's own body, seen along its sides. */
507const EDGE_LIGHT = '#e6dcc2';
508const EDGE_DARK = '#b9ad8d';
509
510interface Drawn {
511 x: number;
512 y: number;
513 z: number;
514 angle: number;
515 w: number;
516 h: number;
517 /** cos of the turn: 1 flat on its back, 0 edge on, negative face down. */
518 facing: number;
519 axis: number;
520 code: number;
521 lit: boolean;
522}
523
524/**
525 * One tile, with the thickness of one.
526 *
527 * Drawn as two passes: the body of the tile, offset down the screen towards the
528 * viewer, and then its face on top of that. The sliver of body left showing
529 * along the bottom edge is what stops a tile reading as a printed card.
530 *
531 * Turning over makes that matter more, not less. Face on you see the face and a
532 * hair of the side; edge on there is no face left to see and the tile is a bar
533 * of ivory as thick as it really is — which is why the squash uses the
534 * rectangle's true projected width, `w·|cos| + thickness·|sin|`, rather than
535 * pinching to nothing and back.
536 */
537function tile(ctx: CanvasRenderingContext2D, art: Art, d: Drawn) {
538 const s = zScale(d.z);
539 const w = d.w * s;
540 const h = d.h * s;
541 const thick = w * THICKNESS;
542 const c = Math.abs(d.facing);
543 const sin = Math.sqrt(Math.max(0, 1 - d.facing * d.facing));
544 // What the whole slab covers across the axis it is turning about.
545 const projected = c + (thick / w) * sin;
546 // How much of the body shows past the face: none of it face on beyond its own
547 // thickness, all of it edge on. Higher tiles show a touch more of their side.
548 const lift = thick * (0.45 + 0.55 * sin) + d.z * 0.05;
549
550 // --- the body of the tile, seen from just off square
551 ctx.save();
552 ctx.translate(d.x, d.y + lift);
553 squash(ctx, projected, d.axis);
554 ctx.rotate(d.angle);
555 const grad = ctx.createLinearGradient(0, -h / 2, 0, h / 2);
556 grad.addColorStop(0, EDGE_DARK);
557 grad.addColorStop(1, EDGE_LIGHT);
558 ctx.fillStyle = grad;
559 tilePath(ctx, w, h);
560 ctx.fill();
561 ctx.restore();
562
563 // --- and its face on top
564 ctx.save();
565 ctx.translate(d.x, d.y);
566 squash(ctx, Math.max(0.015, c), d.axis);
567 ctx.rotate(d.angle);
568 face(ctx, d.code, w, h, art, d.lit, d.facing < 0);
569 ctx.restore();
570}
571
572/**
573 * A soft silhouette under a tile.
574 *
575 * The blur is baked into a sprite once per tile size rather than applied per
576 * draw. `ctx.filter` is the obvious way to write this and the wrong one: every
577 * filtered fill makes the browser allocate and blur its own layer, so sixty
578 * tiles times sixty frames is sixty times sixty blurs a second, which is enough
579 * to make the tab stop answering. One blur, then `drawImage`.
580 *
581 * Height is sold by scaling that sprite up — a shadow that spreads and fades as
582 * the tile rises — which a fixed blur radius could not have done anyway.
583 */
584const shadows = new Map<string, HTMLCanvasElement>();
585
586function shadowSprite(w: number, h: number): HTMLCanvasElement {
587 const key = `${Math.round(w)}x${Math.round(h)}`;
588 const had = shadows.get(key);
589 if (had) return had;
590 // Only ever a handful — one per tile size on screen — but a window being
591 // dragged to resize walks through a lot of them.
592 if (shadows.size > 8) shadows.clear();
593
594 const pad = Math.ceil(Math.max(8, w * 0.5));
595 const c = document.createElement('canvas');
596 c.width = Math.ceil(w) + pad * 2;
597 c.height = Math.ceil(h) + pad * 2;
598 const g = c.getContext('2d');
599 if (g) {
600 g.filter = `blur(${Math.max(1.5, w * 0.11).toFixed(1)}px)`;
601 g.fillStyle = '#000';
602 g.translate(c.width / 2, c.height / 2);
603 g.beginPath();
604 g.roundRect(-w / 2, -h / 2, w, h, w * 0.1);
605 g.fill();
606 }
607 shadows.set(key, c);
608 return c;
609}
610
611/**
612 * Foreshortening along the axis a tile is turning about.
613 *
614 * There is no third dimension here, so an out-of-plane turn is drawn as a
615 * squash. Applied in the *table's* frame, before the tile's own flat rotation,
616 * because the axis it turns about is fixed by the direction it was thrown, not
617 * by which way the tile happens to be facing.
618 */
619function squash(ctx: CanvasRenderingContext2D, across: number, axis: number) {
620 if (across > 0.999) return;
621 ctx.rotate(axis);
622 ctx.scale(across, 1);
623 ctx.rotate(-axis);
624}
625
626function shadow(
627 ctx: CanvasRenderingContext2D,
628 x: number,
629 y: number,
630 z: number,
631 angle: number,
632 w: number,
633 h: number,
634 scale: number,
635 facing: number,
636 axis: number,
637) {
638 // Keyed on the tile's own size, never on the eased one — a scale that changes
639 // every frame would mean a fresh blur every frame, which is the thing this
640 // exists to avoid.
641 const sprite = shadowSprite(w, h);
642 const spread = zScale(z) * scale * (1 + z * 0.004);
643 ctx.save();
644 ctx.globalAlpha = 0.36 - Math.min(0.18, z * 0.0016);
645 ctx.translate(x + z * 0.12, y + 3 + z * 0.34);
646 squash(ctx, Math.abs(facing) + THICKNESS * Math.sqrt(Math.max(0, 1 - facing * facing)), axis);
647 ctx.rotate(angle);
648 ctx.drawImage(
649 sprite,
650 (-sprite.width / 2) * spread,
651 (-sprite.height / 2) * spread,
652 sprite.width * spread,
653 sprite.height * spread,
654 );
655 ctx.restore();
656}
657
658function face(
659 ctx: CanvasRenderingContext2D,
660 tile: number,
661 w: number,
662 h: number,
663 art: Art,
664 lit: boolean,
665 backwards: boolean,
666) {
667 // Part-way through a turn you are looking at the back of it: the same green
668 // tile back the wall is built from. A tile always comes to rest face up, so
669 // this is only ever seen in the air.
670 if (backwards) {
671 if (art.back) ctx.drawImage(art.back, -w / 2, -h / 2, w, h);
672 else {
673 tilePath(ctx, w, h);
674 ctx.fillStyle = '#1c7a4b';
675 ctx.fill();
676 }
677 return;
678 }
679
680 if (art.faces) {
681 // 43 columns, two rows; the second is the sheet's own highlighted variant,
682 // which is what a lifted tile in a hand uses. The tile in your fingers and
683 // the one still to be claimed get it here for the same reason.
684 const cw = art.faces.naturalWidth / SPRITE_COLS;
685 const ch = art.faces.naturalHeight / 2;
686 ctx.drawImage(art.faces, SPRITE_COL[tile] * cw, lit ? ch : 0, cw, ch, -w / 2, -h / 2, w, h);
687 } else {
688 tilePath(ctx, w, h);
689 ctx.fillStyle = '#f6f0e2';
690 ctx.fill();
691 }
692 if (lit) {
693 // A pool of sixty tiles needs more than a lighter face to pick one out.
694 tilePath(ctx, w, h);
695 ctx.strokeStyle = '#d9b45c';
696 ctx.lineWidth = 2;
697 ctx.stroke();
698 }
699}
700
701function tilePath(ctx: CanvasRenderingContext2D, w: number, h: number) {
702 ctx.beginPath();
703 ctx.roundRect(-w / 2, -h / 2, w, h, w * 0.1);
704}