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