anvilsign in

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
102102 }
103103 });
104104
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+
105171 it('is deterministic — the same throws give the same pile', () => {
106172 const pile = () => {
107173 const rng = makeRng(99);
⋯ 192 unchanged lines
modifiedsrc/table/physics.ts+22 −11
⋯ 36 unchanged lines
3737 */
3838 export const WALL_HEIGHT = 30;
3939
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;
4653
4754 /**
4855 * Roughly how much longer a tile keeps travelling after it lands, as a time.
⋯ 2 unchanged lines
5158 */
5259 export const SLIDE_TIME = 1 / FELT_DAMP;
5360
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;
5766 /** How much of a glancing blow becomes spin. Tiles are light and skate. */
5867 const SPIN_TRANSFER = 0.06;
5968
⋯ 300 unchanged lines
360369 for (const wall of world.walls) hitWall(b, wall, impacts);
361370 }
362371
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);
364375 }
365376
366377 collideTiles(world, impacts);
⋯ 1 unchanged line
368379 // that then fell asleep out there would stay out there. Only awake ones need
369380 // it: resolvePair never moves a tile that has settled.
370381 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);
372383 }
373384 // Deciding to sleep comes last, so it judges where the step actually left
374385 // 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
3939 /** A thrown tile hops rather than lofts — it is going through a gap, not over. */
4040 const THROW_LIFT = 0.12;
4141 /** 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;
4343 /** How long a claimed tile takes to leave the table for the seat taking it. */
4444 const TAKE_FLIGHT = 0.34;
4545
⋯ 351 unchanged lines
397397 let vz = 0;
398398
399399 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.
400405 const centre = centreOf(geo.pool);
401406 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;
405408 const speed = Math.hypot(vx, vy);
406409 const aim = Math.min(speed, cap) / (speed || 1);
407410 vx *= aim;
⋯ 65 unchanged lines
modifiedsrc/ui/Hand.tsx+54 −25
⋯ 44 unchanged lines
4545
4646 /** How far a pointer must travel before a tap becomes a drag. */
4747 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+ */
4954 const LIFT_OUT = 22;
5055 /**
5156 * A release this fast is a throw whatever else is true. Below it, the tile has
⋯ 107 unchanged lines
159164 const [carrying, setCarrying] = useState<number | null>(null);
160165
161166 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+ }
163176 if (!ref.current) return;
164177 drag.current = {
165178 from: i,
⋯ 15 unchanged lines
181194
182195 const dx = e.clientX - d.startX;
183196 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.
184199 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;
185203
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);
200224 return;
201225 }
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+ }
210238 }
211239
240+ d.mode = 'sort';
212241 const to = nearestIndex(ref.current, e.clientX, e.clientY);
213242 if (to !== null && to !== d.cur) {
214243 onMove(d.cur, to);
215244 d.cur = to;
216- setSorting(to);
217245 }
246+ setSorting(d.cur);
218247 };
219248
220249 const up = (e: React.PointerEvent) => {
⋯ 90 unchanged lines
modifiedsrc/ui/Pool.tsx+53 −10
⋯ 140 unchanged lines
141141
142142 // Shadows in one pass of their own, offset before rotation, so the light comes
143143 // 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);
146146
147147 const newest = pool.newest;
148148 for (const b of bodies) {
⋯ 28 unchanged lines
177177 }
178178
179179 /**
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.
183190 */
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+
184218 function shadow(
185219 ctx: CanvasRenderingContext2D,
186220 x: number,
⋯ 2 unchanged lines
189223 angle: number,
190224 w: number,
191225 h: number,
226+ scale: number,
192227 ) {
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);
193233 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);
196235 ctx.translate(x + z * 0.12, y + 3 + z * 0.34);
197236 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+ );
201244 ctx.restore();
202245 }
203246
⋯ 33 unchanged lines