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 | |||
| 240 | 240 | return !walls.some((w) => segmentHitsRect(from, to, w)); | |
| 241 | 241 | } | |
| 242 | 242 | ||
| 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 | + | ||
| 243 | 274 | export function segmentHitsRect(a: Point, b: Point, r: Rect): boolean { | |
| 244 | 275 | const dx = b.x - a.x; | |
| 245 | 276 | const dy = b.y - a.y; | |
| ⋯ 17 unchanged lines | |||
modifiedsrc/table/pool.ts+29 −13
| ⋯ 6 unchanged lines | |||
| 7 | 7 | canThrowFrom, | |
| 8 | 8 | centreOf, | |
| 9 | 9 | measure, | |
| 10 | + | spanThrough, | |
| 10 | 11 | type Point, | |
| 11 | 12 | type TableGeometry, | |
| 12 | 13 | } from './geometry'; | |
| ⋯ 460 unchanged lines | |||
| 473 | 474 | let flipOver = 0; | |
| 474 | 475 | ||
| 475 | 476 | 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); | |
| 481 | 488 | 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; | |
| 487 | 501 | vz = liftFor(PLACE_FLIGHT); | |
| 488 | 502 | // Long enough in the air to turn over properly, and harder means more. | |
| 489 | 503 | flipOver = PLACE_FLIGHT; | |
| ⋯ 35 unchanged lines | |||
| 525 | 539 | const from = geo.throwFrom[a.seat] ?? geo.launch[a.seat] ?? centreOf(geo.pool); | |
| 526 | 540 | const open = !LOB_WHEN_WALLED || this.canThrow(a.seat); | |
| 527 | 541 | 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. | |
| 530 | 542 | 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; | |
| 532 | 548 | ||
| 533 | 549 | const vx = (target.x - from.x) / reach; | |
| 534 | 550 | const vy = (target.y - from.y) / reach; | |
| ⋯ 36 unchanged lines | |||