| 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, 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 { TileView } from './TileView'; |
| 14 | |
| 15 | const SEATS: SeatId[] = [0, 1, 2, 3]; |
| 16 | |
| 17 | interface CtlProps { |
| 18 | session: NetSession; |
| 19 | /** For the fullscreen button — a phone's address bar is a sixth of the |
| 20 | * height this screen has to hold a hand in. */ |
| 21 | prefs: Prefs; |
| 22 | onPrefs: (p: Prefs) => void; |
| 23 | } |
| 24 | |
| 25 | /** |
| 26 | * The phone at the party table: just your own tiles, held in landscape, with |
| 27 | * the common screen doing the table's share of the talking. What it keeps of |
| 28 | * the full table is the things that are yours — the buttons a claim window |
| 29 | * owes you, the throw, and your voice. Flick a tile up off the top of the |
| 30 | * phone and it leaves your hand here, crosses the wire, and comes flying in |
| 31 | * from your edge of the shared screen. |
| 32 | * |
| 33 | * Your voice, because the five devices at a party table are all in the same |
| 34 | * room: with sound on, this phone is what says 碰 when you call one, and the |
| 35 | * common screen stops saying it for you. Mute the phone and the table takes |
| 36 | * the job back — see `net/session.ts` for how it is told. |
| 37 | */ |
| 38 | export function Controller({ session, prefs, onPrefs }: CtlProps) { |
| 39 | const seat = session.mySeat; |
| 40 | |
| 41 | // The QR carries which seat it was stuck to; a phone that arrived some other |
| 42 | // way — or lost the parameter on the way in — picks one by hand below. |
| 43 | const [asked, setAsked] = useState(false); |
| 44 | useEffect(() => { |
| 45 | if (asked || seat !== null) return; |
| 46 | const want = seatFromUrl(); |
| 47 | if (want !== null) { |
| 48 | setAsked(true); |
| 49 | session.claimSeat(want); |
| 50 | } |
| 51 | }, [session, seat, asked]); |
| 52 | |
| 53 | if (seat === null) { |
| 54 | return <SeatPick session={session} joining={asked} prefs={prefs} onPrefs={onPrefs} />; |
| 55 | } |
| 56 | return <SeatStrip session={session} seat={seat} prefs={prefs} onPrefs={onPrefs} />; |
| 57 | } |
| 58 | |
| 59 | function SeatPick({ session, joining, prefs, onPrefs }: CtlProps & { joining?: boolean }) { |
| 60 | const t = useT(); |
| 61 | const lang = useLang(); |
| 62 | const seats = session.seats; |
| 63 | const s = session.table.state; |
| 64 | return ( |
| 65 | <div className="ctl ctl-pick"> |
| 66 | <h1>{lang === 'en' ? 'Taiwanese Mahjong' : '台灣麻將'}</h1> |
| 67 | <p className="sub"> |
| 68 | {joining |
| 69 | ? `${t('入座中…', 'taking your seat')} — ${t('或先選別的', 'or pick another')}` |
| 70 | : t('選一個座位', 'pick your seat', ' · ')} |
| 71 | </p> |
| 72 | <div className="pick-grid"> |
| 73 | {SEATS.map((i) => { |
| 74 | const held = seats[i].ids.length; |
| 75 | const wind = (i - s.dealer + 4) % 4; |
| 76 | return ( |
| 77 | <button key={i} className="btn big-btn" onClick={() => session.claimSeat(i)}> |
| 78 | {t(WIND_NAMES[wind], WIND_EN[wind])} |
| 79 | <em> |
| 80 | {held > 0 |
| 81 | ? `${seats[i].name} — ${t('一起打', 'join this hand')}` |
| 82 | : seats[i].name || t('空位', 'open')} |
| 83 | </em> |
| 84 | </button> |
| 85 | ); |
| 86 | })} |
| 87 | </div> |
| 88 | <div className="pick-extras"> |
| 89 | {/* Turning it on here is also the tap that wakes the phone's audio, so |
| 90 | it can start doing the talking for this seat straight away. */} |
| 91 | <SoundToggle prefs={prefs} onPrefs={onPrefs} /> |
| 92 | <FullscreenToggle prefs={prefs} onPrefs={onPrefs} /> |
| 93 | </div> |
| 94 | </div> |
| 95 | ); |
| 96 | } |
| 97 | |
| 98 | /** A tile in the fingers, drawn by us once it has left the row. */ |
| 99 | interface HeldTile { |
| 100 | tile: Tile; |
| 101 | x: number; |
| 102 | y: number; |
| 103 | size: number; |
| 104 | /** Set when it has been let go of: it flies up off the screen and is gone. */ |
| 105 | flung: boolean; |
| 106 | } |
| 107 | |
| 108 | function SeatStrip({ session, seat, prefs, onPrefs }: CtlProps & { seat: SeatId }) { |
| 109 | const t = useT(); |
| 110 | const table = session.table; |
| 111 | const s = table.state; |
| 112 | const p = s.players[seat]; |
| 113 | const [sel, setSel] = useState<number | null>(null); |
| 114 | const [held, setHeld] = useState<HeldTile | null>(null); |
| 115 | |
| 116 | const myTurn = s.phase === 'turn' && s.turn === seat; |
| 117 | const opts = pendingOptions(s, seat); |
| 118 | const canProceed = table.nextDrawer() === seat; |
| 119 | const canHu = myTurn && table.canSelfDrawWin(seat); |
| 120 | const concealedKongs = myTurn ? table.concealedKongs(seat) : []; |
| 121 | const addedKongs = myTurn ? table.addedKongs(seat) : []; |
| 122 | const tenpai = s.phase !== 'handEnd' && s.phase !== 'gameEnd' ? table.tenpai(seat) : []; |
| 123 | const drawnIndex = myTurn && s.drawnSeparate && s.drawnTile !== null ? p.hand.length - 1 : null; |
| 124 | const selTile = sel === null ? null : p.hand[sel] ?? null; |
| 125 | const seatWind = (seat - s.dealer + 4) % 4; |
| 126 | const r = s.result; |
| 127 | |
| 128 | useEffect(() => { |
| 129 | setSel(null); |
| 130 | }, [myTurn, s.handNumber, p.hand.length]); |
| 131 | |
| 132 | const doDiscard = (i: number) => { |
| 133 | table.discard(seat, p.hand[i]); |
| 134 | setSel(null); |
| 135 | }; |
| 136 | |
| 137 | // The flick. `Hand` decides when a drag has become a throw; all that is left |
| 138 | // here is to say it in the seat's own frame — how far along the edge it left |
| 139 | // from, and how fast it was going up and across — and let it go. |
| 140 | const fling = (i: number, t: Throw) => { |
| 141 | const tile = p.hand[i]; |
| 142 | if (tile === undefined) return setHeld(null); |
| 143 | table.discard(seat, tile, { |
| 144 | u: t.x / window.innerWidth, |
| 145 | vx: t.vx, |
| 146 | // Up off the top of the phone is into the table. |
| 147 | vy: -t.vy, |
| 148 | spin: t.spin, |
| 149 | }); |
| 150 | setSel(null); |
| 151 | setHeld((h) => (h ? { ...h, x: t.x, y: t.y, flung: true } : null)); |
| 152 | }; |
| 153 | |
| 154 | const carry = { |
| 155 | onPickUp: (i: number, x: number, y: number, size: number) => |
| 156 | setHeld({ tile: p.hand[i], x, y, size, flung: false }), |
| 157 | onMoveTo: (x: number, y: number) => setHeld((h) => (h && !h.flung ? { ...h, x, y } : h)), |
| 158 | onThrow: fling, |
| 159 | onCancel: () => setHeld(null), |
| 160 | }; |
| 161 | |
| 162 | const status = myTurn |
| 163 | ? { zh: '輪到你', en: 'your turn', hot: true } |
| 164 | : opts |
| 165 | ? { zh: '要叫牌嗎?', en: 'claim it?', hot: true } |
| 166 | : { |
| 167 | zh: `等 ${s.players[s.turn].name}`, |
| 168 | en: `waiting for ${s.players[s.turn].name}`, |
| 169 | hot: false, |
| 170 | }; |
| 171 | |
| 172 | return ( |
| 173 | <div className="ctl"> |
| 174 | <div className="ctl-top"> |
| 175 | <span className="wind"> |
| 176 | <BiEm zh={WIND_NAMES[seatWind]} en={WIND_EN[seatWind]} /> |
| 177 | </span> |
| 178 | <span className="pname">{p.name}</span> |
| 179 | {seat === s.dealer && <span className="tag">{t('莊', 'Dealer')}</span>} |
| 180 | {tenpai.length > 0 && ( |
| 181 | <span className="tag ting"> |
| 182 | {t('聽', 'Ready')} {tenpai.map(labelOf).join('·')} |
| 183 | </span> |
| 184 | )} |
| 185 | <span className={`ctl-status ${status.hot ? 'hot' : ''}`}> |
| 186 | <BiEm zh={status.zh} en={status.en} /> |
| 187 | </span> |
| 188 | {s.lastDiscard && s.phase !== 'handEnd' && ( |
| 189 | <span className="ctl-last"> |
| 190 | {s.players[s.lastDiscard.from].name} {t('打', 'threw')} |
| 191 | <TileView tile={s.lastDiscard.tile} size="small" /> |
| 192 | </span> |
| 193 | )} |
| 194 | <span className="chips">{p.score}</span> |
| 195 | <button className="btn ghost sort" onClick={() => table.sortHand(seat)}> |
| 196 | {t('理牌', 'Sort')} |
| 197 | </button> |
| 198 | {/* This phone's own mute — and, at a party table, whether your calls |
| 199 | come out of your hand or out of the screen in the middle. */} |
| 200 | <SoundToggle prefs={prefs} onPrefs={onPrefs} compact /> |
| 201 | <FullscreenToggle prefs={prefs} onPrefs={onPrefs} compact /> |
| 202 | </div> |
| 203 | |
| 204 | <div className="ctl-actions"> |
| 205 | {opts?.map((o, i) => ( |
| 206 | <button |
| 207 | key={i} |
| 208 | className={`btn claim ${o.type}`} |
| 209 | onClick={() => table.respond(seat, { type: o.type, with: o.with })} |
| 210 | > |
| 211 | <Bi zh={CLAIM_LABEL[o.type][0]} en={CLAIM_LABEL[o.type][1]} sub={o.with && o.with.map(labelOf).join(' ')} /> |
| 212 | </button> |
| 213 | ))} |
| 214 | {opts && !canProceed && ( |
| 215 | <button className="btn pass" onClick={() => table.respond(seat, 'pass')}> |
| 216 | <Bi zh="過" en="Pass" /> |
| 217 | </button> |
| 218 | )} |
| 219 | {canProceed && ( |
| 220 | <button className={`btn proceed ${opts ? 'pass' : ''}`} onClick={() => table.resolveNow()}> |
| 221 | <Bi |
| 222 | zh={s.phase === 'robkong' ? '繼續' : opts ? '過.摸牌' : '摸牌'} |
| 223 | en={s.phase === 'robkong' ? 'Continue' : opts ? 'Pass & draw' : 'Draw'} |
| 224 | /> |
| 225 | </button> |
| 226 | )} |
| 227 | {canHu && ( |
| 228 | <button className="btn claim hu" onClick={() => table.declareSelfDraw(seat)}> |
| 229 | <Bi zh="自摸" en="Self-draw" /> |
| 230 | </button> |
| 231 | )} |
| 232 | {concealedKongs.map((t) => ( |
| 233 | <button key={`ak${t}`} className="btn claim kong" onClick={() => table.declareConcealedKong(seat, t)}> |
| 234 | <Bi zh="暗槓" en="Kong" sub={labelOf(t)} /> |
| 235 | </button> |
| 236 | ))} |
| 237 | {addedKongs.map((t) => ( |
| 238 | <button key={`gk${t}`} className="btn claim kong" onClick={() => table.declareAddedKong(seat, t)}> |
| 239 | <Bi zh="加槓" en="Kong" sub={labelOf(t)} /> |
| 240 | </button> |
| 241 | ))} |
| 242 | {selTile !== null && sel !== null && myTurn && ( |
| 243 | <button className="btn discard" onClick={() => doDiscard(sel)}> |
| 244 | <Bi zh="打出" en="Discard" sub={labelOf(selTile)} /> |
| 245 | </button> |
| 246 | )} |
| 247 | {myTurn && ( |
| 248 | <span className="ctl-hint">{t('往上甩出去', 'flick a tile up to throw it', ' · ')}</span> |
| 249 | )} |
| 250 | </div> |
| 251 | |
| 252 | <div className="ctl-hand"> |
| 253 | <Hand |
| 254 | tiles={p.hand} |
| 255 | drawnIndex={drawnIndex} |
| 256 | selected={sel} |
| 257 | canDiscard={myTurn} |
| 258 | onSelect={setSel} |
| 259 | onDiscard={doDiscard} |
| 260 | onMove={(from, to) => table.reorderHand(seat, from, to)} |
| 261 | carry={carry} |
| 262 | /> |
| 263 | </div> |
| 264 | |
| 265 | {/* The tile out of the row, following the finger — and, once flung, on |
| 266 | its way up off the screen to reappear on the common one. */} |
| 267 | {held && ( |
| 268 | <div |
| 269 | className={`ctl-held ${held.flung ? 'flung' : ''}`} |
| 270 | style={{ left: held.x, top: held.y, width: held.size, height: held.size * 1.375 }} |
| 271 | onAnimationEnd={() => setHeld(null)} |
| 272 | > |
| 273 | <TileView tile={held.tile} /> |
| 274 | </div> |
| 275 | )} |
| 276 | |
| 277 | {r && s.phase === 'handEnd' && ( |
| 278 | <div className="ctl-result"> |
| 279 | {r.drawGame ? ( |
| 280 | <h2>{t('流局', 'Draw', ' · ')}</h2> |
| 281 | ) : ( |
| 282 | r.winners.map((w) => ( |
| 283 | <h2 key={w.seat}> |
| 284 | {s.players[w.seat].name} {w.selfDraw ? t('自摸', 'self-draw') : t('胡牌', 'wins')}{' '} |
| 285 | · {w.tai} {t('台', 'tai')} |
| 286 | </h2> |
| 287 | )) |
| 288 | )} |
| 289 | <div className="deltas"> |
| 290 | {r.deltas.map((d, i) => ( |
| 291 | <span key={i} className={d > 0 ? 'up' : d < 0 ? 'down' : ''}> |
| 292 | {s.players[i].name} {d > 0 ? `+${d}` : d} |
| 293 | </span> |
| 294 | ))} |
| 295 | </div> |
| 296 | <button className="btn big-btn" onClick={() => table.nextHand()}> |
| 297 | <Bi zh="下一局" en="Next hand" /> |
| 298 | </button> |
| 299 | </div> |
| 300 | )} |
| 301 | |
| 302 | {s.phase === 'gameEnd' && ( |
| 303 | <div className="ctl-result"> |
| 304 | <h2>{t('一將終了', 'Game over', ' · ')}</h2> |
| 305 | <div className="deltas"> |
| 306 | {s.players.map((q, i) => ( |
| 307 | <span key={i}> |
| 308 | {q.name} {q.score} |
| 309 | </span> |
| 310 | ))} |
| 311 | </div> |
| 312 | <button className="btn big-btn" onClick={() => table.newGame(s.bots)}> |
| 313 | <Bi zh="再來一將" en="New game" /> |
| 314 | </button> |
| 315 | </div> |
| 316 | )} |
| 317 | </div> |
| 318 | ); |
| 319 | } |