anvilsign in

collin/mahjong

1import { useEffect, useRef, useState } from 'react';
2import { createRoot } from 'react-dom/client';
3import { LOOK, drawShadow, drawTile, loadArt, type Art, type TileLook } from './ui/tileArt';
4import './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. */
22const FACES = [0, 4, 9, 13, 18, 22, 27, 31, 34];
23
24interface Knob {
25 key: keyof TileLook;
26 label: string;
27 min?: number;
28 max?: number;
29 step?: number;
30 note: string;
31}
32
33const 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' },
39 { key: 'edgeWidth', label: 'Outline width', min: 0, max: 4, step: 0.25, note: 'px' },
40 { key: 'shadowAlpha', label: 'Shadow', min: 0, max: 1, step: 0.01, note: 'darkest, on the felt' },
41 { key: 'shadowBlur', label: 'Shadow blur', min: 0, max: 0.4, step: 0.005, note: 'of the width' },
42 { key: 'shadowDown', label: 'Shadow down', min: 0, max: 16, step: 0.5, note: 'px at rest' },
43 { key: 'shadowAcross', label: 'Shadow across', min: 0, max: 0.5, step: 0.01, note: 'per unit up' },
44 { key: 'shadowDownZ', label: 'Shadow down', min: 0, max: 1.2, step: 0.01, note: 'per unit up' },
45 { key: 'shadowSpread', label: 'Shadow spread', min: 0, max: 0.02, step: 0.0005, note: 'per unit up' },
46 { key: 'shadowFade', label: 'Shadow fade', min: 0, max: 0.01, step: 0.0002, note: 'per unit up' },
47];
48
49const 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' },
52 { key: 'edgeLine', label: 'Outline', note: 'takes an alpha' },
53];
54
55/** The felt, as `styles.css` paints it, so the tiles are judged against it. */
56const FELT = '#0d3b2a';
57const FELT_2 = '#07271b';
58
59function Tuner() {
60 const [look, setLook] = useState<TileLook>({ ...LOOK });
61 const [zoom, setZoom] = useState(3);
62 const [art, setArt] = useState<Art>({ faces: null, back: null });
63 const [copied, setCopied] = useState(false);
64 const canvas = useRef<HTMLCanvasElement>(null);
65
66 useEffect(() => {
67 let alive = true;
68 void loadArt().then((a) => alive && setArt(a));
69 return () => {
70 alive = false;
71 };
72 }, []);
73
74 useEffect(() => {
75 const cv = canvas.current;
76 const ctx = cv?.getContext('2d');
77 if (!cv || !ctx) return;
78
79 // A tile in the middle is 35px across on an ordinary window; the hand ones
80 // are the same tile, drawn where you can read them.
81 const sizes = [{ w: 35, z: 0 }, { w: 35, z: 26 }, { w: 56, z: 0 }];
82 const gap = 18;
83 // Room for the widest tile turned on its corner, and for its shadow.
84 const rowH = Math.max(...sizes.map((s) => s.w * 1.375)) * 1.5 + 20;
85 const head = 90;
86 const width = 110 + FACES.length * (56 * 1.5 + gap);
87 const height = head + sizes.length * (rowH + gap);
88
89 const dpr = window.devicePixelRatio || 1;
90 cv.width = Math.round(width * zoom * dpr);
91 cv.height = Math.round(height * zoom * dpr);
92 cv.style.width = `${width * zoom}px`;
93 cv.style.height = `${height * zoom}px`;
94 ctx.setTransform(dpr * zoom, 0, 0, dpr * zoom, 0, 0);
95
96 const felt = ctx.createRadialGradient(width / 2, height / 2, 0, width / 2, height / 2, width * 0.7);
97 felt.addColorStop(0, FELT);
98 felt.addColorStop(1, FELT_2);
99 ctx.fillStyle = felt;
100 ctx.fillRect(0, 0, width, height);
101
102 // Every angle, so a tile resting sideways is judged as well as one square
103 // on — the side is offset down the screen whatever the tile is doing.
104 sizes.forEach((size, row) => {
105 const y = head + row * (rowH + gap) + rowH / 2;
106 FACES.forEach((code, i) => {
107 const x = 90 + i * (56 * 1.5 + gap);
108 const angle = (i / (FACES.length - 1)) * Math.PI - Math.PI / 2;
109 const w = size.w;
110 const h = w * 1.375;
111 drawShadow(ctx, x, y, size.z, angle, w, h, 1, 1, 0, look);
112 drawTile(
113 ctx,
114 art,
115 { x, y, z: size.z, angle, w, h, facing: 1, axis: 0, code, lit: false },
116 look,
117 );
118 });
119 ctx.fillStyle = 'rgba(246,240,226,0.5)';
120 ctx.font = '11px ui-monospace, monospace';
121 ctx.textBaseline = 'middle';
122 ctx.fillText(size.z ? `${size.w}px, up ${size.z}` : `${size.w}px`, 8, y);
123 });
124
125 // A back and a lit tile, since both are drawn on the table too.
126 const y = 44;
127 ctx.fillStyle = 'rgba(246,240,226,0.5)';
128 ctx.font = '11px ui-monospace, monospace';
129 ctx.fillText('plain · lit · back', 8, y);
130 [0, 1, 2].forEach((i) => {
131 const x = 130 + i * 60;
132 drawShadow(ctx, x, y, 0, 0.2, 35, 48, 1, i === 2 ? -1 : 1, 0, look);
133 drawTile(
134 ctx,
135 art,
136 { x, y, z: 0, angle: 0.2, w: 35, h: 48, facing: i === 2 ? -1 : 1, axis: 0, code: 7, lit: i === 1 },
137 look,
138 );
139 });
140 }, [look, zoom, art]);
141
142 const set = (key: keyof TileLook, value: string | number) =>
143 setLook((l) => ({ ...l, [key]: value }));
144
145 const source = `export const LOOK: TileLook = {\n${(Object.keys(LOOK) as (keyof TileLook)[])
146 .map((k) => ` ${k}: ${typeof look[k] === 'string' ? `'${look[k]}'` : look[k]},`)
147 .join('\n')}\n};`;
148
149 return (
150 <div className="tune">
151 <div className="stage">
152 <canvas ref={canvas} />
153 </div>
154 <div className="panel">
155 <h1>Tile look</h1>
156 <p className="hint">
157 Every face at every angle, over the felt, at the sizes they are really drawn. Turn
158 something, look at it, and paste the block at the bottom into <code>ui/tileArt.ts</code>.
159 </p>
160
161 <label className="knob">
162 <span>
163 Zoom<em>×{zoom}</em>
164 </span>
165 <input
166 type="range"
167 min={1}
168 max={8}
169 step={1}
170 value={zoom}
171 onChange={(e) => setZoom(+e.target.value)}
172 />
173 </label>
174
175 {COLOURS.map((c) => (
176 <label className="knob" key={c.key}>
177 <span>
178 {c.label}
179 <em>{c.note}</em>
180 </span>
181 <input
182 type="text"
183 spellCheck={false}
184 value={String(look[c.key])}
185 onChange={(e) => set(c.key, e.target.value)}
186 />
187 <i className="swatch" style={{ background: String(look[c.key]) }} />
188 </label>
189 ))}
190
191 {KNOBS.map((k) => (
192 <label className="knob" key={`${k.key}-${k.note}`}>
193 <span>
194 {k.label}
195 <em>{k.note}</em>
196 </span>
197 <input
198 type="range"
199 min={k.min}
200 max={k.max}
201 step={k.step}
202 value={Number(look[k.key])}
203 onChange={(e) => set(k.key, +e.target.value)}
204 />
205 <output>{Number(look[k.key])}</output>
206 </label>
207 ))}
208
209 <div className="row">
210 <button className="btn" onClick={() => setLook({ ...LOOK })}>
211 Back to the table's
212 </button>
213 <button
214 className="btn"
215 onClick={() => {
216 void navigator.clipboard?.writeText(source);
217 setCopied(true);
218 setTimeout(() => setCopied(false), 1200);
219 }}
220 >
221 {copied ? 'Copied' : 'Copy'}
222 </button>
223 </div>
224 <pre>{source}</pre>
225 </div>
226 </div>
227 );
228}
229
230createRoot(document.getElementById('root')!).render(<Tuner />);