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 with the white left over showing above and below, and nothing is
157 * written in it. The ink is the tiles' own dark green rather than black: a QR
158 * reads on any sufficient contrast, and black on white would be the one thing
159 * on this table not made of the same two colours as everything else.
160 */
161const QR_INK = '#14402f';
162/** The face is the white the sheet paints every other face — this tile is one
163 * of the set, not a card that turned up in it. */
164const QR_GROUND = '#ffffff';
165/**
166 * How much of the tile's width the code takes.
167 *
168 * The rest is the quiet zone a QR needs around it, and it is the number this
169 * cannot be greedy about: at 0.8 the white either side is a little over three
170 * modules, which is close to the four the spec asks for. Pushing it to 0.88
171 * would buy a tenth on each module and leave under two, and a code with no air
172 * round it is a code a phone gives up on — the opposite of the trade worth
173 * making here.
174 */
175const QR_INSET = 0.8;
176
177/**
178 * Pixels per module in the painted face. The face is sized from this rather
179 * than from how big the tile happens to be drawn, so a module is always a whole
180 * number of pixels and the code always fills exactly `QR_INSET` of the width.
181 *
182 * Sizing it the other way round — a canvas the width of the tile, modules
183 * floored into it — is what it did first, and at a tile 33px wide that floored
184 * to one pixel a module and left the code covering 42% of the face instead of
185 * 80%. The canvas is cheap, made once per link, and drawn down to whatever size
186 * the tile is by the same resampler that handles every other face.
187 */
188const QR_UNIT = 8;
189
190const qrFaces = new Map<string, HTMLCanvasElement>();
191
192export function qrFace(url: string): HTMLCanvasElement | null {
193 const had = qrFaces.get(url);
194 if (had) return had;
195
196 let code: { size: number; data: boolean[][] };
197 try {
198 // No quiet zone of its own: the tile's white face is the quiet zone, and
199 // at QR_INSET it is about three and a half modules of it on every side —
200 // more than the one uqr would have added, and it costs no modules.
201 code = encode(url, { border: 0 }) as { size: number; data: boolean[][] };
202 } catch {
203 // An unencodable link is not worth taking the table down over.
204 return null;
205 }
206
207 const side = QR_UNIT * code.size;
208 const w = Math.round(side / QR_INSET);
209 const h = Math.round(w * 1.375);
210 const canvas = document.createElement('canvas');
211 canvas.width = w;
212 canvas.height = h;
213 const ctx = canvas.getContext('2d');
214 if (!ctx) return null;
215
216 ctx.fillStyle = QR_GROUND;
217 ctx.fillRect(0, 0, w, h);
218
219 const x0 = Math.round((w - side) / 2);
220 const y0 = Math.round((h - side) / 2);
221 ctx.fillStyle = QR_INK;
222 for (let r = 0; r < code.size; r++) {
223 for (let c = 0; c < code.size; c++) {
224 if (code.data[r][c]) ctx.fillRect(x0 + c * QR_UNIT, y0 + r * QR_UNIT, QR_UNIT, QR_UNIT);
225 }
226 }
227
228 qrFaces.set(url, canvas);
229 return canvas;
230}
231
232/** Somewhere between two colours, `t` from the first to the second. */
233function mix(a: string, b: string, t: number): string {
234 const hex = (c: string) => [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16));
235 const [ar, ag, ab] = hex(a);
236 const [br, bg, bb] = hex(b);
237 const k = Math.max(0, Math.min(1, t));
238 return `rgb(${Math.round(ar + (br - ar) * k)},${Math.round(ag + (bg - ag) * k)},${Math.round(
239 ab + (bb - ab) * k,
240 )})`;
241}
242
243/**
244 * The sides of the tile you can actually see, lit by the one light.
245 *
246 * A tile is a box lying on cloth, seen from almost overhead and a little in
247 * front. What that shows of its sides is the two or three edges pointing
248 * towards the viewer — down the screen — each a quad between the top face's
249 * edge and the same edge dropped by the tile's depth. Which of them are lit is
250 * decided per edge, from the direction that edge faces on *screen*: turn the
251 * tile and the same physical side comes round to face a different way, and its
252 * shading changes with it, which is the whole point.
253 */
254function sides(ctx: CanvasRenderingContext2D, d: Drawn, w: number, h: number, look: TileLook) {
255 const depth = w * look.depth;
256 if (depth <= 0) return;
257
258 const c = Math.cos(d.angle);
259 const s = Math.sin(d.angle);
260 const corner = (x: number, y: number) => ({ x: d.x + x * c - y * s, y: d.y + x * s + y * c });
261 const box = [
262 corner(-w / 2, -h / 2),
263 corner(w / 2, -h / 2),
264 corner(w / 2, h / 2),
265 corner(-w / 2, h / 2),
266 ];
267
268 // Straight up the screen is 0, turning clockwise, so both of these read like
269 // a compass. `out` is where the tile's base sits from its face — the whole of
270 // the projection, and the only reason any side is in sight at all.
271 const lit = (look.lightAngle * Math.PI) / 180;
272 const light = { x: Math.sin(lit), y: -Math.cos(lit) };
273 const off = (look.depthAngle * Math.PI) / 180;
274 const out = { x: Math.sin(off) * depth, y: -Math.cos(off) * depth };
275
276 for (let i = 0; i < 4; i++) {
277 const a = box[i];
278 const b = box[(i + 1) % 4];
279 // Outward, in screen terms: the edge's normal, turned to point away from
280 // the middle of the tile.
281 const ex = b.x - a.x;
282 const ey = b.y - a.y;
283 const len = Math.hypot(ex, ey) || 1;
284 let nx = ey / len;
285 let ny = -ex / len;
286 if (nx * (a.x + b.x - 2 * d.x) + ny * (a.y + b.y - 2 * d.y) < 0) {
287 nx = -nx;
288 ny = -ny;
289 }
290 // Only the edges the base falls away from are in sight at all.
291 if (nx * out.x + ny * out.y <= 0.001) continue;
292
293 ctx.beginPath();
294 ctx.moveTo(a.x, a.y);
295 ctx.lineTo(b.x, b.y);
296 ctx.lineTo(b.x + out.x, b.y + out.y);
297 ctx.lineTo(a.x + out.x, a.y + out.y);
298 ctx.closePath();
299 // Facing the light is 1, facing away is 0.
300 ctx.fillStyle = mix(look.sideDark, look.sideLight, (nx * light.x + ny * light.y + 1) / 2);
301 ctx.fill();
302 ctx.strokeStyle = look.edgeLine;
303 ctx.lineWidth = look.edgeWidth;
304 ctx.stroke();
305 }
306}
307
308/**
309 * One tile: its face, its outline, and nothing else.
310 *
311 * There used to be a slab of ivory drawn under it and offset down the screen,
312 * so a sliver of the tile's own side showed along the bottom edge and it read
313 * as an object rather than a printed card. Seen at the size a discard is
314 * actually drawn it did not read as thickness — it read as a second, paler
315 * shape stuck to the bottom of every tile, and no amount of shading the thing
316 * fixed that, because the problem was that it was there at all.
317 *
318 * What gives a tile its weight now is the shadow under it and the line round
319 * it. Both of those are true at any size, which the slab never was.
320 */
321export function drawTile(
322 ctx: CanvasRenderingContext2D,
323 art: Art,
324 d: Drawn,
325 look: TileLook = LOOK,
326) {
327 const s = zScale(d.z);
328 const w = d.w * s;
329 const h = d.h * s;
330
331 // The sides first: the face sits on top of them, which is what makes the
332 // whole thing read as a block rather than a card with a border.
333 sides(ctx, d, w, h, look);
334
335 ctx.save();
336 ctx.translate(d.x, d.y);
337 // Edge on there is no face left to see, so it pinches to a line rather than
338 // through to the other side; a hair of width keeps the outline drawable.
339 squash(ctx, Math.max(0.015, Math.abs(d.facing)), d.axis);
340 ctx.rotate(d.angle);
341 face(ctx, d.code, w, h, art, d.lit, d.facing < 0, look, d.image);
342 ctx.restore();
343}
344
345/**
346 * One cell of the sheet, cut out and kept.
347 *
348 * `drawImage` with a source rectangle is allowed to sample *past* that
349 * rectangle when it resamples, and the row under every face is the sheet's own
350 * highlighted variant, whose border is bright blue. Square on and at a right
351 * angle nothing resamples across that boundary and all is well; at any other
352 * angle the blue is dragged in and every turned tile wears a blue fringe along
353 * one edge. Measured, an arbitrary rotation took the worst blue on the tile
354 * from 92 — which is the tile's own ink — to 255.
355 *
356 * Cut out once at native size there is nothing beside it to bleed, and it is
357 * the same 92 at every angle and every size.
358 *
359 * 43 columns, two rows; the second is the highlighted variant, which is what a
360 * lifted tile in a hand uses. The tile in your fingers and the one still to be
361 * claimed get it here for the same reason.
362 */
363const cells = new Map<string, HTMLCanvasElement>();
364let cellsFrom: HTMLImageElement | null = null;
365
366function cellOf(
367 sheet: HTMLImageElement,
368 tile: number,
369 lit: boolean,
370 look: TileLook,
371): HTMLCanvasElement {
372 // A different sheet — a hot reload, in practice — is a different set of cells.
373 if (cellsFrom !== sheet) {
374 cells.clear();
375 cellsFrom = sheet;
376 }
377 const key = `${tile}:${lit}:${look.artBevel}:${look.artEdge}`;
378 const had = cells.get(key);
379 if (had) return had;
380
381 const cw = sheet.naturalWidth / SPRITE_COLS;
382 const ch = sheet.naturalHeight / 2;
383 // A thickness is a thickness whichever way it is measured, so both insets are
384 // taken off the cell's *width*. The painted side is down the left and along
385 // the bottom; the other two carry only the outline.
386 const bevel = cw * look.artBevel;
387 const edge = cw * look.artEdge;
388 const sx = SPRITE_COL[tile] * cw + bevel;
389 const sy = (lit ? ch : 0) + edge;
390 const sw = cw - bevel - edge;
391 const sh = ch - edge - bevel;
392
393 const c = document.createElement('canvas');
394 c.width = Math.max(1, Math.round(sw));
395 c.height = Math.max(1, Math.round(sh));
396 c.getContext('2d')?.drawImage(sheet, sx, sy, sw, sh, 0, 0, c.width, c.height);
397 cells.set(key, c);
398 return c;
399}
400
401/**
402 * A soft silhouette under a tile.
403 *
404 * The blur is baked into a sprite once per tile size rather than applied per
405 * draw. `ctx.filter` is the obvious way to write this and the wrong one: every
406 * filtered fill makes the browser allocate and blur its own layer, so sixty
407 * tiles times sixty frames is sixty times sixty blurs a second, which is enough
408 * to make the tab stop answering. One blur, then `drawImage`.
409 *
410 * Height is sold by scaling that sprite up — a shadow that spreads and fades as
411 * the tile rises — which a fixed blur radius could not have done anyway.
412 */
413const shadows = new Map<string, HTMLCanvasElement>();
414
415function shadowSprite(w: number, h: number, blur: number): HTMLCanvasElement {
416 const key = `${Math.round(w)}x${Math.round(h)}@${blur}`;
417 const had = shadows.get(key);
418 if (had) return had;
419 // Only ever a handful — one per tile size on screen — but a window being
420 // dragged to resize walks through a lot of them.
421 if (shadows.size > 8) shadows.clear();
422
423 const pad = Math.ceil(Math.max(8, w * 0.5));
424 const c = document.createElement('canvas');
425 c.width = Math.ceil(w) + pad * 2;
426 c.height = Math.ceil(h) + pad * 2;
427 const g = c.getContext('2d');
428 if (g) {
429 g.filter = `blur(${Math.max(1.5, w * blur).toFixed(1)}px)`;
430 g.fillStyle = '#000';
431 g.translate(c.width / 2, c.height / 2);
432 g.beginPath();
433 g.roundRect(-w / 2, -h / 2, w, h, w * 0.1);
434 g.fill();
435 }
436 shadows.set(key, c);
437 return c;
438}
439
440/**
441 * Foreshortening along the axis a tile is turning about.
442 *
443 * There is no third dimension here, so an out-of-plane turn is drawn as a
444 * squash. Applied in the *table's* frame, before the tile's own flat rotation,
445 * because the axis it turns about is fixed by the direction it was thrown, not
446 * by which way the tile happens to be facing.
447 */
448function squash(ctx: CanvasRenderingContext2D, across: number, axis: number) {
449 if (across > 0.999) return;
450 ctx.rotate(axis);
451 ctx.scale(across, 1);
452 ctx.rotate(-axis);
453}
454
455export function drawShadow(
456 ctx: CanvasRenderingContext2D,
457 x: number,
458 y: number,
459 z: number,
460 angle: number,
461 w: number,
462 h: number,
463 scale: number,
464 facing: number,
465 axis: number,
466 look: TileLook = LOOK,
467) {
468 // Keyed on the tile's own size, never on the eased one — a scale that changes
469 // every frame would mean a fresh blur every frame, which is the thing this
470 // exists to avoid.
471 const sprite = shadowSprite(w, h, look.shadowBlur);
472 const spread = zScale(z) * scale * (1 + z * look.shadowSpread);
473 ctx.save();
474 ctx.globalAlpha = look.shadowAlpha - Math.min(look.shadowAlpha / 2, z * look.shadowFade);
475 ctx.translate(x + z * look.shadowAcross, y + look.shadowDown + z * look.shadowDownZ);
476 squash(ctx, Math.abs(facing), axis);
477 ctx.rotate(angle);
478 ctx.drawImage(
479 sprite,
480 (-sprite.width / 2) * spread,
481 (-sprite.height / 2) * spread,
482 sprite.width * spread,
483 sprite.height * spread,
484 );
485 ctx.restore();
486}
487
488function face(
489 ctx: CanvasRenderingContext2D,
490 tile: number,
491 w: number,
492 h: number,
493 art: Art,
494 lit: boolean,
495 backwards: boolean,
496 look: TileLook,
497 image?: CanvasImageSource,
498) {
499 // Part-way through a turn you are looking at the back of it: the same green
500 // tile back the wall is built from. A tile always comes to rest face up, so
501 // this is only ever seen in the air.
502 if (backwards) {
503 if (art.back) ctx.drawImage(art.back, -w / 2, -h / 2, w, h);
504 else {
505 tilePath(ctx, w, h);
506 ctx.fillStyle = '#1c7a4b';
507 ctx.fill();
508 }
509 // A green back on green cloth needs the edge more than a white face does.
510 tilePath(ctx, w, h);
511 ctx.strokeStyle = look.edgeLine;
512 ctx.lineWidth = look.edgeWidth;
513 ctx.stroke();
514 return;
515 }
516
517 if (image) {
518 ctx.drawImage(image, -w / 2, -h / 2, w, h);
519 } else if (art.faces) {
520 ctx.drawImage(cellOf(art.faces, tile, lit, look), -w / 2, -h / 2, w, h);
521 } else {
522 tilePath(ctx, w, h);
523 ctx.fillStyle = '#f6f0e2';
524 ctx.fill();
525 }
526 // The edge the shrunken art no longer carries for itself.
527 tilePath(ctx, w, h);
528 ctx.strokeStyle = look.edgeLine;
529 ctx.lineWidth = look.edgeWidth;
530 ctx.stroke();
531 if (lit) {
532 // A pool of sixty tiles needs more than a lighter face to pick one out.
533 tilePath(ctx, w, h);
534 ctx.strokeStyle = '#d9b45c';
535 ctx.lineWidth = 2;
536 ctx.stroke();
537 }
538}
539
540function tilePath(ctx: CanvasRenderingContext2D, w: number, h: number) {
541 ctx.beginPath();
542 ctx.roundRect(-w / 2, -h / 2, w, h, w * 0.1);
543}