anvilsign in

collin/mahjong

1import type { Tile } from '../game/tiles';
2import type { SeatId } from '../game/types';
3
4/**
5 * The tiles in the middle of the table, as things with weight.
6 *
7 * Pure and deterministic: no DOM, no clock, no randomness. Where the world's
8 * measurements come from is `geometry.ts`; what gets thrown into it and when is
9 * `pool.ts`; drawing it is `ui/Pool.tsx`. Anything random — the spread on a
10 * placed tile, the scatter a reloaded pile is rebuilt from — is decided by the
11 * caller and arrives as a spawn impulse, which is what lets the same hand come
12 * back to the same pile after a refresh.
13 *
14 * Everything is in CSS pixels, table-local, and seconds.
15 */
16
17/** One fixed step. Small enough that a hard flick can't tunnel a wall. */
18export const FIXED_DT = 1 / 120;
19/**
20 * Most catch-up a single frame will simulate. A tab that was in the background
21 * for a minute comes back to a settled pile, not to a spin through 7200 steps.
22 */
23const MAX_SUBSTEPS = 8;
24
25/** Falls onto the felt in about half a second from a normal throw. */
26const GRAVITY = 2600;
27/** Tiles are dense and land flat; they do not bounce much. */
28const Z_RESTITUTION = 0.34;
29/** Below this the tile is flat on the felt: sliding, and in the way of others. */
30const ON_FELT = 8;
31/**
32 * How high a standing stack of two reaches. A tile still above this sails over
33 * the wall — which is exactly what a *placed* tile does, and what a thrown one
34 * must not need to do.
35 */
36export const WALL_HEIGHT = 30;
37
38/** Felt friction: an exponential part that kills speed, and a constant part
39 * that actually brings it to a stop rather than to an asymptote. */
40const FELT_DAMP = 4.2;
41const FELT_STOP = 90;
42const SPIN_DAMP = 5;
43const SPIN_STOP = 1.2;
44
45const BOUND_RESTITUTION = 0.42;
46const BOUND_FRICTION = 0.86;
47const TILE_RESTITUTION = 0.4;
48
49/** Asleep after this many consecutive steps of going nowhere. */
50const SLEEP_SPEED = 7;
51const SLEEP_SPIN = 0.2;
52const SLEEP_STEPS = 10;
53/**
54 * Overlap small enough to live with. Without this a crowded pool never sleeps:
55 * a tile squeezed between its neighbours and the pool's edge gets pushed out of
56 * contact and clamped back into it on every step, for ever, and each of those
57 * counts as being disturbed. Tolerating a fraction of a pixel costs nothing to
58 * look at and is what lets the pile go quiet.
59 */
60const CONTACT_SLOP = 0.5;
61/**
62 * A body moving this slowly for this long is put to sleep whatever it thinks it
63 * is doing. The backstop that guarantees the pile always settles — counted from
64 * when it last woke, not from when it was thrown.
65 */
66const AWAKE_LIMIT = 600;
67
68/** Pulls a tile that landed short of the pool in towards the rest of them. */
69const ENTRY_PULL = 700;
70/**
71 * How long a tile gets to find its own way into the pool before it is simply
72 * put there. A discard is game state made visible — it has to be in the middle
73 * where it can be seen and counted — so a tile that has got itself wedged
74 * against a standing stack, or is bouncing between two of them, cannot be left
75 * out there. Two seconds is far longer than any real throw takes to settle.
76 */
77const ENTRY_GRACE = 240;
78
79/** Quietest impact worth a sound, as a fraction of a hard landing. */
80const IMPACT_FLOOR = 0.12;
81
82export interface Rect {
83 x: number;
84 y: number;
85 w: number;
86 h: number;
87}
88
89export interface Body {
90 tile: Tile;
91 /** Who threw it — for the fly-out when it is claimed, not for any marking. */
92 seat: SeatId;
93 /** Centre on the felt plane. */
94 x: number;
95 y: number;
96 vx: number;
97 vy: number;
98 /** Height above the felt, and its rate. Only ever >= 0. */
99 z: number;
100 vz: number;
101 angle: number;
102 spin: number;
103 w: number;
104 h: number;
105 /**
106 * Whether it has ever been inside the pool. Until it has, it is free to fly
107 * over the wall from a seat's edge; once it has, the pool keeps it.
108 */
109 entered: boolean;
110 resting: boolean;
111 /** Consecutive slow steps, counting towards sleep. */
112 still: number;
113 /** Steps lived, for the grace period on getting into the pool. */
114 age: number;
115 /** Steps since it last woke, for the backstop on settling. */
116 awake: number;
117}
118
119/** A collision loud enough to hear. `strength` is 0..1. */
120export interface Impact {
121 x: number;
122 y: number;
123 strength: number;
124}
125
126export interface World {
127 /** Where tiles come to rest — inside the wall square. */
128 bounds: Rect;
129 /** Stacks of two still standing. Half and spent stacks are not obstacles. */
130 walls: Rect[];
131 bodies: Body[];
132 /** Left-over time from the last frame, so steps stay a fixed size. */
133 carry: number;
134}
135
136export interface Spawn {
137 tile: Tile;
138 seat: SeatId;
139 x: number;
140 y: number;
141 vx: number;
142 vy: number;
143 z: number;
144 vz: number;
145 angle: number;
146 spin: number;
147 w: number;
148 h: number;
149 /** Already settled — a pile being rebuilt after a reload or an undo. */
150 atRest?: boolean;
151}
152
153export function createWorld(bounds: Rect, walls: Rect[] = []): World {
154 return { bounds, walls, bodies: [], carry: 0 };
155}
156
157export function addBody(world: World, s: Spawn): Body {
158 const body: Body = {
159 tile: s.tile,
160 seat: s.seat,
161 x: s.x,
162 y: s.y,
163 vx: s.atRest ? 0 : s.vx,
164 vy: s.atRest ? 0 : s.vy,
165 z: s.atRest ? 0 : s.z,
166 vz: s.atRest ? 0 : s.vz,
167 angle: s.angle,
168 spin: s.atRest ? 0 : s.spin,
169 w: s.w,
170 h: s.h,
171 entered: !!s.atRest,
172 resting: !!s.atRest,
173 still: s.atRest ? SLEEP_STEPS : 0,
174 age: 0,
175 awake: 0,
176 };
177 world.bodies.push(body);
178 // A tile dropped straight into a settled pile still has to make room.
179 if (s.atRest) separate(world, body);
180 return body;
181}
182
183/** Collision radius. A shade under the tile's mean half-extent, so a pool
184 * overlaps the way a real one does instead of packing like marbles. */
185const radiusOf = (b: Body) => (b.w + b.h) * 0.22;
186
187const inside = (r: Rect, x: number, y: number) =>
188 x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h;
189
190/**
191 * Run the world forward. `elapsed` is real seconds since the last call; it is
192 * consumed in fixed steps so the result does not depend on the frame rate.
193 * Returns the impacts worth a sound.
194 */
195export function advance(world: World, elapsed: number): Impact[] {
196 world.carry += Math.max(0, elapsed);
197 let steps = Math.floor(world.carry / FIXED_DT);
198 if (steps > MAX_SUBSTEPS) {
199 // Drop the backlog rather than working through it.
200 world.carry = 0;
201 steps = MAX_SUBSTEPS;
202 } else {
203 world.carry -= steps * FIXED_DT;
204 }
205
206 const impacts: Impact[] = [];
207 for (let i = 0; i < steps; i++) step(world, impacts);
208 return impacts;
209}
210
211/** One fixed step. Exported for the tests, which drive it directly. */
212export function step(world: World, impacts: Impact[] = []): Impact[] {
213 const dt = FIXED_DT;
214
215 for (const b of world.bodies) {
216 if (b.resting) continue;
217 b.age++;
218 b.awake++;
219
220 // --- through the air ---
221 if (b.z > 0 || b.vz !== 0) {
222 b.vz -= GRAVITY * dt;
223 b.z += b.vz * dt;
224 if (b.z <= 0) {
225 const landing = Math.abs(b.vz);
226 b.z = 0;
227 // Below a knock it stops dead rather than shivering out a bounce.
228 b.vz = landing > 40 ? landing * Z_RESTITUTION : 0;
229 const strength = Math.min(1, landing / 900);
230 if (strength > IMPACT_FLOOR) impacts.push({ x: b.x, y: b.y, strength });
231 }
232 }
233
234 b.x += b.vx * dt;
235 b.y += b.vy * dt;
236 b.angle += b.spin * dt;
237
238 if (inside(world.bounds, b.x, b.y)) b.entered = true;
239 else if (b.age > ENTRY_GRACE) {
240 // Out of time going the long way round. Put it in, gently.
241 b.entered = true;
242 b.z = 0;
243 b.vz = 0;
244 clampInto(world.bounds, b);
245 }
246
247 // --- on the felt ---
248 if (b.z <= ON_FELT) {
249 damp(b, dt);
250 // A tile that fell short skids in rather than sitting outside the pool.
251 if (!b.entered) pullIn(world, b, dt);
252 }
253
254 // A tile still above the wall is clear of it.
255 if (b.z < WALL_HEIGHT) {
256 for (const wall of world.walls) hitWall(b, wall, impacts);
257 }
258
259 if (b.entered) keepInBounds(world, b, impacts);
260 }
261
262 collideTiles(world, impacts);
263 // Deciding to sleep comes last, so it judges where the step actually left
264 // things — before, a contact resolved after the decision would undo it and
265 // a crowded pool would stay awake for ever.
266 for (const b of world.bodies) if (!b.resting) trySleep(b);
267 return impacts;
268}
269
270function damp(b: Body, dt: number) {
271 const k = Math.exp(-FELT_DAMP * dt);
272 b.vx *= k;
273 b.vy *= k;
274 b.spin *= Math.exp(-SPIN_DAMP * dt);
275
276 const speed = Math.hypot(b.vx, b.vy);
277 if (speed > 0) {
278 const next = Math.max(0, speed - FELT_STOP * dt);
279 b.vx = (b.vx / speed) * next;
280 b.vy = (b.vy / speed) * next;
281 }
282 const spin = Math.abs(b.spin);
283 if (spin > 0) {
284 const next = Math.max(0, spin - SPIN_STOP * dt);
285 b.spin = Math.sign(b.spin) * next;
286 }
287}
288
289/** Nudges a tile that landed outside the pool towards the rest of them. */
290function pullIn(world: World, b: Body, dt: number) {
291 const cx = world.bounds.x + world.bounds.w / 2;
292 const cy = world.bounds.y + world.bounds.h / 2;
293 const dx = cx - b.x;
294 const dy = cy - b.y;
295 const d = Math.hypot(dx, dy) || 1;
296 b.vx += (dx / d) * ENTRY_PULL * dt;
297 b.vy += (dy / d) * ENTRY_PULL * dt;
298}
299
300/** Bounce off a standing stack. The stacks are axis-aligned, so the shallower
301 * overlap is the axis to push out along. */
302function hitWall(b: Body, wall: Rect, impacts: Impact[]) {
303 const r = radiusOf(b);
304 const left = b.x + r - wall.x;
305 const right = wall.x + wall.w - (b.x - r);
306 const top = b.y + r - wall.y;
307 const bottom = wall.y + wall.h - (b.y - r);
308 if (left <= 0 || right <= 0 || top <= 0 || bottom <= 0) return;
309
310 const speed = Math.hypot(b.vx, b.vy);
311 const min = Math.min(left, right, top, bottom);
312 if (min === left) {
313 b.x = wall.x - r;
314 b.vx = -Math.abs(b.vx) * BOUND_RESTITUTION;
315 } else if (min === right) {
316 b.x = wall.x + wall.w + r;
317 b.vx = Math.abs(b.vx) * BOUND_RESTITUTION;
318 } else if (min === top) {
319 b.y = wall.y - r;
320 b.vy = -Math.abs(b.vy) * BOUND_RESTITUTION;
321 } else {
322 b.y = wall.y + wall.h + r;
323 b.vy = Math.abs(b.vy) * BOUND_RESTITUTION;
324 }
325 b.spin *= 0.7;
326 b.resting = false;
327 b.still = 0;
328 const strength = Math.min(1, speed / 1100);
329 if (strength > IMPACT_FLOOR) impacts.push({ x: b.x, y: b.y, strength });
330}
331
332/** The pool's edge. Once a tile is in, it stays in — this is the invariant the
333 * tests pin, since a discard that slid off the table would be lost. */
334function keepInBounds(world: World, b: Body, impacts: Impact[]) {
335 const r = radiusOf(b);
336 const { x, y, w, h } = world.bounds;
337 // A pool narrower than a tile would otherwise fight itself; centre instead.
338 const minX = w <= r * 2 ? x + w / 2 : x + r;
339 const maxX = w <= r * 2 ? x + w / 2 : x + w - r;
340 const minY = h <= r * 2 ? y + h / 2 : y + r;
341 const maxY = h <= r * 2 ? y + h / 2 : y + h - r;
342
343 let hit = 0;
344 if (b.x < minX) {
345 b.x = minX;
346 hit = Math.max(hit, Math.abs(b.vx));
347 b.vx = Math.abs(b.vx) * BOUND_RESTITUTION;
348 b.vy *= BOUND_FRICTION;
349 } else if (b.x > maxX) {
350 b.x = maxX;
351 hit = Math.max(hit, Math.abs(b.vx));
352 b.vx = -Math.abs(b.vx) * BOUND_RESTITUTION;
353 b.vy *= BOUND_FRICTION;
354 }
355 if (b.y < minY) {
356 b.y = minY;
357 hit = Math.max(hit, Math.abs(b.vy));
358 b.vy = Math.abs(b.vy) * BOUND_RESTITUTION;
359 b.vx *= BOUND_FRICTION;
360 } else if (b.y > maxY) {
361 b.y = maxY;
362 hit = Math.max(hit, Math.abs(b.vy));
363 b.vy = -Math.abs(b.vy) * BOUND_RESTITUTION;
364 b.vx *= BOUND_FRICTION;
365 }
366 // Only a real knock counts as being disturbed. A tile held against the edge
367 // by its neighbours is clamped every single step, and treating that as a
368 // knock would keep the whole pool awake for ever.
369 if (hit > SLEEP_SPEED) {
370 b.still = 0;
371 const strength = Math.min(1, hit / 1100);
372 if (strength > IMPACT_FLOOR) impacts.push({ x: b.x, y: b.y, strength });
373 }
374}
375
376/**
377 * Tile against tile, circles rather than rotated rectangles. A mahjong pool is
378 * a forgiving thing to approximate and tiles are near enough square (1 : 1.375)
379 * that the difference does not read; rotated-rectangle SAT is a drop-in upgrade
380 * here if it ever does.
381 *
382 * Only tiles flat on the felt take part, so a thrown tile passes over the pile
383 * instead of shouldering through it, and only awake ones are checked against
384 * the rest — which is nearly always one tile against a sleeping pile.
385 */
386function collideTiles(world: World, impacts: Impact[]) {
387 const bodies = world.bodies;
388 for (let i = 0; i < bodies.length; i++) {
389 const a = bodies[i];
390 if (a.z > ON_FELT) continue;
391 for (let j = i + 1; j < bodies.length; j++) {
392 const b = bodies[j];
393 if (b.z > ON_FELT) continue;
394 // A pile that has settled is left alone until something disturbs it.
395 if (a.resting && b.resting) continue;
396 resolvePair(a, b, impacts);
397 }
398 }
399}
400
401function resolvePair(a: Body, b: Body, impacts: Impact[]) {
402 const ra = radiusOf(a);
403 const rb = radiusOf(b);
404 const min = ra + rb;
405 let dx = b.x - a.x;
406 let dy = b.y - a.y;
407 let d = Math.hypot(dx, dy);
408 if (d >= min) return;
409 // Resting in contact is not a collision. See CONTACT_SLOP.
410 const overlap = min - d - CONTACT_SLOP;
411 if (overlap <= 0) return;
412
413 if (d < 0.0001) {
414 // Exactly on top of each other: pick an axis so they can get apart.
415 dx = 1;
416 dy = 0;
417 d = 1;
418 }
419 const nx = dx / d;
420 const ny = dy / d;
421
422 // Push apart. A tile that has settled is the heavy one: a tile thrown into a
423 // pool shoulders in, rather than the pool scattering out of its way.
424 const aShare = a.resting === b.resting ? 0.5 : a.resting ? 0 : 1;
425 const bShare = 1 - aShare;
426 a.x -= nx * overlap * aShare;
427 a.y -= ny * overlap * aShare;
428 b.x += nx * overlap * bShare;
429 b.y += ny * overlap * bShare;
430
431 // Impulse along the centre line, plus a spin kick off what is left over.
432 //
433 // Only a genuine approach speed counts, and only that wakes anything. Nudging
434 // two touching tiles apart is not a collision: a jammed corner of the pool is
435 // corrected by a fraction of a pixel every step for as long as it is jammed,
436 // and treating each of those as a shove would keep the pile awake for ever.
437 const rvx = b.vx - a.vx;
438 const rvy = b.vy - a.vy;
439 const along = rvx * nx + rvy * ny;
440 if (along >= -SLEEP_SPEED) return;
441
442 const jolt = -along * (1 + TILE_RESTITUTION) * 0.5;
443 a.vx -= nx * jolt;
444 a.vy -= ny * jolt;
445 b.vx += nx * jolt;
446 b.vy += ny * jolt;
447
448 const tangent = rvx * -ny + rvy * nx;
449 a.spin -= tangent * 0.004;
450 b.spin += tangent * 0.004;
451
452 const strength = Math.min(1, Math.abs(along) / 900);
453 if (strength > IMPACT_FLOOR) impacts.push({ x: b.x, y: b.y, strength });
454
455 // Whatever was asleep has been shoved, so it is awake now.
456 wake(a);
457 wake(b);
458}
459
460function wake(b: Body) {
461 if (b.resting) b.awake = 0;
462 b.resting = false;
463 b.still = 0;
464}
465
466function sleep(b: Body) {
467 b.vx = 0;
468 b.vy = 0;
469 b.spin = 0;
470 b.z = 0;
471 b.vz = 0;
472 b.resting = true;
473 b.awake = 0;
474}
475
476function trySleep(b: Body) {
477 const speed = Math.hypot(b.vx, b.vy);
478 const slow = b.entered && b.z <= 0 && b.vz === 0 && speed < SLEEP_SPEED && Math.abs(b.spin) < SLEEP_SPIN;
479 if (slow && ++b.still >= SLEEP_STEPS) return sleep(b);
480 if (!slow) b.still = 0;
481 // Backstop: shuffling about in a crowded pool for this long is settled enough.
482 if (b.entered && b.awake > AWAKE_LIMIT && speed < SLEEP_SPEED * 4) sleep(b);
483}
484
485/**
486 * Make room for a tile placed straight into a settled pile, without running
487 * time. Used when a pile is rebuilt — a reload, an undo — where the tiles have
488 * to be somewhere sensible immediately and nothing should appear to move.
489 */
490function separate(world: World, body: Body, passes = 12) {
491 // Which way to go when two tiles are exactly on top of each other. Stepping
492 // by the golden angle fans successive tiles out in every direction, where a
493 // fixed axis would march them all one way and pack them against a pool edge.
494 const escapeAngle = world.bodies.indexOf(body) * 2.39996;
495
496 for (let p = 0; p < passes; p++) {
497 let moved = false;
498 for (const other of world.bodies) {
499 if (other === body) continue;
500 const min = radiusOf(body) + radiusOf(other);
501 let dx = body.x - other.x;
502 let dy = body.y - other.y;
503 let d = Math.hypot(dx, dy);
504 if (d >= min) continue;
505 if (d < 0.0001) {
506 dx = Math.cos(escapeAngle + p);
507 dy = Math.sin(escapeAngle + p);
508 d = 1;
509 }
510 body.x = other.x + (dx / d) * min;
511 body.y = other.y + (dy / d) * min;
512 moved = true;
513 }
514 clampInto(world.bounds, body);
515 if (!moved) break;
516 }
517}
518
519function clampInto(r: Rect, b: Body) {
520 const rad = radiusOf(b);
521 const minX = r.w <= rad * 2 ? r.x + r.w / 2 : r.x + rad;
522 const maxX = r.w <= rad * 2 ? r.x + r.w / 2 : r.x + r.w - rad;
523 const minY = r.h <= rad * 2 ? r.y + r.h / 2 : r.y + rad;
524 const maxY = r.h <= rad * 2 ? r.y + r.h / 2 : r.y + r.h - rad;
525 b.x = Math.min(maxX, Math.max(minX, b.x));
526 b.y = Math.min(maxY, Math.max(minY, b.y));
527}
528
529/** Whether everything has settled — what the render loop idles on. */
530export const settled = (world: World) => world.bodies.every((b) => b.resting);
531
532/** Scale a tile is drawn at, given how high it is. Sells the arc on a flat
533 * canvas; the renderer offsets the shadow by the same height. */
534export const zScale = (z: number) => 1 + z * 0.0016;