collin/mahjong · b9eed9cc
A rhombus of any corner, and the walls lap as far as you like
Collin Richards · 2026-08-21 21:13 UTC · b9eed9ccf2e8bb03ad44703c54cabb8da03f2f47 · parent fba98f79 · browse files
modifiedREADME.md+25 −17
| ⋯ 566 unchanged lines | |||
| 567 | 567 | how big a stack may be and `WallRing` hands the answer back to the CSS as | |
| 568 | 568 | `--ws`. | |
| 569 | 569 | ||
| 570 | - | The square is set at an angle — `--wall-turn` in `styles.css`, 45° — so its four | |
| 571 | - | arms run out towards the corners of the table rather than squaring up with its | |
| 572 | - | edges. That one property is the only place it is decided: `geometry.ts` reads it | |
| 573 | - | back off the element, so the colliders turn with it, and `WallRing` reads it too | |
| 574 | - | so the square is built smaller by however much the turn costs it (stood on its | |
| 575 | - | corner a square needs its diagonal where it used to need its side). Set it to | |
| 576 | - | zero and everything squares up again. The lap — how far a wall runs past its own corner, the overlap that makes four | |
| 577 | - | straight walls a pinwheel — is not a number anybody gets to choose. For four | |
| 578 | - | equal walls to close it has to be exactly one wall-depth, and any other value is | |
| 579 | - | either a gap or an overshoot. So it isn't one: `--wall-len` is a side's length, | |
| 580 | - | eighteen stacks exactly, set from `WallRing` alongside `--ws`; the opening is | |
| 581 | - | that less one depth; and each wall is pinned at the corner it is built from. The | |
| 582 | - | corners meet because there is no arithmetic left for them to disagree about. | |
| 583 | - | `--wall-room`, `--wall-x` and `--wall-y` put it where you want it, and `npm run dev` serves **`/wall.html`** for turning | |
| 584 | - | all four by hand — the real `WallRing` in a real `.center`, with every collider | |
| 585 | - | the physics would be handed drawn over the top, because "it looks right" and "a | |
| 586 | - | tile would bounce off it there" are two different claims. | |
| 570 | + | The four walls are placed by `rhombus()` in `game/wall.ts`, which is where the | |
| 571 | + | whole shape lives: a rhombus of any corner angle, at any rotation, with the | |
| 572 | + | walls lapping over each other by however much you ask for. At ninety degrees it | |
| 573 | + | is the square everyone knows. | |
| 574 | + | ||
| 575 | + | Each wall is pinned at the corner it is built from and runs its whole length | |
| 576 | + | from there, which carries it past the far corner and over the outside of the | |
| 577 | + | next wall along — that overhang is the pinwheel, and it is why the corners are | |
| 578 | + | covered rather than mitred. How far a wall has to overrun depends on the corner | |
| 579 | + | it is turning: two bands of thickness `t` meeting at an interior angle ψ overlap | |
| 580 | + | by `t / tan(ψ/2)`. A rhombus has two different corner angles and its four walls | |
| 581 | + | are all the same eighteen stacks long, so they cannot each have their own | |
| 582 | + | overrun — all four take the *sharper* corner's, which means the shallow corners | |
| 583 | + | overlap by more than they strictly need. That is a thicker join and never a gap, | |
| 584 | + | at any angle. | |
| 585 | + | ||
| 586 | + | The CSS only places what it is told: `.wall-side` is one shape — a row of stacks | |
| 587 | + | going right, standing `--wd` deep — and a transform per side. There are no | |
| 588 | + | `left`/`top` special cases, because a rhombus at any angle but ninety cannot be | |
| 589 | + | described with them. Each stack carries its own `data-angle`, so `geometry.ts` | |
| 590 | + | hands the physics a turned box rather than trying to recover the angle from a | |
| 591 | + | bounding rect, which cannot say. `--wall-angle`, `--wall-turn` and `--wall-lap` | |
| 592 | + | are the three numbers, and `npm run dev` serves **`/wall.html`** for turning | |
| 593 | + | them by hand — the real `WallRing` in a real `.center`, with every collider the | |
| 594 | + | physics would be handed drawn over the top. | |
| 587 | 595 | ||
| 588 | 596 | Nothing about the square is calculated twice. Its size lives entirely in CSS | |
| 589 | 597 | (`--ws`, `--wd`), so `geometry.ts` **measures** it instead of restating that | |
| ⋯ 72 unchanged lines | |||
modifiedsrc/game/wall.ts+104 −27
| ⋯ 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 `ringCell`. | |
| 105 | + | * has to be readable, and those are two different jobs. See `rhombus`. | |
| 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 opening left in the middle of it, as a box round the four corners. */ | |
| 122 | + | open: { x: number; y: number; w: number; h: number }; | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | const rad = (deg: number) => (deg * Math.PI) / 180; | |
| 126 | + | ||
| 109 | 127 | /** | |
| 110 | - | * How big a stack may be, to build that square in the middle there is. | |
| 128 | + | * Where the four walls stand. | |
| 111 | 129 | * | |
| 112 | - | * A side of n stacks runs one wall-depth past its own corner — the overlap that | |
| 113 | - | * makes the # — so it needs `n * s + d` across, and with `d = s * ratio` that is | |
| 114 | - | * `(n + ratio) * s`. Square, so the tighter of the two directions decides. | |
| 130 | + | * A rhombus of any corner angle, which at ninety degrees is the square everyone | |
| 131 | + | * knows. Four walls of the same eighteen stacks, each pinned at the corner it is | |
| 132 | + | * built from and running its whole length from there — which carries it past the | |
| 133 | + | * far corner, over the outside of the next wall along. That overhang is the | |
| 134 | + | * pinwheel, and it is the reason the corners are covered rather than mitred. | |
| 115 | 135 | * | |
| 116 | - | * `ratio` is the stack's long side over its short one, measured off the element | |
| 117 | - | * rather than restated here: it is CSS's business what shape a stack is. | |
| 118 | - | */ | |
| 119 | - | export function ringCell(space: { w: number; h: number }, ratio: number): number { | |
| 120 | - | // A side is its eighteen stacks; the opening is that less one wall-depth, and | |
| 121 | - | // a wall hangs off each side of the opening. So across the middle: | |
| 122 | - | // opening = (n - ratio) * s walls = 2 * ratio * s | |
| 123 | - | // outer = (n + ratio) * s | |
| 124 | - | return Math.max(1, Math.min(space.w, space.h) / (STACKS_PER_SIDE + ratio)); | |
| 125 | - | } | |
| 126 | - | ||
| 127 | - | /** | |
| 128 | - | * The opening the square leaves in the middle — where the discards go. | |
| 136 | + | * How far a wall has to overrun to cover the next one depends on the corner it | |
| 137 | + | * is turning: two bands of thickness `t` meeting at an interior angle ψ overlap | |
| 138 | + | * by `t / tan(ψ/2)` along the edge. A rhombus has two different corner angles, | |
| 139 | + | * so the four walls would each want a different overrun — and they cannot have | |
| 140 | + | * one, because they are all the same eighteen stacks long. So all four take the | |
| 141 | + | * *sharper* corner's overrun: the shallow corners then overlap by more than they | |
| 142 | + | * strictly need, which is a thicker join and never a gap. At ninety degrees the | |
| 143 | + | * two are equal and it comes out as exactly one wall-depth, which is where the | |
| 144 | + | * square's own arithmetic came from. | |
| 129 | 145 | * | |
| 130 | - | * A side holds its stacks and then runs one wall-depth past its own corner — | |
| 131 | - | * over the outside of the next wall along, which is the pinwheel — so what the | |
| 132 | - | * opening is short by is exactly that depth. It is not a free number: any other | |
| 133 | - | * value leaves the four walls either short of each other or thrown out past | |
| 134 | - | * each other, and both of those are the gap. | |
| 146 | + | * Everything is linear in `len` and `thick`, so the caller can solve for the | |
| 147 | + | * tile by asking once at a size of one and scaling the answer. | |
| 135 | 148 | */ | |
| 136 | - | export const ringOpening = (cap: RingCapacity, cell: { s: number; d: number }) => ({ | |
| 137 | - | w: Math.max(cell.s, cap.h * cell.s - cell.d), | |
| 138 | - | h: Math.max(cell.s, cap.v * cell.s - cell.d), | |
| 139 | - | }); | |
| 149 | + | export function rhombus( | |
| 150 | + | len: number, | |
| 151 | + | thick: number, | |
| 152 | + | angle: number, | |
| 153 | + | turn = 0, | |
| 154 | + | lapping = 1, | |
| 155 | + | ): Rhombus { | |
| 156 | + | // Interior angles alternate round a rhombus and come to a straight line. | |
| 157 | + | const a = Math.min(179, Math.max(1, angle)); | |
| 158 | + | const sharp = Math.min(a, 180 - a); | |
| 159 | + | // What the corner needs, and then as much more of it as is asked for. Never | |
| 160 | + | // less: below one the walls stop reaching each other, which is the gap. Above | |
| 161 | + | // it they simply lie over each other further, and the corners close either | |
| 162 | + | // way — the wall keeps its whole length whatever this is, so a longer overrun | |
| 163 | + | // pulls the corners in rather than leaving anything behind. | |
| 164 | + | const lap = (thick / Math.tan(rad(sharp) / 2)) * Math.max(1, lapping); | |
| 165 | + | // Corner to corner. The wall is longer than this by the overrun. | |
| 166 | + | const edge = Math.max(thick, len - lap); | |
| 167 | + | ||
| 168 | + | // Two pairs of parallel sides: turning by `a` then by `180 - a`, twice round. | |
| 169 | + | const dirs = [turn, turn + a, turn + 180, turn + 180 + a]; | |
| 170 | + | const u = (deg: number) => ({ x: Math.cos(rad(deg)), y: Math.sin(rad(deg)) }); | |
| 171 | + | ||
| 172 | + | // Corners, hung so the middle of them lands on the middle of the ring. | |
| 173 | + | const u0 = u(dirs[0]); | |
| 174 | + | const u1 = u(dirs[1]); | |
| 175 | + | const corners = [{ x: (-edge * (u0.x + u1.x)) / 2, y: (-edge * (u0.y + u1.y)) / 2 }]; | |
| 176 | + | corners.push({ x: corners[0].x + edge * u0.x, y: corners[0].y + edge * u0.y }); | |
| 177 | + | corners.push({ x: corners[1].x + edge * u1.x, y: corners[1].y + edge * u1.y }); | |
| 178 | + | corners.push({ x: corners[2].x - edge * u0.x, y: corners[2].y - edge * u0.y }); | |
| 179 | + | ||
| 180 | + | const places: WallPlace[] = []; | |
| 181 | + | const xs: number[] = []; | |
| 182 | + | const ys: number[] = []; | |
| 183 | + | for (let i = 0; i < 4; i++) { | |
| 184 | + | const dir = dirs[i]; | |
| 185 | + | const along = u(dir); | |
| 186 | + | // A box laid down at an angle grows along its own +y, which is its | |
| 187 | + | // direction turned a quarter clockwise. Whichever way that points, the wall | |
| 188 | + | // has to end up on the *outside*, so it is hung off the far side when it | |
| 189 | + | // does not. | |
| 190 | + | const nx = -Math.sin(rad(dir)); | |
| 191 | + | const ny = Math.cos(rad(dir)); | |
| 192 | + | const mid = { x: corners[i].x + (edge / 2) * along.x, y: corners[i].y + (edge / 2) * along.y }; | |
| 193 | + | const outward = nx * mid.x + ny * mid.y >= 0; | |
| 194 | + | const x = corners[i].x + (outward ? 0 : -thick * nx); | |
| 195 | + | const y = corners[i].y + (outward ? 0 : -thick * ny); | |
| 196 | + | places.push({ x, y, dir }); | |
| 197 | + | // Its four corners, for the footprint. | |
| 198 | + | for (const [dl, dt] of [[0, 0], [len, 0], [len, thick], [0, thick]]) { | |
| 199 | + | xs.push(x + dl * along.x + dt * nx); | |
| 200 | + | ys.push(y + dl * along.y + dt * ny); | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | const cx = corners.map((c) => c.x); | |
| 205 | + | const cy = corners.map((c) => c.y); | |
| 206 | + | return { | |
| 207 | + | places, | |
| 208 | + | box: { w: Math.max(...xs) - Math.min(...xs), h: Math.max(...ys) - Math.min(...ys) }, | |
| 209 | + | open: { | |
| 210 | + | x: Math.min(...cx), | |
| 211 | + | y: Math.min(...cy), | |
| 212 | + | w: Math.max(...cx) - Math.min(...cx), | |
| 213 | + | h: Math.max(...cy) - Math.min(...cy), | |
| 214 | + | }, | |
| 215 | + | }; | |
| 216 | + | } | |
| 140 | 217 | ||
| 141 | 218 | /** A stack and which of the 72 it is — the index is what the physics reads. */ | |
| 142 | 219 | export interface Placed { | |
| ⋯ 50 unchanged lines | |||
modifiedsrc/styles.css+27 −59
| ⋯ 674 unchanged lines | |||
| 675 | 675 | The one place this is decided: `table/geometry.ts` reads it back off the | |
| 676 | 676 | element, so the colliders turn with it and nothing has to be kept in step | |
| 677 | 677 | by hand. Zero squares it up again. */ | |
| 678 | - | --wall-turn: 45deg; | |
| 679 | - | /* A side's length, set from ui/WallRing: eighteen stacks, exactly. The lap — | |
| 680 | - | how far a wall runs past its own corner, the overlap that makes four | |
| 681 | - | straight walls a pinwheel — is not a number anybody gets to choose. For | |
| 682 | - | four equal walls to close it has to be one wall-depth, no more and no less, | |
| 683 | - | and any other value is a gap or an overshoot. So it is not set here: the | |
| 684 | - | side is its stacks, the opening is the side less one depth, and the corners | |
| 685 | - | meet because there is no arithmetic left for them to disagree about. */ | |
| 678 | + | --wall-turn: 0; | |
| 679 | + | /* A side's length, set from ui/WallRing: eighteen stacks, exactly. | |
| 680 | + | ||
| 681 | + | Where each of the four *goes* is set from there too, per side, as an origin | |
| 682 | + | and a direction — because the square is a rhombus now and a rhombus at any | |
| 683 | + | angle but ninety cannot be described with `left` and `top`. `--wall-angle` | |
| 684 | + | is the corner it turns; `game/wall.ts` works the rest out and the CSS only | |
| 685 | + | places what it is told. */ | |
| 686 | 686 | --wall-len: 0px; | |
| 687 | - | /* How much of the room it has the square actually takes, and where its middle | |
| 688 | - | sits relative to the middle of the table. All three are turned by hand in | |
| 689 | - | `/wall.html` and pasted back here. */ | |
| 690 | - | --wall-room: 0.95; | |
| 691 | - | --wall-x: 0px; | |
| 692 | - | --wall-y: 0px; | |
| 687 | + | --wall-angle: 90; | |
| 688 | + | /* How far each wall runs past its corner, as a multiple of what that corner | |
| 689 | + | needs to be covered. One is the tightest join there can be; more is a | |
| 690 | + | longer overlap. There is no less — under one is a gap, so it is clamped. */ | |
| 691 | + | --wall-lap: 1; | |
| 693 | 692 | position: absolute; | |
| 694 | 693 | top: 50%; | |
| 695 | 694 | left: 50%; | |
| 696 | 695 | width: 0; | |
| 697 | 696 | height: 0; | |
| 698 | - | transform: translate(-50%, -50%) translate(var(--wall-x), var(--wall-y)) | |
| 699 | - | rotate(var(--wall-turn)); | |
| 697 | + | /* The square is not turned as a whole any more: each wall carries its own | |
| 698 | + | direction, `--wall-turn` included, so the ring is only ever the middle it | |
| 699 | + | is hung from. */ | |
| 700 | + | transform: translate(-50%, -50%); | |
| 700 | 701 | pointer-events: none; | |
| 701 | 702 | z-index: 0; | |
| 702 | 703 | } | |
| ⋯ 10 unchanged lines | |||
| 713 | 714 | time, which is what turns four straight walls into a pinwheel. A wall shorter | |
| 714 | 715 | than its side has been drawn from, so what is left of it packs towards the | |
| 715 | 716 | far end and the gap opens at the break. */ | |
| 717 | + | /* Laid down at its own corner and run out along its own direction. Every wall | |
| 718 | + | is the same shape as every other — a row of stacks going right and standing | |
| 719 | + | `--wd` deep — and only the transform tells them apart, which is what makes | |
| 720 | + | any corner angle possible at all. */ | |
| 716 | 721 | .wall-side { | |
| 717 | 722 | position: absolute; | |
| 723 | + | left: 50%; | |
| 724 | + | top: 50%; | |
| 725 | + | width: var(--wall-len); | |
| 726 | + | height: var(--wd); | |
| 718 | 727 | display: flex; | |
| 719 | 728 | justify-content: flex-end; | |
| 729 | + | transform-origin: 0 0; | |
| 730 | + | transform: translate(var(--vx), var(--vy)) rotate(var(--dir)); | |
| 720 | 731 | } | |
| 721 | - | /* Each wall is pinned at the corner it is built from and runs its own length | |
| 722 | - | from there, which carries it one depth past the far one — over the outside of | |
| 723 | - | the next wall along, which is the pinwheel. Every side the same way round. */ | |
| 724 | - | .wall-side.top { | |
| 725 | - | top: calc(-1 * var(--wd)); | |
| 726 | - | left: 0; | |
| 727 | - | width: var(--wall-len); | |
| 728 | - | height: var(--wd); | |
| 729 | - | } | |
| 730 | - | .wall-side.right { | |
| 731 | - | right: calc(-1 * var(--wd)); | |
| 732 | - | top: 0; | |
| 733 | - | height: var(--wall-len); | |
| 734 | - | width: var(--wd); | |
| 735 | - | flex-direction: column; | |
| 736 | - | } | |
| 737 | - | .wall-side.bottom { | |
| 738 | - | bottom: calc(-1 * var(--wd)); | |
| 739 | - | right: 0; | |
| 740 | - | width: var(--wall-len); | |
| 741 | - | height: var(--wd); | |
| 742 | - | flex-direction: row-reverse; | |
| 743 | - | } | |
| 744 | - | .wall-side.left { | |
| 745 | - | left: calc(-1 * var(--wd)); | |
| 746 | - | bottom: 0; | |
| 747 | - | height: var(--wall-len); | |
| 748 | - | width: var(--wd); | |
| 749 | - | flex-direction: column-reverse; | |
| 750 | - | } | |
| 751 | 732 | .wall-stack { | |
| 752 | 733 | position: relative; | |
| 753 | 734 | flex: none; | |
| 754 | 735 | width: var(--ws); | |
| 755 | 736 | height: var(--wd); | |
| 756 | 737 | } | |
| 757 | - | .wall-side.left .wall-stack, | |
| 758 | - | .wall-side.right .wall-stack { | |
| 759 | - | width: var(--wd); | |
| 760 | - | height: var(--ws); | |
| 761 | - | } | |
| 762 | 738 | .wall-stack i { | |
| 763 | 739 | position: absolute; | |
| 764 | 740 | top: 50%; | |
| ⋯ 5 unchanged lines | |||
| 770 | 746 | background-size: 100% 100%; | |
| 771 | 747 | filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.45)); | |
| 772 | 748 | } | |
| 773 | - | .wall-side.left .wall-stack i, | |
| 774 | - | .wall-side.right .wall-stack i { | |
| 775 | - | transform: rotate(90deg); | |
| 776 | - | } | |
| 777 | 749 | /* One tile left in the stack. */ | |
| 778 | 750 | .wall-stack.s1 i { | |
| 779 | 751 | opacity: 0.8; | |
| 780 | 752 | transform: scale(0.86); | |
| 781 | - | } | |
| 782 | - | .wall-side.left .wall-stack.s1 i, | |
| 783 | - | .wall-side.right .wall-stack.s1 i { | |
| 784 | - | transform: rotate(90deg) scale(0.86); | |
| 785 | 753 | } | |
| 786 | 754 | .wall-stack.next::after { | |
| 787 | 755 | content: ''; | |
| ⋯ 1536 unchanged lines | |||
modifiedsrc/table/geometry.ts+3 −13
| ⋯ 93 unchanged lines | |||
| 94 | 94 | // upright box *around* the turned one. What is true either way is the centre | |
| 95 | 95 | // it reports and the size the layout gave it, and those two plus the angle are | |
| 96 | 96 | // the stack itself. | |
| 97 | - | const turn = wallTurn(ring); | |
| 98 | 97 | for (const el of ring.querySelectorAll<HTMLElement>('[data-stack]')) { | |
| 99 | 98 | 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; | |
| 100 | 102 | const around = local(el.getBoundingClientRect()); | |
| 101 | 103 | const r: Rect = { | |
| 102 | 104 | x: around.x + around.w / 2 - el.offsetWidth / 2, | |
| ⋯ 96 unchanged lines | |||
| 199 | 201 | const r = el?.getBoundingClientRect(); | |
| 200 | 202 | const w = r && r.width > 0 ? Math.min(r.width, r.height) : fallback; | |
| 201 | 203 | return { w, h: w * 1.375 }; | |
| 202 | - | } | |
| 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 | 204 | } | |
| 215 | 205 | ||
| 216 | 206 | export const centreOf = (r: Rect): Point => ({ x: r.x + r.w / 2, y: r.y + r.h / 2 }); | |
| ⋯ 118 unchanged lines | |||
modifiedsrc/table/physics.test.ts+64 −24
| ⋯ 1 unchanged line | |||
| 2 | 2 | import { makeRng, STACKS_PER_SIDE, WALL_STACKS } from '../game/tiles'; | |
| 3 | 3 | import { | |
| 4 | 4 | isBarrier, | |
| 5 | - | ringCell, | |
| 5 | + | rhombus, | |
| 6 | 6 | RING, | |
| 7 | 7 | ringLayout, | |
| 8 | - | ringOpening, | |
| 9 | 8 | SEAT_WALL_SIDE, | |
| 10 | 9 | sideStacks, | |
| 11 | 10 | wallStacks, | |
| ⋯ 569 unchanged lines | |||
| 581 | 580 | describe('the square is the whole wall', () => { | |
| 582 | 581 | // A stack is a tile wide and 1.375 tiles deep, whatever a tile comes out as. | |
| 583 | 582 | const RATIO = 1.375; | |
| 583 | + | const SIDE = STACKS_PER_SIDE; | |
| 584 | + | ||
| 585 | + | /** Every corner of every wall, as the layout would lay them down. */ | |
| 586 | + | const walls = (r: ReturnType<typeof rhombus>, len: number, thick: number) => | |
| 587 | + | r.places.map((p) => { | |
| 588 | + | const rad = (p.dir * Math.PI) / 180; | |
| 589 | + | const ax = Math.cos(rad); | |
| 590 | + | const ay = Math.sin(rad); | |
| 591 | + | const nx = -Math.sin(rad); | |
| 592 | + | const ny = Math.cos(rad); | |
| 593 | + | return [ | |
| 594 | + | [0, 0], | |
| 595 | + | [len, 0], | |
| 596 | + | [len, thick], | |
| 597 | + | [0, thick], | |
| 598 | + | ].map(([l, t]) => ({ x: p.x + l * ax + t * nx, y: p.y + l * ay + t * ny })); | |
| 599 | + | }); | |
| 584 | 600 | ||
| 585 | 601 | it('is always eighteen stacks of two a side, and so a hundred and forty-four tiles', () => { | |
| 586 | - | expect(RING).toEqual({ h: STACKS_PER_SIDE, v: STACKS_PER_SIDE }); | |
| 602 | + | expect(RING).toEqual({ h: SIDE, v: SIDE }); | |
| 587 | 603 | expect(2 * (RING.h + RING.v)).toBe(WALL_STACKS); | |
| 588 | 604 | expect(2 * (RING.h + RING.v) * 2).toBe(144); | |
| 589 | 605 | }); | |
| 590 | 606 | ||
| 591 | - | it('fits that square in the middle it is given, by sizing the tile', () => { | |
| 592 | - | for (const space of [{ w: 990, h: 360 }, { w: 400, h: 400 }, { w: 300, h: 140 }]) { | |
| 593 | - | const s = ringCell(space, RATIO); | |
| 594 | - | const d = s * RATIO; | |
| 595 | - | // A side is eighteen stacks and one depth of overlap, and it has to fit | |
| 596 | - | // the tighter of the two directions — that is the whole of the sizing. | |
| 597 | - | const side = STACKS_PER_SIDE * s + d; | |
| 598 | - | expect(side).toBeLessThanOrEqual(Math.min(space.w, space.h) + 0.001); | |
| 599 | - | expect(side).toBeGreaterThan(Math.min(space.w, space.h) - 0.001); | |
| 607 | + | it('closes at every corner, at every angle, however much it laps', () => { | |
| 608 | + | for (const angle of [90, 60, 120, 45, 135, 30, 150]) { | |
| 609 | + | for (const lapping of [1, 1.5, 3]) { | |
| 610 | + | const r = rhombus(SIDE, RATIO, angle, 0, lapping); | |
| 611 | + | const quads = walls(r, SIDE, RATIO); | |
| 612 | + | // Each wall's far end has to reach past where the next one starts, | |
| 613 | + | // measured along the next wall's own direction: that overlap is the | |
| 614 | + | // corner being covered, and it must never be negative. | |
| 615 | + | for (let i = 0; i < 4; i++) { | |
| 616 | + | const next = r.places[(i + 1) % 4]; | |
| 617 | + | const rad = (next.dir * Math.PI) / 180; | |
| 618 | + | const along = (p: { x: number; y: number }) => | |
| 619 | + | (p.x - next.x) * Math.cos(rad) + (p.y - next.y) * Math.sin(rad); | |
| 620 | + | // Zero is the tightest join there can be — the two exactly meeting, | |
| 621 | + | // which is what the corner's own mitre comes to. Below zero is a gap. | |
| 622 | + | const reach = Math.max(...quads[i].map(along)); | |
| 623 | + | expect(reach).toBeGreaterThanOrEqual(-1e-6); | |
| 624 | + | } | |
| 625 | + | } | |
| 600 | 626 | } | |
| 601 | 627 | }); | |
| 602 | 628 | ||
| 603 | - | it('gives the tile up rather than the wall, when the middle is small', () => { | |
| 604 | - | const big = ringCell({ w: 990, h: 560 }, RATIO); | |
| 605 | - | const small = ringCell({ w: 400, h: 260 }, RATIO); | |
| 606 | - | // Same square, smaller tiles. It never trades away a stack. | |
| 607 | - | expect(small).toBeLessThan(big); | |
| 608 | - | expect(small).toBeGreaterThan(0); | |
| 609 | - | }); | |
| 610 | - | ||
| 611 | - | it('leaves the middle of itself open for the discards', () => { | |
| 612 | - | const s = ringCell({ w: 600, h: 600 }, RATIO); | |
| 613 | - | const cell = { s, d: s * RATIO }; | |
| 614 | - | expect(ringOpening(RING, cell).w).toBeCloseTo(STACKS_PER_SIDE * s - cell.d, 6); | |
| 629 | + | it('is a rhombus: four equal sides, and the corner it was asked for', () => { | |
| 630 | + | const r = rhombus(SIDE, RATIO, 60); | |
| 631 | + | const at = (i: number) => r.places[i]; | |
| 632 | + | const turn = (i: number) => { | |
| 633 | + | const d = ((at((i + 1) % 4).dir - at(i).dir) % 360 + 360) % 360; | |
| 634 | + | return d; | |
| 635 | + | }; | |
| 636 | + | // Turning by the corner and then by its supplement, twice, comes to a full | |
| 637 | + | // circle — which is what makes it close. | |
| 638 | + | expect(turn(0) + turn(1)).toBeCloseTo(180, 6); | |
| 639 | + | expect(turn(0)).toBeCloseTo(60, 6); | |
| 640 | + | }); | |
| 641 | + | ||
| 642 | + | it('is square at ninety, and laps by exactly one wall-depth there', () => { | |
| 643 | + | const r = rhombus(SIDE, RATIO, 90); | |
| 644 | + | // A square's corner needs t / tan(45) — one depth, which is what the | |
| 645 | + | // hand-written layout used before any of this was worked out. | |
| 646 | + | expect(r.open.w).toBeCloseTo(SIDE - RATIO, 6); | |
| 647 | + | expect(r.open.h).toBeCloseTo(SIDE - RATIO, 6); | |
| 648 | + | }); | |
| 649 | + | ||
| 650 | + | it('scales cleanly, so the tile can be solved for by asking once', () => { | |
| 651 | + | const one = rhombus(SIDE, RATIO, 70); | |
| 652 | + | const ten = rhombus(SIDE * 10, RATIO * 10, 70); | |
| 653 | + | expect(ten.box.w).toBeCloseTo(one.box.w * 10, 6); | |
| 654 | + | expect(ten.box.h).toBeCloseTo(one.box.h * 10, 6); | |
| 615 | 655 | }); | |
| 616 | 656 | }); | |
| 617 | 657 | ||
| ⋯ 59 unchanged lines | |||
modifiedsrc/tuneWall.tsx+10 −13
| ⋯ 30 unchanged lines | |||
| 31 | 31 | ||
| 32 | 32 | interface Placing { | |
| 33 | 33 | turn: number; | |
| 34 | - | room: number; | |
| 35 | - | x: number; | |
| 36 | - | y: number; | |
| 34 | + | angle: number; | |
| 35 | + | lap: number; | |
| 37 | 36 | } | |
| 38 | 37 | ||
| 39 | - | const START: Placing = { turn: 45, room: 0.95, x: 0, y: 0 }; | |
| 38 | + | const START: Placing = { turn: 0, angle: 90, lap: 1 }; | |
| 40 | 39 | ||
| 41 | 40 | const KNOBS: Knob[] = [ | |
| 42 | - | { key: 'turn', label: 'Turn', min: 0, max: 90, step: 1, note: '° clockwise', unit: 'deg' }, | |
| 43 | - | { key: 'room', label: 'Room it takes', min: 0.3, max: 1.3, step: 0.01, note: 'of the middle', unit: '' }, | |
| 44 | - | { key: 'x', label: 'Across', min: -200, max: 200, step: 1, note: 'from the middle', unit: 'px' }, | |
| 45 | - | { key: 'y', label: 'Down', min: -200, max: 200, step: 1, note: 'from the middle', unit: 'px' }, | |
| 41 | + | { key: 'angle', label: 'Corner', min: 20, max: 160, step: 1, note: '° — 90 is the square', unit: 'deg' }, | |
| 42 | + | { key: 'turn', label: 'Turn', min: 0, max: 360, step: 1, note: '° clockwise', unit: 'deg' }, | |
| 43 | + | { key: 'lap', label: 'Overlap', min: 1, max: 4, step: 0.05, note: '× what the corner needs', unit: '' }, | |
| 46 | 44 | ]; | |
| 47 | 45 | ||
| 48 | 46 | /** Where the hand has got to, so the square can be seen whole and eaten away. */ | |
| ⋯ 21 unchanged lines | |||
| 70 | 68 | useLayoutEffect(() => { | |
| 71 | 69 | const ring = table.current?.querySelector<HTMLElement>('.wall-ring'); | |
| 72 | 70 | if (!ring) return; | |
| 73 | - | ring.style.setProperty('--wall-turn', `${place.turn}deg`); | |
| 74 | - | ring.style.setProperty('--wall-room', `${place.room}`); | |
| 75 | - | ring.style.setProperty('--wall-x', `${place.x}px`); | |
| 76 | - | ring.style.setProperty('--wall-y', `${place.y}px`); | |
| 71 | + | ring.style.setProperty('--wall-turn', `${place.turn}`); | |
| 72 | + | ring.style.setProperty('--wall-angle', `${place.angle}`); | |
| 73 | + | ring.style.setProperty('--wall-lap', `${place.lap}`); | |
| 77 | 74 | }, [place]); | |
| 78 | 75 | ||
| 79 | 76 | // Then measure what came out and draw it, which is the whole point. | |
| ⋯ 64 unchanged lines | |||
| 144 | 141 | }; | |
| 145 | 142 | }, [place, drawn, showColliders, state]); | |
| 146 | 143 | ||
| 147 | - | const css = `.wall-ring {\n --wall-turn: ${place.turn}deg;\n --wall-room: ${place.room};\n --wall-x: ${place.x}px;\n --wall-y: ${place.y}px;\n}`; | |
| 144 | + | const css = `.wall-ring {\n --wall-angle: ${place.angle};\n --wall-turn: ${place.turn};\n --wall-lap: ${place.lap};\n}`; | |
| 148 | 145 | ||
| 149 | 146 | return ( | |
| 150 | 147 | <div className="tune"> | |
| ⋯ 84 unchanged lines | |||
modifiedsrc/ui/WallRing.tsx+40 −24
| 1 | 1 | import { useLayoutEffect, useRef, useState } from 'react'; | |
| 2 | + | import { STACKS_PER_SIDE } from '../game/tiles'; | |
| 2 | 3 | import { | |
| 3 | - | ringCell, | |
| 4 | + | rhombus, | |
| 4 | 5 | ringLayout, | |
| 5 | - | ringOpening, | |
| 6 | 6 | RING, | |
| 7 | 7 | WALL_SIDES, | |
| 8 | 8 | wallStacks, | |
| 9 | - | type RingCapacity, | |
| 9 | + | type Rhombus, | |
| 10 | 10 | type WallProgress, | |
| 11 | 11 | } from '../game/wall'; | |
| 12 | 12 | ||
| ⋯ 17 unchanged lines | |||
| 30 | 30 | export function WallRing({ state }: { state: WallProgress }) { | |
| 31 | 31 | const t = useT(); | |
| 32 | 32 | const ring = useRef<HTMLDivElement>(null); | |
| 33 | - | const { cap, opening, cell } = useSquare(ring); | |
| 34 | - | const sides = ringLayout(wallStacks(state), cap); | |
| 33 | + | const { opening, cell, shape } = useSquare(ring); | |
| 34 | + | const sides = ringLayout(wallStacks(state), RING); | |
| 35 | 35 | ||
| 36 | 36 | return ( | |
| 37 | 37 | <div | |
| ⋯ 19 unchanged lines | |||
| 57 | 57 | to be the size of a stack before any of them have been placed. */} | |
| 58 | 58 | <i className="wall-probe" /> | |
| 59 | 59 | {WALL_SIDES.map((cls, side) => ( | |
| 60 | - | <div className={`wall-side ${cls}`} key={cls}> | |
| 60 | + | <div | |
| 61 | + | className={`wall-side ${cls}`} | |
| 62 | + | key={cls} | |
| 63 | + | style={ | |
| 64 | + | shape | |
| 65 | + | ? ({ | |
| 66 | + | '--vx': `${shape.places[side].x}px`, | |
| 67 | + | '--vy': `${shape.places[side].y}px`, | |
| 68 | + | '--dir': `${shape.places[side].dir}deg`, | |
| 69 | + | } as React.CSSProperties) | |
| 70 | + | : undefined | |
| 71 | + | } | |
| 72 | + | > | |
| 61 | 73 | {sides[side].map(({ index, stack }) => ( | |
| 62 | 74 | <div | |
| 63 | 75 | key={index} | |
| ⋯ 2 unchanged lines | |||
| 66 | 78 | // `Stack.dead` says so and the engine ends the hand on it — but a | |
| 67 | 79 | // wall on a table is one wall, not a wall with the end greyed off. | |
| 68 | 80 | className={`wall-stack s${stack.count}${stack.next ? ' next' : ''}`} | |
| 81 | + | // The layout already knows which way this stack is lying, so the | |
| 82 | + | // physics is told rather than left to work it out off a box that | |
| 83 | + | // has been turned. See table/geometry.ts. | |
| 84 | + | data-angle={shape ? shape.places[side].dir : 0} | |
| 69 | 85 | > | |
| 70 | 86 | <i /> | |
| 71 | 87 | </div> | |
| ⋯ 5 unchanged lines | |||
| 77 | 93 | } | |
| 78 | 94 | ||
| 79 | 95 | interface Square { | |
| 80 | - | cap: RingCapacity; | |
| 81 | 96 | /** The opening the square leaves, once there is a middle to measure. */ | |
| 82 | 97 | opening: { w: number; h: number } | null; | |
| 83 | 98 | /** How big a stack came out — the wall's own tile size, in px. */ | |
| 84 | 99 | cell: number; | |
| 100 | + | /** Where the four walls stand, from `rhombus`. */ | |
| 101 | + | shape: Rhombus | null; | |
| 85 | 102 | } | |
| 86 | 103 | ||
| 87 | 104 | /** | |
| ⋯ 4 unchanged lines | |||
| 92 | 109 | * then eaten, so it does not close in on the discards lying inside it. | |
| 93 | 110 | */ | |
| 94 | 111 | function useSquare(ring: React.RefObject<HTMLDivElement | null>): Square { | |
| 95 | - | const [square, setSquare] = useState<Square>({ cap: RING, opening: null, cell: 0 }); | |
| 112 | + | const [square, setSquare] = useState<Square>({ opening: null, cell: 0, shape: null }); | |
| 96 | 113 | ||
| 97 | 114 | useLayoutEffect(() => { | |
| 98 | 115 | const el = ring.current; | |
| ⋯ 12 unchanged lines | |||
| 111 | 128 | // is √2 and the square comes out about seven tenths the size, which is | |
| 112 | 129 | // what keeps its arms on the table rather than off the edge of it. | |
| 113 | 130 | const style = getComputedStyle(el); | |
| 114 | - | const turn = (parseFloat(style.getPropertyValue('--wall-turn')) || 0) * (Math.PI / 180); | |
| 115 | - | const spread = Math.abs(Math.cos(turn)) + Math.abs(Math.sin(turn)); | |
| 116 | - | const room = parseFloat(style.getPropertyValue('--wall-room')) || 1; | |
| 117 | - | // Eighteen stacks and one depth of overlap is `(18 + ratio) * s` across, | |
| 118 | - | // and turned that covers `spread` times as much — so the room it has to | |
| 119 | - | // be solved in is the middle divided by the turn. | |
| 120 | - | const s = ringCell( | |
| 121 | - | { | |
| 122 | - | w: (middle.clientWidth * room) / spread, | |
| 123 | - | h: (middle.clientHeight * room) / spread, | |
| 124 | - | }, | |
| 125 | - | ratio, | |
| 131 | + | const turn = parseFloat(style.getPropertyValue('--wall-turn')) || 0; | |
| 132 | + | const angle = parseFloat(style.getPropertyValue('--wall-angle')) || 90; | |
| 133 | + | // A multiple of the overrun the corner actually needs. One is the tightest | |
| 134 | + | // join there can be; more is a longer overlap, and there is no less. | |
| 135 | + | const lap = parseFloat(style.getPropertyValue('--wall-lap')) || 1; | |
| 136 | + | ||
| 137 | + | // Everything about the shape is linear in the stack, so it is asked for | |
| 138 | + | // once at a stack of one and the answer scaled to the middle there is. | |
| 139 | + | const unit = rhombus(STACKS_PER_SIDE, ratio, angle, turn, lap); | |
| 140 | + | const s = Math.max( | |
| 141 | + | 1, | |
| 142 | + | Math.min(middle.clientWidth / unit.box.w, middle.clientHeight / unit.box.h), | |
| 126 | 143 | ); | |
| 127 | - | // The square is always eighteen a side; what fits it is the tile. | |
| 128 | - | const cell = { s, d: s * ratio }; | |
| 129 | - | const opening = ringOpening(RING, cell); | |
| 144 | + | const shape = rhombus(STACKS_PER_SIDE * s, ratio * s, angle, turn, lap); | |
| 145 | + | const opening = { w: shape.open.w, h: shape.open.h }; | |
| 130 | 146 | setSquare((was) => | |
| 131 | 147 | was.cell === s && was.opening?.w === opening.w && was.opening?.h === opening.h | |
| 132 | 148 | ? was | |
| 133 | - | : { cap: RING, opening, cell: s }, | |
| 149 | + | : { opening, cell: s, shape }, | |
| 134 | 150 | ); | |
| 135 | 151 | }; | |
| 136 | 152 | ||
| ⋯ 11 unchanged lines | |||