anvilsign in

collin/mahjong

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