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 * A soft silhouette under a tile.
126 *
127 * The blur is baked into a sprite once per tile size rather than applied per
128 * draw. `ctx.filter` is the obvious way to write this and the wrong one: every
129 * filtered fill makes the browser allocate and blur its own layer, so sixty
130 * tiles times sixty frames is sixty times sixty blurs a second, which is enough
131 * to make the tab stop answering. One blur, then `drawImage`.
132 *
133 * Height is sold by scaling that sprite up — a shadow that spreads and fades as
134 * the tile rises — which a fixed blur radius could not have done anyway.
135 */
136const shadows = new Map<string, HTMLCanvasElement>();
137
138function shadowSprite(w: number, h: number, blur: number): HTMLCanvasElement {
139 const key = `${Math.round(w)}x${Math.round(h)}@${blur}`;
140 const had = shadows.get(key);
141 if (had) return had;
142 // Only ever a handful — one per tile size on screen — but a window being
143 // dragged to resize walks through a lot of them.
144 if (shadows.size > 8) shadows.clear();
145
146 const pad = Math.ceil(Math.max(8, w * 0.5));
147 const c = document.createElement('canvas');
148 c.width = Math.ceil(w) + pad * 2;
149 c.height = Math.ceil(h) + pad * 2;
150 const g = c.getContext('2d');
151 if (g) {
152 g.filter = `blur(${Math.max(1.5, w * blur).toFixed(1)}px)`;
153 g.fillStyle = '#000';
154 g.translate(c.width / 2, c.height / 2);
155 g.beginPath();
156 g.roundRect(-w / 2, -h / 2, w, h, w * 0.1);
157 g.fill();
158 }
159 shadows.set(key, c);
160 return c;
161}
162
163/**
164 * Foreshortening along the axis a tile is turning about.
165 *
166 * There is no third dimension here, so an out-of-plane turn is drawn as a
167 * squash. Applied in the *table's* frame, before the tile's own flat rotation,
168 * because the axis it turns about is fixed by the direction it was thrown, not
169 * by which way the tile happens to be facing.
170 */
171function squash(ctx: CanvasRenderingContext2D, across: number, axis: number) {
172 if (across > 0.999) return;
173 ctx.rotate(axis);
174 ctx.scale(across, 1);
175 ctx.rotate(-axis);
176}
177
178export function drawShadow(
179 ctx: CanvasRenderingContext2D,
180 x: number,
181 y: number,
182 z: number,
183 angle: number,
184 w: number,
185 h: number,
186 scale: number,
187 facing: number,
188 axis: number,
189 look: TileLook = LOOK,
190) {
191 // Keyed on the tile's own size, never on the eased one — a scale that changes
192 // every frame would mean a fresh blur every frame, which is the thing this
193 // exists to avoid.
194 const sprite = shadowSprite(w, h, look.shadowBlur);
195 const spread = zScale(z) * scale * (1 + z * look.shadowSpread);
196 ctx.save();
197 ctx.globalAlpha = look.shadowAlpha - Math.min(look.shadowAlpha / 2, z * look.shadowFade);
198 ctx.translate(x + z * look.shadowAcross, y + look.shadowDown + z * look.shadowDownZ);
199 squash(ctx, Math.abs(facing), axis);
200 ctx.rotate(angle);
201 ctx.drawImage(
202 sprite,
203 (-sprite.width / 2) * spread,
204 (-sprite.height / 2) * spread,
205 sprite.width * spread,
206 sprite.height * spread,
207 );
208 ctx.restore();
209}
210
211function face(
212 ctx: CanvasRenderingContext2D,
213 tile: number,
214 w: number,
215 h: number,
216 art: Art,
217 lit: boolean,
218 backwards: boolean,
219 look: TileLook,
220) {
221 // Part-way through a turn you are looking at the back of it: the same green
222 // tile back the wall is built from. A tile always comes to rest face up, so
223 // this is only ever seen in the air.
224 if (backwards) {
225 if (art.back) ctx.drawImage(art.back, -w / 2, -h / 2, w, h);
226 else {
227 tilePath(ctx, w, h);
228 ctx.fillStyle = '#1c7a4b';
229 ctx.fill();
230 }
231 // A green back on green cloth needs the edge more than a white face does.
232 tilePath(ctx, w, h);
233 ctx.strokeStyle = look.edgeLine;
234 ctx.lineWidth = look.edgeWidth;
235 ctx.stroke();
236 return;
237 }
238
239 if (art.faces) {
240 // 43 columns, two rows; the second is the sheet's own highlighted variant,
241 // which is what a lifted tile in a hand uses. The tile in your fingers and
242 // the one still to be claimed get it here for the same reason.
243 const cw = art.faces.naturalWidth / SPRITE_COLS;
244 const ch = art.faces.naturalHeight / 2;
245 ctx.drawImage(art.faces, SPRITE_COL[tile] * cw, lit ? ch : 0, cw, ch, -w / 2, -h / 2, w, h);
246 } else {
247 tilePath(ctx, w, h);
248 ctx.fillStyle = '#f6f0e2';
249 ctx.fill();
250 }
251 // The edge the shrunken art no longer carries for itself.
252 tilePath(ctx, w, h);
253 ctx.strokeStyle = look.edgeLine;
254 ctx.lineWidth = look.edgeWidth;
255 ctx.stroke();
256 if (lit) {
257 // A pool of sixty tiles needs more than a lighter face to pick one out.
258 tilePath(ctx, w, h);
259 ctx.strokeStyle = '#d9b45c';
260 ctx.lineWidth = 2;
261 ctx.stroke();
262 }
263}
264
265function tilePath(ctx: CanvasRenderingContext2D, w: number, h: number) {
266 ctx.beginPath();
267 ctx.roundRect(-w / 2, -h / 2, w, h, w * 0.1);
268}