anvilsign in

collin/mahjong

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