| 1 | import { useEffect, 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 { Bi, CLAIM_LABEL } from './Bi'; |
| 6 | import { Hand } from './Hand'; |
| 7 | import { TileView } from './TileView'; |
| 8 | |
| 9 | const ROT: Record<SeatId, number> = { 0: 0, 1: -90, 2: 180, 3: 90 }; |
| 10 | // seat 0 = bottom (near edge of the laptop), then counter-clockwise on screen: |
| 11 | // 1 = right, 2 = top, 3 = left — which is the natural 東南西北 seating order. |
| 12 | const SLOT: Record<SeatId, string> = { 0: 'slot-bottom', 1: 'slot-right', 2: 'slot-top', 3: 'slot-left' }; |
| 13 | |
| 14 | interface Props { |
| 15 | game: Game; |
| 16 | seat: SeatId; |
| 17 | onHelp: () => void; |
| 18 | } |
| 19 | |
| 20 | export function Seat({ game, seat, onHelp }: Props) { |
| 21 | const s = game.state; |
| 22 | const p = s.players[seat]; |
| 23 | const [sel, setSel] = useState<number | null>(null); |
| 24 | const myTurn = s.phase === 'turn' && s.turn === seat; |
| 25 | const opts = s.options[seat]; |
| 26 | const responded = s.responses[seat] !== undefined; |
| 27 | const claiming = (s.phase === 'claims' || s.phase === 'robkong') && !!opts && !responded; |
| 28 | // Whoever draws next closes the claim window — nobody has to wait on a |
| 29 | // player who hasn't noticed their buttons. |
| 30 | const canProceed = game.nextDrawer() === seat; |
| 31 | |
| 32 | useEffect(() => { |
| 33 | setSel(null); |
| 34 | }, [myTurn, s.handNumber, p.hand.length, s.drawnTile]); |
| 35 | |
| 36 | const seatWind = (seat - s.dealer + 4) % 4; |
| 37 | const isDealer = seat === s.dealer; |
| 38 | const live = s.phase !== 'handEnd' && s.phase !== 'lobby'; |
| 39 | const tenpai = live ? game.tenpai(seat) : []; |
| 40 | const sacred = live ? game.sacredTiles(seat) : []; |
| 41 | const concealedKongs = myTurn ? game.concealedKongs(seat) : []; |
| 42 | const addedKongs = myTurn ? game.addedKongs(seat) : []; |
| 43 | const canHu = myTurn && game.canSelfDrawWin(seat); |
| 44 | const delta = s.phase === 'handEnd' ? s.result?.deltas[seat] ?? 0 : 0; |
| 45 | |
| 46 | const doDiscard = (i: number) => { |
| 47 | game.discard(seat, p.hand[i]); |
| 48 | setSel(null); |
| 49 | }; |
| 50 | |
| 51 | // The tile just drawn is held apart at the end of the hand until it is played |
| 52 | // or the player slides it in, so it stays obvious which tile just arrived. |
| 53 | const drawnIndex = myTurn && s.drawnSeparate && s.drawnTile !== null ? p.hand.length - 1 : null; |
| 54 | const selTile = sel === null ? null : p.hand[sel] ?? null; |
| 55 | |
| 56 | return ( |
| 57 | <div className={`slot ${SLOT[seat]}`}> |
| 58 | <div |
| 59 | className={`seat ${myTurn ? 'active' : ''} ${claiming ? 'claiming' : ''}`} |
| 60 | style={{ transform: `translate(-50%, -50%) rotate(${ROT[seat]}deg)` }} |
| 61 | > |
| 62 | <div className="discards"> |
| 63 | {p.discards.map((t, i) => ( |
| 64 | <TileView |
| 65 | key={i} |
| 66 | tile={t} |
| 67 | size="small" |
| 68 | highlight={s.lastDiscard?.from === seat && i === p.discards.length - 1} |
| 69 | /> |
| 70 | ))} |
| 71 | </div> |
| 72 | |
| 73 | <div className="melds"> |
| 74 | {p.melds.map((m, i) => ( |
| 75 | <div className="meld" key={i}> |
| 76 | {m.tiles.map((t, j) => ( |
| 77 | <TileView key={j} tile={t} size="meld" faceDown={m.kind === 'ankong' && j > 0 && j < 3} /> |
| 78 | ))} |
| 79 | </div> |
| 80 | ))} |
| 81 | {p.flowers.length > 0 && ( |
| 82 | <div className="meld flowers"> |
| 83 | {p.flowers.map((t, i) => ( |
| 84 | <TileView key={i} tile={t} size="meld" /> |
| 85 | ))} |
| 86 | </div> |
| 87 | )} |
| 88 | </div> |
| 89 | |
| 90 | <div className="actionbar"> |
| 91 | {claiming && ( |
| 92 | <> |
| 93 | {opts!.map((o, i) => ( |
| 94 | <button |
| 95 | key={i} |
| 96 | className={`btn claim ${o.type}`} |
| 97 | onClick={() => game.respond(seat, { type: o.type, with: o.with })} |
| 98 | > |
| 99 | <Bi |
| 100 | zh={CLAIM_LABEL[o.type][0]} |
| 101 | en={CLAIM_LABEL[o.type][1]} |
| 102 | sub={o.with && o.with.map(labelOf).join(' ')} |
| 103 | /> |
| 104 | </button> |
| 105 | ))} |
| 106 | <button className="btn pass" onClick={() => game.respond(seat, 'pass')}> |
| 107 | <Bi zh="過" en="Pass" /> |
| 108 | </button> |
| 109 | </> |
| 110 | )} |
| 111 | {canProceed && ( |
| 112 | <button className="btn proceed" onClick={() => game.resolveNow()}> |
| 113 | <Bi |
| 114 | zh={s.phase === 'robkong' ? '繼續' : '摸牌'} |
| 115 | en={s.phase === 'robkong' ? 'Continue' : 'Draw'} |
| 116 | /> |
| 117 | </button> |
| 118 | )} |
| 119 | {!claiming && !canProceed && responded && s.phase !== 'turn' && ( |
| 120 | <div className="waiting">已回應 Responded</div> |
| 121 | )} |
| 122 | {myTurn && ( |
| 123 | <> |
| 124 | {canHu && ( |
| 125 | <button className="btn claim hu" onClick={() => game.declareSelfDraw(seat)}> |
| 126 | <Bi zh="自摸" en="Self-draw" /> |
| 127 | </button> |
| 128 | )} |
| 129 | {concealedKongs.map((t) => ( |
| 130 | <button key={`ak${t}`} className="btn claim kong" onClick={() => game.declareConcealedKong(seat, t)}> |
| 131 | <Bi zh="暗槓" en="Concealed kong" sub={labelOf(t)} /> |
| 132 | </button> |
| 133 | ))} |
| 134 | {addedKongs.map((t) => ( |
| 135 | <button key={`gk${t}`} className="btn claim kong" onClick={() => game.declareAddedKong(seat, t)}> |
| 136 | <Bi zh="加槓" en="Added kong" sub={labelOf(t)} /> |
| 137 | </button> |
| 138 | ))} |
| 139 | {selTile !== null && sel !== null && ( |
| 140 | <button className="btn discard" onClick={() => doDiscard(sel)}> |
| 141 | <Bi zh="打出" en="Discard" sub={labelOf(selTile)} /> |
| 142 | </button> |
| 143 | )} |
| 144 | </> |
| 145 | )} |
| 146 | {delta !== 0 && <div className={`delta ${delta > 0 ? 'up' : 'down'}`}>{delta > 0 ? `+${delta}` : delta}</div>} |
| 147 | </div> |
| 148 | |
| 149 | <Hand |
| 150 | tiles={p.hand} |
| 151 | drawnIndex={drawnIndex} |
| 152 | selected={sel} |
| 153 | canDiscard={myTurn} |
| 154 | onSelect={setSel} |
| 155 | onDiscard={doDiscard} |
| 156 | onMove={(from, to) => game.reorderHand(seat, from, to)} |
| 157 | /> |
| 158 | |
| 159 | <div className="nameplate"> |
| 160 | <span className={`wind ${isDealer ? 'dealer' : ''}`}> |
| 161 | {WIND_NAMES[seatWind]} |
| 162 | <em>{WIND_EN[seatWind]}</em> |
| 163 | </span> |
| 164 | <span className="pname">{p.name}</span> |
| 165 | {isDealer && ( |
| 166 | <span className="tag"> |
| 167 | 莊 Dealer{s.dealerStreak > 0 ? ` 連${s.dealerStreak}` : ''} |
| 168 | </span> |
| 169 | )} |
| 170 | {tenpai.length > 0 && ( |
| 171 | <span className="tag ting">聽 Ready {tenpai.map(labelOf).join('·')}</span> |
| 172 | )} |
| 173 | {/* Without this the 胡 button just quietly fails to appear. */} |
| 174 | {sacred.length > 0 && ( |
| 175 | <span className="tag sacred" title="過水 — passed on winning with these until your next draw"> |
| 176 | 過水 {sacred.map(labelOf).join('·')} |
| 177 | </span> |
| 178 | )} |
| 179 | <span className="chips">{p.score}</span> |
| 180 | {p.hand.length > 0 && s.phase !== 'handEnd' && ( |
| 181 | <button className="btn ghost sort" onClick={() => game.sortHand(seat)}> |
| 182 | 理牌 Sort |
| 183 | </button> |
| 184 | )} |
| 185 | <button className="helpbtn" onClick={onHelp} aria-label="規則 rules"> |
| 186 | ? |
| 187 | </button> |
| 188 | </div> |
| 189 | </div> |
| 190 | </div> |
| 191 | ); |
| 192 | } |