anvilsign in

collin/mahjong

1import { useEffect, useRef, useState } from 'react';
2import type { TableCtl } from '../game/ctl';
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 { SeatQr } from './SeatQr';
9import { SEAT_ROT, SEAT_SLOT } from './rotation';
10import { TileView } from './TileView';
11
12/**
13 * A seat that exists somewhere else — a phone at the party table, or an online
14 * player. What of it shows on this screen is decided here.
15 */
16export interface RemoteSeat {
17 /** Party screen: the link that claims this seat, drawn as a QR while free. */
18 qrUrl?: string;
19 /** Who holds it from their own device, or null while nobody does. */
20 holder?: string | null;
21 /** Online host: the seat's player is gone; offer it to the computer. */
22 onHandToBot?: () => void;
23}
24
25interface Props {
26 game: TableCtl;
27 seat: SeatId;
28 /**
29 * Which edge of this screen the seat is drawn at. At a shared table that is
30 * the seat itself; online the table is turned so your own seat is at the
31 * bottom, wherever you are actually sitting.
32 */
33 pos?: SeatId;
34 /**
35 * Whether this device may act for the seat. The hotseat table says yes to
36 * everyone, the online table only to your own chair — everything private
37 * (the tiles' faces, the 聽 and 過水 badges, the buttons) keys off this.
38 */
39 local?: boolean;
40 remote?: RemoteSeat;
41 /** Phone-sized table: nothing is rotated and only the bottom gets a full strip. */
42 compact?: boolean;
43 /** The tiles in the middle. Absent on a phone, which has no middle. */
44 pool?: TablePool;
45 onHelp: () => void;
46}
47
48export function Seat({ game, seat, pos = seat, local = true, remote, compact, pool, onHelp }: Props) {
49 const s = game.state;
50 const p = s.players[seat];
51 const [sel, setSel] = useState<number | null>(null);
52 // Held down to re-show a claimed seat's QR — for the player who lost theirs.
53 const [peek, setPeek] = useState(false);
54 const bot = s.bots[seat];
55 // Somebody this device answers for: a person here, not the computer, not a
56 // hand that lives on another screen.
57 const acts = local && !bot;
58 // A hand this device may not read goes face down and its 聽 / 過水 badges
59 // stay off, since both would give it away. At the end of a hand everyone
60 // turns their tiles over, as they would.
61 const concealed = !acts && s.phase !== 'handEnd' && s.phase !== 'gameEnd';
62 const myTurn = s.phase === 'turn' && s.turn === seat && acts;
63 const opts = acts ? s.options[seat] : undefined;
64 const responded = s.responses[seat] !== undefined;
65 const claiming = (s.phase === 'claims' || s.phase === 'robkong') && !!opts && !responded;
66 // Whoever draws next closes the claim window — nobody has to wait on a
67 // player who hasn't noticed their buttons.
68 const canProceed = game.nextDrawer() === seat && acts;
69
70 useEffect(() => {
71 setSel(null);
72 }, [myTurn, s.handNumber, p.hand.length, s.drawnTile]);
73
74 const seatWind = (seat - s.dealer + 4) % 4;
75 const isDealer = seat === s.dealer;
76 const live = s.phase !== 'handEnd' && s.phase !== 'lobby';
77 const tenpai = live && acts ? game.tenpai(seat) : [];
78 const sacred = live && acts ? game.sacredTiles(seat) : [];
79 const concealedKongs = myTurn ? game.concealedKongs(seat) : [];
80 const addedKongs = myTurn ? game.addedKongs(seat) : [];
81 const canHu = myTurn && game.canSelfDrawWin(seat);
82 const delta = s.phase === 'handEnd' ? s.result?.deltas[seat] ?? 0 : 0;
83 // 一炮多響 can put more than one seat here, so this is a `some`, not a find.
84 const won = s.phase === 'handEnd' && !!s.result?.winners.some((w) => w.seat === seat);
85 // A deserted online seat, waiting for its person to come back — or for the
86 // host to hand it to the computer so the table can move.
87 const deserted = !!remote?.onHandToBot && !bot && remote.holder == null && live;
88
89 // Compact lays the discards out as one line that scrolls rather than a block
90 // that wraps, so the newest tile — the only one anybody is looking for — has
91 // to be kept in view. Harmless at full size, where nothing overflows.
92 const discardsRef = useRef<HTMLDivElement>(null);
93 useEffect(() => {
94 const el = discardsRef.current;
95 if (!el) return;
96 el.scrollLeft = el.scrollWidth;
97 el.scrollTop = el.scrollHeight;
98 }, [p.discards.length]);
99
100 const doDiscard = (i: number) => {
101 game.discard(seat, p.hand[i]);
102 setSel(null);
103 };
104
105 // The tile just drawn is held apart at the end of the hand until it is played
106 // or the player slides it in, so it stays obvious which tile just arrived.
107 const drawnIndex = myTurn && s.drawnSeparate && s.drawnTile !== null ? p.hand.length - 1 : null;
108 const selTile = sel === null ? null : p.hand[sel] ?? null;
109
110 // Picking a tile up and throwing it in. Only on your own turn, and only where
111 // there is a middle of the table to throw into — the phone layout has none, so
112 // there the row goes back to only ever being rearranged.
113 const carry =
114 pool && myTurn
115 ? {
116 onPickUp: (i: number, x: number, y: number, size: number) => {
117 pool.pickUp({
118 seat,
119 tile: p.hand[i],
120 index: i,
121 x,
122 y,
123 // Facing the way it did in the hand, so it does not snap round the
124 // instant it is lifted.
125 angle: (SEAT_ROT[pos] * Math.PI) / 180,
126 w: size,
127 h: size * 1.375,
128 });
129 pool.wake();
130 },
131 onMoveTo: (x: number, y: number) => {
132 pool.moveHeld(x, y);
133 pool.wake();
134 },
135 onThrow: (i: number, t: { x: number; y: number; vx: number; vy: number; spin: number }) => {
136 const tile = p.hand[i];
137 if (tile === undefined) return pool.cancelHeld();
138 // Where it left the fingers is where the throw starts from.
139 pool.moveHeld(t.x, t.y);
140 pool.throwHeld(t.vx, t.vy, t.spin);
141 game.discard(seat, tile);
142 setSel(null);
143 },
144 onCancel: () => {
145 pool.cancelHeld();
146 pool.wake();
147 },
148 }
149 : undefined;
150
151 return (
152 // `data-seat` is how table/geometry.ts finds this seat's hand to work out
153 // where it throws from — see measure(). It carries the seat itself, not the
154 // edge it is drawn at: the physics is about who threw the tile.
155 <div className={`slot ${SEAT_SLOT[pos]} ${pos === 0 ? 'mine' : 'opp'}`} data-seat={seat}>
156 <div
157 className={`seat ${myTurn ? 'active' : ''} ${claiming ? 'claiming' : ''}`}
158 style={compact ? undefined : { transform: `translate(-50%, -50%) rotate(${SEAT_ROT[pos]}deg)` }}
159 >
160 {/* Points down the strip at whoever won. It is inside the seat, so it
161 turns with it and lands on their edge of the table wherever they
162 are sitting — no working out which way is which. */}
163 {won && (
164 <div className="win-arrow" aria-hidden>
165 ▼
166 </div>
167 )}
168
169 {/* Party screen: a free seat wears its QR; scanning it takes the hand
170 to a phone. A claimed seat hides it, but its player can ask for it
171 back — the 手機 tag below holds it down to reveal. */}
172 {remote?.qrUrl && (remote.holder == null || peek) && (
173 <div className="qr-dock">
174 <SeatQr url={remote.qrUrl} />
175 <span className="qr-hint">
176 掃碼用手機拿牌
177 <em>scan to play from your phone</em>
178 </span>
179 </div>
180 )}
181
182 {/* Only on a phone. At full size a discard is thrown into the middle
183 and stays there for the hand, so the row would be the same tiles
184 listed twice. */}
185 {compact && (
186 <div className="discards" ref={discardsRef}>
187 {p.discards.map((t, i) => (
188 <TileView
189 key={i}
190 tile={t}
191 size="small"
192 highlight={s.lastDiscard?.from === seat && i === p.discards.length - 1}
193 />
194 ))}
195 </div>
196 )}
197
198 <div className="melds">
199 {p.melds.map((m, i) => (
200 <div className="meld" key={i}>
201 {m.tiles.map((t, j) => (
202 <TileView key={j} tile={t} size="meld" faceDown={m.kind === 'ankong' && j > 0 && j < 3} />
203 ))}
204 </div>
205 ))}
206 {p.flowers.length > 0 && (
207 <div className="meld flowers">
208 {p.flowers.map((t, i) => (
209 <TileView key={i} tile={t} size="meld" />
210 ))}
211 </div>
212 )}
213 </div>
214
215 <div className="actionbar">
216 {claiming && (
217 <>
218 {opts!.map((o, i) => (
219 <button
220 key={i}
221 className={`btn claim ${o.type}`}
222 onClick={() => game.respond(seat, { type: o.type, with: o.with })}
223 >
224 <Bi
225 zh={CLAIM_LABEL[o.type][0]}
226 en={CLAIM_LABEL[o.type][1]}
227 sub={o.with && o.with.map(labelOf).join(' ')}
228 />
229 </button>
230 ))}
231 {/* The seat that draws next declines by picking up — see below. */}
232 {!canProceed && (
233 <button className="btn pass" onClick={() => game.respond(seat, 'pass')}>
234 <Bi zh="過" en="Pass" />
235 </button>
236 )}
237 </>
238 )}
239 {canProceed && (
240 <button
241 className={`btn proceed ${claiming ? 'pass' : ''}`}
242 onClick={() => game.resolveNow()}
243 >
244 <Bi
245 zh={s.phase === 'robkong' ? '繼續' : claiming ? '過.摸牌' : '摸牌'}
246 en={s.phase === 'robkong' ? 'Continue' : claiming ? 'Pass & draw' : 'Draw'}
247 />
248 </button>
249 )}
250 {!claiming && !canProceed && responded && s.phase !== 'turn' && (
251 <div className="waiting">已回應 Responded</div>
252 )}
253 {deserted && (
254 <button className="btn ghost" onClick={remote!.onHandToBot}>
255 <Bi zh="電腦代打" en="Hand to computer" />
256 </button>
257 )}
258 {myTurn && (
259 <>
260 {canHu && (
261 <button className="btn claim hu" onClick={() => game.declareSelfDraw(seat)}>
262 <Bi zh="自摸" en="Self-draw" />
263 </button>
264 )}
265 {concealedKongs.map((t) => (
266 <button key={`ak${t}`} className="btn claim kong" onClick={() => game.declareConcealedKong(seat, t)}>
267 <Bi zh="暗槓" en="Concealed kong" sub={labelOf(t)} />
268 </button>
269 ))}
270 {addedKongs.map((t) => (
271 <button key={`gk${t}`} className="btn claim kong" onClick={() => game.declareAddedKong(seat, t)}>
272 <Bi zh="加槓" en="Added kong" sub={labelOf(t)} />
273 </button>
274 ))}
275 {selTile !== null && sel !== null && (
276 <button className="btn discard" onClick={() => doDiscard(sel)}>
277 <Bi zh="打出" en="Discard" sub={labelOf(selTile)} />
278 </button>
279 )}
280 </>
281 )}
282 {delta !== 0 && <div className={`delta ${delta > 0 ? 'up' : 'down'}`}>{delta > 0 ? `+${delta}` : delta}</div>}
283 </div>
284
285 <Hand
286 tiles={p.hand}
287 faceDown={concealed}
288 drawnIndex={drawnIndex}
289 selected={sel}
290 canDiscard={myTurn}
291 onSelect={setSel}
292 onDiscard={doDiscard}
293 onMove={(from, to) => game.reorderHand(seat, from, to)}
294 carry={carry}
295 />
296
297 <div className="nameplate">
298 <span className={`wind ${isDealer ? 'dealer' : ''}`}>
299 {WIND_NAMES[seatWind]}
300 <em>{WIND_EN[seatWind]}</em>
301 </span>
302 <span className="pname">{p.name}</span>
303 {remote?.holder != null && (
304 <span
305 className="tag phone"
306 title="這手牌在手機上 · played from a phone. 按住看 QR · hold to show the QR again"
307 onPointerDown={() => setPeek(true)}
308 onPointerUp={() => setPeek(false)}
309 onPointerCancel={() => setPeek(false)}
310 onPointerLeave={() => setPeek(false)}
311 >
312 📱 {remote.holder}
313 </span>
314 )}
315 {deserted && <span className="tag sacred">已離線 offline</span>}
316 {isDealer && (
317 <span className="tag">
318 莊 Dealer{s.dealerStreak > 0 ? ` 連${s.dealerStreak}` : ''}
319 </span>
320 )}
321 {tenpai.length > 0 && (
322 <span className="tag ting">聽 Ready {tenpai.map(labelOf).join('·')}</span>
323 )}
324 {/* Without this the 胡 button just quietly fails to appear. */}
325 {sacred.length > 0 && (
326 <span className="tag sacred" title="過水 — passed on winning with these until your next draw">
327 過水 {sacred.map(labelOf).join('·')}
328 </span>
329 )}
330 <span className="chips">{p.score}</span>
331 {p.hand.length > 0 && s.phase !== 'handEnd' && acts && (
332 <button className="btn ghost sort" onClick={() => game.sortHand(seat)}>
333 理牌 Sort
334 </button>
335 )}
336 <button className="helpbtn" onClick={onHelp} aria-label="規則 rules">
337 ?
338 </button>
339 </div>
340 </div>
341 </div>
342 );
343}