collin/mahjong · da592f4d
Let the complaint finish before the next player moves
Collin Richards · 2026-08-19 22:02 UTC · da592f4d743663f107c6d00e2b930e1089eef0d5 · parent 7a1ed833 · browse files
modifiedsrc/App.tsx+3 −2
| ⋯ 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 | - | // The computer waits for a thrown tile to land before it reaches for it. | |
| 113 | - | useEffect(() => new AutoPlay(game, () => pool.throwInFlight).start(), [game, pool]); | |
| 112 | + | // The computer waits for a thrown tile to land — and for whoever it landed on | |
| 113 | + | // to finish saying so — before it reaches for it. | |
| 114 | + | useEffect(() => new AutoPlay(game, () => pool.busy).start(), [game, pool]); | |
| 114 | 115 | ||
| 115 | 116 | if (phase === 'lobby') { | |
| 116 | 117 | const round = saved ? Math.floor(saved.state.rotations / 4) % 4 : 0; | |
| ⋯ 97 unchanged lines | |||
modifiedsrc/game/autoplay.ts+16 −10
| ⋯ 30 unchanged lines | |||
| 31 | 31 | /** How often to look again while a thrown tile is still in the air. */ | |
| 32 | 32 | const SETTLE_POLL = 70; | |
| 33 | 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. | |
| 34 | + | * The longest the table will hold before going on anyway. Waiting on the | |
| 35 | + | * animation is a courtesy; a game that could not continue because something in | |
| 36 | + | * the middle would not settle would be a game that had stopped. | |
| 37 | + | * | |
| 38 | + | * Long enough to cover the worst case rather than the usual one: a tile still | |
| 39 | + | * skating about, and then somebody saying a whole sentence about where it ended | |
| 40 | + | * up. Cut short of that and the line gets talked over by the very move it was | |
| 41 | + | * meant to hold up. | |
| 37 | 42 | */ | |
| 38 | - | const SETTLE_LIMIT = 2500; | |
| 43 | + | const SETTLE_LIMIT = 4500; | |
| 39 | 44 | ||
| 40 | 45 | /** Whose move it is, if it is a bot's, and how long to sit on it. */ | |
| 41 | 46 | export function pendingBot(game: Game): { seat: SeatId; delay: number } | null { | |
| ⋯ 56 unchanged lines | |||
| 98 | 103 | private busy: () => boolean; | |
| 99 | 104 | ||
| 100 | 105 | /** | |
| 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. | |
| 106 | + | * `busy` is asked whether the table is mid-something: a discard still in the | |
| 107 | + | * air on its way to the middle, or a player still complaining about where the | |
| 108 | + | * last one landed. Nobody at a real table reaches across for a tile that has | |
| 109 | + | * not landed — a 碰 called over one still in flight looks like it was snatched | |
| 110 | + | * out of the air — and nobody talks over the person they just hit with it. | |
| 111 | + | * The engine has no idea any of this exists; this is the same | |
| 112 | + | * watching-from-outside trick the rest of the file plays. | |
| 107 | 113 | */ | |
| 108 | 114 | constructor(game: Game, busy: () => boolean = () => false) { | |
| 109 | 115 | this.game = game; | |
| ⋯ 45 unchanged lines | |||
modifiedsrc/game/sound.ts+19 −9
| ⋯ 122 unchanged lines | |||
| 123 | 123 | * Speak one or more clips back to back. Calls land on top of each other at | |
| 124 | 124 | * table pace, so anything still talking is cut off: the newest call is the | |
| 125 | 125 | * only one that matters. | |
| 126 | + | * | |
| 127 | + | * Returns how long it will be talking for, in seconds — zero if it is not | |
| 128 | + | * going to say anything at all. That is what lets the table hold still while | |
| 129 | + | * somebody is speaking, and only for as long as they are. | |
| 126 | 130 | */ | |
| 127 | - | function say(...names: string[]) { | |
| 131 | + | function say(...names: string[]): number { | |
| 128 | 132 | const c = audio(); | |
| 129 | - | if (!voiceOn || !enabled || !c || !master) return; | |
| 133 | + | if (!voiceOn || !enabled || !c || !master) return 0; | |
| 130 | 134 | if (!clips.size) { | |
| 131 | 135 | // Nothing to say yet: either still coming down the wire, or waiting for | |
| 132 | 136 | // this very gesture to be allowed to decode. | |
| 133 | 137 | decodeVoicePack(c); | |
| 134 | 138 | void fetchVoicePack(); | |
| 135 | - | return; | |
| 139 | + | return 0; | |
| 136 | 140 | } | |
| 137 | 141 | ||
| 138 | 142 | for (const src of speaking) { | |
| ⋯ 12 unchanged lines | |||
| 151 | 155 | voiceGain.connect(master); | |
| 152 | 156 | } | |
| 153 | 157 | ||
| 154 | - | let at = c.currentTime + 0.02; | |
| 158 | + | const from = c.currentTime + 0.02; | |
| 159 | + | let at = from; | |
| 155 | 160 | for (const name of names) { | |
| 156 | 161 | const buf = clips.get(name); | |
| 157 | 162 | if (!buf) continue; | |
| ⋯ 4 unchanged lines | |||
| 162 | 167 | speaking.push(src); | |
| 163 | 168 | at += buf.duration + 0.05; | |
| 164 | 169 | } | |
| 170 | + | return at - from; | |
| 165 | 171 | } | |
| 166 | 172 | ||
| 167 | 173 | export function setSoundEnabled(on: boolean) { | |
| ⋯ 95 unchanged lines | |||
| 263 | 269 | * | |
| 264 | 270 | * Only the physics knows this happened, so like `knock` it is not a | |
| 265 | 271 | * `SoundEvent`: the engine neither knows nor cares where a discard ended up. | |
| 272 | + | * | |
| 273 | + | * Returns how long it will be talking for, so the table can wait it out — a | |
| 274 | + | * computer player reaching for the tile over the top of somebody complaining | |
| 275 | + | * about it is the whole thing landing on nobody. | |
| 266 | 276 | */ | |
| 267 | 277 | let lastBarge = 0; | |
| 268 | - | export function barge(strength: number) { | |
| 269 | - | if (!enabled) return; | |
| 278 | + | export function barge(strength: number): number { | |
| 279 | + | if (!enabled) return 0; | |
| 270 | 280 | const c = audio(); | |
| 271 | - | if (!c || !master) return; | |
| 281 | + | if (!c || !master) return 0; | |
| 272 | 282 | // One complaint per throw, not one per bounce. | |
| 273 | - | if (c.currentTime - lastBarge < 1.6) return; | |
| 283 | + | if (c.currentTime - lastBarge < 1.6) return 0; | |
| 274 | 284 | lastBarge = c.currentTime; | |
| 275 | 285 | ||
| 276 | 286 | clack(c, 0, 0.5 + strength * 0.5); | |
| ⋯ 5 unchanged lines | |||
| 282 | 292 | let pick = Math.floor(Math.random() * WATCH_IT.length); | |
| 283 | 293 | if (WATCH_IT[pick] === lastWords) pick = (pick + 1) % WATCH_IT.length; | |
| 284 | 294 | lastWords = WATCH_IT[pick]; | |
| 285 | - | say(lastWords); | |
| 295 | + | return say(lastWords); | |
| 286 | 296 | } | |
| 287 | 297 | ||
| 288 | 298 | /** | |
| ⋯ 89 unchanged lines | |||
modifiedsrc/table/pool.ts+20 −0
| ⋯ 136 unchanged lines | |||
| 137 | 137 | private melds = [0, 0, 0, 0]; | |
| 138 | 138 | /** Tiles on their way off the table. Drawn, but out of the physics. */ | |
| 139 | 139 | private leaving: Leaving[] = []; | |
| 140 | + | /** When the table will have stopped talking. See `heckle`. */ | |
| 141 | + | private quietAt = 0; | |
| 140 | 142 | ||
| 141 | 143 | // Which seats can throw, as a string, so the seats can re-render when it | |
| 142 | 144 | // changes. Read through useSyncExternalStore, the same way the game is. | |
| ⋯ 225 unchanged lines | |||
| 368 | 370 | return !!b && !b.resting; | |
| 369 | 371 | } | |
| 370 | 372 | ||
| 373 | + | /** | |
| 374 | + | * Somebody is being told off for where they put that tile. Held for as long | |
| 375 | + | * as the line takes to say, told to us by whoever is saying it. | |
| 376 | + | */ | |
| 377 | + | heckle(seconds: number) { | |
| 378 | + | this.quietAt = Math.max(this.quietAt, Date.now() + seconds * 1000); | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | /** | |
| 382 | + | * Whether the table is mid-something and a computer player should wait: a | |
| 383 | + | * tile still in the air, or a player still complaining about the last one. | |
| 384 | + | * Reaching for a tile over the top of somebody objecting to it lands the | |
| 385 | + | * objection on nobody. | |
| 386 | + | */ | |
| 387 | + | get busy(): boolean { | |
| 388 | + | return this.throwInFlight || Date.now() < this.quietAt; | |
| 389 | + | } | |
| 390 | + | ||
| 371 | 391 | /** Nothing is moving, so the render loop can stop until something changes. */ | |
| 372 | 392 | get settled(): boolean { | |
| 373 | 393 | return !this.held && this.leaving.length === 0 && (!this.world || settled(this.world)); | |
| ⋯ 213 unchanged lines | |||
modifiedsrc/ui/Pool.tsx+16 −3
| ⋯ 72 unchanged lines | |||
| 73 | 73 | // A contact quiet enough not to be worth hearing still counts as a tile | |
| 74 | 74 | // arriving in somebody's lap, which is why the two thresholds differ. | |
| 75 | 75 | if (i.strength > AUDIBLE) loudest = Math.max(loudest, i.strength); | |
| 76 | - | if (i.edge) shove(table, EDGE_SEAT[i.edge], i.seat, i.strength); | |
| 76 | + | if (i.edge) shove(pool, table, EDGE_SEAT[i.edge], i.seat, i.strength); | |
| 77 | 77 | } | |
| 78 | 78 | if (loudest > 0) knock(loudest); | |
| 79 | 79 | paint(ctx, canvas, pool, live.current.art); | |
| ⋯ 120 unchanged lines | |||
| 200 | 200 | /** Quieter than this and the contact is not worth a clack of its own. */ | |
| 201 | 201 | const AUDIBLE = 0.12; | |
| 202 | 202 | ||
| 203 | + | /** How long the knocked row rocks for, in seconds. Matches `shoved` in the CSS, | |
| 204 | + | * which is the other half of it: the table waits at least this long. */ | |
| 205 | + | const SHAKE = 0.34; | |
| 206 | + | ||
| 203 | 207 | /** | |
| 204 | 208 | * A tile has ended up in this seat's tiles — knocked into them, or simply come | |
| 205 | 209 | * to a stop off the table on their side. Either way it is theirs to complain | |
| ⋯ 5 unchanged lines | |||
| 211 | 215 | * about it. Not for the thrower's own edge: you cannot be barged by your own | |
| 212 | 216 | * discard, and everybody's tile passes over their own row on the way out. | |
| 213 | 217 | */ | |
| 214 | - | function shove(table: HTMLElement, seat: SeatId, thrower: SeatId | undefined, strength: number) { | |
| 218 | + | function shove( | |
| 219 | + | pool: TablePool, | |
| 220 | + | table: HTMLElement, | |
| 221 | + | seat: SeatId, | |
| 222 | + | thrower: SeatId | undefined, | |
| 223 | + | strength: number, | |
| 224 | + | ) { | |
| 215 | 225 | if (seat === thrower) return; | |
| 216 | - | barge(strength); | |
| 226 | + | // Hold the table while it is being said and while the row is still rocking. | |
| 227 | + | // The computer plays on a timer of its own, and a 碰 called over the top of | |
| 228 | + | // somebody objecting to the tile lands the objection on nobody. | |
| 229 | + | pool.heckle(Math.max(SHAKE, barge(strength))); | |
| 217 | 230 | const hand = table.querySelector<HTMLElement>(`[data-seat="${seat}"] .hand`); | |
| 218 | 231 | if (!hand || hand.classList.contains('shoved')) return; | |
| 219 | 232 | hand.classList.add('shoved'); | |
| ⋯ 305 unchanged lines | |||