anvilsign in

collin/mahjong · 3e1678b5

Rebuild the wall as a smaller square, out of what is left of it

Collin Richards · 2026-08-19 21:35 UTC · 3e1678b5383447d140e5d47a43a143103141364d · parent 8e93cfa1 · browse files

modifiedREADME.md+33 −9
⋯ 39 unchanged lines
4040 and a 摸 label, instead of being sorted invisibly into it. It merges into the
4141 hand once you discard. 補花 replacements and kong replacements are marked the
4242 same way.
43-- **The wall** is drawn in the centre as a square: four staggered sides of 18
44- stacks of two, the way it's built on a real table. The gold stack is the break
45- point you draw from, stacks shrink to a single tile and then vanish as they're
46- used, and the dimmed tail is the 16-tile 底牌 that ends the hand — which is
47- also the end kong and flower replacements are taken from, so the square is
48- eaten from both directions at once.
43+- **The wall** is drawn in the centre as a square of four staggered sides, laid
44+ out like a `#` with the corners open, the way it's built on a real table. The
45+ gold stack is the break point you draw from, stacks shrink to a single tile and
46+ then vanish as they're used, and the dimmed tail is the 16-tile 底牌 that ends
47+ the hand — which is also the end kong and flower replacements are taken from,
48+ so the square is eaten from both directions at once. Only what is still
49+ standing is drawn, and it is evened up over the four sides as it goes, which
50+ is what lets every tile left in it be the size of a tile in your hand — see
51+ [what is left of the wall](#what-is-left-of-the-wall).
4952 - **Discard** — drag a tile out of your row and it comes up out of your hand and
5053 follows your finger anywhere on the table; let go and it goes in, from where
5154 you let go and at the speed you let go at. Dragging *along* the row is still
⋯ 320 unchanged lines
372375 piece.
373376
374377 Nothing about the square is calculated twice. Its size lives entirely in CSS
375-(`--ring`, `--ws`, `--wd`), so `geometry.ts` **measures** it instead of restating
376-that arithmetic: `WallRing` already renders every stack as a real element tagged
377-with its index, and the colliders are those elements' bounding rects. That one
378+(`--ws`, `--wd`), so `geometry.ts` **measures** it instead of restating that
379+arithmetic: `WallRing` already renders every stack as a real element tagged with
380+its index, and the colliders are those elements' bounding rects. That one
378381 mapping is what makes the wall a physical object — the thing a thrown tile
379382 bounces off, and the thing the throw gate casts its ray at.
380383
384+## What is left of the wall
385+
386+Four walls of eighteen full-size stacks want about 560px a side. No ordinary
387+window has that between the top and bottom strips, and the tile is not allowed
388+to shrink to make it fit — a tile in the wall is the same tile you play with, so
389+drawing it smaller was a visible lie.
390+
391+What saves it is that nobody ever sees a whole wall: four hands come off the
392+front before the table is on screen, so barely half of it is left by then. So
393+only the stacks still standing are drawn, and they are laid out around a ring
394+cut to the **middle** rather than to eighteen — `ringLayout` in `game/wall.ts`
395+measures how many stacks a side can take (`WallRing` renders one hidden cell and
396+asks CSS how big it came out) and shares what is left over the four sides in
397+proportion. Every seat keeps a wall in front of it instead of the remainder
398+piling into one corner, and the order runs all the way round, so the break point
399+is still at the head of it and the 底牌 tail still at the end.
400+
401+Shuffling the wall along as it is eaten costs nothing to look at, because every
402+stack shows the same green back — and it is what people do to a half-eaten wall
403+anyway.
404+
381405 `pool.ts` never listens for events. It **diffs** the discards against what it is
382406 already drawing, keyed by `seat:index` — and those keys never shift, because
383407 discards are only ever pushed and a claim only ever pops the one on top. So a
⋯ 4 unchanged lines
modifiedsrc/game/wall.ts+107 −1
⋯ 66 unchanged lines
6767 */
6868 export const SEAT_WALL_SIDE: Record<SeatId, number> = { 0: 2, 1: 1, 2: 0, 3: 3 };
6969
70-/** The stacks making up one side, in the order that side is drawn. */
70+/** The stacks making up one quarter of the square as it was built. */
7171 export function sideStacks(stacks: Stack[], side: number): Stack[] {
7272 return stacks.slice(side * STACKS_PER_SIDE, (side + 1) * STACKS_PER_SIDE);
7373 }
74+
75+/** How many stacks a side of the square holds, once the middle is measured. */
76+export interface RingCapacity {
77+ /** Along the top and the bottom. */
78+ h: number;
79+ /** Down the left and the right. */
80+ v: number;
81+}
82+
83+/** Dealt out before anybody looks at the table: sixteen tiles, four ways. */
84+const DEALT = 64;
85+/**
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.
93+ */
94+export const DEALT_STACKS = WALL_STACKS - DEALT / 2;
95+
96+/**
97+ * The square to build, given the middle to build it in and the size of a stack.
98+ *
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.
103+ */
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);
111+
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 };
120+}
121+
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),
126+});
127+
128+/** A stack and which of the 72 it is — the index is what the physics reads. */
129+export interface Placed {
130+ index: number;
131+ stack: Stack;
132+}
133+
134+/**
135+ * Where what is left of the wall actually goes.
136+ *
137+ * Four walls of eighteen full-size stacks want about 560px a side, and the
138+ * middle of the table is nothing like that in both directions at once — so a
139+ * square built to the tile is a square that hangs out under the players. What
140+ * saves it is that by the time anyone is looking, four hands have been dealt
141+ * off the front and there is nothing like a whole wall left: only what is still
142+ * standing is drawn, and it is laid out around a ring cut to the middle rather
143+ * than to the tile. Players push the remaining stacks about to keep them tidy
144+ * for exactly this reason.
145+ *
146+ * The rebuilt square is the size of the wall as it is dealt, so at the start
147+ * the run fills it exactly, and from then on it is simply *eaten*: the tail is
148+ * pinned to the end of the square and normal draws take stacks off the front,
149+ * which leaves a growing gap where they were and moves nothing else. Kong and
150+ * flower replacements come off the other end and shorten it from there. Both
151+ * ends are where they would be on a table, and the square keeps the size it was
152+ * built at — a square that shrank every draw would close in on the discards
153+ * lying inside it.
154+ */
155+export function ringLayout(stacks: Stack[], cap: RingCapacity): Placed[][] {
156+ const sides: Placed[][] = [[], [], [], []];
157+ const lengths = [cap.h, cap.v, cap.h, cap.v];
158+ const slots = 2 * (cap.h + cap.v);
159+ if (slots <= 0) return sides;
160+
161+ // Each stack has its own place in the square and keeps it: the far end is the
162+ // far end of the square, and everything counts back from there. Draws off the
163+ // front open a gap at the break point, draws off the tail shorten the other
164+ // end, and no tile that is still standing ever has to move.
165+ const offset = WALL_STACKS - slots;
166+
167+ for (let index = 0; index < stacks.length; index++) {
168+ const stack = stacks[index];
169+ // A wall too long for its square only happens before a hand is dealt, and
170+ // then only for the frame it takes to deal it. What falls off the start is
171+ // the part about to be drawn anyway.
172+ let at = index - offset;
173+ if (stack.count === 0 || at < 0 || at >= slots) continue;
174+ let side = 0;
175+ while (at >= lengths[side]) at -= lengths[side++];
176+ sides[side].push({ index, stack });
177+ }
178+ return sides;
179+}
modifiedsrc/styles.css+38 −40
⋯ 613 unchanged lines
614614 }
615615 /* The wall: a square of four staggered sides, 18 stacks of two each, drawn
616616 with the tileset's own blank tile. Sits behind the centre readout. */
617-/* The wall, built the way it is built on a table: four walls of eighteen stacks
618- laid out like a # rather than mitred into a closed square. Each one runs the
619- full eighteen and overhangs the corner of the next, which leaves the corners
620- open and — the point of it — means the opening in the middle no longer has to
621- be as long as a wall. That is what lets every tile on the table be the same
622- size: a closed square of eighteen full-size stacks a side needs about 560px in
623- the middle, which most windows have not got, so something had to shrink. Here
624- the tails simply run out under the players' strips instead.
617+/* The wall, built the way it is built on a table: four walls laid out like a #
618+ rather than mitred into a closed square, each one overhanging the corner of
619+ the next so the corners stay open.
620+
621+ Built to the wall as it is dealt rather than to 144 tiles: eighteen full-size
622+ stacks a side is about 560px, which no ordinary window has between the top
623+ and bottom strips, and by the time anyone is looking there is barely half a
624+ wall left to lay out. How big the square comes out and which stack goes where
625+ is `game/wall.ts`, measured and applied by `ui/WallRing` — the size below is
626+ only of one stack.
625627
626- `.wall-ring` is the opening, not the outside: the sides hang off its edges. */
628+ `.wall-ring` is the opening, not the outside: the sides hang off its edges,
629+ so the square encloses the pool the way it does on a table. */
627630 .wall-ring {
631+ --ws: var(--tile-w);
632+ --wd: calc(var(--ws) * 1.375);
628633 position: absolute;
629634 top: 50%;
630635 left: 50%;
631- --ws: var(--tile-w);
632- --wd: calc(var(--ws) * 1.375);
633- --wall-len: calc(var(--ws) * 18);
634- --ring: min(
635- calc(100vh - 2 * var(--depth) - 16px),
636- calc(100vw - 2 * var(--depth) - 16px),
637- var(--wall-len)
638- );
639- /* How far a wall runs past the opening. Nothing at all once the middle is big
640- enough to take a whole wall, which is when a plain square is right anyway. */
641- --over: calc(var(--wall-len) - var(--ring));
642- width: var(--ring);
643- height: var(--ring);
636+ width: 0;
637+ height: 0;
644638 transform: translate(-50%, -50%);
645639 pointer-events: none;
646640 z-index: 0;
647641 }
642+/* Never drawn. It is here to be measured: how big a stack came out is CSS's
643+ answer, and the layout needs it in stacks before it has placed any. */
644+.wall-probe {
645+ position: absolute;
646+ visibility: hidden;
647+ width: var(--ws);
648+ height: var(--wd);
649+}
650+/* Each wall sits outside its edge of the opening, flush at the end it is built
651+ from and running one wall-depth past the far corner — the same way round each
652+ time, which is what turns four straight walls into a pinwheel. A wall shorter
653+ than its side has been drawn from, so what is left of it packs towards the
654+ far end and the gap opens at the break. */
648655 .wall-side {
649656 position: absolute;
650657 display: flex;
658+ justify-content: flex-end;
651659 }
652-/* Each wall sits outside its edge of the opening and overhangs one end of it,
653- the same way round each time — which is what turns four straight walls into a
654- pinwheel with its corners open. */
655660 .wall-side.top {
656661 top: calc(-1 * var(--wd));
657- left: calc(-1 * var(--over));
658- width: var(--wall-len);
662+ left: 0;
663+ right: calc(-1 * var(--wd));
659664 height: var(--wd);
660665 }
661666 .wall-side.right {
662667 right: calc(-1 * var(--wd));
663- top: calc(-1 * var(--over));
668+ top: 0;
669+ bottom: calc(-1 * var(--wd));
664670 width: var(--wd);
665- height: var(--wall-len);
666671 flex-direction: column;
667672 }
668673 .wall-side.bottom {
669674 bottom: calc(-1 * var(--wd));
670- right: calc(-1 * var(--over));
671- width: var(--wall-len);
675+ right: 0;
676+ left: calc(-1 * var(--wd));
672677 height: var(--wd);
673678 flex-direction: row-reverse;
674679 }
675680 .wall-side.left {
676681 left: calc(-1 * var(--wd));
677- bottom: calc(-1 * var(--over));
682+ bottom: 0;
683+ top: calc(-1 * var(--wd));
678684 width: var(--wd);
679- height: var(--wall-len);
680685 flex-direction: column-reverse;
681686 }
682687 .wall-stack {
⋯ 30 unchanged lines
713718 .wall-side.left .wall-stack.s1 i,
714719 .wall-side.right .wall-stack.s1 i {
715720 transform: rotate(90deg) scale(0.86);
716-}
717-/* Spent stacks stay as ghosts so the square keeps its shape. */
718-.wall-stack.s0 i {
719- background: none;
720- filter: none;
721- border: 1px dashed rgba(246, 240, 226, 0.11);
722- border-radius: 2px;
723721 }
724722 .wall-stack.dead i {
725723 opacity: 0.4;
⋯ 1245 unchanged lines
modifiedsrc/table/geometry.ts+18 −6
⋯ 4 unchanged lines
55 /**
66 * Where everything on the table actually is, in pixels.
77 *
8- * The wall square's size lives entirely in CSS — `--ring`, `--ws`, `--wd` in
9- * styles.css work it out from the viewport and the strip depth — so rather than
10- * restate that arithmetic here and let the two drift apart, this *measures* it.
8+ * The wall's size lives entirely in CSS — `--ws` and `--wd` in styles.css work
9+ * it out from the tile, and how much of it there is to lay out is decided in
10+ * `game/wall.ts` — so rather than restate any of that here and let them drift
11+ * apart, this *measures* what came out.
1112 * `WallRing` renders every stack as a real element tagged with its index, so the
1213 * colliders come from `getBoundingClientRect()` on the things already on screen.
1314 * That is the whole mapping from the HTML to the physics.
⋯ 57 unchanged lines
7172 // A stack element is --ws by --wd (or the other way round on the left and
7273 // right sides), so its narrower side is the tile's width whichever wall it is
7374 // in — the fallback for sizing a pool tile before any hand has been dealt.
74- let short = 0;
75+ // The probe is one cell that is always there, spent wall or not.
76+ const probe = ring.querySelector<HTMLElement>('.wall-probe')?.getBoundingClientRect();
77+ let short = probe ? Math.min(probe.width, probe.height) : 0;
7578 for (const el of ring.querySelectorAll<HTMLElement>('[data-stack]')) {
7679 const i = Number(el.dataset.stack);
7780 const r = local(el.getBoundingClientRect());
⋯ 88 unchanged lines
166169 * off to one side counts, which is how you would actually throw it.
167170 */
168171 export function canThrow(geo: TableGeometry, seat: SeatId): boolean {
169- const from = geo.launch[seat];
172+ const from = throwsFrom(geo, seat);
170173 return !!from && canThrowFrom(geo, from);
171174 }
172175
173176 /**
177+ * Where a tile this seat plays actually starts from — the end of the row, not
178+ * the middle of it. The same point `pool.ts` launches from, and it has to be:
179+ * asking whether the line is clear from somewhere the tile is not is how a tile
180+ * gets waved through and then stopped dead by a stack it was never clear of.
181+ */
182+const throwsFrom = (geo: TableGeometry, seat: SeatId): Point | undefined =>
183+ geo.throwFrom[seat] ?? geo.launch[seat];
184+
185+/**
174186 * The same question asked from a particular point rather than from a seat.
175187 *
176188 * This is the one that matters for a tile somebody is holding: they have picked
⋯ 12 unchanged lines
189201
190202 /** Somewhere in the pool this seat could actually get a tile to, or null. */
191203 export function aimAt(geo: TableGeometry, seat: SeatId): Point | null {
192- const from = geo.launch[seat];
204+ const from = throwsFrom(geo, seat);
193205 if (!from) return null;
194206 const open = aimPoints(geo).filter((to) => clearLine(geo.walls, from, to));
195207 if (open.length === 0) return null;
⋯ 55 unchanged lines
modifiedsrc/table/physics.test.ts+104 −1
11 import { describe, expect, it } from 'vitest';
22 import { makeRng, STACKS_PER_SIDE } from '../game/tiles';
3-import { isBarrier, SEAT_WALL_SIDE, sideStacks, wallStacks } from '../game/wall';
3+import {
4+ DEALT_STACKS,
5+ isBarrier,
6+ ringFor,
7+ ringLayout,
8+ ringOpening,
9+ SEAT_WALL_SIDE,
10+ sideStacks,
11+ wallStacks,
12+ type Placed,
13+} from '../game/wall';
414 import type { SeatId } from '../game/types';
515 import {
616 addBody,
⋯ 418 unchanged lines
425435 expect(sideStacks(s, 0)).toHaveLength(STACKS_PER_SIDE);
426436 });
427437 });
438+
439+describe('ringFor', () => {
440+ // A tile as it comes out on an ordinary window, and the stack it makes.
441+ const cell = { s: 31, d: 43 };
442+ const holds = (cap: { h: number; v: number }) => 2 * (cap.h + cap.v);
443+
444+ it('builds a square the size of the wall as it is dealt', () => {
445+ const cap = ringFor({ w: 990, h: 360 }, cell);
446+ expect(cap).toEqual({ h: 10, v: 10 });
447+ expect(holds(cap)).toBe(DEALT_STACKS);
448+ // And it leaves the middle of itself for the discards.
449+ expect(ringOpening(cap, cell).w).toBe(10 * 31 - 43);
450+ });
451+
452+ it('gives up being square before it gives up a tile', () => {
453+ // A middle too shallow for ten stacks down its side: the wall gets longer
454+ // rather than the tiles getting smaller.
455+ const cap = ringFor({ w: 990, h: 220 }, cell);
456+ expect(cap.v).toBeLessThan(10);
457+ expect(cap.h).toBeGreaterThan(10);
458+ expect(holds(cap)).toBeGreaterThanOrEqual(DEALT_STACKS);
459+ });
460+
461+ it('never asks for more room than it has, however small the middle', () => {
462+ for (const space of [{ w: 990, h: 360 }, { w: 400, h: 400 }, { w: 300, h: 140 }, { w: 60, h: 40 }]) {
463+ const cap = ringFor(space, cell);
464+ expect(cap.h).toBeGreaterThanOrEqual(1);
465+ expect(cap.v).toBeGreaterThanOrEqual(1);
466+ expect(cap.h * cell.s + cell.d).toBeLessThanOrEqual(Math.max(space.w, cell.s + cell.d));
467+ expect(cap.v * cell.s + cell.d).toBeLessThanOrEqual(Math.max(space.h, cell.s + cell.d));
468+ }
469+ });
470+});
471+
472+describe('ringLayout', () => {
473+ const at = (drawnFront: number, drawnBack = 0) =>
474+ wallStacks({ drawnFront, drawnBack, rules: { wallReserve: 16 } });
475+ const flat = (sides: Placed[][]) => sides.flat();
476+ // A middle much wider than it is deep, which is what an ordinary window has.
477+ const wide = { h: 30, v: 10 };
478+
479+ it('draws what is still standing and nothing else', () => {
480+ const sides = ringLayout(at(65), wide);
481+ const placed = flat(sides);
482+ expect(placed).toHaveLength(40);
483+ expect(placed.every((p) => p.stack.count > 0)).toBe(true);
484+ // In order all the way round: the break point first, the 底牌 tail last.
485+ expect(placed[0].index).toBe(32);
486+ expect(placed[0].stack.next).toBe(true);
487+ expect(placed[placed.length - 1].index).toBe(71);
488+ expect(placed.every((p, i) => i === 0 || p.index > placed[i - 1].index)).toBe(true);
489+ });
490+
491+ it('fills the square it was built for, then is eaten from the break point', () => {
492+ // Built to the wall as dealt, so at the deal it comes out full and square.
493+ const square = { h: 10, v: 10 };
494+ expect(ringLayout(at(64), square).map((s) => s.length)).toEqual([10, 10, 10, 10]);
495+ // From there the gap opens where the drawing is happening and nothing else
496+ // moves: the far end stays pinned to the end of the square.
497+ const eaten = ringLayout(at(78), square);
498+ expect(eaten.map((s) => s.length)).toEqual([3, 10, 10, 10]);
499+ expect(eaten[0][0].stack.next).toBe(true);
500+ // And the other end shortens when a kong or a flower takes from the tail.
501+ expect(ringLayout(at(64, 6), square).map((s) => s.length)).toEqual([10, 10, 10, 7]);
502+ });
503+
504+ it('never puts more on a side than fits, however little ring there is', () => {
505+ for (const cap of [
506+ { h: 30, v: 10 },
507+ { h: 18, v: 18 },
508+ { h: 6, v: 2 },
509+ { h: 1, v: 1 },
510+ ]) {
511+ for (const front of [0, 1, 65, 100, 128]) {
512+ const sides = ringLayout(at(front), cap);
513+ const lengths = [cap.h, cap.v, cap.h, cap.v];
514+ sides.forEach((side, i) => expect(side.length).toBeLessThanOrEqual(lengths[i]));
515+ const live = at(front).filter((s) => s.count > 0).length;
516+ expect(flat(sides)).toHaveLength(Math.min(live, 2 * (cap.h + cap.v)));
517+ }
518+ }
519+ });
520+
521+ it('keeps the far end of the wall when there is not room for all of it', () => {
522+ // Overflow only happens before a hand is dealt. What has to go is the front,
523+ // since that is the part about to be drawn anyway.
524+ const sides = ringLayout(at(0), { h: 6, v: 2 });
525+ const placed = flat(sides);
526+ expect(placed).toHaveLength(16);
527+ expect(placed[placed.length - 1].index).toBe(71);
528+ expect(placed[0].index).toBe(56);
529+ });
530+});
modifiedsrc/table/pool.ts+7 −5
⋯ 75 unchanged lines
7676 * Whether a wall standing in the way turns a discard into a lob over the top
7777 * rather than a flick across the table.
7878 *
79- * Off for now, deliberately: every discard slides, because sliding is what a
80- * discard should look like. The lob is kept whole below and this is the only
81- * switch that reaches it — when there is a rule for when a throw is called for
82- * instead, it goes here and nothing else has to move.
79+ * Back on now that the square is a square again. It was off while the wall ran
80+ * along the edges of the middle, where there was nothing much to get over and
81+ * everything read better slid; a square standing in the middle with the pool
82+ * inside it is the other case, and a tile slid flat at it simply bounces off
83+ * and ends up lying outside — which is not where a discard goes. As the square
84+ * is eaten the gaps open and the slide comes back on its own, seat by seat.
8385 */
84-const LOB_WHEN_WALLED: boolean = false;
86+const LOB_WHEN_WALLED: boolean = true;
8587
8688 /** A tile lifted out of a hand and carried about. Drawn, but not simulated. */
8789 export interface Held {
⋯ 481 unchanged lines
modifiedsrc/ui/WallRing.tsx+96 −29
1-import { STACKS_PER_SIDE } from '../game/tiles';
2-import { WALL_SIDES, wallStacks } from '../game/wall';
1+import { useLayoutEffect, useRef, useState } from 'react';
2+import {
3+ ringFor,
4+ ringLayout,
5+ ringOpening,
6+ WALL_SIDES,
7+ wallStacks,
8+ type RingCapacity,
9+} from '../game/wall';
310 import type { GameState } from '../game/engine';
411
512 /**
6- * The wall as it sits on a real table: a square of four staggered sides, 18
7- * stacks of two to a side. Position 0 is the break point — draws eat the square
8- * from there, while kong and flower replacements come off the far end (the 底牌
9- * tail), so it is consumed from both directions at once. Spent stacks stay as
10- * faint ghosts so the square keeps its shape all game.
13+ * What is left of the wall, built as a square in the middle of the table.
1114 *
12- * What is left where is `game/wall.ts`, because the physics needs the same
13- * answer: a stack still standing is what a thrown tile has to clear.
15+ * Four staggered sides like a #, with the corners open — but a square the size
16+ * of the wall *as it is dealt*, not of 144 tiles. Eighteen full-size stacks a
17+ * side is longer than the middle of any ordinary window is tall, and by the
18+ * time anyone is looking there is nothing like that much wall left, so it is
19+ * rebuilt smaller and the tiles stay the size of the ones in your hand. What
20+ * goes where is `ringFor` and `ringLayout` in `game/wall.ts`; this measures the
21+ * middle for them and puts the answer on screen.
1422 *
1523 * Every stack carries its index, and that is load-bearing — `table/geometry.ts`
16- * measures these elements to build the colliders, rather than restating the CSS
17- * that sizes the square.
24+ * measures these elements to build the colliders rather than restating the CSS
25+ * that sizes them.
1826 */
1927 export function WallRing({ state }: { state: GameState }) {
20- const stacks = wallStacks(state);
28+ const ring = useRef<HTMLDivElement>(null);
29+ const { cap, opening } = useSquare(ring);
30+ const sides = ringLayout(wallStacks(state), cap);
2131
2232 return (
23- <div className="wall-ring" aria-label="牌牆">
24- {WALL_SIDES.map((cls, side) => {
25- const from = side * STACKS_PER_SIDE;
26- return (
27- <div className={`wall-side ${cls}`} key={cls}>
28- {stacks.slice(from, from + STACKS_PER_SIDE).map((st, i) => (
29- <div
30- key={i}
31- data-stack={from + i}
32- className={`wall-stack s${st.count}${st.dead ? ' dead' : ''}${st.next ? ' next' : ''}`}
33- >
34- <i />
35- </div>
36- ))}
37- </div>
38- );
39- })}
33+ <div
34+ className="wall-ring"
35+ ref={ring}
36+ aria-label="牌牆"
37+ style={opening ? { width: opening.w, height: opening.h } : undefined}
38+ >
39+ {/* One cell, never drawn: the ring is measured in stacks, so something has
40+ to be the size of a stack before any of them have been placed. */}
41+ <i className="wall-probe" />
42+ {WALL_SIDES.map((cls, side) => (
43+ <div className={`wall-side ${cls}`} key={cls}>
44+ {sides[side].map(({ index, stack }) => (
45+ <div
46+ key={index}
47+ data-stack={index}
48+ className={`wall-stack s${stack.count}${stack.dead ? ' dead' : ''}${
49+ stack.next ? ' next' : ''
50+ }`}
51+ >
52+ <i />
53+ </div>
54+ ))}
55+ </div>
56+ ))}
4057 </div>
4158 );
4259 }
60+
61+interface Square {
62+ cap: RingCapacity;
63+ /** The opening the square leaves, once there is a middle to measure. */
64+ opening: { w: number; h: number } | null;
65+}
66+
67+/**
68+ * How big to build the square, measured rather than worked out twice: how big a
69+ * tile is is CSS's business, so one cell is rendered and asked how it came out,
70+ * and the middle is whatever the four strips have left. Nothing here depends on
71+ * the state of the hand — the square is built once for the window it is in and
72+ * then eaten, so it does not close in on the discards lying inside it.
73+ */
74+function useSquare(ring: React.RefObject<HTMLDivElement | null>): Square {
75+ const [square, setSquare] = useState<Square>({ cap: { h: 1, v: 1 }, opening: null });
76+
77+ useLayoutEffect(() => {
78+ const el = ring.current;
79+ const middle = el?.parentElement;
80+ const probe = el?.querySelector<HTMLElement>('.wall-probe');
81+ if (!el || !middle || !probe) return;
82+
83+ const read = () => {
84+ const box = probe.getBoundingClientRect();
85+ const cell = { s: box.width, d: box.height };
86+ if (cell.s <= 0) return;
87+ const cap = ringFor({ w: middle.clientWidth, h: middle.clientHeight }, cell);
88+ const opening = ringOpening(cap, cell);
89+ setSquare((was) =>
90+ was.cap.h === cap.h &&
91+ was.cap.v === cap.v &&
92+ was.opening?.w === opening.w &&
93+ was.opening?.h === opening.h
94+ ? was
95+ : { cap, opening },
96+ );
97+ };
98+
99+ read();
100+ // The middle, not the square: the square's own size is this effect's doing,
101+ // and watching it would be watching itself.
102+ const observer = new ResizeObserver(read);
103+ observer.observe(middle);
104+ observer.observe(probe);
105+ return () => observer.disconnect();
106+ }, [ring]);
107+
108+ return square;
109+}