collin/mahjong · bbdef458
Count a tile arriving in somebody's tiles however it got there
Collin Richards · 2026-08-19 21:51 UTC · bbdef458b0281651b752400593a3cd48630b0a57 · parent a8f341f6 · browse files
modifiedREADME.md+6 −4
| ⋯ 71 unchanged lines | |||
| 72 | 72 | there, so a gap off to one side counts, and a stack worn down to a single tile | |
| 73 | 73 | is low enough to go over. A seat whose wall is still up says 牆未開 on its | |
| 74 | 74 | nameplate, so a lob is never a mystery. | |
| 75 | - | - **Throw it too hard** and it comes off the pool and into somebody's tiles — | |
| 76 | - | the middle stops exactly where their hand starts. Their row takes the knock | |
| 77 | - | and rocks back, and with 報牌 on they tell you about it (喂,小心點). Never | |
| 78 | - | your own edge: you cannot be barged by your own discard. | |
| 75 | + | - **Throw it badly** and it ends up in somebody's tiles — the middle stops | |
| 76 | + | exactly where their hand starts, so a tile that comes off the pool arrives in | |
| 77 | + | their lap, and one that falls short lands out by their seat. Either way their | |
| 78 | + | row takes the knock and rocks back, and with 報牌 on they tell you about it | |
| 79 | + | (喂,小心點). How hard it got there only decides how loudly. Never your own | |
| 80 | + | edge: you cannot be barged by your own discard. | |
| 79 | 81 | - **Arranging** — drag any tile in your hand to reorder it; 理牌 sorts it back | |
| 80 | 82 | into suit order. Hands are never auto-sorted after the deal, so an arrangement | |
| 81 | 83 | you set up survives draws, claims and kongs. | |
| ⋯ 334 unchanged lines | |||
modifiedsrc/table/physics.test.ts+73 −7
| ⋯ 183 unchanged lines | |||
| 184 | 184 | // The middle stops where a player's tiles start, so an edge taken at speed | |
| 185 | 185 | // is a discard thrown into somebody's hand — which is a thing they answer. | |
| 186 | 186 | const world = createWorld(POOL); | |
| 187 | - | const b = addBody(world, { | |
| 187 | + | addBody(world, { | |
| 188 | 188 | tile: 0, | |
| 189 | 189 | seat: 2, | |
| 190 | 190 | x: POOL.x + POOL.w - 30, | |
| ⋯ 13 unchanged lines | |||
| 204 | 204 | for (const hit of step(world)) if (hit.edge) edges.push(`${hit.edge}:${hit.seat}`); | |
| 205 | 205 | } | |
| 206 | 206 | expect(edges[0]).toBe('right:2'); | |
| 207 | - | // And a tile that merely rolls up to the edge is not an incident. | |
| 208 | - | b.resting = false; | |
| 209 | - | b.vx = 20; | |
| 210 | - | const gentle = []; | |
| 211 | - | for (let i = 0; i < 60; i++) for (const hit of step(world)) if (hit.edge) gentle.push(hit); | |
| 212 | - | expect(gentle).toHaveLength(0); | |
| 207 | + | ||
| 208 | + | // A tile that merely rolls into the edge counts too — arriving in | |
| 209 | + | // somebody's tiles is arriving in them however gently it happened. | |
| 210 | + | const gentle = createWorld(POOL); | |
| 211 | + | addBody(gentle, { | |
| 212 | + | tile: 0, | |
| 213 | + | seat: 1, | |
| 214 | + | x: POOL.x + POOL.w - 20, | |
| 215 | + | y: POOL.y + POOL.h / 2, | |
| 216 | + | vx: 90, | |
| 217 | + | vy: 0, | |
| 218 | + | z: 0, | |
| 219 | + | vz: 0, | |
| 220 | + | angle: 0, | |
| 221 | + | spin: 0, | |
| 222 | + | w: TILE.w, | |
| 223 | + | h: TILE.h, | |
| 224 | + | }); | |
| 225 | + | const rolled: string[] = []; | |
| 226 | + | for (let i = 0; i < SETTLE_CAP && !settled(gentle); i++) { | |
| 227 | + | for (const hit of step(gentle)) if (hit.edge) rolled.push(`${hit.edge}:${hit.seat}`); | |
| 228 | + | } | |
| 229 | + | expect(rolled[0]).toBe('right:1'); | |
| 230 | + | ||
| 231 | + | // But a tile lying against the edge is not arriving anywhere. It is held | |
| 232 | + | // there every single step, and every one of those would be a complaint. | |
| 233 | + | const held = createWorld(POOL); | |
| 234 | + | const still = addBody(held, { | |
| 235 | + | tile: 0, | |
| 236 | + | seat: 1, | |
| 237 | + | x: POOL.x + POOL.w, | |
| 238 | + | y: POOL.y + POOL.h / 2, | |
| 239 | + | vx: 0, | |
| 240 | + | vy: 0, | |
| 241 | + | z: 0, | |
| 242 | + | vz: 0, | |
| 243 | + | angle: 0, | |
| 244 | + | spin: 0, | |
| 245 | + | w: TILE.w, | |
| 246 | + | h: TILE.h, | |
| 247 | + | atRest: true, | |
| 248 | + | }); | |
| 249 | + | still.resting = false; | |
| 250 | + | const pinned = []; | |
| 251 | + | for (let i = 0; i < 120; i++) for (const hit of step(held)) if (hit.edge) pinned.push(hit); | |
| 252 | + | expect(pinned).toHaveLength(0); | |
| 253 | + | }); | |
| 254 | + | ||
| 255 | + | it('says whose side a tile that landed short came in from', () => { | |
| 256 | + | // Nothing was struck on the way — it simply fell short, out by somebody's | |
| 257 | + | // seat, and came onto the table past them. Still theirs to complain about. | |
| 258 | + | const world = createWorld(POOL); | |
| 259 | + | addBody(world, { | |
| 260 | + | tile: 0, | |
| 261 | + | seat: 2, | |
| 262 | + | x: POOL.x - 120, | |
| 263 | + | y: POOL.y + POOL.h / 2, | |
| 264 | + | vx: 0, | |
| 265 | + | vy: 0, | |
| 266 | + | z: 0, | |
| 267 | + | vz: 0, | |
| 268 | + | angle: 0, | |
| 269 | + | spin: 0, | |
| 270 | + | w: TILE.w, | |
| 271 | + | h: TILE.h, | |
| 272 | + | }); | |
| 273 | + | ||
| 274 | + | const edges: string[] = []; | |
| 275 | + | for (let i = 0; i < SETTLE_CAP && !settled(world); i++) { | |
| 276 | + | for (const hit of step(world)) if (hit.edge) edges.push(`${hit.edge}:${hit.seat}`); | |
| 277 | + | } | |
| 278 | + | expect(edges[0]).toBe('left:2'); | |
| 213 | 279 | }); | |
| 214 | 280 | ||
| 215 | 281 | it('always comes to rest face up, however it tumbles', () => { | |
| ⋯ 347 unchanged lines | |||
modifiedsrc/table/physics.ts+28 −3
| ⋯ 355 unchanged lines | |||
| 356 | 356 | x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h; | |
| 357 | 357 | ||
| 358 | 358 | /** | |
| 359 | + | * Whose side of the table a tile has ended up off, if it is off any of them. | |
| 360 | + | * The furthest it is out, so a tile that overshot a corner belongs to whichever | |
| 361 | + | * of the two it went past by more. | |
| 362 | + | */ | |
| 363 | + | function outsideEdge(r: Rect, b: Body): Edge | undefined { | |
| 364 | + | const out: [Edge, number][] = [ | |
| 365 | + | ['left', r.x - b.x], | |
| 366 | + | ['right', b.x - (r.x + r.w)], | |
| 367 | + | ['top', r.y - b.y], | |
| 368 | + | ['bottom', b.y - (r.y + r.h)], | |
| 369 | + | ]; | |
| 370 | + | let worst: [Edge, number] = out[0]; | |
| 371 | + | for (const one of out) if (one[1] > worst[1]) worst = one; | |
| 372 | + | return worst[1] > 0 ? worst[0] : undefined; | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | /** | |
| 359 | 376 | * Run the world forward. `elapsed` is real seconds since the last call; it is | |
| 360 | 377 | * consumed in fixed steps so the result does not depend on the frame rate. | |
| 361 | 378 | * Returns the impacts worth a sound. | |
| ⋯ 63 unchanged lines | |||
| 425 | 442 | ||
| 426 | 443 | if (inside(world.bounds, b.x, b.y)) b.entered = true; | |
| 427 | 444 | else if (b.age > ENTRY_GRACE) { | |
| 428 | - | // Out of time going the long way round. Put it in, gently. | |
| 445 | + | // Out of time going the long way round. Put it in, gently — but this is a | |
| 446 | + | // tile that ended up off the table on somebody's side, which is worth | |
| 447 | + | // saying so even though nothing was actually struck. | |
| 448 | + | const edge = outsideEdge(world.bounds, b); | |
| 449 | + | if (edge) impacts.push({ x: b.x, y: b.y, strength: 0.3, edge, seat: b.seat }); | |
| 429 | 450 | b.entered = true; | |
| 430 | 451 | b.z = 0; | |
| 431 | 452 | b.vz = 0; | |
| ⋯ 149 unchanged lines | |||
| 581 | 602 | // Only a real knock counts as being disturbed. A tile held against the edge | |
| 582 | 603 | // by its neighbours is clamped every single step, and treating that as a knock | |
| 583 | 604 | // would keep the whole pool awake for ever. | |
| 605 | + | // | |
| 606 | + | // Anything that does arrive under its own steam is reported, however gently: | |
| 607 | + | // the edge is where somebody's tiles are, so a discard rolling into them is | |
| 608 | + | // an event whatever speed it got there at. Which of them are loud enough to | |
| 609 | + | // be worth a sound is the caller's business. | |
| 584 | 610 | if (hit > SLEEP_SPEED) { | |
| 585 | 611 | b.still = 0; | |
| 586 | - | const strength = Math.min(1, hit / 1100); | |
| 587 | - | if (strength > 0.12) impacts.push({ x: b.x, y: b.y, strength, edge, seat: b.seat }); | |
| 612 | + | impacts.push({ x: b.x, y: b.y, strength: Math.min(1, hit / 1100), edge, seat: b.seat }); | |
| 588 | 613 | } | |
| 589 | 614 | } | |
| 590 | 615 | ||
| ⋯ 237 unchanged lines | |||
modifiedsrc/ui/Pool.tsx+9 −9
| ⋯ 69 unchanged lines | |||
| 70 | 70 | const impacts = pool.advance(dt); | |
| 71 | 71 | let loudest = 0; | |
| 72 | 72 | for (const i of impacts) { | |
| 73 | - | loudest = Math.max(loudest, i.strength); | |
| 74 | - | if (i.edge && i.strength > BARGE) shove(table, EDGE_SEAT[i.edge], i.seat, i.strength); | |
| 73 | + | // A contact quiet enough not to be worth hearing still counts as a tile | |
| 74 | + | // arriving in somebody's lap, which is why the two thresholds differ. | |
| 75 | + | if (i.strength > AUDIBLE) loudest = Math.max(loudest, i.strength); | |
| 76 | + | if (i.edge) shove(table, EDGE_SEAT[i.edge], i.seat, i.strength); | |
| 75 | 77 | } | |
| 76 | 78 | if (loudest > 0) knock(loudest); | |
| 77 | 79 | paint(ctx, canvas, pool, live.current.art); | |
| ⋯ 117 unchanged lines | |||
| 195 | 197 | /** How high a tile in somebody's fingers is held above the felt. */ | |
| 196 | 198 | const HELD_Z = 52; | |
| 197 | 199 | ||
| 198 | - | /** | |
| 199 | - | * Hard enough to be a throw at somebody rather than a tile coming to rest | |
| 200 | - | * against the near side. A tile that merely rolls up to the edge is not an | |
| 201 | - | * incident; one still doing a couple of hundred pixels a second is. | |
| 202 | - | */ | |
| 203 | - | const BARGE = 0.3; | |
| 200 | + | /** Quieter than this and the contact is not worth a clack of its own. */ | |
| 201 | + | const AUDIBLE = 0.12; | |
| 204 | 202 | ||
| 205 | 203 | /** | |
| 206 | - | * A tile has come off the pool and into this seat's tiles. | |
| 204 | + | * A tile has ended up in this seat's tiles — knocked into them, or simply come | |
| 205 | + | * to a stop off the table on their side. Either way it is theirs to complain | |
| 206 | + | * about, and how hard it arrived only decides how loudly. | |
| 207 | 207 | * | |
| 208 | 208 | * Their row takes the knock — straight on the element rather than through | |
| 209 | 209 | * React, because this happens inside the physics loop and re-rendering four | |
| ⋯ 315 unchanged lines | |||