anvilsign in

collin/mahjong

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