collin/mahjong · 2259ceab
Take the tile off the rail, and let a hard flick actually land hard
Collin Richards · 2026-08-19 10:27 UTC · 2259ceab153d44d6167c81f42ab27c9b164230d2 · parent 6d18dd3d · browse files
modifiedsrc/table/physics.test.ts+66 −0
| ⋯ 101 unchanged lines | |||
| 102 | 102 | } | |
| 103 | 103 | }); | |
| 104 | 104 | ||
| 105 | + | it('carries a hard throw much further than a soft one', () => { | |
| 106 | + | // The whole point of throwing it yourself: how hard you flick has to show. | |
| 107 | + | // Friction heavy enough to eat the difference would make every discard land | |
| 108 | + | // in the same place however it was let go of. | |
| 109 | + | // Ground covered, not distance from where it started: a hard throw crosses | |
| 110 | + | // the square and comes back off the far side, which is the point of it. | |
| 111 | + | const travel = (speed: number) => { | |
| 112 | + | const world = createWorld(POOL); | |
| 113 | + | const b = addBody(world, { | |
| 114 | + | tile: 0, | |
| 115 | + | seat: 0, | |
| 116 | + | x: POOL.x + 20, | |
| 117 | + | y: POOL.y + POOL.h / 2, | |
| 118 | + | vx: speed, | |
| 119 | + | vy: 0, | |
| 120 | + | z: 0, | |
| 121 | + | vz: 0, | |
| 122 | + | angle: 0, | |
| 123 | + | spin: 0, | |
| 124 | + | w: TILE.w, | |
| 125 | + | h: TILE.h, | |
| 126 | + | }); | |
| 127 | + | let covered = 0; | |
| 128 | + | for (let i = 0; i < SETTLE_CAP && !settled(world); i++) { | |
| 129 | + | const x = b.x; | |
| 130 | + | const y = b.y; | |
| 131 | + | step(world); | |
| 132 | + | covered += Math.hypot(b.x - x, b.y - y); | |
| 133 | + | } | |
| 134 | + | return covered; | |
| 135 | + | }; | |
| 136 | + | ||
| 137 | + | const soft = travel(220); | |
| 138 | + | const hard = travel(2200); | |
| 139 | + | expect(hard).toBeGreaterThan(soft * 3); | |
| 140 | + | // And a hard one gets right across the square rather than dying halfway. | |
| 141 | + | expect(hard).toBeGreaterThan(POOL.w * 0.55); | |
| 142 | + | }); | |
| 143 | + | ||
| 144 | + | it('bounces a hard throw off the far side instead of sticking to it', () => { | |
| 145 | + | const world = createWorld(POOL); | |
| 146 | + | const b = addBody(world, { | |
| 147 | + | tile: 0, | |
| 148 | + | seat: 0, | |
| 149 | + | x: POOL.x + 20, | |
| 150 | + | y: POOL.y + POOL.h / 2, | |
| 151 | + | vx: 2600, | |
| 152 | + | vy: 0, | |
| 153 | + | z: 0, | |
| 154 | + | vz: 0, | |
| 155 | + | angle: 0, | |
| 156 | + | spin: 0, | |
| 157 | + | w: TILE.w, | |
| 158 | + | h: TILE.h, | |
| 159 | + | }); | |
| 160 | + | // Run until it has been turned around by the far wall. | |
| 161 | + | let bounced = false; | |
| 162 | + | for (let i = 0; i < SETTLE_CAP && !bounced; i++) { | |
| 163 | + | step(world); | |
| 164 | + | if (b.vx < -40) bounced = true; | |
| 165 | + | } | |
| 166 | + | expect(bounced).toBe(true); | |
| 167 | + | runToRest(world); | |
| 168 | + | expect(outside(b, POOL)).toBeLessThan(0.5); | |
| 169 | + | }); | |
| 170 | + | ||
| 105 | 171 | it('is deterministic — the same throws give the same pile', () => { | |
| 106 | 172 | const pile = () => { | |
| 107 | 173 | const rng = makeRng(99); | |
| ⋯ 192 unchanged lines | |||
modifiedsrc/table/physics.ts+22 −11
| ⋯ 36 unchanged lines | |||
| 37 | 37 | */ | |
| 38 | 38 | export const WALL_HEIGHT = 30; | |
| 39 | 39 | ||
| 40 | - | /** Felt friction: an exponential part that kills speed, and a constant part | |
| 41 | - | * that actually brings it to a stop rather than to an asymptote. */ | |
| 42 | - | const FELT_DAMP = 4.2; | |
| 43 | - | const FELT_STOP = 90; | |
| 44 | - | const SPIN_DAMP = 5; | |
| 45 | - | const SPIN_STOP = 1.2; | |
| 40 | + | /** | |
| 41 | + | * Felt friction: an exponential part that kills speed, and a constant part that | |
| 42 | + | * actually brings it to a stop rather than to an asymptote. | |
| 43 | + | * | |
| 44 | + | * Deliberately light. A tile thrown hard should cross the square, come off the | |
| 45 | + | * far side and barge through what is already lying there before it settles — | |
| 46 | + | * heavier friction ate the throw in the first few inches and every discard | |
| 47 | + | * ended up looking the same however it was let go of. | |
| 48 | + | */ | |
| 49 | + | const FELT_DAMP = 2.6; | |
| 50 | + | const FELT_STOP = 70; | |
| 51 | + | const SPIN_DAMP = 3.4; | |
| 52 | + | const SPIN_STOP = 0.9; | |
| 46 | 53 | ||
| 47 | 54 | /** | |
| 48 | 55 | * Roughly how much longer a tile keeps travelling after it lands, as a time. | |
| ⋯ 2 unchanged lines | |||
| 51 | 58 | */ | |
| 52 | 59 | export const SLIDE_TIME = 1 / FELT_DAMP; | |
| 53 | 60 | ||
| 54 | - | const BOUND_RESTITUTION = 0.42; | |
| 55 | - | const BOUND_FRICTION = 0.86; | |
| 56 | - | const TILE_RESTITUTION = 0.32; | |
| 61 | + | /** Off the inside of the wall square, and off another tile. Bouncy enough that | |
| 62 | + | * a hard throw ricochets instead of sticking where it first lands. */ | |
| 63 | + | const BOUND_RESTITUTION = 0.55; | |
| 64 | + | const BOUND_FRICTION = 0.9; | |
| 65 | + | const TILE_RESTITUTION = 0.42; | |
| 57 | 66 | /** How much of a glancing blow becomes spin. Tiles are light and skate. */ | |
| 58 | 67 | const SPIN_TRANSFER = 0.06; | |
| 59 | 68 | ||
| ⋯ 300 unchanged lines | |||
| 360 | 369 | for (const wall of world.walls) hitWall(b, wall, impacts); | |
| 361 | 370 | } | |
| 362 | 371 | ||
| 363 | - | if (b.entered) keepInBounds(world, b, impacts); | |
| 372 | + | // The pool's edge is the inside face of the wall, and it is only as tall as | |
| 373 | + | // the wall — a tile lobbed high is over the top of it, not stopped by it. | |
| 374 | + | if (b.entered && b.z < WALL_HEIGHT) keepInBounds(world, b, impacts); | |
| 364 | 375 | } | |
| 365 | 376 | ||
| 366 | 377 | collideTiles(world, impacts); | |
| ⋯ 1 unchanged line | |||
| 368 | 379 | // that then fell asleep out there would stay out there. Only awake ones need | |
| 369 | 380 | // it: resolvePair never moves a tile that has settled. | |
| 370 | 381 | for (const b of world.bodies) { | |
| 371 | - | if (b.entered && !b.resting) clampInto(world.bounds, b); | |
| 382 | + | if (b.entered && !b.resting && b.z < WALL_HEIGHT) clampInto(world.bounds, b); | |
| 372 | 383 | } | |
| 373 | 384 | // Deciding to sleep comes last, so it judges where the step actually left | |
| 374 | 385 | // things — before, a contact resolved after the decision would undo it and a | |
| ⋯ 286 unchanged lines | |||
modifiedsrc/table/pool.ts+7 −4
| ⋯ 38 unchanged lines | |||
| 39 | 39 | /** A thrown tile hops rather than lofts — it is going through a gap, not over. */ | |
| 40 | 40 | const THROW_LIFT = 0.12; | |
| 41 | 41 | /** Fastest flick we will honour, px/s. Beyond this it is a slip, not a throw. */ | |
| 42 | - | export const MAX_THROW_SPEED = 2800; | |
| 42 | + | export const MAX_THROW_SPEED = 3600; | |
| 43 | 43 | /** How long a claimed tile takes to leave the table for the seat taking it. */ | |
| 44 | 44 | const TAKE_FLIGHT = 0.34; | |
| 45 | 45 | ||
| ⋯ 351 unchanged lines | |||
| 397 | 397 | let vz = 0; | |
| 398 | 398 | ||
| 399 | 399 | if (!slide) { | |
| 400 | + | // Lofted over a standing wall. It still has to *land* on the table | |
| 401 | + | // rather than sail off the far side, so there is a ceiling on how fast | |
| 402 | + | // it can cross — but a generous one: a hard throw is meant to reach the | |
| 403 | + | // far wall, come off it and barge through the pile, and only a limp one | |
| 404 | + | // should drop in the middle and stay there. | |
| 400 | 405 | const centre = centreOf(geo.pool); | |
| 401 | 406 | const reach = PLACE_FLIGHT + SLIDE_TIME; | |
| 402 | - | // Hard enough to clear the wall, and never hard enough to slam the far | |
| 403 | - | // side of the square — a lob has to land on the table. | |
| 404 | - | const cap = (Math.hypot(centre.x - thrown.x, centre.y - thrown.y) / reach) * 1.25; | |
| 407 | + | const cap = (Math.hypot(centre.x - thrown.x, centre.y - thrown.y) / reach) * 2.4; | |
| 405 | 408 | const speed = Math.hypot(vx, vy); | |
| 406 | 409 | const aim = Math.min(speed, cap) / (speed || 1); | |
| 407 | 410 | vx *= aim; | |
| ⋯ 65 unchanged lines | |||
modifiedsrc/ui/Hand.tsx+54 −25
| ⋯ 44 unchanged lines | |||
| 45 | 45 | ||
| 46 | 46 | /** How far a pointer must travel before a tap becomes a drag. */ | |
| 47 | 47 | const DRAG_SLOP = 12; | |
| 48 | - | /** How far out of the row before the tile is genuinely picked up. */ | |
| 48 | + | /** | |
| 49 | + | * How far out of the row before the tile is genuinely picked up — in either | |
| 50 | + | * direction, so it comes up whichever way you pull it off the row rather than | |
| 51 | + | * only towards the table. It is put back down again at 0.6 of this, and the gap | |
| 52 | + | * between the two is what stops it flickering when a finger rests on the line. | |
| 53 | + | */ | |
| 49 | 54 | const LIFT_OUT = 22; | |
| 50 | 55 | /** | |
| 51 | 56 | * A release this fast is a throw whatever else is true. Below it, the tile has | |
| ⋯ 107 unchanged lines | |||
| 159 | 164 | const [carrying, setCarrying] = useState<number | null>(null); | |
| 160 | 165 | ||
| 161 | 166 | const down = (e: React.PointerEvent, i: number) => { | |
| 162 | - | (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); | |
| 167 | + | // Capture is what lets a tile keep following a finger once it has left the | |
| 168 | + | // row — without it the events stop the moment the pointer is over something | |
| 169 | + | // else. It throws if the pointer has already gone, which is not a reason to | |
| 170 | + | // drop the whole gesture. | |
| 171 | + | try { | |
| 172 | + | (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); | |
| 173 | + | } catch { | |
| 174 | + | // Carry on uncaptured; a drag inside the row still works. | |
| 175 | + | } | |
| 163 | 176 | if (!ref.current) return; | |
| 164 | 177 | drag.current = { | |
| 165 | 178 | from: i, | |
| ⋯ 15 unchanged lines | |||
| 181 | 194 | ||
| 182 | 195 | const dx = e.clientX - d.startX; | |
| 183 | 196 | const dy = e.clientY - d.startY; | |
| 197 | + | // Signed distance out of the row, and distance along it. Both measured off | |
| 198 | + | // the elements, so this reads the same from any of the four edges. | |
| 184 | 199 | const out = dx * d.outward.x + dy * d.outward.y; | |
| 200 | + | const along = Math.abs(dx * -d.outward.y + dy * d.outward.x); | |
| 201 | + | const lift = Math.abs(out); | |
| 202 | + | const canLift = !!carry && canDiscard; | |
| 185 | 203 | ||
| 186 | - | if (d.mode === 'undecided') { | |
| 187 | - | if (Math.hypot(dx, dy) < DRAG_SLOP) return; | |
| 188 | - | // Along the row is arranging your hand; out of it is picking the tile up. | |
| 189 | - | // Which axis the movement is mostly on decides, once, and it sticks. | |
| 190 | - | const along = Math.abs(dx * -d.outward.y + dy * d.outward.x); | |
| 191 | - | if (carry && canDiscard && out > along && out > LIFT_OUT) { | |
| 192 | - | d.mode = 'carry'; | |
| 193 | - | setCarrying(d.from); | |
| 194 | - | carry.onPickUp( | |
| 195 | - | d.from, | |
| 196 | - | e.clientX - d.outward.base.x, | |
| 197 | - | e.clientY - d.outward.base.y, | |
| 198 | - | tileWidth(ref.current, d.from), | |
| 199 | - | ); | |
| 204 | + | if (d.mode === 'undecided' && Math.hypot(dx, dy) < DRAG_SLOP) return; | |
| 205 | + | ||
| 206 | + | // Which of the two the gesture is is asked *every* move, not decided once | |
| 207 | + | // at the start and stuck with. Deciding once meant a drag that set off a few | |
| 208 | + | // degrees along the row was welded to the row for the rest of its life, and | |
| 209 | + | // the tile could never be got out of the hand at all. | |
| 210 | + | if (canLift) { | |
| 211 | + | if (lift > LIFT_OUT) { | |
| 212 | + | if (d.mode !== 'carry') { | |
| 213 | + | d.mode = 'carry'; | |
| 214 | + | setSorting(null); | |
| 215 | + | setCarrying(d.from); | |
| 216 | + | carry.onPickUp( | |
| 217 | + | d.from, | |
| 218 | + | e.clientX - d.outward.base.x, | |
| 219 | + | e.clientY - d.outward.base.y, | |
| 220 | + | tileWidth(ref.current, d.from), | |
| 221 | + | ); | |
| 222 | + | } | |
| 223 | + | carry.onMoveTo(e.clientX - d.outward.base.x, e.clientY - d.outward.base.y); | |
| 200 | 224 | return; | |
| 201 | 225 | } | |
| 202 | - | if (out > along && out > LIFT_OUT) return; // wants to lift, but cannot | |
| 203 | - | d.mode = 'sort'; | |
| 204 | - | setSorting(d.cur); | |
| 205 | - | } | |
| 206 | - | ||
| 207 | - | if (d.mode === 'carry') { | |
| 208 | - | carry?.onMoveTo(e.clientX - d.outward.base.x, e.clientY - d.outward.base.y); | |
| 209 | - | return; | |
| 226 | + | // Brought back over your own hand: put it down, and it is a tile in a row | |
| 227 | + | // again. The gap between the two thresholds is what stops it flickering | |
| 228 | + | // between the two when a finger sits on the boundary. | |
| 229 | + | if (d.mode === 'carry') { | |
| 230 | + | if (lift > LIFT_OUT * 0.6) return; | |
| 231 | + | carry.onCancel(); | |
| 232 | + | setCarrying(null); | |
| 233 | + | } else if (d.mode === 'undecided' && lift >= along) { | |
| 234 | + | // On its way out but not out yet. Nothing should shuffle in the row | |
| 235 | + | // underneath a tile that is about to leave it. | |
| 236 | + | return; | |
| 237 | + | } | |
| 210 | 238 | } | |
| 211 | 239 | ||
| 240 | + | d.mode = 'sort'; | |
| 212 | 241 | const to = nearestIndex(ref.current, e.clientX, e.clientY); | |
| 213 | 242 | if (to !== null && to !== d.cur) { | |
| 214 | 243 | onMove(d.cur, to); | |
| 215 | 244 | d.cur = to; | |
| 216 | - | setSorting(to); | |
| 217 | 245 | } | |
| 246 | + | setSorting(d.cur); | |
| 218 | 247 | }; | |
| 219 | 248 | ||
| 220 | 249 | const up = (e: React.PointerEvent) => { | |
| ⋯ 90 unchanged lines | |||
modifiedsrc/ui/Pool.tsx+53 −10
| ⋯ 140 unchanged lines | |||
| 141 | 141 | ||
| 142 | 142 | // Shadows in one pass of their own, offset before rotation, so the light comes | |
| 143 | 143 | // from one direction across the whole pool rather than turning with each tile. | |
| 144 | - | for (const b of bodies) shadow(ctx, b.x, b.y, b.z, b.angle, b.w * b.scale, b.h * b.scale); | |
| 145 | - | if (held) shadow(ctx, held.x, held.y, HELD_Z, held.angle, held.w, held.h); | |
| 144 | + | for (const b of bodies) shadow(ctx, b.x, b.y, b.z, b.angle, b.w, b.h, b.scale); | |
| 145 | + | if (held) shadow(ctx, held.x, held.y, HELD_Z, held.angle, held.w, held.h, 1); | |
| 146 | 146 | ||
| 147 | 147 | const newest = pool.newest; | |
| 148 | 148 | for (const b of bodies) { | |
| ⋯ 28 unchanged lines | |||
| 177 | 177 | } | |
| 178 | 178 | ||
| 179 | 179 | /** | |
| 180 | - | * A soft silhouette under a tile. Blurred where the browser will do it — a hard | |
| 181 | - | * black slab reads as a second tile rather than as a shadow — and simply a faint | |
| 182 | - | * one where it will not. | |
| 180 | + | * A soft silhouette under a tile. | |
| 181 | + | * | |
| 182 | + | * The blur is baked into a sprite once per tile size rather than applied per | |
| 183 | + | * draw. `ctx.filter` is the obvious way to write this and the wrong one: every | |
| 184 | + | * filtered fill makes the browser allocate and blur its own layer, so sixty | |
| 185 | + | * tiles times sixty frames is sixty times sixty blurs a second, which is enough | |
| 186 | + | * to make the tab stop answering. One blur, then `drawImage`. | |
| 187 | + | * | |
| 188 | + | * Height is sold by scaling that sprite up — a shadow that spreads and fades as | |
| 189 | + | * the tile rises — which a fixed blur radius could not have done anyway. | |
| 183 | 190 | */ | |
| 191 | + | const shadows = new Map<string, HTMLCanvasElement>(); | |
| 192 | + | ||
| 193 | + | function shadowSprite(w: number, h: number): HTMLCanvasElement { | |
| 194 | + | const key = `${Math.round(w)}x${Math.round(h)}`; | |
| 195 | + | const had = shadows.get(key); | |
| 196 | + | if (had) return had; | |
| 197 | + | // Only ever a handful — one per tile size on screen — but a window being | |
| 198 | + | // dragged to resize walks through a lot of them. | |
| 199 | + | if (shadows.size > 8) shadows.clear(); | |
| 200 | + | ||
| 201 | + | const pad = Math.ceil(Math.max(8, w * 0.5)); | |
| 202 | + | const c = document.createElement('canvas'); | |
| 203 | + | c.width = Math.ceil(w) + pad * 2; | |
| 204 | + | c.height = Math.ceil(h) + pad * 2; | |
| 205 | + | const g = c.getContext('2d'); | |
| 206 | + | if (g) { | |
| 207 | + | g.filter = `blur(${Math.max(1.5, w * 0.11).toFixed(1)}px)`; | |
| 208 | + | g.fillStyle = '#000'; | |
| 209 | + | g.translate(c.width / 2, c.height / 2); | |
| 210 | + | g.beginPath(); | |
| 211 | + | g.roundRect(-w / 2, -h / 2, w, h, w * 0.1); | |
| 212 | + | g.fill(); | |
| 213 | + | } | |
| 214 | + | shadows.set(key, c); | |
| 215 | + | return c; | |
| 216 | + | } | |
| 217 | + | ||
| 184 | 218 | function shadow( | |
| 185 | 219 | ctx: CanvasRenderingContext2D, | |
| 186 | 220 | x: number, | |
| ⋯ 2 unchanged lines | |||
| 189 | 223 | angle: number, | |
| 190 | 224 | w: number, | |
| 191 | 225 | h: number, | |
| 226 | + | scale: number, | |
| 192 | 227 | ) { | |
| 228 | + | // Keyed on the tile's own size, never on the eased one — a scale that changes | |
| 229 | + | // every frame would mean a fresh blur every frame, which is the thing this | |
| 230 | + | // exists to avoid. | |
| 231 | + | const sprite = shadowSprite(w, h); | |
| 232 | + | const spread = zScale(z) * scale * (1 + z * 0.004); | |
| 193 | 233 | ctx.save(); | |
| 194 | - | ctx.filter = `blur(${(2.5 + z * 0.07).toFixed(1)}px)`; | |
| 195 | - | ctx.fillStyle = `rgba(0, 0, 0, ${(0.34 - Math.min(0.16, z * 0.0014)).toFixed(3)})`; | |
| 234 | + | ctx.globalAlpha = 0.36 - Math.min(0.18, z * 0.0016); | |
| 196 | 235 | ctx.translate(x + z * 0.12, y + 3 + z * 0.34); | |
| 197 | 236 | ctx.rotate(angle); | |
| 198 | - | const s = zScale(z) * 1.03; | |
| 199 | - | tilePath(ctx, w * s, h * s); | |
| 200 | - | ctx.fill(); | |
| 237 | + | ctx.drawImage( | |
| 238 | + | sprite, | |
| 239 | + | (-sprite.width / 2) * spread, | |
| 240 | + | (-sprite.height / 2) * spread, | |
| 241 | + | sprite.width * spread, | |
| 242 | + | sprite.height * spread, | |
| 243 | + | ); | |
| 201 | 244 | ctx.restore(); | |
| 202 | 245 | } | |
| 203 | 246 | ||
| ⋯ 33 unchanged lines | |||