collin/mahjong · 6e2784f0
The square is the wall again: eighteen of two a side, and it meets itself
Collin Richards · 2026-08-21 20:43 UTC · 6e2784f0e22cb28ae3f5b51bdfd2a1ebf4d397a7 · parent 81bd92f0 · browse files
modifiedREADME.md+16 −2
| ⋯ 554 unchanged lines | |||
| 555 | 555 | nothing to clip it, so a tile lifted out of a hand crosses the table in one | |
| 556 | 556 | piece. | |
| 557 | 557 | ||
| 558 | + | The square is the whole wall: eighteen stacks of two a side, four sides, 144 | |
| 559 | + | tiles, the way it is built on a table. It used to be rebuilt to what was *left* | |
| 560 | + | after a hand had been dealt — barely half of it — because eighteen full-size | |
| 561 | + | stacks a side wants about 560px and no ordinary window has that between the top | |
| 562 | + | and bottom strips. That bought a square at the price of it not being the wall: | |
| 563 | + | a side ran out of stacks before it reached its corner, so the four of them never | |
| 564 | + | met. What gives instead is the tile. A wall tile is not a hand tile — on a table | |
| 565 | + | it is the same tile, but on a screen the wall has to fit the middle and the hand | |
| 566 | + | has to be readable, and those are two different jobs, so `ringCell` solves for | |
| 567 | + | how big a stack may be and `WallRing` hands the answer back to the CSS as | |
| 568 | + | `--ws`. | |
| 569 | + | ||
| 558 | 570 | The square is set at an angle — `--wall-turn` in `styles.css`, 45° — so its four | |
| 559 | 571 | arms run out towards the corners of the table rather than squaring up with its | |
| 560 | 572 | edges. That one property is the only place it is decided: `geometry.ts` reads it | |
| 561 | 573 | back off the element, so the colliders turn with it, and `WallRing` reads it too | |
| 562 | 574 | so the square is built smaller by however much the turn costs it (stood on its | |
| 563 | 575 | corner a square needs its diagonal where it used to need its side). Set it to | |
| 564 | - | zero and everything squares up again. `--wall-room`, `--wall-x` and `--wall-y` | |
| 565 | - | put it where you want it, and `npm run dev` serves **`/wall.html`** for turning | |
| 576 | + | zero and everything squares up again. `--wall-lap` is how far each wall runs past its own corner, in wall-depths — | |
| 577 | + | that overlap is the whole of what makes four straight walls a pinwheel, and at 0 | |
| 578 | + | they merely meet. `--wall-room`, `--wall-x` and `--wall-y` put it where you want | |
| 579 | + | it, and `npm run dev` serves **`/wall.html`** for turning | |
| 566 | 580 | all four by hand — the real `WallRing` in a real `.center`, with every collider | |
| 567 | 581 | the physics would be handed drawn over the top, because "it looks right" and "a | |
| 568 | 582 | tile would bounce off it there" are two different claims. | |
| ⋯ 75 unchanged lines | |||
modifiedsrc/game/wall.ts+50 −31
| ⋯ 82 unchanged lines | |||
| 83 | 83 | /** Dealt out before anybody looks at the table: sixteen tiles, four ways. */ | |
| 84 | 84 | const DEALT = 64; | |
| 85 | 85 | /** | |
| 86 | - | * How much wall is left once a hand has been dealt — what the square is | |
| 87 | - | * actually built to. Four walls of eighteen is what 144 tiles make while they | |
| 88 | - | * are still in a neat square, and nobody ever sees that: by the time the table | |
| 89 | - | * is on screen, four hands have come off the front and barely half of it is | |
| 90 | - | * left. So the square is rebuilt to what is left, which is what lets it be a | |
| 91 | - | * square at all — eighteen full-size stacks a side needs about 560px, and no | |
| 92 | - | * ordinary window has that between the top and bottom strips. | |
| 86 | + | * How much wall is left once a hand has been dealt. The square is built to the | |
| 87 | + | * whole hundred and forty-four now rather than to this — see `RING` — so this | |
| 88 | + | * is only a fact about the wall, and what the tests measure a fresh deal | |
| 89 | + | * against. | |
| 93 | 90 | */ | |
| 94 | 91 | export const DEALT_STACKS = WALL_STACKS - DEALT / 2; | |
| 95 | 92 | ||
| 96 | 93 | /** | |
| 97 | - | * The square to build, given the middle to build it in and the size of a stack. | |
| 94 | + | * The square is the whole wall: eighteen stacks of two a side, four sides, the | |
| 95 | + | * hundred and forty-four tiles, exactly as it is built on a table. | |
| 98 | 96 | * | |
| 99 | - | * Square if it can be — that is the shape everyone knows — and only as long a | |
| 100 | - | * side as the wall it has to hold. It gives up being square only when the | |
| 101 | - | * middle is too shallow even for that, and then the extra goes along whichever | |
| 102 | - | * way there is room, which is how it stays a wall rather than shrinking a tile. | |
| 97 | + | * It used to be rebuilt to what was *left* after a hand had been dealt — barely | |
| 98 | + | * half of it — because eighteen full-size stacks a side wants about 560px and no | |
| 99 | + | * ordinary window has that between the top and bottom strips. That bought a | |
| 100 | + | * square, at the price of it not being the wall: a side ran out of stacks before | |
| 101 | + | * it reached its corner, so the four of them never met. | |
| 102 | + | * | |
| 103 | + | * What gives instead is the tile. A wall tile is not a hand tile — on a table it | |
| 104 | + | * is the same tile, but on a screen the wall has to fit the middle and the hand | |
| 105 | + | * has to be readable, and those are two different jobs. See `ringCell`. | |
| 103 | 106 | */ | |
| 104 | - | export function ringFor(space: { w: number; h: number }, cell: { s: number; d: number }): RingCapacity { | |
| 105 | - | // A side runs one wall-depth past its own corner — the overlap that makes the | |
| 106 | - | // # — so a side of n stacks needs n * s + d across the middle. | |
| 107 | - | const most = (across: number) => Math.max(1, Math.floor((across - cell.d) / cell.s)); | |
| 108 | - | const hMost = most(space.w); | |
| 109 | - | const vMost = most(space.h); | |
| 110 | - | const side = Math.ceil(DEALT_STACKS / 4); | |
| 107 | + | export const RING: RingCapacity = { h: STACKS_PER_SIDE, v: STACKS_PER_SIDE }; | |
| 111 | 108 | ||
| 112 | - | let h = Math.min(hMost, side); | |
| 113 | - | let v = Math.min(vMost, side); | |
| 114 | - | // Squashed by the middle: give back along whichever side still has room. | |
| 115 | - | while (2 * (h + v) < DEALT_STACKS && (h < hMost || v < vMost)) { | |
| 116 | - | if (h < hMost && (h <= v || v >= vMost)) h++; | |
| 117 | - | else v++; | |
| 118 | - | } | |
| 119 | - | return { h, v }; | |
| 109 | + | /** | |
| 110 | + | * How big a stack may be, to build that square in the middle there is. | |
| 111 | + | * | |
| 112 | + | * A side of n stacks runs one wall-depth past its own corner — the overlap that | |
| 113 | + | * makes the # — so it needs `n * s + d` across, and with `d = s * ratio` that is | |
| 114 | + | * `(n + ratio) * s`. Square, so the tighter of the two directions decides. | |
| 115 | + | * | |
| 116 | + | * `ratio` is the stack's long side over its short one, measured off the element | |
| 117 | + | * rather than restated here: it is CSS's business what shape a stack is. | |
| 118 | + | */ | |
| 119 | + | export function ringCell( | |
| 120 | + | space: { w: number; h: number }, | |
| 121 | + | ratio: number, | |
| 122 | + | lap = ratio, | |
| 123 | + | ): number { | |
| 124 | + | // Outer footprint: the opening, plus a wall hanging off each side of it. | |
| 125 | + | // opening = n*s - lap*s walls = 2 * ratio*s | |
| 126 | + | // outer = (n + 2*ratio - lap) * s | |
| 127 | + | return Math.max(1, Math.min(space.w, space.h) / (STACKS_PER_SIDE + 2 * ratio - lap)); | |
| 120 | 128 | } | |
| 121 | 129 | ||
| 122 | - | /** The opening the square leaves in the middle — where the discards go. */ | |
| 123 | - | export const ringOpening = (cap: RingCapacity, cell: { s: number; d: number }) => ({ | |
| 124 | - | w: Math.max(cell.s, cap.h * cell.s - cell.d), | |
| 125 | - | h: Math.max(cell.s, cap.v * cell.s - cell.d), | |
| 130 | + | /** | |
| 131 | + | * The opening the square leaves in the middle — where the discards go. | |
| 132 | + | * | |
| 133 | + | * A side holds its stacks and then runs `lap` past its own corner: that overlap | |
| 134 | + | * is the whole of what makes four straight walls a pinwheel, and it is what the | |
| 135 | + | * opening is short by. One wall-depth is the square everyone knows; less draws | |
| 136 | + | * the arms in, more throws them out. | |
| 137 | + | */ | |
| 138 | + | export const ringOpening = ( | |
| 139 | + | cap: RingCapacity, | |
| 140 | + | cell: { s: number; d: number }, | |
| 141 | + | lap = cell.d, | |
| 142 | + | ) => ({ | |
| 143 | + | w: Math.max(cell.s, cap.h * cell.s - lap), | |
| 144 | + | h: Math.max(cell.s, cap.v * cell.s - lap), | |
| 126 | 145 | }); | |
| 127 | 146 | ||
| 128 | 147 | /** A stack and which of the 72 it is — the index is what the physics reads. */ | |
| ⋯ 51 unchanged lines | |||
modifiedsrc/styles.css+15 −17
| ⋯ 661 unchanged lines | |||
| 662 | 662 | `.wall-ring` is the opening, not the outside: the sides hang off its edges, | |
| 663 | 663 | so the square encloses the pool the way it does on a table. */ | |
| 664 | 664 | .wall-ring { | |
| 665 | - | /* How big a tile in the wall is, against one in a hand. | |
| 666 | - | ||
| 667 | - | A side is only as long as the stacks that fit on it, so this is really the | |
| 668 | - | knob for *how many* tiles the square holds: shrink it and more of them fit, | |
| 669 | - | the sides reach their corners, and the pinwheel closes up. At 1 they are the | |
| 670 | - | same tile and a side runs short, which is where the gaps at the corners come | |
| 671 | - | from. Turned by hand in `/wall.html` like the rest of the square. */ | |
| 672 | - | --wall-tile: 1; | |
| 673 | - | --ws: calc(var(--tile-w) * var(--wall-tile)); | |
| 665 | + | /* A stack's short side. Set from ui/WallRing, not from here and not from the | |
| 666 | + | hand's tile: the square is eighteen stacks a side whatever the window is, | |
| 667 | + | so it is the *tile* that gives, and how much it gives is arithmetic on the | |
| 668 | + | middle. The fallback is only for the frame before the first measure. */ | |
| 669 | + | --ws: var(--tile-w); | |
| 674 | 670 | --wd: calc(var(--ws) * 1.375); | |
| 675 | 671 | /* How far round the whole square is set. Four walls built the same way round | |
| 676 | 672 | are already a pinwheel; standing it on its corner is what sends the arms | |
| ⋯ 3 unchanged lines | |||
| 680 | 676 | element, so the colliders turn with it and nothing has to be kept in step | |
| 681 | 677 | by hand. Zero squares it up again. */ | |
| 682 | 678 | --wall-turn: 45deg; | |
| 679 | + | /* How far a wall runs past its own corner, in wall-depths. That overlap is | |
| 680 | + | the whole of what makes four straight walls a pinwheel: at 1 it is the | |
| 681 | + | square everyone knows, at 0 the four of them just meet, and above 1 the | |
| 682 | + | arms are thrown out past each other. */ | |
| 683 | + | --wall-lap: 1; | |
| 684 | + | --lap: calc(var(--wd) * var(--wall-lap)); | |
| 683 | 685 | /* How much of the room it has the square actually takes, and where its middle | |
| 684 | 686 | sits relative to the middle of the table. All three are turned by hand in | |
| 685 | 687 | `/wall.html` and pasted back here. */ | |
| ⋯ 31 unchanged lines | |||
| 717 | 719 | .wall-side.top { | |
| 718 | 720 | top: calc(-1 * var(--wd)); | |
| 719 | 721 | left: 0; | |
| 720 | - | right: calc(-1 * var(--wd)); | |
| 722 | + | right: calc(-1 * var(--lap)); | |
| 721 | 723 | height: var(--wd); | |
| 722 | 724 | } | |
| 723 | 725 | .wall-side.right { | |
| 724 | 726 | right: calc(-1 * var(--wd)); | |
| 725 | 727 | top: 0; | |
| 726 | - | bottom: calc(-1 * var(--wd)); | |
| 728 | + | bottom: calc(-1 * var(--lap)); | |
| 727 | 729 | width: var(--wd); | |
| 728 | 730 | flex-direction: column; | |
| 729 | 731 | } | |
| 730 | 732 | .wall-side.bottom { | |
| 731 | 733 | bottom: calc(-1 * var(--wd)); | |
| 732 | 734 | right: 0; | |
| 733 | - | left: calc(-1 * var(--wd)); | |
| 735 | + | left: calc(-1 * var(--lap)); | |
| 734 | 736 | height: var(--wd); | |
| 735 | 737 | flex-direction: row-reverse; | |
| 736 | 738 | } | |
| 737 | 739 | .wall-side.left { | |
| 738 | 740 | left: calc(-1 * var(--wd)); | |
| 739 | 741 | bottom: 0; | |
| 740 | - | top: calc(-1 * var(--wd)); | |
| 742 | + | top: calc(-1 * var(--lap)); | |
| 741 | 743 | width: var(--wd); | |
| 742 | 744 | flex-direction: column-reverse; | |
| 743 | 745 | } | |
| ⋯ 31 unchanged lines | |||
| 775 | 777 | .wall-side.left .wall-stack.s1 i, | |
| 776 | 778 | .wall-side.right .wall-stack.s1 i { | |
| 777 | 779 | transform: rotate(90deg) scale(0.86); | |
| 778 | - | } | |
| 779 | - | .wall-stack.dead i { | |
| 780 | - | opacity: 0.4; | |
| 781 | - | filter: grayscale(0.6); | |
| 782 | 780 | } | |
| 783 | 781 | .wall-stack.next::after { | |
| 784 | 782 | content: ''; | |
| ⋯ 1536 unchanged lines | |||
modifiedsrc/table/physics.test.ts+36 −32
| 1 | 1 | import { describe, expect, it } from 'vitest'; | |
| 2 | - | import { makeRng, STACKS_PER_SIDE } from '../game/tiles'; | |
| 2 | + | import { makeRng, STACKS_PER_SIDE, WALL_STACKS } from '../game/tiles'; | |
| 3 | 3 | import { | |
| 4 | - | DEALT_STACKS, | |
| 5 | 4 | isBarrier, | |
| 6 | - | ringFor, | |
| 5 | + | ringCell, | |
| 6 | + | RING, | |
| 7 | 7 | ringLayout, | |
| 8 | 8 | ringOpening, | |
| 9 | 9 | SEAT_WALL_SIDE, | |
| ⋯ 568 unchanged lines | |||
| 578 | 578 | }); | |
| 579 | 579 | }); | |
| 580 | 580 | ||
| 581 | - | describe('ringFor', () => { | |
| 582 | - | // A tile as it comes out on an ordinary window, and the stack it makes. | |
| 583 | - | const cell = { s: 31, d: 43 }; | |
| 584 | - | const holds = (cap: { h: number; v: number }) => 2 * (cap.h + cap.v); | |
| 585 | - | ||
| 586 | - | it('builds a square the size of the wall as it is dealt', () => { | |
| 587 | - | const cap = ringFor({ w: 990, h: 360 }, cell); | |
| 588 | - | expect(cap).toEqual({ h: 10, v: 10 }); | |
| 589 | - | expect(holds(cap)).toBe(DEALT_STACKS); | |
| 590 | - | // And it leaves the middle of itself for the discards. | |
| 591 | - | expect(ringOpening(cap, cell).w).toBe(10 * 31 - 43); | |
| 592 | - | }); | |
| 593 | - | ||
| 594 | - | it('gives up being square before it gives up a tile', () => { | |
| 595 | - | // A middle too shallow for ten stacks down its side: the wall gets longer | |
| 596 | - | // rather than the tiles getting smaller. | |
| 597 | - | const cap = ringFor({ w: 990, h: 220 }, cell); | |
| 598 | - | expect(cap.v).toBeLessThan(10); | |
| 599 | - | expect(cap.h).toBeGreaterThan(10); | |
| 600 | - | expect(holds(cap)).toBeGreaterThanOrEqual(DEALT_STACKS); | |
| 601 | - | }); | |
| 602 | - | ||
| 603 | - | it('never asks for more room than it has, however small the middle', () => { | |
| 604 | - | for (const space of [{ w: 990, h: 360 }, { w: 400, h: 400 }, { w: 300, h: 140 }, { w: 60, h: 40 }]) { | |
| 605 | - | const cap = ringFor(space, cell); | |
| 606 | - | expect(cap.h).toBeGreaterThanOrEqual(1); | |
| 607 | - | expect(cap.v).toBeGreaterThanOrEqual(1); | |
| 608 | - | expect(cap.h * cell.s + cell.d).toBeLessThanOrEqual(Math.max(space.w, cell.s + cell.d)); | |
| 609 | - | expect(cap.v * cell.s + cell.d).toBeLessThanOrEqual(Math.max(space.h, cell.s + cell.d)); | |
| 581 | + | describe('the square is the whole wall', () => { | |
| 582 | + | // A stack is a tile wide and 1.375 tiles deep, whatever a tile comes out as. | |
| 583 | + | const RATIO = 1.375; | |
| 584 | + | ||
| 585 | + | it('is always eighteen stacks of two a side, and so a hundred and forty-four tiles', () => { | |
| 586 | + | expect(RING).toEqual({ h: STACKS_PER_SIDE, v: STACKS_PER_SIDE }); | |
| 587 | + | expect(2 * (RING.h + RING.v)).toBe(WALL_STACKS); | |
| 588 | + | expect(2 * (RING.h + RING.v) * 2).toBe(144); | |
| 589 | + | }); | |
| 590 | + | ||
| 591 | + | it('fits that square in the middle it is given, by sizing the tile', () => { | |
| 592 | + | for (const space of [{ w: 990, h: 360 }, { w: 400, h: 400 }, { w: 300, h: 140 }]) { | |
| 593 | + | const s = ringCell(space, RATIO); | |
| 594 | + | const d = s * RATIO; | |
| 595 | + | // A side is eighteen stacks and one depth of overlap, and it has to fit | |
| 596 | + | // the tighter of the two directions — that is the whole of the sizing. | |
| 597 | + | const side = STACKS_PER_SIDE * s + d; | |
| 598 | + | expect(side).toBeLessThanOrEqual(Math.min(space.w, space.h) + 0.001); | |
| 599 | + | expect(side).toBeGreaterThan(Math.min(space.w, space.h) - 0.001); | |
| 610 | 600 | } | |
| 611 | 601 | }); | |
| 602 | + | ||
| 603 | + | it('gives the tile up rather than the wall, when the middle is small', () => { | |
| 604 | + | const big = ringCell({ w: 990, h: 560 }, RATIO); | |
| 605 | + | const small = ringCell({ w: 400, h: 260 }, RATIO); | |
| 606 | + | // Same square, smaller tiles. It never trades away a stack. | |
| 607 | + | expect(small).toBeLessThan(big); | |
| 608 | + | expect(small).toBeGreaterThan(0); | |
| 609 | + | }); | |
| 610 | + | ||
| 611 | + | it('leaves the middle of itself open for the discards', () => { | |
| 612 | + | const s = ringCell({ w: 600, h: 600 }, RATIO); | |
| 613 | + | const cell = { s, d: s * RATIO }; | |
| 614 | + | expect(ringOpening(RING, cell).w).toBeCloseTo(STACKS_PER_SIDE * s - cell.d, 6); | |
| 615 | + | }); | |
| 612 | 616 | }); | |
| 613 | 617 | ||
| 614 | 618 | describe('ringLayout', () => { | |
| ⋯ 58 unchanged lines | |||
modifiedsrc/tuneWall.tsx+5 −5
| ⋯ 31 unchanged lines | |||
| 32 | 32 | interface Placing { | |
| 33 | 33 | turn: number; | |
| 34 | 34 | room: number; | |
| 35 | - | tile: number; | |
| 35 | + | lap: number; | |
| 36 | 36 | x: number; | |
| 37 | 37 | y: number; | |
| 38 | 38 | } | |
| 39 | 39 | ||
| 40 | - | const START: Placing = { turn: 45, room: 0.95, tile: 1, x: 0, y: 0 }; | |
| 40 | + | const START: Placing = { turn: 45, room: 0.95, lap: 1, x: 0, y: 0 }; | |
| 41 | 41 | ||
| 42 | 42 | const KNOBS: Knob[] = [ | |
| 43 | 43 | { key: 'turn', label: 'Turn', min: 0, max: 90, step: 1, note: '° clockwise', unit: 'deg' }, | |
| 44 | 44 | { key: 'room', label: 'Room it takes', min: 0.3, max: 1.3, step: 0.01, note: 'of the middle', unit: '' }, | |
| 45 | - | { key: 'tile', label: 'Tile size', min: 0.3, max: 1.4, step: 0.02, note: 'against a hand tile', unit: '' }, | |
| 45 | + | { key: 'lap', label: 'Pinwheel lap', min: 0, max: 2, step: 0.05, note: 'wall-depths past the corner', unit: '' }, | |
| 46 | 46 | { key: 'x', label: 'Across', min: -200, max: 200, step: 1, note: 'from the middle', unit: 'px' }, | |
| 47 | 47 | { key: 'y', label: 'Down', min: -200, max: 200, step: 1, note: 'from the middle', unit: 'px' }, | |
| 48 | 48 | ]; | |
| ⋯ 25 unchanged lines | |||
| 74 | 74 | if (!ring) return; | |
| 75 | 75 | ring.style.setProperty('--wall-turn', `${place.turn}deg`); | |
| 76 | 76 | ring.style.setProperty('--wall-room', `${place.room}`); | |
| 77 | - | ring.style.setProperty('--wall-tile', `${place.tile}`); | |
| 77 | + | ring.style.setProperty('--wall-lap', `${place.lap}`); | |
| 78 | 78 | ring.style.setProperty('--wall-x', `${place.x}px`); | |
| 79 | 79 | ring.style.setProperty('--wall-y', `${place.y}px`); | |
| 80 | 80 | }, [place]); | |
| ⋯ 66 unchanged lines | |||
| 147 | 147 | }; | |
| 148 | 148 | }, [place, drawn, showColliders, state]); | |
| 149 | 149 | ||
| 150 | - | const css = `.wall-ring {\n --wall-turn: ${place.turn}deg;\n --wall-room: ${place.room};\n --wall-tile: ${place.tile};\n --wall-x: ${place.x}px;\n --wall-y: ${place.y}px;\n}`; | |
| 150 | + | const css = `.wall-ring {\n --wall-turn: ${place.turn}deg;\n --wall-room: ${place.room};\n --wall-lap: ${place.lap};\n --wall-x: ${place.x}px;\n --wall-y: ${place.y}px;\n}`; | |
| 151 | 151 | ||
| 152 | 152 | return ( | |
| 153 | 153 | <div className="tune"> | |
| ⋯ 84 unchanged lines | |||
modifiedsrc/ui/WallRing.tsx+37 −23
| 1 | 1 | import { useLayoutEffect, useRef, useState } from 'react'; | |
| 2 | 2 | import { | |
| 3 | - | ringFor, | |
| 3 | + | ringCell, | |
| 4 | 4 | ringLayout, | |
| 5 | 5 | ringOpening, | |
| 6 | + | RING, | |
| 6 | 7 | WALL_SIDES, | |
| 7 | 8 | wallStacks, | |
| 8 | 9 | type RingCapacity, | |
| ⋯ 10 unchanged lines | |||
| 19 | 20 | * side is longer than the middle of any ordinary window is tall, and by the | |
| 20 | 21 | * time anyone is looking there is nothing like that much wall left, so it is | |
| 21 | 22 | * rebuilt smaller and the tiles stay the size of the ones in your hand. What | |
| 22 | - | * goes where is `ringFor` and `ringLayout` in `game/wall.ts`; this measures the | |
| 23 | + | * goes where is `RING` and `ringLayout` in `game/wall.ts`; this measures the | |
| 23 | 24 | * middle for them and puts the answer on screen. | |
| 24 | 25 | * | |
| 25 | 26 | * Every stack carries its index, and that is load-bearing — `table/geometry.ts` | |
| ⋯ 3 unchanged lines | |||
| 29 | 30 | export function WallRing({ state }: { state: WallProgress }) { | |
| 30 | 31 | const t = useT(); | |
| 31 | 32 | const ring = useRef<HTMLDivElement>(null); | |
| 32 | - | const { cap, opening } = useSquare(ring); | |
| 33 | + | const { cap, opening, cell } = useSquare(ring); | |
| 33 | 34 | const sides = ringLayout(wallStacks(state), cap); | |
| 34 | 35 | ||
| 35 | 36 | return ( | |
| ⋯ 1 unchanged line | |||
| 37 | 38 | className="wall-ring" | |
| 38 | 39 | ref={ring} | |
| 39 | 40 | aria-label={t('牌牆', 'The wall')} | |
| 40 | - | style={opening ? { width: opening.w, height: opening.h } : undefined} | |
| 41 | + | style={ | |
| 42 | + | opening | |
| 43 | + | ? // `--ws` is set from here rather than from the tile, because the | |
| 44 | + | // square is the fixed thing now and the tile is what gives. | |
| 45 | + | ({ width: opening.w, height: opening.h, '--ws': `${cell}px` } as React.CSSProperties) | |
| 46 | + | : undefined | |
| 47 | + | } | |
| 41 | 48 | > | |
| 42 | 49 | {/* One cell, never drawn: the ring is measured in stacks, so something has | |
| 43 | 50 | to be the size of a stack before any of them have been placed. */} | |
| ⋯ 4 unchanged lines | |||
| 48 | 55 | <div | |
| 49 | 56 | key={index} | |
| 50 | 57 | data-stack={index} | |
| 51 | - | className={`wall-stack s${stack.count}${stack.dead ? ' dead' : ''}${ | |
| 52 | - | stack.next ? ' next' : '' | |
| 53 | - | }`} | |
| 58 | + | // The 16-tile 底牌 tail is not marked out. It is still the tail — | |
| 59 | + | // `Stack.dead` says so and the engine ends the hand on it — but a | |
| 60 | + | // wall on a table is one wall, not a wall with the end greyed off. | |
| 61 | + | className={`wall-stack s${stack.count}${stack.next ? ' next' : ''}`} | |
| 54 | 62 | > | |
| 55 | 63 | <i /> | |
| 56 | 64 | </div> | |
| ⋯ 8 unchanged lines | |||
| 65 | 73 | cap: RingCapacity; | |
| 66 | 74 | /** The opening the square leaves, once there is a middle to measure. */ | |
| 67 | 75 | opening: { w: number; h: number } | null; | |
| 76 | + | /** How big a stack came out — the wall's own tile size, in px. */ | |
| 77 | + | cell: number; | |
| 68 | 78 | } | |
| 69 | 79 | ||
| 70 | 80 | /** | |
| ⋯ 4 unchanged lines | |||
| 75 | 85 | * then eaten, so it does not close in on the discards lying inside it. | |
| 76 | 86 | */ | |
| 77 | 87 | function useSquare(ring: React.RefObject<HTMLDivElement | null>): Square { | |
| 78 | - | const [square, setSquare] = useState<Square>({ cap: { h: 1, v: 1 }, opening: null }); | |
| 88 | + | const [square, setSquare] = useState<Square>({ cap: RING, opening: null, cell: 0 }); | |
| 79 | 89 | ||
| 80 | 90 | useLayoutEffect(() => { | |
| 81 | 91 | const el = ring.current; | |
| ⋯ 3 unchanged lines | |||
| 85 | 95 | ||
| 86 | 96 | const read = () => { | |
| 87 | 97 | const box = probe.getBoundingClientRect(); | |
| 88 | - | const cell = { s: box.width, d: box.height }; | |
| 89 | - | if (cell.s <= 0) return; | |
| 98 | + | if (box.width <= 0) return; | |
| 99 | + | // What shape a stack is, is CSS's business — read it, don't restate it. | |
| 100 | + | const ratio = box.height / box.width; | |
| 90 | 101 | // Stood on its corner, a square needs its *diagonal* where it used to | |
| 91 | 102 | // need its side — so the room it has to be built in shrinks by however | |
| 92 | 103 | // much the turn costs. Square on that is 1 and nothing changes; at 45° it | |
| ⋯ 3 unchanged lines | |||
| 96 | 107 | const turn = (parseFloat(style.getPropertyValue('--wall-turn')) || 0) * (Math.PI / 180); | |
| 97 | 108 | const spread = Math.abs(Math.cos(turn)) + Math.abs(Math.sin(turn)); | |
| 98 | 109 | const room = parseFloat(style.getPropertyValue('--wall-room')) || 1; | |
| 99 | - | // The walls hang *outside* the opening, one depth on each side, and | |
| 100 | - | // `ringFor` only expects to be told about one of them. Turned, the other | |
| 101 | - | // one is what runs off the table, so it comes off here. | |
| 102 | - | const cap = ringFor( | |
| 110 | + | // How far a wall runs past its corner, as a share of a stack's short side. | |
| 111 | + | const lapOf = parseFloat(style.getPropertyValue('--wall-lap')); | |
| 112 | + | const lap = ratio * (Number.isFinite(lapOf) ? lapOf : 1); | |
| 113 | + | // Eighteen stacks and one depth of overlap is `(18 + ratio) * s` across, | |
| 114 | + | // and turned that covers `spread` times as much — so the room it has to | |
| 115 | + | // be solved in is the middle divided by the turn. | |
| 116 | + | const s = ringCell( | |
| 103 | 117 | { | |
| 104 | - | w: (middle.clientWidth * room) / spread - cell.d, | |
| 105 | - | h: (middle.clientHeight * room) / spread - cell.d, | |
| 118 | + | w: (middle.clientWidth * room) / spread, | |
| 119 | + | h: (middle.clientHeight * room) / spread, | |
| 106 | 120 | }, | |
| 107 | - | cell, | |
| 121 | + | ratio, | |
| 122 | + | lap, | |
| 108 | 123 | ); | |
| 109 | - | const opening = ringOpening(cap, cell); | |
| 124 | + | // The square is always eighteen a side; what fits it is the tile. | |
| 125 | + | const cell = { s, d: s * ratio }; | |
| 126 | + | const opening = ringOpening(RING, cell, s * lap); | |
| 110 | 127 | setSquare((was) => | |
| 111 | - | was.cap.h === cap.h && | |
| 112 | - | was.cap.v === cap.v && | |
| 113 | - | was.opening?.w === opening.w && | |
| 114 | - | was.opening?.h === opening.h | |
| 128 | + | was.cell === s && was.opening?.w === opening.w && was.opening?.h === opening.h | |
| 115 | 129 | ? was | |
| 116 | - | : { cap, opening }, | |
| 130 | + | : { cap: RING, opening, cell: s }, | |
| 117 | 131 | ); | |
| 118 | 132 | }; | |
| 119 | 133 | ||
| ⋯ 11 unchanged lines | |||