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: 0, max: 0.95, step: 0.05, note: 'tiles of daylight', 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
59 // Stable across renders: the measuring effect keys off it, and a fresh object
60 // every render would have it re-measuring for ever.
61 const state = useMemo<WallProgress>(
62 () => ({ drawnFront: drawn, drawnBack: 0, rules: { wallReserve: 16 } }),
63 [drawn],
64 );
65
66 // On the ring itself rather than a stylesheet, so this page and the table are
67 // reading the very same custom properties.
68 useLayoutEffect(() => {
69 const ring = table.current?.querySelector<HTMLElement>('.wall-ring');
70 if (!ring) return;
71 ring.style.setProperty('--wall-turn', `${place.turn}`);
72 ring.style.setProperty('--wall-angle', `${place.angle}`);
73 ring.style.setProperty('--wall-gap', `${place.gap}`);
74 }, [place]);
75
76 // Then measure what came out and draw it, which is the whole point.
77 useEffect(() => {
78 const el = table.current;
79 const cv = canvas.current;
80 const ctx = cv?.getContext('2d');
81 if (!el || !cv || !ctx) return;
82
83 const paint = () => {
84 const dpr = window.devicePixelRatio || 1;
85 cv.width = Math.round(el.clientWidth * dpr);
86 cv.height = Math.round(el.clientHeight * dpr);
87 cv.style.width = `${el.clientWidth}px`;
88 cv.style.height = `${el.clientHeight}px`;
89 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
90 ctx.clearRect(0, 0, el.clientWidth, el.clientHeight);
91 if (!showColliders) return;
92
93 const geo = measure(el, state);
94 if (!geo) return;
95
96 // How many stacks are standing, and the most room any one side has left
97 // over — which is the gap you can see at the corners, since a side packs
98 // its stacks to the end it was built from.
99 const all = [...el.querySelectorAll<HTMLElement>('[data-stack]')];
100 const sides = [...el.querySelectorAll<HTMLElement>('.wall-side')];
101 let gap = 0;
102 for (const side of sides) {
103 const kids = [...side.querySelectorAll<HTMLElement>('[data-stack]')];
104 if (!kids.length) continue;
105 const along = side.classList.contains('left') || side.classList.contains('right');
106 const room = along ? side.clientHeight : side.clientWidth;
107 const used = kids.reduce((n, k) => n + (along ? k.offsetHeight : k.offsetWidth), 0);
108 gap = Math.max(gap, room - used);
109 }
110 setBuilt((was) =>
111 was.stacks === all.length && Math.round(was.gap) === Math.round(gap)
112 ? was
113 : { stacks: all.length, gap },
114 );
115
116 ctx.lineWidth = 1;
117 ctx.strokeStyle = '#ff2d20';
118 for (const w of geo.walls) {
119 ctx.save();
120 ctx.translate(w.rect.x + w.rect.w / 2, w.rect.y + w.rect.h / 2);
121 ctx.rotate(w.angle ?? 0);
122 ctx.strokeRect(-w.rect.w / 2, -w.rect.h / 2, w.rect.w, w.rect.h);
123 ctx.restore();
124 }
125
126 // Where a *tapped* discard is aimed. Not a fence, and not turned with the
127 // square — which is the one thing on this page that is an approximation.
128 ctx.setLineDash([5, 5]);
129 ctx.strokeStyle = 'rgba(255, 45, 32, 0.45)';
130 ctx.strokeRect(geo.pool.x + 0.5, geo.pool.y + 0.5, geo.pool.w - 1, geo.pool.h - 1);
131 ctx.setLineDash([]);
132 };
133
134 // After the layout has caught up with the properties just set.
135 const id = requestAnimationFrame(paint);
136 const observer = new ResizeObserver(paint);
137 observer.observe(el);
138 // A wall has been pushed along its line. Nothing it is measured against has
139 // changed size, so this is the only way to hear about it — the same event
140 // the pool listens for, which is what keeps the colliders on the tiles.
141 const moved = () => requestAnimationFrame(paint);
142 el.addEventListener('wallmoved', moved);
143 return () => {
144 cancelAnimationFrame(id);
145 el.removeEventListener('wallmoved', moved);
146 observer.disconnect();
147 };
148 }, [place, drawn, showColliders, state]);
149
150 const css = `.wall-ring {\n --wall-angle: ${place.angle};\n --wall-turn: ${place.turn};\n --wall-gap: ${place.gap};\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 />);