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: '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
44const 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. */
49const FELT = '#0d3b2a';
50const FELT_2 = '#07271b';
51
52function 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
223createRoot(document.getElementById('root')!).render(<Tuner />);