| 1 | import { STACKS_PER_SIDE, WALL_SIZE, WALL_STACKS } from './tiles'; |
| 2 | import 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 | */ |
| 16 | export interface WallProgress { |
| 17 | drawnFront: number; |
| 18 | drawnBack: number; |
| 19 | rules: { wallReserve: number }; |
| 20 | } |
| 21 | |
| 22 | export 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 | */ |
| 35 | export 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 | */ |
| 57 | export const isBarrier = (s: Stack) => s.count === 2; |
| 58 | |
| 59 | /** The four sides in the order WallRing lays them out, clockwise from the top. */ |
| 60 | export const WALL_SIDES = ['top', 'right', 'bottom', 'left'] as const; |
| 61 | export 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 | */ |
| 68 | export 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. */ |
| 71 | export 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. */ |
| 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. 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 | */ |
| 91 | export 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 | */ |
| 107 | export 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. */ |
| 111 | export interface WallPlace { |
| 112 | x: number; |
| 113 | y: number; |
| 114 | dir: number; |
| 115 | } |
| 116 | |
| 117 | export 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 | |
| 127 | const 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 | */ |
| 157 | export 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 | const tile = len / STACKS_PER_SIDE; |
| 170 | const shut = thick / Math.tan(rad(sharp) / 2); |
| 171 | const lap = Math.max(0, shut - Math.min(0.99, Math.max(0, gap)) * tile); |
| 172 | // Corner to corner. The wall is longer than this by the overrun. |
| 173 | const edge = Math.max(thick, len - lap); |
| 174 | |
| 175 | // Two pairs of parallel sides: turning by `a` then by `180 - a`, twice round. |
| 176 | const dirs = [turn, turn + a, turn + 180, turn + 180 + a]; |
| 177 | const u = (deg: number) => ({ x: Math.cos(rad(deg)), y: Math.sin(rad(deg)) }); |
| 178 | |
| 179 | // Corners, hung so the middle of them lands on the middle of the ring. |
| 180 | const u0 = u(dirs[0]); |
| 181 | const u1 = u(dirs[1]); |
| 182 | const corners = [{ x: (-edge * (u0.x + u1.x)) / 2, y: (-edge * (u0.y + u1.y)) / 2 }]; |
| 183 | corners.push({ x: corners[0].x + edge * u0.x, y: corners[0].y + edge * u0.y }); |
| 184 | corners.push({ x: corners[1].x + edge * u1.x, y: corners[1].y + edge * u1.y }); |
| 185 | corners.push({ x: corners[2].x - edge * u0.x, y: corners[2].y - edge * u0.y }); |
| 186 | |
| 187 | const places: WallPlace[] = []; |
| 188 | const xs: number[] = []; |
| 189 | const ys: number[] = []; |
| 190 | for (let i = 0; i < 4; i++) { |
| 191 | const dir = dirs[i]; |
| 192 | const along = u(dir); |
| 193 | // A box laid down at an angle grows along its own +y, which is its |
| 194 | // direction turned a quarter clockwise. Whichever way that points, the wall |
| 195 | // has to end up on the *outside*, so it is hung off the far side when it |
| 196 | // does not. |
| 197 | const nx = -Math.sin(rad(dir)); |
| 198 | const ny = Math.cos(rad(dir)); |
| 199 | const mid = { x: corners[i].x + (edge / 2) * along.x, y: corners[i].y + (edge / 2) * along.y }; |
| 200 | const outward = nx * mid.x + ny * mid.y >= 0; |
| 201 | const x = corners[i].x + (outward ? 0 : -thick * nx); |
| 202 | const y = corners[i].y + (outward ? 0 : -thick * ny); |
| 203 | places.push({ x, y, dir }); |
| 204 | // Its four corners, for the footprint. |
| 205 | for (const [dl, dt] of [[0, 0], [len, 0], [len, thick], [0, thick]]) { |
| 206 | xs.push(x + dl * along.x + dt * nx); |
| 207 | ys.push(y + dl * along.y + dt * ny); |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | const cx = corners.map((c) => c.x); |
| 212 | const cy = corners.map((c) => c.y); |
| 213 | return { |
| 214 | places, |
| 215 | gap: shut - lap, |
| 216 | box: { w: Math.max(...xs) - Math.min(...xs), h: Math.max(...ys) - Math.min(...ys) }, |
| 217 | open: { |
| 218 | x: Math.min(...cx), |
| 219 | y: Math.min(...cy), |
| 220 | w: Math.max(...cx) - Math.min(...cx), |
| 221 | h: Math.max(...cy) - Math.min(...cy), |
| 222 | }, |
| 223 | }; |
| 224 | } |
| 225 | |
| 226 | /** A stack and which of the 72 it is — the index is what the physics reads. */ |
| 227 | export interface Placed { |
| 228 | index: number; |
| 229 | stack: Stack; |
| 230 | } |
| 231 | |
| 232 | /** |
| 233 | * Where what is left of the wall actually goes. |
| 234 | * |
| 235 | * Four walls of eighteen full-size stacks want about 560px a side, and the |
| 236 | * middle of the table is nothing like that in both directions at once — so a |
| 237 | * square built to the tile is a square that hangs out under the players. What |
| 238 | * saves it is that by the time anyone is looking, four hands have been dealt |
| 239 | * off the front and there is nothing like a whole wall left: only what is still |
| 240 | * standing is drawn, and it is laid out around a ring cut to the middle rather |
| 241 | * than to the tile. Players push the remaining stacks about to keep them tidy |
| 242 | * for exactly this reason. |
| 243 | * |
| 244 | * The rebuilt square is the size of the wall as it is dealt, so at the start |
| 245 | * the run fills it exactly, and from then on it is simply *eaten*: the tail is |
| 246 | * pinned to the end of the square and normal draws take stacks off the front, |
| 247 | * which leaves a growing gap where they were and moves nothing else. Kong and |
| 248 | * flower replacements come off the other end and shorten it from there. Both |
| 249 | * ends are where they would be on a table, and the square keeps the size it was |
| 250 | * built at — a square that shrank every draw would close in on the discards |
| 251 | * lying inside it. |
| 252 | */ |
| 253 | export function ringLayout(stacks: Stack[], cap: RingCapacity): Placed[][] { |
| 254 | const sides: Placed[][] = [[], [], [], []]; |
| 255 | const lengths = [cap.h, cap.v, cap.h, cap.v]; |
| 256 | const slots = 2 * (cap.h + cap.v); |
| 257 | if (slots <= 0) return sides; |
| 258 | |
| 259 | // Each stack has its own place in the square and keeps it: the far end is the |
| 260 | // far end of the square, and everything counts back from there. Draws off the |
| 261 | // front open a gap at the break point, draws off the tail shorten the other |
| 262 | // end, and no tile that is still standing ever has to move. |
| 263 | const offset = WALL_STACKS - slots; |
| 264 | |
| 265 | for (let index = 0; index < stacks.length; index++) { |
| 266 | const stack = stacks[index]; |
| 267 | // A wall too long for its square only happens before a hand is dealt, and |
| 268 | // then only for the frame it takes to deal it. What falls off the start is |
| 269 | // the part about to be drawn anyway. |
| 270 | let at = index - offset; |
| 271 | if (stack.count === 0 || at < 0 || at >= slots) continue; |
| 272 | let side = 0; |
| 273 | while (at >= lengths[side]) at -= lengths[side++]; |
| 274 | sides[side].push({ index, stack }); |
| 275 | } |
| 276 | return sides; |
| 277 | } |