| 1 | import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; |
| 2 | import { AutoPlay } from './game/autoplay'; |
| 3 | import { Game } from './game/engine'; |
| 4 | import { loadPrefs, savePrefs, type Prefs } from './game/prefs'; |
| 5 | import { loadSave } from './game/save'; |
| 6 | import { play, setSoundEnabled, setVoiceEnabled, setVolume } from './game/sound'; |
| 7 | import { WIND_EN, WIND_NAMES } from './game/tiles'; |
| 8 | import type { SeatId } from './game/types'; |
| 9 | import { Center } from './ui/Center'; |
| 10 | import { useCompact } from './ui/compact'; |
| 11 | import { FullscreenToggle } from './ui/FullscreenToggle'; |
| 12 | import { enterFullscreen, useFullscreen } from './ui/fullscreen'; |
| 13 | import { Help } from './ui/Help'; |
| 14 | import { Seat } from './ui/Seat'; |
| 15 | import { Settings } from './ui/Settings'; |
| 16 | import './styles.css'; |
| 17 | |
| 18 | const SEATS: SeatId[] = [0, 1, 2, 3]; |
| 19 | /** The human always takes the bottom seat; the other three are the computer's. */ |
| 20 | const SOLO_SEATS = [false, true, true, true]; |
| 21 | const HOTSEAT_SEATS = [false, false, false, false]; |
| 22 | |
| 23 | export default function App() { |
| 24 | const [prefs, setPrefs] = useState<Prefs>(loadPrefs); |
| 25 | // Phone-sized: everything reads upright for the one person holding it. |
| 26 | const compact = useCompact(); |
| 27 | const fullscreen = useFullscreen(); |
| 28 | // Built once, from the preferences as they stood at startup; after that the |
| 29 | // settings screen edits the live game rather than replacing it. |
| 30 | const [game] = useState(() => new Game(prefs.names, prefs.rules)); |
| 31 | useSyncExternalStore(game.subscribe, game.getSnapshot); |
| 32 | const [helpSeat, setHelpSeat] = useState<SeatId | null>(null); |
| 33 | const [settingsOpen, setSettingsOpen] = useState(false); |
| 34 | // Read once at startup; after that the live game is the source of truth. |
| 35 | const [saved] = useState(() => loadSave()); |
| 36 | const phase = game.state.phase; |
| 37 | |
| 38 | // Names and stakes live in the game state as well as in the preferences — |
| 39 | // saved games keep the settings they were played under. |
| 40 | const commitSettings = (p: Prefs) => { |
| 41 | setPrefs(p); |
| 42 | game.applySettings(p.names, p.rules); |
| 43 | setSettingsOpen(false); |
| 44 | }; |
| 45 | // The panel auditions sound changes as you make them, so cancelling has to |
| 46 | // put the audio back the way it was. |
| 47 | const cancelSettings = () => { |
| 48 | setSoundEnabled(prefs.sound); |
| 49 | setVoiceEnabled(prefs.voice); |
| 50 | setVolume(prefs.volume); |
| 51 | setSettingsOpen(false); |
| 52 | }; |
| 53 | const settingsPanel = settingsOpen && ( |
| 54 | <Settings |
| 55 | prefs={prefs} |
| 56 | onSave={commitSettings} |
| 57 | onClose={cancelSettings} |
| 58 | stakesLocked={phase !== 'lobby' && phase !== 'gameEnd'} |
| 59 | bots={game.state.bots} |
| 60 | /> |
| 61 | ); |
| 62 | |
| 63 | // On <html> rather than passed around, so the panels that render outside the |
| 64 | // table — settings, the rules sheet, the lobby — can style themselves off it. |
| 65 | useEffect(() => { |
| 66 | document.documentElement.classList.toggle('compact', compact); |
| 67 | }, [compact]); |
| 68 | |
| 69 | // Leaving fullscreen by the phone's own gesture is a decision too, and should |
| 70 | // not be undone the next time a hand starts. A reload never reaches here: |
| 71 | // there is no true-to-false to watch on a fresh mount, which is exactly what |
| 72 | // lets the preference survive one. |
| 73 | const wasFullscreen = useRef(false); |
| 74 | useEffect(() => { |
| 75 | // Same object back when it is already off, so the toggle's own click does |
| 76 | // not get written through a second time. |
| 77 | if (wasFullscreen.current && !fullscreen) { |
| 78 | setPrefs((p) => (p.fullscreen ? { ...p, fullscreen: false } : p)); |
| 79 | } |
| 80 | wasFullscreen.current = fullscreen; |
| 81 | }, [fullscreen]); |
| 82 | |
| 83 | // The browser hands the screen back on every reload, and this game is built |
| 84 | // to be put down and picked up again. Starting a hand is a tap, and a tap is |
| 85 | // the only place the request is allowed — so that is where it goes back on. |
| 86 | const startGame = (bots: boolean[]) => { |
| 87 | if (compact && prefs.fullscreen && !fullscreen) enterFullscreen(); |
| 88 | game.newGame(bots, prefs.names); |
| 89 | }; |
| 90 | const resumeGame = () => { |
| 91 | if (compact && prefs.fullscreen && !fullscreen) enterFullscreen(); |
| 92 | game.restore(saved!.state); |
| 93 | }; |
| 94 | |
| 95 | // Preferences are written straight through, so a mute survives a refresh. |
| 96 | useEffect(() => { |
| 97 | savePrefs(prefs); |
| 98 | setSoundEnabled(prefs.sound); |
| 99 | setVoiceEnabled(prefs.voice); |
| 100 | setVolume(prefs.volume); |
| 101 | }, [prefs]); |
| 102 | useEffect(() => game.onSound(play), [game]); |
| 103 | // Watches the table for the whole session and only stirs when a seat the |
| 104 | // computer holds is the one being waited on. |
| 105 | useEffect(() => new AutoPlay(game).start(), [game]); |
| 106 | |
| 107 | if (phase === 'lobby') { |
| 108 | const round = saved ? Math.floor(saved.state.rotations / 4) % 4 : 0; |
| 109 | const savedSolo = saved?.state.bots.some(Boolean); |
| 110 | return ( |
| 111 | <div className="lobby"> |
| 112 | <h1>台灣麻將</h1> |
| 113 | <p className="sub">Taiwanese Mahjong · 16 tiles · four players, one screen</p> |
| 114 | <p className="hint"> |
| 115 | 把擋板放在螢幕四邊,各自遮住自己的牌。 |
| 116 | <br /> |
| 117 | Stand a card over your edge of the screen to hide your hand. |
| 118 | </p> |
| 119 | |
| 120 | {saved && ( |
| 121 | <div className="resume"> |
| 122 | <div className="resume-line"> |
| 123 | {savedSolo ? '單人 Solo' : '同桌 Hotseat'} · {WIND_NAMES[round]}圈 · 第{' '} |
| 124 | {saved.state.handNumber} 局 · {WIND_EN[round]} round, hand {saved.state.handNumber} |
| 125 | </div> |
| 126 | <div className="resume-scores"> |
| 127 | {saved.state.players.map((p, i) => ( |
| 128 | <span key={i}> |
| 129 | {p.name} <b>{p.score}</b> |
| 130 | </span> |
| 131 | ))} |
| 132 | </div> |
| 133 | <div className="resume-when">存於 saved {relativeTime(saved.savedAt)}</div> |
| 134 | </div> |
| 135 | )} |
| 136 | |
| 137 | <div className="lobby-buttons"> |
| 138 | {saved && ( |
| 139 | <button className="btn big-btn" onClick={resumeGame}> |
| 140 | 繼續對局 Resume |
| 141 | </button> |
| 142 | )} |
| 143 | <button |
| 144 | className={`btn big-btn ${saved ? 'ghost' : ''}`} |
| 145 | onClick={() => startGame(SOLO_SEATS)} |
| 146 | > |
| 147 | 單人對局 Single player |
| 148 | <em>你坐東家,其餘三家由電腦代打 · you at 東, three computer players</em> |
| 149 | </button> |
| 150 | <button className="btn big-btn ghost" onClick={() => startGame(HOTSEAT_SEATS)}> |
| 151 | 四人同桌 Hotseat |
| 152 | <em>四個人共用一個螢幕 · four people, one screen</em> |
| 153 | </button> |
| 154 | </div> |
| 155 | |
| 156 | <div className="lobby-extras"> |
| 157 | <button className="btn ghost pill" onClick={() => setSettingsOpen(true)}> |
| 158 | ⚙ 設定 Settings |
| 159 | </button> |
| 160 | {/* Only where the address bar is actually in the way. A desktop has |
| 161 | F11, and the lobby there does not need another button. */} |
| 162 | {compact && <FullscreenToggle prefs={prefs} onPrefs={setPrefs} />} |
| 163 | </div> |
| 164 | {settingsPanel} |
| 165 | </div> |
| 166 | ); |
| 167 | } |
| 168 | |
| 169 | return ( |
| 170 | <div className="table"> |
| 171 | {SEATS.map((s) => ( |
| 172 | <Seat key={s} game={game} seat={s} compact={compact} onHelp={() => setHelpSeat(s)} /> |
| 173 | ))} |
| 174 | <Center |
| 175 | game={game} |
| 176 | prefs={prefs} |
| 177 | compact={compact} |
| 178 | onPrefs={setPrefs} |
| 179 | onSettings={() => setSettingsOpen(true)} |
| 180 | /> |
| 181 | {helpSeat !== null && <Help seat={helpSeat} compact={compact} onClose={() => setHelpSeat(null)} />} |
| 182 | {settingsPanel} |
| 183 | </div> |
| 184 | ); |
| 185 | } |
| 186 | |
| 187 | function relativeTime(ts: number): string { |
| 188 | const mins = Math.round((Date.now() - ts) / 60000); |
| 189 | if (mins < 1) return '剛剛 just now'; |
| 190 | if (mins < 60) return `${mins} 分鐘前 · ${mins} min ago`; |
| 191 | const hours = Math.round(mins / 60); |
| 192 | if (hours < 24) return `${hours} 小時前 · ${hours} h ago`; |
| 193 | const days = Math.round(hours / 24); |
| 194 | return `${days} 天前 · ${days} d ago`; |
| 195 | } |