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** — off by default. A dry clack as a tile goes down, and a distinct
67 two-note chime when a claim window opens, which is how a slow player notices
68 their 碰 is available before the next player draws it shut. Everything is
69 synthesised with a few oscillators (`src/game/sound.ts`) rather than sampled,
70 so there is nothing to load and the cue lands on the same frame as the tile.
71 Toggle it from the 🔊 button next to 復原, or from the settings screen.
72- **報牌 (voice)** — a second switch under sound calls the game out loud: 碰,
73 吃, 槓, 胡了, 自摸, and the name of every tile as it is discarded — 三條,
74 五萬, 東風. A flower says 補花 and then which one. A new call cuts off one
75 still being spoken; at table pace the newest is the only one that matters.
76 See [the voice pack](#the-voice-pack) for where the audio comes from.
77- **Settings** — 設定 from the lobby, or from the game-over panel: player names,
78 底 / 台 / starting chips, the house-rule switches below, and sound. Kept in
79 `localStorage` separately from the save, so they carry over to the next game.
80 Stakes are locked once a game is under way — they'd otherwise rewrite chips
81 already won.
82
83## Saving
84
85The game state is plain data, so a save is just its JSON in `localStorage`,
86rewritten after every move. Close the lid, refresh, or run the battery flat and
87nothing is lost — the lobby offers **繼續對局 Resume** alongside **開新局 New
88game**, showing the round, hand number, chip counts and when it was saved. A
89save is validated before it is offered (four players, 144 tiles accounted for),
90so a truncated or hand-edited one is ignored rather than loaded into a broken
91table. `VERSION` in `save.ts` retires old saves if the state shape changes.
92
93## Rules implemented
94
95- 144 tiles (four of each suit/honour, eight flowers), 16-tile hands, dealer
96 draws the 17th.
97- 補花 at the deal and on every drawn flower, replacements from the back of the
98 wall.
99- 吃 only from 上家; 碰/槓/胡 from anyone. 明槓, 暗槓, 加槓, 搶槓, 槓上開花.
100- 流局 when 16 tiles remain (`Rules.wallReserve`); the dealer keeps the deal on a
101 draw or on a dealer win (連莊), otherwise the deal passes and the round wind
102 advances every four passes. A full 四圈 game is 16 dealer passes.
103
104### House rules (switches in `Rules`, all on the settings screen)
105
106- **過水 `sacredDiscard`** (on) — pass on a tile you could have won with and it
107 is dead to you until your own next draw; the seat shows a 過水 badge listing
108 what it is locked out of, so the missing 胡 button is never a mystery. A draw
109 from either end of the wall lifts it. **`sacredClearedByClaim`** (off) decides
110 whether taking a 吃 / 碰 / 槓 lifts it too — tables genuinely differ.
111- **一炮多響 `multipleWinners`** (off) — one discard pays out to every seat that
112 calls on it, each settled separately against the discarder. With it off the
113 tile goes to the caller nearest the discarder. Either way the table now waits
114 for other seats that *can* win before settling, so the nearest seat wins the
115 tile rather than the quickest hand — and the 摸牌 button still closes the
116 window on anyone dithering.
117- **包牌 `liability`** (on) — feeding the pung that completes a visible 大三元
118 or 大四喜 makes the feeder answer for the whole hand, in place of all three
119 payers. Only the seat that fed the *last* of those sets is on the hook, and
120 only if it came off a discard: a hand that assembled them itself, or closed
121 the set with a 暗槓, has nobody to blame.
122
123### 台 scoring (`src/game/tai.ts`)
124
125自摸 1 · 門清 1 · 門清自摸 +1 · 全求人 2 · 平胡 2 · 五門齊 2 · 正花 1 each ·
126花槓 2 · 八仙過海 8 · 圈風 / 門風 1 each · 三元牌 1 each · 小三元 4 · 大三元 8 ·
127小四喜 8 · 大四喜 16 · 碰碰胡 4 · 混一色 4 · 清一色 8 · 字一色 16 ·
128三暗刻 2 / 四暗刻 5 / 五暗刻 8 · 獨聽 · 單釣 1 · 搶槓 1 · 槓上開花 1 ·
129海底撈月 1 · 河底撈魚 1 · 天胡 16 · 地胡 16 · 人胡 8 · 莊家 1 (連N拉N → 2N+1)
130
131Ambiguous hands are decomposed every legal way and scored at the best reading.
132
133**Payment** (`Game.settle`): one unit is `底 + 台 × 台值`
134(`DEFAULT_RULES` = 底 3, 台 1, 100 chips each).
135放槍一家付 — the discarder alone pays one unit; on 自摸 all three pay one unit
136each. 拉莊 is billed to the dealer alone when the dealer is a payer, and added
137to the whole hand when the dealer wins.
138
139House rules vary a lot; the tai table and payments are plain data/functions in
140`tai.ts` and `types.ts` if yours differ.
141
142## The voice pack
143
144A mahjong table only ever says about fifty things — 42 tile names and a handful
145of calls — so the whole vocabulary is rendered ahead of time into
146`public/voice/` (~330 kB of mp3) rather than left to whatever speech synthesis
147the browser happens to have. On Linux that is usually espeak-ng, which is
148intelligible but sounds like a modem; and on a machine with no Chinese voice at
149all the feature would silently do nothing. Shipping the audio makes playback
150instant, identical everywhere, and lets a call be cut off mid-word when the
151next one lands, all through the same Web Audio graph as the chimes.
152
153`scripts/voice.mjs` is the authoring step, not part of `npm run build`. It needs
154[piper](https://github.com/OHF-Voice/piper1-gpl) and ffmpeg on PATH:
155
156```
157node scripts/voice.mjs # → public/voice/*.mp3 + manifest.json
158PIPER_MODEL=/path/to/voice.onnx node scripts/voice.mjs # a different voice
159```
160
161The wording lives in that script, and some of it is deliberately not the bare
162tile character: 東 alone is a direction where 東風 is the tile, and the dragons
163are 紅中 / 發財 / 白板 the way they are actually called — which also gives the
164phonemiser enough context to get the tone right, since 中 on its own is as
165likely to come out zhòng.
166
167The clips here were rendered with piper's `zh_CN-huayan-medium`. If you
168redistribute this app, check that voice's model card in
169[piper-voices](https://huggingface.co/rhasspy/piper-voices) for the terms
170attached to it, the same way you would the tile art below — or re-render the
171pack with a voice whose terms suit you, which is a single command.
172
173## Tile art
174
175`public/tiles/tiles.svg` is the **postmodern** tileset from
176[gnome-mahjongg](https://gitlab.gnome.org/GNOME/gnome-mahjongg), extracted from
177the installed binary's GResource — a 43×2 sprite sheet (second row is the
178highlighted variant, used for a lifted tile). `public/tiles/back.png` is the
179blank tile from its **smooth** theme, tinted jade, since a solitaire game has no
180face-down art of its own.
181
182That art is **GPL-2.0-or-later**. Fine for playing at home; if you ever
183distribute this app, either honour the GPL or swap the two files for art of your
184own — `SPRITE_COL` in `tiles.ts` is the only mapping that would need updating.
185
186Known gaps and house rules that aren't implemented yet are listed in
187[TODO.md](TODO.md).
188
189## Layout
190
191```
192public/tiles/ sprite sheet + tile back
193src/game/tiles.ts tile codes, wall, shuffle, sprite mapping
194src/game/hu.ts hand decomposition, 聽 detection, wait shapes
195src/game/tai.ts 台 scoring
196src/game/engine.ts state machine: deal, turns, claim resolution, settlement
197src/ui/ TileView, Hand (drag), Seat (rotated strip), Center, Help
198```