anvilsign in

collin/mahjong

1import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
2import { createRoot } from 'react-dom/client';
3import { measure } from './table/geometry';
4import type { WallProgress } from './game/wall';
5import { WallRing } from './ui/WallRing';
6import './styles.css';
7import './tune.css';
8
9/**
10 * A page for standing the wall square where you want it.
11 *
12 * It is the real `<WallRing>` in a real `.center`, so what is on screen is what
13 * the table builds — and over the top of it, drawn from `measure()`, every
14 * collider the physics would actually be given. That second part is the point
15 * of the page rather than a nicety: the square is laid out in CSS and read back
16 * off the elements, so "it looks right" and "a tile would bounce off it there"
17 * are two different claims, and this is where you get to check both at once.
18 *
19 * Dev only — `wall.html` is not one of the build's entry points.
20 */
21
22interface Knob {
23 key: keyof Placing;
24 label: string;
25 min: number;
26 max: number;
27 step: number;
28 note: string;
29 unit: string;
30}
31
32interface Placing {
33 turn: number;
34 room: number;
35 tile: number;
36 x: number;
37 y: number;
38}
39
40const START: Placing = { turn: 45, room: 0.95, tile: 1, x: 0, y: 0 };
41
42const KNOBS: Knob[] = [
43 { key: 'turn', label: 'Turn', min: 0, max: 90, step: 1, note: '° clockwise', unit: 'deg' },
44 { key: 'room', label: 'Room it takes', min: 0.3, max: 1.3, step: 0.01, note: 'of the middle', unit: '' },
45 { key: 'tile', label: 'Tile size', min: 0.3, max: 1.4, step: 0.02, note: 'against a hand tile', unit: '' },
46 { key: 'x', label: 'Across', min: -200, max: 200, step: 1, note: 'from the middle', unit: 'px' },
47 { key: 'y', label: 'Down', min: -200, max: 200, step: 1, note: 'from the middle', unit: 'px' },
48];
49
50/** Where the hand has got to, so the square can be seen whole and eaten away. */
51const DRAWN = [0, 16, 32, 48, 64, 80, 100, 120];
52
53function WallTuner() {
54 const [place, setPlace] = useState<Placing>({ ...START });
55 const [drawn, setDrawn] = useState(0);
56 const [showColliders, setShowColliders] = useState(true);
57 const table = useRef<HTMLDivElement>(null);
58 const canvas = useRef<HTMLCanvasElement>(null);
59 // What the square actually came out as, so the number of tiles is a thing you
60 // can read rather than count off the screen.
61 const [built, setBuilt] = useState({ stacks: 0, gap: 0 });
62
63 // Stable across renders: the measuring effect keys off it, and a fresh object
64 // every render would have it re-measuring for ever.
65 const state = useMemo<WallProgress>(
66 () => ({ drawnFront: drawn, drawnBack: 0, rules: { wallReserve: 16 } }),
67 [drawn],
68 );
69
70 // On the ring itself rather than a stylesheet, so this page and the table are
71 // reading the very same custom properties.
72 useLayoutEffect(() => {
73 const ring = table.current?.querySelector<HTMLElement>('.wall-ring');
74 if (!ring) return;
75 ring.style.setProperty('--wall-turn', `${place.turn}deg`);
76 ring.style.setProperty('--wall-room', `${place.room}`);
77 ring.style.setProperty('--wall-tile', `${place.tile}`);
78 ring.style.setProperty('--wall-x', `${place.x}px`);
79 ring.style.setProperty('--wall-y', `${place.y}px`);
80 }, [place]);
81
82 // Then measure what came out and draw it, which is the whole point.
83 useEffect(() => {
84 const el = table.current;
85 const cv = canvas.current;
86 const ctx = cv?.getContext('2d');
87 if (!el || !cv || !ctx) return;
88
89 const paint = () => {
90 const dpr = window.devicePixelRatio || 1;
91 cv.width = Math.round(el.clientWidth * dpr);
92 cv.height = Math.round(el.clientHeight * dpr);
93 cv.style.width = `${el.clientWidth}px`;
94 cv.style.height = `${el.clientHeight}px`;
95 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
96 ctx.clearRect(0, 0, el.clientWidth, el.clientHeight);
97 if (!showColliders) return;
98
99 const geo = measure(el, state);
100 if (!geo) return;
101
102 // How many stacks are standing, and the most room any one side has left
103 // over — which is the gap you can see at the corners, since a side packs
104 // its stacks to the end it was built from.
105 const all = [...el.querySelectorAll<HTMLElement>('[data-stack]')];
106 const sides = [...el.querySelectorAll<HTMLElement>('.wall-side')];
107 let gap = 0;
108 for (const side of sides) {
109 const kids = [...side.querySelectorAll<HTMLElement>('[data-stack]')];
110 if (!kids.length) continue;
111 const along = side.classList.contains('left') || side.classList.contains('right');
112 const room = along ? side.clientHeight : side.clientWidth;
113 const used = kids.reduce((n, k) => n + (along ? k.offsetHeight : k.offsetWidth), 0);
114 gap = Math.max(gap, room - used);
115 }
116 setBuilt((was) =>
117 was.stacks === all.length && Math.round(was.gap) === Math.round(gap)
118 ? was
119 : { stacks: all.length, gap },
120 );
121
122 ctx.lineWidth = 1;
123 ctx.strokeStyle = '#ff2d20';
124 for (const w of geo.walls) {
125 ctx.save();
126 ctx.translate(w.rect.x + w.rect.w / 2, w.rect.y + w.rect.h / 2);
127 ctx.rotate(w.angle ?? 0);
128 ctx.strokeRect(-w.rect.w / 2, -w.rect.h / 2, w.rect.w, w.rect.h);
129 ctx.restore();
130 }
131
132 // Where a *tapped* discard is aimed. Not a fence, and not turned with the
133 // square — which is the one thing on this page that is an approximation.
134 ctx.setLineDash([5, 5]);
135 ctx.strokeStyle = 'rgba(255, 45, 32, 0.45)';
136 ctx.strokeRect(geo.pool.x + 0.5, geo.pool.y + 0.5, geo.pool.w - 1, geo.pool.h - 1);
137 ctx.setLineDash([]);
138 };
139
140 // After the layout has caught up with the properties just set.
141 const id = requestAnimationFrame(paint);
142 const observer = new ResizeObserver(paint);
143 observer.observe(el);
144 return () => {
145 cancelAnimationFrame(id);
146 observer.disconnect();
147 };
148 }, [place, drawn, showColliders, state]);
149
150 const css = `.wall-ring {\n --wall-turn: ${place.turn}deg;\n --wall-room: ${place.room};\n --wall-tile: ${place.tile};\n --wall-x: ${place.x}px;\n --wall-y: ${place.y}px;\n}`;
151
152 return (
153 <div className="tune">
154 <div className="stage wall-stage">
155 <div className="table" ref={table}>
156 <div className="center">
157 <WallRing state={state} />
158 </div>
159 <canvas className="wall-overlay" ref={canvas} />
160 </div>
161 </div>
162
163 <div className="panel">
164 <h1>The wall square</h1>
165 <p className="hint">
166 The real <code>WallRing</code> in a real <code>.center</code>, with every collider the
167 physics would be handed drawn over it in red. Paste the block at the bottom into{' '}
168 <code>styles.css</code>.
169 </p>
170
171 {KNOBS.map((k) => (
172 <label className="knob" key={k.key}>
173 <span>
174 {k.label}
175 <em>{k.note}</em>
176 </span>
177 <input
178 type="range"
179 min={k.min}
180 max={k.max}
181 step={k.step}
182 value={place[k.key]}
183 onChange={(e) => setPlace((p) => ({ ...p, [k.key]: +e.target.value }))}
184 />
185 <output>{place[k.key]}</output>
186 </label>
187 ))}
188
189 <label className="knob">
190 <span>
191 Drawn from it<em>how far into the hand</em>
192 </span>
193 <input
194 type="range"
195 min={0}
196 max={DRAWN.length - 1}
197 step={1}
198 value={DRAWN.indexOf(drawn) < 0 ? 0 : DRAWN.indexOf(drawn)}
199 onChange={(e) => setDrawn(DRAWN[+e.target.value])}
200 />
201 <output>{drawn}</output>
202 </label>
203
204 <label className="knob">
205 <span>
206 Colliders<em>what the physics gets</em>
207 </span>
208 <input
209 type="checkbox"
210 checked={showColliders}
211 onChange={(e) => setShowColliders(e.target.checked)}
212 />
213 <output />
214 </label>
215
216 <p className="hint built">
217 {built.stacks} stacks standing · {built.stacks * 2} tiles ·{' '}
218 {built.gap > 1
219 ? `${Math.round(built.gap)}px of empty side`
220 : 'the sides are full'}
221 </p>
222
223 <div className="row">
224 <button className="btn" onClick={() => setPlace({ ...START })}>
225 Back to the table's
226 </button>
227 <button className="btn" onClick={() => void navigator.clipboard?.writeText(css)}>
228 Copy
229 </button>
230 </div>
231 <pre>{css}</pre>
232 </div>
233 </div>
234 );
235}
236
237createRoot(document.getElementById('root')!).render(<WallTuner />);