anvilsign in

collin/mahjong · 792fe2fd

Work the tile's sides out instead of letting the art paint them

Collin Richards · 2026-08-21 20:13 UTC · 792fe2fd5aadc565072a65f5b3bfee01648e5c03 · parent c1de3f15 · browse files

modifiedREADME.md+16 −0
⋯ 446 unchanged lines
447447 the only way to know is to look at it and move something. (`tiles.html` is not
448448 one of the build's entry points, so it does not ship.)
449449
450+The sheet is not a picture of a tile's *face*. It is a picture of a whole tile,
451+drawn in an oblique projection, with its own sides painted down the left and
452+along the bottom and its own light coming from the top right — a 16px bevel on a
453+128px cell. That is fine while every tile stands the same way up, and wrong the
454+moment they are scattered: rotating the sprite rotates a pre-rendered solid, so
455+a pool of discards ends up with a different sun on each tile.
456+
457+So the art is cropped back to the flat face, and the solid is worked out
458+instead. A tile is a box lying on cloth: take the four corners of the rotated
459+rectangle, offset them by the projection (`depthAngle`, down and to the left,
460+the same direction the art used), and fill the two or three quads whose edges
461+the base falls away from — each shaded from the direction that edge faces *on
462+screen*. Turn the tile and the same physical side comes round to face a
463+different way and its shading changes with it, which is the whole point. No 3D
464+engine, no z-buffer: one projection vector and a dot product per edge.
465+
450466 Each cell of the sheet is cut out into its own canvas the first time it is
451467 wanted. `drawImage` with a source rectangle is allowed to sample past that
452468 rectangle when it resamples, and the row under every face is the highlighted
⋯ 163 unchanged lines
modifiedsrc/tune.tsx+7 −0
⋯ 30 unchanged lines
3131 }
3232
3333 const KNOBS: Knob[] = [
34+ { key: 'depth', label: 'Depth', min: 0, max: 0.5, step: 0.005, note: 'of the width' },
35+ { key: 'depthAngle', label: 'Base sits', min: 0, max: 360, step: 5, note: '° clockwise from up' },
36+ { key: 'lightAngle', label: 'Light', min: 0, max: 360, step: 5, note: '° clockwise from up' },
37+ { key: 'artBevel', label: 'Crop the art', min: 0, max: 0.3, step: 0.005, note: "its painted side" },
38+ { key: 'artEdge', label: 'Crop the art', min: 0, max: 0.1, step: 0.002, note: 'its outline' },
3439 { key: 'edgeWidth', label: 'Outline width', min: 0, max: 4, step: 0.25, note: 'px' },
3540 { key: 'shadowAlpha', label: 'Shadow', min: 0, max: 1, step: 0.01, note: 'darkest, on the felt' },
3641 { key: 'shadowBlur', label: 'Shadow blur', min: 0, max: 0.4, step: 0.005, note: 'of the width' },
⋯ 5 unchanged lines
4247 ];
4348
4449 const COLOURS: { key: keyof TileLook; label: string; note: string }[] = [
50+ { key: 'sideLight', label: 'Side, lit', note: 'facing the light' },
51+ { key: 'sideDark', label: 'Side, away', note: 'facing away from it' },
4552 { key: 'edgeLine', label: 'Outline', note: 'takes an alpha' },
4653 ];
4754
⋯ 176 unchanged lines
modifiedsrc/ui/tileArt.ts+147 −16
⋯ 34 unchanged lines
3535 */
3636 edgeLine: string;
3737 edgeWidth: number;
38+ /**
39+ * How thick a tile stands, as a fraction of its width, and where the light
40+ * is — clockwise from straight up the screen, in degrees.
41+ *
42+ * The sides are *worked out*, not painted. The art has a bevel baked into it,
43+ * which was fine while every tile stood the same way up and wrong the moment
44+ * they were scattered: a painted highlight turns with the tile, so a pool of
45+ * discards ended up with a different light on each one. These are the four
46+ * real edges of the rotated rectangle, extruded down the screen, and only the
47+ * ones actually facing you are drawn — so a tile at any angle is lit by the
48+ * one light over the table.
49+ */
50+ depth: number;
51+ /**
52+ * Which way the tile's base sits from its face, clockwise from up. Down and
53+ * to the left is what the art itself uses, and it is what lets you see two
54+ * sides of a tile at once rather than one.
55+ */
56+ depthAngle: number;
57+ lightAngle: number;
58+ /**
59+ * How much of each cell is the *painted* side, and how much is its outline,
60+ * both as a fraction of the cell's width.
61+ *
62+ * The sheet is not a picture of a tile's face — it is a picture of a whole
63+ * tile, drawn in the same oblique projection as this, with its own sides
64+ * painted down the left and along the bottom and its own light coming from
65+ * the top right. That was fine while every tile stood the same way up. Turned,
66+ * it is a pre-rendered solid being spun, which is why a scattered pool looked
67+ * like every tile had its own private sun. Cropped back to the flat face, the
68+ * art is a face and the solid is worked out here.
69+ */
70+ artBevel: number;
71+ artEdge: number;
72+ /** The tile's own ivory, full on the light and full away from it. */
73+ sideLight: string;
74+ sideDark: string;
3875 /** The shadow: how dark at rest, how much height washes it out, how far it is
3976 * offset across and down, how wide it spreads, and how soft it is. */
4077 shadowAlpha: number;
⋯ 8 unchanged lines
4986 export const LOOK: TileLook = {
5087 edgeLine: 'rgba(52, 43, 29, 0.55)',
5188 edgeWidth: 1,
89+ depth: 0.125,
90+ depthAngle: 225,
91+ lightAngle: 35,
92+ // The two greys the art shades its own sides with, so a tile is the tile it
93+ // always was — only lit by one sun instead of one each.
94+ sideLight: '#d7d7d7',
95+ sideDark: '#9f9f9f',
96+ artBevel: 0.125,
97+ artEdge: 0.016,
5298 shadowAlpha: 0.36,
5399 shadowFade: 0.0016,
54100 shadowAcross: 0.12,
⋯ 33 unchanged lines
88134 lit: boolean;
89135 }
90136
137+/** Somewhere between two colours, `t` from the first to the second. */
138+function mix(a: string, b: string, t: number): string {
139+ const hex = (c: string) => [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16));
140+ const [ar, ag, ab] = hex(a);
141+ const [br, bg, bb] = hex(b);
142+ const k = Math.max(0, Math.min(1, t));
143+ return `rgb(${Math.round(ar + (br - ar) * k)},${Math.round(ag + (bg - ag) * k)},${Math.round(
144+ ab + (bb - ab) * k,
145+ )})`;
146+}
147+
91148 /**
149+ * The sides of the tile you can actually see, lit by the one light.
150+ *
151+ * A tile is a box lying on cloth, seen from almost overhead and a little in
152+ * front. What that shows of its sides is the two or three edges pointing
153+ * towards the viewer — down the screen — each a quad between the top face's
154+ * edge and the same edge dropped by the tile's depth. Which of them are lit is
155+ * decided per edge, from the direction that edge faces on *screen*: turn the
156+ * tile and the same physical side comes round to face a different way, and its
157+ * shading changes with it, which is the whole point.
158+ */
159+function sides(ctx: CanvasRenderingContext2D, d: Drawn, w: number, h: number, look: TileLook) {
160+ const depth = w * look.depth;
161+ if (depth <= 0) return;
162+
163+ const c = Math.cos(d.angle);
164+ const s = Math.sin(d.angle);
165+ const corner = (x: number, y: number) => ({ x: d.x + x * c - y * s, y: d.y + x * s + y * c });
166+ const box = [
167+ corner(-w / 2, -h / 2),
168+ corner(w / 2, -h / 2),
169+ corner(w / 2, h / 2),
170+ corner(-w / 2, h / 2),
171+ ];
172+
173+ // Straight up the screen is 0, turning clockwise, so both of these read like
174+ // a compass. `out` is where the tile's base sits from its face — the whole of
175+ // the projection, and the only reason any side is in sight at all.
176+ const lit = (look.lightAngle * Math.PI) / 180;
177+ const light = { x: Math.sin(lit), y: -Math.cos(lit) };
178+ const off = (look.depthAngle * Math.PI) / 180;
179+ const out = { x: Math.sin(off) * depth, y: -Math.cos(off) * depth };
180+
181+ for (let i = 0; i < 4; i++) {
182+ const a = box[i];
183+ const b = box[(i + 1) % 4];
184+ // Outward, in screen terms: the edge's normal, turned to point away from
185+ // the middle of the tile.
186+ const ex = b.x - a.x;
187+ const ey = b.y - a.y;
188+ const len = Math.hypot(ex, ey) || 1;
189+ let nx = ey / len;
190+ let ny = -ex / len;
191+ if (nx * (a.x + b.x - 2 * d.x) + ny * (a.y + b.y - 2 * d.y) < 0) {
192+ nx = -nx;
193+ ny = -ny;
194+ }
195+ // Only the edges the base falls away from are in sight at all.
196+ if (nx * out.x + ny * out.y <= 0.001) continue;
197+
198+ ctx.beginPath();
199+ ctx.moveTo(a.x, a.y);
200+ ctx.lineTo(b.x, b.y);
201+ ctx.lineTo(b.x + out.x, b.y + out.y);
202+ ctx.lineTo(a.x + out.x, a.y + out.y);
203+ ctx.closePath();
204+ // Facing the light is 1, facing away is 0.
205+ ctx.fillStyle = mix(look.sideDark, look.sideLight, (nx * light.x + ny * light.y + 1) / 2);
206+ ctx.fill();
207+ ctx.strokeStyle = look.edgeLine;
208+ ctx.lineWidth = look.edgeWidth;
209+ ctx.stroke();
210+ }
211+}
212+
213+/**
92214 * One tile: its face, its outline, and nothing else.
93215 *
94216 * There used to be a slab of ivory drawn under it and offset down the screen,
⋯ 16 unchanged lines
111233 const w = d.w * s;
112234 const h = d.h * s;
113235
236+ // The sides first: the face sits on top of them, which is what makes the
237+ // whole thing read as a block rather than a card with a border.
238+ sides(ctx, d, w, h, look);
239+
114240 ctx.save();
115241 ctx.translate(d.x, d.y);
116242 // Edge on there is no face left to see, so it pinches to a line rather than
⋯ 25 unchanged lines
142268 const cells = new Map<string, HTMLCanvasElement>();
143269 let cellsFrom: HTMLImageElement | null = null;
144270
145-function cellOf(sheet: HTMLImageElement, tile: number, lit: boolean): HTMLCanvasElement {
271+function cellOf(
272+ sheet: HTMLImageElement,
273+ tile: number,
274+ lit: boolean,
275+ look: TileLook,
276+): HTMLCanvasElement {
146277 // A different sheet — a hot reload, in practice — is a different set of cells.
147278 if (cellsFrom !== sheet) {
148279 cells.clear();
149280 cellsFrom = sheet;
150281 }
151- const key = `${tile}:${lit}`;
282+ const key = `${tile}:${lit}:${look.artBevel}:${look.artEdge}`;
152283 const had = cells.get(key);
153284 if (had) return had;
154285
155286 const cw = sheet.naturalWidth / SPRITE_COLS;
156287 const ch = sheet.naturalHeight / 2;
288+ // A thickness is a thickness whichever way it is measured, so both insets are
289+ // taken off the cell's *width*. The painted side is down the left and along
290+ // the bottom; the other two carry only the outline.
291+ const bevel = cw * look.artBevel;
292+ const edge = cw * look.artEdge;
293+ const sx = SPRITE_COL[tile] * cw + bevel;
294+ const sy = (lit ? ch : 0) + edge;
295+ const sw = cw - bevel - edge;
296+ const sh = ch - edge - bevel;
297+
157298 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- );
299+ c.width = Math.max(1, Math.round(sw));
300+ c.height = Math.max(1, Math.round(sh));
301+ c.getContext('2d')?.drawImage(sheet, sx, sy, sw, sh, 0, 0, c.width, c.height);
171302 cells.set(key, c);
172303 return c;
173304 }
⋯ 114 unchanged lines
288419 }
289420
290421 if (art.faces) {
291- ctx.drawImage(cellOf(art.faces, tile, lit), -w / 2, -h / 2, w, h);
422+ ctx.drawImage(cellOf(art.faces, tile, lit, look), -w / 2, -h / 2, w, h);
292423 } else {
293424 tilePath(ctx, w, h);
294425 ctx.fillStyle = '#f6f0e2';
⋯ 20 unchanged lines