anvilsign in

collin/mahjong · 366ba77f

One table, and a card over every hand

Collin Richards · 2026-08-25 08:28 UTC · 366ba77f83cd36fc19838e79f29d88e008170dcb · parent d5af31aa · browse files

modifiedREADME.md+86 −33
11 # 台灣麻將 — four-player mahjong, on one touchscreen or over the wire
22
33 Sixteen-tile Taiwanese mahjong for four people sitting around a single laptop.
4-All four hands are on screen at once, each rotated to face its own edge — put a
5-piece of card or plastic over your strip so the others can't see your tiles.
6-Every label is Chinese with English underneath; the tiles themselves stay
7-Chinese, because that is what the tiles say.
8-
9-Nobody else around? **單人對局 Single player** on the lobby gives you the
10-bottom seat and hands the other three to the computer — same rules, same
11-table, same scoring. See [computer players](#computer-players).
12-
13-Friends elsewhere? **線上對戰 Online game** puts everyone at the same table
14-from their own devices, and **手機派對 Party mode** keeps the laptop as the
15-table while phones scan a QR to hold their own tiles — see
16-[playing online](#playing-online).
4+All four hands are on screen at once, each rotated to face its own edge and
5+each face down until its player taps it — the piece of card everybody used to
6+lay over their strip, done by the table itself. Every label is Chinese with
7+English underneath; the tiles themselves stay Chinese, because that is what the
8+tiles say.
9+
10+**四人同桌** on the lobby is that table, and it is one mode rather than two:
11+four people at one screen, and any of them free to take their own hand onto
12+their phone from the QR in their gear — the tiles leave the screen and the
13+throw comes back over the wire. Nobody has to, and nothing has to be decided up
14+front. See [one table](#one-table).
15+
16+Nobody else around? **單人對局 Single player** gives you the bottom seat and
17+hands the other three to the computer — same rules, same table, same scoring.
18+See [computer players](#computer-players).
19+
20+Everybody somewhere else? **線上對戰 Online game** puts the four of you at the
21+same table from four devices, each turned so your own seat is the near edge —
22+see [playing online](#playing-online).
1723
1824 ```
1925 npm install
⋯ 21 unchanged lines
4147
4248 ## Playing
4349
50+- **Your hand is face down** at a shared screen, and a tap turns it over. It
51+ covers itself again when the hand is dealt and when your turn ends — the two
52+ moments the tiles stop being needed, and without them a hand turned over once
53+ stays turned over all evening, which is the same as never having covered it.
54+ 蓋牌 beside 理牌 puts it back sooner. While it is covered the only button the
55+ strip has is 看牌: every other one names a tile — 打出 五萬, 吃 with these two
56+ — and would say through the card what is under it. The 看牌 of whoever the
57+ table is waiting on wears a gold ring, since whose go it is was never a
58+ secret. Nothing is covered where the screen belongs to one player: solo, and
59+ online, where everybody has a device of their own.
4460 - **The tile you just drew** is held apart from the sorted hand with a gold ring
4561 and a 摸 label, instead of being sorted invisibly into it. It merges into the
4662 hand once you discard. 補花 replacements and kong replacements are marked the
⋯ 116 unchanged lines
163179 Stakes are locked once a game is under way — they'd otherwise rewrite chips
164180 already won.
165181
182+## One table
183+
184+四人同桌 and 手機派對 were two buttons on the lobby, and they were never two
185+games: one was the other with the phones left in people's pockets. Every party
186+table played its unclaimed seats hotseat-style at the screen already, and every
187+hotseat table would have taken a phone if it had had a room to put it in. So
188+there is one button now, and picking up your phone is not a mode — it is
189+something one player does, mid-hand, without asking the other three.
190+
191+What that costs is a decision about when to open the room, and the answer is:
192+not while anybody is waiting. **The tiles are dealt at once**, on this screen,
193+before a websocket has been opened — a laptop with no server behind it is still
194+four people at a table, and nobody should watch a spinner to start playing. The
195+room is opened behind the game, and when it lands it *inherits* the hand in
196+progress (`startParty(names, rules, from)` restores rather than deals) instead
197+of dealing over the top of it. The only thing that changes on screen is that
198+the QRs start working. If it never lands, nothing does.
199+
200+**Each seat's QR is its own**, and lives in that seat's gear beside the sound
201+and the undo, carrying `?room=…&seat=N`. Scanning it lands on that chair
202+without anybody having to say which one they are in: the hand leaves the shared
203+screen for good — face down there, not under a card that lifts — and the phone
204+gets it face up with the claim buttons and the throw. There is a QR for the
205+whole table in the middle of the felt as well, but only while the table is
206+still being sat down at; the moment the first tile is thrown it steps aside,
207+because from then on the middle is where the tiles go.
208+
209+**The save follows the screen.** A table that has opened a room keeps writing
210+`localStorage` (`engine.persist` on the screen's engine, off on every phone —
211+they mirror, and have no business writing it), so 繼續對局 still works and
212+resumes the same way the mode starts: restore here, open the room behind it,
213+publish the hand as it stood.
214+
166215 ## Playing online
167216
168-Two networked modes, both built on a room relay of our own — `server/rooms.ts`,
217+Both networked shapes — a shared screen with phones on it, and four devices in
218+four places — are one room relay of our own: `server/rooms.ts`,
169219 a couple hundred lines of Node on `ws`. The relay knows nothing about mahjong:
170220 rooms with codes, host election by join order, one bag of shared state only the
171221 host may write, and events fanned out to everyone else. One client — the
⋯ 12 unchanged lines
184234 own seat is the bottom edge; other people's tiles are face down. Joining
185235 mid-game takes over a vacant or computer seat, so a dropped friend can
186236 reload and carry on.
187-- **手機派對 Party mode** — the laptop stays the table, exactly like hotseat,
188- and each seat's corner of the felt wears a QR. Scanning one loads a
189- controller on the phone: just that hand, face up, with the claim buttons —
190- and the throw. Flick a tile up off the top of the phone and it sails in from
191- your edge of the common screen, at the speed and angle you let go of it
192- (`NetThrow`, mapped into table coordinates by `TablePool.throwFromNet`). The
193- seat's tiles go face down on the shared screen and its QR hides; hold the 📱
194- tag on the nameplate to show it again for a player who lost theirs. A phone
195- that leaves hands the seat back to the laptop. Seats never lock: a second
196- phone scanning the same QR joins the same hand — two people playing one
197- hand, whoever acts first acts. And with sound on, the phone in your hand is
198- what says your calls — see [audio, by seat](#audio-by-seat).
237+- **四人同桌 at one screen, with phones** — the mode above, and on the wire
238+ still `mode: 'party'`: the laptop is the table and a phone holds one hand.
239+ Scanning a seat's QR loads a controller: just that hand, face up, with the
240+ claim buttons — and the throw. Flick a tile up off the top of the phone and
241+ it sails in from your edge of the common screen, at the speed and angle you
242+ let go of it (`NetThrow`, mapped into table coordinates by
243+ `TablePool.throwFromNet`). The seat's tiles go face down on the shared screen
244+ and stay there; hold the 📱 tag on its nameplate to show the table's QR again
245+ for a player who lost theirs. A phone that leaves hands the seat back to the
246+ laptop, where it is covered-with-a-tap like the rest. Seats never lock: a
247+ second phone scanning the same QR joins the same hand — two people playing
248+ one hand, whoever acts first acts. And with sound on, the phone in your hand
249+ is what says your calls — see [audio, by seat](#audio-by-seat).
199250
200251 Design notes, in the order they bit:
201252
⋯ 163 unchanged lines
365416
366417 The game state is plain data, so a save is just its JSON in `localStorage`,
367418 rewritten after every move. Close the lid, refresh, or run the battery flat and
368-nothing is lost — the lobby offers **繼續對局 Resume** above the two ways to
369-start a fresh one, showing whether it was a solo or a hotseat game, the round,
370-hand number, chip counts and when it was saved. Which seats the computer holds
371-is part of the state, so a save resumes as the kind of game it was. A
419+nothing is lost — the lobby offers **繼續對局 Resume** above the ways to start a
420+fresh one, showing whether it was a solo game or a table, the round, hand
421+number, chip counts and when it was saved. Which seats the computer holds is
422+part of the state, so a save resumes as the kind of game it was — a solo game
423+here, a table with its room opened again behind it, so the phones that were on
424+it can come back. A
372425 save is validated before it is offered (four players, 144 tiles accounted for),
373426 so a truncated or hand-edited one is ignored rather than loaded into a broken
374427 table. `VERSION` in `save.ts` retires old saves if the state shape changes.
⋯ 75 unchanged lines
450503 rather than building one per contact. Old Safari has no `StereoPannerNode`; it
451504 plays where it always did.
452505
453-**At a party table, the words come out of the player.** Five devices in one
506+**At a shared table, the words come out of the player.** Five devices in one
454507 room, and the split the sound should take is the one the room already has: the
455508 table makes the noise, because the tiles are on the table, and the phone in
456-somebody's hand says their calls, because the calls are theirs. So the party
509+somebody's hand says their calls, because the calls are theirs. So the table's
457510 screen drops the *voice* for a seat while keeping the clack, and the phone
458511 holding that seat says 碰 and names its own discards and makes no table noise
459512 at all.
⋯ 120 unchanged lines
580633 src/net/table.ts TableCtl over the wire: mirror + intents + hand overlay
581634 src/net/protocol.ts what crosses the wire, in one place
582635 src/net/handOrder.ts your own arrangement, kept on your own device
583-src/ui/Controller.tsx the phone at the party table: your tiles and the throw
636+src/ui/Controller.tsx the phone at the shared table: your tiles and the throw
584637 src/ui/OnlineLobby.tsx seats and the deal button, before an online game
585638 server/rooms.ts the relay: rooms, host election, state fan-out
586639 server/index.ts production: dist/ and the relay from one port
⋯ 195 unchanged lines
modifiedsrc/App.tsx+84 −38
11 import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
22 import { AutoPlay } from './game/autoplay';
33 import type { TableCtl } from './game/ctl';
4-import { Game, type SoundCue } from './game/engine';
4+import { Game, type GameState, type SoundCue } from './game/engine';
55 import { loadPrefs, savePrefs, type Lang, type Prefs } from './game/prefs';
66 import { loadSave } from './game/save';
77 import {
⋯ 279 unchanged lines
287287 if (compact && prefs.fullscreen && !fullscreen) enterFullscreen();
288288 game.newGame(bots, prefs.names);
289289 };
290+
291+ /**
292+ * 四人同桌 — the table itself. Four people at this screen, each hand face
293+ * down under a tap, and any of them free to take their hand onto a phone
294+ * from the QR in their own gear. Hotseat and 手機派對 used to be two buttons
295+ * on this lobby and they were never two games: one was the other with the
296+ * phones left in your pocket.
297+ *
298+ * So the tiles are dealt here and now — a screen with no server behind it is
299+ * still four people at a table, and nobody should watch a spinner to start
300+ * playing — and the room is opened behind it. When it lands it inherits the
301+ * hand in progress rather than dealing over the top of it, and the only
302+ * thing that changes on screen is that the QRs start working. If it never
303+ * lands, nothing does.
304+ */
305+ const openTable = async (from?: GameState) => {
306+ if (compact && prefs.fullscreen && !fullscreen) enterFullscreen();
307+ if (from) game.restore(from);
308+ else game.newGame(HOTSEAT_SEATS, prefs.names);
309+ const s = await connect();
310+ if (s?.isHost && !s.mode) s.startParty(prefs.names, prefs.rules, game.state);
311+ };
290312 const resumeGame = () => {
291- if (compact && prefs.fullscreen && !fullscreen) enterFullscreen();
292- game.restore(saved!.state);
313+ // A saved game the computer was playing stays a solo game; a saved table
314+ // opens its room again, so the phones that were on it can come back.
315+ if (saved!.state.bots.some(Boolean)) {
316+ if (compact && prefs.fullscreen && !fullscreen) enterFullscreen();
317+ game.restore(saved!.state);
318+ } else {
319+ void openTable(saved!.state);
320+ }
293321 };
294322
295- // The two networked ways to start. Whoever creates the room is its host and
296- // may open a mode; a visitor who follows the link into a room where nothing
297- // has started yet waits for the host's choice.
323+ // Online is still its own thing: everybody on their own device, nobody
324+ // sharing a screen, and a seat lobby before anything is dealt. Whoever
325+ // creates the room is its host and may open a mode; a visitor who follows
326+ // the link into a room where nothing has started yet waits for the choice.
298327 const openOnline = async () => {
299328 const s = await connect();
300329 if (s?.isHost && !s.mode) s.openOnlineLobby();
301330 };
302- const openParty = async () => {
303- const s = await connect();
304- if (s?.isHost && !s.mode) s.startParty(prefs.names, prefs.rules);
305- };
306331
307332 // Preferences are written straight through, so a mute survives a refresh.
308333 useEffect(() => {
⋯ 71 unchanged lines
380405 {saved && (
381406 <div className="resume">
382407 <div className="resume-line">
383- {savedSolo ? t('單人', 'Solo') : t('同桌', 'Hotseat')} ·{' '}
408+ {savedSolo ? t('單人', 'Solo') : t('同桌', 'Table')} ·{' '}
384409 {t(
385410 `${WIND_NAMES[round]}圈 · 第 ${saved.state.handNumber} 局`,
386411 `${WIND_EN[round]} round, hand ${saved.state.handNumber}`,
⋯ 31 unchanged lines
418443 />
419444 </em>
420445 </button>
421- <button className="btn big-btn ghost" onClick={() => startGame(HOTSEAT_SEATS)}>
422- <Bi zh="四人同桌" en="Hotseat" />
446+ {/* One table, phones optional — see openTable. Not behind the room
447+ check the online button is behind: this one deals first and asks
448+ the server afterwards. */}
449+ <button className="btn big-btn ghost" onClick={() => void openTable()}>
450+ <Bi zh="四人同桌" en="Four at one table" />
423451 <em>
424- <BiText zh="四個人共用一個螢幕" en="four people, one screen" />
452+ <BiText
453+ zh="四個人共用一個螢幕,牌先蓋著,也可以掃碼用手機拿牌"
454+ en="four people, one screen — hands stay face down until you tap, and any of them can move to a phone"
455+ />
425456 </em>
426457 </button>
427458 {(!session || (session.isHost && netIdle)) && (
428- <>
429- <button className="btn big-btn ghost" onClick={openOnline} disabled={connecting}>
430- <Bi zh="線上對戰" en="Online game" />
431- <em>
432- <BiText zh="各自用自己的裝置" en="everyone joins from their own device" />
433- </em>
434- </button>
435- <button className="btn big-btn ghost" onClick={openParty} disabled={connecting}>
436- <Bi zh="手機派對" en="Party mode" />
437- <em>
438- <BiText
439- zh="這螢幕當桌,手機掃碼入座"
440- en="this screen is the table, scan the QR to join"
441- />
442- </em>
443- </button>
444- </>
459+ <button className="btn big-btn ghost" onClick={openOnline} disabled={connecting}>
460+ <Bi zh="線上對戰" en="Online game" />
461+ <em>
462+ <BiText zh="各自用自己的裝置" en="everyone joins from their own device" />
463+ </em>
464+ </button>
445465 )}
446466 {session && !session.isHost && netIdle && (
447467 <p className="hint">
⋯ 76 unchanged lines
524544 };
525545 };
526546
547+ // Whether this screen is one several people are sitting round, in which case
548+ // every hand played at it starts face down: your neighbour can see your
549+ // tiles, and the answer used to be a piece of card laid over your strip.
550+ //
551+ // A table with a room open is always one of those, however few hands are
552+ // still being played on it — the people whose hands went to their phones did
553+ // not leave the room, they are sitting right there looking at the screen.
554+ // Off where the screen belongs to one player: solo, and online, where
555+ // everybody has a device of their own.
556+ const sharing = netActive
557+ ? netMode === 'party'
558+ : SEATS.filter((s) => seatProps(s).local && !ctl.state.bots[s]).length > 1;
559+ // Where a hand goes when its player would rather hold it themselves. Only
560+ // where there is a room to go to.
561+ const seatUrl = (seat: SeatId) =>
562+ session && netMode === 'party'
563+ ? roomLink(session.room.code, session.room.publicBase, seat)
564+ : undefined;
565+
527566 return (
528567 <div className="table">
529568 {SEATS.map((s) => {
⋯ 5 unchanged lines
535574 seat={s}
536575 pos={posOf(s)}
537576 local={sp.local}
577+ shared={sharing}
538578 remote={sp.remote}
539579 compact={compact}
540580 pool={compact ? undefined : pool}
541581 onHelp={() => setHelpSeat(posOf(s))}
542- onMenu={compact ? undefined : () => setMenuSeat(posOf(s))}
582+ onMenu={compact ? undefined : () => setMenuSeat(s)}
543583 />
544584 );
545585 })}
546586 {/* The tiles in the middle. Not on a phone: the compact layout has no
547587 centre square for them to be thrown into. */}
548588 {!compact && <Pool game={ctl} pool={pool} viewSeat={viewSeat} />}
549- {/* Party: one QR for the whole table, lying in the middle of the felt
550- as a giant face-up tile. Scanning it lands a phone on the seat
551- picker. Once every hand is held it steps aside, and comes back while
552- any seat's 📱 tag is held down — for a player who lost theirs. */}
589+ {/* One QR for the whole table, lying in the middle of the felt as a
590+ giant face-up tile. Scanning it lands a phone on the seat picker.
591+ It is up while the table is still being sat down at — nothing thrown
592+ yet, and a hand still to be claimed — and steps aside the moment play
593+ starts, because from then on the middle is where the tiles go and a
594+ phone is asked for from the seat's own gear instead. It comes back
595+ while any seat's 📱 tag is held down, for a player who lost theirs. */}
553596 {netMode === 'party' &&
554597 !compact &&
555- (qrPeek || session!.seats.some((s) => s.ids.length === 0)) && (
598+ (qrPeek ||
599+ (session!.seats.some((s) => s.ids.length === 0) &&
600+ ctl.state.players.every((p) => p.discards.length === 0))) && (
556601 <div className="qr-dock">
557602 <SeatQr url={roomLink(session!.room.code, session!.room.publicBase)} />
558603 <span className="qr-hint">
⋯ 16 unchanged lines
575620 <TableMenu
576621 game={ctl}
577622 prefs={prefs}
578- seat={menuSeat}
623+ pos={posOf(menuSeat)}
624+ seatUrl={seatUrl(menuSeat)}
579625 compact={compact}
580626 onPrefs={setPrefs}
581627 onSettings={() => setSettingsOpen(true)}
⋯ 20 unchanged lines
modifiedsrc/net/protocol.ts+12 −8
⋯ 122 unchanged lines
123123 }
124124
125125 /**
126- * The link the table's QR carries: this very page, with just the room in the
127- * query. No seat — a phone that scans it lands on the seat picker, which is
128- * also what lets a pair deliberately share a hand. (An old link with a `seat`
129- * param still works: seatFromUrl above keeps reading it.) The page's own URL
130- * serves the game directly and keeps every param, which on a phone also means
131- * the whole screen belongs to the hand.
126+ * The link a QR carries: this very page, with the room in the query.
127+ *
128+ * Without a seat it is the table's own — a phone that scans it lands on the
129+ * seat picker, which is also what lets a pair deliberately share a hand. With
130+ * one it is a particular hand's, the QR inside that seat's own gear, and
131+ * scanning it takes that hand off the shared screen and onto the phone without
132+ * anybody having to say which chair they are in.
133+ *
134+ * The page's own URL serves the game directly and keeps every param, which on
135+ * a phone also means the whole screen belongs to the hand.
132136 *
133137 * `base` is the server's public face (`room.publicBase` — its rsgrok
134138 * tunnel): a link has to be an address the phone can reach, which the host
135139 * screen's own `localhost` is not. The path survives the swap; the tunnel
136140 * fronts the same server.
137141 */
138-export function roomLink(code: string, base?: string): string {
142+export function roomLink(code: string, base?: string, seat?: SeatId): string {
139143 const u = new URL(window.location.href);
140144 if (base) {
141145 try {
⋯ 4 unchanged lines
146150 // A malformed base loses to a working local link.
147151 }
148152 }
149- u.search = `?room=${code}`;
153+ u.search = seat === undefined ? `?room=${code}` : `?room=${code}&seat=${seat}`;
150154 u.hash = '';
151155 return u.toString();
152156 }
modifiedsrc/net/session.ts+22 −8
11 import { AutoPlay } from '../game/autoplay';
22 import type { NetThrow } from '../game/ctl';
3-import type { SoundEvent } from '../game/engine';
3+import type { GameState, SoundEvent } from '../game/engine';
44 import type { Tile } from '../game/tiles';
55 import type { Rules, SeatId } from '../game/types';
66 import {
⋯ 232 unchanged lines
239239 }
240240
241241 /**
242- * The party table: this device becomes the common screen and deals at once.
243- * All four seats start as people at this screen, exactly like hotseat — a
244- * phone that scans a seat's QR takes that seat over, and gives it back if it
245- * leaves.
242+ * Open the room around this screen's table: this device becomes the common
243+ * screen, and all four seats start as people sitting at it — which is
244+ * exactly what they already were. A phone that scans a seat's QR takes that
245+ * hand over, and gives it back if it leaves.
246+ *
247+ * `from` is a game already being played here, and is the usual case: the
248+ * table deals the moment it is asked to and opens the room afterwards, so
249+ * nobody waits on a websocket to start playing and a screen with no server
250+ * behind it is still four people at a table. The room, when it arrives,
251+ * inherits the hand in progress rather than dealing over the top of it.
246252 */
247- startParty(names: string[], rules: Rules) {
253+ startParty(names: string[], rules: Rules, from?: GameState) {
248254 console.log(`[mahjong] startParty (host: ${this.isHost})`);
249255 if (!this.isHost) return;
250256 this.role = 'screen';
257+ // The screen is where the save lives — it is the device that will still be
258+ // here tomorrow. Phones mirror; they have no business writing it.
259+ this.table.engine.persist = true;
251260 const seats = EMPTY_SEATS.map((_, i) => ({ ids: [], name: names[i] ?? '' }));
252261 this.room.setState({
253262 mode: 'party',
⋯ 2 unchanged lines
256265 voices: [false, false, false, false] as never,
257266 });
258267 const eng = this.table.engine;
259- eng.applySettings(names, rules);
260- eng.newGame([false, false, false, false], names);
268+ if (from) {
269+ // Its own copy: the local engine goes on owning the object it handed us.
270+ eng.restore(structuredClone(from));
271+ } else {
272+ eng.applySettings(names, rules);
273+ eng.newGame([false, false, false, false], names);
274+ }
261275 }
262276
263277 /** Ask for a seat — a numbered one, or any free one. */
⋯ 208 unchanged lines
modifiedsrc/styles.css+68 −0
⋯ 265 unchanged lines
266266 align-items: flex-end;
267267 flex: none;
268268 }
269+/* Your own hand, face down at a shared screen until you tap it. The backs are
270+ already the picture; all this adds is that it is *askable* — a hand that
271+ lifts a little under the finger, the way a card laid over a strip does. The
272+ three other hands, which are face down because they are not yours, get none
273+ of it and stay flat. See the `shared` prop in ui/Seat. */
274+.hand.covered {
275+ cursor: pointer;
276+ transition: transform 0.12s ease-out, filter 0.12s ease-out;
277+}
278+.hand.covered:hover,
279+.hand.covered:focus-visible {
280+ transform: translateY(-3px);
281+ filter: brightness(1.12);
282+ outline: none;
283+}
284+.hand.covered:active {
285+ transform: translateY(-1px);
286+}
287+
269288 /* Somebody has thrown a tile hard enough to come off the pool and into this
270289 row. It takes the knock and rocks back — the sound is `barge` in game/sound,
271290 and what decides it happened is the physics, in ui/Pool. Inside the seat's
⋯ 384 unchanged lines
656675 :root:not(.compact) .actionbar .btn.claim.chow {
657676 color: #1d6640;
658677 }
678+/* 看牌 is the only button a covered hand has, and it is the one button here
679+ that is not done *with* a tile — it is done to the card lying over them. So
680+ it wears the back rather than the face, and reads as the thing beside it. */
681+:root:not(.compact) .actionbar .btn.reveal {
682+ background: url('./assets/back.png') center / 100% 100%;
683+ color: var(--ivory);
684+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
685+}
686+/* And the table is waiting on this one. Whose go it is was never a secret, so
687+ the ring says so — which is what stops four face-down hands and no buttons
688+ reading as a table that has stopped rather than one waiting for somebody to
689+ look at their tiles. */
690+:root:not(.compact) .actionbar .btn.reveal.hot {
691+ outline: 2px solid var(--gold);
692+ outline-offset: -2px;
693+ box-shadow: 0 2px 3px rgba(0, 0, 0, 0.45), 0 0 10px rgba(217, 180, 92, 0.45);
694+}
659695 /* 過 and 摸牌 are not calls, so they are the plain face — and a pass reads as
660696 the quiet one it is. */
661697 :root:not(.compact) .actionbar .btn.pass,
⋯ 1862 unchanged lines
25242560 flex-direction: column;
25252561 gap: 8px;
25262562 margin-top: 10px;
2563+ /* The panel is a fixed height at its player's edge, and the QR at the foot
2564+ of this list is what first made the list taller than it. Scroll rather
2565+ than spill: the panel is turned to face one player, and a menu that ran
2566+ off the table would run off it upside down for two of them. */
2567+ min-height: 0;
2568+ overflow-y: auto;
25272569 }
25282570 .menu-body .btn {
25292571 justify-content: flex-start;
⋯ 3 unchanged lines
25332575 opacity: 0.4;
25342576 cursor: default;
25352577 }
2578+/* The way off this screen: the QR for taking *this* hand onto a phone, turned
2579+ to face the player whose hand it is along with everything else in here.
2580+ Smaller than the one that lies in the middle of the felt — a phone held over
2581+ an open menu is a foot away, not across the table. */
2582+.menu-qr {
2583+ display: flex;
2584+ align-items: center;
2585+ gap: 10px;
2586+ padding-top: 4px;
2587+ border-top: 1px solid rgba(246, 240, 226, 0.14);
2588+}
2589+.menu-qr .seat-qr {
2590+ --qw: 92px;
2591+ flex: none;
2592+}
2593+.menu-qr-hint {
2594+ font-size: 12px;
2595+ line-height: 1.35;
2596+ opacity: 0.75;
2597+}
2598+.menu-qr-hint em {
2599+ display: block;
2600+ font-style: normal;
2601+ font-size: 11px;
2602+ opacity: 0.75;
2603+}
modifiedsrc/ui/Hand.tsx+17 −1
⋯ 16 unchanged lines
1717 tiles: Tile[];
1818 /** Somebody else's hand: shown as backs, and not to be poked at. */
1919 faceDown?: boolean;
20+ /**
21+ * Face down but yours to turn over — the shared table, where a hand starts
22+ * covered and a tap on the backs lifts the card off it. Absent on a hand
23+ * that is face down because it is not yours to see.
24+ */
25+ onFaceUp?: () => void;
2026 /** Index of the tile held apart from the rest, if any. */
2127 drawnIndex: number | null;
2228 selected: number | null;
⋯ 136 unchanged lines
159165 export function Hand({
160166 tiles,
161167 faceDown,
168+ onFaceUp,
162169 drawnIndex,
163170 selected,
164171 canDiscard,
⋯ 127 unchanged lines
292299 };
293300
294301 if (faceDown) {
302+ // The whole row is the tap target when it is yours to turn over: sixteen
303+ // tile-sized buttons would be sixteen ways to do one thing.
295304 return (
296- <div className="hand">
305+ <div
306+ className={`hand ${onFaceUp ? 'covered' : ''}`}
307+ role={onFaceUp ? 'button' : undefined}
308+ tabIndex={onFaceUp ? 0 : undefined}
309+ aria-label={onFaceUp ? t('看牌', 'look at your hand', ' · ') : undefined}
310+ onClick={onFaceUp}
311+ onKeyDown={onFaceUp && ((e) => (e.key === 'Enter' || e.key === ' ') && onFaceUp())}
312+ >
297313 {tiles.map((_, i) => (
298314 <div key={i} className="hand-cell">
299315 <TileView faceDown />
⋯ 50 unchanged lines
modifiedsrc/ui/Seat.tsx+66 −1
⋯ 46 unchanged lines
4747 * (the tiles' faces, the 聽 and 過水 badges, the buttons) keys off this.
4848 */
4949 local?: boolean;
50+ /**
51+ * More than one person is playing at this screen, so a hand this device may
52+ * act for is still not a hand everybody may read. It starts face down and is
53+ * turned over with a tap — the piece of card people used to lay over their
54+ * strip, which is what the game asked for before it could do this itself.
55+ *
56+ * False where the screen belongs to one player — solo, online, a phone —
57+ * because covering your own hand from yourself is just a tap in the way.
58+ */
59+ shared?: boolean;
5060 remote?: RemoteSeat;
5161 /** Phone-sized table: nothing is rotated and only the bottom gets a full strip. */
5262 compact?: boolean;
⋯ 10 unchanged lines
6373 seat,
6474 pos = seat,
6575 local = true,
76+ shared,
6677 remote,
6778 compact,
6879 pool,
⋯ 7 unchanged lines
7687 const bot = s.bots[seat];
7788 // Somebody this device answers for: a person here, not the computer, not a
7889 // hand that lives on another screen.
79- const acts = local && !bot;
90+ const plays = local && !bot;
91+ // Face down with your own hand under it: three other people are looking at
92+ // this screen. Lifted by a tap and put back by one, and put back on its own
93+ // when the hand is dealt and when this seat has finished its turn — the two
94+ // moments the tiles stop being needed and would otherwise sit there in the
95+ // open. See the `shared` prop.
96+ const [shown, setShown] = useState(false);
97+ const covered = !!shared && plays && !shown;
98+ // Somebody this device answers for *and* may read: everything private — the
99+ // tiles' faces, the 聽 and 過水 badges, the buttons that name a tile — keys
100+ // off this rather than off who is sitting there.
101+ const acts = plays && !covered;
80102 // A hand this device may not read goes face down and its 聽 / 過水 badges
81103 // stay off, since both would give it away. At the end of a hand everyone
82104 // turns their tiles over, as they would.
⋯ 10 unchanged lines
93115 setSel(null);
94116 }, [myTurn, s.handNumber, p.hand.length, s.drawnTile]);
95117
118+ // The card goes back over the hand when it is dealt, and again when this
119+ // seat's turn ends. Both are moments the tiles stop being needed, and
120+ // without them a hand turned over once stays turned over for the rest of the
121+ // evening — which is the same as never having covered it. Everything else is
122+ // the player's own tap.
123+ const dealt = s.phase === 'lobby' ? -1 : s.handNumber;
124+ useEffect(() => {
125+ setShown(false);
126+ }, [dealt]);
127+ const wasPlaying = useRef(false);
128+ const playing = s.phase === 'turn' && s.turn === seat;
129+ /**
130+ * The table is waiting on this seat: its go, or its pick-up that closes a
131+ * claim window. Both are public — whose turn it is has never been a secret —
132+ * so a covered hand may light its 看牌 without saying anything about what is
133+ * under the card. Without it a table of four face-down hands and no buttons
134+ * anywhere reads as stuck rather than as waiting for somebody to look.
135+ */
136+ const awaited = plays && (playing || game.nextDrawer() === seat);
137+ useEffect(() => {
138+ if (wasPlaying.current && !playing) setShown(false);
139+ wasPlaying.current = playing;
140+ }, [playing]);
141+
96142 const seatWind = (seat - s.dealer + 4) % 4;
97143 const isDealer = seat === s.dealer;
98144 const live = s.phase !== 'handEnd' && s.phase !== 'lobby';
⋯ 148 unchanged lines
247293 <Hand
248294 tiles={p.hand}
249295 faceDown={concealed}
296+ onFaceUp={covered ? () => setShown(true) : undefined}
250297 drawnIndex={drawnIndex}
251298 selected={sel}
252299 canDiscard={myTurn}
⋯ 4 unchanged lines
257304 />
258305
259306 <div className="actionbar">
307+ {/* The one thing a covered hand can do. Every other button here
308+ names a tile — 打出 五萬, 吃 with these two — so they wait until
309+ their player is the one looking. */}
310+ {covered && (
311+ <button
312+ className={`btn reveal ${awaited ? 'hot' : ''}`}
313+ onClick={() => setShown(true)}
314+ >
315+ <Bi zh="看牌" en="Look" />
316+ </button>
317+ )}
260318 {claiming && (
261319 <>
262320 {opts!.map((o, i) => (
⋯ 123 unchanged lines
386444 {t('理牌', 'Sort')}
387445 </button>
388446 )}
447+ {/* Put it back yourself, without waiting for the turn to end —
448+ somebody has walked round behind you, or you are done looking. */}
449+ {shared && plays && shown && s.phase !== 'handEnd' && s.phase !== 'gameEnd' && (
450+ <button className="btn ghost sort" onClick={() => setShown(false)}>
451+ {t('蓋牌', 'Cover')}
452+ </button>
453+ )}
389454 <button className="helpbtn" onClick={onHelp} aria-label={t('規則', 'rules')}>
390455 ?
391456 </button>
⋯ 194 unchanged lines
modifiedsrc/ui/TableMenu.tsx+26 −4
⋯ 4 unchanged lines
55 import { FullscreenToggle } from './FullscreenToggle';
66 import { useLang, useT } from './lang';
77 import { SEAT_ROT, SEAT_SLOT } from './rotation';
8+import { SeatQr } from './SeatQr';
89 import { SoundToggle } from './SoundToggle';
910
1011 /**
⋯ 14 unchanged lines
2526 export function TableMenu({
2627 game,
2728 prefs,
28- seat,
29+ pos,
2930 compact,
31+ seatUrl,
3032 onPrefs,
3133 onSettings,
3234 onClose,
⋯ 1 unchanged line
3436 game: TableCtl;
3537 prefs: Prefs;
3638 /** The edge this is drawn at — the seat's own, so it faces its player. */
37- seat: SeatId;
39+ pos: SeatId;
3840 compact?: boolean;
41+ /**
42+ * The QR for taking *this hand* onto a phone, where there is a room to take
43+ * it into. Absent when the table is on its own — no server, or an online
44+ * game, where everybody is already on their own device.
45+ */
46+ seatUrl?: string;
3947 onPrefs: (p: Prefs) => void;
4048 onSettings: () => void;
4149 onClose: () => void;
⋯ 4 unchanged lines
4654 const undoable = s.phase === 'gameEnd' ? null : game.undoable();
4755
4856 return (
49- <div className={`help-slot ${SEAT_SLOT[seat]}`} onClick={onClose}>
57+ <div className={`help-slot ${SEAT_SLOT[pos]}`} onClick={onClose}>
5058 <div
5159 className="help menu"
5260 style={{
5361 transform: compact
5462 ? 'translate(-50%, -50%)'
55- : `translate(-50%, -50%) rotate(${SEAT_ROT[seat]}deg)`,
63+ : `translate(-50%, -50%) rotate(${SEAT_ROT[pos]}deg)`,
5664 }}
5765 onClick={(e) => e.stopPropagation()}
5866 >
⋯ 38 unchanged lines
97105 <SoundToggle prefs={prefs} onPrefs={onPrefs} />
98106 <FullscreenToggle prefs={prefs} onPrefs={onPrefs} />
99107
108+ {/* Taking this hand off the shared screen. The QR is the seat's own,
109+ so scanning it lands on this chair without anybody having to say
110+ which one they are in — and the tiles here go face down for good
111+ rather than under a card that lifts. */}
112+ {seatUrl && (
113+ <div className="menu-qr">
114+ <SeatQr url={seatUrl} />
115+ <span className="menu-qr-hint">
116+ {lang !== 'en' && '掃碼把這副牌拿到手機上'}
117+ {lang !== 'zh' && <em>scan to hold this hand on your phone</em>}
118+ </span>
119+ </div>
120+ )}
121+
100122 {/* The moment anybody wants a name or the stakes changed is the
101123 moment they went looking for a gear. */}
102124 <button
⋯ 13 unchanged lines