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