| 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 | angle: number; |
| 35 | gap: number; |
| 36 | } |
| 37 | |
| 38 | const START: Placing = { turn: 0, angle: 90, gap: 0 }; |
| 39 | |
| 40 | const 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. */ |
| 47 | const DRAWN = [0, 16, 32, 48, 64, 80, 100, 120]; |
| 48 | |
| 49 | function 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 | 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-gap: ${place.gap};\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 | |
| 231 | createRoot(document.getElementById('root')!).render(<WallTuner />); |