| 1 | import { useEffect, useState } from 'react'; |
| 2 | import { pendingOptions } from '../game/ctl'; |
| 3 | import type { Prefs } from '../game/prefs'; |
| 4 | import { labelOf, WIND_EN, WIND_NAMES, type Tile } from '../game/tiles'; |
| 5 | import type { SeatId } from '../game/types'; |
| 6 | import type { NetSession } from '../net/session'; |
| 7 | import { seatFromUrl } from '../net/protocol'; |
| 8 | import { Bi, BiEm, BiText, CLAIM_LABEL } from './Bi'; |
| 9 | import { useLang, useT } from './lang'; |
| 10 | import { FullscreenToggle } from './FullscreenToggle'; |
| 11 | import { Hand, type Throw } from './Hand'; |
| 12 | import { SoundToggle } from './SoundToggle'; |
| 13 | import { TurnClock, turnId } from './TurnClock'; |
| 14 | import { TileView } from './TileView'; |
| 15 | |
| 16 | const SEATS: SeatId[] = [0, 1, 2, 3]; |
| 17 | |
| 18 | interface CtlProps { |
| 19 | session: NetSession; |
| 20 | /** For the fullscreen button — a phone's address bar is a sixth of the |
| 21 | * height this screen has to hold a hand in. */ |
| 22 | prefs: Prefs; |
| 23 | onPrefs: (p: Prefs) => void; |
| 24 | } |
| 25 | |
| 26 | /** |
| 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 | * |
| 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 | */ |
| 39 | export function Controller({ session, prefs, onPrefs }: CtlProps) { |
| 40 | const seat = session.mySeat; |
| 41 | if (seat === null) return <SeatPick session={session} prefs={prefs} onPrefs={onPrefs} />; |
| 42 | return <SeatStrip session={session} seat={seat} prefs={prefs} onPrefs={onPrefs} />; |
| 43 | } |
| 44 | |
| 45 | /** |
| 46 | * A phone with nothing to hold: it is in a room, and the room has no table in |
| 47 | * it. |
| 48 | * |
| 49 | * Which is a real thing that happens rather than an error — the laptop closed |
| 50 | * its lid, the server restarted and the room came back empty, or somebody was |
| 51 | * sent the link before the tiles were dealt. From the phone all three look the |
| 52 | * same and none of them is anything it can fix, so it says which of the two |
| 53 | * situations it is in and waits, rather than offering to deal a table of its |
| 54 | * own on a screen that could never show one. |
| 55 | * |
| 56 | * The room code is on it because that is the one thing worth reading out loud |
| 57 | * to whoever is holding the laptop. |
| 58 | */ |
| 59 | export function Adrift({ session }: { session: NetSession }) { |
| 60 | const t = useT(); |
| 61 | const lang = useLang(); |
| 62 | return ( |
| 63 | <div className="ctl ctl-adrift"> |
| 64 | <h1>{lang === 'en' ? 'Taiwanese Mahjong' : '台灣麻將'}</h1> |
| 65 | <p className="sub">{session.room.code}</p> |
| 66 | {session.err ? ( |
| 67 | <> |
| 68 | <p className="adrift-line">{t('連線中斷', 'Connection lost', ' · ')}</p> |
| 69 | <p className="hint"> |
| 70 | <BiText |
| 71 | zh="正在重新連線,回來的時候你還坐在原位。" |
| 72 | en="Reconnecting — your seat is still yours when it comes back." |
| 73 | /> |
| 74 | </p> |
| 75 | </> |
| 76 | ) : ( |
| 77 | <> |
| 78 | <p className="adrift-line">{t('這桌還沒開', 'No table here yet', ' · ')}</p> |
| 79 | <p className="hint"> |
| 80 | <BiText |
| 81 | zh="這個房間裡還沒有牌局。等開桌的那台按下開始,這頁就會自己接上。" |
| 82 | en="Nothing has been dealt in this room. When whoever has the table starts a game, this page joins it by itself." |
| 83 | /> |
| 84 | </p> |
| 85 | </> |
| 86 | )} |
| 87 | <p className="hint"> |
| 88 | {t(`${session.playerCount} 人在房`, `${session.playerCount} here`, ' · ')} |
| 89 | </p> |
| 90 | </div> |
| 91 | ); |
| 92 | } |
| 93 | |
| 94 | /** |
| 95 | * Which hand you are taking. Shown to any device that has walked into a room |
| 96 | * and is not the table it was opened around — a phone off a QR, a friend off |
| 97 | * the invite link. |
| 98 | * |
| 99 | * Every seat is offered, not only the empty ones. A hand the computer is |
| 100 | * playing is the ordinary way in — that is what the three chairs nobody has |
| 101 | * taken are — and a hand somebody else is already holding can be sat down at |
| 102 | * too, which is how a pair play one hand off two devices. |
| 103 | */ |
| 104 | export function SeatPick({ session, prefs, onPrefs }: CtlProps) { |
| 105 | const t = useT(); |
| 106 | const lang = useLang(); |
| 107 | const seats = session.seats; |
| 108 | const s = session.table.state; |
| 109 | |
| 110 | // A seat's own QR carries which hand it was stuck to, so scanning it lands |
| 111 | // on that chair without anybody having to say which one they are in. The |
| 112 | // table's shared QR names none, and then the choice is made here. |
| 113 | const [asked, setAsked] = useState(false); |
| 114 | const want = seatFromUrl(); |
| 115 | useEffect(() => { |
| 116 | if (asked || want === null) return; |
| 117 | setAsked(true); |
| 118 | session.claimSeat(want); |
| 119 | }, [session, want, asked]); |
| 120 | |
| 121 | return ( |
| 122 | <div className="ctl ctl-pick"> |
| 123 | <h1>{lang === 'en' ? 'Taiwanese Mahjong' : '台灣麻將'}</h1> |
| 124 | <p className="sub"> |
| 125 | {asked |
| 126 | ? `${t('入座中…', 'taking your seat')} — ${t('或先選別的', 'or pick another')}` |
| 127 | : t('選一家', 'pick a hand', ' · ')} |
| 128 | </p> |
| 129 | <div className="pick-grid"> |
| 130 | {SEATS.map((i) => { |
| 131 | const held = seats[i].ids.length; |
| 132 | const wind = (i - s.dealer + 4) % 4; |
| 133 | return ( |
| 134 | <button key={i} className="btn big-btn" onClick={() => session.claimSeat(i)}> |
| 135 | {t(WIND_NAMES[wind], WIND_EN[wind])} |
| 136 | <em> |
| 137 | {held > 0 |
| 138 | ? `${seats[i].name} — ${t('一起打', 'join this hand')}` |
| 139 | : s.bots[i] |
| 140 | ? `${t('電腦代打中', 'the computer has it')} — ${t('接手', 'take over')}` |
| 141 | : seats[i].name || t('在桌上', 'played at the table', ' · ')} |
| 142 | </em> |
| 143 | </button> |
| 144 | ); |
| 145 | })} |
| 146 | </div> |
| 147 | <div className="pick-extras"> |
| 148 | {/* Turning it on here is also the tap that wakes the phone's audio, so |
| 149 | it can start doing the talking for this seat straight away. */} |
| 150 | <SoundToggle prefs={prefs} onPrefs={onPrefs} /> |
| 151 | <FullscreenToggle prefs={prefs} onPrefs={onPrefs} /> |
| 152 | </div> |
| 153 | </div> |
| 154 | ); |
| 155 | } |
| 156 | |
| 157 | /** A tile in the fingers, drawn by us once it has left the row. */ |
| 158 | interface HeldTile { |
| 159 | tile: Tile; |
| 160 | x: number; |
| 161 | y: number; |
| 162 | size: number; |
| 163 | /** Set when it has been let go of: it flies up off the screen and is gone. */ |
| 164 | flung: boolean; |
| 165 | } |
| 166 | |
| 167 | function SeatStrip({ session, seat, prefs, onPrefs }: CtlProps & { seat: SeatId }) { |
| 168 | const t = useT(); |
| 169 | const table = session.table; |
| 170 | const s = table.state; |
| 171 | const p = s.players[seat]; |
| 172 | const [sel, setSel] = useState<number | null>(null); |
| 173 | const [held, setHeld] = useState<HeldTile | null>(null); |
| 174 | |
| 175 | const myTurn = s.phase === 'turn' && s.turn === seat; |
| 176 | const opts = pendingOptions(s, seat); |
| 177 | const canProceed = table.nextDrawer() === seat; |
| 178 | const canHu = myTurn && table.canSelfDrawWin(seat); |
| 179 | const concealedKongs = myTurn ? table.concealedKongs(seat) : []; |
| 180 | const addedKongs = myTurn ? table.addedKongs(seat) : []; |
| 181 | const tenpai = s.phase !== 'handEnd' && s.phase !== 'gameEnd' ? table.tenpai(seat) : []; |
| 182 | const drawnIndex = myTurn && s.drawnSeparate && s.drawnTile !== null ? p.hand.length - 1 : null; |
| 183 | const selTile = sel === null ? null : p.hand[sel] ?? null; |
| 184 | const seatWind = (seat - s.dealer + 4) % 4; |
| 185 | const r = s.result; |
| 186 | |
| 187 | useEffect(() => { |
| 188 | setSel(null); |
| 189 | }, [myTurn, s.handNumber, p.hand.length]); |
| 190 | |
| 191 | const doDiscard = (i: number) => { |
| 192 | table.discard(seat, p.hand[i]); |
| 193 | setSel(null); |
| 194 | }; |
| 195 | |
| 196 | // The flick. `Hand` decides when a drag has become a throw; all that is left |
| 197 | // here is to say it in the seat's own frame — how far along the edge it left |
| 198 | // from, and how fast it was going up and across — and let it go. |
| 199 | const fling = (i: number, t: Throw) => { |
| 200 | const tile = p.hand[i]; |
| 201 | if (tile === undefined) return setHeld(null); |
| 202 | table.discard(seat, tile, { |
| 203 | u: t.x / window.innerWidth, |
| 204 | vx: t.vx, |
| 205 | // Up off the top of the phone is into the table. |
| 206 | vy: -t.vy, |
| 207 | spin: t.spin, |
| 208 | }); |
| 209 | setSel(null); |
| 210 | setHeld((h) => (h ? { ...h, x: t.x, y: t.y, flung: true } : null)); |
| 211 | }; |
| 212 | |
| 213 | const carry = { |
| 214 | onPickUp: (i: number, x: number, y: number, size: number) => |
| 215 | setHeld({ tile: p.hand[i], x, y, size, flung: false }), |
| 216 | onMoveTo: (x: number, y: number) => setHeld((h) => (h && !h.flung ? { ...h, x, y } : h)), |
| 217 | onThrow: fling, |
| 218 | onCancel: () => setHeld(null), |
| 219 | }; |
| 220 | |
| 221 | const status = myTurn |
| 222 | ? { zh: '輪到你', en: 'your turn', hot: true } |
| 223 | : opts |
| 224 | ? { zh: '要叫牌嗎?', en: 'claim it?', hot: true } |
| 225 | : { |
| 226 | zh: `等 ${s.players[s.turn].name}`, |
| 227 | en: `waiting for ${s.players[s.turn].name}`, |
| 228 | hot: false, |
| 229 | }; |
| 230 | |
| 231 | /** |
| 232 | * Four people at one table and one screen in the middle of it: the phone in |
| 233 | * a pocket, or face down beside a drink, is the one that has not noticed the |
| 234 | * game has come round. So when it does, this phone says so with everything |
| 235 | * it has — it goes gold edge to edge, and it buzzes once. |
| 236 | * |
| 237 | * The buzz is per turn, not per render: the string changes when the table |
| 238 | * moves, and only then. Phones that will not vibrate (a desk browser, one |
| 239 | * that has not been tapped yet) simply do not, and the gold is still there. |
| 240 | */ |
| 241 | const calling = myTurn ? 'turn' : opts ? 'claim' : null; |
| 242 | const callKey = calling && `${calling}:${turnId(s)}`; |
| 243 | useEffect(() => { |
| 244 | if (!callKey) return; |
| 245 | navigator.vibrate?.(callKey.startsWith('turn') ? [90, 70, 90] : [40, 60, 40]); |
| 246 | }, [callKey]); |
| 247 | |
| 248 | return ( |
| 249 | <div className={`ctl ${calling ? `ctl-${calling}` : ''} ${session.err ? 'ctl-cut' : ''}`}> |
| 250 | {/* The wire has gone. Said across the top of the hand rather than in a |
| 251 | corner, because everything below it has quietly stopped working: a |
| 252 | call tapped now is dropped on the floor, and a hand that looks live |
| 253 | and does nothing is worse than one that says why. The room reconnects |
| 254 | on its own and this clears when it does. */} |
| 255 | {session.err && ( |
| 256 | <div className="ctl-cut-bar"> |
| 257 | <BiEm zh="連線中斷,重新連線中" en="Connection lost — reconnecting" /> |
| 258 | </div> |
| 259 | )} |
| 260 | {/* The same seven seconds the table is showing everyone else, on the |
| 261 | screen of the person they are all waiting for. */} |
| 262 | {myTurn && <TurnClock key={turnId(s)} />} |
| 263 | <div className="ctl-top"> |
| 264 | <span className="wind"> |
| 265 | <BiEm zh={WIND_NAMES[seatWind]} en={WIND_EN[seatWind]} /> |
| 266 | </span> |
| 267 | <span className="pname">{p.name}</span> |
| 268 | {seat === s.dealer && <span className="tag">{t('莊', 'Dealer')}</span>} |
| 269 | {tenpai.length > 0 && ( |
| 270 | <span className="tag ting"> |
| 271 | {t('聽', 'Ready')} {tenpai.map(labelOf).join('·')} |
| 272 | </span> |
| 273 | )} |
| 274 | <span className={`ctl-status ${status.hot ? 'hot' : ''}`}> |
| 275 | <BiEm zh={status.zh} en={status.en} /> |
| 276 | </span> |
| 277 | {s.lastDiscard && s.phase !== 'handEnd' && ( |
| 278 | <span className="ctl-last"> |
| 279 | {s.players[s.lastDiscard.from].name} {t('打', 'threw')} |
| 280 | <TileView tile={s.lastDiscard.tile} size="small" /> |
| 281 | </span> |
| 282 | )} |
| 283 | <span className="chips">{p.score}</span> |
| 284 | <button className="btn ghost sort" onClick={() => table.sortHand(seat)}> |
| 285 | {t('理牌', 'Sort')} |
| 286 | </button> |
| 287 | {/* This phone's own mute — and, at a party table, whether your calls |
| 288 | come out of your hand or out of the screen in the middle. */} |
| 289 | <SoundToggle prefs={prefs} onPrefs={onPrefs} compact /> |
| 290 | <FullscreenToggle prefs={prefs} onPrefs={onPrefs} compact /> |
| 291 | </div> |
| 292 | |
| 293 | <div className="ctl-actions"> |
| 294 | {opts?.map((o, i) => ( |
| 295 | <button |
| 296 | key={i} |
| 297 | className={`btn claim ${o.type}`} |
| 298 | onClick={() => table.respond(seat, { type: o.type, with: o.with })} |
| 299 | > |
| 300 | <Bi zh={CLAIM_LABEL[o.type][0]} en={CLAIM_LABEL[o.type][1]} sub={o.with && o.with.map(labelOf).join(' ')} /> |
| 301 | </button> |
| 302 | ))} |
| 303 | {opts && !canProceed && ( |
| 304 | <button className="btn pass" onClick={() => table.respond(seat, 'pass')}> |
| 305 | <Bi zh="過" en="Pass" /> |
| 306 | </button> |
| 307 | )} |
| 308 | {canProceed && ( |
| 309 | <button className={`btn proceed ${opts ? 'pass' : ''}`} onClick={() => table.resolveNow()}> |
| 310 | <Bi |
| 311 | zh={s.phase === 'robkong' ? '繼續' : opts ? '過.摸牌' : '摸牌'} |
| 312 | en={s.phase === 'robkong' ? 'Continue' : opts ? 'Pass & draw' : 'Draw'} |
| 313 | /> |
| 314 | </button> |
| 315 | )} |
| 316 | {canHu && ( |
| 317 | <button className="btn claim hu" onClick={() => table.declareSelfDraw(seat)}> |
| 318 | <Bi zh="自摸" en="Self-draw" /> |
| 319 | </button> |
| 320 | )} |
| 321 | {concealedKongs.map((t) => ( |
| 322 | <button key={`ak${t}`} className="btn claim kong" onClick={() => table.declareConcealedKong(seat, t)}> |
| 323 | <Bi zh="暗槓" en="Kong" sub={labelOf(t)} /> |
| 324 | </button> |
| 325 | ))} |
| 326 | {addedKongs.map((t) => ( |
| 327 | <button key={`gk${t}`} className="btn claim kong" onClick={() => table.declareAddedKong(seat, t)}> |
| 328 | <Bi zh="加槓" en="Kong" sub={labelOf(t)} /> |
| 329 | </button> |
| 330 | ))} |
| 331 | {selTile !== null && sel !== null && myTurn && ( |
| 332 | <button className="btn discard" onClick={() => doDiscard(sel)}> |
| 333 | <Bi zh="打出" en="Discard" sub={labelOf(selTile)} /> |
| 334 | </button> |
| 335 | )} |
| 336 | {myTurn && ( |
| 337 | <span className="ctl-hint">{t('往上甩出去', 'flick a tile up to throw it', ' · ')}</span> |
| 338 | )} |
| 339 | </div> |
| 340 | |
| 341 | <div className="ctl-hand"> |
| 342 | <Hand |
| 343 | tiles={p.hand} |
| 344 | drawnIndex={drawnIndex} |
| 345 | selected={sel} |
| 346 | canDiscard={myTurn} |
| 347 | onSelect={setSel} |
| 348 | onDiscard={doDiscard} |
| 349 | onMove={(from, to) => table.reorderHand(seat, from, to)} |
| 350 | carry={carry} |
| 351 | /> |
| 352 | </div> |
| 353 | |
| 354 | {/* The tile out of the row, following the finger — and, once flung, on |
| 355 | its way up off the screen to reappear on the common one. */} |
| 356 | {held && ( |
| 357 | <div |
| 358 | className={`ctl-held ${held.flung ? 'flung' : ''}`} |
| 359 | style={{ left: held.x, top: held.y, width: held.size, height: held.size * 1.375 }} |
| 360 | onAnimationEnd={() => setHeld(null)} |
| 361 | > |
| 362 | <TileView tile={held.tile} /> |
| 363 | </div> |
| 364 | )} |
| 365 | |
| 366 | {r && s.phase === 'handEnd' && ( |
| 367 | <div className="ctl-result"> |
| 368 | {r.drawGame ? ( |
| 369 | <h2>{t('流局', 'Draw', ' · ')}</h2> |
| 370 | ) : ( |
| 371 | r.winners.map((w) => ( |
| 372 | <h2 key={w.seat}> |
| 373 | {s.players[w.seat].name} {w.selfDraw ? t('自摸', 'self-draw') : t('胡牌', 'wins')}{' '} |
| 374 | · {w.tai} {t('台', 'tai')} |
| 375 | </h2> |
| 376 | )) |
| 377 | )} |
| 378 | <div className="deltas"> |
| 379 | {r.deltas.map((d, i) => ( |
| 380 | <span key={i} className={d > 0 ? 'up' : d < 0 ? 'down' : ''}> |
| 381 | {s.players[i].name} {d > 0 ? `+${d}` : d} |
| 382 | </span> |
| 383 | ))} |
| 384 | </div> |
| 385 | <button className="btn big-btn" onClick={() => table.nextHand()}> |
| 386 | <Bi zh="下一局" en="Next hand" /> |
| 387 | </button> |
| 388 | </div> |
| 389 | )} |
| 390 | |
| 391 | {s.phase === 'gameEnd' && ( |
| 392 | <div className="ctl-result"> |
| 393 | <h2>{t('一將終了', 'Game over', ' · ')}</h2> |
| 394 | <div className="deltas"> |
| 395 | {s.players.map((q, i) => ( |
| 396 | <span key={i}> |
| 397 | {q.name} {q.score} |
| 398 | </span> |
| 399 | ))} |
| 400 | </div> |
| 401 | <button className="btn big-btn" onClick={() => table.newGame(s.bots)}> |
| 402 | <Bi zh="再來一將" en="New game" /> |
| 403 | </button> |
| 404 | </div> |
| 405 | )} |
| 406 | </div> |
| 407 | ); |
| 408 | } |