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