| 1 | import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; |
| 2 | import { createRoot } from 'react-dom/client'; |
| 3 | import { measure } from './table/geometry'; |
| 4 | import type { WallProgress } from './game/wall'; |
| 5 | import { WallRing } from './ui/WallRing'; |
| 6 | import './styles.css'; |
| 7 | import './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 | |
| 22 | interface 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 | |
| 32 | interface Placing { |
| 33 | turn: number; |
| 34 | room: number; |
| 35 | tile: number; |
| 36 | x: number; |
| 37 | y: number; |
| 38 | } |
| 39 | |
| 40 | const START: Placing = { turn: 45, room: 0.95, tile: 1, x: 0, y: 0 }; |
| 41 | |
| 42 | const 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. */ |
| 51 | const DRAWN = [0, 16, 32, 48, 64, 80, 100, 120]; |
| 52 | |
| 53 | function 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 | |
| 237 | createRoot(document.getElementById('root')!).render(<WallTuner />); |