anvilsign in

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
592592 described with them. Each stack carries its own `data-angle`, so `geometry.ts`
593593 hands the physics a turned box rather than trying to recover the angle from a
594594 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.
598605
599606 Nothing about the square is calculated twice. Its size lives entirely in CSS
600607 (`--ws`, `--wd`), so `geometry.ts` **measures** it instead of restating that
⋯ 72 unchanged lines
modifiedsrc/game/wall.ts+27 −115
⋯ 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 `rhombus`.
105+ * has to be readable, and those are two different jobs. See `wallSquare`.
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 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;
125121 }
126-
127-const rad = (deg: number) => (deg * Math.PI) / 180;
128122
129123 /**
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.
146125 *
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.
153131 *
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.
156135 */
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 };
229141 }
230142
231143 /** 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
655655 opacity: 0.5;
656656 letter-spacing: 0.1em;
657657 }
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. */
660658 /* 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.
663661
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.
670668
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,
672670 so the square encloses the pool the way it does on a table. */
673671 .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
675673 *table* — its height — so that eighteen stacks fit it, rather than off the
676674 room the four strips leave in the middle, which a real wall never fitted
677675 and which had the tile down to under half a hand's. The fallback here is
678676 only for the frame before the first measure. */
679677 --ws: var(--tile-w);
680678 --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. */
696682 --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;
705683 position: absolute;
706684 top: 50%;
707685 left: 50%;
708686 width: 0;
709687 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. */
713688 transform: translate(-50%, -50%);
714689 pointer-events: none;
715690 z-index: 0;
⋯ 6 unchanged lines
722697 width: var(--ws);
723698 height: var(--wd);
724699 }
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. */
734707 .wall-side {
735708 position: absolute;
736- left: 50%;
737- top: 50%;
738- width: var(--wall-len);
739- height: var(--wd);
740709 display: flex;
741710 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));
747714 /* The ring lets everything through; a wall is the one thing you can take
748715 hold of. */
749716 pointer-events: auto;
750717 cursor: grab;
751718 }
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+}
752752 .wall-stack {
753753 position: relative;
754754 flex: none;
755755 width: var(--ws);
756756 height: var(--wd);
757757 }
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. */
758766 .wall-stack i {
759767 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;
765769 background-image: url('./assets/back.png');
766770 background-size: 100% 100%;
767771 /* 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. */
773776 box-shadow: 0 1px 1px rgba(0, 0, 0, 0.45);
774777 }
775778 /* One tile left in the stack. */
⋯ 1542 unchanged lines
modifiedsrc/table/geometry.ts+12 −48
⋯ 88 unchanged lines
8989 // The probe is one cell that is always there, spent wall or not.
9090 const probe = ring.querySelector<HTMLElement>('.wall-probe')?.getBoundingClientRect();
9191 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.
9795 for (const el of ring.querySelectorAll<HTMLElement>('[data-stack]')) {
9896 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());
10998 short = Math.max(short, Math.min(r.w, r.h));
11099 const stack = stacks[i];
111100 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 });
114103 }
115104 }
116105 if (short === 0) return null;
117106
118107 // `.wall-ring` is the opening itself — the walls hang off its edges — so the
119108 // 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());
136113
137114 const launch: Partial<Record<SeatId, Point>> = {};
138115 const throwFrom: Partial<Record<SeatId, Point>> = {};
⋯ 147 unchanged lines
286263
287264 /** Segment against every standing stack, by the slab method. */
288265 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));
290267 }
291268
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 {
306270 const dx = b.x - a.x;
307271 const dy = b.y - a.y;
308272 let t0 = 0;
⋯ 16 unchanged lines
modifiedsrc/table/physics.test.ts+24 −73
⋯ 1 unchanged line
22 import { makeRng, STACKS_PER_SIDE, WALL_STACKS } from '../game/tiles';
33 import {
44 isBarrier,
5- rhombus,
5+ wallSquare,
66 RING,
77 ringLayout,
88 SEAT_WALL_SIDE,
⋯ 600 unchanged lines
609609 const RATIO = 1.375;
610610 const SIDE = STACKS_PER_SIDE;
611611
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-
628612 it('is always eighteen stacks of two a side, and so a hundred and forty-four tiles', () => {
629613 expect(RING).toEqual({ h: SIDE, v: SIDE });
630614 expect(2 * (RING.h + RING.v)).toBe(WALL_STACKS);
631615 expect(2 * (RING.h + RING.v) * 2).toBe(144);
632616 });
633617
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);
690641 });
691642 });
692643
⋯ 59 unchanged lines
modifiedsrc/table/physics.ts+3 −6
⋯ 228 unchanged lines
229229 */
230230 export interface Barrier {
231231 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;
235232 seat?: SeatId;
236233 }
237234
⋯ 331 unchanged lines
569566 for (const wall of world.walls) {
570567 if (wall.seat === b.seat && !b.leftSeat) {
571568 // 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);
573570 if (over && over.depth > CONTACT_SLOP) inOwnHand = true;
574571 continue;
575572 }
⋯ 57 unchanged lines
633630 for (const w of world.walls) {
634631 // Your own tiles are not solid to you until you are clear of them.
635632 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);
637634 if (hit && hit.depth > CONTACT_SLOP) {
638635 wake(b);
639636 break;
⋯ 32 unchanged lines
672669 /** Bounce off whatever is standing there, corner to corner if that is how it
673670 * hits — a stack of the wall, a laid-down set, somebody's hand. */
674671 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);
676673 if (!hit || hit.depth <= CONTACT_SLOP) return;
677674
678675 const speed = Math.hypot(b.vx, b.vy);
⋯ 339 unchanged lines
modifiedsrc/tune.css+0 −33
⋯ 143 unchanged lines
144144 opacity: 0.85;
145145 }
146146
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
398398 // The table's own edge, and every tile standing on it.
399399 ctx.strokeStyle = '#ff2d20';
400400 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);
413402
414403 // Where a throw is aimed. Not a fence — see TableGeometry.pool.
415404 const aim = pool.poolRect;
⋯ 85 unchanged lines
modifiedsrc/ui/WallRing.tsx+72 −128
11 import { useLayoutEffect, useRef, useState } from 'react';
22 import { STACKS_PER_SIDE } from '../game/tiles';
33 import {
4- rhombus,
54 ringLayout,
65 RING,
6+ wallSquare,
77 WALL_SIDES,
88 wallStacks,
9- type Rhombus,
109 type WallProgress,
10+ type WallSquare,
1111 } from '../game/wall';
1212
1313 import { useT } from './lang';
⋯ 1 unchanged line
1515 /**
1616 * What is left of the wall, built as a square in the middle of the table.
1717 *
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.
2526 *
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+ *
2632 * Every stack carries its index, and that is load-bearing — `table/geometry.ts`
2733 * measures these elements to build the colliders rather than restating the CSS
2834 * that sizes them.
⋯ 1 unchanged line
3036 export function WallRing({ state }: { state: WallProgress }) {
3137 const t = useT();
3238 const ring = useRef<HTMLDivElement>(null);
33- const { opening, cell, shape } = useSquare(ring);
39+ const square = useSquare(ring);
3440 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(' ');
3946
4047 return (
4148 <div
⋯ 2 unchanged lines
4451 data-nudged={nudged}
4552 aria-label={t('牌牆', 'The wall')}
4653 style={
47- opening
54+ square
4855 ? // `--ws` is set from here rather than from the tile, because the
4956 // square is the fixed thing now and the tile is what gives.
5057 ({
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
5562 // the corners to come out of by a pixel.
56- '--wall-len': `${RING.h * cell}px`,
63+ '--wall-len': `${square.len}px`,
5764 } as React.CSSProperties)
5865 : undefined
5966 }
⋯ 7 unchanged lines
6774 key={cls}
6875 onPointerDown={take(side)}
6976 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
7981 }
8082 >
8183 {sides[side].map(({ index, stack }) => (
⋯ 4 unchanged lines
8688 // `Stack.dead` says so and the engine ends the hand on it — but a
8789 // wall on a table is one wall, not a wall with the end greyed off.
8890 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}
9391 >
9492 <i />
9593 </div>
⋯ 5 unchanged lines
10199 }
102100
103101 /**
104- * Pushing a wall along its own line.
102+ * Pushing a wall about.
105103 *
106104 * What everybody does to a wall as it is eaten: the stacks are drawn from one
107105 * end, a gap opens where the drawing is happening, and somebody shoves what is
108106 * left along to close it up and keep it in reach. It moves the tiles and
109107 * 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.
112110 *
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.
118115 *
119116 * The pool is told, because a wall that has moved is a wall a thrown tile
120117 * bounces off somewhere else, and the pool only re-measures when it has reason
121118 * to — `invalidate` in table/pool.ts, and the listener in ui/Pool.tsx.
122119 */
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 })));
129122 // Where the walls are, as the drag knows it. React state is what draws them,
130123 // and it commits when it commits — which is one push behind if the pointer
131124 // has already gone up. This is the same thing, now.
132125 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);
141127 // What the drag needs to know, kept current without the listeners below
142128 // 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;
145131
146132 const moved = () => ring.current?.dispatchEvent(new CustomEvent('wallmoved', { bubbles: true }));
147133
148134 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;
151136 // A wall being pushed, not the table being reached into.
152137 e.stopPropagation();
153138 e.preventDefault();
154- const rad = (place.dir * Math.PI) / 180;
155- const ax = Math.cos(rad);
156- const ay = Math.sin(rad);
157139 try {
158140 e.currentTarget.setPointerCapture?.(e.pointerId);
159141 } catch {
160142 // Uncaptured still works while the pointer stays over the wall.
161143 }
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] };
171145 };
172146
173147 // Put on once and left alone. Registering these per render was three
⋯ 2 unchanged lines
176150 const move = (e: PointerEvent) => {
177151 const d = drag.current;
178152 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;
181155 const clamp = (v: number) => Math.min(bound, Math.max(-bound, v));
182156 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),
185159 };
186160 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;
188162 live.current = live.current.map((v, i) => (i === d.side ? to : v));
189163 setSlide(live.current);
190164 moved();
⋯ 6 unchanged lines
197171 // Said out loud at the end of the push, not during it: what the fingers
198172 // found, in the form it would be written down in.
199173 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)}`;
201175 console.log(
202176 `[wall] ${WALL_SIDES[d.side]} pushed to ${say(at[d.side])} — ` +
203177 WALL_SIDES.map((cls, i) => `${cls} ${say(at[i])}`).join(' · '),
⋯ 13 unchanged lines
217191 return { slide, take };
218192 }
219193
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. */
221195 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;
233198 }
234199
235200 /**
236201 * How big to build the square, measured rather than worked out twice: how big a
237202 * 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.
241206 */
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);
244209
245210 useLayoutEffect(() => {
246211 const el = ring.current;
⋯ 1 unchanged line
248213 const probe = el?.querySelector<HTMLElement>('.wall-probe');
249214 if (!el || !middle || !probe) return;
250215
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.
256220 let last = '';
257221
258222 const read = () => {
⋯ 2 unchanged lines
261225 // What shape a stack is, is CSS's business — read it, don't restate it.
262226 // Scale-free, so this is the same number however big the square came out.
263227 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;
270228
271229 // Sized off the tight way across the table, not the room between the four
272230 // 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.
276234 const fit = (el.closest('.table') as HTMLElement | null) ?? middle;
277235 const room = fit.clientHeight;
278- const from = `${room}/${ratio}/${angle}/${turn}/${gap}`;
236+ const from = `${Math.round(room)}/${ratio.toFixed(3)}`;
279237 if (from === last) return;
280238 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));
287240 };
288241
289242 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,
291244 // and watching it would be watching itself. The probe is not watched for
292245 // the same reason — it is `--ws` wearing a box, and `--ws` is the answer.
246+ const watch = (el.closest('.table') as HTMLElement | null) ?? middle;
293247 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();
306250 }, [ring]);
307251
308252 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>