anvilsign in

collin/mahjong

1import { useEffect, useRef, useState } from 'react';
2import type { TableCtl } from '../game/ctl';
3import { labelOf, WIND_EN, WIND_NAMES, type Tile } from '../game/tiles';
4import type { SeatId } from '../game/types';
5import { MAX_THROW_SPEED, type TablePool } from '../table/pool';
6import { Bi, BiEm, CLAIM_LABEL } from './Bi';
7import { showingColliders } from './colliders';
8import { useT } from './lang';
9import { Hand, type Carry } from './Hand';
10import { SEAT_ROT, SEAT_SLOT } from './rotation';
11import { TileView } from './TileView';
12
13/**
14 * A seat that exists somewhere else — a phone at the party table, or an online
15 * player. What of it shows on this screen is decided here.
16 */
17export interface RemoteSeat {
18 /** Who holds it from their own device, or null while nobody does. */
19 holder?: string | null;
20 /** Party screen: holding the 📱 tag re-shows the table's shared join QR,
21 * for the player who lost theirs. The QR itself lives in the table's
22 * corner — see App — because the strip clips its own contents. */
23 onPeek?: (show: boolean) => void;
24 /** Online host: the seat's player is gone; offer it to the computer. */
25 onHandToBot?: () => void;
26}
27
28interface Props {
29 game: TableCtl;
30 seat: SeatId;
31 /**
32 * Which edge of this screen the seat is drawn at. At a shared table that is
33 * the seat itself; online the table is turned so your own seat is at the
34 * bottom, wherever you are actually sitting.
35 */
36 pos?: SeatId;
37 /**
38 * Whether this device may act for the seat. The hotseat table says yes to
39 * everyone, the online table only to your own chair — everything private
40 * (the tiles' faces, the 聽 and 過水 badges, the buttons) keys off this.
41 */
42 local?: boolean;
43 remote?: RemoteSeat;
44 /** Phone-sized table: nothing is rotated and only the bottom gets a full strip. */
45 compact?: boolean;
46 /** The tiles in the middle. Absent on a phone, which has no middle. */
47 pool?: TablePool;
48 onHelp: () => void;
49}
50
51export function Seat({ game, seat, pos = seat, local = true, remote, compact, pool, onHelp }: Props) {
52 const t = useT();
53 const s = game.state;
54 const p = s.players[seat];
55 const [sel, setSel] = useState<number | null>(null);
56 const bot = s.bots[seat];
57 // Somebody this device answers for: a person here, not the computer, not a
58 // hand that lives on another screen.
59 const acts = local && !bot;
60 // A hand this device may not read goes face down and its 聽 / 過水 badges
61 // stay off, since both would give it away. At the end of a hand everyone
62 // turns their tiles over, as they would.
63 const concealed = !acts && s.phase !== 'handEnd' && s.phase !== 'gameEnd';
64 const myTurn = s.phase === 'turn' && s.turn === seat && acts;
65 const opts = acts ? s.options[seat] : undefined;
66 const responded = s.responses[seat] !== undefined;
67 const claiming = (s.phase === 'claims' || s.phase === 'robkong') && !!opts && !responded;
68 // Whoever draws next closes the claim window — nobody has to wait on a
69 // player who hasn't noticed their buttons.
70 const canProceed = game.nextDrawer() === seat && acts;
71
72 useEffect(() => {
73 setSel(null);
74 }, [myTurn, s.handNumber, p.hand.length, s.drawnTile]);
75
76 const seatWind = (seat - s.dealer + 4) % 4;
77 const isDealer = seat === s.dealer;
78 const live = s.phase !== 'handEnd' && s.phase !== 'lobby';
79 const tenpai = live && acts ? game.tenpai(seat) : [];
80 const sacred = live && acts ? game.sacredTiles(seat) : [];
81 const concealedKongs = myTurn ? game.concealedKongs(seat) : [];
82 const addedKongs = myTurn ? game.addedKongs(seat) : [];
83 const canHu = myTurn && game.canSelfDrawWin(seat);
84 const delta = s.phase === 'handEnd' ? s.result?.deltas[seat] ?? 0 : 0;
85 // 一炮多響 can put more than one seat here, so this is a `some`, not a find.
86 const won = s.phase === 'handEnd' && !!s.result?.winners.some((w) => w.seat === seat);
87 // A deserted online seat, waiting for its person to come back — or for the
88 // host to hand it to the computer so the table can move.
89 const deserted = !!remote?.onHandToBot && !bot && remote.holder == null && live;
90
91 // Compact lays the discards out as one line that scrolls rather than a block
92 // that wraps, so the newest tile — the only one anybody is looking for — has
93 // to be kept in view. Harmless at full size, where nothing overflows.
94 const discardsRef = useRef<HTMLDivElement>(null);
95 useEffect(() => {
96 const el = discardsRef.current;
97 if (!el) return;
98 el.scrollLeft = el.scrollWidth;
99 el.scrollTop = el.scrollHeight;
100 }, [p.discards.length]);
101
102 const doDiscard = (i: number) => {
103 // Tapping plays the tile for real, which would end the sandbox's turn and
104 // set the computer going. Throwing is the thing being tested; tapping just
105 // puts the tile down again.
106 if (showingColliders()) return setSel(null);
107 game.discard(seat, p.hand[i]);
108 setSel(null);
109 };
110
111 // The tile just drawn is held apart at the end of the hand until it is played
112 // or the player slides it in, so it stays obvious which tile just arrived.
113 const drawnIndex = myTurn && s.drawnSeparate && s.drawnTile !== null ? p.hand.length - 1 : null;
114 const selTile = sel === null ? null : p.hand[sel] ?? null;
115
116 // Picking a tile up and throwing it in. Only on your own turn, and only where
117 // there is a middle of the table to throw into — the phone layout has none, so
118 // there the row goes back to only ever being rearranged.
119 const carry =
120 pool && myTurn
121 ? {
122 onPickUp: (i: number, x: number, y: number, size: number) => {
123 pool.pickUp({
124 seat,
125 tile: p.hand[i],
126 index: i,
127 x,
128 y,
129 // Facing the way it did in the hand, so it does not snap round the
130 // instant it is lifted.
131 angle: (SEAT_ROT[pos] * Math.PI) / 180,
132 w: size,
133 h: size * 1.375,
134 });
135 pool.wake();
136 },
137 onMoveTo: (x: number, y: number) => {
138 pool.moveHeld(x, y);
139 pool.wake();
140 },
141 onThrow: (i: number, t: { x: number; y: number; vx: number; vy: number; spin: number }) => {
142 const tile = p.hand[i];
143 if (tile === undefined) return pool.cancelHeld();
144 // Where it left the fingers is where the throw starts from.
145 pool.moveHeld(t.x, t.y);
146 // With the colliders drawn the table is a sandbox: the throw is the
147 // real throw, but it is not a discard. The tile stays in the hand
148 // and the game does not move, so the same flick can be made again
149 // and again against the boxes on screen.
150 if (showingColliders()) {
151 pool.throwLoose(t.vx, t.vy, t.spin);
152 setSel(null);
153 return;
154 }
155 pool.throwHeld(t.vx, t.vy, t.spin);
156 game.discard(seat, tile);
157 setSel(null);
158 },
159 onCancel: () => {
160 pool.cancelHeld();
161 pool.wake();
162 },
163 }
164 : undefined;
165
166 useKeyboardHand({ myTurn, hand: p.hand, sel, setSel, drawnIndex, carry, pool, seat, doDiscard });
167
168 return (
169 // `data-seat` is how table/geometry.ts finds this seat's hand to work out
170 // where it throws from — see measure(). It carries the seat itself, not the
171 // edge it is drawn at: the physics is about who threw the tile.
172 <div className={`slot ${SEAT_SLOT[pos]} ${pos === 0 ? 'mine' : 'opp'}`} data-seat={seat}>
173 <div
174 className={`seat ${myTurn ? 'active' : ''} ${claiming ? 'claiming' : ''}`}
175 style={compact ? undefined : { transform: `translate(-50%, -50%) rotate(${SEAT_ROT[pos]}deg)` }}
176 >
177 {/* Points down the strip at whoever won. It is inside the seat, so it
178 turns with it and lands on their edge of the table wherever they
179 are sitting — no working out which way is which. */}
180 {won && (
181 <div className="win-arrow" aria-hidden>
182 ▼
183 </div>
184 )}
185
186 {/* Only on a phone. At full size a discard is thrown into the middle
187 and stays there for the hand, so the row would be the same tiles
188 listed twice. */}
189 {compact && (
190 <div className="discards" ref={discardsRef}>
191 {p.discards.map((t, i) => (
192 <TileView
193 key={i}
194 tile={t}
195 size="small"
196 highlight={s.lastDiscard?.from === seat && i === p.discards.length - 1}
197 />
198 ))}
199 </div>
200 )}
201
202 <div className="melds">
203 {p.melds.map((m, i) => (
204 <div className="meld" key={i}>
205 {m.tiles.map((t, j) => (
206 <TileView key={j} tile={t} size="meld" faceDown={m.kind === 'ankong' && j > 0 && j < 3} />
207 ))}
208 </div>
209 ))}
210 {p.flowers.length > 0 && (
211 <div className="meld flowers">
212 {p.flowers.map((t, i) => (
213 <TileView key={i} tile={t} size="meld" />
214 ))}
215 </div>
216 )}
217 </div>
218
219 {/* The tiles and the buttons that act on them, on one row: 打出 sits at
220 the right-hand end of your own hand, where the tile you are about to
221 throw already is. It used to be a row of its own above the hand, and
222 a row that is only there sometimes is a row that shoves the sets and
223 flowers up the strip every time you lift a tile. On a phone there is
224 no room beside a hand, so the wrapper steps out of the way and the
225 bar goes back to being its own row — `display: contents`. */}
226 <div className="hand-row">
227 <Hand
228 tiles={p.hand}
229 faceDown={concealed}
230 drawnIndex={drawnIndex}
231 selected={sel}
232 canDiscard={myTurn}
233 onSelect={setSel}
234 onDiscard={doDiscard}
235 onMove={(from, to) => game.reorderHand(seat, from, to)}
236 carry={carry}
237 />
238
239 <div className="actionbar">
240 {claiming && (
241 <>
242 {opts!.map((o, i) => (
243 <button
244 key={i}
245 className={`btn claim ${o.type}`}
246 onClick={() => game.respond(seat, { type: o.type, with: o.with })}
247 >
248 <Bi
249 zh={CLAIM_LABEL[o.type][0]}
250 en={CLAIM_LABEL[o.type][1]}
251 sub={o.with && o.with.map(labelOf).join(' ')}
252 />
253 </button>
254 ))}
255 {/* The seat that draws next declines by picking up — see below. */}
256 {!canProceed && (
257 <button className="btn pass" onClick={() => game.respond(seat, 'pass')}>
258 <Bi zh="過" en="Pass" />
259 </button>
260 )}
261 </>
262 )}
263 {canProceed && (
264 <button
265 className={`btn proceed ${claiming ? 'pass' : ''}`}
266 onClick={() => game.resolveNow()}
267 >
268 <Bi
269 zh={s.phase === 'robkong' ? '繼續' : claiming ? '過.摸牌' : '摸牌'}
270 en={s.phase === 'robkong' ? 'Continue' : claiming ? 'Pass & draw' : 'Draw'}
271 />
272 </button>
273 )}
274 {!claiming && !canProceed && responded && s.phase !== 'turn' && (
275 <div className="waiting">{t('已回應', 'Responded')}</div>
276 )}
277 {deserted && (
278 <button className="btn ghost" onClick={remote!.onHandToBot}>
279 <Bi zh="電腦代打" en="Hand to computer" />
280 </button>
281 )}
282 {myTurn && (
283 <>
284 {canHu && (
285 <button className="btn claim hu" onClick={() => game.declareSelfDraw(seat)}>
286 <Bi zh="自摸" en="Self-draw" />
287 </button>
288 )}
289 {concealedKongs.map((t) => (
290 <button key={`ak${t}`} className="btn claim kong" onClick={() => game.declareConcealedKong(seat, t)}>
291 <Bi zh="暗槓" en="Concealed kong" sub={labelOf(t)} />
292 </button>
293 ))}
294 {addedKongs.map((t) => (
295 <button key={`gk${t}`} className="btn claim kong" onClick={() => game.declareAddedKong(seat, t)}>
296 <Bi zh="加槓" en="Added kong" sub={labelOf(t)} />
297 </button>
298 ))}
299 {selTile !== null && sel !== null && (
300 <button className="btn discard" onClick={() => doDiscard(sel)}>
301 <Bi zh="打出" en="Discard" sub={labelOf(selTile)} />
302 </button>
303 )}
304 </>
305 )}
306 {delta !== 0 && <div className={`delta ${delta > 0 ? 'up' : 'down'}`}>{delta > 0 ? `+${delta}` : delta}</div>}
307 </div>
308 </div>
309
310 <div className="nameplate">
311 <span className={`wind ${isDealer ? 'dealer' : ''}`}>
312 <BiEm zh={WIND_NAMES[seatWind]} en={WIND_EN[seatWind]} />
313 </span>
314 <span className="pname">{p.name}</span>
315 {remote?.holder != null && (
316 <span
317 className="tag phone"
318 title={`${t('這手牌在手機上', 'played from a phone', ' · ')}. ${t(
319 '按住看 QR',
320 'hold to show the QR again',
321 ' · ',
322 )}`}
323 onPointerDown={() => remote.onPeek?.(true)}
324 onPointerUp={() => remote.onPeek?.(false)}
325 onPointerCancel={() => remote.onPeek?.(false)}
326 onPointerLeave={() => remote.onPeek?.(false)}
327 >
328 📱 {remote.holder}
329 </span>
330 )}
331 {deserted && <span className="tag sacred">{t('已離線', 'offline')}</span>}
332 {isDealer && (
333 <span className="tag">
334 {t('莊', 'Dealer')}
335 {s.dealerStreak > 0 ? ` ${t(`連${s.dealerStreak}`, `×${s.dealerStreak + 1}`)}` : ''}
336 </span>
337 )}
338 {tenpai.length > 0 && (
339 <span className="tag ting">
340 {t('聽', 'Ready')} {tenpai.map(labelOf).join('·')}
341 </span>
342 )}
343 {/* Without this the 胡 button just quietly fails to appear. */}
344 {sacred.length > 0 && (
345 <span
346 className="tag sacred"
347 title={t(
348 '過水 — 放棄過這幾張的胡,到自己再摸牌前不能胡',
349 '過水 — passed on winning with these until your next draw',
350 )}
351 >
352 {t('過水', 'Sacred')} {sacred.map(labelOf).join('·')}
353 </span>
354 )}
355 <span className="chips">{p.score}</span>
356 {p.hand.length > 0 && s.phase !== 'handEnd' && acts && (
357 <button className="btn ghost sort" onClick={() => game.sortHand(seat)}>
358 {t('理牌', 'Sort')}
359 </button>
360 )}
361 <button className="helpbtn" onClick={onHelp} aria-label={t('規則', 'rules')}>
362 ?
363 </button>
364 </div>
365 </div>
366 </div>
367 );
368}
369
370/**
371 * A hand played without letting go of the keys.
372 *
373 * ← and → walk the selection along the row, and space throws the tile that is
374 * lifted. How long space is *held* is how hard it goes: the tile draws back out
375 * of the hand while the key is down, the way an arm does, and leaves when it is
376 * let go. A tap of the key is a gentle slide in; a long hold is a tile that
377 * crosses the square and barges what is lying there.
378 *
379 * The same two calls the fingers make — `carry.onPickUp` and `carry.onThrow` in
380 * ui/Seat — so a thrown tile is thrown by the physics either way, and the throw
381 * gate applies: from a seat with a standing wall in front of it there is no
382 * line in, so space places the tile over the wall instead, exactly as a tap
383 * does. Only ever on the seat whose turn it is, so the four of them never
384 * argue about a key.
385 */
386function useKeyboardHand({
387 myTurn,
388 hand,
389 sel,
390 setSel,
391 drawnIndex,
392 carry,
393 pool,
394 seat,
395 doDiscard,
396}: {
397 myTurn: boolean;
398 hand: Tile[];
399 sel: number | null;
400 setSel: (to: number | null) => void;
401 drawnIndex: number | null;
402 carry: Carry | undefined;
403 pool?: TablePool;
404 seat: SeatId;
405 doDiscard: (i: number) => void;
406}) {
407 // What the listeners read. Kept current without them being torn down and put
408 // back on every render to see it.
409 const now = useRef({ myTurn, hand, sel, setSel, drawnIndex, carry, pool, seat, doDiscard });
410 now.current = { myTurn, hand, sel, setSel, drawnIndex, carry, pool, seat, doDiscard };
411 const charge = useRef<Charge | null>(null);
412
413 useEffect(() => {
414 /** Where the lifted tile is, and which way the middle of the table lies. */
415 const aim = (index: number) => {
416 const table = document.querySelector<HTMLElement>('.table');
417 const cell = document.querySelector<HTMLElement>(
418 `[data-seat="${now.current.seat}"] .hand-cell:nth-child(${index + 1}) .tile`,
419 );
420 if (!table || !cell) return null;
421 const base = table.getBoundingClientRect();
422 const box = cell.getBoundingClientRect();
423 const from = {
424 x: box.left + box.width / 2 - base.left,
425 y: box.top + box.height / 2 - base.top,
426 };
427 // At the middle of the square if there is one, and at the middle of the
428 // table if the wall has been pushed somewhere odd — either way it is the
429 // line a tile would be slid along.
430 const ring = table.querySelector<HTMLElement>('.wall-ring')?.getBoundingClientRect();
431 const to = ring
432 ? { x: ring.left + ring.width / 2 - base.left, y: ring.top + ring.height / 2 - base.top }
433 : { x: base.width / 2, y: base.height / 2 };
434 const dx = to.x - from.x;
435 const dy = to.y - from.y;
436 const len = Math.hypot(dx, dy) || 1;
437 return { from, size: Math.min(box.width, box.height), dir: { x: dx / len, y: dy / len } };
438 };
439
440 const drawBack = () => {
441 const c = charge.current;
442 if (!c) return;
443 const p = power(c.at);
444 now.current.carry?.onMoveTo(c.from.x - c.dir.x * pullOf(p), c.from.y - c.dir.y * pullOf(p));
445 c.frame = requestAnimationFrame(drawBack);
446 };
447
448 const letGo = () => {
449 const c = charge.current;
450 charge.current = null;
451 if (!c) return;
452 cancelAnimationFrame(c.frame);
453 return c;
454 };
455
456 const down = (e: KeyboardEvent) => {
457 const { myTurn, hand, sel, setSel, drawnIndex, carry, pool } = now.current;
458 // The colliders sandbox has the keys — see ui/colliders.
459 if (!myTurn || showingColliders() || e.metaKey || e.ctrlKey || e.altKey) return;
460 if (e.target instanceof HTMLElement && e.target.matches('input, textarea, select')) return;
461 const n = hand.length;
462 if (n === 0) return;
463
464 if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
465 e.preventDefault();
466 const step = e.key === 'ArrowRight' ? 1 : -1;
467 // Nothing lifted yet: start at the tile just drawn, which is the one
468 // the decision is actually about.
469 setSel(sel === null ? drawnIndex ?? (step > 0 ? 0 : n - 1) : (sel + step + n) % n);
470 return;
471 }
472 if (e.key === 'Escape') {
473 if (letGo()) carry?.onCancel();
474 setSel(null);
475 return;
476 }
477 if (e.key !== ' ' || e.repeat) return;
478 e.preventDefault();
479 if (sel === null) return setSel(drawnIndex ?? n - 1);
480 if (charge.current || !carry) return;
481
482 const at = aim(sel);
483 if (!at) return;
484 // Wound up in the fingers. Nothing has been played yet — Escape, or a
485 // wall in the way, still puts it back in the row.
486 carry.onPickUp(sel, at.from.x, at.from.y, at.size);
487 charge.current = { index: sel, at: performance.now(), from: at.from, dir: at.dir, frame: 0 };
488 // Whether there is a line in at all is the pool's question, asked from
489 // where the tile actually is — the same gate a flick meets.
490 charge.current.thrown = !pool || pool.canThrowFrom(at.from.x, at.from.y);
491 drawBack();
492 };
493
494 const up = (e: KeyboardEvent) => {
495 if (e.key !== ' ') return;
496 const c = letGo();
497 if (!c) return;
498 const { carry, doDiscard } = now.current;
499 if (!carry) return;
500 if (!c.thrown) {
501 // No line in. It goes over the wall instead of through it, which is
502 // what a tap has always done.
503 carry.onCancel();
504 doDiscard(c.index);
505 return;
506 }
507 const p = power(c.at);
508 const speed = SLIDE_IN + (MAX_THROW_SPEED - SLIDE_IN) * p;
509 const pull = pullOf(p);
510 carry.onThrow(c.index, {
511 x: c.from.x - c.dir.x * pull,
512 y: c.from.y - c.dir.y * pull,
513 vx: c.dir.x * speed,
514 vy: c.dir.y * speed,
515 spin: 0,
516 });
517 };
518
519 window.addEventListener('keydown', down);
520 window.addEventListener('keyup', up);
521 return () => {
522 window.removeEventListener('keydown', down);
523 window.removeEventListener('keyup', up);
524 const c = charge.current;
525 charge.current = null;
526 if (c) cancelAnimationFrame(c.frame);
527 };
528 }, []);
529}
530
531/** A tile wound up but not yet let go of. */
532interface Charge {
533 index: number;
534 /** When the key went down. */
535 at: number;
536 from: { x: number; y: number };
537 dir: { x: number; y: number };
538 frame: number;
539 /** Whether there is a line into the middle from where it was lifted. */
540 thrown?: boolean;
541}
542
543/** How long space has to be held for a throw at full stretch. */
544const FULL_CHARGE = 700;
545/** A tap of the key: enough to slide the tile in and no more. */
546const SLIDE_IN = 900;
547
548const power = (since: number) => Math.min(1, Math.max(0, (performance.now() - since) / FULL_CHARGE));
549/** How far the tile is drawn back out of the row, which is the power gauge. */
550const pullOf = (p: number) => 8 + 52 * p;