anvilsign in

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
567567 how big a stack may be and `WallRing` hands the answer back to the CSS as
568568 `--ws`.
569569
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.
587595
588596 Nothing about the square is calculated twice. Its size lives entirely in CSS
589597 (`--ws`, `--wd`), so `geometry.ts` **measures** it instead of restating that
⋯ 72 unchanged lines
modifiedsrc/game/wall.ts+104 −27
⋯ 101 unchanged lines
102102 *
103103 * What gives instead is the tile. A wall tile is not a hand tile — on a table it
104104 * 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`.
106106 */
107107 export const RING: RingCapacity = { h: STACKS_PER_SIDE, v: STACKS_PER_SIDE };
108108
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+
109127 /**
110- * How big a stack may be, to build that square in the middle there is.
128+ * Where the four walls stand.
111129 *
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.
115135 *
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.
129145 *
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.
135148 */
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+}
140217
141218 /** A stack and which of the 72 it is — the index is what the physics reads. */
142219 export interface Placed {
⋯ 50 unchanged lines
modifiedsrc/styles.css+27 −59
⋯ 674 unchanged lines
675675 The one place this is decided: `table/geometry.ts` reads it back off the
676676 element, so the colliders turn with it and nothing has to be kept in step
677677 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. */
686686 --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;
693692 position: absolute;
694693 top: 50%;
695694 left: 50%;
696695 width: 0;
697696 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%);
700701 pointer-events: none;
701702 z-index: 0;
702703 }
⋯ 10 unchanged lines
713714 time, which is what turns four straight walls into a pinwheel. A wall shorter
714715 than its side has been drawn from, so what is left of it packs towards the
715716 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. */
716721 .wall-side {
717722 position: absolute;
723+ left: 50%;
724+ top: 50%;
725+ width: var(--wall-len);
726+ height: var(--wd);
718727 display: flex;
719728 justify-content: flex-end;
729+ transform-origin: 0 0;
730+ transform: translate(var(--vx), var(--vy)) rotate(var(--dir));
720731 }
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-}
751732 .wall-stack {
752733 position: relative;
753734 flex: none;
754735 width: var(--ws);
755736 height: var(--wd);
756737 }
757-.wall-side.left .wall-stack,
758-.wall-side.right .wall-stack {
759- width: var(--wd);
760- height: var(--ws);
761-}
762738 .wall-stack i {
763739 position: absolute;
764740 top: 50%;
⋯ 5 unchanged lines
770746 background-size: 100% 100%;
771747 filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.45));
772748 }
773-.wall-side.left .wall-stack i,
774-.wall-side.right .wall-stack i {
775- transform: rotate(90deg);
776-}
777749 /* One tile left in the stack. */
778750 .wall-stack.s1 i {
779751 opacity: 0.8;
780752 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);
785753 }
786754 .wall-stack.next::after {
787755 content: '';
⋯ 1536 unchanged lines
modifiedsrc/table/geometry.ts+3 −13
⋯ 93 unchanged lines
9494 // upright box *around* the turned one. What is true either way is the centre
9595 // it reports and the size the layout gave it, and those two plus the angle are
9696 // the stack itself.
97- const turn = wallTurn(ring);
9897 for (const el of ring.querySelectorAll<HTMLElement>('[data-stack]')) {
9998 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;
100102 const around = local(el.getBoundingClientRect());
101103 const r: Rect = {
102104 x: around.x + around.w / 2 - el.offsetWidth / 2,
⋯ 96 unchanged lines
199201 const r = el?.getBoundingClientRect();
200202 const w = r && r.width > 0 ? Math.min(r.width, r.height) : fallback;
201203 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;
214204 }
215205
216206 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
22 import { makeRng, STACKS_PER_SIDE, WALL_STACKS } from '../game/tiles';
33 import {
44 isBarrier,
5- ringCell,
5+ rhombus,
66 RING,
77 ringLayout,
8- ringOpening,
98 SEAT_WALL_SIDE,
109 sideStacks,
1110 wallStacks,
⋯ 569 unchanged lines
581580 describe('the square is the whole wall', () => {
582581 // A stack is a tile wide and 1.375 tiles deep, whatever a tile comes out as.
583582 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+ });
584600
585601 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 });
587603 expect(2 * (RING.h + RING.v)).toBe(WALL_STACKS);
588604 expect(2 * (RING.h + RING.v) * 2).toBe(144);
589605 });
590606
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+ }
600626 }
601627 });
602628
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);
615655 });
616656 });
617657
⋯ 59 unchanged lines
modifiedsrc/tuneWall.tsx+10 −13
⋯ 30 unchanged lines
3131
3232 interface Placing {
3333 turn: number;
34- room: number;
35- x: number;
36- y: number;
34+ angle: number;
35+ lap: number;
3736 }
3837
39-const START: Placing = { turn: 45, room: 0.95, x: 0, y: 0 };
38+const START: Placing = { turn: 0, angle: 90, lap: 1 };
4039
4140 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: '' },
4644 ];
4745
4846 /** Where the hand has got to, so the square can be seen whole and eaten away. */
⋯ 21 unchanged lines
7068 useLayoutEffect(() => {
7169 const ring = table.current?.querySelector<HTMLElement>('.wall-ring');
7270 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}`);
7774 }, [place]);
7875
7976 // Then measure what came out and draw it, which is the whole point.
⋯ 64 unchanged lines
144141 };
145142 }, [place, drawn, showColliders, state]);
146143
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}`;
148145
149146 return (
150147 <div className="tune">
⋯ 84 unchanged lines
modifiedsrc/ui/WallRing.tsx+40 −24
11 import { useLayoutEffect, useRef, useState } from 'react';
2+import { STACKS_PER_SIDE } from '../game/tiles';
23 import {
3- ringCell,
4+ rhombus,
45 ringLayout,
5- ringOpening,
66 RING,
77 WALL_SIDES,
88 wallStacks,
9- type RingCapacity,
9+ type Rhombus,
1010 type WallProgress,
1111 } from '../game/wall';
1212
⋯ 17 unchanged lines
3030 export function WallRing({ state }: { state: WallProgress }) {
3131 const t = useT();
3232 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);
3535
3636 return (
3737 <div
⋯ 19 unchanged lines
5757 to be the size of a stack before any of them have been placed. */}
5858 <i className="wall-probe" />
5959 {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+ >
6173 {sides[side].map(({ index, stack }) => (
6274 <div
6375 key={index}
⋯ 2 unchanged lines
6678 // `Stack.dead` says so and the engine ends the hand on it — but a
6779 // wall on a table is one wall, not a wall with the end greyed off.
6880 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}
6985 >
7086 <i />
7187 </div>
⋯ 5 unchanged lines
7793 }
7894
7995 interface Square {
80- cap: RingCapacity;
8196 /** The opening the square leaves, once there is a middle to measure. */
8297 opening: { w: number; h: number } | null;
8398 /** How big a stack came out — the wall's own tile size, in px. */
8499 cell: number;
100+ /** Where the four walls stand, from `rhombus`. */
101+ shape: Rhombus | null;
85102 }
86103
87104 /**
⋯ 4 unchanged lines
92109 * then eaten, so it does not close in on the discards lying inside it.
93110 */
94111 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 });
96113
97114 useLayoutEffect(() => {
98115 const el = ring.current;
⋯ 12 unchanged lines
111128 // is √2 and the square comes out about seven tenths the size, which is
112129 // what keeps its arms on the table rather than off the edge of it.
113130 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),
126143 );
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 };
130146 setSquare((was) =>
131147 was.cell === s && was.opening?.w === opening.w && was.opening?.h === opening.h
132148 ? was
133- : { cap: RING, opening, cell: s },
149+ : { opening, cell: s, shape },
134150 );
135151 };
136152
⋯ 11 unchanged lines