anvilsign in

collin/mahjong

1import backUrl from '../assets/back.png';
2import tilesUrl from '../assets/tiles.svg';
3import { SPRITE_COL, SPRITE_COLS } from '../game/tiles';
4import { zScale } from '../table/physics';
5
6/**
7 * Drawing one tile, and the shadow under it.
8 *
9 * Pulled out of the renderer so the numbers in `LOOK` can be turned by hand
10 * against the real thing rather than guessed at: `tiles.html` is a page of
11 * these at every angle and size with a slider on each of them. Nothing in here
12 * knows about the game, the pool or the table — it is a tile, a canvas, and how
13 * a tile is meant to look on cloth.
14 */
15
16/** The art, as something canvas can draw. */
17export interface Art {
18 faces: HTMLImageElement | null;
19 back: HTMLImageElement | null;
20}
21
22/**
23 * Every number that decides how a tile reads. Defaults are what the table uses;
24 * the tuner hands in its own while it is being turned.
25 */
26export interface TileLook {
27 /**
28 * The line round the outside.
29 *
30 * The art already has one — a single black pixel all the way round each 128px
31 * cell — but a tile in the middle is drawn about a quarter of that, and
32 * `drawImage` box-filters that one pixel down to about a quarter of its
33 * strength. Drawn as a stroke instead of relying on the bitmap, it holds at
34 * any size.
35 */
36 edgeLine: string;
37 edgeWidth: number;
38 /** The shadow: how dark at rest, how much height washes it out, how far it is
39 * offset across and down, how wide it spreads, and how soft it is. */
40 shadowAlpha: number;
41 shadowFade: number;
42 shadowAcross: number;
43 shadowDown: number;
44 shadowDownZ: number;
45 shadowSpread: number;
46 shadowBlur: number;
47}
48
49export const LOOK: TileLook = {
50 edgeLine: 'rgba(52, 43, 29, 0.55)',
51 edgeWidth: 1,
52 shadowAlpha: 0.36,
53 shadowFade: 0.0016,
54 shadowAcross: 0.12,
55 shadowDown: 3,
56 shadowDownZ: 0.34,
57 shadowSpread: 0.004,
58 shadowBlur: 0.11,
59};
60
61/** Load the sheet and the back — the same files the CSS tiles use, so the pool
62 * cannot drift from the rest of the table. */
63export function loadArt(): Promise<Art> {
64 const load = (src: string) => {
65 const el = new Image();
66 el.src = src;
67 // Not fatal: the pool falls back to blank tiles rather than emptying out.
68 return el.decode().then(
69 () => el,
70 () => null,
71 );
72 };
73 return Promise.all([load(tilesUrl), load(backUrl)]).then(([faces, back]) => ({ faces, back }));
74}
75
76
77export interface Drawn {
78 x: number;
79 y: number;
80 z: number;
81 angle: number;
82 w: number;
83 h: number;
84 /** cos of the turn: 1 flat on its back, 0 edge on, negative face down. */
85 facing: number;
86 axis: number;
87 code: number;
88 lit: boolean;
89}
90
91/**
92 * One tile: its face, its outline, and nothing else.
93 *
94 * There used to be a slab of ivory drawn under it and offset down the screen,
95 * so a sliver of the tile's own side showed along the bottom edge and it read
96 * as an object rather than a printed card. Seen at the size a discard is
97 * actually drawn it did not read as thickness — it read as a second, paler
98 * shape stuck to the bottom of every tile, and no amount of shading the thing
99 * fixed that, because the problem was that it was there at all.
100 *
101 * What gives a tile its weight now is the shadow under it and the line round
102 * it. Both of those are true at any size, which the slab never was.
103 */
104export function drawTile(
105 ctx: CanvasRenderingContext2D,
106 art: Art,
107 d: Drawn,
108 look: TileLook = LOOK,
109) {
110 const s = zScale(d.z);
111 const w = d.w * s;
112 const h = d.h * s;
113
114 ctx.save();
115 ctx.translate(d.x, d.y);
116 // Edge on there is no face left to see, so it pinches to a line rather than
117 // through to the other side; a hair of width keeps the outline drawable.
118 squash(ctx, Math.max(0.015, Math.abs(d.facing)), d.axis);
119 ctx.rotate(d.angle);
120 face(ctx, d.code, w, h, art, d.lit, d.facing < 0, look);
121 ctx.restore();
122}
123
124/**
125 * One cell of the sheet, cut out and kept.
126 *
127 * `drawImage` with a source rectangle is allowed to sample *past* that
128 * rectangle when it resamples, and the row under every face is the sheet's own
129 * highlighted variant, whose border is bright blue. Square on and at a right
130 * angle nothing resamples across that boundary and all is well; at any other
131 * angle the blue is dragged in and every turned tile wears a blue fringe along
132 * one edge. Measured, an arbitrary rotation took the worst blue on the tile
133 * from 92 — which is the tile's own ink — to 255.
134 *
135 * Cut out once at native size there is nothing beside it to bleed, and it is
136 * the same 92 at every angle and every size.
137 *
138 * 43 columns, two rows; the second is the highlighted variant, which is what a
139 * lifted tile in a hand uses. The tile in your fingers and the one still to be
140 * claimed get it here for the same reason.
141 */
142const cells = new Map<string, HTMLCanvasElement>();
143let cellsFrom: HTMLImageElement | null = null;
144
145function cellOf(sheet: HTMLImageElement, tile: number, lit: boolean): HTMLCanvasElement {
146 // A different sheet — a hot reload, in practice — is a different set of cells.
147 if (cellsFrom !== sheet) {
148 cells.clear();
149 cellsFrom = sheet;
150 }
151 const key = `${tile}:${lit}`;
152 const had = cells.get(key);
153 if (had) return had;
154
155 const cw = sheet.naturalWidth / SPRITE_COLS;
156 const ch = sheet.naturalHeight / 2;
157 const c = document.createElement('canvas');
158 c.width = Math.round(cw);
159 c.height = Math.round(ch);
160 c.getContext('2d')?.drawImage(
161 sheet,
162 SPRITE_COL[tile] * cw,
163 lit ? ch : 0,
164 cw,
165 ch,
166 0,
167 0,
168 c.width,
169 c.height,
170 );
171 cells.set(key, c);
172 return c;
173}
174
175/**
176 * A soft silhouette under a tile.
177 *
178 * The blur is baked into a sprite once per tile size rather than applied per
179 * draw. `ctx.filter` is the obvious way to write this and the wrong one: every
180 * filtered fill makes the browser allocate and blur its own layer, so sixty
181 * tiles times sixty frames is sixty times sixty blurs a second, which is enough
182 * to make the tab stop answering. One blur, then `drawImage`.
183 *
184 * Height is sold by scaling that sprite up — a shadow that spreads and fades as
185 * the tile rises — which a fixed blur radius could not have done anyway.
186 */
187const shadows = new Map<string, HTMLCanvasElement>();
188
189function shadowSprite(w: number, h: number, blur: number): HTMLCanvasElement {
190 const key = `${Math.round(w)}x${Math.round(h)}@${blur}`;
191 const had = shadows.get(key);
192 if (had) return had;
193 // Only ever a handful — one per tile size on screen — but a window being
194 // dragged to resize walks through a lot of them.
195 if (shadows.size > 8) shadows.clear();
196
197 const pad = Math.ceil(Math.max(8, w * 0.5));
198 const c = document.createElement('canvas');
199 c.width = Math.ceil(w) + pad * 2;
200 c.height = Math.ceil(h) + pad * 2;
201 const g = c.getContext('2d');
202 if (g) {
203 g.filter = `blur(${Math.max(1.5, w * blur).toFixed(1)}px)`;
204 g.fillStyle = '#000';
205 g.translate(c.width / 2, c.height / 2);
206 g.beginPath();
207 g.roundRect(-w / 2, -h / 2, w, h, w * 0.1);
208 g.fill();
209 }
210 shadows.set(key, c);
211 return c;
212}
213
214/**
215 * Foreshortening along the axis a tile is turning about.
216 *
217 * There is no third dimension here, so an out-of-plane turn is drawn as a
218 * squash. Applied in the *table's* frame, before the tile's own flat rotation,
219 * because the axis it turns about is fixed by the direction it was thrown, not
220 * by which way the tile happens to be facing.
221 */
222function squash(ctx: CanvasRenderingContext2D, across: number, axis: number) {
223 if (across > 0.999) return;
224 ctx.rotate(axis);
225 ctx.scale(across, 1);
226 ctx.rotate(-axis);
227}
228
229export function drawShadow(
230 ctx: CanvasRenderingContext2D,
231 x: number,
232 y: number,
233 z: number,
234 angle: number,
235 w: number,
236 h: number,
237 scale: number,
238 facing: number,
239 axis: number,
240 look: TileLook = LOOK,
241) {
242 // Keyed on the tile's own size, never on the eased one — a scale that changes
243 // every frame would mean a fresh blur every frame, which is the thing this
244 // exists to avoid.
245 const sprite = shadowSprite(w, h, look.shadowBlur);
246 const spread = zScale(z) * scale * (1 + z * look.shadowSpread);
247 ctx.save();
248 ctx.globalAlpha = look.shadowAlpha - Math.min(look.shadowAlpha / 2, z * look.shadowFade);
249 ctx.translate(x + z * look.shadowAcross, y + look.shadowDown + z * look.shadowDownZ);
250 squash(ctx, Math.abs(facing), axis);
251 ctx.rotate(angle);
252 ctx.drawImage(
253 sprite,
254 (-sprite.width / 2) * spread,
255 (-sprite.height / 2) * spread,
256 sprite.width * spread,
257 sprite.height * spread,
258 );
259 ctx.restore();
260}
261
262function face(
263 ctx: CanvasRenderingContext2D,
264 tile: number,
265 w: number,
266 h: number,
267 art: Art,
268 lit: boolean,
269 backwards: boolean,
270 look: TileLook,
271) {
272 // Part-way through a turn you are looking at the back of it: the same green
273 // tile back the wall is built from. A tile always comes to rest face up, so
274 // this is only ever seen in the air.
275 if (backwards) {
276 if (art.back) ctx.drawImage(art.back, -w / 2, -h / 2, w, h);
277 else {
278 tilePath(ctx, w, h);
279 ctx.fillStyle = '#1c7a4b';
280 ctx.fill();
281 }
282 // A green back on green cloth needs the edge more than a white face does.
283 tilePath(ctx, w, h);
284 ctx.strokeStyle = look.edgeLine;
285 ctx.lineWidth = look.edgeWidth;
286 ctx.stroke();
287 return;
288 }
289
290 if (art.faces) {
291 ctx.drawImage(cellOf(art.faces, tile, lit), -w / 2, -h / 2, w, h);
292 } else {
293 tilePath(ctx, w, h);
294 ctx.fillStyle = '#f6f0e2';
295 ctx.fill();
296 }
297 // The edge the shrunken art no longer carries for itself.
298 tilePath(ctx, w, h);
299 ctx.strokeStyle = look.edgeLine;
300 ctx.lineWidth = look.edgeWidth;
301 ctx.stroke();
302 if (lit) {
303 // A pool of sixty tiles needs more than a lighter face to pick one out.
304 tilePath(ctx, w, h);
305 ctx.strokeStyle = '#d9b45c';
306 ctx.lineWidth = 2;
307 ctx.stroke();
308 }
309}
310
311function tilePath(ctx: CanvasRenderingContext2D, w: number, h: number) {
312 ctx.beginPath();
313 ctx.roundRect(-w / 2, -h / 2, w, h, w * 0.1);
314}