anvilsign in

collin/mahjong

1import { encode } from 'uqr';
2import backUrl from '../assets/back.png';
3import tilesUrl from '../assets/tiles.svg';
4import { SPRITE_COL, SPRITE_COLS } from '../game/tiles';
5import { zScale } from '../table/physics';
6
7/**
8 * Drawing one tile, and the shadow under it.
9 *
10 * Pulled out of the renderer so the numbers in `LOOK` can be turned by hand
11 * against the real thing rather than guessed at: `tiles.html` is a page of
12 * these at every angle and size with a slider on each of them. Nothing in here
13 * knows about the game, the pool or the table — it is a tile, a canvas, and how
14 * a tile is meant to look on cloth.
15 */
16
17/** The art, as something canvas can draw. */
18export interface Art {
19 faces: HTMLImageElement | null;
20 back: HTMLImageElement | null;
21}
22
23/**
24 * Every number that decides how a tile reads. Defaults are what the table uses;
25 * the tuner hands in its own while it is being turned.
26 */
27export interface TileLook {
28 /**
29 * The line round the outside.
30 *
31 * The art already has one — a single black pixel all the way round each 128px
32 * cell — but a tile in the middle is drawn about a quarter of that, and
33 * `drawImage` box-filters that one pixel down to about a quarter of its
34 * strength. Drawn as a stroke instead of relying on the bitmap, it holds at
35 * any size.
36 */
37 edgeLine: string;
38 edgeWidth: number;
39 /**
40 * How thick a tile stands, as a fraction of its width, and where the light
41 * is — clockwise from straight up the screen, in degrees.
42 *
43 * The sides are *worked out*, not painted. The art has a bevel baked into it,
44 * which was fine while every tile stood the same way up and wrong the moment
45 * they were scattered: a painted highlight turns with the tile, so a pool of
46 * discards ended up with a different light on each one. These are the four
47 * real edges of the rotated rectangle, extruded down the screen, and only the
48 * ones actually facing you are drawn — so a tile at any angle is lit by the
49 * one light over the table.
50 */
51 depth: number;
52 /**
53 * Which way the tile's base sits from its face, clockwise from up. Down and
54 * to the left is what the art itself uses, and it is what lets you see two
55 * sides of a tile at once rather than one.
56 */
57 depthAngle: number;
58 lightAngle: number;
59 /**
60 * How much of each cell is the *painted* side, and how much is its outline,
61 * both as a fraction of the cell's width.
62 *
63 * The sheet is not a picture of a tile's face — it is a picture of a whole
64 * tile, drawn in the same oblique projection as this, with its own sides
65 * painted down the left and along the bottom and its own light coming from
66 * the top right. That was fine while every tile stood the same way up. Turned,
67 * it is a pre-rendered solid being spun, which is why a scattered pool looked
68 * like every tile had its own private sun. Cropped back to the flat face, the
69 * art is a face and the solid is worked out here.
70 */
71 artBevel: number;
72 artEdge: number;
73 /** The tile's own ivory, full on the light and full away from it. */
74 sideLight: string;
75 sideDark: string;
76 /** The shadow: how dark at rest, how much height washes it out, how far it is
77 * offset across and down, how wide it spreads, and how soft it is. */
78 shadowAlpha: number;
79 shadowFade: number;
80 shadowAcross: number;
81 shadowDown: number;
82 shadowDownZ: number;
83 shadowSpread: number;
84 shadowBlur: number;
85}
86
87export const LOOK: TileLook = {
88 edgeLine: 'rgba(52, 43, 29, 0.55)',
89 edgeWidth: 1,
90 depth: 0.125,
91 depthAngle: 225,
92 lightAngle: 35,
93 // The two greys the art shades its own sides with, so a tile is the tile it
94 // always was — only lit by one sun instead of one each.
95 sideLight: '#d7d7d7',
96 sideDark: '#9f9f9f',
97 artBevel: 0.125,
98 artEdge: 0.016,
99 shadowAlpha: 0.36,
100 shadowFade: 0.0016,
101 shadowAcross: 0.12,
102 shadowDown: 3,
103 shadowDownZ: 0.34,
104 shadowSpread: 0.004,
105 shadowBlur: 0.11,
106};
107
108/** Load the sheet and the back — the same files the CSS tiles use, so the pool
109 * cannot drift from the rest of the table. */
110export function loadArt(): Promise<Art> {
111 const load = (src: string) => {
112 const el = new Image();
113 el.src = src;
114 // Not fatal: the pool falls back to blank tiles rather than emptying out.
115 return el.decode().then(
116 () => el,
117 () => null,
118 );
119 };
120 return Promise.all([load(tilesUrl), load(backUrl)]).then(([faces, back]) => ({ faces, back }));
121}
122
123
124export interface Drawn {
125 x: number;
126 y: number;
127 z: number;
128 angle: number;
129 w: number;
130 h: number;
131 /** cos of the turn: 1 flat on its back, 0 edge on, negative face down. */
132 facing: number;
133 axis: number;
134 code: number;
135 lit: boolean;
136 /**
137 * A face painted rather than cut out of the sheet — the join tile's QR. It is
138 * drawn into the same rectangle a sheet cell would fill, so everything else
139 * about the tile is unchanged: the same sides worked out from its angle, the
140 * same one light over the table, the same shadow, the same edge line. It is a
141 * tile with a different thing on it, which is what a 中 is.
142 */
143 image?: CanvasImageSource;
144}
145
146/**
147 * The join tile's face: the room's QR, cut into a tile the way a dragon's
148 * character is.
149 *
150 * Painted into an offscreen canvas of the face's own proportions and handed to
151 * `drawTile` as `image`, so from there on it is a tile like any other — the
152 * pool works out its sides from the angle it happens to be lying at, the one
153 * light over the table shades them, and it throws the same shadow on the felt.
154 *
155 * The code is square and a tile is not, so it sits in the square across the
156 * width and the white left over above and below carries SCAN and JOIN — the
157 * two bands a suit tile spends on its number and its suit, spent here on
158 * saying what the pattern between them is for. The ink is the tiles' own dark
159 * green rather than black: a QR reads on any sufficient contrast, and black on
160 * white would be the one thing on this table not made of the same two colours
161 * as everything else.
162 */
163const QR_INK = '#14402f';
164/** The face is the white the sheet paints every other face — this tile is one
165 * of the set, not a card that turned up in it. */
166const QR_GROUND = '#ffffff';
167/** How much of the tile's width the code takes, leaving a quiet margin. A QR
168 * needs one, and a tile's face has an edge the art already leaves clear. */
169const QR_INSET = 0.8;
170/** The two words, as tall as the band they sit in allows. */
171const QR_LABEL = 0.13;
172
173const qrFaces = new Map<string, HTMLCanvasElement>();
174
175export function qrFace(url: string, width: number): HTMLCanvasElement | null {
176 // Rounded, so a pool that re-measures by a pixel does not repaint the code.
177 const w = Math.max(64, Math.round(width / 16) * 16);
178 const key = `${w}:${url}`;
179 const had = qrFaces.get(key);
180 if (had) return had;
181
182 let code: { size: number; data: boolean[][] };
183 try {
184 code = encode(url) as { size: number; data: boolean[][] };
185 } catch {
186 // An unencodable link is not worth taking the table down over.
187 return null;
188 }
189
190 const h = Math.round(w * 1.375);
191 const canvas = document.createElement('canvas');
192 canvas.width = w;
193 canvas.height = h;
194 const ctx = canvas.getContext('2d');
195 if (!ctx) return null;
196
197 ctx.fillStyle = QR_GROUND;
198 ctx.fillRect(0, 0, w, h);
199
200 // Whole pixels per module, so no two modules end up different widths — a
201 // resampled QR is a QR a phone has to work at.
202 const span = w * QR_INSET;
203 const unit = Math.max(1, Math.floor(span / code.size));
204 const side = unit * code.size;
205 const x0 = Math.round((w - side) / 2);
206 const y0 = Math.round((h - side) / 2);
207 ctx.fillStyle = QR_INK;
208 for (let r = 0; r < code.size; r++) {
209 for (let c = 0; c < code.size; c++) {
210 if (code.data[r][c]) ctx.fillRect(x0 + c * unit, y0 + r * unit, unit, unit);
211 }
212 }
213
214 // One word in each band, centred in it. Letter-spaced, because two short
215 // words set small want the air — and because it is what the English under
216 // every Chinese label on this table already does.
217 const size = w * QR_LABEL;
218 ctx.font = `600 ${size}px ui-sans-serif, system-ui, "Segoe UI", sans-serif`;
219 ctx.textAlign = 'center';
220 ctx.textBaseline = 'middle';
221 // Not everywhere; where it is missing the words simply set tighter.
222 try {
223 ctx.letterSpacing = `${size * 0.18}px`;
224 } catch {
225 // older engines
226 }
227 // Nudged right by half the tracking: the last letter's own trailing space is
228 // counted into the width, and centred text hangs off to the left without it.
229 const nudge = size * 0.09;
230 ctx.fillText('SCAN', w / 2 + nudge, y0 / 2);
231 ctx.fillText('JOIN', w / 2 + nudge, h - y0 / 2);
232
233 qrFaces.set(key, canvas);
234 return canvas;
235}
236
237/** Somewhere between two colours, `t` from the first to the second. */
238function mix(a: string, b: string, t: number): string {
239 const hex = (c: string) => [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16));
240 const [ar, ag, ab] = hex(a);
241 const [br, bg, bb] = hex(b);
242 const k = Math.max(0, Math.min(1, t));
243 return `rgb(${Math.round(ar + (br - ar) * k)},${Math.round(ag + (bg - ag) * k)},${Math.round(
244 ab + (bb - ab) * k,
245 )})`;
246}
247
248/**
249 * The sides of the tile you can actually see, lit by the one light.
250 *
251 * A tile is a box lying on cloth, seen from almost overhead and a little in
252 * front. What that shows of its sides is the two or three edges pointing
253 * towards the viewer — down the screen — each a quad between the top face's
254 * edge and the same edge dropped by the tile's depth. Which of them are lit is
255 * decided per edge, from the direction that edge faces on *screen*: turn the
256 * tile and the same physical side comes round to face a different way, and its
257 * shading changes with it, which is the whole point.
258 */
259function sides(ctx: CanvasRenderingContext2D, d: Drawn, w: number, h: number, look: TileLook) {
260 const depth = w * look.depth;
261 if (depth <= 0) return;
262
263 const c = Math.cos(d.angle);
264 const s = Math.sin(d.angle);
265 const corner = (x: number, y: number) => ({ x: d.x + x * c - y * s, y: d.y + x * s + y * c });
266 const box = [
267 corner(-w / 2, -h / 2),
268 corner(w / 2, -h / 2),
269 corner(w / 2, h / 2),
270 corner(-w / 2, h / 2),
271 ];
272
273 // Straight up the screen is 0, turning clockwise, so both of these read like
274 // a compass. `out` is where the tile's base sits from its face — the whole of
275 // the projection, and the only reason any side is in sight at all.
276 const lit = (look.lightAngle * Math.PI) / 180;
277 const light = { x: Math.sin(lit), y: -Math.cos(lit) };
278 const off = (look.depthAngle * Math.PI) / 180;
279 const out = { x: Math.sin(off) * depth, y: -Math.cos(off) * depth };
280
281 for (let i = 0; i < 4; i++) {
282 const a = box[i];
283 const b = box[(i + 1) % 4];
284 // Outward, in screen terms: the edge's normal, turned to point away from
285 // the middle of the tile.
286 const ex = b.x - a.x;
287 const ey = b.y - a.y;
288 const len = Math.hypot(ex, ey) || 1;
289 let nx = ey / len;
290 let ny = -ex / len;
291 if (nx * (a.x + b.x - 2 * d.x) + ny * (a.y + b.y - 2 * d.y) < 0) {
292 nx = -nx;
293 ny = -ny;
294 }
295 // Only the edges the base falls away from are in sight at all.
296 if (nx * out.x + ny * out.y <= 0.001) continue;
297
298 ctx.beginPath();
299 ctx.moveTo(a.x, a.y);
300 ctx.lineTo(b.x, b.y);
301 ctx.lineTo(b.x + out.x, b.y + out.y);
302 ctx.lineTo(a.x + out.x, a.y + out.y);
303 ctx.closePath();
304 // Facing the light is 1, facing away is 0.
305 ctx.fillStyle = mix(look.sideDark, look.sideLight, (nx * light.x + ny * light.y + 1) / 2);
306 ctx.fill();
307 ctx.strokeStyle = look.edgeLine;
308 ctx.lineWidth = look.edgeWidth;
309 ctx.stroke();
310 }
311}
312
313/**
314 * One tile: its face, its outline, and nothing else.
315 *
316 * There used to be a slab of ivory drawn under it and offset down the screen,
317 * so a sliver of the tile's own side showed along the bottom edge and it read
318 * as an object rather than a printed card. Seen at the size a discard is
319 * actually drawn it did not read as thickness — it read as a second, paler
320 * shape stuck to the bottom of every tile, and no amount of shading the thing
321 * fixed that, because the problem was that it was there at all.
322 *
323 * What gives a tile its weight now is the shadow under it and the line round
324 * it. Both of those are true at any size, which the slab never was.
325 */
326export function drawTile(
327 ctx: CanvasRenderingContext2D,
328 art: Art,
329 d: Drawn,
330 look: TileLook = LOOK,
331) {
332 const s = zScale(d.z);
333 const w = d.w * s;
334 const h = d.h * s;
335
336 // The sides first: the face sits on top of them, which is what makes the
337 // whole thing read as a block rather than a card with a border.
338 sides(ctx, d, w, h, look);
339
340 ctx.save();
341 ctx.translate(d.x, d.y);
342 // Edge on there is no face left to see, so it pinches to a line rather than
343 // through to the other side; a hair of width keeps the outline drawable.
344 squash(ctx, Math.max(0.015, Math.abs(d.facing)), d.axis);
345 ctx.rotate(d.angle);
346 face(ctx, d.code, w, h, art, d.lit, d.facing < 0, look, d.image);
347 ctx.restore();
348}
349
350/**
351 * One cell of the sheet, cut out and kept.
352 *
353 * `drawImage` with a source rectangle is allowed to sample *past* that
354 * rectangle when it resamples, and the row under every face is the sheet's own
355 * highlighted variant, whose border is bright blue. Square on and at a right
356 * angle nothing resamples across that boundary and all is well; at any other
357 * angle the blue is dragged in and every turned tile wears a blue fringe along
358 * one edge. Measured, an arbitrary rotation took the worst blue on the tile
359 * from 92 — which is the tile's own ink — to 255.
360 *
361 * Cut out once at native size there is nothing beside it to bleed, and it is
362 * the same 92 at every angle and every size.
363 *
364 * 43 columns, two rows; the second is the highlighted variant, which is what a
365 * lifted tile in a hand uses. The tile in your fingers and the one still to be
366 * claimed get it here for the same reason.
367 */
368const cells = new Map<string, HTMLCanvasElement>();
369let cellsFrom: HTMLImageElement | null = null;
370
371function cellOf(
372 sheet: HTMLImageElement,
373 tile: number,
374 lit: boolean,
375 look: TileLook,
376): HTMLCanvasElement {
377 // A different sheet — a hot reload, in practice — is a different set of cells.
378 if (cellsFrom !== sheet) {
379 cells.clear();
380 cellsFrom = sheet;
381 }
382 const key = `${tile}:${lit}:${look.artBevel}:${look.artEdge}`;
383 const had = cells.get(key);
384 if (had) return had;
385
386 const cw = sheet.naturalWidth / SPRITE_COLS;
387 const ch = sheet.naturalHeight / 2;
388 // A thickness is a thickness whichever way it is measured, so both insets are
389 // taken off the cell's *width*. The painted side is down the left and along
390 // the bottom; the other two carry only the outline.
391 const bevel = cw * look.artBevel;
392 const edge = cw * look.artEdge;
393 const sx = SPRITE_COL[tile] * cw + bevel;
394 const sy = (lit ? ch : 0) + edge;
395 const sw = cw - bevel - edge;
396 const sh = ch - edge - bevel;
397
398 const c = document.createElement('canvas');
399 c.width = Math.max(1, Math.round(sw));
400 c.height = Math.max(1, Math.round(sh));
401 c.getContext('2d')?.drawImage(sheet, sx, sy, sw, sh, 0, 0, c.width, c.height);
402 cells.set(key, c);
403 return c;
404}
405
406/**
407 * A soft silhouette under a tile.
408 *
409 * The blur is baked into a sprite once per tile size rather than applied per
410 * draw. `ctx.filter` is the obvious way to write this and the wrong one: every
411 * filtered fill makes the browser allocate and blur its own layer, so sixty
412 * tiles times sixty frames is sixty times sixty blurs a second, which is enough
413 * to make the tab stop answering. One blur, then `drawImage`.
414 *
415 * Height is sold by scaling that sprite up — a shadow that spreads and fades as
416 * the tile rises — which a fixed blur radius could not have done anyway.
417 */
418const shadows = new Map<string, HTMLCanvasElement>();
419
420function shadowSprite(w: number, h: number, blur: number): HTMLCanvasElement {
421 const key = `${Math.round(w)}x${Math.round(h)}@${blur}`;
422 const had = shadows.get(key);
423 if (had) return had;
424 // Only ever a handful — one per tile size on screen — but a window being
425 // dragged to resize walks through a lot of them.
426 if (shadows.size > 8) shadows.clear();
427
428 const pad = Math.ceil(Math.max(8, w * 0.5));
429 const c = document.createElement('canvas');
430 c.width = Math.ceil(w) + pad * 2;
431 c.height = Math.ceil(h) + pad * 2;
432 const g = c.getContext('2d');
433 if (g) {
434 g.filter = `blur(${Math.max(1.5, w * blur).toFixed(1)}px)`;
435 g.fillStyle = '#000';
436 g.translate(c.width / 2, c.height / 2);
437 g.beginPath();
438 g.roundRect(-w / 2, -h / 2, w, h, w * 0.1);
439 g.fill();
440 }
441 shadows.set(key, c);
442 return c;
443}
444
445/**
446 * Foreshortening along the axis a tile is turning about.
447 *
448 * There is no third dimension here, so an out-of-plane turn is drawn as a
449 * squash. Applied in the *table's* frame, before the tile's own flat rotation,
450 * because the axis it turns about is fixed by the direction it was thrown, not
451 * by which way the tile happens to be facing.
452 */
453function squash(ctx: CanvasRenderingContext2D, across: number, axis: number) {
454 if (across > 0.999) return;
455 ctx.rotate(axis);
456 ctx.scale(across, 1);
457 ctx.rotate(-axis);
458}
459
460export function drawShadow(
461 ctx: CanvasRenderingContext2D,
462 x: number,
463 y: number,
464 z: number,
465 angle: number,
466 w: number,
467 h: number,
468 scale: number,
469 facing: number,
470 axis: number,
471 look: TileLook = LOOK,
472) {
473 // Keyed on the tile's own size, never on the eased one — a scale that changes
474 // every frame would mean a fresh blur every frame, which is the thing this
475 // exists to avoid.
476 const sprite = shadowSprite(w, h, look.shadowBlur);
477 const spread = zScale(z) * scale * (1 + z * look.shadowSpread);
478 ctx.save();
479 ctx.globalAlpha = look.shadowAlpha - Math.min(look.shadowAlpha / 2, z * look.shadowFade);
480 ctx.translate(x + z * look.shadowAcross, y + look.shadowDown + z * look.shadowDownZ);
481 squash(ctx, Math.abs(facing), axis);
482 ctx.rotate(angle);
483 ctx.drawImage(
484 sprite,
485 (-sprite.width / 2) * spread,
486 (-sprite.height / 2) * spread,
487 sprite.width * spread,
488 sprite.height * spread,
489 );
490 ctx.restore();
491}
492
493function face(
494 ctx: CanvasRenderingContext2D,
495 tile: number,
496 w: number,
497 h: number,
498 art: Art,
499 lit: boolean,
500 backwards: boolean,
501 look: TileLook,
502 image?: CanvasImageSource,
503) {
504 // Part-way through a turn you are looking at the back of it: the same green
505 // tile back the wall is built from. A tile always comes to rest face up, so
506 // this is only ever seen in the air.
507 if (backwards) {
508 if (art.back) ctx.drawImage(art.back, -w / 2, -h / 2, w, h);
509 else {
510 tilePath(ctx, w, h);
511 ctx.fillStyle = '#1c7a4b';
512 ctx.fill();
513 }
514 // A green back on green cloth needs the edge more than a white face does.
515 tilePath(ctx, w, h);
516 ctx.strokeStyle = look.edgeLine;
517 ctx.lineWidth = look.edgeWidth;
518 ctx.stroke();
519 return;
520 }
521
522 if (image) {
523 ctx.drawImage(image, -w / 2, -h / 2, w, h);
524 } else if (art.faces) {
525 ctx.drawImage(cellOf(art.faces, tile, lit, look), -w / 2, -h / 2, w, h);
526 } else {
527 tilePath(ctx, w, h);
528 ctx.fillStyle = '#f6f0e2';
529 ctx.fill();
530 }
531 // The edge the shrunken art no longer carries for itself.
532 tilePath(ctx, w, h);
533 ctx.strokeStyle = look.edgeLine;
534 ctx.lineWidth = look.edgeWidth;
535 ctx.stroke();
536 if (lit) {
537 // A pool of sixty tiles needs more than a lighter face to pick one out.
538 tilePath(ctx, w, h);
539 ctx.strokeStyle = '#d9b45c';
540 ctx.lineWidth = 2;
541 ctx.stroke();
542 }
543}
544
545function tilePath(ctx: CanvasRenderingContext2D, w: number, h: number) {
546 ctx.beginPath();
547 ctx.roundRect(-w / 2, -h / 2, w, h, w * 0.1);
548}