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 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}