anvilsign in

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
7272 there, so a gap off to one side counts, and a stack worn down to a single tile
7373 is low enough to go over. A seat whose wall is still up says 牆未開 on its
7474 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.
7579 - **Arranging** — drag any tile in your hand to reorder it; 理牌 sorts it back
7680 into suit order. Hands are never auto-sorted after the deal, so an arrangement
7781 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
6262 selfDraw: '自摸',
6363 drawGame: '流局',
6464 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: '輕一點啦',
6569 });
6670
6771 mkdirSync(OUT, { recursive: true });
⋯ 27 unchanged lines
modifiedsrc/game/sound.ts+30 −0
⋯ 253 unchanged lines
254254 }
255255
256256 /**
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+/**
257287 * Each cue has its own shape so it is identifiable without looking up — the
258288 * whole reason for the feature is a player noticing across the table.
259289 */
⋯ 69 unchanged lines
modifiedsrc/styles.css+24 −0
⋯ 251 unchanged lines
252252 align-items: flex-end;
253253 flex: none;
254254 }
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+}
255279 /* Every hand tile sits in a draggable cell. */
256280 .hand-cell {
257281 position: relative;
⋯ 1711 unchanged lines
modifiedsrc/table/physics.test.ts+32 −0
⋯ 179 unchanged lines
180180 expect(outside(b, POOL)).toBeLessThan(0.5);
181181 });
182182
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+
183215 it('always comes to rest face up, however it tumbles', () => {
184216 // A tile lying face down in the discards is a tile nobody can read, so this
185217 // is a guarantee rather than something the animation happens to get right.
⋯ 345 unchanged lines
modifiedsrc/table/physics.ts+18 −1
⋯ 176 unchanged lines
177177 awake: number;
178178 }
179179
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+
180188 /** A collision loud enough to hear. `strength` is 0..1. */
181189 export interface Impact {
182190 x: number;
183191 y: number;
184192 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;
185197 }
186198
187199 export interface World {
⋯ 351 unchanged lines
539551 const maxY = h <= half.y * 2 ? y + h / 2 : y + h - half.y;
540552
541553 let hit = 0;
554+ let edge: Edge | undefined;
542555 if (b.x < minX) {
543556 b.x = minX;
544557 hit = Math.max(hit, Math.abs(b.vx));
558+ edge = 'left';
545559 b.vx = Math.abs(b.vx) * BOUND_RESTITUTION;
546560 b.vy *= BOUND_FRICTION;
547561 } else if (b.x > maxX) {
548562 b.x = maxX;
549563 hit = Math.max(hit, Math.abs(b.vx));
564+ edge = 'right';
550565 b.vx = -Math.abs(b.vx) * BOUND_RESTITUTION;
551566 b.vy *= BOUND_FRICTION;
552567 }
553568 if (b.y < minY) {
554569 b.y = minY;
570+ if (Math.abs(b.vy) > hit) edge = 'top';
555571 hit = Math.max(hit, Math.abs(b.vy));
556572 b.vy = Math.abs(b.vy) * BOUND_RESTITUTION;
557573 b.vx *= BOUND_FRICTION;
558574 } else if (b.y > maxY) {
559575 b.y = maxY;
576+ if (Math.abs(b.vy) > hit) edge = 'bottom';
560577 hit = Math.max(hit, Math.abs(b.vy));
561578 b.vy = -Math.abs(b.vy) * BOUND_RESTITUTION;
562579 b.vx *= BOUND_FRICTION;
⋯ 4 unchanged lines
567584 if (hit > SLEEP_SPEED) {
568585 b.still = 0;
569586 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 });
571588 }
572589 }
573590
⋯ 237 unchanged lines
modifiedsrc/ui/Pool.tsx+32 −2
11 import { useEffect, useRef, useState } from 'react';
22 import type { Game } from '../game/engine';
3-import { knock } from '../game/sound';
3+import { barge, knock } from '../game/sound';
44 import { SPRITE_COL, SPRITE_COLS } from '../game/tiles';
55 import { faceOn, zScale } from '../table/physics';
66 import type { TablePool } from '../table/pool';
7+import type { SeatId } from '../game/types';
8+import { EDGE_SEAT } from './rotation';
79
810 /**
911 * The tiles in the middle of the table, drawn.
⋯ 57 unchanged lines
6769 // rest of a scatter is the same event.
6870 const impacts = pool.advance(dt);
6971 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+ }
7176 if (loudest > 0) knock(loudest);
7277 paint(ctx, canvas, pool, live.current.art);
7378
⋯ 117 unchanged lines
191196 const HELD_Z = 52;
192197
193198 /**
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+/**
194224 * Park the reachable patch over the pool. Driven off the measurement rather
195225 * than restated in CSS, for the same reason the colliders are: the wall square's
196226 * 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';
12 import type { SeatId } from '../game/types';
23
34 /**
⋯ 3 unchanged lines
78 */
89 export const SEAT_ROT: Record<SeatId, number> = { 0: 0, 1: -90, 2: 180, 3: 90 };
910
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+
1018 export const SEAT_SLOT: Record<SeatId, string> = {
1119 0: 'slot-bottom',
1220 1: 'slot-right',
⋯ 3 unchanged lines