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