anvilsign in

collin/mahjong

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}
21
22export interface Stack {
23 /** Tiles still in this stack: 2 full, 1 half, 0 spent. */
24 count: 0 | 1 | 2;
25 /** Part of the 16-tile 底牌 tail that ends the hand. */
26 dead: boolean;
27 /** The break point — the stack the next draw comes off. */
28 next: boolean;
29}
30
31/**
32 * Position 0 is the break point. Stack `i` holds positions `2i` and `2i+1`, and
33 * a position is still there if it is past the front and short of the back.
34 */
35export function wallStacks(s: WallProgress): Stack[] {
36 const front = s.drawnFront;
37 const back = WALL_SIZE - s.drawnBack;
38 const deadFrom = back - s.rules.wallReserve;
39
40 return Array.from({ length: WALL_STACKS }, (_, i) => {
41 const a = i * 2;
42 const b = a + 1;
43 const live = (p: number) => p >= front && p < back;
44 const count = ((live(a) ? 1 : 0) + (live(b) ? 1 : 0)) as 0 | 1 | 2;
45 return { count, dead: b >= deadFrom, next: front === a || front === b };
46 });
47}
48
49/**
50 * Whether a stack is something a thrown tile has to get past.
51 *
52 * A stack of two stands as tall as the tile being thrown at it. One of one is
53 * low enough to sail over, and a spent one is not there at all — so only a full
54 * stack is a barrier. This is the rule behind both halves of the throw: the
55 * colliders the pile bounces off, and whether a seat may flick at all.
56 */
57export const isBarrier = (s: Stack) => s.count === 2;
58
59/** The four sides in the order WallRing lays them out, clockwise from the top. */
60export const WALL_SIDES = ['top', 'right', 'bottom', 'left'] as const;
61export type WallSide = (typeof WALL_SIDES)[number];
62
63/**
64 * Which side of the square sits in front of each seat — the one a seat has to
65 * throw over. Seats run bottom, right, top, left (see ui/rotation.ts) and the
66 * sides are drawn top, right, bottom, left, so the two orders are not the same.
67 */
68export const SEAT_WALL_SIDE: Record<SeatId, number> = { 0: 2, 1: 1, 2: 0, 3: 3 };
69
70/** The stacks making up one quarter of the square as it was built. */
71export function sideStacks(stacks: Stack[], side: number): Stack[] {
72 return stacks.slice(side * STACKS_PER_SIDE, (side + 1) * STACKS_PER_SIDE);
73}
74
75/** How many stacks a side of the square holds, once the middle is measured. */
76export 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. */
84const DEALT = 64;
85/**
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.
90 */
91export const DEALT_STACKS = WALL_STACKS - DEALT / 2;
92
93/**
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.
96 *
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 `rhombus`.
106 */
107export const RING: RingCapacity = { h: STACKS_PER_SIDE, v: STACKS_PER_SIDE };
108
109/** One wall, placed: where its own near corner sits from the middle of the
110 * ring, and which way it runs. Degrees clockwise from east, as CSS turns. */
111export interface WallPlace {
112 x: number;
113 y: number;
114 dir: number;
115}
116
117export interface Rhombus {
118 places: WallPlace[];
119 /** The whole thing's footprint, walls included — what has to fit the middle. */
120 box: { w: number; h: number };
121 /** The daylight actually left at a corner, along the edge. Under a tile. */
122 gap: number;
123 /** The opening left in the middle of it, as a box round the four corners. */
124 open: { x: number; y: number; w: number; h: number };
125}
126
127const rad = (deg: number) => (deg * Math.PI) / 180;
128
129/**
130 * Where the four walls stand.
131 *
132 * A rhombus of any corner angle, which at ninety degrees is the square everyone
133 * knows. Four walls of the same eighteen stacks, each pinned at the corner it is
134 * built from and running its whole length from there — which carries it past the
135 * far corner, over the outside of the next wall along. That overhang is the
136 * pinwheel, and it is the reason the corners are covered rather than mitred.
137 *
138 * How far a wall has to overrun to *close* the corner it is turning depends on
139 * that corner: two bands of thickness `t` meeting at an interior angle ψ overlap
140 * by `t / tan(ψ/2)` along the edge. At ninety degrees that is exactly one
141 * wall-depth, which is where the square's own arithmetic came from. A rhombus
142 * has two different corner angles, so the four walls would each want a different
143 * overrun and cannot have one, being all the same eighteen stacks long — so all
144 * four take the *sharper* corner's, and the shallow corners are the ones that
145 * lap further than they strictly need.
146 *
147 * That is the *closed* corner, and a real one is not closed. Walls get pushed up
148 * against each other by hand and there is always a little daylight left where
149 * they meet, so `gap` takes some of the overrun back — measured in tiles, since
150 * that is what anybody looking at it would measure it in, and held to less than
151 * one of them. A whole tile of daylight is a wall that has stopped meeting the
152 * next; anything under that is a corner.
153 *
154 * Everything is linear in `len` and `thick`, so the caller can solve for the
155 * tile by asking once at a size of one and scaling the answer.
156 */
157export function rhombus(
158 len: number,
159 thick: number,
160 angle: number,
161 turn = 0,
162 gap = 0,
163): Rhombus {
164 // Interior angles alternate round a rhombus and come to a straight line.
165 const a = Math.min(179, Math.max(1, angle));
166 const sharp = Math.min(a, 180 - a);
167 // What it takes to close the corner, less the daylight asked for. Under a
168 // tile of it, always: a whole one and the walls have stopped meeting.
169 //
170 // Negative is the other way — the walls laid further over each other than the
171 // corner needs, which pulls the four corners in and takes the opening down
172 // with them. That is the only lever on how much middle the square leaves,
173 // since its sides are eighteen stacks whatever else happens.
174 const tile = len / STACKS_PER_SIDE;
175 const shut = thick / Math.tan(rad(sharp) / 2);
176 const lap = Math.max(0, shut - Math.min(0.99, Math.max(-STACKS_PER_SIDE / 3, gap)) * tile);
177 // Corner to corner. The wall is longer than this by the overrun.
178 const edge = Math.max(thick, len - lap);
179
180 // Two pairs of parallel sides: turning by `a` then by `180 - a`, twice round.
181 const dirs = [turn, turn + a, turn + 180, turn + 180 + a];
182 const u = (deg: number) => ({ x: Math.cos(rad(deg)), y: Math.sin(rad(deg)) });
183
184 // Corners, hung so the middle of them lands on the middle of the ring.
185 const u0 = u(dirs[0]);
186 const u1 = u(dirs[1]);
187 const corners = [{ x: (-edge * (u0.x + u1.x)) / 2, y: (-edge * (u0.y + u1.y)) / 2 }];
188 corners.push({ x: corners[0].x + edge * u0.x, y: corners[0].y + edge * u0.y });
189 corners.push({ x: corners[1].x + edge * u1.x, y: corners[1].y + edge * u1.y });
190 corners.push({ x: corners[2].x - edge * u0.x, y: corners[2].y - edge * u0.y });
191
192 const places: WallPlace[] = [];
193 const xs: number[] = [];
194 const ys: number[] = [];
195 for (let i = 0; i < 4; i++) {
196 const dir = dirs[i];
197 const along = u(dir);
198 // A box laid down at an angle grows along its own +y, which is its
199 // direction turned a quarter clockwise. Whichever way that points, the wall
200 // has to end up on the *outside*, so it is hung off the far side when it
201 // does not.
202 const nx = -Math.sin(rad(dir));
203 const ny = Math.cos(rad(dir));
204 const mid = { x: corners[i].x + (edge / 2) * along.x, y: corners[i].y + (edge / 2) * along.y };
205 const outward = nx * mid.x + ny * mid.y >= 0;
206 const x = corners[i].x + (outward ? 0 : -thick * nx);
207 const y = corners[i].y + (outward ? 0 : -thick * ny);
208 places.push({ x, y, dir });
209 // Its four corners, for the footprint.
210 for (const [dl, dt] of [[0, 0], [len, 0], [len, thick], [0, thick]]) {
211 xs.push(x + dl * along.x + dt * nx);
212 ys.push(y + dl * along.y + dt * ny);
213 }
214 }
215
216 const cx = corners.map((c) => c.x);
217 const cy = corners.map((c) => c.y);
218 return {
219 places,
220 gap: shut - lap,
221 box: { w: Math.max(...xs) - Math.min(...xs), h: Math.max(...ys) - Math.min(...ys) },
222 open: {
223 x: Math.min(...cx),
224 y: Math.min(...cy),
225 w: Math.max(...cx) - Math.min(...cx),
226 h: Math.max(...cy) - Math.min(...cy),
227 },
228 };
229}
230
231/** A stack and which of the 72 it is — the index is what the physics reads. */
232export interface Placed {
233 index: number;
234 stack: Stack;
235}
236
237/**
238 * Where what is left of the wall actually goes.
239 *
240 * Four walls of eighteen full-size stacks want about 560px a side, and the
241 * middle of the table is nothing like that in both directions at once — so a
242 * square built to the tile is a square that hangs out under the players. What
243 * saves it is that by the time anyone is looking, four hands have been dealt
244 * off the front and there is nothing like a whole wall left: only what is still
245 * standing is drawn, and it is laid out around a ring cut to the middle rather
246 * than to the tile. Players push the remaining stacks about to keep them tidy
247 * for exactly this reason.
248 *
249 * The rebuilt square is the size of the wall as it is dealt, so at the start
250 * the run fills it exactly, and from then on it is simply *eaten*: the tail is
251 * pinned to the end of the square and normal draws take stacks off the front,
252 * which leaves a growing gap where they were and moves nothing else. Kong and
253 * flower replacements come off the other end and shorten it from there. Both
254 * ends are where they would be on a table, and the square keeps the size it was
255 * built at — a square that shrank every draw would close in on the discards
256 * lying inside it.
257 */
258export function ringLayout(stacks: Stack[], cap: RingCapacity): Placed[][] {
259 const sides: Placed[][] = [[], [], [], []];
260 const lengths = [cap.h, cap.v, cap.h, cap.v];
261 const slots = 2 * (cap.h + cap.v);
262 if (slots <= 0) return sides;
263
264 // Each stack has its own place in the square and keeps it: the far end is the
265 // far end of the square, and everything counts back from there. Draws off the
266 // front open a gap at the break point, draws off the tail shorten the other
267 // end, and no tile that is still standing ever has to move.
268 const offset = WALL_STACKS - slots;
269
270 for (let index = 0; index < stacks.length; index++) {
271 const stack = stacks[index];
272 // A wall too long for its square only happens before a hand is dealt, and
273 // then only for the frame it takes to deal it. What falls off the start is
274 // the part about to be drawn anyway.
275 let at = index - offset;
276 if (stack.count === 0 || at < 0 || at >= slots) continue;
277 let side = 0;
278 while (at >= lengths[side]) at -= lengths[side++];
279 sides[side].push({ index, stack });
280 }
281 return sides;
282}