anvilsign in

collin/mahjong

master / src / game / wall.ts
1import { STACKS_PER_SIDE, WALL_SIZE, WALL_STACKS } from './tiles';
2import type { SeatId } from './types';
3
4/**
5 * What the wall square looks like right now.
6 *
7 * The square is eaten from both ends at once — normal draws come off the front,
8 * kong and flower replacements off the back — so how much of it is left is not
9 * one number but a per-stack question, and two things want the answer: the
10 * drawing of it (ui/WallRing) and the physics (table/geometry), since a stack
11 * still standing is something a thrown tile has to get past.
12 *
13 * Deliberately typed against the fields it reads rather than `GameState`, so it
14 * stays a function of the wall and nothing else.
15 */
16export interface WallProgress {
17 drawnFront: number;
18 drawnBack: number;
19 rules: { wallReserve: number };
20 /** Who threw the dice, and what they came up. See `breakAt`. */
21 dealer: SeatId;
22 dice: number[];
23}
24
25export interface Stack {
26 /** Tiles still in this stack: 2 full, 1 half, 0 spent. */
27 count: 0 | 1 | 2;
28 /** Part of the 16-tile 底牌 tail that ends the hand. */
29 dead: boolean;
30 /** The break point — the stack the next draw comes off. */
31 next: boolean;
32}
33
34/**
35 * Position 0 is the break point. Stack `i` holds positions `2i` and `2i+1`, and
36 * a position is still there if it is past the front and short of the back.
37 */
38export function wallStacks(s: WallProgress): Stack[] {
39 const front = s.drawnFront;
40 const back = WALL_SIZE - s.drawnBack;
41 const deadFrom = back - s.rules.wallReserve;
42
43 return Array.from({ length: WALL_STACKS }, (_, i) => {
44 const a = i * 2;
45 const b = a + 1;
46 const live = (p: number) => p >= front && p < back;
47 const count = ((live(a) ? 1 : 0) + (live(b) ? 1 : 0)) as 0 | 1 | 2;
48 return { count, dead: b >= deadFrom, next: front === a || front === b };
49 });
50}
51
52/**
53 * Whether a stack is something a thrown tile has to get past.
54 *
55 * A stack of two stands as tall as the tile being thrown at it. One of one is
56 * low enough to sail over, and a spent one is not there at all — so only a full
57 * stack is a barrier. This is the rule behind both halves of the throw: the
58 * colliders the pile bounces off, and whether a seat may flick at all.
59 */
60export const isBarrier = (s: Stack) => s.count === 2;
61
62/** The four sides in the order WallRing lays them out, clockwise from the top. */
63export const WALL_SIDES = ['top', 'right', 'bottom', 'left'] as const;
64export type WallSide = (typeof WALL_SIDES)[number];
65
66/**
67 * Which side of the square sits in front of each seat — the one a seat has to
68 * throw over. Seats run bottom, right, top, left (see ui/rotation.ts) and the
69 * sides are drawn top, right, bottom, left, so the two orders are not the same.
70 */
71export const SEAT_WALL_SIDE: Record<SeatId, number> = { 0: 2, 1: 1, 2: 0, 3: 3 };
72
73/** The stacks making up one quarter of the square as it was built. */
74export function sideStacks(stacks: Stack[], side: number): Stack[] {
75 return stacks.slice(side * STACKS_PER_SIDE, (side + 1) * STACKS_PER_SIDE);
76}
77
78/** How many stacks a side of the square holds, once the middle is measured. */
79export interface RingCapacity {
80 /** Along the top and the bottom. */
81 h: number;
82 /** Down the left and the right. */
83 v: number;
84}
85
86/** Dealt out before anybody looks at the table: sixteen tiles, four ways. */
87const DEALT = 64;
88/**
89 * How much wall is left once a hand has been dealt. The square is built to the
90 * whole hundred and forty-four now rather than to this — see `RING` — so this
91 * is only a fact about the wall, and what the tests measure a fresh deal
92 * against.
93 */
94export const DEALT_STACKS = WALL_STACKS - DEALT / 2;
95
96/**
97 * The square is the whole wall: eighteen stacks of two a side, four sides, the
98 * hundred and forty-four tiles, exactly as it is built on a table.
99 *
100 * It used to be rebuilt to what was *left* after a hand had been dealt — barely
101 * half of it — because eighteen full-size stacks a side wants about 560px and no
102 * ordinary window has that between the top and bottom strips. That bought a
103 * square, at the price of it not being the wall: a side ran out of stacks before
104 * it reached its corner, so the four of them never met.
105 *
106 * What gives instead is the tile. A wall tile is not a hand tile — on a table it
107 * is the same tile, but on a screen the wall has to fit the middle and the hand
108 * has to be readable, and those are two different jobs. See `wallSquare`.
109 */
110export const RING: RingCapacity = { h: STACKS_PER_SIDE, v: STACKS_PER_SIDE };
111
112/** The square, in pixels, once the room it has to fit has been measured. */
113export interface WallSquare {
114 /** A stack's short side — the wall's own tile width. */
115 cell: number;
116 /** One wall, end to end: eighteen stacks. */
117 len: number;
118 /** How deep a wall stands, which is a stack's long side. */
119 thick: number;
120 /** The opening left in the middle: where the discards go. */
121 open: number;
122 /** What the whole thing takes up, walls included. */
123 outer: number;
124}
125
126/**
127 * How big the square comes out, given the tile it is built from.
128 *
129 * Four walls of eighteen stacks, laid down square and nothing turned. Each is
130 * pinned at the corner it is built from and runs its whole length from there,
131 * which carries it one wall-depth past the far corner and over the end of the
132 * next wall along — the same way round each time. That is the pinwheel, and it
133 * is why the corners are covered rather than mitred.
134 *
135 * So the whole thing measures a wall's length *plus* one depth across, and
136 * leaves that length *less* one depth in the middle.
137 *
138 * The tile is the given, and the square is whatever that comes to. It used to
139 * be solved the other way about — the room across the table was the given and
140 * the tile was what gave — which bought a square that fitted at the price of
141 * the one thing about a wall tile that is not negotiable: it is the tile you
142 * are playing with. A tile that changed size between the wall and your hand
143 * read as a different, smaller set of tiles sitting in the middle. `--tile-w`
144 * in styles.css is the one size, and this follows it.
145 */
146export function wallSquare(cell: number, ratio: number): WallSquare {
147 const len = cell * STACKS_PER_SIDE;
148 const thick = cell * ratio;
149 return { cell, len, thick, open: len - thick, outer: len + thick };
150}
151
152/**
153 * Where the four walls actually stand, in tiles from where the square built
154 * them: found by pushing them about on a real table, which is what the drag in
155 * `ui/WallRing` is for. `x` is across the table and `y` down it, both in tiles
156 * so the placement carries to any window. Nought is the wall left where it was
157 * built. In `WALL_SIDES` order.
158 *
159 * A ladder, as it stands: the two flat walls drawn in towards each other until
160 * the band they leave between them is the room the four strips actually leave,
161 * and the two upright ones pushed out past the ends of it. Eighteen full-size
162 * stacks is longer than the middle of a table is deep, so something has to
163 * overhang; this is a choice about *what* does.
164 */
165export const WALL_PLACED: WallNudge[] = [
166 { x: -0.47, y: 5.06 },
167 { x: 1.29, y: -0.76 },
168 { x: 0.88, y: -4.31 },
169 { x: -1.6, y: 1.13 },
170];
171
172/** How far a wall has been pushed from where it was built, in tiles. */
173export interface WallNudge {
174 x: number;
175 y: number;
176}
177
178/** 擲骰 — the three dice a Taiwanese dealer throws to open a hand. */
179export type Dice = [number, number, number];
180
181/** Three of them, from the same seeded rng that shuffled the wall. */
182export function rollDice(rng: () => number): Dice {
183 return [0, 0, 0].map(() => 1 + Math.floor(rng() * 6)) as Dice;
184}
185
186/**
187 * Where the wall is broken, as a place in the square.
188 *
189 * The dealer throws, and the total does two jobs at once. It counts round the
190 * table — the dealer themself is one, then on round to their right, which is
191 * the way play goes — to pick *whose* wall is opened. Then the same number
192 * counts stacks in from the right-hand end of that wall, and the break is
193 * behind them: the tile drawn first is the next one along, and the drawing runs
194 * away to the left from there, on round the square.
195 *
196 * The square makes that easy to say. Every wall is laid down from the corner
197 * its own player's right hand falls on — that is what the pinwheel is — so the
198 * run of 72 places already starts at the right end of each side, and the break
199 * is simply so many places into the side the dice picked.
200 *
201 * Nothing about the *tiles* turns on it: the wall was shuffled before it was
202 * built, so which stack is drawn first is decided either way. What it moves is
203 * where in the middle of the table the gap opens, and which player has to reach
204 * furthest for the next draw — which is the whole of what it does at a table
205 * too, and is worth having because it is what a hand looks like.
206 */
207export function breakAt(dealer: SeatId, dice: number[]): number {
208 const total = dice.reduce((n, d) => n + d, 0);
209 if (total <= 0) return 0;
210 // One is the dealer, and the count goes the way the turn does.
211 const whose = ((dealer + total - 1) % 4) as SeatId;
212 return (SEAT_WALL_SIDE[whose] * STACKS_PER_SIDE + total) % WALL_STACKS;
213}
214
215/** A stack and which of the 72 it is — the index is what the physics reads. */
216export interface Placed {
217 index: number;
218 stack: Stack;
219}
220
221/**
222 * Where what is left of the wall actually goes.
223 *
224 * Four walls of eighteen full-size stacks want about 560px a side, and the
225 * middle of the table is nothing like that in both directions at once — so a
226 * square built to the tile is a square that hangs out under the players. What
227 * saves it is that by the time anyone is looking, four hands have been dealt
228 * off the front and there is nothing like a whole wall left: only what is still
229 * standing is drawn, and it is laid out around a ring cut to the middle rather
230 * than to the tile. Players push the remaining stacks about to keep them tidy
231 * for exactly this reason.
232 *
233 * The rebuilt square is the size of the wall as it is dealt, so at the start
234 * the run fills it exactly, and from then on it is simply *eaten*: the tail is
235 * pinned to the end of the square and normal draws take stacks off the front,
236 * which leaves a growing gap where they were and moves nothing else. Kong and
237 * flower replacements come off the other end and shorten it from there. Both
238 * ends are where they would be on a table, and the square keeps the size it was
239 * built at — a square that shrank every draw would close in on the discards
240 * lying inside it.
241 *
242 * `from` is the break: which place in the square the first tile drawn comes
243 * off, from `breakAt`. Every place is returned whether or not there is still a
244 * stack standing on it, because with the break anywhere but the corner a wall
245 * is eaten from its *middle* — a row that closed the gap up would drag the
246 * whole tail of the wall along the table behind it.
247 */
248export function ringLayout(stacks: Stack[], cap: RingCapacity, from = 0): Placed[][] {
249 const sides: Placed[][] = [[], [], [], []];
250 const lengths = [cap.h, cap.v, cap.h, cap.v];
251 const slots = 2 * (cap.h + cap.v);
252 if (slots <= 0) return sides;
253
254 // Each stack has its own place in the square and keeps it: the far end is the
255 // far end of the square, and everything counts back from there. Draws off the
256 // front open a gap at the break point, draws off the tail shorten the other
257 // end, and no tile that is still standing ever has to move.
258 const offset = WALL_STACKS - slots;
259
260 const run: (Placed | null)[] = Array.from({ length: slots }, () => null);
261 for (let index = 0; index < stacks.length; index++) {
262 // A wall too long for its square only happens before a hand is dealt, and
263 // then only for the frame it takes to deal it. What falls off the start is
264 // the part about to be drawn anyway.
265 const at = index - offset;
266 if (at < 0 || at >= slots) continue;
267 run[(at + from) % slots] = { index, stack: stacks[index] };
268 }
269
270 let p = 0;
271 for (let side = 0; side < 4; side++) {
272 for (let i = 0; i < lengths[side]; i++, p++) {
273 const place = run[p];
274 if (place) sides[side].push(place);
275 }
276 }
277 return sides;
278}