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