anvilsign in

collin/mahjong · af3ae648

Every player's own gear, in their own strip

Collin Richards · 2026-08-25 07:44 UTC · af3ae648cb0a3e2a21f3431c966d5cd91ed8db63 · parent e319e006 · browse files

modifiedsrc/App.tsx+16 −0
⋯ 22 unchanged lines
2323 import { TablePool } from './table/pool';
2424 import { Pool } from './ui/Pool';
2525 import { Seat, type RemoteSeat } from './ui/Seat';
26+import { TableMenu } from './ui/TableMenu';
2627 import { SeatQr } from './ui/SeatQr';
2728 import { Settings } from './ui/Settings';
2829 import './styles.css';
⋯ 30 unchanged lines
5960 const [pool] = useState(() => new TablePool());
6061 useSyncExternalStore(pool.subscribe, pool.getSnapshot);
6162 const [helpSeat, setHelpSeat] = useState<SeatId | null>(null);
63+ // Whose gear is open — the same per-edge treatment as the rules panel, so
64+ // what a player opens faces them and nobody else. See ui/TableMenu.
65+ const [menuSeat, setMenuSeat] = useState<SeatId | null>(null);
6266 const [settingsOpen, setSettingsOpen] = useState(false);
6367 // The table's join QR, held back into view from any seat's 📱 tag.
6468 const [qrPeek, setQrPeek] = useState(false);
⋯ 362 unchanged lines
427431 compact={compact}
428432 pool={compact ? undefined : pool}
429433 onHelp={() => setHelpSeat(posOf(s))}
434+ onMenu={compact ? undefined : () => setMenuSeat(posOf(s))}
430435 />
431436 );
432437 })}
⋯ 25 unchanged lines
458463 onSettings={() => setSettingsOpen(true)}
459464 />
460465 {helpSeat !== null && <Help seat={helpSeat} compact={compact} onClose={() => setHelpSeat(null)} />}
466+ {menuSeat !== null && (
467+ <TableMenu
468+ game={ctl}
469+ prefs={prefs}
470+ seat={menuSeat}
471+ compact={compact}
472+ onPrefs={setPrefs}
473+ onSettings={() => setSettingsOpen(true)}
474+ onClose={() => setMenuSeat(null)}
475+ />
476+ )}
461477 {settingsPanel}
462478 </div>
463479 );
⋯ 14 unchanged lines
modifiedsrc/styles.css+34 −0
⋯ 971 unchanged lines
972972 }
973973
974974 /* ---------- help panel ---------- */
975+.helpbtn.gear {
976+ font-size: 13px;
977+}
975978 .helpbtn {
976979 margin-left: 2px;
977980 width: 22px;
⋯ 1420 unchanged lines
23982401 letter-spacing: 0.02em;
23992402 white-space: nowrap;
24002403 }
2404+
2405+/* The gear's panel. Same shell as the rules sheet — anchored in the player's
2406+ own strip and turned to face it — but it is four buttons, not a book, so it
2407+ is only as tall as they are. */
2408+/* `--hd` is how deep it stands, and `--off` — how far out of the middle it is
2409+ pushed towards its own edge — is worked out from it, so the menu sets the
2410+ depth rather than letting its content decide it. Four buttons' worth. */
2411+.help-slot.slot-bottom .help.menu,
2412+.help-slot.slot-top .help.menu,
2413+.help-slot.slot-left .help.menu,
2414+.help-slot.slot-right .help.menu {
2415+ width: min(420px, calc(100vw - 60px));
2416+ --hd: 268px;
2417+}
2418+.help.menu {
2419+ padding: 10px 18px 16px;
2420+}
2421+.menu-body {
2422+ display: flex;
2423+ flex-direction: column;
2424+ gap: 8px;
2425+ margin-top: 10px;
2426+}
2427+.menu-body .btn {
2428+ justify-content: flex-start;
2429+ width: 100%;
2430+}
2431+.menu-body .btn:disabled {
2432+ opacity: 0.4;
2433+ cursor: default;
2434+}
modifiedsrc/ui/Center.tsx+31 −24
⋯ 203 unchanged lines
204204 <DiceDebug game={game} whole={whole} onWhole={setWhole} />
205205 )}
206206
207- <div className="table-controls">
208- {undoable && (
209- <button className="btn ghost undo" onClick={() => game.undo()}>
210- <span className="undo-arrow" aria-hidden>
211- ↺
212- </span>
213- <span className="undo-what">
214- {lang !== 'en' && (
215- <span className="zh" lang="zh-TW">
216- 復原 {undoable.zh}
217- </span>
218- )}
219- {lang !== 'zh' && (
220- <span className="en" lang="en">
221- Undo · {undoable.en}
222- </span>
223- )}
224- </span>
225- </button>
226- )}
227- {/* Only on the layout the address bar actually crowds. */}
228- {compact && <FullscreenToggle prefs={prefs} onPrefs={onPrefs} compact />}
229- <SoundToggle prefs={prefs} onPrefs={onPrefs} compact />
230- </div>
207+ {/* A phone is one player looking at one screen, so its controls can sit in
208+ the middle of it. A table is not: the middle is where the pile lands,
209+ four people are looking at it, and it is upside down to two of them —
210+ so at full size these live in each player's own strip instead, behind
211+ the gear beside their `?`. See ui/TableMenu. */}
212+ {compact && (
213+ <div className="table-controls">
214+ {undoable && (
215+ <button className="btn ghost undo" onClick={() => game.undo()}>
216+ <span className="undo-arrow" aria-hidden>
217+ ↺
218+ </span>
219+ <span className="undo-what">
220+ {lang !== 'en' && (
221+ <span className="zh" lang="zh-TW">
222+ 復原 {undoable.zh}
223+ </span>
224+ )}
225+ {lang !== 'zh' && (
226+ <span className="en" lang="en">
227+ Undo · {undoable.en}
228+ </span>
229+ )}
230+ </span>
231+ </button>
232+ )}
233+ {/* Only on the layout the address bar actually crowds. */}
234+ <FullscreenToggle prefs={prefs} onPrefs={onPrefs} compact />
235+ <SoundToggle prefs={prefs} onPrefs={onPrefs} compact />
236+ </div>
237+ )}
231238 </div>
232239 );
233240 }
modifiedsrc/ui/Seat.tsx+21 −1
⋯ 45 unchanged lines
4646 /** The tiles in the middle. Absent on a phone, which has no middle. */
4747 pool?: TablePool;
4848 onHelp: () => void;
49+ /** The gear beside the `?` — this player's own table controls. Absent on a
50+ * phone, where they are in the middle bar and there is no middle to crowd. */
51+ onMenu?: () => void;
4952 }
5053
51-export function Seat({ game, seat, pos = seat, local = true, remote, compact, pool, onHelp }: Props) {
54+export function Seat({
55+ game,
56+ seat,
57+ pos = seat,
58+ local = true,
59+ remote,
60+ compact,
61+ pool,
62+ onHelp,
63+ onMenu,
64+}: Props) {
5265 const t = useT();
5366 const s = game.state;
5467 const p = s.players[seat];
⋯ 306 unchanged lines
361374 <button className="helpbtn" onClick={onHelp} aria-label={t('規則', 'rules')}>
362375 ?
363376 </button>
377+ {/* This player's own copy of the table's controls — sound, undo, the
378+ settings — turned to face their own edge. See ui/TableMenu. */}
379+ {onMenu && (
380+ <button className="helpbtn gear" onClick={onMenu} aria-label={t('桌面設定', 'table controls')}>
381+ ⚙
382+ </button>
383+ )}
364384 </div>
365385 </div>
366386 </div>
⋯ 184 unchanged lines
addedsrc/ui/TableMenu.tsx+115 −0
1+import type { TableCtl } from '../game/ctl';
2+import type { Prefs } from '../game/prefs';
3+import type { SeatId } from '../game/types';
4+import { Bi } from './Bi';
5+import { FullscreenToggle } from './FullscreenToggle';
6+import { useLang, useT } from './lang';
7+import { SEAT_ROT, SEAT_SLOT } from './rotation';
8+import { SoundToggle } from './SoundToggle';
9+
10+/**
11+ * One player's own handful of table controls, opened from the gear beside the
12+ * `?` in their nameplate.
13+ *
14+ * These used to sit in a bar in the middle of the table, which is the one place
15+ * on a table that belongs to everybody: the pile lands there, four people are
16+ * looking at it, and it is upside down to two of them. So they moved out to the
17+ * edges — same treatment as the rules panel, anchored in the seat's own strip
18+ * and turned to face it, so what you open reads the right way up to you and to
19+ * nobody else.
20+ *
21+ * Undo is deliberately in all four of them rather than only the seat that made
22+ * the mistake: whoever spots a mis-tap should be able to take it back, which is
23+ * how it works when four people share one screen.
24+ */
25+export function TableMenu({
26+ game,
27+ prefs,
28+ seat,
29+ compact,
30+ onPrefs,
31+ onSettings,
32+ onClose,
33+}: {
34+ game: TableCtl;
35+ prefs: Prefs;
36+ /** The edge this is drawn at — the seat's own, so it faces its player. */
37+ seat: SeatId;
38+ compact?: boolean;
39+ onPrefs: (p: Prefs) => void;
40+ onSettings: () => void;
41+ onClose: () => void;
42+}) {
43+ const t = useT();
44+ const lang = useLang();
45+ const s = game.state;
46+ const undoable = s.phase === 'gameEnd' ? null : game.undoable();
47+
48+ return (
49+ <div className={`help-slot ${SEAT_SLOT[seat]}`} onClick={onClose}>
50+ <div
51+ className="help menu"
52+ style={{
53+ transform: compact
54+ ? 'translate(-50%, -50%)'
55+ : `translate(-50%, -50%) rotate(${SEAT_ROT[seat]}deg)`,
56+ }}
57+ onClick={(e) => e.stopPropagation()}
58+ >
59+ <div className="help-head">
60+ <h3>{t('桌面', 'Table', ' · ')}</h3>
61+ <button className="btn ghost" onClick={onClose}>
62+ {t('關閉', 'Close')}
63+ </button>
64+ </div>
65+ <div className="menu-body">
66+ <button
67+ className="btn ghost undo"
68+ disabled={!undoable}
69+ onClick={() => {
70+ game.undo();
71+ onClose();
72+ }}
73+ >
74+ <span className="undo-arrow" aria-hidden>
75+ ↺
76+ </span>
77+ <span className="undo-what">
78+ {undoable ? (
79+ <>
80+ {lang !== 'en' && (
81+ <span className="zh" lang="zh-TW">
82+ 復原 {undoable.zh}
83+ </span>
84+ )}
85+ {lang !== 'zh' && (
86+ <span className="en" lang="en">
87+ Undo · {undoable.en}
88+ </span>
89+ )}
90+ </>
91+ ) : (
92+ <span>{t('沒有可復原的', 'Nothing to undo', ' · ')}</span>
93+ )}
94+ </span>
95+ </button>
96+
97+ <SoundToggle prefs={prefs} onPrefs={onPrefs} />
98+ <FullscreenToggle prefs={prefs} onPrefs={onPrefs} />
99+
100+ {/* The moment anybody wants a name or the stakes changed is the
101+ moment they went looking for a gear. */}
102+ <button
103+ className="btn ghost"
104+ onClick={() => {
105+ onClose();
106+ onSettings();
107+ }}
108+ >
109+ <Bi zh="設定" en="Settings" />
110+ </button>
111+ </div>
112+ </div>
113+ </div>
114+ );
115+}