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 | |||
| 109 | 109 | useEffect(() => game.onSound(play), [game]); | |
| 110 | 110 | // Watches the table for the whole session and only stirs when a seat the | |
| 111 | 111 | // 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]); | |
| 113 | 114 | ||
| 114 | 115 | if (phase === 'lobby') { | |
| 115 | 116 | const round = saved ? Math.floor(saved.state.rotations / 4) % 4 : 0; | |
| ⋯ 97 unchanged lines | |||
modifiedsrc/game/autoplay.ts+33 −3
| ⋯ 27 unchanged lines | |||
| 28 | 28 | const THINK_CLAIM = 380; | |
| 29 | 29 | /** A little unevenness, so the table doesn't tick like a metronome. */ | |
| 30 | 30 | 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; | |
| 31 | 39 | ||
| 32 | 40 | /** Whose move it is, if it is a bot's, and how long to sit on it. */ | |
| 33 | 41 | export function pendingBot(game: Game): { seat: SeatId; delay: number } | null { | |
| ⋯ 53 unchanged lines | |||
| 87 | 95 | export class AutoPlay { | |
| 88 | 96 | private timer: ReturnType<typeof setTimeout> | null = null; | |
| 89 | 97 | private game: Game; | |
| 98 | + | private busy: () => boolean; | |
| 90 | 99 | ||
| 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) { | |
| 92 | 109 | this.game = game; | |
| 110 | + | this.busy = busy; | |
| 93 | 111 | } | |
| 94 | 112 | ||
| 95 | 113 | /** Start watching. Returns the disposer. */ | |
| ⋯ 19 unchanged lines | |||
| 115 | 133 | // The state can move under us — a person taking an undo, say — so the move | |
| 116 | 134 | // is worked out again at the moment it is played, never here. | |
| 117 | 135 | const at = this.game.getSnapshot(); | |
| 118 | - | this.timer = setTimeout(() => { | |
| 136 | + | let waitedFrom = 0; | |
| 137 | + | ||
| 138 | + | const play = () => { | |
| 119 | 139 | this.timer = null; | |
| 120 | 140 | 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 | + | } | |
| 121 | 149 | stepBots(this.game); | |
| 122 | - | }, wait); | |
| 150 | + | }; | |
| 151 | + | ||
| 152 | + | this.timer = setTimeout(play, wait); | |
| 123 | 153 | }; | |
| 124 | 154 | } | |
modifiedsrc/styles.css+16 −0
| ⋯ 528 unchanged lines | |||
| 529 | 529 | pointer-events: none; | |
| 530 | 530 | z-index: 1; | |
| 531 | 531 | } | |
| 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 | + | } | |
| 532 | 545 | ||
| 533 | 546 | /* ---------- center ---------- */ | |
| 534 | 547 | .center { | |
| ⋯ 152 unchanged lines | |||
| 687 | 700 | box-shadow: 0 0 12px rgba(217, 180, 92, 0.7); | |
| 688 | 701 | } | |
| 689 | 702 | ||
| 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. */ | |
| 690 | 705 | .facing { | |
| 706 | + | pointer-events: none; | |
| 691 | 707 | position: relative; | |
| 692 | 708 | z-index: 1; | |
| 693 | 709 | display: flex; | |
| ⋯ 1227 unchanged lines | |||
modifiedsrc/table/geometry.ts+77 −5
| ⋯ 20 unchanged lines | |||
| 21 | 21 | export interface TableGeometry { | |
| 22 | 22 | /** Table size, world units — the world's origin is the table's top left. */ | |
| 23 | 23 | 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`. */ | |
| 25 | 26 | 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; | |
| 26 | 36 | /** Stacks of two still standing. Half stacks are low enough to throw over. */ | |
| 27 | 37 | walls: Rect[]; | |
| 28 | 38 | /** A tile in the pool is the same tile that was in the wall. */ | |
| 29 | 39 | 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. */ | |
| 31 | 41 | 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>>; | |
| 32 | 45 | } | |
| 33 | 46 | ||
| 34 | 47 | export interface Point { | |
| ⋯ 45 unchanged lines | |||
| 80 | 93 | }; | |
| 81 | 94 | ||
| 82 | 95 | const launch: Partial<Record<SeatId, Point>> = {}; | |
| 96 | + | const throwFrom: Partial<Record<SeatId, Point>> = {}; | |
| 83 | 97 | for (const seat of SEATS) { | |
| 84 | 98 | const hand = table.querySelector<HTMLElement>(`[data-seat="${seat}"] .hand`); | |
| 85 | 99 | if (!hand) continue; | |
| ⋯ 2 unchanged lines | |||
| 88 | 102 | // A rotated element's bounding box is still centred on the element, so this | |
| 89 | 103 | // is the right point whichever edge the seat is sitting at. | |
| 90 | 104 | 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]; | |
| 91 | 115 | } | |
| 92 | 116 | ||
| 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 | + | ||
| 93 | 121 | return { | |
| 94 | 122 | size: { w: base.width, h: base.height }, | |
| 95 | 123 | pool, | |
| 124 | + | felt, | |
| 96 | 125 | walls, | |
| 97 | - | tile: { w: short, h: short * 1.375 }, | |
| 126 | + | tile: poolTile(table, short), | |
| 98 | 127 | launch, | |
| 128 | + | throwFrom, | |
| 99 | 129 | }; | |
| 100 | 130 | } | |
| 101 | 131 | ||
| 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 | + | ||
| 102 | 154 | export const centreOf = (r: Rect): Point => ({ x: r.x + r.w / 2, y: r.y + r.h / 2 }); | |
| 103 | 155 | ||
| 104 | 156 | /** | |
| ⋯ 10 unchanged lines | |||
| 115 | 167 | */ | |
| 116 | 168 | export function canThrow(geo: TableGeometry, seat: SeatId): boolean { | |
| 117 | 169 | 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; | |
| 119 | 184 | return aimPoints(geo).some((to) => clearLine(geo.walls, from, to)); | |
| 120 | 185 | } | |
| 121 | 186 | ||
| 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 | + | ||
| 122 | 190 | /** Somewhere in the pool this seat could actually get a tile to, or null. */ | |
| 123 | 191 | export function aimAt(geo: TableGeometry, seat: SeatId): Point | null { | |
| 124 | 192 | const from = geo.launch[seat]; | |
| ⋯ 20 unchanged lines | |||
| 145 | 213 | */ | |
| 146 | 214 | function aimPoints(geo: TableGeometry): Point[] { | |
| 147 | 215 | 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]; | |
| 149 | 221 | const out: Point[] = []; | |
| 150 | 222 | for (const u of steps) for (const v of steps) out.push({ x: x + w * u, y: y + h * v }); | |
| 151 | 223 | return out; | |
| ⋯ 27 unchanged lines | |||
modifiedsrc/table/physics.test.ts+64 −2
| ⋯ 4 unchanged lines | |||
| 5 | 5 | import { | |
| 6 | 6 | addBody, | |
| 7 | 7 | createWorld, | |
| 8 | + | faceOn, | |
| 9 | + | liftFor, | |
| 8 | 10 | outside, | |
| 9 | 11 | overlapping, | |
| 10 | 12 | settled, | |
| ⋯ 6 unchanged lines | |||
| 17 | 19 | const POOL: Rect = { x: 200, y: 200, w: 220, h: 220 }; | |
| 18 | 20 | /** Same aspect as a real tile, roughly pool-tile sized. */ | |
| 19 | 21 | 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; | |
| 22 | 24 | ||
| 23 | 25 | function throwAt(rng: () => number): Spawn { | |
| 24 | 26 | const centre = { x: POOL.x + POOL.w / 2, y: POOL.y + POOL.h / 2 }; | |
| ⋯ 143 unchanged lines | |||
| 168 | 170 | expect(outside(b, POOL)).toBeLessThan(0.5); | |
| 169 | 171 | }); | |
| 170 | 172 | ||
| 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 | + | ||
| 171 | 228 | it('is deterministic — the same throws give the same pile', () => { | |
| 172 | 229 | const pile = () => { | |
| 173 | 230 | const rng = makeRng(99); | |
| ⋯ 53 unchanged lines | |||
| 227 | 284 | }); | |
| 228 | 285 | for (let i = 0; i < 8; i++) at(i); | |
| 229 | 286 | ||
| 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); | |
| 230 | 290 | expect(settled(world)).toBe(true); | |
| 231 | 291 | for (const b of world.bodies) expect(outside(b, POOL)).toBeLessThan(0.5); | |
| 232 | 292 | // Nothing sharing ground with anything else. | |
| ⋯ 14 unchanged lines | |||
| 247 | 307 | pool: POOL, | |
| 248 | 308 | walls, | |
| 249 | 309 | tile: TILE, | |
| 310 | + | felt: { x: 120, y: 120, w: 380, h: 380 }, | |
| 250 | 311 | launch: { 0: { x: 310, y: 560 } }, | |
| 312 | + | throwFrom: { 0: { x: 310, y: 560 } }, | |
| 251 | 313 | }; | |
| 252 | 314 | } | |
| 253 | 315 | ||
| ⋯ 112 unchanged lines | |||
modifiedsrc/table/physics.ts+148 −9
| ⋯ 97 unchanged lines | |||
| 98 | 98 | /** How fast a tile drawn at hand size shrinks to the size it is in the pool. */ | |
| 99 | 99 | const SCALE_EASE = 6; | |
| 100 | 100 | ||
| 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 | + | ||
| 101 | 117 | export interface Rect { | |
| 102 | 118 | x: number; | |
| 103 | 119 | y: number; | |
| ⋯ 24 unchanged lines | |||
| 128 | 144 | vz: number; | |
| 129 | 145 | angle: number; | |
| 130 | 146 | 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; | |
| 131 | 156 | w: number; | |
| 132 | 157 | h: number; | |
| 133 | 158 | /** | |
| ⋯ 33 unchanged lines | |||
| 167 | 192 | bodies: Body[]; | |
| 168 | 193 | /** Left-over time from the last frame, so steps stay a fixed size. */ | |
| 169 | 194 | 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; | |
| 170 | 204 | } | |
| 171 | 205 | ||
| 172 | 206 | export interface Spawn { | |
| ⋯ 7 unchanged lines | |||
| 180 | 214 | vz: number; | |
| 181 | 215 | angle: number; | |
| 182 | 216 | 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; | |
| 183 | 222 | w: number; | |
| 184 | 223 | h: number; | |
| 185 | 224 | /** Drawn this much bigger to start with, easing to its pool size. */ | |
| ⋯ 3 unchanged lines | |||
| 189 | 228 | } | |
| 190 | 229 | ||
| 191 | 230 | 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 }; | |
| 193 | 232 | } | |
| 194 | 233 | ||
| 195 | 234 | export function addBody(world: World, s: Spawn): Body { | |
| ⋯ 8 unchanged lines | |||
| 204 | 243 | vz: s.atRest ? 0 : s.vz, | |
| 205 | 244 | angle: s.angle, | |
| 206 | 245 | 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, | |
| 207 | 252 | w: s.w, | |
| 208 | 253 | h: s.h, | |
| 209 | 254 | scale: s.atRest ? 1 : s.scale ?? 1, | |
| ⋯ 4 unchanged lines | |||
| 214 | 259 | awake: 0, | |
| 215 | 260 | }; | |
| 216 | 261 | 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 | + | } | |
| 219 | 269 | return body; | |
| 220 | 270 | } | |
| 221 | 271 | ||
| ⋯ 95 unchanged lines | |||
| 317 | 367 | /** One fixed step. Exported for the tests, which drive it directly. */ | |
| 318 | 368 | export function step(world: World, impacts: Impact[] = []): Impact[] { | |
| 319 | 369 | const dt = FIXED_DT; | |
| 370 | + | world.contacts = 0; | |
| 320 | 371 | ||
| 321 | 372 | for (const b of world.bodies) { | |
| 322 | 373 | if (b.scale !== 1) { | |
| ⋯ 22 unchanged lines | |||
| 345 | 396 | b.y += b.vy * dt; | |
| 346 | 397 | b.angle += b.spin * dt; | |
| 347 | 398 | ||
| 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 | + | ||
| 348 | 414 | if (inside(world.bounds, b.x, b.y)) b.entered = true; | |
| 349 | 415 | else if (b.age > ENTRY_GRACE) { | |
| 350 | 416 | // Out of time going the long way round. Put it in, gently. | |
| ⋯ 27 unchanged lines | |||
| 378 | 444 | // Being shoved by a neighbour can put a tile back over the edge, and a tile | |
| 379 | 445 | // that then fell asleep out there would stay out there. Only awake ones need | |
| 380 | 446 | // 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. | |
| 381 | 449 | 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); | |
| 383 | 451 | } | |
| 384 | 452 | // Deciding to sleep comes last, so it judges where the step actually left | |
| 385 | 453 | // things — before, a contact resolved after the decision would undo it and a | |
| 386 | 454 | // crowded pool would stay awake for ever. | |
| 387 | 455 | 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; | |
| 388 | 461 | return impacts; | |
| 389 | 462 | } | |
| 390 | 463 | ||
| ⋯ 123 unchanged lines | |||
| 514 | 587 | for (let j = i + 1; j < bodies.length; j++) { | |
| 515 | 588 | const b = bodies[j]; | |
| 516 | 589 | if (b.z > ON_FELT) continue; | |
| 517 | - | if (a.resting && b.resting) continue; | |
| 518 | 590 | 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); | |
| 520 | 596 | } | |
| 521 | 597 | } | |
| 522 | 598 | } | |
| 523 | 599 | ||
| 524 | - | function resolvePair(a: Body, b: Body, impacts: Impact[]) { | |
| 600 | + | function resolvePair(a: Body, b: Body, impacts: Impact[], world: World) { | |
| 525 | 601 | const hit = overlapOf(a, b); | |
| 526 | 602 | if (!hit) return; | |
| 527 | 603 | // Resting in contact is not a collision. See CONTACT_SLOP. | |
| ⋯ 1 unchanged line | |||
| 529 | 605 | if (depth <= 0) return; | |
| 530 | 606 | const { nx, ny } = hit; | |
| 531 | 607 | ||
| 608 | + | world.contacts++; | |
| 532 | 609 | // Push apart. A tile that has settled is the heavy one: a tile thrown into a | |
| 533 | 610 | // pool shoulders in, rather than the pool scattering out of its way. | |
| 534 | 611 | const aShare = a.resting === b.resting ? 0.5 : a.resting ? 0 : 1; | |
| ⋯ 44 unchanged lines | |||
| 579 | 656 | b.spin = 0; | |
| 580 | 657 | b.z = 0; | |
| 581 | 658 | 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; | |
| 582 | 663 | b.resting = true; | |
| 583 | 664 | b.awake = 0; | |
| 584 | 665 | } | |
| ⋯ 1 unchanged line | |||
| 586 | 667 | function trySleep(b: Body) { | |
| 587 | 668 | const speed = Math.hypot(b.vx, b.vy); | |
| 588 | 669 | 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; | |
| 590 | 676 | if (slow && ++b.still >= SLEEP_STEPS) return sleep(b); | |
| 591 | 677 | if (!slow) b.still = 0; | |
| 592 | 678 | // Backstop: shuffling about in a crowded pool for this long is settled enough. | |
| ⋯ 41 unchanged lines | |||
| 634 | 720 | b.y = Math.min(maxY, Math.max(minY, b.y)); | |
| 635 | 721 | } | |
| 636 | 722 | ||
| 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 | + | ||
| 637 | 731 | /** Whether everything has settled — what the render loop idles on. */ | |
| 638 | 732 | 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); | |
| 640 | 739 | ||
| 641 | 740 | /** How far outside the pool a tile is sitting. The tests' invariant. */ | |
| 642 | 741 | export function outside(b: Body, pool: Rect): number { | |
| ⋯ 7 unchanged lines | |||
| 650 | 749 | ); | |
| 651 | 750 | } | |
| 652 | 751 | ||
| 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 | + | ||
| 653 | 792 | /** Whether two tiles are sharing ground, which they must never do at rest. */ | |
| 654 | 793 | export function overlapping(a: Body, b: Body): number { | |
| 655 | 794 | const hit = overlapOf(a, b); | |
| ⋯ 16 unchanged lines | |||
modifiedsrc/table/pool.ts+95 −13
| 1 | 1 | import type { GameState } from '../game/engine'; | |
| 2 | 2 | import { makeRng, type Tile } from '../game/tiles'; | |
| 3 | 3 | import type { SeatId } from '../game/types'; | |
| 4 | - | import { aimAt, canThrow, centreOf, measure, type Point, type TableGeometry } from './geometry'; | |
| 5 | 4 | import { | |
| 5 | + | aimAt, | |
| 6 | + | canThrow, | |
| 7 | + | canThrowFrom, | |
| 8 | + | centreOf, | |
| 9 | + | measure, | |
| 10 | + | type Point, | |
| 11 | + | type TableGeometry, | |
| 12 | + | } from './geometry'; | |
| 13 | + | import { | |
| 6 | 14 | addBody, | |
| 7 | 15 | advance as advanceWorld, | |
| 8 | 16 | createWorld, | |
| 9 | 17 | liftFor, | |
| 10 | 18 | reshape, | |
| 11 | 19 | settled, | |
| 20 | + | stir, | |
| 12 | 21 | SLIDE_TIME, | |
| 13 | 22 | type Body, | |
| 14 | 23 | type Impact, | |
| 24 | + | type Rect, | |
| 15 | 25 | type World, | |
| 16 | 26 | } from './physics'; | |
| 17 | 27 | ||
| ⋯ 18 unchanged lines | |||
| 36 | 46 | ||
| 37 | 47 | /** How long a placed tile is in the air. Long enough to arc over the wall. */ | |
| 38 | 48 | 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; | |
| 41 | 51 | /** Fastest flick we will honour, px/s. Beyond this it is a slip, not a throw. */ | |
| 42 | 52 | export const MAX_THROW_SPEED = 3600; | |
| 43 | 53 | /** How long a claimed tile takes to leave the table for the seat taking it. */ | |
| ⋯ 8 unchanged lines | |||
| 52 | 62 | t: number; | |
| 53 | 63 | } | |
| 54 | 64 | ||
| 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 | + | ||
| 55 | 76 | /** A tile lifted out of a hand and carried about. Drawn, but not simulated. */ | |
| 56 | 77 | export interface Held { | |
| 57 | 78 | seat: SeatId; | |
| ⋯ 100 unchanged lines | |||
| 158 | 179 | return x >= p.x && x <= p.x + p.w && y >= p.y && y <= p.y + p.h; | |
| 159 | 180 | } | |
| 160 | 181 | ||
| 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 | + | ||
| 161 | 204 | // ---- the tile in your fingers ----------------------------------------- | |
| 162 | 205 | ||
| 163 | 206 | /** Lifted out of a hand. From here the canvas draws it, not the DOM. */ | |
| ⋯ 137 unchanged lines | |||
| 301 | 344 | return (this.newestKey && this.entries.get(this.newestKey)) || null; | |
| 302 | 345 | } | |
| 303 | 346 | ||
| 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 | + | ||
| 304 | 358 | /** Nothing is moving, so the render loop can stop until something changes. */ | |
| 305 | 359 | get settled(): boolean { | |
| 306 | 360 | return !this.held && this.leaving.length === 0 && (!this.world || settled(this.world)); | |
| ⋯ 23 unchanged lines | |||
| 330 | 384 | this.setOpenness('----'); | |
| 331 | 385 | return; | |
| 332 | 386 | } | |
| 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); | |
| 335 | 392 | // Only here, never per frame: a hundred-odd ray casts is nothing once a | |
| 336 | 393 | // draw, and a great deal every sixtieth of a second. | |
| 337 | 394 | this.setOpenness(SEATS.map((s) => (canThrow(geo, s) ? 'y' : '-')).join('')); | |
| ⋯ 53 unchanged lines | |||
| 391 | 448 | // wants to be. A standing wall means it has to be *thrown* instead: lifted | |
| 392 | 449 | // over the top and dropped in, and then the flick's speed decides how far | |
| 393 | 450 | // 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. | |
| 394 | 455 | 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); | |
| 396 | 457 | let { vx, vy } = thrown; | |
| 397 | 458 | let vz = 0; | |
| 459 | + | const speed = Math.hypot(vx, vy); | |
| 460 | + | let flipTurns = 0; | |
| 461 | + | let flipOver = 0; | |
| 398 | 462 | ||
| 399 | 463 | if (!slide) { | |
| 400 | 464 | // Lofted over a standing wall. It still has to *land* on the table | |
| ⋯ 4 unchanged lines | |||
| 405 | 469 | const centre = centreOf(geo.pool); | |
| 406 | 470 | const reach = PLACE_FLIGHT + SLIDE_TIME; | |
| 407 | 471 | const cap = (Math.hypot(centre.x - thrown.x, centre.y - thrown.y) / reach) * 2.4; | |
| 408 | - | const speed = Math.hypot(vx, vy); | |
| 409 | 472 | const aim = Math.min(speed, cap) / (speed || 1); | |
| 410 | 473 | vx *= aim; | |
| 411 | 474 | vy *= aim; | |
| 412 | 475 | 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; | |
| 413 | 479 | } 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; | |
| 416 | 486 | } | |
| 417 | 487 | ||
| 418 | 488 | return addBody(this.world!, { | |
| ⋯ 7 unchanged lines | |||
| 426 | 496 | vz, | |
| 427 | 497 | angle: thrown.angle, | |
| 428 | 498 | 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), | |
| 429 | 503 | w: geo.tile.w, | |
| 430 | 504 | h: geo.tile.h, | |
| 431 | 505 | // It was drawn at hand size a moment ago; it shrinks on the way down. | |
| ⋯ 1 unchanged line | |||
| 433 | 507 | }); | |
| 434 | 508 | } | |
| 435 | 509 | ||
| 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); | |
| 438 | 514 | const target = this.aimFor(geo, a.seat, rng); | |
| 439 | 515 | // Aim for where it comes to *rest*, not where it first touches down: a tile | |
| 440 | 516 | // keeps sliding after it lands, and SLIDE_TIME is roughly how much further. | |
| 441 | 517 | const flight = open ? 0.34 + rng() * 0.1 : PLACE_FLIGHT; | |
| 442 | 518 | const reach = flight + SLIDE_TIME; | |
| 443 | 519 | ||
| 520 | + | const vx = (target.x - from.x) / reach; | |
| 521 | + | const vy = (target.y - from.y) / reach; | |
| 444 | 522 | return addBody(this.world!, { | |
| 445 | 523 | tile: a.tile, | |
| 446 | 524 | seat: a.seat, | |
| 447 | 525 | x: from.x, | |
| 448 | 526 | y: from.y, | |
| 449 | - | vx: (target.x - from.x) / reach, | |
| 450 | - | vy: (target.y - from.y) / reach, | |
| 527 | + | vx, | |
| 528 | + | vy, | |
| 451 | 529 | z: 0, | |
| 452 | 530 | vz: liftFor(flight), | |
| 453 | 531 | angle: (rng() - 0.5) * Math.PI, | |
| 454 | 532 | 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), | |
| 455 | 537 | w: geo.tile.w, | |
| 456 | 538 | h: geo.tile.h, | |
| 457 | 539 | }); | |
| ⋯ 18 unchanged lines | |||
modifiedsrc/ui/Pool.tsx+255 −40
| ⋯ 1 unchanged line | |||
| 2 | 2 | import type { Game } from '../game/engine'; | |
| 3 | 3 | import { knock } from '../game/sound'; | |
| 4 | 4 | import { SPRITE_COL, SPRITE_COLS } from '../game/tiles'; | |
| 5 | - | import { zScale } from '../table/physics'; | |
| 5 | + | import { faceOn, zScale } from '../table/physics'; | |
| 6 | 6 | import type { TablePool } from '../table/pool'; | |
| 7 | 7 | ||
| 8 | 8 | /** | |
| ⋯ 11 unchanged lines | |||
| 20 | 20 | */ | |
| 21 | 21 | export function Pool({ game, pool }: { game: Game; pool: TablePool }) { | |
| 22 | 22 | const canvasRef = useRef<HTMLCanvasElement>(null); | |
| 23 | - | const sheet = useSheet(); | |
| 23 | + | const reachRef = useRef<HTMLDivElement>(null); | |
| 24 | + | const art = useSheet(); | |
| 25 | + | const wash = useWash(pool); | |
| 24 | 26 | ||
| 25 | 27 | // 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 }; | |
| 28 | 30 | ||
| 29 | 31 | // Poked by the effect below, and by the gesture through pool.wake. | |
| 30 | 32 | const wake = useRef<() => void>(() => {}); | |
| ⋯ 29 unchanged lines | |||
| 60 | 62 | previous = now; | |
| 61 | 63 | ||
| 62 | 64 | pool.sync(live.current.state); | |
| 65 | + | placeReach(reachRef.current, pool); | |
| 63 | 66 | // The loudest contact this frame is the one you would actually hear; the | |
| 64 | 67 | // rest of a scatter is the same event. | |
| 65 | 68 | const impacts = pool.advance(dt); | |
| 66 | 69 | let loudest = 0; | |
| 67 | 70 | for (const i of impacts) loudest = Math.max(loudest, i.strength); | |
| 68 | 71 | if (loudest > 0) knock(loudest); | |
| 69 | - | paint(ctx, canvas, pool, live.current.sheet); | |
| 72 | + | paint(ctx, canvas, pool, live.current.art); | |
| 70 | 73 | ||
| 71 | 74 | // Keep going while anything is moving; otherwise wait to be poked. | |
| 72 | 75 | if (!pool.settled) frame = requestAnimationFrame(tick); | |
| ⋯ 25 unchanged lines | |||
| 98 | 101 | wake.current(); | |
| 99 | 102 | }); | |
| 100 | 103 | ||
| 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; | |
| 102 | 159 | } | |
| 103 | 160 | ||
| 104 | 161 | /** | |
| 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. | |
| 107 | 166 | */ | |
| 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 }); | |
| 110 | 169 | useEffect(() => { | |
| 111 | 170 | 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; | |
| 116 | 174 | // 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 | + | ); | |
| 118 | 183 | return () => { | |
| 119 | 184 | alive = false; | |
| 120 | 185 | }; | |
| 121 | 186 | }, []); | |
| 122 | - | return img; | |
| 187 | + | return art; | |
| 123 | 188 | } | |
| 124 | 189 | ||
| 125 | 190 | /** How high a tile in somebody's fingers is held above the felt. */ | |
| 126 | 191 | const HELD_Z = 52; | |
| 127 | 192 | ||
| 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 | + | ||
| 128 | 217 | function paint( | |
| 129 | 218 | ctx: CanvasRenderingContext2D, | |
| 130 | 219 | canvas: HTMLCanvasElement, | |
| 131 | 220 | pool: TablePool, | |
| 132 | - | sheet: HTMLImageElement | null, | |
| 221 | + | art: Art, | |
| 133 | 222 | ) { | |
| 134 | 223 | const dpr = window.devicePixelRatio || 1; | |
| 135 | 224 | ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| ⋯ 5 unchanged lines | |||
| 141 | 230 | ||
| 142 | 231 | // Shadows in one pass of their own, offset before rotation, so the light comes | |
| 143 | 232 | // 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); | |
| 146 | 237 | ||
| 147 | 238 | const newest = pool.newest; | |
| 148 | 239 | 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 | + | }); | |
| 155 | 252 | } | |
| 156 | 253 | ||
| 157 | 254 | // On its way to whoever called it — lifted, turning, and fading as it goes. | |
| 158 | 255 | for (const { body: b, fade } of departing) { | |
| 159 | - | const s = zScale(b.z) * b.scale; | |
| 160 | 256 | ctx.save(); | |
| 161 | 257 | 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 | + | }); | |
| 165 | 270 | ctx.restore(); | |
| 166 | 271 | } | |
| 167 | 272 | ||
| 168 | 273 | // Whatever is in your fingers is above everything else on the table. | |
| 169 | 274 | 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 | + | }); | |
| 176 | 287 | } | |
| 177 | 288 | } | |
| 178 | 289 | ||
| 179 | 290 | /** | |
| 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 | + | /** | |
| 180 | 363 | * A soft silhouette under a tile. | |
| 181 | 364 | * | |
| 182 | 365 | * The blur is baked into a sprite once per tile size rather than applied per | |
| ⋯ 32 unchanged lines | |||
| 215 | 398 | return c; | |
| 216 | 399 | } | |
| 217 | 400 | ||
| 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 | + | ||
| 218 | 416 | function shadow( | |
| 219 | 417 | ctx: CanvasRenderingContext2D, | |
| 220 | 418 | x: number, | |
| ⋯ 3 unchanged lines | |||
| 224 | 422 | w: number, | |
| 225 | 423 | h: number, | |
| 226 | 424 | scale: number, | |
| 425 | + | facing: number, | |
| 426 | + | axis: number, | |
| 227 | 427 | ) { | |
| 228 | 428 | // Keyed on the tile's own size, never on the eased one — a scale that changes | |
| 229 | 429 | // every frame would mean a fresh blur every frame, which is the thing this | |
| ⋯ 3 unchanged lines | |||
| 233 | 433 | ctx.save(); | |
| 234 | 434 | ctx.globalAlpha = 0.36 - Math.min(0.18, z * 0.0016); | |
| 235 | 435 | 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); | |
| 236 | 437 | ctx.rotate(angle); | |
| 237 | 438 | ctx.drawImage( | |
| 238 | 439 | sprite, | |
| ⋯ 10 unchanged lines | |||
| 249 | 450 | tile: number, | |
| 250 | 451 | w: number, | |
| 251 | 452 | h: number, | |
| 252 | - | sheet: HTMLImageElement | null, | |
| 453 | + | art: Art, | |
| 253 | 454 | lit: boolean, | |
| 455 | + | backwards: boolean, | |
| 254 | 456 | ) { | |
| 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) { | |
| 256 | 471 | // 43 columns, two rows; the second is the sheet's own highlighted variant, | |
| 257 | 472 | // which is what a lifted tile in a hand uses. The tile in your fingers and | |
| 258 | 473 | // 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); | |
| 262 | 477 | } else { | |
| 263 | 478 | tilePath(ctx, w, h); | |
| 264 | 479 | ctx.fillStyle = '#f6f0e2'; | |
| ⋯ 15 unchanged lines | |||
modifiedsrc/ui/Seat.tsx+0 −10
| ⋯ 257 unchanged lines | |||
| 258 | 258 | {tenpai.length > 0 && ( | |
| 259 | 259 | <span className="tag ting">聽 Ready {tenpai.map(labelOf).join('·')}</span> | |
| 260 | 260 | )} | |
| 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 | - | )} | |
| 271 | 261 | {/* Without this the 胡 button just quietly fails to appear. */} | |
| 272 | 262 | {sacred.length > 0 && ( | |
| 273 | 263 | <span className="tag sacred" title="過水 — passed on winning with these until your next draw"> | |
| ⋯ 17 unchanged lines | |||