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