anvilsign in

collin/mahjong · 5d3e6b14

A page for turning these by eye, and the slab goes

Collin Richards · 2026-08-21 19:59 UTC · 5d3e6b14b3cd19301491aadb5c58483ca3bccbb9 · parent 16cdd507 · browse files

modifiedREADME.md+16 −0
⋯ 438 unchanged lines
439439 blank tile from its **smooth** theme, tinted jade, since a solitaire game has no
440440 face-down art of its own.
441441
442+How a tile is drawn on the canvas — the outline round it and the shadow under
443+it — lives in `src/ui/tileArt.ts`, and `npm run dev` serves **`/tiles.html`**, a
444+page of every face at every angle over the felt with a slider on each of those
445+numbers and the finished block ready to paste back. None of it can be judged
446+from the values: it is 246 white against a dark green at 35 pixels across, and
447+the only way to know is to look at it and move something. (`tiles.html` is not
448+one of the build's entry points, so it does not ship.)
449+
450+A tile used to be drawn as two passes — a slab of ivory offset down the screen
451+with the face on top, so a sliver of the tile's own side showed along the bottom
452+edge. At the size a discard is actually drawn that never read as thickness; it
453+read as a second, paler shape stuck to the bottom of every tile. Shading it
454+better did not help, because the problem was that it was there. What gives a
455+tile its weight now is the shadow and the outline, both of which are true at
456+any size.
457+
442458 That art is **GPL-2.0-or-later**. Fine for playing at home; if you ever
443459 distribute this app, either honour the GPL or swap the two files for art of your
444460 own — `SPRITE_COL` in `tiles.ts` is the only mapping that would need updating.
⋯ 148 unchanged lines
addedsrc/tune.css+145 −0
1+/* The tile tuner. Its own stylesheet rather than the table's: nothing here is
2+ the game, and the game should not have to carry it. */
3+
4+:root {
5+ color-scheme: dark;
6+ --ink: #f6f0e2;
7+ --gold: #d9b45c;
8+}
9+
10+* {
11+ box-sizing: border-box;
12+}
13+
14+body {
15+ margin: 0;
16+ background: #14181a;
17+ color: var(--ink);
18+ font: 14px/1.4 system-ui, -apple-system, 'Segoe UI', sans-serif;
19+}
20+
21+.tune {
22+ display: flex;
23+ min-height: 100vh;
24+ align-items: flex-start;
25+}
26+
27+/* The tiles get the room; the sliders get a column. */
28+.stage {
29+ flex: 1;
30+ min-width: 0;
31+ overflow: auto;
32+ padding: 16px;
33+}
34+.stage canvas {
35+ display: block;
36+ border-radius: 8px;
37+ image-rendering: pixelated;
38+}
39+
40+.panel {
41+ flex: none;
42+ width: 340px;
43+ padding: 16px 16px 40px;
44+ border-left: 1px solid rgba(246, 240, 226, 0.12);
45+ position: sticky;
46+ top: 0;
47+ max-height: 100vh;
48+ overflow: auto;
49+}
50+.panel h1 {
51+ margin: 0 0 6px;
52+ font-size: 18px;
53+ font-weight: 500;
54+ color: var(--gold);
55+}
56+.panel .hint {
57+ margin: 0 0 16px;
58+ font-size: 12px;
59+ opacity: 0.6;
60+}
61+.panel code {
62+ font-family: ui-monospace, monospace;
63+}
64+
65+.knob {
66+ display: grid;
67+ grid-template-columns: 130px 1fr auto;
68+ align-items: center;
69+ gap: 8px;
70+ margin-bottom: 7px;
71+}
72+.knob > span {
73+ display: flex;
74+ flex-direction: column;
75+ font-size: 12px;
76+}
77+.knob em {
78+ font-style: normal;
79+ font-size: 10px;
80+ opacity: 0.5;
81+}
82+.knob input[type='range'] {
83+ width: 100%;
84+ accent-color: var(--gold);
85+}
86+.knob input[type='text'] {
87+ width: 100%;
88+ background: #0d1012;
89+ border: 1px solid rgba(246, 240, 226, 0.18);
90+ border-radius: 5px;
91+ color: var(--ink);
92+ font-family: ui-monospace, monospace;
93+ font-size: 11px;
94+ padding: 4px 6px;
95+}
96+.knob output {
97+ font-family: ui-monospace, monospace;
98+ font-size: 11px;
99+ opacity: 0.75;
100+ min-width: 52px;
101+ text-align: right;
102+}
103+.knob .swatch {
104+ width: 20px;
105+ height: 20px;
106+ border-radius: 4px;
107+ border: 1px solid rgba(246, 240, 226, 0.25);
108+ /* Checks behind it, so an alpha reads as an alpha. */
109+ background-image: linear-gradient(45deg, #555 25%, transparent 25%),
110+ linear-gradient(-45deg, #555 25%, transparent 25%);
111+ background-size: 8px 8px;
112+}
113+
114+.row {
115+ display: flex;
116+ gap: 8px;
117+ margin: 14px 0 10px;
118+}
119+.btn {
120+ flex: 1;
121+ padding: 7px 10px;
122+ border-radius: 6px;
123+ border: 1px solid rgba(246, 240, 226, 0.25);
124+ background: transparent;
125+ color: var(--ink);
126+ font: inherit;
127+ font-size: 12px;
128+ cursor: pointer;
129+}
130+.btn:hover {
131+ border-color: var(--gold);
132+ color: var(--gold);
133+}
134+
135+.panel pre {
136+ margin: 0;
137+ padding: 10px;
138+ background: #0d1012;
139+ border-radius: 6px;
140+ font-family: ui-monospace, monospace;
141+ font-size: 11px;
142+ line-height: 1.5;
143+ white-space: pre-wrap;
144+ opacity: 0.85;
145+}
addedsrc/tune.tsx+223 −0
1+import { useEffect, useRef, useState } from 'react';
2+import { createRoot } from 'react-dom/client';
3+import { LOOK, drawShadow, drawTile, loadArt, type Art, type TileLook } from './ui/tileArt';
4+import './tune.css';
5+
6+/**
7+ * A page for turning the numbers that decide how a tile looks, against the
8+ * thing itself.
9+ *
10+ * How a tile reads on cloth is not something anybody can work out from the
11+ * values — it is 246 white against a dark green, a slab of ivory a few pixels
12+ * tall, and a shadow, and the only way to know is to look at it and move
13+ * something. So: every tile, at every angle, at the sizes they are actually
14+ * drawn, over the same felt, with a slider on each number and the finished
15+ * values ready to paste back into `ui/tileArt.ts`.
16+ *
17+ * Dev only — `tiles.html` is not one of the build's entry points, so it is
18+ * served by `npm run dev` and does not ship.
19+ */
20+
21+/** One row of the sheet, plus a couple of backs, at pool and hand size. */
22+const FACES = [0, 4, 9, 13, 18, 22, 27, 31, 34];
23+
24+interface Knob {
25+ key: keyof TileLook;
26+ label: string;
27+ min?: number;
28+ max?: number;
29+ step?: number;
30+ note: string;
31+}
32+
33+const KNOBS: Knob[] = [
34+ { key: 'edgeWidth', label: 'Outline width', min: 0, max: 4, step: 0.25, note: 'px' },
35+ { key: 'shadowAlpha', label: 'Shadow', min: 0, max: 1, step: 0.01, note: 'darkest, on the felt' },
36+ { key: 'shadowBlur', label: 'Shadow blur', min: 0, max: 0.4, step: 0.005, note: 'of the width' },
37+ { key: 'shadowDown', label: 'Shadow down', min: 0, max: 16, step: 0.5, note: 'px at rest' },
38+ { key: 'shadowAcross', label: 'Shadow across', min: 0, max: 0.5, step: 0.01, note: 'per unit up' },
39+ { key: 'shadowDownZ', label: 'Shadow down', min: 0, max: 1.2, step: 0.01, note: 'per unit up' },
40+ { key: 'shadowSpread', label: 'Shadow spread', min: 0, max: 0.02, step: 0.0005, note: 'per unit up' },
41+ { key: 'shadowFade', label: 'Shadow fade', min: 0, max: 0.01, step: 0.0002, note: 'per unit up' },
42+];
43+
44+const COLOURS: { key: keyof TileLook; label: string; note: string }[] = [
45+ { key: 'edgeLine', label: 'Outline', note: 'takes an alpha' },
46+];
47+
48+/** The felt, as `styles.css` paints it, so the tiles are judged against it. */
49+const FELT = '#0d3b2a';
50+const FELT_2 = '#07271b';
51+
52+function Tuner() {
53+ const [look, setLook] = useState<TileLook>({ ...LOOK });
54+ const [zoom, setZoom] = useState(3);
55+ const [art, setArt] = useState<Art>({ faces: null, back: null });
56+ const [copied, setCopied] = useState(false);
57+ const canvas = useRef<HTMLCanvasElement>(null);
58+
59+ useEffect(() => {
60+ let alive = true;
61+ void loadArt().then((a) => alive && setArt(a));
62+ return () => {
63+ alive = false;
64+ };
65+ }, []);
66+
67+ useEffect(() => {
68+ const cv = canvas.current;
69+ const ctx = cv?.getContext('2d');
70+ if (!cv || !ctx) return;
71+
72+ // A tile in the middle is 35px across on an ordinary window; the hand ones
73+ // are the same tile, drawn where you can read them.
74+ const sizes = [{ w: 35, z: 0 }, { w: 35, z: 26 }, { w: 56, z: 0 }];
75+ const gap = 18;
76+ // Room for the widest tile turned on its corner, and for its shadow.
77+ const rowH = Math.max(...sizes.map((s) => s.w * 1.375)) * 1.5 + 20;
78+ const head = 90;
79+ const width = 110 + FACES.length * (56 * 1.5 + gap);
80+ const height = head + sizes.length * (rowH + gap);
81+
82+ const dpr = window.devicePixelRatio || 1;
83+ cv.width = Math.round(width * zoom * dpr);
84+ cv.height = Math.round(height * zoom * dpr);
85+ cv.style.width = `${width * zoom}px`;
86+ cv.style.height = `${height * zoom}px`;
87+ ctx.setTransform(dpr * zoom, 0, 0, dpr * zoom, 0, 0);
88+
89+ const felt = ctx.createRadialGradient(width / 2, height / 2, 0, width / 2, height / 2, width * 0.7);
90+ felt.addColorStop(0, FELT);
91+ felt.addColorStop(1, FELT_2);
92+ ctx.fillStyle = felt;
93+ ctx.fillRect(0, 0, width, height);
94+
95+ // Every angle, so a tile resting sideways is judged as well as one square
96+ // on — the side is offset down the screen whatever the tile is doing.
97+ sizes.forEach((size, row) => {
98+ const y = head + row * (rowH + gap) + rowH / 2;
99+ FACES.forEach((code, i) => {
100+ const x = 90 + i * (56 * 1.5 + gap);
101+ const angle = (i / (FACES.length - 1)) * Math.PI - Math.PI / 2;
102+ const w = size.w;
103+ const h = w * 1.375;
104+ drawShadow(ctx, x, y, size.z, angle, w, h, 1, 1, 0, look);
105+ drawTile(
106+ ctx,
107+ art,
108+ { x, y, z: size.z, angle, w, h, facing: 1, axis: 0, code, lit: false },
109+ look,
110+ );
111+ });
112+ ctx.fillStyle = 'rgba(246,240,226,0.5)';
113+ ctx.font = '11px ui-monospace, monospace';
114+ ctx.textBaseline = 'middle';
115+ ctx.fillText(size.z ? `${size.w}px, up ${size.z}` : `${size.w}px`, 8, y);
116+ });
117+
118+ // A back and a lit tile, since both are drawn on the table too.
119+ const y = 44;
120+ ctx.fillStyle = 'rgba(246,240,226,0.5)';
121+ ctx.font = '11px ui-monospace, monospace';
122+ ctx.fillText('plain · lit · back', 8, y);
123+ [0, 1, 2].forEach((i) => {
124+ const x = 130 + i * 60;
125+ drawShadow(ctx, x, y, 0, 0.2, 35, 48, 1, i === 2 ? -1 : 1, 0, look);
126+ drawTile(
127+ ctx,
128+ art,
129+ { x, y, z: 0, angle: 0.2, w: 35, h: 48, facing: i === 2 ? -1 : 1, axis: 0, code: 7, lit: i === 1 },
130+ look,
131+ );
132+ });
133+ }, [look, zoom, art]);
134+
135+ const set = (key: keyof TileLook, value: string | number) =>
136+ setLook((l) => ({ ...l, [key]: value }));
137+
138+ const source = `export const LOOK: TileLook = {\n${(Object.keys(LOOK) as (keyof TileLook)[])
139+ .map((k) => ` ${k}: ${typeof look[k] === 'string' ? `'${look[k]}'` : look[k]},`)
140+ .join('\n')}\n};`;
141+
142+ return (
143+ <div className="tune">
144+ <div className="stage">
145+ <canvas ref={canvas} />
146+ </div>
147+ <div className="panel">
148+ <h1>Tile look</h1>
149+ <p className="hint">
150+ Every face at every angle, over the felt, at the sizes they are really drawn. Turn
151+ something, look at it, and paste the block at the bottom into <code>ui/tileArt.ts</code>.
152+ </p>
153+
154+ <label className="knob">
155+ <span>
156+ Zoom<em>×{zoom}</em>
157+ </span>
158+ <input
159+ type="range"
160+ min={1}
161+ max={8}
162+ step={1}
163+ value={zoom}
164+ onChange={(e) => setZoom(+e.target.value)}
165+ />
166+ </label>
167+
168+ {COLOURS.map((c) => (
169+ <label className="knob" key={c.key}>
170+ <span>
171+ {c.label}
172+ <em>{c.note}</em>
173+ </span>
174+ <input
175+ type="text"
176+ spellCheck={false}
177+ value={String(look[c.key])}
178+ onChange={(e) => set(c.key, e.target.value)}
179+ />
180+ <i className="swatch" style={{ background: String(look[c.key]) }} />
181+ </label>
182+ ))}
183+
184+ {KNOBS.map((k) => (
185+ <label className="knob" key={`${k.key}-${k.note}`}>
186+ <span>
187+ {k.label}
188+ <em>{k.note}</em>
189+ </span>
190+ <input
191+ type="range"
192+ min={k.min}
193+ max={k.max}
194+ step={k.step}
195+ value={Number(look[k.key])}
196+ onChange={(e) => set(k.key, +e.target.value)}
197+ />
198+ <output>{Number(look[k.key])}</output>
199+ </label>
200+ ))}
201+
202+ <div className="row">
203+ <button className="btn" onClick={() => setLook({ ...LOOK })}>
204+ Back to the table's
205+ </button>
206+ <button
207+ className="btn"
208+ onClick={() => {
209+ void navigator.clipboard?.writeText(source);
210+ setCopied(true);
211+ setTimeout(() => setCopied(false), 1200);
212+ }}
213+ >
214+ {copied ? 'Copied' : 'Copy'}
215+ </button>
216+ </div>
217+ <pre>{source}</pre>
218+ </div>
219+ </div>
220+ );
221+}
222+
223+createRoot(document.getElementById('root')!).render(<Tuner />);
modifiedsrc/ui/Pool.tsx+9 −282
11 import { useEffect, useRef, useState } from 'react';
2-// Imported rather than named by path, so the canvas draws the very files the
3-// CSS tiles use wherever the bundle ends up being served from.
4-import backUrl from '../assets/back.png';
5-import tilesUrl from '../assets/tiles.svg';
62 import type { TableCtl } from '../game/ctl';
73 import { barge, knock } from '../game/sound';
8-import { SPRITE_COL, SPRITE_COLS } from '../game/tiles';
9-import { aabbOf, centreLimits, faceOn, zScale, type Rect } from '../table/physics';
4+import { aabbOf, centreLimits, faceOn, type Rect } from '../table/physics';
105 import type { TablePool } from '../table/pool';
116 import type { SeatId } from '../game/types';
127 import {
⋯ 9 unchanged lines
2217 showingColliders,
2318 } from './colliders';
2419 import { EDGE_SEAT } from './rotation';
20+import { drawShadow, drawTile, loadArt, type Art } from './tileArt';
2521
2622 /**
2723 * The tiles in the middle of the table, drawn.
⋯ 209 unchanged lines
237233 };
238234 }
239235
240-interface Art {
241- faces: HTMLImageElement | null;
242- back: HTMLImageElement | null;
243-}
244-
245-/**
246- * The tile art, as something canvas can draw: the face sheet, and the green
247- * back a tile shows while it is turning over. Both are the same files the CSS
248- * uses for the tiles in the hands and the wall, so the pool cannot drift from
249- * the rest of the table.
250- */
236+/** The sheet and the green back, once, shared by every tile drawn. */
251237 function useSheet(): Art {
252238 const [art, setArt] = useState<Art>({ faces: null, back: null });
253239 useEffect(() => {
254240 let alive = true;
255- const load = (src: string) => {
256- const el = new Image();
257- el.src = src;
258- // Not fatal: the pool falls back to blank tiles rather than emptying out.
259- return el.decode().then(
260- () => el,
261- () => null,
262- );
263- };
264- void Promise.all([load(tilesUrl), load(backUrl)]).then(
265- ([faces, back]) => alive && setArt({ faces, back }),
266- );
241+ void loadArt().then((a) => alive && setArt(a));
267242 return () => {
268243 alive = false;
269244 };
⋯ 57 unchanged lines
327302 // Shadows in one pass of their own, offset before rotation, so the light comes
328303 // from one direction across the whole pool rather than turning with each tile.
329304 for (const b of bodies) {
330- shadow(ctx, b.x, b.y, b.z, b.angle, b.w, b.h, b.scale, faceOn(b), b.flipAxis);
305+ drawShadow(ctx, b.x, b.y, b.z, b.angle, b.w, b.h, b.scale, faceOn(b), b.flipAxis);
331306 }
332- if (held) shadow(ctx, held.x, held.y, HELD_Z, held.angle, held.w, held.h, 1, 1, 0);
307+ if (held) drawShadow(ctx, held.x, held.y, HELD_Z, held.angle, held.w, held.h, 1, 1, 0);
333308
334309 const newest = pool.newest;
335310 for (const b of bodies) {
336- tile(ctx, art, {
311+ drawTile(ctx, art, {
337312 x: b.x,
338313 y: b.y,
339314 z: b.z,
⋯ 11 unchanged lines
351326 for (const { body: b, fade } of departing) {
352327 ctx.save();
353328 ctx.globalAlpha = fade;
354- tile(ctx, art, {
329+ drawTile(ctx, art, {
355330 x: b.x,
356331 y: b.y,
357332 z: b.z,
⋯ 10 unchanged lines
368343
369344 // Whatever is in your fingers is above everything else on the table.
370345 if (held) {
371- tile(ctx, art, {
346+ drawTile(ctx, art, {
372347 x: held.x,
373348 y: held.y,
374349 z: HELD_Z,
⋯ 129 unchanged lines
504479 ctx.restore();
505480 }
506481
507-/**
508- * How thick a tile is, as a fraction of its width. A real one is about
509- * three-quarters as thick as it is wide; this is the part of that you can see
510- * from almost overhead, which is a good deal less.
511- */
512-const THICKNESS = 0.3;
513-/** The ivory of the tile's own body, seen along its sides. */
514-const EDGE_LIGHT = '#e6dcc2';
515-const EDGE_DARK = '#b9ad8d';
516-/**
517- * The line round the outside of a tile.
518- *
519- * The art already has one — a single black pixel all the way round each 128px
520- * cell — but a tile in the middle is drawn about a quarter of that size, and
521- * `drawImage` box-filters that one pixel down to a quarter of its strength. On
522- * the two sides the art shades for itself, left and bottom, there is a painted
523- * bevel underneath and you cannot tell. On the other two there is nothing but
524- * the outline, so white met green with no edge at all and the tile read as a
525- * flat shape rather than something lying on cloth. Drawn as a stroke instead of
526- * relying on the bitmap, it holds at any size.
527- */
528-const EDGE_LINE = 'rgba(52, 43, 29, 0.55)';
529-
530-interface Drawn {
531- x: number;
532- y: number;
533- z: number;
534- angle: number;
535- w: number;
536- h: number;
537- /** cos of the turn: 1 flat on its back, 0 edge on, negative face down. */
538- facing: number;
539- axis: number;
540- code: number;
541- lit: boolean;
542-}
543-
544-/**
545- * One tile, with the thickness of one.
546- *
547- * Drawn as two passes: the body of the tile, offset down the screen towards the
548- * viewer, and then its face on top of that. The sliver of body left showing
549- * along the bottom edge is what stops a tile reading as a printed card.
550- *
551- * Turning over makes that matter more, not less. Face on you see the face and a
552- * hair of the side; edge on there is no face left to see and the tile is a bar
553- * of ivory as thick as it really is — which is why the squash uses the
554- * rectangle's true projected width, `w·|cos| + thickness·|sin|`, rather than
555- * pinching to nothing and back.
556- */
557-function tile(ctx: CanvasRenderingContext2D, art: Art, d: Drawn) {
558- const s = zScale(d.z);
559- const w = d.w * s;
560- const h = d.h * s;
561- const thick = w * THICKNESS;
562- const c = Math.abs(d.facing);
563- const sin = Math.sqrt(Math.max(0, 1 - d.facing * d.facing));
564- // What the whole slab covers across the axis it is turning about.
565- const projected = c + (thick / w) * sin;
566- // How much of the body shows past the face: none of it face on beyond its own
567- // thickness, all of it edge on. Higher tiles show a touch more of their side.
568- const lift = thick * (0.45 + 0.55 * sin) + d.z * 0.05;
569-
570- // --- the body of the tile, seen from just off square
571- //
572- // Only the sliver past the bottom of the face is ever seen of this, and it is
573- // the side of the tile: it faces the viewer rather than the light, so it is
574- // the *darker* end of the ivory. It used to be the lighter one — the gradient
575- // ran dark at the tile's top, which is the half the face covers — so the one
576- // part on show was within a tenth of the white face and read as more face
577- // rather than as thickness.
578- //
579- // Shaded down the screen rather than down the tile. The slab is offset
580- // straight down whatever angle the tile lies at, so the sliver is always along
581- // its lower edge; a gradient that turned with the tile put the shading
582- // somewhere else entirely as soon as one came to rest sideways. The path is
583- // laid down rotated and then filled with the rotation taken back off, which
584- // leaves the shape turned and the light coming from one direction across the
585- // whole pool — the same direction the shadows fall in.
586- ctx.save();
587- ctx.translate(d.x, d.y + lift);
588- squash(ctx, projected, d.axis);
589- ctx.save();
590- ctx.rotate(d.angle);
591- tilePath(ctx, w, h);
592- ctx.restore();
593- const grad = ctx.createLinearGradient(0, h / 2 - lift, 0, h / 2);
594- grad.addColorStop(0, EDGE_LIGHT);
595- grad.addColorStop(1, EDGE_DARK);
596- ctx.fillStyle = grad;
597- ctx.fill();
598- ctx.strokeStyle = EDGE_LINE;
599- ctx.lineWidth = 1;
600- ctx.stroke();
601- ctx.restore();
602-
603- // --- and its face on top
604- ctx.save();
605- ctx.translate(d.x, d.y);
606- squash(ctx, Math.max(0.015, c), d.axis);
607- ctx.rotate(d.angle);
608- face(ctx, d.code, w, h, art, d.lit, d.facing < 0);
609- ctx.restore();
610-}
611-
612-/**
613- * A soft silhouette under a tile.
614- *
615- * The blur is baked into a sprite once per tile size rather than applied per
616- * draw. `ctx.filter` is the obvious way to write this and the wrong one: every
617- * filtered fill makes the browser allocate and blur its own layer, so sixty
618- * tiles times sixty frames is sixty times sixty blurs a second, which is enough
619- * to make the tab stop answering. One blur, then `drawImage`.
620- *
621- * Height is sold by scaling that sprite up — a shadow that spreads and fades as
622- * the tile rises — which a fixed blur radius could not have done anyway.
623- */
624-const shadows = new Map<string, HTMLCanvasElement>();
625-
626-function shadowSprite(w: number, h: number): HTMLCanvasElement {
627- const key = `${Math.round(w)}x${Math.round(h)}`;
628- const had = shadows.get(key);
629- if (had) return had;
630- // Only ever a handful — one per tile size on screen — but a window being
631- // dragged to resize walks through a lot of them.
632- if (shadows.size > 8) shadows.clear();
633-
634- const pad = Math.ceil(Math.max(8, w * 0.5));
635- const c = document.createElement('canvas');
636- c.width = Math.ceil(w) + pad * 2;
637- c.height = Math.ceil(h) + pad * 2;
638- const g = c.getContext('2d');
639- if (g) {
640- g.filter = `blur(${Math.max(1.5, w * 0.11).toFixed(1)}px)`;
641- g.fillStyle = '#000';
642- g.translate(c.width / 2, c.height / 2);
643- g.beginPath();
644- g.roundRect(-w / 2, -h / 2, w, h, w * 0.1);
645- g.fill();
646- }
647- shadows.set(key, c);
648- return c;
649-}
650-
651-/**
652- * Foreshortening along the axis a tile is turning about.
653- *
654- * There is no third dimension here, so an out-of-plane turn is drawn as a
655- * squash. Applied in the *table's* frame, before the tile's own flat rotation,
656- * because the axis it turns about is fixed by the direction it was thrown, not
657- * by which way the tile happens to be facing.
658- */
659-function squash(ctx: CanvasRenderingContext2D, across: number, axis: number) {
660- if (across > 0.999) return;
661- ctx.rotate(axis);
662- ctx.scale(across, 1);
663- ctx.rotate(-axis);
664-}
665-
666-function shadow(
667- ctx: CanvasRenderingContext2D,
668- x: number,
669- y: number,
670- z: number,
671- angle: number,
672- w: number,
673- h: number,
674- scale: number,
675- facing: number,
676- axis: number,
677-) {
678- // Keyed on the tile's own size, never on the eased one — a scale that changes
679- // every frame would mean a fresh blur every frame, which is the thing this
680- // exists to avoid.
681- const sprite = shadowSprite(w, h);
682- const spread = zScale(z) * scale * (1 + z * 0.004);
683- ctx.save();
684- ctx.globalAlpha = 0.36 - Math.min(0.18, z * 0.0016);
685- ctx.translate(x + z * 0.12, y + 3 + z * 0.34);
686- squash(ctx, Math.abs(facing) + THICKNESS * Math.sqrt(Math.max(0, 1 - facing * facing)), axis);
687- ctx.rotate(angle);
688- ctx.drawImage(
689- sprite,
690- (-sprite.width / 2) * spread,
691- (-sprite.height / 2) * spread,
692- sprite.width * spread,
693- sprite.height * spread,
694- );
695- ctx.restore();
696-}
697-
698-function face(
699- ctx: CanvasRenderingContext2D,
700- tile: number,
701- w: number,
702- h: number,
703- art: Art,
704- lit: boolean,
705- backwards: boolean,
706-) {
707- // Part-way through a turn you are looking at the back of it: the same green
708- // tile back the wall is built from. A tile always comes to rest face up, so
709- // this is only ever seen in the air.
710- if (backwards) {
711- if (art.back) ctx.drawImage(art.back, -w / 2, -h / 2, w, h);
712- else {
713- tilePath(ctx, w, h);
714- ctx.fillStyle = '#1c7a4b';
715- ctx.fill();
716- }
717- // A green back on green cloth needs the edge more than a white face does.
718- tilePath(ctx, w, h);
719- ctx.strokeStyle = EDGE_LINE;
720- ctx.lineWidth = 1;
721- ctx.stroke();
722- return;
723- }
724-
725- if (art.faces) {
726- // 43 columns, two rows; the second is the sheet's own highlighted variant,
727- // which is what a lifted tile in a hand uses. The tile in your fingers and
728- // the one still to be claimed get it here for the same reason.
729- const cw = art.faces.naturalWidth / SPRITE_COLS;
730- const ch = art.faces.naturalHeight / 2;
731- ctx.drawImage(art.faces, SPRITE_COL[tile] * cw, lit ? ch : 0, cw, ch, -w / 2, -h / 2, w, h);
732- } else {
733- tilePath(ctx, w, h);
734- ctx.fillStyle = '#f6f0e2';
735- ctx.fill();
736- }
737- // The edge the shrunken art no longer carries for itself.
738- tilePath(ctx, w, h);
739- ctx.strokeStyle = EDGE_LINE;
740- ctx.lineWidth = 1;
741- ctx.stroke();
742- if (lit) {
743- // A pool of sixty tiles needs more than a lighter face to pick one out.
744- tilePath(ctx, w, h);
745- ctx.strokeStyle = '#d9b45c';
746- ctx.lineWidth = 2;
747- ctx.stroke();
748- }
749-}
750-
751-function tilePath(ctx: CanvasRenderingContext2D, w: number, h: number) {
752- ctx.beginPath();
753- ctx.roundRect(-w / 2, -h / 2, w, h, w * 0.1);
754-}
addedsrc/ui/tileArt.ts+268 −0
1+import backUrl from '../assets/back.png';
2+import tilesUrl from '../assets/tiles.svg';
3+import { SPRITE_COL, SPRITE_COLS } from '../game/tiles';
4+import { 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. */
17+export 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+ */
26+export 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+
49+export 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. */
63+export 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+
77+export 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+ */
104+export 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+ */
136+const shadows = new Map<string, HTMLCanvasElement>();
137+
138+function 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+ */
171+function 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+
178+export 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+
211+function 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+
265+function tilePath(ctx: CanvasRenderingContext2D, w: number, h: number) {
266+ ctx.beginPath();
267+ ctx.roundRect(-w / 2, -h / 2, w, h, w * 0.1);
268+}
addedtiles.html+13 −0
1+<!doctype html>
2+<html lang="en" translate="no">
3+ <head>
4+ <meta charset="UTF-8" />
5+ <meta name="google" content="notranslate" />
6+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+ <title>Tile look</title>
8+ </head>
9+ <body>
10+ <div id="root"></div>
11+ <script type="module" src="/src/tune.tsx"></script>
12+ </body>
13+</html>