anvilsign in

collin/mahjong

1import { isBarrier, wallStacks, type WallProgress } from '../game/wall';
2import type { SeatId } from '../game/types';
3import type { Barrier, 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 table itself, and nothing nearer. Not a fence anybody meets in play: it
30 * is the backstop that keeps a tile from being lost off the screen entirely,
31 * and it sits at the edge of the glass where nothing can reach it.
32 *
33 * What actually stops a tile is `barriers` — the tiles. The middle is not a
34 * box, it is a square of eighteen stacks a side with people's hands beyond,
35 * and as the wall is eaten away the pool spills out through the gaps exactly
36 * as it does on a real table, until it fetches up against somebody's tiles.
37 */
38 felt: Rect;
39 /**
40 * Stacks of two still standing. Half stacks are low enough to throw over.
41 * The throw gate's business — what the physics collides with is `barriers`,
42 * which includes these.
43 */
44 walls: Barrier[];
45 /**
46 * Everything solid, which is to say every tile on the table: the standing
47 * stacks, the sets people have laid down, and the sixteen each of them is
48 * holding. Measured off the elements themselves, like the wall — a tile you
49 * can see is a tile a thrown one hits.
50 */
51 barriers: Barrier[];
52 /** A tile in the pool is the same tile that was in the wall. */
53 tile: { w: number; h: number };
54 /** The middle of each seat's hand, if that seat's hand is on screen. */
55 launch: Partial<Record<SeatId, Point>>;
56 /** The right-hand end of each seat's hand, which is where a tile is thrown
57 * from: the tile just drawn sits there, and that is the hand you throw with. */
58 throwFrom: Partial<Record<SeatId, Point>>;
59}
60
61export interface Point {
62 x: number;
63 y: number;
64}
65
66/**
67 * Read the table. Returns null when there is no wall square to measure — the
68 * compact layout has none, and neither does a table that has not laid out yet.
69 */
70export function measure(table: HTMLElement, state: WallProgress): TableGeometry | null {
71 const ring = table.querySelector<HTMLElement>('.wall-ring');
72 if (!ring) return null;
73
74 const base = table.getBoundingClientRect();
75 if (base.width === 0 || base.height === 0) return null;
76 const local = (r: DOMRect): Rect => ({
77 x: r.left - base.left,
78 y: r.top - base.top,
79 w: r.width,
80 h: r.height,
81 });
82
83 const stacks = wallStacks(state);
84 const walls: Barrier[] = [];
85 const barriers: Barrier[] = [];
86 // A stack element is --ws by --wd (or the other way round on the left and
87 // right sides), so its narrower side is the tile's width whichever wall it is
88 // in — the fallback for sizing a pool tile before any hand has been dealt.
89 // The probe is one cell that is always there, spent wall or not.
90 const probe = ring.querySelector<HTMLElement>('.wall-probe')?.getBoundingClientRect();
91 let short = probe ? Math.min(probe.width, probe.height) : 0;
92 // The whole square is set at an angle — see `--wall-turn` in styles.css — so
93 // a stack's box is not what `getBoundingClientRect` reports, which is only the
94 // upright box *around* the turned one. What is true either way is the centre
95 // it reports and the size the layout gave it, and those two plus the angle are
96 // the stack itself.
97 const turn = wallTurn(ring);
98 for (const el of ring.querySelectorAll<HTMLElement>('[data-stack]')) {
99 const i = Number(el.dataset.stack);
100 const around = local(el.getBoundingClientRect());
101 const r: Rect = {
102 x: around.x + around.w / 2 - el.offsetWidth / 2,
103 y: around.y + around.h / 2 - el.offsetHeight / 2,
104 w: el.offsetWidth,
105 h: el.offsetHeight,
106 };
107 short = Math.max(short, Math.min(r.w, r.h));
108 const stack = stacks[i];
109 if (stack && isBarrier(stack)) {
110 walls.push({ rect: r, angle: turn });
111 barriers.push({ rect: r, angle: turn });
112 }
113 }
114 if (short === 0) return null;
115
116 // `.wall-ring` is the opening itself — the walls hang off its edges — so the
117 // pool is simply its box. (It used to be the wall's outside, which is why this
118 // had to subtract the wall's own thickness.)
119 //
120 // Taken at the size the layout gave it rather than the size it covers once
121 // turned: the opening is the same opening whichever way round the square is
122 // standing, and this is only ever where a *tapped* discard is aimed. A flick
123 // is not aimed at anything — it goes where it was thrown.
124 const around = local(ring.getBoundingClientRect());
125 const openW = Math.max(short, ring.offsetWidth);
126 const openH = Math.max(short, ring.offsetHeight);
127 const ringRect: Rect = {
128 x: around.x + around.w / 2 - openW / 2,
129 y: around.y + around.h / 2 - openH / 2,
130 w: openW,
131 h: openH,
132 };
133 const pool: Rect = ringRect;
134
135 const launch: Partial<Record<SeatId, Point>> = {};
136 const throwFrom: Partial<Record<SeatId, Point>> = {};
137 for (const seat of SEATS) {
138 const hand = table.querySelector<HTMLElement>(`[data-seat="${seat}"] .hand`);
139 if (!hand) continue;
140 const r = hand.getBoundingClientRect();
141 if (r.width === 0 && r.height === 0) continue;
142 // A rotated element's bounding box is still centred on the element, so this
143 // is the right point whichever edge the seat is sitting at.
144 launch[seat] = { x: r.left + r.width / 2 - base.left, y: r.top + r.height / 2 - base.top };
145
146 // The end of the row rather than the middle of it. That is where the tile
147 // just drawn is sitting and which hand it is in, so it is where a throw
148 // comes from — measured off the last cell, so it is the right end of the
149 // *row* whichever way round the seat is turned.
150 const end = hand.querySelector<HTMLElement>('.hand-cell:last-child');
151 const er = end?.getBoundingClientRect();
152 throwFrom[seat] = er
153 ? { x: er.left + er.width / 2 - base.left, y: er.top + er.height / 2 - base.top }
154 : launch[seat];
155
156 // Every tile this seat has on the table, one collider each. A row of them
157 // is adjacent, so a hand comes out as a solid wall without being described
158 // as one anywhere — the same trick the wall square is measured with.
159 const slot = table.querySelector<HTMLElement>(`[data-seat="${seat}"]`);
160 for (const el of slot?.querySelectorAll<HTMLElement>('.hand .tile, .melds .tile') ?? []) {
161 const t = local(el.getBoundingClientRect());
162 if (t.w > 0 && t.h > 0) barriers.push({ rect: t, seat });
163 }
164 }
165
166 // The glass, not the green. Nothing meets this in play — it is only there so
167 // a tile that got past everybody's tiles is still on the table afterwards.
168 const felt: Rect = { x: 0, y: 0, w: base.width, h: base.height };
169
170 return {
171 size: { w: base.width, h: base.height },
172 pool,
173 felt,
174 walls,
175 barriers,
176 tile: poolTile(table, short),
177 launch,
178 throwFrom,
179 };
180}
181
182/**
183 * How big a tile in the middle is drawn: the same size as the tiles being
184 * played with.
185 *
186 * It used to be the size of a tile in the wall, which is much smaller, and that
187 * was a mistake you could see rather than measure — a tile thrown from a hand
188 * shrank as it crossed the table, so it read as being dropped away from you into
189 * the distance rather than skidded across a flat one. They are the same tiles,
190 * so they are the same size.
191 *
192 * A seat's strip is rotated by a right angle, which swaps its bounding box, and
193 * a tile is always taller than it is wide — so the narrower side is the width
194 * whichever edge is measured. Falls back to the wall's own tile if no hand has
195 * been dealt yet.
196 */
197function poolTile(table: HTMLElement, fallback: number): { w: number; h: number } {
198 const el = table.querySelector<HTMLElement>('.hand .tile');
199 const r = el?.getBoundingClientRect();
200 const w = r && r.width > 0 ? Math.min(r.width, r.height) : fallback;
201 return { w, h: w * 1.375 };
202}
203
204/**
205 * How far round the wall square is set, in radians.
206 *
207 * Read off the element rather than written down here, the same way everything
208 * else about the wall is: `--wall-turn` in styles.css is the one place it is
209 * decided, and turning it there turns the colliders with it.
210 */
211function wallTurn(ring: HTMLElement): number {
212 const deg = parseFloat(getComputedStyle(ring).getPropertyValue('--wall-turn'));
213 return Number.isFinite(deg) ? (deg * Math.PI) / 180 : 0;
214}
215
216export const centreOf = (r: Rect): Point => ({ x: r.x + r.w / 2, y: r.y + r.h / 2 });
217
218/**
219 * Whether a seat has a clear line into the middle — the throw gate.
220 *
221 * Not a rule bolted on beside the physics; it *is* the physics, asked ahead of
222 * time. While a standing stack of two is between a seat and the pool, a tile
223 * flicked from there would hit it, so the seat cannot throw and has to place the
224 * tile over the wall instead (tap-tap, or 打出). As the hand eats the square,
225 * gaps open and seats come good one at a time.
226 *
227 * Aim is not just dead ahead: a fan of lines spread across the pool means a gap
228 * off to one side counts, which is how you would actually throw it.
229 */
230export function canThrow(geo: TableGeometry, seat: SeatId): boolean {
231 const from = throwsFrom(geo, seat);
232 return !!from && canThrowFrom(geo, from);
233}
234
235/**
236 * Where a tile this seat plays actually starts from — the end of the row, not
237 * the middle of it. The same point `pool.ts` launches from, and it has to be:
238 * asking whether the line is clear from somewhere the tile is not is how a tile
239 * gets waved through and then stopped dead by a stack it was never clear of.
240 */
241const throwsFrom = (geo: TableGeometry, seat: SeatId): Point | undefined =>
242 geo.throwFrom[seat] ?? geo.launch[seat];
243
244/**
245 * The same question asked from a particular point rather than from a seat.
246 *
247 * This is the one that matters for a tile somebody is holding: they have picked
248 * it up and can carry it anywhere, so what decides whether it can be slid in is
249 * the line from wherever they let go of it — not the line from the hand it came
250 * out of. Judging a throw released out over the square by the view from the
251 * player's own edge called almost everything a lob.
252 */
253export function canThrowFrom(geo: TableGeometry, from: Point): boolean {
254 if (inRect(geo.pool, from)) return true;
255 return aimPoints(geo).some((to) => clearLine(geo.walls, from, to));
256}
257
258export const inRect = (r: Rect, p: Point) =>
259 p.x >= r.x && p.x <= r.x + r.w && p.y >= r.y && p.y <= r.y + r.h;
260
261/** Somewhere in the pool this seat could actually get a tile to, or null. */
262export function aimAt(geo: TableGeometry, seat: SeatId): Point | null {
263 const from = throwsFrom(geo, seat);
264 if (!from) return null;
265 const open = aimPoints(geo).filter((to) => clearLine(geo.walls, from, to));
266 if (open.length === 0) return null;
267 // The most central thing reachable, so a tile lands among the others rather
268 // than hugging whichever edge it squeezed past.
269 const c = centreOf(geo.pool);
270 return open.reduce((best, p) =>
271 Math.hypot(p.x - c.x, p.y - c.y) < Math.hypot(best.x - c.x, best.y - c.y) ? p : best,
272 );
273}
274
275/**
276 * Landing spots to try for: a grid over the whole pool, not a line through its
277 * middle.
278 *
279 * A throw only has to *get inside* the square — once past the wall the tile
280 * slides to a stop wherever it ends up. So the near corners matter as much as
281 * the centre, and they matter more: they sit much closer to the seat, which is
282 * what makes a gap well off to one side worth aiming through at all. Aiming
283 * only at the middle would call a seat walled in when it plainly is not.
284 */
285function aimPoints(geo: TableGeometry): Point[] {
286 const { x, y, w, h } = geo.pool;
287 // Fine rather than coarse, and it costs nothing: this runs once when a tile
288 // is let go of, not per frame. A sparse grid missed narrow gaps and called a
289 // throw impossible when a tile would plainly have gone through, and sliding is
290 // what a discard should be whenever it can be.
291 const steps = [0.06, 0.2, 0.35, 0.5, 0.65, 0.8, 0.94];
292 const out: Point[] = [];
293 for (const u of steps) for (const v of steps) out.push({ x: x + w * u, y: y + h * v });
294 return out;
295}
296
297/** Segment against every standing stack, by the slab method. */
298function clearLine(walls: Barrier[], from: Point, to: Point): boolean {
299 return !walls.some((w) => segmentHitsRect(from, to, w.rect, w.angle));
300}
301
302export function segmentHitsRect(a: Point, b: Point, r: Rect, angle = 0): boolean {
303 // A turned box is the same box asked from where it is standing: put the
304 // segment into its frame and the slabs are axis-aligned again.
305 if (angle) {
306 const cx = r.x + r.w / 2;
307 const cy = r.y + r.h / 2;
308 const c = Math.cos(-angle);
309 const s = Math.sin(-angle);
310 const into = (p: Point): Point => ({
311 x: cx + (p.x - cx) * c - (p.y - cy) * s,
312 y: cy + (p.x - cx) * s + (p.y - cy) * c,
313 });
314 return segmentHitsRect(into(a), into(b), r);
315 }
316 const dx = b.x - a.x;
317 const dy = b.y - a.y;
318 let t0 = 0;
319 let t1 = 1;
320
321 // Each axis clips the span of the segment that could still be inside.
322 const slab = (p: number, d: number, lo: number, hi: number): boolean => {
323 if (Math.abs(d) < 1e-9) return p >= lo && p <= hi;
324 const near = (lo - p) / d;
325 const far = (hi - p) / d;
326 t0 = Math.max(t0, Math.min(near, far));
327 t1 = Math.min(t1, Math.max(near, far));
328 return t1 >= t0;
329 };
330
331 if (!slab(a.x, dx, r.x, r.x + r.w)) return false;
332 if (!slab(a.y, dy, r.y, r.y + r.h)) return false;
333 return t1 >= t0;
334}