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 type { TablePool } from '../table/pool';
6import { Bi, CLAIM_LABEL } from './Bi';
7import { Hand } from './Hand';
8import { TileView } from './TileView';
9
10const 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.
13const SLOT: Record<SeatId, string> = { 0: 'slot-bottom', 1: 'slot-right', 2: 'slot-top', 3: 'slot-left' };
14
15interface 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
25export 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}