anvilsign in

collin/mahjong

RenderedSource

1# 台灣麻將 — four-player hotseat on one touchscreen
2
3Sixteen-tile Taiwanese mahjong for four people sitting around a single laptop.
4All four hands are on screen at once, each rotated to face its own edge — put a
5piece of card or plastic over your strip so the others can't see your tiles.
6Every label is Chinese with English underneath; the tiles themselves stay
7Chinese, because that is what the tiles say.
8
9```
10npm install
11npm run dev # then open the browser full-screen (F11)
12npm test # rules-engine tests, incl. 200 randomly-played hands
13```
14
15## Screen layout
16
17```
18 ┌──── 對家 (rotated 180°) ────┐
19 上家 (rotated 90°) │ wall / discards │ 下家 (rotated −90°)
20 └──── 自家 (upright, near you) ┘
21```
22
23Seat 0 is the bottom edge, and play runs 0 → 1 (right) → 2 (top) → 3 (left),
24which is the normal counter-clockwise 東南西北 order.
25
26Each strip shows, from the player's edge inwards: nameplate (seat wind, 莊
27marker, 聽 badge, chip count) → concealed hand → action buttons → melds and
28flowers → that player's discard row.
29
30## Playing
31
32- **The tile you just drew** is held apart from the sorted hand with a gold ring
33 and a 摸 label, instead of being sorted invisibly into it. It merges into the
34 hand once you discard. 補花 replacements and kong replacements are marked the
35 same way.
36- **The wall** is drawn in the centre as a square: four staggered sides of 18
37 stacks of two, the way it's built on a real table. The gold stack is the break
38 point you draw from, stacks shrink to a single tile and then vanish as they're
39 used, and the dimmed tail is the 16-tile 底牌 that ends the hand — which is
40 also the end kong and flower replacements are taken from, so the square is
41 eaten from both directions at once.
42- **Discard** — tap a tile to lift it, tap again (or the 打出 button) to throw it.
43 Action buttons sit on the right of your own strip, within thumb reach.
44- **Arranging** — drag any tile in your hand to reorder it; 理牌 sorts it back
45 into suit order. Hands are never auto-sorted after the deal, so an arrangement
46 you set up survives draws, claims and kongs.
47- **Rules** — the `?` on your nameplate opens a bilingual rules panel anchored to
48 your own edge of the table and rotated to face you.
49- **Claiming** — after a discard, every seat that *can* claim gets 胡 / 槓 / 碰 /
50 吃 / 過 buttons on their own strip, and they resolve by priority (胡 > 槓/碰 >
51 吃; ties go to the player nearest the discarder in turn order).
52 **The table never blocks on a claim.** The next player gets a 摸牌 button and
53 may draw whenever they like, which shuts the window on anyone who hasn't
54 called — exactly like shouting 碰 before the next player picks up. A claim
55 already declared still stands, so calling in time always wins the tile. If
56 everyone answers first, play advances on its own without the extra tap.
57- **On your turn** — 自摸, 暗槓 and 加槓 appear automatically when legal.
58 加槓 offers everyone else a 搶槓 chance.
59- **Undo** — a 復原 button in the middle of the table takes back the last
60 action, naming what it will undo (`復原 玩家 2 打 五萬`). It sits in the
61 centre rather than on a seat because whoever *spots* the mis-tap should be
62 able to reach it. Twenty actions deep, cleared when the next hand is dealt.
63 Snapshots live outside the saved state, so an undo does not survive a
64 refresh: what you can take back is what happened while everyone was still
65 watching it happen.
66- **Sound** — on by default, muted from the 🔊 button in the middle of the
67 table or from settings. A dry clack as a tile goes down, and a distinct
68 two-note chime when a claim window opens, which is how a slow player notices
69 their 碰 is available before the next player draws it shut. Everything is
70 synthesised with a few oscillators (`src/game/sound.ts`) rather than sampled,
71 so there is nothing to load and the cue lands on the same frame as the tile.
72 Browsers refuse to start audio before the page has been clicked, so the first
73 sound anyone hears is the deal — triggered by the button that starts it.
74- **報牌 (voice)** — a second switch under sound calls the game out loud: 碰,
75 吃, 槓, 胡了, 自摸, and the name of every tile as it is discarded — 三條,
76 五萬, 東風. A flower says 補花 and then which one. A new call cuts off one
77 still being spoken; at table pace the newest is the only one that matters.
78 See [the voice pack](#the-voice-pack) for where the audio comes from.
79- **Settings** — 設定 from the lobby, or from the game-over panel: player names,
80 底 / 台 / starting chips, the house-rule switches below, and sound. Kept in
81 `localStorage` separately from the save, so they carry over to the next game.
82 Stakes are locked once a game is under way — they'd otherwise rewrite chips
83 already won.
84
85## Saving
86
87The game state is plain data, so a save is just its JSON in `localStorage`,
88rewritten after every move. Close the lid, refresh, or run the battery flat and
89nothing is lost — the lobby offers **繼續對局 Resume** alongside **開新局 New
90game**, showing the round, hand number, chip counts and when it was saved. A
91save is validated before it is offered (four players, 144 tiles accounted for),
92so a truncated or hand-edited one is ignored rather than loaded into a broken
93table. `VERSION` in `save.ts` retires old saves if the state shape changes.
94
95## Rules implemented
96
97- 144 tiles (four of each suit/honour, eight flowers), 16-tile hands, dealer
98 draws the 17th.
99- 補花 at the deal and on every drawn flower, replacements from the back of the
100 wall.
101- 吃 only from 上家; 碰/槓/胡 from anyone. 明槓, 暗槓, 加槓, 搶槓, 槓上開花.
102- 流局 when 16 tiles remain (`Rules.wallReserve`); the dealer keeps the deal on a
103 draw or on a dealer win (連莊), otherwise the deal passes and the round wind
104 advances every four passes. A full 四圈 game is 16 dealer passes.
105
106### House rules (switches in `Rules`, all on the settings screen)
107
108- **過水 `sacredDiscard`** (on) — pass on a tile you could have won with and it
109 is dead to you until your own next draw; the seat shows a 過水 badge listing
110 what it is locked out of, so the missing 胡 button is never a mystery. A draw
111 from either end of the wall lifts it. **`sacredClearedByClaim`** (off) decides
112 whether taking a 吃 / 碰 / 槓 lifts it too — tables genuinely differ.
113- **一炮多響 `multipleWinners`** (off) — one discard pays out to every seat that
114 calls on it, each settled separately against the discarder. With it off the
115 tile goes to the caller nearest the discarder. Either way the table now waits
116 for other seats that *can* win before settling, so the nearest seat wins the
117 tile rather than the quickest hand — and the 摸牌 button still closes the
118 window on anyone dithering.
119- **包牌 `liability`** (on) — feeding the pung that completes a visible 大三元
120 or 大四喜 makes the feeder answer for the whole hand, in place of all three
121 payers. Only the seat that fed the *last* of those sets is on the hook, and
122 only if it came off a discard: a hand that assembled them itself, or closed
123 the set with a 暗槓, has nobody to blame.
124
125### 台 scoring (`src/game/tai.ts`)
126
127自摸 1 · 門清 1 · 門清自摸 +1 · 全求人 2 · 平胡 2 · 五門齊 2 · 正花 1 each ·
128花槓 2 · 八仙過海 8 · 圈風 / 門風 1 each · 三元牌 1 each · 小三元 4 · 大三元 8 ·
129小四喜 8 · 大四喜 16 · 碰碰胡 4 · 混一色 4 · 清一色 8 · 字一色 16 ·
130三暗刻 2 / 四暗刻 5 / 五暗刻 8 · 獨聽 · 單釣 1 · 搶槓 1 · 槓上開花 1 ·
131海底撈月 1 · 河底撈魚 1 · 天胡 16 · 地胡 16 · 人胡 8 · 莊家 1 (連N拉N → 2N+1)
132
133Ambiguous hands are decomposed every legal way and scored at the best reading.
134
135**Payment** (`Game.settle`): one unit is `底 + 台 × 台值`
136(`DEFAULT_RULES` = 底 3, 台 1, 100 chips each).
137放槍一家付 — the discarder alone pays one unit; on 自摸 all three pay one unit
138each. 拉莊 is billed to the dealer alone when the dealer is a payer, and added
139to the whole hand when the dealer wins.
140
141House rules vary a lot; the tai table and payments are plain data/functions in
142`tai.ts` and `types.ts` if yours differ.
143
144## The voice pack
145
146A mahjong table only ever says about fifty things — 42 tile names and a handful
147of calls — so the whole vocabulary is rendered ahead of time into
148`public/voice/` (~330 kB of mp3) rather than left to whatever speech synthesis
149the browser happens to have. On Linux that is usually espeak-ng, which is
150intelligible but sounds like a modem; and on a machine with no Chinese voice at
151all the feature would silently do nothing. Shipping the audio makes playback
152instant, identical everywhere, and lets a call be cut off mid-word when the
153next one lands, all through the same Web Audio graph as the chimes.
154
155`scripts/voice.mjs` is the authoring step, not part of `npm run build`. It needs
156[piper](https://github.com/OHF-Voice/piper1-gpl) and ffmpeg on PATH:
157
158```
159node scripts/voice.mjs # → public/voice/*.mp3 + manifest.json
160PIPER_MODEL=/path/to/voice.onnx node scripts/voice.mjs # a different voice
161```
162
163The wording lives in that script, and some of it is deliberately not the bare
164tile character: 東 alone is a direction where 東風 is the tile, and the dragons
165are 紅中 / 發財 / 白板 the way they are actually called — which also gives the
166phonemiser enough context to get the tone right, since 中 on its own is as
167likely to come out zhòng.
168
169The clips here were rendered with piper's `zh_CN-huayan-medium`. If you
170redistribute this app, check that voice's model card in
171[piper-voices](https://huggingface.co/rhasspy/piper-voices) for the terms
172attached to it, the same way you would the tile art below — or re-render the
173pack with a voice whose terms suit you, which is a single command.
174
175## Tile art
176
177`public/tiles/tiles.svg` is the **postmodern** tileset from
178[gnome-mahjongg](https://gitlab.gnome.org/GNOME/gnome-mahjongg), extracted from
179the installed binary's GResource — a 43×2 sprite sheet (second row is the
180highlighted variant, used for a lifted tile). `public/tiles/back.png` is the
181blank tile from its **smooth** theme, tinted jade, since a solitaire game has no
182face-down art of its own.
183
184That art is **GPL-2.0-or-later**. Fine for playing at home; if you ever
185distribute this app, either honour the GPL or swap the two files for art of your
186own — `SPRITE_COL` in `tiles.ts` is the only mapping that would need updating.
187
188Known gaps and house rules that aren't implemented yet are listed in
189[TODO.md](TODO.md).
190
191## Layout
192
193```
194public/tiles/ sprite sheet + tile back
195src/game/tiles.ts tile codes, wall, shuffle, sprite mapping
196src/game/hu.ts hand decomposition, 聽 detection, wait shapes
197src/game/tai.ts 台 scoring
198src/game/engine.ts state machine: deal, turns, claim resolution, settlement
199src/ui/ TileView, Hand (drag), Seat (rotated strip), Center, Help
200```