| 1 | import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; |
| 2 | import { AutoPlay } from './game/autoplay'; |
| 3 | import type { TableCtl } from './game/ctl'; |
| 4 | import { Game } from './game/engine'; |
| 5 | import { loadPrefs, savePrefs, type Lang, type Prefs } from './game/prefs'; |
| 6 | import { loadSave } from './game/save'; |
| 7 | import { play, setSoundEnabled, setVoiceEnabled, setVolume } from './game/sound'; |
| 8 | import { WIND_EN, WIND_NAMES } from './game/tiles'; |
| 9 | import type { SeatId } from './game/types'; |
| 10 | import { roomFromUrl, roomLink, seatFromUrl } from './net/protocol'; |
| 11 | import { NetSession } from './net/session'; |
| 12 | import { Bi, BiText } from './ui/Bi'; |
| 13 | import { Center } from './ui/Center'; |
| 14 | import { useCompact } from './ui/compact'; |
| 15 | import { Controller } from './ui/Controller'; |
| 16 | import { FullscreenToggle } from './ui/FullscreenToggle'; |
| 17 | import { enterFullscreen, useFullscreen } from './ui/fullscreen'; |
| 18 | import { Help } from './ui/Help'; |
| 19 | import { pick, setLang, useLang, useT } from './ui/lang'; |
| 20 | import { LanguageToggle } from './ui/LanguageToggle'; |
| 21 | import { OnlineLobby } from './ui/OnlineLobby'; |
| 22 | import { TablePool } from './table/pool'; |
| 23 | import { Pool } from './ui/Pool'; |
| 24 | import { Seat, type RemoteSeat } from './ui/Seat'; |
| 25 | import { SeatQr } from './ui/SeatQr'; |
| 26 | import { Settings } from './ui/Settings'; |
| 27 | import './styles.css'; |
| 28 | |
| 29 | const SEATS: SeatId[] = [0, 1, 2, 3]; |
| 30 | /** The human always takes the bottom seat; the other three are the computer's. */ |
| 31 | const SOLO_SEATS = [false, true, true, true]; |
| 32 | const HOTSEAT_SEATS = [false, false, false, false]; |
| 33 | |
| 34 | // One join per page, whoever asks first — the lobby button, or the ?room= a |
| 35 | // shared link carries. A promise rather than a flag, so a second caller waits |
| 36 | // on the same join instead of starting another. |
| 37 | let joining: Promise<NetSession> | null = null; |
| 38 | const joinOnce = () => (joining ??= NetSession.join()); |
| 39 | |
| 40 | const subNothing = () => () => {}; |
| 41 | const zero = () => 0; |
| 42 | |
| 43 | export default function App() { |
| 44 | const [prefs, setPrefs] = useState<Prefs>(loadPrefs); |
| 45 | const t = useT(); |
| 46 | const lang = useLang(); |
| 47 | // Phone-sized: everything reads upright for the one person holding it. |
| 48 | const compact = useCompact(); |
| 49 | const fullscreen = useFullscreen(); |
| 50 | // Built once, from the preferences as they stood at startup; after that the |
| 51 | // settings screen edits the live game rather than replacing it. |
| 52 | const [game] = useState(() => new Game(prefs.names, prefs.rules)); |
| 53 | useSyncExternalStore(game.subscribe, game.getSnapshot); |
| 54 | // The tiles in the middle. Owned here rather than inside <Pool> because the |
| 55 | // seats need it too: a hand has to know whether it can be thrown from, and |
| 56 | // which seats those are changes as the wall is eaten away. |
| 57 | const [pool] = useState(() => new TablePool()); |
| 58 | useSyncExternalStore(pool.subscribe, pool.getSnapshot); |
| 59 | const [helpSeat, setHelpSeat] = useState<SeatId | null>(null); |
| 60 | const [settingsOpen, setSettingsOpen] = useState(false); |
| 61 | // The table's join QR, held back into view from any seat's 📱 tag. |
| 62 | const [qrPeek, setQrPeek] = useState(false); |
| 63 | // Read once at startup; after that the live game is the source of truth. |
| 64 | const [saved] = useState(() => loadSave()); |
| 65 | |
| 66 | // ---- the room, if this page is in one ---------------------------------- |
| 67 | const [session, setSession] = useState<NetSession | null>(null); |
| 68 | const [netErr, setNetErr] = useState<string | null>(null); |
| 69 | const [connecting, setConnecting] = useState(false); |
| 70 | useSyncExternalStore(session?.subscribe ?? subNothing, session?.getSnapshot ?? zero); |
| 71 | useSyncExternalStore(session?.table.subscribe ?? subNothing, session?.table.getSnapshot ?? zero); |
| 72 | |
| 73 | const connect = async (): Promise<NetSession | null> => { |
| 74 | setConnecting(true); |
| 75 | console.log('[mahjong] connecting…'); |
| 76 | try { |
| 77 | const s = await joinOnce(); |
| 78 | setSession(s); |
| 79 | setNetErr(null); |
| 80 | return s; |
| 81 | } catch (e) { |
| 82 | console.log('[mahjong] join failed:', e); |
| 83 | setNetErr(e instanceof Error ? e.message : String(e)); |
| 84 | joining = null; |
| 85 | return null; |
| 86 | } finally { |
| 87 | setConnecting(false); |
| 88 | } |
| 89 | }; |
| 90 | |
| 91 | // Opened from a room link — ?room= in the address — so |
| 92 | // everyone on that link shares one table. Join it before anyone taps |
| 93 | // anything: presence is the first thing a shared room owes its visitors. |
| 94 | const auto = useRef(false); |
| 95 | useEffect(() => { |
| 96 | if (auto.current || !roomFromUrl()) return; |
| 97 | auto.current = true; |
| 98 | void connect(); |
| 99 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 100 | }, []); |
| 101 | |
| 102 | // The full-size tables hand phone throws to the pool, and hold the computer |
| 103 | // players while something is still skidding across it. |
| 104 | useEffect(() => { |
| 105 | if (!session) return; |
| 106 | session.onThrowFx = (seat, t) => { |
| 107 | pool.throwFromNet(seat, t); |
| 108 | pool.wake(); |
| 109 | }; |
| 110 | session.setBusy(() => pool.busy); |
| 111 | return () => { |
| 112 | session.onThrowFx = () => {}; |
| 113 | session.setBusy(() => false); |
| 114 | }; |
| 115 | }, [session, pool]); |
| 116 | useEffect(() => session?.table.onSound(play), [session]); |
| 117 | |
| 118 | // Walking back into a running online game unseated — a reload, or a friend |
| 119 | // arriving late — takes the first empty chair, deserted or computer-run. |
| 120 | const reclaimed = useRef(false); |
| 121 | useEffect(() => { |
| 122 | if (!session || session.mode !== 'online' || !session.started) return; |
| 123 | if (reclaimed.current || session.mySeat !== null) return; |
| 124 | if (session.seats.some((s) => s.ids.length === 0)) { |
| 125 | reclaimed.current = true; |
| 126 | session.claimSeat(null); |
| 127 | } |
| 128 | }); |
| 129 | |
| 130 | const netMode = session?.mode ?? null; |
| 131 | const mySeat = session?.mySeat ?? null; |
| 132 | // The party table's common screen. Usually the device that started the game; |
| 133 | // after a reload that flag is gone, so anything full-sized and unseated in a |
| 134 | // party room shows the table rather than a phone's controller — unless it |
| 135 | // arrived through a seat's QR, which is a hand asking to be held whatever |
| 136 | // the screen size. |
| 137 | const isScreen = |
| 138 | session?.role === 'screen' || |
| 139 | (netMode === 'party' && !compact && mySeat === null && seatFromUrl() === null); |
| 140 | const netActive = !!session && (netMode === 'party' ? true : netMode === 'online' && session.started); |
| 141 | |
| 142 | // Which engine this screen draws and drives. |
| 143 | const ctl: TableCtl = netActive ? session!.table : game; |
| 144 | const phase = ctl.state.phase; |
| 145 | // Online the table is turned so your own seat is at the bottom edge. |
| 146 | const viewSeat: SeatId = netMode === 'online' ? mySeat ?? 0 : 0; |
| 147 | const posOf = (seat: SeatId): SeatId => (((seat - viewSeat + 4) % 4) as SeatId); |
| 148 | |
| 149 | // Names and stakes live in the game state as well as in the preferences — |
| 150 | // saved games keep the settings they were played under. |
| 151 | const commitSettings = (p: Prefs) => { |
| 152 | setPrefs(p); |
| 153 | ctl.applySettings(p.names, p.rules); |
| 154 | setSettingsOpen(false); |
| 155 | }; |
| 156 | // The panel auditions sound changes as you make them, so cancelling has to |
| 157 | // put the audio back the way it was. |
| 158 | const cancelSettings = () => { |
| 159 | setSoundEnabled(prefs.sound); |
| 160 | setVoiceEnabled(prefs.voice); |
| 161 | setVolume(prefs.volume); |
| 162 | setSettingsOpen(false); |
| 163 | }; |
| 164 | const settingsPanel = settingsOpen && ( |
| 165 | <Settings |
| 166 | prefs={prefs} |
| 167 | onSave={commitSettings} |
| 168 | onClose={cancelSettings} |
| 169 | stakesLocked={phase !== 'lobby' && phase !== 'gameEnd'} |
| 170 | bots={ctl.state.bots} |
| 171 | /> |
| 172 | ); |
| 173 | |
| 174 | // On <html> rather than passed around, so the panels that render outside the |
| 175 | // table — settings, the rules sheet, the lobby — can style themselves off it. |
| 176 | useEffect(() => { |
| 177 | document.documentElement.classList.toggle('compact', compact); |
| 178 | }, [compact]); |
| 179 | |
| 180 | // Every <Bi> on the table reads the store rather than a prop, so this is the |
| 181 | // one place the saved preference has to reach it. It also sets <html lang>. |
| 182 | useEffect(() => setLang(prefs.lang), [prefs.lang]); |
| 183 | |
| 184 | // Leaving fullscreen by the phone's own gesture is a decision too, and should |
| 185 | // not be undone the next time a hand starts. A reload never reaches here: |
| 186 | // there is no true-to-false to watch on a fresh mount, which is exactly what |
| 187 | // lets the preference survive one. |
| 188 | const wasFullscreen = useRef(false); |
| 189 | useEffect(() => { |
| 190 | // Same object back when it is already off, so the toggle's own click does |
| 191 | // not get written through a second time. |
| 192 | if (wasFullscreen.current && !fullscreen) { |
| 193 | setPrefs((p) => (p.fullscreen ? { ...p, fullscreen: false } : p)); |
| 194 | } |
| 195 | wasFullscreen.current = fullscreen; |
| 196 | }, [fullscreen]); |
| 197 | |
| 198 | // The browser hands the screen back on every reload, and this game is built |
| 199 | // to be put down and picked up again. Starting a hand is a tap, and a tap is |
| 200 | // the only place the request is allowed — so that is where it goes back on. |
| 201 | const startGame = (bots: boolean[]) => { |
| 202 | if (compact && prefs.fullscreen && !fullscreen) enterFullscreen(); |
| 203 | game.newGame(bots, prefs.names); |
| 204 | }; |
| 205 | const resumeGame = () => { |
| 206 | if (compact && prefs.fullscreen && !fullscreen) enterFullscreen(); |
| 207 | game.restore(saved!.state); |
| 208 | }; |
| 209 | |
| 210 | // The two networked ways to start. Whoever creates the room is its host and |
| 211 | // may open a mode; a visitor who follows the link into a room where nothing |
| 212 | // has started yet waits for the host's choice. |
| 213 | const openOnline = async () => { |
| 214 | const s = await connect(); |
| 215 | if (s?.isHost && !s.mode) s.openOnlineLobby(); |
| 216 | }; |
| 217 | const openParty = async () => { |
| 218 | const s = await connect(); |
| 219 | if (s?.isHost && !s.mode) s.startParty(prefs.names, prefs.rules); |
| 220 | }; |
| 221 | |
| 222 | // Preferences are written straight through, so a mute survives a refresh. |
| 223 | useEffect(() => { |
| 224 | savePrefs(prefs); |
| 225 | setSoundEnabled(prefs.sound); |
| 226 | setVoiceEnabled(prefs.voice); |
| 227 | setVolume(prefs.volume); |
| 228 | }, [prefs]); |
| 229 | useEffect(() => game.onSound(play), [game]); |
| 230 | // Watches the table for the whole session and only stirs when a seat the |
| 231 | // computer holds is the one being waited on. Idle in a networked game, where |
| 232 | // whichever device is the room's host runs its own AutoPlay. |
| 233 | // The computer waits for a thrown tile to land — and for whoever it landed on |
| 234 | // to finish saying so — before it reaches for it. |
| 235 | useEffect(() => new AutoPlay(game, () => pool.busy).start(), [game, pool]); |
| 236 | |
| 237 | // ---- routing ------------------------------------------------------------ |
| 238 | // A phone at the party table holds a hand, not the table. |
| 239 | if (session && netMode === 'party' && !isScreen) { |
| 240 | return <Controller session={session} prefs={prefs} onPrefs={setPrefs} />; |
| 241 | } |
| 242 | if (session && netMode === 'online' && !session.started) { |
| 243 | return <OnlineLobby session={session} prefs={prefs} />; |
| 244 | } |
| 245 | |
| 246 | if (!netActive && phase === 'lobby') { |
| 247 | const round = saved ? Math.floor(saved.state.rotations / 4) % 4 : 0; |
| 248 | const savedSolo = saved?.state.bots.some(Boolean); |
| 249 | const netIdle = !session?.mode; |
| 250 | return ( |
| 251 | <div className="lobby"> |
| 252 | {/* The title is already the bilingual pair: the name in Chinese, the |
| 253 | English underneath. Only the single-language modes change it. */} |
| 254 | <h1>{lang === 'en' ? 'Taiwanese Mahjong' : '台灣麻將'}</h1> |
| 255 | <p className="sub"> |
| 256 | {lang === 'zh' |
| 257 | ? '十六張 · 四人' |
| 258 | : lang === 'en' |
| 259 | ? // The name is already the heading above; here it would be twice. |
| 260 | '16 tiles · four players' |
| 261 | : 'Taiwanese Mahjong · 16 tiles · four players'} |
| 262 | </p> |
| 263 | |
| 264 | {saved && ( |
| 265 | <div className="resume"> |
| 266 | <div className="resume-line"> |
| 267 | {savedSolo ? t('單人', 'Solo') : t('同桌', 'Hotseat')} ·{' '} |
| 268 | {t( |
| 269 | `${WIND_NAMES[round]}圈 · 第 ${saved.state.handNumber} 局`, |
| 270 | `${WIND_EN[round]} round, hand ${saved.state.handNumber}`, |
| 271 | ' · ', |
| 272 | )} |
| 273 | </div> |
| 274 | <div className="resume-scores"> |
| 275 | {saved.state.players.map((p, i) => ( |
| 276 | <span key={i}> |
| 277 | {p.name} <b>{p.score}</b> |
| 278 | </span> |
| 279 | ))} |
| 280 | </div> |
| 281 | <div className="resume-when"> |
| 282 | {t('存於', 'saved')} {relativeTime(saved.savedAt, prefs.lang)} |
| 283 | </div> |
| 284 | </div> |
| 285 | )} |
| 286 | |
| 287 | <div className="lobby-buttons"> |
| 288 | {saved && ( |
| 289 | <button className="btn big-btn" onClick={resumeGame}> |
| 290 | <Bi zh="繼續對局" en="Resume" /> |
| 291 | </button> |
| 292 | )} |
| 293 | <button |
| 294 | className={`btn big-btn ${saved ? 'ghost' : ''}`} |
| 295 | onClick={() => startGame(SOLO_SEATS)} |
| 296 | > |
| 297 | <Bi zh="單人對局" en="Single player" /> |
| 298 | <em> |
| 299 | <BiText |
| 300 | zh="你坐東家,其餘三家由電腦代打" |
| 301 | en="you at 東, three computer players" |
| 302 | /> |
| 303 | </em> |
| 304 | </button> |
| 305 | <button className="btn big-btn ghost" onClick={() => startGame(HOTSEAT_SEATS)}> |
| 306 | <Bi zh="四人同桌" en="Hotseat" /> |
| 307 | <em> |
| 308 | <BiText zh="四個人共用一個螢幕" en="four people, one screen" /> |
| 309 | </em> |
| 310 | </button> |
| 311 | {(!session || (session.isHost && netIdle)) && ( |
| 312 | <> |
| 313 | <button className="btn big-btn ghost" onClick={openOnline} disabled={connecting}> |
| 314 | <Bi zh="線上對戰" en="Online game" /> |
| 315 | <em> |
| 316 | <BiText zh="各自用自己的裝置" en="everyone joins from their own device" /> |
| 317 | </em> |
| 318 | </button> |
| 319 | <button className="btn big-btn ghost" onClick={openParty} disabled={connecting}> |
| 320 | <Bi zh="手機派對" en="Party mode" /> |
| 321 | <em> |
| 322 | <BiText |
| 323 | zh="這螢幕當桌,手機掃碼入座" |
| 324 | en="this screen is the table, scan the QR to join" |
| 325 | /> |
| 326 | </em> |
| 327 | </button> |
| 328 | </> |
| 329 | )} |
| 330 | {session && !session.isHost && netIdle && ( |
| 331 | <p className="hint"> |
| 332 | <BiText |
| 333 | zh={`已連線 ${session.room.code} · ${session.playerCount} 人在房,等房主選模式`} |
| 334 | en={`connected ${session.room.code} — waiting for the host to pick a mode`} |
| 335 | /> |
| 336 | </p> |
| 337 | )} |
| 338 | </div> |
| 339 | |
| 340 | {(netErr || session?.err) && <p className="hint">{netErr ?? session?.err}</p>} |
| 341 | {/* Where the connection stands, in so many words. The room chrome is |
| 342 | not always there to say it, and a button that cannot work yet |
| 343 | should say why rather than doing nothing. */} |
| 344 | <p className="hint"> |
| 345 | {connecting |
| 346 | ? t('連線中', 'connecting…') |
| 347 | : session |
| 348 | ? `${t('已連線', 'connected')} · ${session.room.code} · ${session.playerCount} ${t( |
| 349 | '人', |
| 350 | session.playerCount === 1 ? 'player' : 'players', |
| 351 | )} · ${ |
| 352 | session.isHost |
| 353 | ? t('你是房主', 'you host') |
| 354 | : t('房主在別台', 'host elsewhere') |
| 355 | }` |
| 356 | : roomFromUrl() |
| 357 | ? t('未連線', 'not connected') |
| 358 | : t('本機 · 線上模式會自動連線', 'offline · online modes connect on tap', ' · ')} |
| 359 | </p> |
| 360 | |
| 361 | <div className="lobby-extras"> |
| 362 | <button className="btn ghost pill" onClick={() => setSettingsOpen(true)}> |
| 363 | ⚙ {t('設定', 'Settings')} |
| 364 | </button> |
| 365 | {/* A setting, but out here where it can be found without first |
| 366 | reading the button that hides it. */} |
| 367 | <LanguageToggle prefs={prefs} onPrefs={setPrefs} compact={compact} /> |
| 368 | {/* Only where the address bar is actually in the way. A desktop has |
| 369 | F11, and the lobby there does not need another button. */} |
| 370 | {compact && <FullscreenToggle prefs={prefs} onPrefs={setPrefs} />} |
| 371 | </div> |
| 372 | {settingsPanel} |
| 373 | </div> |
| 374 | ); |
| 375 | } |
| 376 | |
| 377 | // What each seat is to this particular screen: whose buttons it shows, whose |
| 378 | // tiles it may turn over, and what stands in for the seats that live on |
| 379 | // other devices. |
| 380 | const seatProps = (seat: SeatId): { local: boolean; remote?: RemoteSeat } => { |
| 381 | if (!netActive) return { local: true }; |
| 382 | const slot = session!.seats[seat]; |
| 383 | const held = slot.ids.length; |
| 384 | if (netMode === 'party') { |
| 385 | return { |
| 386 | // A seat nobody has scanned is played right here, hotseat-style. |
| 387 | local: held === 0, |
| 388 | remote: { |
| 389 | // More phones than one on a hand is a pair playing it together. |
| 390 | holder: |
| 391 | held > 0 |
| 392 | ? `${slot.name || t('手機', 'phone')}${held > 1 ? ` ×${held}` : ''}` |
| 393 | : null, |
| 394 | onPeek: setQrPeek, |
| 395 | }, |
| 396 | }; |
| 397 | } |
| 398 | return { |
| 399 | local: seat === mySeat, |
| 400 | // A deserted human seat: the host's screen offers it to the computer. |
| 401 | remote: |
| 402 | held === 0 && !ctl.state.bots[seat] && session!.isHost |
| 403 | ? { holder: null, onHandToBot: () => session!.handToBot(seat) } |
| 404 | : undefined, |
| 405 | }; |
| 406 | }; |
| 407 | |
| 408 | return ( |
| 409 | <div className="table"> |
| 410 | {SEATS.map((s) => { |
| 411 | const sp = seatProps(s); |
| 412 | return ( |
| 413 | <Seat |
| 414 | key={s} |
| 415 | game={ctl} |
| 416 | seat={s} |
| 417 | pos={posOf(s)} |
| 418 | local={sp.local} |
| 419 | remote={sp.remote} |
| 420 | compact={compact} |
| 421 | pool={compact ? undefined : pool} |
| 422 | onHelp={() => setHelpSeat(posOf(s))} |
| 423 | /> |
| 424 | ); |
| 425 | })} |
| 426 | {/* The tiles in the middle. Not on a phone: the compact layout has no |
| 427 | centre square for them to be thrown into. */} |
| 428 | {!compact && <Pool game={ctl} pool={pool} viewSeat={viewSeat} />} |
| 429 | {/* Party: one QR for the whole table, lying in the middle of the felt |
| 430 | as a giant face-up tile. Scanning it lands a phone on the seat |
| 431 | picker. Once every hand is held it steps aside, and comes back while |
| 432 | any seat's 📱 tag is held down — for a player who lost theirs. */} |
| 433 | {netMode === 'party' && |
| 434 | !compact && |
| 435 | (qrPeek || session!.seats.some((s) => s.ids.length === 0)) && ( |
| 436 | <div className="qr-dock"> |
| 437 | <SeatQr url={roomLink(session!.room.code, session!.room.publicBase)} /> |
| 438 | <span className="qr-hint"> |
| 439 | {lang !== 'en' && '掃碼入座,用手機拿牌'} |
| 440 | {lang !== 'zh' && <em>scan to join from your phone</em>} |
| 441 | </span> |
| 442 | </div> |
| 443 | )} |
| 444 | <Center |
| 445 | game={ctl} |
| 446 | prefs={prefs} |
| 447 | compact={compact} |
| 448 | viewSeat={viewSeat} |
| 449 | preferSeat={netMode === 'online' ? mySeat : null} |
| 450 | onPrefs={setPrefs} |
| 451 | onSettings={() => setSettingsOpen(true)} |
| 452 | /> |
| 453 | {helpSeat !== null && <Help seat={helpSeat} compact={compact} onClose={() => setHelpSeat(null)} />} |
| 454 | {settingsPanel} |
| 455 | </div> |
| 456 | ); |
| 457 | } |
| 458 | |
| 459 | function relativeTime(ts: number, lang: Lang): string { |
| 460 | // The number is the same in both, so it is said once and only the unit is |
| 461 | // picked — otherwise bilingual would read '5 分鐘前 · 5 min ago'. |
| 462 | const t = (zh: string, en: string) => pick(lang, zh, en, ' · '); |
| 463 | const mins = Math.round((Date.now() - ts) / 60000); |
| 464 | if (mins < 1) return t('剛剛', 'just now'); |
| 465 | if (mins < 60) return `${mins} ${t('分鐘前', 'min ago')}`; |
| 466 | const hours = Math.round(mins / 60); |
| 467 | if (hours < 24) return `${hours} ${t('小時前', 'h ago')}`; |
| 468 | const days = Math.round(hours / 24); |
| 469 | return `${days} ${t('天前', 'd ago')}`; |
| 470 | } |