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