anvilsign in

collin/mahjong · a8f341f6

Land a lobbed tile inside the square rather than short of it

Collin Richards · 2026-08-19 21:46 UTC · a8f341f64cb464e7603cab4610af982b153a7916 · parent b57cc629 · browse files

modifiedsrc/table/geometry.ts+31 −0
⋯ 239 unchanged lines
240240 return !walls.some((w) => segmentHitsRect(from, to, w));
241241 }
242242
243+/**
244+ * How far along a line from `from` the square starts and ends, or null if the
245+ * line misses it altogether.
246+ *
247+ * What a lob needs to know. A tile lifted over the wall has to come *down*
248+ * inside the square — solving for where it would come to rest instead lands it
249+ * short, against the outside of the wall, which is exactly where a discard must
250+ * not end up. So the throw is aimed at somewhere between these two.
251+ */
252+export function spanThrough(
253+ r: Rect,
254+ from: Point,
255+ dir: Point,
256+): { near: number; far: number } | null {
257+ let t0 = 0;
258+ let t1 = Infinity;
259+
260+ const slab = (p: number, d: number, lo: number, hi: number): boolean => {
261+ if (Math.abs(d) < 1e-9) return p >= lo && p <= hi;
262+ const a = (lo - p) / d;
263+ const b = (hi - p) / d;
264+ t0 = Math.max(t0, Math.min(a, b));
265+ t1 = Math.min(t1, Math.max(a, b));
266+ return t1 >= t0;
267+ };
268+
269+ if (!slab(from.x, dir.x, r.x, r.x + r.w)) return null;
270+ if (!slab(from.y, dir.y, r.y, r.y + r.h)) return null;
271+ return t1 >= t0 ? { near: t0, far: t1 } : null;
272+}
273+
243274 export function segmentHitsRect(a: Point, b: Point, r: Rect): boolean {
244275 const dx = b.x - a.x;
245276 const dy = b.y - a.y;
⋯ 17 unchanged lines
modifiedsrc/table/pool.ts+29 −13
⋯ 6 unchanged lines
77 canThrowFrom,
88 centreOf,
99 measure,
10+ spanThrough,
1011 type Point,
1112 type TableGeometry,
1213 } from './geometry';
⋯ 460 unchanged lines
473474 let flipOver = 0;
474475
475476 if (!slide) {
476- // Lofted over a standing wall. It still has to *land* on the table
477- // rather than sail off the far side, so there is a ceiling on how fast
478- // it can cross — but a generous one: a hard throw is meant to reach the
479- // far wall, come off it and barge through the pile, and only a limp one
480- // should drop in the middle and stay there.
477+ // Lofted over a standing wall, and it has to come *down* inside the
478+ // square: a lob solved for where it would come to rest touches down
479+ // short, on the wrong side of the wall, and stops dead against it.
480+ //
481+ // So the flick picks a landing spot along its own line, between just
482+ // inside the near edge of the square and just short of the far one. A
483+ // limp one drops in over the near wall, a hard one carries to the far
484+ // side and barges through whatever is lying there, and neither of them
485+ // can leave the tile outside.
486+ const dir = speed > 0 ? { x: vx / speed, y: vy / speed } : { x: 0, y: -1 };
487+ const span = spanThrough(geo.pool, thrown, dir);
481488 const centre = centreOf(geo.pool);
482- const reach = PLACE_FLIGHT + SLIDE_TIME;
483- const cap = (Math.hypot(centre.x - thrown.x, centre.y - thrown.y) / reach) * 2.4;
484- const aim = Math.min(speed, cap) / (speed || 1);
485- vx *= aim;
486- vy *= aim;
489+ let carry = speed * PLACE_FLIGHT;
490+ if (span) {
491+ const near = span.near + geo.tile.h;
492+ carry = Math.min(Math.max(near, carry), Math.max(near, span.far - geo.tile.h));
493+ } else {
494+ // Flicked somewhere that is not the square at all — put it in anyway.
495+ carry = Math.hypot(centre.x - thrown.x, centre.y - thrown.y);
496+ dir.x = (centre.x - thrown.x) / (carry || 1);
497+ dir.y = (centre.y - thrown.y) / (carry || 1);
498+ }
499+ vx = (dir.x * carry) / PLACE_FLIGHT;
500+ vy = (dir.y * carry) / PLACE_FLIGHT;
487501 vz = liftFor(PLACE_FLIGHT);
488502 // Long enough in the air to turn over properly, and harder means more.
489503 flipOver = PLACE_FLIGHT;
⋯ 35 unchanged lines
525539 const from = geo.throwFrom[a.seat] ?? geo.launch[a.seat] ?? centreOf(geo.pool);
526540 const open = !LOB_WHEN_WALLED || this.canThrow(a.seat);
527541 const target = this.aimFor(geo, a.seat, rng);
528- // Aim for where it comes to *rest*, not where it first touches down: a tile
529- // keeps sliding after it lands, and SLIDE_TIME is roughly how much further.
530542 const flight = open ? 0.34 + rng() * 0.1 : PLACE_FLIGHT;
531- const reach = flight + SLIDE_TIME;
543+ // A slide is aimed at where it comes to *rest*, since it keeps going after
544+ // it lands and SLIDE_TIME is roughly how much further. A lob is aimed at
545+ // where it touches *down*, because it has to come down on the inside of the
546+ // wall — solving for the resting place lands it short, against the outside.
547+ const reach = open ? flight + SLIDE_TIME : flight;
532548
533549 const vx = (target.x - from.x) / reach;
534550 const vy = (target.y - from.y) / reach;
⋯ 36 unchanged lines