collin/mahjong · 366ba77f
One table, and a card over every hand
Collin Richards · 2026-08-25 08:28 UTC · 366ba77f83cd36fc19838e79f29d88e008170dcb · parent d5af31aa · browse files
modifiedREADME.md+86 −33
| 1 | 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 | - | All four hands are on screen at once, each rotated to face its own edge — put a | |
| 5 | - | piece of card or plastic over your strip so the others can't see your tiles. | |
| 6 | - | Every label is Chinese with English underneath; the tiles themselves stay | |
| 7 | - | Chinese, because that is what the tiles say. | |
| 8 | - | ||
| 9 | - | Nobody else around? **單人對局 Single player** on the lobby gives you the | |
| 10 | - | bottom seat and hands the other three to the computer — same rules, same | |
| 11 | - | table, same scoring. See [computer players](#computer-players). | |
| 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). | |
| 4 | + | All four hands are on screen at once, each rotated to face its own edge and | |
| 5 | + | each face down until its player taps it — the piece of card everybody used to | |
| 6 | + | lay over their strip, done by the table itself. Every label is Chinese with | |
| 7 | + | English underneath; the tiles themselves stay Chinese, because that is what the | |
| 8 | + | tiles say. | |
| 9 | + | ||
| 10 | + | **四人同桌** on the lobby is that table, and it is one mode rather than two: | |
| 11 | + | four people at one screen, and any of them free to take their own hand onto | |
| 12 | + | their phone from the QR in their gear — the tiles leave the screen and the | |
| 13 | + | throw comes back over the wire. Nobody has to, and nothing has to be decided up | |
| 14 | + | front. See [one table](#one-table). | |
| 15 | + | ||
| 16 | + | Nobody else around? **單人對局 Single player** gives you the bottom seat and | |
| 17 | + | hands the other three to the computer — same rules, same table, same scoring. | |
| 18 | + | See [computer players](#computer-players). | |
| 19 | + | ||
| 20 | + | Everybody somewhere else? **線上對戰 Online game** puts the four of you at the | |
| 21 | + | same table from four devices, each turned so your own seat is the near edge — | |
| 22 | + | see [playing online](#playing-online). | |
| 17 | 23 | ||
| 18 | 24 | ``` | |
| 19 | 25 | npm install | |
| ⋯ 21 unchanged lines | |||
| 41 | 47 | ||
| 42 | 48 | ## Playing | |
| 43 | 49 | ||
| 50 | + | - **Your hand is face down** at a shared screen, and a tap turns it over. It | |
| 51 | + | covers itself again when the hand is dealt and when your turn ends — the two | |
| 52 | + | moments the tiles stop being needed, and without them a hand turned over once | |
| 53 | + | stays turned over all evening, which is the same as never having covered it. | |
| 54 | + | 蓋牌 beside 理牌 puts it back sooner. While it is covered the only button the | |
| 55 | + | strip has is 看牌: every other one names a tile — 打出 五萬, 吃 with these two | |
| 56 | + | — and would say through the card what is under it. The 看牌 of whoever the | |
| 57 | + | table is waiting on wears a gold ring, since whose go it is was never a | |
| 58 | + | secret. Nothing is covered where the screen belongs to one player: solo, and | |
| 59 | + | online, where everybody has a device of their own. | |
| 44 | 60 | - **The tile you just drew** is held apart from the sorted hand with a gold ring | |
| 45 | 61 | and a 摸 label, instead of being sorted invisibly into it. It merges into the | |
| 46 | 62 | hand once you discard. 補花 replacements and kong replacements are marked the | |
| ⋯ 116 unchanged lines | |||
| 163 | 179 | Stakes are locked once a game is under way — they'd otherwise rewrite chips | |
| 164 | 180 | already won. | |
| 165 | 181 | ||
| 182 | + | ## One table | |
| 183 | + | ||
| 184 | + | 四人同桌 and 手機派對 were two buttons on the lobby, and they were never two | |
| 185 | + | games: one was the other with the phones left in people's pockets. Every party | |
| 186 | + | table played its unclaimed seats hotseat-style at the screen already, and every | |
| 187 | + | hotseat table would have taken a phone if it had had a room to put it in. So | |
| 188 | + | there is one button now, and picking up your phone is not a mode — it is | |
| 189 | + | something one player does, mid-hand, without asking the other three. | |
| 190 | + | ||
| 191 | + | What that costs is a decision about when to open the room, and the answer is: | |
| 192 | + | not while anybody is waiting. **The tiles are dealt at once**, on this screen, | |
| 193 | + | before a websocket has been opened — a laptop with no server behind it is still | |
| 194 | + | four people at a table, and nobody should watch a spinner to start playing. The | |
| 195 | + | room is opened behind the game, and when it lands it *inherits* the hand in | |
| 196 | + | progress (`startParty(names, rules, from)` restores rather than deals) instead | |
| 197 | + | of dealing over the top of it. The only thing that changes on screen is that | |
| 198 | + | the QRs start working. If it never lands, nothing does. | |
| 199 | + | ||
| 200 | + | **Each seat's QR is its own**, and lives in that seat's gear beside the sound | |
| 201 | + | and the undo, carrying `?room=…&seat=N`. Scanning it lands on that chair | |
| 202 | + | without anybody having to say which one they are in: the hand leaves the shared | |
| 203 | + | screen for good — face down there, not under a card that lifts — and the phone | |
| 204 | + | gets it face up with the claim buttons and the throw. There is a QR for the | |
| 205 | + | whole table in the middle of the felt as well, but only while the table is | |
| 206 | + | still being sat down at; the moment the first tile is thrown it steps aside, | |
| 207 | + | because from then on the middle is where the tiles go. | |
| 208 | + | ||
| 209 | + | **The save follows the screen.** A table that has opened a room keeps writing | |
| 210 | + | `localStorage` (`engine.persist` on the screen's engine, off on every phone — | |
| 211 | + | they mirror, and have no business writing it), so 繼續對局 still works and | |
| 212 | + | resumes the same way the mode starts: restore here, open the room behind it, | |
| 213 | + | publish the hand as it stood. | |
| 214 | + | ||
| 166 | 215 | ## Playing online | |
| 167 | 216 | ||
| 168 | - | Two networked modes, both built on a room relay of our own — `server/rooms.ts`, | |
| 217 | + | Both networked shapes — a shared screen with phones on it, and four devices in | |
| 218 | + | four places — are one room relay of our own: `server/rooms.ts`, | |
| 169 | 219 | a couple hundred lines of Node on `ws`. The relay knows nothing about mahjong: | |
| 170 | 220 | rooms with codes, host election by join order, one bag of shared state only the | |
| 171 | 221 | host may write, and events fanned out to everyone else. One client — the | |
| ⋯ 12 unchanged lines | |||
| 184 | 234 | own seat is the bottom edge; other people's tiles are face down. Joining | |
| 185 | 235 | mid-game takes over a vacant or computer seat, so a dropped friend can | |
| 186 | 236 | reload and carry on. | |
| 187 | - | - **手機派對 Party mode** — the laptop stays the table, exactly like hotseat, | |
| 188 | - | and each seat's corner of the felt wears a QR. Scanning one loads a | |
| 189 | - | controller on the phone: just that hand, face up, with the claim buttons — | |
| 190 | - | and the throw. Flick a tile up off the top of the phone and it sails in from | |
| 191 | - | your edge of the common screen, at the speed and angle you let go of it | |
| 192 | - | (`NetThrow`, mapped into table coordinates by `TablePool.throwFromNet`). The | |
| 193 | - | seat's tiles go face down on the shared screen and its QR hides; hold the 📱 | |
| 194 | - | tag on the nameplate to show it again for a player who lost theirs. A phone | |
| 195 | - | that leaves hands the seat back to the laptop. Seats never lock: a second | |
| 196 | - | phone scanning the same QR joins the same hand — two people playing one | |
| 197 | - | hand, whoever acts first acts. And with sound on, the phone in your hand is | |
| 198 | - | what says your calls — see [audio, by seat](#audio-by-seat). | |
| 237 | + | - **四人同桌 at one screen, with phones** — the mode above, and on the wire | |
| 238 | + | still `mode: 'party'`: the laptop is the table and a phone holds one hand. | |
| 239 | + | Scanning a seat's QR loads a controller: just that hand, face up, with the | |
| 240 | + | claim buttons — and the throw. Flick a tile up off the top of the phone and | |
| 241 | + | it sails in from your edge of the common screen, at the speed and angle you | |
| 242 | + | let go of it (`NetThrow`, mapped into table coordinates by | |
| 243 | + | `TablePool.throwFromNet`). The seat's tiles go face down on the shared screen | |
| 244 | + | and stay there; hold the 📱 tag on its nameplate to show the table's QR again | |
| 245 | + | for a player who lost theirs. A phone that leaves hands the seat back to the | |
| 246 | + | laptop, where it is covered-with-a-tap like the rest. Seats never lock: a | |
| 247 | + | second phone scanning the same QR joins the same hand — two people playing | |
| 248 | + | one hand, whoever acts first acts. And with sound on, the phone in your hand | |
| 249 | + | is what says your calls — see [audio, by seat](#audio-by-seat). | |
| 199 | 250 | ||
| 200 | 251 | Design notes, in the order they bit: | |
| 201 | 252 | ||
| ⋯ 163 unchanged lines | |||
| 365 | 416 | ||
| 366 | 417 | The game state is plain data, so a save is just its JSON in `localStorage`, | |
| 367 | 418 | rewritten after every move. Close the lid, refresh, or run the battery flat and | |
| 368 | - | nothing is lost — the lobby offers **繼續對局 Resume** above the two ways to | |
| 369 | - | start a fresh one, showing whether it was a solo or a hotseat game, the round, | |
| 370 | - | hand number, chip counts and when it was saved. Which seats the computer holds | |
| 371 | - | is part of the state, so a save resumes as the kind of game it was. A | |
| 419 | + | nothing is lost — the lobby offers **繼續對局 Resume** above the ways to start a | |
| 420 | + | fresh one, showing whether it was a solo game or a table, the round, hand | |
| 421 | + | number, chip counts and when it was saved. Which seats the computer holds is | |
| 422 | + | part of the state, so a save resumes as the kind of game it was — a solo game | |
| 423 | + | here, a table with its room opened again behind it, so the phones that were on | |
| 424 | + | it can come back. A | |
| 372 | 425 | save is validated before it is offered (four players, 144 tiles accounted for), | |
| 373 | 426 | so a truncated or hand-edited one is ignored rather than loaded into a broken | |
| 374 | 427 | table. `VERSION` in `save.ts` retires old saves if the state shape changes. | |
| ⋯ 75 unchanged lines | |||
| 450 | 503 | rather than building one per contact. Old Safari has no `StereoPannerNode`; it | |
| 451 | 504 | plays where it always did. | |
| 452 | 505 | ||
| 453 | - | **At a party table, the words come out of the player.** Five devices in one | |
| 506 | + | **At a shared table, the words come out of the player.** Five devices in one | |
| 454 | 507 | room, and the split the sound should take is the one the room already has: the | |
| 455 | 508 | table makes the noise, because the tiles are on the table, and the phone in | |
| 456 | - | somebody's hand says their calls, because the calls are theirs. So the party | |
| 509 | + | somebody's hand says their calls, because the calls are theirs. So the table's | |
| 457 | 510 | screen drops the *voice* for a seat while keeping the clack, and the phone | |
| 458 | 511 | holding that seat says 碰 and names its own discards and makes no table noise | |
| 459 | 512 | at all. | |
| ⋯ 120 unchanged lines | |||
| 580 | 633 | src/net/table.ts TableCtl over the wire: mirror + intents + hand overlay | |
| 581 | 634 | src/net/protocol.ts what crosses the wire, in one place | |
| 582 | 635 | src/net/handOrder.ts your own arrangement, kept on your own device | |
| 583 | - | src/ui/Controller.tsx the phone at the party table: your tiles and the throw | |
| 636 | + | src/ui/Controller.tsx the phone at the shared table: your tiles and the throw | |
| 584 | 637 | src/ui/OnlineLobby.tsx seats and the deal button, before an online game | |
| 585 | 638 | server/rooms.ts the relay: rooms, host election, state fan-out | |
| 586 | 639 | server/index.ts production: dist/ and the relay from one port | |
| ⋯ 195 unchanged lines | |||
modifiedsrc/App.tsx+84 −38
| 1 | 1 | import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; | |
| 2 | 2 | import { AutoPlay } from './game/autoplay'; | |
| 3 | 3 | import type { TableCtl } from './game/ctl'; | |
| 4 | - | import { Game, type SoundCue } from './game/engine'; | |
| 4 | + | import { Game, type GameState, type SoundCue } from './game/engine'; | |
| 5 | 5 | import { loadPrefs, savePrefs, type Lang, type Prefs } from './game/prefs'; | |
| 6 | 6 | import { loadSave } from './game/save'; | |
| 7 | 7 | import { | |
| ⋯ 279 unchanged lines | |||
| 287 | 287 | if (compact && prefs.fullscreen && !fullscreen) enterFullscreen(); | |
| 288 | 288 | game.newGame(bots, prefs.names); | |
| 289 | 289 | }; | |
| 290 | + | ||
| 291 | + | /** | |
| 292 | + | * 四人同桌 — the table itself. Four people at this screen, each hand face | |
| 293 | + | * down under a tap, and any of them free to take their hand onto a phone | |
| 294 | + | * from the QR in their own gear. Hotseat and 手機派對 used to be two buttons | |
| 295 | + | * on this lobby and they were never two games: one was the other with the | |
| 296 | + | * phones left in your pocket. | |
| 297 | + | * | |
| 298 | + | * So the tiles are dealt here and now — a screen with no server behind it is | |
| 299 | + | * still four people at a table, and nobody should watch a spinner to start | |
| 300 | + | * playing — and the room is opened behind it. When it lands it inherits the | |
| 301 | + | * hand in progress rather than dealing over the top of it, and the only | |
| 302 | + | * thing that changes on screen is that the QRs start working. If it never | |
| 303 | + | * lands, nothing does. | |
| 304 | + | */ | |
| 305 | + | const openTable = async (from?: GameState) => { | |
| 306 | + | if (compact && prefs.fullscreen && !fullscreen) enterFullscreen(); | |
| 307 | + | if (from) game.restore(from); | |
| 308 | + | else game.newGame(HOTSEAT_SEATS, prefs.names); | |
| 309 | + | const s = await connect(); | |
| 310 | + | if (s?.isHost && !s.mode) s.startParty(prefs.names, prefs.rules, game.state); | |
| 311 | + | }; | |
| 290 | 312 | const resumeGame = () => { | |
| 291 | - | if (compact && prefs.fullscreen && !fullscreen) enterFullscreen(); | |
| 292 | - | game.restore(saved!.state); | |
| 313 | + | // A saved game the computer was playing stays a solo game; a saved table | |
| 314 | + | // opens its room again, so the phones that were on it can come back. | |
| 315 | + | if (saved!.state.bots.some(Boolean)) { | |
| 316 | + | if (compact && prefs.fullscreen && !fullscreen) enterFullscreen(); | |
| 317 | + | game.restore(saved!.state); | |
| 318 | + | } else { | |
| 319 | + | void openTable(saved!.state); | |
| 320 | + | } | |
| 293 | 321 | }; | |
| 294 | 322 | ||
| 295 | - | // The two networked ways to start. Whoever creates the room is its host and | |
| 296 | - | // may open a mode; a visitor who follows the link into a room where nothing | |
| 297 | - | // has started yet waits for the host's choice. | |
| 323 | + | // Online is still its own thing: everybody on their own device, nobody | |
| 324 | + | // sharing a screen, and a seat lobby before anything is dealt. Whoever | |
| 325 | + | // creates the room is its host and may open a mode; a visitor who follows | |
| 326 | + | // the link into a room where nothing has started yet waits for the choice. | |
| 298 | 327 | const openOnline = async () => { | |
| 299 | 328 | const s = await connect(); | |
| 300 | 329 | if (s?.isHost && !s.mode) s.openOnlineLobby(); | |
| 301 | 330 | }; | |
| 302 | - | const openParty = async () => { | |
| 303 | - | const s = await connect(); | |
| 304 | - | if (s?.isHost && !s.mode) s.startParty(prefs.names, prefs.rules); | |
| 305 | - | }; | |
| 306 | 331 | ||
| 307 | 332 | // Preferences are written straight through, so a mute survives a refresh. | |
| 308 | 333 | useEffect(() => { | |
| ⋯ 71 unchanged lines | |||
| 380 | 405 | {saved && ( | |
| 381 | 406 | <div className="resume"> | |
| 382 | 407 | <div className="resume-line"> | |
| 383 | - | {savedSolo ? t('單人', 'Solo') : t('同桌', 'Hotseat')} ·{' '} | |
| 408 | + | {savedSolo ? t('單人', 'Solo') : t('同桌', 'Table')} ·{' '} | |
| 384 | 409 | {t( | |
| 385 | 410 | `${WIND_NAMES[round]}圈 · 第 ${saved.state.handNumber} 局`, | |
| 386 | 411 | `${WIND_EN[round]} round, hand ${saved.state.handNumber}`, | |
| ⋯ 31 unchanged lines | |||
| 418 | 443 | /> | |
| 419 | 444 | </em> | |
| 420 | 445 | </button> | |
| 421 | - | <button className="btn big-btn ghost" onClick={() => startGame(HOTSEAT_SEATS)}> | |
| 422 | - | <Bi zh="四人同桌" en="Hotseat" /> | |
| 446 | + | {/* One table, phones optional — see openTable. Not behind the room | |
| 447 | + | check the online button is behind: this one deals first and asks | |
| 448 | + | the server afterwards. */} | |
| 449 | + | <button className="btn big-btn ghost" onClick={() => void openTable()}> | |
| 450 | + | <Bi zh="四人同桌" en="Four at one table" /> | |
| 423 | 451 | <em> | |
| 424 | - | <BiText zh="四個人共用一個螢幕" en="four people, one screen" /> | |
| 452 | + | <BiText | |
| 453 | + | zh="四個人共用一個螢幕,牌先蓋著,也可以掃碼用手機拿牌" | |
| 454 | + | en="four people, one screen — hands stay face down until you tap, and any of them can move to a phone" | |
| 455 | + | /> | |
| 425 | 456 | </em> | |
| 426 | 457 | </button> | |
| 427 | 458 | {(!session || (session.isHost && netIdle)) && ( | |
| 428 | - | <> | |
| 429 | - | <button className="btn big-btn ghost" onClick={openOnline} disabled={connecting}> | |
| 430 | - | <Bi zh="線上對戰" en="Online game" /> | |
| 431 | - | <em> | |
| 432 | - | <BiText zh="各自用自己的裝置" en="everyone joins from their own device" /> | |
| 433 | - | </em> | |
| 434 | - | </button> | |
| 435 | - | <button className="btn big-btn ghost" onClick={openParty} disabled={connecting}> | |
| 436 | - | <Bi zh="手機派對" en="Party mode" /> | |
| 437 | - | <em> | |
| 438 | - | <BiText | |
| 439 | - | zh="這螢幕當桌,手機掃碼入座" | |
| 440 | - | en="this screen is the table, scan the QR to join" | |
| 441 | - | /> | |
| 442 | - | </em> | |
| 443 | - | </button> | |
| 444 | - | </> | |
| 459 | + | <button className="btn big-btn ghost" onClick={openOnline} disabled={connecting}> | |
| 460 | + | <Bi zh="線上對戰" en="Online game" /> | |
| 461 | + | <em> | |
| 462 | + | <BiText zh="各自用自己的裝置" en="everyone joins from their own device" /> | |
| 463 | + | </em> | |
| 464 | + | </button> | |
| 445 | 465 | )} | |
| 446 | 466 | {session && !session.isHost && netIdle && ( | |
| 447 | 467 | <p className="hint"> | |
| ⋯ 76 unchanged lines | |||
| 524 | 544 | }; | |
| 525 | 545 | }; | |
| 526 | 546 | ||
| 547 | + | // Whether this screen is one several people are sitting round, in which case | |
| 548 | + | // every hand played at it starts face down: your neighbour can see your | |
| 549 | + | // tiles, and the answer used to be a piece of card laid over your strip. | |
| 550 | + | // | |
| 551 | + | // A table with a room open is always one of those, however few hands are | |
| 552 | + | // still being played on it — the people whose hands went to their phones did | |
| 553 | + | // not leave the room, they are sitting right there looking at the screen. | |
| 554 | + | // Off where the screen belongs to one player: solo, and online, where | |
| 555 | + | // everybody has a device of their own. | |
| 556 | + | const sharing = netActive | |
| 557 | + | ? netMode === 'party' | |
| 558 | + | : SEATS.filter((s) => seatProps(s).local && !ctl.state.bots[s]).length > 1; | |
| 559 | + | // Where a hand goes when its player would rather hold it themselves. Only | |
| 560 | + | // where there is a room to go to. | |
| 561 | + | const seatUrl = (seat: SeatId) => | |
| 562 | + | session && netMode === 'party' | |
| 563 | + | ? roomLink(session.room.code, session.room.publicBase, seat) | |
| 564 | + | : undefined; | |
| 565 | + | ||
| 527 | 566 | return ( | |
| 528 | 567 | <div className="table"> | |
| 529 | 568 | {SEATS.map((s) => { | |
| ⋯ 5 unchanged lines | |||
| 535 | 574 | seat={s} | |
| 536 | 575 | pos={posOf(s)} | |
| 537 | 576 | local={sp.local} | |
| 577 | + | shared={sharing} | |
| 538 | 578 | remote={sp.remote} | |
| 539 | 579 | compact={compact} | |
| 540 | 580 | pool={compact ? undefined : pool} | |
| 541 | 581 | onHelp={() => setHelpSeat(posOf(s))} | |
| 542 | - | onMenu={compact ? undefined : () => setMenuSeat(posOf(s))} | |
| 582 | + | onMenu={compact ? undefined : () => setMenuSeat(s)} | |
| 543 | 583 | /> | |
| 544 | 584 | ); | |
| 545 | 585 | })} | |
| 546 | 586 | {/* The tiles in the middle. Not on a phone: the compact layout has no | |
| 547 | 587 | centre square for them to be thrown into. */} | |
| 548 | 588 | {!compact && <Pool game={ctl} pool={pool} viewSeat={viewSeat} />} | |
| 549 | - | {/* Party: one QR for the whole table, lying in the middle of the felt | |
| 550 | - | as a giant face-up tile. Scanning it lands a phone on the seat | |
| 551 | - | picker. Once every hand is held it steps aside, and comes back while | |
| 552 | - | any seat's 📱 tag is held down — for a player who lost theirs. */} | |
| 589 | + | {/* One QR for the whole table, lying in the middle of the felt as a | |
| 590 | + | giant face-up tile. Scanning it lands a phone on the seat picker. | |
| 591 | + | It is up while the table is still being sat down at — nothing thrown | |
| 592 | + | yet, and a hand still to be claimed — and steps aside the moment play | |
| 593 | + | starts, because from then on the middle is where the tiles go and a | |
| 594 | + | phone is asked for from the seat's own gear instead. It comes back | |
| 595 | + | while any seat's 📱 tag is held down, for a player who lost theirs. */} | |
| 553 | 596 | {netMode === 'party' && | |
| 554 | 597 | !compact && | |
| 555 | - | (qrPeek || session!.seats.some((s) => s.ids.length === 0)) && ( | |
| 598 | + | (qrPeek || | |
| 599 | + | (session!.seats.some((s) => s.ids.length === 0) && | |
| 600 | + | ctl.state.players.every((p) => p.discards.length === 0))) && ( | |
| 556 | 601 | <div className="qr-dock"> | |
| 557 | 602 | <SeatQr url={roomLink(session!.room.code, session!.room.publicBase)} /> | |
| 558 | 603 | <span className="qr-hint"> | |
| ⋯ 16 unchanged lines | |||
| 575 | 620 | <TableMenu | |
| 576 | 621 | game={ctl} | |
| 577 | 622 | prefs={prefs} | |
| 578 | - | seat={menuSeat} | |
| 623 | + | pos={posOf(menuSeat)} | |
| 624 | + | seatUrl={seatUrl(menuSeat)} | |
| 579 | 625 | compact={compact} | |
| 580 | 626 | onPrefs={setPrefs} | |
| 581 | 627 | onSettings={() => setSettingsOpen(true)} | |
| ⋯ 20 unchanged lines | |||
modifiedsrc/net/protocol.ts+12 −8
| ⋯ 122 unchanged lines | |||
| 123 | 123 | } | |
| 124 | 124 | ||
| 125 | 125 | /** | |
| 126 | - | * The link the table's QR carries: this very page, with just the room in the | |
| 127 | - | * query. No seat — a phone that scans it lands on the seat picker, which is | |
| 128 | - | * also what lets a pair deliberately share a hand. (An old link with a `seat` | |
| 129 | - | * param still works: seatFromUrl above keeps reading it.) The page's own URL | |
| 130 | - | * serves the game directly and keeps every param, which on a phone also means | |
| 131 | - | * the whole screen belongs to the hand. | |
| 126 | + | * The link a QR carries: this very page, with the room in the query. | |
| 127 | + | * | |
| 128 | + | * Without a seat it is the table's own — a phone that scans it lands on the | |
| 129 | + | * seat picker, which is also what lets a pair deliberately share a hand. With | |
| 130 | + | * one it is a particular hand's, the QR inside that seat's own gear, and | |
| 131 | + | * scanning it takes that hand off the shared screen and onto the phone without | |
| 132 | + | * anybody having to say which chair they are in. | |
| 133 | + | * | |
| 134 | + | * The page's own URL serves the game directly and keeps every param, which on | |
| 135 | + | * a phone also means the whole screen belongs to the hand. | |
| 132 | 136 | * | |
| 133 | 137 | * `base` is the server's public face (`room.publicBase` — its rsgrok | |
| 134 | 138 | * tunnel): a link has to be an address the phone can reach, which the host | |
| 135 | 139 | * screen's own `localhost` is not. The path survives the swap; the tunnel | |
| 136 | 140 | * fronts the same server. | |
| 137 | 141 | */ | |
| 138 | - | export function roomLink(code: string, base?: string): string { | |
| 142 | + | export function roomLink(code: string, base?: string, seat?: SeatId): string { | |
| 139 | 143 | const u = new URL(window.location.href); | |
| 140 | 144 | if (base) { | |
| 141 | 145 | try { | |
| ⋯ 4 unchanged lines | |||
| 146 | 150 | // A malformed base loses to a working local link. | |
| 147 | 151 | } | |
| 148 | 152 | } | |
| 149 | - | u.search = `?room=${code}`; | |
| 153 | + | u.search = seat === undefined ? `?room=${code}` : `?room=${code}&seat=${seat}`; | |
| 150 | 154 | u.hash = ''; | |
| 151 | 155 | return u.toString(); | |
| 152 | 156 | } | |
modifiedsrc/net/session.ts+22 −8
| 1 | 1 | import { AutoPlay } from '../game/autoplay'; | |
| 2 | 2 | import type { NetThrow } from '../game/ctl'; | |
| 3 | - | import type { SoundEvent } from '../game/engine'; | |
| 3 | + | import type { GameState, SoundEvent } from '../game/engine'; | |
| 4 | 4 | import type { Tile } from '../game/tiles'; | |
| 5 | 5 | import type { Rules, SeatId } from '../game/types'; | |
| 6 | 6 | import { | |
| ⋯ 232 unchanged lines | |||
| 239 | 239 | } | |
| 240 | 240 | ||
| 241 | 241 | /** | |
| 242 | - | * The party table: this device becomes the common screen and deals at once. | |
| 243 | - | * All four seats start as people at this screen, exactly like hotseat — a | |
| 244 | - | * phone that scans a seat's QR takes that seat over, and gives it back if it | |
| 245 | - | * leaves. | |
| 242 | + | * Open the room around this screen's table: this device becomes the common | |
| 243 | + | * screen, and all four seats start as people sitting at it — which is | |
| 244 | + | * exactly what they already were. A phone that scans a seat's QR takes that | |
| 245 | + | * hand over, and gives it back if it leaves. | |
| 246 | + | * | |
| 247 | + | * `from` is a game already being played here, and is the usual case: the | |
| 248 | + | * table deals the moment it is asked to and opens the room afterwards, so | |
| 249 | + | * nobody waits on a websocket to start playing and a screen with no server | |
| 250 | + | * behind it is still four people at a table. The room, when it arrives, | |
| 251 | + | * inherits the hand in progress rather than dealing over the top of it. | |
| 246 | 252 | */ | |
| 247 | - | startParty(names: string[], rules: Rules) { | |
| 253 | + | startParty(names: string[], rules: Rules, from?: GameState) { | |
| 248 | 254 | console.log(`[mahjong] startParty (host: ${this.isHost})`); | |
| 249 | 255 | if (!this.isHost) return; | |
| 250 | 256 | this.role = 'screen'; | |
| 257 | + | // The screen is where the save lives — it is the device that will still be | |
| 258 | + | // here tomorrow. Phones mirror; they have no business writing it. | |
| 259 | + | this.table.engine.persist = true; | |
| 251 | 260 | const seats = EMPTY_SEATS.map((_, i) => ({ ids: [], name: names[i] ?? '' })); | |
| 252 | 261 | this.room.setState({ | |
| 253 | 262 | mode: 'party', | |
| ⋯ 2 unchanged lines | |||
| 256 | 265 | voices: [false, false, false, false] as never, | |
| 257 | 266 | }); | |
| 258 | 267 | const eng = this.table.engine; | |
| 259 | - | eng.applySettings(names, rules); | |
| 260 | - | eng.newGame([false, false, false, false], names); | |
| 268 | + | if (from) { | |
| 269 | + | // Its own copy: the local engine goes on owning the object it handed us. | |
| 270 | + | eng.restore(structuredClone(from)); | |
| 271 | + | } else { | |
| 272 | + | eng.applySettings(names, rules); | |
| 273 | + | eng.newGame([false, false, false, false], names); | |
| 274 | + | } | |
| 261 | 275 | } | |
| 262 | 276 | ||
| 263 | 277 | /** Ask for a seat — a numbered one, or any free one. */ | |
| ⋯ 208 unchanged lines | |||
modifiedsrc/styles.css+68 −0
| ⋯ 265 unchanged lines | |||
| 266 | 266 | align-items: flex-end; | |
| 267 | 267 | flex: none; | |
| 268 | 268 | } | |
| 269 | + | /* Your own hand, face down at a shared screen until you tap it. The backs are | |
| 270 | + | already the picture; all this adds is that it is *askable* — a hand that | |
| 271 | + | lifts a little under the finger, the way a card laid over a strip does. The | |
| 272 | + | three other hands, which are face down because they are not yours, get none | |
| 273 | + | of it and stay flat. See the `shared` prop in ui/Seat. */ | |
| 274 | + | .hand.covered { | |
| 275 | + | cursor: pointer; | |
| 276 | + | transition: transform 0.12s ease-out, filter 0.12s ease-out; | |
| 277 | + | } | |
| 278 | + | .hand.covered:hover, | |
| 279 | + | .hand.covered:focus-visible { | |
| 280 | + | transform: translateY(-3px); | |
| 281 | + | filter: brightness(1.12); | |
| 282 | + | outline: none; | |
| 283 | + | } | |
| 284 | + | .hand.covered:active { | |
| 285 | + | transform: translateY(-1px); | |
| 286 | + | } | |
| 287 | + | ||
| 269 | 288 | /* Somebody has thrown a tile hard enough to come off the pool and into this | |
| 270 | 289 | row. It takes the knock and rocks back — the sound is `barge` in game/sound, | |
| 271 | 290 | and what decides it happened is the physics, in ui/Pool. Inside the seat's | |
| ⋯ 384 unchanged lines | |||
| 656 | 675 | :root:not(.compact) .actionbar .btn.claim.chow { | |
| 657 | 676 | color: #1d6640; | |
| 658 | 677 | } | |
| 678 | + | /* 看牌 is the only button a covered hand has, and it is the one button here | |
| 679 | + | that is not done *with* a tile — it is done to the card lying over them. So | |
| 680 | + | it wears the back rather than the face, and reads as the thing beside it. */ | |
| 681 | + | :root:not(.compact) .actionbar .btn.reveal { | |
| 682 | + | background: url('./assets/back.png') center / 100% 100%; | |
| 683 | + | color: var(--ivory); | |
| 684 | + | text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); | |
| 685 | + | } | |
| 686 | + | /* And the table is waiting on this one. Whose go it is was never a secret, so | |
| 687 | + | the ring says so — which is what stops four face-down hands and no buttons | |
| 688 | + | reading as a table that has stopped rather than one waiting for somebody to | |
| 689 | + | look at their tiles. */ | |
| 690 | + | :root:not(.compact) .actionbar .btn.reveal.hot { | |
| 691 | + | outline: 2px solid var(--gold); | |
| 692 | + | outline-offset: -2px; | |
| 693 | + | box-shadow: 0 2px 3px rgba(0, 0, 0, 0.45), 0 0 10px rgba(217, 180, 92, 0.45); | |
| 694 | + | } | |
| 659 | 695 | /* 過 and 摸牌 are not calls, so they are the plain face — and a pass reads as | |
| 660 | 696 | the quiet one it is. */ | |
| 661 | 697 | :root:not(.compact) .actionbar .btn.pass, | |
| ⋯ 1862 unchanged lines | |||
| 2524 | 2560 | flex-direction: column; | |
| 2525 | 2561 | gap: 8px; | |
| 2526 | 2562 | margin-top: 10px; | |
| 2563 | + | /* The panel is a fixed height at its player's edge, and the QR at the foot | |
| 2564 | + | of this list is what first made the list taller than it. Scroll rather | |
| 2565 | + | than spill: the panel is turned to face one player, and a menu that ran | |
| 2566 | + | off the table would run off it upside down for two of them. */ | |
| 2567 | + | min-height: 0; | |
| 2568 | + | overflow-y: auto; | |
| 2527 | 2569 | } | |
| 2528 | 2570 | .menu-body .btn { | |
| 2529 | 2571 | justify-content: flex-start; | |
| ⋯ 3 unchanged lines | |||
| 2533 | 2575 | opacity: 0.4; | |
| 2534 | 2576 | cursor: default; | |
| 2535 | 2577 | } | |
| 2578 | + | /* The way off this screen: the QR for taking *this* hand onto a phone, turned | |
| 2579 | + | to face the player whose hand it is along with everything else in here. | |
| 2580 | + | Smaller than the one that lies in the middle of the felt — a phone held over | |
| 2581 | + | an open menu is a foot away, not across the table. */ | |
| 2582 | + | .menu-qr { | |
| 2583 | + | display: flex; | |
| 2584 | + | align-items: center; | |
| 2585 | + | gap: 10px; | |
| 2586 | + | padding-top: 4px; | |
| 2587 | + | border-top: 1px solid rgba(246, 240, 226, 0.14); | |
| 2588 | + | } | |
| 2589 | + | .menu-qr .seat-qr { | |
| 2590 | + | --qw: 92px; | |
| 2591 | + | flex: none; | |
| 2592 | + | } | |
| 2593 | + | .menu-qr-hint { | |
| 2594 | + | font-size: 12px; | |
| 2595 | + | line-height: 1.35; | |
| 2596 | + | opacity: 0.75; | |
| 2597 | + | } | |
| 2598 | + | .menu-qr-hint em { | |
| 2599 | + | display: block; | |
| 2600 | + | font-style: normal; | |
| 2601 | + | font-size: 11px; | |
| 2602 | + | opacity: 0.75; | |
| 2603 | + | } | |
modifiedsrc/ui/Hand.tsx+17 −1
| ⋯ 16 unchanged lines | |||
| 17 | 17 | tiles: Tile[]; | |
| 18 | 18 | /** Somebody else's hand: shown as backs, and not to be poked at. */ | |
| 19 | 19 | faceDown?: boolean; | |
| 20 | + | /** | |
| 21 | + | * Face down but yours to turn over — the shared table, where a hand starts | |
| 22 | + | * covered and a tap on the backs lifts the card off it. Absent on a hand | |
| 23 | + | * that is face down because it is not yours to see. | |
| 24 | + | */ | |
| 25 | + | onFaceUp?: () => void; | |
| 20 | 26 | /** Index of the tile held apart from the rest, if any. */ | |
| 21 | 27 | drawnIndex: number | null; | |
| 22 | 28 | selected: number | null; | |
| ⋯ 136 unchanged lines | |||
| 159 | 165 | export function Hand({ | |
| 160 | 166 | tiles, | |
| 161 | 167 | faceDown, | |
| 168 | + | onFaceUp, | |
| 162 | 169 | drawnIndex, | |
| 163 | 170 | selected, | |
| 164 | 171 | canDiscard, | |
| ⋯ 127 unchanged lines | |||
| 292 | 299 | }; | |
| 293 | 300 | ||
| 294 | 301 | if (faceDown) { | |
| 302 | + | // The whole row is the tap target when it is yours to turn over: sixteen | |
| 303 | + | // tile-sized buttons would be sixteen ways to do one thing. | |
| 295 | 304 | return ( | |
| 296 | - | <div className="hand"> | |
| 305 | + | <div | |
| 306 | + | className={`hand ${onFaceUp ? 'covered' : ''}`} | |
| 307 | + | role={onFaceUp ? 'button' : undefined} | |
| 308 | + | tabIndex={onFaceUp ? 0 : undefined} | |
| 309 | + | aria-label={onFaceUp ? t('看牌', 'look at your hand', ' · ') : undefined} | |
| 310 | + | onClick={onFaceUp} | |
| 311 | + | onKeyDown={onFaceUp && ((e) => (e.key === 'Enter' || e.key === ' ') && onFaceUp())} | |
| 312 | + | > | |
| 297 | 313 | {tiles.map((_, i) => ( | |
| 298 | 314 | <div key={i} className="hand-cell"> | |
| 299 | 315 | <TileView faceDown /> | |
| ⋯ 50 unchanged lines | |||
modifiedsrc/ui/Seat.tsx+66 −1
| ⋯ 46 unchanged lines | |||
| 47 | 47 | * (the tiles' faces, the 聽 and 過水 badges, the buttons) keys off this. | |
| 48 | 48 | */ | |
| 49 | 49 | local?: boolean; | |
| 50 | + | /** | |
| 51 | + | * More than one person is playing at this screen, so a hand this device may | |
| 52 | + | * act for is still not a hand everybody may read. It starts face down and is | |
| 53 | + | * turned over with a tap — the piece of card people used to lay over their | |
| 54 | + | * strip, which is what the game asked for before it could do this itself. | |
| 55 | + | * | |
| 56 | + | * False where the screen belongs to one player — solo, online, a phone — | |
| 57 | + | * because covering your own hand from yourself is just a tap in the way. | |
| 58 | + | */ | |
| 59 | + | shared?: boolean; | |
| 50 | 60 | remote?: RemoteSeat; | |
| 51 | 61 | /** Phone-sized table: nothing is rotated and only the bottom gets a full strip. */ | |
| 52 | 62 | compact?: boolean; | |
| ⋯ 10 unchanged lines | |||
| 63 | 73 | seat, | |
| 64 | 74 | pos = seat, | |
| 65 | 75 | local = true, | |
| 76 | + | shared, | |
| 66 | 77 | remote, | |
| 67 | 78 | compact, | |
| 68 | 79 | pool, | |
| ⋯ 7 unchanged lines | |||
| 76 | 87 | const bot = s.bots[seat]; | |
| 77 | 88 | // Somebody this device answers for: a person here, not the computer, not a | |
| 78 | 89 | // hand that lives on another screen. | |
| 79 | - | const acts = local && !bot; | |
| 90 | + | const plays = local && !bot; | |
| 91 | + | // Face down with your own hand under it: three other people are looking at | |
| 92 | + | // this screen. Lifted by a tap and put back by one, and put back on its own | |
| 93 | + | // when the hand is dealt and when this seat has finished its turn — the two | |
| 94 | + | // moments the tiles stop being needed and would otherwise sit there in the | |
| 95 | + | // open. See the `shared` prop. | |
| 96 | + | const [shown, setShown] = useState(false); | |
| 97 | + | const covered = !!shared && plays && !shown; | |
| 98 | + | // Somebody this device answers for *and* may read: everything private — the | |
| 99 | + | // tiles' faces, the 聽 and 過水 badges, the buttons that name a tile — keys | |
| 100 | + | // off this rather than off who is sitting there. | |
| 101 | + | const acts = plays && !covered; | |
| 80 | 102 | // A hand this device may not read goes face down and its 聽 / 過水 badges | |
| 81 | 103 | // stay off, since both would give it away. At the end of a hand everyone | |
| 82 | 104 | // turns their tiles over, as they would. | |
| ⋯ 10 unchanged lines | |||
| 93 | 115 | setSel(null); | |
| 94 | 116 | }, [myTurn, s.handNumber, p.hand.length, s.drawnTile]); | |
| 95 | 117 | ||
| 118 | + | // The card goes back over the hand when it is dealt, and again when this | |
| 119 | + | // seat's turn ends. Both are moments the tiles stop being needed, and | |
| 120 | + | // without them a hand turned over once stays turned over for the rest of the | |
| 121 | + | // evening — which is the same as never having covered it. Everything else is | |
| 122 | + | // the player's own tap. | |
| 123 | + | const dealt = s.phase === 'lobby' ? -1 : s.handNumber; | |
| 124 | + | useEffect(() => { | |
| 125 | + | setShown(false); | |
| 126 | + | }, [dealt]); | |
| 127 | + | const wasPlaying = useRef(false); | |
| 128 | + | const playing = s.phase === 'turn' && s.turn === seat; | |
| 129 | + | /** | |
| 130 | + | * The table is waiting on this seat: its go, or its pick-up that closes a | |
| 131 | + | * claim window. Both are public — whose turn it is has never been a secret — | |
| 132 | + | * so a covered hand may light its 看牌 without saying anything about what is | |
| 133 | + | * under the card. Without it a table of four face-down hands and no buttons | |
| 134 | + | * anywhere reads as stuck rather than as waiting for somebody to look. | |
| 135 | + | */ | |
| 136 | + | const awaited = plays && (playing || game.nextDrawer() === seat); | |
| 137 | + | useEffect(() => { | |
| 138 | + | if (wasPlaying.current && !playing) setShown(false); | |
| 139 | + | wasPlaying.current = playing; | |
| 140 | + | }, [playing]); | |
| 141 | + | ||
| 96 | 142 | const seatWind = (seat - s.dealer + 4) % 4; | |
| 97 | 143 | const isDealer = seat === s.dealer; | |
| 98 | 144 | const live = s.phase !== 'handEnd' && s.phase !== 'lobby'; | |
| ⋯ 148 unchanged lines | |||
| 247 | 293 | <Hand | |
| 248 | 294 | tiles={p.hand} | |
| 249 | 295 | faceDown={concealed} | |
| 296 | + | onFaceUp={covered ? () => setShown(true) : undefined} | |
| 250 | 297 | drawnIndex={drawnIndex} | |
| 251 | 298 | selected={sel} | |
| 252 | 299 | canDiscard={myTurn} | |
| ⋯ 4 unchanged lines | |||
| 257 | 304 | /> | |
| 258 | 305 | ||
| 259 | 306 | <div className="actionbar"> | |
| 307 | + | {/* The one thing a covered hand can do. Every other button here | |
| 308 | + | names a tile — 打出 五萬, 吃 with these two — so they wait until | |
| 309 | + | their player is the one looking. */} | |
| 310 | + | {covered && ( | |
| 311 | + | <button | |
| 312 | + | className={`btn reveal ${awaited ? 'hot' : ''}`} | |
| 313 | + | onClick={() => setShown(true)} | |
| 314 | + | > | |
| 315 | + | <Bi zh="看牌" en="Look" /> | |
| 316 | + | </button> | |
| 317 | + | )} | |
| 260 | 318 | {claiming && ( | |
| 261 | 319 | <> | |
| 262 | 320 | {opts!.map((o, i) => ( | |
| ⋯ 123 unchanged lines | |||
| 386 | 444 | {t('理牌', 'Sort')} | |
| 387 | 445 | </button> | |
| 388 | 446 | )} | |
| 447 | + | {/* Put it back yourself, without waiting for the turn to end — | |
| 448 | + | somebody has walked round behind you, or you are done looking. */} | |
| 449 | + | {shared && plays && shown && s.phase !== 'handEnd' && s.phase !== 'gameEnd' && ( | |
| 450 | + | <button className="btn ghost sort" onClick={() => setShown(false)}> | |
| 451 | + | {t('蓋牌', 'Cover')} | |
| 452 | + | </button> | |
| 453 | + | )} | |
| 389 | 454 | <button className="helpbtn" onClick={onHelp} aria-label={t('規則', 'rules')}> | |
| 390 | 455 | ? | |
| 391 | 456 | </button> | |
| ⋯ 194 unchanged lines | |||
modifiedsrc/ui/TableMenu.tsx+26 −4
| ⋯ 4 unchanged lines | |||
| 5 | 5 | import { FullscreenToggle } from './FullscreenToggle'; | |
| 6 | 6 | import { useLang, useT } from './lang'; | |
| 7 | 7 | import { SEAT_ROT, SEAT_SLOT } from './rotation'; | |
| 8 | + | import { SeatQr } from './SeatQr'; | |
| 8 | 9 | import { SoundToggle } from './SoundToggle'; | |
| 9 | 10 | ||
| 10 | 11 | /** | |
| ⋯ 14 unchanged lines | |||
| 25 | 26 | export function TableMenu({ | |
| 26 | 27 | game, | |
| 27 | 28 | prefs, | |
| 28 | - | seat, | |
| 29 | + | pos, | |
| 29 | 30 | compact, | |
| 31 | + | seatUrl, | |
| 30 | 32 | onPrefs, | |
| 31 | 33 | onSettings, | |
| 32 | 34 | onClose, | |
| ⋯ 1 unchanged line | |||
| 34 | 36 | game: TableCtl; | |
| 35 | 37 | prefs: Prefs; | |
| 36 | 38 | /** The edge this is drawn at — the seat's own, so it faces its player. */ | |
| 37 | - | seat: SeatId; | |
| 39 | + | pos: SeatId; | |
| 38 | 40 | compact?: boolean; | |
| 41 | + | /** | |
| 42 | + | * The QR for taking *this hand* onto a phone, where there is a room to take | |
| 43 | + | * it into. Absent when the table is on its own — no server, or an online | |
| 44 | + | * game, where everybody is already on their own device. | |
| 45 | + | */ | |
| 46 | + | seatUrl?: string; | |
| 39 | 47 | onPrefs: (p: Prefs) => void; | |
| 40 | 48 | onSettings: () => void; | |
| 41 | 49 | onClose: () => void; | |
| ⋯ 4 unchanged lines | |||
| 46 | 54 | const undoable = s.phase === 'gameEnd' ? null : game.undoable(); | |
| 47 | 55 | ||
| 48 | 56 | return ( | |
| 49 | - | <div className={`help-slot ${SEAT_SLOT[seat]}`} onClick={onClose}> | |
| 57 | + | <div className={`help-slot ${SEAT_SLOT[pos]}`} onClick={onClose}> | |
| 50 | 58 | <div | |
| 51 | 59 | className="help menu" | |
| 52 | 60 | style={{ | |
| 53 | 61 | transform: compact | |
| 54 | 62 | ? 'translate(-50%, -50%)' | |
| 55 | - | : `translate(-50%, -50%) rotate(${SEAT_ROT[seat]}deg)`, | |
| 63 | + | : `translate(-50%, -50%) rotate(${SEAT_ROT[pos]}deg)`, | |
| 56 | 64 | }} | |
| 57 | 65 | onClick={(e) => e.stopPropagation()} | |
| 58 | 66 | > | |
| ⋯ 38 unchanged lines | |||
| 97 | 105 | <SoundToggle prefs={prefs} onPrefs={onPrefs} /> | |
| 98 | 106 | <FullscreenToggle prefs={prefs} onPrefs={onPrefs} /> | |
| 99 | 107 | ||
| 108 | + | {/* Taking this hand off the shared screen. The QR is the seat's own, | |
| 109 | + | so scanning it lands on this chair without anybody having to say | |
| 110 | + | which one they are in — and the tiles here go face down for good | |
| 111 | + | rather than under a card that lifts. */} | |
| 112 | + | {seatUrl && ( | |
| 113 | + | <div className="menu-qr"> | |
| 114 | + | <SeatQr url={seatUrl} /> | |
| 115 | + | <span className="menu-qr-hint"> | |
| 116 | + | {lang !== 'en' && '掃碼把這副牌拿到手機上'} | |
| 117 | + | {lang !== 'zh' && <em>scan to hold this hand on your phone</em>} | |
| 118 | + | </span> | |
| 119 | + | </div> | |
| 120 | + | )} | |
| 121 | + | ||
| 100 | 122 | {/* The moment anybody wants a name or the stakes changed is the | |
| 101 | 123 | moment they went looking for a gear. */} | |
| 102 | 124 | <button | |
| ⋯ 13 unchanged lines | |||