anvilsign in

collin/mahjong

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