collin/mahjong · 9737a8f7
A square wants no rotation to describe it
Collin Richards · 2026-08-25 07:09 UTC · 9737a8f7121abe0b6a7edcbf34824f3859d60603 · parent 9879b92d · browse files
modifiedREADME.md+10 −3
| ⋯ 591 unchanged lines | |||
| 592 | 592 | described with them. Each stack carries its own `data-angle`, so `geometry.ts` | |
| 593 | 593 | hands the physics a turned box rather than trying to recover the angle from a | |
| 594 | 594 | bounding rect, which cannot say. `--wall-angle`, `--wall-turn` and `--wall-lap` | |
| 595 | - | are the three numbers, and `npm run dev` serves **`/wall.html`** for turning | |
| 596 | - | them by hand — the real `WallRing` in a real `.center`, with every collider the | |
| 597 | - | physics would be handed drawn over the top. | |
| 595 | + | are the three numbers. | |
| 596 | + | ||
| 597 | + | Where the four of them **stand** is found at the table itself rather than on a | |
| 598 | + | page of sliders: take hold of a wall in a real game and push it. It moves along | |
| 599 | + | its own line and off it, both walls' worth of freedom, and lets go saying where | |
| 600 | + | it landed — `[wall] top pushed to 12,-40 — top 12,-40 · right 0,0 · …` in the | |
| 601 | + | console, and the same numbers on `.wall-ring[data-nudged]` for reading back off | |
| 602 | + | the DOM. Nothing about the hand moves: the wall is the same wall in the same | |
| 603 | + | order and the next draw is still the next draw. There used to be a `/wall.html` | |
| 604 | + | for this; the table is the honest place to do it, so that page is gone. | |
| 598 | 605 | ||
| 599 | 606 | Nothing about the square is calculated twice. Its size lives entirely in CSS | |
| 600 | 607 | (`--ws`, `--wd`), so `geometry.ts` **measures** it instead of restating that | |
| ⋯ 72 unchanged lines | |||
modifiedsrc/game/wall.ts+27 −115
| ⋯ 101 unchanged lines | |||
| 102 | 102 | * | |
| 103 | 103 | * What gives instead is the tile. A wall tile is not a hand tile — on a table it | |
| 104 | 104 | * is the same tile, but on a screen the wall has to fit the middle and the hand | |
| 105 | - | * has to be readable, and those are two different jobs. See `rhombus`. | |
| 105 | + | * has to be readable, and those are two different jobs. See `wallSquare`. | |
| 106 | 106 | */ | |
| 107 | 107 | export const RING: RingCapacity = { h: STACKS_PER_SIDE, v: STACKS_PER_SIDE }; | |
| 108 | 108 | ||
| 109 | - | /** One wall, placed: where its own near corner sits from the middle of the | |
| 110 | - | * ring, and which way it runs. Degrees clockwise from east, as CSS turns. */ | |
| 111 | - | export interface WallPlace { | |
| 112 | - | x: number; | |
| 113 | - | y: number; | |
| 114 | - | dir: number; | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | export interface Rhombus { | |
| 118 | - | places: WallPlace[]; | |
| 119 | - | /** The whole thing's footprint, walls included — what has to fit the middle. */ | |
| 120 | - | box: { w: number; h: number }; | |
| 121 | - | /** The daylight actually left at a corner, along the edge. Under a tile. */ | |
| 122 | - | gap: number; | |
| 123 | - | /** The opening left in the middle of it, as a box round the four corners. */ | |
| 124 | - | open: { x: number; y: number; w: number; h: number }; | |
| 109 | + | /** The square, in pixels, once the room it has to fit has been measured. */ | |
| 110 | + | export interface WallSquare { | |
| 111 | + | /** A stack's short side — the wall's own tile width. */ | |
| 112 | + | cell: number; | |
| 113 | + | /** One wall, end to end: eighteen stacks. */ | |
| 114 | + | len: number; | |
| 115 | + | /** How deep a wall stands, which is a stack's long side. */ | |
| 116 | + | thick: number; | |
| 117 | + | /** The opening left in the middle: where the discards go. */ | |
| 118 | + | open: number; | |
| 119 | + | /** What the whole thing takes up, walls included. */ | |
| 120 | + | outer: number; | |
| 125 | 121 | } | |
| 126 | - | ||
| 127 | - | const rad = (deg: number) => (deg * Math.PI) / 180; | |
| 128 | 122 | ||
| 129 | 123 | /** | |
| 130 | - | * Where the four walls stand. | |
| 131 | - | * | |
| 132 | - | * A rhombus of any corner angle, which at ninety degrees is the square everyone | |
| 133 | - | * knows. Four walls of the same eighteen stacks, each pinned at the corner it is | |
| 134 | - | * built from and running its whole length from there — which carries it past the | |
| 135 | - | * far corner, over the outside of the next wall along. That overhang is the | |
| 136 | - | * pinwheel, and it is the reason the corners are covered rather than mitred. | |
| 137 | - | * | |
| 138 | - | * How far a wall has to overrun to *close* the corner it is turning depends on | |
| 139 | - | * that corner: two bands of thickness `t` meeting at an interior angle ψ overlap | |
| 140 | - | * by `t / tan(ψ/2)` along the edge. At ninety degrees that is exactly one | |
| 141 | - | * wall-depth, which is where the square's own arithmetic came from. A rhombus | |
| 142 | - | * has two different corner angles, so the four walls would each want a different | |
| 143 | - | * overrun and cannot have one, being all the same eighteen stacks long — so all | |
| 144 | - | * four take the *sharper* corner's, and the shallow corners are the ones that | |
| 145 | - | * lap further than they strictly need. | |
| 124 | + | * How big to build the square, given the room across the table it has to fit. | |
| 146 | 125 | * | |
| 147 | - | * That is the *closed* corner, and a real one is not closed. Walls get pushed up | |
| 148 | - | * against each other by hand and there is always a little daylight left where | |
| 149 | - | * they meet, so `gap` takes some of the overrun back — measured in tiles, since | |
| 150 | - | * that is what anybody looking at it would measure it in, and held to less than | |
| 151 | - | * one of them. A whole tile of daylight is a wall that has stopped meeting the | |
| 152 | - | * next; anything under that is a corner. | |
| 126 | + | * Four walls of eighteen stacks, laid down square and nothing turned. Each is | |
| 127 | + | * pinned at the corner it is built from and runs its whole length from there, | |
| 128 | + | * which carries it one wall-depth past the far corner and over the end of the | |
| 129 | + | * next wall along — the same way round each time. That is the pinwheel, and it | |
| 130 | + | * is why the corners are covered rather than mitred. | |
| 153 | 131 | * | |
| 154 | - | * Everything is linear in `len` and `thick`, so the caller can solve for the | |
| 155 | - | * tile by asking once at a size of one and scaling the answer. | |
| 132 | + | * So the whole thing measures a wall's length *plus* one depth across, and | |
| 133 | + | * leaves that length *less* one depth in the middle. Solved the other way here: | |
| 134 | + | * the room is what there is, and the tile is what gives. | |
| 156 | 135 | */ | |
| 157 | - | export function rhombus( | |
| 158 | - | len: number, | |
| 159 | - | thick: number, | |
| 160 | - | angle: number, | |
| 161 | - | turn = 0, | |
| 162 | - | gap = 0, | |
| 163 | - | ): Rhombus { | |
| 164 | - | // Interior angles alternate round a rhombus and come to a straight line. | |
| 165 | - | const a = Math.min(179, Math.max(1, angle)); | |
| 166 | - | const sharp = Math.min(a, 180 - a); | |
| 167 | - | // What it takes to close the corner, less the daylight asked for. Under a | |
| 168 | - | // tile of it, always: a whole one and the walls have stopped meeting. | |
| 169 | - | // | |
| 170 | - | // Negative is the other way — the walls laid further over each other than the | |
| 171 | - | // corner needs, which pulls the four corners in and takes the opening down | |
| 172 | - | // with them. That is the only lever on how much middle the square leaves, | |
| 173 | - | // since its sides are eighteen stacks whatever else happens. | |
| 174 | - | const tile = len / STACKS_PER_SIDE; | |
| 175 | - | const shut = thick / Math.tan(rad(sharp) / 2); | |
| 176 | - | const lap = Math.max(0, shut - Math.min(0.99, Math.max(-STACKS_PER_SIDE / 3, gap)) * tile); | |
| 177 | - | // Corner to corner. The wall is longer than this by the overrun. | |
| 178 | - | const edge = Math.max(thick, len - lap); | |
| 179 | - | ||
| 180 | - | // Two pairs of parallel sides: turning by `a` then by `180 - a`, twice round. | |
| 181 | - | const dirs = [turn, turn + a, turn + 180, turn + 180 + a]; | |
| 182 | - | const u = (deg: number) => ({ x: Math.cos(rad(deg)), y: Math.sin(rad(deg)) }); | |
| 183 | - | ||
| 184 | - | // Corners, hung so the middle of them lands on the middle of the ring. | |
| 185 | - | const u0 = u(dirs[0]); | |
| 186 | - | const u1 = u(dirs[1]); | |
| 187 | - | const corners = [{ x: (-edge * (u0.x + u1.x)) / 2, y: (-edge * (u0.y + u1.y)) / 2 }]; | |
| 188 | - | corners.push({ x: corners[0].x + edge * u0.x, y: corners[0].y + edge * u0.y }); | |
| 189 | - | corners.push({ x: corners[1].x + edge * u1.x, y: corners[1].y + edge * u1.y }); | |
| 190 | - | corners.push({ x: corners[2].x - edge * u0.x, y: corners[2].y - edge * u0.y }); | |
| 191 | - | ||
| 192 | - | const places: WallPlace[] = []; | |
| 193 | - | const xs: number[] = []; | |
| 194 | - | const ys: number[] = []; | |
| 195 | - | for (let i = 0; i < 4; i++) { | |
| 196 | - | const dir = dirs[i]; | |
| 197 | - | const along = u(dir); | |
| 198 | - | // A box laid down at an angle grows along its own +y, which is its | |
| 199 | - | // direction turned a quarter clockwise. Whichever way that points, the wall | |
| 200 | - | // has to end up on the *outside*, so it is hung off the far side when it | |
| 201 | - | // does not. | |
| 202 | - | const nx = -Math.sin(rad(dir)); | |
| 203 | - | const ny = Math.cos(rad(dir)); | |
| 204 | - | const mid = { x: corners[i].x + (edge / 2) * along.x, y: corners[i].y + (edge / 2) * along.y }; | |
| 205 | - | const outward = nx * mid.x + ny * mid.y >= 0; | |
| 206 | - | const x = corners[i].x + (outward ? 0 : -thick * nx); | |
| 207 | - | const y = corners[i].y + (outward ? 0 : -thick * ny); | |
| 208 | - | places.push({ x, y, dir }); | |
| 209 | - | // Its four corners, for the footprint. | |
| 210 | - | for (const [dl, dt] of [[0, 0], [len, 0], [len, thick], [0, thick]]) { | |
| 211 | - | xs.push(x + dl * along.x + dt * nx); | |
| 212 | - | ys.push(y + dl * along.y + dt * ny); | |
| 213 | - | } | |
| 214 | - | } | |
| 215 | - | ||
| 216 | - | const cx = corners.map((c) => c.x); | |
| 217 | - | const cy = corners.map((c) => c.y); | |
| 218 | - | return { | |
| 219 | - | places, | |
| 220 | - | gap: shut - lap, | |
| 221 | - | box: { w: Math.max(...xs) - Math.min(...xs), h: Math.max(...ys) - Math.min(...ys) }, | |
| 222 | - | open: { | |
| 223 | - | x: Math.min(...cx), | |
| 224 | - | y: Math.min(...cy), | |
| 225 | - | w: Math.max(...cx) - Math.min(...cx), | |
| 226 | - | h: Math.max(...cy) - Math.min(...cy), | |
| 227 | - | }, | |
| 228 | - | }; | |
| 136 | + | export function wallSquare(room: number, ratio: number): WallSquare { | |
| 137 | + | const cell = Math.max(1, room / (STACKS_PER_SIDE + ratio)); | |
| 138 | + | const len = cell * STACKS_PER_SIDE; | |
| 139 | + | const thick = cell * ratio; | |
| 140 | + | return { cell, len, thick, open: len - thick, outer: len + thick }; | |
| 229 | 141 | } | |
| 230 | 142 | ||
| 231 | 143 | /** A stack and which of the 72 it is — the index is what the physics reads. */ | |
| ⋯ 51 unchanged lines | |||
modifiedsrc/styles.css+69 −66
| ⋯ 654 unchanged lines | |||
| 655 | 655 | opacity: 0.5; | |
| 656 | 656 | letter-spacing: 0.1em; | |
| 657 | 657 | } | |
| 658 | - | /* The wall: a square of four staggered sides, 18 stacks of two each, drawn | |
| 659 | - | with the tileset's own blank tile. Sits behind the centre readout. */ | |
| 660 | 658 | /* The wall, built the way it is built on a table: four walls laid out like a # | |
| 661 | - | rather than mitred into a closed square, each one overhanging the corner of | |
| 662 | - | the next so the corners stay open. | |
| 659 | + | rather than mitred into a closed square, each one carrying on past the corner | |
| 660 | + | of the next so no corner is a joint anybody had to cut. | |
| 663 | 661 | ||
| 664 | - | Built to the wall as it is dealt rather than to 144 tiles: eighteen full-size | |
| 665 | - | stacks a side is about 560px, which no ordinary window has between the top | |
| 666 | - | and bottom strips, and by the time anyone is looking there is barely half a | |
| 667 | - | wall left to lay out. How big the square comes out and which stack goes where | |
| 668 | - | is `game/wall.ts`, measured and applied by `ui/WallRing` — the size below is | |
| 669 | - | only of one stack. | |
| 662 | + | Nothing here is turned. A square wants no rotation to describe it: each wall | |
| 663 | + | is pinned to its own edge of the opening, the two flat ones run their stacks | |
| 664 | + | across and the two upright ones run them down, and that is the whole | |
| 665 | + | difference between them. How big the square comes out is `wallSquare` in | |
| 666 | + | `game/wall.ts`, measured and applied by `ui/WallRing`; the size below is only | |
| 667 | + | of one stack. | |
| 670 | 668 | ||
| 671 | - | `.wall-ring` is the opening, not the outside: the sides hang off its edges, | |
| 669 | + | `.wall-ring` is the opening, not the outside: the walls hang off its edges, | |
| 672 | 670 | so the square encloses the pool the way it does on a table. */ | |
| 673 | 671 | .wall-ring { | |
| 674 | - | /* A stack's short side. Solved in ui/WallRing off the tight way across the | |
| 672 | + | /* A stack's short side. Solved in ui/WallRing off the tight way across the | |
| 675 | 673 | *table* — its height — so that eighteen stacks fit it, rather than off the | |
| 676 | 674 | room the four strips leave in the middle, which a real wall never fitted | |
| 677 | 675 | and which had the tile down to under half a hand's. The fallback here is | |
| 678 | 676 | only for the frame before the first measure. */ | |
| 679 | 677 | --ws: var(--tile-w); | |
| 680 | 678 | --wd: calc(var(--ws) * 1.375); | |
| 681 | - | /* How far round the whole square is set. Four walls built the same way round | |
| 682 | - | are already a pinwheel; standing it on its corner is what sends the arms | |
| 683 | - | out at the corners of the table instead of squaring up with its edges. | |
| 684 | - | ||
| 685 | - | The one place this is decided: `table/geometry.ts` reads it back off the | |
| 686 | - | element, so the colliders turn with it and nothing has to be kept in step | |
| 687 | - | by hand. Zero squares it up again. */ | |
| 688 | - | --wall-turn: 0; | |
| 689 | - | /* A side's length, set from ui/WallRing: eighteen stacks, exactly. | |
| 690 | - | ||
| 691 | - | Where each of the four *goes* is set from there too, per side, as an origin | |
| 692 | - | and a direction — because the square is a rhombus now and a rhombus at any | |
| 693 | - | angle but ninety cannot be described with `left` and `top`. `--wall-angle` | |
| 694 | - | is the corner it turns; `game/wall.ts` works the rest out and the CSS only | |
| 695 | - | places what it is told. */ | |
| 679 | + | /* A wall, end to end: eighteen stacks, set from ui/WallRing. It is longer | |
| 680 | + | than the edge of the opening by one wall-depth, and that overhang is what | |
| 681 | + | covers the corner. */ | |
| 696 | 682 | --wall-len: 0px; | |
| 697 | - | --wall-angle: 90; | |
| 698 | - | /* The daylight left where two walls meet, in tiles. Nought is the corner shut | |
| 699 | - | tight; a whole tile is the walls no longer meeting at all, so it is held | |
| 700 | - | under one. Negative is the walls laid further over each other than the | |
| 701 | - | corner needs, which pulls the corners in and is the only thing that makes | |
| 702 | - | the opening in the middle smaller — the sides are eighteen stacks whatever | |
| 703 | - | happens. */ | |
| 704 | - | --wall-gap: 0; | |
| 705 | 683 | position: absolute; | |
| 706 | 684 | top: 50%; | |
| 707 | 685 | left: 50%; | |
| 708 | 686 | width: 0; | |
| 709 | 687 | height: 0; | |
| 710 | - | /* The square is not turned as a whole any more: each wall carries its own | |
| 711 | - | direction, `--wall-turn` included, so the ring is only ever the middle it | |
| 712 | - | is hung from. */ | |
| 713 | 688 | transform: translate(-50%, -50%); | |
| 714 | 689 | pointer-events: none; | |
| 715 | 690 | z-index: 0; | |
| ⋯ 6 unchanged lines | |||
| 722 | 697 | width: var(--ws); | |
| 723 | 698 | height: var(--wd); | |
| 724 | 699 | } | |
| 725 | - | /* Each wall sits outside its edge of the opening, flush at the end it is built | |
| 726 | - | from and running one wall-depth past the far corner — the same way round each | |
| 727 | - | time, which is what turns four straight walls into a pinwheel. A wall shorter | |
| 728 | - | than its side has been drawn from, so what is left of it packs towards the | |
| 729 | - | far end and the gap opens at the break. */ | |
| 730 | - | /* Laid down at its own corner and run out along its own direction. Every wall | |
| 731 | - | is the same shape as every other — a row of stacks going right and standing | |
| 732 | - | `--wd` deep — and only the transform tells them apart, which is what makes | |
| 733 | - | any corner angle possible at all. */ | |
| 700 | + | /* Each wall sits outside its own edge of the opening, flush with the corner it | |
| 701 | + | is built from and running one wall-depth past the far one — the same way | |
| 702 | + | round each time, which is what makes four straight walls a pinwheel. Every | |
| 703 | + | corner of the square is covered by exactly one of them, and no two overlap. | |
| 704 | + | ||
| 705 | + | A wall that has been drawn from packs what is left towards its far end, so | |
| 706 | + | the gap opens at the break rather than at the corner. */ | |
| 734 | 707 | .wall-side { | |
| 735 | 708 | position: absolute; | |
| 736 | - | left: 50%; | |
| 737 | - | top: 50%; | |
| 738 | - | width: var(--wall-len); | |
| 739 | - | height: var(--wd); | |
| 740 | 709 | display: flex; | |
| 741 | 710 | justify-content: flex-end; | |
| 742 | - | transform-origin: 0 0; | |
| 743 | - | /* The nudge comes after the rotate, so it is in the wall's own frame: pushing | |
| 744 | - | a wall pushes it along itself and off itself, not along the table. */ | |
| 745 | - | transform: translate(var(--vx), var(--vy)) rotate(var(--dir)) | |
| 746 | - | translate(var(--along, 0px), var(--across, 0px)); | |
| 711 | + | /* Where it has been pushed to, in the table's own axes — a square wall goes | |
| 712 | + | where the finger goes. */ | |
| 713 | + | transform: translate(var(--dx, 0px), var(--dy, 0px)); | |
| 747 | 714 | /* The ring lets everything through; a wall is the one thing you can take | |
| 748 | 715 | hold of. */ | |
| 749 | 716 | pointer-events: auto; | |
| 750 | 717 | cursor: grab; | |
| 751 | 718 | } | |
| 719 | + | .wall-side.top, | |
| 720 | + | .wall-side.bottom { | |
| 721 | + | width: var(--wall-len); | |
| 722 | + | height: var(--wd); | |
| 723 | + | } | |
| 724 | + | .wall-side.left, | |
| 725 | + | .wall-side.right { | |
| 726 | + | width: var(--wd); | |
| 727 | + | height: var(--wall-len); | |
| 728 | + | } | |
| 729 | + | /* Built from the top left corner, running right over the head of the right. */ | |
| 730 | + | .wall-side.top { | |
| 731 | + | left: 0; | |
| 732 | + | bottom: 100%; | |
| 733 | + | } | |
| 734 | + | /* From the top right, running down. */ | |
| 735 | + | .wall-side.right { | |
| 736 | + | left: 100%; | |
| 737 | + | top: 0; | |
| 738 | + | flex-direction: column; | |
| 739 | + | } | |
| 740 | + | /* From the bottom right, running left. */ | |
| 741 | + | .wall-side.bottom { | |
| 742 | + | right: 0; | |
| 743 | + | top: 100%; | |
| 744 | + | flex-direction: row-reverse; | |
| 745 | + | } | |
| 746 | + | /* From the bottom left, running up. */ | |
| 747 | + | .wall-side.left { | |
| 748 | + | right: 100%; | |
| 749 | + | bottom: 0; | |
| 750 | + | flex-direction: column-reverse; | |
| 751 | + | } | |
| 752 | 752 | .wall-stack { | |
| 753 | 753 | position: relative; | |
| 754 | 754 | flex: none; | |
| 755 | 755 | width: var(--ws); | |
| 756 | 756 | height: var(--wd); | |
| 757 | 757 | } | |
| 758 | + | /* Lying the other way about, which is how the two upright walls are built. */ | |
| 759 | + | .wall-side.left > .wall-stack, | |
| 760 | + | .wall-side.right > .wall-stack { | |
| 761 | + | width: var(--wd); | |
| 762 | + | height: var(--ws); | |
| 763 | + | } | |
| 764 | + | /* The tile itself fills its stack, whichever way round the stack is lying — so | |
| 765 | + | the two upright walls need nothing said about them here. */ | |
| 758 | 766 | .wall-stack i { | |
| 759 | 767 | position: absolute; | |
| 760 | - | top: 50%; | |
| 761 | - | left: 50%; | |
| 762 | - | width: var(--ws); | |
| 763 | - | height: var(--wd); | |
| 764 | - | margin: calc(var(--wd) / -2) 0 0 calc(var(--ws) / -2); | |
| 768 | + | inset: 0; | |
| 765 | 769 | background-image: url('./assets/back.png'); | |
| 766 | 770 | background-size: 100% 100%; | |
| 767 | 771 | /* A box-shadow, not a drop-shadow. The square is seventy-two stacks now | |
| 768 | - | rather than the twenty-odd that were left after a deal, and every one of | |
| 769 | - | them sits inside a rotated container: `filter` gives each its own layer to | |
| 770 | - | rasterise, and seventy-two of those being redrawn while a slider moves is | |
| 771 | - | enough to make the page stop answering. The tile is a rectangle, so the | |
| 772 | - | cheap shadow is the same picture. */ | |
| 772 | + | rather than the twenty-odd that were left after a deal: `filter` gives each | |
| 773 | + | its own layer to rasterise, and seventy-two of those being redrawn while a | |
| 774 | + | wall is pushed about is enough to make the page stop answering. The tile is | |
| 775 | + | a rectangle, so the cheap shadow is the same picture. */ | |
| 773 | 776 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.45); | |
| 774 | 777 | } | |
| 775 | 778 | /* One tile left in the stack. */ | |
| ⋯ 1542 unchanged lines | |||
modifiedsrc/table/geometry.ts+12 −48
| ⋯ 88 unchanged lines | |||
| 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. | |
| 92 | + | // Nothing in the square is turned, so a stack's bounding rect *is* the stack: | |
| 93 | + | // the box the layout gave it, in the table's own axes. A stack on the left or | |
| 94 | + | // right wall is lying the other way about, and its rect says so. | |
| 97 | 95 | for (const el of ring.querySelectorAll<HTMLElement>('[data-stack]')) { | |
| 98 | 96 | const i = Number(el.dataset.stack); | |
| 99 | - | // Which way this stack is lying, told rather than deduced: the layout knows | |
| 100 | - | // it exactly, and a turned box's bounding rect cannot say. | |
| 101 | - | const turn = ((Number(el.dataset.angle) || 0) * Math.PI) / 180; | |
| 102 | - | const around = local(el.getBoundingClientRect()); | |
| 103 | - | const r: Rect = { | |
| 104 | - | x: around.x + around.w / 2 - el.offsetWidth / 2, | |
| 105 | - | y: around.y + around.h / 2 - el.offsetHeight / 2, | |
| 106 | - | w: el.offsetWidth, | |
| 107 | - | h: el.offsetHeight, | |
| 108 | - | }; | |
| 97 | + | const r = local(el.getBoundingClientRect()); | |
| 109 | 98 | short = Math.max(short, Math.min(r.w, r.h)); | |
| 110 | 99 | const stack = stacks[i]; | |
| 111 | 100 | if (stack && isBarrier(stack)) { | |
| 112 | - | walls.push({ rect: r, angle: turn }); | |
| 113 | - | barriers.push({ rect: r, angle: turn }); | |
| 101 | + | walls.push({ rect: r }); | |
| 102 | + | barriers.push({ rect: r }); | |
| 114 | 103 | } | |
| 115 | 104 | } | |
| 116 | 105 | if (short === 0) return null; | |
| 117 | 106 | ||
| 118 | 107 | // `.wall-ring` is the opening itself — the walls hang off its edges — so the | |
| 119 | 108 | // pool is simply its box. (It used to be the wall's outside, which is why this | |
| 120 | - | // had to subtract the wall's own thickness.) | |
| 121 | - | // | |
| 122 | - | // Taken at the size the layout gave it rather than the size it covers once | |
| 123 | - | // turned: the opening is the same opening whichever way round the square is | |
| 124 | - | // standing, and this is only ever where a *tapped* discard is aimed. A flick | |
| 125 | - | // is not aimed at anything — it goes where it was thrown. | |
| 126 | - | const around = local(ring.getBoundingClientRect()); | |
| 127 | - | const openW = Math.max(short, ring.offsetWidth); | |
| 128 | - | const openH = Math.max(short, ring.offsetHeight); | |
| 129 | - | const ringRect: Rect = { | |
| 130 | - | x: around.x + around.w / 2 - openW / 2, | |
| 131 | - | y: around.y + around.h / 2 - openH / 2, | |
| 132 | - | w: openW, | |
| 133 | - | h: openH, | |
| 134 | - | }; | |
| 135 | - | const pool: Rect = ringRect; | |
| 109 | + | // had to subtract the wall's own thickness.) This is only ever where a | |
| 110 | + | // *tapped* discard is aimed; a flick is not aimed at anything — it goes where | |
| 111 | + | // it was thrown. | |
| 112 | + | const pool: Rect = local(ring.getBoundingClientRect()); | |
| 136 | 113 | ||
| 137 | 114 | const launch: Partial<Record<SeatId, Point>> = {}; | |
| 138 | 115 | const throwFrom: Partial<Record<SeatId, Point>> = {}; | |
| ⋯ 147 unchanged lines | |||
| 286 | 263 | ||
| 287 | 264 | /** Segment against every standing stack, by the slab method. */ | |
| 288 | 265 | function clearLine(walls: Barrier[], from: Point, to: Point): boolean { | |
| 289 | - | return !walls.some((w) => segmentHitsRect(from, to, w.rect, w.angle)); | |
| 266 | + | return !walls.some((w) => segmentHitsRect(from, to, w.rect)); | |
| 290 | 267 | } | |
| 291 | 268 | ||
| 292 | - | export function segmentHitsRect(a: Point, b: Point, r: Rect, angle = 0): boolean { | |
| 293 | - | // A turned box is the same box asked from where it is standing: put the | |
| 294 | - | // segment into its frame and the slabs are axis-aligned again. | |
| 295 | - | if (angle) { | |
| 296 | - | const cx = r.x + r.w / 2; | |
| 297 | - | const cy = r.y + r.h / 2; | |
| 298 | - | const c = Math.cos(-angle); | |
| 299 | - | const s = Math.sin(-angle); | |
| 300 | - | const into = (p: Point): Point => ({ | |
| 301 | - | x: cx + (p.x - cx) * c - (p.y - cy) * s, | |
| 302 | - | y: cy + (p.x - cx) * s + (p.y - cy) * c, | |
| 303 | - | }); | |
| 304 | - | return segmentHitsRect(into(a), into(b), r); | |
| 305 | - | } | |
| 269 | + | export function segmentHitsRect(a: Point, b: Point, r: Rect): boolean { | |
| 306 | 270 | const dx = b.x - a.x; | |
| 307 | 271 | const dy = b.y - a.y; | |
| 308 | 272 | let t0 = 0; | |
| ⋯ 16 unchanged lines | |||
modifiedsrc/table/physics.test.ts+24 −73
| ⋯ 1 unchanged line | |||
| 2 | 2 | import { makeRng, STACKS_PER_SIDE, WALL_STACKS } from '../game/tiles'; | |
| 3 | 3 | import { | |
| 4 | 4 | isBarrier, | |
| 5 | - | rhombus, | |
| 5 | + | wallSquare, | |
| 6 | 6 | RING, | |
| 7 | 7 | ringLayout, | |
| 8 | 8 | SEAT_WALL_SIDE, | |
| ⋯ 600 unchanged lines | |||
| 609 | 609 | const RATIO = 1.375; | |
| 610 | 610 | const SIDE = STACKS_PER_SIDE; | |
| 611 | 611 | ||
| 612 | - | /** Every corner of every wall, as the layout would lay them down. */ | |
| 613 | - | const walls = (r: ReturnType<typeof rhombus>, len: number, thick: number) => | |
| 614 | - | r.places.map((p) => { | |
| 615 | - | const rad = (p.dir * Math.PI) / 180; | |
| 616 | - | const ax = Math.cos(rad); | |
| 617 | - | const ay = Math.sin(rad); | |
| 618 | - | const nx = -Math.sin(rad); | |
| 619 | - | const ny = Math.cos(rad); | |
| 620 | - | return [ | |
| 621 | - | [0, 0], | |
| 622 | - | [len, 0], | |
| 623 | - | [len, thick], | |
| 624 | - | [0, thick], | |
| 625 | - | ].map(([l, t]) => ({ x: p.x + l * ax + t * nx, y: p.y + l * ay + t * ny })); | |
| 626 | - | }); | |
| 627 | - | ||
| 628 | 612 | it('is always eighteen stacks of two a side, and so a hundred and forty-four tiles', () => { | |
| 629 | 613 | expect(RING).toEqual({ h: SIDE, v: SIDE }); | |
| 630 | 614 | expect(2 * (RING.h + RING.v)).toBe(WALL_STACKS); | |
| 631 | 615 | expect(2 * (RING.h + RING.v) * 2).toBe(144); | |
| 632 | 616 | }); | |
| 633 | 617 | ||
| 634 | - | it('shuts every corner at every angle when no daylight is asked for', () => { | |
| 635 | - | for (const angle of [90, 60, 120, 45, 135, 30, 150]) { | |
| 636 | - | const r = rhombus(SIDE, RATIO, angle, 0, 0); | |
| 637 | - | const quads = walls(r, SIDE, RATIO); | |
| 638 | - | // Each wall's far end has to reach where the next one starts, measured | |
| 639 | - | // along the next wall's own direction. Below zero is daylight. | |
| 640 | - | for (let i = 0; i < 4; i++) { | |
| 641 | - | const next = r.places[(i + 1) % 4]; | |
| 642 | - | const rad = (next.dir * Math.PI) / 180; | |
| 643 | - | const along = (p: { x: number; y: number }) => | |
| 644 | - | (p.x - next.x) * Math.cos(rad) + (p.y - next.y) * Math.sin(rad); | |
| 645 | - | expect(Math.max(...quads[i].map(along))).toBeGreaterThanOrEqual(-1e-6); | |
| 646 | - | } | |
| 647 | - | } | |
| 648 | - | }); | |
| 649 | - | ||
| 650 | - | it('leaves the daylight it is asked for, and never a whole tile of it', () => { | |
| 651 | - | const tile = 1; | |
| 652 | - | for (const angle of [90, 60, 120, 40]) { | |
| 653 | - | for (const asked of [0, 0.25, 0.5, 0.95, 4]) { | |
| 654 | - | const r = rhombus(SIDE, RATIO, angle, 0, asked); | |
| 655 | - | // What was asked for, in tiles — and clamped under one however much is | |
| 656 | - | // asked, because a whole tile is the walls no longer meeting. | |
| 657 | - | expect(r.gap).toBeLessThan(tile); | |
| 658 | - | expect(r.gap).toBeGreaterThanOrEqual(-1e-9); | |
| 659 | - | if (asked <= 0.95) expect(r.gap).toBeCloseTo(Math.min(asked, 0.99) * tile, 6); | |
| 660 | - | } | |
| 661 | - | } | |
| 662 | - | }); | |
| 663 | - | ||
| 664 | - | it('is a rhombus: four equal sides, and the corner it was asked for', () => { | |
| 665 | - | const r = rhombus(SIDE, RATIO, 60); | |
| 666 | - | const at = (i: number) => r.places[i]; | |
| 667 | - | const turn = (i: number) => { | |
| 668 | - | const d = ((at((i + 1) % 4).dir - at(i).dir) % 360 + 360) % 360; | |
| 669 | - | return d; | |
| 670 | - | }; | |
| 671 | - | // Turning by the corner and then by its supplement, twice, comes to a full | |
| 672 | - | // circle — which is what makes it close. | |
| 673 | - | expect(turn(0) + turn(1)).toBeCloseTo(180, 6); | |
| 674 | - | expect(turn(0)).toBeCloseTo(60, 6); | |
| 675 | - | }); | |
| 676 | - | ||
| 677 | - | it('is square at ninety, and laps by exactly one wall-depth there', () => { | |
| 678 | - | const r = rhombus(SIDE, RATIO, 90); | |
| 679 | - | // A square's corner needs t / tan(45) — one depth, which is what the | |
| 680 | - | // hand-written layout used before any of this was worked out. | |
| 681 | - | expect(r.open.w).toBeCloseTo(SIDE - RATIO, 6); | |
| 682 | - | expect(r.open.h).toBeCloseTo(SIDE - RATIO, 6); | |
| 683 | - | }); | |
| 684 | - | ||
| 685 | - | it('scales cleanly, so the tile can be solved for by asking once', () => { | |
| 686 | - | const one = rhombus(SIDE, RATIO, 70); | |
| 687 | - | const ten = rhombus(SIDE * 10, RATIO * 10, 70); | |
| 688 | - | expect(ten.box.w).toBeCloseTo(one.box.w * 10, 6); | |
| 689 | - | expect(ten.box.h).toBeCloseTo(one.box.h * 10, 6); | |
| 618 | + | it('fits the room it was given, walls and all', () => { | |
| 619 | + | const s = wallSquare(765, RATIO); | |
| 620 | + | expect(s.outer).toBeCloseTo(765, 6); | |
| 621 | + | expect(s.len).toBeCloseTo(s.cell * SIDE, 6); | |
| 622 | + | expect(s.thick).toBeCloseTo(s.cell * RATIO, 6); | |
| 623 | + | }); | |
| 624 | + | ||
| 625 | + | it('closes every corner with the overhang, and leaves the rest as the opening', () => { | |
| 626 | + | const s = wallSquare(600, RATIO); | |
| 627 | + | // A wall runs its whole length from the corner it is built from, which | |
| 628 | + | // carries it one depth past the far one and over the end of the next. | |
| 629 | + | expect(s.outer - s.open).toBeCloseTo(2 * s.thick, 6); | |
| 630 | + | expect(s.open).toBeCloseTo(s.len - s.thick, 6); | |
| 631 | + | // Four walls, each covering exactly one corner: the sum of what they cover | |
| 632 | + | // is the outer square less the opening. | |
| 633 | + | expect(s.outer * s.outer - s.open * s.open).toBeCloseTo(4 * s.len * s.thick, 6); | |
| 634 | + | }); | |
| 635 | + | ||
| 636 | + | it('scales cleanly, so a bigger table is the same square drawn bigger', () => { | |
| 637 | + | const one = wallSquare(100, RATIO); | |
| 638 | + | const ten = wallSquare(1000, RATIO); | |
| 639 | + | expect(ten.cell).toBeCloseTo(one.cell * 10, 6); | |
| 640 | + | expect(ten.open).toBeCloseTo(one.open * 10, 6); | |
| 690 | 641 | }); | |
| 691 | 642 | }); | |
| 692 | 643 | ||
| ⋯ 59 unchanged lines | |||
modifiedsrc/table/physics.ts+3 −6
| ⋯ 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; | |
| 235 | 232 | seat?: SeatId; | |
| 236 | 233 | } | |
| 237 | 234 | ||
| ⋯ 331 unchanged lines | |||
| 569 | 566 | for (const wall of world.walls) { | |
| 570 | 567 | if (wall.seat === b.seat && !b.leftSeat) { | |
| 571 | 568 | // Still inside the row it was thrown from: note it, but pass through. | |
| 572 | - | const over = overlapOf(boxOf(wall.rect, wall.angle), b); | |
| 569 | + | const over = overlapOf(boxOf(wall.rect), b); | |
| 573 | 570 | if (over && over.depth > CONTACT_SLOP) inOwnHand = true; | |
| 574 | 571 | continue; | |
| 575 | 572 | } | |
| ⋯ 57 unchanged lines | |||
| 633 | 630 | for (const w of world.walls) { | |
| 634 | 631 | // Your own tiles are not solid to you until you are clear of them. | |
| 635 | 632 | if (w.seat === b.seat && !b.leftSeat) continue; | |
| 636 | - | const hit = overlapOf(boxOf(w.rect, w.angle), b); | |
| 633 | + | const hit = overlapOf(boxOf(w.rect), b); | |
| 637 | 634 | if (hit && hit.depth > CONTACT_SLOP) { | |
| 638 | 635 | wake(b); | |
| 639 | 636 | break; | |
| ⋯ 32 unchanged lines | |||
| 672 | 669 | /** Bounce off whatever is standing there, corner to corner if that is how it | |
| 673 | 670 | * hits — a stack of the wall, a laid-down set, somebody's hand. */ | |
| 674 | 671 | function hitWall(b: Body, wall: Barrier, impacts: Impact[]) { | |
| 675 | - | const hit = overlapOf(boxOf(wall.rect, wall.angle), b); | |
| 672 | + | const hit = overlapOf(boxOf(wall.rect), b); | |
| 676 | 673 | if (!hit || hit.depth <= CONTACT_SLOP) return; | |
| 677 | 674 | ||
| 678 | 675 | const speed = Math.hypot(b.vx, b.vy); | |
| ⋯ 339 unchanged lines | |||
modifiedsrc/tune.css+0 −33
| ⋯ 143 unchanged lines | |||
| 144 | 144 | opacity: 0.85; | |
| 145 | 145 | } | |
| 146 | 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 | - | } | |
deletedsrc/tuneWall.tsx+0 −244
| 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: -6, max: 0.95, step: 0.05, note: 'tiles — under 0 is overlap', 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 | - | // Where the four walls have been pushed to, in their own frames — the numbers | |
| 59 | - | // to hand back when you have found where they should have been standing. | |
| 60 | - | const [nudged, setNudged] = useState('0,0 0,0 0,0 0,0'); | |
| 61 | - | ||
| 62 | - | // Stable across renders: the measuring effect keys off it, and a fresh object | |
| 63 | - | // every render would have it re-measuring for ever. | |
| 64 | - | const state = useMemo<WallProgress>( | |
| 65 | - | () => ({ drawnFront: drawn, drawnBack: 0, rules: { wallReserve: 16 } }), | |
| 66 | - | [drawn], | |
| 67 | - | ); | |
| 68 | - | ||
| 69 | - | // On the ring itself rather than a stylesheet, so this page and the table are | |
| 70 | - | // reading the very same custom properties. | |
| 71 | - | useLayoutEffect(() => { | |
| 72 | - | const ring = table.current?.querySelector<HTMLElement>('.wall-ring'); | |
| 73 | - | if (!ring) return; | |
| 74 | - | ring.style.setProperty('--wall-turn', `${place.turn}`); | |
| 75 | - | ring.style.setProperty('--wall-angle', `${place.angle}`); | |
| 76 | - | ring.style.setProperty('--wall-gap', `${place.gap}`); | |
| 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 | - | const pushed = el.querySelector<HTMLElement>('.wall-ring')?.dataset.nudged; | |
| 99 | - | if (pushed) setNudged((was) => (was === pushed ? was : pushed)); | |
| 100 | - | ||
| 101 | - | // How many stacks are standing, and the most room any one side has left | |
| 102 | - | // over — which is the gap you can see at the corners, since a side packs | |
| 103 | - | // its stacks to the end it was built from. | |
| 104 | - | const all = [...el.querySelectorAll<HTMLElement>('[data-stack]')]; | |
| 105 | - | const sides = [...el.querySelectorAll<HTMLElement>('.wall-side')]; | |
| 106 | - | let gap = 0; | |
| 107 | - | for (const side of sides) { | |
| 108 | - | const kids = [...side.querySelectorAll<HTMLElement>('[data-stack]')]; | |
| 109 | - | if (!kids.length) continue; | |
| 110 | - | const along = side.classList.contains('left') || side.classList.contains('right'); | |
| 111 | - | const room = along ? side.clientHeight : side.clientWidth; | |
| 112 | - | const used = kids.reduce((n, k) => n + (along ? k.offsetHeight : k.offsetWidth), 0); | |
| 113 | - | gap = Math.max(gap, room - used); | |
| 114 | - | } | |
| 115 | - | setBuilt((was) => | |
| 116 | - | was.stacks === all.length && Math.round(was.gap) === Math.round(gap) | |
| 117 | - | ? was | |
| 118 | - | : { stacks: all.length, gap }, | |
| 119 | - | ); | |
| 120 | - | ||
| 121 | - | ctx.lineWidth = 1; | |
| 122 | - | ctx.strokeStyle = '#ff2d20'; | |
| 123 | - | for (const w of geo.walls) { | |
| 124 | - | ctx.save(); | |
| 125 | - | ctx.translate(w.rect.x + w.rect.w / 2, w.rect.y + w.rect.h / 2); | |
| 126 | - | ctx.rotate(w.angle ?? 0); | |
| 127 | - | ctx.strokeRect(-w.rect.w / 2, -w.rect.h / 2, w.rect.w, w.rect.h); | |
| 128 | - | ctx.restore(); | |
| 129 | - | } | |
| 130 | - | ||
| 131 | - | // Where a *tapped* discard is aimed. Not a fence, and not turned with the | |
| 132 | - | // square — which is the one thing on this page that is an approximation. | |
| 133 | - | ctx.setLineDash([5, 5]); | |
| 134 | - | ctx.strokeStyle = 'rgba(255, 45, 32, 0.45)'; | |
| 135 | - | ctx.strokeRect(geo.pool.x + 0.5, geo.pool.y + 0.5, geo.pool.w - 1, geo.pool.h - 1); | |
| 136 | - | ctx.setLineDash([]); | |
| 137 | - | }; | |
| 138 | - | ||
| 139 | - | // After the layout has caught up with the properties just set. | |
| 140 | - | const id = requestAnimationFrame(paint); | |
| 141 | - | const observer = new ResizeObserver(paint); | |
| 142 | - | observer.observe(el); | |
| 143 | - | // A wall has been pushed along its line. Nothing it is measured against has | |
| 144 | - | // changed size, so this is the only way to hear about it — the same event | |
| 145 | - | // the pool listens for, which is what keeps the colliders on the tiles. | |
| 146 | - | const moved = () => requestAnimationFrame(paint); | |
| 147 | - | el.addEventListener('wallmoved', moved); | |
| 148 | - | return () => { | |
| 149 | - | cancelAnimationFrame(id); | |
| 150 | - | el.removeEventListener('wallmoved', moved); | |
| 151 | - | observer.disconnect(); | |
| 152 | - | }; | |
| 153 | - | }, [place, drawn, showColliders, state]); | |
| 154 | - | ||
| 155 | - | const css = `.wall-ring {\n --wall-angle: ${place.angle};\n --wall-turn: ${place.turn};\n --wall-gap: ${place.gap};\n}`; | |
| 156 | - | ||
| 157 | - | return ( | |
| 158 | - | <div className="tune"> | |
| 159 | - | <div className="stage wall-stage"> | |
| 160 | - | <div className="table" ref={table}> | |
| 161 | - | <div className="center"> | |
| 162 | - | <WallRing state={state} /> | |
| 163 | - | </div> | |
| 164 | - | <canvas className="wall-overlay" ref={canvas} /> | |
| 165 | - | </div> | |
| 166 | - | </div> | |
| 167 | - | ||
| 168 | - | <div className="panel"> | |
| 169 | - | <h1>The wall square</h1> | |
| 170 | - | <p className="hint"> | |
| 171 | - | The real <code>WallRing</code> in a real <code>.center</code>, with every collider the | |
| 172 | - | physics would be handed drawn over it in red. Paste the block at the bottom into{' '} | |
| 173 | - | <code>styles.css</code>. | |
| 174 | - | </p> | |
| 175 | - | ||
| 176 | - | {KNOBS.map((k) => ( | |
| 177 | - | <label className="knob" key={k.key}> | |
| 178 | - | <span> | |
| 179 | - | {k.label} | |
| 180 | - | <em>{k.note}</em> | |
| 181 | - | </span> | |
| 182 | - | <input | |
| 183 | - | type="range" | |
| 184 | - | min={k.min} | |
| 185 | - | max={k.max} | |
| 186 | - | step={k.step} | |
| 187 | - | value={place[k.key]} | |
| 188 | - | onChange={(e) => setPlace((p) => ({ ...p, [k.key]: +e.target.value }))} | |
| 189 | - | /> | |
| 190 | - | <output>{place[k.key]}</output> | |
| 191 | - | </label> | |
| 192 | - | ))} | |
| 193 | - | ||
| 194 | - | <label className="knob"> | |
| 195 | - | <span> | |
| 196 | - | Drawn from it<em>how far into the hand</em> | |
| 197 | - | </span> | |
| 198 | - | <input | |
| 199 | - | type="range" | |
| 200 | - | min={0} | |
| 201 | - | max={DRAWN.length - 1} | |
| 202 | - | step={1} | |
| 203 | - | value={DRAWN.indexOf(drawn) < 0 ? 0 : DRAWN.indexOf(drawn)} | |
| 204 | - | onChange={(e) => setDrawn(DRAWN[+e.target.value])} | |
| 205 | - | /> | |
| 206 | - | <output>{drawn}</output> | |
| 207 | - | </label> | |
| 208 | - | ||
| 209 | - | <label className="knob"> | |
| 210 | - | <span> | |
| 211 | - | Colliders<em>what the physics gets</em> | |
| 212 | - | </span> | |
| 213 | - | <input | |
| 214 | - | type="checkbox" | |
| 215 | - | checked={showColliders} | |
| 216 | - | onChange={(e) => setShowColliders(e.target.checked)} | |
| 217 | - | /> | |
| 218 | - | <output /> | |
| 219 | - | </label> | |
| 220 | - | ||
| 221 | - | <p className="hint built"> | |
| 222 | - | pushed to {nudged} | |
| 223 | - | <br /> | |
| 224 | - | {built.stacks} stacks standing · {built.stacks * 2} tiles ·{' '} | |
| 225 | - | {built.gap > 1 | |
| 226 | - | ? `${Math.round(built.gap)}px of empty side` | |
| 227 | - | : 'the sides are full'} | |
| 228 | - | </p> | |
| 229 | - | ||
| 230 | - | <div className="row"> | |
| 231 | - | <button className="btn" onClick={() => setPlace({ ...START })}> | |
| 232 | - | Back to the table's | |
| 233 | - | </button> | |
| 234 | - | <button className="btn" onClick={() => void navigator.clipboard?.writeText(css)}> | |
| 235 | - | Copy | |
| 236 | - | </button> | |
| 237 | - | </div> | |
| 238 | - | <pre>{css}</pre> | |
| 239 | - | </div> | |
| 240 | - | </div> | |
| 241 | - | ); | |
| 242 | - | } | |
| 243 | - | ||
| 244 | - | createRoot(document.getElementById('root')!).render(<WallTuner />); |
modifiedsrc/ui/Pool.tsx+1 −12
| ⋯ 397 unchanged lines | |||
| 398 | 398 | // The table's own edge, and every tile standing on it. | |
| 399 | 399 | ctx.strokeStyle = '#ff2d20'; | |
| 400 | 400 | line(world.bounds); | |
| 401 | - | for (const w of world.walls) { | |
| 402 | - | if (!w.angle) { | |
| 403 | - | line(w.rect); | |
| 404 | - | continue; | |
| 405 | - | } | |
| 406 | - | // Turned, so it is drawn turned — the wall square stands on its corner. | |
| 407 | - | ctx.save(); | |
| 408 | - | ctx.translate(w.rect.x + w.rect.w / 2, w.rect.y + w.rect.h / 2); | |
| 409 | - | ctx.rotate(w.angle); | |
| 410 | - | ctx.strokeRect(-w.rect.w / 2, -w.rect.h / 2, w.rect.w, w.rect.h); | |
| 411 | - | ctx.restore(); | |
| 412 | - | } | |
| 401 | + | for (const w of world.walls) line(w.rect); | |
| 413 | 402 | ||
| 414 | 403 | // Where a throw is aimed. Not a fence — see TableGeometry.pool. | |
| 415 | 404 | const aim = pool.poolRect; | |
| ⋯ 85 unchanged lines | |||
modifiedsrc/ui/WallRing.tsx+72 −128
| 1 | 1 | import { useLayoutEffect, useRef, useState } from 'react'; | |
| 2 | 2 | import { STACKS_PER_SIDE } from '../game/tiles'; | |
| 3 | 3 | import { | |
| 4 | - | rhombus, | |
| 5 | 4 | ringLayout, | |
| 6 | 5 | RING, | |
| 6 | + | wallSquare, | |
| 7 | 7 | WALL_SIDES, | |
| 8 | 8 | wallStacks, | |
| 9 | - | type Rhombus, | |
| 10 | 9 | type WallProgress, | |
| 10 | + | type WallSquare, | |
| 11 | 11 | } from '../game/wall'; | |
| 12 | 12 | ||
| 13 | 13 | import { useT } from './lang'; | |
| ⋯ 1 unchanged line | |||
| 15 | 15 | /** | |
| 16 | 16 | * What is left of the wall, built as a square in the middle of the table. | |
| 17 | 17 | * | |
| 18 | - | * Four staggered sides like a #, with the corners open — but a square the size | |
| 19 | - | * of the wall *as it is dealt*, not of 144 tiles. Eighteen full-size stacks a | |
| 20 | - | * side is longer than the middle of any ordinary window is tall, and by the | |
| 21 | - | * time anyone is looking there is nothing like that much wall left, so it is | |
| 22 | - | * rebuilt smaller and the tiles stay the size of the ones in your hand. What | |
| 23 | - | * goes where is `RING` and `ringLayout` in `game/wall.ts`; this measures the | |
| 24 | - | * middle for them and puts the answer on screen. | |
| 18 | + | * Four staggered sides like a #, with the corners covered rather than mitred — | |
| 19 | + | * but a square the size of the wall *as it is dealt*, not of 144 tiles. | |
| 20 | + | * Eighteen full-size stacks a side is longer than the middle of any ordinary | |
| 21 | + | * window is tall, and by the time anyone is looking there is nothing like that | |
| 22 | + | * much wall left, so it is rebuilt smaller and the tiles stay the size of the | |
| 23 | + | * ones in your hand. What goes where is `RING` and `ringLayout` in | |
| 24 | + | * `game/wall.ts`; this measures the middle for them and puts the answer on | |
| 25 | + | * screen. | |
| 25 | 26 | * | |
| 27 | + | * Nothing here is turned. A square wants no rotation to describe it: each wall | |
| 28 | + | * is pinned to its own edge of the opening in CSS, the two flat ones run their | |
| 29 | + | * stacks across and the two upright ones run them down, and that is the whole | |
| 30 | + | * of it. `--wall-len` and `--ws` are the only numbers the CSS is given. | |
| 31 | + | * | |
| 26 | 32 | * Every stack carries its index, and that is load-bearing — `table/geometry.ts` | |
| 27 | 33 | * measures these elements to build the colliders rather than restating the CSS | |
| 28 | 34 | * that sizes them. | |
| ⋯ 1 unchanged line | |||
| 30 | 36 | export function WallRing({ state }: { state: WallProgress }) { | |
| 31 | 37 | const t = useT(); | |
| 32 | 38 | const ring = useRef<HTMLDivElement>(null); | |
| 33 | - | const { opening, cell, shape } = useSquare(ring); | |
| 39 | + | const square = useSquare(ring); | |
| 34 | 40 | const sides = ringLayout(wallStacks(state), RING); | |
| 35 | - | const { slide, take } = useSliding(ring, shape, cell); | |
| 36 | - | // Where they have been pushed to, for `/wall.html` to read back off the DOM | |
| 37 | - | // rather than have it threaded through as a prop nothing else wants. | |
| 38 | - | const nudged = slide.map((n) => `${Math.round(n.along)},${Math.round(n.across)}`).join(' '); | |
| 41 | + | const { slide, take } = useSliding(ring, square); | |
| 42 | + | // Where they have been pushed to, for the console and anything reading it | |
| 43 | + | // back off the DOM rather than have it threaded through as a prop nothing | |
| 44 | + | // else wants. | |
| 45 | + | const nudged = slide.map((n) => `${Math.round(n.x)},${Math.round(n.y)}`).join(' '); | |
| 39 | 46 | ||
| 40 | 47 | return ( | |
| 41 | 48 | <div | |
| ⋯ 2 unchanged lines | |||
| 44 | 51 | data-nudged={nudged} | |
| 45 | 52 | aria-label={t('牌牆', 'The wall')} | |
| 46 | 53 | style={ | |
| 47 | - | opening | |
| 54 | + | square | |
| 48 | 55 | ? // `--ws` is set from here rather than from the tile, because the | |
| 49 | 56 | // square is the fixed thing now and the tile is what gives. | |
| 50 | 57 | ({ | |
| 51 | - | width: opening.w, | |
| 52 | - | height: opening.h, | |
| 53 | - | '--ws': `${cell}px`, | |
| 54 | - | // A side is its stacks and nothing else, so there is no sum for | |
| 58 | + | width: square.open, | |
| 59 | + | height: square.open, | |
| 60 | + | '--ws': `${square.cell}px`, | |
| 61 | + | // A wall is its stacks and nothing else, so there is no sum for | |
| 55 | 62 | // the corners to come out of by a pixel. | |
| 56 | - | '--wall-len': `${RING.h * cell}px`, | |
| 63 | + | '--wall-len': `${square.len}px`, | |
| 57 | 64 | } as React.CSSProperties) | |
| 58 | 65 | : undefined | |
| 59 | 66 | } | |
| ⋯ 7 unchanged lines | |||
| 67 | 74 | key={cls} | |
| 68 | 75 | onPointerDown={take(side)} | |
| 69 | 76 | style={ | |
| 70 | - | shape | |
| 71 | - | ? ({ | |
| 72 | - | '--vx': `${shape.places[side].x}px`, | |
| 73 | - | '--vy': `${shape.places[side].y}px`, | |
| 74 | - | '--dir': `${shape.places[side].dir}deg`, | |
| 75 | - | '--along': `${slide[side].along}px`, | |
| 76 | - | '--across': `${slide[side].across}px`, | |
| 77 | - | } as React.CSSProperties) | |
| 78 | - | : undefined | |
| 77 | + | { | |
| 78 | + | '--dx': `${slide[side].x}px`, | |
| 79 | + | '--dy': `${slide[side].y}px`, | |
| 80 | + | } as React.CSSProperties | |
| 79 | 81 | } | |
| 80 | 82 | > | |
| 81 | 83 | {sides[side].map(({ index, stack }) => ( | |
| ⋯ 4 unchanged lines | |||
| 86 | 88 | // `Stack.dead` says so and the engine ends the hand on it — but a | |
| 87 | 89 | // wall on a table is one wall, not a wall with the end greyed off. | |
| 88 | 90 | className={`wall-stack s${stack.count}${stack.next ? ' next' : ''}`} | |
| 89 | - | // The layout already knows which way this stack is lying, so the | |
| 90 | - | // physics is told rather than left to work it out off a box that | |
| 91 | - | // has been turned. See table/geometry.ts. | |
| 92 | - | data-angle={shape ? shape.places[side].dir : 0} | |
| 93 | 91 | > | |
| 94 | 92 | <i /> | |
| 95 | 93 | </div> | |
| ⋯ 5 unchanged lines | |||
| 101 | 99 | } | |
| 102 | 100 | ||
| 103 | 101 | /** | |
| 104 | - | * Pushing a wall along its own line. | |
| 102 | + | * Pushing a wall about. | |
| 105 | 103 | * | |
| 106 | 104 | * What everybody does to a wall as it is eaten: the stacks are drawn from one | |
| 107 | 105 | * end, a gap opens where the drawing is happening, and somebody shoves what is | |
| 108 | 106 | * left along to close it up and keep it in reach. It moves the tiles and | |
| 109 | 107 | * nothing else — the wall is the same wall in the same order and the next draw | |
| 110 | - | * is still the next draw, exactly as washing the discards about moves no | |
| 111 | - | * tile that anybody is counting. | |
| 108 | + | * is still the next draw, exactly as washing the discards about moves no tile | |
| 109 | + | * that anybody is counting. | |
| 112 | 110 | * | |
| 113 | - | * Both ways, and off its line as well as along it: the point of being able to | |
| 114 | - | * push one is finding where it should have been standing, and a wall held to | |
| 115 | - | * its own line can only answer half of that. `along` runs the way the wall | |
| 116 | - | * points, `across` is square to it — its own frame, so a wall at any angle | |
| 117 | - | * moves the way the finger does. | |
| 111 | + | * The finger's own direction, since the square is square: a wall goes where it | |
| 112 | + | * is pushed. The point of being able to push one is finding where it should | |
| 113 | + | * have been standing, and a wall held to its own line can only answer half of | |
| 114 | + | * that. | |
| 118 | 115 | * | |
| 119 | 116 | * The pool is told, because a wall that has moved is a wall a thrown tile | |
| 120 | 117 | * bounces off somewhere else, and the pool only re-measures when it has reason | |
| 121 | 118 | * to — `invalidate` in table/pool.ts, and the listener in ui/Pool.tsx. | |
| 122 | 119 | */ | |
| 123 | - | function useSliding( | |
| 124 | - | ring: React.RefObject<HTMLDivElement | null>, | |
| 125 | - | shape: Rhombus | null, | |
| 126 | - | cell: number, | |
| 127 | - | ) { | |
| 128 | - | const [slide, setSlide] = useState<Nudge[]>(() => [0, 1, 2, 3].map(() => ({ along: 0, across: 0 }))); | |
| 120 | + | function useSliding(ring: React.RefObject<HTMLDivElement | null>, square: WallSquare | null) { | |
| 121 | + | const [slide, setSlide] = useState<Nudge[]>(() => [0, 1, 2, 3].map(() => ({ x: 0, y: 0 }))); | |
| 129 | 122 | // Where the walls are, as the drag knows it. React state is what draws them, | |
| 130 | 123 | // and it commits when it commits — which is one push behind if the pointer | |
| 131 | 124 | // has already gone up. This is the same thing, now. | |
| 132 | 125 | const live = useRef<Nudge[]>(slide); | |
| 133 | - | const drag = useRef<{ | |
| 134 | - | side: number; | |
| 135 | - | ax: number; | |
| 136 | - | ay: number; | |
| 137 | - | fromAlong: number; | |
| 138 | - | fromAcross: number; | |
| 139 | - | at: Nudge; | |
| 140 | - | } | null>(null); | |
| 126 | + | const drag = useRef<{ side: number; fromX: number; fromY: number; at: Nudge } | null>(null); | |
| 141 | 127 | // What the drag needs to know, kept current without the listeners below | |
| 142 | 128 | // having to be torn down and put back on every render to see it. | |
| 143 | - | const now = useRef({ shape, cell }); | |
| 144 | - | now.current = { shape, cell }; | |
| 129 | + | const now = useRef(square); | |
| 130 | + | now.current = square; | |
| 145 | 131 | ||
| 146 | 132 | const moved = () => ring.current?.dispatchEvent(new CustomEvent('wallmoved', { bubbles: true })); | |
| 147 | 133 | ||
| 148 | 134 | const take = (side: number) => (e: React.PointerEvent<HTMLDivElement>) => { | |
| 149 | - | const place = now.current.shape?.places[side]; | |
| 150 | - | if (!place || e.button !== 0) return; | |
| 135 | + | if (!now.current || e.button !== 0) return; | |
| 151 | 136 | // A wall being pushed, not the table being reached into. | |
| 152 | 137 | e.stopPropagation(); | |
| 153 | 138 | e.preventDefault(); | |
| 154 | - | const rad = (place.dir * Math.PI) / 180; | |
| 155 | - | const ax = Math.cos(rad); | |
| 156 | - | const ay = Math.sin(rad); | |
| 157 | 139 | try { | |
| 158 | 140 | e.currentTarget.setPointerCapture?.(e.pointerId); | |
| 159 | 141 | } catch { | |
| 160 | 142 | // Uncaptured still works while the pointer stays over the wall. | |
| 161 | 143 | } | |
| 162 | - | drag.current = { | |
| 163 | - | side, | |
| 164 | - | ax, | |
| 165 | - | ay, | |
| 166 | - | fromAlong: e.clientX * ax + e.clientY * ay, | |
| 167 | - | // Across is the wall's own normal: its direction turned a quarter on. | |
| 168 | - | fromAcross: -e.clientX * ay + e.clientY * ax, | |
| 169 | - | at: live.current[side], | |
| 170 | - | }; | |
| 144 | + | drag.current = { side, fromX: e.clientX, fromY: e.clientY, at: live.current[side] }; | |
| 171 | 145 | }; | |
| 172 | 146 | ||
| 173 | 147 | // Put on once and left alone. Registering these per render was three | |
| ⋯ 2 unchanged lines | |||
| 176 | 150 | const move = (e: PointerEvent) => { | |
| 177 | 151 | const d = drag.current; | |
| 178 | 152 | if (!d) return; | |
| 179 | - | // The finger, in the wall's own frame: how far along it, and how far off. | |
| 180 | - | const bound = STACKS_PER_SIDE * now.current.cell * 2; | |
| 153 | + | // Nowhere near off the table, however far the pointer has run. | |
| 154 | + | const bound = STACKS_PER_SIDE * (now.current?.cell ?? 0) * 2; | |
| 181 | 155 | const clamp = (v: number) => Math.min(bound, Math.max(-bound, v)); | |
| 182 | 156 | const to: Nudge = { | |
| 183 | - | along: clamp(d.at.along + (e.clientX * d.ax + e.clientY * d.ay - d.fromAlong)), | |
| 184 | - | across: clamp(d.at.across + (-e.clientX * d.ay + e.clientY * d.ax - d.fromAcross)), | |
| 157 | + | x: clamp(d.at.x + e.clientX - d.fromX), | |
| 158 | + | y: clamp(d.at.y + e.clientY - d.fromY), | |
| 185 | 159 | }; | |
| 186 | 160 | const had = live.current[d.side]; | |
| 187 | - | if (had.along === to.along && had.across === to.across) return; | |
| 161 | + | if (had.x === to.x && had.y === to.y) return; | |
| 188 | 162 | live.current = live.current.map((v, i) => (i === d.side ? to : v)); | |
| 189 | 163 | setSlide(live.current); | |
| 190 | 164 | moved(); | |
| ⋯ 6 unchanged lines | |||
| 197 | 171 | // Said out loud at the end of the push, not during it: what the fingers | |
| 198 | 172 | // found, in the form it would be written down in. | |
| 199 | 173 | const at = live.current; | |
| 200 | - | const say = (n: Nudge) => `${Math.round(n.along)},${Math.round(n.across)}`; | |
| 174 | + | const say = (n: Nudge) => `${Math.round(n.x)},${Math.round(n.y)}`; | |
| 201 | 175 | console.log( | |
| 202 | 176 | `[wall] ${WALL_SIDES[d.side]} pushed to ${say(at[d.side])} — ` + | |
| 203 | 177 | WALL_SIDES.map((cls, i) => `${cls} ${say(at[i])}`).join(' · '), | |
| ⋯ 13 unchanged lines | |||
| 217 | 191 | return { slide, take }; | |
| 218 | 192 | } | |
| 219 | 193 | ||
| 220 | - | /** How far a wall has been pushed from where it was built, in its own frame. */ | |
| 194 | + | /** How far a wall has been pushed from where it was built, in table pixels. */ | |
| 221 | 195 | export interface Nudge { | |
| 222 | - | along: number; | |
| 223 | - | across: number; | |
| 224 | - | } | |
| 225 | - | ||
| 226 | - | interface Square { | |
| 227 | - | /** The opening the square leaves, once there is a middle to measure. */ | |
| 228 | - | opening: { w: number; h: number } | null; | |
| 229 | - | /** How big a stack came out — the wall's own tile size, in px. */ | |
| 230 | - | cell: number; | |
| 231 | - | /** Where the four walls stand, from `rhombus`. */ | |
| 232 | - | shape: Rhombus | null; | |
| 196 | + | x: number; | |
| 197 | + | y: number; | |
| 233 | 198 | } | |
| 234 | 199 | ||
| 235 | 200 | /** | |
| 236 | 201 | * How big to build the square, measured rather than worked out twice: how big a | |
| 237 | 202 | * tile is is CSS's business, so one cell is rendered and asked how it came out, | |
| 238 | - | * and the middle is whatever the four strips have left. Nothing here depends on | |
| 239 | - | * the state of the hand — the square is built once for the window it is in and | |
| 240 | - | * then eaten, so it does not close in on the discards lying inside it. | |
| 203 | + | * and `wallSquare` solves for the rest. Nothing here depends on the state of the | |
| 204 | + | * hand — the square is built once for the window it is in and then eaten, so it | |
| 205 | + | * does not close in on the discards lying inside it. | |
| 241 | 206 | */ | |
| 242 | - | function useSquare(ring: React.RefObject<HTMLDivElement | null>): Square { | |
| 243 | - | const [square, setSquare] = useState<Square>({ opening: null, cell: 0, shape: null }); | |
| 207 | + | function useSquare(ring: React.RefObject<HTMLDivElement | null>): WallSquare | null { | |
| 208 | + | const [square, setSquare] = useState<WallSquare | null>(null); | |
| 244 | 209 | ||
| 245 | 210 | useLayoutEffect(() => { | |
| 246 | 211 | const el = ring.current; | |
| ⋯ 1 unchanged line | |||
| 248 | 213 | const probe = el?.querySelector<HTMLElement>('.wall-probe'); | |
| 249 | 214 | if (!el || !middle || !probe) return; | |
| 250 | 215 | ||
| 251 | - | // Everything the square is worked out *from*. Both observers below can be | |
| 252 | - | // set off by this effect's own output — the probe is sized by the `--ws` it | |
| 253 | - | // writes, and the ring's style attribute is where it writes it — so the | |
| 254 | - | // guard is not a nicety: without it, one recompute schedules the next and | |
| 255 | - | // the tab stops answering. Same inputs, nothing to do. | |
| 216 | + | // Everything the square is worked out *from*. The observer below can be set | |
| 217 | + | // off by this effect's own output — the ring's size is what it writes — so | |
| 218 | + | // the guard is not a nicety: without it, one recompute schedules the next | |
| 219 | + | // and the tab stops answering. Same inputs, nothing to do. | |
| 256 | 220 | let last = ''; | |
| 257 | 221 | ||
| 258 | 222 | const read = () => { | |
| ⋯ 2 unchanged lines | |||
| 261 | 225 | // What shape a stack is, is CSS's business — read it, don't restate it. | |
| 262 | 226 | // Scale-free, so this is the same number however big the square came out. | |
| 263 | 227 | const ratio = box.height / box.width; | |
| 264 | - | const style = getComputedStyle(el); | |
| 265 | - | // The corner it turns, how far round it is set, and how much daylight is | |
| 266 | - | // left where two walls meet — in tiles, and under one of them. | |
| 267 | - | const angle = parseFloat(style.getPropertyValue('--wall-angle')) || 90; | |
| 268 | - | const turn = parseFloat(style.getPropertyValue('--wall-turn')) || 0; | |
| 269 | - | const gap = parseFloat(style.getPropertyValue('--wall-gap')) || 0; | |
| 270 | 228 | ||
| 271 | 229 | // Sized off the tight way across the table, not the room between the four | |
| 272 | 230 | // strips. Height is what a table is short of — 765 against 1396 — so that | |
| 273 | - | // is what eighteen stacks have to fit, and the tile is whatever that comes | |
| 274 | - | // to. It is the whole table because the square is bigger than the middle | |
| 275 | - | // and always was; where it goes from here is a separate question. | |
| 231 | + | // is what the square has to fit, and the tile is whatever that comes to. | |
| 232 | + | // It is the whole table because the square is bigger than the middle and | |
| 233 | + | // always was; where it goes from here is a separate question. | |
| 276 | 234 | const fit = (el.closest('.table') as HTMLElement | null) ?? middle; | |
| 277 | 235 | const room = fit.clientHeight; | |
| 278 | - | const from = `${room}/${ratio}/${angle}/${turn}/${gap}`; | |
| 236 | + | const from = `${Math.round(room)}/${ratio.toFixed(3)}`; | |
| 279 | 237 | if (from === last) return; | |
| 280 | 238 | last = from; | |
| 281 | - | ||
| 282 | - | // Linear in the stack, so it is asked once at a stack of one and scaled. | |
| 283 | - | const unit = rhombus(STACKS_PER_SIDE, ratio, angle, turn, gap); | |
| 284 | - | const s = Math.max(1, room / unit.box.h); | |
| 285 | - | const shape = rhombus(STACKS_PER_SIDE * s, ratio * s, angle, turn, gap); | |
| 286 | - | setSquare({ opening: { w: shape.open.w, h: shape.open.h }, cell: s, shape }); | |
| 239 | + | setSquare(wallSquare(room, ratio)); | |
| 287 | 240 | }; | |
| 288 | 241 | ||
| 289 | 242 | read(); | |
| 290 | - | // The middle, not the square: the square's own size is this effect's doing, | |
| 243 | + | // The table, not the square: the square's own size is this effect's doing, | |
| 291 | 244 | // and watching it would be watching itself. The probe is not watched for | |
| 292 | 245 | // the same reason — it is `--ws` wearing a box, and `--ws` is the answer. | |
| 246 | + | const watch = (el.closest('.table') as HTMLElement | null) ?? middle; | |
| 293 | 247 | const observer = new ResizeObserver(read); | |
| 294 | - | observer.observe(middle); | |
| 295 | - | // The shape is three numbers on the element and none of them changes | |
| 296 | - | // anything that has a size, so the observer above would never hear about | |
| 297 | - | // one. `/wall.html` sets them on the ring itself, so that is what is | |
| 298 | - | // watched — and this effect writes to that same attribute, which is what | |
| 299 | - | // `last` is there to absorb. | |
| 300 | - | const styled = new MutationObserver(read); | |
| 301 | - | styled.observe(el, { attributes: true, attributeFilter: ['style'] }); | |
| 302 | - | return () => { | |
| 303 | - | observer.disconnect(); | |
| 304 | - | styled.disconnect(); | |
| 305 | - | }; | |
| 248 | + | observer.observe(watch); | |
| 249 | + | return () => observer.disconnect(); | |
| 306 | 250 | }, [ring]); | |
| 307 | 251 | ||
| 308 | 252 | return square; | |
| ⋯ 1 unchanged line | |||
deletedwall.html+0 −13
| 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> |