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 } 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, SeatPick } 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 { TablePool } from './table/pool';
34import { Pool } from './ui/Pool';
35import { Seat, type SeatWho } from './ui/Seat';
36import { TableMenu } from './ui/TableMenu';
37import { SeatQr } from './ui/SeatQr';
38import { Settings } from './ui/Settings';
39import './styles.css';
40
41const SEATS: SeatId[] = [0, 1, 2, 3];
42/**
43 * What 開始 deals: you at the bottom edge, the computer on the other three.
44 *
45 * It is a starting point rather than a mode. Anybody sitting down at this
46 * screen taps a computer's strip and takes that hand; anybody with a phone
47 * scans into it; and either of those can be given back to the computer again,
48 * mid-hand, without the table stopping. See `seatWho` below.
49 */
50const OPENING_SEATS = [false, true, true, true];
51
52// One join per page, whoever asks first — the invite, or the ?room= a shared
53// link carries. A promise rather than a flag, so a second caller waits on the
54// same join instead of starting another.
55let joining: Promise<NetSession> | null = null;
56const joinOnce = (engine: Game) => (joining ??= NetSession.join(engine));
57
58const subNothing = () => () => {};
59const zero = () => 0;
60
61/**
62 * Where a cue is heard, and by which device — everything the sound needs to
63 * know that only this screen can tell it. Handed to `sound.play` per cue; see
64 * `castFor` for the reasoning and `game/sound.ts` for what it does with it.
65 */
66interface Earshot {
67 /** Seat number → which edge of *this* screen it is sitting at. */
68 posOf: (seat: SeatId) => SeatId;
69 mySeat: SeatId | null;
70 /** Whether this device is the one the table is being played at. */
71 isTable: boolean;
72 /** Whether this device is a hand held beside a table that is making the
73 * noise for it — a phone in a room. */
74 handheld: boolean;
75 /** Seats whose own phone is saying their calls for them. */
76 voices: boolean[];
77}
78
79/**
80 * Which parts of a cue this device plays, and where from.
81 *
82 * Anything showing the whole table plays all of it, panned to the edge the
83 * seat is sitting at, so 碰 from 上家 comes from your left. That covers a
84 * screen on its own, and a screen with a room around it, and somebody playing
85 * from their own laptop three cities away.
86 *
87 * A phone in a room is the exception, and the reason is the room: the table is
88 * right there making the noise, so the phone makes none of it. It says its own
89 * seat's calls, because the words are that player's, and it chimes for a claim
90 * window somebody else opened, which is a nudge meant for the person holding
91 * it and nobody else. The table hears the phone claim the job — see `voices`
92 * in `net/protocol.ts` — and goes quiet for that seat, so the 碰 comes out of
93 * the hand of whoever called it.
94 */
95function castFor(cue: SoundCue, room: Earshot): Cast {
96 const seat = cue.seat;
97 if (room.handheld) {
98 // A phone still on the seat picker holds nothing and speaks for nobody.
99 if (seat === undefined || room.mySeat === null) return { voice: false, noise: false };
100 const mine = seat === room.mySeat;
101 // No panning: a phone is not a table, it is one player, and everything it
102 // says comes from where it is — in their hand.
103 return { pos: null, voice: mine, noise: mine ? false : cue.kind === 'claimWindow' };
104 }
105 const pos = seat === undefined ? null : (room.posOf(seat) as Pos);
106 const spoken = room.isTable && seat !== undefined && room.voices[seat] === true;
107 return { pos, voice: !spoken, noise: true };
108}
109
110export default function App() {
111 const [prefs, setPrefs] = useState<Prefs>(loadPrefs);
112 const t = useT();
113 const lang = useLang();
114 // Phone-sized: everything reads upright for the one person holding it.
115 const compact = useCompact();
116 const fullscreen = useFullscreen();
117 // Built once, from the preferences as they stood at startup; after that the
118 // settings screen edits the live game rather than replacing it.
119 const [game] = useState(() => new Game(prefs.names, prefs.rules));
120 useSyncExternalStore(game.subscribe, game.getSnapshot);
121 exposeGame(game);
122 // The tiles in the middle. Owned here rather than inside <Pool> because the
123 // seats need it too: a hand has to know whether it can be thrown from, and
124 // which seats those are changes as the wall is eaten away.
125 const [pool] = useState(() => new TablePool());
126 useSyncExternalStore(pool.subscribe, pool.getSnapshot);
127 const [helpSeat, setHelpSeat] = useState<SeatId | null>(null);
128 // Whose gear is open — the same per-edge treatment as the rules panel, so
129 // what a player opens faces them and nobody else. See ui/TableMenu.
130 const [menuSeat, setMenuSeat] = useState<SeatId | null>(null);
131 const [settingsOpen, setSettingsOpen] = useState(false);
132 // The table's join QR, held back into view from any seat's 📱 tag.
133 const [qrPeek, setQrPeek] = useState(false);
134 // Read once at startup; after that the live game is the source of truth.
135 const [saved] = useState(() => loadSave());
136
137 // ---- the room, if this page is in one ----------------------------------
138 const [session, setSession] = useState<NetSession | null>(null);
139 const [netErr, setNetErr] = useState<string | null>(null);
140 const [connecting, setConnecting] = useState(false);
141 useSyncExternalStore(session?.subscribe ?? subNothing, session?.getSnapshot ?? zero);
142 useSyncExternalStore(session?.table.subscribe ?? subNothing, session?.table.getSnapshot ?? zero);
143
144 /**
145 * Get this page into a room. `open` says this device is the one bringing the
146 * table: it publishes what it is already playing, and from then on the QRs
147 * work and a phone can take a hand off it.
148 *
149 * The publish happens before the session is handed to React on purpose — a
150 * render in between would catch a room that has joined but has no table in
151 * it yet, and flash the seat picker at the very device that owns the table.
152 */
153 const connect = async (open = false): Promise<NetSession | null> => {
154 setConnecting(true);
155 console.log('[mahjong] connecting…');
156 try {
157 const s = await joinOnce(game);
158 if (open) s.publishTable(prefs.names);
159 setSession(s);
160 setNetErr(null);
161 return s;
162 } catch (e) {
163 console.log('[mahjong] join failed:', e);
164 setNetErr(e instanceof Error ? e.message : String(e));
165 joining = null;
166 return null;
167 } finally {
168 setConnecting(false);
169 }
170 };
171
172 // Opened from a room link — ?room= in the address — so
173 // everyone on that link shares one table. Join it before anyone taps
174 // anything: presence is the first thing a shared room owes its visitors.
175 const auto = useRef(false);
176 useEffect(() => {
177 if (auto.current || !roomFromUrl()) return;
178 auto.current = true;
179 void connect();
180 // eslint-disable-next-line react-hooks/exhaustive-deps
181 }, []);
182
183 // The full-size tables hand phone throws to the pool, so a tile flicked off
184 // a phone comes flying in from that player's edge.
185 useEffect(() => {
186 if (!session) return;
187 session.onThrowFx = (seat, t) => {
188 pool.throwFromNet(seat, t);
189 pool.wake();
190 };
191 return () => {
192 session.onThrowFx = () => {};
193 };
194 }, [session, pool]);
195 // Where every cue is heard from, kept in a ref rather than in the closures
196 // below: the two subscriptions outlive any particular arrangement of the
197 // table, and re-subscribing on every rotation would drop cues mid-hand.
198 const earshot = useRef<Earshot>({
199 posOf: (s) => s,
200 mySeat: null,
201 isTable: true,
202 handheld: false,
203 voices: [false, false, false, false],
204 });
205
206 const mySeat = session?.mySeat ?? null;
207 /**
208 * Whether this device is the one the table is being played at.
209 *
210 * Without a room it always is — there is only the one screen. With one it is
211 * whichever device opened it, which is where every hand no phone has taken
212 * goes on being played. Everything else — a phone that scanned in, a friend
213 * on their own laptop — is holding a hand, not running a table.
214 */
215 const isTable = !session || session.isScreen;
216 /** A hand held on a phone, with the table it belongs to somewhere else. */
217 const handheld = !!session && compact && !isTable;
218
219 /**
220 * The table this page is playing at. It is the same engine either way: a
221 * room is a wrapper that goes on around a game in progress, not a second
222 * game, which is what lets four people join one at a time without the tiles
223 * being dealt again.
224 */
225 const ctl: TableCtl = session ? session.table : game;
226 const phase = ctl.state.phase;
227 /** Whether this device is the one actually running the game. */
228 const authority = !session || session.isHost;
229
230 // ---- who is playing which hand -----------------------------------------
231 const holdersOf = (seat: SeatId): string[] => session?.seats[seat].ids ?? [];
232 /** Whether this device answers for a seat: a hand it holds, or — at the
233 * table — a hand nobody has taken away and the computer is not playing. */
234 const playsHere = (seat: SeatId): boolean => {
235 const held = holdersOf(seat);
236 if (held.length > 0) return held.includes(session!.room.me.id);
237 return isTable && !ctl.state.bots[seat];
238 };
239 const mine = SEATS.filter(playsHere);
240
241 /**
242 * Turned so your own seat is the near edge — but only for a device holding
243 * one hand and no table. Four people round a screen are sitting where they
244 * are sitting, and turning the felt under them would move three of them.
245 */
246 const viewSeat: SeatId = !isTable && mine.length === 1 ? mine[0] : 0;
247 const posOf = (seat: SeatId): SeatId => (((seat - viewSeat + 4) % 4) as SeatId);
248 // Everything the sound needs to know about where this screen is sitting, and
249 // who else in the room is going to be talking. See castFor, above.
250 earshot.current = {
251 posOf,
252 mySeat,
253 isTable,
254 handheld,
255 voices: session?.voiceSeats ?? [false, false, false, false],
256 };
257
258 // The one engine, so exactly one device runs the computer players, exactly
259 // one writes the save, and undo belongs to the table rather than to a hand.
260 useEffect(() => {
261 game.persist = authority;
262 }, [game, authority]);
263 useEffect(() => {
264 if (session) session.table.allowUndo = isTable;
265 }, [session, isTable]);
266
267 // Names and stakes live in the game state as well as in the preferences —
268 // saved games keep the settings they were played under.
269 const commitSettings = (p: Prefs) => {
270 setPrefs(p);
271 ctl.applySettings(p.names, p.rules);
272 setSettingsOpen(false);
273 };
274 // The panel auditions sound changes as you make them, so cancelling has to
275 // put the audio back the way it was.
276 const cancelSettings = () => {
277 setSoundEnabled(prefs.sound);
278 setVoiceEnabled(prefs.voice);
279 setVolume(prefs.volume);
280 setSpatial(prefs.spatial);
281 setSettingsOpen(false);
282 };
283 const settingsPanel = settingsOpen && (
284 <Settings
285 prefs={prefs}
286 onSave={commitSettings}
287 onClose={cancelSettings}
288 stakesLocked={phase !== 'lobby' && phase !== 'gameEnd'}
289 bots={ctl.state.bots}
290 />
291 );
292
293 // On <html> rather than passed around, so the panels that render outside the
294 // table — settings, the rules sheet, the lobby — can style themselves off it.
295 useEffect(() => {
296 document.documentElement.classList.toggle('compact', compact);
297 }, [compact]);
298
299 // Every <Bi> on the table reads the store rather than a prop, so this is the
300 // one place the saved preference has to reach it. It also sets <html lang>.
301 useEffect(() => setLang(prefs.lang), [prefs.lang]);
302
303 // Leaving fullscreen by the phone's own gesture is a decision too, and should
304 // not be undone the next time a hand starts. A reload never reaches here:
305 // there is no true-to-false to watch on a fresh mount, which is exactly what
306 // lets the preference survive one.
307 const wasFullscreen = useRef(false);
308 useEffect(() => {
309 // Same object back when it is already off, so the toggle's own click does
310 // not get written through a second time.
311 if (wasFullscreen.current && !fullscreen) {
312 setPrefs((p) => (p.fullscreen ? { ...p, fullscreen: false } : p));
313 }
314 wasFullscreen.current = fullscreen;
315 }, [fullscreen]);
316
317 // The browser hands the screen back on every reload, and this game is built
318 // to be put down and picked up again. Starting a hand is a tap, and a tap is
319 // the only place the request is allowed — so that is where it goes back on.
320 const goFullscreen = () => {
321 if (compact && prefs.fullscreen && !fullscreen) enterFullscreen();
322 };
323
324 /**
325 * 開始. One button, because there is one game.
326 *
327 * The tiles are dealt here and now, on this device, with nothing on the wire
328 * — you at the bottom edge and the computer on the other three. That is not
329 * a mode, it is a table with three chairs still empty, and it is the shape
330 * every other arrangement grows out of: somebody sits down at this screen
331 * and taps a computer's strip, somebody else scans a seat's QR onto their
332 * phone, a friend three cities away opens the invite link. Each of those
333 * swaps one hand, mid-game, and nothing is dealt again.
334 *
335 * Nothing here waits on a server, and a page that never reaches one still
336 * plays a full game.
337 */
338 const startGame = () => {
339 goFullscreen();
340 game.newGame(OPENING_SEATS, prefs.names);
341 // Started inside a room somebody had already opened and left empty: the
342 // table it was waiting for is this one.
343 session?.publishTable(prefs.names);
344 };
345 const resumeGame = () => {
346 goFullscreen();
347 game.restore(saved!.state);
348 session?.publishTable(prefs.names);
349 };
350
351 /**
352 * Open the room around this table, so a phone can be pointed at it. The
353 * first thing that needs one asks for it — the invite QR, or a seat being
354 * sent to a phone — and until then this page has said nothing to any server.
355 */
356 const openRoom = async (): Promise<NetSession | null> => (session ? session : connect(true));
357 /** Take a hand onto a phone: open the room if it is not open, then put that
358 * seat's own QR up in its own gear, facing its own edge. */
359 const toPhone = async (seat: SeatId) => {
360 setMenuSeat(seat);
361 await openRoom();
362 };
363 /** A hand changing hands between a person at this screen and the computer.
364 * Networked or not, it is the same one call on the same one engine. */
365 const setSeatBot = (seat: SeatId, bot: boolean) => {
366 if (session) session.setBot(seat, bot);
367 else game.setBot(seat, bot);
368 };
369
370 // Preferences are written straight through, so a mute survives a refresh.
371 useEffect(() => {
372 savePrefs(prefs);
373 setSoundEnabled(prefs.sound);
374 setVoiceEnabled(prefs.voice);
375 setVolume(prefs.volume);
376 setSpatial(prefs.spatial);
377 }, [prefs]);
378
379 // A browser holds the audio hardware shut until the page has been touched,
380 // and a phone that arrived by QR may never touch anything that asks for it
381 // by name. So the first touch anywhere opens it — which on a phone is also
382 // what lets it tell the table it can do the talking.
383 useEffect(() => {
384 const wake = () => unlock();
385 window.addEventListener('pointerdown', wake, true);
386 window.addEventListener('keydown', wake, true);
387 return () => {
388 window.removeEventListener('pointerdown', wake, true);
389 window.removeEventListener('keydown', wake, true);
390 };
391 }, []);
392
393 // Tell the room whether this device is going to say anything, and say so
394 // again whenever that changes — a mute, a 報牌 toggle, the tap that wakes a
395 // phone's audio, the tab going to sleep.
396 //
397 // Only a phone claims the job. A phone is small enough to be in somebody's
398 // hand and a hand is in the room, so the table can safely go quiet for that
399 // seat and let the 碰 come out of it. A friend playing from their own laptop
400 // three cities away is not in the room and never takes the words away from
401 // anybody's table.
402 useEffect(() => {
403 if (!session) return;
404 const tell = () => session.reportVoice(voiceLive() && compact);
405 tell();
406 return onAudioChange(tell);
407 }, [session, compact, prefs.sound, prefs.voice]);
408 // One subscription, on whichever table this page is drawing. Off the engine
409 // when there is no room; off the room's own feed when there is, which on the
410 // host is the engine again and everywhere else is what the host sent.
411 useEffect(() => ctl.onSound((cue) => play(cue, castFor(cue, earshot.current))), [ctl]);
412 // Watches the table and only stirs when a seat the computer holds is the one
413 // being waited on. Exactly one device runs it: the one running the game, so
414 // three phones watching a table do not each play its computers' hands.
415 //
416 // The computer waits for a thrown tile to land — and for whoever it landed on
417 // to finish saying so — before it reaches for it. `showingColliders` holds it
418 // still as well: the throwing sandbox is no use if the table plays on around
419 // it.
420 useEffect(() => {
421 if (!authority) return;
422 return new AutoPlay(game, () => pool.busy || showingColliders()).start();
423 }, [authority, game, pool]);
424
425 // ---- routing ------------------------------------------------------------
426 // A device in somebody else's room holds a hand, not a table: a phone shows
427 // the hand and nothing else, and anything bigger shows the whole table
428 // turned round to that seat's edge. Either way it picks a chair first.
429 if (session && !isTable && session.open) {
430 if (compact) return <Controller session={session} prefs={prefs} onPrefs={setPrefs} />;
431 if (mine.length === 0) {
432 return <SeatPick session={session} prefs={prefs} onPrefs={setPrefs} />;
433 }
434 }
435
436 if (phase === 'lobby') {
437 const round = saved ? Math.floor(saved.state.rotations / 4) % 4 : 0;
438 return (
439 <div className="lobby">
440 {/* The title is already the bilingual pair: the name in Chinese, the
441 English underneath. Only the single-language modes change it. */}
442 <h1>{lang === 'en' ? 'Taiwanese Mahjong' : '台灣麻將'}</h1>
443 <p className="sub">
444 {lang === 'zh'
445 ? '十六張 · 四人'
446 : lang === 'en'
447 ? // The name is already the heading above; here it would be twice.
448 '16 tiles · four players'
449 : 'Taiwanese Mahjong · 16 tiles · four players'}
450 </p>
451
452 {saved && (
453 <div className="resume">
454 <div className="resume-line">
455 {t(
456 `${WIND_NAMES[round]}圈 · 第 ${saved.state.handNumber} 局`,
457 `${WIND_EN[round]} round, hand ${saved.state.handNumber}`,
458 ' · ',
459 )}
460 </div>
461 <div className="resume-scores">
462 {saved.state.players.map((p, i) => (
463 <span key={i}>
464 {p.name} <b>{p.score}</b>
465 </span>
466 ))}
467 </div>
468 <div className="resume-when">
469 {t('存於', 'saved')} {relativeTime(saved.savedAt, prefs.lang)}
470 </div>
471 </div>
472 )}
473
474 <div className="lobby-buttons">
475 {saved && authority && (
476 <button className="btn big-btn" onClick={resumeGame}>
477 <Bi zh="繼續對局" en="Resume" />
478 </button>
479 )}
480 {/* One button, because there is one game. It deals you in against
481 three computers and asks nothing of any server; everybody else
482 arrives afterwards, one hand at a time, without it being dealt
483 again. See startGame.
484
485 Hidden only in the one place it cannot work: standing in
486 somebody else's room, where the table is theirs to deal. */}
487 {authority && (
488 <button className={`btn big-btn ${saved ? 'ghost' : ''}`} onClick={startGame}>
489 <Bi zh="開始" en="Start" />
490 <em>
491 <BiText
492 zh="先發牌,其餘三家由電腦代打;有人要打,點他那一家坐下,或掃碼用手機拿牌"
493 en="dealt straight away, computers on the other three — tap a seat to sit down at this screen, or scan into it from a phone"
494 />
495 </em>
496 </button>
497 )}
498 {session && !session.isHost && !session.open && (
499 <p className="hint">
500 <BiText
501 zh={`已連線 ${session.room.code} · ${session.playerCount} 人在房,等房主開局`}
502 en={`connected ${session.room.code} — waiting for the host to deal`}
503 />
504 </p>
505 )}
506 </div>
507
508 {(netErr || session?.err) && <p className="hint">{netErr ?? session?.err}</p>}
509 {/* Where the connection stands, in so many words — but only when there
510 is something to say. A game that has not asked for a room yet is
511 not offline, it simply has not needed one. */}
512 {(connecting || session || roomFromUrl()) && (
513 <p className="hint">
514 {connecting
515 ? t('連線中', 'connecting…')
516 : session
517 ? `${t('已連線', 'connected')} · ${session.room.code} · ${session.playerCount} ${t(
518 '人',
519 session.playerCount === 1 ? 'player' : 'players',
520 )}`
521 : t('未連線', 'not connected')}
522 </p>
523 )}
524
525 <div className="lobby-extras">
526 <button className="btn ghost pill" onClick={() => setSettingsOpen(true)}>
527 ⚙ {t('設定', 'Settings')}
528 </button>
529 {/* A setting, but out here where it can be found without first
530 reading the button that hides it. */}
531 <LanguageToggle prefs={prefs} onPrefs={setPrefs} compact={compact} />
532 {/* Only where the address bar is actually in the way. A desktop has
533 F11, and the lobby there does not need another button. */}
534 {compact && <FullscreenToggle prefs={prefs} onPrefs={setPrefs} />}
535 </div>
536 {settingsPanel}
537 </div>
538 );
539 }
540
541 /**
542 * What each seat is to this particular screen: whose buttons it shows, whose
543 * tiles it may turn over, and — because a hand can change hands mid-game —
544 * what it offers to do about it.
545 *
546 * The three answers are the same three everywhere: the computer is playing
547 * it, somebody at this screen is, or it has gone to a device of its own. Any
548 * of them turns into any other without the tiles being dealt again, which is
549 * the whole of what used to be three modes on a lobby.
550 */
551 const seatWho = (seat: SeatId): SeatWho | undefined => {
552 // A hand on somebody's own device is theirs; the table's controls for the
553 // other three are the table's business, not a visitor's.
554 if (!isTable) return undefined;
555 const slot = session?.seats[seat];
556 const held = slot?.ids.length ?? 0;
557 const bot = ctl.state.bots[seat];
558 return {
559 // More devices than one on a hand is a pair playing it together.
560 holder:
561 held > 0 ? `${slot!.name || t('手機', 'phone')}${held > 1 ? ` ×${held}` : ''}` : null,
562 // Its device went. The hand is being kept for a moment in case that was
563 // a pocket rather than a goodbye — see `vacate` in net/session.ts.
564 waiting: session?.isWaiting(seat) === true,
565 onPeek: session ? setQrPeek : undefined,
566 // The table has gone quiet for this seat on purpose: the phone on it is
567 // doing the calling.
568 speaks: session?.voiceSeats[seat] === true,
569 // Somebody has sat down in front of this edge and wants the hand the
570 // computer has been playing. One tap, mid-hand, tiles and all.
571 onSit: held === 0 && bot ? () => setSeatBot(seat, false) : undefined,
572 // And back again — nobody is going to play it, or nobody wants to.
573 onBot: held === 0 && !bot ? () => setSeatBot(seat, true) : undefined,
574 onPhone: held === 0 ? () => void toPhone(seat) : undefined,
575 };
576 };
577
578 /**
579 * Whether this screen is one several people are sitting round, in which case
580 * every hand played at it starts face down: your neighbour can see your
581 * tiles, and the answer used to be a piece of card laid over your strip.
582 *
583 * It is simply how many hands this device is playing. One is one person —
584 * playing the computer, or holding their own hand on their own device — and
585 * covering your own tiles from yourself is a tap in the way. Two or more is
586 * a screen with more than one person's hand on it, whatever else is true
587 * about the room.
588 */
589 const sharing = mine.length > 1;
590 // Where a hand goes when its player would rather hold it themselves. Only
591 // once there is a room for it to go to; before that the gear offers to open
592 // one instead.
593 const seatUrl = (seat: SeatId) =>
594 session ? roomLink(session.room.code, session.room.publicBase, seat) : undefined;
595
596 return (
597 <div className="table">
598 {/* The wire has gone quiet. Said out loud on the table too, not only on
599 the lobby it is easy to have left behind: a hand that has stopped
600 answering should say why rather than looking like a game that has
601 stopped. The room reconnects on its own, and this clears when state
602 starts flowing again. */}
603 {session?.err && <div className="net-warn">{session.err}</div>}
604 {SEATS.map((s) => {
605 return (
606 <Seat
607 key={s}
608 game={ctl}
609 seat={s}
610 pos={posOf(s)}
611 local={playsHere(s)}
612 shared={sharing}
613 who={seatWho(s)}
614 compact={compact}
615 pool={compact ? undefined : pool}
616 onHelp={() => setHelpSeat(posOf(s))}
617 onMenu={compact ? undefined : () => setMenuSeat(s)}
618 />
619 );
620 })}
621 {/* The tiles in the middle. Not on a phone: the compact layout has no
622 centre square for them to be thrown into. */}
623 {!compact && <Pool game={ctl} pool={pool} viewSeat={viewSeat} />}
624 {/* One QR for the whole table, lying in the middle of the felt as a
625 giant face-up tile. Scanning it lands a phone on the seat picker.
626 It is up while the table is still being sat down at — nothing thrown
627 yet, and a hand still to be claimed — and steps aside the moment play
628 starts, because from then on the middle is where the tiles go and a
629 phone is asked for from the seat's own gear instead. It comes back
630 while any seat's 📱 tag is held down, for a player who lost theirs. */}
631 {session &&
632 isTable &&
633 !compact &&
634 (qrPeek ||
635 (session.seats.some((s) => s.ids.length === 0) &&
636 ctl.state.players.every((p) => p.discards.length === 0))) && (
637 <div className="qr-dock">
638 <SeatQr url={roomLink(session!.room.code, session!.room.publicBase)} />
639 <span className="qr-hint">
640 {lang !== 'en' && '掃碼入座,用手機拿牌'}
641 {lang !== 'zh' && <em>scan to join from your phone</em>}
642 </span>
643 </div>
644 )}
645 <Center
646 game={ctl}
647 prefs={prefs}
648 compact={compact}
649 viewSeat={viewSeat}
650 preferSeat={isTable ? null : mySeat}
651 onPrefs={setPrefs}
652 onSettings={() => setSettingsOpen(true)}
653 />
654 {helpSeat !== null && <Help seat={helpSeat} compact={compact} onClose={() => setHelpSeat(null)} />}
655 {menuSeat !== null && (
656 <TableMenu
657 game={ctl}
658 prefs={prefs}
659 pos={posOf(menuSeat)}
660 seatUrl={seatUrl(menuSeat)}
661 who={seatWho(menuSeat)}
662 compact={compact}
663 onPrefs={setPrefs}
664 onSettings={() => setSettingsOpen(true)}
665 onClose={() => setMenuSeat(null)}
666 />
667 )}
668 {settingsPanel}
669 </div>
670 );
671}
672
673function relativeTime(ts: number, lang: Lang): string {
674 // The number is the same in both, so it is said once and only the unit is
675 // picked — otherwise bilingual would read '5 分鐘前 · 5 min ago'.
676 const t = (zh: string, en: string) => pick(lang, zh, en, ' · ');
677 const mins = Math.round((Date.now() - ts) / 60000);
678 if (mins < 1) return t('剛剛', 'just now');
679 if (mins < 60) return `${mins} ${t('分鐘前', 'min ago')}`;
680 const hours = Math.round(mins / 60);
681 if (hours < 24) return `${hours} ${t('小時前', 'h ago')}`;
682 const days = Math.round(hours / 24);
683 return `${days} ${t('天前', 'd ago')}`;
684}