| 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: '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 | |
| 49 | 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' }, |
| 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. */ |
| 56 | const FELT = '#0d3b2a'; |
| 57 | const FELT_2 = '#07271b'; |
| 58 | |
| 59 | function 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 | |
| 230 | createRoot(document.getElementById('root')!).render(<Tuner />); |