collin/mahjong · 3f4c297b
Party mode: one shared QR for the whole table
Collin Richards · 2026-08-20 10:35 UTC · 3f4c297b571ac5fc98d05833b005178f9011e077 · parent 3d11fb14 · browse files
modifiedsrc/App.tsx+19 −23
| ⋯ 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, seatFromUrl, seatLink } from './net/protocol'; | |
| 10 | + | import { roomFromUrl, roomLink, seatFromUrl } from './net/protocol'; | |
| 11 | 11 | import { NetSession } from './net/session'; | |
| 12 | 12 | import { Center } from './ui/Center'; | |
| 13 | 13 | import { useCompact } from './ui/compact'; | |
| ⋯ 39 unchanged lines | |||
| 53 | 53 | useSyncExternalStore(pool.subscribe, pool.getSnapshot); | |
| 54 | 54 | const [helpSeat, setHelpSeat] = useState<SeatId | null>(null); | |
| 55 | 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); | |
| 56 | + | // The table's join QR, held back into view from any seat's 📱 tag. | |
| 57 | + | const [qrPeek, setQrPeek] = useState(false); | |
| 58 | 58 | // Read once at startup; after that the live game is the source of truth. | |
| 59 | 59 | const [saved] = useState(() => loadSave()); | |
| 60 | 60 | ||
| ⋯ 224 unchanged lines | |||
| 285 | 285 | </button> | |
| 286 | 286 | <button className="btn big-btn ghost" onClick={openParty} disabled={connecting}> | |
| 287 | 287 | 手機派對 Party mode | |
| 288 | - | <em>這螢幕當桌,掃 QR 用手機拿牌 · this screen is the table, phones hold the hands</em> | |
| 288 | + | <em>這螢幕當桌,手機掃碼入座 · this screen is the table, scan the QR to join</em> | |
| 289 | 289 | </button> | |
| 290 | 290 | </> | |
| 291 | 291 | )} | |
| ⋯ 49 unchanged lines | |||
| 341 | 341 | remote: { | |
| 342 | 342 | // More phones than one on a hand is a pair playing it together. | |
| 343 | 343 | holder: held > 0 ? `${slot.name || '手機 phone'}${held > 1 ? ` ×${held}` : ''}` : null, | |
| 344 | - | onPeek: (show: boolean) => setQrPeek(show ? seat : null), | |
| 344 | + | onPeek: setQrPeek, | |
| 345 | 345 | }, | |
| 346 | 346 | }; | |
| 347 | 347 | } | |
| ⋯ 28 unchanged lines | |||
| 376 | 376 | {/* The tiles in the middle. Not on a phone: the compact layout has no | |
| 377 | 377 | centre square for them to be thrown into. */} | |
| 378 | 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. */} | |
| 379 | + | {/* Party: one QR for the whole table, in the corner of the felt — the | |
| 380 | + | corners are the one part of the table nothing else wants, and the | |
| 381 | + | strips clip their own contents. Scanning it lands a phone on the seat | |
| 382 | + | picker. Once every hand is held it steps aside, and comes back while | |
| 383 | + | any seat's 📱 tag is held down — for a player who lost theirs. */} | |
| 384 | 384 | {netMode === 'party' && | |
| 385 | 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 | - | })} | |
| 386 | + | (qrPeek || session!.seats.some((s) => s.ids.length === 0)) && ( | |
| 387 | + | <div className="qr-dock"> | |
| 388 | + | <SeatQr url={roomLink(session!.room.code)} /> | |
| 389 | + | <span className="qr-hint"> | |
| 390 | + | 掃碼入座,用手機拿牌 | |
| 391 | + | <em>scan to join from your phone</em> | |
| 392 | + | </span> | |
| 393 | + | </div> | |
| 394 | + | )} | |
| 399 | 395 | <Center | |
| 400 | 396 | game={ctl} | |
| 401 | 397 | prefs={prefs} | |
| ⋯ 21 unchanged lines | |||
modifiedsrc/net/protocol.ts+9 −8
| ⋯ 102 unchanged lines | |||
| 103 | 103 | } | |
| 104 | 104 | ||
| 105 | 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. | |
| 106 | + | * The link the table's QR carries: this very page, with just the room in the | |
| 107 | + | * query. No seat — a phone that scans it lands on the seat picker, which is | |
| 108 | + | * also what lets a pair deliberately share a hand. (An old link with a `seat` | |
| 109 | + | * param still works: seatFromUrl above keeps reading it.) The page's own URL | |
| 110 | + | * (the raw bundle on antics, the dev server at home) serves the game directly | |
| 111 | + | * and keeps every param, which on a phone also means the whole screen belongs | |
| 112 | + | * to the hand. | |
| 112 | 113 | */ | |
| 113 | - | export function seatLink(code: string, seat: SeatId): string { | |
| 114 | + | export function roomLink(code: string): string { | |
| 114 | 115 | const u = new URL(window.location.href); | |
| 115 | - | u.search = `?room=${code}&seat=${seat}`; | |
| 116 | + | u.search = `?room=${code}`; | |
| 116 | 117 | u.hash = ''; | |
| 117 | 118 | return u.toString(); | |
| 118 | 119 | } | |
modifiedsrc/styles.css+6 −27
| ⋯ 1990 unchanged lines | |||
| 1991 | 1991 | flex: none; | |
| 1992 | 1992 | } | |
| 1993 | 1993 | ||
| 1994 | - | /* ---------- the party table's QRs ---------- */ | |
| 1995 | - | /* A free seat wears its QR at the end of the strip, inside the seat's own | |
| 1996 | - | rotation so it sits by the right edge of the table whoever it belongs to. | |
| 1997 | - | It must not cover the hand: an unclaimed seat is still played right here. */ | |
| 1994 | + | /* ---------- the party table's QR ---------- */ | |
| 1995 | + | /* One join code for the whole table, in the bottom-right corner — the felt | |
| 1996 | + | the four strips leave over. It must not cover any hand: an unclaimed seat | |
| 1997 | + | is still played right here. */ | |
| 1998 | 1998 | .qr-dock { | |
| 1999 | 1999 | position: absolute; | |
| 2000 | + | right: 10px; | |
| 2001 | + | bottom: 10px; | |
| 2000 | 2002 | z-index: 3; | |
| 2001 | 2003 | display: flex; | |
| 2002 | 2004 | flex-direction: column; | |
| ⋯ 5 unchanged lines | |||
| 2008 | 2010 | border: 1px solid rgba(217, 180, 92, 0.45); | |
| 2009 | 2011 | /* Purely something to look at: taps fall through to the table under it. */ | |
| 2010 | 2012 | 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); | |
| 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 | 2013 | } | |
| 2035 | 2014 | .seat-qr { | |
| 2036 | 2015 | width: clamp(72px, calc(var(--depth) * 0.44), 132px); | |
| ⋯ 217 unchanged lines | |||
modifiedsrc/ui/Seat.tsx+1 −1
| ⋯ 14 unchanged lines | |||
| 15 | 15 | export interface RemoteSeat { | |
| 16 | 16 | /** Who holds it from their own device, or null while nobody does. */ | |
| 17 | 17 | holder?: string | null; | |
| 18 | - | /** Party screen: holding the 📱 tag re-shows this seat's QR on the table, | |
| 18 | + | /** Party screen: holding the 📱 tag re-shows the table's shared join QR, | |
| 19 | 19 | * for the player who lost theirs. The QR itself lives in the table's | |
| 20 | 20 | * corner — see App — because the strip clips its own contents. */ | |
| 21 | 21 | onPeek?: (show: boolean) => void; | |
| ⋯ 308 unchanged lines | |||