collin/mahjong · 84d53205
Pick a discard up and put it somewhere, or flick it across the table
Collin Richards · 2026-08-21 19:14 UTC · 84d532056230b5bd5e75c5d8ec675c5fb48171a9 · parent c8f4c34b · browse files
modifiedREADME.md+9 −0
| ⋯ 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 | + | The pile is handled directly. Press on a discard and it comes up out of the | |
| 488 | + | pile, carried over the top of the others rather than ploughing through them; | |
| 489 | + | let go slowly and it is set down there, flick and it goes off across the felt at | |
| 490 | + | the speed it left your fingers — the same throw a tile gets out of a hand, made | |
| 491 | + | from the middle of the table instead. Pressing on bare felt does nothing, and | |
| 492 | + | the press passes through to whatever was really under it: the tiles are drawn on | |
| 493 | + | a canvas with no elements to hit, so the hit test is against the physics and the | |
| 494 | + | listener is a capturing one on the table itself. | |
| 495 | + | ||
| 487 | 496 | A flick is the whole of a throw: the direction and the speed it leaves at are | |
| 488 | 497 | the hand's, and nothing between the fingers and the felt changes either. The one | |
| 489 | 498 | thing the table decides is *height* — with a standing wall in front of the | |
| ⋯ 94 unchanged lines | |||
modifiedsrc/styles.css+6 −11
| ⋯ 581 unchanged lines | |||
| 582 | 582 | pointer-events: none; | |
| 583 | 583 | z-index: 1; | |
| 584 | 584 | } | |
| 585 | - | /* The patch of table you can put a finger into to wash the tiles about. Exactly | |
| 586 | - | the pool and no more — the canvas covers the whole table and must never take | |
| 587 | - | a click meant for a strip, so the reachable part is its own small box, parked | |
| 588 | - | over the pool from the measurement by ui/Pool.tsx. */ | |
| 589 | - | .pool-reach { | |
| 590 | - | position: absolute; | |
| 591 | - | z-index: 1; | |
| 585 | + | /* The pile is handled directly: a discard is picked up, carried over the top of | |
| 586 | + | the others and set down or flicked away. The tiles are on a canvas with no | |
| 587 | + | elements to hit, so the hit test is against the physics and the listener is a | |
| 588 | + | capturing one on the table itself — see `dragging` in ui/Pool.tsx. Nothing | |
| 589 | + | here needs a box; `touch-action` is what stops a drag scrolling the page. */ | |
| 590 | + | .table { | |
| 592 | 591 | touch-action: none; | |
| 593 | - | cursor: grab; | |
| 594 | - | } | |
| 595 | - | .pool-reach:active { | |
| 596 | - | cursor: grabbing; | |
| 597 | 592 | } | |
| 598 | 593 | ||
| 599 | 594 | /* ---------- center ---------- */ | |
| ⋯ 1703 unchanged lines | |||
modifiedsrc/table/physics.ts+63 −40
| ⋯ 104 unchanged lines | |||
| 105 | 105 | /** How fast a tile drawn at hand size shrinks to the size it is in the pool. */ | |
| 106 | 106 | const SCALE_EASE = 6; | |
| 107 | 107 | ||
| 108 | - | /** Washing the tiles about: how much of a finger's speed a tile takes on, how | |
| 109 | - | * hard it is nudged clear, and the fastest shove it will pass on. */ | |
| 110 | - | const STIR_DRAG = 0.55; | |
| 111 | - | const STIR_NUDGE = 30; | |
| 112 | - | const STIR_MAX_SPEED = 1500; | |
| 113 | - | ||
| 114 | 108 | export const TAU = Math.PI * 2; | |
| 115 | 109 | /** | |
| 116 | 110 | * How quickly whatever is left of a tumble is taken up once the tile is down. | |
| ⋯ 73 unchanged lines | |||
| 190 | 184 | * tiles are not solid to you until you are out of them. | |
| 191 | 185 | */ | |
| 192 | 186 | leftSeat: boolean; | |
| 187 | + | /** | |
| 188 | + | * In somebody's fingers. Its position is set from outside — a finger is not | |
| 189 | + | * a force — so the solver leaves it entirely alone: it does not fall, drift, | |
| 190 | + | * settle or take a shove, and it is held high enough to pass over everything | |
| 191 | + | * else while it is being carried. | |
| 192 | + | */ | |
| 193 | + | carried: boolean; | |
| 193 | 194 | resting: boolean; | |
| 194 | 195 | /** Consecutive slow steps, counting towards sleep. */ | |
| 195 | 196 | still: number; | |
| ⋯ 106 unchanged lines | |||
| 302 | 303 | entered: !!s.atRest, | |
| 303 | 304 | // A rebuilt pile is already lying in the middle, not in anybody's hand. | |
| 304 | 305 | leftSeat: !!s.atRest, | |
| 306 | + | carried: false, | |
| 305 | 307 | resting: !!s.atRest, | |
| 306 | 308 | still: s.atRest ? SLEEP_STEPS : 0, | |
| 307 | 309 | age: 0, | |
| ⋯ 166 unchanged lines | |||
| 474 | 476 | b.scale += (1 - b.scale) * Math.min(1, SCALE_EASE * dt); | |
| 475 | 477 | if (Math.abs(b.scale - 1) < 0.002) b.scale = 1; | |
| 476 | 478 | } | |
| 479 | + | // A tile in the fingers is driven, not simulated. Nothing below applies | |
| 480 | + | // to it, and it stays awake so it is drawn where the finger put it. | |
| 481 | + | if (b.carried) { | |
| 482 | + | b.still = 0; | |
| 483 | + | b.awake = 0; | |
| 484 | + | continue; | |
| 485 | + | } | |
| 477 | 486 | if (b.resting) continue; | |
| 478 | 487 | b.age++; | |
| 479 | 488 | b.awake++; | |
| ⋯ 431 unchanged lines | |||
| 911 | 920 | } | |
| 912 | 921 | ||
| 913 | 922 | /** | |
| 914 | - | * A finger, or a cursor, pushing the tiles about in the middle — 洗牌, the thing | |
| 915 | - | * everybody does to a pool of discards while waiting for their turn. | |
| 916 | - | * | |
| 917 | - | * Modelled as a circle that simply cannot be inside a tile: anything it touches | |
| 918 | - | * is shoved clear of it and handed some of the speed it was moving at. It has no | |
| 919 | - | * mass and takes no impulse back, which is what a finger on a table is. | |
| 923 | + | * How high a tile is held while it is being dragged. Above `WALL_HEIGHT` so it | |
| 924 | + | * clears the wall, and above `ON_FELT` so it is out of the way of every tile it | |
| 925 | + | * is carried over — the pile does not part in front of a finger, the tile goes | |
| 926 | + | * over the top of it, which is what picking one up is. | |
| 920 | 927 | */ | |
| 921 | - | export function stir( | |
| 922 | - | world: World, | |
| 923 | - | x: number, | |
| 924 | - | y: number, | |
| 925 | - | vx: number, | |
| 926 | - | vy: number, | |
| 927 | - | radius: number, | |
| 928 | - | ) { | |
| 929 | - | // A frantic swipe should not fire tiles off like bullets. | |
| 930 | - | const speed = Math.hypot(vx, vy); | |
| 931 | - | const trim = speed > STIR_MAX_SPEED ? STIR_MAX_SPEED / speed : 1; | |
| 928 | + | export const CARRY_Z = 46; | |
| 932 | 929 | ||
| 933 | - | for (const b of world.bodies) { | |
| 934 | - | if (b.z > ON_FELT) continue; | |
| 935 | - | const dx = b.x - x; | |
| 936 | - | const dy = b.y - y; | |
| 937 | - | const clear = radius + circumradius(b); | |
| 938 | - | const d = Math.hypot(dx, dy); | |
| 939 | - | if (d > clear) continue; | |
| 940 | - | ||
| 941 | - | const nx = d < 0.001 ? 1 : dx / d; | |
| 942 | - | const ny = d < 0.001 ? 0 : dy / d; | |
| 943 | - | b.x = x + nx * clear; | |
| 944 | - | b.y = y + ny * clear; | |
| 945 | - | b.vx += vx * trim * STIR_DRAG + nx * STIR_NUDGE; | |
| 946 | - | b.vy += vy * trim * STIR_DRAG + ny * STIR_NUDGE; | |
| 947 | - | // Dragged past off-centre, a tile turns under your finger. | |
| 948 | - | b.spin += (vx * trim * ny - vy * trim * nx) * 0.0016; | |
| 949 | - | wake(b); | |
| 930 | + | /** The topmost tile under a point, or null. What a finger has taken hold of. | |
| 931 | + | * Last first: later tiles were thrown later and lie on top. */ | |
| 932 | + | export function tileAt(world: World, x: number, y: number): Body | null { | |
| 933 | + | for (let i = world.bodies.length - 1; i >= 0; i--) { | |
| 934 | + | const b = world.bodies[i]; | |
| 935 | + | const c = Math.cos(-b.angle); | |
| 936 | + | const s = Math.sin(-b.angle); | |
| 937 | + | const dx = x - b.x; | |
| 938 | + | const dy = y - b.y; | |
| 939 | + | // Into the tile's own frame, where the test is a rectangle again. | |
| 940 | + | if (Math.abs(dx * c - dy * s) <= b.w / 2 && Math.abs(dx * s + dy * c) <= b.h / 2) return b; | |
| 950 | 941 | } | |
| 942 | + | return null; | |
| 943 | + | } | |
| 944 | + | ||
| 945 | + | /** Taken out of the pile and held above it. */ | |
| 946 | + | export function lift(b: Body) { | |
| 947 | + | b.carried = true; | |
| 948 | + | b.resting = false; | |
| 949 | + | b.z = CARRY_Z; | |
| 950 | + | b.vx = 0; | |
| 951 | + | b.vy = 0; | |
| 952 | + | b.vz = 0; | |
| 953 | + | b.spin = 0; | |
| 954 | + | b.flipSpin = 0; | |
| 955 | + | // Whatever it comes down on, it comes down face up, as a tile does. | |
| 956 | + | b.flip = Math.round(b.flip / TAU) * TAU; | |
| 957 | + | } | |
| 958 | + | ||
| 959 | + | /** Let go of it: dropped where it is, or sent off at whatever it was flicked. */ | |
| 960 | + | export function drop(b: Body, vx: number, vy: number, spin: number) { | |
| 961 | + | b.carried = false; | |
| 962 | + | b.vx = vx; | |
| 963 | + | b.vy = vy; | |
| 964 | + | b.spin = spin; | |
| 965 | + | // It was being held up; from here it simply falls. | |
| 966 | + | b.vz = 0; | |
| 967 | + | b.still = 0; | |
| 968 | + | b.awake = 0; | |
| 969 | + | b.resting = false; | |
| 970 | + | // Thrown from inside the pile rather than from a hand, so it is already past | |
| 971 | + | // every seat's tiles and they are solid to it straight away. | |
| 972 | + | b.leftSeat = true; | |
| 973 | + | b.entered = true; | |
| 951 | 974 | } | |
| 952 | 975 | ||
| 953 | 976 | /** Whether two tiles are sharing ground, which they must never do at rest. */ | |
| ⋯ 18 unchanged lines | |||
modifiedsrc/table/pool.ts+60 −9
| ⋯ 18 unchanged lines | |||
| 19 | 19 | liftFor, | |
| 20 | 20 | reshape, | |
| 21 | 21 | settled, | |
| 22 | - | stir, | |
| 22 | + | drop, | |
| 23 | + | lift, | |
| 24 | + | tileAt, | |
| 23 | 25 | SLIDE_TIME, | |
| 24 | 26 | type Body, | |
| 25 | 27 | type Impact, | |
| ⋯ 65 unchanged lines | |||
| 91 | 93 | * A wall's worth: enough to test a crowded pool, not enough to bury it. */ | |
| 92 | 94 | const LOOSE_LIMIT = 60; | |
| 93 | 95 | ||
| 96 | + | /** Let go of moving faster than this and it was a flick, not a place. Slow | |
| 97 | + | * enough that putting a tile down deliberately never sends it skidding. */ | |
| 98 | + | const DRAG_FLICK = 260; | |
| 99 | + | ||
| 94 | 100 | /** A tile lifted out of a hand and carried about. Drawn, but not simulated. */ | |
| 95 | 101 | export interface Held { | |
| 96 | 102 | seat: SeatId; | |
| ⋯ 71 unchanged lines | |||
| 168 | 174 | * threw reads exactly like a tile that bounced off something invisible. | |
| 169 | 175 | */ | |
| 170 | 176 | throws: ThrowMark[] = []; | |
| 177 | + | /** The discard being dragged about, if any. */ | |
| 178 | + | private carried: Body | null = null; | |
| 171 | 179 | /** | |
| 172 | 180 | * Sandbox throws: real tiles in the real world, but belonging to no discard, | |
| 173 | 181 | * so the reconciler never looks at them — nothing in `entries` means nothing | |
| ⋯ 18 unchanged lines | |||
| 192 | 200 | this.world = null; | |
| 193 | 201 | this.geo = null; | |
| 194 | 202 | this.loose = []; | |
| 203 | + | this.carried = null; | |
| 195 | 204 | this.held = null; | |
| 196 | 205 | this.release = null; | |
| 197 | 206 | this.entries.clear(); | |
| ⋯ 46 unchanged lines | |||
| 244 | 253 | } | |
| 245 | 254 | ||
| 246 | 255 | /** | |
| 247 | - | * Push the tiles in the middle about — washing them, the way everybody does | |
| 248 | - | * to a pool of discards while waiting. Purely cosmetic: where a discard is | |
| 249 | - | * lying is not part of the game. | |
| 256 | + | * Take hold of whatever discard is under the finger. | |
| 257 | + | * | |
| 258 | + | * The pile is a pile of tiles, so the thing you do to it is pick one up and | |
| 259 | + | * put it somewhere else — which is what everybody does to a pool of discards | |
| 260 | + | * while waiting for their turn. It comes up out of the pile and is carried | |
| 261 | + | * over the top of it rather than ploughing through it. | |
| 250 | 262 | */ | |
| 251 | - | stir(x: number, y: number, vx: number, vy: number) { | |
| 252 | - | if (!this.world || !this.geo) return; | |
| 253 | - | // A finger's worth: about a tile across. | |
| 254 | - | stir(this.world, x, y, vx, vy, this.geo.tile.w * 0.6); | |
| 263 | + | grabAt(x: number, y: number): boolean { | |
| 264 | + | if (!this.world) return false; | |
| 265 | + | const b = tileAt(this.world, x, y); | |
| 266 | + | if (!b) return false; | |
| 267 | + | lift(b); | |
| 268 | + | this.carried = b; | |
| 269 | + | return true; | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | /** Follow the finger. A carried tile is placed, not pushed. */ | |
| 273 | + | dragTo(x: number, y: number, angle?: number) { | |
| 274 | + | const b = this.carried; | |
| 275 | + | if (!b) return; | |
| 276 | + | b.x = x; | |
| 277 | + | b.y = y; | |
| 278 | + | if (angle !== undefined) b.angle = angle; | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | /** | |
| 282 | + | * Let go. Slowly, and it is put down where it is; flicked, and it goes off | |
| 283 | + | * across the felt at the speed it left the fingers — the same throw a tile | |
| 284 | + | * gets out of a hand, made from the middle of the table instead. | |
| 285 | + | */ | |
| 286 | + | releaseGrab(vx: number, vy: number, spin: number) { | |
| 287 | + | const b = this.carried; | |
| 288 | + | if (!b) return; | |
| 289 | + | this.carried = null; | |
| 290 | + | const speed = Math.hypot(vx, vy); | |
| 291 | + | const scale = speed > MAX_THROW_SPEED ? MAX_THROW_SPEED / speed : 1; | |
| 292 | + | const flick = speed > DRAG_FLICK; | |
| 293 | + | drop(b, flick ? vx * scale : 0, flick ? vy * scale : 0, flick ? spin : 0); | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | /** Whether a discard is in somebody's fingers right now. */ | |
| 297 | + | get dragging(): boolean { | |
| 298 | + | return !!this.carried; | |
| 255 | 299 | } | |
| 256 | 300 | ||
| 257 | 301 | // ---- the tile in your fingers ----------------------------------------- | |
| ⋯ 179 unchanged lines | |||
| 437 | 481 | if (want.has(key)) continue; | |
| 438 | 482 | const at = world.bodies.indexOf(body); | |
| 439 | 483 | if (at >= 0) world.bodies.splice(at, 1); | |
| 484 | + | // Claimed out from under the finger holding it. | |
| 485 | + | if (this.carried === body) this.carried = null; | |
| 440 | 486 | this.entries.delete(key); | |
| 441 | 487 | taken.push(body); | |
| 442 | 488 | } | |
| ⋯ 98 unchanged lines | |||
| 541 | 587 | ||
| 542 | 588 | /** Nothing is moving, so the render loop can stop until something changes. */ | |
| 543 | 589 | get settled(): boolean { | |
| 544 | - | return !this.held && this.leaving.length === 0 && (!this.world || settled(this.world)); | |
| 590 | + | return ( | |
| 591 | + | !this.held && | |
| 592 | + | !this.carried && | |
| 593 | + | this.leaving.length === 0 && | |
| 594 | + | (!this.world || settled(this.world)) | |
| 595 | + | ); | |
| 545 | 596 | } | |
| 546 | 597 | ||
| 547 | 598 | // ---- measuring --------------------------------------------------------- | |
| ⋯ 208 unchanged lines | |||
modifiedsrc/ui/Pool.tsx+74 −67
| ⋯ 47 unchanged lines | |||
| 48 | 48 | viewSeat?: SeatId; | |
| 49 | 49 | }) { | |
| 50 | 50 | const canvasRef = useRef<HTMLCanvasElement>(null); | |
| 51 | - | const reachRef = useRef<HTMLDivElement>(null); | |
| 52 | 51 | const art = useSheet(); | |
| 53 | - | const wash = useWash(pool); | |
| 54 | 52 | ||
| 55 | 53 | // What the loop reads. Refreshed on every render, read on every frame. | |
| 56 | 54 | const live = useRef({ state: game.state, art, viewSeat }); | |
| ⋯ 10 unchanged lines | |||
| 67 | 65 | ||
| 68 | 66 | pool.attach(table); | |
| 69 | 67 | exposePool(pool); | |
| 68 | + | const stopDragging = dragging(table as HTMLElement, pool, () => wake.current()); | |
| 70 | 69 | ||
| 71 | 70 | let frame = 0; | |
| 72 | 71 | let previous = 0; | |
| ⋯ 18 unchanged lines | |||
| 91 | 90 | previous = now; | |
| 92 | 91 | ||
| 93 | 92 | pool.sync(live.current.state); | |
| 94 | - | placeReach(reachRef.current, pool); | |
| 95 | 93 | // A tile in the fingers goes over the table's own buttons — seat 0 throws | |
| 96 | 94 | // from the *right-hand end* of its row, which is directly under the undo | |
| 97 | 95 | // button — so while one is being carried they stop taking the pointer. | |
| ⋯ 43 unchanged lines | |||
| 141 | 139 | resize(); | |
| 142 | 140 | ||
| 143 | 141 | return () => { | |
| 142 | + | stopDragging(); | |
| 144 | 143 | window.removeEventListener('keydown', onKey); | |
| 145 | 144 | stopColliders(); | |
| 146 | 145 | observer.disconnect(); | |
| ⋯ 10 unchanged lines | |||
| 157 | 156 | wake.current(); | |
| 158 | 157 | }); | |
| 159 | 158 | ||
| 160 | - | return ( | |
| 161 | - | <> | |
| 162 | - | <canvas className="tile-pool" ref={canvasRef} aria-hidden /> | |
| 163 | - | {/* The patch of table you can reach into. Sized to the pool from the | |
| 164 | - | measurement rather than from CSS, and only that patch, so it cannot | |
| 165 | - | swallow clicks meant for the strips around it. */} | |
| 166 | - | <div className="pool-reach" ref={reachRef} onPointerDown={wash.down} onPointerMove={wash.move} onPointerUp={wash.up} onPointerCancel={wash.up} /> | |
| 167 | - | </> | |
| 168 | - | ); | |
| 159 | + | return <canvas className="tile-pool" ref={canvasRef} aria-hidden />; | |
| 169 | 160 | } | |
| 170 | 161 | ||
| 171 | 162 | /** | |
| 172 | - | * Washing the tiles about with a finger. | |
| 163 | + | * Picking a discard up and putting it somewhere else. | |
| 173 | 164 | * | |
| 174 | 165 | * A discard's position is not part of the game — the engine neither knows nor | |
| 175 | - | * cares where in the middle a tile is lying — so this is free to be pure fidget, | |
| 176 | - | * the thing everybody does to the pool while waiting for their turn. | |
| 166 | + | * cares where in the middle a tile is lying — so the pile is free to be handled | |
| 167 | + | * the way a real one is: take hold of a tile, carry it over the top of the | |
| 168 | + | * others, and either set it down or send it skidding off with a flick. | |
| 169 | + | * | |
| 170 | + | * The listener is on the table and it *captures*, because the tiles are drawn | |
| 171 | + | * on a canvas that has no elements to hit. It hit-tests the pile first and only | |
| 172 | + | * takes the gesture when a tile is actually under the finger; anything else is | |
| 173 | + | * let straight through to whatever it was really aimed at — a tile in a hand, | |
| 174 | + | * the settings button, the wall. | |
| 177 | 175 | */ | |
| 178 | - | function useWash(pool: TablePool) { | |
| 179 | - | const last = useRef<{ x: number; y: number; t: number } | null>(null); | |
| 176 | + | function dragging(table: HTMLElement, pool: TablePool, wake: () => void) { | |
| 177 | + | let samples: { x: number; y: number; t: number }[] = []; | |
| 180 | 178 | ||
| 181 | - | const at = (e: React.PointerEvent) => { | |
| 182 | - | const table = (e.currentTarget as HTMLElement).parentElement; | |
| 183 | - | const r = table?.getBoundingClientRect(); | |
| 184 | - | return { x: e.clientX - (r?.left ?? 0), y: e.clientY - (r?.top ?? 0), t: e.timeStamp }; | |
| 179 | + | const at = (e: PointerEvent) => { | |
| 180 | + | const r = table.getBoundingClientRect(); | |
| 181 | + | return { x: e.clientX - r.left, y: e.clientY - r.top, t: e.timeStamp }; | |
| 185 | 182 | }; | |
| 186 | 183 | ||
| 187 | - | return { | |
| 188 | - | down: (e: React.PointerEvent) => { | |
| 189 | - | try { | |
| 190 | - | (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); | |
| 191 | - | } catch { | |
| 192 | - | // Uncaptured still works while the pointer stays over the pool. | |
| 193 | - | } | |
| 194 | - | last.current = at(e); | |
| 195 | - | }, | |
| 196 | - | move: (e: React.PointerEvent) => { | |
| 197 | - | const prev = last.current; | |
| 198 | - | if (!prev) return; | |
| 199 | - | const now = at(e); | |
| 200 | - | const dt = (now.t - prev.t) / 1000; | |
| 201 | - | last.current = now; | |
| 202 | - | if (dt <= 0) return; | |
| 203 | - | pool.stir(now.x, now.y, (now.x - prev.x) / dt, (now.y - prev.y) / dt); | |
| 204 | - | pool.wake(); | |
| 205 | - | }, | |
| 206 | - | up: () => { | |
| 207 | - | last.current = null; | |
| 208 | - | }, | |
| 184 | + | const down = (e: PointerEvent) => { | |
| 185 | + | // Only the primary button, and only where there is a tile to take hold of. | |
| 186 | + | if (e.button !== 0) return; | |
| 187 | + | const p = at(e); | |
| 188 | + | if (!pool.grabAt(p.x, p.y)) return; | |
| 189 | + | e.stopPropagation(); | |
| 190 | + | e.preventDefault(); | |
| 191 | + | samples = [p]; | |
| 192 | + | try { | |
| 193 | + | table.setPointerCapture?.(e.pointerId); | |
| 194 | + | } catch { | |
| 195 | + | // Uncaptured still works while the pointer stays over the table. | |
| 196 | + | } | |
| 197 | + | wake(); | |
| 198 | + | }; | |
| 199 | + | ||
| 200 | + | const move = (e: PointerEvent) => { | |
| 201 | + | if (!pool.dragging) return; | |
| 202 | + | e.stopPropagation(); | |
| 203 | + | const p = at(e); | |
| 204 | + | // Only the tail matters, and only the recent tail: a flick is the last few | |
| 205 | + | // milliseconds of a drag, not an average over the whole of it. | |
| 206 | + | samples.push(p); | |
| 207 | + | if (samples.length > 6) samples.shift(); | |
| 208 | + | pool.dragTo(p.x, p.y); | |
| 209 | + | wake(); | |
| 210 | + | }; | |
| 211 | + | ||
| 212 | + | const up = (e: PointerEvent) => { | |
| 213 | + | if (!pool.dragging) return; | |
| 214 | + | e.stopPropagation(); | |
| 215 | + | const p = at(e); | |
| 216 | + | samples.push(p); | |
| 217 | + | const first = samples[0]; | |
| 218 | + | const dt = (p.t - first.t) / 1000; | |
| 219 | + | const vx = dt > 0 ? (p.x - first.x) / dt : 0; | |
| 220 | + | const vy = dt > 0 ? (p.y - first.y) / dt : 0; | |
| 221 | + | // A flick that curves puts a turn on the tile, as one out of a hand does. | |
| 222 | + | const spin = dt > 0 ? ((p.x - first.x) * 0.004) / dt / 100 : 0; | |
| 223 | + | pool.releaseGrab(vx, vy, spin); | |
| 224 | + | samples = []; | |
| 225 | + | wake(); | |
| 226 | + | }; | |
| 227 | + | ||
| 228 | + | table.addEventListener('pointerdown', down, true); | |
| 229 | + | table.addEventListener('pointermove', move, true); | |
| 230 | + | table.addEventListener('pointerup', up, true); | |
| 231 | + | table.addEventListener('pointercancel', up, true); | |
| 232 | + | return () => { | |
| 233 | + | table.removeEventListener('pointerdown', down, true); | |
| 234 | + | table.removeEventListener('pointermove', move, true); | |
| 235 | + | table.removeEventListener('pointerup', up, true); | |
| 236 | + | table.removeEventListener('pointercancel', up, true); | |
| 209 | 237 | }; | |
| 210 | 238 | } | |
| 211 | 239 | ||
| ⋯ 70 unchanged lines | |||
| 282 | 310 | hand.addEventListener('animationend', () => hand.classList.remove('shoved'), { once: true }); | |
| 283 | 311 | } | |
| 284 | 312 | ||
| 285 | - | /** | |
| 286 | - | * Park the reachable patch over the pool. Driven off the measurement rather | |
| 287 | - | * than restated in CSS, for the same reason the colliders are: the wall square's | |
| 288 | - | * size is worked out in one place and this follows it. | |
| 289 | - | */ | |
| 290 | - | function placeReach(el: HTMLDivElement | null, pool: TablePool) { | |
| 291 | - | if (!el) return; | |
| 292 | - | const r = pool.poolRect; | |
| 293 | - | if (!r) { | |
| 294 | - | el.style.display = 'none'; | |
| 295 | - | el.dataset.box = ''; | |
| 296 | - | return; | |
| 297 | - | } | |
| 298 | - | const box = [r.x, r.y, r.w, r.h].map(Math.round); | |
| 299 | - | const stamp = box.join(','); | |
| 300 | - | if (el.dataset.box === stamp) return; | |
| 301 | - | el.dataset.box = stamp; | |
| 302 | - | el.style.display = ''; | |
| 303 | - | el.style.left = `${box[0]}px`; | |
| 304 | - | el.style.top = `${box[1]}px`; | |
| 305 | - | el.style.width = `${box[2]}px`; | |
| 306 | - | el.style.height = `${box[3]}px`; | |
| 307 | - | } | |
| 308 | - | ||
| 309 | 313 | function paint( | |
| 310 | 314 | ctx: CanvasRenderingContext2D, | |
| 311 | 315 | canvas: HTMLCanvasElement, | |
| ⋯ 114 unchanged lines | |||
| 426 | 430 | const live = world.bodies.find((b) => !b.resting) ?? pool.newest; | |
| 427 | 431 | ||
| 428 | 432 | for (const b of world.bodies) { | |
| 433 | + | // A tile in the fingers is held over everything and collides with nothing, | |
| 434 | + | // so it gets no box: the boxes are what is solid. | |
| 435 | + | if (b.carried) continue; | |
| 429 | 436 | ctx.strokeStyle = b === live ? 'rgba(255, 45, 32, 0.9)' : 'rgba(255, 45, 32, 0.35)'; | |
| 430 | 437 | ctx.save(); | |
| 431 | 438 | ctx.translate(b.x, b.y); | |
| ⋯ 273 unchanged lines | |||