anvilsign in

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
484484 had rather than a freshly scattered one — positions are derived from the hand
485485 number and each tile's place in its thrower's discards, never saved.
486486
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+
487497 What stops a tile is other tiles. There is no fence: the standing stacks of the
488498 wall, the sets people have laid down and the sixteen each of them is holding are
489499 all measured as colliders, so the pile is held in by the ring of tiles round it
⋯ 30 unchanged lines
520530 | box hugging each pool tile | the rotated rectangle it really collides with |
521531 | 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 |
522532
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 |
524534 | 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 |
525535
526536 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
267267 return !walls.some((w) => segmentHitsRect(from, to, w));
268268 }
269269
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-
301270 export function segmentHitsRect(a: Point, b: Point, r: Rect): boolean {
302271 const dx = b.x - a.x;
303272 const dy = b.y - a.y;
⋯ 17 unchanged lines
modifiedsrc/table/pool.ts+18 −40
⋯ 8 unchanged lines
99 canThrowFrom,
1010 centreOf,
1111 measure,
12- spanThrough,
1312 type Point,
1413 type TableGeometry,
1514 } from './geometry';
⋯ 611 unchanged lines
627626 * point testing with it.
628627 */
629628 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.
631640 //
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.
641644 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;
644647 let vz = 0;
645648 const speed = Math.hypot(vx, vy);
646649 let flipTurns = 0;
647650 let flipOver = 0;
648651 let how = set ? 'slide, from inside the square' : 'slide';
649652
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';
677655 vz = liftFor(PLACE_FLIGHT);
678656 // Long enough in the air to turn over properly, and harder means more.
679657 flipOver = PLACE_FLIGHT;
⋯ 98 unchanged lines