anvilsign in

collin/mahjong · 6e2784f0

The square is the wall again: eighteen of two a side, and it meets itself

Collin Richards · 2026-08-21 20:43 UTC · 6e2784f0e22cb28ae3f5b51bdfd2a1ebf4d397a7 · parent 81bd92f0 · browse files

modifiedREADME.md+16 −2
⋯ 554 unchanged lines
555555 nothing to clip it, so a tile lifted out of a hand crosses the table in one
556556 piece.
557557
558+The square is the whole wall: eighteen stacks of two a side, four sides, 144
559+tiles, the way it is built on a table. It used to be rebuilt to what was *left*
560+after a hand had been dealt — barely half of it — because eighteen full-size
561+stacks a side wants about 560px and no ordinary window has that between the top
562+and bottom strips. That bought a square at the price of it not being the wall:
563+a side ran out of stacks before it reached its corner, so the four of them never
564+met. What gives instead is the tile. A wall tile is not a hand tile — on a table
565+it is the same tile, but on a screen the wall has to fit the middle and the hand
566+has to be readable, and those are two different jobs, so `ringCell` solves for
567+how big a stack may be and `WallRing` hands the answer back to the CSS as
568+`--ws`.
569+
558570 The square is set at an angle — `--wall-turn` in `styles.css`, 45° — so its four
559571 arms run out towards the corners of the table rather than squaring up with its
560572 edges. That one property is the only place it is decided: `geometry.ts` reads it
561573 back off the element, so the colliders turn with it, and `WallRing` reads it too
562574 so the square is built smaller by however much the turn costs it (stood on its
563575 corner a square needs its diagonal where it used to need its side). Set it to
564-zero and everything squares up again. `--wall-room`, `--wall-x` and `--wall-y`
565-put it where you want it, and `npm run dev` serves **`/wall.html`** for turning
576+zero and everything squares up again. `--wall-lap` is how far each wall runs past its own corner, in wall-depths —
577+that overlap is the whole of what makes four straight walls a pinwheel, and at 0
578+they merely meet. `--wall-room`, `--wall-x` and `--wall-y` put it where you want
579+it, and `npm run dev` serves **`/wall.html`** for turning
566580 all four by hand — the real `WallRing` in a real `.center`, with every collider
567581 the physics would be handed drawn over the top, because "it looks right" and "a
568582 tile would bounce off it there" are two different claims.
⋯ 75 unchanged lines
modifiedsrc/game/wall.ts+50 −31
⋯ 82 unchanged lines
8383 /** Dealt out before anybody looks at the table: sixteen tiles, four ways. */
8484 const DEALT = 64;
8585 /**
86- * How much wall is left once a hand has been dealt — what the square is
87- * actually built to. Four walls of eighteen is what 144 tiles make while they
88- * are still in a neat square, and nobody ever sees that: by the time the table
89- * is on screen, four hands have come off the front and barely half of it is
90- * left. So the square is rebuilt to what is left, which is what lets it be a
91- * square at all — eighteen full-size stacks a side needs about 560px, and no
92- * ordinary window has that between the top and bottom strips.
86+ * How much wall is left once a hand has been dealt. The square is built to the
87+ * whole hundred and forty-four now rather than to this — see `RING` — so this
88+ * is only a fact about the wall, and what the tests measure a fresh deal
89+ * against.
9390 */
9491 export const DEALT_STACKS = WALL_STACKS - DEALT / 2;
9592
9693 /**
97- * The square to build, given the middle to build it in and the size of a stack.
94+ * The square is the whole wall: eighteen stacks of two a side, four sides, the
95+ * hundred and forty-four tiles, exactly as it is built on a table.
9896 *
99- * Square if it can be — that is the shape everyone knows — and only as long a
100- * side as the wall it has to hold. It gives up being square only when the
101- * middle is too shallow even for that, and then the extra goes along whichever
102- * way there is room, which is how it stays a wall rather than shrinking a tile.
97+ * It used to be rebuilt to what was *left* after a hand had been dealt — barely
98+ * half of it — because eighteen full-size stacks a side wants about 560px and no
99+ * ordinary window has that between the top and bottom strips. That bought a
100+ * square, at the price of it not being the wall: a side ran out of stacks before
101+ * it reached its corner, so the four of them never met.
102+ *
103+ * What gives instead is the tile. A wall tile is not a hand tile — on a table it
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`.
103106 */
104-export function ringFor(space: { w: number; h: number }, cell: { s: number; d: number }): RingCapacity {
105- // A side runs one wall-depth past its own corner — the overlap that makes the
106- // # — so a side of n stacks needs n * s + d across the middle.
107- const most = (across: number) => Math.max(1, Math.floor((across - cell.d) / cell.s));
108- const hMost = most(space.w);
109- const vMost = most(space.h);
110- const side = Math.ceil(DEALT_STACKS / 4);
107+export const RING: RingCapacity = { h: STACKS_PER_SIDE, v: STACKS_PER_SIDE };
111108
112- let h = Math.min(hMost, side);
113- let v = Math.min(vMost, side);
114- // Squashed by the middle: give back along whichever side still has room.
115- while (2 * (h + v) < DEALT_STACKS && (h < hMost || v < vMost)) {
116- if (h < hMost && (h <= v || v >= vMost)) h++;
117- else v++;
118- }
119- return { h, v };
109+/**
110+ * How big a stack may be, to build that square in the middle there is.
111+ *
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.
115+ *
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(
120+ space: { w: number; h: number },
121+ ratio: number,
122+ lap = ratio,
123+): number {
124+ // Outer footprint: the opening, plus a wall hanging off each side of it.
125+ // opening = n*s - lap*s walls = 2 * ratio*s
126+ // outer = (n + 2*ratio - lap) * s
127+ return Math.max(1, Math.min(space.w, space.h) / (STACKS_PER_SIDE + 2 * ratio - lap));
120128 }
121129
122-/** The opening the square leaves in the middle — where the discards go. */
123-export const ringOpening = (cap: RingCapacity, cell: { s: number; d: number }) => ({
124- w: Math.max(cell.s, cap.h * cell.s - cell.d),
125- h: Math.max(cell.s, cap.v * cell.s - cell.d),
130+/**
131+ * The opening the square leaves in the middle — where the discards go.
132+ *
133+ * A side holds its stacks and then runs `lap` past its own corner: that overlap
134+ * is the whole of what makes four straight walls a pinwheel, and it is what the
135+ * opening is short by. One wall-depth is the square everyone knows; less draws
136+ * the arms in, more throws them out.
137+ */
138+export const ringOpening = (
139+ cap: RingCapacity,
140+ cell: { s: number; d: number },
141+ lap = cell.d,
142+) => ({
143+ w: Math.max(cell.s, cap.h * cell.s - lap),
144+ h: Math.max(cell.s, cap.v * cell.s - lap),
126145 });
127146
128147 /** A stack and which of the 72 it is — the index is what the physics reads. */
⋯ 51 unchanged lines
modifiedsrc/styles.css+15 −17
⋯ 661 unchanged lines
662662 `.wall-ring` is the opening, not the outside: the sides hang off its edges,
663663 so the square encloses the pool the way it does on a table. */
664664 .wall-ring {
665- /* How big a tile in the wall is, against one in a hand.
666-
667- A side is only as long as the stacks that fit on it, so this is really the
668- knob for *how many* tiles the square holds: shrink it and more of them fit,
669- the sides reach their corners, and the pinwheel closes up. At 1 they are the
670- same tile and a side runs short, which is where the gaps at the corners come
671- from. Turned by hand in `/wall.html` like the rest of the square. */
672- --wall-tile: 1;
673- --ws: calc(var(--tile-w) * var(--wall-tile));
665+ /* A stack's short side. Set from ui/WallRing, not from here and not from the
666+ hand's tile: the square is eighteen stacks a side whatever the window is,
667+ so it is the *tile* that gives, and how much it gives is arithmetic on the
668+ middle. The fallback is only for the frame before the first measure. */
669+ --ws: var(--tile-w);
674670 --wd: calc(var(--ws) * 1.375);
675671 /* How far round the whole square is set. Four walls built the same way round
676672 are already a pinwheel; standing it on its corner is what sends the arms
⋯ 3 unchanged lines
680676 element, so the colliders turn with it and nothing has to be kept in step
681677 by hand. Zero squares it up again. */
682678 --wall-turn: 45deg;
679+ /* How far a wall runs past its own corner, in wall-depths. That overlap is
680+ the whole of what makes four straight walls a pinwheel: at 1 it is the
681+ square everyone knows, at 0 the four of them just meet, and above 1 the
682+ arms are thrown out past each other. */
683+ --wall-lap: 1;
684+ --lap: calc(var(--wd) * var(--wall-lap));
683685 /* How much of the room it has the square actually takes, and where its middle
684686 sits relative to the middle of the table. All three are turned by hand in
685687 `/wall.html` and pasted back here. */
⋯ 31 unchanged lines
717719 .wall-side.top {
718720 top: calc(-1 * var(--wd));
719721 left: 0;
720- right: calc(-1 * var(--wd));
722+ right: calc(-1 * var(--lap));
721723 height: var(--wd);
722724 }
723725 .wall-side.right {
724726 right: calc(-1 * var(--wd));
725727 top: 0;
726- bottom: calc(-1 * var(--wd));
728+ bottom: calc(-1 * var(--lap));
727729 width: var(--wd);
728730 flex-direction: column;
729731 }
730732 .wall-side.bottom {
731733 bottom: calc(-1 * var(--wd));
732734 right: 0;
733- left: calc(-1 * var(--wd));
735+ left: calc(-1 * var(--lap));
734736 height: var(--wd);
735737 flex-direction: row-reverse;
736738 }
737739 .wall-side.left {
738740 left: calc(-1 * var(--wd));
739741 bottom: 0;
740- top: calc(-1 * var(--wd));
742+ top: calc(-1 * var(--lap));
741743 width: var(--wd);
742744 flex-direction: column-reverse;
743745 }
⋯ 31 unchanged lines
775777 .wall-side.left .wall-stack.s1 i,
776778 .wall-side.right .wall-stack.s1 i {
777779 transform: rotate(90deg) scale(0.86);
778-}
779-.wall-stack.dead i {
780- opacity: 0.4;
781- filter: grayscale(0.6);
782780 }
783781 .wall-stack.next::after {
784782 content: '';
⋯ 1536 unchanged lines
modifiedsrc/table/physics.test.ts+36 −32
11 import { describe, expect, it } from 'vitest';
2-import { makeRng, STACKS_PER_SIDE } from '../game/tiles';
2+import { makeRng, STACKS_PER_SIDE, WALL_STACKS } from '../game/tiles';
33 import {
4- DEALT_STACKS,
54 isBarrier,
6- ringFor,
5+ ringCell,
6+ RING,
77 ringLayout,
88 ringOpening,
99 SEAT_WALL_SIDE,
⋯ 568 unchanged lines
578578 });
579579 });
580580
581-describe('ringFor', () => {
582- // A tile as it comes out on an ordinary window, and the stack it makes.
583- const cell = { s: 31, d: 43 };
584- const holds = (cap: { h: number; v: number }) => 2 * (cap.h + cap.v);
585-
586- it('builds a square the size of the wall as it is dealt', () => {
587- const cap = ringFor({ w: 990, h: 360 }, cell);
588- expect(cap).toEqual({ h: 10, v: 10 });
589- expect(holds(cap)).toBe(DEALT_STACKS);
590- // And it leaves the middle of itself for the discards.
591- expect(ringOpening(cap, cell).w).toBe(10 * 31 - 43);
592- });
593-
594- it('gives up being square before it gives up a tile', () => {
595- // A middle too shallow for ten stacks down its side: the wall gets longer
596- // rather than the tiles getting smaller.
597- const cap = ringFor({ w: 990, h: 220 }, cell);
598- expect(cap.v).toBeLessThan(10);
599- expect(cap.h).toBeGreaterThan(10);
600- expect(holds(cap)).toBeGreaterThanOrEqual(DEALT_STACKS);
601- });
602-
603- it('never asks for more room than it has, however small the middle', () => {
604- for (const space of [{ w: 990, h: 360 }, { w: 400, h: 400 }, { w: 300, h: 140 }, { w: 60, h: 40 }]) {
605- const cap = ringFor(space, cell);
606- expect(cap.h).toBeGreaterThanOrEqual(1);
607- expect(cap.v).toBeGreaterThanOrEqual(1);
608- expect(cap.h * cell.s + cell.d).toBeLessThanOrEqual(Math.max(space.w, cell.s + cell.d));
609- expect(cap.v * cell.s + cell.d).toBeLessThanOrEqual(Math.max(space.h, cell.s + cell.d));
581+describe('the square is the whole wall', () => {
582+ // A stack is a tile wide and 1.375 tiles deep, whatever a tile comes out as.
583+ const RATIO = 1.375;
584+
585+ 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 });
587+ expect(2 * (RING.h + RING.v)).toBe(WALL_STACKS);
588+ expect(2 * (RING.h + RING.v) * 2).toBe(144);
589+ });
590+
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);
610600 }
611601 });
602+
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);
615+ });
612616 });
613617
614618 describe('ringLayout', () => {
⋯ 58 unchanged lines
modifiedsrc/tuneWall.tsx+5 −5
⋯ 31 unchanged lines
3232 interface Placing {
3333 turn: number;
3434 room: number;
35- tile: number;
35+ lap: number;
3636 x: number;
3737 y: number;
3838 }
3939
40-const START: Placing = { turn: 45, room: 0.95, tile: 1, x: 0, y: 0 };
40+const START: Placing = { turn: 45, room: 0.95, lap: 1, x: 0, y: 0 };
4141
4242 const KNOBS: Knob[] = [
4343 { key: 'turn', label: 'Turn', min: 0, max: 90, step: 1, note: '° clockwise', unit: 'deg' },
4444 { key: 'room', label: 'Room it takes', min: 0.3, max: 1.3, step: 0.01, note: 'of the middle', unit: '' },
45- { key: 'tile', label: 'Tile size', min: 0.3, max: 1.4, step: 0.02, note: 'against a hand tile', unit: '' },
45+ { key: 'lap', label: 'Pinwheel lap', min: 0, max: 2, step: 0.05, note: 'wall-depths past the corner', unit: '' },
4646 { key: 'x', label: 'Across', min: -200, max: 200, step: 1, note: 'from the middle', unit: 'px' },
4747 { key: 'y', label: 'Down', min: -200, max: 200, step: 1, note: 'from the middle', unit: 'px' },
4848 ];
⋯ 25 unchanged lines
7474 if (!ring) return;
7575 ring.style.setProperty('--wall-turn', `${place.turn}deg`);
7676 ring.style.setProperty('--wall-room', `${place.room}`);
77- ring.style.setProperty('--wall-tile', `${place.tile}`);
77+ ring.style.setProperty('--wall-lap', `${place.lap}`);
7878 ring.style.setProperty('--wall-x', `${place.x}px`);
7979 ring.style.setProperty('--wall-y', `${place.y}px`);
8080 }, [place]);
⋯ 66 unchanged lines
147147 };
148148 }, [place, drawn, showColliders, state]);
149149
150- const css = `.wall-ring {\n --wall-turn: ${place.turn}deg;\n --wall-room: ${place.room};\n --wall-tile: ${place.tile};\n --wall-x: ${place.x}px;\n --wall-y: ${place.y}px;\n}`;
150+ const css = `.wall-ring {\n --wall-turn: ${place.turn}deg;\n --wall-room: ${place.room};\n --wall-lap: ${place.lap};\n --wall-x: ${place.x}px;\n --wall-y: ${place.y}px;\n}`;
151151
152152 return (
153153 <div className="tune">
⋯ 84 unchanged lines
modifiedsrc/ui/WallRing.tsx+37 −23
11 import { useLayoutEffect, useRef, useState } from 'react';
22 import {
3- ringFor,
3+ ringCell,
44 ringLayout,
55 ringOpening,
6+ RING,
67 WALL_SIDES,
78 wallStacks,
89 type RingCapacity,
⋯ 10 unchanged lines
1920 * side is longer than the middle of any ordinary window is tall, and by the
2021 * time anyone is looking there is nothing like that much wall left, so it is
2122 * rebuilt smaller and the tiles stay the size of the ones in your hand. What
22- * goes where is `ringFor` and `ringLayout` in `game/wall.ts`; this measures the
23+ * goes where is `RING` and `ringLayout` in `game/wall.ts`; this measures the
2324 * middle for them and puts the answer on screen.
2425 *
2526 * Every stack carries its index, and that is load-bearing — `table/geometry.ts`
⋯ 3 unchanged lines
2930 export function WallRing({ state }: { state: WallProgress }) {
3031 const t = useT();
3132 const ring = useRef<HTMLDivElement>(null);
32- const { cap, opening } = useSquare(ring);
33+ const { cap, opening, cell } = useSquare(ring);
3334 const sides = ringLayout(wallStacks(state), cap);
3435
3536 return (
⋯ 1 unchanged line
3738 className="wall-ring"
3839 ref={ring}
3940 aria-label={t('牌牆', 'The wall')}
40- style={opening ? { width: opening.w, height: opening.h } : undefined}
41+ style={
42+ opening
43+ ? // `--ws` is set from here rather than from the tile, because the
44+ // square is the fixed thing now and the tile is what gives.
45+ ({ width: opening.w, height: opening.h, '--ws': `${cell}px` } as React.CSSProperties)
46+ : undefined
47+ }
4148 >
4249 {/* One cell, never drawn: the ring is measured in stacks, so something has
4350 to be the size of a stack before any of them have been placed. */}
⋯ 4 unchanged lines
4855 <div
4956 key={index}
5057 data-stack={index}
51- className={`wall-stack s${stack.count}${stack.dead ? ' dead' : ''}${
52- stack.next ? ' next' : ''
53- }`}
58+ // The 16-tile 底牌 tail is not marked out. It is still the tail —
59+ // `Stack.dead` says so and the engine ends the hand on it — but a
60+ // wall on a table is one wall, not a wall with the end greyed off.
61+ className={`wall-stack s${stack.count}${stack.next ? ' next' : ''}`}
5462 >
5563 <i />
5664 </div>
⋯ 8 unchanged lines
6573 cap: RingCapacity;
6674 /** The opening the square leaves, once there is a middle to measure. */
6775 opening: { w: number; h: number } | null;
76+ /** How big a stack came out — the wall's own tile size, in px. */
77+ cell: number;
6878 }
6979
7080 /**
⋯ 4 unchanged lines
7585 * then eaten, so it does not close in on the discards lying inside it.
7686 */
7787 function useSquare(ring: React.RefObject<HTMLDivElement | null>): Square {
78- const [square, setSquare] = useState<Square>({ cap: { h: 1, v: 1 }, opening: null });
88+ const [square, setSquare] = useState<Square>({ cap: RING, opening: null, cell: 0 });
7989
8090 useLayoutEffect(() => {
8191 const el = ring.current;
⋯ 3 unchanged lines
8595
8696 const read = () => {
8797 const box = probe.getBoundingClientRect();
88- const cell = { s: box.width, d: box.height };
89- if (cell.s <= 0) return;
98+ if (box.width <= 0) return;
99+ // What shape a stack is, is CSS's business — read it, don't restate it.
100+ const ratio = box.height / box.width;
90101 // Stood on its corner, a square needs its *diagonal* where it used to
91102 // need its side — so the room it has to be built in shrinks by however
92103 // much the turn costs. Square on that is 1 and nothing changes; at 45° it
⋯ 3 unchanged lines
96107 const turn = (parseFloat(style.getPropertyValue('--wall-turn')) || 0) * (Math.PI / 180);
97108 const spread = Math.abs(Math.cos(turn)) + Math.abs(Math.sin(turn));
98109 const room = parseFloat(style.getPropertyValue('--wall-room')) || 1;
99- // The walls hang *outside* the opening, one depth on each side, and
100- // `ringFor` only expects to be told about one of them. Turned, the other
101- // one is what runs off the table, so it comes off here.
102- const cap = ringFor(
110+ // How far a wall runs past its corner, as a share of a stack's short side.
111+ const lapOf = parseFloat(style.getPropertyValue('--wall-lap'));
112+ const lap = ratio * (Number.isFinite(lapOf) ? lapOf : 1);
113+ // Eighteen stacks and one depth of overlap is `(18 + ratio) * s` across,
114+ // and turned that covers `spread` times as much — so the room it has to
115+ // be solved in is the middle divided by the turn.
116+ const s = ringCell(
103117 {
104- w: (middle.clientWidth * room) / spread - cell.d,
105- h: (middle.clientHeight * room) / spread - cell.d,
118+ w: (middle.clientWidth * room) / spread,
119+ h: (middle.clientHeight * room) / spread,
106120 },
107- cell,
121+ ratio,
122+ lap,
108123 );
109- const opening = ringOpening(cap, cell);
124+ // The square is always eighteen a side; what fits it is the tile.
125+ const cell = { s, d: s * ratio };
126+ const opening = ringOpening(RING, cell, s * lap);
110127 setSquare((was) =>
111- was.cap.h === cap.h &&
112- was.cap.v === cap.v &&
113- was.opening?.w === opening.w &&
114- was.opening?.h === opening.h
128+ was.cell === s && was.opening?.w === opening.w && was.opening?.h === opening.h
115129 ? was
116- : { cap, opening },
130+ : { cap: RING, opening, cell: s },
117131 );
118132 };
119133
⋯ 11 unchanged lines