anvilsign in

collin/mahjong

1import { isBarrier, wallStacks, type WallProgress } from '../game/wall';
2import type { SeatId } from '../game/types';
3import type { Rect } from './physics';
4
5/**
6 * Where everything on the table actually is, in pixels.
7 *
8 * The wall square's size lives entirely in CSS — `--ring`, `--ws`, `--wd` in
9 * styles.css work it out from the viewport and the strip depth — so rather than
10 * restate that arithmetic here and let the two drift apart, this *measures* it.
11 * `WallRing` renders every stack as a real element tagged with its index, so the
12 * colliders come from `getBoundingClientRect()` on the things already on screen.
13 * That is the whole mapping from the HTML to the physics.
14 *
15 * Measuring is one layout flush over ~74 elements. It happens when the window
16 * resizes and when the wall's shape changes, never per frame.
17 */
18
19const SEATS: SeatId[] = [0, 1, 2, 3];
20
21export interface TableGeometry {
22 /** Table size, world units — the world's origin is the table's top left. */
23 size: { w: number; h: number };
24 /** Inside the wall square: where a discard is aimed, and where a rebuilt pile
25 * is laid out. Not a fence — see `felt`. */
26 pool: Rect;
27 /**
28 * The whole middle of the table, between the four strips. This is what
29 * actually stops a tile, because the square is not a box: it is eighteen
30 * stacks a side, and once they have been drawn there is nothing there. A tile
31 * is held in by the stacks still standing, and by the edge of the felt beyond
32 * them — so as the wall is eaten away the pool spills out through the gaps,
33 * exactly as it does on a table.
34 */
35 felt: Rect;
36 /** Stacks of two still standing. Half stacks are low enough to throw over. */
37 walls: Rect[];
38 /** A tile in the pool is the same tile that was in the wall. */
39 tile: { w: number; h: number };
40 /** The middle of each seat's hand, if that seat's hand is on screen. */
41 launch: Partial<Record<SeatId, Point>>;
42 /** The right-hand end of each seat's hand, which is where a tile is thrown
43 * from: the tile just drawn sits there, and that is the hand you throw with. */
44 throwFrom: Partial<Record<SeatId, Point>>;
45}
46
47export interface Point {
48 x: number;
49 y: number;
50}
51
52/**
53 * Read the table. Returns null when there is no wall square to measure — the
54 * compact layout has none, and neither does a table that has not laid out yet.
55 */
56export function measure(table: HTMLElement, state: WallProgress): TableGeometry | null {
57 const ring = table.querySelector<HTMLElement>('.wall-ring');
58 if (!ring) return null;
59
60 const base = table.getBoundingClientRect();
61 if (base.width === 0 || base.height === 0) return null;
62 const local = (r: DOMRect): Rect => ({
63 x: r.left - base.left,
64 y: r.top - base.top,
65 w: r.width,
66 h: r.height,
67 });
68
69 const stacks = wallStacks(state);
70 const walls: Rect[] = [];
71 // A stack element is --ws by --wd (or the other way round on the left and
72 // right sides), which gives us both the tile size and the wall's thickness
73 // without knowing either number.
74 let short = 0;
75 let long = 0;
76 for (const el of ring.querySelectorAll<HTMLElement>('[data-stack]')) {
77 const i = Number(el.dataset.stack);
78 const r = local(el.getBoundingClientRect());
79 short = Math.max(short, Math.min(r.w, r.h));
80 long = Math.max(long, Math.max(r.w, r.h));
81 const stack = stacks[i];
82 if (stack && isBarrier(stack)) walls.push(r);
83 }
84 if (short === 0) return null;
85
86 // The pool is the square the wall encloses, less the wall's own thickness.
87 const ringRect = local(ring.getBoundingClientRect());
88 const pool: Rect = {
89 x: ringRect.x + long,
90 y: ringRect.y + long,
91 w: Math.max(short, ringRect.w - long * 2),
92 h: Math.max(short, ringRect.h - long * 2),
93 };
94
95 const launch: Partial<Record<SeatId, Point>> = {};
96 const throwFrom: Partial<Record<SeatId, Point>> = {};
97 for (const seat of SEATS) {
98 const hand = table.querySelector<HTMLElement>(`[data-seat="${seat}"] .hand`);
99 if (!hand) continue;
100 const r = hand.getBoundingClientRect();
101 if (r.width === 0 && r.height === 0) continue;
102 // A rotated element's bounding box is still centred on the element, so this
103 // is the right point whichever edge the seat is sitting at.
104 launch[seat] = { x: r.left + r.width / 2 - base.left, y: r.top + r.height / 2 - base.top };
105
106 // The end of the row rather than the middle of it. That is where the tile
107 // just drawn is sitting and which hand it is in, so it is where a throw
108 // comes from — measured off the last cell, so it is the right end of the
109 // *row* whichever way round the seat is turned.
110 const end = hand.querySelector<HTMLElement>('.hand-cell:last-child');
111 const er = end?.getBoundingClientRect();
112 throwFrom[seat] = er
113 ? { x: er.left + er.width / 2 - base.left, y: er.top + er.height / 2 - base.top }
114 : launch[seat];
115 }
116
117 // The felt is the centre element's own box — the area the four strips leave.
118 const centre = table.querySelector<HTMLElement>('.center');
119 const felt = centre ? local(centre.getBoundingClientRect()) : ringRect;
120
121 return {
122 size: { w: base.width, h: base.height },
123 pool,
124 felt,
125 walls,
126 tile: poolTile(table, short),
127 launch,
128 throwFrom,
129 };
130}
131
132/**
133 * How big a tile in the middle is drawn: the same size as the tiles being
134 * played with.
135 *
136 * It used to be the size of a tile in the wall, which is much smaller, and that
137 * was a mistake you could see rather than measure — a tile thrown from a hand
138 * shrank as it crossed the table, so it read as being dropped away from you into
139 * the distance rather than skidded across a flat one. They are the same tiles,
140 * so they are the same size.
141 *
142 * A seat's strip is rotated by a right angle, which swaps its bounding box, and
143 * a tile is always taller than it is wide — so the narrower side is the width
144 * whichever edge is measured. Falls back to the wall's own tile if no hand has
145 * been dealt yet.
146 */
147function poolTile(table: HTMLElement, fallback: number): { w: number; h: number } {
148 const el = table.querySelector<HTMLElement>('.hand .tile');
149 const r = el?.getBoundingClientRect();
150 const w = r && r.width > 0 ? Math.min(r.width, r.height) : fallback;
151 return { w, h: w * 1.375 };
152}
153
154export const centreOf = (r: Rect): Point => ({ x: r.x + r.w / 2, y: r.y + r.h / 2 });
155
156/**
157 * Whether a seat has a clear line into the middle — the throw gate.
158 *
159 * Not a rule bolted on beside the physics; it *is* the physics, asked ahead of
160 * time. While a standing stack of two is between a seat and the pool, a tile
161 * flicked from there would hit it, so the seat cannot throw and has to place the
162 * tile over the wall instead (tap-tap, or 打出). As the hand eats the square,
163 * gaps open and seats come good one at a time.
164 *
165 * Aim is not just dead ahead: a fan of lines spread across the pool means a gap
166 * off to one side counts, which is how you would actually throw it.
167 */
168export function canThrow(geo: TableGeometry, seat: SeatId): boolean {
169 const from = geo.launch[seat];
170 return !!from && canThrowFrom(geo, from);
171}
172
173/**
174 * The same question asked from a particular point rather than from a seat.
175 *
176 * This is the one that matters for a tile somebody is holding: they have picked
177 * it up and can carry it anywhere, so what decides whether it can be slid in is
178 * the line from wherever they let go of it — not the line from the hand it came
179 * out of. Judging a throw released out over the square by the view from the
180 * player's own edge called almost everything a lob.
181 */
182export function canThrowFrom(geo: TableGeometry, from: Point): boolean {
183 if (inRect(geo.pool, from)) return true;
184 return aimPoints(geo).some((to) => clearLine(geo.walls, from, to));
185}
186
187export const inRect = (r: Rect, p: Point) =>
188 p.x >= r.x && p.x <= r.x + r.w && p.y >= r.y && p.y <= r.y + r.h;
189
190/** Somewhere in the pool this seat could actually get a tile to, or null. */
191export function aimAt(geo: TableGeometry, seat: SeatId): Point | null {
192 const from = geo.launch[seat];
193 if (!from) return null;
194 const open = aimPoints(geo).filter((to) => clearLine(geo.walls, from, to));
195 if (open.length === 0) return null;
196 // The most central thing reachable, so a tile lands among the others rather
197 // than hugging whichever edge it squeezed past.
198 const c = centreOf(geo.pool);
199 return open.reduce((best, p) =>
200 Math.hypot(p.x - c.x, p.y - c.y) < Math.hypot(best.x - c.x, best.y - c.y) ? p : best,
201 );
202}
203
204/**
205 * Landing spots to try for: a grid over the whole pool, not a line through its
206 * middle.
207 *
208 * A throw only has to *get inside* the square — once past the wall the tile
209 * slides to a stop wherever it ends up. So the near corners matter as much as
210 * the centre, and they matter more: they sit much closer to the seat, which is
211 * what makes a gap well off to one side worth aiming through at all. Aiming
212 * only at the middle would call a seat walled in when it plainly is not.
213 */
214function aimPoints(geo: TableGeometry): Point[] {
215 const { x, y, w, h } = geo.pool;
216 // Fine rather than coarse, and it costs nothing: this runs once when a tile
217 // is let go of, not per frame. A sparse grid missed narrow gaps and called a
218 // throw impossible when a tile would plainly have gone through, and sliding is
219 // what a discard should be whenever it can be.
220 const steps = [0.06, 0.2, 0.35, 0.5, 0.65, 0.8, 0.94];
221 const out: Point[] = [];
222 for (const u of steps) for (const v of steps) out.push({ x: x + w * u, y: y + h * v });
223 return out;
224}
225
226/** Segment against every standing stack, by the slab method. */
227function clearLine(walls: Rect[], from: Point, to: Point): boolean {
228 return !walls.some((w) => segmentHitsRect(from, to, w));
229}
230
231export function segmentHitsRect(a: Point, b: Point, r: Rect): boolean {
232 const dx = b.x - a.x;
233 const dy = b.y - a.y;
234 let t0 = 0;
235 let t1 = 1;
236
237 // Each axis clips the span of the segment that could still be inside.
238 const slab = (p: number, d: number, lo: number, hi: number): boolean => {
239 if (Math.abs(d) < 1e-9) return p >= lo && p <= hi;
240 const near = (lo - p) / d;
241 const far = (hi - p) / d;
242 t0 = Math.max(t0, Math.min(near, far));
243 t1 = Math.min(t1, Math.max(near, far));
244 return t1 >= t0;
245 };
246
247 if (!slab(a.x, dx, r.x, r.x + r.w)) return false;
248 if (!slab(a.y, dy, r.y, r.y + r.h)) return false;
249 return t1 >= t0;
250}