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 | |||
| 23 | 23 | import { TablePool } from './table/pool'; | |
| 24 | 24 | import { Pool } from './ui/Pool'; | |
| 25 | 25 | import { Seat, type RemoteSeat } from './ui/Seat'; | |
| 26 | + | import { TableMenu } from './ui/TableMenu'; | |
| 26 | 27 | import { SeatQr } from './ui/SeatQr'; | |
| 27 | 28 | import { Settings } from './ui/Settings'; | |
| 28 | 29 | import './styles.css'; | |
| ⋯ 30 unchanged lines | |||
| 59 | 60 | const [pool] = useState(() => new TablePool()); | |
| 60 | 61 | useSyncExternalStore(pool.subscribe, pool.getSnapshot); | |
| 61 | 62 | 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); | |
| 62 | 66 | const [settingsOpen, setSettingsOpen] = useState(false); | |
| 63 | 67 | // The table's join QR, held back into view from any seat's 📱 tag. | |
| 64 | 68 | const [qrPeek, setQrPeek] = useState(false); | |
| ⋯ 362 unchanged lines | |||
| 427 | 431 | compact={compact} | |
| 428 | 432 | pool={compact ? undefined : pool} | |
| 429 | 433 | onHelp={() => setHelpSeat(posOf(s))} | |
| 434 | + | onMenu={compact ? undefined : () => setMenuSeat(posOf(s))} | |
| 430 | 435 | /> | |
| 431 | 436 | ); | |
| 432 | 437 | })} | |
| ⋯ 25 unchanged lines | |||
| 458 | 463 | onSettings={() => setSettingsOpen(true)} | |
| 459 | 464 | /> | |
| 460 | 465 | {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 | + | )} | |
| 461 | 477 | {settingsPanel} | |
| 462 | 478 | </div> | |
| 463 | 479 | ); | |
| ⋯ 14 unchanged lines | |||
modifiedsrc/styles.css+34 −0
| ⋯ 971 unchanged lines | |||
| 972 | 972 | } | |
| 973 | 973 | ||
| 974 | 974 | /* ---------- help panel ---------- */ | |
| 975 | + | .helpbtn.gear { | |
| 976 | + | font-size: 13px; | |
| 977 | + | } | |
| 975 | 978 | .helpbtn { | |
| 976 | 979 | margin-left: 2px; | |
| 977 | 980 | width: 22px; | |
| ⋯ 1420 unchanged lines | |||
| 2398 | 2401 | letter-spacing: 0.02em; | |
| 2399 | 2402 | white-space: nowrap; | |
| 2400 | 2403 | } | |
| 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 | |||
| 204 | 204 | <DiceDebug game={game} whole={whole} onWhole={setWhole} /> | |
| 205 | 205 | )} | |
| 206 | 206 | ||
| 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 | + | )} | |
| 231 | 238 | </div> | |
| 232 | 239 | ); | |
| 233 | 240 | } | |
modifiedsrc/ui/Seat.tsx+21 −1
| ⋯ 45 unchanged lines | |||
| 46 | 46 | /** The tiles in the middle. Absent on a phone, which has no middle. */ | |
| 47 | 47 | pool?: TablePool; | |
| 48 | 48 | 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; | |
| 49 | 52 | } | |
| 50 | 53 | ||
| 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) { | |
| 52 | 65 | const t = useT(); | |
| 53 | 66 | const s = game.state; | |
| 54 | 67 | const p = s.players[seat]; | |
| ⋯ 306 unchanged lines | |||
| 361 | 374 | <button className="helpbtn" onClick={onHelp} aria-label={t('規則', 'rules')}> | |
| 362 | 375 | ? | |
| 363 | 376 | </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 | + | )} | |
| 364 | 384 | </div> | |
| 365 | 385 | </div> | |
| 366 | 386 | </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 | + | } |