collin/mahjong · c3d1289b
The empty melds row stops being a wall, and a meld is a full tile
Collin Richards · 2026-08-21 18:03 UTC · c3d1289b6539f800067046718eb8a510c9596bd5 · parent feaee21e · browse files
modifiedsrc/styles.css+32 −7
| ⋯ 7 unchanged lines | |||
| 8 | 8 | middle of the table now rather than in a row inside each strip, and the | |
| 9 | 9 | depth that freed up was given to the centre instead of to the tiles. The | |
| 10 | 10 | wall square and the pile inside it are what wanted the room. */ | |
| 11 | - | --depth: clamp(202px, 26vh, 276px); | |
| 11 | + | --depth: clamp(226px, 30vh, 307px); | |
| 12 | 12 | --h-len: max(160px, calc(100vw - 2 * var(--depth))); | |
| 13 | 13 | --v-len: 100vh; | |
| 14 | 14 | /* Height the action buttons occupy, held whether or not any are showing. | |
| ⋯ 2 unchanged lines | |||
| 17 | 17 | is what keeps everything above it still. Sized for the tallest button: the | |
| 18 | 18 | ones that name a tile (打出 五萬, 吃 三萬 四萬) carry a third line. */ | |
| 19 | 19 | --bar-h: 52px; | |
| 20 | - | /* Largest tile a strip can hold. Edge inwards it has to fit the melds (0.86 | |
| 21 | - | tiles tall), the button bar, the hand (1.71 + 6px) and the nameplate | |
| 22 | - | (28px), plus its own padding, gaps and margins — so 2.57 tiles plus 122px. | |
| 20 | + | /* Largest tile a strip can hold. Edge inwards it has to fit the melds (a | |
| 21 | + | full tile, 1.375 tall, since they are the same tiles as the ones in the | |
| 22 | + | hand), the button bar, the hand (1.71 + 6px) and the nameplate (28px), plus | |
| 23 | + | its own padding, gaps and margins — so 3.085 tiles plus 122px. | |
| 24 | + | ||
| 25 | + | The melds used to be drawn at 0.62 of a tile and budgeted at 0.86, which is | |
| 26 | + | where the old 2.57 came from; `--depth` grew by the difference rather than | |
| 27 | + | the tiles shrinking, for the same reason it shrank when the discards moved | |
| 28 | + | out to the middle. The strip is deeper, but the felt is *bigger* than it | |
| 29 | + | was for any seat that has not claimed anything — see geometry.ts. | |
| 23 | 30 | ||
| 24 | 31 | It used to be 3.45 tiles: the missing 0.88 is the row of discards, which is | |
| 25 | 32 | in the middle of the table now. `--depth` came down by the same amount | |
| ⋯ 3 unchanged lines | |||
| 29 | 36 | Tile size otherwise follows the strip's *length*, which is why a window | |
| 30 | 37 | with less height than F11 kept full-size tiles and left the strip's | |
| 31 | 38 | contents to be cut off. */ | |
| 32 | - | --tile-fit: calc((var(--depth) - 122px) / 2.57); | |
| 39 | + | --tile-fit: calc((var(--depth) - 122px) / 3.085); | |
| 33 | 40 | ||
| 34 | 41 | /* One size for every tile on the table — in a hand, in the wall, and lying in | |
| 35 | 42 | the middle. They are the same tiles, so drawing them at different sizes was | |
| ⋯ 168 unchanged lines | |||
| 204 | 211 | border-radius: calc(var(--tw) * 0.1); | |
| 205 | 212 | transition: transform 0.12s ease, filter 0.12s ease; | |
| 206 | 213 | } | |
| 214 | + | /* A melded tile is the same tile it was in the hand it came out of, so it is | |
| 215 | + | the same size. It used to be drawn at 0.62 and read as a different, smaller | |
| 216 | + | set of tiles sitting beside the real ones. */ | |
| 207 | 217 | .tile.t-meld { | |
| 208 | - | --tw: calc(var(--tile-w) * 0.62); | |
| 218 | + | --tw: var(--tile-w); | |
| 209 | 219 | } | |
| 210 | 220 | .tile.t-small { | |
| 211 | 221 | --tw: calc(var(--tile-w) * 0.64); | |
| ⋯ 108 unchanged lines | |||
| 320 | 330 | pointer-events: none; | |
| 321 | 331 | } | |
| 322 | 332 | ||
| 333 | + | /* No reservation. The strip is packed towards its outer edge and this is the | |
| 334 | + | innermost thing in it, so a meld appearing grows inwards and moves nothing — | |
| 335 | + | the height was only ever holding open a band of empty green between the seat | |
| 336 | + | and the middle, which table/geometry.ts then had to hand back to the felt. | |
| 337 | + | Room for one is still budgeted in `--tile-fit`, so a claim cannot overflow | |
| 338 | + | the strip. */ | |
| 323 | 339 | .melds { | |
| 324 | 340 | display: flex; | |
| 325 | 341 | gap: 10px; | |
| 326 | 342 | flex-wrap: wrap; | |
| 327 | 343 | justify-content: center; | |
| 328 | - | min-height: calc(var(--tile-w) * 0.86); | |
| 329 | 344 | flex: none; | |
| 330 | 345 | } | |
| 331 | 346 | .meld { | |
| ⋯ 1236 unchanged lines | |||
| 1568 | 1583 | min-height: 0; | |
| 1569 | 1584 | max-width: 100%; | |
| 1570 | 1585 | } | |
| 1586 | + | /* Same size as the hand they were laid down from — which on an opponent's card | |
| 1587 | + | is that card's own smaller tile, not the table's. */ | |
| 1588 | + | .compact .opp .melds .tile { | |
| 1589 | + | --tw: calc(var(--tile-w) * 0.52); | |
| 1590 | + | } | |
| 1571 | 1591 | .compact .opp .meld.flowers { | |
| 1572 | 1592 | margin-left: 5px; | |
| 1573 | 1593 | padding-left: 5px; | |
| ⋯ 51 unchanged lines | |||
| 1625 | 1645 | min-height: 0; | |
| 1626 | 1646 | gap: 6px; | |
| 1627 | 1647 | } | |
| 1648 | + | /* Full size, like the hand under it — the discards beside them stay at half, | |
| 1649 | + | which is now the whole of what tells the two rows apart. */ | |
| 1650 | + | .compact .mine .melds .tile { | |
| 1651 | + | --tw: calc(var(--tile-w) * 0.72); | |
| 1652 | + | } | |
| 1628 | 1653 | .compact .mine .discards { | |
| 1629 | 1654 | grid-area: discards; | |
| 1630 | 1655 | /* One line that scrolls, kept pinned to its end by Seat.tsx — wrapping would | |
| ⋯ 641 unchanged lines | |||
modifiedsrc/table/geometry.ts+83 −2
| ⋯ 116 unchanged lines | |||
| 117 | 117 | : launch[seat]; | |
| 118 | 118 | } | |
| 119 | 119 | ||
| 120 | - | // The felt is the centre element's own box — the area the four strips leave. | |
| 120 | + | // The felt is the centre element's own box — the area the four strips leave — | |
| 121 | + | // grown into whatever of those strips nobody is using yet. | |
| 121 | 122 | const centre = table.querySelector<HTMLElement>('.center'); | |
| 122 | - | const felt = centre ? local(centre.getBoundingClientRect()) : ringRect; | |
| 123 | + | const box = centre ? local(centre.getBoundingClientRect()) : ringRect; | |
| 124 | + | const felt = reaching(box, stripEdges(table, base, local)); | |
| 123 | 125 | ||
| 124 | 126 | return { | |
| 125 | 127 | size: { w: base.width, h: base.height }, | |
| ⋯ 28 unchanged lines | |||
| 154 | 156 | return { w, h: w * 1.375 }; | |
| 155 | 157 | } | |
| 156 | 158 | ||
| 159 | + | /** | |
| 160 | + | * The inside edge of each strip's contents, where any of them has been read. | |
| 161 | + | * Missing sides are ones there was nothing to measure. | |
| 162 | + | */ | |
| 163 | + | export interface StripEdges { | |
| 164 | + | left?: number; | |
| 165 | + | right?: number; | |
| 166 | + | top?: number; | |
| 167 | + | bottom?: number; | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | /** | |
| 171 | + | * The felt, out to where each strip's tiles and buttons actually begin. | |
| 172 | + | * | |
| 173 | + | * `.center` is only what is left over once every strip has been given its full | |
| 174 | + | * `--depth`, and a strip is built from its outer edge inwards: the melds sit at | |
| 175 | + | * the inside end of it, and until somebody 吃s or 碰s there is nothing there. So | |
| 176 | + | * between the middle and the first thing you can see of a seat there is a band | |
| 177 | + | * of plain green — most of a hand, for most seats — and a tile that stopped at | |
| 178 | + | * `.center`'s edge stopped in the middle of it, against nothing. That band is | |
| 179 | + | * table, and this is what hands it back. | |
| 180 | + | * | |
| 181 | + | * Only ever outwards. The middle is never smaller than the box the layout left. | |
| 182 | + | */ | |
| 183 | + | export function reaching(centre: Rect, edges: StripEdges): Rect { | |
| 184 | + | const right = centre.x + centre.w; | |
| 185 | + | const bottom = centre.y + centre.h; | |
| 186 | + | const x = Math.min(centre.x, edges.left ?? centre.x); | |
| 187 | + | const y = Math.min(centre.y, edges.top ?? centre.y); | |
| 188 | + | return { | |
| 189 | + | x, | |
| 190 | + | y, | |
| 191 | + | w: Math.max(right, edges.right ?? right) - x, | |
| 192 | + | h: Math.max(bottom, edges.bottom ?? bottom) - y, | |
| 193 | + | }; | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | /** | |
| 197 | + | * Read those edges off the four strips. A child holding nothing — the melds row | |
| 198 | + | * before a claim, the button bar with no buttons up — is space nobody is using, | |
| 199 | + | * so what counts is the first child with something in it. | |
| 200 | + | */ | |
| 201 | + | function stripEdges(table: HTMLElement, base: DOMRect, local: (r: DOMRect) => Rect): StripEdges { | |
| 202 | + | const edges: StripEdges = {}; | |
| 203 | + | for (const slot of table.querySelectorAll<HTMLElement>('.slot[data-seat]')) { | |
| 204 | + | const seat = slot.querySelector<HTMLElement>('.seat'); | |
| 205 | + | if (!seat) continue; | |
| 206 | + | ||
| 207 | + | let used: Rect | null = null; | |
| 208 | + | for (const child of seat.children) { | |
| 209 | + | // The winner's arrow points into the middle from the strip's inside edge | |
| 210 | + | // and is gone again next hand; it is not what a seat takes up. | |
| 211 | + | if (child.classList.contains('win-arrow')) continue; | |
| 212 | + | if (child.childElementCount === 0 && !child.textContent?.trim()) continue; | |
| 213 | + | const r = local(child.getBoundingClientRect()); | |
| 214 | + | if (r.w === 0 || r.h === 0) continue; | |
| 215 | + | used = used | |
| 216 | + | ? { | |
| 217 | + | x: Math.min(used.x, r.x), | |
| 218 | + | y: Math.min(used.y, r.y), | |
| 219 | + | w: Math.max(used.x + used.w, r.x + r.w) - Math.min(used.x, r.x), | |
| 220 | + | h: Math.max(used.y + used.h, r.y + r.h) - Math.min(used.y, r.y), | |
| 221 | + | } | |
| 222 | + | : r; | |
| 223 | + | } | |
| 224 | + | if (!used) continue; | |
| 225 | + | ||
| 226 | + | // Which edge of the table this strip is against. The left and right ones | |
| 227 | + | // are the only two that touch the sides, so they are what is asked first. | |
| 228 | + | const box = local(slot.getBoundingClientRect()); | |
| 229 | + | if (box.x < 1) edges.left = Math.max(edges.left ?? 0, used.x + used.w); | |
| 230 | + | else if (box.x + box.w > base.width - 1) { | |
| 231 | + | edges.right = Math.min(edges.right ?? base.width, used.x); | |
| 232 | + | } else if (box.y < 1) edges.top = Math.max(edges.top ?? 0, used.y + used.h); | |
| 233 | + | else edges.bottom = Math.min(edges.bottom ?? base.height, used.y); | |
| 234 | + | } | |
| 235 | + | return edges; | |
| 236 | + | } | |
| 237 | + | ||
| 157 | 238 | export const centreOf = (r: Rect): Point => ({ x: r.x + r.w / 2, y: r.y + r.h / 2 }); | |
| 158 | 239 | ||
| 159 | 240 | /** | |
| ⋯ 134 unchanged lines | |||
modifiedsrc/table/physics.test.ts+29 −1
| ⋯ 24 unchanged lines | |||
| 25 | 25 | type Rect, | |
| 26 | 26 | type Spawn, | |
| 27 | 27 | } from './physics'; | |
| 28 | - | import { canThrow, segmentHitsRect, type TableGeometry } from './geometry'; | |
| 28 | + | import { canThrow, reaching, segmentHitsRect, type TableGeometry } from './geometry'; | |
| 29 | 29 | ||
| 30 | 30 | const POOL: Rect = { x: 200, y: 200, w: 220, h: 220 }; | |
| 31 | 31 | /** Same aspect as a real tile, roughly pool-tile sized. */ | |
| ⋯ 470 unchanged lines | |||
| 502 | 502 | }); | |
| 503 | 503 | }); | |
| 504 | 504 | ||
| 505 | + | describe('the felt reaching into the strips', () => { | |
| 506 | + | // `.center` is what is left once every strip has taken its full depth; the | |
| 507 | + | // inside end of a strip is empty until somebody claims something. | |
| 508 | + | const CENTRE: Rect = { x: 300, y: 300, w: 400, h: 400 }; | |
| 509 | + | ||
| 510 | + | it('grows out to where a strip that has laid nothing down actually starts', () => { | |
| 511 | + | const felt = reaching(CENTRE, { left: 260, right: 740, top: 250, bottom: 750 }); | |
| 512 | + | expect(felt).toEqual({ x: 260, y: 250, w: 480, h: 500 }); | |
| 513 | + | }); | |
| 514 | + | ||
| 515 | + | it('leaves a side alone once that seat has tiles down against the middle', () => { | |
| 516 | + | // Melds out to the centre's own edge: nothing to give back on that side. | |
| 517 | + | const felt = reaching(CENTRE, { left: 300, right: 740 }); | |
| 518 | + | expect(felt.x).toBe(300); | |
| 519 | + | expect(felt.x + felt.w).toBe(740); | |
| 520 | + | }); | |
| 521 | + | ||
| 522 | + | it('never shrinks the middle, whatever a strip claims to reach', () => { | |
| 523 | + | // A strip whose contents overhang the centre would otherwise invert it. | |
| 524 | + | const felt = reaching(CENTRE, { left: 340, top: 360, right: 660, bottom: 640 }); | |
| 525 | + | expect(felt).toEqual(CENTRE); | |
| 526 | + | }); | |
| 527 | + | ||
| 528 | + | it('is the centre itself when there is nothing to measure', () => { | |
| 529 | + | expect(reaching(CENTRE, {})).toEqual(CENTRE); | |
| 530 | + | }); | |
| 531 | + | }); | |
| 532 | + | ||
| 505 | 533 | describe('segmentHitsRect', () => { | |
| 506 | 534 | const r: Rect = { x: 10, y: 10, w: 20, h: 20 }; | |
| 507 | 535 | ||
| ⋯ 164 unchanged lines | |||