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 GameState, 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
291 /**
292 * 四人同桌 — the table itself. Four people at this screen, each hand face
293 * down under a tap, and any of them free to take their hand onto a phone
294 * from the QR in their own gear. Hotseat and 手機派對 used to be two buttons
295 * on this lobby and they were never two games: one was the other with the
296 * phones left in your pocket.
297 *
298 * So the tiles are dealt here and now — a screen with no server behind it is
299 * still four people at a table, and nobody should watch a spinner to start
300 * playing — and the room is opened behind it. When it lands it inherits the
301 * hand in progress rather than dealing over the top of it, and the only
302 * thing that changes on screen is that the QRs start working. If it never
303 * lands, nothing does.
304 */
305 const openTable = async (from?: GameState) => {
306 if (compact && prefs.fullscreen && !fullscreen) enterFullscreen();
307 if (from) game.restore(from);
308 else game.newGame(HOTSEAT_SEATS, prefs.names);
309 const s = await connect();
310 if (s?.isHost && !s.mode) s.startParty(prefs.names, prefs.rules, game.state);
311 };
312 const resumeGame = () => {
313 // A saved game the computer was playing stays a solo game; a saved table
314 // opens its room again, so the phones that were on it can come back.
315 if (saved!.state.bots.some(Boolean)) {
316 if (compact && prefs.fullscreen && !fullscreen) enterFullscreen();
317 game.restore(saved!.state);
318 } else {
319 void openTable(saved!.state);
320 }
321 };
322
323 // Online is still its own thing: everybody on their own device, nobody
324 // sharing a screen, and a seat lobby before anything is dealt. Whoever
325 // creates the room is its host and may open a mode; a visitor who follows
326 // the link into a room where nothing has started yet waits for the choice.
327 const openOnline = async () => {
328 const s = await connect();
329 if (s?.isHost && !s.mode) s.openOnlineLobby();
330 };
331
332 // Preferences are written straight through, so a mute survives a refresh.
333 useEffect(() => {
334 savePrefs(prefs);
335 setSoundEnabled(prefs.sound);
336 setVoiceEnabled(prefs.voice);
337 setVolume(prefs.volume);
338 setSpatial(prefs.spatial);
339 }, [prefs]);
340
341 // A browser holds the audio hardware shut until the page has been touched,
342 // and a phone that arrived by QR may never touch anything that asks for it
343 // by name. So the first touch anywhere opens it — which on a phone is also
344 // what lets it tell the table it can do the talking.
345 useEffect(() => {
346 const wake = () => unlock();
347 window.addEventListener('pointerdown', wake, true);
348 window.addEventListener('keydown', wake, true);
349 return () => {
350 window.removeEventListener('pointerdown', wake, true);
351 window.removeEventListener('keydown', wake, true);
352 };
353 }, []);
354
355 // Tell the room whether this device is going to say anything, and say so
356 // again whenever that changes — a mute, a 報牌 toggle, the tap that wakes a
357 // phone's audio, the tab going to sleep. A party table only stops calling a
358 // seat's tiles once the phone on that seat has claimed the job.
359 useEffect(() => {
360 if (!session) return;
361 const tell = () => session.reportVoice(voiceLive());
362 tell();
363 return onAudioChange(tell);
364 }, [session, prefs.sound, prefs.voice]);
365 useEffect(() => game.onSound((cue) => play(cue, castFor(cue, earshot.current))), [game]);
366 // Watches the table for the whole session and only stirs when a seat the
367 // computer holds is the one being waited on. Idle in a networked game, where
368 // whichever device is the room's host runs its own AutoPlay.
369 // The computer waits for a thrown tile to land — and for whoever it landed on
370 // to finish saying so — before it reaches for it.
371 // `showingColliders` holds the computer still as well: the throwing sandbox
372 // is no use if the table plays on around it.
373 useEffect(
374 () => new AutoPlay(game, () => pool.busy || showingColliders()).start(),
375 [game, pool],
376 );
377
378 // ---- routing ------------------------------------------------------------
379 // A phone at the party table holds a hand, not the table.
380 if (session && netMode === 'party' && !isScreen) {
381 return <Controller session={session} prefs={prefs} onPrefs={setPrefs} />;
382 }
383 if (session && netMode === 'online' && !session.started) {
384 return <OnlineLobby session={session} prefs={prefs} />;
385 }
386
387 if (!netActive && phase === 'lobby') {
388 const round = saved ? Math.floor(saved.state.rotations / 4) % 4 : 0;
389 const savedSolo = saved?.state.bots.some(Boolean);
390 const netIdle = !session?.mode;
391 return (
392 <div className="lobby">
393 {/* The title is already the bilingual pair: the name in Chinese, the
394 English underneath. Only the single-language modes change it. */}
395 <h1>{lang === 'en' ? 'Taiwanese Mahjong' : '台灣麻將'}</h1>
396 <p className="sub">
397 {lang === 'zh'
398 ? '十六張 · 四人'
399 : lang === 'en'
400 ? // The name is already the heading above; here it would be twice.
401 '16 tiles · four players'
402 : 'Taiwanese Mahjong · 16 tiles · four players'}
403 </p>
404
405 {saved && (
406 <div className="resume">
407 <div className="resume-line">
408 {savedSolo ? t('單人', 'Solo') : t('同桌', 'Table')} ·{' '}
409 {t(
410 `${WIND_NAMES[round]}圈 · 第 ${saved.state.handNumber} 局`,
411 `${WIND_EN[round]} round, hand ${saved.state.handNumber}`,
412 ' · ',
413 )}
414 </div>
415 <div className="resume-scores">
416 {saved.state.players.map((p, i) => (
417 <span key={i}>
418 {p.name} <b>{p.score}</b>
419 </span>
420 ))}
421 </div>
422 <div className="resume-when">
423 {t('存於', 'saved')} {relativeTime(saved.savedAt, prefs.lang)}
424 </div>
425 </div>
426 )}
427
428 <div className="lobby-buttons">
429 {saved && (
430 <button className="btn big-btn" onClick={resumeGame}>
431 <Bi zh="繼續對局" en="Resume" />
432 </button>
433 )}
434 <button
435 className={`btn big-btn ${saved ? 'ghost' : ''}`}
436 onClick={() => startGame(SOLO_SEATS)}
437 >
438 <Bi zh="單人對局" en="Single player" />
439 <em>
440 <BiText
441 zh="你坐東家,其餘三家由電腦代打"
442 en="you at 東, three computer players"
443 />
444 </em>
445 </button>
446 {/* One table, phones optional — see openTable. Not behind the room
447 check the online button is behind: this one deals first and asks
448 the server afterwards. */}
449 <button className="btn big-btn ghost" onClick={() => void openTable()}>
450 <Bi zh="四人同桌" en="Four at one table" />
451 <em>
452 <BiText
453 zh="四個人共用一個螢幕,牌先蓋著,也可以掃碼用手機拿牌"
454 en="four people, one screen — hands stay face down until you tap, and any of them can move to a phone"
455 />
456 </em>
457 </button>
458 {(!session || (session.isHost && netIdle)) && (
459 <button className="btn big-btn ghost" onClick={openOnline} disabled={connecting}>
460 <Bi zh="線上對戰" en="Online game" />
461 <em>
462 <BiText zh="各自用自己的裝置" en="everyone joins from their own device" />
463 </em>
464 </button>
465 )}
466 {session && !session.isHost && netIdle && (
467 <p className="hint">
468 <BiText
469 zh={`已連線 ${session.room.code} · ${session.playerCount} 人在房,等房主選模式`}
470 en={`connected ${session.room.code} — waiting for the host to pick a mode`}
471 />
472 </p>
473 )}
474 </div>
475
476 {(netErr || session?.err) && <p className="hint">{netErr ?? session?.err}</p>}
477 {/* Where the connection stands, in so many words. The room chrome is
478 not always there to say it, and a button that cannot work yet
479 should say why rather than doing nothing. */}
480 <p className="hint">
481 {connecting
482 ? t('連線中', 'connecting…')
483 : session
484 ? `${t('已連線', 'connected')} · ${session.room.code} · ${session.playerCount} ${t(
485 '人',
486 session.playerCount === 1 ? 'player' : 'players',
487 )} · ${
488 session.isHost
489 ? t('你是房主', 'you host')
490 : t('房主在別台', 'host elsewhere')
491 }`
492 : roomFromUrl()
493 ? t('未連線', 'not connected')
494 : t('本機 · 線上模式會自動連線', 'offline · online modes connect on tap', ' · ')}
495 </p>
496
497 <div className="lobby-extras">
498 <button className="btn ghost pill" onClick={() => setSettingsOpen(true)}>
499 ⚙ {t('設定', 'Settings')}
500 </button>
501 {/* A setting, but out here where it can be found without first
502 reading the button that hides it. */}
503 <LanguageToggle prefs={prefs} onPrefs={setPrefs} compact={compact} />
504 {/* Only where the address bar is actually in the way. A desktop has
505 F11, and the lobby there does not need another button. */}
506 {compact && <FullscreenToggle prefs={prefs} onPrefs={setPrefs} />}
507 </div>
508 {settingsPanel}
509 </div>
510 );
511 }
512
513 // What each seat is to this particular screen: whose buttons it shows, whose
514 // tiles it may turn over, and what stands in for the seats that live on
515 // other devices.
516 const seatProps = (seat: SeatId): { local: boolean; remote?: RemoteSeat } => {
517 if (!netActive) return { local: true };
518 const slot = session!.seats[seat];
519 const held = slot.ids.length;
520 if (netMode === 'party') {
521 return {
522 // A seat nobody has scanned is played right here, hotseat-style.
523 local: held === 0,
524 remote: {
525 // More phones than one on a hand is a pair playing it together.
526 holder:
527 held > 0
528 ? `${slot.name || t('手機', 'phone')}${held > 1 ? ` ×${held}` : ''}`
529 : null,
530 onPeek: setQrPeek,
531 // The table has gone quiet for this seat on purpose: the phone on it
532 // is doing the calling.
533 speaks: session!.voiceSeats[seat] === true,
534 },
535 };
536 }
537 return {
538 local: seat === mySeat,
539 // A deserted human seat: the host's screen offers it to the computer.
540 remote:
541 held === 0 && !ctl.state.bots[seat] && session!.isHost
542 ? { holder: null, onHandToBot: () => session!.handToBot(seat) }
543 : undefined,
544 };
545 };
546
547 // Whether this screen is one several people are sitting round, in which case
548 // every hand played at it starts face down: your neighbour can see your
549 // tiles, and the answer used to be a piece of card laid over your strip.
550 //
551 // A table with a room open is always one of those, however few hands are
552 // still being played on it — the people whose hands went to their phones did
553 // not leave the room, they are sitting right there looking at the screen.
554 // Off where the screen belongs to one player: solo, and online, where
555 // everybody has a device of their own.
556 const sharing = netActive
557 ? netMode === 'party'
558 : SEATS.filter((s) => seatProps(s).local && !ctl.state.bots[s]).length > 1;
559 // Where a hand goes when its player would rather hold it themselves. Only
560 // where there is a room to go to.
561 const seatUrl = (seat: SeatId) =>
562 session && netMode === 'party'
563 ? roomLink(session.room.code, session.room.publicBase, seat)
564 : undefined;
565
566 return (
567 <div className="table">
568 {SEATS.map((s) => {
569 const sp = seatProps(s);
570 return (
571 <Seat
572 key={s}
573 game={ctl}
574 seat={s}
575 pos={posOf(s)}
576 local={sp.local}
577 shared={sharing}
578 remote={sp.remote}
579 compact={compact}
580 pool={compact ? undefined : pool}
581 onHelp={() => setHelpSeat(posOf(s))}
582 onMenu={compact ? undefined : () => setMenuSeat(s)}
583 />
584 );
585 })}
586 {/* The tiles in the middle. Not on a phone: the compact layout has no
587 centre square for them to be thrown into. */}
588 {!compact && <Pool game={ctl} pool={pool} viewSeat={viewSeat} />}
589 {/* One QR for the whole table, lying in the middle of the felt as a
590 giant face-up tile. Scanning it lands a phone on the seat picker.
591 It is up while the table is still being sat down at — nothing thrown
592 yet, and a hand still to be claimed — and steps aside the moment play
593 starts, because from then on the middle is where the tiles go and a
594 phone is asked for from the seat's own gear instead. It comes back
595 while any seat's 📱 tag is held down, for a player who lost theirs. */}
596 {netMode === 'party' &&
597 !compact &&
598 (qrPeek ||
599 (session!.seats.some((s) => s.ids.length === 0) &&
600 ctl.state.players.every((p) => p.discards.length === 0))) && (
601 <div className="qr-dock">
602 <SeatQr url={roomLink(session!.room.code, session!.room.publicBase)} />
603 <span className="qr-hint">
604 {lang !== 'en' && '掃碼入座,用手機拿牌'}
605 {lang !== 'zh' && <em>scan to join from your phone</em>}
606 </span>
607 </div>
608 )}
609 <Center
610 game={ctl}
611 prefs={prefs}
612 compact={compact}
613 viewSeat={viewSeat}
614 preferSeat={netMode === 'online' ? mySeat : null}
615 onPrefs={setPrefs}
616 onSettings={() => setSettingsOpen(true)}
617 />
618 {helpSeat !== null && <Help seat={helpSeat} compact={compact} onClose={() => setHelpSeat(null)} />}
619 {menuSeat !== null && (
620 <TableMenu
621 game={ctl}
622 prefs={prefs}
623 pos={posOf(menuSeat)}
624 seatUrl={seatUrl(menuSeat)}
625 compact={compact}
626 onPrefs={setPrefs}
627 onSettings={() => setSettingsOpen(true)}
628 onClose={() => setMenuSeat(null)}
629 />
630 )}
631 {settingsPanel}
632 </div>
633 );
634}
635
636function relativeTime(ts: number, lang: Lang): string {
637 // The number is the same in both, so it is said once and only the unit is
638 // picked — otherwise bilingual would read '5 分鐘前 · 5 min ago'.
639 const t = (zh: string, en: string) => pick(lang, zh, en, ' · ');
640 const mins = Math.round((Date.now() - ts) / 60000);
641 if (mins < 1) return t('剛剛', 'just now');
642 if (mins < 60) return `${mins} ${t('分鐘前', 'min ago')}`;
643 const hours = Math.round(mins / 60);
644 if (hours < 24) return `${hours} ${t('小時前', 'h ago')}`;
645 const days = Math.round(hours / 24);
646 return `${days} ${t('天前', 'd ago')}`;
647}