anvilsign in

collin/mahjong

master / src / table / physics.ts
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 scatter on a
10 * placed tile, where a rebuilt pile's tiles sit — is decided by the caller and
11 * arrives as a spawn impulse, which is what lets the same hand come back to the
12 * same pile after a refresh.
13 *
14 * Tiles are rectangles and they are solved as rectangles: two of them can lean
15 * against each other at any angle but never share the same ground. Everything is
16 * in CSS pixels, table-local, and seconds.
17 */
18
19/** One fixed step. Small enough that a hard flick can't tunnel a wall. */
20export const FIXED_DT = 1 / 120;
21/**
22 * Most catch-up a single frame will simulate. A tab that was in the background
23 * for a minute comes back to a settled pile, not to a spin through 7200 steps.
24 */
25const MAX_SUBSTEPS = 8;
26
27/** Falls onto the felt in about half a second from a normal throw. */
28const GRAVITY = 2600;
29/** Tiles are dense and land flat; they do not bounce much. */
30const Z_RESTITUTION = 0.34;
31/** Below this the tile is flat on the felt: sliding, and in the way of others. */
32const ON_FELT = 8;
33/**
34 * How high a standing stack of two reaches. A tile still above this sails over
35 * the wall — which is exactly what a *placed* tile does, and what a thrown one
36 * must not need to do.
37 */
38export const WALL_HEIGHT = 30;
39
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 */
49const FELT_DAMP = 2.6;
50const FELT_STOP = 70;
51const SPIN_DAMP = 3.4;
52const SPIN_STOP = 0.9;
53
54/**
55 * Roughly how much longer a tile keeps travelling after it lands, as a time.
56 * Aiming a throw means solving for where it comes to *rest*, not where it first
57 * touches down, so `pool.ts` adds this to the flight time.
58 */
59export const SLIDE_TIME = 1 / FELT_DAMP;
60
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. */
63const BOUND_RESTITUTION = 0.55;
64const BOUND_FRICTION = 0.9;
65const TILE_RESTITUTION = 0.42;
66/**
67 * Tile on tile, across the face of the contact. Everything a tile can hit is
68 * another tile: the standing stacks, the sets laid down, the sixteen in
69 * somebody's hand. So there is one number for all of it, and it is what turns
70 * a glancing blow into a turn rather than a slide.
71 */
72const TILE_FRICTION = 0.32;
73/**
74 * How much of a knock's *moment* the tile actually keeps.
75 *
76 * A free rectangle struck off its middle takes the whole of it, and would come
77 * off the corner of a stack spinning like a top. A tile does not, and the
78 * reason is not in the solver: it is lying flat on cloth, and the cloth under
79 * the whole of its face resists the turn for as long as the contact lasts. The
80 * felt is modelled as a drag on the centre, not as a torque on the face, so
81 * that resistance has nowhere else to go and is put back here, as the fraction
82 * of the moment that survives the felt. Only the angular half of a contact is
83 * scaled: a head-on hit is unaffected, because there is no moment in one.
84 */
85const FELT_HOLD = 0.1;
86
87/** Asleep after this many consecutive steps of going nowhere. */
88const SLEEP_SPEED = 7;
89const SLEEP_SPIN = 0.2;
90const SLEEP_STEPS = 10;
91/**
92 * Overlap small enough to live with. Without this a crowded pool never sleeps:
93 * a tile squeezed between its neighbours and the pool's edge gets pushed out of
94 * contact and shoved back into it on every step, for ever. A fifth of a pixel is
95 * not something you can see, and it is what lets the pile go quiet.
96 */
97const CONTACT_SLOP = 0.2;
98/**
99 * A body moving this slowly for this long is put to sleep whatever it thinks it
100 * is doing. The backstop that guarantees the pile always settles — counted from
101 * when it last woke, not from when it was thrown.
102 */
103const AWAKE_LIMIT = 600;
104
105/** Pulls a tile that landed short of the pool in towards the rest of them. */
106const ENTRY_PULL = 700;
107/**
108 * How long a tile gets to find its own way into the pool before it is simply
109 * put there. A discard is game state made visible — it has to be in the middle
110 * where it can be seen and counted — so a tile that has got itself wedged
111 * against a standing stack, or is bouncing between two of them, cannot be left
112 * out there. Two seconds is far longer than any real throw takes to settle.
113 */
114const ENTRY_GRACE = 240;
115
116/**
117 * How hard a tile that merely *arrived* counts as having arrived: enough for
118 * the seat it came in past to notice, deliberately under the threshold the
119 * renderer plays a clack at. Nothing was hit, so nothing should be heard.
120 */
121const ARRIVED = 0.1;
122
123/** How fast a tile drawn at hand size shrinks to the size it is in the pool. */
124const SCALE_EASE = 6;
125
126export const TAU = Math.PI * 2;
127/**
128 * How quickly whatever is left of a tumble is taken up once the tile is down.
129 * A thrown tile turns over in the air a whole number of times, so it should be
130 * face up as it lands — but a bounce or a shove can leave it part-way round, and
131 * a mahjong tile on a table is never resting on its face. This takes up the
132 * remainder, and `sleep` guarantees the rest.
133 */
134const FLIP_SETTLE = 14;
135
136export interface Rect {
137 x: number;
138 y: number;
139 w: number;
140 h: number;
141}
142
143/** Anything the collision routines can treat as a rotated rectangle. */
144interface Box {
145 x: number;
146 y: number;
147 w: number;
148 h: number;
149 angle: number;
150}
151
152export interface Body {
153 tile: Tile;
154 /** Who threw it — for the fly-out when it is claimed, not for any marking. */
155 seat: SeatId;
156 /** Centre on the felt plane. */
157 x: number;
158 y: number;
159 vx: number;
160 vy: number;
161 /** Height above the felt, and its rate. Only ever >= 0. */
162 z: number;
163 vz: number;
164 angle: number;
165 spin: number;
166 /**
167 * Turning over in the air, about an axis lying in the table — what a thrown
168 * tile does, as opposed to `angle`, which is it turning flat on the cloth.
169 * Zero, or any whole number of turns, is face up.
170 */
171 flip: number;
172 flipSpin: number;
173 /** Which way that axis points: a tile tumbles across its line of travel. */
174 flipAxis: number;
175 w: number;
176 h: number;
177 /**
178 * Render-only: what to multiply the drawn size by, easing to 1. A tile is
179 * drawn big in a hand so it can be read, and small in the pool because that is
180 * the size it is in the wall, so a thrown one has to settle from one to the
181 * other. Eased here rather than in the renderer so it does not run at a
182 * different speed on a different frame rate.
183 */
184 scale: number;
185 /**
186 * Whether it has ever been inside the pool. Until it has, it is free to fly
187 * over the wall from a seat's edge; once it has, the pool keeps it.
188 */
189 entered: boolean;
190 /**
191 * Which side of the table it is still on its way in over. Every tile is
192 * thrown from outside, so every tile has one until it lands; it is what the
193 * table reports when the tile finally gets in, since by then there is no
194 * longer an edge to read off its position.
195 */
196 over?: Edge;
197 /**
198 * Whether it has got clear of the hand it was thrown from. A tile leaves from
199 * *inside* its own row — `throwFrom` is the middle of the last cell — so on
200 * the frame it is let go it is overlapping its thrower's own tiles, and
201 * without this every throw would ricochet off the hand that made it. Your own
202 * tiles are not solid to you until you are out of them.
203 */
204 leftSeat: boolean;
205 /**
206 * In somebody's fingers. Its position is set from outside — a finger is not
207 * a force — so the solver leaves it entirely alone: it does not fall, drift,
208 * settle or take a shove, and it is held high enough to pass over everything
209 * else while it is being carried.
210 */
211 carried: boolean;
212 resting: boolean;
213 /** Consecutive slow steps, counting towards sleep. */
214 still: number;
215 /** Steps lived, for the grace period on getting into the pool. */
216 age: number;
217 /** Steps since it last woke, for the backstop on settling. */
218 awake: number;
219}
220
221/**
222 * Which edge of the middle a tile ran into. The middle stops where the four
223 * players' tiles start, so an edge is somebody's hand: a tile that reaches one
224 * hard has been thrown into their tiles, and they have something to say about
225 * it. Seats are the caller's business; this only says which way it went off.
226 */
227export type Edge = 'left' | 'right' | 'top' | 'bottom';
228
229/** A collision loud enough to hear. `strength` is 0..1. */
230export interface Impact {
231 x: number;
232 y: number;
233 strength: number;
234 /** Set when it was the edge of the table it hit, not a wall or another tile. */
235 edge?: Edge;
236 /** Whose tiles it ran into, where what it hit belonged to somebody. */
237 into?: SeatId;
238 /** Whose discard it was — you cannot be barged by your own tile. */
239 seat?: SeatId;
240}
241
242/**
243 * Something solid on the table. Everything that stops a tile is another tile:
244 * the stacks still standing in the wall, the sets people have laid down, and
245 * the sixteen everyone is holding. `seat` is whose it is, where it is somebody's
246 * — a tile thrown into a hand is a thing that hand's owner answers.
247 */
248export interface Barrier {
249 rect: Rect;
250 seat?: SeatId;
251}
252
253export interface World {
254 /** Where tiles come to rest — inside the wall square. */
255 bounds: Rect;
256 /** Stacks of two still standing. Half and spent stacks are not obstacles. */
257 walls: Barrier[];
258 bodies: Body[];
259 /** Left-over time from the last frame, so steps stay a fixed size. */
260 carry: number;
261 /**
262 * Overlaps pushed apart in the last step. The pile is not settled while this
263 * is non-zero, however still everything looks — two tiles asleep inside each
264 * other would otherwise stay that way for the rest of the hand, because a
265 * pile that has stopped moving stops being stepped.
266 */
267 contacts: number;
268 /** Steps spent untangling a pile that is otherwise asleep, so it cannot spin. */
269 relaxed: number;
270}
271
272export interface Spawn {
273 tile: Tile;
274 seat: SeatId;
275 x: number;
276 y: number;
277 vx: number;
278 vy: number;
279 z: number;
280 vz: number;
281 angle: number;
282 spin: number;
283 /** Whole turns to make in the air. It lands face up whatever this is. */
284 flipTurns?: number;
285 /** Seconds of flight those turns are spread over. */
286 flipOver?: number;
287 flipAxis?: number;
288 w: number;
289 h: number;
290 /** Drawn this much bigger to start with, easing to its pool size. */
291 scale?: number;
292 /** Already settled — a pile being rebuilt after a reload or an undo. */
293 atRest?: boolean;
294}
295
296export function createWorld(bounds: Rect, walls: Barrier[] = []): World {
297 return { bounds, walls, bodies: [], carry: 0, contacts: 0, relaxed: 0 };
298}
299
300export function addBody(world: World, s: Spawn): Body {
301 const body: Body = {
302 tile: s.tile,
303 seat: s.seat,
304 x: s.x,
305 y: s.y,
306 vx: s.atRest ? 0 : s.vx,
307 vy: s.atRest ? 0 : s.vy,
308 z: s.atRest ? 0 : s.z,
309 vz: s.atRest ? 0 : s.vz,
310 angle: s.angle,
311 spin: s.atRest ? 0 : s.spin,
312 flip: 0,
313 // Spread the turns over the flight, so the tile comes down as it comes back
314 // round to face up rather than being snapped there on landing.
315 flipSpin:
316 s.atRest || !s.flipTurns || !s.flipOver ? 0 : (TAU * s.flipTurns) / s.flipOver,
317 flipAxis: s.flipAxis ?? 0,
318 w: s.w,
319 h: s.h,
320 scale: s.atRest ? 1 : s.scale ?? 1,
321 entered: !!s.atRest,
322 // A rebuilt pile is already lying in the middle, not in anybody's hand.
323 leftSeat: !!s.atRest,
324 carried: false,
325 resting: !!s.atRest,
326 still: s.atRest ? SLEEP_STEPS : 0,
327 age: 0,
328 awake: 0,
329 };
330 world.bodies.push(body);
331 if (s.atRest) {
332 // A tile dropped straight into a settled pile still has to make room. This
333 // is a first shove only — the relaxing below finishes the job, which matters
334 // for a whole pile rebuilt at once when a saved game is picked up.
335 separate(world, body);
336 world.contacts++;
337 }
338 return body;
339}
340
341// ---- rotated rectangles ---------------------------------------------------
342
343/** Distance from a box's centre to its edge, measured along `ax`. */
344function reach(b: Box, ax: { x: number; y: number }): number {
345 const c = Math.cos(b.angle);
346 const s = Math.sin(b.angle);
347 return (
348 Math.abs(c * ax.x + s * ax.y) * (b.w / 2) + Math.abs(-s * ax.x + c * ax.y) * (b.h / 2)
349 );
350}
351
352/** The rotated rectangle's own bounding box, for the pool's straight edges. */
353function aabbHalf(b: Box) {
354 const c = Math.abs(Math.cos(b.angle));
355 const s = Math.abs(Math.sin(b.angle));
356 return { x: (b.w / 2) * c + (b.h / 2) * s, y: (b.w / 2) * s + (b.h / 2) * c };
357}
358
359/** Enough to skip a pair without doing the real work. */
360const circumradius = (b: Box) => Math.hypot(b.w, b.h) / 2;
361
362/**
363 * Separating axis test between two rectangles at any angle. Returns the
364 * shallowest way out — the direction to push `b` away from `a`, and by how much
365 * — or null if they are clear of each other.
366 *
367 * Four axes is all a pair of rectangles needs: each box's two edge normals.
368 */
369function overlapOf(a: Box, b: Box): { nx: number; ny: number; depth: number } | null {
370 const ca = Math.cos(a.angle);
371 const sa = Math.sin(a.angle);
372 const cb = Math.cos(b.angle);
373 const sb = Math.sin(b.angle);
374 const axes = [
375 { x: ca, y: sa },
376 { x: -sa, y: ca },
377 { x: cb, y: sb },
378 { x: -sb, y: cb },
379 ];
380
381 const dx = b.x - a.x;
382 const dy = b.y - a.y;
383 let depth = Infinity;
384 let nx = 0;
385 let ny = 0;
386
387 for (const ax of axes) {
388 const gap = reach(a, ax) + reach(b, ax) - Math.abs(dx * ax.x + dy * ax.y);
389 if (gap <= 0) return null;
390 if (gap < depth) {
391 depth = gap;
392 // Always pointing from a towards b, so callers need not guess.
393 const sign = dx * ax.x + dy * ax.y < 0 ? -1 : 1;
394 nx = ax.x * sign;
395 ny = ax.y * sign;
396 }
397 }
398 return { nx, ny, depth };
399}
400
401/**
402 * How hard a tile is to turn, as 1 / I.
403 *
404 * A rectangle of uniform weight about its own middle: I = m(w² + h²) / 12.
405 * Every tile on the table weighs the same as every other, so mass is 1
406 * throughout and this is the whole of it. That also means an impulse and a
407 * change of speed are the same number, so the two halves of a contact can be
408 * written side by side.
409 *
410 * Scaled by `FELT_HOLD`, because a tile is turning against cloth and not in
411 * mid-air. See there.
412 */
413function invInertia(b: Box): number {
414 return (12 * FELT_HOLD) / (b.w * b.w + b.h * b.h);
415}
416
417/**
418 * How far out from the middle the felt has hold of a tile, as one radius.
419 *
420 * A tile spinning on the cloth is rubbed over the whole of its face, not at a
421 * point, and what that comes to is an average distance from the middle. A
422 * quarter of the diagonal is close enough to the true mean over a rectangle,
423 * and it is the number that puts a turn and a slide into the same units: a
424 * spin of ω rubs the felt at ω × this many pixels a second, which is a speed,
425 * and can be weighed against how fast the tile is travelling. See `damp`.
426 */
427const gripRadius = (b: Box): number => Math.hypot(b.w, b.h) / 4;
428
429/**
430 * Where a tile is pressing on the thing it has run into.
431 *
432 * The separating axis test says which way to push and by how much, but not
433 * where, and where is the whole of whether a knock turns a tile or only shoves
434 * it. A push through the middle is a shove; the same push at one end is a turn,
435 * and the difference is the arm between the two.
436 *
437 * What touches is a *footprint*, not a point. A tile lying square to a wall
438 * meets it along the whole of one side and is pushed through its own middle,
439 * however far along the wall it happens to be. Turn the same tile forty-five
440 * degrees and it meets the wall on a single corner, and that corner is the only
441 * thing pushing back. `flat` is which of those it is, as |sin 2θ|, because a
442 * rectangle comes square to a face every quarter turn; the footprint shrinks
443 * from the whole side to the one corner as it goes.
444 *
445 * `a` is what was hit, where that has ends: only the part of the footprint the
446 * two of them share counts, so a tile that has caught the end of a stack is
447 * pushed at the end of itself. Left out for the table's own edge, which is a
448 * straight line with no ends to catch.
449 */
450function contactOf(b: Box, nx: number, ny: number, a?: Box): { x: number; y: number } {
451 const tx = -ny;
452 const ty = nx;
453 const flat = Math.abs(Math.sin(2 * b.angle));
454 const half = reach(b, { x: tx, y: ty }) * (1 - flat);
455
456 // The corner that arrived first: all there is to lean on when the tile is
457 // not square to what it hit, and where the footprint collapses to.
458 const c = Math.cos(b.angle);
459 const s = Math.sin(b.angle);
460 const u = (c * nx + s * ny <= 0 ? 1 : -1) * (b.w / 2);
461 const v = (-s * nx + c * ny <= 0 ? 1 : -1) * (b.h / 2);
462 const corner = (b.x + c * u - s * v) * tx + (b.y + s * u + c * v) * ty;
463 const mid = b.x * tx + b.y * ty + (corner - (b.x * tx + b.y * ty)) * flat;
464
465 let lo = mid - half;
466 let hi = mid + half;
467 let across: number;
468 if (a) {
469 const ca = a.x * tx + a.y * ty;
470 const ra = reach(a, { x: tx, y: ty });
471 lo = Math.max(lo, ca - ra);
472 hi = Math.min(hi, ca + ra);
473 if (lo > hi) lo = hi = (lo + hi) / 2;
474 across = a.x * nx + a.y * ny + reach(a, { x: nx, y: ny });
475 } else {
476 across = b.x * nx + b.y * ny - reach(b, { x: nx, y: ny });
477 }
478 const along = (lo + hi) / 2;
479 return { x: tx * along + nx * across, y: ty * along + ny * across };
480}
481
482/** A standing stack, as something the tests above can chew on. */
483const boxOf = (r: Rect, angle = 0): Box => ({
484 x: r.x + r.w / 2,
485 y: r.y + r.h / 2,
486 w: r.w,
487 h: r.h,
488 angle,
489});
490
491// ---- stepping -------------------------------------------------------------
492
493/**
494 * Where a tile's *centre* may be for the whole of it to be inside `r`.
495 *
496 * One place rather than three, because the three have to agree: the edge holds
497 * a tile at these limits, and the moment it starts holding it is the moment the
498 * tile reaches them. Deciding that on the centre instead would mean the tile is
499 * declared in while it is still half out, and the first thing the edge did
500 * would be to shove it the rest of the way — half a tile, in one frame, at a
501 * line that is nowhere near the one you can see.
502 *
503 * A pool narrower than a tile would otherwise fight itself, so it centres.
504 */
505function limitsOf(r: Rect, b: Box) {
506 const half = aabbHalf(b);
507 return {
508 minX: r.w <= half.x * 2 ? r.x + r.w / 2 : r.x + half.x,
509 maxX: r.w <= half.x * 2 ? r.x + r.w / 2 : r.x + r.w - half.x,
510 minY: r.h <= half.y * 2 ? r.y + r.h / 2 : r.y + half.y,
511 maxY: r.h <= half.y * 2 ? r.y + r.h / 2 : r.y + r.h - half.y,
512 };
513}
514
515/** Whether the whole tile has made it in — the edge's own test for `entered`. */
516function wholly(r: Rect, b: Body): boolean {
517 const l = limitsOf(r, b);
518 return b.x >= l.minX && b.x <= l.maxX && b.y >= l.minY && b.y <= l.maxY;
519}
520
521/**
522 * Whose side of the table a tile has ended up off, if it is off any of them.
523 * The furthest it is out, so a tile that overshot a corner belongs to whichever
524 * of the two it went past by more.
525 */
526function outsideEdge(r: Rect, b: Body): Edge | undefined {
527 const out: [Edge, number][] = [
528 ['left', r.x - b.x],
529 ['right', b.x - (r.x + r.w)],
530 ['top', r.y - b.y],
531 ['bottom', b.y - (r.y + r.h)],
532 ];
533 let worst: [Edge, number] = out[0];
534 for (const one of out) if (one[1] > worst[1]) worst = one;
535 return worst[1] > 0 ? worst[0] : undefined;
536}
537
538/**
539 * Run the world forward. `elapsed` is real seconds since the last call; it is
540 * consumed in fixed steps so the result does not depend on the frame rate.
541 * Returns the impacts worth a sound.
542 */
543export function advance(world: World, elapsed: number, stopOnImpact = false): Impact[] {
544 world.carry += Math.max(0, elapsed);
545 let steps = Math.floor(world.carry / FIXED_DT);
546 if (steps > MAX_SUBSTEPS) {
547 // Drop the backlog rather than working through it.
548 world.carry = 0;
549 steps = MAX_SUBSTEPS;
550 } else {
551 world.carry -= steps * FIXED_DT;
552 }
553
554 const impacts: Impact[] = [];
555 for (let i = 0; i < steps; i++) {
556 step(world, impacts);
557 // Freeze on the step that resolved the contact, and drop what is left of
558 // the frame — the debugger wants the tile where it touched, not where it
559 // would have been fifteen milliseconds later. See ui/colliders.ts.
560 if (stopOnImpact && impacts.length) {
561 world.carry = 0;
562 break;
563 }
564 }
565 return impacts;
566}
567
568/** One fixed step. Exported for the tests, which drive it directly. */
569export function step(world: World, impacts: Impact[] = []): Impact[] {
570 const dt = FIXED_DT;
571 world.contacts = 0;
572
573 for (const b of world.bodies) {
574 if (b.scale !== 1) {
575 b.scale += (1 - b.scale) * Math.min(1, SCALE_EASE * dt);
576 if (Math.abs(b.scale - 1) < 0.002) b.scale = 1;
577 }
578 // A tile in the fingers is driven, not simulated. Nothing below applies
579 // to it, and it stays awake so it is drawn where the finger put it.
580 if (b.carried) {
581 b.still = 0;
582 b.awake = 0;
583 continue;
584 }
585 if (b.resting) continue;
586 b.age++;
587 b.awake++;
588
589 // --- through the air ---
590 if (b.z > 0 || b.vz !== 0) {
591 b.vz -= GRAVITY * dt;
592 b.z += b.vz * dt;
593 if (b.z <= 0) {
594 const landing = Math.abs(b.vz);
595 b.z = 0;
596 // Below a knock it stops dead rather than shivering out a bounce.
597 b.vz = landing > 40 ? landing * Z_RESTITUTION : 0;
598 const strength = Math.min(1, landing / 900);
599 if (strength > 0.12) impacts.push({ x: b.x, y: b.y, strength });
600 }
601 }
602
603 b.x += b.vx * dt;
604 b.y += b.vy * dt;
605 b.angle += b.spin * dt;
606
607 // Turning over. In the air it tumbles; once it is down, whatever is left of
608 // the turn is taken up so it ends flat on its back with its face showing.
609 if (b.flipSpin !== 0) {
610 if (b.z > 0) {
611 b.flip += b.flipSpin * dt;
612 // The axis it turns over about is a line drawn on the tile, so it goes
613 // round with the tile: a discard that is spinning as well as tumbling
614 // does not keep tumbling across the same line of the table.
615 b.flipAxis += b.spin * dt;
616 } else {
617 const target = Math.round(b.flip / TAU) * TAU;
618 b.flip += (target - b.flip) * Math.min(1, FLIP_SETTLE * dt);
619 if (Math.abs(target - b.flip) < 0.01) {
620 b.flip = target;
621 b.flipSpin = 0;
622 }
623 }
624 }
625
626 if (!b.entered) {
627 // Kept from step to step: on the step it finally gets in it is inside
628 // already, and there would be nothing left to read.
629 b.over = outsideEdge(world.bounds, b) ?? b.over;
630 // In means all of it in. Anything less and keepInBounds below would
631 // finish the job in a single frame, which is a collision with nothing.
632 if (wholly(world.bounds, b)) {
633 b.entered = true;
634 // It came in over somebody's side of the table. Said quietly — nothing
635 // was struck, so there is nothing to hear — but said, because a tile
636 // arriving past a seat is that seat's to complain about. Its own
637 // thrower's edge is every tile's way in, and the caller drops that one.
638 if (b.over) {
639 impacts.push({ x: b.x, y: b.y, strength: ARRIVED, edge: b.over, seat: b.seat });
640 }
641 b.over = undefined;
642 } else if (b.age > ENTRY_GRACE) {
643 // Out of time going the long way round. Put it in, gently — but this is
644 // still a tile that ended up off the table on somebody's side.
645 if (b.over) {
646 impacts.push({ x: b.x, y: b.y, strength: ARRIVED, edge: b.over, seat: b.seat });
647 }
648 b.entered = true;
649 b.over = undefined;
650 b.z = 0;
651 b.vz = 0;
652 clampInto(world.bounds, b);
653 }
654 }
655
656 // --- on the felt ---
657 // Strictly z <= 0, not ON_FELT: friction is between the tile and the cloth,
658 // so a tile still in the air keeps the speed it was thrown with. ON_FELT is
659 // only about being low enough to be in another tile's way.
660 if (b.z <= 0) {
661 damp(b, dt);
662 // A tile that fell short skids in rather than sitting outside the pool.
663 if (!b.entered) pullIn(world, b, dt);
664 }
665
666 // A tile still above the wall is clear of it.
667 if (b.z < WALL_HEIGHT) {
668 let inOwnHand = false;
669 for (const wall of world.walls) {
670 if (wall.seat === b.seat && !b.leftSeat) {
671 // Still inside the row it was thrown from: note it, but pass through.
672 const over = overlapOf(boxOf(wall.rect), b);
673 if (over && over.depth > CONTACT_SLOP) inOwnHand = true;
674 continue;
675 }
676 hitWall(b, wall, impacts);
677 }
678 if (!inOwnHand) b.leftSeat = true;
679 }
680
681 // The pool's edge is the inside face of the wall, and it is only as tall as
682 // the wall — a tile lobbed high is over the top of it, not stopped by it.
683 if (b.entered && b.z < WALL_HEIGHT) keepInBounds(world, b, impacts);
684 }
685
686 collideTiles(world, impacts);
687 // Being shoved by a neighbour can put a tile back over the edge, and a tile
688 // that then fell asleep out there would stay out there. Only awake ones need
689 // it: resolvePair never moves a tile that has settled.
690 // Resting tiles included: they get pushed about by the untangling above, and
691 // a tile shoved over the edge must not be left there just because it is asleep.
692 for (const b of world.bodies) {
693 if (b.entered && b.z < WALL_HEIGHT) clampInto(world.bounds, b);
694 }
695 // Deciding to sleep comes last, so it judges where the step actually left
696 // things — before, a contact resolved after the decision would undo it and a
697 // crowded pool would stay awake for ever.
698 for (const b of world.bodies) if (!b.resting) trySleep(b);
699
700 // A pile that has stopped moving but is still untangling itself keeps being
701 // stepped, up to a point. Without the limit a configuration that cannot quite
702 // be resolved would keep the loop running for the rest of the hand.
703 world.relaxed = world.contacts > 0 && world.bodies.every((b) => b.resting) ? world.relaxed + 1 : 0;
704 return impacts;
705}
706
707/**
708 * The table has been re-measured — the window resized, or the wall was eaten
709 * further back. The tiles keep where they are, but a pool that shrank must not
710 * leave any of them stranded outside it, and the ones it moves have to wake up
711 * so they can settle again.
712 */
713export function reshape(world: World, bounds: Rect, walls: Barrier[]) {
714 world.bounds = bounds;
715 world.walls = walls;
716 for (const b of world.bodies) {
717 if (!b.entered) continue;
718 const x = b.x;
719 const y = b.y;
720 clampInto(bounds, b);
721 if (b.x !== x || b.y !== y) wake(b);
722 }
723
724 // A wall that has moved may be standing where a tile is lying. Anything it
725 // has come to overlap is woken, and the solver shoves it clear over the next
726 // few steps — which is a wall being pushed across a table taking the discards
727 // along in front of it, and is the whole reason you would push one.
728 //
729 // Only what is awake gets looked at each step, so without this a tile that
730 // had gone to sleep would sit inside the wall and stay there.
731 for (const b of world.bodies) {
732 if (b.z >= WALL_HEIGHT || b.carried) continue;
733 for (const w of world.walls) {
734 // Your own tiles are not solid to you until you are clear of them.
735 if (w.seat === b.seat && !b.leftSeat) continue;
736 const hit = overlapOf(boxOf(w.rect), b);
737 if (hit && hit.depth > CONTACT_SLOP) {
738 wake(b);
739 break;
740 }
741 }
742 }
743}
744
745/**
746 * The felt, on a tile that is both skating and turning.
747 *
748 * There is one patch of cloth under the tile and it can only do one thing at a
749 * time: what it spends stopping the slide it does not have left to stop the
750 * turn, and the other way about. So the constant part of the friction, the part
751 * that actually brings a tile to a stop rather than to an asymptote, is shared
752 * out between the two in proportion to how much of the rubbing each accounts
753 * for, the turn counted at `gripRadius` so the two are in the same units.
754 *
755 * A tile doing only one of them is damped exactly as hard as it always was:
756 * all of `FELT_STOP` goes to a pure slide, all of `SPIN_STOP` to a pure spin.
757 * What is new is in between, and what it buys is not distance, which is a few
758 * per cent, but the *ending*. Two brakes running side by side stop at two
759 * different moments, so a tile flicked hard with a little turn on it stops
760 * turning early and skates the rest of the way looking dead, and one barely
761 * pushed with a lot of turn on it stops dead and goes on spinning where it
762 * lies. Sharing the one budget ends them together, which is what a tile
763 * flicked across a table does.
764 */
765function damp(b: Body, dt: number) {
766 const k = Math.exp(-FELT_DAMP * dt);
767 b.vx *= k;
768 b.vy *= k;
769 b.spin *= Math.exp(-SPIN_DAMP * dt);
770
771 const speed = Math.hypot(b.vx, b.vy);
772 const rim = Math.abs(b.spin) * gripRadius(b);
773 const rubbing = speed + rim;
774 if (rubbing <= 0) return;
775
776 if (speed > 0) {
777 const next = Math.max(0, speed - FELT_STOP * (speed / rubbing) * dt);
778 b.vx = (b.vx / speed) * next;
779 b.vy = (b.vy / speed) * next;
780 }
781 if (rim > 0) {
782 const spin = Math.abs(b.spin);
783 b.spin = Math.sign(b.spin) * Math.max(0, spin - SPIN_STOP * (rim / rubbing) * dt);
784 }
785}
786
787/** Nudges a tile that landed outside the pool towards the rest of them. */
788function pullIn(world: World, b: Body, dt: number) {
789 const cx = world.bounds.x + world.bounds.w / 2;
790 const cy = world.bounds.y + world.bounds.h / 2;
791 const dx = cx - b.x;
792 const dy = cy - b.y;
793 const d = Math.hypot(dx, dy) || 1;
794 b.vx += (dx / d) * ENTRY_PULL * dt;
795 b.vy += (dy / d) * ENTRY_PULL * dt;
796}
797
798/** Bounce off whatever is standing there, corner to corner if that is how it
799 * hits — a stack of the wall, a laid-down set, somebody's hand. */
800function hitWall(b: Body, wall: Barrier, impacts: Impact[]) {
801 const box = boxOf(wall.rect);
802 const hit = overlapOf(box, b);
803 if (!hit || hit.depth <= CONTACT_SLOP) return;
804
805 const speed = Math.hypot(b.vx, b.vy);
806 b.x += hit.nx * hit.depth;
807 b.y += hit.ny * hit.depth;
808
809 // A stack does not move, so the whole of the contact is the tile's. What is
810 // asked of it is the speed of the *touching corner* rather than of the
811 // middle: a tile that is already turning arrives at the wall faster on one
812 // side than the other, and that is what decides the bounce.
813 const p = contactOf(b, hit.nx, hit.ny, box);
814 const rx = p.x - b.x;
815 const ry = p.y - b.y;
816 const invI = invInertia(b);
817 const cvx = b.vx - b.spin * ry;
818 const cvy = b.vy + b.spin * rx;
819 const into = cvx * hit.nx + cvy * hit.ny;
820 if (into < 0) {
821 // How much of the knock the corner can take before the tile simply turns
822 // out of the way instead: a hit through the middle has none of this and
823 // rebounds whole, a hit on a corner spends most of itself on the turn.
824 const rn = rx * hit.ny - ry * hit.nx;
825 const j = (-(1 + BOUND_RESTITUTION) * into) / (1 + rn * rn * invI);
826 b.vx += hit.nx * j;
827 b.vy += hit.ny * j;
828 b.spin += rn * j * invI;
829
830 // And along the face. A tile skating past the corner of a stack is dragged
831 // round by it, which is the turn you actually see; how much is capped by
832 // how hard it went in, so a brush is a brush and not a stop.
833 const tx = -hit.ny;
834 const ty = hit.nx;
835 const rt = rx * ty - ry * tx;
836 const slip = cvx * tx + cvy * ty;
837 if (slip !== 0) {
838 const jt =
839 -Math.sign(slip) *
840 Math.min(Math.abs(slip) / (1 + rt * rt * invI), TILE_FRICTION * j);
841 b.vx += tx * jt;
842 b.vy += ty * jt;
843 b.spin += rt * jt * invI;
844 }
845 }
846 b.resting = false;
847 b.still = 0;
848 const strength = Math.min(1, speed / 1100);
849 if (wall.seat !== undefined) {
850 // Into somebody's tiles. Reported however gently it arrived — their row is
851 // theirs, and a discard rolling into it is an event whatever speed it got
852 // there at. Which of them are loud enough to hear is the caller's business.
853 if (speed > SLEEP_SPEED) {
854 impacts.push({ x: b.x, y: b.y, strength, into: wall.seat, seat: b.seat });
855 }
856 } else if (strength > 0.12) {
857 impacts.push({ x: b.x, y: b.y, strength });
858 }
859}
860
861/** The pool's edge. Once a tile is in, it stays in — this is the invariant the
862 * tests pin, since a discard that slid off the table would be lost. */
863function keepInBounds(world: World, b: Body, impacts: Impact[]) {
864 const { minX, maxX, minY, maxY } = limitsOf(world.bounds, b);
865
866 let hit = 0;
867 let edge: Edge | undefined;
868 // Which way the edge pushed back, kept so the slew below knows which corner of
869 // the tile it was that arrived.
870 let nx = 0;
871 let ny = 0;
872 if (b.x < minX) {
873 b.x = minX;
874 hit = Math.max(hit, Math.abs(b.vx));
875 edge = 'left';
876 nx = 1;
877 b.vx = Math.abs(b.vx) * BOUND_RESTITUTION;
878 b.vy *= BOUND_FRICTION;
879 } else if (b.x > maxX) {
880 b.x = maxX;
881 hit = Math.max(hit, Math.abs(b.vx));
882 edge = 'right';
883 nx = -1;
884 b.vx = -Math.abs(b.vx) * BOUND_RESTITUTION;
885 b.vy *= BOUND_FRICTION;
886 }
887 if (b.y < minY) {
888 b.y = minY;
889 if (Math.abs(b.vy) > hit) {
890 edge = 'top';
891 nx = 0;
892 ny = 1;
893 }
894 hit = Math.max(hit, Math.abs(b.vy));
895 b.vy = Math.abs(b.vy) * BOUND_RESTITUTION;
896 b.vx *= BOUND_FRICTION;
897 } else if (b.y > maxY) {
898 b.y = maxY;
899 if (Math.abs(b.vy) > hit) {
900 edge = 'bottom';
901 nx = 0;
902 ny = -1;
903 }
904 hit = Math.max(hit, Math.abs(b.vy));
905 b.vy = -Math.abs(b.vy) * BOUND_RESTITUTION;
906 b.vx *= BOUND_FRICTION;
907 }
908
909 // A tile does not meet a straight edge flat unless it happens to be lying
910 // square to it: what arrives is one corner, and the rest of the tile swings
911 // on round it. Only on a real knock: a tile held against the edge by its
912 // neighbours is clamped every step, and winding it up on each of those would
913 // leave the pool turning for ever.
914 if (hit > SLEEP_SPEED) {
915 const p = contactOf(b, nx, ny);
916 const invI = invInertia(b);
917 const rn = (p.x - b.x) * ny - (p.y - b.y) * nx;
918 b.spin += (rn * hit * (1 + BOUND_RESTITUTION) * invI) / (1 + rn * rn * invI);
919 }
920 // Only a real knock counts as being disturbed. A tile held against the edge
921 // by its neighbours is clamped every single step, and treating that as a knock
922 // would keep the whole pool awake for ever.
923 //
924 // Anything that does arrive under its own steam is reported, however gently:
925 // the edge is where somebody's tiles are, so a discard rolling into them is
926 // an event whatever speed it got there at. Which of them are loud enough to
927 // be worth a sound is the caller's business.
928 if (hit > SLEEP_SPEED) {
929 b.still = 0;
930 impacts.push({ x: b.x, y: b.y, strength: Math.min(1, hit / 1100), edge, seat: b.seat });
931 }
932}
933
934/**
935 * Tile against tile.
936 *
937 * Only tiles flat on the felt take part, so a thrown tile passes over the pile
938 * instead of shouldering through it, and a cheap circle test throws out the pairs
939 * that are nowhere near before the real rectangle test runs. Pairs where both
940 * tiles have settled are left alone entirely, which is nearly all of them.
941 */
942function collideTiles(world: World, impacts: Impact[]) {
943 const bodies = world.bodies;
944 for (let i = 0; i < bodies.length; i++) {
945 const a = bodies[i];
946 if (a.z > ON_FELT) continue;
947 for (let j = i + 1; j < bodies.length; j++) {
948 const b = bodies[j];
949 if (b.z > ON_FELT) continue;
950 if (Math.hypot(b.x - a.x, b.y - a.y) > circumradius(a) + circumradius(b)) continue;
951 // Two tiles that have both settled are still pushed apart if they are
952 // inside each other — they are just not woken up for it. Skipping the pair
953 // outright was how a pile could end up with tiles sharing ground and stay
954 // that way, since nothing steps a pile that has stopped moving.
955 resolvePair(a, b, impacts, world);
956 }
957 }
958}
959
960function resolvePair(a: Body, b: Body, impacts: Impact[], world: World) {
961 const hit = overlapOf(a, b);
962 if (!hit) return;
963 // Resting in contact is not a collision. See CONTACT_SLOP.
964 const depth = hit.depth - CONTACT_SLOP;
965 if (depth <= 0) return;
966 const { nx, ny } = hit;
967
968 world.contacts++;
969 // Push apart. A tile that has settled is the heavy one: a tile thrown into a
970 // pool shoulders in, rather than the pool scattering out of its way.
971 const aShare = a.resting === b.resting ? 0.5 : a.resting ? 0 : 1;
972 const bShare = 1 - aShare;
973 a.x -= nx * depth * aShare;
974 a.y -= ny * depth * aShare;
975 b.x += nx * depth * bShare;
976 b.y += ny * depth * bShare;
977
978 // Where they are actually touching, and how far that is off each middle. A
979 // tile shouldered square in the back is pushed along; the same tile caught on
980 // one corner is turned instead, and it is only this that tells them apart.
981 const p = contactOf(b, nx, ny, a);
982 const rax = p.x - a.x;
983 const ray = p.y - a.y;
984 const rbx = p.x - b.x;
985 const rby = p.y - b.y;
986 const invIa = invInertia(a);
987 const invIb = invInertia(b);
988
989 // Closing speed at the contact rather than between the middles, so a tile
990 // that is already turning arrives with whichever corner is coming round.
991 //
992 // Only a genuine approach speed counts, and only that wakes anything. Nudging
993 // two touching tiles apart is not a collision: a jammed corner of the pool is
994 // corrected by a fraction of a pixel every step for as long as it is jammed,
995 // and treating each of those as a shove would keep the pile awake for ever.
996 const rvx = b.vx - b.spin * rby - (a.vx - a.spin * ray);
997 const rvy = b.vy + b.spin * rbx - (a.vy + a.spin * rax);
998 const along = rvx * nx + rvy * ny;
999 if (along >= -SLEEP_SPEED) return;
1000
1001 // Two tiles of the same weight, so the shove splits in half, plus whatever
1002 // each of them can turn away with, which is the part that was a constant
1003 // before and is now the geometry of the hit.
1004 const ran = rax * ny - ray * nx;
1005 const rbn = rbx * ny - rby * nx;
1006 const jolt =
1007 (-along * (1 + TILE_RESTITUTION)) / (2 + ran * ran * invIa + rbn * rbn * invIb);
1008 a.vx -= nx * jolt;
1009 a.vy -= ny * jolt;
1010 a.spin -= ran * jolt * invIa;
1011 b.vx += nx * jolt;
1012 b.vy += ny * jolt;
1013 b.spin += rbn * jolt * invIb;
1014
1015 // Across the face of it. One tile skidding past another drags on it, and each
1016 // takes the turn that drag has the leverage for. The tiles in a pool ending
1017 // up at every angle to each other is this, and nothing else.
1018 const tx = -ny;
1019 const ty = nx;
1020 const slip = rvx * tx + rvy * ty;
1021 if (slip !== 0) {
1022 const rat = rax * ty - ray * tx;
1023 const rbt = rbx * ty - rby * tx;
1024 const jt =
1025 -Math.sign(slip) *
1026 Math.min(
1027 Math.abs(slip) / (2 + rat * rat * invIa + rbt * rbt * invIb),
1028 TILE_FRICTION * jolt,
1029 );
1030 a.vx -= tx * jt;
1031 a.vy -= ty * jt;
1032 a.spin -= rat * jt * invIa;
1033 b.vx += tx * jt;
1034 b.vy += ty * jt;
1035 b.spin += rbt * jt * invIb;
1036 }
1037
1038 const strength = Math.min(1, Math.abs(along) / 900);
1039 if (strength > 0.12) impacts.push({ x: b.x, y: b.y, strength });
1040
1041 // Whatever was asleep has been shoved, so it is awake now.
1042 wake(a);
1043 wake(b);
1044}
1045
1046function wake(b: Body) {
1047 if (b.resting) b.awake = 0;
1048 b.resting = false;
1049 b.still = 0;
1050}
1051
1052function sleep(b: Body) {
1053 b.vx = 0;
1054 b.vy = 0;
1055 b.spin = 0;
1056 b.z = 0;
1057 b.vz = 0;
1058 // Face up, always. A tile lying face down in the discards would be a tile
1059 // nobody can read, so this is a guarantee and not an easing.
1060 b.flip = Math.round(b.flip / TAU) * TAU;
1061 b.flipSpin = 0;
1062 b.resting = true;
1063 b.awake = 0;
1064}
1065
1066function trySleep(b: Body) {
1067 const speed = Math.hypot(b.vx, b.vy);
1068 const slow =
1069 b.entered &&
1070 b.z <= 0 &&
1071 b.vz === 0 &&
1072 b.flipSpin === 0 &&
1073 speed < SLEEP_SPEED &&
1074 Math.abs(b.spin) < SLEEP_SPIN;
1075 if (slow && ++b.still >= SLEEP_STEPS) return sleep(b);
1076 if (!slow) b.still = 0;
1077 // Backstop: shuffling about in a crowded pool for this long is settled enough.
1078 if (b.entered && b.awake > AWAKE_LIMIT && speed < SLEEP_SPEED * 4) sleep(b);
1079}
1080
1081/**
1082 * Make room for a tile placed straight into a settled pile, without running
1083 * time. Used when a pile is rebuilt — a reload, an undo — where the tiles have
1084 * to be somewhere sensible immediately and nothing should appear to move.
1085 */
1086function separate(world: World, body: Body, passes = 16) {
1087 // Which way to go when two tiles are exactly on top of each other. Stepping
1088 // by the golden angle fans successive tiles out in every direction, where a
1089 // fixed axis would march them all one way and pack them against a pool edge.
1090 const escape = world.bodies.indexOf(body) * 2.39996;
1091
1092 for (let p = 0; p < passes; p++) {
1093 let moved = false;
1094 for (const other of world.bodies) {
1095 if (other === body) continue;
1096 const hit = overlapOf(other, body);
1097 if (!hit || hit.depth <= CONTACT_SLOP) continue;
1098 if (Math.hypot(body.x - other.x, body.y - other.y) < 0.0001) {
1099 body.x += Math.cos(escape + p) * hit.depth;
1100 body.y += Math.sin(escape + p) * hit.depth;
1101 } else {
1102 body.x += hit.nx * hit.depth;
1103 body.y += hit.ny * hit.depth;
1104 }
1105 moved = true;
1106 }
1107 clampInto(world.bounds, body);
1108 if (!moved) break;
1109 }
1110}
1111
1112function clampInto(r: Rect, b: Body) {
1113 const { minX, maxX, minY, maxY } = limitsOf(r, b);
1114 b.x = Math.min(maxX, Math.max(minX, b.x));
1115 b.y = Math.min(maxY, Math.max(minY, b.y));
1116}
1117
1118/**
1119 * How long a motionless pile is allowed to keep untangling itself before the
1120 * result is accepted as good enough. Anything still overlapping after this is
1121 * doing so by a fraction of a pixel, and the loop has better things to do than
1122 * keep running over it.
1123 */
1124const RELAX_LIMIT = 90;
1125
1126/** Whether everything has settled — what the render loop idles on. */
1127export const settled = (world: World) =>
1128 world.bodies.every((b) => b.resting && b.scale === 1) &&
1129 (world.contacts === 0 || world.relaxed > RELAX_LIMIT);
1130
1131/** How square-on the face is: 1 flat on its back, 0 edge-on, negative face down.
1132 * The renderer squashes the tile by this along its tumbling axis. */
1133export const faceOn = (b: Body) => Math.cos(b.flip);
1134
1135/**
1136 * A tile's own bounding box, upright. Not what it collides with — that is the
1137 * rotated rectangle — but what the felt's straight edges are worked out from,
1138 * so the overlay draws both and the difference is visible.
1139 */
1140export function aabbOf(b: Body): Rect {
1141 const half = aabbHalf(b);
1142 return { x: b.x - half.x, y: b.y - half.y, w: half.x * 2, h: half.y * 2 };
1143}
1144
1145/**
1146 * Where this tile's *centre* is allowed to be, which is the edge less its own
1147 * half. This is the line a bounce actually happens at, and it sits half a tile
1148 * inside the one you can see — which is why a tile looks like it turned around
1149 * early. For the overlay; the real work is `limitsOf`.
1150 */
1151export function centreLimits(r: Rect, b: Body): Rect {
1152 const l = limitsOf(r, b);
1153 return { x: l.minX, y: l.minY, w: l.maxX - l.minX, h: l.maxY - l.minY };
1154}
1155
1156/** How far outside the pool a tile is sitting. The tests' invariant. */
1157export function outside(b: Body, pool: Rect): number {
1158 const half = aabbHalf(b);
1159 return Math.max(
1160 0,
1161 pool.x + half.x - b.x,
1162 b.x - (pool.x + pool.w - half.x),
1163 pool.y + half.y - b.y,
1164 b.y - (pool.y + pool.h - half.y),
1165 );
1166}
1167
1168/**
1169 * How high a tile is held while it is being dragged. Above `WALL_HEIGHT` so it
1170 * clears the wall, and above `ON_FELT` so it is out of the way of every tile it
1171 * is carried over — the pile does not part in front of a finger, the tile goes
1172 * over the top of it, which is what picking one up is.
1173 */
1174export const CARRY_Z = 46;
1175
1176/** The topmost tile under a point, or null. What a finger has taken hold of.
1177 * Last first: later tiles were thrown later and lie on top. */
1178export function tileAt(world: World, x: number, y: number): Body | null {
1179 for (let i = world.bodies.length - 1; i >= 0; i--) {
1180 const b = world.bodies[i];
1181 const c = Math.cos(-b.angle);
1182 const s = Math.sin(-b.angle);
1183 const dx = x - b.x;
1184 const dy = y - b.y;
1185 // Into the tile's own frame, where the test is a rectangle again.
1186 if (Math.abs(dx * c - dy * s) <= b.w / 2 && Math.abs(dx * s + dy * c) <= b.h / 2) return b;
1187 }
1188 return null;
1189}
1190
1191/** Taken out of the pile and held above it. */
1192export function lift(b: Body) {
1193 b.carried = true;
1194 b.resting = false;
1195 b.z = CARRY_Z;
1196 b.vx = 0;
1197 b.vy = 0;
1198 b.vz = 0;
1199 b.spin = 0;
1200 b.flipSpin = 0;
1201 // Whatever it comes down on, it comes down face up, as a tile does.
1202 b.flip = Math.round(b.flip / TAU) * TAU;
1203}
1204
1205/** Let go of it: dropped where it is, or sent off at whatever it was flicked. */
1206export function drop(b: Body, vx: number, vy: number, spin: number) {
1207 b.carried = false;
1208 b.vx = vx;
1209 b.vy = vy;
1210 b.spin = spin;
1211 // It was being held up; from here it simply falls.
1212 b.vz = 0;
1213 b.still = 0;
1214 b.awake = 0;
1215 b.resting = false;
1216 // Thrown from inside the pile rather than from a hand, so it is already past
1217 // every seat's tiles and they are solid to it straight away.
1218 b.leftSeat = true;
1219 b.entered = true;
1220}
1221
1222/** Whether two tiles are sharing ground, which they must never do at rest. */
1223export function overlapping(a: Body, b: Body): number {
1224 const hit = overlapOf(a, b);
1225 return hit ? hit.depth : 0;
1226}
1227
1228/** Scale a tile is drawn at, given how high it is. Sells the arc on a flat
1229 * canvas; the renderer offsets the shadow by the same height. */
1230export const zScale = (z: number) => 1 + z * 0.0016;
1231
1232/**
1233 * The upward kick that puts a tile back on the felt after `seconds` in the air.
1234 * How a throw is aimed: pick how long it should take, and the horizontal speed
1235 * follows from the distance.
1236 */
1237export const liftFor = (seconds: number) => (GRAVITY * seconds) / 2;
1238
1239/** How high that kick gets — worth checking against WALL_HEIGHT. */
1240export const peakOf = (vz: number) => (vz * vz) / (2 * GRAVITY);