collin/mahjong · e58f5c3c
Ship it on antics: raw-URL QRs, a host that hosts while hidden, shared seats
Collin Richards · 2026-08-20 09:35 UTC · e58f5c3caf6449fc4778099b00597655f9844670 · parent 82287db9 · browse files
modifiedREADME.md+82 −1
| 1 | - | # 台灣麻將 — four-player hotseat on one touchscreen | |
| 1 | + | # 台灣麻將 — four-player mahjong, on one touchscreen or over the wire | |
| 2 | 2 | ||
| 3 | 3 | Sixteen-tile Taiwanese mahjong for four people sitting around a single laptop. | |
| 4 | 4 | All four hands are on screen at once, each rotated to face its own edge — put a | |
| ⋯ 5 unchanged lines | |||
| 10 | 10 | bottom seat and hands the other three to the computer — same rules, same | |
| 11 | 11 | table, same scoring. See [computer players](#computer-players). | |
| 12 | 12 | ||
| 13 | + | Friends elsewhere? **線上對戰 Online game** puts everyone at the same table | |
| 14 | + | from their own devices, and **手機派對 Party mode** keeps the laptop as the | |
| 15 | + | table while phones scan a QR to hold their own tiles — see | |
| 16 | + | [playing online](#playing-online). | |
| 17 | + | ||
| 13 | 18 | ``` | |
| 14 | 19 | npm install | |
| 15 | 20 | npm run dev # then open the browser full-screen (F11) | |
| ⋯ 120 unchanged lines | |||
| 136 | 141 | Stakes are locked once a game is under way — they'd otherwise rewrite chips | |
| 137 | 142 | already won. | |
| 138 | 143 | ||
| 144 | + | ## Playing online | |
| 145 | + | ||
| 146 | + | Two networked modes, both built on [antics](https://antics.gg) — a room | |
| 147 | + | service for web games with no backend of your own: one client (the antics | |
| 148 | + | *host*) runs the engine, publishes the whole `GameState` as room state after | |
| 149 | + | every move, and plays intents the other devices send as events. The engine was | |
| 150 | + | already a pure JSON state machine, so nothing in `src/game/` knows the network | |
| 151 | + | exists — the same trick `autoplay.ts` plays, stretched over a wire. All of it | |
| 152 | + | lives in `src/net/`. | |
| 153 | + | ||
| 154 | + | - **線上對戰 Online game** — everyone on their own device. Whoever opens the | |
| 155 | + | room picks 線上對戰 and gets a seat lobby; friends who open the same link | |
| 156 | + | land in it, sit down, and the host deals. Empty seats go to the computer, | |
| 157 | + | which runs on the host client — that is what stands in for a server on the | |
| 158 | + | classic (free) tier. Each screen draws the same full table, turned so your | |
| 159 | + | own seat is the bottom edge; other people's tiles are face down. Joining | |
| 160 | + | mid-game takes over a vacant or computer seat, so a dropped friend can | |
| 161 | + | reload and carry on. | |
| 162 | + | - **手機派對 Party mode** — the laptop stays the table, exactly like hotseat, | |
| 163 | + | and each seat's corner of the felt wears a QR. Scanning one loads a | |
| 164 | + | controller on the phone: just that hand, face up, with the claim buttons — | |
| 165 | + | and the throw. Flick a tile up off the top of the phone and it sails in from | |
| 166 | + | your edge of the common screen, at the speed and angle you let go of it | |
| 167 | + | (`NetThrow`, mapped into table coordinates by `TablePool.throwFromNet`). The | |
| 168 | + | seat's tiles go face down on the shared screen and its QR hides; hold the 📱 | |
| 169 | + | tag on the nameplate to show it again for a player who lost theirs. A phone | |
| 170 | + | that leaves hands the seat back to the laptop. Seats never lock: a second | |
| 171 | + | phone scanning the same QR joins the same hand — two people playing one | |
| 172 | + | hand, whoever acts first acts. | |
| 173 | + | ||
| 174 | + | Design notes, in the order they bit: | |
| 175 | + | ||
| 176 | + | - **Seats are claims on a shared map**, `seats: {ids, name}[]` in room state, | |
| 177 | + | arbitrated by the host. Your own hand's arrangement never goes over the wire | |
| 178 | + | — the state carries a multiset and each device keeps its own order | |
| 179 | + | (`src/net/handOrder.ts`), because a round-trip inside a drag gesture is lag | |
| 180 | + | you can feel. | |
| 181 | + | - **The host can change.** antics re-elects when the host leaves; every other | |
| 182 | + | client already mirrors the full state, so the new host starts its engine | |
| 183 | + | from what it was just watching and the game carries on. | |
| 184 | + | - **A hidden host must keep hosting.** Browsers suspend requestAnimationFrame | |
| 185 | + | in background tabs, and the antics SDK flushes state writes off rAF — so a | |
| 186 | + | party screen somebody tabbed away from would silently stop answering. | |
| 187 | + | `src/net/clock.ts` feeds the SDK's `__anticsClock__` injection point from a | |
| 188 | + | Web Worker, whose timers are not throttled. | |
| 189 | + | - **Fairness is social, not cryptographic.** The classic tier shares the whole | |
| 190 | + | state with every client, wall and hands included — a friend with devtools | |
| 191 | + | open can cheat. The antics sim tier would fix that, and costs money. | |
| 192 | + | ||
| 193 | + | ### Hosting on antics | |
| 194 | + | ||
| 195 | + | ``` | |
| 196 | + | npm run build && node scripts/antics-deploy.mjs # keyless — a fresh link each deploy | |
| 197 | + | node scripts/antics-deploy.mjs --project <id> # stable /p/ link (needs `npx antics-cli login`) | |
| 198 | + | ``` | |
| 199 | + | ||
| 200 | + | The deploy script packs the 57 voice clips into one `voice/pack.json` (an | |
| 201 | + | antics bundle holds at most 50 files; `sound.ts` prefers the pack when it is | |
| 202 | + | there). Keyless deploys are pinned to their content hash — share a new link | |
| 203 | + | after every deploy — and keyless rooms hold 8 players and expire after 24 | |
| 204 | + | hours. Deploying under a project gives a share link that follows updates. | |
| 205 | + | ||
| 206 | + | The QR links point at the game's own URL (`/raw/<hash>/?room=…&seat=…`), not | |
| 207 | + | the antics room page: the room page forwards only the parameters it knows | |
| 208 | + | about to the game, and `seat` is not one of them — and a phone is better off | |
| 209 | + | without the chrome anyway. | |
| 210 | + | ||
| 139 | 211 | ## On a phone | |
| 140 | 212 | ||
| 141 | 213 | The table assumes four people sitting around a screen lying flat, and about | |
| ⋯ 217 unchanged lines | |||
| 359 | 431 | src/game/autoplay.ts when it does it, and how long it appears to think | |
| 360 | 432 | src/game/tai.ts 台 scoring | |
| 361 | 433 | src/game/engine.ts state machine: deal, turns, claim resolution, settlement | |
| 434 | + | src/game/ctl.ts TableCtl — the surface the UI drives, local or networked | |
| 362 | 435 | src/game/wall.ts what is left of the square, and what still blocks a throw | |
| 436 | + | src/net/session.ts the antics room: host duties, seats, intents, migration | |
| 437 | + | src/net/table.ts TableCtl over the wire: mirror + intents + hand overlay | |
| 438 | + | src/net/protocol.ts what crosses the wire, in one place | |
| 439 | + | src/net/handOrder.ts your own arrangement, kept on your own device | |
| 440 | + | src/net/clock.ts worker-driven SDK clock, so a hidden host keeps hosting | |
| 441 | + | src/ui/Controller.tsx the phone at the party table: your tiles and the throw | |
| 442 | + | src/ui/OnlineLobby.tsx seats and the deal button, before an online game | |
| 443 | + | scripts/antics-deploy.mjs dist/ → antics.gg, voice pack and all | |
| 363 | 444 | src/table/physics.ts the discard pool: rectangles with weight, pure and testable | |
| 364 | 445 | src/table/geometry.ts the table measured — colliders, launch points, throw gate | |
| 365 | 446 | src/table/pool.ts game state ⇄ tiles in the middle, by diffing the discards | |
| ⋯ 53 unchanged lines | |||
modifiedTODO.md+14 −0
| ⋯ 20 unchanged lines | |||
| 21 | 21 | Flowers score, but some tables pay 花槓 out the moment it completes rather than | |
| 22 | 22 | at settlement, and some deal 補花 differently at the start. Both are `Rules` | |
| 23 | 23 | flags plus a payment hook. | |
| 24 | + | ## Online (new) | |
| 25 | + | ||
| 26 | + | - [ ] a stable share link: deploy under an antics project (`npx antics-cli | |
| 27 | + | login`, then `node scripts/antics-deploy.mjs --project <id>`) — keyless | |
| 28 | + | links pin one build and keyless rooms die after 24h | |
| 29 | + | - [ ] undo online: only the party screen has it, and only while it is the | |
| 30 | + | antics host; online games have none at all | |
| 31 | + | - [ ] full-table online clients throw with the local gesture but others see | |
| 32 | + | the default seat-launch animation — could send the NetThrow for those too | |
| 33 | + | - [ ] cheat-resistance: room state carries the wall and every hand; the antics | |
| 34 | + | sim tier (paid) would keep secrets server-side | |
| 35 | + | - [ ] voice/sfx settings on the phone controller (it inherits defaults, has no | |
| 36 | + | toggle of its own) | |
| 37 | + | ||
| 24 | 38 | - [ ] make the scar bgger | |
| 25 | 39 | - [ ] fgure out the br nao | |
| 26 | 40 | - [ ] computer shouldn't be able to take the pung before the throw animation completes | |
| ⋯ 4 unchanged lines | |||
modifiedscripts/antics-deploy.mjs+9 −0
| ⋯ 34 unchanged lines | |||
| 35 | 35 | ||
| 36 | 36 | async function collect() { | |
| 37 | 37 | const files = {}; | |
| 38 | + | const voice = {}; | |
| 38 | 39 | let bytes = 0; | |
| 39 | 40 | for await (const p of walk(DIST)) { | |
| 40 | 41 | const rel = relative(DIST, p); | |
| 41 | 42 | const ext = extname(rel); | |
| 42 | 43 | const buf = await readFile(p); | |
| 43 | 44 | bytes += buf.length; | |
| 45 | + | // A bundle may hold at most 50 files and the voice pack alone is 57, so | |
| 46 | + | // the clips ship as one JSON of base64 — sound.ts prefers it when present. | |
| 47 | + | if (rel.startsWith('voice/') && ext === '.mp3') { | |
| 48 | + | voice[rel.slice('voice/'.length, -'.mp3'.length)] = buf.toString('base64'); | |
| 49 | + | continue; | |
| 50 | + | } | |
| 51 | + | if (rel === 'voice/manifest.json') continue; | |
| 44 | 52 | files[rel] = TEXT.has(ext) | |
| 45 | 53 | ? buf.toString('utf8') | |
| 46 | 54 | : `data:${MIME[ext] ?? 'application/octet-stream'};base64,${buf.toString('base64')}`; | |
| 47 | 55 | } | |
| 56 | + | if (Object.keys(voice).length) files['voice/pack.json'] = JSON.stringify(voice); | |
| 48 | 57 | return { files, bytes }; | |
| 49 | 58 | } | |
| 50 | 59 | ||
| ⋯ 42 unchanged lines | |||
modifiedsrc/App.tsx+53 −8
| ⋯ 6 unchanged lines | |||
| 7 | 7 | import { play, setSoundEnabled, setVoiceEnabled, setVolume } from './game/sound'; | |
| 8 | 8 | import { WIND_EN, WIND_NAMES } from './game/tiles'; | |
| 9 | 9 | import type { SeatId } from './game/types'; | |
| 10 | - | import { roomFromUrl, seatLink } from './net/protocol'; | |
| 10 | + | import { roomFromUrl, seatFromUrl, seatLink } from './net/protocol'; | |
| 11 | 11 | import { NetSession } from './net/session'; | |
| 12 | 12 | import { Center } from './ui/Center'; | |
| 13 | 13 | import { useCompact } from './ui/compact'; | |
| ⋯ 5 unchanged lines | |||
| 19 | 19 | import { TablePool } from './table/pool'; | |
| 20 | 20 | import { Pool } from './ui/Pool'; | |
| 21 | 21 | import { Seat, type RemoteSeat } from './ui/Seat'; | |
| 22 | + | import { SeatQr } from './ui/SeatQr'; | |
| 22 | 23 | import { Settings } from './ui/Settings'; | |
| 23 | 24 | import './styles.css'; | |
| 24 | 25 | ||
| ⋯ 27 unchanged lines | |||
| 52 | 53 | useSyncExternalStore(pool.subscribe, pool.getSnapshot); | |
| 53 | 54 | const [helpSeat, setHelpSeat] = useState<SeatId | null>(null); | |
| 54 | 55 | const [settingsOpen, setSettingsOpen] = useState(false); | |
| 56 | + | // A claimed seat's QR, held back into view from its 📱 tag. | |
| 57 | + | const [qrPeek, setQrPeek] = useState<SeatId | null>(null); | |
| 55 | 58 | // Read once at startup; after that the live game is the source of truth. | |
| 56 | 59 | const [saved] = useState(() => loadSave()); | |
| 57 | 60 | ||
| ⋯ 6 unchanged lines | |||
| 64 | 67 | ||
| 65 | 68 | const connect = async (): Promise<NetSession | null> => { | |
| 66 | 69 | setConnecting(true); | |
| 70 | + | console.log('[mahjong] connecting…'); | |
| 67 | 71 | try { | |
| 68 | 72 | const s = await joinOnce(); | |
| 69 | 73 | setSession(s); | |
| 70 | 74 | setNetErr(null); | |
| 71 | 75 | return s; | |
| 72 | 76 | } catch (e) { | |
| 77 | + | console.log('[mahjong] join failed:', e); | |
| 73 | 78 | setNetErr(e instanceof Error ? e.message : String(e)); | |
| 74 | 79 | joining = null; | |
| 75 | 80 | return null; | |
| ⋯ 35 unchanged lines | |||
| 111 | 116 | useEffect(() => { | |
| 112 | 117 | if (!session || session.mode !== 'online' || !session.started) return; | |
| 113 | 118 | if (reclaimed.current || session.mySeat !== null) return; | |
| 114 | - | if (session.seats.some((s) => s.id === null)) { | |
| 119 | + | if (session.seats.some((s) => s.ids.length === 0)) { | |
| 115 | 120 | reclaimed.current = true; | |
| 116 | 121 | session.claimSeat(null); | |
| 117 | 122 | } | |
| ⋯ 3 unchanged lines | |||
| 121 | 126 | const mySeat = session?.mySeat ?? null; | |
| 122 | 127 | // The party table's common screen. Usually the device that started the game; | |
| 123 | 128 | // after a reload that flag is gone, so anything full-sized and unseated in a | |
| 124 | - | // party room shows the table rather than a phone's controller. | |
| 125 | - | const isScreen = session?.role === 'screen' || (netMode === 'party' && !compact && mySeat === null); | |
| 129 | + | // party room shows the table rather than a phone's controller — unless it | |
| 130 | + | // arrived through a seat's QR, which is a hand asking to be held whatever | |
| 131 | + | // the screen size. | |
| 132 | + | const isScreen = | |
| 133 | + | session?.role === 'screen' || | |
| 134 | + | (netMode === 'party' && !compact && mySeat === null && seatFromUrl() === null); | |
| 126 | 135 | const netActive = !!session && (netMode === 'party' ? true : netMode === 'online' && session.started); | |
| 127 | 136 | ||
| 128 | 137 | // Which engine this screen draws and drives. | |
| ⋯ 161 unchanged lines | |||
| 290 | 299 | </div> | |
| 291 | 300 | ||
| 292 | 301 | {(netErr || session?.err) && <p className="hint">{netErr ?? session?.err}</p>} | |
| 302 | + | {/* Where the connection stands, in so many words. The room chrome is | |
| 303 | + | not always there to say it, and a button that cannot work yet | |
| 304 | + | should say why rather than doing nothing. */} | |
| 305 | + | <p className="hint"> | |
| 306 | + | {connecting | |
| 307 | + | ? '連線中 connecting…' | |
| 308 | + | : session | |
| 309 | + | ? `已連線 connected · ${session.room.code} · ${session.playerCount} 人 · ${ | |
| 310 | + | session.isHost ? '你是房主 you host' : '房主在別台 host elsewhere' | |
| 311 | + | }` | |
| 312 | + | : roomFromUrl() | |
| 313 | + | ? '未連線 not connected' | |
| 314 | + | : '本機 offline · 線上模式會自動連線 online modes connect on tap'} | |
| 315 | + | </p> | |
| 293 | 316 | ||
| 294 | 317 | <div className="lobby-extras"> | |
| 295 | 318 | <button className="btn ghost pill" onClick={() => setSettingsOpen(true)}> | |
| ⋯ 14 unchanged lines | |||
| 310 | 333 | const seatProps = (seat: SeatId): { local: boolean; remote?: RemoteSeat } => { | |
| 311 | 334 | if (!netActive) return { local: true }; | |
| 312 | 335 | const slot = session!.seats[seat]; | |
| 336 | + | const held = slot.ids.length; | |
| 313 | 337 | if (netMode === 'party') { | |
| 314 | 338 | return { | |
| 315 | 339 | // A seat nobody has scanned is played right here, hotseat-style. | |
| 316 | - | local: slot.id === null, | |
| 340 | + | local: held === 0, | |
| 317 | 341 | remote: { | |
| 318 | - | qrUrl: seatLink(session!.room.link, seat), | |
| 319 | - | holder: slot.id !== null ? slot.name || '手機 phone' : null, | |
| 342 | + | // More phones than one on a hand is a pair playing it together. | |
| 343 | + | holder: held > 0 ? `${slot.name || '手機 phone'}${held > 1 ? ` ×${held}` : ''}` : null, | |
| 344 | + | onPeek: (show: boolean) => setQrPeek(show ? seat : null), | |
| 320 | 345 | }, | |
| 321 | 346 | }; | |
| 322 | 347 | } | |
| ⋯ 1 unchanged line | |||
| 324 | 349 | local: seat === mySeat, | |
| 325 | 350 | // A deserted human seat: the host's screen offers it to the computer. | |
| 326 | 351 | remote: | |
| 327 | - | slot.id === null && !ctl.state.bots[seat] && session!.isHost | |
| 352 | + | held === 0 && !ctl.state.bots[seat] && session!.isHost | |
| 328 | 353 | ? { holder: null, onHandToBot: () => session!.handToBot(seat) } | |
| 329 | 354 | : undefined, | |
| 330 | 355 | }; | |
| ⋯ 20 unchanged lines | |||
| 351 | 376 | {/* The tiles in the middle. Not on a phone: the compact layout has no | |
| 352 | 377 | centre square for them to be thrown into. */} | |
| 353 | 378 | {!compact && <Pool game={ctl} pool={pool} viewSeat={viewSeat} />} | |
| 379 | + | {/* Party: each free seat's QR, in the corner of the felt to that seat's | |
| 380 | + | right — the corners are the one part of the table nothing else wants, | |
| 381 | + | and the strips clip their own contents. Scanning one takes that hand | |
| 382 | + | to the phone; the QR comes back if the phone goes, or while its 📱 | |
| 383 | + | tag is held down. */} | |
| 384 | + | {netMode === 'party' && | |
| 385 | + | !compact && | |
| 386 | + | SEATS.map((seat) => { | |
| 387 | + | const slot = session!.seats[seat]; | |
| 388 | + | if (slot.ids.length > 0 && qrPeek !== seat) return null; | |
| 389 | + | return ( | |
| 390 | + | <div key={seat} className={`qr-dock corner-${posOf(seat)}`}> | |
| 391 | + | <SeatQr url={seatLink(session!.room.code, seat)} /> | |
| 392 | + | <span className="qr-hint"> | |
| 393 | + | 掃碼用手機拿這手牌 | |
| 394 | + | <em>scan to play this hand from your phone</em> | |
| 395 | + | </span> | |
| 396 | + | </div> | |
| 397 | + | ); | |
| 398 | + | })} | |
| 354 | 399 | <Center | |
| 355 | 400 | game={ctl} | |
| 356 | 401 | prefs={prefs} | |
| ⋯ 21 unchanged lines | |||
modifiedsrc/game/sound.ts+18 −0
| ⋯ 82 unchanged lines | |||
| 83 | 83 | * clicked. So the bytes come down straight away and are decoded later, on the | |
| 84 | 84 | * first gesture — by which time the whole pack (~330 kB) is already in hand. | |
| 85 | 85 | */ | |
| 86 | + | /** base64 → the bytes decodeAudioData wants. */ | |
| 87 | + | function fromB64(b64: string): ArrayBuffer { | |
| 88 | + | const bin = atob(b64); | |
| 89 | + | const out = new Uint8Array(bin.length); | |
| 90 | + | for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); | |
| 91 | + | return out.buffer; | |
| 92 | + | } | |
| 93 | + | ||
| 86 | 94 | function fetchVoicePack(): Promise<void> { | |
| 87 | 95 | if (fetching) return fetching; | |
| 88 | 96 | fetching = (async () => { | |
| 97 | + | // Deployed, the whole pack rides in one file — an antics bundle may hold | |
| 98 | + | // at most 50 files, and the voice alone is more than that loose. In dev | |
| 99 | + | // the loose mp3s are still there and the fallback below takes them. | |
| 100 | + | const packed: Record<string, string> | null = await fetch(`${VOICE_DIR}pack.json`) | |
| 101 | + | .then((r) => (r.ok ? r.json() : null)) | |
| 102 | + | .catch(() => null); | |
| 103 | + | if (packed) { | |
| 104 | + | for (const [name, b64] of Object.entries(packed)) encoded.set(name, fromB64(b64)); | |
| 105 | + | return; | |
| 106 | + | } | |
| 89 | 107 | const names: string[] = await fetch(`${VOICE_DIR}manifest.json`).then((r) => r.json()); | |
| 90 | 108 | await Promise.all( | |
| 91 | 109 | names.map(async (name) => { | |
| ⋯ 296 unchanged lines | |||
addedsrc/net/clock.ts+58 −0
| 1 | + | /** | |
| 2 | + | * Keep the room alive while this tab is hidden. | |
| 3 | + | * | |
| 4 | + | * The antics SDK runs its loops — the state flush above all — off | |
| 5 | + | * requestAnimationFrame, which a browser suspends the moment a tab stops being | |
| 6 | + | * visible. For most games that is right: a hidden player is not playing. The | |
| 7 | + | * party table is different — the common screen is the room's host, and a host | |
| 8 | + | * that stops flushing takes the whole room with it: a phone's seat claim is | |
| 9 | + | * processed but never announced, so the phone sits on "pick a seat" forever | |
| 10 | + | * while the answer waits in a queue for the laptop to be looked at. | |
| 11 | + | * | |
| 12 | + | * The SDK provides for exactly this: anything on `globalThis.__anticsClock__` | |
| 13 | + | * replaces its own timers, resolved when each loop starts. So the ticks come | |
| 14 | + | * from a Web Worker instead — worker timers are not throttled in background | |
| 15 | + | * tabs — and the host keeps hosting whether or not its tab is front. | |
| 16 | + | * | |
| 17 | + | * Installed before joinRoom, once. The game's own render loops are untouched: | |
| 18 | + | * they use the real requestAnimationFrame and are happy to sleep with the tab. | |
| 19 | + | */ | |
| 20 | + | export function installBackgroundClock() { | |
| 21 | + | const g = globalThis as unknown as Record<string, unknown>; | |
| 22 | + | if (g.__anticsClock__) return; | |
| 23 | + | ||
| 24 | + | let worker: Worker | null = null; | |
| 25 | + | try { | |
| 26 | + | const src = URL.createObjectURL( | |
| 27 | + | new Blob(['setInterval(() => postMessage(0), 66);'], { type: 'text/javascript' }), | |
| 28 | + | ); | |
| 29 | + | worker = new Worker(src); | |
| 30 | + | URL.revokeObjectURL(src); | |
| 31 | + | } catch { | |
| 32 | + | // A sandbox that refuses workers keeps the SDK's own timers — no worse | |
| 33 | + | // than before. | |
| 34 | + | return; | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | const callbacks = new Map<number, (t: number) => void>(); | |
| 38 | + | let nextId = 1; | |
| 39 | + | worker.onmessage = () => { | |
| 40 | + | if (callbacks.size === 0) return; | |
| 41 | + | const now = performance.now(); | |
| 42 | + | const due = [...callbacks.values()]; | |
| 43 | + | callbacks.clear(); | |
| 44 | + | for (const cb of due) cb(now); | |
| 45 | + | }; | |
| 46 | + | ||
| 47 | + | g.__anticsClock__ = { | |
| 48 | + | now: () => Date.now(), | |
| 49 | + | requestAnimationFrame(cb: (t: number) => void): number { | |
| 50 | + | const id = nextId++; | |
| 51 | + | callbacks.set(id, cb); | |
| 52 | + | return id; | |
| 53 | + | }, | |
| 54 | + | cancelAnimationFrame(id: number) { | |
| 55 | + | callbacks.delete(id); | |
| 56 | + | }, | |
| 57 | + | }; | |
| 58 | + | } |
modifiedsrc/net/protocol.ts+27 −11
| ⋯ 18 unchanged lines | |||
| 19 | 19 | /** The two ways a room can be playing. */ | |
| 20 | 20 | export type NetMode = 'online' | 'party'; | |
| 21 | 21 | ||
| 22 | - | /** Who holds a seat. The name outlives the connection — see the antics docs on | |
| 23 | - | * leavers: record it where it arrives, the match outlives the connection. */ | |
| 22 | + | /** | |
| 23 | + | * Who holds a seat. A list, not a single id: a seat is a hand, and nothing | |
| 24 | + | * stops two people huddling over one hand — a pair playing together from two | |
| 25 | + | * phones simply both hold it, and whoever acts first acts. The name outlives | |
| 26 | + | * every connection — see the antics docs on leavers: record it where it | |
| 27 | + | * arrives, the match outlives the connection. | |
| 28 | + | */ | |
| 24 | 29 | export interface SeatSlot { | |
| 25 | - | /** antics player id, or null while nobody holds it. */ | |
| 26 | - | id: string | null; | |
| 30 | + | /** antics player ids of everyone holding this hand. */ | |
| 31 | + | ids: string[]; | |
| 27 | 32 | name: string; | |
| 28 | 33 | } | |
| 29 | 34 | ||
| ⋯ 40 unchanged lines | |||
| 70 | 75 | export const EV_FX = 'fx'; | |
| 71 | 76 | ||
| 72 | 77 | export const EMPTY_SEATS: SeatSlot[] = [ | |
| 73 | - | { id: null, name: '' }, | |
| 74 | - | { id: null, name: '' }, | |
| 75 | - | { id: null, name: '' }, | |
| 76 | - | { id: null, name: '' }, | |
| 78 | + | { ids: [], name: '' }, | |
| 79 | + | { ids: [], name: '' }, | |
| 80 | + | { ids: [], name: '' }, | |
| 81 | + | { ids: [], name: '' }, | |
| 77 | 82 | ]; | |
| 78 | 83 | ||
| 79 | 84 | /** The seat in the QR's link, if the hosting page passed it through to us. */ | |
| ⋯ 17 unchanged lines | |||
| 97 | 102 | } | |
| 98 | 103 | } | |
| 99 | 104 | ||
| 100 | - | /** The room's invite link with the seat stitched in, for that seat's QR. */ | |
| 101 | - | export const seatLink = (link: string, seat: SeatId) => | |
| 102 | - | `${link}${link.includes('?') ? '&' : '?'}seat=${seat}`; | |
| 105 | + | /** | |
| 106 | + | * The link a seat's QR carries: this very page, with the room and the seat in | |
| 107 | + | * the query. Not the room's invite link — the antics room page forwards only | |
| 108 | + | * the params it knows about to the game, and `seat` is not one of them. The | |
| 109 | + | * page's own URL (the raw bundle on antics, the dev server at home) serves the | |
| 110 | + | * game directly and keeps every param, which on a phone also means the whole | |
| 111 | + | * screen belongs to the hand. | |
| 112 | + | */ | |
| 113 | + | export function seatLink(code: string, seat: SeatId): string { | |
| 114 | + | const u = new URL(window.location.href); | |
| 115 | + | u.search = `?room=${code}&seat=${seat}`; | |
| 116 | + | u.hash = ''; | |
| 117 | + | return u.toString(); | |
| 118 | + | } | |
modifiedsrc/net/session.ts+75 −22
| 1 | 1 | import { joinRoom, type Room } from 'antics-sdk'; | |
| 2 | 2 | import { AutoPlay } from '../game/autoplay'; | |
| 3 | + | import { installBackgroundClock } from './clock'; | |
| 3 | 4 | import type { NetThrow } from '../game/ctl'; | |
| 4 | 5 | import type { SoundEvent } from '../game/engine'; | |
| 5 | 6 | import type { Tile } from '../game/tiles'; | |
| ⋯ 94 unchanged lines | |||
| 100 | 101 | // Mirror whatever the room already holds — a late joiner walks in on the | |
| 101 | 102 | // game as it stands, not on an empty table. | |
| 102 | 103 | this.table.adopt(this.sharedGame()); | |
| 104 | + | ||
| 105 | + | if (import.meta.env.DEV) { | |
| 106 | + | (window as unknown as Record<string, unknown>).__mahjongSession = this; | |
| 107 | + | } | |
| 108 | + | // One line to identify a session from any console, the sandboxed iframe's | |
| 109 | + | // included — where nothing else of ours can be reached from outside. | |
| 110 | + | console.log(`[mahjong] joined ${room.code} as ${room.me.name}, host: ${room.isHost}`); | |
| 111 | + | room.onHostChange((h) => console.log(`[mahjong] host is now ${h?.name} (me: ${room.isHost})`)); | |
| 103 | 112 | } | |
| 104 | 113 | ||
| 105 | 114 | /** Join the room in the page URL, or create a fresh one. */ | |
| 106 | 115 | static async join(): Promise<NetSession> { | |
| 107 | - | const room = await joinRoom({}); | |
| 116 | + | // Before the SDK starts any loop: this device may end up hosting the | |
| 117 | + | // room, and a host must keep answering with its tab in the background. | |
| 118 | + | installBackgroundClock(); | |
| 119 | + | // Deployed on antics the SDK's same-origin detection is right; served from | |
| 120 | + | // anywhere else — the dev server, most of all — it would knock on a host | |
| 121 | + | // that has no room server behind it and hang, so name the real one. | |
| 122 | + | const onAntics = /(^|\.)antics\.gg$/.test(window.location.hostname); | |
| 123 | + | const room = await joinRoom(onAntics ? {} : { server: 'https://antics.gg' }); | |
| 108 | 124 | return new NetSession(room); | |
| 109 | 125 | } | |
| 110 | 126 | ||
| ⋯ 30 unchanged lines | |||
| 141 | 157 | const s = this.room.state.seats; | |
| 142 | 158 | return Array.isArray(s) && s.length === 4 ? (s as unknown as SeatSlot[]) : EMPTY_SEATS; | |
| 143 | 159 | } | |
| 160 | + | /** What a seat is called when nobody from the room holds it — the name the | |
| 161 | + | * party table dealt it with, or nothing at all online. */ | |
| 162 | + | private homeName(seat: SeatId): string { | |
| 163 | + | const h = this.room.state.homes; | |
| 164 | + | return Array.isArray(h) && typeof h[seat] === 'string' ? (h[seat] as string) : ''; | |
| 165 | + | } | |
| 144 | 166 | private sharedGame(): unknown { | |
| 145 | 167 | return this.room.state.game ?? null; | |
| 146 | 168 | } | |
| 147 | 169 | get mySeat(): SeatId | null { | |
| 148 | 170 | const seats = this.seats; | |
| 149 | - | for (const i of SEATS) if (seats[i].id === this.room.me.id) return i; | |
| 171 | + | for (const i of SEATS) if (seats[i].ids.includes(this.room.me.id)) return i; | |
| 150 | 172 | return null; | |
| 151 | 173 | } | |
| 152 | 174 | /** Whether a hand has actually been dealt in this room. */ | |
| ⋯ 14 unchanged lines | |||
| 167 | 189 | */ | |
| 168 | 190 | openOnlineLobby() { | |
| 169 | 191 | if (!this.isHost) return; | |
| 170 | - | this.room.setState({ mode: 'online', seats: structuredClone(EMPTY_SEATS) as never }); | |
| 192 | + | this.room.setState({ | |
| 193 | + | mode: 'online', | |
| 194 | + | seats: structuredClone(EMPTY_SEATS) as never, | |
| 195 | + | homes: ['', '', '', ''] as never, | |
| 196 | + | }); | |
| 171 | 197 | this.claimSeat(0); | |
| 172 | 198 | } | |
| 173 | 199 | ||
| ⋯ 1 unchanged line | |||
| 175 | 201 | startOnline(rules: Rules) { | |
| 176 | 202 | if (!this.isHost) return; | |
| 177 | 203 | const seats = this.seats; | |
| 178 | - | const bots = seats.map((s) => s.id === null); | |
| 204 | + | const bots = seats.map((s) => s.ids.length === 0); | |
| 179 | 205 | if (bots.every(Boolean)) return; // nobody seated at all | |
| 180 | 206 | const names = seats.map((s, i) => s.name || `玩家 ${i + 1}`); | |
| 181 | 207 | const eng = this.table.engine; | |
| ⋯ 8 unchanged lines | |||
| 190 | 216 | * leaves. | |
| 191 | 217 | */ | |
| 192 | 218 | startParty(names: string[], rules: Rules) { | |
| 219 | + | console.log(`[mahjong] startParty (host: ${this.isHost})`); | |
| 193 | 220 | if (!this.isHost) return; | |
| 194 | 221 | this.role = 'screen'; | |
| 195 | - | const seats = EMPTY_SEATS.map((_, i) => ({ id: null, name: names[i] ?? '' })); | |
| 196 | - | this.room.setState({ mode: 'party', seats: seats as never }); | |
| 222 | + | const seats = EMPTY_SEATS.map((_, i) => ({ ids: [], name: names[i] ?? '' })); | |
| 223 | + | this.room.setState({ | |
| 224 | + | mode: 'party', | |
| 225 | + | seats: seats as never, | |
| 226 | + | homes: seats.map((s) => s.name) as never, | |
| 227 | + | }); | |
| 197 | 228 | const eng = this.table.engine; | |
| 198 | 229 | eng.applySettings(names, rules); | |
| 199 | 230 | eng.newGame([false, false, false, false], names); | |
| ⋯ 8 unchanged lines | |||
| 208 | 239 | /** Hand a deserted seat to the computer (host's own button). */ | |
| 209 | 240 | handToBot(seat: SeatId) { | |
| 210 | 241 | if (!this.isHost) return; | |
| 211 | - | if (this.seats[seat].id !== null) return; | |
| 242 | + | if (this.seats[seat].ids.length > 0) return; | |
| 212 | 243 | this.table.engine.setBot(seat, true); | |
| 213 | 244 | } | |
| 214 | 245 | ||
| ⋯ 3 unchanged lines | |||
| 218 | 249 | } | |
| 219 | 250 | ||
| 220 | 251 | // ---- host duties ------------------------------------------------------- | |
| 252 | + | /** | |
| 253 | + | * A claim never fails. A seat is a hand, not a chair: two phones may hold | |
| 254 | + | * the same one — a pair playing together — and whichever acts first acts. | |
| 255 | + | * Claiming a different seat lets go of the one you had. | |
| 256 | + | */ | |
| 221 | 257 | private handleSeat(msg: SeatMsg, from: string) { | |
| 222 | 258 | if (!this.isHost) return; | |
| 223 | 259 | const name = this.room.player(from)?.name ?? ''; | |
| 224 | 260 | const seats = structuredClone(this.seats); | |
| 225 | 261 | let seat = msg.seat; | |
| 226 | 262 | if (seat === null) { | |
| 227 | - | seat = SEATS.find((i) => seats[i].id === null) ?? null; | |
| 263 | + | seat = SEATS.find((i) => seats[i].ids.length === 0) ?? null; | |
| 228 | 264 | if (seat === null) return; | |
| 229 | 265 | } | |
| 230 | - | if (seats[seat].id !== null && seats[seat].id !== from) return; // taken | |
| 231 | - | // One seat per player: claiming a new one lets go of the old. | |
| 232 | - | for (const i of SEATS) if (seats[i].id === from) seats[i] = { id: null, name: seats[i].name }; | |
| 233 | - | seats[seat] = { id: from, name: name || seats[seat].name }; | |
| 234 | - | this.room.setState({ seats: seats as never }); | |
| 266 | + | for (const i of SEATS) seats[i].ids = seats[i].ids.filter((id) => id !== from); | |
| 267 | + | seats[seat].ids.push(from); | |
| 268 | + | // The first holder names the hand; a second pair of hands joins theirs. | |
| 269 | + | if (seats[seat].ids.length === 1 && name) seats[seat].name = name; | |
| 270 | + | this.settleSeats(seats); | |
| 235 | 271 | ||
| 236 | 272 | // Mid-game, the seat comes back from the computer (or the common screen) | |
| 237 | 273 | // to the person who claimed it. | |
| 238 | 274 | if (this.sharedGame() !== null) { | |
| 239 | 275 | this.table.engine.setBot(seat, false); | |
| 240 | - | if (name) this.table.engine.renameSeat(seat, name); | |
| 276 | + | if (seats[seat].name) this.table.engine.renameSeat(seat, seats[seat].name); | |
| 241 | 277 | } | |
| 242 | 278 | this.bump(); | |
| 243 | 279 | } | |
| ⋯ 2 unchanged lines | |||
| 246 | 282 | const seats = structuredClone(this.seats); | |
| 247 | 283 | let changed = false; | |
| 248 | 284 | for (const i of SEATS) { | |
| 249 | - | if (seats[i].id === id) { | |
| 250 | - | seats[i] = { id: null, name: seats[i].name }; | |
| 251 | - | changed = true; | |
| 252 | - | } | |
| 285 | + | const kept = seats[i].ids.filter((x) => x !== id); | |
| 286 | + | if (kept.length !== seats[i].ids.length) changed = true; | |
| 287 | + | seats[i].ids = kept; | |
| 253 | 288 | } | |
| 254 | - | if (changed) this.room.setState({ seats: seats as never }); | |
| 289 | + | if (changed) this.settleSeats(seats); | |
| 255 | 290 | // The seat stays human: at a party table the common screen simply plays it | |
| 256 | 291 | // again, and online the host is offered the 電腦代打 button instead of the | |
| 257 | 292 | // table deciding on its own that a dropped friend is gone for good. | |
| 258 | 293 | } | |
| 259 | 294 | ||
| 295 | + | /** Publish the seat map, giving emptied hands their own names back. */ | |
| 296 | + | private settleSeats(seats: SeatSlot[]) { | |
| 297 | + | const party = this.mode === 'party'; | |
| 298 | + | for (const i of SEATS) { | |
| 299 | + | if (seats[i].ids.length > 0) continue; | |
| 300 | + | if (party) { | |
| 301 | + | // Back to the common screen under the table's own name; online the | |
| 302 | + | // engine keeps the leaver's name — a deserted hand mid-game is still | |
| 303 | + | // theirs to talk about, and to come back to. | |
| 304 | + | seats[i].name = this.homeName(i); | |
| 305 | + | if (seats[i].name) this.table.engine.renameSeat(i, seats[i].name); | |
| 306 | + | } else { | |
| 307 | + | seats[i].name = ''; | |
| 308 | + | } | |
| 309 | + | } | |
| 310 | + | this.room.setState({ seats: seats as never }); | |
| 311 | + | } | |
| 312 | + | ||
| 260 | 313 | private handleAct(msg: ActMsg, from: string) { | |
| 261 | 314 | if (!this.isHost || !msg || typeof msg.seat !== 'number') return; | |
| 262 | 315 | const seat = msg.seat; | |
| 263 | - | // Their own seat — or one nobody holds, which is how the party screen | |
| 316 | + | // A hand they hold — or one nobody holds, which is how the party screen | |
| 264 | 317 | // plays its unclaimed seats when it is not the one running the engine | |
| 265 | 318 | // (after a reload, say, when a phone has inherited the host's chair). | |
| 266 | - | const holder = this.seats[seat]?.id; | |
| 267 | - | if (holder !== from && holder !== null) return; | |
| 319 | + | const holders = this.seats[seat]?.ids ?? []; | |
| 320 | + | if (holders.length > 0 && !holders.includes(from)) return; | |
| 268 | 321 | const eng = this.table.engine; | |
| 269 | 322 | const act = msg.act; | |
| 270 | 323 | switch (act.kind) { | |
| ⋯ 61 unchanged lines | |||
modifiedsrc/styles.css+28 −5
| ⋯ 1996 unchanged lines | |||
| 1997 | 1997 | It must not cover the hand: an unclaimed seat is still played right here. */ | |
| 1998 | 1998 | .qr-dock { | |
| 1999 | 1999 | position: absolute; | |
| 2000 | - | right: 6px; | |
| 2001 | - | bottom: 34px; | |
| 2002 | 2000 | z-index: 3; | |
| 2003 | 2001 | display: flex; | |
| 2004 | 2002 | flex-direction: column; | |
| ⋯ 3 unchanged lines | |||
| 2008 | 2006 | border-radius: 10px; | |
| 2009 | 2007 | background: rgba(10, 49, 38, 0.92); | |
| 2010 | 2008 | border: 1px solid rgba(217, 180, 92, 0.45); | |
| 2009 | + | /* Purely something to look at: taps fall through to the table under it. */ | |
| 2010 | + | pointer-events: none; | |
| 2011 | + | } | |
| 2012 | + | /* One corner per seat, to that player's right, turned to face them the way | |
| 2013 | + | their strip is. The corners are the felt the four strips leave over. */ | |
| 2014 | + | .qr-dock.corner-0 { | |
| 2015 | + | right: 10px; | |
| 2016 | + | bottom: 10px; | |
| 2017 | + | } | |
| 2018 | + | .qr-dock.corner-1 { | |
| 2019 | + | /* Turned a quarter, the tall dock swings wider than its box — stand it | |
| 2020 | + | further off the edge so nothing pokes off screen. */ | |
| 2021 | + | right: 42px; | |
| 2022 | + | top: 10px; | |
| 2023 | + | transform: rotate(-90deg); | |
| 2011 | 2024 | } | |
| 2025 | + | .qr-dock.corner-2 { | |
| 2026 | + | left: 10px; | |
| 2027 | + | top: 10px; | |
| 2028 | + | transform: rotate(180deg); | |
| 2029 | + | } | |
| 2030 | + | .qr-dock.corner-3 { | |
| 2031 | + | left: 42px; | |
| 2032 | + | bottom: 10px; | |
| 2033 | + | transform: rotate(90deg); | |
| 2034 | + | } | |
| 2012 | 2035 | .seat-qr { | |
| 2013 | 2036 | width: clamp(72px, calc(var(--depth) * 0.44), 132px); | |
| 2014 | 2037 | aspect-ratio: 1; | |
| ⋯ 33 unchanged lines | |||
| 2048 | 2071 | screen, with the common screen doing everything else. Tiles get the room — | |
| 2049 | 2072 | they are the only thing this screen is for. */ | |
| 2050 | 2073 | .ctl { | |
| 2051 | - | --tile-w: clamp(40px, 5.2vw, 76px); | |
| 2074 | + | /* The whole hand in one row: 17 tiles and their gaps must fit the width. */ | |
| 2075 | + | --tile-w: clamp(30px, calc((100vw - 56px) / 17.6), 76px); | |
| 2052 | 2076 | position: fixed; | |
| 2053 | 2077 | inset: 0; | |
| 2054 | 2078 | display: flex; | |
| ⋯ 36 unchanged lines | |||
| 2091 | 2115 | color: var(--gold); | |
| 2092 | 2116 | } | |
| 2093 | 2117 | .ctl-status { | |
| 2094 | - | margin-left: auto; | |
| 2095 | 2118 | opacity: 0.75; | |
| 2096 | 2119 | } | |
| 2097 | 2120 | .ctl-status em { | |
| ⋯ 10 unchanged lines | |||
| 2108 | 2131 | display: inline-flex; | |
| 2109 | 2132 | align-items: center; | |
| 2110 | 2133 | gap: 4px; | |
| 2134 | + | margin-left: auto; | |
| 2111 | 2135 | } | |
| 2112 | 2136 | .ctl-top .chips { | |
| 2113 | 2137 | font-weight: 600; | |
| ⋯ 17 unchanged lines | |||
| 2131 | 2155 | display: flex; | |
| 2132 | 2156 | justify-content: center; | |
| 2133 | 2157 | padding: 0 8px calc(10px + env(safe-area-inset-bottom, 0px)); | |
| 2134 | - | overflow-x: auto; | |
| 2135 | 2158 | } | |
| 2136 | 2159 | .ctl-hand .hand { | |
| 2137 | 2160 | flex-wrap: nowrap; | |
| ⋯ 76 unchanged lines | |||
modifiedsrc/ui/Controller.tsx+18 −17
| 1 | - | import { useEffect, useRef, useState } from 'react'; | |
| 1 | + | import { useEffect, useState } from 'react'; | |
| 2 | 2 | import { pendingOptions } from '../game/ctl'; | |
| 3 | 3 | import { labelOf, WIND_EN, WIND_NAMES, type Tile } from '../game/tiles'; | |
| 4 | 4 | import type { SeatId } from '../game/types'; | |
| ⋯ 18 unchanged lines | |||
| 23 | 23 | ||
| 24 | 24 | // The QR carries which seat it was stuck to; a phone that arrived some other | |
| 25 | 25 | // way — or lost the parameter on the way in — picks one by hand below. | |
| 26 | - | const asked = useRef(false); | |
| 26 | + | const [asked, setAsked] = useState(false); | |
| 27 | 27 | useEffect(() => { | |
| 28 | - | if (asked.current || seat !== null) return; | |
| 28 | + | if (asked || seat !== null) return; | |
| 29 | 29 | const want = seatFromUrl(); | |
| 30 | - | if (want !== null && session.seats[want].id === null) { | |
| 31 | - | asked.current = true; | |
| 30 | + | if (want !== null) { | |
| 31 | + | setAsked(true); | |
| 32 | 32 | session.claimSeat(want); | |
| 33 | 33 | } | |
| 34 | - | }, [session, seat]); | |
| 34 | + | }, [session, seat, asked]); | |
| 35 | 35 | ||
| 36 | - | if (seat === null) return <SeatPick session={session} />; | |
| 36 | + | if (seat === null) return <SeatPick session={session} joining={asked} />; | |
| 37 | 37 | return <SeatStrip session={session} seat={seat} />; | |
| 38 | 38 | } | |
| 39 | 39 | ||
| 40 | - | function SeatPick({ session }: { session: NetSession }) { | |
| 40 | + | function SeatPick({ session, joining }: { session: NetSession; joining?: boolean }) { | |
| 41 | 41 | const seats = session.seats; | |
| 42 | 42 | const s = session.table.state; | |
| 43 | 43 | return ( | |
| 44 | 44 | <div className="ctl ctl-pick"> | |
| 45 | 45 | <h1>台灣麻將</h1> | |
| 46 | - | <p className="sub">選一個座位 · pick your seat</p> | |
| 46 | + | <p className="sub"> | |
| 47 | + | {joining ? '入座中… taking your seat — 或先選別的 or pick another' : '選一個座位 · pick your seat'} | |
| 48 | + | </p> | |
| 47 | 49 | <div className="pick-grid"> | |
| 48 | 50 | {SEATS.map((i) => { | |
| 49 | - | const taken = seats[i].id !== null; | |
| 51 | + | const held = seats[i].ids.length; | |
| 50 | 52 | const wind = (i - s.dealer + 4) % 4; | |
| 51 | 53 | return ( | |
| 52 | - | <button | |
| 53 | - | key={i} | |
| 54 | - | className="btn big-btn" | |
| 55 | - | disabled={taken} | |
| 56 | - | onClick={() => session.claimSeat(i)} | |
| 57 | - | > | |
| 54 | + | <button key={i} className="btn big-btn" onClick={() => session.claimSeat(i)}> | |
| 58 | 55 | {WIND_NAMES[wind]} {WIND_EN[wind]} | |
| 59 | - | <em>{taken ? `已有人 · ${seats[i].name}` : seats[i].name || '空位 open'}</em> | |
| 56 | + | <em> | |
| 57 | + | {held > 0 | |
| 58 | + | ? `${seats[i].name} — 一起打 join this hand` | |
| 59 | + | : seats[i].name || '空位 open'} | |
| 60 | + | </em> | |
| 60 | 61 | </button> | |
| 61 | 62 | ); | |
| 62 | 63 | })} | |
| ⋯ 211 unchanged lines | |||
modifiedsrc/ui/OnlineLobby.tsx+9 −9
| ⋯ 16 unchanged lines | |||
| 17 | 17 | const me = session.room.me.id; | |
| 18 | 18 | ||
| 19 | 19 | // Walking in unseated: take the first free chair rather than making everyone | |
| 20 | - | // pick one. Moving is one tap on another seat. | |
| 20 | + | // pick one. Moving is one tap on another seat — including one somebody | |
| 21 | + | // already holds, which is how two people share a hand. | |
| 21 | 22 | const claimed = useRef(false); | |
| 22 | 23 | useEffect(() => { | |
| 23 | 24 | if (claimed.current || session.mySeat !== null) return; | |
| 24 | - | if (seats.some((s) => s.id === null)) { | |
| 25 | + | if (seats.some((s) => s.ids.length === 0)) { | |
| 25 | 26 | claimed.current = true; | |
| 26 | 27 | session.claimSeat(null); | |
| 27 | 28 | } | |
| 28 | 29 | }, [session, seats]); | |
| 29 | 30 | ||
| 30 | - | const seated = seats.filter((s) => s.id !== null).length; | |
| 31 | + | const seated = seats.filter((s) => s.ids.length > 0).length; | |
| 31 | 32 | ||
| 32 | 33 | return ( | |
| 33 | 34 | <div className="lobby"> | |
| ⋯ 8 unchanged lines | |||
| 42 | 43 | <div className="pick-grid"> | |
| 43 | 44 | {SEATS.map((i) => { | |
| 44 | 45 | const slot = seats[i]; | |
| 45 | - | const mine = slot.id === me; | |
| 46 | - | const taken = slot.id !== null && !mine; | |
| 46 | + | const mine = slot.ids.includes(me); | |
| 47 | + | const held = slot.ids.length; | |
| 47 | 48 | return ( | |
| 48 | 49 | <button | |
| 49 | 50 | key={i} | |
| 50 | 51 | className={`btn big-btn ${mine ? '' : 'ghost'}`} | |
| 51 | - | disabled={taken} | |
| 52 | 52 | onClick={() => !mine && session.claimSeat(i)} | |
| 53 | 53 | > | |
| 54 | 54 | {WIND_NAMES[i]} {WIND_EN[i]} | |
| 55 | 55 | <em> | |
| 56 | - | {slot.id === null | |
| 56 | + | {held === 0 | |
| 57 | 57 | ? '空位,開局時由電腦代打 · open — a bot if nobody sits' | |
| 58 | 58 | : mine | |
| 59 | - | ? `${slot.name || '你'} · you` | |
| 60 | - | : slot.name} | |
| 59 | + | ? `${slot.name || '你'} · you${held > 1 ? ` +${held - 1}` : ''}` | |
| 60 | + | : `${slot.name}${held > 1 ? ` +${held - 1}` : ''} · 可同坐 join them`} | |
| 61 | 61 | </em> | |
| 62 | 62 | </button> | |
| 63 | 63 | ); | |
| ⋯ 20 unchanged lines | |||
modifiedsrc/ui/Seat.tsx+8 −22
| ⋯ 4 unchanged lines | |||
| 5 | 5 | import type { TablePool } from '../table/pool'; | |
| 6 | 6 | import { Bi, CLAIM_LABEL } from './Bi'; | |
| 7 | 7 | import { Hand } from './Hand'; | |
| 8 | - | import { SeatQr } from './SeatQr'; | |
| 9 | 8 | import { SEAT_ROT, SEAT_SLOT } from './rotation'; | |
| 10 | 9 | import { TileView } from './TileView'; | |
| 11 | 10 | ||
| ⋯ 2 unchanged lines | |||
| 14 | 13 | * player. What of it shows on this screen is decided here. | |
| 15 | 14 | */ | |
| 16 | 15 | export interface RemoteSeat { | |
| 17 | - | /** Party screen: the link that claims this seat, drawn as a QR while free. */ | |
| 18 | - | qrUrl?: string; | |
| 19 | 16 | /** Who holds it from their own device, or null while nobody does. */ | |
| 20 | 17 | holder?: string | null; | |
| 18 | + | /** Party screen: holding the 📱 tag re-shows this seat's QR on the table, | |
| 19 | + | * for the player who lost theirs. The QR itself lives in the table's | |
| 20 | + | * corner — see App — because the strip clips its own contents. */ | |
| 21 | + | onPeek?: (show: boolean) => void; | |
| 21 | 22 | /** Online host: the seat's player is gone; offer it to the computer. */ | |
| 22 | 23 | onHandToBot?: () => void; | |
| 23 | 24 | } | |
| ⋯ 25 unchanged lines | |||
| 49 | 50 | const s = game.state; | |
| 50 | 51 | const p = s.players[seat]; | |
| 51 | 52 | const [sel, setSel] = useState<number | null>(null); | |
| 52 | - | // Held down to re-show a claimed seat's QR — for the player who lost theirs. | |
| 53 | - | const [peek, setPeek] = useState(false); | |
| 54 | 53 | const bot = s.bots[seat]; | |
| 55 | 54 | // Somebody this device answers for: a person here, not the computer, not a | |
| 56 | 55 | // hand that lives on another screen. | |
| ⋯ 109 unchanged lines | |||
| 166 | 165 | </div> | |
| 167 | 166 | )} | |
| 168 | 167 | ||
| 169 | - | {/* Party screen: a free seat wears its QR; scanning it takes the hand | |
| 170 | - | to a phone. A claimed seat hides it, but its player can ask for it | |
| 171 | - | back — the 手機 tag below holds it down to reveal. */} | |
| 172 | - | {remote?.qrUrl && (remote.holder == null || peek) && ( | |
| 173 | - | <div className="qr-dock"> | |
| 174 | - | <SeatQr url={remote.qrUrl} /> | |
| 175 | - | <span className="qr-hint"> | |
| 176 | - | 掃碼用手機拿牌 | |
| 177 | - | <em>scan to play from your phone</em> | |
| 178 | - | </span> | |
| 179 | - | </div> | |
| 180 | - | )} | |
| 181 | - | ||
| 182 | 168 | {/* Only on a phone. At full size a discard is thrown into the middle | |
| 183 | 169 | and stays there for the hand, so the row would be the same tiles | |
| 184 | 170 | listed twice. */} | |
| ⋯ 119 unchanged lines | |||
| 304 | 290 | <span | |
| 305 | 291 | className="tag phone" | |
| 306 | 292 | title="這手牌在手機上 · played from a phone. 按住看 QR · hold to show the QR again" | |
| 307 | - | onPointerDown={() => setPeek(true)} | |
| 308 | - | onPointerUp={() => setPeek(false)} | |
| 309 | - | onPointerCancel={() => setPeek(false)} | |
| 310 | - | onPointerLeave={() => setPeek(false)} | |
| 293 | + | onPointerDown={() => remote.onPeek?.(true)} | |
| 294 | + | onPointerUp={() => remote.onPeek?.(false)} | |
| 295 | + | onPointerCancel={() => remote.onPeek?.(false)} | |
| 296 | + | onPointerLeave={() => remote.onPeek?.(false)} | |
| 311 | 297 | > | |
| 312 | 298 | 📱 {remote.holder} | |
| 313 | 299 | </span> | |
| ⋯ 30 unchanged lines | |||