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 <div className="discards" ref={discardsRef}>
138 {p.discards.map((t, i) => (
139 <TileView
140 key={i}
141 tile={t}
142 size="small"
143 highlight={s.lastDiscard?.from === seat && i === p.discards.length - 1}
144 />
145 ))}
146 </div>
147
148 <div className="melds">
149 {p.melds.map((m, i) => (
150 <div className="meld" key={i}>
151 {m.tiles.map((t, j) => (
152 <TileView key={j} tile={t} size="meld" faceDown={m.kind === 'ankong' && j > 0 && j < 3} />
153 ))}
154 </div>
155 ))}
156 {p.flowers.length > 0 && (
157 <div className="meld flowers">
158 {p.flowers.map((t, i) => (
159 <TileView key={i} tile={t} size="meld" />
160 ))}
161 </div>
162 )}
163 </div>
164
165 <div className="actionbar">
166 {claiming && (
167 <>
168 {opts!.map((o, i) => (
169 <button
170 key={i}
171 className={`btn claim ${o.type}`}
172 onClick={() => game.respond(seat, { type: o.type, with: o.with })}
173 >
174 <Bi
175 zh={CLAIM_LABEL[o.type][0]}
176 en={CLAIM_LABEL[o.type][1]}
177 sub={o.with && o.with.map(labelOf).join(' ')}
178 />
179 </button>
180 ))}
181 {/* The seat that draws next declines by picking up — see below. */}
182 {!canProceed && (
183 <button className="btn pass" onClick={() => game.respond(seat, 'pass')}>
184 <Bi zh="過" en="Pass" />
185 </button>
186 )}
187 </>
188 )}
189 {canProceed && (
190 <button
191 className={`btn proceed ${claiming ? 'pass' : ''}`}
192 onClick={() => game.resolveNow()}
193 >
194 <Bi
195 zh={s.phase === 'robkong' ? '繼續' : claiming ? '過.摸牌' : '摸牌'}
196 en={s.phase === 'robkong' ? 'Continue' : claiming ? 'Pass & draw' : 'Draw'}
197 />
198 </button>
199 )}
200 {!claiming && !canProceed && responded && s.phase !== 'turn' && (
201 <div className="waiting">已回應 Responded</div>
202 )}
203 {myTurn && (
204 <>
205 {canHu && (
206 <button className="btn claim hu" onClick={() => game.declareSelfDraw(seat)}>
207 <Bi zh="自摸" en="Self-draw" />
208 </button>
209 )}
210 {concealedKongs.map((t) => (
211 <button key={`ak${t}`} className="btn claim kong" onClick={() => game.declareConcealedKong(seat, t)}>
212 <Bi zh="暗槓" en="Concealed kong" sub={labelOf(t)} />
213 </button>
214 ))}
215 {addedKongs.map((t) => (
216 <button key={`gk${t}`} className="btn claim kong" onClick={() => game.declareAddedKong(seat, t)}>
217 <Bi zh="加槓" en="Added kong" sub={labelOf(t)} />
218 </button>
219 ))}
220 {selTile !== null && sel !== null && (
221 <button className="btn discard" onClick={() => doDiscard(sel)}>
222 <Bi zh="打出" en="Discard" sub={labelOf(selTile)} />
223 </button>
224 )}
225 </>
226 )}
227 {delta !== 0 && <div className={`delta ${delta > 0 ? 'up' : 'down'}`}>{delta > 0 ? `+${delta}` : delta}</div>}
228 </div>
229
230 <Hand
231 tiles={p.hand}
232 faceDown={concealed}
233 drawnIndex={drawnIndex}
234 selected={sel}
235 canDiscard={myTurn}
236 onSelect={setSel}
237 onDiscard={doDiscard}
238 onMove={(from, to) => game.reorderHand(seat, from, to)}
239 carry={carry}
240 />
241
242 <div className="nameplate">
243 <span className={`wind ${isDealer ? 'dealer' : ''}`}>
244 {WIND_NAMES[seatWind]}
245 <em>{WIND_EN[seatWind]}</em>
246 </span>
247 <span className="pname">{p.name}</span>
248 {isDealer && (
249 <span className="tag">
250 莊 Dealer{s.dealerStreak > 0 ? ` 連${s.dealerStreak}` : ''}
251 </span>
252 )}
253 {tenpai.length > 0 && (
254 <span className="tag ting">聽 Ready {tenpai.map(labelOf).join('·')}</span>
255 )}
256 {/* Without this the 胡 button just quietly fails to appear. */}
257 {sacred.length > 0 && (
258 <span className="tag sacred" title="過水 — passed on winning with these until your next draw">
259 過水 {sacred.map(labelOf).join('·')}
260 </span>
261 )}
262 <span className="chips">{p.score}</span>
263 {p.hand.length > 0 && s.phase !== 'handEnd' && !bot && (
264 <button className="btn ghost sort" onClick={() => game.sortHand(seat)}>
265 理牌 Sort
266 </button>
267 )}
268 <button className="helpbtn" onClick={onHelp} aria-label="規則 rules">
269 ?
270 </button>
271 </div>
272 </div>
273 </div>
274 );
275}