anvilsign in

collin/mahjong

1import { useEffect, useState } from 'react';
2import type { Game } from '../game/engine';
3import { labelOf, WIND_EN, WIND_NAMES } from '../game/tiles';
4import type { SeatId } from '../game/types';
5import { Bi, CLAIM_LABEL } from './Bi';
6import { Hand } from './Hand';
7import { TileView } from './TileView';
8
9const 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.
12const SLOT: Record<SeatId, string> = { 0: 'slot-bottom', 1: 'slot-right', 2: 'slot-top', 3: 'slot-left' };
13
14interface Props {
15 game: Game;
16 seat: SeatId;
17 onHelp: () => void;
18}
19
20export 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 // A computer seat has no buttons and no readable hand: its tiles go face down
25 // and its 聽 / 過水 badges stay off, since both would give the hand away.
26 // At the end of a hand everyone turns their tiles over, as they would.
27 const bot = s.bots[seat];
28 const concealed = bot && s.phase !== 'handEnd' && s.phase !== 'gameEnd';
29 const myTurn = s.phase === 'turn' && s.turn === seat && !bot;
30 const opts = bot ? undefined : s.options[seat];
31 const responded = s.responses[seat] !== undefined;
32 const claiming = (s.phase === 'claims' || s.phase === 'robkong') && !!opts && !responded;
33 // Whoever draws next closes the claim window — nobody has to wait on a
34 // player who hasn't noticed their buttons.
35 const canProceed = game.nextDrawer() === seat && !bot;
36
37 useEffect(() => {
38 setSel(null);
39 }, [myTurn, s.handNumber, p.hand.length, s.drawnTile]);
40
41 const seatWind = (seat - s.dealer + 4) % 4;
42 const isDealer = seat === s.dealer;
43 const live = s.phase !== 'handEnd' && s.phase !== 'lobby';
44 const tenpai = live && !bot ? game.tenpai(seat) : [];
45 const sacred = live && !bot ? game.sacredTiles(seat) : [];
46 const concealedKongs = myTurn ? game.concealedKongs(seat) : [];
47 const addedKongs = myTurn ? game.addedKongs(seat) : [];
48 const canHu = myTurn && game.canSelfDrawWin(seat);
49 const delta = s.phase === 'handEnd' ? s.result?.deltas[seat] ?? 0 : 0;
50 // 一炮多響 can put more than one seat here, so this is a `some`, not a find.
51 const won = s.phase === 'handEnd' && !!s.result?.winners.some((w) => w.seat === seat);
52
53 const doDiscard = (i: number) => {
54 game.discard(seat, p.hand[i]);
55 setSel(null);
56 };
57
58 // The tile just drawn is held apart at the end of the hand until it is played
59 // or the player slides it in, so it stays obvious which tile just arrived.
60 const drawnIndex = myTurn && s.drawnSeparate && s.drawnTile !== null ? p.hand.length - 1 : null;
61 const selTile = sel === null ? null : p.hand[sel] ?? null;
62
63 return (
64 <div className={`slot ${SLOT[seat]}`}>
65 <div
66 className={`seat ${myTurn ? 'active' : ''} ${claiming ? 'claiming' : ''}`}
67 style={{ transform: `translate(-50%, -50%) rotate(${ROT[seat]}deg)` }}
68 >
69 {/* Points down the strip at whoever won. It is inside the seat, so it
70 turns with it and lands on their edge of the table wherever they
71 are sitting — no working out which way is which. */}
72 {won && (
73 <div className="win-arrow" aria-hidden>
74 ▼
75 </div>
76 )}
77
78 <div className="discards">
79 {p.discards.map((t, i) => (
80 <TileView
81 key={i}
82 tile={t}
83 size="small"
84 highlight={s.lastDiscard?.from === seat && i === p.discards.length - 1}
85 />
86 ))}
87 </div>
88
89 <div className="melds">
90 {p.melds.map((m, i) => (
91 <div className="meld" key={i}>
92 {m.tiles.map((t, j) => (
93 <TileView key={j} tile={t} size="meld" faceDown={m.kind === 'ankong' && j > 0 && j < 3} />
94 ))}
95 </div>
96 ))}
97 {p.flowers.length > 0 && (
98 <div className="meld flowers">
99 {p.flowers.map((t, i) => (
100 <TileView key={i} tile={t} size="meld" />
101 ))}
102 </div>
103 )}
104 </div>
105
106 <div className="actionbar">
107 {claiming && (
108 <>
109 {opts!.map((o, i) => (
110 <button
111 key={i}
112 className={`btn claim ${o.type}`}
113 onClick={() => game.respond(seat, { type: o.type, with: o.with })}
114 >
115 <Bi
116 zh={CLAIM_LABEL[o.type][0]}
117 en={CLAIM_LABEL[o.type][1]}
118 sub={o.with && o.with.map(labelOf).join(' ')}
119 />
120 </button>
121 ))}
122 {/* The seat that draws next declines by picking up — see below. */}
123 {!canProceed && (
124 <button className="btn pass" onClick={() => game.respond(seat, 'pass')}>
125 <Bi zh="過" en="Pass" />
126 </button>
127 )}
128 </>
129 )}
130 {canProceed && (
131 <button
132 className={`btn proceed ${claiming ? 'pass' : ''}`}
133 onClick={() => game.resolveNow()}
134 >
135 <Bi
136 zh={s.phase === 'robkong' ? '繼續' : claiming ? '過.摸牌' : '摸牌'}
137 en={s.phase === 'robkong' ? 'Continue' : claiming ? 'Pass & draw' : 'Draw'}
138 />
139 </button>
140 )}
141 {!claiming && !canProceed && responded && s.phase !== 'turn' && (
142 <div className="waiting">已回應 Responded</div>
143 )}
144 {myTurn && (
145 <>
146 {canHu && (
147 <button className="btn claim hu" onClick={() => game.declareSelfDraw(seat)}>
148 <Bi zh="自摸" en="Self-draw" />
149 </button>
150 )}
151 {concealedKongs.map((t) => (
152 <button key={`ak${t}`} className="btn claim kong" onClick={() => game.declareConcealedKong(seat, t)}>
153 <Bi zh="暗槓" en="Concealed kong" sub={labelOf(t)} />
154 </button>
155 ))}
156 {addedKongs.map((t) => (
157 <button key={`gk${t}`} className="btn claim kong" onClick={() => game.declareAddedKong(seat, t)}>
158 <Bi zh="加槓" en="Added kong" sub={labelOf(t)} />
159 </button>
160 ))}
161 {selTile !== null && sel !== null && (
162 <button className="btn discard" onClick={() => doDiscard(sel)}>
163 <Bi zh="打出" en="Discard" sub={labelOf(selTile)} />
164 </button>
165 )}
166 </>
167 )}
168 {delta !== 0 && <div className={`delta ${delta > 0 ? 'up' : 'down'}`}>{delta > 0 ? `+${delta}` : delta}</div>}
169 </div>
170
171 <Hand
172 tiles={p.hand}
173 faceDown={concealed}
174 drawnIndex={drawnIndex}
175 selected={sel}
176 canDiscard={myTurn}
177 onSelect={setSel}
178 onDiscard={doDiscard}
179 onMove={(from, to) => game.reorderHand(seat, from, to)}
180 />
181
182 <div className="nameplate">
183 <span className={`wind ${isDealer ? 'dealer' : ''}`}>
184 {WIND_NAMES[seatWind]}
185 <em>{WIND_EN[seatWind]}</em>
186 </span>
187 <span className="pname">{p.name}</span>
188 {isDealer && (
189 <span className="tag">
190 莊 Dealer{s.dealerStreak > 0 ? ` 連${s.dealerStreak}` : ''}
191 </span>
192 )}
193 {tenpai.length > 0 && (
194 <span className="tag ting">聽 Ready {tenpai.map(labelOf).join('·')}</span>
195 )}
196 {/* Without this the 胡 button just quietly fails to appear. */}
197 {sacred.length > 0 && (
198 <span className="tag sacred" title="過水 — passed on winning with these until your next draw">
199 過水 {sacred.map(labelOf).join('·')}
200 </span>
201 )}
202 <span className="chips">{p.score}</span>
203 {p.hand.length > 0 && s.phase !== 'handEnd' && !bot && (
204 <button className="btn ghost sort" onClick={() => game.sortHand(seat)}>
205 理牌 Sort
206 </button>
207 )}
208 <button className="helpbtn" onClick={onHelp} aria-label="規則 rules">
209 ?
210 </button>
211 </div>
212 </div>
213 </div>
214 );
215}