collin/mahjong · cee1dfb3
Draw the colliders, since measuring them is what makes them hard to see
Collin Richards · 2026-08-21 18:10 UTC · cee1dfb3ca78a11d452aab69eeedc8810986f517 · parent c3d1289b · browse files
modifiedREADME.md+19 −0
| ⋯ 496 unchanged lines | |||
| 497 | 497 | mapping is what makes the wall a physical object — the thing a thrown tile | |
| 498 | 498 | bounces off, and the thing the throw gate casts its ray at. | |
| 499 | 499 | ||
| 500 | + | ### Seeing the colliders | |
| 501 | + | ||
| 502 | + | Because every boundary is measured off the DOM rather than written down, the | |
| 503 | + | only honest way to check a bounce is to draw the boxes in the coordinate space | |
| 504 | + | the tiles are drawn in. Add `?colliders` to the address, or call `colliders()` | |
| 505 | + | from the console while a hand is running: | |
| 506 | + | ||
| 507 | + | | drawn | is | | |
| 508 | + | | --- | --- | | |
| 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 | | |
| 511 | + | | 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 | | |
| 515 | + | ||
| 516 | + | `colliders(false)` turns it off again. It repaints on toggle, so a settled table | |
| 517 | + | doesn't have to be poked first. | |
| 518 | + | ||
| 500 | 519 | ## What is left of the wall | |
| 501 | 520 | ||
| 502 | 521 | Four walls of eighteen full-size stacks want about 560px a side. No ordinary | |
| ⋯ 25 unchanged lines | |||
modifiedsrc/table/physics.ts+21 −0
| ⋯ 824 unchanged lines | |||
| 825 | 825 | * The renderer squashes the tile by this along its tumbling axis. */ | |
| 826 | 826 | export const faceOn = (b: Body) => Math.cos(b.flip); | |
| 827 | 827 | ||
| 828 | + | /** | |
| 829 | + | * A tile's own bounding box, upright. Not what it collides with — that is the | |
| 830 | + | * rotated rectangle — but what the felt's straight edges are worked out from, | |
| 831 | + | * so the overlay draws both and the difference is visible. | |
| 832 | + | */ | |
| 833 | + | export function aabbOf(b: Body): Rect { | |
| 834 | + | const half = aabbHalf(b); | |
| 835 | + | return { x: b.x - half.x, y: b.y - half.y, w: half.x * 2, h: half.y * 2 }; | |
| 836 | + | } | |
| 837 | + | ||
| 838 | + | /** | |
| 839 | + | * Where this tile's *centre* is allowed to be, which is the edge less its own | |
| 840 | + | * half. This is the line a bounce actually happens at, and it sits half a tile | |
| 841 | + | * inside the one you can see — which is why a tile looks like it turned around | |
| 842 | + | * early. For the overlay; the real work is `limitsOf`. | |
| 843 | + | */ | |
| 844 | + | export function centreLimits(r: Rect, b: Body): Rect { | |
| 845 | + | const l = limitsOf(r, b); | |
| 846 | + | return { x: l.minX, y: l.minY, w: l.maxX - l.minX, h: l.maxY - l.minY }; | |
| 847 | + | } | |
| 848 | + | ||
| 828 | 849 | /** How far outside the pool a tile is sitting. The tests' invariant. */ | |
| 829 | 850 | export function outside(b: Body, pool: Rect): number { | |
| 830 | 851 | const half = aabbHalf(b); | |
| ⋯ 68 unchanged lines | |||
modifiedsrc/ui/Pool.tsx+81 −1
| ⋯ 5 unchanged lines | |||
| 6 | 6 | import type { TableCtl } from '../game/ctl'; | |
| 7 | 7 | import { barge, knock } from '../game/sound'; | |
| 8 | 8 | import { SPRITE_COL, SPRITE_COLS } from '../game/tiles'; | |
| 9 | - | import { faceOn, zScale } from '../table/physics'; | |
| 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 | 13 | import { EDGE_SEAT } from './rotation'; | |
| 13 | 14 | ||
| 14 | 15 | /** | |
| ⋯ 93 unchanged lines | |||
| 108 | 109 | ||
| 109 | 110 | const observer = new ResizeObserver(resize); | |
| 110 | 111 | observer.observe(table); | |
| 112 | + | const stopColliders = onCollidersChange(run); | |
| 111 | 113 | resize(); | |
| 112 | 114 | ||
| 113 | 115 | return () => { | |
| 116 | + | stopColliders(); | |
| 114 | 117 | observer.disconnect(); | |
| 115 | 118 | if (frame) cancelAnimationFrame(frame); | |
| 116 | 119 | wake.current = () => {}; | |
| ⋯ 228 unchanged lines | |||
| 345 | 348 | lit: true, | |
| 346 | 349 | }); | |
| 347 | 350 | } | |
| 351 | + | ||
| 352 | + | // Over the top of all of it, or there would be no point to it. | |
| 353 | + | if (showingColliders()) colliders(ctx, pool); | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | /** | |
| 357 | + | * Every boundary the physics uses, drawn where it actually is. | |
| 358 | + | * | |
| 359 | + | * Solid the felt — the fence that keeps a tile on the table, measured | |
| 360 | + | * out to where each seat's tiles begin (table/geometry.ts). | |
| 361 | + | * Solid each standing stack of two. Half stacks are not drawn because | |
| 362 | + | * they are not colliders: a tile goes over them. | |
| 363 | + | * Dashed the wall square's opening. Only where a throw is *aimed*; it | |
| 364 | + | * stops nothing, which is why the pile spills out of it. | |
| 365 | + | * Dotted, in where the moving tile's own centre is allowed to be — the felt | |
| 366 | + | * less that tile's half width. This is the line a bounce happens | |
| 367 | + | * at, and it is half a tile inside the solid one, which is what | |
| 368 | + | * makes a bounce look early when it is not. | |
| 369 | + | * Per tile the rotated rectangle it really collides with, and, fainter, the | |
| 370 | + | * upright box the felt's straight edges use instead. The two come | |
| 371 | + | * apart as a tile turns, and that difference is worth seeing. | |
| 372 | + | */ | |
| 373 | + | function colliders(ctx: CanvasRenderingContext2D, pool: TablePool) { | |
| 374 | + | const world = pool.world; | |
| 375 | + | if (!world) return; | |
| 376 | + | ||
| 377 | + | const line = (r: Rect) => ctx.strokeRect(r.x + 0.5, r.y + 0.5, r.w - 1, r.h - 1); | |
| 378 | + | ||
| 379 | + | ctx.save(); | |
| 380 | + | ctx.lineWidth = 1; | |
| 381 | + | ||
| 382 | + | // The fence, and the stacks still standing. | |
| 383 | + | ctx.strokeStyle = '#ff2d20'; | |
| 384 | + | line(world.bounds); | |
| 385 | + | for (const w of world.walls) line(w); | |
| 386 | + | ||
| 387 | + | // Where a throw is aimed. Not a fence — see TableGeometry.pool. | |
| 388 | + | const aim = pool.poolRect; | |
| 389 | + | if (aim) { | |
| 390 | + | ctx.setLineDash([5, 5]); | |
| 391 | + | ctx.strokeStyle = 'rgba(255, 45, 32, 0.45)'; | |
| 392 | + | line(aim); | |
| 393 | + | ctx.setLineDash([]); | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | // Whatever is still moving: what it collides with, and what it is held by. | |
| 397 | + | const live = world.bodies.find((b) => !b.resting) ?? pool.newest; | |
| 398 | + | ||
| 399 | + | for (const b of world.bodies) { | |
| 400 | + | ctx.strokeStyle = b === live ? 'rgba(255, 45, 32, 0.9)' : 'rgba(255, 45, 32, 0.35)'; | |
| 401 | + | ctx.save(); | |
| 402 | + | ctx.translate(b.x, b.y); | |
| 403 | + | ctx.rotate(b.angle); | |
| 404 | + | ctx.strokeRect(-b.w / 2, -b.h / 2, b.w, b.h); | |
| 405 | + | ctx.restore(); | |
| 406 | + | ||
| 407 | + | // The upright box the felt's edges are worked out from. Only for the one in | |
| 408 | + | // flight: sixty of them at once is a red mess, not a picture. | |
| 409 | + | if (b === live && b.angle % Math.PI !== 0) { | |
| 410 | + | ctx.setLineDash([2, 3]); | |
| 411 | + | ctx.strokeStyle = 'rgba(255, 45, 32, 0.5)'; | |
| 412 | + | line(aabbOf(b)); | |
| 413 | + | ctx.setLineDash([]); | |
| 414 | + | } | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | if (live) { | |
| 418 | + | // The box that tile's centre is held inside, and where the centre is now. | |
| 419 | + | ctx.setLineDash([2, 3]); | |
| 420 | + | ctx.strokeStyle = 'rgba(255, 45, 32, 0.75)'; | |
| 421 | + | line(centreLimits(world.bounds, live)); | |
| 422 | + | ctx.setLineDash([]); | |
| 423 | + | ctx.fillStyle = '#ff2d20'; | |
| 424 | + | ctx.fillRect(live.x - 1.5, live.y - 1.5, 3, 3); | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | ctx.restore(); | |
| 348 | 428 | } | |
| 349 | 429 | ||
| 350 | 430 | /** | |
| ⋯ 204 unchanged lines | |||
addedsrc/ui/colliders.ts+46 −0
| 1 | + | /** | |
| 2 | + | * The collider overlay: every boundary the physics actually uses, drawn in thin | |
| 3 | + | * red over the table. | |
| 4 | + | * | |
| 5 | + | * Everything the tiles bounce off is *measured* off the DOM rather than written | |
| 6 | + | * down anywhere — the felt is where the four strips leave off, the wall is | |
| 7 | + | * seventy-odd real elements — so the only honest way to check a bounce is to | |
| 8 | + | * draw the boxes in the same coordinate space the tiles are drawn in. A bounce | |
| 9 | + | * that looks wrong is either a box in the wrong place or a box you had not | |
| 10 | + | * thought about, and this tells you which. | |
| 11 | + | * | |
| 12 | + | * On with `?colliders` in the address bar, or `colliders()` from the console | |
| 13 | + | * while a hand is running — which is the point of it, since the thing you want | |
| 14 | + | * to look at is usually already skidding across the table. | |
| 15 | + | */ | |
| 16 | + | ||
| 17 | + | const FLAG = 'colliders'; | |
| 18 | + | ||
| 19 | + | let on = typeof location !== 'undefined' && new URLSearchParams(location.search).has(FLAG); | |
| 20 | + | ||
| 21 | + | const listeners = new Set<() => void>(); | |
| 22 | + | ||
| 23 | + | export const showingColliders = () => on; | |
| 24 | + | ||
| 25 | + | /** Toggle, or set. Returns what it ended up as, so the console says it back. */ | |
| 26 | + | export function toggleColliders(next = !on): boolean { | |
| 27 | + | on = next; | |
| 28 | + | for (const cb of listeners) cb(); | |
| 29 | + | return on; | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /** Repaint when it changes — a settled table is not drawing frames otherwise. */ | |
| 33 | + | export function onCollidersChange(cb: () => void): () => void { | |
| 34 | + | listeners.add(cb); | |
| 35 | + | return () => { | |
| 36 | + | listeners.delete(cb); | |
| 37 | + | }; | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | declare global { | |
| 41 | + | interface Window { | |
| 42 | + | colliders?: (next?: boolean) => boolean; | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | if (typeof window !== 'undefined') window.colliders = toggleColliders; |