| 1 | import { useEffect, useRef, useState } from 'react'; |
| 2 | import type { Game } from '../game/engine'; |
| 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 { TileView } from './TileView'; |
| 9 | |
| 10 | const ROT: Record<SeatId, number> = { 0: 0, 1: -90, 2: 180, 3: 90 }; |
| 11 | // seat 0 = bottom (near edge of the laptop), then counter-clockwise on screen: |
| 12 | // 1 = right, 2 = top, 3 = left — which is the natural 東南西北 seating order. |
| 13 | const SLOT: Record<SeatId, string> = { 0: 'slot-bottom', 1: 'slot-right', 2: 'slot-top', 3: 'slot-left' }; |
| 14 | |
| 15 | interface Props { |
| 16 | game: Game; |
| 17 | seat: SeatId; |
| 18 | /** Phone-sized table: nothing is rotated and only seat 0 gets a full strip. */ |
| 19 | compact?: boolean; |
| 20 | /** The tiles in the middle. Absent on a phone, which has no middle. */ |
| 21 | pool?: TablePool; |
| 22 | onHelp: () => void; |
| 23 | } |
| 24 | |
| 25 | export function Seat({ game, seat, compact, pool, onHelp }: Props) { |
| 26 | const s = game.state; |
| 27 | const p = s.players[seat]; |
| 28 | const [sel, setSel] = useState<number | null>(null); |
| 29 | // A computer seat has no buttons and no readable hand: its tiles go face down |
| 30 | // and its 聽 / 過水 badges stay off, since both would give the hand away. |
| 31 | // At the end of a hand everyone turns their tiles over, as they would. |
| 32 | const bot = s.bots[seat]; |
| 33 | const concealed = bot && s.phase !== 'handEnd' && s.phase !== 'gameEnd'; |
| 34 | const myTurn = s.phase === 'turn' && s.turn === seat && !bot; |
| 35 | const opts = bot ? undefined : s.options[seat]; |
| 36 | const responded = s.responses[seat] !== undefined; |
| 37 | const claiming = (s.phase === 'claims' || s.phase === 'robkong') && !!opts && !responded; |
| 38 | // Whoever draws next closes the claim window — nobody has to wait on a |
| 39 | // player who hasn't noticed their buttons. |
| 40 | const canProceed = game.nextDrawer() === seat && !bot; |
| 41 | |
| 42 | useEffect(() => { |
| 43 | setSel(null); |
| 44 | }, [myTurn, s.handNumber, p.hand.length, s.drawnTile]); |
| 45 | |
| 46 | const seatWind = (seat - s.dealer + 4) % 4; |
| 47 | const isDealer = seat === s.dealer; |
| 48 | const live = s.phase !== 'handEnd' && s.phase !== 'lobby'; |
| 49 | const tenpai = live && !bot ? game.tenpai(seat) : []; |
| 50 | const sacred = live && !bot ? game.sacredTiles(seat) : []; |
| 51 | const concealedKongs = myTurn ? game.concealedKongs(seat) : []; |
| 52 | const addedKongs = myTurn ? game.addedKongs(seat) : []; |
| 53 | const canHu = myTurn && game.canSelfDrawWin(seat); |
| 54 | const delta = s.phase === 'handEnd' ? s.result?.deltas[seat] ?? 0 : 0; |
| 55 | // 一炮多響 can put more than one seat here, so this is a `some`, not a find. |
| 56 | const won = s.phase === 'handEnd' && !!s.result?.winners.some((w) => w.seat === seat); |
| 57 | |
| 58 | // Compact lays the discards out as one line that scrolls rather than a block |
| 59 | // that wraps, so the newest tile — the only one anybody is looking for — has |
| 60 | // to be kept in view. Harmless at full size, where nothing overflows. |
| 61 | const discardsRef = useRef<HTMLDivElement>(null); |
| 62 | useEffect(() => { |
| 63 | const el = discardsRef.current; |
| 64 | if (!el) return; |
| 65 | el.scrollLeft = el.scrollWidth; |
| 66 | el.scrollTop = el.scrollHeight; |
| 67 | }, [p.discards.length]); |
| 68 | |
| 69 | const doDiscard = (i: number) => { |
| 70 | game.discard(seat, p.hand[i]); |
| 71 | setSel(null); |
| 72 | }; |
| 73 | |
| 74 | // The tile just drawn is held apart at the end of the hand until it is played |
| 75 | // or the player slides it in, so it stays obvious which tile just arrived. |
| 76 | const drawnIndex = myTurn && s.drawnSeparate && s.drawnTile !== null ? p.hand.length - 1 : null; |
| 77 | const selTile = sel === null ? null : p.hand[sel] ?? null; |
| 78 | |
| 79 | // Picking a tile up and throwing it in. Only on your own turn, and only where |
| 80 | // there is a middle of the table to throw into — the phone layout has none, so |
| 81 | // there the row goes back to only ever being rearranged. |
| 82 | const carry = |
| 83 | pool && myTurn |
| 84 | ? { |
| 85 | onPickUp: (i: number, x: number, y: number, size: number) => { |
| 86 | pool.pickUp({ |
| 87 | seat, |
| 88 | tile: p.hand[i], |
| 89 | index: i, |
| 90 | x, |
| 91 | y, |
| 92 | // Facing the way it did in the hand, so it does not snap round the |
| 93 | // instant it is lifted. |
| 94 | angle: (ROT[seat] * Math.PI) / 180, |
| 95 | w: size, |
| 96 | h: size * 1.375, |
| 97 | }); |
| 98 | pool.wake(); |
| 99 | }, |
| 100 | onMoveTo: (x: number, y: number) => { |
| 101 | pool.moveHeld(x, y); |
| 102 | pool.wake(); |
| 103 | }, |
| 104 | onThrow: (i: number, t: { x: number; y: number; vx: number; vy: number; spin: number }) => { |
| 105 | const tile = p.hand[i]; |
| 106 | if (tile === undefined) return pool.cancelHeld(); |
| 107 | // Where it left the fingers is where the throw starts from. |
| 108 | pool.moveHeld(t.x, t.y); |
| 109 | pool.throwHeld(t.vx, t.vy, t.spin); |
| 110 | game.discard(seat, tile); |
| 111 | setSel(null); |
| 112 | }, |
| 113 | onCancel: () => { |
| 114 | pool.cancelHeld(); |
| 115 | pool.wake(); |
| 116 | }, |
| 117 | } |
| 118 | : undefined; |
| 119 | |
| 120 | return ( |
| 121 | // `data-seat` is how table/geometry.ts finds this seat's hand to work out |
| 122 | // where it throws from — see measure(). |
| 123 | <div className={`slot ${SLOT[seat]} ${seat === 0 ? 'mine' : 'opp'}`} data-seat={seat}> |
| 124 | <div |
| 125 | className={`seat ${myTurn ? 'active' : ''} ${claiming ? 'claiming' : ''}`} |
| 126 | style={compact ? undefined : { transform: `translate(-50%, -50%) rotate(${ROT[seat]}deg)` }} |
| 127 | > |
| 128 | {/* Points down the strip at whoever won. It is inside the seat, so it |
| 129 | turns with it and lands on their edge of the table wherever they |
| 130 | are sitting — no working out which way is which. */} |
| 131 | {won && ( |
| 132 | <div className="win-arrow" aria-hidden> |
| 133 | ▼ |
| 134 | </div> |
| 135 | )} |
| 136 | |
| 137 | {/* Only on a phone. At full size a discard is thrown into the middle |
| 138 | and stays there for the hand, so the row would be the same tiles |
| 139 | listed twice. */} |
| 140 | {compact && ( |
| 141 | <div className="discards" ref={discardsRef}> |
| 142 | {p.discards.map((t, i) => ( |
| 143 | <TileView |
| 144 | key={i} |
| 145 | tile={t} |
| 146 | size="small" |
| 147 | highlight={s.lastDiscard?.from === seat && i === p.discards.length - 1} |
| 148 | /> |
| 149 | ))} |
| 150 | </div> |
| 151 | )} |
| 152 | |
| 153 | <div className="melds"> |
| 154 | {p.melds.map((m, i) => ( |
| 155 | <div className="meld" key={i}> |
| 156 | {m.tiles.map((t, j) => ( |
| 157 | <TileView key={j} tile={t} size="meld" faceDown={m.kind === 'ankong' && j > 0 && j < 3} /> |
| 158 | ))} |
| 159 | </div> |
| 160 | ))} |
| 161 | {p.flowers.length > 0 && ( |
| 162 | <div className="meld flowers"> |
| 163 | {p.flowers.map((t, i) => ( |
| 164 | <TileView key={i} tile={t} size="meld" /> |
| 165 | ))} |
| 166 | </div> |
| 167 | )} |
| 168 | </div> |
| 169 | |
| 170 | <div className="actionbar"> |
| 171 | {claiming && ( |
| 172 | <> |
| 173 | {opts!.map((o, i) => ( |
| 174 | <button |
| 175 | key={i} |
| 176 | className={`btn claim ${o.type}`} |
| 177 | onClick={() => game.respond(seat, { type: o.type, with: o.with })} |
| 178 | > |
| 179 | <Bi |
| 180 | zh={CLAIM_LABEL[o.type][0]} |
| 181 | en={CLAIM_LABEL[o.type][1]} |
| 182 | sub={o.with && o.with.map(labelOf).join(' ')} |
| 183 | /> |
| 184 | </button> |
| 185 | ))} |
| 186 | {/* The seat that draws next declines by picking up — see below. */} |
| 187 | {!canProceed && ( |
| 188 | <button className="btn pass" onClick={() => game.respond(seat, 'pass')}> |
| 189 | <Bi zh="過" en="Pass" /> |
| 190 | </button> |
| 191 | )} |
| 192 | </> |
| 193 | )} |
| 194 | {canProceed && ( |
| 195 | <button |
| 196 | className={`btn proceed ${claiming ? 'pass' : ''}`} |
| 197 | onClick={() => game.resolveNow()} |
| 198 | > |
| 199 | <Bi |
| 200 | zh={s.phase === 'robkong' ? '繼續' : claiming ? '過.摸牌' : '摸牌'} |
| 201 | en={s.phase === 'robkong' ? 'Continue' : claiming ? 'Pass & draw' : 'Draw'} |
| 202 | /> |
| 203 | </button> |
| 204 | )} |
| 205 | {!claiming && !canProceed && responded && s.phase !== 'turn' && ( |
| 206 | <div className="waiting">已回應 Responded</div> |
| 207 | )} |
| 208 | {myTurn && ( |
| 209 | <> |
| 210 | {canHu && ( |
| 211 | <button className="btn claim hu" onClick={() => game.declareSelfDraw(seat)}> |
| 212 | <Bi zh="自摸" en="Self-draw" /> |
| 213 | </button> |
| 214 | )} |
| 215 | {concealedKongs.map((t) => ( |
| 216 | <button key={`ak${t}`} className="btn claim kong" onClick={() => game.declareConcealedKong(seat, t)}> |
| 217 | <Bi zh="暗槓" en="Concealed kong" sub={labelOf(t)} /> |
| 218 | </button> |
| 219 | ))} |
| 220 | {addedKongs.map((t) => ( |
| 221 | <button key={`gk${t}`} className="btn claim kong" onClick={() => game.declareAddedKong(seat, t)}> |
| 222 | <Bi zh="加槓" en="Added kong" sub={labelOf(t)} /> |
| 223 | </button> |
| 224 | ))} |
| 225 | {selTile !== null && sel !== null && ( |
| 226 | <button className="btn discard" onClick={() => doDiscard(sel)}> |
| 227 | <Bi zh="打出" en="Discard" sub={labelOf(selTile)} /> |
| 228 | </button> |
| 229 | )} |
| 230 | </> |
| 231 | )} |
| 232 | {delta !== 0 && <div className={`delta ${delta > 0 ? 'up' : 'down'}`}>{delta > 0 ? `+${delta}` : delta}</div>} |
| 233 | </div> |
| 234 | |
| 235 | <Hand |
| 236 | tiles={p.hand} |
| 237 | faceDown={concealed} |
| 238 | drawnIndex={drawnIndex} |
| 239 | selected={sel} |
| 240 | canDiscard={myTurn} |
| 241 | onSelect={setSel} |
| 242 | onDiscard={doDiscard} |
| 243 | onMove={(from, to) => game.reorderHand(seat, from, to)} |
| 244 | carry={carry} |
| 245 | /> |
| 246 | |
| 247 | <div className="nameplate"> |
| 248 | <span className={`wind ${isDealer ? 'dealer' : ''}`}> |
| 249 | {WIND_NAMES[seatWind]} |
| 250 | <em>{WIND_EN[seatWind]}</em> |
| 251 | </span> |
| 252 | <span className="pname">{p.name}</span> |
| 253 | {isDealer && ( |
| 254 | <span className="tag"> |
| 255 | 莊 Dealer{s.dealerStreak > 0 ? ` 連${s.dealerStreak}` : ''} |
| 256 | </span> |
| 257 | )} |
| 258 | {tenpai.length > 0 && ( |
| 259 | <span className="tag ting">聽 Ready {tenpai.map(labelOf).join('·')}</span> |
| 260 | )} |
| 261 | {/* Says why a tile lobs over the square rather than sliding into it — |
| 262 | the wall in front of this seat has not been eaten away yet. */} |
| 263 | {myTurn && pool && !pool.canThrow(seat) && ( |
| 264 | <span |
| 265 | className="tag walled" |
| 266 | title="牆未開 — the wall in front of you is still standing, so a tile has to go over it rather than across the table" |
| 267 | > |
| 268 | 牆未開 Wall up |
| 269 | </span> |
| 270 | )} |
| 271 | {/* Without this the 胡 button just quietly fails to appear. */} |
| 272 | {sacred.length > 0 && ( |
| 273 | <span className="tag sacred" title="過水 — passed on winning with these until your next draw"> |
| 274 | 過水 {sacred.map(labelOf).join('·')} |
| 275 | </span> |
| 276 | )} |
| 277 | <span className="chips">{p.score}</span> |
| 278 | {p.hand.length > 0 && s.phase !== 'handEnd' && !bot && ( |
| 279 | <button className="btn ghost sort" onClick={() => game.sortHand(seat)}> |
| 280 | 理牌 Sort |
| 281 | </button> |
| 282 | )} |
| 283 | <button className="helpbtn" onClick={onHelp} aria-label="規則 rules"> |
| 284 | ? |
| 285 | </button> |
| 286 | </div> |
| 287 | </div> |
| 288 | </div> |
| 289 | ); |
| 290 | } |