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 useEffect(() => new AutoPlay(game).start(), [game]);
113
114 if (phase === 'lobby') {
115 const round = saved ? Math.floor(saved.state.rotations / 4) % 4 : 0;
116 const savedSolo = saved?.state.bots.some(Boolean);
117 return (
118 <div className="lobby">
119 <h1>台灣麻將</h1>
120 <p className="sub">Taiwanese Mahjong · 16 tiles · four players, one screen</p>
121 <p className="hint">
122 把擋板放在螢幕四邊,各自遮住自己的牌。
123 <br />
124 Stand a card over your edge of the screen to hide your hand.
125 </p>
126
127 {saved && (
128 <div className="resume">
129 <div className="resume-line">
130 {savedSolo ? '單人 Solo' : '同桌 Hotseat'} · {WIND_NAMES[round]}圈 · 第{' '}
131 {saved.state.handNumber} 局 · {WIND_EN[round]} round, hand {saved.state.handNumber}
132 </div>
133 <div className="resume-scores">
134 {saved.state.players.map((p, i) => (
135 <span key={i}>
136 {p.name} <b>{p.score}</b>
137 </span>
138 ))}
139 </div>
140 <div className="resume-when">存於 saved {relativeTime(saved.savedAt)}</div>
141 </div>
142 )}
143
144 <div className="lobby-buttons">
145 {saved && (
146 <button className="btn big-btn" onClick={resumeGame}>
147 繼續對局 Resume
148 </button>
149 )}
150 <button
151 className={`btn big-btn ${saved ? 'ghost' : ''}`}
152 onClick={() => startGame(SOLO_SEATS)}
153 >
154 單人對局 Single player
155 <em>你坐東家,其餘三家由電腦代打 · you at 東, three computer players</em>
156 </button>
157 <button className="btn big-btn ghost" onClick={() => startGame(HOTSEAT_SEATS)}>
158 四人同桌 Hotseat
159 <em>四個人共用一個螢幕 · four people, one screen</em>
160 </button>
161 </div>
162
163 <div className="lobby-extras">
164 <button className="btn ghost pill" onClick={() => setSettingsOpen(true)}>
165 ⚙ 設定 Settings
166 </button>
167 {/* Only where the address bar is actually in the way. A desktop has
168 F11, and the lobby there does not need another button. */}
169 {compact && <FullscreenToggle prefs={prefs} onPrefs={setPrefs} />}
170 </div>
171 {settingsPanel}
172 </div>
173 );
174 }
175
176 return (
177 <div className="table">
178 {SEATS.map((s) => (
179 <Seat
180 key={s}
181 game={game}
182 seat={s}
183 compact={compact}
184 pool={compact ? undefined : pool}
185 onHelp={() => setHelpSeat(s)}
186 />
187 ))}
188 {/* The tiles in the middle. Not on a phone: the compact layout has no
189 centre square for them to be thrown into. */}
190 {!compact && <Pool game={game} pool={pool} />}
191 <Center
192 game={game}
193 prefs={prefs}
194 compact={compact}
195 onPrefs={setPrefs}
196 onSettings={() => setSettingsOpen(true)}
197 />
198 {helpSeat !== null && <Help seat={helpSeat} compact={compact} onClose={() => setHelpSeat(null)} />}
199 {settingsPanel}
200 </div>
201 );
202}
203
204function relativeTime(ts: number): string {
205 const mins = Math.round((Date.now() - ts) / 60000);
206 if (mins < 1) return '剛剛 just now';
207 if (mins < 60) return `${mins} 分鐘前 · ${mins} min ago`;
208 const hours = Math.round(mins / 60);
209 if (hours < 24) return `${hours} 小時前 · ${hours} h ago`;
210 const days = Math.round(hours / 24);
211 return `${days} 天前 · ${days} d ago`;
212}