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