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's size lives entirely in CSS — `--ws` and `--wd` in styles.css work
9 * it out from the tile, and how much of it there is to lay out is decided in
10 * `game/wall.ts` — so rather than restate any of that here and let them drift
11 * apart, this *measures* what came out.
12 * `WallRing` renders every stack as a real element tagged with its index, so the
13 * colliders come from `getBoundingClientRect()` on the things already on screen.
14 * That is the whole mapping from the HTML to the physics.
15 *
16 * Measuring is one layout flush over ~74 elements. It happens when the window
17 * resizes and when the wall's shape changes, never per frame.
18 */
19
20const SEATS: SeatId[] = [0, 1, 2, 3];
21
22export interface TableGeometry {
23 /** Table size, world units — the world's origin is the table's top left. */
24 size: { w: number; h: number };
25 /** Inside the wall square: where a discard is aimed, and where a rebuilt pile
26 * is laid out. Not a fence — see `felt`. */
27 pool: Rect;
28 /**
29 * The whole middle of the table, between the four strips. This is what
30 * actually stops a tile, because the square is not a box: it is eighteen
31 * stacks a side, and once they have been drawn there is nothing there. A tile
32 * is held in by the stacks still standing, and by the edge of the felt beyond
33 * them — so as the wall is eaten away the pool spills out through the gaps,
34 * exactly as it does on a table.
35 */
36 felt: Rect;
37 /** Stacks of two still standing. Half stacks are low enough to throw over. */
38 walls: Rect[];
39 /** A tile in the pool is the same tile that was in the wall. */
40 tile: { w: number; h: number };
41 /** The middle of each seat's hand, if that seat's hand is on screen. */
42 launch: Partial<Record<SeatId, Point>>;
43 /** The right-hand end of each seat's hand, which is where a tile is thrown
44 * from: the tile just drawn sits there, and that is the hand you throw with. */
45 throwFrom: Partial<Record<SeatId, Point>>;
46}
47
48export interface Point {
49 x: number;
50 y: number;
51}
52
53/**
54 * Read the table. Returns null when there is no wall square to measure — the
55 * compact layout has none, and neither does a table that has not laid out yet.
56 */
57export function measure(table: HTMLElement, state: WallProgress): TableGeometry | null {
58 const ring = table.querySelector<HTMLElement>('.wall-ring');
59 if (!ring) return null;
60
61 const base = table.getBoundingClientRect();
62 if (base.width === 0 || base.height === 0) return null;
63 const local = (r: DOMRect): Rect => ({
64 x: r.left - base.left,
65 y: r.top - base.top,
66 w: r.width,
67 h: r.height,
68 });
69
70 const stacks = wallStacks(state);
71 const walls: Rect[] = [];
72 // A stack element is --ws by --wd (or the other way round on the left and
73 // right sides), so its narrower side is the tile's width whichever wall it is
74 // in — the fallback for sizing a pool tile before any hand has been dealt.
75 // The probe is one cell that is always there, spent wall or not.
76 const probe = ring.querySelector<HTMLElement>('.wall-probe')?.getBoundingClientRect();
77 let short = probe ? Math.min(probe.width, probe.height) : 0;
78 for (const el of ring.querySelectorAll<HTMLElement>('[data-stack]')) {
79 const i = Number(el.dataset.stack);
80 const r = local(el.getBoundingClientRect());
81 short = Math.max(short, Math.min(r.w, r.h));
82 const stack = stacks[i];
83 if (stack && isBarrier(stack)) walls.push(r);
84 }
85 if (short === 0) return null;
86
87 // `.wall-ring` is the opening itself — the walls hang off its edges — so the
88 // pool is simply its box. (It used to be the wall's outside, which is why this
89 // had to subtract the wall's own thickness.)
90 const ringRect = local(ring.getBoundingClientRect());
91 const pool: Rect = {
92 x: ringRect.x,
93 y: ringRect.y,
94 w: Math.max(short, ringRect.w),
95 h: Math.max(short, ringRect.h),
96 };
97
98 const launch: Partial<Record<SeatId, Point>> = {};
99 const throwFrom: Partial<Record<SeatId, Point>> = {};
100 for (const seat of SEATS) {
101 const hand = table.querySelector<HTMLElement>(`[data-seat="${seat}"] .hand`);
102 if (!hand) continue;
103 const r = hand.getBoundingClientRect();
104 if (r.width === 0 && r.height === 0) continue;
105 // A rotated element's bounding box is still centred on the element, so this
106 // is the right point whichever edge the seat is sitting at.
107 launch[seat] = { x: r.left + r.width / 2 - base.left, y: r.top + r.height / 2 - base.top };
108
109 // The end of the row rather than the middle of it. That is where the tile
110 // just drawn is sitting and which hand it is in, so it is where a throw
111 // comes from — measured off the last cell, so it is the right end of the
112 // *row* whichever way round the seat is turned.
113 const end = hand.querySelector<HTMLElement>('.hand-cell:last-child');
114 const er = end?.getBoundingClientRect();
115 throwFrom[seat] = er
116 ? { x: er.left + er.width / 2 - base.left, y: er.top + er.height / 2 - base.top }
117 : launch[seat];
118 }
119
120 // The felt is the centre element's own box — the area the four strips leave —
121 // grown into whatever of those strips nobody is using yet.
122 const centre = table.querySelector<HTMLElement>('.center');
123 const box = centre ? local(centre.getBoundingClientRect()) : ringRect;
124 const felt = reaching(box, stripEdges(table, base, local));
125
126 return {
127 size: { w: base.width, h: base.height },
128 pool,
129 felt,
130 walls,
131 tile: poolTile(table, short),
132 launch,
133 throwFrom,
134 };
135}
136
137/**
138 * How big a tile in the middle is drawn: the same size as the tiles being
139 * played with.
140 *
141 * It used to be the size of a tile in the wall, which is much smaller, and that
142 * was a mistake you could see rather than measure — a tile thrown from a hand
143 * shrank as it crossed the table, so it read as being dropped away from you into
144 * the distance rather than skidded across a flat one. They are the same tiles,
145 * so they are the same size.
146 *
147 * A seat's strip is rotated by a right angle, which swaps its bounding box, and
148 * a tile is always taller than it is wide — so the narrower side is the width
149 * whichever edge is measured. Falls back to the wall's own tile if no hand has
150 * been dealt yet.
151 */
152function poolTile(table: HTMLElement, fallback: number): { w: number; h: number } {
153 const el = table.querySelector<HTMLElement>('.hand .tile');
154 const r = el?.getBoundingClientRect();
155 const w = r && r.width > 0 ? Math.min(r.width, r.height) : fallback;
156 return { w, h: w * 1.375 };
157}
158
159/**
160 * The inside edge of each strip's contents, where any of them has been read.
161 * Missing sides are ones there was nothing to measure.
162 */
163export interface StripEdges {
164 left?: number;
165 right?: number;
166 top?: number;
167 bottom?: number;
168}
169
170/**
171 * The felt, out to where each strip's tiles and buttons actually begin.
172 *
173 * `.center` is only what is left over once every strip has been given its full
174 * `--depth`, and a strip is built from its outer edge inwards: the melds sit at
175 * the inside end of it, and until somebody 吃s or 碰s there is nothing there. So
176 * between the middle and the first thing you can see of a seat there is a band
177 * of plain green — most of a hand, for most seats — and a tile that stopped at
178 * `.center`'s edge stopped in the middle of it, against nothing. That band is
179 * table, and this is what hands it back.
180 *
181 * Only ever outwards. The middle is never smaller than the box the layout left.
182 */
183export function reaching(centre: Rect, edges: StripEdges): Rect {
184 const right = centre.x + centre.w;
185 const bottom = centre.y + centre.h;
186 const x = Math.min(centre.x, edges.left ?? centre.x);
187 const y = Math.min(centre.y, edges.top ?? centre.y);
188 return {
189 x,
190 y,
191 w: Math.max(right, edges.right ?? right) - x,
192 h: Math.max(bottom, edges.bottom ?? bottom) - y,
193 };
194}
195
196/**
197 * Read those edges off the four strips. A child holding nothing — the melds row
198 * before a claim, the button bar with no buttons up — is space nobody is using,
199 * so what counts is the first child with something in it.
200 */
201function stripEdges(table: HTMLElement, base: DOMRect, local: (r: DOMRect) => Rect): StripEdges {
202 const edges: StripEdges = {};
203 for (const slot of table.querySelectorAll<HTMLElement>('.slot[data-seat]')) {
204 const seat = slot.querySelector<HTMLElement>('.seat');
205 if (!seat) continue;
206
207 let used: Rect | null = null;
208 for (const child of seat.children) {
209 // The winner's arrow points into the middle from the strip's inside edge
210 // and is gone again next hand; it is not what a seat takes up.
211 if (child.classList.contains('win-arrow')) continue;
212 if (child.childElementCount === 0 && !child.textContent?.trim()) continue;
213 const r = local(child.getBoundingClientRect());
214 if (r.w === 0 || r.h === 0) continue;
215 used = used
216 ? {
217 x: Math.min(used.x, r.x),
218 y: Math.min(used.y, r.y),
219 w: Math.max(used.x + used.w, r.x + r.w) - Math.min(used.x, r.x),
220 h: Math.max(used.y + used.h, r.y + r.h) - Math.min(used.y, r.y),
221 }
222 : r;
223 }
224 if (!used) continue;
225
226 // Which edge of the table this strip is against. The left and right ones
227 // are the only two that touch the sides, so they are what is asked first.
228 const box = local(slot.getBoundingClientRect());
229 if (box.x < 1) edges.left = Math.max(edges.left ?? 0, used.x + used.w);
230 else if (box.x + box.w > base.width - 1) {
231 edges.right = Math.min(edges.right ?? base.width, used.x);
232 } else if (box.y < 1) edges.top = Math.max(edges.top ?? 0, used.y + used.h);
233 else edges.bottom = Math.min(edges.bottom ?? base.height, used.y);
234 }
235 return edges;
236}
237
238export const centreOf = (r: Rect): Point => ({ x: r.x + r.w / 2, y: r.y + r.h / 2 });
239
240/**
241 * Whether a seat has a clear line into the middle — the throw gate.
242 *
243 * Not a rule bolted on beside the physics; it *is* the physics, asked ahead of
244 * time. While a standing stack of two is between a seat and the pool, a tile
245 * flicked from there would hit it, so the seat cannot throw and has to place the
246 * tile over the wall instead (tap-tap, or 打出). As the hand eats the square,
247 * gaps open and seats come good one at a time.
248 *
249 * Aim is not just dead ahead: a fan of lines spread across the pool means a gap
250 * off to one side counts, which is how you would actually throw it.
251 */
252export function canThrow(geo: TableGeometry, seat: SeatId): boolean {
253 const from = throwsFrom(geo, seat);
254 return !!from && canThrowFrom(geo, from);
255}
256
257/**
258 * Where a tile this seat plays actually starts from — the end of the row, not
259 * the middle of it. The same point `pool.ts` launches from, and it has to be:
260 * asking whether the line is clear from somewhere the tile is not is how a tile
261 * gets waved through and then stopped dead by a stack it was never clear of.
262 */
263const throwsFrom = (geo: TableGeometry, seat: SeatId): Point | undefined =>
264 geo.throwFrom[seat] ?? geo.launch[seat];
265
266/**
267 * The same question asked from a particular point rather than from a seat.
268 *
269 * This is the one that matters for a tile somebody is holding: they have picked
270 * it up and can carry it anywhere, so what decides whether it can be slid in is
271 * the line from wherever they let go of it — not the line from the hand it came
272 * out of. Judging a throw released out over the square by the view from the
273 * player's own edge called almost everything a lob.
274 */
275export function canThrowFrom(geo: TableGeometry, from: Point): boolean {
276 if (inRect(geo.pool, from)) return true;
277 return aimPoints(geo).some((to) => clearLine(geo.walls, from, to));
278}
279
280export const inRect = (r: Rect, p: Point) =>
281 p.x >= r.x && p.x <= r.x + r.w && p.y >= r.y && p.y <= r.y + r.h;
282
283/** Somewhere in the pool this seat could actually get a tile to, or null. */
284export function aimAt(geo: TableGeometry, seat: SeatId): Point | null {
285 const from = throwsFrom(geo, seat);
286 if (!from) return null;
287 const open = aimPoints(geo).filter((to) => clearLine(geo.walls, from, to));
288 if (open.length === 0) return null;
289 // The most central thing reachable, so a tile lands among the others rather
290 // than hugging whichever edge it squeezed past.
291 const c = centreOf(geo.pool);
292 return open.reduce((best, p) =>
293 Math.hypot(p.x - c.x, p.y - c.y) < Math.hypot(best.x - c.x, best.y - c.y) ? p : best,
294 );
295}
296
297/**
298 * Landing spots to try for: a grid over the whole pool, not a line through its
299 * middle.
300 *
301 * A throw only has to *get inside* the square — once past the wall the tile
302 * slides to a stop wherever it ends up. So the near corners matter as much as
303 * the centre, and they matter more: they sit much closer to the seat, which is
304 * what makes a gap well off to one side worth aiming through at all. Aiming
305 * only at the middle would call a seat walled in when it plainly is not.
306 */
307function aimPoints(geo: TableGeometry): Point[] {
308 const { x, y, w, h } = geo.pool;
309 // Fine rather than coarse, and it costs nothing: this runs once when a tile
310 // is let go of, not per frame. A sparse grid missed narrow gaps and called a
311 // throw impossible when a tile would plainly have gone through, and sliding is
312 // what a discard should be whenever it can be.
313 const steps = [0.06, 0.2, 0.35, 0.5, 0.65, 0.8, 0.94];
314 const out: Point[] = [];
315 for (const u of steps) for (const v of steps) out.push({ x: x + w * u, y: y + h * v });
316 return out;
317}
318
319/** Segment against every standing stack, by the slab method. */
320function clearLine(walls: Rect[], from: Point, to: Point): boolean {
321 return !walls.some((w) => segmentHitsRect(from, to, w));
322}
323
324/**
325 * How far along a line from `from` the square starts and ends, or null if the
326 * line misses it altogether.
327 *
328 * What a lob needs to know. A tile lifted over the wall has to come *down*
329 * inside the square — solving for where it would come to rest instead lands it
330 * short, against the outside of the wall, which is exactly where a discard must
331 * not end up. So the throw is aimed at somewhere between these two.
332 */
333export function spanThrough(
334 r: Rect,
335 from: Point,
336 dir: Point,
337): { near: number; far: number } | null {
338 let t0 = 0;
339 let t1 = Infinity;
340
341 const slab = (p: number, d: number, lo: number, hi: number): boolean => {
342 if (Math.abs(d) < 1e-9) return p >= lo && p <= hi;
343 const a = (lo - p) / d;
344 const b = (hi - p) / d;
345 t0 = Math.max(t0, Math.min(a, b));
346 t1 = Math.min(t1, Math.max(a, b));
347 return t1 >= t0;
348 };
349
350 if (!slab(from.x, dir.x, r.x, r.x + r.w)) return null;
351 if (!slab(from.y, dir.y, r.y, r.y + r.h)) return null;
352 return t1 >= t0 ? { near: t0, far: t1 } : null;
353}
354
355export function segmentHitsRect(a: Point, b: Point, r: Rect): boolean {
356 const dx = b.x - a.x;
357 const dy = b.y - a.y;
358 let t0 = 0;
359 let t1 = 1;
360
361 // Each axis clips the span of the segment that could still be inside.
362 const slab = (p: number, d: number, lo: number, hi: number): boolean => {
363 if (Math.abs(d) < 1e-9) return p >= lo && p <= hi;
364 const near = (lo - p) / d;
365 const far = (hi - p) / d;
366 t0 = Math.max(t0, Math.min(near, far));
367 t1 = Math.min(t1, Math.max(near, far));
368 return t1 >= t0;
369 };
370
371 if (!slab(a.x, dx, r.x, r.x + r.w)) return false;
372 if (!slab(a.y, dy, r.y, r.y + r.h)) return false;
373 return t1 >= t0;
374}