collin/mahjong · c8f4c34b
The flick is the throw
Collin Richards · 2026-08-21 19:02 UTC · c8f4c34b500e5304aaee1948654e576655ba0498 · parent 4d0d70a0 · browse files
modifiedREADME.md+11 −1
| ⋯ 483 unchanged lines | |||
| 484 | 484 | had rather than a freshly scattered one — positions are derived from the hand | |
| 485 | 485 | number and each tile's place in its thrower's discards, never saved. | |
| 486 | 486 | ||
| 487 | + | A flick is the whole of a throw: the direction and the speed it leaves at are | |
| 488 | + | the hand's, and nothing between the fingers and the felt changes either. The one | |
| 489 | + | thing the table decides is *height* — with a standing wall in front of the | |
| 490 | + | throw a flat tile would only skid into it, so it is lofted over instead, exactly | |
| 491 | + | as far as it was thrown and simply in the air for the first part of the way. A | |
| 492 | + | tile that carries past the square and out into the open stays there. (It used to | |
| 493 | + | be re-aimed at the middle of the square when the flick's line missed it, which | |
| 494 | + | put the tile on a line nobody had thrown and read, fairly, as a bounce off | |
| 495 | + | nothing.) | |
| 496 | + | ||
| 487 | 497 | What stops a tile is other tiles. There is no fence: the standing stacks of the | |
| 488 | 498 | wall, the sets people have laid down and the sixteen each of them is holding are | |
| 489 | 499 | all measured as colliders, so the pile is held in by the ring of tiles round it | |
| ⋯ 30 unchanged lines | |||
| 520 | 530 | | box hugging each pool tile | the rotated rectangle it really collides with | | |
| 521 | 531 | | dotted box round the moving tile | the upright box the straight edges use instead. It comes apart from the rotated one as the tile turns | | |
| 522 | 532 | ||
| 523 | - | | blue rays from a throw | faint: the flick, as the tile was let go of. Bright: the line it was actually **sent** along, labelled with why. A throw with standing wall in front of it is lobbed rather than slid, and one whose line misses the square is re-aimed at the middle of it — the tile then leaves on a line nobody threw, which reads exactly like a bounce off something invisible | | |
| 533 | + | | blue rays from a throw | faint: the flick, as the tile was let go of. Bright: the line it was actually **sent** along, labelled `slide` or `lob`. These should lie on top of each other — the flick is the throw, and a tile leaving on a line nobody threw reads exactly like a bounce off something invisible | | |
| 524 | 534 | | yellow cross | where contact was *actually* made, fading over a second or so. One of these sitting in open felt with no box under it is a bounce off something that isn't a collider at all — a different bug from a box in the wrong place | | |
| 525 | 535 | ||
| 526 | 536 | Colliders mode is also a **throwing sandbox**: a flick throws for real but is not a | |
| ⋯ 47 unchanged lines | |||
modifiedsrc/table/geometry.ts+0 −31
| ⋯ 266 unchanged lines | |||
| 267 | 267 | return !walls.some((w) => segmentHitsRect(from, to, w)); | |
| 268 | 268 | } | |
| 269 | 269 | ||
| 270 | - | /** | |
| 271 | - | * How far along a line from `from` the square starts and ends, or null if the | |
| 272 | - | * line misses it altogether. | |
| 273 | - | * | |
| 274 | - | * What a lob needs to know. A tile lifted over the wall has to come *down* | |
| 275 | - | * inside the square — solving for where it would come to rest instead lands it | |
| 276 | - | * short, against the outside of the wall, which is exactly where a discard must | |
| 277 | - | * not end up. So the throw is aimed at somewhere between these two. | |
| 278 | - | */ | |
| 279 | - | export function spanThrough( | |
| 280 | - | r: Rect, | |
| 281 | - | from: Point, | |
| 282 | - | dir: Point, | |
| 283 | - | ): { near: number; far: number } | null { | |
| 284 | - | let t0 = 0; | |
| 285 | - | let t1 = Infinity; | |
| 286 | - | ||
| 287 | - | const slab = (p: number, d: number, lo: number, hi: number): boolean => { | |
| 288 | - | if (Math.abs(d) < 1e-9) return p >= lo && p <= hi; | |
| 289 | - | const a = (lo - p) / d; | |
| 290 | - | const b = (hi - p) / d; | |
| 291 | - | t0 = Math.max(t0, Math.min(a, b)); | |
| 292 | - | t1 = Math.min(t1, Math.max(a, b)); | |
| 293 | - | return t1 >= t0; | |
| 294 | - | }; | |
| 295 | - | ||
| 296 | - | if (!slab(from.x, dir.x, r.x, r.x + r.w)) return null; | |
| 297 | - | if (!slab(from.y, dir.y, r.y, r.y + r.h)) return null; | |
| 298 | - | return t1 >= t0 ? { near: t0, far: t1 } : null; | |
| 299 | - | } | |
| 300 | - | ||
| 301 | 270 | export function segmentHitsRect(a: Point, b: Point, r: Rect): boolean { | |
| 302 | 271 | const dx = b.x - a.x; | |
| 303 | 272 | const dy = b.y - a.y; | |
| ⋯ 17 unchanged lines | |||
modifiedsrc/table/pool.ts+18 −40
| ⋯ 8 unchanged lines | |||
| 9 | 9 | canThrowFrom, | |
| 10 | 10 | centreOf, | |
| 11 | 11 | measure, | |
| 12 | - | spanThrough, | |
| 13 | 12 | type Point, | |
| 14 | 13 | type TableGeometry, | |
| 15 | 14 | } from './geometry'; | |
| ⋯ 611 unchanged lines | |||
| 627 | 626 | * point testing with it. | |
| 628 | 627 | */ | |
| 629 | 628 | private fling(geo: TableGeometry, tile: Tile, thrown: Release): Body { | |
| 630 | - | // Which of the two ways a tile goes in, decided by what is in its way. | |
| 629 | + | // The flick is the throw. Where the tile goes and how fast it gets there | |
| 630 | + | // are both the hand's business, and nothing here touches either — a tile | |
| 631 | + | // that leaves at an angle to the flick that made it reads as a bounce off | |
| 632 | + | // something invisible, and there is nothing there to bounce off. | |
| 633 | + | // | |
| 634 | + | // The one thing still decided here is *height*, which is not aim. With a | |
| 635 | + | // standing wall in front of the throw a flat tile would only skid into it, | |
| 636 | + | // so it is lofted over instead: exactly as far as it was thrown, simply in | |
| 637 | + | // the air for the first part of the way. A tile that carries past the | |
| 638 | + | // square and out into the open stays there — the middle is not a box, and | |
| 639 | + | // where a discard came to rest is where somebody threw it. | |
| 631 | 640 | // | |
| 632 | - | // A clear line to the square means it can be *slid* — flat across the | |
| 633 | - | // cloth, at exactly the speed it was let go at, which is what a flick | |
| 634 | - | // wants to be. A standing wall means it has to be *thrown* instead: lifted | |
| 635 | - | // over the top and dropped in, and then the flick's speed decides how far | |
| 636 | - | // across the pool it lands rather than how fast it skates. | |
| 637 | - | // Sliding is the preferred way in, and the question is asked from where | |
| 638 | - | // the tile actually is — a player who carried it out over the square has a | |
| 639 | - | // clear line even though the seat they came from does not. Only a tile | |
| 640 | - | // with nothing but standing wall in front of it has to be lobbed. | |
| 641 | + | // Asked from where the tile actually *is*, not from the seat: a player who | |
| 642 | + | // carried it out over the square has a clear line even though the seat they | |
| 643 | + | // came from does not. | |
| 641 | 644 | const set = this.inPool(thrown.x, thrown.y); | |
| 642 | - | const slide = set || !LOB_WHEN_WALLED || this.canThrowFrom(thrown.x, thrown.y); | |
| 643 | - | let { vx, vy } = thrown; | |
| 645 | + | const walled = !set && LOB_WHEN_WALLED && !this.canThrowFrom(thrown.x, thrown.y); | |
| 646 | + | const { vx, vy } = thrown; | |
| 644 | 647 | let vz = 0; | |
| 645 | 648 | const speed = Math.hypot(vx, vy); | |
| 646 | 649 | let flipTurns = 0; | |
| 647 | 650 | let flipOver = 0; | |
| 648 | 651 | let how = set ? 'slide, from inside the square' : 'slide'; | |
| 649 | 652 | ||
| 650 | - | if (!slide) { | |
| 651 | - | // Lofted over a standing wall, and it has to come *down* inside the | |
| 652 | - | // square: a lob solved for where it would come to rest touches down | |
| 653 | - | // short, on the wrong side of the wall, and stops dead against it. | |
| 654 | - | // | |
| 655 | - | // So the flick picks a landing spot along its own line, between just | |
| 656 | - | // inside the near edge of the square and just short of the far one. A | |
| 657 | - | // limp one drops in over the near wall, a hard one carries to the far | |
| 658 | - | // side and barges through whatever is lying there, and neither of them | |
| 659 | - | // can leave the tile outside. | |
| 660 | - | how = 'lob'; | |
| 661 | - | const dir = speed > 0 ? { x: vx / speed, y: vy / speed } : { x: 0, y: -1 }; | |
| 662 | - | const span = spanThrough(geo.pool, thrown, dir); | |
| 663 | - | const centre = centreOf(geo.pool); | |
| 664 | - | let carry = speed * PLACE_FLIGHT; | |
| 665 | - | if (span) { | |
| 666 | - | const near = span.near + geo.tile.h; | |
| 667 | - | carry = Math.min(Math.max(near, carry), Math.max(near, span.far - geo.tile.h)); | |
| 668 | - | } else { | |
| 669 | - | // Flicked somewhere that is not the square at all — put it in anyway. | |
| 670 | - | how = 'lob, RE-AIMED at the middle'; | |
| 671 | - | carry = Math.hypot(centre.x - thrown.x, centre.y - thrown.y); | |
| 672 | - | dir.x = (centre.x - thrown.x) / (carry || 1); | |
| 673 | - | dir.y = (centre.y - thrown.y) / (carry || 1); | |
| 674 | - | } | |
| 675 | - | vx = (dir.x * carry) / PLACE_FLIGHT; | |
| 676 | - | vy = (dir.y * carry) / PLACE_FLIGHT; | |
| 653 | + | if (walled) { | |
| 654 | + | how = 'lob — over the wall, same line'; | |
| 677 | 655 | vz = liftFor(PLACE_FLIGHT); | |
| 678 | 656 | // Long enough in the air to turn over properly, and harder means more. | |
| 679 | 657 | flipOver = PLACE_FLIGHT; | |
| ⋯ 98 unchanged lines | |||