anvilsign in

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
484484 had rather than a freshly scattered one — positions are derived from the hand
485485 number and each tile's place in its thrower's discards, never saved.
486486
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+
487496 A flick is the whole of a throw: the direction and the speed it leaves at are
488497 the hand's, and nothing between the fingers and the felt changes either. The one
489498 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
582582 pointer-events: none;
583583 z-index: 1;
584584 }
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 {
592591 touch-action: none;
593- cursor: grab;
594-}
595-.pool-reach:active {
596- cursor: grabbing;
597592 }
598593
599594 /* ---------- center ---------- */
⋯ 1703 unchanged lines
modifiedsrc/table/physics.ts+63 −40
⋯ 104 unchanged lines
105105 /** How fast a tile drawn at hand size shrinks to the size it is in the pool. */
106106 const SCALE_EASE = 6;
107107
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-
114108 export const TAU = Math.PI * 2;
115109 /**
116110 * How quickly whatever is left of a tumble is taken up once the tile is down.
⋯ 73 unchanged lines
190184 * tiles are not solid to you until you are out of them.
191185 */
192186 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;
193194 resting: boolean;
194195 /** Consecutive slow steps, counting towards sleep. */
195196 still: number;
⋯ 106 unchanged lines
302303 entered: !!s.atRest,
303304 // A rebuilt pile is already lying in the middle, not in anybody's hand.
304305 leftSeat: !!s.atRest,
306+ carried: false,
305307 resting: !!s.atRest,
306308 still: s.atRest ? SLEEP_STEPS : 0,
307309 age: 0,
⋯ 166 unchanged lines
474476 b.scale += (1 - b.scale) * Math.min(1, SCALE_EASE * dt);
475477 if (Math.abs(b.scale - 1) < 0.002) b.scale = 1;
476478 }
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+ }
477486 if (b.resting) continue;
478487 b.age++;
479488 b.awake++;
⋯ 431 unchanged lines
911920 }
912921
913922 /**
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.
920927 */
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;
932929
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;
950941 }
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;
951974 }
952975
953976 /** 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
1919 liftFor,
2020 reshape,
2121 settled,
22- stir,
22+ drop,
23+ lift,
24+ tileAt,
2325 SLIDE_TIME,
2426 type Body,
2527 type Impact,
⋯ 65 unchanged lines
9193 * A wall's worth: enough to test a crowded pool, not enough to bury it. */
9294 const LOOSE_LIMIT = 60;
9395
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+
94100 /** A tile lifted out of a hand and carried about. Drawn, but not simulated. */
95101 export interface Held {
96102 seat: SeatId;
⋯ 71 unchanged lines
168174 * threw reads exactly like a tile that bounced off something invisible.
169175 */
170176 throws: ThrowMark[] = [];
177+ /** The discard being dragged about, if any. */
178+ private carried: Body | null = null;
171179 /**
172180 * Sandbox throws: real tiles in the real world, but belonging to no discard,
173181 * so the reconciler never looks at them — nothing in `entries` means nothing
⋯ 18 unchanged lines
192200 this.world = null;
193201 this.geo = null;
194202 this.loose = [];
203+ this.carried = null;
195204 this.held = null;
196205 this.release = null;
197206 this.entries.clear();
⋯ 46 unchanged lines
244253 }
245254
246255 /**
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.
250262 */
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;
255299 }
256300
257301 // ---- the tile in your fingers -----------------------------------------
⋯ 179 unchanged lines
437481 if (want.has(key)) continue;
438482 const at = world.bodies.indexOf(body);
439483 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;
440486 this.entries.delete(key);
441487 taken.push(body);
442488 }
⋯ 98 unchanged lines
541587
542588 /** Nothing is moving, so the render loop can stop until something changes. */
543589 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+ );
545596 }
546597
547598 // ---- measuring ---------------------------------------------------------
⋯ 208 unchanged lines
modifiedsrc/ui/Pool.tsx+74 −67
⋯ 47 unchanged lines
4848 viewSeat?: SeatId;
4949 }) {
5050 const canvasRef = useRef<HTMLCanvasElement>(null);
51- const reachRef = useRef<HTMLDivElement>(null);
5251 const art = useSheet();
53- const wash = useWash(pool);
5452
5553 // What the loop reads. Refreshed on every render, read on every frame.
5654 const live = useRef({ state: game.state, art, viewSeat });
⋯ 10 unchanged lines
6765
6866 pool.attach(table);
6967 exposePool(pool);
68+ const stopDragging = dragging(table as HTMLElement, pool, () => wake.current());
7069
7170 let frame = 0;
7271 let previous = 0;
⋯ 18 unchanged lines
9190 previous = now;
9291
9392 pool.sync(live.current.state);
94- placeReach(reachRef.current, pool);
9593 // A tile in the fingers goes over the table's own buttons — seat 0 throws
9694 // from the *right-hand end* of its row, which is directly under the undo
9795 // button — so while one is being carried they stop taking the pointer.
⋯ 43 unchanged lines
141139 resize();
142140
143141 return () => {
142+ stopDragging();
144143 window.removeEventListener('keydown', onKey);
145144 stopColliders();
146145 observer.disconnect();
⋯ 10 unchanged lines
157156 wake.current();
158157 });
159158
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 />;
169160 }
170161
171162 /**
172- * Washing the tiles about with a finger.
163+ * Picking a discard up and putting it somewhere else.
173164 *
174165 * 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.
177175 */
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 }[] = [];
180178
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 };
185182 };
186183
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);
209237 };
210238 }
211239
⋯ 70 unchanged lines
282310 hand.addEventListener('animationend', () => hand.classList.remove('shoved'), { once: true });
283311 }
284312
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-
309313 function paint(
310314 ctx: CanvasRenderingContext2D,
311315 canvas: HTMLCanvasElement,
⋯ 114 unchanged lines
426430 const live = world.bodies.find((b) => !b.resting) ?? pool.newest;
427431
428432 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;
429436 ctx.strokeStyle = b === live ? 'rgba(255, 45, 32, 0.9)' : 'rgba(255, 45, 32, 0.35)';
430437 ctx.save();
431438 ctx.translate(b.x, b.y);
⋯ 273 unchanged lines