collin/mahjong · b57cc629
Tell off whoever throws a tile into your hand
Collin Richards · 2026-08-19 21:43 UTC · b57cc6296a2e9e39288724c48629de928126c2a0 · parent 3e1678b5 · browse files
modifiedREADME.md+4 −0
| ⋯ 71 unchanged lines | |||
| 72 | 72 | there, so a gap off to one side counts, and a stack worn down to a single tile | |
| 73 | 73 | is low enough to go over. A seat whose wall is still up says 牆未開 on its | |
| 74 | 74 | nameplate, so a lob is never a mystery. | |
| 75 | + | - **Throw it too hard** and it comes off the pool and into somebody's tiles — | |
| 76 | + | the middle stops exactly where their hand starts. Their row takes the knock | |
| 77 | + | and rocks back, and with 報牌 on they tell you about it (喂,小心點). Never | |
| 78 | + | your own edge: you cannot be barged by your own discard. | |
| 75 | 79 | - **Arranging** — drag any tile in your hand to reorder it; 理牌 sorts it back | |
| 76 | 80 | into suit order. Hands are never auto-sorted after the deal, so an arrangement | |
| 77 | 81 | you set up survives draws, claims and kongs. | |
| ⋯ 334 unchanged lines | |||
modifiedpublic/voice/manifest.json+1 −1
| 1 | - | ["t0","t9","t18","t1","t10","t19","t2","t11","t20","t3","t12","t21","t4","t13","t22","t5","t14","t23","t6","t15","t24","t7","t16","t25","t8","t17","t26","t27","t28","t29","t30","t31","t32","t33","t34","t35","t36","t37","t38","t39","t40","t41","pung","chow","kong","hu","selfDraw","drawGame","flower"] | |
| 1 | + | ["t0","t9","t18","t1","t10","t19","t2","t11","t20","t3","t12","t21","t4","t13","t22","t5","t14","t23","t6","t15","t24","t7","t16","t25","t8","t17","t26","t27","t28","t29","t30","t31","t32","t33","t34","t35","t36","t37","t38","t39","t40","t41","pung","chow","kong","hu","selfDraw","drawGame","flower","watchIt","watchIt2"] |
addedpublic/voice/watchIt.mp3binary
Binary file
addedpublic/voice/watchIt2.mp3binary
Binary file
modifiedscripts/voice.mjs+4 −0
| ⋯ 61 unchanged lines | |||
| 62 | 62 | selfDraw: '自摸', | |
| 63 | 63 | drawGame: '流局', | |
| 64 | 64 | flower: '補花', | |
| 65 | + | // Said to whoever threw a tile hard enough to come off the pool and into | |
| 66 | + | // your tiles. Two of them, so a rough table does not sound like a parrot. | |
| 67 | + | watchIt: '喂,小心點', | |
| 68 | + | watchIt2: '輕一點啦', | |
| 65 | 69 | }); | |
| 66 | 70 | ||
| 67 | 71 | mkdirSync(OUT, { recursive: true }); | |
| ⋯ 27 unchanged lines | |||
modifiedsrc/game/sound.ts+30 −0
| ⋯ 253 unchanged lines | |||
| 254 | 254 | } | |
| 255 | 255 | ||
| 256 | 256 | /** | |
| 257 | + | * A tile thrown so hard it comes off the pool and into somebody's tiles. | |
| 258 | + | * | |
| 259 | + | * The middle stops where the four players' hands start, so a tile that reaches | |
| 260 | + | * the edge with any speed left has been thrown at whoever is sitting there — | |
| 261 | + | * and at a real table that gets you told. The clack is the tile arriving; the | |
| 262 | + | * grumble under it is the reaction, and 報牌 puts words to it. | |
| 263 | + | * | |
| 264 | + | * Only the physics knows this happened, so like `knock` it is not a | |
| 265 | + | * `SoundEvent`: the engine neither knows nor cares where a discard ended up. | |
| 266 | + | */ | |
| 267 | + | let lastBarge = 0; | |
| 268 | + | export function barge(strength: number) { | |
| 269 | + | if (!enabled) return; | |
| 270 | + | const c = audio(); | |
| 271 | + | if (!c || !master) return; | |
| 272 | + | // One complaint per throw, not one per bounce. | |
| 273 | + | if (c.currentTime - lastBarge < 1.6) return; | |
| 274 | + | lastBarge = c.currentTime; | |
| 275 | + | ||
| 276 | + | clack(c, 0, 0.5 + strength * 0.5); | |
| 277 | + | // Two notes falling, which is what a grumble sounds like. | |
| 278 | + | tone(c, { freq: 300, to: 190, at: 0.05, dur: 0.14, gain: 0.16, type: 'triangle' }); | |
| 279 | + | tone(c, { freq: 240, to: 150, at: 0.2, dur: 0.22, gain: 0.13, type: 'triangle' }); | |
| 280 | + | say(WATCH_IT[Math.floor(Math.random() * WATCH_IT.length)]); | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | /** 喂,小心點 — said by whoever's tiles were hit. Rendered by scripts/voice.mjs. */ | |
| 284 | + | const WATCH_IT = ['watchIt', 'watchIt2']; | |
| 285 | + | ||
| 286 | + | /** | |
| 257 | 287 | * Each cue has its own shape so it is identifiable without looking up — the | |
| 258 | 288 | * whole reason for the feature is a player noticing across the table. | |
| 259 | 289 | */ | |
| ⋯ 69 unchanged lines | |||
modifiedsrc/styles.css+24 −0
| ⋯ 251 unchanged lines | |||
| 252 | 252 | align-items: flex-end; | |
| 253 | 253 | flex: none; | |
| 254 | 254 | } | |
| 255 | + | /* Somebody has thrown a tile hard enough to come off the pool and into this | |
| 256 | + | row. It takes the knock and rocks back — the sound is `barge` in game/sound, | |
| 257 | + | and what decides it happened is the physics, in ui/Pool. Inside the seat's | |
| 258 | + | own rotation, so it is always knocked *away from* the middle of the table. */ | |
| 259 | + | .hand.shoved { | |
| 260 | + | animation: shoved 0.34s ease-out; | |
| 261 | + | } | |
| 262 | + | @keyframes shoved { | |
| 263 | + | 0% { | |
| 264 | + | transform: none; | |
| 265 | + | } | |
| 266 | + | 22% { | |
| 267 | + | transform: translateY(5px) rotate(0.5deg); | |
| 268 | + | } | |
| 269 | + | 55% { | |
| 270 | + | transform: translateY(-2px) rotate(-0.3deg); | |
| 271 | + | } | |
| 272 | + | 80% { | |
| 273 | + | transform: translateY(1px); | |
| 274 | + | } | |
| 275 | + | 100% { | |
| 276 | + | transform: none; | |
| 277 | + | } | |
| 278 | + | } | |
| 255 | 279 | /* Every hand tile sits in a draggable cell. */ | |
| 256 | 280 | .hand-cell { | |
| 257 | 281 | position: relative; | |
| ⋯ 1711 unchanged lines | |||
modifiedsrc/table/physics.test.ts+32 −0
| ⋯ 179 unchanged lines | |||
| 180 | 180 | expect(outside(b, POOL)).toBeLessThan(0.5); | |
| 181 | 181 | }); | |
| 182 | 182 | ||
| 183 | + | it('says which edge a tile was thrown into, and whose tile it was', () => { | |
| 184 | + | // The middle stops where a player's tiles start, so an edge taken at speed | |
| 185 | + | // is a discard thrown into somebody's hand — which is a thing they answer. | |
| 186 | + | const world = createWorld(POOL); | |
| 187 | + | const b = addBody(world, { | |
| 188 | + | tile: 0, | |
| 189 | + | seat: 2, | |
| 190 | + | x: POOL.x + POOL.w - 30, | |
| 191 | + | y: POOL.y + POOL.h / 2, | |
| 192 | + | vx: 2400, | |
| 193 | + | vy: 0, | |
| 194 | + | z: 0, | |
| 195 | + | vz: 0, | |
| 196 | + | angle: 0, | |
| 197 | + | spin: 0, | |
| 198 | + | w: TILE.w, | |
| 199 | + | h: TILE.h, | |
| 200 | + | }); | |
| 201 | + | ||
| 202 | + | const edges: string[] = []; | |
| 203 | + | for (let i = 0; i < SETTLE_CAP && !settled(world); i++) { | |
| 204 | + | for (const hit of step(world)) if (hit.edge) edges.push(`${hit.edge}:${hit.seat}`); | |
| 205 | + | } | |
| 206 | + | expect(edges[0]).toBe('right:2'); | |
| 207 | + | // And a tile that merely rolls up to the edge is not an incident. | |
| 208 | + | b.resting = false; | |
| 209 | + | b.vx = 20; | |
| 210 | + | const gentle = []; | |
| 211 | + | for (let i = 0; i < 60; i++) for (const hit of step(world)) if (hit.edge) gentle.push(hit); | |
| 212 | + | expect(gentle).toHaveLength(0); | |
| 213 | + | }); | |
| 214 | + | ||
| 183 | 215 | it('always comes to rest face up, however it tumbles', () => { | |
| 184 | 216 | // A tile lying face down in the discards is a tile nobody can read, so this | |
| 185 | 217 | // is a guarantee rather than something the animation happens to get right. | |
| ⋯ 345 unchanged lines | |||
modifiedsrc/table/physics.ts+18 −1
| ⋯ 176 unchanged lines | |||
| 177 | 177 | awake: number; | |
| 178 | 178 | } | |
| 179 | 179 | ||
| 180 | + | /** | |
| 181 | + | * Which edge of the middle a tile ran into. The middle stops where the four | |
| 182 | + | * players' tiles start, so an edge is somebody's hand: a tile that reaches one | |
| 183 | + | * hard has been thrown into their tiles, and they have something to say about | |
| 184 | + | * it. Seats are the caller's business; this only says which way it went off. | |
| 185 | + | */ | |
| 186 | + | export type Edge = 'left' | 'right' | 'top' | 'bottom'; | |
| 187 | + | ||
| 180 | 188 | /** A collision loud enough to hear. `strength` is 0..1. */ | |
| 181 | 189 | export interface Impact { | |
| 182 | 190 | x: number; | |
| 183 | 191 | y: number; | |
| 184 | 192 | strength: number; | |
| 193 | + | /** Set when it was the edge of the table it hit, not a wall or another tile. */ | |
| 194 | + | edge?: Edge; | |
| 195 | + | /** Whose discard it was, for an edge — you cannot be barged by your own tile. */ | |
| 196 | + | seat?: SeatId; | |
| 185 | 197 | } | |
| 186 | 198 | ||
| 187 | 199 | export interface World { | |
| ⋯ 351 unchanged lines | |||
| 539 | 551 | const maxY = h <= half.y * 2 ? y + h / 2 : y + h - half.y; | |
| 540 | 552 | ||
| 541 | 553 | let hit = 0; | |
| 554 | + | let edge: Edge | undefined; | |
| 542 | 555 | if (b.x < minX) { | |
| 543 | 556 | b.x = minX; | |
| 544 | 557 | hit = Math.max(hit, Math.abs(b.vx)); | |
| 558 | + | edge = 'left'; | |
| 545 | 559 | b.vx = Math.abs(b.vx) * BOUND_RESTITUTION; | |
| 546 | 560 | b.vy *= BOUND_FRICTION; | |
| 547 | 561 | } else if (b.x > maxX) { | |
| 548 | 562 | b.x = maxX; | |
| 549 | 563 | hit = Math.max(hit, Math.abs(b.vx)); | |
| 564 | + | edge = 'right'; | |
| 550 | 565 | b.vx = -Math.abs(b.vx) * BOUND_RESTITUTION; | |
| 551 | 566 | b.vy *= BOUND_FRICTION; | |
| 552 | 567 | } | |
| 553 | 568 | if (b.y < minY) { | |
| 554 | 569 | b.y = minY; | |
| 570 | + | if (Math.abs(b.vy) > hit) edge = 'top'; | |
| 555 | 571 | hit = Math.max(hit, Math.abs(b.vy)); | |
| 556 | 572 | b.vy = Math.abs(b.vy) * BOUND_RESTITUTION; | |
| 557 | 573 | b.vx *= BOUND_FRICTION; | |
| 558 | 574 | } else if (b.y > maxY) { | |
| 559 | 575 | b.y = maxY; | |
| 576 | + | if (Math.abs(b.vy) > hit) edge = 'bottom'; | |
| 560 | 577 | hit = Math.max(hit, Math.abs(b.vy)); | |
| 561 | 578 | b.vy = -Math.abs(b.vy) * BOUND_RESTITUTION; | |
| 562 | 579 | b.vx *= BOUND_FRICTION; | |
| ⋯ 4 unchanged lines | |||
| 567 | 584 | if (hit > SLEEP_SPEED) { | |
| 568 | 585 | b.still = 0; | |
| 569 | 586 | const strength = Math.min(1, hit / 1100); | |
| 570 | - | if (strength > 0.12) impacts.push({ x: b.x, y: b.y, strength }); | |
| 587 | + | if (strength > 0.12) impacts.push({ x: b.x, y: b.y, strength, edge, seat: b.seat }); | |
| 571 | 588 | } | |
| 572 | 589 | } | |
| 573 | 590 | ||
| ⋯ 237 unchanged lines | |||
modifiedsrc/ui/Pool.tsx+32 −2
| 1 | 1 | import { useEffect, useRef, useState } from 'react'; | |
| 2 | 2 | import type { Game } from '../game/engine'; | |
| 3 | - | import { knock } from '../game/sound'; | |
| 3 | + | import { barge, knock } from '../game/sound'; | |
| 4 | 4 | import { SPRITE_COL, SPRITE_COLS } from '../game/tiles'; | |
| 5 | 5 | import { faceOn, zScale } from '../table/physics'; | |
| 6 | 6 | import type { TablePool } from '../table/pool'; | |
| 7 | + | import type { SeatId } from '../game/types'; | |
| 8 | + | import { EDGE_SEAT } from './rotation'; | |
| 7 | 9 | ||
| 8 | 10 | /** | |
| 9 | 11 | * The tiles in the middle of the table, drawn. | |
| ⋯ 57 unchanged lines | |||
| 67 | 69 | // rest of a scatter is the same event. | |
| 68 | 70 | const impacts = pool.advance(dt); | |
| 69 | 71 | let loudest = 0; | |
| 70 | - | for (const i of impacts) loudest = Math.max(loudest, i.strength); | |
| 72 | + | for (const i of impacts) { | |
| 73 | + | loudest = Math.max(loudest, i.strength); | |
| 74 | + | if (i.edge && i.strength > BARGE) shove(table, EDGE_SEAT[i.edge], i.seat, i.strength); | |
| 75 | + | } | |
| 71 | 76 | if (loudest > 0) knock(loudest); | |
| 72 | 77 | paint(ctx, canvas, pool, live.current.art); | |
| 73 | 78 | ||
| ⋯ 117 unchanged lines | |||
| 191 | 196 | const HELD_Z = 52; | |
| 192 | 197 | ||
| 193 | 198 | /** | |
| 199 | + | * Hard enough to be a throw at somebody rather than a tile coming to rest | |
| 200 | + | * against the near side. A tile that merely rolls up to the edge is not an | |
| 201 | + | * incident; one still doing a couple of hundred pixels a second is. | |
| 202 | + | */ | |
| 203 | + | const BARGE = 0.3; | |
| 204 | + | ||
| 205 | + | /** | |
| 206 | + | * A tile has come off the pool and into this seat's tiles. | |
| 207 | + | * | |
| 208 | + | * Their row takes the knock — straight on the element rather than through | |
| 209 | + | * React, because this happens inside the physics loop and re-rendering four | |
| 210 | + | * strips to shake one of them would be absurd — and the table says something | |
| 211 | + | * about it. Not for the thrower's own edge: you cannot be barged by your own | |
| 212 | + | * discard, and everybody's tile passes over their own row on the way out. | |
| 213 | + | */ | |
| 214 | + | function shove(table: HTMLElement, seat: SeatId, thrower: SeatId | undefined, strength: number) { | |
| 215 | + | if (seat === thrower) return; | |
| 216 | + | barge(strength); | |
| 217 | + | const hand = table.querySelector<HTMLElement>(`[data-seat="${seat}"] .hand`); | |
| 218 | + | if (!hand || hand.classList.contains('shoved')) return; | |
| 219 | + | hand.classList.add('shoved'); | |
| 220 | + | hand.addEventListener('animationend', () => hand.classList.remove('shoved'), { once: true }); | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | /** | |
| 194 | 224 | * Park the reachable patch over the pool. Driven off the measurement rather | |
| 195 | 225 | * than restated in CSS, for the same reason the colliders are: the wall square's | |
| 196 | 226 | * size is worked out in one place and this follows it. | |
| ⋯ 298 unchanged lines | |||
modifiedsrc/ui/rotation.ts+8 −0
| 1 | + | import type { Edge } from '../table/physics'; | |
| 1 | 2 | import type { SeatId } from '../game/types'; | |
| 2 | 3 | ||
| 3 | 4 | /** | |
| ⋯ 3 unchanged lines | |||
| 7 | 8 | */ | |
| 8 | 9 | export const SEAT_ROT: Record<SeatId, number> = { 0: 0, 1: -90, 2: 180, 3: 90 }; | |
| 9 | 10 | ||
| 11 | + | /** | |
| 12 | + | * Who is sitting at each edge of the table — the same fact read the other way | |
| 13 | + | * round. The middle stops where a player's tiles start, so a tile that reaches | |
| 14 | + | * an edge has been thrown at whoever this says. | |
| 15 | + | */ | |
| 16 | + | export const EDGE_SEAT: Record<Edge, SeatId> = { bottom: 0, right: 1, top: 2, left: 3 }; | |
| 17 | + | ||
| 10 | 18 | export const SEAT_SLOT: Record<SeatId, string> = { | |
| 11 | 19 | 0: 'slot-bottom', | |
| 12 | 20 | 1: 'slot-right', | |
| ⋯ 3 unchanged lines | |||