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