anvilsign in

collin/mahjong · bbdef458

Count a tile arriving in somebody's tiles however it got there

Collin Richards · 2026-08-19 21:51 UTC · bbdef458b0281651b752400593a3cd48630b0a57 · parent a8f341f6 · browse files

modifiedREADME.md+6 −4
⋯ 71 unchanged lines
7272 there, so a gap off to one side counts, and a stack worn down to a single tile
7373 is low enough to go over. A seat whose wall is still up says 牆未開 on its
7474 nameplate, so a lob is never a mystery.
75-- **Throw it too hard** and it comes off the pool and into somebody's tiles —
76- the middle stops exactly where their hand starts. Their row takes the knock
77- and rocks back, and with 報牌 on they tell you about it (喂,小心點). Never
78- your own edge: you cannot be barged by your own discard.
75+- **Throw it badly** and it ends up in somebody's tiles — the middle stops
76+ exactly where their hand starts, so a tile that comes off the pool arrives in
77+ their lap, and one that falls short lands out by their seat. Either way their
78+ row takes the knock and rocks back, and with 報牌 on they tell you about it
79+ (喂,小心點). How hard it got there only decides how loudly. Never your own
80+ edge: you cannot be barged by your own discard.
7981 - **Arranging** — drag any tile in your hand to reorder it; 理牌 sorts it back
8082 into suit order. Hands are never auto-sorted after the deal, so an arrangement
8183 you set up survives draws, claims and kongs.
⋯ 334 unchanged lines
modifiedsrc/table/physics.test.ts+73 −7
⋯ 183 unchanged lines
184184 // The middle stops where a player's tiles start, so an edge taken at speed
185185 // is a discard thrown into somebody's hand — which is a thing they answer.
186186 const world = createWorld(POOL);
187- const b = addBody(world, {
187+ addBody(world, {
188188 tile: 0,
189189 seat: 2,
190190 x: POOL.x + POOL.w - 30,
⋯ 13 unchanged lines
204204 for (const hit of step(world)) if (hit.edge) edges.push(`${hit.edge}:${hit.seat}`);
205205 }
206206 expect(edges[0]).toBe('right:2');
207- // And a tile that merely rolls up to the edge is not an incident.
208- b.resting = false;
209- b.vx = 20;
210- const gentle = [];
211- for (let i = 0; i < 60; i++) for (const hit of step(world)) if (hit.edge) gentle.push(hit);
212- expect(gentle).toHaveLength(0);
207+
208+ // A tile that merely rolls into the edge counts too — arriving in
209+ // somebody's tiles is arriving in them however gently it happened.
210+ const gentle = createWorld(POOL);
211+ addBody(gentle, {
212+ tile: 0,
213+ seat: 1,
214+ x: POOL.x + POOL.w - 20,
215+ y: POOL.y + POOL.h / 2,
216+ vx: 90,
217+ vy: 0,
218+ z: 0,
219+ vz: 0,
220+ angle: 0,
221+ spin: 0,
222+ w: TILE.w,
223+ h: TILE.h,
224+ });
225+ const rolled: string[] = [];
226+ for (let i = 0; i < SETTLE_CAP && !settled(gentle); i++) {
227+ for (const hit of step(gentle)) if (hit.edge) rolled.push(`${hit.edge}:${hit.seat}`);
228+ }
229+ expect(rolled[0]).toBe('right:1');
230+
231+ // But a tile lying against the edge is not arriving anywhere. It is held
232+ // there every single step, and every one of those would be a complaint.
233+ const held = createWorld(POOL);
234+ const still = addBody(held, {
235+ tile: 0,
236+ seat: 1,
237+ x: POOL.x + POOL.w,
238+ y: POOL.y + POOL.h / 2,
239+ vx: 0,
240+ vy: 0,
241+ z: 0,
242+ vz: 0,
243+ angle: 0,
244+ spin: 0,
245+ w: TILE.w,
246+ h: TILE.h,
247+ atRest: true,
248+ });
249+ still.resting = false;
250+ const pinned = [];
251+ for (let i = 0; i < 120; i++) for (const hit of step(held)) if (hit.edge) pinned.push(hit);
252+ expect(pinned).toHaveLength(0);
253+ });
254+
255+ it('says whose side a tile that landed short came in from', () => {
256+ // Nothing was struck on the way — it simply fell short, out by somebody's
257+ // seat, and came onto the table past them. Still theirs to complain about.
258+ const world = createWorld(POOL);
259+ addBody(world, {
260+ tile: 0,
261+ seat: 2,
262+ x: POOL.x - 120,
263+ y: POOL.y + POOL.h / 2,
264+ vx: 0,
265+ vy: 0,
266+ z: 0,
267+ vz: 0,
268+ angle: 0,
269+ spin: 0,
270+ w: TILE.w,
271+ h: TILE.h,
272+ });
273+
274+ const edges: string[] = [];
275+ for (let i = 0; i < SETTLE_CAP && !settled(world); i++) {
276+ for (const hit of step(world)) if (hit.edge) edges.push(`${hit.edge}:${hit.seat}`);
277+ }
278+ expect(edges[0]).toBe('left:2');
213279 });
214280
215281 it('always comes to rest face up, however it tumbles', () => {
⋯ 347 unchanged lines
modifiedsrc/table/physics.ts+28 −3
⋯ 355 unchanged lines
356356 x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h;
357357
358358 /**
359+ * Whose side of the table a tile has ended up off, if it is off any of them.
360+ * The furthest it is out, so a tile that overshot a corner belongs to whichever
361+ * of the two it went past by more.
362+ */
363+function outsideEdge(r: Rect, b: Body): Edge | undefined {
364+ const out: [Edge, number][] = [
365+ ['left', r.x - b.x],
366+ ['right', b.x - (r.x + r.w)],
367+ ['top', r.y - b.y],
368+ ['bottom', b.y - (r.y + r.h)],
369+ ];
370+ let worst: [Edge, number] = out[0];
371+ for (const one of out) if (one[1] > worst[1]) worst = one;
372+ return worst[1] > 0 ? worst[0] : undefined;
373+}
374+
375+/**
359376 * Run the world forward. `elapsed` is real seconds since the last call; it is
360377 * consumed in fixed steps so the result does not depend on the frame rate.
361378 * Returns the impacts worth a sound.
⋯ 63 unchanged lines
425442
426443 if (inside(world.bounds, b.x, b.y)) b.entered = true;
427444 else if (b.age > ENTRY_GRACE) {
428- // Out of time going the long way round. Put it in, gently.
445+ // Out of time going the long way round. Put it in, gently — but this is a
446+ // tile that ended up off the table on somebody's side, which is worth
447+ // saying so even though nothing was actually struck.
448+ const edge = outsideEdge(world.bounds, b);
449+ if (edge) impacts.push({ x: b.x, y: b.y, strength: 0.3, edge, seat: b.seat });
429450 b.entered = true;
430451 b.z = 0;
431452 b.vz = 0;
⋯ 149 unchanged lines
581602 // Only a real knock counts as being disturbed. A tile held against the edge
582603 // by its neighbours is clamped every single step, and treating that as a knock
583604 // would keep the whole pool awake for ever.
605+ //
606+ // Anything that does arrive under its own steam is reported, however gently:
607+ // the edge is where somebody's tiles are, so a discard rolling into them is
608+ // an event whatever speed it got there at. Which of them are loud enough to
609+ // be worth a sound is the caller's business.
584610 if (hit > SLEEP_SPEED) {
585611 b.still = 0;
586- const strength = Math.min(1, hit / 1100);
587- if (strength > 0.12) impacts.push({ x: b.x, y: b.y, strength, edge, seat: b.seat });
612+ impacts.push({ x: b.x, y: b.y, strength: Math.min(1, hit / 1100), edge, seat: b.seat });
588613 }
589614 }
590615
⋯ 237 unchanged lines
modifiedsrc/ui/Pool.tsx+9 −9
⋯ 69 unchanged lines
7070 const impacts = pool.advance(dt);
7171 let loudest = 0;
7272 for (const i of impacts) {
73- loudest = Math.max(loudest, i.strength);
74- if (i.edge && i.strength > BARGE) shove(table, EDGE_SEAT[i.edge], i.seat, i.strength);
73+ // A contact quiet enough not to be worth hearing still counts as a tile
74+ // arriving in somebody's lap, which is why the two thresholds differ.
75+ if (i.strength > AUDIBLE) loudest = Math.max(loudest, i.strength);
76+ if (i.edge) shove(table, EDGE_SEAT[i.edge], i.seat, i.strength);
7577 }
7678 if (loudest > 0) knock(loudest);
7779 paint(ctx, canvas, pool, live.current.art);
⋯ 117 unchanged lines
195197 /** How high a tile in somebody's fingers is held above the felt. */
196198 const HELD_Z = 52;
197199
198-/**
199- * Hard enough to be a throw at somebody rather than a tile coming to rest
200- * against the near side. A tile that merely rolls up to the edge is not an
201- * incident; one still doing a couple of hundred pixels a second is.
202- */
203-const BARGE = 0.3;
200+/** Quieter than this and the contact is not worth a clack of its own. */
201+const AUDIBLE = 0.12;
204202
205203 /**
206- * A tile has come off the pool and into this seat's tiles.
204+ * A tile has ended up in this seat's tiles — knocked into them, or simply come
205+ * to a stop off the table on their side. Either way it is theirs to complain
206+ * about, and how hard it arrived only decides how loudly.
207207 *
208208 * Their row takes the knock — straight on the element rather than through
209209 * React, because this happens inside the physics loop and re-rendering four
⋯ 315 unchanged lines