collin/mahjong · 9da3ffb8
Nothing stops a tile but another tile
Collin Richards · 2026-08-21 18:25 UTC · 9da3ffb8bf522a0292981624d2b82717a3dcfaf3 · parent cee1dfb3 · browse files
modifiedREADME.md+17 −6
| ⋯ 483 unchanged lines | |||
| 484 | 484 | had rather than a freshly scattered one — positions are derived from the hand | |
| 485 | 485 | number and each tile's place in its thrower's discards, never saved. | |
| 486 | 486 | ||
| 487 | + | What stops a tile is other tiles. There is no fence: the standing stacks of the | |
| 488 | + | wall, the sets people have laid down and the sixteen each of them is holding are | |
| 489 | + | all measured as colliders, so the pile is held in by the ring of tiles round it | |
| 490 | + | exactly as it is on a real table, and spills through whatever gaps the wall has | |
| 491 | + | been eaten into. The table's own edge is a backstop at the screen edge, there | |
| 492 | + | only so a tile that got past all of that isn't lost. | |
| 493 | + | ||
| 487 | 494 | It is drawn on a canvas that spans the **whole table**, not just the centre. | |
| 488 | 495 | That is deliberate: `.slot` and `.seat` both clip their own contents, which is | |
| 489 | 496 | why a tile can't be animated out of a strip as an element. On the canvas there is | |
| ⋯ 16 unchanged lines | |||
| 506 | 513 | ||
| 507 | 514 | | drawn | is | | |
| 508 | 515 | | --- | --- | | |
| 509 | - | | solid rectangle round the middle | the felt — what actually keeps a tile on the table, out to where each seat's tiles begin | | |
| 510 | - | | solid box on a wall stack | a standing stack of two. Half stacks aren't drawn because they aren't colliders: a tile goes over them | | |
| 516 | + | | solid rectangle at the screen edge | the table itself. Nothing meets this in play — it is only there so a tile that got past everybody's tiles is still on the table afterwards | | |
| 517 | + | | solid box on every tile | a collider. Every tile on the table is one: the stacks still standing, the sets people have laid down, and the sixteen each of them is holding. Half stacks aren't drawn because they aren't colliders — a tile goes over them | | |
| 511 | 518 | | dashed rectangle | the wall square's opening — where a throw is *aimed*. It stops nothing, which is why the pile spills out of it | | |
| 512 | - | | dotted rectangle, inset | where the moving tile's own centre may be: the felt less that tile's half width. **This is the line a bounce happens at**, half a tile inside the solid one, which is what makes a bounce look early when it isn't | | |
| 513 | - | | box hugging each tile | the rotated rectangle it really collides with | | |
| 514 | - | | dotted box round the moving tile | the upright box the felt's straight edges use instead. It comes apart from the rotated one as the tile turns | | |
| 519 | + | | dotted rectangle, inset | where the moving tile's own centre may be against the table's edge, which is its half width in from it | | |
| 520 | + | | box hugging each pool tile | the rotated rectangle it really collides with | | |
| 521 | + | | dotted box round the moving tile | the upright box the straight edges use instead. It comes apart from the rotated one as the tile turns | | |
| 515 | 522 | ||
| 516 | 523 | `colliders(false)` turns it off again. It repaints on toggle, so a settled table | |
| 517 | - | doesn't have to be poked first. | |
| 524 | + | doesn't have to be poked first. In a dev build `pool` is the live pool: the | |
| 525 | + | overlay draws `pool.world.walls`, the physics' own copy rather than a fresh | |
| 526 | + | measurement, so if what's drawn ever disagrees with the DOM it is the world that | |
| 527 | + | went stale — which `pool.measuredAt` will show, since the stamp carries every | |
| 528 | + | hand's shape. | |
| 518 | 529 | ||
| 519 | 530 | ## What is left of the wall | |
| 520 | 531 | ||
| ⋯ 26 unchanged lines | |||
modifiedsrc/styles.css+6 −0
| ⋯ 1186 unchanged lines | |||
| 1187 | 1187 | /* ---------- shared table controls ---------- */ | |
| 1188 | 1188 | /* Bottom-right of the centre square, opposite the log. Unrotated on purpose: | |
| 1189 | 1189 | it belongs to the table, not to a seat. */ | |
| 1190 | + | /* Out of the way of a throw. The undo button is a wide target lying on the | |
| 1191 | + | playing surface at the bottom-right of the middle, which is exactly where a | |
| 1192 | + | tile leaves seat 0's hand — see the `carrying` class in ui/Pool.tsx. */ | |
| 1193 | + | .table.carrying .table-controls { | |
| 1194 | + | pointer-events: none; | |
| 1195 | + | } | |
| 1190 | 1196 | .table-controls { | |
| 1191 | 1197 | position: absolute; | |
| 1192 | 1198 | /* Over the pile, same as .result. */ | |
| ⋯ 1104 unchanged lines | |||
modifiedsrc/table/geometry.ts+39 −93
| 1 | 1 | import { isBarrier, wallStacks, type WallProgress } from '../game/wall'; | |
| 2 | 2 | import type { SeatId } from '../game/types'; | |
| 3 | - | import type { Rect } from './physics'; | |
| 3 | + | import type { Barrier, Rect } from './physics'; | |
| 4 | 4 | ||
| 5 | 5 | /** | |
| 6 | 6 | * Where everything on the table actually is, in pixels. | |
| ⋯ 19 unchanged lines | |||
| 26 | 26 | * is laid out. Not a fence — see `felt`. */ | |
| 27 | 27 | pool: Rect; | |
| 28 | 28 | /** | |
| 29 | - | * The whole middle of the table, between the four strips. This is what | |
| 30 | - | * actually stops a tile, because the square is not a box: it is eighteen | |
| 31 | - | * stacks a side, and once they have been drawn there is nothing there. A tile | |
| 32 | - | * is held in by the stacks still standing, and by the edge of the felt beyond | |
| 33 | - | * them — so as the wall is eaten away the pool spills out through the gaps, | |
| 34 | - | * exactly as it does on a table. | |
| 29 | + | * The table itself, and nothing nearer. Not a fence anybody meets in play: it | |
| 30 | + | * is the backstop that keeps a tile from being lost off the screen entirely, | |
| 31 | + | * and it sits at the edge of the glass where nothing can reach it. | |
| 32 | + | * | |
| 33 | + | * What actually stops a tile is `barriers` — the tiles. The middle is not a | |
| 34 | + | * box, it is a square of eighteen stacks a side with people's hands beyond, | |
| 35 | + | * and as the wall is eaten away the pool spills out through the gaps exactly | |
| 36 | + | * as it does on a real table, until it fetches up against somebody's tiles. | |
| 35 | 37 | */ | |
| 36 | 38 | felt: Rect; | |
| 37 | - | /** Stacks of two still standing. Half stacks are low enough to throw over. */ | |
| 39 | + | /** | |
| 40 | + | * Stacks of two still standing. Half stacks are low enough to throw over. | |
| 41 | + | * The throw gate's business — what the physics collides with is `barriers`, | |
| 42 | + | * which includes these. | |
| 43 | + | */ | |
| 38 | 44 | walls: Rect[]; | |
| 45 | + | /** | |
| 46 | + | * Everything solid, which is to say every tile on the table: the standing | |
| 47 | + | * stacks, the sets people have laid down, and the sixteen each of them is | |
| 48 | + | * holding. Measured off the elements themselves, like the wall — a tile you | |
| 49 | + | * can see is a tile a thrown one hits. | |
| 50 | + | */ | |
| 51 | + | barriers: Barrier[]; | |
| 39 | 52 | /** A tile in the pool is the same tile that was in the wall. */ | |
| 40 | 53 | tile: { w: number; h: number }; | |
| 41 | 54 | /** The middle of each seat's hand, if that seat's hand is on screen. */ | |
| ⋯ 27 unchanged lines | |||
| 69 | 82 | ||
| 70 | 83 | const stacks = wallStacks(state); | |
| 71 | 84 | const walls: Rect[] = []; | |
| 85 | + | const barriers: Barrier[] = []; | |
| 72 | 86 | // A stack element is --ws by --wd (or the other way round on the left and | |
| 73 | 87 | // right sides), so its narrower side is the tile's width whichever wall it is | |
| 74 | 88 | // in — the fallback for sizing a pool tile before any hand has been dealt. | |
| ⋯ 5 unchanged lines | |||
| 80 | 94 | const r = local(el.getBoundingClientRect()); | |
| 81 | 95 | short = Math.max(short, Math.min(r.w, r.h)); | |
| 82 | 96 | const stack = stacks[i]; | |
| 83 | - | if (stack && isBarrier(stack)) walls.push(r); | |
| 97 | + | if (stack && isBarrier(stack)) { | |
| 98 | + | walls.push(r); | |
| 99 | + | barriers.push({ rect: r }); | |
| 100 | + | } | |
| 84 | 101 | } | |
| 85 | 102 | if (short === 0) return null; | |
| 86 | 103 | ||
| ⋯ 28 unchanged lines | |||
| 115 | 132 | throwFrom[seat] = er | |
| 116 | 133 | ? { x: er.left + er.width / 2 - base.left, y: er.top + er.height / 2 - base.top } | |
| 117 | 134 | : launch[seat]; | |
| 135 | + | ||
| 136 | + | // Every tile this seat has on the table, one collider each. A row of them | |
| 137 | + | // is adjacent, so a hand comes out as a solid wall without being described | |
| 138 | + | // as one anywhere — the same trick the wall square is measured with. | |
| 139 | + | const slot = table.querySelector<HTMLElement>(`[data-seat="${seat}"]`); | |
| 140 | + | for (const el of slot?.querySelectorAll<HTMLElement>('.hand .tile, .melds .tile') ?? []) { | |
| 141 | + | const t = local(el.getBoundingClientRect()); | |
| 142 | + | if (t.w > 0 && t.h > 0) barriers.push({ rect: t, seat }); | |
| 143 | + | } | |
| 118 | 144 | } | |
| 119 | 145 | ||
| 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. | |
| 122 | - | const centre = table.querySelector<HTMLElement>('.center'); | |
| 123 | - | const box = centre ? local(centre.getBoundingClientRect()) : ringRect; | |
| 124 | - | const felt = reaching(box, stripEdges(table, base, local)); | |
| 146 | + | // The glass, not the green. Nothing meets this in play — it is only there so | |
| 147 | + | // a tile that got past everybody's tiles is still on the table afterwards. | |
| 148 | + | const felt: Rect = { x: 0, y: 0, w: base.width, h: base.height }; | |
| 125 | 149 | ||
| 126 | 150 | return { | |
| 127 | 151 | size: { w: base.width, h: base.height }, | |
| 128 | 152 | pool, | |
| 129 | 153 | felt, | |
| 130 | 154 | walls, | |
| 155 | + | barriers, | |
| 131 | 156 | tile: poolTile(table, short), | |
| 132 | 157 | launch, | |
| 133 | 158 | throwFrom, | |
| ⋯ 20 unchanged lines | |||
| 154 | 179 | const r = el?.getBoundingClientRect(); | |
| 155 | 180 | const w = r && r.width > 0 ? Math.min(r.width, r.height) : fallback; | |
| 156 | 181 | return { w, h: w * 1.375 }; | |
| 157 | - | } | |
| 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 | 182 | } | |
| 237 | 183 | ||
| 238 | 184 | export const centreOf = (r: Rect): Point => ({ x: r.x + r.w / 2, y: r.y + r.h / 2 }); | |
| ⋯ 136 unchanged lines | |||
modifiedsrc/table/physics.test.ts+3 −30
| ⋯ 24 unchanged lines | |||
| 25 | 25 | type Rect, | |
| 26 | 26 | type Spawn, | |
| 27 | 27 | } from './physics'; | |
| 28 | - | import { canThrow, reaching, segmentHitsRect, type TableGeometry } from './geometry'; | |
| 28 | + | import { canThrow, 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. */ | |
| ⋯ 362 unchanged lines | |||
| 394 | 394 | it('gets a tile into the pool even when the wall is in the way', () => { | |
| 395 | 395 | // A standing wall right across the throw, with the tile flicked flat at it: | |
| 396 | 396 | // it cannot get in on its own, and must not be left outside. | |
| 397 | - | const world = createWorld(POOL, [{ x: 150, y: 440, w: 320, h: 36 }]); | |
| 397 | + | const world = createWorld(POOL, [{ rect: { x: 150, y: 440, w: 320, h: 36 } }]); | |
| 398 | 398 | addBody(world, { | |
| 399 | 399 | tile: 0, | |
| 400 | 400 | seat: 0, | |
| ⋯ 56 unchanged lines | |||
| 457 | 457 | size: { w: 620, h: 620 }, | |
| 458 | 458 | pool: POOL, | |
| 459 | 459 | walls, | |
| 460 | + | barriers: walls.map((rect) => ({ rect })), | |
| 460 | 461 | tile: TILE, | |
| 461 | 462 | felt: { x: 120, y: 120, w: 380, h: 380 }, | |
| 462 | 463 | launch: { 0: { x: 310, y: 560 } }, | |
| ⋯ 36 unchanged lines | |||
| 499 | 500 | ||
| 500 | 501 | it('is open when the square has been eaten away entirely', () => { | |
| 501 | 502 | expect(canThrow(geometry([]), 0)).toBe(true); | |
| 502 | - | }); | |
| 503 | - | }); | |
| 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 | 503 | }); | |
| 531 | 504 | }); | |
| 532 | 505 | ||
| ⋯ 167 unchanged lines | |||
modifiedsrc/table/physics.ts+31 −8
| ⋯ 205 unchanged lines | |||
| 206 | 206 | strength: number; | |
| 207 | 207 | /** Set when it was the edge of the table it hit, not a wall or another tile. */ | |
| 208 | 208 | edge?: Edge; | |
| 209 | - | /** Whose discard it was, for an edge — you cannot be barged by your own tile. */ | |
| 209 | + | /** Whose tiles it ran into, where what it hit belonged to somebody. */ | |
| 210 | + | into?: SeatId; | |
| 211 | + | /** Whose discard it was — you cannot be barged by your own tile. */ | |
| 212 | + | seat?: SeatId; | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | /** | |
| 216 | + | * Something solid on the table. Everything that stops a tile is another tile: | |
| 217 | + | * the stacks still standing in the wall, the sets people have laid down, and | |
| 218 | + | * the sixteen everyone is holding. `seat` is whose it is, where it is somebody's | |
| 219 | + | * — a tile thrown into a hand is a thing that hand's owner answers. | |
| 220 | + | */ | |
| 221 | + | export interface Barrier { | |
| 222 | + | rect: Rect; | |
| 210 | 223 | seat?: SeatId; | |
| 211 | 224 | } | |
| 212 | 225 | ||
| ⋯ 1 unchanged line | |||
| 214 | 227 | /** Where tiles come to rest — inside the wall square. */ | |
| 215 | 228 | bounds: Rect; | |
| 216 | 229 | /** Stacks of two still standing. Half and spent stacks are not obstacles. */ | |
| 217 | - | walls: Rect[]; | |
| 230 | + | walls: Barrier[]; | |
| 218 | 231 | bodies: Body[]; | |
| 219 | 232 | /** Left-over time from the last frame, so steps stay a fixed size. */ | |
| 220 | 233 | carry: number; | |
| ⋯ 32 unchanged lines | |||
| 253 | 266 | atRest?: boolean; | |
| 254 | 267 | } | |
| 255 | 268 | ||
| 256 | - | export function createWorld(bounds: Rect, walls: Rect[] = []): World { | |
| 269 | + | export function createWorld(bounds: Rect, walls: Barrier[] = []): World { | |
| 257 | 270 | return { bounds, walls, bodies: [], carry: 0, contacts: 0, relaxed: 0 }; | |
| 258 | 271 | } | |
| 259 | 272 | ||
| ⋯ 296 unchanged lines | |||
| 556 | 569 | * leave any of them stranded outside it, and the ones it moves have to wake up | |
| 557 | 570 | * so they can settle again. | |
| 558 | 571 | */ | |
| 559 | - | export function reshape(world: World, bounds: Rect, walls: Rect[]) { | |
| 572 | + | export function reshape(world: World, bounds: Rect, walls: Barrier[]) { | |
| 560 | 573 | world.bounds = bounds; | |
| 561 | 574 | world.walls = walls; | |
| 562 | 575 | for (const b of world.bodies) { | |
| ⋯ 32 unchanged lines | |||
| 595 | 608 | b.vy += (dy / d) * ENTRY_PULL * dt; | |
| 596 | 609 | } | |
| 597 | 610 | ||
| 598 | - | /** Bounce off a standing stack, corner to corner if that is how it hits. */ | |
| 599 | - | function hitWall(b: Body, wall: Rect, impacts: Impact[]) { | |
| 600 | - | const hit = overlapOf(boxOf(wall), b); | |
| 611 | + | /** Bounce off whatever is standing there, corner to corner if that is how it | |
| 612 | + | * hits — a stack of the wall, a laid-down set, somebody's hand. */ | |
| 613 | + | function hitWall(b: Body, wall: Barrier, impacts: Impact[]) { | |
| 614 | + | const hit = overlapOf(boxOf(wall.rect), b); | |
| 601 | 615 | if (!hit || hit.depth <= CONTACT_SLOP) return; | |
| 602 | 616 | ||
| 603 | 617 | const speed = Math.hypot(b.vx, b.vy); | |
| ⋯ 9 unchanged lines | |||
| 613 | 627 | b.resting = false; | |
| 614 | 628 | b.still = 0; | |
| 615 | 629 | const strength = Math.min(1, speed / 1100); | |
| 616 | - | if (strength > 0.12) impacts.push({ x: b.x, y: b.y, strength }); | |
| 630 | + | if (wall.seat !== undefined) { | |
| 631 | + | // Into somebody's tiles. Reported however gently it arrived — their row is | |
| 632 | + | // theirs, and a discard rolling into it is an event whatever speed it got | |
| 633 | + | // there at. Which of them are loud enough to hear is the caller's business. | |
| 634 | + | if (speed > SLEEP_SPEED) { | |
| 635 | + | impacts.push({ x: b.x, y: b.y, strength, into: wall.seat, seat: b.seat }); | |
| 636 | + | } | |
| 637 | + | } else if (strength > 0.12) { | |
| 638 | + | impacts.push({ x: b.x, y: b.y, strength }); | |
| 639 | + | } | |
| 617 | 640 | } | |
| 618 | 641 | ||
| 619 | 642 | /** The pool's edge. Once a tile is in, it stays in — this is the invariant the | |
| ⋯ 300 unchanged lines | |||
modifiedsrc/table/pool.ts+9 −6
| ⋯ 474 unchanged lines | |||
| 475 | 475 | private remeasure(state: GameState) { | |
| 476 | 476 | const table = this.table; | |
| 477 | 477 | if (!table) return; | |
| 478 | - | const stamp = `${state.drawnFront}/${state.drawnBack}/${Math.round( | |
| 478 | + | // Every tile on the table is a collider now, so the shape of each hand and | |
| 479 | + | // each set laid down is part of what has to be re-read — not just the wall. | |
| 480 | + | const hands = state.players.map((p) => `${p.hand.length}.${p.melds.length}.${p.flowers.length}`); | |
| 481 | + | const stamp = `${state.drawnFront}/${state.drawnBack}/${hands.join(',')}/${Math.round( | |
| 479 | 482 | table.clientWidth, | |
| 480 | 483 | )}x${Math.round(table.clientHeight)}`; | |
| 481 | 484 | if (stamp === this.measuredAt) return; | |
| ⋯ 7 unchanged lines | |||
| 489 | 492 | this.setOpenness('----'); | |
| 490 | 493 | return; | |
| 491 | 494 | } | |
| 492 | - | // Bounded by the felt, not by the square: what keeps a tile in the middle is | |
| 493 | - | // the stacks still standing, and once they are gone the pool is free to | |
| 494 | - | // spread out through the gap the way a real one does. | |
| 495 | - | if (!this.world) this.world = createWorld(geo.felt, geo.walls); | |
| 496 | - | else reshape(this.world, geo.felt, geo.walls); | |
| 495 | + | // What keeps a tile in is the tiles: the stacks still standing, the sets | |
| 496 | + | // laid down, and the hands beyond them. `felt` is the table's own edge and | |
| 497 | + | // is only there so a tile that got past all of that is still on the table. | |
| 498 | + | if (!this.world) this.world = createWorld(geo.felt, geo.barriers); | |
| 499 | + | else reshape(this.world, geo.felt, geo.barriers); | |
| 497 | 500 | // Only here, never per frame: a hundred-odd ray casts is nothing once a | |
| 498 | 501 | // draw, and a great deal every sixtieth of a second. | |
| 499 | 502 | this.setOpenness(SEATS.map((s) => (canThrow(geo, s) ? 'y' : '-')).join('')); | |
| ⋯ 179 unchanged lines | |||
modifiedsrc/ui/Pool.tsx+12 −4
| ⋯ 8 unchanged lines | |||
| 9 | 9 | import { aabbOf, centreLimits, faceOn, zScale, type Rect } from '../table/physics'; | |
| 10 | 10 | import type { TablePool } from '../table/pool'; | |
| 11 | 11 | import type { SeatId } from '../game/types'; | |
| 12 | - | import { onCollidersChange, showingColliders } from './colliders'; | |
| 12 | + | import { exposePool, onCollidersChange, showingColliders } from './colliders'; | |
| 13 | 13 | import { EDGE_SEAT } from './rotation'; | |
| 14 | 14 | ||
| 15 | 15 | /** | |
| ⋯ 39 unchanged lines | |||
| 55 | 55 | if (!canvas || !table || !ctx) return; | |
| 56 | 56 | ||
| 57 | 57 | pool.attach(table); | |
| 58 | + | exposePool(pool); | |
| 58 | 59 | ||
| 59 | 60 | let frame = 0; | |
| 60 | 61 | let previous = 0; | |
| ⋯ 19 unchanged lines | |||
| 80 | 81 | ||
| 81 | 82 | pool.sync(live.current.state); | |
| 82 | 83 | placeReach(reachRef.current, pool); | |
| 84 | + | // A tile in the fingers goes over the table's own buttons — seat 0 throws | |
| 85 | + | // from the *right-hand end* of its row, which is directly under the undo | |
| 86 | + | // button — so while one is being carried they stop taking the pointer. | |
| 87 | + | table.classList.toggle('carrying', !!pool.held); | |
| 83 | 88 | // The loudest contact this frame is the one you would actually hear; the | |
| 84 | 89 | // rest of a scatter is the same event. | |
| 85 | 90 | const impacts = pool.advance(dt); | |
| ⋯ 2 unchanged lines | |||
| 88 | 93 | // A contact quiet enough not to be worth hearing still counts as a tile | |
| 89 | 94 | // arriving in somebody's lap, which is why the two thresholds differ. | |
| 90 | 95 | if (i.strength > AUDIBLE) loudest = Math.max(loudest, i.strength); | |
| 91 | - | if (i.edge) { | |
| 96 | + | // Straight into somebody's tiles — the wall square is nobody's, so this | |
| 97 | + | // is a hand or a set that has been laid down. | |
| 98 | + | if (i.into !== undefined) shove(pool, table, i.into, i.seat, i.strength); | |
| 99 | + | else if (i.edge) { | |
| 92 | 100 | const at = ((EDGE_SEAT[i.edge] + live.current.viewSeat) % 4) as SeatId; | |
| 93 | 101 | shove(pool, table, at, i.seat, i.strength); | |
| 94 | 102 | } | |
| ⋯ 284 unchanged lines | |||
| 379 | 387 | ctx.save(); | |
| 380 | 388 | ctx.lineWidth = 1; | |
| 381 | 389 | ||
| 382 | - | // The fence, and the stacks still standing. | |
| 390 | + | // The table's own edge, and every tile standing on it. | |
| 383 | 391 | ctx.strokeStyle = '#ff2d20'; | |
| 384 | 392 | line(world.bounds); | |
| 385 | - | for (const w of world.walls) line(w); | |
| 393 | + | for (const w of world.walls) line(w.rect); | |
| 386 | 394 | ||
| 387 | 395 | // Where a throw is aimed. Not a fence — see TableGeometry.pool. | |
| 388 | 396 | const aim = pool.poolRect; | |
| ⋯ 246 unchanged lines | |||
modifiedsrc/ui/colliders.ts+12 −0
| ⋯ 39 unchanged lines | |||
| 40 | 40 | declare global { | |
| 41 | 41 | interface Window { | |
| 42 | 42 | colliders?: (next?: boolean) => boolean; | |
| 43 | + | /** The live pool, for poking at from the console. Dev builds only. */ | |
| 44 | + | pool?: unknown; | |
| 43 | 45 | } | |
| 44 | 46 | } | |
| 45 | 47 | ||
| 46 | 48 | if (typeof window !== 'undefined') window.colliders = toggleColliders; | |
| 49 | + | ||
| 50 | + | /** | |
| 51 | + | * Hand the console the pool itself, so `pool.world.walls` and `pool.geo` can be | |
| 52 | + | * read against what is drawn. The overlay draws the world's own copy rather than | |
| 53 | + | * a fresh measurement on purpose — if the two ever disagree it is the world that | |
| 54 | + | * is stale, and that is a thing you want to be able to catch. | |
| 55 | + | */ | |
| 56 | + | export function exposePool(pool: unknown) { | |
| 57 | + | if (import.meta.env.DEV && typeof window !== 'undefined') window.pool = pool; | |
| 58 | + | } | |