| 1 | import { useEffect, useRef, useState } from 'react'; |
| 2 | import type { TableCtl } from '../game/ctl'; |
| 3 | import { labelOf, WIND_EN, WIND_NAMES } from '../game/tiles'; |
| 4 | import type { SeatId } from '../game/types'; |
| 5 | import type { TablePool } from '../table/pool'; |
| 6 | import { Bi, CLAIM_LABEL } from './Bi'; |
| 7 | import { Hand } from './Hand'; |
| 8 | import { SeatQr } from './SeatQr'; |
| 9 | import { SEAT_ROT, SEAT_SLOT } from './rotation'; |
| 10 | import { TileView } from './TileView'; |
| 11 | |
| 12 | /** |
| 13 | * A seat that exists somewhere else — a phone at the party table, or an online |
| 14 | * player. What of it shows on this screen is decided here. |
| 15 | */ |
| 16 | export interface RemoteSeat { |
| 17 | /** Party screen: the link that claims this seat, drawn as a QR while free. */ |
| 18 | qrUrl?: string; |
| 19 | /** Who holds it from their own device, or null while nobody does. */ |
| 20 | holder?: string | null; |
| 21 | /** Online host: the seat's player is gone; offer it to the computer. */ |
| 22 | onHandToBot?: () => void; |
| 23 | } |
| 24 | |
| 25 | interface Props { |
| 26 | game: TableCtl; |
| 27 | seat: SeatId; |
| 28 | /** |
| 29 | * Which edge of this screen the seat is drawn at. At a shared table that is |
| 30 | * the seat itself; online the table is turned so your own seat is at the |
| 31 | * bottom, wherever you are actually sitting. |
| 32 | */ |
| 33 | pos?: SeatId; |
| 34 | /** |
| 35 | * Whether this device may act for the seat. The hotseat table says yes to |
| 36 | * everyone, the online table only to your own chair — everything private |
| 37 | * (the tiles' faces, the 聽 and 過水 badges, the buttons) keys off this. |
| 38 | */ |
| 39 | local?: boolean; |
| 40 | remote?: RemoteSeat; |
| 41 | /** Phone-sized table: nothing is rotated and only the bottom gets a full strip. */ |
| 42 | compact?: boolean; |
| 43 | /** The tiles in the middle. Absent on a phone, which has no middle. */ |
| 44 | pool?: TablePool; |
| 45 | onHelp: () => void; |
| 46 | } |
| 47 | |
| 48 | export function Seat({ game, seat, pos = seat, local = true, remote, compact, pool, onHelp }: Props) { |
| 49 | const s = game.state; |
| 50 | const p = s.players[seat]; |
| 51 | const [sel, setSel] = useState<number | null>(null); |
| 52 | // Held down to re-show a claimed seat's QR — for the player who lost theirs. |
| 53 | const [peek, setPeek] = useState(false); |
| 54 | const bot = s.bots[seat]; |
| 55 | // Somebody this device answers for: a person here, not the computer, not a |
| 56 | // hand that lives on another screen. |
| 57 | const acts = local && !bot; |
| 58 | // A hand this device may not read goes face down and its 聽 / 過水 badges |
| 59 | // stay off, since both would give it away. At the end of a hand everyone |
| 60 | // turns their tiles over, as they would. |
| 61 | const concealed = !acts && s.phase !== 'handEnd' && s.phase !== 'gameEnd'; |
| 62 | const myTurn = s.phase === 'turn' && s.turn === seat && acts; |
| 63 | const opts = acts ? s.options[seat] : undefined; |
| 64 | const responded = s.responses[seat] !== undefined; |
| 65 | const claiming = (s.phase === 'claims' || s.phase === 'robkong') && !!opts && !responded; |
| 66 | // Whoever draws next closes the claim window — nobody has to wait on a |
| 67 | // player who hasn't noticed their buttons. |
| 68 | const canProceed = game.nextDrawer() === seat && acts; |
| 69 | |
| 70 | useEffect(() => { |
| 71 | setSel(null); |
| 72 | }, [myTurn, s.handNumber, p.hand.length, s.drawnTile]); |
| 73 | |
| 74 | const seatWind = (seat - s.dealer + 4) % 4; |
| 75 | const isDealer = seat === s.dealer; |
| 76 | const live = s.phase !== 'handEnd' && s.phase !== 'lobby'; |
| 77 | const tenpai = live && acts ? game.tenpai(seat) : []; |
| 78 | const sacred = live && acts ? game.sacredTiles(seat) : []; |
| 79 | const concealedKongs = myTurn ? game.concealedKongs(seat) : []; |
| 80 | const addedKongs = myTurn ? game.addedKongs(seat) : []; |
| 81 | const canHu = myTurn && game.canSelfDrawWin(seat); |
| 82 | const delta = s.phase === 'handEnd' ? s.result?.deltas[seat] ?? 0 : 0; |
| 83 | // 一炮多響 can put more than one seat here, so this is a `some`, not a find. |
| 84 | const won = s.phase === 'handEnd' && !!s.result?.winners.some((w) => w.seat === seat); |
| 85 | // A deserted online seat, waiting for its person to come back — or for the |
| 86 | // host to hand it to the computer so the table can move. |
| 87 | const deserted = !!remote?.onHandToBot && !bot && remote.holder == null && live; |
| 88 | |
| 89 | // Compact lays the discards out as one line that scrolls rather than a block |
| 90 | // that wraps, so the newest tile — the only one anybody is looking for — has |
| 91 | // to be kept in view. Harmless at full size, where nothing overflows. |
| 92 | const discardsRef = useRef<HTMLDivElement>(null); |
| 93 | useEffect(() => { |
| 94 | const el = discardsRef.current; |
| 95 | if (!el) return; |
| 96 | el.scrollLeft = el.scrollWidth; |
| 97 | el.scrollTop = el.scrollHeight; |
| 98 | }, [p.discards.length]); |
| 99 | |
| 100 | const doDiscard = (i: number) => { |
| 101 | game.discard(seat, p.hand[i]); |
| 102 | setSel(null); |
| 103 | }; |
| 104 | |
| 105 | // The tile just drawn is held apart at the end of the hand until it is played |
| 106 | // or the player slides it in, so it stays obvious which tile just arrived. |
| 107 | const drawnIndex = myTurn && s.drawnSeparate && s.drawnTile !== null ? p.hand.length - 1 : null; |
| 108 | const selTile = sel === null ? null : p.hand[sel] ?? null; |
| 109 | |
| 110 | // Picking a tile up and throwing it in. Only on your own turn, and only where |
| 111 | // there is a middle of the table to throw into — the phone layout has none, so |
| 112 | // there the row goes back to only ever being rearranged. |
| 113 | const carry = |
| 114 | pool && myTurn |
| 115 | ? { |
| 116 | onPickUp: (i: number, x: number, y: number, size: number) => { |
| 117 | pool.pickUp({ |
| 118 | seat, |
| 119 | tile: p.hand[i], |
| 120 | index: i, |
| 121 | x, |
| 122 | y, |
| 123 | // Facing the way it did in the hand, so it does not snap round the |
| 124 | // instant it is lifted. |
| 125 | angle: (SEAT_ROT[pos] * Math.PI) / 180, |
| 126 | w: size, |
| 127 | h: size * 1.375, |
| 128 | }); |
| 129 | pool.wake(); |
| 130 | }, |
| 131 | onMoveTo: (x: number, y: number) => { |
| 132 | pool.moveHeld(x, y); |
| 133 | pool.wake(); |
| 134 | }, |
| 135 | onThrow: (i: number, t: { x: number; y: number; vx: number; vy: number; spin: number }) => { |
| 136 | const tile = p.hand[i]; |
| 137 | if (tile === undefined) return pool.cancelHeld(); |
| 138 | // Where it left the fingers is where the throw starts from. |
| 139 | pool.moveHeld(t.x, t.y); |
| 140 | pool.throwHeld(t.vx, t.vy, t.spin); |
| 141 | game.discard(seat, tile); |
| 142 | setSel(null); |
| 143 | }, |
| 144 | onCancel: () => { |
| 145 | pool.cancelHeld(); |
| 146 | pool.wake(); |
| 147 | }, |
| 148 | } |
| 149 | : undefined; |
| 150 | |
| 151 | return ( |
| 152 | // `data-seat` is how table/geometry.ts finds this seat's hand to work out |
| 153 | // where it throws from — see measure(). It carries the seat itself, not the |
| 154 | // edge it is drawn at: the physics is about who threw the tile. |
| 155 | <div className={`slot ${SEAT_SLOT[pos]} ${pos === 0 ? 'mine' : 'opp'}`} data-seat={seat}> |
| 156 | <div |
| 157 | className={`seat ${myTurn ? 'active' : ''} ${claiming ? 'claiming' : ''}`} |
| 158 | style={compact ? undefined : { transform: `translate(-50%, -50%) rotate(${SEAT_ROT[pos]}deg)` }} |
| 159 | > |
| 160 | {/* Points down the strip at whoever won. It is inside the seat, so it |
| 161 | turns with it and lands on their edge of the table wherever they |
| 162 | are sitting — no working out which way is which. */} |
| 163 | {won && ( |
| 164 | <div className="win-arrow" aria-hidden> |
| 165 | ▼ |
| 166 | </div> |
| 167 | )} |
| 168 | |
| 169 | {/* Party screen: a free seat wears its QR; scanning it takes the hand |
| 170 | to a phone. A claimed seat hides it, but its player can ask for it |
| 171 | back — the 手機 tag below holds it down to reveal. */} |
| 172 | {remote?.qrUrl && (remote.holder == null || peek) && ( |
| 173 | <div className="qr-dock"> |
| 174 | <SeatQr url={remote.qrUrl} /> |
| 175 | <span className="qr-hint"> |
| 176 | 掃碼用手機拿牌 |
| 177 | <em>scan to play from your phone</em> |
| 178 | </span> |
| 179 | </div> |
| 180 | )} |
| 181 | |
| 182 | {/* Only on a phone. At full size a discard is thrown into the middle |
| 183 | and stays there for the hand, so the row would be the same tiles |
| 184 | listed twice. */} |
| 185 | {compact && ( |
| 186 | <div className="discards" ref={discardsRef}> |
| 187 | {p.discards.map((t, i) => ( |
| 188 | <TileView |
| 189 | key={i} |
| 190 | tile={t} |
| 191 | size="small" |
| 192 | highlight={s.lastDiscard?.from === seat && i === p.discards.length - 1} |
| 193 | /> |
| 194 | ))} |
| 195 | </div> |
| 196 | )} |
| 197 | |
| 198 | <div className="melds"> |
| 199 | {p.melds.map((m, i) => ( |
| 200 | <div className="meld" key={i}> |
| 201 | {m.tiles.map((t, j) => ( |
| 202 | <TileView key={j} tile={t} size="meld" faceDown={m.kind === 'ankong' && j > 0 && j < 3} /> |
| 203 | ))} |
| 204 | </div> |
| 205 | ))} |
| 206 | {p.flowers.length > 0 && ( |
| 207 | <div className="meld flowers"> |
| 208 | {p.flowers.map((t, i) => ( |
| 209 | <TileView key={i} tile={t} size="meld" /> |
| 210 | ))} |
| 211 | </div> |
| 212 | )} |
| 213 | </div> |
| 214 | |
| 215 | <div className="actionbar"> |
| 216 | {claiming && ( |
| 217 | <> |
| 218 | {opts!.map((o, i) => ( |
| 219 | <button |
| 220 | key={i} |
| 221 | className={`btn claim ${o.type}`} |
| 222 | onClick={() => game.respond(seat, { type: o.type, with: o.with })} |
| 223 | > |
| 224 | <Bi |
| 225 | zh={CLAIM_LABEL[o.type][0]} |
| 226 | en={CLAIM_LABEL[o.type][1]} |
| 227 | sub={o.with && o.with.map(labelOf).join(' ')} |
| 228 | /> |
| 229 | </button> |
| 230 | ))} |
| 231 | {/* The seat that draws next declines by picking up — see below. */} |
| 232 | {!canProceed && ( |
| 233 | <button className="btn pass" onClick={() => game.respond(seat, 'pass')}> |
| 234 | <Bi zh="過" en="Pass" /> |
| 235 | </button> |
| 236 | )} |
| 237 | </> |
| 238 | )} |
| 239 | {canProceed && ( |
| 240 | <button |
| 241 | className={`btn proceed ${claiming ? 'pass' : ''}`} |
| 242 | onClick={() => game.resolveNow()} |
| 243 | > |
| 244 | <Bi |
| 245 | zh={s.phase === 'robkong' ? '繼續' : claiming ? '過.摸牌' : '摸牌'} |
| 246 | en={s.phase === 'robkong' ? 'Continue' : claiming ? 'Pass & draw' : 'Draw'} |
| 247 | /> |
| 248 | </button> |
| 249 | )} |
| 250 | {!claiming && !canProceed && responded && s.phase !== 'turn' && ( |
| 251 | <div className="waiting">已回應 Responded</div> |
| 252 | )} |
| 253 | {deserted && ( |
| 254 | <button className="btn ghost" onClick={remote!.onHandToBot}> |
| 255 | <Bi zh="電腦代打" en="Hand to computer" /> |
| 256 | </button> |
| 257 | )} |
| 258 | {myTurn && ( |
| 259 | <> |
| 260 | {canHu && ( |
| 261 | <button className="btn claim hu" onClick={() => game.declareSelfDraw(seat)}> |
| 262 | <Bi zh="自摸" en="Self-draw" /> |
| 263 | </button> |
| 264 | )} |
| 265 | {concealedKongs.map((t) => ( |
| 266 | <button key={`ak${t}`} className="btn claim kong" onClick={() => game.declareConcealedKong(seat, t)}> |
| 267 | <Bi zh="暗槓" en="Concealed kong" sub={labelOf(t)} /> |
| 268 | </button> |
| 269 | ))} |
| 270 | {addedKongs.map((t) => ( |
| 271 | <button key={`gk${t}`} className="btn claim kong" onClick={() => game.declareAddedKong(seat, t)}> |
| 272 | <Bi zh="加槓" en="Added kong" sub={labelOf(t)} /> |
| 273 | </button> |
| 274 | ))} |
| 275 | {selTile !== null && sel !== null && ( |
| 276 | <button className="btn discard" onClick={() => doDiscard(sel)}> |
| 277 | <Bi zh="打出" en="Discard" sub={labelOf(selTile)} /> |
| 278 | </button> |
| 279 | )} |
| 280 | </> |
| 281 | )} |
| 282 | {delta !== 0 && <div className={`delta ${delta > 0 ? 'up' : 'down'}`}>{delta > 0 ? `+${delta}` : delta}</div>} |
| 283 | </div> |
| 284 | |
| 285 | <Hand |
| 286 | tiles={p.hand} |
| 287 | faceDown={concealed} |
| 288 | drawnIndex={drawnIndex} |
| 289 | selected={sel} |
| 290 | canDiscard={myTurn} |
| 291 | onSelect={setSel} |
| 292 | onDiscard={doDiscard} |
| 293 | onMove={(from, to) => game.reorderHand(seat, from, to)} |
| 294 | carry={carry} |
| 295 | /> |
| 296 | |
| 297 | <div className="nameplate"> |
| 298 | <span className={`wind ${isDealer ? 'dealer' : ''}`}> |
| 299 | {WIND_NAMES[seatWind]} |
| 300 | <em>{WIND_EN[seatWind]}</em> |
| 301 | </span> |
| 302 | <span className="pname">{p.name}</span> |
| 303 | {remote?.holder != null && ( |
| 304 | <span |
| 305 | className="tag phone" |
| 306 | title="這手牌在手機上 · played from a phone. 按住看 QR · hold to show the QR again" |
| 307 | onPointerDown={() => setPeek(true)} |
| 308 | onPointerUp={() => setPeek(false)} |
| 309 | onPointerCancel={() => setPeek(false)} |
| 310 | onPointerLeave={() => setPeek(false)} |
| 311 | > |
| 312 | 📱 {remote.holder} |
| 313 | </span> |
| 314 | )} |
| 315 | {deserted && <span className="tag sacred">已離線 offline</span>} |
| 316 | {isDealer && ( |
| 317 | <span className="tag"> |
| 318 | 莊 Dealer{s.dealerStreak > 0 ? ` 連${s.dealerStreak}` : ''} |
| 319 | </span> |
| 320 | )} |
| 321 | {tenpai.length > 0 && ( |
| 322 | <span className="tag ting">聽 Ready {tenpai.map(labelOf).join('·')}</span> |
| 323 | )} |
| 324 | {/* Without this the 胡 button just quietly fails to appear. */} |
| 325 | {sacred.length > 0 && ( |
| 326 | <span className="tag sacred" title="過水 — passed on winning with these until your next draw"> |
| 327 | 過水 {sacred.map(labelOf).join('·')} |
| 328 | </span> |
| 329 | )} |
| 330 | <span className="chips">{p.score}</span> |
| 331 | {p.hand.length > 0 && s.phase !== 'handEnd' && acts && ( |
| 332 | <button className="btn ghost sort" onClick={() => game.sortHand(seat)}> |
| 333 | 理牌 Sort |
| 334 | </button> |
| 335 | )} |
| 336 | <button className="helpbtn" onClick={onHelp} aria-label="規則 rules"> |
| 337 | ? |
| 338 | </button> |
| 339 | </div> |
| 340 | </div> |
| 341 | </div> |
| 342 | ); |
| 343 | } |