anvilsign in

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
77 import { play, setSoundEnabled, setVoiceEnabled, setVolume } from './game/sound';
88 import { WIND_EN, WIND_NAMES } from './game/tiles';
99 import type { SeatId } from './game/types';
10-import { roomFromUrl, seatFromUrl, seatLink } from './net/protocol';
10+import { roomFromUrl, roomLink, seatFromUrl } from './net/protocol';
1111 import { NetSession } from './net/session';
1212 import { Center } from './ui/Center';
1313 import { useCompact } from './ui/compact';
⋯ 39 unchanged lines
5353 useSyncExternalStore(pool.subscribe, pool.getSnapshot);
5454 const [helpSeat, setHelpSeat] = useState<SeatId | null>(null);
5555 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);
5858 // Read once at startup; after that the live game is the source of truth.
5959 const [saved] = useState(() => loadSave());
6060
⋯ 224 unchanged lines
285285 </button>
286286 <button className="btn big-btn ghost" onClick={openParty} disabled={connecting}>
287287 手機派對 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>
289289 </button>
290290 </>
291291 )}
⋯ 49 unchanged lines
341341 remote: {
342342 // More phones than one on a hand is a pair playing it together.
343343 holder: held > 0 ? `${slot.name || '手機 phone'}${held > 1 ? ` ×${held}` : ''}` : null,
344- onPeek: (show: boolean) => setQrPeek(show ? seat : null),
344+ onPeek: setQrPeek,
345345 },
346346 };
347347 }
⋯ 28 unchanged lines
376376 {/* The tiles in the middle. Not on a phone: the compact layout has no
377377 centre square for them to be thrown into. */}
378378 {!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. */}
384384 {netMode === 'party' &&
385385 !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+ )}
399395 <Center
400396 game={ctl}
401397 prefs={prefs}
⋯ 21 unchanged lines
modifiedsrc/net/protocol.ts+9 −8
⋯ 102 unchanged lines
103103 }
104104
105105 /**
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.
112113 */
113-export function seatLink(code: string, seat: SeatId): string {
114+export function roomLink(code: string): string {
114115 const u = new URL(window.location.href);
115- u.search = `?room=${code}&seat=${seat}`;
116+ u.search = `?room=${code}`;
116117 u.hash = '';
117118 return u.toString();
118119 }
modifiedsrc/styles.css+6 −27
⋯ 1990 unchanged lines
19911991 flex: none;
19921992 }
19931993
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. */
19981998 .qr-dock {
19991999 position: absolute;
2000+ right: 10px;
2001+ bottom: 10px;
20002002 z-index: 3;
20012003 display: flex;
20022004 flex-direction: column;
⋯ 5 unchanged lines
20082010 border: 1px solid rgba(217, 180, 92, 0.45);
20092011 /* Purely something to look at: taps fall through to the table under it. */
20102012 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);
20342013 }
20352014 .seat-qr {
20362015 width: clamp(72px, calc(var(--depth) * 0.44), 132px);
⋯ 217 unchanged lines
modifiedsrc/ui/Seat.tsx+1 −1
⋯ 14 unchanged lines
1515 export interface RemoteSeat {
1616 /** Who holds it from their own device, or null while nobody does. */
1717 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,
1919 * for the player who lost theirs. The QR itself lives in the table's
2020 * corner — see App — because the strip clips its own contents. */
2121 onPeek?: (show: boolean) => void;
⋯ 308 unchanged lines