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 lap: number;
36}
37
38const START: Placing = { turn: 0, angle: 90, lap: 1 };
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: 'lap', label: 'Overlap', min: 1, max: 4, step: 0.05, note: '× what the corner needs', 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-lap', `${place.lap}`);
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 return () => {
139 cancelAnimationFrame(id);
140 observer.disconnect();
141 };
142 }, [place, drawn, showColliders, state]);
143
144 const css = `.wall-ring {\n --wall-angle: ${place.angle};\n --wall-turn: ${place.turn};\n --wall-lap: ${place.lap};\n}`;
145
146 return (
147 <div className="tune">
148 <div className="stage wall-stage">
149 <div className="table" ref={table}>
150 <div className="center">
151 <WallRing state={state} />
152 </div>
153 <canvas className="wall-overlay" ref={canvas} />
154 </div>
155 </div>
156
157 <div className="panel">
158 <h1>The wall square</h1>
159 <p className="hint">
160 The real <code>WallRing</code> in a real <code>.center</code>, with every collider the
161 physics would be handed drawn over it in red. Paste the block at the bottom into{' '}
162 <code>styles.css</code>.
163 </p>
164
165 {KNOBS.map((k) => (
166 <label className="knob" key={k.key}>
167 <span>
168 {k.label}
169 <em>{k.note}</em>
170 </span>
171 <input
172 type="range"
173 min={k.min}
174 max={k.max}
175 step={k.step}
176 value={place[k.key]}
177 onChange={(e) => setPlace((p) => ({ ...p, [k.key]: +e.target.value }))}
178 />
179 <output>{place[k.key]}</output>
180 </label>
181 ))}
182
183 <label className="knob">
184 <span>
185 Drawn from it<em>how far into the hand</em>
186 </span>
187 <input
188 type="range"
189 min={0}
190 max={DRAWN.length - 1}
191 step={1}
192 value={DRAWN.indexOf(drawn) < 0 ? 0 : DRAWN.indexOf(drawn)}
193 onChange={(e) => setDrawn(DRAWN[+e.target.value])}
194 />
195 <output>{drawn}</output>
196 </label>
197
198 <label className="knob">
199 <span>
200 Colliders<em>what the physics gets</em>
201 </span>
202 <input
203 type="checkbox"
204 checked={showColliders}
205 onChange={(e) => setShowColliders(e.target.checked)}
206 />
207 <output />
208 </label>
209
210 <p className="hint built">
211 {built.stacks} stacks standing · {built.stacks * 2} tiles ·{' '}
212 {built.gap > 1
213 ? `${Math.round(built.gap)}px of empty side`
214 : 'the sides are full'}
215 </p>
216
217 <div className="row">
218 <button className="btn" onClick={() => setPlace({ ...START })}>
219 Back to the table's
220 </button>
221 <button className="btn" onClick={() => void navigator.clipboard?.writeText(css)}>
222 Copy
223 </button>
224 </div>
225 <pre>{css}</pre>
226 </div>
227 </div>
228 );
229}
230
231createRoot(document.getElementById('root')!).render(<WallTuner />);