collin/mahjong · 81bd92f0
Stand the wall square on its corner, and a page for placing it
Collin Richards · 2026-08-21 20:35 UTC · 81bd92f06dacbd92072c009227f96852fee72b47 · parent 792fe2fd · browse files
modifiedREADME.md+12 −0
| ⋯ 554 unchanged lines | |||
| 555 | 555 | nothing to clip it, so a tile lifted out of a hand crosses the table in one | |
| 556 | 556 | piece. | |
| 557 | 557 | ||
| 558 | + | The square is set at an angle — `--wall-turn` in `styles.css`, 45° — so its four | |
| 559 | + | arms run out towards the corners of the table rather than squaring up with its | |
| 560 | + | edges. That one property is the only place it is decided: `geometry.ts` reads it | |
| 561 | + | back off the element, so the colliders turn with it, and `WallRing` reads it too | |
| 562 | + | so the square is built smaller by however much the turn costs it (stood on its | |
| 563 | + | corner a square needs its diagonal where it used to need its side). Set it to | |
| 564 | + | zero and everything squares up again. `--wall-room`, `--wall-x` and `--wall-y` | |
| 565 | + | put it where you want it, and `npm run dev` serves **`/wall.html`** for turning | |
| 566 | + | all four by hand — the real `WallRing` in a real `.center`, with every collider | |
| 567 | + | the physics would be handed drawn over the top, because "it looks right" and "a | |
| 568 | + | tile would bounce off it there" are two different claims. | |
| 569 | + | ||
| 558 | 570 | Nothing about the square is calculated twice. Its size lives entirely in CSS | |
| 559 | 571 | (`--ws`, `--wd`), so `geometry.ts` **measures** it instead of restating that | |
| 560 | 572 | arithmetic: `WallRing` already renders every stack as a real element tagged with | |
| ⋯ 71 unchanged lines | |||
modifiedsrc/styles.css+25 −2
| ⋯ 661 unchanged lines | |||
| 662 | 662 | `.wall-ring` is the opening, not the outside: the sides hang off its edges, | |
| 663 | 663 | so the square encloses the pool the way it does on a table. */ | |
| 664 | 664 | .wall-ring { | |
| 665 | - | --ws: var(--tile-w); | |
| 665 | + | /* How big a tile in the wall is, against one in a hand. | |
| 666 | + | ||
| 667 | + | A side is only as long as the stacks that fit on it, so this is really the | |
| 668 | + | knob for *how many* tiles the square holds: shrink it and more of them fit, | |
| 669 | + | the sides reach their corners, and the pinwheel closes up. At 1 they are the | |
| 670 | + | same tile and a side runs short, which is where the gaps at the corners come | |
| 671 | + | from. Turned by hand in `/wall.html` like the rest of the square. */ | |
| 672 | + | --wall-tile: 1; | |
| 673 | + | --ws: calc(var(--tile-w) * var(--wall-tile)); | |
| 666 | 674 | --wd: calc(var(--ws) * 1.375); | |
| 675 | + | /* How far round the whole square is set. Four walls built the same way round | |
| 676 | + | are already a pinwheel; standing it on its corner is what sends the arms | |
| 677 | + | out at the corners of the table instead of squaring up with its edges. | |
| 678 | + | ||
| 679 | + | The one place this is decided: `table/geometry.ts` reads it back off the | |
| 680 | + | element, so the colliders turn with it and nothing has to be kept in step | |
| 681 | + | by hand. Zero squares it up again. */ | |
| 682 | + | --wall-turn: 45deg; | |
| 683 | + | /* How much of the room it has the square actually takes, and where its middle | |
| 684 | + | sits relative to the middle of the table. All three are turned by hand in | |
| 685 | + | `/wall.html` and pasted back here. */ | |
| 686 | + | --wall-room: 0.95; | |
| 687 | + | --wall-x: 0px; | |
| 688 | + | --wall-y: 0px; | |
| 667 | 689 | position: absolute; | |
| 668 | 690 | top: 50%; | |
| 669 | 691 | left: 50%; | |
| 670 | 692 | width: 0; | |
| 671 | 693 | height: 0; | |
| 672 | - | transform: translate(-50%, -50%); | |
| 694 | + | transform: translate(-50%, -50%) translate(var(--wall-x), var(--wall-y)) | |
| 695 | + | rotate(var(--wall-turn)); | |
| 673 | 696 | pointer-events: none; | |
| 674 | 697 | z-index: 0; | |
| 675 | 698 | } | |
| ⋯ 1622 unchanged lines | |||
modifiedsrc/table/geometry.ts+59 −14
| ⋯ 40 unchanged lines | |||
| 41 | 41 | * The throw gate's business — what the physics collides with is `barriers`, | |
| 42 | 42 | * which includes these. | |
| 43 | 43 | */ | |
| 44 | - | walls: Rect[]; | |
| 44 | + | walls: Barrier[]; | |
| 45 | 45 | /** | |
| 46 | 46 | * Everything solid, which is to say every tile on the table: the standing | |
| 47 | 47 | * stacks, the sets people have laid down, and the sixteen each of them is | |
| ⋯ 33 unchanged lines | |||
| 81 | 81 | }); | |
| 82 | 82 | ||
| 83 | 83 | const stacks = wallStacks(state); | |
| 84 | - | const walls: Rect[] = []; | |
| 84 | + | const walls: Barrier[] = []; | |
| 85 | 85 | const barriers: Barrier[] = []; | |
| 86 | 86 | // A stack element is --ws by --wd (or the other way round on the left and | |
| 87 | 87 | // right sides), so its narrower side is the tile's width whichever wall it is | |
| ⋯ 1 unchanged line | |||
| 89 | 89 | // The probe is one cell that is always there, spent wall or not. | |
| 90 | 90 | const probe = ring.querySelector<HTMLElement>('.wall-probe')?.getBoundingClientRect(); | |
| 91 | 91 | let short = probe ? Math.min(probe.width, probe.height) : 0; | |
| 92 | + | // The whole square is set at an angle — see `--wall-turn` in styles.css — so | |
| 93 | + | // a stack's box is not what `getBoundingClientRect` reports, which is only the | |
| 94 | + | // upright box *around* the turned one. What is true either way is the centre | |
| 95 | + | // it reports and the size the layout gave it, and those two plus the angle are | |
| 96 | + | // the stack itself. | |
| 97 | + | const turn = wallTurn(ring); | |
| 92 | 98 | for (const el of ring.querySelectorAll<HTMLElement>('[data-stack]')) { | |
| 93 | 99 | const i = Number(el.dataset.stack); | |
| 94 | - | const r = local(el.getBoundingClientRect()); | |
| 100 | + | const around = local(el.getBoundingClientRect()); | |
| 101 | + | const r: Rect = { | |
| 102 | + | x: around.x + around.w / 2 - el.offsetWidth / 2, | |
| 103 | + | y: around.y + around.h / 2 - el.offsetHeight / 2, | |
| 104 | + | w: el.offsetWidth, | |
| 105 | + | h: el.offsetHeight, | |
| 106 | + | }; | |
| 95 | 107 | short = Math.max(short, Math.min(r.w, r.h)); | |
| 96 | 108 | const stack = stacks[i]; | |
| 97 | 109 | if (stack && isBarrier(stack)) { | |
| 98 | - | walls.push(r); | |
| 99 | - | barriers.push({ rect: r }); | |
| 110 | + | walls.push({ rect: r, angle: turn }); | |
| 111 | + | barriers.push({ rect: r, angle: turn }); | |
| 100 | 112 | } | |
| 101 | 113 | } | |
| 102 | 114 | if (short === 0) return null; | |
| ⋯ 1 unchanged line | |||
| 104 | 116 | // `.wall-ring` is the opening itself — the walls hang off its edges — so the | |
| 105 | 117 | // pool is simply its box. (It used to be the wall's outside, which is why this | |
| 106 | 118 | // had to subtract the wall's own thickness.) | |
| 107 | - | const ringRect = local(ring.getBoundingClientRect()); | |
| 108 | - | const pool: Rect = { | |
| 109 | - | x: ringRect.x, | |
| 110 | - | y: ringRect.y, | |
| 111 | - | w: Math.max(short, ringRect.w), | |
| 112 | - | h: Math.max(short, ringRect.h), | |
| 119 | + | // | |
| 120 | + | // Taken at the size the layout gave it rather than the size it covers once | |
| 121 | + | // turned: the opening is the same opening whichever way round the square is | |
| 122 | + | // standing, and this is only ever where a *tapped* discard is aimed. A flick | |
| 123 | + | // is not aimed at anything — it goes where it was thrown. | |
| 124 | + | const around = local(ring.getBoundingClientRect()); | |
| 125 | + | const openW = Math.max(short, ring.offsetWidth); | |
| 126 | + | const openH = Math.max(short, ring.offsetHeight); | |
| 127 | + | const ringRect: Rect = { | |
| 128 | + | x: around.x + around.w / 2 - openW / 2, | |
| 129 | + | y: around.y + around.h / 2 - openH / 2, | |
| 130 | + | w: openW, | |
| 131 | + | h: openH, | |
| 113 | 132 | }; | |
| 133 | + | const pool: Rect = ringRect; | |
| 114 | 134 | ||
| 115 | 135 | const launch: Partial<Record<SeatId, Point>> = {}; | |
| 116 | 136 | const throwFrom: Partial<Record<SeatId, Point>> = {}; | |
| ⋯ 64 unchanged lines | |||
| 181 | 201 | return { w, h: w * 1.375 }; | |
| 182 | 202 | } | |
| 183 | 203 | ||
| 204 | + | /** | |
| 205 | + | * How far round the wall square is set, in radians. | |
| 206 | + | * | |
| 207 | + | * Read off the element rather than written down here, the same way everything | |
| 208 | + | * else about the wall is: `--wall-turn` in styles.css is the one place it is | |
| 209 | + | * decided, and turning it there turns the colliders with it. | |
| 210 | + | */ | |
| 211 | + | function wallTurn(ring: HTMLElement): number { | |
| 212 | + | const deg = parseFloat(getComputedStyle(ring).getPropertyValue('--wall-turn')); | |
| 213 | + | return Number.isFinite(deg) ? (deg * Math.PI) / 180 : 0; | |
| 214 | + | } | |
| 215 | + | ||
| 184 | 216 | export const centreOf = (r: Rect): Point => ({ x: r.x + r.w / 2, y: r.y + r.h / 2 }); | |
| 185 | 217 | ||
| 186 | 218 | /** | |
| ⋯ 76 unchanged lines | |||
| 263 | 295 | } | |
| 264 | 296 | ||
| 265 | 297 | /** Segment against every standing stack, by the slab method. */ | |
| 266 | - | function clearLine(walls: Rect[], from: Point, to: Point): boolean { | |
| 267 | - | return !walls.some((w) => segmentHitsRect(from, to, w)); | |
| 298 | + | function clearLine(walls: Barrier[], from: Point, to: Point): boolean { | |
| 299 | + | return !walls.some((w) => segmentHitsRect(from, to, w.rect, w.angle)); | |
| 268 | 300 | } | |
| 269 | 301 | ||
| 270 | - | export function segmentHitsRect(a: Point, b: Point, r: Rect): boolean { | |
| 302 | + | export function segmentHitsRect(a: Point, b: Point, r: Rect, angle = 0): boolean { | |
| 303 | + | // A turned box is the same box asked from where it is standing: put the | |
| 304 | + | // segment into its frame and the slabs are axis-aligned again. | |
| 305 | + | if (angle) { | |
| 306 | + | const cx = r.x + r.w / 2; | |
| 307 | + | const cy = r.y + r.h / 2; | |
| 308 | + | const c = Math.cos(-angle); | |
| 309 | + | const s = Math.sin(-angle); | |
| 310 | + | const into = (p: Point): Point => ({ | |
| 311 | + | x: cx + (p.x - cx) * c - (p.y - cy) * s, | |
| 312 | + | y: cy + (p.x - cx) * s + (p.y - cy) * c, | |
| 313 | + | }); | |
| 314 | + | return segmentHitsRect(into(a), into(b), r); | |
| 315 | + | } | |
| 271 | 316 | const dx = b.x - a.x; | |
| 272 | 317 | const dy = b.y - a.y; | |
| 273 | 318 | let t0 = 0; | |
| ⋯ 16 unchanged lines | |||
modifiedsrc/table/physics.test.ts+1 −1
| ⋯ 455 unchanged lines | |||
| 456 | 456 | return { | |
| 457 | 457 | size: { w: 620, h: 620 }, | |
| 458 | 458 | pool: POOL, | |
| 459 | - | walls, | |
| 459 | + | walls: walls.map((rect) => ({ rect })), | |
| 460 | 460 | barriers: walls.map((rect) => ({ rect })), | |
| 461 | 461 | tile: TILE, | |
| 462 | 462 | felt: { x: 120, y: 120, w: 380, h: 380 }, | |
| ⋯ 210 unchanged lines | |||
modifiedsrc/table/physics.ts+7 −4
| ⋯ 228 unchanged lines | |||
| 229 | 229 | */ | |
| 230 | 230 | export interface Barrier { | |
| 231 | 231 | rect: Rect; | |
| 232 | + | /** Turned, where it is turned: the wall is built as a pinwheel and the whole | |
| 233 | + | * square is set at an angle, so its stacks are not axis-aligned boxes. */ | |
| 234 | + | angle?: number; | |
| 232 | 235 | seat?: SeatId; | |
| 233 | 236 | } | |
| 234 | 237 | ||
| ⋯ 146 unchanged lines | |||
| 381 | 384 | } | |
| 382 | 385 | ||
| 383 | 386 | /** A standing stack, as something the tests above can chew on. */ | |
| 384 | - | const boxOf = (r: Rect): Box => ({ | |
| 387 | + | const boxOf = (r: Rect, angle = 0): Box => ({ | |
| 385 | 388 | x: r.x + r.w / 2, | |
| 386 | 389 | y: r.y + r.h / 2, | |
| 387 | 390 | w: r.w, | |
| 388 | 391 | h: r.h, | |
| 389 | - | angle: 0, | |
| 392 | + | angle, | |
| 390 | 393 | }); | |
| 391 | 394 | ||
| 392 | 395 | // ---- stepping ------------------------------------------------------------- | |
| ⋯ 173 unchanged lines | |||
| 566 | 569 | for (const wall of world.walls) { | |
| 567 | 570 | if (wall.seat === b.seat && !b.leftSeat) { | |
| 568 | 571 | // Still inside the row it was thrown from: note it, but pass through. | |
| 569 | - | const over = overlapOf(boxOf(wall.rect), b); | |
| 572 | + | const over = overlapOf(boxOf(wall.rect, wall.angle), b); | |
| 570 | 573 | if (over && over.depth > CONTACT_SLOP) inOwnHand = true; | |
| 571 | 574 | continue; | |
| 572 | 575 | } | |
| ⋯ 76 unchanged lines | |||
| 649 | 652 | /** Bounce off whatever is standing there, corner to corner if that is how it | |
| 650 | 653 | * hits — a stack of the wall, a laid-down set, somebody's hand. */ | |
| 651 | 654 | function hitWall(b: Body, wall: Barrier, impacts: Impact[]) { | |
| 652 | - | const hit = overlapOf(boxOf(wall.rect), b); | |
| 655 | + | const hit = overlapOf(boxOf(wall.rect, wall.angle), b); | |
| 653 | 656 | if (!hit || hit.depth <= CONTACT_SLOP) return; | |
| 654 | 657 | ||
| 655 | 658 | const speed = Math.hypot(b.vx, b.vy); | |
| ⋯ 339 unchanged lines | |||
modifiedsrc/tune.css+34 −0
| ⋯ 142 unchanged lines | |||
| 143 | 143 | white-space: pre-wrap; | |
| 144 | 144 | opacity: 0.85; | |
| 145 | 145 | } | |
| 146 | + | ||
| 147 | + | /* ---------- the wall square's page ---------- */ | |
| 148 | + | /* A patch of table to stand the square in, and a canvas over it for the | |
| 149 | + | colliders. The felt is the game's own, since that is what it will sit on. */ | |
| 150 | + | .wall-stage { | |
| 151 | + | display: flex; | |
| 152 | + | align-items: center; | |
| 153 | + | justify-content: center; | |
| 154 | + | padding: 24px; | |
| 155 | + | } | |
| 156 | + | .wall-stage .table { | |
| 157 | + | position: relative; | |
| 158 | + | width: min(76vw, 900px); | |
| 159 | + | height: min(76vh, 720px); | |
| 160 | + | border-radius: 10px; | |
| 161 | + | overflow: hidden; | |
| 162 | + | } | |
| 163 | + | /* The middle is the whole of it here: there are no seats to leave room for, and | |
| 164 | + | the square is the only thing being looked at. */ | |
| 165 | + | .wall-stage .center { | |
| 166 | + | inset: 0; | |
| 167 | + | } | |
| 168 | + | .wall-overlay { | |
| 169 | + | position: absolute; | |
| 170 | + | inset: 0; | |
| 171 | + | pointer-events: none; | |
| 172 | + | } | |
| 173 | + | /* What the square came out as, under the sliders that decided it. */ | |
| 174 | + | .panel .built { | |
| 175 | + | margin: 12px 0 0; | |
| 176 | + | font-size: 12px; | |
| 177 | + | opacity: 0.75; | |
| 178 | + | color: var(--gold); | |
| 179 | + | } | |
addedsrc/tuneWall.tsx+237 −0
| 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 />); |
modifiedsrc/ui/Pool.tsx+12 −1
| ⋯ 389 unchanged lines | |||
| 390 | 390 | // The table's own edge, and every tile standing on it. | |
| 391 | 391 | ctx.strokeStyle = '#ff2d20'; | |
| 392 | 392 | line(world.bounds); | |
| 393 | - | for (const w of world.walls) line(w.rect); | |
| 393 | + | for (const w of world.walls) { | |
| 394 | + | if (!w.angle) { | |
| 395 | + | line(w.rect); | |
| 396 | + | continue; | |
| 397 | + | } | |
| 398 | + | // Turned, so it is drawn turned — the wall square stands on its corner. | |
| 399 | + | ctx.save(); | |
| 400 | + | ctx.translate(w.rect.x + w.rect.w / 2, w.rect.y + w.rect.h / 2); | |
| 401 | + | ctx.rotate(w.angle); | |
| 402 | + | ctx.strokeRect(-w.rect.w / 2, -w.rect.h / 2, w.rect.w, w.rect.h); | |
| 403 | + | ctx.restore(); | |
| 404 | + | } | |
| 394 | 405 | ||
| 395 | 406 | // Where a throw is aimed. Not a fence — see TableGeometry.pool. | |
| 396 | 407 | const aim = pool.poolRect; | |
| ⋯ 85 unchanged lines | |||
modifiedsrc/ui/WallRing.tsx+22 −3
| ⋯ 5 unchanged lines | |||
| 6 | 6 | WALL_SIDES, | |
| 7 | 7 | wallStacks, | |
| 8 | 8 | type RingCapacity, | |
| 9 | + | type WallProgress, | |
| 9 | 10 | } from '../game/wall'; | |
| 10 | - | import type { GameState } from '../game/engine'; | |
| 11 | + | ||
| 11 | 12 | import { useT } from './lang'; | |
| 12 | 13 | ||
| 13 | 14 | /** | |
| ⋯ 11 unchanged lines | |||
| 25 | 26 | * measures these elements to build the colliders rather than restating the CSS | |
| 26 | 27 | * that sizes them. | |
| 27 | 28 | */ | |
| 28 | - | export function WallRing({ state }: { state: GameState }) { | |
| 29 | + | export function WallRing({ state }: { state: WallProgress }) { | |
| 29 | 30 | const t = useT(); | |
| 30 | 31 | const ring = useRef<HTMLDivElement>(null); | |
| 31 | 32 | const { cap, opening } = useSquare(ring); | |
| ⋯ 54 unchanged lines | |||
| 86 | 87 | const box = probe.getBoundingClientRect(); | |
| 87 | 88 | const cell = { s: box.width, d: box.height }; | |
| 88 | 89 | if (cell.s <= 0) return; | |
| 89 | - | const cap = ringFor({ w: middle.clientWidth, h: middle.clientHeight }, cell); | |
| 90 | + | // Stood on its corner, a square needs its *diagonal* where it used to | |
| 91 | + | // need its side — so the room it has to be built in shrinks by however | |
| 92 | + | // much the turn costs. Square on that is 1 and nothing changes; at 45° it | |
| 93 | + | // is √2 and the square comes out about seven tenths the size, which is | |
| 94 | + | // what keeps its arms on the table rather than off the edge of it. | |
| 95 | + | const style = getComputedStyle(el); | |
| 96 | + | const turn = (parseFloat(style.getPropertyValue('--wall-turn')) || 0) * (Math.PI / 180); | |
| 97 | + | const spread = Math.abs(Math.cos(turn)) + Math.abs(Math.sin(turn)); | |
| 98 | + | const room = parseFloat(style.getPropertyValue('--wall-room')) || 1; | |
| 99 | + | // The walls hang *outside* the opening, one depth on each side, and | |
| 100 | + | // `ringFor` only expects to be told about one of them. Turned, the other | |
| 101 | + | // one is what runs off the table, so it comes off here. | |
| 102 | + | const cap = ringFor( | |
| 103 | + | { | |
| 104 | + | w: (middle.clientWidth * room) / spread - cell.d, | |
| 105 | + | h: (middle.clientHeight * room) / spread - cell.d, | |
| 106 | + | }, | |
| 107 | + | cell, | |
| 108 | + | ); | |
| 90 | 109 | const opening = ringOpening(cap, cell); | |
| 91 | 110 | setSquare((was) => | |
| 92 | 111 | was.cap.h === cap.h && | |
| ⋯ 19 unchanged lines | |||
addedwall.html+13 −0
| 1 | + | <!doctype html> | |
| 2 | + | <html lang="en" translate="no"> | |
| 3 | + | <head> | |
| 4 | + | <meta charset="UTF-8" /> | |
| 5 | + | <meta name="google" content="notranslate" /> | |
| 6 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| 7 | + | <title>The wall square</title> | |
| 8 | + | </head> | |
| 9 | + | <body> | |
| 10 | + | <div id="root"></div> | |
| 11 | + | <script type="module" src="/src/tuneWall.tsx"></script> | |
| 12 | + | </body> | |
| 13 | + | </html> |