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 | |||
| 710 | 710 | been eaten into. The table's own edge is a backstop at the screen edge, there | |
| 711 | 711 | only so a tile that got past all of that isn't lost. | |
| 712 | 712 | ||
| 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 | + | ||
| 713 | 749 | It is drawn on a canvas that spans the **whole table**, not just the centre. | |
| 714 | 750 | That is deliberate: `.slot` and `.seat` both clip their own contents, which is | |
| 715 | 751 | why a tile can't be animated out of a strip as an element. On the canvas there is | |
| ⋯ 69 unchanged lines | |||
| 785 | 821 | the same seeded rng that shuffled, so a seed replays a hand's gap as well as its | |
| 786 | 822 | tiles. | |
| 787 | 823 | ||
| 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. | |
| 793 | 831 | Nothing about the square is calculated twice. Its size lives entirely in CSS | |
| 794 | 832 | (`--ws`, `--wd`), so `geometry.ts` **measures** it instead of restating that | |
| 795 | 833 | arithmetic: `WallRing` already renders every stack as a real element tagged with | |
| ⋯ 71 unchanged lines | |||
modifiedsrc/game/ctl.ts+2 −2
| ⋯ 46 unchanged lines | |||
| 47 | 47 | ||
| 48 | 48 | /** | |
| 49 | 49 | * 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. | |
| 52 | 52 | */ | |
| 53 | 53 | reroll?(...dice: number[]): number[]; | |
| 54 | 54 | ||
| ⋯ 24 unchanged lines | |||
modifiedsrc/styles.css+0 −64
| ⋯ 2826 unchanged lines | |||
| 2827 | 2827 | flex-wrap: wrap; | |
| 2828 | 2828 | } | |
| 2829 | 2829 | ||
| 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 | - | ||
| 2894 | 2830 | /* The gear's panel. Same shell as the rules sheet — anchored in the player's | |
| 2895 | 2831 | own strip and turned to face it — but it is four buttons, not a book, so it | |
| 2896 | 2832 | is only as tall as they are. */ | |
| ⋯ 59 unchanged lines | |||
modifiedsrc/table/physics.test.ts+118 −0
| ⋯ 452 unchanged lines | |||
| 453 | 453 | ||
| 454 | 454 | // --------------------------------------------------------------------------- | |
| 455 | 455 | ||
| 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 | + | ||
| 456 | 574 | /** A seat-0 view: pool up the screen, a wall across it, hand below. */ | |
| 457 | 575 | function geometry(walls: Rect[]): TableGeometry { | |
| 458 | 576 | return { | |
| ⋯ 321 unchanged lines | |||
modifiedsrc/table/physics.ts+246 −20
| ⋯ 62 unchanged lines | |||
| 63 | 63 | const BOUND_RESTITUTION = 0.55; | |
| 64 | 64 | const BOUND_FRICTION = 0.9; | |
| 65 | 65 | 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; | |
| 68 | 86 | ||
| 69 | 87 | /** Asleep after this many consecutive steps of going nowhere. */ | |
| 70 | 88 | const SLEEP_SPEED = 7; | |
| ⋯ 309 unchanged lines | |||
| 380 | 398 | return { nx, ny, depth }; | |
| 381 | 399 | } | |
| 382 | 400 | ||
| 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 | + | ||
| 383 | 482 | /** A standing stack, as something the tests above can chew on. */ | |
| 384 | 483 | const boxOf = (r: Rect, angle = 0): Box => ({ | |
| 385 | 484 | x: r.x + r.w / 2, | |
| ⋯ 124 unchanged lines | |||
| 510 | 609 | if (b.flipSpin !== 0) { | |
| 511 | 610 | if (b.z > 0) { | |
| 512 | 611 | 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; | |
| 513 | 616 | } else { | |
| 514 | 617 | const target = Math.round(b.flip / TAU) * TAU; | |
| 515 | 618 | b.flip += (target - b.flip) * Math.min(1, FLIP_SETTLE * dt); | |
| ⋯ 123 unchanged lines | |||
| 639 | 742 | } | |
| 640 | 743 | } | |
| 641 | 744 | ||
| 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 | + | */ | |
| 642 | 765 | function damp(b: Body, dt: number) { | |
| 643 | 766 | const k = Math.exp(-FELT_DAMP * dt); | |
| 644 | 767 | b.vx *= k; | |
| ⋯ 1 unchanged line | |||
| 646 | 769 | b.spin *= Math.exp(-SPIN_DAMP * dt); | |
| 647 | 770 | ||
| 648 | 771 | 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 | + | ||
| 649 | 776 | 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); | |
| 651 | 778 | b.vx = (b.vx / speed) * next; | |
| 652 | 779 | b.vy = (b.vy / speed) * next; | |
| 653 | 780 | } | |
| 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 | + | } | |
| 656 | 785 | } | |
| 657 | 786 | ||
| 658 | 787 | /** Nudges a tile that landed outside the pool towards the rest of them. */ | |
| ⋯ 10 unchanged lines | |||
| 669 | 798 | /** Bounce off whatever is standing there, corner to corner if that is how it | |
| 670 | 799 | * hits — a stack of the wall, a laid-down set, somebody's hand. */ | |
| 671 | 800 | 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); | |
| 673 | 803 | if (!hit || hit.depth <= CONTACT_SLOP) return; | |
| 674 | 804 | ||
| 675 | 805 | const speed = Math.hypot(b.vx, b.vy); | |
| 676 | 806 | b.x += hit.nx * hit.depth; | |
| 677 | 807 | 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; | |
| 680 | 820 | 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 | + | } | |
| 683 | 845 | } | |
| 684 | - | b.spin *= 0.7; | |
| 685 | 846 | b.resting = false; | |
| 686 | 847 | b.still = 0; | |
| 687 | 848 | const strength = Math.min(1, speed / 1100); | |
| ⋯ 16 unchanged lines | |||
| 704 | 865 | ||
| 705 | 866 | let hit = 0; | |
| 706 | 867 | 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; | |
| 707 | 872 | if (b.x < minX) { | |
| 708 | 873 | b.x = minX; | |
| 709 | 874 | hit = Math.max(hit, Math.abs(b.vx)); | |
| 710 | 875 | edge = 'left'; | |
| 876 | + | nx = 1; | |
| 711 | 877 | b.vx = Math.abs(b.vx) * BOUND_RESTITUTION; | |
| 712 | 878 | b.vy *= BOUND_FRICTION; | |
| 713 | 879 | } else if (b.x > maxX) { | |
| 714 | 880 | b.x = maxX; | |
| 715 | 881 | hit = Math.max(hit, Math.abs(b.vx)); | |
| 716 | 882 | edge = 'right'; | |
| 883 | + | nx = -1; | |
| 717 | 884 | b.vx = -Math.abs(b.vx) * BOUND_RESTITUTION; | |
| 718 | 885 | b.vy *= BOUND_FRICTION; | |
| 719 | 886 | } | |
| 720 | 887 | if (b.y < minY) { | |
| 721 | 888 | 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 | + | } | |
| 723 | 894 | hit = Math.max(hit, Math.abs(b.vy)); | |
| 724 | 895 | b.vy = Math.abs(b.vy) * BOUND_RESTITUTION; | |
| 725 | 896 | b.vx *= BOUND_FRICTION; | |
| 726 | 897 | } else if (b.y > maxY) { | |
| 727 | 898 | 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 | + | } | |
| 729 | 904 | hit = Math.max(hit, Math.abs(b.vy)); | |
| 730 | 905 | b.vy = -Math.abs(b.vy) * BOUND_RESTITUTION; | |
| 731 | 906 | b.vx *= BOUND_FRICTION; | |
| 732 | 907 | } | |
| 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 | + | } | |
| 733 | 920 | // Only a real knock counts as being disturbed. A tile held against the edge | |
| 734 | 921 | // by its neighbours is clamped every single step, and treating that as a knock | |
| 735 | 922 | // would keep the whole pool awake for ever. | |
| ⋯ 52 unchanged lines | |||
| 788 | 975 | b.x += nx * depth * bShare; | |
| 789 | 976 | b.y += ny * depth * bShare; | |
| 790 | 977 | ||
| 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. | |
| 792 | 991 | // | |
| 793 | 992 | // Only a genuine approach speed counts, and only that wakes anything. Nudging | |
| 794 | 993 | // two touching tiles apart is not a collision: a jammed corner of the pool is | |
| 795 | 994 | // corrected by a fraction of a pixel every step for as long as it is jammed, | |
| 796 | 995 | // 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); | |
| 799 | 998 | const along = rvx * nx + rvy * ny; | |
| 800 | 999 | if (along >= -SLEEP_SPEED) return; | |
| 801 | 1000 | ||
| 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); | |
| 803 | 1008 | a.vx -= nx * jolt; | |
| 804 | 1009 | a.vy -= ny * jolt; | |
| 1010 | + | a.spin -= ran * jolt * invIa; | |
| 805 | 1011 | b.vx += nx * jolt; | |
| 806 | 1012 | b.vy += ny * jolt; | |
| 1013 | + | b.spin += rbn * jolt * invIb; | |
| 807 | 1014 | ||
| 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 | + | } | |
| 811 | 1037 | ||
| 812 | 1038 | const strength = Math.min(1, Math.abs(along) / 900); | |
| 813 | 1039 | 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'; | |
| 2 | 1 | import type { TableCtl } from '../game/ctl'; | |
| 3 | 2 | import type { Prefs } from '../game/prefs'; | |
| 4 | 3 | import type { SeatId } from '../game/types'; | |
| 5 | 4 | import { labelOf } from '../game/tiles'; | |
| 6 | 5 | import { Bi, BiEm } from './Bi'; | |
| 7 | - | import { DiceDebug } from './DiceDebug'; | |
| 8 | 6 | import { useLang, useT } from './lang'; | |
| 9 | 7 | import { FullscreenToggle } from './FullscreenToggle'; | |
| 10 | 8 | import { SoundToggle } from './SoundToggle'; | |
| ⋯ 18 unchanged lines | |||
| 29 | 27 | export function Center({ game, prefs, compact, viewSeat = 0, preferSeat = null, onPrefs, onSettings }: Props) { | |
| 30 | 28 | const t = useT(); | |
| 31 | 29 | 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); | |
| 36 | 30 | const s = game.state; | |
| 37 | 31 | const r = s.result; | |
| 38 | 32 | // Point the live readout at whoever can move the game along. | |
| ⋯ 21 unchanged lines | |||
| 60 | 54 | <em>{t('剩牌', 'left')}</em> | |
| 61 | 55 | </div> | |
| 62 | 56 | ) : ( | |
| 63 | - | <WallRing state={s} whole={whole} /> | |
| 57 | + | <WallRing state={s} /> | |
| 64 | 58 | ))} | |
| 65 | 59 | ||
| 66 | 60 | <div | |
| ⋯ 128 unchanged lines | |||
| 195 | 189 | </button> | |
| 196 | 190 | </div> | |
| 197 | 191 | </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} /> | |
| 205 | 192 | )} | |
| 206 | 193 | ||
| 207 | 194 | {/* 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
| 1 | 1 | import { useRef, useState } from 'react'; | |
| 2 | 2 | import type { Tile } from '../game/tiles'; | |
| 3 | + | import { flickOf, type Sample } from './flick'; | |
| 3 | 4 | import { useT } from './lang'; | |
| 4 | 5 | import { TileView } from './TileView'; | |
| 5 | 6 | ||
| ⋯ 70 unchanged lines | |||
| 76 | 77 | const COMMIT_OUT = 70; | |
| 77 | 78 | /** Samples kept for reading the speed of a flick. */ | |
| 78 | 79 | const SAMPLES = 5; | |
| 79 | - | ||
| 80 | - | interface Sample { | |
| 81 | - | x: number; | |
| 82 | - | y: number; | |
| 83 | - | t: number; | |
| 84 | - | } | |
| 85 | 80 | ||
| 86 | 81 | /** | |
| 87 | 82 | * Finds the tile nearest the pointer. Works whatever the seat's rotation: | |
| ⋯ 183 unchanged lines | |||
| 271 | 266 | if (!d) return; | |
| 272 | 267 | ||
| 273 | 268 | 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); | |
| 275 | 271 | const dx = e.clientX - d.startX; | |
| 276 | 272 | const dy = e.clientY - d.startY; | |
| 277 | 273 | const out = dx * d.outward.x + dy * d.outward.y; | |
| ⋯ 63 unchanged lines | |||
| 341 | 337 | ))} | |
| 342 | 338 | </div> | |
| 343 | 339 | ); | |
| 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)) }; | |
| 365 | 340 | } | |
modifiedsrc/ui/Pool.tsx+7 −10
| ⋯ 15 unchanged lines | |||
| 16 | 16 | recentImpacts, | |
| 17 | 17 | showingColliders, | |
| 18 | 18 | } from './colliders'; | |
| 19 | + | import { flickOf, type Sample } from './flick'; | |
| 19 | 20 | import { EDGE_SEAT } from './rotation'; | |
| 20 | 21 | import { drawShadow, drawTile, loadArt, type Art } from './tileArt'; | |
| 21 | 22 | ||
| ⋯ 166 unchanged lines | |||
| 188 | 189 | * the settings button, the wall. | |
| 189 | 190 | */ | |
| 190 | 191 | function dragging(table: HTMLElement, pool: TablePool, wake: () => void) { | |
| 191 | - | let samples: { x: number; y: number; t: number }[] = []; | |
| 192 | + | let samples: Sample[] = []; | |
| 192 | 193 | ||
| 193 | 194 | const at = (e: PointerEvent) => { | |
| 194 | 195 | const r = table.getBoundingClientRect(); | |
| ⋯ 31 unchanged lines | |||
| 226 | 227 | const up = (e: PointerEvent) => { | |
| 227 | 228 | if (!pool.dragging) return; | |
| 228 | 229 | 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); | |
| 238 | 235 | samples = []; | |
| 239 | 236 | wake(); | |
| 240 | 237 | }; | |
| ⋯ 281 unchanged lines | |||
modifiedsrc/ui/WallRing.tsx+2 −12
| ⋯ 35 unchanged lines | |||
| 36 | 36 | * measures these elements to build the colliders rather than restating the CSS | |
| 37 | 37 | * that sizes them. | |
| 38 | 38 | */ | |
| 39 | - | export function WallRing({ state, whole }: { state: WallProgress; whole?: boolean }) { | |
| 39 | + | export function WallRing({ state }: { state: WallProgress }) { | |
| 40 | 40 | const t = useT(); | |
| 41 | 41 | const ring = useRef<HTMLDivElement>(null); | |
| 42 | 42 | 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)); | |
| 54 | 44 | const { slide, take } = useSliding(ring, square); | |
| 55 | 45 | // Where they have been pushed to, in tiles — the form `WALL_PLACED` is | |
| 56 | 46 | // 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 | + | } |