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 discards come to rest. */
25 pool: Rect;
26 /** Stacks of two still standing. Half stacks are low enough to throw over. */
27 walls: Rect[];
28 /** A tile in the pool is the same tile that was in the wall. */
29 tile: { w: number; h: number };
30 /** Where each seat throws from, if that seat's hand is on screen. */
31 launch: Partial<Record<SeatId, Point>>;
32}
33
34export interface Point {
35 x: number;
36 y: number;
37}
38
39/**
40 * Read the table. Returns null when there is no wall square to measure — the
41 * compact layout has none, and neither does a table that has not laid out yet.
42 */
43export function measure(table: HTMLElement, state: WallProgress): TableGeometry | null {
44 const ring = table.querySelector<HTMLElement>('.wall-ring');
45 if (!ring) return null;
46
47 const base = table.getBoundingClientRect();
48 if (base.width === 0 || base.height === 0) return null;
49 const local = (r: DOMRect): Rect => ({
50 x: r.left - base.left,
51 y: r.top - base.top,
52 w: r.width,
53 h: r.height,
54 });
55
56 const stacks = wallStacks(state);
57 const walls: Rect[] = [];
58 // A stack element is --ws by --wd (or the other way round on the left and
59 // right sides), which gives us both the tile size and the wall's thickness
60 // without knowing either number.
61 let short = 0;
62 let long = 0;
63 for (const el of ring.querySelectorAll<HTMLElement>('[data-stack]')) {
64 const i = Number(el.dataset.stack);
65 const r = local(el.getBoundingClientRect());
66 short = Math.max(short, Math.min(r.w, r.h));
67 long = Math.max(long, Math.max(r.w, r.h));
68 const stack = stacks[i];
69 if (stack && isBarrier(stack)) walls.push(r);
70 }
71 if (short === 0) return null;
72
73 // The pool is the square the wall encloses, less the wall's own thickness.
74 const ringRect = local(ring.getBoundingClientRect());
75 const pool: Rect = {
76 x: ringRect.x + long,
77 y: ringRect.y + long,
78 w: Math.max(short, ringRect.w - long * 2),
79 h: Math.max(short, ringRect.h - long * 2),
80 };
81
82 const launch: Partial<Record<SeatId, Point>> = {};
83 for (const seat of SEATS) {
84 const hand = table.querySelector<HTMLElement>(`[data-seat="${seat}"] .hand`);
85 if (!hand) continue;
86 const r = hand.getBoundingClientRect();
87 if (r.width === 0 && r.height === 0) continue;
88 // A rotated element's bounding box is still centred on the element, so this
89 // is the right point whichever edge the seat is sitting at.
90 launch[seat] = { x: r.left + r.width / 2 - base.left, y: r.top + r.height / 2 - base.top };
91 }
92
93 return {
94 size: { w: base.width, h: base.height },
95 pool,
96 walls,
97 tile: { w: short, h: short * 1.375 },
98 launch,
99 };
100}
101
102export const centreOf = (r: Rect): Point => ({ x: r.x + r.w / 2, y: r.y + r.h / 2 });
103
104/**
105 * Whether a seat has a clear line into the middle — the throw gate.
106 *
107 * Not a rule bolted on beside the physics; it *is* the physics, asked ahead of
108 * time. While a standing stack of two is between a seat and the pool, a tile
109 * flicked from there would hit it, so the seat cannot throw and has to place the
110 * tile over the wall instead (tap-tap, or 打出). As the hand eats the square,
111 * gaps open and seats come good one at a time.
112 *
113 * Aim is not just dead ahead: a fan of lines spread across the pool means a gap
114 * off to one side counts, which is how you would actually throw it.
115 */
116export function canThrow(geo: TableGeometry, seat: SeatId): boolean {
117 const from = geo.launch[seat];
118 if (!from) return false;
119 return aimPoints(geo).some((to) => clearLine(geo.walls, from, to));
120}
121
122/** Somewhere in the pool this seat could actually get a tile to, or null. */
123export function aimAt(geo: TableGeometry, seat: SeatId): Point | null {
124 const from = geo.launch[seat];
125 if (!from) return null;
126 const open = aimPoints(geo).filter((to) => clearLine(geo.walls, from, to));
127 if (open.length === 0) return null;
128 // The most central thing reachable, so a tile lands among the others rather
129 // than hugging whichever edge it squeezed past.
130 const c = centreOf(geo.pool);
131 return open.reduce((best, p) =>
132 Math.hypot(p.x - c.x, p.y - c.y) < Math.hypot(best.x - c.x, best.y - c.y) ? p : best,
133 );
134}
135
136/**
137 * Landing spots to try for: a grid over the whole pool, not a line through its
138 * middle.
139 *
140 * A throw only has to *get inside* the square — once past the wall the tile
141 * slides to a stop wherever it ends up. So the near corners matter as much as
142 * the centre, and they matter more: they sit much closer to the seat, which is
143 * what makes a gap well off to one side worth aiming through at all. Aiming
144 * only at the middle would call a seat walled in when it plainly is not.
145 */
146function aimPoints(geo: TableGeometry): Point[] {
147 const { x, y, w, h } = geo.pool;
148 const steps = [0.08, 0.3, 0.5, 0.7, 0.92];
149 const out: Point[] = [];
150 for (const u of steps) for (const v of steps) out.push({ x: x + w * u, y: y + h * v });
151 return out;
152}
153
154/** Segment against every standing stack, by the slab method. */
155function clearLine(walls: Rect[], from: Point, to: Point): boolean {
156 return !walls.some((w) => segmentHitsRect(from, to, w));
157}
158
159export function segmentHitsRect(a: Point, b: Point, r: Rect): boolean {
160 const dx = b.x - a.x;
161 const dy = b.y - a.y;
162 let t0 = 0;
163 let t1 = 1;
164
165 // Each axis clips the span of the segment that could still be inside.
166 const slab = (p: number, d: number, lo: number, hi: number): boolean => {
167 if (Math.abs(d) < 1e-9) return p >= lo && p <= hi;
168 const near = (lo - p) / d;
169 const far = (hi - p) / d;
170 t0 = Math.max(t0, Math.min(near, far));
171 t1 = Math.min(t1, Math.max(near, far));
172 return t1 >= t0;
173 };
174
175 if (!slab(a.x, dx, r.x, r.x + r.w)) return false;
176 if (!slab(a.y, dy, r.y, r.y + r.h)) return false;
177 return t1 >= t0;
178}