anvilsign in

collin/mahjong · e8590b15

The QR is a tile, and the table treats it like one

Collin Richards · 2026-08-25 10:25 UTC · e8590b15370e05e38015d39cf1b9fb3cfb0fd694 · parent 95126eff · browse files

modifiedREADME.md+41 −19
⋯ 15 unchanged lines
1616
1717 - somebody sits down at this screen and **taps a hand the computer has been
1818 playing** — the tiles turn over and are theirs;
19-- somebody **scans a seat's QR** and takes that hand onto their phone;
19+- somebody **scans the tile lying in the middle of the felt** — it says SCAN
20+ and JOIN, and it gets knocked about by the discards like everything else
21+ there — or a particular seat's own QR from its gear, and takes that hand onto
22+ their phone;
2023 - somebody **opens the invite link** from three cities away and takes one from
2124 their own laptop, with the table turned so their seat is the near edge.
2225
⋯ 216 unchanged lines
239242 | 這裡 | this screen, face down under a tap | — | its QR, from its gear |
240243 | 手機 | whoever scanned in | they leave → the computer | — |
241244
242-**Nothing touches the network until a phone is wanted.** A table of people and
243-computers is four hands and an engine; there is nothing for a server to do, and
244-`src/net/` is never constructed. The room is opened by the first thing that
245-needs one — the 用手機拿牌 in a seat's gear — and when it opens it *publishes
246-the hand in progress* rather than dealing over the top of it
247-(`NetSession.publishTable`). The engine is the page's own either way:
248-`NetTable` is a wrapper that goes on around a live `Game`, not a second one, so
249-opening a room costs a `structuredClone` and changes nothing on screen except
250-that the QRs start working.
245+**Opening a room is not the same as playing over one**, and the difference is
246+what keeps a game against three computers off the wire. 開始 deals and then
247+joins a room in the background, so there is a code for the QR tile to carry
248+from the first tile — but all `publishTable` writes is a seat map. The game
249+itself is published the moment somebody actually walks into the room
250+(`publishGameIfOwed`, off `room.onJoin`) and not one move before it, so an
251+evening played alone costs one handshake and then silence. Nothing waits on any
252+of it: the deal happens first, on this device, and a page that never reaches a
253+server plays a full game.
254+
255+The engine is the page's own either way. `NetTable` is a wrapper that goes on
256+around a live `Game`, not a second one, so a room arriving mid-hand changes
257+nothing on screen except that the QR tile appears.
258+
259+**The table's QR is a tile.** It lies in the middle of the felt with SCAN above
260+the code and JOIN below it, white face, dark-green ink, in the two bands a suit
261+tile spends on its number and its suit — and it is a tile all the way down. It
262+is a body in the pool like any other (`TablePool.joinTile`): a discard thrown
263+at it knocks it aside and turns it, it can be picked up and put somewhere less
264+in the way or flicked off across the cloth, the same one light over the table
265+works out its sides from whatever angle it has ended up at, and it throws the
266+same shadow. Nothing about it is a dialog. Its face is painted rather than cut
267+out of the sprite sheet — `qrFace` in `ui/tileArt.ts`, handed to `drawTile` as
268+`image` — which is the only thing that makes it different from a 中. It is put
269+back in the middle when a hand is dealt, because by then the pile it was shoved
270+to the edge of has been swept away.
271+
272+No room means no tile. A table that cannot reach a server is a table with one
273+fewer tile on it and nothing else different.
251274
252275 **Each seat's QR is its own**, and lives in that seat's gear beside the sound
253276 and the undo, carrying `?room=…&seat=N`. Scanning it lands on that chair
254277 without anybody having to say which one they are in: the hand leaves the shared
255278 screen for good — face down there, not under a card that lifts — and the phone
256-gets it face up with the claim buttons and the throw. There is a QR for the
257-whole table in the middle of the felt as well, but only while the table is
258-still being sat down at; the moment the first tile is thrown it steps aside,
259-because from then on the middle is where the tiles go.
279+gets it face up with the claim buttons and the throw. It wears the same white
280+tile face as the one on the felt, so a code to scan looks the same wherever it
281+turns up.
260282
261283 **A hand that loses its device is kept for fifteen seconds and then given to
262284 the computer** (`EMPTY_SEAT_GRACE`). A phone locking itself looks exactly like
⋯ 43 unchanged lines
306328 way in, and the ones somebody already holds, which is how two people play one
307329 hand off two devices. A seat's own QR names its chair and skips the picker.
308330 - **The device the table was opened on** goes on showing the table: the felt,
309- the middle, the wall, and every hand no phone has taken, covered-with-a-tap
310- like a hotseat game. Hold the 📱 tag on a nameplate to show the table's QR
311- again for a player who lost theirs. With sound on, the phone in each player's
312- hand is what says their calls and the table goes quiet for that seat — see
313- [audio, by seat](#audio-by-seat).
331+ the middle, the wall, the SCAN/JOIN tile lying among the discards, and every
332+ hand no phone has taken, covered-with-a-tap like a hotseat game. With sound
333+ on, the phone in each player's hand is what says their calls and the table
334+ goes quiet for that seat — see [audio, by seat](#audio-by-seat).
314335
315336 Design notes, in the order they bit:
316337
⋯ 396 unchanged lines
713734 src/table/physics.ts the discard pool: rectangles with weight, pure and testable
714735 src/table/geometry.ts the table measured — colliders, launch points, throw gate
715736 src/table/pool.ts game state ⇄ tiles in the middle, by diffing the discards
737+src/ui/tileArt.ts one tile drawn on canvas — sides, shadow, and the QR face
716738 src/ui/ TileView, Hand (drag and throw), Seat, Center, Pool, Help
717739 ```
718740
⋯ 227 unchanged lines
modifiedsrc/App.tsx+44 −38
⋯ 33 unchanged lines
3434 import { Pool } from './ui/Pool';
3535 import { Seat, type SeatWho } from './ui/Seat';
3636 import { TableMenu } from './ui/TableMenu';
37-import { SeatQr } from './ui/SeatQr';
3837 import { Settings } from './ui/Settings';
3938 import './styles.css';
4039
⋯ 88 unchanged lines
129128 // what a player opens faces them and nobody else. See ui/TableMenu.
130129 const [menuSeat, setMenuSeat] = useState<SeatId | null>(null);
131130 const [settingsOpen, setSettingsOpen] = useState(false);
132- // The table's join QR, held back into view from any seat's 📱 tag.
133- const [qrPeek, setQrPeek] = useState(false);
134131 // Read once at startup; after that the live game is the source of truth.
135132 const [saved] = useState(() => loadSave());
136133
⋯ 201 unchanged lines
338335 const startGame = () => {
339336 goFullscreen();
340337 game.newGame(OPENING_SEATS, prefs.names);
341- // Started inside a room somebody had already opened and left empty: the
342- // table it was waiting for is this one.
343- session?.publishTable(prefs.names);
338+ void openRoom();
344339 };
345340 const resumeGame = () => {
346341 goFullscreen();
347342 game.restore(saved!.state);
348- session?.publishTable(prefs.names);
343+ void openRoom();
349344 };
350345
351346 /**
352- * Open the room around this table, so a phone can be pointed at it. The
353- * first thing that needs one asks for it — the invite QR, or a seat being
354- * sent to a phone — and until then this page has said nothing to any server.
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.
355356 */
356- const openRoom = async (): Promise<NetSession | null> => (session ? session : connect(true));
357- /** Take a hand onto a phone: open the room if it is not open, then put that
358- * seat's own QR up in its own gear, facing its own edge. */
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. */
359370 const toPhone = async (seat: SeatId) => {
360371 setMenuSeat(seat);
361372 await openRoom();
⋯ 194 unchanged lines
556567 // Its device went. The hand is being kept for a moment in case that was
557568 // a pocket rather than a goodbye — see `vacate` in net/session.ts.
558569 waiting: session?.isWaiting(seat) === true,
559- onPeek: session ? setQrPeek : undefined,
560570 // The table has gone quiet for this seat on purpose: the phone on it is
561571 // doing the calling.
562572 speaks: session?.voiceSeats[seat] === true,
⋯ 23 unchanged lines
586596 // one instead.
587597 const seatUrl = (seat: SeatId) =>
588598 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;
589613
590614 return (
591615 <div className="table">
⋯ 20 unchanged lines
612636 />
613637 );
614638 })}
615- {/* The tiles in the middle. Not on a phone: the compact layout has no
616- centre square for them to be thrown into. */}
617- {!compact && <Pool game={ctl} pool={pool} viewSeat={viewSeat} />}
618- {/* One QR for the whole table, lying in the middle of the felt as a
619- giant face-up tile. Scanning it lands a phone on the seat picker.
620- It is up while the table is still being sat down at — nothing thrown
621- yet, and a hand still to be claimed — and steps aside the moment play
622- starts, because from then on the middle is where the tiles go and a
623- phone is asked for from the seat's own gear instead. It comes back
624- while any seat's 📱 tag is held down, for a player who lost theirs. */}
625- {session &&
626- isTable &&
627- !compact &&
628- (qrPeek ||
629- (session.seats.some((s) => s.ids.length === 0) &&
630- ctl.state.players.every((p) => p.discards.length === 0))) && (
631- <div className="qr-dock">
632- <SeatQr url={roomLink(session!.room.code, session!.room.publicBase)} />
633- <span className="qr-hint">
634- {lang !== 'en' && '掃碼入座,用手機拿牌'}
635- {lang !== 'zh' && <em>scan to join from your phone</em>}
636- </span>
637- </div>
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+ )}
639645 <Center
640646 game={ctl}
641647 prefs={prefs}
⋯ 37 unchanged lines
modifiedsrc/net/session.ts+38 −12
⋯ 91 unchanged lines
9292 room.on(EV_SEAT, (p, from) => this.handleSeat(p as unknown as SeatMsg, from)),
9393 room.on(EV_FX, (p) => this.handleFx(p as unknown as FxMsg)),
9494 room.on(EV_VOICE, (p, from) => this.handleVoice(p as unknown as VoiceMsg, from)),
95- room.onJoin(() => this.bump()),
95+ room.onJoin(() => {
96+ this.publishGameIfOwed();
97+ this.bump();
98+ }),
9699 room.onLeave((p) => {
97100 if (this.isHost) {
98101 this.talkers.delete(p.id);
⋯ 27 unchanged lines
126129 // and whatever we tried to publish across it was dropped, so the device
127130 // that owns the table says it all again.
128131 room.onResume(() => {
132+ this.publishGameIfOwed();
129133 if (this.isHost && this.sharedGame() !== null) this.publishGame();
130134 this.reviewSeats();
131135 this.armStranded();
⋯ 119 unchanged lines
251255
252256 // ---- opening the room --------------------------------------------------
253257 /**
254- * Put this device's table into the room.
258+ * Put this device's table into the room: four seats nobody has taken, and
259+ * this device as where they are being played.
255260 *
256- * The game is already being played — a room is never opened around an empty
257- * table — so this publishes it as it stands rather than dealing anything.
258- * All four seats start held by nobody, which is what they already were:
259- * hands played at this screen. A phone that scans a seat's QR takes one over
260- * and gives it back if it leaves.
261+ * The game itself is deliberately *not* published here. A room with a QR in
262+ * it and nobody in it is a table waiting to be joined, and there is nothing
263+ * for the wire to carry until somebody actually arrives — so the whole of
264+ * what this costs, for an evening played against three computers, is this
265+ * one small write. `publishGameIfOwed` starts the state flowing when the
266+ * first phone walks in, and it does not stop after that.
261267 *
262268 * Only the host can, because room state is the host's to write. Whoever
263- * created the room is the host, so the device that asked for the QR is
264- * normally the one that gets here; a device that joined somebody else's room
265- * has nothing to publish and returns.
269+ * created the room is the host, so the device that dealt is normally the one
270+ * that gets here; a device that joined somebody else's room has nothing to
271+ * publish and returns, and so does one whose room already has a table.
266272 */
267273 publishTable(names: string[]) {
268274 if (!this.isHost) return;
269- if (this.sharedGame() !== null) return; // already open — somebody beat us to it
275+ if (this.screenId !== '' && this.room.player(this.screenId)) return;
270276 console.log('[mahjong] opening the room around this table');
271277 const seats = SEATS.map((i) => ({ ids: [] as string[], name: names[i] ?? '' }));
272278 this.room.setState({
⋯ 1 unchanged line
274280 homes: seats.map((q) => q.name) as never,
275281 voices: [false, false, false, false] as never,
276282 screen: this.room.me.id as never,
277- game: structuredClone(this.table.engine.state) as never,
278283 });
279284 this.bump();
280285 }
281286
282287 /**
288+ * Somebody has walked into the room, so the table is worth publishing. Until
289+ * this has happened once the room has been told nothing but its seat map,
290+ * and every move has stayed on this device.
291+ */
292+ private publishGameIfOwed() {
293+ if (!this.isHost || !this.isScreen) return;
294+ if (this.sharedGame() !== null) return;
295+ if (this.room.players.length < 2) return;
296+ if (this.table.engine.state.phase === 'lobby') return;
297+ console.log('[mahjong] somebody is here — publishing the table');
298+ this.publishGame();
299+ this.bump();
300+ }
301+
302+ /** Whether this room has a code to point a phone at — which is to say, one
303+ * a phone could actually walk into. */
304+ get invitable(): boolean {
305+ return this.room.code !== '';
306+ }
307+
308+ /**
283309 * Hand a seat to the computer, or take it back off it. Asked for from the
284310 * table — the screen's own button, and the tap on a computer's strip that
285311 * says somebody has sat down there.
⋯ 265 unchanged lines
modifiedsrc/styles.css+5 −35
⋯ 2528 unchanged lines
25292529 down. Below the result panel (z-index 2, later in the DOM), above the pool
25302530 and the centre readout (1): scannable while it matters, never over the
25312531 scores. */
2532-.qr-dock {
2533- position: absolute;
2534- left: 50%;
2535- top: 50%;
2536- transform: translate(-50%, -50%);
2537- z-index: 2;
2538- display: flex;
2539- flex-direction: column;
2540- align-items: center;
2541- gap: 8px;
2542- /* Purely something to look at: taps fall through to the table under it. */
2543- pointer-events: none;
2544-}
2545-/* The QR wears the face of a giant tile: ivory, softly bevelled, the same
2546- rounded corners and resting shadow the sprite gives the real ones, with the
2547- code engraved in the deep green the dragons are cut in. */
2532+/* The QR the seat's gear offers, wearing the face of a tile: the same ivory,
2533+ the same rounded corners, the same resting shadow. The table's own join code
2534+ is a real tile out on the felt (see ui/Pool) and this is its twin in a panel,
2535+ so a code to scan looks the same wherever it turns up. */
25482536 .seat-qr {
2549- --qw: clamp(96px, calc(var(--depth) * 0.56), 168px);
2537+ --qw: 92px;
25502538 width: var(--qw);
25512539 height: calc(var(--qw) * 1.2);
25522540 display: grid;
⋯ 13 unchanged lines
25662554 height: auto;
25672555 aspect-ratio: 1;
25682556 }
2569-.qr-hint {
2570- font-size: 11px;
2571- line-height: 1.35;
2572- color: var(--ivory);
2573- text-align: center;
2574- max-width: 190px;
2575- padding: 5px 12px;
2576- border-radius: 999px;
2577- background: rgba(10, 49, 38, 0.88);
2578- border: 1px solid rgba(217, 180, 92, 0.4);
2579-}
2580-.qr-hint em {
2581- display: block;
2582- font-style: normal;
2583- opacity: 0.6;
2584- font-size: 9px;
2585-}
25862557 .nameplate .tag.phone {
25872558 background: rgba(29, 90, 168, 0.35);
25882559 border-color: rgba(29, 90, 168, 0.7);
⋯ 303 unchanged lines
28922863 border-top: 1px solid rgba(246, 240, 226, 0.14);
28932864 }
28942865 .menu-qr .seat-qr {
2895- --qw: 92px;
28962866 flex: none;
28972867 }
28982868 .menu-qr-hint {
⋯ 32 unchanged lines
modifiedsrc/table/pool.ts+85 −0
⋯ 92 unchanged lines
9393 * A wall's worth: enough to test a crowded pool, not enough to bury it. */
9494 const LOOSE_LIMIT = 60;
9595
96+/**
97+ * How much bigger than a discard the join tile is drawn.
98+ *
99+ * A QR has to be read by a phone held over the table, and a tile in the pool is
100+ * drawn the size it was in the wall. Big enough to scan without leaning in,
101+ * small enough to still be a tile somebody could pick up and put down again.
102+ */
103+const JOIN_SCALE = 2.4;
104+/** How it lies. Nothing on a table is ever quite square to it. */
105+const JOIN_ANGLE = -0.07;
106+
96107 /** Let go of moving faster than this and it was a flick, not a place. Slow
97108 * enough that putting a tile down deliberately never sends it skidding. */
98109 const DRAG_FLICK = 260;
⋯ 83 unchanged lines
182193 * to cull. See `throwLoose`.
183194 */
184195 private loose: Body[] = [];
196+ /**
197+ * The tile with the room's QR on its face — a real body in the real world,
198+ * belonging to no discard, so the reconciler never looks at it.
199+ *
200+ * It is a tile like any other. It takes a knock from a discard thrown at it,
201+ * it slides, it can be picked up and put somewhere less in the way, and it
202+ * throws a shadow on the felt. What is different about it is only its face,
203+ * which the renderer paints rather than cutting out of the sheet, and its
204+ * size, which a phone has to be able to read from across the table.
205+ */
206+ joinTile: Body | null = null;
185207 /** When the table will have stopped talking. See `heckle`. */
186208 private quietAt = 0;
187209
⋯ 12 unchanged lines
200222 this.world = null;
201223 this.geo = null;
202224 this.loose = [];
225+ this.joinTile = null;
203226 this.carried = null;
204227 this.held = null;
205228 this.release = null;
⋯ 258 unchanged lines
464487 // A new hand is a new pile, and nothing should fly anywhere.
465488 const fresh = state.handNumber !== this.hand;
466489 this.hand = state.handNumber;
490+ // The pile it was shoved to the edge of has just been swept away, so the
491+ // QR goes back where a thing to be scanned belongs. Left alone it would
492+ // spend an evening being nudged further into a corner.
493+ if (fresh && this.joinTile) {
494+ const c = centreOf(geo.pool);
495+ this.joinTile.x = c.x;
496+ this.joinTile.y = c.y;
497+ this.joinTile.z = 0;
498+ this.joinTile.vx = 0;
499+ this.joinTile.vy = 0;
500+ this.joinTile.vz = 0;
501+ this.joinTile.angle = JOIN_ANGLE;
502+ this.joinTile.spin = 0;
503+ }
467504
468505 const want = new Set<string>();
469506 const added: { key: string; seat: SeatId; index: number; tile: Tile }[] = [];
⋯ 50 unchanged lines
520557 this.newestKey = ld ? `${ld.from}:${state.players[ld.from].discards.length - 1}` : null;
521558 }
522559
560+ /**
561+ * Whether the room's QR is lying on the table.
562+ *
563+ * Called every frame with the answer, so it comes and goes with the room
564+ * itself: no server to reach and there is simply no tile, which is the whole
565+ * of the offline story. Adding it is idempotent; the body it makes is the
566+ * one that stays until the room does.
567+ */
568+ setJoinTile(show: boolean) {
569+ const world = this.world;
570+ const geo = this.geo;
571+ if (!world || !geo) return;
572+ if (!show) {
573+ if (this.joinTile) {
574+ const at = world.bodies.indexOf(this.joinTile);
575+ if (at >= 0) world.bodies.splice(at, 1);
576+ if (this.carried === this.joinTile) this.carried = null;
577+ this.joinTile = null;
578+ }
579+ return;
580+ }
581+ if (this.joinTile && world.bodies.includes(this.joinTile)) return;
582+ this.joinTile = this.placeJoinTile(geo);
583+ }
584+
585+ /** Lay it in the middle of the felt, at rest. */
586+ private placeJoinTile(geo: TableGeometry): Body {
587+ const c = centreOf(geo.pool);
588+ return addBody(this.world!, {
589+ // Never drawn from the sheet — the renderer paints this one's face — so
590+ // the tile it claims to be is only what the physics needs it not to be
591+ // undefined.
592+ tile: 0 as Tile,
593+ seat: 0,
594+ x: c.x,
595+ y: c.y,
596+ vx: 0,
597+ vy: 0,
598+ z: 0,
599+ vz: 0,
600+ angle: JOIN_ANGLE,
601+ spin: 0,
602+ w: geo.tile.w * JOIN_SCALE,
603+ h: geo.tile.h * JOIN_SCALE,
604+ atRest: true,
605+ });
606+ }
607+
523608 /** Run the physics on. Returns impacts worth a sound. */
524609 advance(seconds: number, stopOnImpact = false): Impact[] {
525610 this.carryOff(seconds);
⋯ 281 unchanged lines
modifiedsrc/ui/Pool.tsx+23 −4
⋯ 17 unchanged lines
1818 } from './colliders';
1919 import { flickOf, type Sample } from './flick';
2020 import { EDGE_SEAT } from './rotation';
21-import { drawShadow, drawTile, loadArt, type Art } from './tileArt';
21+import { drawShadow, drawTile, loadArt, qrFace, type Art } from './tileArt';
2222
2323 /**
2424 * The tiles in the middle of the table, drawn.
⋯ 12 unchanged lines
3737 game,
3838 pool,
3939 viewSeat = 0,
40+ joinUrl,
4041 }: {
4142 game: TableCtl;
4243 pool: TablePool;
4344 /** The seat drawn at the bottom of this screen — see App. An edge the physics
4445 * reports belongs to whoever is drawn there, not to seat 0 by birthright. */
4546 viewSeat?: SeatId;
47+ /**
48+ * Where a phone should point itself to join this table, if there is anywhere.
49+ * Undefined puts no tile on the felt at all — no room, no server, nothing to
50+ * scan, and nothing pretending otherwise.
51+ */
52+ joinUrl?: string;
4653 }) {
4754 const canvasRef = useRef<HTMLCanvasElement>(null);
4855 const art = useSheet();
4956
5057 // What the loop reads. Refreshed on every render, read on every frame.
51- const live = useRef({ state: game.state, art, viewSeat });
52- live.current = { state: game.state, art, viewSeat };
58+ const live = useRef({ state: game.state, art, viewSeat, joinUrl });
59+ live.current = { state: game.state, art, viewSeat, joinUrl };
5360
5461 // Poked by the effect below, and by the gesture through pool.wake.
5562 const wake = useRef<() => void>(() => {});
⋯ 38 unchanged lines
94101 previous = now;
95102
96103 pool.sync(live.current.state);
104+ // The QR comes and goes with the room, and the tile with it. Asked every
105+ // frame rather than watched, because the answer is one boolean and the
106+ // pool takes it idempotently.
107+ pool.setJoinTile(!!live.current.joinUrl);
97108 // A tile in the fingers goes over the table's own buttons — seat 0 throws
98109 // from the *right-hand end* of its row, which is directly under the undo
99110 // button — so while one is being carried they stop taking the pointer.
⋯ 29 unchanged lines
129140 // Out in the middle a sound has a real position rather than an edge it
130141 // belongs to, and the physics knows it to the pixel.
131142 if (loudest > 0) knock(loudest, spotOf(pool, loudAt!));
132- paint(ctx, canvas, pool, live.current.art);
143+ paint(ctx, canvas, pool, live.current.art, live.current.joinUrl);
133144
134145 // Keep going while anything is moving; otherwise wait to be poked. A held
135146 // world is never running: it is stepped by hand and repainted each time.
⋯ 191 unchanged lines
327338 canvas: HTMLCanvasElement,
328339 pool: TablePool,
329340 art: Art,
341+ joinUrl?: string,
330342 ) {
331343 const dpr = window.devicePixelRatio || 1;
332344 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
⋯ 11 unchanged lines
344356 if (held) drawShadow(ctx, held.x, held.y, HELD_Z, held.angle, held.w, held.h, 1, 1, 0);
345357
346358 const newest = pool.newest;
359+ // The one tile whose face is painted rather than cut out of the sheet. Asked
360+ // for at the size it is actually being drawn, so the code is never resampled
361+ // and a phone never has to work at it.
362+ const join = pool.joinTile;
363+ const joinArt =
364+ join && joinUrl ? qrFace(joinUrl, join.w * join.scale * (window.devicePixelRatio || 1)) : null;
347365 for (const b of bodies) {
348366 drawTile(ctx, art, {
349367 x: b.x,
⋯ 6 unchanged lines
356374 axis: b.flipAxis,
357375 code: b.tile,
358376 lit: b === newest,
377+ image: b === join ? joinArt ?? undefined : undefined,
359378 });
360379 }
361380
⋯ 157 unchanged lines
modifiedsrc/ui/Seat.tsx+1 −13
⋯ 29 unchanged lines
3030 * the moment is up, and gives it straight back to whoever comes for it.
3131 */
3232 waiting?: boolean;
33- /** Holding the 📱 tag re-shows the table's shared join QR, for the player
34- * who lost theirs. The QR itself lives in the table's corner — see App —
35- * because the strip clips its own contents. */
36- onPeek?: (show: boolean) => void;
3733 /** Somebody has sat down at this edge: take the hand off the computer. */
3834 onSit?: () => void;
3935 /** Nobody is going to play it here: give it to the computer. */
⋯ 431 unchanged lines
471467 {who?.holder != null && (
472468 <span
473469 className="tag phone"
474- title={`${t('這手牌在手機上', 'played from a phone', ' · ')}. ${t(
475- '按住看 QR',
476- 'hold to show the QR again',
477- ' · ',
478- )}`}
479- onPointerDown={() => who.onPeek?.(true)}
480- onPointerUp={() => who.onPeek?.(false)}
481- onPointerCancel={() => who.onPeek?.(false)}
482- onPointerLeave={() => who.onPeek?.(false)}
470+ title={t('這手牌在手機上', 'played from a phone', ' · ')}
483471 >
484472 📱 {who.holder}
485473 </span>
⋯ 243 unchanged lines
modifiedsrc/ui/tileArt.ts+105 −2
1+import { encode } from 'uqr';
12 import backUrl from '../assets/back.png';
23 import tilesUrl from '../assets/tiles.svg';
34 import { SPRITE_COL, SPRITE_COLS } from '../game/tiles';
⋯ 128 unchanged lines
132133 axis: number;
133134 code: number;
134135 lit: boolean;
136+ /**
137+ * A face painted rather than cut out of the sheet — the join tile's QR. It is
138+ * drawn into the same rectangle a sheet cell would fill, so everything else
139+ * about the tile is unchanged: the same sides worked out from its angle, the
140+ * same one light over the table, the same shadow, the same edge line. It is a
141+ * tile with a different thing on it, which is what a 中 is.
142+ */
143+ image?: CanvasImageSource;
135144 }
136145
146+/**
147+ * The join tile's face: the room's QR, cut into a tile the way a dragon's
148+ * character is.
149+ *
150+ * Painted into an offscreen canvas of the face's own proportions and handed to
151+ * `drawTile` as `image`, so from there on it is a tile like any other — the
152+ * pool works out its sides from the angle it happens to be lying at, the one
153+ * light over the table shades them, and it throws the same shadow on the felt.
154+ *
155+ * The code is square and a tile is not, so it sits in the square across the
156+ * width and the white left over above and below carries SCAN and JOIN — the
157+ * two bands a suit tile spends on its number and its suit, spent here on
158+ * saying what the pattern between them is for. The ink is the tiles' own dark
159+ * green rather than black: a QR reads on any sufficient contrast, and black on
160+ * white would be the one thing on this table not made of the same two colours
161+ * as everything else.
162+ */
163+const QR_INK = '#14402f';
164+/** The face is the white the sheet paints every other face — this tile is one
165+ * of the set, not a card that turned up in it. */
166+const QR_GROUND = '#ffffff';
167+/** How much of the tile's width the code takes, leaving a quiet margin. A QR
168+ * needs one, and a tile's face has an edge the art already leaves clear. */
169+const QR_INSET = 0.8;
170+/** The two words, as tall as the band they sit in allows. */
171+const QR_LABEL = 0.13;
172+
173+const qrFaces = new Map<string, HTMLCanvasElement>();
174+
175+export function qrFace(url: string, width: number): HTMLCanvasElement | null {
176+ // Rounded, so a pool that re-measures by a pixel does not repaint the code.
177+ const w = Math.max(64, Math.round(width / 16) * 16);
178+ const key = `${w}:${url}`;
179+ const had = qrFaces.get(key);
180+ if (had) return had;
181+
182+ let code: { size: number; data: boolean[][] };
183+ try {
184+ code = encode(url) as { size: number; data: boolean[][] };
185+ } catch {
186+ // An unencodable link is not worth taking the table down over.
187+ return null;
188+ }
189+
190+ const h = Math.round(w * 1.375);
191+ const canvas = document.createElement('canvas');
192+ canvas.width = w;
193+ canvas.height = h;
194+ const ctx = canvas.getContext('2d');
195+ if (!ctx) return null;
196+
197+ ctx.fillStyle = QR_GROUND;
198+ ctx.fillRect(0, 0, w, h);
199+
200+ // Whole pixels per module, so no two modules end up different widths — a
201+ // resampled QR is a QR a phone has to work at.
202+ const span = w * QR_INSET;
203+ const unit = Math.max(1, Math.floor(span / code.size));
204+ const side = unit * code.size;
205+ const x0 = Math.round((w - side) / 2);
206+ const y0 = Math.round((h - side) / 2);
207+ ctx.fillStyle = QR_INK;
208+ for (let r = 0; r < code.size; r++) {
209+ for (let c = 0; c < code.size; c++) {
210+ if (code.data[r][c]) ctx.fillRect(x0 + c * unit, y0 + r * unit, unit, unit);
211+ }
212+ }
213+
214+ // One word in each band, centred in it. Letter-spaced, because two short
215+ // words set small want the air — and because it is what the English under
216+ // every Chinese label on this table already does.
217+ const size = w * QR_LABEL;
218+ ctx.font = `600 ${size}px ui-sans-serif, system-ui, "Segoe UI", sans-serif`;
219+ ctx.textAlign = 'center';
220+ ctx.textBaseline = 'middle';
221+ // Not everywhere; where it is missing the words simply set tighter.
222+ try {
223+ ctx.letterSpacing = `${size * 0.18}px`;
224+ } catch {
225+ // older engines
226+ }
227+ // Nudged right by half the tracking: the last letter's own trailing space is
228+ // counted into the width, and centred text hangs off to the left without it.
229+ const nudge = size * 0.09;
230+ ctx.fillText('SCAN', w / 2 + nudge, y0 / 2);
231+ ctx.fillText('JOIN', w / 2 + nudge, h - y0 / 2);
232+
233+ qrFaces.set(key, canvas);
234+ return canvas;
235+}
236+
137237 /** Somewhere between two colours, `t` from the first to the second. */
138238 function mix(a: string, b: string, t: number): string {
139239 const hex = (c: string) => [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16));
⋯ 103 unchanged lines
243343 // through to the other side; a hair of width keeps the outline drawable.
244344 squash(ctx, Math.max(0.015, Math.abs(d.facing)), d.axis);
245345 ctx.rotate(d.angle);
246- face(ctx, d.code, w, h, art, d.lit, d.facing < 0, look);
346+ face(ctx, d.code, w, h, art, d.lit, d.facing < 0, look, d.image);
247347 ctx.restore();
248348 }
249349
⋯ 149 unchanged lines
399499 lit: boolean,
400500 backwards: boolean,
401501 look: TileLook,
502+ image?: CanvasImageSource,
402503 ) {
403504 // Part-way through a turn you are looking at the back of it: the same green
404505 // tile back the wall is built from. A tile always comes to rest face up, so
⋯ 13 unchanged lines
418519 return;
419520 }
420521
421- if (art.faces) {
522+ if (image) {
523+ ctx.drawImage(image, -w / 2, -h / 2, w, h);
524+ } else if (art.faces) {
422525 ctx.drawImage(cellOf(art.faces, tile, lit, look), -w / 2, -h / 2, w, h);
423526 } else {
424527 tilePath(ctx, w, h);
⋯ 21 unchanged lines