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 — 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 */
94export 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 */
104export 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. */
123export 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. */
129export 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 */
155export 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}