anvilsign in

collin/mahjong · 63e2e36a

Give the tiles thickness, a tumble, and room to spill out of the square

Collin Richards · 2026-08-19 10:54 UTC · 63e2e36a4895c80e7f78e0e03e07fa9fc261861f · parent 2259ceab · browse files

modifiedsrc/App.tsx+2 −1
⋯ 108 unchanged lines
109109 useEffect(() => game.onSound(play), [game]);
110110 // Watches the table for the whole session and only stirs when a seat the
111111 // computer holds is the one being waited on.
112- useEffect(() => new AutoPlay(game).start(), [game]);
112+ // The computer waits for a thrown tile to land before it reaches for it.
113+ useEffect(() => new AutoPlay(game, () => pool.throwInFlight).start(), [game, pool]);
113114
114115 if (phase === 'lobby') {
115116 const round = saved ? Math.floor(saved.state.rotations / 4) % 4 : 0;
⋯ 97 unchanged lines
modifiedsrc/game/autoplay.ts+33 −3
⋯ 27 unchanged lines
2828 const THINK_CLAIM = 380;
2929 /** A little unevenness, so the table doesn't tick like a metronome. */
3030 const JITTER = 250;
31+/** How often to look again while a thrown tile is still in the air. */
32+const SETTLE_POLL = 70;
33+/**
34+ * The longest the table will hold for a tile to land before going on anyway.
35+ * Waiting on the animation is a courtesy; a game that could not continue because
36+ * something in the middle would not settle would be a game that had stopped.
37+ */
38+const SETTLE_LIMIT = 2500;
3139
3240 /** Whose move it is, if it is a bot's, and how long to sit on it. */
3341 export function pendingBot(game: Game): { seat: SeatId; delay: number } | null {
⋯ 53 unchanged lines
8795 export class AutoPlay {
8896 private timer: ReturnType<typeof setTimeout> | null = null;
8997 private game: Game;
98+ private busy: () => boolean;
9099
91- constructor(game: Game) {
100+ /**
101+ * `busy` is asked whether the table is mid-animation — a discard still in the
102+ * air on its way to the middle. Nobody at a real table reaches across for a
103+ * tile that has not landed, and a 碰 called over a tile still turning over in
104+ * flight looks like the tile was snatched out of the air. The engine has no
105+ * idea any of this exists; this is the same watching-from-outside trick the
106+ * rest of the file plays.
107+ */
108+ constructor(game: Game, busy: () => boolean = () => false) {
92109 this.game = game;
110+ this.busy = busy;
93111 }
94112
95113 /** Start watching. Returns the disposer. */
⋯ 19 unchanged lines
115133 // The state can move under us — a person taking an undo, say — so the move
116134 // is worked out again at the moment it is played, never here.
117135 const at = this.game.getSnapshot();
118- this.timer = setTimeout(() => {
136+ let waitedFrom = 0;
137+
138+ const play = () => {
119139 this.timer = null;
120140 if (this.game.getSnapshot() !== at) return;
141+ if (this.busy()) {
142+ const now = Date.now();
143+ if (!waitedFrom) waitedFrom = now;
144+ if (now - waitedFrom < SETTLE_LIMIT) {
145+ this.timer = setTimeout(play, SETTLE_POLL);
146+ return;
147+ }
148+ }
121149 stepBots(this.game);
122- }, wait);
150+ };
151+
152+ this.timer = setTimeout(play, wait);
123153 };
124154 }
modifiedsrc/styles.css+16 −0
⋯ 528 unchanged lines
529529 pointer-events: none;
530530 z-index: 1;
531531 }
532+/* The patch of table you can put a finger into to wash the tiles about. Exactly
533+ the pool and no more — the canvas covers the whole table and must never take
534+ a click meant for a strip, so the reachable part is its own small box, parked
535+ over the pool from the measurement by ui/Pool.tsx. */
536+.pool-reach {
537+ position: absolute;
538+ z-index: 1;
539+ touch-action: none;
540+ cursor: grab;
541+}
542+.pool-reach:active {
543+ cursor: grabbing;
544+}
532545
533546 /* ---------- center ---------- */
534547 .center {
⋯ 152 unchanged lines
687700 box-shadow: 0 0 12px rgba(217, 180, 92, 0.7);
688701 }
689702
703+/* Text only, and it sits over the pool — so it must not swallow a finger that
704+ is reaching in to wash the tiles about. */
690705 .facing {
706+ pointer-events: none;
691707 position: relative;
692708 z-index: 1;
693709 display: flex;
⋯ 1227 unchanged lines
modifiedsrc/table/geometry.ts+77 −5
⋯ 20 unchanged lines
2121 export interface TableGeometry {
2222 /** Table size, world units — the world's origin is the table's top left. */
2323 size: { w: number; h: number };
24- /** Inside the wall square: where discards come to rest. */
24+ /** Inside the wall square: where a discard is aimed, and where a rebuilt pile
25+ * is laid out. Not a fence — see `felt`. */
2526 pool: Rect;
27+ /**
28+ * The whole middle of the table, between the four strips. This is what
29+ * actually stops a tile, because the square is not a box: it is eighteen
30+ * stacks a side, and once they have been drawn there is nothing there. A tile
31+ * is held in by the stacks still standing, and by the edge of the felt beyond
32+ * them — so as the wall is eaten away the pool spills out through the gaps,
33+ * exactly as it does on a table.
34+ */
35+ felt: Rect;
2636 /** Stacks of two still standing. Half stacks are low enough to throw over. */
2737 walls: Rect[];
2838 /** A tile in the pool is the same tile that was in the wall. */
2939 tile: { w: number; h: number };
30- /** Where each seat throws from, if that seat's hand is on screen. */
40+ /** The middle of each seat's hand, if that seat's hand is on screen. */
3141 launch: Partial<Record<SeatId, Point>>;
42+ /** The right-hand end of each seat's hand, which is where a tile is thrown
43+ * from: the tile just drawn sits there, and that is the hand you throw with. */
44+ throwFrom: Partial<Record<SeatId, Point>>;
3245 }
3346
3447 export interface Point {
⋯ 45 unchanged lines
8093 };
8194
8295 const launch: Partial<Record<SeatId, Point>> = {};
96+ const throwFrom: Partial<Record<SeatId, Point>> = {};
8397 for (const seat of SEATS) {
8498 const hand = table.querySelector<HTMLElement>(`[data-seat="${seat}"] .hand`);
8599 if (!hand) continue;
⋯ 2 unchanged lines
88102 // A rotated element's bounding box is still centred on the element, so this
89103 // is the right point whichever edge the seat is sitting at.
90104 launch[seat] = { x: r.left + r.width / 2 - base.left, y: r.top + r.height / 2 - base.top };
105+
106+ // The end of the row rather than the middle of it. That is where the tile
107+ // just drawn is sitting and which hand it is in, so it is where a throw
108+ // comes from — measured off the last cell, so it is the right end of the
109+ // *row* whichever way round the seat is turned.
110+ const end = hand.querySelector<HTMLElement>('.hand-cell:last-child');
111+ const er = end?.getBoundingClientRect();
112+ throwFrom[seat] = er
113+ ? { x: er.left + er.width / 2 - base.left, y: er.top + er.height / 2 - base.top }
114+ : launch[seat];
91115 }
92116
117+ // The felt is the centre element's own box — the area the four strips leave.
118+ const centre = table.querySelector<HTMLElement>('.center');
119+ const felt = centre ? local(centre.getBoundingClientRect()) : ringRect;
120+
93121 return {
94122 size: { w: base.width, h: base.height },
95123 pool,
124+ felt,
96125 walls,
97- tile: { w: short, h: short * 1.375 },
126+ tile: poolTile(table, short),
98127 launch,
128+ throwFrom,
99129 };
100130 }
101131
132+/**
133+ * How big a tile in the middle is drawn: the same size as the tiles being
134+ * played with.
135+ *
136+ * It used to be the size of a tile in the wall, which is much smaller, and that
137+ * was a mistake you could see rather than measure — a tile thrown from a hand
138+ * shrank as it crossed the table, so it read as being dropped away from you into
139+ * the distance rather than skidded across a flat one. They are the same tiles,
140+ * so they are the same size.
141+ *
142+ * A seat's strip is rotated by a right angle, which swaps its bounding box, and
143+ * a tile is always taller than it is wide — so the narrower side is the width
144+ * whichever edge is measured. Falls back to the wall's own tile if no hand has
145+ * been dealt yet.
146+ */
147+function poolTile(table: HTMLElement, fallback: number): { w: number; h: number } {
148+ const el = table.querySelector<HTMLElement>('.hand .tile');
149+ const r = el?.getBoundingClientRect();
150+ const w = r && r.width > 0 ? Math.min(r.width, r.height) : fallback;
151+ return { w, h: w * 1.375 };
152+}
153+
102154 export const centreOf = (r: Rect): Point => ({ x: r.x + r.w / 2, y: r.y + r.h / 2 });
103155
104156 /**
⋯ 10 unchanged lines
115167 */
116168 export function canThrow(geo: TableGeometry, seat: SeatId): boolean {
117169 const from = geo.launch[seat];
118- if (!from) return false;
170+ return !!from && canThrowFrom(geo, from);
171+}
172+
173+/**
174+ * The same question asked from a particular point rather than from a seat.
175+ *
176+ * This is the one that matters for a tile somebody is holding: they have picked
177+ * it up and can carry it anywhere, so what decides whether it can be slid in is
178+ * the line from wherever they let go of it — not the line from the hand it came
179+ * out of. Judging a throw released out over the square by the view from the
180+ * player's own edge called almost everything a lob.
181+ */
182+export function canThrowFrom(geo: TableGeometry, from: Point): boolean {
183+ if (inRect(geo.pool, from)) return true;
119184 return aimPoints(geo).some((to) => clearLine(geo.walls, from, to));
120185 }
121186
187+export const inRect = (r: Rect, p: Point) =>
188+ p.x >= r.x && p.x <= r.x + r.w && p.y >= r.y && p.y <= r.y + r.h;
189+
122190 /** Somewhere in the pool this seat could actually get a tile to, or null. */
123191 export function aimAt(geo: TableGeometry, seat: SeatId): Point | null {
124192 const from = geo.launch[seat];
⋯ 20 unchanged lines
145213 */
146214 function aimPoints(geo: TableGeometry): Point[] {
147215 const { x, y, w, h } = geo.pool;
148- const steps = [0.08, 0.3, 0.5, 0.7, 0.92];
216+ // Fine rather than coarse, and it costs nothing: this runs once when a tile
217+ // is let go of, not per frame. A sparse grid missed narrow gaps and called a
218+ // throw impossible when a tile would plainly have gone through, and sliding is
219+ // what a discard should be whenever it can be.
220+ const steps = [0.06, 0.2, 0.35, 0.5, 0.65, 0.8, 0.94];
149221 const out: Point[] = [];
150222 for (const u of steps) for (const v of steps) out.push({ x: x + w * u, y: y + h * v });
151223 return out;
⋯ 27 unchanged lines
modifiedsrc/table/physics.test.ts+64 −2
⋯ 4 unchanged lines
55 import {
66 addBody,
77 createWorld,
8+ faceOn,
9+ liftFor,
810 outside,
911 overlapping,
1012 settled,
⋯ 6 unchanged lines
1719 const POOL: Rect = { x: 200, y: 200, w: 220, h: 220 };
1820 /** Same aspect as a real tile, roughly pool-tile sized. */
1921 const TILE = { w: 26, h: 36 };
20-/** Generous: nothing should still be moving after four seconds. */
21-const SETTLE_CAP = 480;
22+/** Generous: moving, and then untangling itself, is done well inside this. */
23+const SETTLE_CAP = 700;
2224
2325 function throwAt(rng: () => number): Spawn {
2426 const centre = { x: POOL.x + POOL.w / 2, y: POOL.y + POOL.h / 2 };
⋯ 143 unchanged lines
168170 expect(outside(b, POOL)).toBeLessThan(0.5);
169171 });
170172
173+ it('always comes to rest face up, however it tumbles', () => {
174+ // A tile lying face down in the discards is a tile nobody can read, so this
175+ // is a guarantee rather than something the animation happens to get right.
176+ const rng = makeRng(31337);
177+ const world = createWorld(POOL);
178+ for (let i = 0; i < 20; i++) {
179+ const spawn = throwAt(rng);
180+ addBody(world, {
181+ ...spawn,
182+ // Every awkward number of turns, over every awkward flight time.
183+ flipTurns: 1 + Math.floor(rng() * 3),
184+ flipOver: 0.2 + rng() * 0.5,
185+ flipAxis: rng() * Math.PI * 2,
186+ });
187+ for (let s = 0; s < 40; s++) step(world);
188+ }
189+ runToRest(world);
190+
191+ for (const b of world.bodies) {
192+ expect(b.resting).toBe(true);
193+ // Square on to the table, not edge on and not face down.
194+ expect(faceOn(b)).toBeCloseTo(1, 6);
195+ }
196+ });
197+
198+ it('turns a lobbed tile over in the air and lands it flat', () => {
199+ const world = createWorld(POOL);
200+ const b = addBody(world, {
201+ tile: 0,
202+ seat: 0,
203+ x: POOL.x + POOL.w / 2,
204+ y: POOL.y + POOL.h + 90,
205+ vx: 0,
206+ vy: -320,
207+ z: 0,
208+ vz: liftFor(0.6),
209+ angle: 0,
210+ spin: 0,
211+ flipTurns: 2,
212+ flipOver: 0.6,
213+ flipAxis: Math.PI / 2,
214+ w: TILE.w,
215+ h: TILE.h,
216+ });
217+
218+ // Somewhere in the air it must actually show its back, or it never turned.
219+ let showedBack = false;
220+ for (let i = 0; i < SETTLE_CAP && !settled(world); i++) {
221+ step(world);
222+ if (faceOn(b) < -0.2) showedBack = true;
223+ }
224+ expect(showedBack).toBe(true);
225+ expect(faceOn(b)).toBeCloseTo(1, 6);
226+ });
227+
171228 it('is deterministic — the same throws give the same pile', () => {
172229 const pile = () => {
173230 const rng = makeRng(99);
⋯ 53 unchanged lines
227284 });
228285 for (let i = 0; i < 8; i++) at(i);
229286
287+ // Placing them is a first shove; the world untangles the rest without
288+ // anything appearing to move, and is not settled until it has.
289+ runToRest(world);
230290 expect(settled(world)).toBe(true);
231291 for (const b of world.bodies) expect(outside(b, POOL)).toBeLessThan(0.5);
232292 // Nothing sharing ground with anything else.
⋯ 14 unchanged lines
247307 pool: POOL,
248308 walls,
249309 tile: TILE,
310+ felt: { x: 120, y: 120, w: 380, h: 380 },
250311 launch: { 0: { x: 310, y: 560 } },
312+ throwFrom: { 0: { x: 310, y: 560 } },
251313 };
252314 }
253315
⋯ 112 unchanged lines
modifiedsrc/table/physics.ts+148 −9
⋯ 97 unchanged lines
9898 /** How fast a tile drawn at hand size shrinks to the size it is in the pool. */
9999 const SCALE_EASE = 6;
100100
101+/** Washing the tiles about: how much of a finger's speed a tile takes on, how
102+ * hard it is nudged clear, and the fastest shove it will pass on. */
103+const STIR_DRAG = 0.55;
104+const STIR_NUDGE = 30;
105+const STIR_MAX_SPEED = 1500;
106+
107+export const TAU = Math.PI * 2;
108+/**
109+ * How quickly whatever is left of a tumble is taken up once the tile is down.
110+ * A thrown tile turns over in the air a whole number of times, so it should be
111+ * face up as it lands — but a bounce or a shove can leave it part-way round, and
112+ * a mahjong tile on a table is never resting on its face. This takes up the
113+ * remainder, and `sleep` guarantees the rest.
114+ */
115+const FLIP_SETTLE = 14;
116+
101117 export interface Rect {
102118 x: number;
103119 y: number;
⋯ 24 unchanged lines
128144 vz: number;
129145 angle: number;
130146 spin: number;
147+ /**
148+ * Turning over in the air, about an axis lying in the table — what a thrown
149+ * tile does, as opposed to `angle`, which is it turning flat on the cloth.
150+ * Zero, or any whole number of turns, is face up.
151+ */
152+ flip: number;
153+ flipSpin: number;
154+ /** Which way that axis points: a tile tumbles across its line of travel. */
155+ flipAxis: number;
131156 w: number;
132157 h: number;
133158 /**
⋯ 33 unchanged lines
167192 bodies: Body[];
168193 /** Left-over time from the last frame, so steps stay a fixed size. */
169194 carry: number;
195+ /**
196+ * Overlaps pushed apart in the last step. The pile is not settled while this
197+ * is non-zero, however still everything looks — two tiles asleep inside each
198+ * other would otherwise stay that way for the rest of the hand, because a
199+ * pile that has stopped moving stops being stepped.
200+ */
201+ contacts: number;
202+ /** Steps spent untangling a pile that is otherwise asleep, so it cannot spin. */
203+ relaxed: number;
170204 }
171205
172206 export interface Spawn {
⋯ 7 unchanged lines
180214 vz: number;
181215 angle: number;
182216 spin: number;
217+ /** Whole turns to make in the air. It lands face up whatever this is. */
218+ flipTurns?: number;
219+ /** Seconds of flight those turns are spread over. */
220+ flipOver?: number;
221+ flipAxis?: number;
183222 w: number;
184223 h: number;
185224 /** Drawn this much bigger to start with, easing to its pool size. */
⋯ 3 unchanged lines
189228 }
190229
191230 export function createWorld(bounds: Rect, walls: Rect[] = []): World {
192- return { bounds, walls, bodies: [], carry: 0 };
231+ return { bounds, walls, bodies: [], carry: 0, contacts: 0, relaxed: 0 };
193232 }
194233
195234 export function addBody(world: World, s: Spawn): Body {
⋯ 8 unchanged lines
204243 vz: s.atRest ? 0 : s.vz,
205244 angle: s.angle,
206245 spin: s.atRest ? 0 : s.spin,
246+ flip: 0,
247+ // Spread the turns over the flight, so the tile comes down as it comes back
248+ // round to face up rather than being snapped there on landing.
249+ flipSpin:
250+ s.atRest || !s.flipTurns || !s.flipOver ? 0 : (TAU * s.flipTurns) / s.flipOver,
251+ flipAxis: s.flipAxis ?? 0,
207252 w: s.w,
208253 h: s.h,
209254 scale: s.atRest ? 1 : s.scale ?? 1,
⋯ 4 unchanged lines
214259 awake: 0,
215260 };
216261 world.bodies.push(body);
217- // A tile dropped straight into a settled pile still has to make room.
218- if (s.atRest) separate(world, body);
262+ if (s.atRest) {
263+ // A tile dropped straight into a settled pile still has to make room. This
264+ // is a first shove only — the relaxing below finishes the job, which matters
265+ // for a whole pile rebuilt at once when a saved game is picked up.
266+ separate(world, body);
267+ world.contacts++;
268+ }
219269 return body;
220270 }
221271
⋯ 95 unchanged lines
317367 /** One fixed step. Exported for the tests, which drive it directly. */
318368 export function step(world: World, impacts: Impact[] = []): Impact[] {
319369 const dt = FIXED_DT;
370+ world.contacts = 0;
320371
321372 for (const b of world.bodies) {
322373 if (b.scale !== 1) {
⋯ 22 unchanged lines
345396 b.y += b.vy * dt;
346397 b.angle += b.spin * dt;
347398
399+ // Turning over. In the air it tumbles; once it is down, whatever is left of
400+ // the turn is taken up so it ends flat on its back with its face showing.
401+ if (b.flipSpin !== 0) {
402+ if (b.z > 0) {
403+ b.flip += b.flipSpin * dt;
404+ } else {
405+ const target = Math.round(b.flip / TAU) * TAU;
406+ b.flip += (target - b.flip) * Math.min(1, FLIP_SETTLE * dt);
407+ if (Math.abs(target - b.flip) < 0.01) {
408+ b.flip = target;
409+ b.flipSpin = 0;
410+ }
411+ }
412+ }
413+
348414 if (inside(world.bounds, b.x, b.y)) b.entered = true;
349415 else if (b.age > ENTRY_GRACE) {
350416 // Out of time going the long way round. Put it in, gently.
⋯ 27 unchanged lines
378444 // Being shoved by a neighbour can put a tile back over the edge, and a tile
379445 // that then fell asleep out there would stay out there. Only awake ones need
380446 // it: resolvePair never moves a tile that has settled.
447+ // Resting tiles included: they get pushed about by the untangling above, and
448+ // a tile shoved over the edge must not be left there just because it is asleep.
381449 for (const b of world.bodies) {
382- if (b.entered && !b.resting && b.z < WALL_HEIGHT) clampInto(world.bounds, b);
450+ if (b.entered && b.z < WALL_HEIGHT) clampInto(world.bounds, b);
383451 }
384452 // Deciding to sleep comes last, so it judges where the step actually left
385453 // things — before, a contact resolved after the decision would undo it and a
386454 // crowded pool would stay awake for ever.
387455 for (const b of world.bodies) if (!b.resting) trySleep(b);
456+
457+ // A pile that has stopped moving but is still untangling itself keeps being
458+ // stepped, up to a point. Without the limit a configuration that cannot quite
459+ // be resolved would keep the loop running for the rest of the hand.
460+ world.relaxed = world.contacts > 0 && world.bodies.every((b) => b.resting) ? world.relaxed + 1 : 0;
388461 return impacts;
389462 }
390463
⋯ 123 unchanged lines
514587 for (let j = i + 1; j < bodies.length; j++) {
515588 const b = bodies[j];
516589 if (b.z > ON_FELT) continue;
517- if (a.resting && b.resting) continue;
518590 if (Math.hypot(b.x - a.x, b.y - a.y) > circumradius(a) + circumradius(b)) continue;
519- resolvePair(a, b, impacts);
591+ // Two tiles that have both settled are still pushed apart if they are
592+ // inside each other — they are just not woken up for it. Skipping the pair
593+ // outright was how a pile could end up with tiles sharing ground and stay
594+ // that way, since nothing steps a pile that has stopped moving.
595+ resolvePair(a, b, impacts, world);
520596 }
521597 }
522598 }
523599
524-function resolvePair(a: Body, b: Body, impacts: Impact[]) {
600+function resolvePair(a: Body, b: Body, impacts: Impact[], world: World) {
525601 const hit = overlapOf(a, b);
526602 if (!hit) return;
527603 // Resting in contact is not a collision. See CONTACT_SLOP.
⋯ 1 unchanged line
529605 if (depth <= 0) return;
530606 const { nx, ny } = hit;
531607
608+ world.contacts++;
532609 // Push apart. A tile that has settled is the heavy one: a tile thrown into a
533610 // pool shoulders in, rather than the pool scattering out of its way.
534611 const aShare = a.resting === b.resting ? 0.5 : a.resting ? 0 : 1;
⋯ 44 unchanged lines
579656 b.spin = 0;
580657 b.z = 0;
581658 b.vz = 0;
659+ // Face up, always. A tile lying face down in the discards would be a tile
660+ // nobody can read, so this is a guarantee and not an easing.
661+ b.flip = Math.round(b.flip / TAU) * TAU;
662+ b.flipSpin = 0;
582663 b.resting = true;
583664 b.awake = 0;
584665 }
⋯ 1 unchanged line
586667 function trySleep(b: Body) {
587668 const speed = Math.hypot(b.vx, b.vy);
588669 const slow =
589- b.entered && b.z <= 0 && b.vz === 0 && speed < SLEEP_SPEED && Math.abs(b.spin) < SLEEP_SPIN;
670+ b.entered &&
671+ b.z <= 0 &&
672+ b.vz === 0 &&
673+ b.flipSpin === 0 &&
674+ speed < SLEEP_SPEED &&
675+ Math.abs(b.spin) < SLEEP_SPIN;
590676 if (slow && ++b.still >= SLEEP_STEPS) return sleep(b);
591677 if (!slow) b.still = 0;
592678 // Backstop: shuffling about in a crowded pool for this long is settled enough.
⋯ 41 unchanged lines
634720 b.y = Math.min(maxY, Math.max(minY, b.y));
635721 }
636722
723+/**
724+ * How long a motionless pile is allowed to keep untangling itself before the
725+ * result is accepted as good enough. Anything still overlapping after this is
726+ * doing so by a fraction of a pixel, and the loop has better things to do than
727+ * keep running over it.
728+ */
729+const RELAX_LIMIT = 90;
730+
637731 /** Whether everything has settled — what the render loop idles on. */
638732 export const settled = (world: World) =>
639- world.bodies.every((b) => b.resting && b.scale === 1);
733+ world.bodies.every((b) => b.resting && b.scale === 1) &&
734+ (world.contacts === 0 || world.relaxed > RELAX_LIMIT);
735+
736+/** How square-on the face is: 1 flat on its back, 0 edge-on, negative face down.
737+ * The renderer squashes the tile by this along its tumbling axis. */
738+export const faceOn = (b: Body) => Math.cos(b.flip);
640739
641740 /** How far outside the pool a tile is sitting. The tests' invariant. */
642741 export function outside(b: Body, pool: Rect): number {
⋯ 7 unchanged lines
650749 );
651750 }
652751
752+/**
753+ * A finger, or a cursor, pushing the tiles about in the middle — 洗牌, the thing
754+ * everybody does to a pool of discards while waiting for their turn.
755+ *
756+ * Modelled as a circle that simply cannot be inside a tile: anything it touches
757+ * is shoved clear of it and handed some of the speed it was moving at. It has no
758+ * mass and takes no impulse back, which is what a finger on a table is.
759+ */
760+export function stir(
761+ world: World,
762+ x: number,
763+ y: number,
764+ vx: number,
765+ vy: number,
766+ radius: number,
767+) {
768+ // A frantic swipe should not fire tiles off like bullets.
769+ const speed = Math.hypot(vx, vy);
770+ const trim = speed > STIR_MAX_SPEED ? STIR_MAX_SPEED / speed : 1;
771+
772+ for (const b of world.bodies) {
773+ if (b.z > ON_FELT) continue;
774+ const dx = b.x - x;
775+ const dy = b.y - y;
776+ const clear = radius + circumradius(b);
777+ const d = Math.hypot(dx, dy);
778+ if (d > clear) continue;
779+
780+ const nx = d < 0.001 ? 1 : dx / d;
781+ const ny = d < 0.001 ? 0 : dy / d;
782+ b.x = x + nx * clear;
783+ b.y = y + ny * clear;
784+ b.vx += vx * trim * STIR_DRAG + nx * STIR_NUDGE;
785+ b.vy += vy * trim * STIR_DRAG + ny * STIR_NUDGE;
786+ // Dragged past off-centre, a tile turns under your finger.
787+ b.spin += (vx * trim * ny - vy * trim * nx) * 0.0016;
788+ wake(b);
789+ }
790+}
791+
653792 /** Whether two tiles are sharing ground, which they must never do at rest. */
654793 export function overlapping(a: Body, b: Body): number {
655794 const hit = overlapOf(a, b);
⋯ 16 unchanged lines
modifiedsrc/table/pool.ts+95 −13
11 import type { GameState } from '../game/engine';
22 import { makeRng, type Tile } from '../game/tiles';
33 import type { SeatId } from '../game/types';
4-import { aimAt, canThrow, centreOf, measure, type Point, type TableGeometry } from './geometry';
54 import {
5+ aimAt,
6+ canThrow,
7+ canThrowFrom,
8+ centreOf,
9+ measure,
10+ type Point,
11+ type TableGeometry,
12+} from './geometry';
13+import {
614 addBody,
715 advance as advanceWorld,
816 createWorld,
917 liftFor,
1018 reshape,
1119 settled,
20+ stir,
1221 SLIDE_TIME,
1322 type Body,
1423 type Impact,
24+ type Rect,
1525 type World,
1626 } from './physics';
1727
⋯ 18 unchanged lines
3646
3747 /** How long a placed tile is in the air. Long enough to arc over the wall. */
3848 const PLACE_FLIGHT = 0.62;
39-/** A thrown tile hops rather than lofts — it is going through a gap, not over. */
40-const THROW_LIFT = 0.12;
49+/** A flicked tile hops rather than lofts — across the table, not over a wall. */
50+const THROW_LIFT = 0.18;
4151 /** Fastest flick we will honour, px/s. Beyond this it is a slip, not a throw. */
4252 export const MAX_THROW_SPEED = 3600;
4353 /** How long a claimed tile takes to leave the table for the seat taking it. */
⋯ 8 unchanged lines
5262 t: number;
5363 }
5464
65+/**
66+ * Whether a wall standing in the way turns a discard into a lob over the top
67+ * rather than a flick across the table.
68+ *
69+ * Off for now, deliberately: every discard slides, because sliding is what a
70+ * discard should look like. The lob is kept whole below and this is the only
71+ * switch that reaches it — when there is a rule for when a throw is called for
72+ * instead, it goes here and nothing else has to move.
73+ */
74+const LOB_WHEN_WALLED: boolean = false;
75+
5576 /** A tile lifted out of a hand and carried about. Drawn, but not simulated. */
5677 export interface Held {
5778 seat: SeatId;
⋯ 100 unchanged lines
158179 return x >= p.x && x <= p.x + p.w && y >= p.y && y <= p.y + p.h;
159180 }
160181
182+ /** Whether a tile let go of *here* has a clear line in. Not the same question
183+ * as `canThrow(seat)`: a carried tile is wherever the player took it. */
184+ canThrowFrom(x: number, y: number): boolean {
185+ return !!this.geo && canThrowFrom(this.geo, { x, y });
186+ }
187+
188+ /** Where the pool sits on the table, for the patch of it you can reach into. */
189+ get poolRect(): Rect | null {
190+ return this.geo?.pool ?? null;
191+ }
192+
193+ /**
194+ * Push the tiles in the middle about — washing them, the way everybody does
195+ * to a pool of discards while waiting. Purely cosmetic: where a discard is
196+ * lying is not part of the game.
197+ */
198+ stir(x: number, y: number, vx: number, vy: number) {
199+ if (!this.world || !this.geo) return;
200+ // A finger's worth: about a tile across.
201+ stir(this.world, x, y, vx, vy, this.geo.tile.w * 0.6);
202+ }
203+
161204 // ---- the tile in your fingers -----------------------------------------
162205
163206 /** Lifted out of a hand. From here the canvas draws it, not the DOM. */
⋯ 137 unchanged lines
301344 return (this.newestKey && this.entries.get(this.newestKey)) || null;
302345 }
303346
347+ /**
348+ * A tile is still on its way to the middle. The computer players hold off
349+ * while this is true — nobody at a real table reaches across for a tile that
350+ * has not landed, and a 碰 called over one still turning over in the air looks
351+ * like it was snatched out of flight.
352+ */
353+ get throwInFlight(): boolean {
354+ const b = this.newest;
355+ return !!b && !b.resting;
356+ }
357+
304358 /** Nothing is moving, so the render loop can stop until something changes. */
305359 get settled(): boolean {
306360 return !this.held && this.leaving.length === 0 && (!this.world || settled(this.world));
⋯ 23 unchanged lines
330384 this.setOpenness('----');
331385 return;
332386 }
333- if (!this.world) this.world = createWorld(geo.pool, geo.walls);
334- else reshape(this.world, geo.pool, geo.walls);
387+ // Bounded by the felt, not by the square: what keeps a tile in the middle is
388+ // the stacks still standing, and once they are gone the pool is free to
389+ // spread out through the gap the way a real one does.
390+ if (!this.world) this.world = createWorld(geo.felt, geo.walls);
391+ else reshape(this.world, geo.felt, geo.walls);
335392 // Only here, never per frame: a hundred-odd ray casts is nothing once a
336393 // draw, and a great deal every sixtieth of a second.
337394 this.setOpenness(SEATS.map((s) => (canThrow(geo, s) ? 'y' : '-')).join(''));
⋯ 53 unchanged lines
391448 // wants to be. A standing wall means it has to be *thrown* instead: lifted
392449 // over the top and dropped in, and then the flick's speed decides how far
393450 // across the pool it lands rather than how fast it skates.
451+ // Sliding is the preferred way in, and the question is asked from where
452+ // the tile actually is — a player who carried it out over the square has a
453+ // clear line even though the seat they came from does not. Only a tile
454+ // with nothing but standing wall in front of it has to be lobbed.
394455 const set = this.inPool(thrown.x, thrown.y);
395- const slide = set || this.canThrow(a.seat);
456+ const slide = set || !LOB_WHEN_WALLED || this.canThrowFrom(thrown.x, thrown.y);
396457 let { vx, vy } = thrown;
397458 let vz = 0;
459+ const speed = Math.hypot(vx, vy);
460+ let flipTurns = 0;
461+ let flipOver = 0;
398462
399463 if (!slide) {
400464 // Lofted over a standing wall. It still has to *land* on the table
⋯ 4 unchanged lines
405469 const centre = centreOf(geo.pool);
406470 const reach = PLACE_FLIGHT + SLIDE_TIME;
407471 const cap = (Math.hypot(centre.x - thrown.x, centre.y - thrown.y) / reach) * 2.4;
408- const speed = Math.hypot(vx, vy);
409472 const aim = Math.min(speed, cap) / (speed || 1);
410473 vx *= aim;
411474 vy *= aim;
412475 vz = liftFor(PLACE_FLIGHT);
476+ // Long enough in the air to turn over properly, and harder means more.
477+ flipOver = PLACE_FLIGHT;
478+ flipTurns = speed > 1300 ? 2 : 1;
413479 } else if (!set) {
414- // A skated tile still leaves the fingers slightly off the cloth.
415- vz = liftFor(THROW_LIFT);
480+ // A skated tile leaves the fingers off the cloth, and turns over on its
481+ // way down — harder means longer in the air and more turns in it.
482+ const hop = THROW_LIFT + Math.min(0.22, (speed / MAX_THROW_SPEED) * 0.28);
483+ vz = liftFor(hop);
484+ flipOver = hop;
485+ flipTurns = speed > 2200 ? 2 : speed > 600 ? 1 : 0;
416486 }
417487
418488 return addBody(this.world!, {
⋯ 7 unchanged lines
426496 vz,
427497 angle: thrown.angle,
428498 spin: thrown.spin,
499+ flipTurns,
500+ flipOver,
501+ // It tumbles across its line of travel, the way a thrown tile does.
502+ flipAxis: Math.atan2(vy, vx),
429503 w: geo.tile.w,
430504 h: geo.tile.h,
431505 // It was drawn at hand size a moment ago; it shrinks on the way down.
⋯ 1 unchanged line
433507 });
434508 }
435509
436- const from = geo.launch[a.seat] ?? centreOf(geo.pool);
437- const open = this.canThrow(a.seat);
510+ // Off the end of the hand, not the middle of it — a tile is thrown from the
511+ // hand holding it, which is the one at the right-hand end of the row.
512+ const from = geo.throwFrom[a.seat] ?? geo.launch[a.seat] ?? centreOf(geo.pool);
513+ const open = !LOB_WHEN_WALLED || this.canThrow(a.seat);
438514 const target = this.aimFor(geo, a.seat, rng);
439515 // Aim for where it comes to *rest*, not where it first touches down: a tile
440516 // keeps sliding after it lands, and SLIDE_TIME is roughly how much further.
441517 const flight = open ? 0.34 + rng() * 0.1 : PLACE_FLIGHT;
442518 const reach = flight + SLIDE_TIME;
443519
520+ const vx = (target.x - from.x) / reach;
521+ const vy = (target.y - from.y) / reach;
444522 return addBody(this.world!, {
445523 tile: a.tile,
446524 seat: a.seat,
447525 x: from.x,
448526 y: from.y,
449- vx: (target.x - from.x) / reach,
450- vy: (target.y - from.y) / reach,
527+ vx,
528+ vy,
451529 z: 0,
452530 vz: liftFor(flight),
453531 angle: (rng() - 0.5) * Math.PI,
454532 spin: (rng() - 0.5) * (open ? 7 : 2.5),
533+ // It turns over on its way in, whichever way it is going.
534+ flipTurns: 1,
535+ flipOver: flight,
536+ flipAxis: Math.atan2(vy, vx),
455537 w: geo.tile.w,
456538 h: geo.tile.h,
457539 });
⋯ 18 unchanged lines
modifiedsrc/ui/Pool.tsx+255 −40
⋯ 1 unchanged line
22 import type { Game } from '../game/engine';
33 import { knock } from '../game/sound';
44 import { SPRITE_COL, SPRITE_COLS } from '../game/tiles';
5-import { zScale } from '../table/physics';
5+import { faceOn, zScale } from '../table/physics';
66 import type { TablePool } from '../table/pool';
77
88 /**
⋯ 11 unchanged lines
2020 */
2121 export function Pool({ game, pool }: { game: Game; pool: TablePool }) {
2222 const canvasRef = useRef<HTMLCanvasElement>(null);
23- const sheet = useSheet();
23+ const reachRef = useRef<HTMLDivElement>(null);
24+ const art = useSheet();
25+ const wash = useWash(pool);
2426
2527 // What the loop reads. Refreshed on every render, read on every frame.
26- const live = useRef({ state: game.state, sheet });
27- live.current = { state: game.state, sheet };
28+ const live = useRef({ state: game.state, art });
29+ live.current = { state: game.state, art };
2830
2931 // Poked by the effect below, and by the gesture through pool.wake.
3032 const wake = useRef<() => void>(() => {});
⋯ 29 unchanged lines
6062 previous = now;
6163
6264 pool.sync(live.current.state);
65+ placeReach(reachRef.current, pool);
6366 // The loudest contact this frame is the one you would actually hear; the
6467 // rest of a scatter is the same event.
6568 const impacts = pool.advance(dt);
6669 let loudest = 0;
6770 for (const i of impacts) loudest = Math.max(loudest, i.strength);
6871 if (loudest > 0) knock(loudest);
69- paint(ctx, canvas, pool, live.current.sheet);
72+ paint(ctx, canvas, pool, live.current.art);
7073
7174 // Keep going while anything is moving; otherwise wait to be poked.
7275 if (!pool.settled) frame = requestAnimationFrame(tick);
⋯ 25 unchanged lines
98101 wake.current();
99102 });
100103
101- return <canvas className="tile-pool" ref={canvasRef} aria-hidden />;
104+ return (
105+ <>
106+ <canvas className="tile-pool" ref={canvasRef} aria-hidden />
107+ {/* The patch of table you can reach into. Sized to the pool from the
108+ measurement rather than from CSS, and only that patch, so it cannot
109+ swallow clicks meant for the strips around it. */}
110+ <div className="pool-reach" ref={reachRef} onPointerDown={wash.down} onPointerMove={wash.move} onPointerUp={wash.up} onPointerCancel={wash.up} />
111+ </>
112+ );
113+}
114+
115+/**
116+ * Washing the tiles about with a finger.
117+ *
118+ * A discard's position is not part of the game — the engine neither knows nor
119+ * cares where in the middle a tile is lying — so this is free to be pure fidget,
120+ * the thing everybody does to the pool while waiting for their turn.
121+ */
122+function useWash(pool: TablePool) {
123+ const last = useRef<{ x: number; y: number; t: number } | null>(null);
124+
125+ const at = (e: React.PointerEvent) => {
126+ const table = (e.currentTarget as HTMLElement).parentElement;
127+ const r = table?.getBoundingClientRect();
128+ return { x: e.clientX - (r?.left ?? 0), y: e.clientY - (r?.top ?? 0), t: e.timeStamp };
129+ };
130+
131+ return {
132+ down: (e: React.PointerEvent) => {
133+ try {
134+ (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
135+ } catch {
136+ // Uncaptured still works while the pointer stays over the pool.
137+ }
138+ last.current = at(e);
139+ },
140+ move: (e: React.PointerEvent) => {
141+ const prev = last.current;
142+ if (!prev) return;
143+ const now = at(e);
144+ const dt = (now.t - prev.t) / 1000;
145+ last.current = now;
146+ if (dt <= 0) return;
147+ pool.stir(now.x, now.y, (now.x - prev.x) / dt, (now.y - prev.y) / dt);
148+ pool.wake();
149+ },
150+ up: () => {
151+ last.current = null;
152+ },
153+ };
154+}
155+
156+interface Art {
157+ faces: HTMLImageElement | null;
158+ back: HTMLImageElement | null;
102159 }
103160
104161 /**
105- * The tile sheet, as something canvas can draw. The same file the CSS uses for
106- * the tiles in the hands, so the pool cannot drift from the rest of the table.
162+ * The tile art, as something canvas can draw: the face sheet, and the green
163+ * back a tile shows while it is turning over. Both are the same files the CSS
164+ * uses for the tiles in the hands and the wall, so the pool cannot drift from
165+ * the rest of the table.
107166 */
108-function useSheet(): HTMLImageElement | null {
109- const [img, setImg] = useState<HTMLImageElement | null>(null);
167+function useSheet(): Art {
168+ const [art, setArt] = useState<Art>({ faces: null, back: null });
110169 useEffect(() => {
111170 let alive = true;
112- const el = new Image();
113- el.src = '/tiles/tiles.svg';
114- el.decode()
115- .then(() => alive && setImg(el))
171+ const load = (src: string) => {
172+ const el = new Image();
173+ el.src = src;
116174 // Not fatal: the pool falls back to blank tiles rather than emptying out.
117- .catch(() => {});
175+ return el.decode().then(
176+ () => el,
177+ () => null,
178+ );
179+ };
180+ void Promise.all([load('/tiles/tiles.svg'), load('/tiles/back.png')]).then(
181+ ([faces, back]) => alive && setArt({ faces, back }),
182+ );
118183 return () => {
119184 alive = false;
120185 };
121186 }, []);
122- return img;
187+ return art;
123188 }
124189
125190 /** How high a tile in somebody's fingers is held above the felt. */
126191 const HELD_Z = 52;
127192
193+/**
194+ * Park the reachable patch over the pool. Driven off the measurement rather
195+ * than restated in CSS, for the same reason the colliders are: the wall square's
196+ * size is worked out in one place and this follows it.
197+ */
198+function placeReach(el: HTMLDivElement | null, pool: TablePool) {
199+ if (!el) return;
200+ const r = pool.poolRect;
201+ if (!r) {
202+ el.style.display = 'none';
203+ el.dataset.box = '';
204+ return;
205+ }
206+ const box = [r.x, r.y, r.w, r.h].map(Math.round);
207+ const stamp = box.join(',');
208+ if (el.dataset.box === stamp) return;
209+ el.dataset.box = stamp;
210+ el.style.display = '';
211+ el.style.left = `${box[0]}px`;
212+ el.style.top = `${box[1]}px`;
213+ el.style.width = `${box[2]}px`;
214+ el.style.height = `${box[3]}px`;
215+}
216+
128217 function paint(
129218 ctx: CanvasRenderingContext2D,
130219 canvas: HTMLCanvasElement,
131220 pool: TablePool,
132- sheet: HTMLImageElement | null,
221+ art: Art,
133222 ) {
134223 const dpr = window.devicePixelRatio || 1;
135224 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
⋯ 5 unchanged lines
141230
142231 // Shadows in one pass of their own, offset before rotation, so the light comes
143232 // 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.h, b.scale);
145- if (held) shadow(ctx, held.x, held.y, HELD_Z, held.angle, held.w, held.h, 1);
233+ for (const b of bodies) {
234+ shadow(ctx, b.x, b.y, b.z, b.angle, b.w, b.h, b.scale, faceOn(b), b.flipAxis);
235+ }
236+ if (held) shadow(ctx, held.x, held.y, HELD_Z, held.angle, held.w, held.h, 1, 1, 0);
146237
147238 const newest = pool.newest;
148239 for (const b of bodies) {
149- const s = zScale(b.z) * b.scale;
150- ctx.save();
151- ctx.translate(b.x, b.y);
152- ctx.rotate(b.angle);
153- face(ctx, b.tile, b.w * s, b.h * s, sheet, b === newest);
154- ctx.restore();
240+ tile(ctx, art, {
241+ x: b.x,
242+ y: b.y,
243+ z: b.z,
244+ angle: b.angle,
245+ w: b.w * b.scale,
246+ h: b.h * b.scale,
247+ facing: faceOn(b),
248+ axis: b.flipAxis,
249+ code: b.tile,
250+ lit: b === newest,
251+ });
155252 }
156253
157254 // On its way to whoever called it — lifted, turning, and fading as it goes.
158255 for (const { body: b, fade } of departing) {
159- const s = zScale(b.z) * b.scale;
160256 ctx.save();
161257 ctx.globalAlpha = fade;
162- ctx.translate(b.x, b.y);
163- ctx.rotate(b.angle);
164- face(ctx, b.tile, b.w * s, b.h * s, sheet, true);
258+ tile(ctx, art, {
259+ x: b.x,
260+ y: b.y,
261+ z: b.z,
262+ angle: b.angle,
263+ w: b.w * b.scale,
264+ h: b.h * b.scale,
265+ facing: faceOn(b),
266+ axis: b.flipAxis,
267+ code: b.tile,
268+ lit: true,
269+ });
165270 ctx.restore();
166271 }
167272
168273 // Whatever is in your fingers is above everything else on the table.
169274 if (held) {
170- const s = zScale(HELD_Z);
171- ctx.save();
172- ctx.translate(held.x, held.y);
173- ctx.rotate(held.angle);
174- face(ctx, held.tile, held.w * s, held.h * s, sheet, true);
175- ctx.restore();
275+ tile(ctx, art, {
276+ x: held.x,
277+ y: held.y,
278+ z: HELD_Z,
279+ angle: held.angle,
280+ w: held.w,
281+ h: held.h,
282+ facing: 1,
283+ axis: 0,
284+ code: held.tile,
285+ lit: true,
286+ });
176287 }
177288 }
178289
179290 /**
291+ * How thick a tile is, as a fraction of its width. A real one is about
292+ * three-quarters as thick as it is wide; this is the part of that you can see
293+ * from almost overhead, which is a good deal less.
294+ */
295+const THICKNESS = 0.3;
296+/** The ivory of the tile's own body, seen along its sides. */
297+const EDGE_LIGHT = '#e6dcc2';
298+const EDGE_DARK = '#b9ad8d';
299+
300+interface Drawn {
301+ x: number;
302+ y: number;
303+ z: number;
304+ angle: number;
305+ w: number;
306+ h: number;
307+ /** cos of the turn: 1 flat on its back, 0 edge on, negative face down. */
308+ facing: number;
309+ axis: number;
310+ code: number;
311+ lit: boolean;
312+}
313+
314+/**
315+ * One tile, with the thickness of one.
316+ *
317+ * Drawn as two passes: the body of the tile, offset down the screen towards the
318+ * viewer, and then its face on top of that. The sliver of body left showing
319+ * along the bottom edge is what stops a tile reading as a printed card.
320+ *
321+ * Turning over makes that matter more, not less. Face on you see the face and a
322+ * hair of the side; edge on there is no face left to see and the tile is a bar
323+ * of ivory as thick as it really is — which is why the squash uses the
324+ * rectangle's true projected width, `w·|cos| + thickness·|sin|`, rather than
325+ * pinching to nothing and back.
326+ */
327+function tile(ctx: CanvasRenderingContext2D, art: Art, d: Drawn) {
328+ const s = zScale(d.z);
329+ const w = d.w * s;
330+ const h = d.h * s;
331+ const thick = w * THICKNESS;
332+ const c = Math.abs(d.facing);
333+ const sin = Math.sqrt(Math.max(0, 1 - d.facing * d.facing));
334+ // What the whole slab covers across the axis it is turning about.
335+ const projected = c + (thick / w) * sin;
336+ // How much of the body shows past the face: none of it face on beyond its own
337+ // thickness, all of it edge on. Higher tiles show a touch more of their side.
338+ const lift = thick * (0.45 + 0.55 * sin) + d.z * 0.05;
339+
340+ // --- the body of the tile, seen from just off square
341+ ctx.save();
342+ ctx.translate(d.x, d.y + lift);
343+ squash(ctx, projected, d.axis);
344+ ctx.rotate(d.angle);
345+ const grad = ctx.createLinearGradient(0, -h / 2, 0, h / 2);
346+ grad.addColorStop(0, EDGE_DARK);
347+ grad.addColorStop(1, EDGE_LIGHT);
348+ ctx.fillStyle = grad;
349+ tilePath(ctx, w, h);
350+ ctx.fill();
351+ ctx.restore();
352+
353+ // --- and its face on top
354+ ctx.save();
355+ ctx.translate(d.x, d.y);
356+ squash(ctx, Math.max(0.015, c), d.axis);
357+ ctx.rotate(d.angle);
358+ face(ctx, d.code, w, h, art, d.lit, d.facing < 0);
359+ ctx.restore();
360+}
361+
362+/**
180363 * A soft silhouette under a tile.
181364 *
182365 * The blur is baked into a sprite once per tile size rather than applied per
⋯ 32 unchanged lines
215398 return c;
216399 }
217400
401+/**
402+ * Foreshortening along the axis a tile is turning about.
403+ *
404+ * There is no third dimension here, so an out-of-plane turn is drawn as a
405+ * squash. Applied in the *table's* frame, before the tile's own flat rotation,
406+ * because the axis it turns about is fixed by the direction it was thrown, not
407+ * by which way the tile happens to be facing.
408+ */
409+function squash(ctx: CanvasRenderingContext2D, across: number, axis: number) {
410+ if (across > 0.999) return;
411+ ctx.rotate(axis);
412+ ctx.scale(across, 1);
413+ ctx.rotate(-axis);
414+}
415+
218416 function shadow(
219417 ctx: CanvasRenderingContext2D,
220418 x: number,
⋯ 3 unchanged lines
224422 w: number,
225423 h: number,
226424 scale: number,
425+ facing: number,
426+ axis: number,
227427 ) {
228428 // Keyed on the tile's own size, never on the eased one — a scale that changes
229429 // every frame would mean a fresh blur every frame, which is the thing this
⋯ 3 unchanged lines
233433 ctx.save();
234434 ctx.globalAlpha = 0.36 - Math.min(0.18, z * 0.0016);
235435 ctx.translate(x + z * 0.12, y + 3 + z * 0.34);
436+ squash(ctx, Math.abs(facing) + THICKNESS * Math.sqrt(Math.max(0, 1 - facing * facing)), axis);
236437 ctx.rotate(angle);
237438 ctx.drawImage(
238439 sprite,
⋯ 10 unchanged lines
249450 tile: number,
250451 w: number,
251452 h: number,
252- sheet: HTMLImageElement | null,
453+ art: Art,
253454 lit: boolean,
455+ backwards: boolean,
254456 ) {
255- if (sheet) {
457+ // Part-way through a turn you are looking at the back of it: the same green
458+ // tile back the wall is built from. A tile always comes to rest face up, so
459+ // this is only ever seen in the air.
460+ if (backwards) {
461+ if (art.back) ctx.drawImage(art.back, -w / 2, -h / 2, w, h);
462+ else {
463+ tilePath(ctx, w, h);
464+ ctx.fillStyle = '#1c7a4b';
465+ ctx.fill();
466+ }
467+ return;
468+ }
469+
470+ if (art.faces) {
256471 // 43 columns, two rows; the second is the sheet's own highlighted variant,
257472 // which is what a lifted tile in a hand uses. The tile in your fingers and
258473 // the one still to be claimed get it here for the same reason.
259- const cw = sheet.naturalWidth / SPRITE_COLS;
260- const ch = sheet.naturalHeight / 2;
261- ctx.drawImage(sheet, SPRITE_COL[tile] * cw, lit ? ch : 0, cw, ch, -w / 2, -h / 2, w, h);
474+ const cw = art.faces.naturalWidth / SPRITE_COLS;
475+ const ch = art.faces.naturalHeight / 2;
476+ ctx.drawImage(art.faces, SPRITE_COL[tile] * cw, lit ? ch : 0, cw, ch, -w / 2, -h / 2, w, h);
262477 } else {
263478 tilePath(ctx, w, h);
264479 ctx.fillStyle = '#f6f0e2';
⋯ 15 unchanged lines
modifiedsrc/ui/Seat.tsx+0 −10
⋯ 257 unchanged lines
258258 {tenpai.length > 0 && (
259259 <span className="tag ting">聽 Ready {tenpai.map(labelOf).join('·')}</span>
260260 )}
261- {/* Says why a tile lobs over the square rather than sliding into it —
262- the wall in front of this seat has not been eaten away yet. */}
263- {myTurn && pool && !pool.canThrow(seat) && (
264- <span
265- className="tag walled"
266- title="牆未開 — the wall in front of you is still standing, so a tile has to go over it rather than across the table"
267- >
268- 牆未開 Wall up
269- </span>
270- )}
271261 {/* Without this the 胡 button just quietly fails to appear. */}
272262 {sacred.length > 0 && (
273263 <span className="tag sacred" title="過水 — passed on winning with these until your next draw">
⋯ 17 unchanged lines