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