collin/mahjong · 2317d367
One game, and the hands change hands while it is being played
Collin Richards · 2026-08-25 10:04 UTC · 2317d367e8f3d4971d4fb5809b0bd7e6ec2c9c6d · parent ca82579b · browse files
modifiedREADME.md+118 −79
| ⋯ 6 unchanged lines | |||
| 7 | 7 | English underneath; the tiles themselves stay Chinese, because that is what the | |
| 8 | 8 | tiles say. | |
| 9 | 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). | |
| 10 | + | **開始** is the only button on the lobby, because there is one game. It deals | |
| 11 | + | you in at the bottom edge with the computer on the other three, on this device, | |
| 12 | + | without a word said to any server. Everything people call a mode is that table | |
| 13 | + | with different hands changing hands, one at a time, mid-game: | |
| 14 | + | ||
| 15 | + | - somebody sits down at this screen and **taps a hand the computer has been | |
| 16 | + | playing** — the tiles turn over and are theirs; | |
| 17 | + | - somebody **scans a seat's QR** and takes that hand onto their phone; | |
| 18 | + | - somebody **opens the invite link** from three cities away and takes one from | |
| 19 | + | their own laptop, with the table turned so their seat is the near edge. | |
| 20 | + | ||
| 21 | + | Each of those swaps one seat and redeals nothing, and each undoes: a hand goes | |
| 22 | + | back to the computer with a tap, and one that loses its device gets given back | |
| 23 | + | to the computer on its own so the table never stops. See | |
| 24 | + | [one table](#one-table), [computer players](#computer-players) and | |
| 25 | + | [playing online](#playing-online). | |
| 23 | 26 | ||
| 24 | 27 | ``` | |
| 25 | 28 | npm install | |
| ⋯ 29 unchanged lines | |||
| 55 | 58 | strip has is 看牌: every other one names a tile — 打出 五萬, 吃 with these two | |
| 56 | 59 | — and would say through the card what is under it. The 看牌 of whoever the | |
| 57 | 60 | 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. | |
| 61 | + | secret. Nothing is covered where the screen is playing one hand, which is a | |
| 62 | + | screen that belongs to one player — three computers and you, or your own | |
| 63 | + | tiles on your own device. | |
| 60 | 64 | - **The tile you just drew** is held apart from the sorted hand with a gold ring | |
| 61 | 65 | and a 摸 label, instead of being sorted invisibly into it. It merges into the | |
| 62 | 66 | hand once you discard. 補花 replacements and kong replacements are marked the | |
| ⋯ 91 unchanged lines | |||
| 154 | 158 | skipped; the line the bar leaves behind breathes, and the table goes on | |
| 155 | 159 | waiting. It is a nudge, not a rule. Said on a covered hand too — whose turn | |
| 156 | 160 | it is was never one of the things the card is hiding — and only where the | |
| 157 | - | screen is shared: solo has nobody to tell, and online everybody is already | |
| 158 | - | looking at their own device. A phone at the table gets the same seven seconds | |
| 161 | + | screen is playing more than one hand, since a screen playing one has nobody | |
| 162 | + | to tell. A phone at the table gets the same seven seconds | |
| 159 | 163 | across its top, a gold rim edge to edge (red for a claim window), and one | |
| 160 | 164 | short buzz as the game comes round to it. | |
| 161 | 165 | - **On the keys** — ← and → walk the selection along your hand and space throws | |
| ⋯ 41 unchanged lines | |||
| 203 | 207 | whose 碰 that was without looking up, which is the whole point of hearing it. | |
| 204 | 208 | Tiles out in the middle are placed properly rather than by edge: the physics | |
| 205 | 209 | knows to the pixel where a tile landed, and that is where the knock comes | |
| 206 | - | from. Online the table is turned so your own seat is at the bottom, and the | |
| 207 | - | sound turns with it. See [audio, by seat](#audio-by-seat). | |
| 210 | + | from. A device holding one hand turns the table so that hand is at the | |
| 211 | + | bottom, and the sound turns with it. See [audio, by seat](#audio-by-seat). | |
| 208 | 212 | - **Settings** — 設定 from the lobby, or from the game-over panel: player names, | |
| 209 | 213 | 底 / 台 / starting chips, the house-rule switches below, and sound. Kept in | |
| 210 | 214 | `localStorage` separately from the save, so they carry over to the next game. | |
| ⋯ 2 unchanged lines | |||
| 213 | 217 | ||
| 214 | 218 | ## One table | |
| 215 | 219 | ||
| 216 | - | 四人同桌 and 手機派對 were two buttons on the lobby, and they were never two | |
| 217 | - | games: one was the other with the phones left in people's pockets. Every party | |
| 218 | - | table played its unclaimed seats hotseat-style at the screen already, and every | |
| 219 | - | hotseat table would have taken a phone if it had had a room to put it in. So | |
| 220 | - | there is one button now, and picking up your phone is not a mode — it is | |
| 221 | - | something one player does, mid-hand, without asking the other three. | |
| 222 | - | ||
| 223 | - | What that costs is a decision about when to open the room, and the answer is: | |
| 224 | - | not while anybody is waiting. **The tiles are dealt at once**, on this screen, | |
| 225 | - | before a websocket has been opened — a laptop with no server behind it is still | |
| 226 | - | four people at a table, and nobody should watch a spinner to start playing. The | |
| 227 | - | room is opened behind the game, and when it lands it *inherits* the hand in | |
| 228 | - | progress (`startParty(names, rules, from)` restores rather than deals) instead | |
| 229 | - | of dealing over the top of it. The only thing that changes on screen is that | |
| 230 | - | the QRs start working. If it never lands, nothing does. | |
| 220 | + | There were three buttons on this lobby — 單人對局, 四人同桌, 線上對戰 — and | |
| 221 | + | they were never three games. Every party table played its unclaimed seats | |
| 222 | + | hotseat-style at the screen already; every hotseat table would have taken a | |
| 223 | + | phone if it had had a room to put it in; every online table filled its empty | |
| 224 | + | chairs with the same computer players the solo game is made of. They differed | |
| 225 | + | in who was holding which hand, and that is not something a lobby should have to | |
| 226 | + | know before the tiles are dealt. | |
| 227 | + | ||
| 228 | + | So there is one button, and the question it used to ask is answered | |
| 229 | + | continuously instead. A hand is played by one of three things — **the computer, | |
| 230 | + | somebody at this screen, or a device of its own** — and any of them becomes any | |
| 231 | + | other while the hand is in progress, with nothing redealt and nobody consulted | |
| 232 | + | but the person doing it. | |
| 233 | + | ||
| 234 | + | | | played by | becomes a person here | becomes a phone | | |
| 235 | + | |---|---|---|---| | |
| 236 | + | | 電腦 | the bots in `autoplay.ts` | tap the strip — 坐下 | its QR, from its gear | | |
| 237 | + | | 這裡 | this screen, face down under a tap | — | its QR, from its gear | | |
| 238 | + | | 手機 | whoever scanned in | they leave → the computer | — | | |
| 239 | + | ||
| 240 | + | **Nothing touches the network until a phone is wanted.** A table of people and | |
| 241 | + | computers is four hands and an engine; there is nothing for a server to do, and | |
| 242 | + | `src/net/` is never constructed. The room is opened by the first thing that | |
| 243 | + | needs one — the 用手機拿牌 in a seat's gear — and when it opens it *publishes | |
| 244 | + | the hand in progress* rather than dealing over the top of it | |
| 245 | + | (`NetSession.publishTable`). The engine is the page's own either way: | |
| 246 | + | `NetTable` is a wrapper that goes on around a live `Game`, not a second one, so | |
| 247 | + | opening a room costs a `structuredClone` and changes nothing on screen except | |
| 248 | + | that the QRs start working. | |
| 231 | 249 | ||
| 232 | 250 | **Each seat's QR is its own**, and lives in that seat's gear beside the sound | |
| 233 | 251 | and the undo, carrying `?room=…&seat=N`. Scanning it lands on that chair | |
| ⋯ 4 unchanged lines | |||
| 238 | 256 | still being sat down at; the moment the first tile is thrown it steps aside, | |
| 239 | 257 | because from then on the middle is where the tiles go. | |
| 240 | 258 | ||
| 241 | - | **The save follows the screen.** A table that has opened a room keeps writing | |
| 242 | - | `localStorage` (`engine.persist` on the screen's engine, off on every phone — | |
| 243 | - | they mirror, and have no business writing it), so 繼續對局 still works and | |
| 244 | - | resumes the same way the mode starts: restore here, open the room behind it, | |
| 245 | - | publish the hand as it stood. | |
| 259 | + | **A hand that loses its device is kept for fifteen seconds and then given to | |
| 260 | + | the computer** (`EMPTY_SEAT_GRACE`). A phone locking itself looks exactly like | |
| 261 | + | a phone leaving for good and only one of them means it, so the nameplate shows | |
| 262 | + | ⏳ and the table waits; when the wait is up the computer picks the tiles up and | |
| 263 | + | play carries on. Nothing is lost either way — coming back takes the seat | |
| 264 | + | straight off the computer again, and so does one tap on it at the table. The | |
| 265 | + | same countdown covers the screen closing its lid: the room records which device | |
| 266 | + | the table is being played at (`screen` in room state), and hands with neither a | |
| 267 | + | device nor a table behind them go the same way. | |
| 268 | + | ||
| 269 | + | **The save follows the table.** Whichever device is running the game writes | |
| 270 | + | `localStorage` (`engine.persist = authority`; every mirror has it off, and no | |
| 271 | + | business writing it), so 繼續對局 restores here and the room, if one is wanted | |
| 272 | + | again, opens behind it exactly as it did the first time. | |
| 273 | + | ||
| 274 | + | **Covering is arithmetic, not a mode.** A hand starts face down when this | |
| 275 | + | device is playing more than one of them — which is the definition of a screen | |
| 276 | + | several people are sitting round. One hand is one person, whether they are | |
| 277 | + | playing three computers or holding their own tiles on their own phone, and | |
| 278 | + | covering your own tiles from yourself is a tap in the way. | |
| 246 | 279 | ||
| 247 | 280 | ## Playing online | |
| 248 | 281 | ||
| 249 | - | Both networked shapes — a shared screen with phones on it, and four devices in | |
| 250 | - | four places — are one room relay of our own: `server/rooms.ts`, | |
| 282 | + | Every device that is not the table is on one room relay of our own: | |
| 283 | + | `server/rooms.ts`, | |
| 251 | 284 | a couple hundred lines of Node on `ws`. The relay knows nothing about mahjong: | |
| 252 | 285 | rooms with codes, host election by join order, one bag of shared state only the | |
| 253 | 286 | host may write, and events fanned out to everyone else. One client — the | |
| ⋯ 4 unchanged lines | |||
| 258 | 291 | end of the wire is `src/net/room.ts`; the rest of `src/net/` is the game | |
| 259 | 292 | riding it. | |
| 260 | 293 | ||
| 261 | - | - **線上對戰 Online game** — everyone on their own device. Whoever opens the | |
| 262 | - | room picks 線上對戰 and gets a seat lobby; friends who open the same link | |
| 263 | - | land in it, sit down, and the host deals. Empty seats go to the computer, | |
| 264 | - | which runs on the host client — that is what stands in for a server on the | |
| 265 | - | classic (free) tier. Each screen draws the same full table, turned so your | |
| 266 | - | own seat is the bottom edge; other people's tiles are face down. Joining | |
| 267 | - | mid-game takes over a vacant or computer seat, so a dropped friend can | |
| 268 | - | reload and carry on. | |
| 269 | - | - **四人同桌 at one screen, with phones** — the mode above, and on the wire | |
| 270 | - | still `mode: 'party'`: the laptop is the table and a phone holds one hand. | |
| 271 | - | Scanning a seat's QR loads a controller: just that hand, face up, with the | |
| 272 | - | claim buttons — and the throw. Flick a tile up off the top of the phone and | |
| 273 | - | it sails in from your edge of the common screen, at the speed and angle you | |
| 274 | - | let go of it (`NetThrow`, mapped into table coordinates by | |
| 275 | - | `TablePool.throwFromNet`). The seat's tiles go face down on the shared screen | |
| 276 | - | and stay there; hold the 📱 tag on its nameplate to show the table's QR again | |
| 277 | - | for a player who lost theirs. A phone that leaves hands the seat back to the | |
| 278 | - | laptop, where it is covered-with-a-tap like the rest. Seats never lock: a | |
| 279 | - | second phone scanning the same QR joins the same hand — two people playing | |
| 280 | - | one hand, whoever acts first acts. And with sound on, the phone in your hand | |
| 281 | - | is what says your calls — see [audio, by seat](#audio-by-seat). | |
| 294 | + | - **A device that holds one hand** shows that hand. A phone shows just the | |
| 295 | + | hand, face up, with the claim buttons — and the throw: flick a tile up off | |
| 296 | + | the top of the phone and it sails in from your edge of the common screen, at | |
| 297 | + | the speed and angle you let go of it (`NetThrow`, mapped into table | |
| 298 | + | coordinates by `TablePool.throwFromNet`). Anything bigger shows the whole | |
| 299 | + | table instead, turned so that seat is the bottom edge, with everyone else's | |
| 300 | + | tiles face down. Same room, same seat map; the only thing deciding between | |
| 301 | + | them is how much screen there is. | |
| 302 | + | - **A device that holds no hand yet** gets the seat picker, and every seat is | |
| 303 | + | offered — including the ones the computer is playing, which is the ordinary | |
| 304 | + | way in, and the ones somebody already holds, which is how two people play one | |
| 305 | + | hand off two devices. A seat's own QR names its chair and skips the picker. | |
| 306 | + | - **The device the table was opened on** goes on showing the table: the felt, | |
| 307 | + | the middle, the wall, and every hand no phone has taken, covered-with-a-tap | |
| 308 | + | like a hotseat game. Hold the 📱 tag on a nameplate to show the table's QR | |
| 309 | + | again for a player who lost theirs. With sound on, the phone in each player's | |
| 310 | + | hand is what says their calls and the table goes quiet for that seat — see | |
| 311 | + | [audio, by seat](#audio-by-seat). | |
| 282 | 312 | ||
| 283 | 313 | Design notes, in the order they bit: | |
| 284 | 314 | ||
| ⋯ 10 unchanged lines | |||
| 295 | 325 | and throttle timers in background tabs, so nothing in `src/net/` runs on a | |
| 296 | 326 | loop: state writes flush on a microtask, everything inbound arrives over the | |
| 297 | 327 | WebSocket (whose delivery is not throttled), and dead connections are the | |
| 298 | - | server's ping loop's problem. A party screen somebody tabbed away from | |
| 328 | + | server's ping loop's problem. A table screen somebody tabbed away from | |
| 299 | 329 | keeps answering. | |
| 300 | 330 | - **Identity is a UUID in sessionStorage**, chosen by the device and taken at | |
| 301 | 331 | its word by the relay. The seat map is keyed on it, so a reload or a wifi | |
| ⋯ 146 unchanged lines | |||
| 448 | 478 | ||
| 449 | 479 | The game state is plain data, so a save is just its JSON in `localStorage`, | |
| 450 | 480 | rewritten after every move. Close the lid, refresh, or run the battery flat and | |
| 451 | - | nothing is lost — the lobby offers **繼續對局 Resume** above the ways to start a | |
| 452 | - | fresh one, showing whether it was a solo game or a table, the round, hand | |
| 453 | - | number, chip counts and when it was saved. Which seats the computer holds is | |
| 454 | - | part of the state, so a save resumes as the kind of game it was — a solo game | |
| 455 | - | here, a table with its room opened again behind it, so the phones that were on | |
| 456 | - | it can come back. A | |
| 481 | + | nothing is lost — the lobby offers **繼續對局 Resume** above 開始, showing the | |
| 482 | + | round, hand number, chip counts and when it was saved. Which seats the computer | |
| 483 | + | holds is part of the state, so a save comes back with the same hands in the | |
| 484 | + | same places, and everybody who was on a phone scans back in exactly as they did | |
| 485 | + | the first time — a room is opened around a resumed table the same way it is | |
| 486 | + | opened around a fresh one, when somebody wants one. It is written by whichever | |
| 487 | + | device is running the game (`engine.persist = authority`), so the mirrors on | |
| 488 | + | everyone else's screens never fight it for the slot. A | |
| 457 | 489 | save is validated before it is offered (four players, 144 tiles accounted for), | |
| 458 | 490 | so a truncated or hand-edited one is ignored rather than loaded into a broken | |
| 459 | 491 | table. `VERSION` in `save.ts` retires old saves if the state shape changes. | |
| ⋯ 62 unchanged lines | |||
| 522 | 554 | ||
| 523 | 555 | **A seat becomes a place.** `App.tsx` turns a seat into a *position round this | |
| 524 | 556 | screen* — 0 the near edge, 1 the right, 2 across, 3 the left — because that is | |
| 525 | - | what the ear wants and the seat number is not: online the table is turned so | |
| 526 | - | you are always at the bottom, and the sound has to turn with it. | |
| 557 | + | what the ear wants and the seat number is not: a device holding one hand turns | |
| 558 | + | the table so that hand is at the bottom, and the sound has to turn with it. | |
| 527 | 559 | `game/sound.ts` gives each position a bus of its own: a pan, a gain, and a | |
| 528 | 560 | lowpass that opens all the way for the near edge and closes to 5 kHz for the | |
| 529 | 561 | far one. Distance is two things at once — quieter, and with the top taken off | |
| ⋯ 5 unchanged lines | |||
| 535 | 567 | rather than building one per contact. Old Safari has no `StereoPannerNode`; it | |
| 536 | 568 | plays where it always did. | |
| 537 | 569 | ||
| 538 | - | **At a shared table, the words come out of the player.** Five devices in one | |
| 539 | - | room, and the split the sound should take is the one the room already has: the | |
| 570 | + | **Where there is a table and phones round it, the words come out of the | |
| 571 | + | player.** The split the sound should take is the one the room already has: the | |
| 540 | 572 | table makes the noise, because the tiles are on the table, and the phone in | |
| 541 | 573 | somebody's hand says their calls, because the calls are theirs. So the table's | |
| 542 | 574 | screen drops the *voice* for a seat while keeping the clack, and the phone | |
| 543 | 575 | holding that seat says 碰 and names its own discards and makes no table noise | |
| 544 | 576 | at all. | |
| 545 | 577 | ||
| 578 | + | **Only a phone claims that job**, and that is the whole of how the split knows | |
| 579 | + | where it applies. A phone is small enough to be in a hand and a hand is in the | |
| 580 | + | room; a friend playing from their own laptop three cities away is not, and | |
| 581 | + | never takes the words away from anybody's table. It is a proxy rather than a | |
| 582 | + | fact — software cannot know who is sitting where — but it is the one property | |
| 583 | + | that actually correlates with it, and it fails in the harmless direction: | |
| 584 | + | the worst a wrong guess costs is a call said twice in two rooms. | |
| 585 | + | ||
| 546 | 586 | That only works if the phone really is going to say it, so it is asked rather | |
| 547 | 587 | than assumed. `sound.voiceLive()` is the honest answer — sound on, 報牌 on, the | |
| 548 | 588 | audio hardware awake, the pack decoded — and every device reports it to the | |
| ⋯ 1 unchanged line | |||
| 550 | 590 | hands have a device that will speak for them and publishes that as `voices` in | |
| 551 | 591 | room state; a nameplate wearing 🔊 next to its 📱 is a seat the table has gone | |
| 552 | 592 | quiet for on purpose. Mute the phone, put it to sleep, hand the seat back — | |
| 553 | - | the flag drops and the table takes the calls back mid-hand. The host's own | |
| 554 | - | device never counts, even when it holds seats: it is the table, and a table | |
| 555 | - | that fell silent on the grounds that it was about to speak would say nothing at | |
| 556 | - | all. | |
| 593 | + | the flag drops and the table takes the calls back mid-hand. The device the | |
| 594 | + | table is being played at never counts, even when it holds seats: it *is* the | |
| 595 | + | table, and a table that fell silent on the grounds that it was about to speak | |
| 596 | + | would say nothing at all. | |
| 557 | 597 | ||
| 558 | 598 | A browser will not start audio before the page has been touched, and a phone | |
| 559 | 599 | that arrived by QR may never touch anything that asks for it by name — so the | |
| ⋯ 105 unchanged lines | |||
| 665 | 705 | src/net/table.ts TableCtl over the wire: mirror + intents + hand overlay | |
| 666 | 706 | src/net/protocol.ts what crosses the wire, in one place | |
| 667 | 707 | src/net/handOrder.ts your own arrangement, kept on your own device | |
| 668 | - | src/ui/Controller.tsx the phone at the shared table: your tiles and the throw | |
| 669 | - | src/ui/OnlineLobby.tsx seats and the deal button, before an online game | |
| 708 | + | src/ui/Controller.tsx a hand held on a phone, plus the seat picker for any device | |
| 670 | 709 | server/rooms.ts the relay: rooms, host election, state fan-out | |
| 671 | 710 | server/index.ts production: dist/ and the relay from one port | |
| 672 | 711 | src/table/physics.ts the discard pool: rectangles with weight, pure and testable | |
| ⋯ 232 unchanged lines | |||
modifiedTODO.md+5 −4
| ⋯ 25 unchanged lines | |||
| 26 | 26 | - [ ] put it on a real domain: `npm run serve` behind a TLS reverse proxy | |
| 27 | 27 | (phones only get camera and fullscreen on https) — anvil, a VPS, a | |
| 28 | 28 | tailscale funnel, anything that terminates TLS | |
| 29 | - | - [ ] undo online: only the party screen has it, and only while it is the | |
| 30 | - | room's host; online games have none at all | |
| 31 | - | - [ ] full-table online clients throw with the local gesture but others see | |
| 32 | - | the default seat-launch animation — could send the NetThrow for those too | |
| 29 | + | - [ ] undo on a device that is not the table: only the table screen has it, and | |
| 30 | + | only while it is the room's host. A phone holding one hand has none | |
| 31 | + | - [ ] full-size clients holding one hand throw with the local gesture but | |
| 32 | + | others see the default seat-launch animation — could send the NetThrow | |
| 33 | + | for those too | |
| 33 | 34 | - [ ] cheat-resistance: room state carries the wall and every hand; the relay | |
| 34 | 35 | is ours now, so the engine could move into `server/rooms.ts` and keep | |
| 35 | 36 | secrets server-side | |
| ⋯ 10 unchanged lines | |||
modifiedsrc/App.tsx+263 −226
| 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 GameState, type SoundCue } from './game/engine'; | |
| 4 | + | import { Game, 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 { | |
| ⋯ 10 unchanged lines | |||
| 18 | 18 | } from './game/sound'; | |
| 19 | 19 | import { WIND_EN, WIND_NAMES } from './game/tiles'; | |
| 20 | 20 | import type { SeatId } from './game/types'; | |
| 21 | - | import { roomFromUrl, roomLink, seatFromUrl } from './net/protocol'; | |
| 21 | + | import { roomFromUrl, roomLink } from './net/protocol'; | |
| 22 | 22 | import { NetSession } from './net/session'; | |
| 23 | 23 | import { Bi, BiText } from './ui/Bi'; | |
| 24 | 24 | import { Center } from './ui/Center'; | |
| 25 | 25 | import { exposeGame, showingColliders } from './ui/colliders'; | |
| 26 | 26 | import { useCompact } from './ui/compact'; | |
| 27 | - | import { Controller } from './ui/Controller'; | |
| 27 | + | import { Controller, SeatPick } from './ui/Controller'; | |
| 28 | 28 | import { FullscreenToggle } from './ui/FullscreenToggle'; | |
| 29 | 29 | import { enterFullscreen, useFullscreen } from './ui/fullscreen'; | |
| 30 | 30 | import { Help } from './ui/Help'; | |
| 31 | 31 | import { pick, setLang, useLang, useT } from './ui/lang'; | |
| 32 | 32 | import { LanguageToggle } from './ui/LanguageToggle'; | |
| 33 | - | import { OnlineLobby } from './ui/OnlineLobby'; | |
| 34 | 33 | import { TablePool } from './table/pool'; | |
| 35 | 34 | import { Pool } from './ui/Pool'; | |
| 36 | - | import { Seat, type RemoteSeat } from './ui/Seat'; | |
| 35 | + | import { Seat, type SeatWho } from './ui/Seat'; | |
| 37 | 36 | import { TableMenu } from './ui/TableMenu'; | |
| 38 | 37 | import { SeatQr } from './ui/SeatQr'; | |
| 39 | 38 | import { Settings } from './ui/Settings'; | |
| 40 | 39 | import './styles.css'; | |
| 41 | 40 | ||
| 42 | 41 | const SEATS: SeatId[] = [0, 1, 2, 3]; | |
| 43 | - | /** The human always takes the bottom seat; the other three are the computer's. */ | |
| 44 | - | const SOLO_SEATS = [false, true, true, true]; | |
| 45 | - | const HOTSEAT_SEATS = [false, false, false, false]; | |
| 42 | + | /** | |
| 43 | + | * What 開始 deals: you at the bottom edge, the computer on the other three. | |
| 44 | + | * | |
| 45 | + | * It is a starting point rather than a mode. Anybody sitting down at this | |
| 46 | + | * screen taps a computer's strip and takes that hand; anybody with a phone | |
| 47 | + | * scans into it; and either of those can be given back to the computer again, | |
| 48 | + | * mid-hand, without the table stopping. See `seatWho` below. | |
| 49 | + | */ | |
| 50 | + | const OPENING_SEATS = [false, true, true, true]; | |
| 46 | 51 | ||
| 47 | - | // One join per page, whoever asks first — the lobby button, or the ?room= a | |
| 48 | - | // shared link carries. A promise rather than a flag, so a second caller waits | |
| 49 | - | // on the same join instead of starting another. | |
| 52 | + | // One join per page, whoever asks first — the invite, or the ?room= a shared | |
| 53 | + | // link carries. A promise rather than a flag, so a second caller waits on the | |
| 54 | + | // same join instead of starting another. | |
| 50 | 55 | let joining: Promise<NetSession> | null = null; | |
| 51 | - | const joinOnce = () => (joining ??= NetSession.join()); | |
| 56 | + | const joinOnce = (engine: Game) => (joining ??= NetSession.join(engine)); | |
| 52 | 57 | ||
| 53 | 58 | const subNothing = () => () => {}; | |
| 54 | 59 | const zero = () => 0; | |
| ⋯ 6 unchanged lines | |||
| 61 | 66 | interface Earshot { | |
| 62 | 67 | /** Seat number → which edge of *this* screen it is sitting at. */ | |
| 63 | 68 | posOf: (seat: SeatId) => SeatId; | |
| 64 | - | mode: 'online' | 'party' | null; | |
| 65 | 69 | mySeat: SeatId | null; | |
| 66 | - | /** Whether this device is the party table rather than a hand at it. */ | |
| 67 | - | isScreen: boolean; | |
| 70 | + | /** Whether this device is the one the table is being played at. */ | |
| 71 | + | isTable: boolean; | |
| 72 | + | /** Whether this device is a hand held beside a table that is making the | |
| 73 | + | * noise for it — a phone in a room. */ | |
| 74 | + | handheld: boolean; | |
| 68 | 75 | /** Seats whose own phone is saying their calls for them. */ | |
| 69 | 76 | voices: boolean[]; | |
| 70 | 77 | } | |
| ⋯ 1 unchanged line | |||
| 72 | 79 | /** | |
| 73 | 80 | * Which parts of a cue this device plays, and where from. | |
| 74 | 81 | * | |
| 75 | - | * A single screen — solo, hotseat, or an online table — plays all of it, panned | |
| 76 | - | * to the edge the seat is sitting at, so 碰 from 上家 comes from your left. | |
| 82 | + | * Anything showing the whole table plays all of it, panned to the edge the | |
| 83 | + | * seat is sitting at, so 碰 from 上家 comes from your left. That covers a | |
| 84 | + | * screen on its own, and a screen with a room around it, and somebody playing | |
| 85 | + | * from their own laptop three cities away. | |
| 77 | 86 | * | |
| 78 | - | * A party table is a room with five devices in it, and the sound is split the | |
| 79 | - | * way the room already splits: the table makes the noise, because the tiles | |
| 80 | - | * are on the table, and the phone in a player's hand says the words, because | |
| 81 | - | * the words are theirs. So the table drops the voice for any seat whose phone | |
| 82 | - | * has said it can talk, and a phone plays nothing but its own seat's calls — | |
| 83 | - | * plus the chime for a claim window somebody else opened, which is a nudge | |
| 84 | - | * meant for the person holding the phone and nobody else in the room. | |
| 87 | + | * A phone in a room is the exception, and the reason is the room: the table is | |
| 88 | + | * right there making the noise, so the phone makes none of it. It says its own | |
| 89 | + | * seat's calls, because the words are that player's, and it chimes for a claim | |
| 90 | + | * window somebody else opened, which is a nudge meant for the person holding | |
| 91 | + | * it and nobody else. The table hears the phone claim the job — see `voices` | |
| 92 | + | * in `net/protocol.ts` — and goes quiet for that seat, so the 碰 comes out of | |
| 93 | + | * the hand of whoever called it. | |
| 85 | 94 | */ | |
| 86 | 95 | function castFor(cue: SoundCue, room: Earshot): Cast { | |
| 87 | 96 | const seat = cue.seat; | |
| 88 | - | if (room.mode === 'party' && !room.isScreen) { | |
| 97 | + | if (room.handheld) { | |
| 89 | 98 | // A phone still on the seat picker holds nothing and speaks for nobody. | |
| 90 | 99 | if (seat === undefined || room.mySeat === null) return { voice: false, noise: false }; | |
| 91 | 100 | const mine = seat === room.mySeat; | |
| ⋯ 2 unchanged lines | |||
| 94 | 103 | return { pos: null, voice: mine, noise: mine ? false : cue.kind === 'claimWindow' }; | |
| 95 | 104 | } | |
| 96 | 105 | const pos = seat === undefined ? null : (room.posOf(seat) as Pos); | |
| 97 | - | const spoken = room.mode === 'party' && seat !== undefined && room.voices[seat] === true; | |
| 106 | + | const spoken = room.isTable && seat !== undefined && room.voices[seat] === true; | |
| 98 | 107 | return { pos, voice: !spoken, noise: true }; | |
| 99 | 108 | } | |
| 100 | 109 | ||
| ⋯ 31 unchanged lines | |||
| 132 | 141 | useSyncExternalStore(session?.subscribe ?? subNothing, session?.getSnapshot ?? zero); | |
| 133 | 142 | useSyncExternalStore(session?.table.subscribe ?? subNothing, session?.table.getSnapshot ?? zero); | |
| 134 | 143 | ||
| 135 | - | const connect = async (): Promise<NetSession | null> => { | |
| 144 | + | /** | |
| 145 | + | * Get this page into a room. `open` says this device is the one bringing the | |
| 146 | + | * table: it publishes what it is already playing, and from then on the QRs | |
| 147 | + | * work and a phone can take a hand off it. | |
| 148 | + | * | |
| 149 | + | * The publish happens before the session is handed to React on purpose — a | |
| 150 | + | * render in between would catch a room that has joined but has no table in | |
| 151 | + | * it yet, and flash the seat picker at the very device that owns the table. | |
| 152 | + | */ | |
| 153 | + | const connect = async (open = false): Promise<NetSession | null> => { | |
| 136 | 154 | setConnecting(true); | |
| 137 | 155 | console.log('[mahjong] connecting…'); | |
| 138 | 156 | try { | |
| 139 | - | const s = await joinOnce(); | |
| 157 | + | const s = await joinOnce(game); | |
| 158 | + | if (open) s.publishTable(prefs.names); | |
| 140 | 159 | setSession(s); | |
| 141 | 160 | setNetErr(null); | |
| 142 | 161 | return s; | |
| ⋯ 18 unchanged lines | |||
| 161 | 180 | // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 162 | 181 | }, []); | |
| 163 | 182 | ||
| 164 | - | // The full-size tables hand phone throws to the pool, and hold the computer | |
| 165 | - | // players while something is still skidding across it. | |
| 183 | + | // The full-size tables hand phone throws to the pool, so a tile flicked off | |
| 184 | + | // a phone comes flying in from that player's edge. | |
| 166 | 185 | useEffect(() => { | |
| 167 | 186 | if (!session) return; | |
| 168 | 187 | session.onThrowFx = (seat, t) => { | |
| 169 | 188 | pool.throwFromNet(seat, t); | |
| 170 | 189 | pool.wake(); | |
| 171 | 190 | }; | |
| 172 | - | session.setBusy(() => pool.busy); | |
| 173 | 191 | return () => { | |
| 174 | 192 | session.onThrowFx = () => {}; | |
| 175 | - | session.setBusy(() => false); | |
| 176 | 193 | }; | |
| 177 | 194 | }, [session, pool]); | |
| 178 | 195 | // Where every cue is heard from, kept in a ref rather than in the closures | |
| ⋯ 1 unchanged line | |||
| 180 | 197 | // table, and re-subscribing on every rotation would drop cues mid-hand. | |
| 181 | 198 | const earshot = useRef<Earshot>({ | |
| 182 | 199 | posOf: (s) => s, | |
| 183 | - | mode: null, | |
| 184 | 200 | mySeat: null, | |
| 185 | - | isScreen: false, | |
| 201 | + | isTable: true, | |
| 202 | + | handheld: false, | |
| 186 | 203 | voices: [false, false, false, false], | |
| 187 | 204 | }); | |
| 188 | - | useEffect( | |
| 189 | - | () => session?.table.onSound((cue) => play(cue, castFor(cue, earshot.current))), | |
| 190 | - | [session], | |
| 191 | - | ); | |
| 192 | 205 | ||
| 193 | - | // Walking back into a running online game unseated — a reload, or a friend | |
| 194 | - | // arriving late — takes the first empty chair, deserted or computer-run. | |
| 195 | - | const reclaimed = useRef(false); | |
| 196 | - | useEffect(() => { | |
| 197 | - | if (!session || session.mode !== 'online' || !session.started) return; | |
| 198 | - | if (reclaimed.current || session.mySeat !== null) return; | |
| 199 | - | if (session.seats.some((s) => s.ids.length === 0)) { | |
| 200 | - | reclaimed.current = true; | |
| 201 | - | session.claimSeat(null); | |
| 202 | - | } | |
| 203 | - | }); | |
| 204 | - | ||
| 205 | - | const netMode = session?.mode ?? null; | |
| 206 | 206 | const mySeat = session?.mySeat ?? null; | |
| 207 | - | // The party table's common screen. Usually the device that started the game; | |
| 208 | - | // after a reload that flag is gone, so anything full-sized and unseated in a | |
| 209 | - | // party room shows the table rather than a phone's controller — unless it | |
| 210 | - | // arrived through a seat's QR, which is a hand asking to be held whatever | |
| 211 | - | // the screen size. | |
| 212 | - | const isScreen = | |
| 213 | - | session?.role === 'screen' || | |
| 214 | - | (netMode === 'party' && !compact && mySeat === null && seatFromUrl() === null); | |
| 215 | - | const netActive = !!session && (netMode === 'party' ? true : netMode === 'online' && session.started); | |
| 207 | + | /** | |
| 208 | + | * Whether this device is the one the table is being played at. | |
| 209 | + | * | |
| 210 | + | * Without a room it always is — there is only the one screen. With one it is | |
| 211 | + | * whichever device opened it, which is where every hand no phone has taken | |
| 212 | + | * goes on being played. Everything else — a phone that scanned in, a friend | |
| 213 | + | * on their own laptop — is holding a hand, not running a table. | |
| 214 | + | */ | |
| 215 | + | const isTable = !session || session.isScreen; | |
| 216 | + | /** A hand held on a phone, with the table it belongs to somewhere else. */ | |
| 217 | + | const handheld = !!session && compact && !isTable; | |
| 216 | 218 | ||
| 217 | - | // Which engine this screen draws and drives. | |
| 218 | - | const ctl: TableCtl = netActive ? session!.table : game; | |
| 219 | + | /** | |
| 220 | + | * The table this page is playing at. It is the same engine either way: a | |
| 221 | + | * room is a wrapper that goes on around a game in progress, not a second | |
| 222 | + | * game, which is what lets four people join one at a time without the tiles | |
| 223 | + | * being dealt again. | |
| 224 | + | */ | |
| 225 | + | const ctl: TableCtl = session ? session.table : game; | |
| 219 | 226 | const phase = ctl.state.phase; | |
| 220 | - | // Online the table is turned so your own seat is at the bottom edge. | |
| 221 | - | const viewSeat: SeatId = netMode === 'online' ? mySeat ?? 0 : 0; | |
| 227 | + | /** Whether this device is the one actually running the game. */ | |
| 228 | + | const authority = !session || session.isHost; | |
| 229 | + | ||
| 230 | + | // ---- who is playing which hand ----------------------------------------- | |
| 231 | + | const holdersOf = (seat: SeatId): string[] => session?.seats[seat].ids ?? []; | |
| 232 | + | /** Whether this device answers for a seat: a hand it holds, or — at the | |
| 233 | + | * table — a hand nobody has taken away and the computer is not playing. */ | |
| 234 | + | const playsHere = (seat: SeatId): boolean => { | |
| 235 | + | const held = holdersOf(seat); | |
| 236 | + | if (held.length > 0) return held.includes(session!.room.me.id); | |
| 237 | + | return isTable && !ctl.state.bots[seat]; | |
| 238 | + | }; | |
| 239 | + | const mine = SEATS.filter(playsHere); | |
| 240 | + | ||
| 241 | + | /** | |
| 242 | + | * Turned so your own seat is the near edge — but only for a device holding | |
| 243 | + | * one hand and no table. Four people round a screen are sitting where they | |
| 244 | + | * are sitting, and turning the felt under them would move three of them. | |
| 245 | + | */ | |
| 246 | + | const viewSeat: SeatId = !isTable && mine.length === 1 ? mine[0] : 0; | |
| 222 | 247 | const posOf = (seat: SeatId): SeatId => (((seat - viewSeat + 4) % 4) as SeatId); | |
| 223 | 248 | // Everything the sound needs to know about where this screen is sitting, and | |
| 224 | 249 | // who else in the room is going to be talking. See castFor, above. | |
| 225 | 250 | earshot.current = { | |
| 226 | 251 | posOf, | |
| 227 | - | mode: netMode, | |
| 228 | 252 | mySeat, | |
| 229 | - | isScreen, | |
| 253 | + | isTable, | |
| 254 | + | handheld, | |
| 230 | 255 | voices: session?.voiceSeats ?? [false, false, false, false], | |
| 231 | 256 | }; | |
| 232 | 257 | ||
| 258 | + | // The one engine, so exactly one device runs the computer players, exactly | |
| 259 | + | // one writes the save, and undo belongs to the table rather than to a hand. | |
| 260 | + | useEffect(() => { | |
| 261 | + | game.persist = authority; | |
| 262 | + | }, [game, authority]); | |
| 263 | + | useEffect(() => { | |
| 264 | + | if (session) session.table.allowUndo = isTable; | |
| 265 | + | }, [session, isTable]); | |
| 266 | + | ||
| 233 | 267 | // Names and stakes live in the game state as well as in the preferences — | |
| 234 | 268 | // saved games keep the settings they were played under. | |
| 235 | 269 | const commitSettings = (p: Prefs) => { | |
| ⋯ 47 unchanged lines | |||
| 283 | 317 | // The browser hands the screen back on every reload, and this game is built | |
| 284 | 318 | // to be put down and picked up again. Starting a hand is a tap, and a tap is | |
| 285 | 319 | // the only place the request is allowed — so that is where it goes back on. | |
| 286 | - | const startGame = (bots: boolean[]) => { | |
| 320 | + | const goFullscreen = () => { | |
| 287 | 321 | if (compact && prefs.fullscreen && !fullscreen) enterFullscreen(); | |
| 288 | - | game.newGame(bots, prefs.names); | |
| 289 | 322 | }; | |
| 290 | 323 | ||
| 291 | 324 | /** | |
| 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. | |
| 325 | + | * 開始. One button, because there is one game. | |
| 297 | 326 | * | |
| 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. | |
| 327 | + | * The tiles are dealt here and now, on this device, with nothing on the wire | |
| 328 | + | * — you at the bottom edge and the computer on the other three. That is not | |
| 329 | + | * a mode, it is a table with three chairs still empty, and it is the shape | |
| 330 | + | * every other arrangement grows out of: somebody sits down at this screen | |
| 331 | + | * and taps a computer's strip, somebody else scans a seat's QR onto their | |
| 332 | + | * phone, a friend three cities away opens the invite link. Each of those | |
| 333 | + | * swaps one hand, mid-game, and nothing is dealt again. | |
| 334 | + | * | |
| 335 | + | * Nothing here waits on a server, and a page that never reaches one still | |
| 336 | + | * plays a full game. | |
| 304 | 337 | */ | |
| 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); | |
| 338 | + | const startGame = () => { | |
| 339 | + | goFullscreen(); | |
| 340 | + | 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); | |
| 311 | 344 | }; | |
| 312 | 345 | const resumeGame = () => { | |
| 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 | - | } | |
| 346 | + | goFullscreen(); | |
| 347 | + | game.restore(saved!.state); | |
| 348 | + | session?.publishTable(prefs.names); | |
| 321 | 349 | }; | |
| 322 | 350 | ||
| 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. | |
| 327 | - | const openOnline = async () => { | |
| 328 | - | const s = await connect(); | |
| 329 | - | if (s?.isHost && !s.mode) s.openOnlineLobby(); | |
| 351 | + | /** | |
| 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. | |
| 355 | + | */ | |
| 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. */ | |
| 359 | + | const toPhone = async (seat: SeatId) => { | |
| 360 | + | setMenuSeat(seat); | |
| 361 | + | await openRoom(); | |
| 362 | + | }; | |
| 363 | + | /** A hand changing hands between a person at this screen and the computer. | |
| 364 | + | * Networked or not, it is the same one call on the same one engine. */ | |
| 365 | + | const setSeatBot = (seat: SeatId, bot: boolean) => { | |
| 366 | + | if (session) session.setBot(seat, bot); | |
| 367 | + | else game.setBot(seat, bot); | |
| 330 | 368 | }; | |
| 331 | 369 | ||
| 332 | 370 | // Preferences are written straight through, so a mute survives a refresh. | |
| ⋯ 21 unchanged lines | |||
| 354 | 392 | ||
| 355 | 393 | // Tell the room whether this device is going to say anything, and say so | |
| 356 | 394 | // again whenever that changes — a mute, a 報牌 toggle, the tap that wakes a | |
| 357 | - | // phone's audio, the tab going to sleep. A party table only stops calling a | |
| 358 | - | // seat's tiles once the phone on that seat has claimed the job. | |
| 395 | + | // phone's audio, the tab going to sleep. | |
| 396 | + | // | |
| 397 | + | // Only a phone claims the job. A phone is small enough to be in somebody's | |
| 398 | + | // hand and a hand is in the room, so the table can safely go quiet for that | |
| 399 | + | // seat and let the 碰 come out of it. A friend playing from their own laptop | |
| 400 | + | // three cities away is not in the room and never takes the words away from | |
| 401 | + | // anybody's table. | |
| 359 | 402 | useEffect(() => { | |
| 360 | 403 | if (!session) return; | |
| 361 | - | const tell = () => session.reportVoice(voiceLive()); | |
| 404 | + | const tell = () => session.reportVoice(voiceLive() && compact); | |
| 362 | 405 | tell(); | |
| 363 | 406 | return onAudioChange(tell); | |
| 364 | - | }, [session, prefs.sound, prefs.voice]); | |
| 365 | - | useEffect(() => game.onSound((cue) => play(cue, castFor(cue, earshot.current))), [game]); | |
| 366 | - | // Watches the table for the whole session and only stirs when a seat the | |
| 367 | - | // computer holds is the one being waited on. Idle in a networked game, where | |
| 368 | - | // whichever device is the room's host runs its own AutoPlay. | |
| 407 | + | }, [session, compact, prefs.sound, prefs.voice]); | |
| 408 | + | // One subscription, on whichever table this page is drawing. Off the engine | |
| 409 | + | // when there is no room; off the room's own feed when there is, which on the | |
| 410 | + | // host is the engine again and everywhere else is what the host sent. | |
| 411 | + | useEffect(() => ctl.onSound((cue) => play(cue, castFor(cue, earshot.current))), [ctl]); | |
| 412 | + | // Watches the table and only stirs when a seat the computer holds is the one | |
| 413 | + | // being waited on. Exactly one device runs it: the one running the game, so | |
| 414 | + | // three phones watching a table do not each play its computers' hands. | |
| 415 | + | // | |
| 369 | 416 | // The computer waits for a thrown tile to land — and for whoever it landed on | |
| 370 | - | // to finish saying so — before it reaches for it. | |
| 371 | - | // `showingColliders` holds the computer still as well: the throwing sandbox | |
| 372 | - | // is no use if the table plays on around it. | |
| 373 | - | useEffect( | |
| 374 | - | () => new AutoPlay(game, () => pool.busy || showingColliders()).start(), | |
| 375 | - | [game, pool], | |
| 376 | - | ); | |
| 417 | + | // to finish saying so — before it reaches for it. `showingColliders` holds it | |
| 418 | + | // still as well: the throwing sandbox is no use if the table plays on around | |
| 419 | + | // it. | |
| 420 | + | useEffect(() => { | |
| 421 | + | if (!authority) return; | |
| 422 | + | return new AutoPlay(game, () => pool.busy || showingColliders()).start(); | |
| 423 | + | }, [authority, game, pool]); | |
| 377 | 424 | ||
| 378 | 425 | // ---- routing ------------------------------------------------------------ | |
| 379 | - | // A phone at the party table holds a hand, not the table. | |
| 380 | - | if (session && netMode === 'party' && !isScreen) { | |
| 381 | - | return <Controller session={session} prefs={prefs} onPrefs={setPrefs} />; | |
| 382 | - | } | |
| 383 | - | if (session && netMode === 'online' && !session.started) { | |
| 384 | - | return <OnlineLobby session={session} prefs={prefs} />; | |
| 426 | + | // A device in somebody else's room holds a hand, not a table: a phone shows | |
| 427 | + | // the hand and nothing else, and anything bigger shows the whole table | |
| 428 | + | // turned round to that seat's edge. Either way it picks a chair first. | |
| 429 | + | if (session && !isTable && session.open) { | |
| 430 | + | if (compact) return <Controller session={session} prefs={prefs} onPrefs={setPrefs} />; | |
| 431 | + | if (mine.length === 0) { | |
| 432 | + | return <SeatPick session={session} prefs={prefs} onPrefs={setPrefs} />; | |
| 433 | + | } | |
| 385 | 434 | } | |
| 386 | 435 | ||
| 387 | - | if (!netActive && phase === 'lobby') { | |
| 436 | + | if (phase === 'lobby') { | |
| 388 | 437 | const round = saved ? Math.floor(saved.state.rotations / 4) % 4 : 0; | |
| 389 | - | const savedSolo = saved?.state.bots.some(Boolean); | |
| 390 | - | const netIdle = !session?.mode; | |
| 391 | 438 | return ( | |
| 392 | 439 | <div className="lobby"> | |
| 393 | 440 | {/* The title is already the bilingual pair: the name in Chinese, the | |
| ⋯ 11 unchanged lines | |||
| 405 | 452 | {saved && ( | |
| 406 | 453 | <div className="resume"> | |
| 407 | 454 | <div className="resume-line"> | |
| 408 | - | {savedSolo ? t('單人', 'Solo') : t('同桌', 'Table')} ·{' '} | |
| 409 | 455 | {t( | |
| 410 | 456 | `${WIND_NAMES[round]}圈 · 第 ${saved.state.handNumber} 局`, | |
| 411 | 457 | `${WIND_EN[round]} round, hand ${saved.state.handNumber}`, | |
| ⋯ 14 unchanged lines | |||
| 426 | 472 | )} | |
| 427 | 473 | ||
| 428 | 474 | <div className="lobby-buttons"> | |
| 429 | - | {saved && ( | |
| 475 | + | {saved && authority && ( | |
| 430 | 476 | <button className="btn big-btn" onClick={resumeGame}> | |
| 431 | 477 | <Bi zh="繼續對局" en="Resume" /> | |
| 432 | 478 | </button> | |
| 433 | 479 | )} | |
| 434 | - | <button | |
| 435 | - | className={`btn big-btn ${saved ? 'ghost' : ''}`} | |
| 436 | - | onClick={() => startGame(SOLO_SEATS)} | |
| 437 | - | > | |
| 438 | - | <Bi zh="單人對局" en="Single player" /> | |
| 439 | - | <em> | |
| 440 | - | <BiText | |
| 441 | - | zh="你坐東家,其餘三家由電腦代打" | |
| 442 | - | en="you at 東, three computer players" | |
| 443 | - | /> | |
| 444 | - | </em> | |
| 445 | - | </button> | |
| 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" /> | |
| 480 | + | {/* One button, because there is one game. It deals you in against | |
| 481 | + | three computers and asks nothing of any server; everybody else | |
| 482 | + | arrives afterwards, one hand at a time, without it being dealt | |
| 483 | + | again. See startGame. | |
| 484 | + | ||
| 485 | + | Hidden only in the one place it cannot work: standing in | |
| 486 | + | somebody else's room, where the table is theirs to deal. */} | |
| 487 | + | {authority && ( | |
| 488 | + | <button className={`btn big-btn ${saved ? 'ghost' : ''}`} onClick={startGame}> | |
| 489 | + | <Bi zh="開始" en="Start" /> | |
| 451 | 490 | <em> | |
| 452 | 491 | <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" | |
| 492 | + | zh="先發牌,其餘三家由電腦代打;有人要打,點他那一家坐下,或掃碼用手機拿牌" | |
| 493 | + | en="dealt straight away, computers on the other three — tap a seat to sit down at this screen, or scan into it from a phone" | |
| 455 | 494 | /> | |
| 456 | 495 | </em> | |
| 457 | 496 | </button> | |
| 458 | - | {(!session || (session.isHost && netIdle)) && ( | |
| 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> | |
| 465 | 497 | )} | |
| 466 | - | {session && !session.isHost && netIdle && ( | |
| 498 | + | {session && !session.isHost && !session.open && ( | |
| 467 | 499 | <p className="hint"> | |
| 468 | 500 | <BiText | |
| 469 | - | zh={`已連線 ${session.room.code} · ${session.playerCount} 人在房,等房主選模式`} | |
| 470 | - | en={`connected ${session.room.code} — waiting for the host to pick a mode`} | |
| 501 | + | zh={`已連線 ${session.room.code} · ${session.playerCount} 人在房,等房主開局`} | |
| 502 | + | en={`connected ${session.room.code} — waiting for the host to deal`} | |
| 471 | 503 | /> | |
| 472 | 504 | </p> | |
| 473 | 505 | )} | |
| 474 | 506 | </div> | |
| 475 | 507 | ||
| 476 | 508 | {(netErr || session?.err) && <p className="hint">{netErr ?? session?.err}</p>} | |
| 477 | - | {/* Where the connection stands, in so many words. The room chrome is | |
| 478 | - | not always there to say it, and a button that cannot work yet | |
| 479 | - | should say why rather than doing nothing. */} | |
| 480 | - | <p className="hint"> | |
| 481 | - | {connecting | |
| 482 | - | ? t('連線中', 'connecting…') | |
| 483 | - | : session | |
| 484 | - | ? `${t('已連線', 'connected')} · ${session.room.code} · ${session.playerCount} ${t( | |
| 485 | - | '人', | |
| 486 | - | session.playerCount === 1 ? 'player' : 'players', | |
| 487 | - | )} · ${ | |
| 488 | - | session.isHost | |
| 489 | - | ? t('你是房主', 'you host') | |
| 490 | - | : t('房主在別台', 'host elsewhere') | |
| 491 | - | }` | |
| 492 | - | : roomFromUrl() | |
| 493 | - | ? t('未連線', 'not connected') | |
| 494 | - | : t('本機 · 線上模式會自動連線', 'offline · online modes connect on tap', ' · ')} | |
| 495 | - | </p> | |
| 509 | + | {/* Where the connection stands, in so many words — but only when there | |
| 510 | + | is something to say. A game that has not asked for a room yet is | |
| 511 | + | not offline, it simply has not needed one. */} | |
| 512 | + | {(connecting || session || roomFromUrl()) && ( | |
| 513 | + | <p className="hint"> | |
| 514 | + | {connecting | |
| 515 | + | ? t('連線中', 'connecting…') | |
| 516 | + | : session | |
| 517 | + | ? `${t('已連線', 'connected')} · ${session.room.code} · ${session.playerCount} ${t( | |
| 518 | + | '人', | |
| 519 | + | session.playerCount === 1 ? 'player' : 'players', | |
| 520 | + | )}` | |
| 521 | + | : t('未連線', 'not connected')} | |
| 522 | + | </p> | |
| 523 | + | )} | |
| 496 | 524 | ||
| 497 | 525 | <div className="lobby-extras"> | |
| 498 | 526 | <button className="btn ghost pill" onClick={() => setSettingsOpen(true)}> | |
| ⋯ 11 unchanged lines | |||
| 510 | 538 | ); | |
| 511 | 539 | } | |
| 512 | 540 | ||
| 513 | - | // What each seat is to this particular screen: whose buttons it shows, whose | |
| 514 | - | // tiles it may turn over, and what stands in for the seats that live on | |
| 515 | - | // other devices. | |
| 516 | - | const seatProps = (seat: SeatId): { local: boolean; remote?: RemoteSeat } => { | |
| 517 | - | if (!netActive) return { local: true }; | |
| 518 | - | const slot = session!.seats[seat]; | |
| 519 | - | const held = slot.ids.length; | |
| 520 | - | if (netMode === 'party') { | |
| 521 | - | return { | |
| 522 | - | // A seat nobody has scanned is played right here, hotseat-style. | |
| 523 | - | local: held === 0, | |
| 524 | - | remote: { | |
| 525 | - | // More phones than one on a hand is a pair playing it together. | |
| 526 | - | holder: | |
| 527 | - | held > 0 | |
| 528 | - | ? `${slot.name || t('手機', 'phone')}${held > 1 ? ` ×${held}` : ''}` | |
| 529 | - | : null, | |
| 530 | - | onPeek: setQrPeek, | |
| 531 | - | // The table has gone quiet for this seat on purpose: the phone on it | |
| 532 | - | // is doing the calling. | |
| 533 | - | speaks: session!.voiceSeats[seat] === true, | |
| 534 | - | }, | |
| 535 | - | }; | |
| 536 | - | } | |
| 541 | + | /** | |
| 542 | + | * What each seat is to this particular screen: whose buttons it shows, whose | |
| 543 | + | * tiles it may turn over, and — because a hand can change hands mid-game — | |
| 544 | + | * what it offers to do about it. | |
| 545 | + | * | |
| 546 | + | * The three answers are the same three everywhere: the computer is playing | |
| 547 | + | * it, somebody at this screen is, or it has gone to a device of its own. Any | |
| 548 | + | * of them turns into any other without the tiles being dealt again, which is | |
| 549 | + | * the whole of what used to be three modes on a lobby. | |
| 550 | + | */ | |
| 551 | + | const seatWho = (seat: SeatId): SeatWho | undefined => { | |
| 552 | + | // A hand on somebody's own device is theirs; the table's controls for the | |
| 553 | + | // other three are the table's business, not a visitor's. | |
| 554 | + | if (!isTable) return undefined; | |
| 555 | + | const slot = session?.seats[seat]; | |
| 556 | + | const held = slot?.ids.length ?? 0; | |
| 557 | + | const bot = ctl.state.bots[seat]; | |
| 537 | 558 | return { | |
| 538 | - | local: seat === mySeat, | |
| 539 | - | // A deserted human seat: the host's screen offers it to the computer. | |
| 540 | - | remote: | |
| 541 | - | held === 0 && !ctl.state.bots[seat] && session!.isHost | |
| 542 | - | ? { holder: null, onHandToBot: () => session!.handToBot(seat) } | |
| 543 | - | : undefined, | |
| 559 | + | // More devices than one on a hand is a pair playing it together. | |
| 560 | + | holder: | |
| 561 | + | held > 0 ? `${slot!.name || t('手機', 'phone')}${held > 1 ? ` ×${held}` : ''}` : null, | |
| 562 | + | // Its device went. The hand is being kept for a moment in case that was | |
| 563 | + | // a pocket rather than a goodbye — see `vacate` in net/session.ts. | |
| 564 | + | waiting: session?.isWaiting(seat) === true, | |
| 565 | + | onPeek: session ? setQrPeek : undefined, | |
| 566 | + | // The table has gone quiet for this seat on purpose: the phone on it is | |
| 567 | + | // doing the calling. | |
| 568 | + | speaks: session?.voiceSeats[seat] === true, | |
| 569 | + | // Somebody has sat down in front of this edge and wants the hand the | |
| 570 | + | // computer has been playing. One tap, mid-hand, tiles and all. | |
| 571 | + | onSit: held === 0 && bot ? () => setSeatBot(seat, false) : undefined, | |
| 572 | + | // And back again — nobody is going to play it, or nobody wants to. | |
| 573 | + | onBot: held === 0 && !bot ? () => setSeatBot(seat, true) : undefined, | |
| 574 | + | onPhone: held === 0 ? () => void toPhone(seat) : undefined, | |
| 544 | 575 | }; | |
| 545 | 576 | }; | |
| 546 | 577 | ||
| 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; | |
| 578 | + | /** | |
| 579 | + | * Whether this screen is one several people are sitting round, in which case | |
| 580 | + | * every hand played at it starts face down: your neighbour can see your | |
| 581 | + | * tiles, and the answer used to be a piece of card laid over your strip. | |
| 582 | + | * | |
| 583 | + | * It is simply how many hands this device is playing. One is one person — | |
| 584 | + | * playing the computer, or holding their own hand on their own device — and | |
| 585 | + | * covering your own tiles from yourself is a tap in the way. Two or more is | |
| 586 | + | * a screen with more than one person's hand on it, whatever else is true | |
| 587 | + | * about the room. | |
| 588 | + | */ | |
| 589 | + | const sharing = mine.length > 1; | |
| 559 | 590 | // Where a hand goes when its player would rather hold it themselves. Only | |
| 560 | - | // where there is a room to go to. | |
| 591 | + | // once there is a room for it to go to; before that the gear offers to open | |
| 592 | + | // one instead. | |
| 561 | 593 | const seatUrl = (seat: SeatId) => | |
| 562 | - | session && netMode === 'party' | |
| 563 | - | ? roomLink(session.room.code, session.room.publicBase, seat) | |
| 564 | - | : undefined; | |
| 594 | + | session ? roomLink(session.room.code, session.room.publicBase, seat) : undefined; | |
| 565 | 595 | ||
| 566 | 596 | return ( | |
| 567 | 597 | <div className="table"> | |
| 598 | + | {/* The wire has gone quiet. Said out loud on the table too, not only on | |
| 599 | + | the lobby it is easy to have left behind: a hand that has stopped | |
| 600 | + | answering should say why rather than looking like a game that has | |
| 601 | + | stopped. The room reconnects on its own, and this clears when state | |
| 602 | + | starts flowing again. */} | |
| 603 | + | {session?.err && <div className="net-warn">{session.err}</div>} | |
| 568 | 604 | {SEATS.map((s) => { | |
| 569 | - | const sp = seatProps(s); | |
| 570 | 605 | return ( | |
| 571 | 606 | <Seat | |
| 572 | 607 | key={s} | |
| 573 | 608 | game={ctl} | |
| 574 | 609 | seat={s} | |
| 575 | 610 | pos={posOf(s)} | |
| 576 | - | local={sp.local} | |
| 611 | + | local={playsHere(s)} | |
| 577 | 612 | shared={sharing} | |
| 578 | - | remote={sp.remote} | |
| 613 | + | who={seatWho(s)} | |
| 579 | 614 | compact={compact} | |
| 580 | 615 | pool={compact ? undefined : pool} | |
| 581 | 616 | onHelp={() => setHelpSeat(posOf(s))} | |
| ⋯ 11 unchanged lines | |||
| 593 | 628 | starts, because from then on the middle is where the tiles go and a | |
| 594 | 629 | phone is asked for from the seat's own gear instead. It comes back | |
| 595 | 630 | while any seat's 📱 tag is held down, for a player who lost theirs. */} | |
| 596 | - | {netMode === 'party' && | |
| 631 | + | {session && | |
| 632 | + | isTable && | |
| 597 | 633 | !compact && | |
| 598 | 634 | (qrPeek || | |
| 599 | - | (session!.seats.some((s) => s.ids.length === 0) && | |
| 635 | + | (session.seats.some((s) => s.ids.length === 0) && | |
| 600 | 636 | ctl.state.players.every((p) => p.discards.length === 0))) && ( | |
| 601 | 637 | <div className="qr-dock"> | |
| 602 | 638 | <SeatQr url={roomLink(session!.room.code, session!.room.publicBase)} /> | |
| ⋯ 8 unchanged lines | |||
| 611 | 647 | prefs={prefs} | |
| 612 | 648 | compact={compact} | |
| 613 | 649 | viewSeat={viewSeat} | |
| 614 | - | preferSeat={netMode === 'online' ? mySeat : null} | |
| 650 | + | preferSeat={isTable ? null : mySeat} | |
| 615 | 651 | onPrefs={setPrefs} | |
| 616 | 652 | onSettings={() => setSettingsOpen(true)} | |
| 617 | 653 | /> | |
| ⋯ 4 unchanged lines | |||
| 622 | 658 | prefs={prefs} | |
| 623 | 659 | pos={posOf(menuSeat)} | |
| 624 | 660 | seatUrl={seatUrl(menuSeat)} | |
| 661 | + | who={seatWho(menuSeat)} | |
| 625 | 662 | compact={compact} | |
| 626 | 663 | onPrefs={setPrefs} | |
| 627 | 664 | onSettings={() => setSettingsOpen(true)} | |
| ⋯ 20 unchanged lines | |||
modifiedsrc/net/protocol.ts+28 −10
| ⋯ 13 unchanged lines | |||
| 14 | 14 | * engine methods a button would have called. The engine itself never knows any | |
| 15 | 15 | * of this is happening — the same trick `autoplay.ts` plays, stretched over a | |
| 16 | 16 | * network. | |
| 17 | + | * | |
| 18 | + | * There is one kind of game. A room is not opened until somebody wants a | |
| 19 | + | * phone in it, and what it carries after that is only ever the same four | |
| 20 | + | * questions: who is holding each hand, where the hands nobody holds are being | |
| 21 | + | * played, and what the table looks like. Solo, four round a laptop and four in | |
| 22 | + | * four cities are all arrangements of those, not modes — see `App.tsx`. | |
| 17 | 23 | */ | |
| 18 | 24 | ||
| 19 | - | /** The two ways a room can be playing. */ | |
| 20 | - | export type NetMode = 'online' | 'party'; | |
| 21 | - | ||
| 22 | 25 | /** | |
| 23 | 26 | * Who holds a seat. A list, not a single id: a seat is a hand, and nothing | |
| 24 | 27 | * stops two people huddling over one hand — a pair playing together from two | |
| ⋯ 9 unchanged lines | |||
| 34 | 37 | ||
| 35 | 38 | /** The shared room state, as the host writes it. */ | |
| 36 | 39 | export interface SharedKeys { | |
| 37 | - | mode: NetMode; | |
| 38 | 40 | seats: SeatSlot[]; | |
| 39 | - | /** The whole engine state. null while the room is still in its lobby. */ | |
| 41 | + | /** | |
| 42 | + | * The device the table is being played at: the one that opened the room, and | |
| 43 | + | * the one that goes on playing every seat no phone has taken. '' once it has | |
| 44 | + | * left, which is what tells the room those seats have nobody behind them and | |
| 45 | + | * should go to the computer — see `armStranded` in session.ts. | |
| 46 | + | * | |
| 47 | + | * It is not a mode. A laptop with four people round it and a phone with one | |
| 48 | + | * are the same thing here: whichever device the game was started on is where | |
| 49 | + | * the unclaimed hands are played, and every other device holds a hand. | |
| 50 | + | */ | |
| 51 | + | screen: string; | |
| 52 | + | /** The whole engine state. Never null in practice: a room is opened around a | |
| 53 | + | * table that is already being played at. */ | |
| 40 | 54 | game: GameState | null; | |
| 41 | 55 | /** | |
| 42 | 56 | * Which seats have a device on them that will say their calls out loud — | |
| 43 | 57 | * a phone in somebody's hand, with sound and 報牌 on and the hardware awake. | |
| 44 | 58 | * | |
| 45 | - | * The table reads this and shuts up for those seats: at a party table all | |
| 46 | - | * five devices are in the same room, so the 碰 should come out of the hand | |
| 47 | - | * of the person calling it and not out of the laptop in the middle. Held by | |
| 48 | - | * the host, which is the only thing that hears every phone. | |
| 59 | + | * Only a phone claims it — the device is small enough to be in a hand, and | |
| 60 | + | * a hand is in the room. The screen reads this and shuts up for those seats, | |
| 61 | + | * so the 碰 comes out of the hand of the person calling it and not out of | |
| 62 | + | * the laptop in the middle. Held by the host, which is the only thing that | |
| 63 | + | * hears every device. | |
| 49 | 64 | */ | |
| 50 | 65 | voices: boolean[]; | |
| 51 | 66 | } | |
| ⋯ 7 unchanged lines | |||
| 59 | 74 | | { kind: 'addkong'; tile: Tile } | |
| 60 | 75 | | { kind: 'selfDraw' } | |
| 61 | 76 | | { kind: 'nextHand' } | |
| 62 | - | | { kind: 'newGame' }; | |
| 77 | + | | { kind: 'newGame' } | |
| 78 | + | /** Give this hand to the computer, or take it back off it. Only ever about a | |
| 79 | + | * hand nobody is holding — see `handleAct`. */ | |
| 80 | + | | { kind: 'bot'; on: boolean }; | |
| 63 | 81 | ||
| 64 | 82 | export interface ActMsg { | |
| 65 | 83 | seat: SeatId; | |
| ⋯ 91 unchanged lines | |||
modifiedsrc/net/room.ts+10 −0
| ⋯ 98 unchanged lines | |||
| 99 | 99 | private joinCbs = new Set<(p: PlayerInfo) => void>(); | |
| 100 | 100 | private leaveCbs = new Set<(p: PlayerInfo) => void>(); | |
| 101 | 101 | private hostCbs = new Set<(host: PlayerInfo | null) => void>(); | |
| 102 | + | private resumeCbs = new Set<() => void>(); | |
| 102 | 103 | private errorCbs = new Set<(e: RoomError) => void>(); | |
| 103 | 104 | private evCbs = new Map<string, Set<(payload: unknown, from: string) => void>>(); | |
| 104 | 105 | ||
| ⋯ 75 unchanged lines | |||
| 180 | 181 | onHostChange(cb: (host: PlayerInfo | null) => void): Unsub { | |
| 181 | 182 | return sub(this.hostCbs, cb); | |
| 182 | 183 | } | |
| 184 | + | /** | |
| 185 | + | * The connection came back and the room was rejoined. Whatever we meant to | |
| 186 | + | * say while it was down never went, and what the server holds is as old as | |
| 187 | + | * the blip — so whoever owns the state should say it again. | |
| 188 | + | */ | |
| 189 | + | onResume(cb: () => void): Unsub { | |
| 190 | + | return sub(this.resumeCbs, cb); | |
| 191 | + | } | |
| 183 | 192 | onError(cb: (e: RoomError) => void): Unsub { | |
| 184 | 193 | return sub(this.errorCbs, cb); | |
| 185 | 194 | } | |
| ⋯ 111 unchanged lines | |||
| 297 | 306 | if (p.id !== this.me.id && !before.some((q) => q.id === p.id)) | |
| 298 | 307 | for (const cb of this.joinCbs) cb(p); | |
| 299 | 308 | if (prevHost !== this.hostId) for (const cb of this.hostCbs) cb(this.host); | |
| 309 | + | for (const cb of this.resumeCbs) cb(); | |
| 300 | 310 | } | |
| 301 | 311 | ||
| 302 | 312 | private emitError(e: RoomError) { | |
| ⋯ 13 unchanged lines | |||
modifiedsrc/net/session.ts+204 −139
| 1 | - | import { AutoPlay } from '../game/autoplay'; | |
| 2 | 1 | import type { NetThrow } from '../game/ctl'; | |
| 3 | - | import type { GameState, SoundEvent } from '../game/engine'; | |
| 2 | + | import type { Game, SoundEvent } from '../game/engine'; | |
| 4 | 3 | import type { Tile } from '../game/tiles'; | |
| 5 | - | import type { Rules, SeatId } from '../game/types'; | |
| 4 | + | import type { SeatId } from '../game/types'; | |
| 6 | 5 | import { | |
| 7 | 6 | EMPTY_SEATS, | |
| 8 | 7 | EV_ACT, | |
| ⋯ 2 unchanged lines | |||
| 11 | 10 | EV_VOICE, | |
| 12 | 11 | type ActMsg, | |
| 13 | 12 | type FxMsg, | |
| 14 | - | type NetMode, | |
| 15 | 13 | type SeatMsg, | |
| 16 | 14 | roomFromUrl, | |
| 17 | 15 | type SeatSlot, | |
| ⋯ 5 unchanged lines | |||
| 23 | 21 | const SEATS: SeatId[] = [0, 1, 2, 3]; | |
| 24 | 22 | ||
| 25 | 23 | /** | |
| 24 | + | * How long a hand waits for the device that was holding it before the computer | |
| 25 | + | * takes it. A wifi blip, a phone locking itself, a walk to the kitchen — all | |
| 26 | + | * shorter than this; a battery going flat is not. The seat is not lost either | |
| 27 | + | * way: coming back takes it straight off the computer again, and so does one | |
| 28 | + | * tap on it at the table. | |
| 29 | + | */ | |
| 30 | + | const EMPTY_SEAT_GRACE = 15_000; | |
| 31 | + | ||
| 32 | + | /** | |
| 26 | 33 | * One device's connection to a room, and everything that follows from it. | |
| 27 | 34 | * | |
| 28 | 35 | * The relay elects a host — the earliest joiner still connected — and the host | |
| ⋯ 3 unchanged lines | |||
| 32 | 39 | * already current — it starts its own engine from what it was just watching | |
| 33 | 40 | * and the game carries on. | |
| 34 | 41 | * | |
| 35 | - | * `role` is this device's, not the room's: the laptop that starts a party game | |
| 36 | - | * is the `screen` everyone throws at; every other device is a `player`. | |
| 42 | + | * There is one kind of game in here. The room carries who holds which hand and | |
| 43 | + | * which device the table is being played at, and every arrangement people call | |
| 44 | + | * a mode — four round a laptop, four in four cities, one against three | |
| 45 | + | * computers — is some setting of those two. A room is only opened when a phone | |
| 46 | + | * is wanted; until then none of this runs at all. | |
| 37 | 47 | */ | |
| 38 | - | export type NetRole = 'player' | 'screen'; | |
| 39 | - | ||
| 40 | 48 | export class NetSession { | |
| 41 | 49 | readonly room: Room; | |
| 42 | 50 | readonly table: NetTable; | |
| 43 | - | role: NetRole = 'player'; | |
| 44 | 51 | /** Latest connection trouble worth showing, or null. */ | |
| 45 | 52 | err: string | null = null; | |
| 46 | 53 | /** | |
| ⋯ 2 unchanged lines | |||
| 49 | 56 | */ | |
| 50 | 57 | onThrowFx: (seat: SeatId, t: NetThrow) => void = () => {}; | |
| 51 | 58 | ||
| 52 | - | private autoplay: AutoPlay | null = null; | |
| 53 | - | private stopAutoplay: (() => void) | null = null; | |
| 59 | + | /** Hands whose device has gone, counting down to the computer taking them. */ | |
| 60 | + | private waiting = new Map<SeatId, ReturnType<typeof setTimeout>>(); | |
| 54 | 61 | /** Who in the room has said they can talk. Kept by the host; see `voices`. */ | |
| 55 | 62 | private talkers = new Map<string, boolean>(); | |
| 56 | 63 | /** The last thing we told the host about ourselves, so we only say changes. */ | |
| 57 | 64 | private toldVoice: boolean | null = null; | |
| 58 | 65 | /** Whether *we* can talk, kept so a new host can be told the same thing. */ | |
| 59 | 66 | private myVoice = false; | |
| 60 | - | private busy: () => boolean = () => false; | |
| 61 | 67 | private listeners = new Set<() => void>(); | |
| 62 | 68 | private version = 0; | |
| 63 | 69 | private unsubs: (() => void)[] = []; | |
| 64 | 70 | ||
| 65 | - | private constructor(room: Room) { | |
| 71 | + | private constructor(room: Room, engine: Game) { | |
| 66 | 72 | this.room = room; | |
| 67 | - | this.table = new NetTable({ | |
| 68 | - | isAuthority: () => this.isHost, | |
| 69 | - | mySeat: () => this.mySeat, | |
| 70 | - | sendAct: (seat, act) => this.room.send(EV_ACT, { seat, act } as never), | |
| 71 | - | }); | |
| 73 | + | this.table = new NetTable( | |
| 74 | + | { | |
| 75 | + | isAuthority: () => this.isHost, | |
| 76 | + | mySeat: () => this.mySeat, | |
| 77 | + | sendAct: (seat, act) => this.room.send(EV_ACT, { seat, act } as never), | |
| 78 | + | }, | |
| 79 | + | engine, | |
| 80 | + | ); | |
| 72 | 81 | ||
| 73 | 82 | const u = this.unsubs; | |
| 74 | 83 | u.push( | |
| ⋯ 1 unchanged line | |||
| 76 | 85 | // State flowing means the connection is back, whatever it said before. | |
| 77 | 86 | this.err = null; | |
| 78 | 87 | this.table.adopt(this.sharedGame()); | |
| 79 | - | this.syncAutoplay(); | |
| 88 | + | this.reviewSeats(); | |
| 80 | 89 | this.bump(); | |
| 81 | 90 | }), | |
| 82 | 91 | room.on(EV_ACT, (p, from) => this.handleAct(p as unknown as ActMsg, from)), | |
| ⋯ 4 unchanged lines | |||
| 87 | 96 | room.onLeave((p) => { | |
| 88 | 97 | if (this.isHost) { | |
| 89 | 98 | this.talkers.delete(p.id); | |
| 99 | + | // The table itself walking out — a closed laptop, a phone that | |
| 100 | + | // started the game and went home. Nobody inherits being the table: | |
| 101 | + | // the hands it was playing have nobody behind them now, and say so. | |
| 102 | + | if (p.id === this.screenId) this.room.setState({ screen: '' as never }); | |
| 90 | 103 | this.vacate(p.id); | |
| 104 | + | this.armStranded(); | |
| 91 | 105 | this.publishVoices(); | |
| 92 | 106 | } | |
| 93 | 107 | this.bump(); | |
| 94 | 108 | }), | |
| 95 | 109 | room.onHostChange(() => { | |
| 96 | 110 | // A new host's engine is its mirror — already current. It only has to | |
| 97 | - | // start acting like the host, which `syncAutoplay` and the guards on | |
| 98 | - | // every handler do by asking `isHost` fresh each time. | |
| 99 | - | this.syncAutoplay(); | |
| 111 | + | // start acting like the host, which the guards on every handler do by | |
| 112 | + | // asking `isHost` fresh each time. | |
| 113 | + | // | |
| 114 | + | // The countdowns on empty hands are not inherited — they lived in the | |
| 115 | + | // old host's timers — so they are worked out again from what the room | |
| 116 | + | // says, which is where the answer was all along. | |
| 117 | + | this.reviewSeats(); | |
| 118 | + | this.armStranded(); | |
| 100 | 119 | // What it does not inherit is who told the old host they could talk, | |
| 101 | 120 | // so everybody says it again. | |
| 102 | 121 | this.toldVoice = null; | |
| 103 | 122 | this.reportVoice(this.myVoice); | |
| 104 | 123 | this.bump(); | |
| 105 | 124 | }), | |
| 125 | + | // Back after a blip. The room's copy of the table is as old as the gap | |
| 126 | + | // and whatever we tried to publish across it was dropped, so the device | |
| 127 | + | // that owns the table says it all again. | |
| 128 | + | room.onResume(() => { | |
| 129 | + | if (this.isHost && this.sharedGame() !== null) this.publishGame(); | |
| 130 | + | this.reviewSeats(); | |
| 131 | + | this.armStranded(); | |
| 132 | + | }), | |
| 106 | 133 | room.onError((e) => { | |
| 107 | 134 | this.err = `${e.message} — ${e.hint}`; | |
| 108 | 135 | this.bump(); | |
| 109 | 136 | }), | |
| 110 | 137 | // The host's own engine is the source of truth: publish every change. | |
| 111 | 138 | this.table.engine.subscribe(() => { | |
| 112 | - | if (this.isHost && this.mode) this.publishGame(); | |
| 113 | - | this.syncAutoplay(); | |
| 139 | + | if (this.isHost && this.sharedGame() !== null) this.publishGame(); | |
| 140 | + | this.reviewSeats(); | |
| 114 | 141 | }), | |
| 115 | 142 | this.table.engine.onSound((cue) => { | |
| 116 | 143 | if (!this.isHost) return; | |
| ⋯ 19 unchanged lines | |||
| 136 | 163 | room.onHostChange((h) => console.log(`[mahjong] host is now ${h?.name} (me: ${room.isHost})`)); | |
| 137 | 164 | } | |
| 138 | 165 | ||
| 139 | - | /** Join the room in the page URL, or create a fresh one. */ | |
| 140 | - | static async join(): Promise<NetSession> { | |
| 166 | + | /** | |
| 167 | + | * Join the room in the page URL, or create a fresh one, and put it around the | |
| 168 | + | * game this page is already playing. The engine is the page's own: a room is | |
| 169 | + | * a wrapper, not a second table, so opening one mid-hand costs nothing. | |
| 170 | + | */ | |
| 171 | + | static async join(engine: Game): Promise<NetSession> { | |
| 141 | 172 | const room = await joinRoom({ room: roomFromUrl() ?? undefined }); | |
| 142 | 173 | // The room code goes into the address bar: the page's own URL becomes the | |
| 143 | 174 | // invite link, and a reload walks back into the same room — and, with the | |
| ⋯ 5 unchanged lines | |||
| 149 | 180 | } catch { | |
| 150 | 181 | // A sandbox that refuses history rewrites still gets to play. | |
| 151 | 182 | } | |
| 152 | - | return new NetSession(room); | |
| 183 | + | return new NetSession(room, engine); | |
| 153 | 184 | } | |
| 154 | 185 | ||
| 155 | 186 | leave() { | |
| 156 | 187 | for (const u of this.unsubs) u(); | |
| 157 | 188 | this.unsubs = []; | |
| 158 | - | this.stopAutoplay?.(); | |
| 159 | - | this.stopAutoplay = null; | |
| 189 | + | for (const t of this.waiting.values()) clearTimeout(t); | |
| 190 | + | this.waiting.clear(); | |
| 160 | 191 | this.room.leave(); | |
| 161 | 192 | } | |
| 162 | 193 | ||
| ⋯ 14 unchanged lines | |||
| 177 | 208 | get isHost() { | |
| 178 | 209 | return this.room.isHost; | |
| 179 | 210 | } | |
| 180 | - | get mode(): NetMode | null { | |
| 181 | - | const m = this.room.state.mode; | |
| 182 | - | return m === 'online' || m === 'party' ? m : null; | |
| 211 | + | /** | |
| 212 | + | * The device the table is being played at — the one that opened the room. It | |
| 213 | + | * is where every hand no phone has taken is played, and it is the one screen | |
| 214 | + | * that goes quiet for a seat whose phone is doing the talking. | |
| 215 | + | */ | |
| 216 | + | get screenId(): string { | |
| 217 | + | const id = this.room.state.screen; | |
| 218 | + | return typeof id === 'string' ? id : ''; | |
| 219 | + | } | |
| 220 | + | /** Whether this device is that one. */ | |
| 221 | + | get isScreen(): boolean { | |
| 222 | + | return this.screenId !== '' && this.screenId === this.room.me.id; | |
| 183 | 223 | } | |
| 184 | 224 | get seats(): SeatSlot[] { | |
| 185 | 225 | const s = this.room.state.seats; | |
| 186 | 226 | return Array.isArray(s) && s.length === 4 ? (s as unknown as SeatSlot[]) : EMPTY_SEATS; | |
| 187 | 227 | } | |
| 188 | 228 | /** What a seat is called when nobody from the room holds it — the name the | |
| 189 | - | * party table dealt it with, or nothing at all online. */ | |
| 229 | + | * table dealt it with. */ | |
| 190 | 230 | private homeName(seat: SeatId): string { | |
| 191 | 231 | const h = this.room.state.homes; | |
| 192 | 232 | return Array.isArray(h) && typeof h[seat] === 'string' ? (h[seat] as string) : ''; | |
| ⋯ 6 unchanged lines | |||
| 199 | 239 | for (const i of SEATS) if (seats[i].ids.includes(this.room.me.id)) return i; | |
| 200 | 240 | return null; | |
| 201 | 241 | } | |
| 202 | - | /** Whether a hand has actually been dealt in this room. */ | |
| 203 | - | get started(): boolean { | |
| 204 | - | const g = this.table.engine.state; | |
| 205 | - | return this.sharedGame() !== null && g.phase !== 'lobby'; | |
| 242 | + | /** Whether this room has a table in it yet — one has been published, and a | |
| 243 | + | * hand actually dealt. */ | |
| 244 | + | get open(): boolean { | |
| 245 | + | return this.sharedGame() !== null && this.table.engine.state.phase !== 'lobby'; | |
| 206 | 246 | } | |
| 207 | 247 | /** Everyone here, for the lobby's presence line. */ | |
| 208 | 248 | get playerCount() { | |
| 209 | 249 | return this.room.players.length; | |
| 210 | 250 | } | |
| 211 | 251 | ||
| 212 | - | // ---- lobby / starting -------------------------------------------------- | |
| 252 | + | // ---- opening the room -------------------------------------------------- | |
| 213 | 253 | /** | |
| 214 | - | * Open an online game in this room: publish the mode and take a seat. Only | |
| 215 | - | * the host can (room state is the host's to write); the button that calls | |
| 216 | - | * this is only shown to the host. | |
| 254 | + | * Put this device's table into the room. | |
| 255 | + | * | |
| 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 | + | * | |
| 262 | + | * 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. | |
| 217 | 266 | */ | |
| 218 | - | openOnlineLobby() { | |
| 267 | + | publishTable(names: string[]) { | |
| 219 | 268 | if (!this.isHost) return; | |
| 269 | + | if (this.sharedGame() !== null) return; // already open — somebody beat us to it | |
| 270 | + | console.log('[mahjong] opening the room around this table'); | |
| 271 | + | const seats = SEATS.map((i) => ({ ids: [] as string[], name: names[i] ?? '' })); | |
| 220 | 272 | this.room.setState({ | |
| 221 | - | mode: 'online', | |
| 222 | - | seats: structuredClone(EMPTY_SEATS) as never, | |
| 223 | - | homes: ['', '', '', ''] as never, | |
| 273 | + | seats: seats as never, | |
| 274 | + | homes: seats.map((q) => q.name) as never, | |
| 224 | 275 | voices: [false, false, false, false] as never, | |
| 276 | + | screen: this.room.me.id as never, | |
| 277 | + | game: structuredClone(this.table.engine.state) as never, | |
| 225 | 278 | }); | |
| 226 | - | this.claimSeat(0); | |
| 227 | - | } | |
| 228 | - | ||
| 229 | - | /** Deal the online game. Seats nobody claimed go to the computer. */ | |
| 230 | - | startOnline(rules: Rules) { | |
| 231 | - | if (!this.isHost) return; | |
| 232 | - | const seats = this.seats; | |
| 233 | - | const bots = seats.map((s) => s.ids.length === 0); | |
| 234 | - | if (bots.every(Boolean)) return; // nobody seated at all | |
| 235 | - | const names = seats.map((s, i) => s.name || `玩家 ${i + 1}`); | |
| 236 | - | const eng = this.table.engine; | |
| 237 | - | eng.applySettings(names, rules); | |
| 238 | - | eng.newGame(bots, names); | |
| 279 | + | this.bump(); | |
| 239 | 280 | } | |
| 240 | 281 | ||
| 241 | 282 | /** | |
| 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. | |
| 283 | + | * Hand a seat to the computer, or take it back off it. Asked for from the | |
| 284 | + | * table — the screen's own button, and the tap on a computer's strip that | |
| 285 | + | * says somebody has sat down there. | |
| 252 | 286 | */ | |
| 253 | - | startParty(names: string[], rules: Rules, from?: GameState) { | |
| 254 | - | console.log(`[mahjong] startParty (host: ${this.isHost})`); | |
| 255 | - | if (!this.isHost) return; | |
| 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; | |
| 260 | - | const seats = EMPTY_SEATS.map((_, i) => ({ ids: [], name: names[i] ?? '' })); | |
| 261 | - | this.room.setState({ | |
| 262 | - | mode: 'party', | |
| 263 | - | seats: seats as never, | |
| 264 | - | homes: seats.map((s) => s.name) as never, | |
| 265 | - | voices: [false, false, false, false] as never, | |
| 266 | - | }); | |
| 267 | - | const eng = this.table.engine; | |
| 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 | - | } | |
| 287 | + | setBot(seat: SeatId, bot: boolean) { | |
| 288 | + | if (bot && this.seats[seat].ids.length > 0) return; // somebody is holding it | |
| 289 | + | this.clearWait(seat); | |
| 290 | + | if (this.isHost) this.table.engine.setBot(seat, bot); | |
| 291 | + | else this.room.send(EV_ACT, { seat, act: { kind: 'bot', on: bot } } as never); | |
| 275 | 292 | } | |
| 276 | 293 | ||
| 277 | 294 | /** Ask for a seat — a numbered one, or any free one. */ | |
| ⋯ 2 unchanged lines | |||
| 280 | 297 | else this.room.send(EV_SEAT, { seat } as never); | |
| 281 | 298 | } | |
| 282 | 299 | ||
| 283 | - | /** Hand a deserted seat to the computer (host's own button). */ | |
| 284 | - | handToBot(seat: SeatId) { | |
| 285 | - | if (!this.isHost) return; | |
| 286 | - | if (this.seats[seat].ids.length > 0) return; | |
| 287 | - | this.table.engine.setBot(seat, true); | |
| 288 | - | } | |
| 289 | - | ||
| 290 | - | /** The pool animations to wait on before a computer player moves. */ | |
| 291 | - | setBusy(busy: () => boolean) { | |
| 292 | - | this.busy = busy; | |
| 293 | - | } | |
| 294 | - | ||
| 295 | 300 | // ---- host duties ------------------------------------------------------- | |
| 296 | 301 | /** | |
| 297 | 302 | * A claim never fails. A seat is a hand, not a chair: two phones may hold | |
| ⋯ 15 unchanged lines | |||
| 313 | 318 | if (seats[seat].ids.length === 1 && name) seats[seat].name = name; | |
| 314 | 319 | this.settleSeats(seats); | |
| 315 | 320 | ||
| 316 | - | // Mid-game, the seat comes back from the computer (or the common screen) | |
| 317 | - | // to the person who claimed it. | |
| 321 | + | // The hand comes back from the computer — or off the screen it was being | |
| 322 | + | // played at — to the person who claimed it. Mid-hand and all: taking a | |
| 323 | + | // seat is taking whatever is in it, which is the point. | |
| 324 | + | this.clearWait(seat); | |
| 318 | 325 | if (this.sharedGame() !== null) { | |
| 319 | 326 | this.table.engine.setBot(seat, false); | |
| 320 | 327 | if (seats[seat].name) this.table.engine.renameSeat(seat, seats[seat].name); | |
| ⋯ 1 unchanged line | |||
| 322 | 329 | this.bump(); | |
| 323 | 330 | } | |
| 324 | 331 | ||
| 332 | + | /** | |
| 333 | + | * Somebody's device is gone. Any hand it was the last one holding starts | |
| 334 | + | * counting down, and the computer takes it when the count runs out — the | |
| 335 | + | * table has to be able to keep playing, and a hand nobody is holding is a | |
| 336 | + | * hand nobody is playing. | |
| 337 | + | * | |
| 338 | + | * Not immediately, because a phone locking itself looks exactly like a phone | |
| 339 | + | * leaving for good and only one of them means it. And not for keeps either | |
| 340 | + | * way: whoever it was walks back into the same seat when they come back, and | |
| 341 | + | * anyone at the table can take it off the computer with one tap. | |
| 342 | + | */ | |
| 325 | 343 | private vacate(id: string) { | |
| 326 | 344 | const seats = structuredClone(this.seats); | |
| 327 | - | let changed = false; | |
| 345 | + | const emptied: SeatId[] = []; | |
| 328 | 346 | for (const i of SEATS) { | |
| 329 | 347 | const kept = seats[i].ids.filter((x) => x !== id); | |
| 330 | - | if (kept.length !== seats[i].ids.length) changed = true; | |
| 348 | + | if (kept.length === seats[i].ids.length) continue; | |
| 331 | 349 | seats[i].ids = kept; | |
| 350 | + | if (kept.length === 0) emptied.push(i); | |
| 332 | 351 | } | |
| 333 | - | if (changed) this.settleSeats(seats); | |
| 334 | - | // The seat stays human: at a party table the common screen simply plays it | |
| 335 | - | // again, and online the host is offered the 電腦代打 button instead of the | |
| 336 | - | // table deciding on its own that a dropped friend is gone for good. | |
| 352 | + | if (emptied.length === 0) return; | |
| 353 | + | this.settleSeats(seats); | |
| 354 | + | for (const i of emptied) this.armWait(i); | |
| 337 | 355 | } | |
| 338 | 356 | ||
| 339 | - | /** Publish the seat map, giving emptied hands their own names back. */ | |
| 357 | + | /** | |
| 358 | + | * Hands with nobody on them and no table behind them either — the screen | |
| 359 | + | * closed its lid, and the phones that are left are each holding their own. | |
| 360 | + | * Same countdown, same computer at the end of it. | |
| 361 | + | */ | |
| 362 | + | private armStranded() { | |
| 363 | + | if (!this.isHost || this.sharedGame() === null) return; | |
| 364 | + | if (this.screenId !== '' && this.room.player(this.screenId)) return; | |
| 365 | + | // The screen went without anyone left to notice — it was the host, so its | |
| 366 | + | // own leave handler never ran. Whoever inherited the room says so now. | |
| 367 | + | if (this.screenId !== '') this.room.setState({ screen: '' as never }); | |
| 368 | + | for (const i of SEATS) { | |
| 369 | + | if (this.seats[i].ids.length === 0 && !this.table.engine.state.bots[i]) this.armWait(i); | |
| 370 | + | } | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | private armWait(seat: SeatId) { | |
| 374 | + | if (!this.isHost || this.waiting.has(seat)) return; | |
| 375 | + | this.waiting.set( | |
| 376 | + | seat, | |
| 377 | + | setTimeout(() => { | |
| 378 | + | this.waiting.delete(seat); | |
| 379 | + | if (!this.isHost || this.seats[seat].ids.length > 0) return; | |
| 380 | + | this.table.engine.setBot(seat, true); | |
| 381 | + | this.bump(); | |
| 382 | + | }, EMPTY_SEAT_GRACE), | |
| 383 | + | ); | |
| 384 | + | this.bump(); | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | private clearWait(seat: SeatId) { | |
| 388 | + | const t = this.waiting.get(seat); | |
| 389 | + | if (t === undefined) return; | |
| 390 | + | clearTimeout(t); | |
| 391 | + | this.waiting.delete(seat); | |
| 392 | + | this.bump(); | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | /** A hand somebody walked away from, still being kept for them. */ | |
| 396 | + | isWaiting(seat: SeatId): boolean { | |
| 397 | + | return this.waiting.has(seat); | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | /** Countdowns on hands that have since been filled, or given away, are over. */ | |
| 401 | + | private reviewSeats() { | |
| 402 | + | if (!this.isHost) { | |
| 403 | + | for (const t of this.waiting.values()) clearTimeout(t); | |
| 404 | + | this.waiting.clear(); | |
| 405 | + | return; | |
| 406 | + | } | |
| 407 | + | const bots = this.table.engine.state.bots; | |
| 408 | + | for (const i of SEATS) { | |
| 409 | + | if (this.seats[i].ids.length > 0 || bots[i]) this.clearWait(i); | |
| 410 | + | } | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | /** | |
| 414 | + | * Publish the seat map, giving emptied hands the table's own name for them | |
| 415 | + | * back — 東家, or whatever the settings called it. The person who was | |
| 416 | + | * holding it took their name with them when they went. | |
| 417 | + | */ | |
| 340 | 418 | private settleSeats(seats: SeatSlot[]) { | |
| 341 | - | const party = this.mode === 'party'; | |
| 342 | 419 | for (const i of SEATS) { | |
| 343 | 420 | if (seats[i].ids.length > 0) continue; | |
| 344 | - | if (party) { | |
| 345 | - | // Back to the common screen under the table's own name; online the | |
| 346 | - | // engine keeps the leaver's name — a deserted hand mid-game is still | |
| 347 | - | // theirs to talk about, and to come back to. | |
| 348 | - | seats[i].name = this.homeName(i); | |
| 349 | - | if (seats[i].name) this.table.engine.renameSeat(i, seats[i].name); | |
| 350 | - | } else { | |
| 351 | - | seats[i].name = ''; | |
| 352 | - | } | |
| 421 | + | seats[i].name = this.homeName(i); | |
| 422 | + | if (seats[i].name) this.table.engine.renameSeat(i, seats[i].name); | |
| 353 | 423 | } | |
| 354 | 424 | this.room.setState({ seats: seats as never }); | |
| 355 | 425 | // A hand that changed hands changed who speaks for it. | |
| ⋯ 3 unchanged lines | |||
| 359 | 429 | private handleAct(msg: ActMsg, from: string) { | |
| 360 | 430 | if (!this.isHost || !msg || typeof msg.seat !== 'number') return; | |
| 361 | 431 | const seat = msg.seat; | |
| 362 | - | // A hand they hold — or one nobody holds, which is how the party screen | |
| 363 | - | // plays its unclaimed seats when it is not the one running the engine | |
| 364 | - | // (after a reload, say, when a phone has inherited the host's chair). | |
| 432 | + | // A hand they hold — or, for a hand nobody holds, the device the table is | |
| 433 | + | // being played at, which is how the screen plays its unclaimed seats when | |
| 434 | + | // it is not the one running the engine (after a reload, say, when a phone | |
| 435 | + | // has inherited the host's chair). | |
| 365 | 436 | const holders = this.seats[seat]?.ids ?? []; | |
| 366 | - | if (holders.length > 0 && !holders.includes(from)) return; | |
| 437 | + | const table = this.screenId || this.room.me.id; | |
| 438 | + | if (holders.length > 0 ? !holders.includes(from) : from !== table) return; | |
| 367 | 439 | const eng = this.table.engine; | |
| 368 | 440 | const act = msg.act; | |
| 369 | 441 | switch (act.kind) { | |
| ⋯ 27 unchanged lines | |||
| 397 | 469 | case 'newGame': | |
| 398 | 470 | eng.newGame(eng.state.bots); | |
| 399 | 471 | break; | |
| 472 | + | case 'bot': | |
| 473 | + | // Only ever about a hand nobody is holding — the guard above has | |
| 474 | + | // already turned away anyone asking about somebody else's. | |
| 475 | + | this.clearWait(seat); | |
| 476 | + | eng.setBot(seat, act.on); | |
| 477 | + | break; | |
| 400 | 478 | } | |
| 401 | 479 | } | |
| 402 | 480 | ||
| ⋯ 41 unchanged lines | |||
| 444 | 522 | */ | |
| 445 | 523 | private publishVoices() { | |
| 446 | 524 | if (!this.isHost) return; | |
| 447 | - | const screen = this.mode === 'party' ? this.room.me.id : ''; | |
| 525 | + | const screen = this.screenId; | |
| 448 | 526 | const voices = SEATS.map((i) => | |
| 449 | 527 | this.seats[i].ids.some((id) => id !== screen && this.talkers.get(id) === true), | |
| 450 | 528 | ); | |
| ⋯ 17 unchanged lines | |||
| 468 | 546 | // The engine mutates its state in place; the room keeps what it is handed, | |
| 469 | 547 | // so it gets a copy that will hold still. | |
| 470 | 548 | this.room.setState({ game: structuredClone(this.table.engine.state) as never }); | |
| 471 | - | } | |
| 472 | - | ||
| 473 | - | /** The computer plays exactly when this device is the host of a live game. */ | |
| 474 | - | private syncAutoplay() { | |
| 475 | - | const should = this.isHost && this.mode !== null && this.sharedGame() !== null; | |
| 476 | - | if (should && !this.autoplay) { | |
| 477 | - | this.autoplay = new AutoPlay(this.table.engine, () => this.busy()); | |
| 478 | - | this.stopAutoplay = this.autoplay.start(); | |
| 479 | - | } else if (!should && this.autoplay) { | |
| 480 | - | this.stopAutoplay?.(); | |
| 481 | - | this.autoplay = null; | |
| 482 | - | this.stopAutoplay = null; | |
| 483 | - | } | |
| 484 | 549 | } | |
| 485 | 550 | } | |
modifiedsrc/net/table.ts+6 −5
| ⋯ 20 unchanged lines | |||
| 21 | 21 | const PENDING_TTL = 3000; | |
| 22 | 22 | ||
| 23 | 23 | /** | |
| 24 | - | * The online table: the same surface the engine offers (`TableCtl`), backed by | |
| 25 | - | * a `Game` that is either the real one or a mirror of the host's. | |
| 24 | + | * The networked table: the same surface the engine offers (`TableCtl`), put | |
| 25 | + | * around the one `Game` this page has. There is no second engine and no copy — | |
| 26 | + | * opening a room is a wrapper going on, and a hand in progress carries straight | |
| 27 | + | * through it. | |
| 26 | 28 | * | |
| 27 | 29 | * On the host the inner engine is authoritative and calls go straight into it. | |
| 28 | 30 | * Everywhere else the inner engine is a mirror kept current by `adopt`, and a | |
| ⋯ 29 unchanged lines | |||
| 58 | 60 | ||
| 59 | 61 | private sounds = new Set<(cue: SoundCue) => void>(); | |
| 60 | 62 | ||
| 61 | - | constructor(wire: TableWire) { | |
| 63 | + | constructor(wire: TableWire, engine: Game) { | |
| 62 | 64 | this.wire = wire; | |
| 63 | - | this.engine = new Game(); | |
| 64 | - | this.engine.persist = false; | |
| 65 | + | this.engine = engine; | |
| 65 | 66 | this.engine.subscribe(() => this.bump()); | |
| 66 | 67 | } | |
| 67 | 68 | ||
| ⋯ 197 unchanged lines | |||
modifiedsrc/styles.css+39 −0
| ⋯ 795 unchanged lines | |||
| 796 | 796 | .btn.discard { | |
| 797 | 797 | background: linear-gradient(180deg, #fff2cf, #e0c070); | |
| 798 | 798 | } | |
| 799 | + | /* Somebody pulling up a chair at a hand the computer has been playing. It is | |
| 800 | + | the only button on that strip, so it may as well be the warm one — and it is | |
| 801 | + | the same gold as 摸牌, because both are the table's way of saying this edge | |
| 802 | + | is yours now. */ | |
| 803 | + | .btn.sit { | |
| 804 | + | background: rgba(217, 180, 92, 0.18); | |
| 805 | + | border-color: rgba(217, 180, 92, 0.55); | |
| 806 | + | color: #f6f0e2; | |
| 807 | + | } | |
| 799 | 808 | ||
| 800 | 809 | /* ---------- the action buttons are tiles ---------- */ | |
| 801 | 810 | /* 打出, 碰, 吃, 槓, 胡, 過 are all things you do *with* a tile, and they sit in | |
| ⋯ 1779 unchanged lines | |||
| 2581 | 2590 | user-select: none; | |
| 2582 | 2591 | touch-action: none; | |
| 2583 | 2592 | } | |
| 2593 | + | /* The device holding this hand has gone quiet and the hand is being kept for | |
| 2594 | + | it. Amber rather than red: nothing is lost, and in a few seconds the | |
| 2595 | + | computer picks the tiles up so the table can carry on. */ | |
| 2596 | + | .nameplate .tag.waiting { | |
| 2597 | + | background: rgba(217, 180, 92, 0.28); | |
| 2598 | + | border-color: rgba(217, 180, 92, 0.6); | |
| 2599 | + | padding-inline: 5px; | |
| 2600 | + | } | |
| 2584 | 2601 | /* This hand is calling for itself, out of the phone holding it. Sits next to | |
| 2585 | 2602 | the 📱 tag and reads as part of it, which is what it is. */ | |
| 2586 | 2603 | .nameplate .tag.voice { | |
| ⋯ 302 unchanged lines | |||
| 2889 | 2906 | font-size: 11px; | |
| 2890 | 2907 | opacity: 0.75; | |
| 2891 | 2908 | } | |
| 2909 | + | ||
| 2910 | + | /* ---------- the wire, when it goes ---------- */ | |
| 2911 | + | /* One line across the top of the felt. The room is already reconnecting on its | |
| 2912 | + | own, so this says what is happening rather than asking for anything, and it | |
| 2913 | + | goes away by itself when state starts flowing again. Above the tiles and out | |
| 2914 | + | of the way of every strip, which all sit against their own edge. */ | |
| 2915 | + | .net-warn { | |
| 2916 | + | position: absolute; | |
| 2917 | + | top: 6px; | |
| 2918 | + | left: 50%; | |
| 2919 | + | transform: translateX(-50%); | |
| 2920 | + | z-index: 60; | |
| 2921 | + | max-width: 70vw; | |
| 2922 | + | padding: 4px 12px; | |
| 2923 | + | border-radius: 999px; | |
| 2924 | + | background: rgba(20, 40, 30, 0.82); | |
| 2925 | + | border: 1px solid rgba(217, 180, 92, 0.5); | |
| 2926 | + | color: rgba(246, 240, 226, 0.85); | |
| 2927 | + | font-size: 12px; | |
| 2928 | + | text-align: center; | |
| 2929 | + | pointer-events: none; | |
| 2930 | + | } | |
modifiedsrc/ui/Controller.tsx+39 −30
| ⋯ 23 unchanged lines | |||
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | 26 | /** | |
| 27 | - | * The phone at the party table: just your own tiles, held in landscape, with | |
| 28 | - | * the common screen doing the table's share of the talking. What it keeps of | |
| 29 | - | * the full table is the things that are yours — the buttons a claim window | |
| 30 | - | * owes you, the throw, and your voice. Flick a tile up off the top of the | |
| 31 | - | * phone and it leaves your hand here, crosses the wire, and comes flying in | |
| 32 | - | * from your edge of the shared screen. | |
| 27 | + | * A hand held on a phone: just your own tiles, held in landscape, with the | |
| 28 | + | * table doing the table's share of the talking. What it keeps of the full | |
| 29 | + | * table is the things that are yours — the buttons a claim window owes you, | |
| 30 | + | * the throw, and your voice. Flick a tile up off the top of the phone and it | |
| 31 | + | * leaves your hand here, crosses the wire, and comes flying in from your edge | |
| 32 | + | * of the shared screen. | |
| 33 | 33 | * | |
| 34 | - | * Your voice, because the five devices at a party table are all in the same | |
| 35 | - | * room: with sound on, this phone is what says 碰 when you call one, and the | |
| 36 | - | * common screen stops saying it for you. Mute the phone and the table takes | |
| 37 | - | * the job back — see `net/session.ts` for how it is told. | |
| 34 | + | * Your voice, because a phone is in somebody's hand and a hand is in the room: | |
| 35 | + | * with sound on, this phone is what says 碰 when you call one, and the table | |
| 36 | + | * stops saying it for you. Mute the phone and the table takes the job back — | |
| 37 | + | * see `net/session.ts` for how it is told. | |
| 38 | 38 | */ | |
| 39 | 39 | export function Controller({ session, prefs, onPrefs }: CtlProps) { | |
| 40 | 40 | const seat = session.mySeat; | |
| 41 | - | ||
| 42 | - | // The QR carries which seat it was stuck to; a phone that arrived some other | |
| 43 | - | // way — or lost the parameter on the way in — picks one by hand below. | |
| 44 | - | const [asked, setAsked] = useState(false); | |
| 45 | - | useEffect(() => { | |
| 46 | - | if (asked || seat !== null) return; | |
| 47 | - | const want = seatFromUrl(); | |
| 48 | - | if (want !== null) { | |
| 49 | - | setAsked(true); | |
| 50 | - | session.claimSeat(want); | |
| 51 | - | } | |
| 52 | - | }, [session, seat, asked]); | |
| 53 | - | ||
| 54 | - | if (seat === null) { | |
| 55 | - | return <SeatPick session={session} joining={asked} prefs={prefs} onPrefs={onPrefs} />; | |
| 56 | - | } | |
| 41 | + | if (seat === null) return <SeatPick session={session} prefs={prefs} onPrefs={onPrefs} />; | |
| 57 | 42 | return <SeatStrip session={session} seat={seat} prefs={prefs} onPrefs={onPrefs} />; | |
| 58 | 43 | } | |
| 59 | 44 | ||
| 60 | - | function SeatPick({ session, joining, prefs, onPrefs }: CtlProps & { joining?: boolean }) { | |
| 45 | + | /** | |
| 46 | + | * Which hand you are taking. Shown to any device that has walked into a room | |
| 47 | + | * and is not the table it was opened around — a phone off a QR, a friend off | |
| 48 | + | * the invite link. | |
| 49 | + | * | |
| 50 | + | * Every seat is offered, not only the empty ones. A hand the computer is | |
| 51 | + | * playing is the ordinary way in — that is what the three chairs nobody has | |
| 52 | + | * taken are — and a hand somebody else is already holding can be sat down at | |
| 53 | + | * too, which is how a pair play one hand off two devices. | |
| 54 | + | */ | |
| 55 | + | export function SeatPick({ session, prefs, onPrefs }: CtlProps) { | |
| 61 | 56 | const t = useT(); | |
| 62 | 57 | const lang = useLang(); | |
| 63 | 58 | const seats = session.seats; | |
| 64 | 59 | const s = session.table.state; | |
| 60 | + | ||
| 61 | + | // A seat's own QR carries which hand it was stuck to, so scanning it lands | |
| 62 | + | // on that chair without anybody having to say which one they are in. The | |
| 63 | + | // table's shared QR names none, and then the choice is made here. | |
| 64 | + | const [asked, setAsked] = useState(false); | |
| 65 | + | const want = seatFromUrl(); | |
| 66 | + | useEffect(() => { | |
| 67 | + | if (asked || want === null) return; | |
| 68 | + | setAsked(true); | |
| 69 | + | session.claimSeat(want); | |
| 70 | + | }, [session, want, asked]); | |
| 71 | + | ||
| 65 | 72 | return ( | |
| 66 | 73 | <div className="ctl ctl-pick"> | |
| 67 | 74 | <h1>{lang === 'en' ? 'Taiwanese Mahjong' : '台灣麻將'}</h1> | |
| 68 | 75 | <p className="sub"> | |
| 69 | - | {joining | |
| 76 | + | {asked | |
| 70 | 77 | ? `${t('入座中…', 'taking your seat')} — ${t('或先選別的', 'or pick another')}` | |
| 71 | - | : t('選一個座位', 'pick your seat', ' · ')} | |
| 78 | + | : t('選一家', 'pick a hand', ' · ')} | |
| 72 | 79 | </p> | |
| 73 | 80 | <div className="pick-grid"> | |
| 74 | 81 | {SEATS.map((i) => { | |
| ⋯ 5 unchanged lines | |||
| 80 | 87 | <em> | |
| 81 | 88 | {held > 0 | |
| 82 | 89 | ? `${seats[i].name} — ${t('一起打', 'join this hand')}` | |
| 83 | - | : seats[i].name || t('空位', 'open')} | |
| 90 | + | : s.bots[i] | |
| 91 | + | ? `${t('電腦代打中', 'the computer has it')} — ${t('接手', 'take over')}` | |
| 92 | + | : seats[i].name || t('在桌上', 'played at the table', ' · ')} | |
| 84 | 93 | </em> | |
| 85 | 94 | </button> | |
| 86 | 95 | ); | |
| ⋯ 254 unchanged lines | |||
modifiedsrc/ui/Hand.tsx+1 −1
| ⋯ 301 unchanged lines | |||
| 302 | 302 | className={`hand ${onFaceUp ? 'covered' : ''}`} | |
| 303 | 303 | role={onFaceUp ? 'button' : undefined} | |
| 304 | 304 | tabIndex={onFaceUp ? 0 : undefined} | |
| 305 | - | aria-label={onFaceUp ? t('看牌', 'look at your hand', ' · ') : undefined} | |
| 305 | + | aria-label={onFaceUp ? t('看牌', 'look at this hand', ' · ') : undefined} | |
| 306 | 306 | onClick={onFaceUp} | |
| 307 | 307 | onKeyDown={onFaceUp && ((e) => (e.key === 'Enter' || e.key === ' ') && onFaceUp())} | |
| 308 | 308 | > | |
| ⋯ 32 unchanged lines | |||
deletedsrc/ui/OnlineLobby.tsx+0 −100
| 1 | - | import { useEffect, useRef } from 'react'; | |
| 2 | - | import type { Prefs } from '../game/prefs'; | |
| 3 | - | import { WIND_EN, WIND_NAMES } from '../game/tiles'; | |
| 4 | - | import type { SeatId } from '../game/types'; | |
| 5 | - | import type { NetSession } from '../net/session'; | |
| 6 | - | import { Bi, BiText } from './Bi'; | |
| 7 | - | import { useLang, useT } from './lang'; | |
| 8 | - | ||
| 9 | - | const SEATS: SeatId[] = [0, 1, 2, 3]; | |
| 10 | - | ||
| 11 | - | /** | |
| 12 | - | * The room before the deal: who is here, who is sitting where, and — for the | |
| 13 | - | * host — the button that starts it. Seats nobody takes go to the computer, so | |
| 14 | - | * two friends and two bots is a game, not a wait. | |
| 15 | - | */ | |
| 16 | - | export function OnlineLobby({ session, prefs }: { session: NetSession; prefs: Prefs }) { | |
| 17 | - | const t = useT(); | |
| 18 | - | const lang = useLang(); | |
| 19 | - | const seats = session.seats; | |
| 20 | - | const me = session.room.me.id; | |
| 21 | - | ||
| 22 | - | // Walking in unseated: take the first free chair rather than making everyone | |
| 23 | - | // pick one. Moving is one tap on another seat — including one somebody | |
| 24 | - | // already holds, which is how two people share a hand. | |
| 25 | - | const claimed = useRef(false); | |
| 26 | - | useEffect(() => { | |
| 27 | - | if (claimed.current || session.mySeat !== null) return; | |
| 28 | - | if (seats.some((s) => s.ids.length === 0)) { | |
| 29 | - | claimed.current = true; | |
| 30 | - | session.claimSeat(null); | |
| 31 | - | } | |
| 32 | - | }, [session, seats]); | |
| 33 | - | ||
| 34 | - | const seated = seats.filter((s) => s.ids.length > 0).length; | |
| 35 | - | ||
| 36 | - | return ( | |
| 37 | - | <div className="lobby"> | |
| 38 | - | <h1>{lang === 'en' ? 'Taiwanese Mahjong' : '台灣麻將'}</h1> | |
| 39 | - | <p className="sub"> | |
| 40 | - | {t('線上對戰', 'online game', ' · ')} · {session.playerCount}{' '} | |
| 41 | - | {t('人在房', 'in the room')} | |
| 42 | - | </p> | |
| 43 | - | <p className="hint"> | |
| 44 | - | <BiText | |
| 45 | - | zh="把這頁的連結傳給朋友,他們一開就進同一房。" | |
| 46 | - | en="Share this page's invite link — anyone who opens it lands at this table." | |
| 47 | - | /> | |
| 48 | - | </p> | |
| 49 | - | ||
| 50 | - | <div className="pick-grid"> | |
| 51 | - | {SEATS.map((i) => { | |
| 52 | - | const slot = seats[i]; | |
| 53 | - | const mine = slot.ids.includes(me); | |
| 54 | - | const held = slot.ids.length; | |
| 55 | - | return ( | |
| 56 | - | <button | |
| 57 | - | key={i} | |
| 58 | - | className={`btn big-btn ${mine ? '' : 'ghost'}`} | |
| 59 | - | onClick={() => !mine && session.claimSeat(i)} | |
| 60 | - | > | |
| 61 | - | {t(WIND_NAMES[i], WIND_EN[i])} | |
| 62 | - | <em> | |
| 63 | - | {held === 0 | |
| 64 | - | ? t('空位,開局時由電腦代打', 'open — a bot if nobody sits', ' · ') | |
| 65 | - | : mine | |
| 66 | - | ? `${slot.name || t('你', 'you')}${ | |
| 67 | - | held > 1 ? ` +${held - 1}` : '' | |
| 68 | - | } · ${t('你', 'you')}` | |
| 69 | - | : `${slot.name}${held > 1 ? ` +${held - 1}` : ''} · ${t('可同坐', 'join them')}`} | |
| 70 | - | </em> | |
| 71 | - | </button> | |
| 72 | - | ); | |
| 73 | - | })} | |
| 74 | - | </div> | |
| 75 | - | ||
| 76 | - | <div className="lobby-buttons"> | |
| 77 | - | {session.isHost ? ( | |
| 78 | - | <button | |
| 79 | - | className="btn big-btn" | |
| 80 | - | disabled={seated === 0} | |
| 81 | - | onClick={() => session.startOnline(prefs.rules)} | |
| 82 | - | > | |
| 83 | - | <Bi | |
| 84 | - | zh="開局" | |
| 85 | - | en="Deal" | |
| 86 | - | sub={ | |
| 87 | - | seated < 4 | |
| 88 | - | ? t(`${4 - seated} 個電腦`, `${4 - seated} bots fill in`) | |
| 89 | - | : undefined | |
| 90 | - | } | |
| 91 | - | /> | |
| 92 | - | </button> | |
| 93 | - | ) : ( | |
| 94 | - | <p className="hint">{t('等房主開局', 'waiting for the host to deal', ' · ')}</p> | |
| 95 | - | )} | |
| 96 | - | </div> | |
| 97 | - | {session.err && <p className="hint">{session.err}</p>} | |
| 98 | - | </div> | |
| 99 | - | ); | |
| 100 | - | } |
modifiedsrc/ui/Seat.tsx+70 −26
| ⋯ 11 unchanged lines | |||
| 12 | 12 | import { TileView } from './TileView'; | |
| 13 | 13 | ||
| 14 | 14 | /** | |
| 15 | - | * A seat that exists somewhere else — a phone at the party table, or an online | |
| 16 | - | * player. What of it shows on this screen is decided here. | |
| 15 | + | * Who is playing this hand, and what the table can do about it. | |
| 16 | + | * | |
| 17 | + | * There are three answers and they are the same three everywhere: the computer | |
| 18 | + | * has it, somebody at this screen has it, or it has gone onto a device of its | |
| 19 | + | * own. Any of them turns into any other mid-game — that is what the callbacks | |
| 20 | + | * are — and none of them redeals anything. Only the device the table is being | |
| 21 | + | * played at gets one of these; a visitor holding one hand has no business | |
| 22 | + | * moving the other three about. | |
| 17 | 23 | */ | |
| 18 | - | export interface RemoteSeat { | |
| 24 | + | export interface SeatWho { | |
| 19 | 25 | /** Who holds it from their own device, or null while nobody does. */ | |
| 20 | 26 | holder?: string | null; | |
| 21 | - | /** Party screen: holding the 📱 tag re-shows the table's shared join QR, | |
| 22 | - | * for the player who lost theirs. The QR itself lives in the table's | |
| 23 | - | * corner — see App — because the strip clips its own contents. */ | |
| 27 | + | /** | |
| 28 | + | * The device holding it has gone and the hand is being kept for a moment in | |
| 29 | + | * case that was a pocket rather than a goodbye. The computer takes it when | |
| 30 | + | * the moment is up, and gives it straight back to whoever comes for it. | |
| 31 | + | */ | |
| 32 | + | 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. */ | |
| 24 | 36 | onPeek?: (show: boolean) => void; | |
| 25 | - | /** Online host: the seat's player is gone; offer it to the computer. */ | |
| 26 | - | onHandToBot?: () => void; | |
| 37 | + | /** Somebody has sat down at this edge: take the hand off the computer. */ | |
| 38 | + | onSit?: () => void; | |
| 39 | + | /** Nobody is going to play it here: give it to the computer. */ | |
| 40 | + | onBot?: () => void; | |
| 41 | + | /** Take it onto a phone — opens the room first if there is not one yet. */ | |
| 42 | + | onPhone?: () => void; | |
| 27 | 43 | /** | |
| 28 | - | * Party table: this seat's phone has its sound on, so the calls for this | |
| 29 | - | * hand are coming out of it rather than out of this screen. Worth saying on | |
| 30 | - | * the nameplate, because it is the difference between the table having gone | |
| 31 | - | * quiet for a seat and having gone wrong. | |
| 44 | + | * This seat's phone has its sound on, so the calls for this hand are coming | |
| 45 | + | * out of it rather than out of this screen. Worth saying on the nameplate, | |
| 46 | + | * because it is the difference between the table having gone quiet for a | |
| 47 | + | * seat and having gone wrong. | |
| 32 | 48 | */ | |
| 33 | 49 | speaks?: boolean; | |
| 34 | 50 | } | |
| ⋯ 23 unchanged lines | |||
| 58 | 74 | * because covering your own hand from yourself is just a tap in the way. | |
| 59 | 75 | */ | |
| 60 | 76 | shared?: boolean; | |
| 61 | - | remote?: RemoteSeat; | |
| 77 | + | who?: SeatWho; | |
| 62 | 78 | /** Phone-sized table: nothing is rotated and only the bottom gets a full strip. */ | |
| 63 | 79 | compact?: boolean; | |
| 64 | 80 | /** The tiles in the middle. Absent on a phone, which has no middle. */ | |
| ⋯ 10 unchanged lines | |||
| 75 | 91 | pos = seat, | |
| 76 | 92 | local = true, | |
| 77 | 93 | shared, | |
| 78 | - | remote, | |
| 94 | + | who, | |
| 79 | 95 | compact, | |
| 80 | 96 | pool, | |
| 81 | 97 | onHelp, | |
| ⋯ 81 unchanged lines | |||
| 163 | 179 | const delta = s.phase === 'handEnd' ? s.result?.deltas[seat] ?? 0 : 0; | |
| 164 | 180 | // 一炮多響 can put more than one seat here, so this is a `some`, not a find. | |
| 165 | 181 | const won = s.phase === 'handEnd' && !!s.result?.winners.some((w) => w.seat === seat); | |
| 166 | - | // A deserted online seat, waiting for its person to come back — or for the | |
| 167 | - | // host to hand it to the computer so the table can move. | |
| 168 | - | const deserted = !!remote?.onHandToBot && !bot && remote.holder == null && live; | |
| 182 | + | // The computer is playing this hand and nobody has taken it off it. One tap | |
| 183 | + | // on the strip does — which is the whole of sitting down at this table. | |
| 184 | + | const sittable = !!who?.onSit && bot && live; | |
| 185 | + | // Its device has gone and the hand is being kept for it. Whoever is at the | |
| 186 | + | // table can stop waiting and hand it over now. | |
| 187 | + | const deserted = !!who?.waiting && !bot && live; | |
| 169 | 188 | ||
| 170 | 189 | // Compact lays the discards out as one line that scrolls rather than a block | |
| 171 | 190 | // that wraps, so the newest tile — the only one anybody is looking for — has | |
| ⋯ 144 unchanged lines | |||
| 316 | 335 | <Hand | |
| 317 | 336 | tiles={p.hand} | |
| 318 | 337 | faceDown={concealed} | |
| 319 | - | onFaceUp={covered ? () => setShown(true) : undefined} | |
| 338 | + | /* Under the card, a tap lifts it. On a hand the computer is | |
| 339 | + | playing, the same tap is somebody pulling up a chair: the hand | |
| 340 | + | becomes theirs and turns over in the one gesture, which is as | |
| 341 | + | much ceremony as sitting down at a table ever gets. */ | |
| 342 | + | onFaceUp={covered ? () => setShown(true) : sittable ? who!.onSit : undefined} | |
| 320 | 343 | drawnIndex={drawnIndex} | |
| 321 | 344 | selected={sel} | |
| 322 | 345 | canDiscard={myTurn} | |
| ⋯ 59 unchanged lines | |||
| 382 | 405 | {!claiming && !canProceed && responded && s.phase !== 'turn' && ( | |
| 383 | 406 | <div className="waiting">{t('已回應', 'Responded')}</div> | |
| 384 | 407 | )} | |
| 408 | + | {/* Sitting down. It is on the strip rather than in the gear because | |
| 409 | + | it is the one thing somebody who has just pulled up a chair wants | |
| 410 | + | to do, and they should not have to find a menu to do it. The | |
| 411 | + | tiles come out from under the card in the same tap. */} | |
| 412 | + | {sittable && ( | |
| 413 | + | <button className="btn ghost sit" onClick={who!.onSit}> | |
| 414 | + | <Bi zh="坐下" en="Sit here" /> | |
| 415 | + | </button> | |
| 416 | + | )} | |
| 385 | 417 | {deserted && ( | |
| 386 | - | <button className="btn ghost" onClick={remote!.onHandToBot}> | |
| 418 | + | <button className="btn ghost" onClick={who!.onBot}> | |
| 387 | 419 | <Bi zh="電腦代打" en="Hand to computer" /> | |
| 388 | 420 | </button> | |
| 389 | 421 | )} | |
| ⋯ 34 unchanged lines | |||
| 424 | 456 | <BiEm zh={WIND_NAMES[seatWind]} en={WIND_EN[seatWind]} /> | |
| 425 | 457 | </span> | |
| 426 | 458 | <span className="pname">{p.name}</span> | |
| 427 | - | {remote?.holder != null && ( | |
| 459 | + | {who?.waiting && ( | |
| 460 | + | <span | |
| 461 | + | className="tag waiting" | |
| 462 | + | title={t( | |
| 463 | + | '這手牌的裝置斷了,先等一下,等不到就交給電腦', | |
| 464 | + | 'this hand lost its device — kept a moment, then the computer takes it', | |
| 465 | + | ' · ', | |
| 466 | + | )} | |
| 467 | + | > | |
| 468 | + | ⏳ | |
| 469 | + | </span> | |
| 470 | + | )} | |
| 471 | + | {who?.holder != null && ( | |
| 428 | 472 | <span | |
| 429 | 473 | className="tag phone" | |
| 430 | 474 | title={`${t('這手牌在手機上', 'played from a phone', ' · ')}. ${t( | |
| ⋯ 1 unchanged line | |||
| 432 | 476 | 'hold to show the QR again', | |
| 433 | 477 | ' · ', | |
| 434 | 478 | )}`} | |
| 435 | - | onPointerDown={() => remote.onPeek?.(true)} | |
| 436 | - | onPointerUp={() => remote.onPeek?.(false)} | |
| 437 | - | onPointerCancel={() => remote.onPeek?.(false)} | |
| 438 | - | onPointerLeave={() => remote.onPeek?.(false)} | |
| 479 | + | onPointerDown={() => who.onPeek?.(true)} | |
| 480 | + | onPointerUp={() => who.onPeek?.(false)} | |
| 481 | + | onPointerCancel={() => who.onPeek?.(false)} | |
| 482 | + | onPointerLeave={() => who.onPeek?.(false)} | |
| 439 | 483 | > | |
| 440 | - | 📱 {remote.holder} | |
| 484 | + | 📱 {who.holder} | |
| 441 | 485 | </span> | |
| 442 | 486 | )} | |
| 443 | - | {remote?.speaks && ( | |
| 487 | + | {who?.speaks && ( | |
| 444 | 488 | <span | |
| 445 | 489 | className="tag voice" | |
| 446 | 490 | title={t('喊牌從這支手機出來', 'this hand calls from its own phone', ' · ')} | |
| ⋯ 238 unchanged lines | |||
modifiedsrc/ui/TableMenu.tsx+55 −12
| ⋯ 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 type { SeatWho } from './Seat'; | |
| 8 | 9 | import { SeatQr } from './SeatQr'; | |
| 9 | 10 | import { SoundToggle } from './SoundToggle'; | |
| 10 | 11 | ||
| ⋯ 18 unchanged lines | |||
| 29 | 30 | pos, | |
| 30 | 31 | compact, | |
| 31 | 32 | seatUrl, | |
| 33 | + | who, | |
| 32 | 34 | onPrefs, | |
| 33 | 35 | onSettings, | |
| 34 | 36 | onClose, | |
| ⋯ 4 unchanged lines | |||
| 39 | 41 | pos: SeatId; | |
| 40 | 42 | compact?: boolean; | |
| 41 | 43 | /** | |
| 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. | |
| 44 | + | * The QR for taking *this hand* onto a phone, once there is a room to take | |
| 45 | + | * it into. Absent until then — `who.onPhone` is the button that opens one. | |
| 45 | 46 | */ | |
| 46 | 47 | seatUrl?: string; | |
| 48 | + | /** Who is playing this hand, and what can be done about that. */ | |
| 49 | + | who?: SeatWho; | |
| 47 | 50 | onPrefs: (p: Prefs) => void; | |
| 48 | 51 | onSettings: () => void; | |
| 49 | 52 | onClose: () => void; | |
| ⋯ 55 unchanged lines | |||
| 105 | 108 | <SoundToggle prefs={prefs} onPrefs={onPrefs} /> | |
| 106 | 109 | <FullscreenToggle prefs={prefs} onPrefs={onPrefs} /> | |
| 107 | 110 | ||
| 111 | + | {/* Who is playing this hand. All three answers live in one place, | |
| 112 | + | and each is one tap away from either other: the computer has it, | |
| 113 | + | somebody at this screen has it, or it is on a phone. Nothing here | |
| 114 | + | deals anything, mid-hand included — the tiles simply change | |
| 115 | + | hands, which is what happens when somebody sits down. */} | |
| 116 | + | {who?.onSit && ( | |
| 117 | + | <button | |
| 118 | + | className="btn ghost" | |
| 119 | + | onClick={() => { | |
| 120 | + | who.onSit!(); | |
| 121 | + | onClose(); | |
| 122 | + | }} | |
| 123 | + | > | |
| 124 | + | <Bi zh="我來打" en="I'll play it" sub={t('從電腦手上接過來', 'take it off the computer')} /> | |
| 125 | + | </button> | |
| 126 | + | )} | |
| 127 | + | {who?.onBot && ( | |
| 128 | + | <button | |
| 129 | + | className="btn ghost" | |
| 130 | + | onClick={() => { | |
| 131 | + | who.onBot!(); | |
| 132 | + | onClose(); | |
| 133 | + | }} | |
| 134 | + | > | |
| 135 | + | <Bi zh="電腦代打" en="Hand to computer" /> | |
| 136 | + | </button> | |
| 137 | + | )} | |
| 138 | + | ||
| 108 | 139 | {/* Taking this hand off the shared screen. The QR is the seat's own, | |
| 109 | 140 | so scanning it lands on this chair without anybody having to say | |
| 110 | 141 | which one they are in — and the tiles here go face down for good | |
| 111 | 142 | 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 | - | )} | |
| 143 | + | {who?.onPhone && | |
| 144 | + | (seatUrl ? ( | |
| 145 | + | <div className="menu-qr"> | |
| 146 | + | <SeatQr url={seatUrl} /> | |
| 147 | + | <span className="menu-qr-hint"> | |
| 148 | + | {lang !== 'en' && '掃碼把這副牌拿到手機上'} | |
| 149 | + | {lang !== 'zh' && <em>scan to hold this hand on your phone</em>} | |
| 150 | + | </span> | |
| 151 | + | </div> | |
| 152 | + | ) : ( | |
| 153 | + | // No room yet, and none was needed: a table of people and | |
| 154 | + | // computers never says a word to a server. The first person who | |
| 155 | + | // wants a phone in the game is the one who opens it. | |
| 156 | + | <button className="btn ghost" onClick={who.onPhone}> | |
| 157 | + | <Bi | |
| 158 | + | zh="用手機拿牌" | |
| 159 | + | en="Onto a phone" | |
| 160 | + | sub={t('開房間,出 QR', 'opens the room and puts up a QR')} | |
| 161 | + | /> | |
| 162 | + | </button> | |
| 163 | + | ))} | |
| 121 | 164 | ||
| 122 | 165 | {/* The moment anybody wants a name or the stakes changed is the | |
| 123 | 166 | moment they went looking for a gear. */} | |
| ⋯ 14 unchanged lines | |||