anvilsign in

collin/mahjong · e2f4495f

Break the wall where the dice say

Collin Richards · 2026-08-25 07:30 UTC · e2f4495f95ecf4b029ca8e050ed565b9e7453f33 · parent 9737a8f7 · browse files

modifiedsrc/App.tsx+2 −1
⋯ 10 unchanged lines
1111 import { NetSession } from './net/session';
1212 import { Bi, BiText } from './ui/Bi';
1313 import { Center } from './ui/Center';
14-import { showingColliders } from './ui/colliders';
14+import { exposeGame, showingColliders } from './ui/colliders';
1515 import { useCompact } from './ui/compact';
1616 import { Controller } from './ui/Controller';
1717 import { FullscreenToggle } from './ui/FullscreenToggle';
⋯ 34 unchanged lines
5252 // settings screen edits the live game rather than replacing it.
5353 const [game] = useState(() => new Game(prefs.names, prefs.rules));
5454 useSyncExternalStore(game.subscribe, game.getSnapshot);
55+ exposeGame(game);
5556 // The tiles in the middle. Owned here rather than inside <Pool> because the
5657 // seats need it too: a hand has to know whether it can be thrown from, and
5758 // which seats those are changes as the wall is eaten away.
⋯ 419 unchanged lines
modifiedsrc/game/ctl.ts+7 −0
⋯ 44 unchanged lines
4545 undoable(): { zh: string; en: string } | null;
4646 undo(): void;
4747
48+ /**
49+ * Throw the dice again, moving where the wall is broken and nothing else.
50+ * Only the engine has it — an online table's wall is the host's business —
51+ * so it is what the dice tool checks for before it draws itself.
52+ */
53+ reroll?(...dice: number[]): number[];
54+
4855 canSelfDrawWin(seat: SeatId): boolean;
4956 concealedKongs(seat: SeatId): Tile[];
5057 addedKongs(seat: SeatId): Tile[];
⋯ 21 unchanged lines
modifiedsrc/game/engine.ts+31 −1
⋯ 23 unchanged lines
2424 type Rules,
2525 type SeatId,
2626 } from './types';
27+import { rollDice, type Dice } from './wall';
2728
2829 export type Phase = 'lobby' | 'turn' | 'claims' | 'robkong' | 'handEnd' | 'gameEnd';
2930
⋯ 85 unchanged lines
115116 sacred: Tile[][];
116117 afterKong: boolean;
117118 robKongTile: Tile | null;
119+ /**
120+ * 擲骰 — what the dealer threw to open this hand. Where the wall is broken
121+ * and nothing else: the tiles were shuffled before the square was built, so
122+ * the dice move the gap round the table rather than change anybody's hand.
123+ * `breakAt` in game/wall.ts reads them.
124+ */
125+ dice: Dice;
118126 /** How many of the original 144 wall positions have been taken off the front. */
119127 drawnFront: number;
120128 /** ...and off the back (kong / flower replacements). */
⋯ 54 unchanged lines
175183 sacred: [[], [], [], []],
176184 afterKong: false,
177185 robKongTile: null,
186+ dice: [1, 1, 1],
178187 drawnFront: 0,
179188 drawnBack: 0,
180189 result: null,
⋯ 68 unchanged lines
249258 this.emit();
250259 }
251260
261+ /**
262+ * Throw the dice again without touching the tiles — `game.reroll(6, 6, 6)`
263+ * from the console, or `game.reroll()` for a fresh throw.
264+ *
265+ * Only the break moves, which is the only thing the dice decide here: the
266+ * wall was shuffled before it was built, so nobody's hand changes and the
267+ * draw order is the draw order. It is how the square gets looked at broken
268+ * somewhere other than wherever this hand happened to open.
269+ */
270+ reroll(...dice: number[]) {
271+ this.state.dice =
272+ dice.length === 3 ? (dice as Dice) : rollDice(makeRng(Math.floor(Math.random() * 2 ** 31)));
273+ this.emit();
274+ return this.state.dice;
275+ }
276+
252277 get hasBots() {
253278 return this.state.bots.some(Boolean);
254279 }
⋯ 71 unchanged lines
326351 // Once the next hand is dealt the last one is settled and paid; there is
327352 // nothing left to take back, and the tiles are already shuffled away.
328353 this.undoStack = [];
329- s.wall = shuffle(buildWall(), makeRng(seed));
354+ // One rng for the whole hand's setup, so the same seed rebuilds the same
355+ // wall *and* throws the same dice — a hand replayed is the hand that was
356+ // played, gap and all.
357+ const rng = makeRng(seed);
358+ s.wall = shuffle(buildWall(), rng);
359+ s.dice = rollDice(rng);
330360 s.handNumber++;
331361 for (const p of s.players) {
332362 p.hand = [];
⋯ 698 unchanged lines
modifiedsrc/game/save.ts+1 −1
⋯ 2 unchanged lines
33
44 const KEY = 'taiwanese-mahjong/save';
55 /** Bump when GameState changes shape; older saves are then ignored rather than crashing. */
6-const VERSION = 3;
6+const VERSION = 4;
77
88 export interface SavedGame {
99 version: number;
⋯ 57 unchanged lines
modifiedsrc/game/wall.ts+79 −7
⋯ 16 unchanged lines
1717 drawnFront: number;
1818 drawnBack: number;
1919 rules: { wallReserve: number };
20+ /** Who threw the dice, and what they came up. See `breakAt`. */
21+ dealer: SeatId;
22+ dice: number[];
2023 }
2124
2225 export interface Stack {
⋯ 117 unchanged lines
140143 return { cell, len, thick, open: len - thick, outer: len + thick };
141144 }
142145
146+/**
147+ * Where the four walls actually stand, in tiles from where the square built
148+ * them: found by pushing them about on a real table, which is what the drag in
149+ * `ui/WallRing` is for. `x` is across the table and `y` down it, both in tiles
150+ * so the placement carries to any window. Nought is the wall left where it was
151+ * built. In `WALL_SIDES` order.
152+ */
153+export const WALL_PLACED: WallNudge[] = [
154+ { x: 0, y: 0 },
155+ { x: -1.98, y: -6.7 },
156+ { x: 0.52, y: -4.91 },
157+ { x: -1.11, y: 0.46 },
158+];
159+
160+/** How far a wall has been pushed from where it was built, in tiles. */
161+export interface WallNudge {
162+ x: number;
163+ y: number;
164+}
165+
166+/** 擲骰 — the three dice a Taiwanese dealer throws to open a hand. */
167+export type Dice = [number, number, number];
168+
169+/** Three of them, from the same seeded rng that shuffled the wall. */
170+export function rollDice(rng: () => number): Dice {
171+ return [0, 0, 0].map(() => 1 + Math.floor(rng() * 6)) as Dice;
172+}
173+
174+/**
175+ * Where the wall is broken, as a place in the square.
176+ *
177+ * The dealer throws, and the total does two jobs at once. It counts round the
178+ * table — the dealer themself is one, then on round to their right, which is
179+ * the way play goes — to pick *whose* wall is opened. Then the same number
180+ * counts stacks in from the right-hand end of that wall, and the break is
181+ * behind them: the tile drawn first is the next one along, and the drawing runs
182+ * away to the left from there, on round the square.
183+ *
184+ * The square makes that easy to say. Every wall is laid down from the corner
185+ * its own player's right hand falls on — that is what the pinwheel is — so the
186+ * run of 72 places already starts at the right end of each side, and the break
187+ * is simply so many places into the side the dice picked.
188+ *
189+ * Nothing about the *tiles* turns on it: the wall was shuffled before it was
190+ * built, so which stack is drawn first is decided either way. What it moves is
191+ * where in the middle of the table the gap opens, and which player has to reach
192+ * furthest for the next draw — which is the whole of what it does at a table
193+ * too, and is worth having because it is what a hand looks like.
194+ */
195+export function breakAt(dealer: SeatId, dice: number[]): number {
196+ const total = dice.reduce((n, d) => n + d, 0);
197+ if (total <= 0) return 0;
198+ // One is the dealer, and the count goes the way the turn does.
199+ const whose = ((dealer + total - 1) % 4) as SeatId;
200+ return (SEAT_WALL_SIDE[whose] * STACKS_PER_SIDE + total) % WALL_STACKS;
201+}
202+
143203 /** A stack and which of the 72 it is — the index is what the physics reads. */
144204 export interface Placed {
145205 index: number;
⋯ 20 unchanged lines
166226 * ends are where they would be on a table, and the square keeps the size it was
167227 * built at — a square that shrank every draw would close in on the discards
168228 * lying inside it.
229+ *
230+ * `from` is the break: which place in the square the first tile drawn comes
231+ * off, from `breakAt`. Every place is returned whether or not there is still a
232+ * stack standing on it, because with the break anywhere but the corner a wall
233+ * is eaten from its *middle* — a row that closed the gap up would drag the
234+ * whole tail of the wall along the table behind it.
169235 */
170-export function ringLayout(stacks: Stack[], cap: RingCapacity): Placed[][] {
236+export function ringLayout(stacks: Stack[], cap: RingCapacity, from = 0): Placed[][] {
171237 const sides: Placed[][] = [[], [], [], []];
172238 const lengths = [cap.h, cap.v, cap.h, cap.v];
173239 const slots = 2 * (cap.h + cap.v);
⋯ 5 unchanged lines
179245 // end, and no tile that is still standing ever has to move.
180246 const offset = WALL_STACKS - slots;
181247
248+ const run: (Placed | null)[] = Array.from({ length: slots }, () => null);
182249 for (let index = 0; index < stacks.length; index++) {
183- const stack = stacks[index];
184250 // A wall too long for its square only happens before a hand is dealt, and
185251 // then only for the frame it takes to deal it. What falls off the start is
186252 // the part about to be drawn anyway.
187- let at = index - offset;
188- if (stack.count === 0 || at < 0 || at >= slots) continue;
189- let side = 0;
190- while (at >= lengths[side]) at -= lengths[side++];
191- sides[side].push({ index, stack });
253+ const at = index - offset;
254+ if (at < 0 || at >= slots) continue;
255+ run[(at + from) % slots] = { index, stack: stacks[index] };
256+ }
257+
258+ let p = 0;
259+ for (let side = 0; side < 4; side++) {
260+ for (let i = 0; i < lengths[side]; i++, p++) {
261+ const place = run[p];
262+ if (place) sides[side].push(place);
263+ }
192264 }
193265 return sides;
194266 }
modifiedsrc/styles.css+70 −3
⋯ 448 unchanged lines
449449 .actionbar:empty {
450450 min-height: 0;
451451 }
452+/* Except on the seat whose turn it is. There a button is one tap away — lift a
453+ tile and 打出 arrives — and a bar that springs into being under your hand
454+ shoves the sets and flowers above it up the strip while you are still
455+ deciding. The seat that is about to need the room keeps it. */
456+.seat.active .actionbar:empty {
457+ min-height: var(--bar-h);
458+}
452459 /* Every button on the bar is the same height, so the row is the same height
453460 whichever ones are up: without this a 打出 五萬 stands taller than a bare 過
454461 and the strip shuffles itself every time you lift a tile. */
⋯ 247 unchanged lines
702709 round each time, which is what makes four straight walls a pinwheel. Every
703710 corner of the square is covered by exactly one of them, and no two overlap.
704711
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. */
712+ Every place in the wall is here whether or not there is still a stack on it —
713+ see `ringLayout` — so the row never closes up and the gap stays where the
714+ drawing actually got to. */
707715 .wall-side {
708716 position: absolute;
709717 display: flex;
710- justify-content: flex-end;
711718 /* Where it has been pushed to, in the table's own axes — a square wall goes
712719 where the finger goes. */
713720 transform: translate(var(--dx, 0px), var(--dy, 0px));
⋯ 1604 unchanged lines
23182325 opacity: 0.65;
23192326 margin-top: 2px;
23202327 }
2328+
2329+/* The dice tool. Dev builds only — `import.meta.env.DEV` in ui/Center — so it
2330+ is styled for being useful rather than for being lived with: bottom left, out
2331+ of the way of the undo button and of every seat's own strip. */
2332+.dice-debug {
2333+ position: absolute;
2334+ z-index: 2;
2335+ left: 10px;
2336+ bottom: 8px;
2337+ display: flex;
2338+ flex-direction: column;
2339+ align-items: center;
2340+ gap: 4px;
2341+ padding: 6px 10px 5px;
2342+ border-radius: 10px;
2343+ background: rgba(0, 0, 0, 0.32);
2344+ border: 1px solid rgba(217, 180, 92, 0.25);
2345+ font-size: 11px;
2346+ /* A wall gets pushed about through this corner, and the panel is only here to
2347+ be read — so the finger goes through it, and only the buttons take it. */
2348+ pointer-events: none;
2349+}
2350+.dice-debug .btn {
2351+ pointer-events: auto;
2352+}
2353+.table.carrying .dice-debug {
2354+ pointer-events: none;
2355+}
2356+.dice-faces {
2357+ display: flex;
2358+ align-items: baseline;
2359+ gap: 4px;
2360+ line-height: 1;
2361+}
2362+.dice-faces b {
2363+ font-size: 26px;
2364+ font-weight: 400;
2365+ color: var(--gold);
2366+}
2367+.dice-faces em {
2368+ font-style: normal;
2369+ font-size: 13px;
2370+ font-weight: 700;
2371+ opacity: 0.75;
2372+ margin-left: 4px;
2373+}
2374+.dice-row {
2375+ display: flex;
2376+ gap: 4px;
2377+}
2378+.dice-row .btn {
2379+ font-size: 12px;
2380+ min-height: 0;
2381+ padding: 3px 9px;
2382+}
2383+.dice-says {
2384+ opacity: 0.6;
2385+ letter-spacing: 0.02em;
2386+ white-space: nowrap;
2387+}
modifiedsrc/table/physics.test.ts+92 −19
11 import { describe, expect, it } from 'vitest';
22 import { makeRng, STACKS_PER_SIDE, WALL_STACKS } from '../game/tiles';
33 import {
4+ breakAt,
45 isBarrier,
56 wallSquare,
67 RING,
78 ringLayout,
9+ rollDice,
810 SEAT_WALL_SIDE,
911 sideStacks,
1012 wallStacks,
⋯ 539 unchanged lines
550552
551553 describe('wallStacks', () => {
552554 const at = (drawnFront: number, drawnBack = 0) =>
553- wallStacks({ drawnFront, drawnBack, rules: { wallReserve: 16 } });
555+ wallStacks({ drawnFront, drawnBack, dealer: 0, dice: [1, 1, 1], rules: { wallReserve: 16 } });
554556
555557 it('starts as 72 full stacks', () => {
556558 const s = at(0);
⋯ 86 unchanged lines
643645
644646 describe('ringLayout', () => {
645647 const at = (drawnFront: number, drawnBack = 0) =>
646- wallStacks({ drawnFront, drawnBack, rules: { wallReserve: 16 } });
648+ wallStacks({ drawnFront, drawnBack, dealer: 0, dice: [1, 1, 1], rules: { wallReserve: 16 } });
647649 const flat = (sides: Placed[][]) => sides.flat();
650+ const standing = (sides: Placed[][]) => flat(sides).filter((p) => p.stack.count > 0);
648651 // A middle much wider than it is deep, which is what an ordinary window has.
649652 const wide = { h: 30, v: 10 };
650653
651- it('draws what is still standing and nothing else', () => {
652- const sides = ringLayout(at(65), wide);
654+ it('keeps every place in the square, standing or spent', () => {
655+ const sides = ringLayout(at(65), RING);
653656 const placed = flat(sides);
654- expect(placed).toHaveLength(40);
655- expect(placed.every((p) => p.stack.count > 0)).toBe(true);
656- // In order all the way round: the break point first, the 底牌 tail last.
657- expect(placed[0].index).toBe(32);
658- expect(placed[0].stack.next).toBe(true);
659- expect(placed[placed.length - 1].index).toBe(71);
660- expect(placed.every((p, i) => i === 0 || p.index > placed[i - 1].index)).toBe(true);
657+ expect(placed).toHaveLength(WALL_STACKS);
658+ // In order all the way round from the break: the first drawn first, the
659+ // 底牌 tail last.
660+ expect(placed.map((p) => p.index)).toEqual(placed.map((_, i) => i));
661+ expect(standing(sides)).toHaveLength(40);
662+ expect(standing(sides)[0].stack.next).toBe(true);
663+ expect(standing(sides)[39].index).toBe(71);
661664 });
662665
663666 it('fills the square it was built for, then is eaten from the break point', () => {
664667 // Built to the wall as dealt, so at the deal it comes out full and square.
665668 const square = { h: 10, v: 10 };
666- expect(ringLayout(at(64), square).map((s) => s.length)).toEqual([10, 10, 10, 10]);
669+ const perSide = (sides: Placed[][]) =>
670+ sides.map((s) => s.filter((p) => p.stack.count > 0).length);
671+ expect(perSide(ringLayout(at(64), square))).toEqual([10, 10, 10, 10]);
667672 // From there the gap opens where the drawing is happening and nothing else
668673 // moves: the far end stays pinned to the end of the square.
669- const eaten = ringLayout(at(78), square);
670- expect(eaten.map((s) => s.length)).toEqual([3, 10, 10, 10]);
671- expect(eaten[0][0].stack.next).toBe(true);
674+ expect(perSide(ringLayout(at(78), square))).toEqual([3, 10, 10, 10]);
672675 // And the other end shortens when a kong or a flower takes from the tail.
673- expect(ringLayout(at(64, 6), square).map((s) => s.length)).toEqual([10, 10, 10, 7]);
676+ expect(perSide(ringLayout(at(64, 6), square))).toEqual([10, 10, 10, 7]);
674677 });
675678
676679 it('never puts more on a side than fits, however little ring there is', () => {
⋯ 8 unchanged lines
685688 const lengths = [cap.h, cap.v, cap.h, cap.v];
686689 sides.forEach((side, i) => expect(side.length).toBeLessThanOrEqual(lengths[i]));
687690 const live = at(front).filter((s) => s.count > 0).length;
688- expect(flat(sides)).toHaveLength(Math.min(live, 2 * (cap.h + cap.v)));
691+ expect(standing(sides).length).toBeLessThanOrEqual(Math.min(live, 2 * (cap.h + cap.v)));
689692 }
690693 }
691694 });
⋯ 1 unchanged line
693696 it('keeps the far end of the wall when there is not room for all of it', () => {
694697 // Overflow only happens before a hand is dealt. What has to go is the front,
695698 // since that is the part about to be drawn anyway.
696- const sides = ringLayout(at(0), { h: 6, v: 2 });
697- const placed = flat(sides);
699+ const placed = flat(ringLayout(at(0), { h: 6, v: 2 }));
698700 expect(placed).toHaveLength(16);
699701 expect(placed[placed.length - 1].index).toBe(71);
700702 expect(placed[0].index).toBe(56);
701703 });
704+
705+ it('a wide middle still lays every one of its places out in order', () => {
706+ const placed = flat(ringLayout(at(0), wide));
707+ const first = placed[0].index;
708+ expect(placed.map((p) => p.index)).toEqual(placed.map((_, i) => first + i));
709+ });
710+});
711+
712+describe('擲骰 — where the dice say to break the wall', () => {
713+ const fresh = (dealer: SeatId, dice: number[]) =>
714+ ringLayout(
715+ wallStacks({ drawnFront: 0, drawnBack: 0, dealer, dice, rules: { wallReserve: 16 } }),
716+ RING,
717+ breakAt(dealer, dice),
718+ ).flat();
719+ /** Three dice that come to `n`, for every total three of them can make. */
720+ const totalling = (n: number) => [n - 2, 1, 1];
721+
722+ it('is three dice, and the same seed throws them the same way', () => {
723+ const one = rollDice(makeRng(7));
724+ expect(one).toHaveLength(3);
725+ expect(one.every((d) => d >= 1 && d <= 6)).toBe(true);
726+ expect(rollDice(makeRng(7))).toEqual(one);
727+ expect(rollDice(makeRng(8))).not.toEqual([0, 0, 0]);
728+ });
729+
730+ it('opens the wall of the seat the total counts to, the dealer being one', () => {
731+ for (const dealer of [0, 1, 2, 3] as SeatId[]) {
732+ for (let total = 3; total <= 18; total++) {
733+ const run = fresh(dealer, totalling(total));
734+ // Where the first tile drawn is standing, as a place in the square.
735+ const head = run.findIndex((p) => p.index === 0);
736+ // The count goes round the table the way the turn does, and every wall
737+ // is laid down from its own player's right-hand corner — so the break
738+ // is that many places into the side the dice picked.
739+ const whose = ((dealer + total - 1) % 4) as SeatId;
740+ expect(head).toBe((SEAT_WALL_SIDE[whose] * STACKS_PER_SIDE + total) % WALL_STACKS);
741+ }
742+ }
743+ });
744+
745+ it('walks the break all the way round the square, and never off it', () => {
746+ const seen = new Set<number>();
747+ for (const dealer of [0, 1, 2, 3] as SeatId[])
748+ for (let total = 3; total <= 18; total++) {
749+ const head = fresh(dealer, totalling(total)).findIndex((p) => p.index === 0);
750+ expect(head).toBeGreaterThanOrEqual(0);
751+ expect(head).toBeLessThan(WALL_STACKS);
752+ seen.add(head);
753+ }
754+ // Four seats' worth of walls, sixteen totals: every side gets opened.
755+ expect(seen.size).toBeGreaterThan(STACKS_PER_SIDE);
756+ expect(new Set([...seen].map((p) => Math.floor(p / STACKS_PER_SIDE))).size).toBe(4);
757+ });
758+
759+ it('leaves the 底牌 tail standing just behind the break, wherever it is', () => {
760+ for (const total of [3, 7, 12, 18]) {
761+ const run = fresh(2, totalling(total));
762+ const head = run.findIndex((p) => p.index === 0);
763+ expect(run[(head + WALL_STACKS - 1) % WALL_STACKS].index).toBe(71);
764+ }
765+ });
766+
767+ it('moves the gap and nothing else: the same wall, drawn in the same order', () => {
768+ const order = (dice: number[]) =>
769+ fresh(0, dice)
770+ .slice()
771+ .sort((a, b) => a.index - b.index)
772+ .map((p) => p.index);
773+ expect(order(totalling(3))).toEqual(order(totalling(11)));
774+ });
702775 });
modifiedsrc/ui/Center.tsx+6 −0
⋯ 2 unchanged lines
33 import type { SeatId } from '../game/types';
44 import { labelOf } from '../game/tiles';
55 import { Bi, BiEm } from './Bi';
6+import { DiceDebug } from './DiceDebug';
67 import { useLang, useT } from './lang';
78 import { FullscreenToggle } from './FullscreenToggle';
89 import { SoundToggle } from './SoundToggle';
⋯ 182 unchanged lines
191192 </div>
192193 )}
193194
195+ {/* Dev only, and only where there is a square to look at: the dice decide
196+ where the wall is broken, and that is a thing you want to be able to
197+ step through rather than wait for. */}
198+ {import.meta.env.DEV && !compact && s.phase !== 'gameEnd' && <DiceDebug game={game} />}
199+
194200 <div className="table-controls">
195201 {undoable && (
196202 <button className="btn ghost undo" onClick={() => game.undo()}>
⋯ 24 unchanged lines
addedsrc/ui/DiceDebug.tsx+64 −0
1+import type { TableCtl } from '../game/ctl';
2+import type { SeatId } from '../game/types';
3+import { breakAt, SEAT_WALL_SIDE, WALL_SIDES } from '../game/wall';
4+import { STACKS_PER_SIDE } from '../game/tiles';
5+import { useT } from './lang';
6+
7+/**
8+ * The dealer's dice, with a hand on them. Dev builds only.
9+ *
10+ * Where the wall is broken is thrown for once at the start of a hand and then
11+ * lived with, which is right at a table and useless for looking at: half the
12+ * question about the square is what it looks like opened at each of the
13+ * seventy-two places it can be opened at, and waiting for the dice to walk you
14+ * round is not a way to find out. So the total is a thing you can step through
15+ * here — every one three dice can make, 3 to 18 — and the square is redrawn on
16+ * each with the gap somewhere else.
17+ *
18+ * It moves the gap and nothing else: the tiles were shuffled before the wall
19+ * was built, so nobody's hand changes and the draw order is the draw order.
20+ * See `reroll` in game/engine.ts and `breakAt` in game/wall.ts.
21+ */
22+export function DiceDebug({ game }: { game: TableCtl }) {
23+ const t = useT();
24+ const s = game.state;
25+ if (!game.reroll) return null;
26+
27+ const total = s.dice.reduce((n, d) => n + d, 0);
28+ // Who the count landed on, and how far into their wall — the two things the
29+ // total decides, said out loud so the square can be checked against them.
30+ const whose = ((s.dealer + total - 1) % 4) as SeatId;
31+ const into = total % STACKS_PER_SIDE;
32+ const step = (to: number) => game.reroll?.(to - 2, 1, 1);
33+
34+ return (
35+ <div className="dice-debug">
36+ <div className="dice-faces">
37+ {s.dice.map((d, i) => (
38+ <b key={i}>{d}</b>
39+ ))}
40+ <em>{total}</em>
41+ </div>
42+ <div className="dice-row">
43+ <button className="btn ghost" onClick={() => step(Math.max(3, total - 1))} disabled={total <= 3}>
44+ −
45+ </button>
46+ <button className="btn ghost" onClick={() => game.reroll?.()}>
47+ {t('擲骰', 'Throw')}
48+ </button>
49+ <button
50+ className="btn ghost"
51+ onClick={() => step(Math.min(18, total + 1))}
52+ disabled={total >= 18}
53+ >
54+ +
55+ </button>
56+ </div>
57+ <div className="dice-says">
58+ {s.players[whose].name} · {WALL_SIDES[SEAT_WALL_SIDE[whose]]} ·{' '}
59+ {t(`第 ${into || STACKS_PER_SIDE} 疊`, `${into || STACKS_PER_SIDE} in`)} ·{' '}
60+ {t('斷點', 'break')} {breakAt(s.dealer, s.dice)}
61+ </div>
62+ </div>
63+ );
64+}
modifiedsrc/ui/WallRing.tsx+32 −24
11 import { useLayoutEffect, useRef, useState } from 'react';
22 import { STACKS_PER_SIDE } from '../game/tiles';
33 import {
4+ breakAt,
45 ringLayout,
56 RING,
67 wallSquare,
8+ WALL_PLACED,
79 WALL_SIDES,
810 wallStacks,
11+ type WallNudge,
912 type WallProgress,
1013 type WallSquare,
1114 } from '../game/wall';
⋯ 25 unchanged lines
3740 const t = useT();
3841 const ring = useRef<HTMLDivElement>(null);
3942 const square = useSquare(ring);
40- const sides = ringLayout(wallStacks(state), RING);
43+ const sides = ringLayout(wallStacks(state), RING, breakAt(state.dealer, state.dice));
4144 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(' ');
45+ // Where they have been pushed to, in tiles — the form `WALL_PLACED` is
46+ // written in, so what a push found can be pasted straight back into it. For
47+ // the console and anything reading it off the DOM rather than have it
48+ // threaded through as a prop nothing else wants.
49+ const nudged = slide.map(say).join(' ');
4650
4751 return (
4852 <div
⋯ 26 unchanged lines
7579 onPointerDown={take(side)}
7680 style={
7781 {
78- '--dx': `${slide[side].x}px`,
79- '--dy': `${slide[side].y}px`,
82+ '--dx': `${slide[side].x * (square?.cell ?? 0)}px`,
83+ '--dy': `${slide[side].y * (square?.cell ?? 0)}px`,
8084 } as React.CSSProperties
8185 }
8286 >
⋯ 6 unchanged lines
8993 // wall on a table is one wall, not a wall with the end greyed off.
9094 className={`wall-stack s${stack.count}${stack.next ? ' next' : ''}`}
9195 >
92- <i />
96+ {/* A spent place keeps its room in the row and shows nothing:
97+ that hole is where the drawing has got to. */}
98+ {stack.count > 0 && <i />}
9399 </div>
94100 ))}
95101 </div>
⋯ 22 unchanged lines
118124 * to — `invalidate` in table/pool.ts, and the listener in ui/Pool.tsx.
119125 */
120126 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 })));
127+ // Starting from where the last person to push them about left them, so a
128+ // push carries on from the placement rather than from the built square, and
129+ // what comes back is the number to write down.
130+ const [slide, setSlide] = useState<WallNudge[]>(() => WALL_PLACED.map((n) => ({ ...n })));
122131 // Where the walls are, as the drag knows it. React state is what draws them,
123132 // and it commits when it commits — which is one push behind if the pointer
124133 // has already gone up. This is the same thing, now.
125- const live = useRef<Nudge[]>(slide);
126- const drag = useRef<{ side: number; fromX: number; fromY: number; at: Nudge } | null>(null);
134+ const live = useRef<WallNudge[]>(slide);
135+ const drag = useRef<{ side: number; fromX: number; fromY: number; at: WallNudge } | null>(null);
127136 // What the drag needs to know, kept current without the listeners below
128137 // having to be torn down and put back on every render to see it.
129138 const now = useRef(square);
⋯ 19 unchanged lines
149158 useLayoutEffect(() => {
150159 const move = (e: PointerEvent) => {
151160 const d = drag.current;
152- if (!d) return;
153- // Nowhere near off the table, however far the pointer has run.
154- const bound = STACKS_PER_SIDE * (now.current?.cell ?? 0) * 2;
161+ const cell = now.current?.cell;
162+ if (!d || !cell) return;
163+ // In tiles, which is what the placement is written in: the pointer moves
164+ // in pixels and a tile is however many of them the window came out at.
165+ const bound = STACKS_PER_SIDE * 2;
155166 const clamp = (v: number) => Math.min(bound, Math.max(-bound, v));
156- const to: Nudge = {
157- x: clamp(d.at.x + e.clientX - d.fromX),
158- y: clamp(d.at.y + e.clientY - d.fromY),
167+ const to: WallNudge = {
168+ x: clamp(d.at.x + (e.clientX - d.fromX) / cell),
169+ y: clamp(d.at.y + (e.clientY - d.fromY) / cell),
159170 };
160171 const had = live.current[d.side];
161172 if (had.x === to.x && had.y === to.y) return;
⋯ 7 unchanged lines
169180 drag.current = null;
170181 moved();
171182 // Said out loud at the end of the push, not during it: what the fingers
172- // found, in the form it would be written down in.
183+ // found, in tiles, which is the form `WALL_PLACED` is written in.
173184 const at = live.current;
174- const say = (n: Nudge) => `${Math.round(n.x)},${Math.round(n.y)}`;
175185 console.log(
176186 `[wall] ${WALL_SIDES[d.side]} pushed to ${say(at[d.side])} — ` +
177187 WALL_SIDES.map((cls, i) => `${cls} ${say(at[i])}`).join(' · '),
⋯ 13 unchanged lines
191201 return { slide, take };
192202 }
193203
194-/** How far a wall has been pushed from where it was built, in table pixels. */
195-export interface Nudge {
196- x: number;
197- y: number;
198-}
204+/** A push, in the two decimal places `WALL_PLACED` is written to. */
205+const say = (n: WallNudge) => `${round(n.x)},${round(n.y)}`;
206+const round = (v: number) => Math.round(v * 100) / 100;
199207
200208 /**
201209 * How big to build the square, measured rather than worked out twice: how big a
⋯ 52 unchanged lines
modifiedsrc/ui/colliders.ts+12 −0
⋯ 160 unchanged lines
161161 stepOn?: (n?: number) => void;
162162 /** The live pool, for poking at from the console. Dev builds only. */
163163 pool?: unknown;
164+ /** The live game, likewise — `game.reroll(6, 6, 6)` breaks the wall
165+ * somewhere else without touching a tile. */
166+ game?: unknown;
164167 }
165168 }
166169
⋯ 13 unchanged lines
180183 export function exposePool(pool: unknown) {
181184 if (import.meta.env.DEV && typeof window !== 'undefined') window.pool = pool;
182185 }
186+
187+/**
188+ * And the game, for the same reason. `game.reroll(1, 1, 1)` through
189+ * `game.reroll(6, 6, 6)` walks the break all the way round the square, which is
190+ * the only way to see a wall opened anywhere but where this hand opened it.
191+ */
192+export function exposeGame(game: unknown) {
193+ if (import.meta.env.DEV && typeof window !== 'undefined') window.game = game;
194+}