anvilsign in

collin/mahjong · ca82579b

A tile turns because of where it was hit

Collin Richards · 2026-08-25 09:51 UTC · ca82579bf0406d8f5a9befd6234ffcb39f817746 · parent 073d00e3 · browse files

modifiedREADME.md+43 −5
⋯ 709 unchanged lines
710710 been eaten into. The table's own edge is a backstop at the screen edge, there
711711 only so a tile that got past all of that isn't lost.
712712
713+A tile that only ever slid would look like a puck. What makes a pool of discards
714+look like one is that every tile in it has come to rest at some angle nobody
715+chose, and that is a thing to *earn* rather than to scatter in. A tile turns
716+because of where it was hit.
717+
718+So a contact happens somewhere in particular. The separating axis test says which
719+way to push and by how much; `contactOf` says where, and the arm from there to
720+the tile's middle is what a turn is made of. A tile shouldered square in the back
721+is pushed along and does not turn at all. The same shove, moved along until it is
722+catching one end of the tile, turns it instead, and which way round follows from
723+which end. What touches is a footprint and not a point: a tile lying square to a
724+wall meets it along the whole of one side and is pushed through its own middle
725+however far along the wall it is, and the same tile at forty-five degrees meets
726+it on one corner and swings on round it. Everything a tile can hit is another
727+tile, so the standing stacks, the laid-down sets and the table's own straight
728+edge are all the same contact with the same friction across the face of it.
729+
730+Two things keep that honest. The first is that a tile is not free to pivot. It is
731+lying flat on cloth, and the cloth under the whole of its face resists the turn
732+for as long as the contact lasts, so only a tenth of the moment a knock would
733+give a free rectangle actually reaches it (`FELT_HOLD`). Without that, a tile off
734+the corner of a stack comes away spinning like a top. The second is that the felt
735+is one patch of cloth doing one thing: what it spends stopping the slide it has
736+not got left for the turn, so the two share a single friction budget rather than
737+running as two brakes side by side. What that buys is the *ending*. A tile
738+flicked hard with a little turn on it used to stop turning early and skate on
739+looking dead, and one barely pushed with a lot of turn on it stopped dead and
740+went on spinning where it lay. They now run out together.
741+
742+The turn a tile arrives with is the flick's own. `ui/flick.ts` reads a release
743+the same way wherever it was made, out of a hand or off the pile in the middle,
744+and the spin it reports is the angle the stroke swung through between its first
745+half and its second, over the time it took. That is a rate, in rad/s, which is
746+what a wrist actually does: a straight flick turns nothing however hard it is
747+thrown, and the same curve made twice as fast puts twice the turn on the tile.
748+
713749 It is drawn on a canvas that spans the **whole table**, not just the centre.
714750 That is deliberate: `.slot` and `.seat` both clip their own contents, which is
715751 why a tile can't be animated out of a strip as an element. On the canvas there is
⋯ 69 unchanged lines
785821 the same seeded rng that shuffled, so a seed replays a hand's gap as well as its
786822 tiles.
787823
788-In a dev build a panel in the corner of the table steps the total through every
789-one three dice can make, 3 to 18, and redraws the square on each; **整牆 144**
790-puts every tile back, which is what the square is built to and what it only ever
791-is for the moment before the deal. `game.reroll(6, 6, 6)` does the same from the
792-console.
824+Where the wall opened is thrown for once and then lived with, which is right at
825+a table and awkward to look at: half the question about the square is what it
826+looks like opened at each of the seventy-two places it can be opened at.
827+`game.reroll(1, 1, 1)` through `game.reroll(6, 6, 6)` from the console walks the
828+break all the way round, and `game.reroll()` throws afresh. It moves the gap and
829+nothing else: the tiles were shuffled before the wall was built, so nobody's
830+hand changes and the draw order is the draw order.
793831 Nothing about the square is calculated twice. Its size lives entirely in CSS
794832 (`--ws`, `--wd`), so `geometry.ts` **measures** it instead of restating that
795833 arithmetic: `WallRing` already renders every stack as a real element tagged with
⋯ 71 unchanged lines
modifiedsrc/game/ctl.ts+2 −2
⋯ 46 unchanged lines
4747
4848 /**
4949 * Throw the dice again, moving where the wall is broken and nothing else.
50- * Only the engine has it — an online table's wall is the host's business —
51- * so it is what the dice tool checks for before it draws itself.
50+ * Optional because only the engine has it: an online table's wall is the
51+ * host's business. From the console; see `exposeGame` in ui/colliders.
5252 */
5353 reroll?(...dice: number[]): number[];
5454
⋯ 24 unchanged lines
modifiedsrc/styles.css+0 −64
⋯ 2826 unchanged lines
28272827 flex-wrap: wrap;
28282828 }
28292829
2830-/* The dice tool. Dev builds only — `import.meta.env.DEV` in ui/Center — so it
2831- is styled for being useful rather than for being lived with: bottom left, out
2832- of the way of the undo button and of every seat's own strip. */
2833-.dice-debug {
2834- position: absolute;
2835- z-index: 2;
2836- left: 10px;
2837- bottom: 8px;
2838- display: flex;
2839- flex-direction: column;
2840- align-items: center;
2841- gap: 4px;
2842- padding: 6px 10px 5px;
2843- border-radius: 10px;
2844- background: rgba(0, 0, 0, 0.32);
2845- border: 1px solid rgba(217, 180, 92, 0.25);
2846- font-size: 11px;
2847- /* A wall gets pushed about through this corner, and the panel is only here to
2848- be read — so the finger goes through it, and only the buttons take it. */
2849- pointer-events: none;
2850-}
2851-.dice-debug .btn {
2852- pointer-events: auto;
2853-}
2854-.table.carrying .dice-debug {
2855- pointer-events: none;
2856-}
2857-.dice-faces {
2858- display: flex;
2859- align-items: baseline;
2860- gap: 4px;
2861- line-height: 1;
2862-}
2863-.dice-faces b {
2864- font-size: 26px;
2865- font-weight: 400;
2866- color: var(--gold);
2867-}
2868-.dice-faces em {
2869- font-style: normal;
2870- font-size: 13px;
2871- font-weight: 700;
2872- opacity: 0.75;
2873- margin-left: 4px;
2874-}
2875-.dice-row {
2876- display: flex;
2877- gap: 4px;
2878-}
2879-.dice-row .btn {
2880- font-size: 12px;
2881- min-height: 0;
2882- padding: 3px 9px;
2883-}
2884-.dice-row .btn.on {
2885- border-color: var(--gold);
2886- color: var(--gold);
2887-}
2888-.dice-says {
2889- opacity: 0.6;
2890- letter-spacing: 0.02em;
2891- white-space: nowrap;
2892-}
2893-
28942830 /* The gear's panel. Same shell as the rules sheet — anchored in the player's
28952831 own strip and turned to face it — but it is four buttons, not a book, so it
28962832 is only as tall as they are. */
⋯ 59 unchanged lines
modifiedsrc/table/physics.test.ts+118 −0
⋯ 452 unchanged lines
453453
454454 // ---------------------------------------------------------------------------
455455
456+/**
457+ * A tile is turned by *where* it is hit, and by nothing else.
458+ *
459+ * The whole of the spin model is that a contact happens somewhere in particular
460+ * rather than between two middles: the arm from a tile's centre to the place it
461+ * is being pushed is what a turn is made of, and a push straight through the
462+ * centre has none. These pin that, because it is the thing that is easy to
463+ * break by reaching for the centres because they are to hand.
464+ */
465+describe('what turns a tile', () => {
466+ const lying = (over: Partial<Spawn> = {}): Spawn => ({
467+ tile: 0,
468+ seat: 0,
469+ x: POOL.x + POOL.w / 2,
470+ y: POOL.y + POOL.h / 2,
471+ vx: 0,
472+ vy: 0,
473+ z: 0,
474+ vz: 0,
475+ angle: 0,
476+ spin: 0,
477+ w: TILE.w,
478+ h: TILE.h,
479+ ...over,
480+ });
481+
482+ it('does not turn a tile shouldered square in the back', () => {
483+ const world = createWorld(POOL);
484+ // Directly behind, and lined up: nothing about this is off to one side.
485+ const struck = addBody(world, lying());
486+ const hitter = addBody(world, lying({ y: POOL.y + POOL.h / 2 + TILE.h, vy: -700 }));
487+ for (let i = 0; i < 30; i++) step(world);
488+ expect(struck.vy).toBeLessThan(-100);
489+ expect(Math.abs(struck.spin)).toBeLessThan(0.05);
490+ expect(Math.abs(hitter.spin)).toBeLessThan(0.05);
491+ });
492+
493+ it('turns one caught off to the side, and the way the arm says', () => {
494+ const world = createWorld(POOL);
495+ const struck = addBody(world, lying());
496+ // The same shove, moved along the tile until it is catching one end of it.
497+ addBody(world, lying({ x: POOL.x + POOL.w / 2 + TILE.w * 0.4, y: POOL.y + POOL.h / 2 + TILE.h, vy: -700 }));
498+ for (let i = 0; i < 30; i++) step(world);
499+ expect(struck.vy).toBeLessThan(-100);
500+ expect(Math.abs(struck.spin)).toBeGreaterThan(0.5);
501+ // Pushed forward on its right-hand end, so it comes round anticlockwise on
502+ // the screen, which is a falling angle, since y runs down.
503+ expect(struck.spin).toBeLessThan(0);
504+ });
505+
506+ it('slews a tile that catches the edge on a corner, and not one lying square to it', () => {
507+ const run = (angle: number) => {
508+ const world = createWorld(POOL);
509+ const b = addBody(world, lying({ y: POOL.y + POOL.h - TILE.h, vy: -900, angle }));
510+ for (let i = 0; i < 30; i++) step(world);
511+ return Math.abs(b.spin);
512+ };
513+ // Lying square to the edge it runs into: it meets the whole of one side at
514+ // once, and there is no arm anywhere in that.
515+ expect(run(0)).toBeLessThan(0.05);
516+ expect(run(Math.PI / 2)).toBeLessThan(0.05);
517+ // At forty-five degrees it meets it on one corner, and swings on round it.
518+ expect(run(Math.PI / 4)).toBeGreaterThan(1);
519+ });
520+
521+ it('stops a slide and a turn together, not one long before the other', () => {
522+ // One patch of cloth, doing one thing: what it spends stopping the turn it
523+ // has not got left for the slide, and the other way about. Two brakes
524+ // running side by side, which is what a spin damped on its own is, let a
525+ // tile skate on for half a second after it has visibly stopped turning, or
526+ // sit turning where it stopped, and both of those read as wrong.
527+ const run = (vx: number, spin: number) => {
528+ const world = createWorld(POOL);
529+ const b = addBody(world, lying({ x: POOL.x + TILE.w, vx, spin }));
530+ const from = b.x;
531+ let stopped = -1;
532+ let stilled = -1;
533+ for (let i = 0; i < 400; i++) {
534+ step(world);
535+ if (stopped < 0 && Math.abs(b.vx) < 1) stopped = i;
536+ if (stilled < 0 && Math.abs(b.spin) < 0.05) stilled = i;
537+ }
538+ return { stopped, stilled, went: b.x - from };
539+ };
540+ // Thrown hard with only a little turn on it, and flicked barely at all with
541+ // a lot: both ways round, the two run out at about the same moment.
542+ const hard = run(900, 2);
543+ expect(Math.abs(hard.stopped - hard.stilled)).toBeLessThan(20);
544+ const turned = run(90, 9);
545+ expect(Math.abs(turned.stopped - turned.stilled)).toBeLessThan(30);
546+ // And a tile doing both carries further than the same tile only sliding,
547+ // because the turn is taking some of the friction that would have stopped it.
548+ expect(run(300, 8).went).toBeGreaterThan(run(300, 0).went);
549+ });
550+
551+ it('does not wind the pool up: a crowded pile turns hard and still comes to rest', () => {
552+ const rng = makeRng(20260825);
553+ let fastest = 0;
554+ for (let trial = 0; trial < 8; trial++) {
555+ const world = createWorld(POOL);
556+ for (let i = 0; i < 16; i++) {
557+ addBody(world, throwAt(rng));
558+ for (let s = 0; s < 40; s++) {
559+ step(world);
560+ for (const b of world.bodies) fastest = Math.max(fastest, Math.abs(b.spin));
561+ }
562+ }
563+ expect(runToRest(world)).toBeLessThan(SETTLE_CAP);
564+ }
565+ // Tiles thrown at each other do come off spinning, which is the point, but
566+ // a contact that fed itself would run away, and this is where that shows.
567+ expect(fastest).toBeGreaterThan(9);
568+ expect(fastest).toBeLessThan(40);
569+ });
570+});
571+
572+// ---------------------------------------------------------------------------
573+
456574 /** A seat-0 view: pool up the screen, a wall across it, hand below. */
457575 function geometry(walls: Rect[]): TableGeometry {
458576 return {
⋯ 321 unchanged lines
modifiedsrc/table/physics.ts+246 −20
⋯ 62 unchanged lines
6363 const BOUND_RESTITUTION = 0.55;
6464 const BOUND_FRICTION = 0.9;
6565 const TILE_RESTITUTION = 0.42;
66-/** How much of a glancing blow becomes spin. Tiles are light and skate. */
67-const SPIN_TRANSFER = 0.06;
66+/**
67+ * Tile on tile, across the face of the contact. Everything a tile can hit is
68+ * another tile: the standing stacks, the sets laid down, the sixteen in
69+ * somebody's hand. So there is one number for all of it, and it is what turns
70+ * a glancing blow into a turn rather than a slide.
71+ */
72+const TILE_FRICTION = 0.32;
73+/**
74+ * How much of a knock's *moment* the tile actually keeps.
75+ *
76+ * A free rectangle struck off its middle takes the whole of it, and would come
77+ * off the corner of a stack spinning like a top. A tile does not, and the
78+ * reason is not in the solver: it is lying flat on cloth, and the cloth under
79+ * the whole of its face resists the turn for as long as the contact lasts. The
80+ * felt is modelled as a drag on the centre, not as a torque on the face, so
81+ * that resistance has nowhere else to go and is put back here, as the fraction
82+ * of the moment that survives the felt. Only the angular half of a contact is
83+ * scaled: a head-on hit is unaffected, because there is no moment in one.
84+ */
85+const FELT_HOLD = 0.1;
6886
6987 /** Asleep after this many consecutive steps of going nowhere. */
7088 const SLEEP_SPEED = 7;
⋯ 309 unchanged lines
380398 return { nx, ny, depth };
381399 }
382400
401+/**
402+ * How hard a tile is to turn, as 1 / I.
403+ *
404+ * A rectangle of uniform weight about its own middle: I = m(w² + h²) / 12.
405+ * Every tile on the table weighs the same as every other, so mass is 1
406+ * throughout and this is the whole of it. That also means an impulse and a
407+ * change of speed are the same number, so the two halves of a contact can be
408+ * written side by side.
409+ *
410+ * Scaled by `FELT_HOLD`, because a tile is turning against cloth and not in
411+ * mid-air. See there.
412+ */
413+function invInertia(b: Box): number {
414+ return (12 * FELT_HOLD) / (b.w * b.w + b.h * b.h);
415+}
416+
417+/**
418+ * How far out from the middle the felt has hold of a tile, as one radius.
419+ *
420+ * A tile spinning on the cloth is rubbed over the whole of its face, not at a
421+ * point, and what that comes to is an average distance from the middle. A
422+ * quarter of the diagonal is close enough to the true mean over a rectangle,
423+ * and it is the number that puts a turn and a slide into the same units: a
424+ * spin of ω rubs the felt at ω × this many pixels a second, which is a speed,
425+ * and can be weighed against how fast the tile is travelling. See `damp`.
426+ */
427+const gripRadius = (b: Box): number => Math.hypot(b.w, b.h) / 4;
428+
429+/**
430+ * Where a tile is pressing on the thing it has run into.
431+ *
432+ * The separating axis test says which way to push and by how much, but not
433+ * where, and where is the whole of whether a knock turns a tile or only shoves
434+ * it. A push through the middle is a shove; the same push at one end is a turn,
435+ * and the difference is the arm between the two.
436+ *
437+ * What touches is a *footprint*, not a point. A tile lying square to a wall
438+ * meets it along the whole of one side and is pushed through its own middle,
439+ * however far along the wall it happens to be. Turn the same tile forty-five
440+ * degrees and it meets the wall on a single corner, and that corner is the only
441+ * thing pushing back. `flat` is which of those it is, as |sin 2θ|, because a
442+ * rectangle comes square to a face every quarter turn; the footprint shrinks
443+ * from the whole side to the one corner as it goes.
444+ *
445+ * `a` is what was hit, where that has ends: only the part of the footprint the
446+ * two of them share counts, so a tile that has caught the end of a stack is
447+ * pushed at the end of itself. Left out for the table's own edge, which is a
448+ * straight line with no ends to catch.
449+ */
450+function contactOf(b: Box, nx: number, ny: number, a?: Box): { x: number; y: number } {
451+ const tx = -ny;
452+ const ty = nx;
453+ const flat = Math.abs(Math.sin(2 * b.angle));
454+ const half = reach(b, { x: tx, y: ty }) * (1 - flat);
455+
456+ // The corner that arrived first: all there is to lean on when the tile is
457+ // not square to what it hit, and where the footprint collapses to.
458+ const c = Math.cos(b.angle);
459+ const s = Math.sin(b.angle);
460+ const u = (c * nx + s * ny <= 0 ? 1 : -1) * (b.w / 2);
461+ const v = (-s * nx + c * ny <= 0 ? 1 : -1) * (b.h / 2);
462+ const corner = (b.x + c * u - s * v) * tx + (b.y + s * u + c * v) * ty;
463+ const mid = b.x * tx + b.y * ty + (corner - (b.x * tx + b.y * ty)) * flat;
464+
465+ let lo = mid - half;
466+ let hi = mid + half;
467+ let across: number;
468+ if (a) {
469+ const ca = a.x * tx + a.y * ty;
470+ const ra = reach(a, { x: tx, y: ty });
471+ lo = Math.max(lo, ca - ra);
472+ hi = Math.min(hi, ca + ra);
473+ if (lo > hi) lo = hi = (lo + hi) / 2;
474+ across = a.x * nx + a.y * ny + reach(a, { x: nx, y: ny });
475+ } else {
476+ across = b.x * nx + b.y * ny - reach(b, { x: nx, y: ny });
477+ }
478+ const along = (lo + hi) / 2;
479+ return { x: tx * along + nx * across, y: ty * along + ny * across };
480+}
481+
383482 /** A standing stack, as something the tests above can chew on. */
384483 const boxOf = (r: Rect, angle = 0): Box => ({
385484 x: r.x + r.w / 2,
⋯ 124 unchanged lines
510609 if (b.flipSpin !== 0) {
511610 if (b.z > 0) {
512611 b.flip += b.flipSpin * dt;
612+ // The axis it turns over about is a line drawn on the tile, so it goes
613+ // round with the tile: a discard that is spinning as well as tumbling
614+ // does not keep tumbling across the same line of the table.
615+ b.flipAxis += b.spin * dt;
513616 } else {
514617 const target = Math.round(b.flip / TAU) * TAU;
515618 b.flip += (target - b.flip) * Math.min(1, FLIP_SETTLE * dt);
⋯ 123 unchanged lines
639742 }
640743 }
641744
745+/**
746+ * The felt, on a tile that is both skating and turning.
747+ *
748+ * There is one patch of cloth under the tile and it can only do one thing at a
749+ * time: what it spends stopping the slide it does not have left to stop the
750+ * turn, and the other way about. So the constant part of the friction, the part
751+ * that actually brings a tile to a stop rather than to an asymptote, is shared
752+ * out between the two in proportion to how much of the rubbing each accounts
753+ * for, the turn counted at `gripRadius` so the two are in the same units.
754+ *
755+ * A tile doing only one of them is damped exactly as hard as it always was:
756+ * all of `FELT_STOP` goes to a pure slide, all of `SPIN_STOP` to a pure spin.
757+ * What is new is in between, and what it buys is not distance, which is a few
758+ * per cent, but the *ending*. Two brakes running side by side stop at two
759+ * different moments, so a tile flicked hard with a little turn on it stops
760+ * turning early and skates the rest of the way looking dead, and one barely
761+ * pushed with a lot of turn on it stops dead and goes on spinning where it
762+ * lies. Sharing the one budget ends them together, which is what a tile
763+ * flicked across a table does.
764+ */
642765 function damp(b: Body, dt: number) {
643766 const k = Math.exp(-FELT_DAMP * dt);
644767 b.vx *= k;
⋯ 1 unchanged line
646769 b.spin *= Math.exp(-SPIN_DAMP * dt);
647770
648771 const speed = Math.hypot(b.vx, b.vy);
772+ const rim = Math.abs(b.spin) * gripRadius(b);
773+ const rubbing = speed + rim;
774+ if (rubbing <= 0) return;
775+
649776 if (speed > 0) {
650- const next = Math.max(0, speed - FELT_STOP * dt);
777+ const next = Math.max(0, speed - FELT_STOP * (speed / rubbing) * dt);
651778 b.vx = (b.vx / speed) * next;
652779 b.vy = (b.vy / speed) * next;
653780 }
654- const spin = Math.abs(b.spin);
655- if (spin > 0) b.spin = Math.sign(b.spin) * Math.max(0, spin - SPIN_STOP * dt);
781+ if (rim > 0) {
782+ const spin = Math.abs(b.spin);
783+ b.spin = Math.sign(b.spin) * Math.max(0, spin - SPIN_STOP * (rim / rubbing) * dt);
784+ }
656785 }
657786
658787 /** Nudges a tile that landed outside the pool towards the rest of them. */
⋯ 10 unchanged lines
669798 /** Bounce off whatever is standing there, corner to corner if that is how it
670799 * hits — a stack of the wall, a laid-down set, somebody's hand. */
671800 function hitWall(b: Body, wall: Barrier, impacts: Impact[]) {
672- const hit = overlapOf(boxOf(wall.rect), b);
801+ const box = boxOf(wall.rect);
802+ const hit = overlapOf(box, b);
673803 if (!hit || hit.depth <= CONTACT_SLOP) return;
674804
675805 const speed = Math.hypot(b.vx, b.vy);
676806 b.x += hit.nx * hit.depth;
677807 b.y += hit.ny * hit.depth;
678- // Reflect whatever was heading into the wall; keep what was sliding along it.
679- const into = b.vx * hit.nx + b.vy * hit.ny;
808+
809+ // A stack does not move, so the whole of the contact is the tile's. What is
810+ // asked of it is the speed of the *touching corner* rather than of the
811+ // middle: a tile that is already turning arrives at the wall faster on one
812+ // side than the other, and that is what decides the bounce.
813+ const p = contactOf(b, hit.nx, hit.ny, box);
814+ const rx = p.x - b.x;
815+ const ry = p.y - b.y;
816+ const invI = invInertia(b);
817+ const cvx = b.vx - b.spin * ry;
818+ const cvy = b.vy + b.spin * rx;
819+ const into = cvx * hit.nx + cvy * hit.ny;
680820 if (into < 0) {
681- b.vx -= hit.nx * into * (1 + BOUND_RESTITUTION);
682- b.vy -= hit.ny * into * (1 + BOUND_RESTITUTION);
821+ // How much of the knock the corner can take before the tile simply turns
822+ // out of the way instead: a hit through the middle has none of this and
823+ // rebounds whole, a hit on a corner spends most of itself on the turn.
824+ const rn = rx * hit.ny - ry * hit.nx;
825+ const j = (-(1 + BOUND_RESTITUTION) * into) / (1 + rn * rn * invI);
826+ b.vx += hit.nx * j;
827+ b.vy += hit.ny * j;
828+ b.spin += rn * j * invI;
829+
830+ // And along the face. A tile skating past the corner of a stack is dragged
831+ // round by it, which is the turn you actually see; how much is capped by
832+ // how hard it went in, so a brush is a brush and not a stop.
833+ const tx = -hit.ny;
834+ const ty = hit.nx;
835+ const rt = rx * ty - ry * tx;
836+ const slip = cvx * tx + cvy * ty;
837+ if (slip !== 0) {
838+ const jt =
839+ -Math.sign(slip) *
840+ Math.min(Math.abs(slip) / (1 + rt * rt * invI), TILE_FRICTION * j);
841+ b.vx += tx * jt;
842+ b.vy += ty * jt;
843+ b.spin += rt * jt * invI;
844+ }
683845 }
684- b.spin *= 0.7;
685846 b.resting = false;
686847 b.still = 0;
687848 const strength = Math.min(1, speed / 1100);
⋯ 16 unchanged lines
704865
705866 let hit = 0;
706867 let edge: Edge | undefined;
868+ // Which way the edge pushed back, kept so the slew below knows which corner of
869+ // the tile it was that arrived.
870+ let nx = 0;
871+ let ny = 0;
707872 if (b.x < minX) {
708873 b.x = minX;
709874 hit = Math.max(hit, Math.abs(b.vx));
710875 edge = 'left';
876+ nx = 1;
711877 b.vx = Math.abs(b.vx) * BOUND_RESTITUTION;
712878 b.vy *= BOUND_FRICTION;
713879 } else if (b.x > maxX) {
714880 b.x = maxX;
715881 hit = Math.max(hit, Math.abs(b.vx));
716882 edge = 'right';
883+ nx = -1;
717884 b.vx = -Math.abs(b.vx) * BOUND_RESTITUTION;
718885 b.vy *= BOUND_FRICTION;
719886 }
720887 if (b.y < minY) {
721888 b.y = minY;
722- if (Math.abs(b.vy) > hit) edge = 'top';
889+ if (Math.abs(b.vy) > hit) {
890+ edge = 'top';
891+ nx = 0;
892+ ny = 1;
893+ }
723894 hit = Math.max(hit, Math.abs(b.vy));
724895 b.vy = Math.abs(b.vy) * BOUND_RESTITUTION;
725896 b.vx *= BOUND_FRICTION;
726897 } else if (b.y > maxY) {
727898 b.y = maxY;
728- if (Math.abs(b.vy) > hit) edge = 'bottom';
899+ if (Math.abs(b.vy) > hit) {
900+ edge = 'bottom';
901+ nx = 0;
902+ ny = -1;
903+ }
729904 hit = Math.max(hit, Math.abs(b.vy));
730905 b.vy = -Math.abs(b.vy) * BOUND_RESTITUTION;
731906 b.vx *= BOUND_FRICTION;
732907 }
908+
909+ // A tile does not meet a straight edge flat unless it happens to be lying
910+ // square to it: what arrives is one corner, and the rest of the tile swings
911+ // on round it. Only on a real knock: a tile held against the edge by its
912+ // neighbours is clamped every step, and winding it up on each of those would
913+ // leave the pool turning for ever.
914+ if (hit > SLEEP_SPEED) {
915+ const p = contactOf(b, nx, ny);
916+ const invI = invInertia(b);
917+ const rn = (p.x - b.x) * ny - (p.y - b.y) * nx;
918+ b.spin += (rn * hit * (1 + BOUND_RESTITUTION) * invI) / (1 + rn * rn * invI);
919+ }
733920 // Only a real knock counts as being disturbed. A tile held against the edge
734921 // by its neighbours is clamped every single step, and treating that as a knock
735922 // would keep the whole pool awake for ever.
⋯ 52 unchanged lines
788975 b.x += nx * depth * bShare;
789976 b.y += ny * depth * bShare;
790977
791- // Impulse along the way out, plus a spin kick off what is left over.
978+ // Where they are actually touching, and how far that is off each middle. A
979+ // tile shouldered square in the back is pushed along; the same tile caught on
980+ // one corner is turned instead, and it is only this that tells them apart.
981+ const p = contactOf(b, nx, ny, a);
982+ const rax = p.x - a.x;
983+ const ray = p.y - a.y;
984+ const rbx = p.x - b.x;
985+ const rby = p.y - b.y;
986+ const invIa = invInertia(a);
987+ const invIb = invInertia(b);
988+
989+ // Closing speed at the contact rather than between the middles, so a tile
990+ // that is already turning arrives with whichever corner is coming round.
792991 //
793992 // Only a genuine approach speed counts, and only that wakes anything. Nudging
794993 // two touching tiles apart is not a collision: a jammed corner of the pool is
795994 // corrected by a fraction of a pixel every step for as long as it is jammed,
796995 // and treating each of those as a shove would keep the pile awake for ever.
797- const rvx = b.vx - a.vx;
798- const rvy = b.vy - a.vy;
996+ const rvx = b.vx - b.spin * rby - (a.vx - a.spin * ray);
997+ const rvy = b.vy + b.spin * rbx - (a.vy + a.spin * rax);
799998 const along = rvx * nx + rvy * ny;
800999 if (along >= -SLEEP_SPEED) return;
8011000
802- const jolt = -along * (1 + TILE_RESTITUTION) * 0.5;
1001+ // Two tiles of the same weight, so the shove splits in half, plus whatever
1002+ // each of them can turn away with, which is the part that was a constant
1003+ // before and is now the geometry of the hit.
1004+ const ran = rax * ny - ray * nx;
1005+ const rbn = rbx * ny - rby * nx;
1006+ const jolt =
1007+ (-along * (1 + TILE_RESTITUTION)) / (2 + ran * ran * invIa + rbn * rbn * invIb);
8031008 a.vx -= nx * jolt;
8041009 a.vy -= ny * jolt;
1010+ a.spin -= ran * jolt * invIa;
8051011 b.vx += nx * jolt;
8061012 b.vy += ny * jolt;
1013+ b.spin += rbn * jolt * invIb;
8071014
808- const tangent = rvx * -ny + rvy * nx;
809- a.spin -= tangent * SPIN_TRANSFER * 0.06;
810- b.spin += tangent * SPIN_TRANSFER * 0.06;
1015+ // Across the face of it. One tile skidding past another drags on it, and each
1016+ // takes the turn that drag has the leverage for. The tiles in a pool ending
1017+ // up at every angle to each other is this, and nothing else.
1018+ const tx = -ny;
1019+ const ty = nx;
1020+ const slip = rvx * tx + rvy * ty;
1021+ if (slip !== 0) {
1022+ const rat = rax * ty - ray * tx;
1023+ const rbt = rbx * ty - rby * tx;
1024+ const jt =
1025+ -Math.sign(slip) *
1026+ Math.min(
1027+ Math.abs(slip) / (2 + rat * rat * invIa + rbt * rbt * invIb),
1028+ TILE_FRICTION * jolt,
1029+ );
1030+ a.vx -= tx * jt;
1031+ a.vy -= ty * jt;
1032+ a.spin -= rat * jt * invIa;
1033+ b.vx += tx * jt;
1034+ b.vy += ty * jt;
1035+ b.spin += rbt * jt * invIb;
1036+ }
8111037
8121038 const strength = Math.min(1, Math.abs(along) / 900);
8131039 if (strength > 0.12) impacts.push({ x: b.x, y: b.y, strength });
⋯ 201 unchanged lines
modifiedsrc/ui/Center.tsx+1 −14
1-import { useState } from 'react';
21 import type { TableCtl } from '../game/ctl';
32 import type { Prefs } from '../game/prefs';
43 import type { SeatId } from '../game/types';
54 import { labelOf } from '../game/tiles';
65 import { Bi, BiEm } from './Bi';
7-import { DiceDebug } from './DiceDebug';
86 import { useLang, useT } from './lang';
97 import { FullscreenToggle } from './FullscreenToggle';
108 import { SoundToggle } from './SoundToggle';
⋯ 18 unchanged lines
2927 export function Center({ game, prefs, compact, viewSeat = 0, preferSeat = null, onPrefs, onSettings }: Props) {
3028 const t = useT();
3129 const lang = useLang();
32- // Dev only, and a look rather than a state: the square drawn with all 144
33- // tiles standing, which is what it is built to and what it only ever is for
34- // the moment before the deal. See the dice panel.
35- const [whole, setWhole] = useState(false);
3630 const s = game.state;
3731 const r = s.result;
3832 // Point the live readout at whoever can move the game along.
⋯ 21 unchanged lines
6054 <em>{t('剩牌', 'left')}</em>
6155 </div>
6256 ) : (
63- <WallRing state={s} whole={whole} />
57+ <WallRing state={s} />
6458 ))}
6559
6660 <div
⋯ 128 unchanged lines
195189 </button>
196190 </div>
197191 </div>
198- )}
199-
200- {/* Dev only, and only where there is a square to look at: the dice decide
201- where the wall is broken, and that is a thing you want to be able to
202- step through rather than wait for. */}
203- {import.meta.env.DEV && !compact && s.phase !== 'gameEnd' && (
204- <DiceDebug game={game} whole={whole} onWhole={setWhole} />
205192 )}
206193
207194 {/* A phone is one player looking at one screen, so its controls can sit in
⋯ 33 unchanged lines
deletedsrc/ui/DiceDebug.tsx+0 −85
1-import type { TableCtl } from '../game/ctl';
2-import type { SeatId } from '../game/types';
3-import { breakAt, SEAT_WALL_SIDE, WALL_SIDES } from '../game/wall';
4-import { STACKS_PER_SIDE } from '../game/tiles';
5-import { useT } from './lang';
6-
7-/**
8- * The dealer's dice, with a hand on them. Dev builds only.
9- *
10- * Where the wall is broken is thrown for once at the start of a hand and then
11- * lived with, which is right at a table and useless for looking at: half the
12- * question about the square is what it looks like opened at each of the
13- * seventy-two places it can be opened at, and waiting for the dice to walk you
14- * round is not a way to find out. So the total is a thing you can step through
15- * here — every one three dice can make, 3 to 18 — and the square is redrawn on
16- * each with the gap somewhere else.
17- *
18- * It moves the gap and nothing else: the tiles were shuffled before the wall
19- * was built, so nobody's hand changes and the draw order is the draw order.
20- * See `reroll` in game/engine.ts and `breakAt` in game/wall.ts.
21- */
22-export function DiceDebug({
23- game,
24- whole,
25- onWhole,
26-}: {
27- game: TableCtl;
28- /** Drawing the wall as if nothing had been taken off it — see `WallRing`. */
29- whole: boolean;
30- onWhole: (whole: boolean) => void;
31-}) {
32- const t = useT();
33- const s = game.state;
34- if (!game.reroll) return null;
35-
36- const total = s.dice.reduce((n, d) => n + d, 0);
37- // Who the count landed on, and how far into their wall — the two things the
38- // total decides, said out loud so the square can be checked against them.
39- const whose = ((s.dealer + total - 1) % 4) as SeatId;
40- const into = total % STACKS_PER_SIDE;
41- const step = (to: number) => game.reroll?.(to - 2, 1, 1);
42-
43- return (
44- <div className="dice-debug">
45- <div className="dice-faces">
46- {s.dice.map((d, i) => (
47- <b key={i}>{d}</b>
48- ))}
49- <em>{total}</em>
50- </div>
51- <div className="dice-row">
52- <button className="btn ghost" onClick={() => step(Math.max(3, total - 1))} disabled={total <= 3}>
53- −
54- </button>
55- <button className="btn ghost" onClick={() => game.reroll?.()}>
56- {t('擲骰', 'Throw')}
57- </button>
58- <button
59- className="btn ghost"
60- onClick={() => step(Math.min(18, total + 1))}
61- disabled={total >= 18}
62- >
63- +
64- </button>
65- </div>
66- <div className="dice-row">
67- {/* The wall is only whole for the moment before the deal, and that is
68- the shape the square was built to. Puts all 144 back to look at,
69- without touching a tile anybody is holding. */}
70- <button
71- className={`btn ghost ${whole ? 'on' : ''}`}
72- onClick={() => onWhole(!whole)}
73- aria-pressed={whole}
74- >
75- {t('整牆 144', 'Whole wall')}
76- </button>
77- </div>
78- <div className="dice-says">
79- {s.players[whose].name} · {WALL_SIDES[SEAT_WALL_SIDE[whose]]} ·{' '}
80- {t(`第 ${into || STACKS_PER_SIDE} 疊`, `${into || STACKS_PER_SIDE} in`)} ·{' '}
81- {t('斷點', 'break')} {breakAt(s.dealer, s.dice)}
82- </div>
83- </div>
84- );
85-}
modifiedsrc/ui/Hand.tsx+3 −28
11 import { useRef, useState } from 'react';
22 import type { Tile } from '../game/tiles';
3+import { flickOf, type Sample } from './flick';
34 import { useT } from './lang';
45 import { TileView } from './TileView';
56
⋯ 70 unchanged lines
7677 const COMMIT_OUT = 70;
7778 /** Samples kept for reading the speed of a flick. */
7879 const SAMPLES = 5;
79-
80-interface Sample {
81- x: number;
82- y: number;
83- t: number;
84-}
8580
8681 /**
8782 * Finds the tile nearest the pointer. Works whatever the seat's rotation:
⋯ 183 unchanged lines
271266 if (!d) return;
272267
273268 if (d.mode === 'carry') {
274- const v = velocityOf(d.samples, e.timeStamp, e.clientX, e.clientY);
269+ d.samples.push({ x: e.clientX, y: e.clientY, t: e.timeStamp });
270+ const v = flickOf(d.samples);
275271 const dx = e.clientX - d.startX;
276272 const dy = e.clientY - d.startY;
277273 const out = dx * d.outward.x + dy * d.outward.y;
⋯ 63 unchanged lines
341337 ))}
342338 </div>
343339 );
344-}
345-
346-/**
347- * How fast the tile was moving when it was let go of, from the last few pointer
348- * positions rather than just the final one — a single event pair is noisy enough
349- * to turn a firm throw into a limp one.
350- */
351-function velocityOf(samples: Sample[], t: number, x: number, y: number) {
352- const last = { x, y, t };
353- // Far enough back to be a measurement, near enough to still be the flick.
354- const first = samples.find((s) => last.t - s.t < 90) ?? samples[0];
355- const dt = (last.t - first.t) / 1000;
356- if (!first || dt <= 0.001) return { vx: 0, vy: 0, spin: 0 };
357-
358- const vx = (last.x - first.x) / dt;
359- const vy = (last.y - first.y) / dt;
360- // Spin comes from how much the flick bent between its two halves.
361- const mid = samples[Math.floor(samples.length / 2)] ?? first;
362- const bend =
363- (mid.x - first.x) * (last.y - mid.y) - (mid.y - first.y) * (last.x - mid.x);
364- return { vx, vy, spin: Math.max(-9, Math.min(9, bend * 0.004)) };
365340 }
modifiedsrc/ui/Pool.tsx+7 −10
⋯ 15 unchanged lines
1616 recentImpacts,
1717 showingColliders,
1818 } from './colliders';
19+import { flickOf, type Sample } from './flick';
1920 import { EDGE_SEAT } from './rotation';
2021 import { drawShadow, drawTile, loadArt, type Art } from './tileArt';
2122
⋯ 166 unchanged lines
188189 * the settings button, the wall.
189190 */
190191 function dragging(table: HTMLElement, pool: TablePool, wake: () => void) {
191- let samples: { x: number; y: number; t: number }[] = [];
192+ let samples: Sample[] = [];
192193
193194 const at = (e: PointerEvent) => {
194195 const r = table.getBoundingClientRect();
⋯ 31 unchanged lines
226227 const up = (e: PointerEvent) => {
227228 if (!pool.dragging) return;
228229 e.stopPropagation();
229- const p = at(e);
230- samples.push(p);
231- const first = samples[0];
232- const dt = (p.t - first.t) / 1000;
233- const vx = dt > 0 ? (p.x - first.x) / dt : 0;
234- const vy = dt > 0 ? (p.y - first.y) / dt : 0;
235- // A flick that curves puts a turn on the tile, as one out of a hand does.
236- const spin = dt > 0 ? ((p.x - first.x) * 0.004) / dt / 100 : 0;
237- pool.releaseGrab(vx, vy, spin);
230+ samples.push(at(e));
231+ // Read exactly as a throw out of a hand is: same gesture, same reading,
232+ // curve on it and all. See ui/flick.
233+ const v = flickOf(samples);
234+ pool.releaseGrab(v.vx, v.vy, v.spin);
238235 samples = [];
239236 wake();
240237 };
⋯ 281 unchanged lines
modifiedsrc/ui/WallRing.tsx+2 −12
⋯ 35 unchanged lines
3636 * measures these elements to build the colliders rather than restating the CSS
3737 * that sizes them.
3838 */
39-export function WallRing({ state, whole }: { state: WallProgress; whole?: boolean }) {
39+export function WallRing({ state }: { state: WallProgress }) {
4040 const t = useT();
4141 const ring = useRef<HTMLDivElement>(null);
4242 const square = useSquare(ring);
43- // `whole` puts every tile back — a hundred and forty-four, the square as it
44- // is built and before anybody has drawn from it. Dev only, from the dice
45- // panel: the wall is only whole for the moment before the deal, which is not
46- // long enough to look at it in. It is a *look*, not a state — the hand is
47- // untouched, and the physics follows what is drawn (table/geometry.ts reads
48- // the stacks off the screen), so a tile thrown at this bounces off it.
49- const sides = ringLayout(
50- wallStacks(whole ? { ...state, drawnFront: 0, drawnBack: 0 } : state),
51- RING,
52- breakAt(state.dealer, state.dice),
53- );
43+ const sides = ringLayout(wallStacks(state), RING, breakAt(state.dealer, state.dice));
5444 const { slide, take } = useSliding(ring, square);
5545 // Where they have been pushed to, in tiles — the form `WALL_PLACED` is
5646 // written in, so what a push found can be pasted straight back into it. For
⋯ 210 unchanged lines
addedsrc/ui/flick.test.ts+51 −0
1+import { describe, expect, it } from 'vitest';
2+import { flickOf, type Sample } from './flick';
3+
4+/** A stroke of `n` points, each turned `turn` radians further round than the
5+ * last, `step` px apart, one every `ms`. `turn` of 0 is a straight flick. */
6+function stroke(n: number, step: number, turn: number, ms: number): Sample[] {
7+ const out: Sample[] = [{ x: 0, y: 0, t: 0 }];
8+ let a = 0;
9+ for (let i = 1; i < n; i++) {
10+ const p = out[i - 1];
11+ out.push({ x: p.x + Math.cos(a) * step, y: p.y + Math.sin(a) * step, t: i * ms });
12+ a += turn;
13+ }
14+ return out;
15+}
16+
17+describe('reading a flick', () => {
18+ it('reads the speed off the stroke, not off its last pair of points', () => {
19+ // 20px every 10ms is 2000px/s, whatever the sampling happened to be.
20+ const v = flickOf(stroke(6, 20, 0, 10));
21+ expect(Math.hypot(v.vx, v.vy)).toBeCloseTo(2000, 0);
22+ });
23+
24+ it('puts no turn on a straight flick, however fast it was made', () => {
25+ expect(flickOf(stroke(6, 20, 0, 10)).spin).toBe(0);
26+ expect(flickOf(stroke(6, 60, 0, 10)).spin).toBe(0);
27+ });
28+
29+ it('puts a turn on one that curves, and the way it curved', () => {
30+ const right = flickOf(stroke(6, 20, 0.25, 10)).spin;
31+ const left = flickOf(stroke(6, 20, -0.25, 10)).spin;
32+ expect(right).toBeGreaterThan(0.5);
33+ expect(left).toBeCloseTo(-right, 6);
34+ });
35+
36+ it('reads the same curve made faster as more turn, which is what a rate means', () => {
37+ // The same shape, drawn in half the time. The old measure was the area
38+ // between the two halves of the stroke and had no time in it at all, so a
39+ // lazy curl and a hooked flick came out the same.
40+ const slow = flickOf(stroke(6, 20, 0.25, 20)).spin;
41+ const fast = flickOf(stroke(6, 20, 0.25, 10)).spin;
42+ expect(fast).toBeCloseTo(slow * 2, 4);
43+ });
44+
45+ it('gives up rather than guessing when there is nothing to read', () => {
46+ expect(flickOf([])).toEqual({ vx: 0, vy: 0, spin: 0 });
47+ expect(flickOf([{ x: 0, y: 0, t: 0 }])).toEqual({ vx: 0, vy: 0, spin: 0 });
48+ // A press that never moved is not a curve, whatever the jitter says.
49+ expect(flickOf(stroke(6, 1, 0.6, 10)).spin).toBe(0);
50+ });
51+});
addedsrc/ui/flick.ts+85 −0
1+/**
2+ * Reading a throw off a finger.
3+ *
4+ * Two places let go of a tile: out of a hand (`ui/Hand`) and off the pile in
5+ * the middle (`ui/Pool`). They are the same gesture, so they read it the same
6+ * way. What comes out is the whole of a throw: how fast it left, which way, and
7+ * how hard it was turning as it went.
8+ */
9+
10+export interface Sample {
11+ x: number;
12+ y: number;
13+ /** `event.timeStamp`, ms. */
14+ t: number;
15+}
16+
17+/** What a throw is, once the flick has been read. Speeds in px/s, spin rad/s. */
18+export interface Flick {
19+ vx: number;
20+ vy: number;
21+ spin: number;
22+}
23+
24+/**
25+ * How far back a flick is measured, in ms. Far enough back to be a measurement,
26+ * near enough that it is still the flick and not the drag before it: a single
27+ * event pair is noisy enough to turn a firm throw into a limp one.
28+ */
29+const WINDOW = 90;
30+
31+/**
32+ * Displacement under this is not a direction, it is jitter. Both halves of the
33+ * flick have to clear it before the angle between them means anything.
34+ */
35+const STRAIGHT = 6;
36+
37+/**
38+ * How much of the flick's own turn the tile keeps.
39+ *
40+ * A wrist that curves through a throw is turning the tile as well as sending
41+ * it, but not with the whole of that rate: the tile is being let go of over the
42+ * back half of the curve, not carried round the whole of it. Rather less than
43+ * half, by eye.
44+ */
45+const FROM_CURVE = 0.55;
46+
47+/** Fastest turn a flick will be read as putting on a tile, rad/s. Beyond about
48+ * this the tile is a blur and nobody can see which one it is. */
49+const MAX_SPIN = 9;
50+
51+/**
52+ * How fast the tile was moving when it was let go of, and how hard it was
53+ * turning, from the last few pointer positions rather than just the final pair.
54+ *
55+ * The turn is the flick's *curvature*: the angle between the first half of the
56+ * stroke and the second, over the time it took. That is a rate, in rad/s, and
57+ * is the thing a wrist actually does. It used to be read off the raw area
58+ * between the two halves, which has no time in it at all, so a lazy curl and a
59+ * hard hooked flick came out the same. A straight flick turns nothing, whatever
60+ * speed it was thrown at.
61+ */
62+export function flickOf(samples: Sample[]): Flick {
63+ const last = samples[samples.length - 1];
64+ if (!last) return { vx: 0, vy: 0, spin: 0 };
65+ const first = samples.find((s) => last.t - s.t < WINDOW) ?? samples[0];
66+ const dt = (last.t - first.t) / 1000;
67+ if (dt <= 0.001) return { vx: 0, vy: 0, spin: 0 };
68+
69+ const vx = (last.x - first.x) / dt;
70+ const vy = (last.y - first.y) / dt;
71+
72+ // The stroke in two halves, and the angle it swung through between them.
73+ const stroke = samples.slice(samples.indexOf(first));
74+ const mid = stroke[Math.floor(stroke.length / 2)] ?? first;
75+ const ax = mid.x - first.x;
76+ const ay = mid.y - first.y;
77+ const bx = last.x - mid.x;
78+ const by = last.y - mid.y;
79+ if (Math.hypot(ax, ay) < STRAIGHT || Math.hypot(bx, by) < STRAIGHT) {
80+ return { vx, vy, spin: 0 };
81+ }
82+ const swung = Math.atan2(ax * by - ay * bx, ax * bx + ay * by);
83+ const spin = (swung / dt) * FROM_CURVE;
84+ return { vx, vy, spin: Math.max(-MAX_SPIN, Math.min(MAX_SPIN, spin)) };
85+}