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