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 { TurnClock, turnId } from './TurnClock';
12import { TileView } from './TileView';
13
14/**
15 * Who is playing this hand, and what the table can do about it.
16 *
17 * There are three answers and they are the same three everywhere: the computer
18 * has it, somebody at this screen has it, or it has gone onto a device of its
19 * own. Any of them turns into any other mid-game — that is what the callbacks
20 * are — and none of them redeals anything. Only the device the table is being
21 * played at gets one of these; a visitor holding one hand has no business
22 * moving the other three about.
23 */
24export interface SeatWho {
25 /** Who holds it from their own device, or null while nobody does. */
26 holder?: string | null;
27 /**
28 * The device holding it has gone and the hand is being kept for a moment in
29 * case that was a pocket rather than a goodbye. The computer takes it when
30 * the moment is up, and gives it straight back to whoever comes for it.
31 */
32 waiting?: boolean;
33 /** Somebody has sat down at this edge: take the hand off the computer. */
34 onSit?: () => void;
35 /** Nobody is going to play it here: give it to the computer. */
36 onBot?: () => void;
37 /** Take it onto a phone — opens the room first if there is not one yet. */
38 onPhone?: () => void;
39 /**
40 * This seat's phone has its sound on, so the calls for this hand are coming
41 * out of it rather than out of this screen. Worth saying on the nameplate,
42 * because it is the difference between the table having gone quiet for a
43 * seat and having gone wrong.
44 */
45 speaks?: boolean;
46}
47
48interface Props {
49 game: TableCtl;
50 seat: SeatId;
51 /**
52 * Which edge of this screen the seat is drawn at. At a shared table that is
53 * the seat itself; online the table is turned so your own seat is at the
54 * bottom, wherever you are actually sitting.
55 */
56 pos?: SeatId;
57 /**
58 * Whether this device may act for the seat. The hotseat table says yes to
59 * everyone, the online table only to your own chair — everything private
60 * (the tiles' faces, the 聽 and 過水 badges, the buttons) keys off this.
61 */
62 local?: boolean;
63 /**
64 * More than one person is playing at this screen, so a hand this device may
65 * act for is still not a hand everybody may read. It starts face down and is
66 * turned over with a tap — the piece of card people used to lay over their
67 * strip, which is what the game asked for before it could do this itself.
68 *
69 * False where the screen belongs to one player — solo, online, a phone —
70 * because covering your own hand from yourself is just a tap in the way.
71 */
72 shared?: boolean;
73 who?: SeatWho;
74 /** Phone-sized table: nothing is rotated and only the bottom gets a full strip. */
75 compact?: boolean;
76 /** The tiles in the middle. Absent on a phone, which has no middle. */
77 pool?: TablePool;
78 onHelp: () => void;
79 /** The gear beside the `?` — this player's own table controls. Absent on a
80 * phone, where they are in the middle bar and there is no middle to crowd. */
81 onMenu?: () => void;
82}
83
84export function Seat({
85 game,
86 seat,
87 pos = seat,
88 local = true,
89 shared,
90 who,
91 compact,
92 pool,
93 onHelp,
94 onMenu,
95}: Props) {
96 const t = useT();
97 const s = game.state;
98 const p = s.players[seat];
99 const [sel, setSel] = useState<number | null>(null);
100 const bot = s.bots[seat];
101 // Somebody this device answers for: a person here, not the computer, not a
102 // hand that lives on another screen.
103 const plays = local && !bot;
104 // Face down with your own hand under it: three other people are looking at
105 // this screen. Lifted by a tap and put back by one, and put back on its own
106 // when the hand is dealt and when this seat has finished its turn — the two
107 // moments the tiles stop being needed and would otherwise sit there in the
108 // open. See the `shared` prop.
109 const [shown, setShown] = useState(false);
110 const covered = !!shared && plays && !shown;
111 // Somebody this device answers for *and* may read: everything private — the
112 // tiles' faces, the 聽 and 過水 badges, the buttons that name a tile — keys
113 // off this rather than off who is sitting there.
114 const acts = plays && !covered;
115 // A hand this device may not read goes face down and its 聽 / 過水 badges
116 // stay off, since both would give it away. At the end of a hand everyone
117 // turns their tiles over, as they would.
118 const concealed = !acts && s.phase !== 'handEnd' && s.phase !== 'gameEnd';
119 const myTurn = s.phase === 'turn' && s.turn === seat && acts;
120 const opts = acts ? s.options[seat] : undefined;
121 const responded = s.responses[seat] !== undefined;
122 const claiming = (s.phase === 'claims' || s.phase === 'robkong') && !!opts && !responded;
123 // Whoever draws next closes the claim window — nobody has to wait on a
124 // player who hasn't noticed their buttons.
125 const canProceed = game.nextDrawer() === seat && acts;
126
127 useEffect(() => {
128 setSel(null);
129 }, [myTurn, s.handNumber, p.hand.length, s.drawnTile]);
130
131 // The card goes back over the hand when it is dealt, and again when this
132 // seat's turn ends. Both are moments the tiles stop being needed, and
133 // without them a hand turned over once stays turned over for the rest of the
134 // evening — which is the same as never having covered it. Everything else is
135 // the player's own tap.
136 const dealt = s.phase === 'lobby' ? -1 : s.handNumber;
137 useEffect(() => {
138 setShown(false);
139 }, [dealt]);
140 const wasPlaying = useRef(false);
141 const playing = s.phase === 'turn' && s.turn === seat;
142 /**
143 * The table is waiting on this seat: its go, or its pick-up that closes a
144 * claim window. Both are public — whose turn it is has never been a secret —
145 * so a covered hand may light its 看牌 without saying anything about what is
146 * under the card. Without it a table of four face-down hands and no buttons
147 * anywhere reads as stuck rather than as waiting for somebody to look.
148 */
149 const awaited = plays && (playing || game.nextDrawer() === seat);
150 /**
151 * Whose go it is, said out loud on a screen several people are watching —
152 * the strip lights and the clock runs. Public, so a hand still face down
153 * under its card lights the same as one being read: whose turn it is has
154 * never been a secret, and the seat that has not noticed is exactly the one
155 * this is for.
156 *
157 * Only where the screen is shared. On a solo table there is nobody to tell,
158 * and online everybody is looking at their own device, where their own turn
159 * is the one thing the screen is already about.
160 */
161 const onTheClock = !!shared && playing && !bot;
162 useEffect(() => {
163 if (wasPlaying.current && !playing) setShown(false);
164 wasPlaying.current = playing;
165 }, [playing]);
166
167 const seatWind = (seat - s.dealer + 4) % 4;
168 const isDealer = seat === s.dealer;
169 const live = s.phase !== 'handEnd' && s.phase !== 'lobby';
170 const tenpai = live && acts ? game.tenpai(seat) : [];
171 const sacred = live && acts ? game.sacredTiles(seat) : [];
172 const concealedKongs = myTurn ? game.concealedKongs(seat) : [];
173 const addedKongs = myTurn ? game.addedKongs(seat) : [];
174 const canHu = myTurn && game.canSelfDrawWin(seat);
175 const delta = s.phase === 'handEnd' ? s.result?.deltas[seat] ?? 0 : 0;
176 // 一炮多響 can put more than one seat here, so this is a `some`, not a find.
177 const won = s.phase === 'handEnd' && !!s.result?.winners.some((w) => w.seat === seat);
178 // The computer is playing this hand and nobody has taken it off it. One tap
179 // on the strip does — which is the whole of sitting down at this table.
180 const sittable = !!who?.onSit && bot && live;
181 // Its device has gone and the hand is being kept for it. Whoever is at the
182 // table can stop waiting and hand it over now.
183 const deserted = !!who?.waiting && !bot && live;
184
185 // Compact lays the discards out as one line that scrolls rather than a block
186 // that wraps, so the newest tile — the only one anybody is looking for — has
187 // to be kept in view. Harmless at full size, where nothing overflows.
188 const discardsRef = useRef<HTMLDivElement>(null);
189 useEffect(() => {
190 const el = discardsRef.current;
191 if (!el) return;
192 el.scrollLeft = el.scrollWidth;
193 el.scrollTop = el.scrollHeight;
194 }, [p.discards.length]);
195
196 const doDiscard = (i: number) => {
197 // Tapping plays the tile for real, which would end the sandbox's turn and
198 // set the computer going. Throwing is the thing being tested; tapping just
199 // puts the tile down again.
200 if (showingColliders()) return setSel(null);
201 game.discard(seat, p.hand[i]);
202 setSel(null);
203 };
204
205 // The tile just drawn is held apart at the end of the hand until it is played
206 // or the player slides it in, so it stays obvious which tile just arrived.
207 const drawnIndex = myTurn && s.drawnSeparate && s.drawnTile !== null ? p.hand.length - 1 : null;
208 const selTile = sel === null ? null : p.hand[sel] ?? null;
209
210 // Picking a tile up and throwing it in. Only on your own turn, and only where
211 // there is a middle of the table to throw into — the phone layout has none, so
212 // there the row goes back to only ever being rearranged.
213 const carry =
214 pool && myTurn
215 ? {
216 onPickUp: (i: number, x: number, y: number, size: number) => {
217 pool.pickUp({
218 seat,
219 tile: p.hand[i],
220 index: i,
221 x,
222 y,
223 // Facing the way it did in the hand, so it does not snap round the
224 // instant it is lifted.
225 angle: (SEAT_ROT[pos] * Math.PI) / 180,
226 w: size,
227 h: size * 1.375,
228 });
229 pool.wake();
230 },
231 onMoveTo: (x: number, y: number) => {
232 pool.moveHeld(x, y);
233 pool.wake();
234 },
235 onThrow: (i: number, t: { x: number; y: number; vx: number; vy: number; spin: number }) => {
236 const tile = p.hand[i];
237 if (tile === undefined) return pool.cancelHeld();
238 // Where it left the fingers is where the throw starts from.
239 pool.moveHeld(t.x, t.y);
240 // With the colliders drawn the table is a sandbox: the throw is the
241 // real throw, but it is not a discard. The tile stays in the hand
242 // and the game does not move, so the same flick can be made again
243 // and again against the boxes on screen.
244 if (showingColliders()) {
245 pool.throwLoose(t.vx, t.vy, t.spin);
246 setSel(null);
247 return;
248 }
249 pool.throwHeld(t.vx, t.vy, t.spin);
250 game.discard(seat, tile);
251 setSel(null);
252 },
253 onCancel: () => {
254 pool.cancelHeld();
255 pool.wake();
256 },
257 }
258 : undefined;
259
260 useKeyboardHand({ myTurn, hand: p.hand, sel, setSel, drawnIndex, carry, pool, seat, doDiscard });
261
262 return (
263 // `data-seat` is how table/geometry.ts finds this seat's hand to work out
264 // where it throws from — see measure(). It carries the seat itself, not the
265 // edge it is drawn at: the physics is about who threw the tile.
266 <div className={`slot ${SEAT_SLOT[pos]} ${pos === 0 ? 'mine' : 'opp'}`} data-seat={seat}>
267 <div
268 className={`seat ${myTurn ? 'active' : ''} ${claiming ? 'claiming' : ''} ${
269 onTheClock ? 'awaited' : ''
270 }`}
271 style={compact ? undefined : { transform: `translate(-50%, -50%) rotate(${SEAT_ROT[pos]}deg)` }}
272 >
273 {/* Seven seconds of gold along this seat's inner edge. A screen with
274 four people at it is the one place the game can go quiet without
275 anybody noticing — three of them are looking at the table and the
276 fourth is looking at their phone — so the table says whose go it is
277 loudly enough to be read from the other side of it. Keyed on the
278 turn, which is what starts it over; nothing at all happens when it
279 runs out. */}
280 {onTheClock && <TurnClock key={turnId(s)} />}
281 {/* Points down the strip at whoever won. It is inside the seat, so it
282 turns with it and lands on their edge of the table wherever they
283 are sitting — no working out which way is which. */}
284 {won && (
285 <div className="win-arrow" aria-hidden>
286 ▼
287 </div>
288 )}
289
290 {/* Only on a phone. At full size a discard is thrown into the middle
291 and stays there for the hand, so the row would be the same tiles
292 listed twice. */}
293 {compact && (
294 <div className="discards" ref={discardsRef}>
295 {p.discards.map((t, i) => (
296 <TileView
297 key={i}
298 tile={t}
299 size="small"
300 highlight={s.lastDiscard?.from === seat && i === p.discards.length - 1}
301 />
302 ))}
303 </div>
304 )}
305
306 <div className="melds">
307 {p.melds.map((m, i) => (
308 <div className="meld" key={i}>
309 {m.tiles.map((t, j) => (
310 <TileView key={j} tile={t} size="meld" faceDown={m.kind === 'ankong' && j > 0 && j < 3} />
311 ))}
312 </div>
313 ))}
314 {p.flowers.length > 0 && (
315 <div className="meld flowers">
316 {p.flowers.map((t, i) => (
317 <TileView key={i} tile={t} size="meld" />
318 ))}
319 </div>
320 )}
321 </div>
322
323 {/* The tiles and the buttons that act on them, on one row: 打出 sits at
324 the right-hand end of your own hand, where the tile you are about to
325 throw already is. It used to be a row of its own above the hand, and
326 a row that is only there sometimes is a row that shoves the sets and
327 flowers up the strip every time you lift a tile. On a phone there is
328 no room beside a hand, so the wrapper steps out of the way and the
329 bar goes back to being its own row — `display: contents`. */}
330 <div className="hand-row">
331 <Hand
332 tiles={p.hand}
333 faceDown={concealed}
334 /* Under the card, a tap lifts it. On a hand the computer is
335 playing, the same tap is somebody pulling up a chair: the hand
336 becomes theirs and turns over in the one gesture, which is as
337 much ceremony as sitting down at a table ever gets. */
338 onFaceUp={covered ? () => setShown(true) : sittable ? who!.onSit : undefined}
339 drawnIndex={drawnIndex}
340 selected={sel}
341 canDiscard={myTurn}
342 onSelect={setSel}
343 onDiscard={doDiscard}
344 onMove={(from, to) => game.reorderHand(seat, from, to)}
345 carry={carry}
346 />
347
348 <div className="actionbar">
349 {/* The one thing a covered hand can do. Every other button here
350 names a tile — 打出 五萬, 吃 with these two — so they wait until
351 their player is the one looking. */}
352 {covered && (
353 <button
354 className={`btn reveal ${awaited ? 'hot' : ''}`}
355 onClick={() => setShown(true)}
356 >
357 <Bi zh="看牌" en="Look" />
358 </button>
359 )}
360 {claiming && (
361 <>
362 {opts!.map((o, i) => (
363 <button
364 key={i}
365 className={`btn claim ${o.type}`}
366 onClick={() => game.respond(seat, { type: o.type, with: o.with })}
367 >
368 <Bi
369 zh={CLAIM_LABEL[o.type][0]}
370 en={CLAIM_LABEL[o.type][1]}
371 sub={o.with && o.with.map(labelOf).join(' ')}
372 />
373 </button>
374 ))}
375 {/* The seat that draws next declines by picking up — see below. */}
376 {!canProceed && (
377 <button className="btn pass" onClick={() => game.respond(seat, 'pass')}>
378 <Bi zh="過" en="Pass" />
379 </button>
380 )}
381 </>
382 )}
383 {canProceed && (
384 <button
385 className={`btn proceed ${claiming ? 'pass' : ''}`}
386 onClick={() => game.resolveNow()}
387 >
388 {/* 摸牌 either way, even when picking up is also how this seat
389 declines a 碰 it could have called. It used to say 過.摸牌
390 there, to make it plain that drawing forfeits the call — but
391 the call is a tile of its own sitting right beside this one
392 now, so the choice is on the table rather than needing to be
393 spelled out, and four characters on a tile that is one tile
394 wide is a squeeze that bought nothing. */}
395 <Bi
396 zh={s.phase === 'robkong' ? '繼續' : '摸牌'}
397 en={s.phase === 'robkong' ? 'Continue' : 'Draw'}
398 />
399 </button>
400 )}
401 {!claiming && !canProceed && responded && s.phase !== 'turn' && (
402 <div className="waiting">{t('已回應', 'Responded')}</div>
403 )}
404 {/* Sitting down. It is on the strip rather than in the gear because
405 it is the one thing somebody who has just pulled up a chair wants
406 to do, and they should not have to find a menu to do it. The
407 tiles come out from under the card in the same tap. */}
408 {sittable && (
409 <button className="btn ghost sit" onClick={who!.onSit}>
410 <Bi zh="坐下" en="Sit here" />
411 </button>
412 )}
413 {deserted && (
414 <button className="btn ghost" onClick={who!.onBot}>
415 <Bi zh="電腦代打" en="Hand to computer" />
416 </button>
417 )}
418 {myTurn && (
419 <>
420 {canHu && (
421 <button className="btn claim hu" onClick={() => game.declareSelfDraw(seat)}>
422 <Bi zh="自摸" en="Self-draw" />
423 </button>
424 )}
425 {concealedKongs.map((t) => (
426 <button key={`ak${t}`} className="btn claim kong" onClick={() => game.declareConcealedKong(seat, t)}>
427 <Bi zh="暗槓" en="Concealed kong" sub={labelOf(t)} />
428 </button>
429 ))}
430 {addedKongs.map((t) => (
431 <button key={`gk${t}`} className="btn claim kong" onClick={() => game.declareAddedKong(seat, t)}>
432 <Bi zh="加槓" en="Added kong" sub={labelOf(t)} />
433 </button>
434 ))}
435 {selTile !== null && sel !== null && (
436 <button className="btn discard" onClick={() => doDiscard(sel)}>
437 {/* No tile named on it: the tile it would throw is the one
438 lifted out of the row an inch to its left, wearing a gold
439 ring, and saying 五萬 on the button as well only crowds
440 the face of it. */}
441 <Bi zh="打出" en="Discard" />
442 </button>
443 )}
444 </>
445 )}
446 {delta !== 0 && <div className={`delta ${delta > 0 ? 'up' : 'down'}`}>{delta > 0 ? `+${delta}` : delta}</div>}
447 </div>
448 </div>
449
450 <div className="nameplate">
451 <span className={`wind ${isDealer ? 'dealer' : ''}`}>
452 <BiEm zh={WIND_NAMES[seatWind]} en={WIND_EN[seatWind]} />
453 </span>
454 <span className="pname">{p.name}</span>
455 {who?.waiting && (
456 <span
457 className="tag waiting"
458 title={t(
459 '這手牌的裝置斷了,先等一下,等不到就交給電腦',
460 'this hand lost its device — kept a moment, then the computer takes it',
461 ' · ',
462 )}
463 >
464 ⏳
465 </span>
466 )}
467 {who?.holder != null && (
468 <span
469 className="tag phone"
470 title={t('這手牌在手機上', 'played from a phone', ' · ')}
471 >
472 📱 {who.holder}
473 </span>
474 )}
475 {who?.speaks && (
476 <span
477 className="tag voice"
478 title={t('喊牌從這支手機出來', 'this hand calls from its own phone', ' · ')}
479 >
480 🔊
481 </span>
482 )}
483 {deserted && <span className="tag sacred">{t('已離線', 'offline')}</span>}
484 {isDealer && (
485 <span className="tag">
486 {t('莊', 'Dealer')}
487 {s.dealerStreak > 0 ? ` ${t(`連${s.dealerStreak}`, `×${s.dealerStreak + 1}`)}` : ''}
488 </span>
489 )}
490 {tenpai.length > 0 && (
491 <span className="tag ting">
492 {t('聽', 'Ready')} {tenpai.map(labelOf).join('·')}
493 </span>
494 )}
495 {/* Without this the 胡 button just quietly fails to appear. */}
496 {sacred.length > 0 && (
497 <span
498 className="tag sacred"
499 title={t(
500 '過水 — 放棄過這幾張的胡,到自己再摸牌前不能胡',
501 '過水 — passed on winning with these until your next draw',
502 )}
503 >
504 {t('過水', 'Sacred')} {sacred.map(labelOf).join('·')}
505 </span>
506 )}
507 <span className="chips">{p.score}</span>
508 {p.hand.length > 0 && s.phase !== 'handEnd' && acts && (
509 <button className="btn ghost sort" onClick={() => game.sortHand(seat)}>
510 {t('理牌', 'Sort')}
511 </button>
512 )}
513 {/* Put it back yourself, without waiting for the turn to end —
514 somebody has walked round behind you, or you are done looking. */}
515 {shared && plays && shown && s.phase !== 'handEnd' && s.phase !== 'gameEnd' && (
516 <button className="btn ghost sort" onClick={() => setShown(false)}>
517 {t('蓋牌', 'Cover')}
518 </button>
519 )}
520 <button className="helpbtn" onClick={onHelp} aria-label={t('規則', 'rules')}>
521 ?
522 </button>
523 {/* This player's own copy of the table's controls — sound, undo, the
524 settings — turned to face their own edge. See ui/TableMenu. */}
525 {onMenu && (
526 <button className="helpbtn gear" onClick={onMenu} aria-label={t('桌面設定', 'table controls')}>
527 ⚙
528 </button>
529 )}
530 </div>
531 </div>
532 </div>
533 );
534}
535
536/**
537 * A hand played without letting go of the keys.
538 *
539 * ← and → walk the selection along the row, and space throws the tile that is
540 * lifted. How long space is *held* is how hard it goes: the tile draws back out
541 * of the hand while the key is down, the way an arm does, and leaves when it is
542 * let go. A tap of the key is a gentle slide in; a long hold is a tile that
543 * crosses the square and barges what is lying there.
544 *
545 * The same two calls the fingers make — `carry.onPickUp` and `carry.onThrow` in
546 * ui/Seat — so a thrown tile is thrown by the physics either way, and the throw
547 * gate applies: from a seat with a standing wall in front of it there is no
548 * line in, so space places the tile over the wall instead, exactly as a tap
549 * does. Only ever on the seat whose turn it is, so the four of them never
550 * argue about a key.
551 */
552function useKeyboardHand({
553 myTurn,
554 hand,
555 sel,
556 setSel,
557 drawnIndex,
558 carry,
559 pool,
560 seat,
561 doDiscard,
562}: {
563 myTurn: boolean;
564 hand: Tile[];
565 sel: number | null;
566 setSel: (to: number | null) => void;
567 drawnIndex: number | null;
568 carry: Carry | undefined;
569 pool?: TablePool;
570 seat: SeatId;
571 doDiscard: (i: number) => void;
572}) {
573 // What the listeners read. Kept current without them being torn down and put
574 // back on every render to see it.
575 const now = useRef({ myTurn, hand, sel, setSel, drawnIndex, carry, pool, seat, doDiscard });
576 now.current = { myTurn, hand, sel, setSel, drawnIndex, carry, pool, seat, doDiscard };
577 const charge = useRef<Charge | null>(null);
578
579 useEffect(() => {
580 /** Where the lifted tile is, and which way the middle of the table lies. */
581 const aim = (index: number) => {
582 const table = document.querySelector<HTMLElement>('.table');
583 const cell = document.querySelector<HTMLElement>(
584 `[data-seat="${now.current.seat}"] .hand-cell:nth-child(${index + 1}) .tile`,
585 );
586 if (!table || !cell) return null;
587 const base = table.getBoundingClientRect();
588 const box = cell.getBoundingClientRect();
589 const from = {
590 x: box.left + box.width / 2 - base.left,
591 y: box.top + box.height / 2 - base.top,
592 };
593 // At the middle of the square if there is one, and at the middle of the
594 // table if the wall has been pushed somewhere odd — either way it is the
595 // line a tile would be slid along.
596 const ring = table.querySelector<HTMLElement>('.wall-ring')?.getBoundingClientRect();
597 const to = ring
598 ? { x: ring.left + ring.width / 2 - base.left, y: ring.top + ring.height / 2 - base.top }
599 : { x: base.width / 2, y: base.height / 2 };
600 const dx = to.x - from.x;
601 const dy = to.y - from.y;
602 const len = Math.hypot(dx, dy) || 1;
603 return { from, size: Math.min(box.width, box.height), dir: { x: dx / len, y: dy / len } };
604 };
605
606 const drawBack = () => {
607 const c = charge.current;
608 if (!c) return;
609 const p = power(c.at);
610 now.current.carry?.onMoveTo(c.from.x - c.dir.x * pullOf(p), c.from.y - c.dir.y * pullOf(p));
611 c.frame = requestAnimationFrame(drawBack);
612 };
613
614 const letGo = () => {
615 const c = charge.current;
616 charge.current = null;
617 if (!c) return;
618 cancelAnimationFrame(c.frame);
619 return c;
620 };
621
622 const down = (e: KeyboardEvent) => {
623 const { myTurn, hand, sel, setSel, drawnIndex, carry, pool } = now.current;
624 // The colliders sandbox has the keys — see ui/colliders.
625 if (!myTurn || showingColliders() || e.metaKey || e.ctrlKey || e.altKey) return;
626 if (e.target instanceof HTMLElement && e.target.matches('input, textarea, select')) return;
627 const n = hand.length;
628 if (n === 0) return;
629
630 if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
631 e.preventDefault();
632 const step = e.key === 'ArrowRight' ? 1 : -1;
633 // Nothing lifted yet: start at the tile just drawn, which is the one
634 // the decision is actually about.
635 setSel(sel === null ? drawnIndex ?? (step > 0 ? 0 : n - 1) : (sel + step + n) % n);
636 return;
637 }
638 if (e.key === 'Escape') {
639 if (letGo()) carry?.onCancel();
640 setSel(null);
641 return;
642 }
643 if (e.key !== ' ' || e.repeat) return;
644 e.preventDefault();
645 if (sel === null) return setSel(drawnIndex ?? n - 1);
646 if (charge.current || !carry) return;
647
648 const at = aim(sel);
649 if (!at) return;
650 // Wound up in the fingers. Nothing has been played yet — Escape, or a
651 // wall in the way, still puts it back in the row.
652 carry.onPickUp(sel, at.from.x, at.from.y, at.size);
653 charge.current = { index: sel, at: performance.now(), from: at.from, dir: at.dir, frame: 0 };
654 // Whether there is a line in at all is the pool's question, asked from
655 // where the tile actually is — the same gate a flick meets.
656 charge.current.thrown = !pool || pool.canThrowFrom(at.from.x, at.from.y);
657 drawBack();
658 };
659
660 const up = (e: KeyboardEvent) => {
661 if (e.key !== ' ') return;
662 const c = letGo();
663 if (!c) return;
664 const { carry, doDiscard } = now.current;
665 if (!carry) return;
666 if (!c.thrown) {
667 // No line in. It goes over the wall instead of through it, which is
668 // what a tap has always done.
669 carry.onCancel();
670 doDiscard(c.index);
671 return;
672 }
673 const p = power(c.at);
674 const speed = SLIDE_IN + (MAX_THROW_SPEED - SLIDE_IN) * p;
675 const pull = pullOf(p);
676 carry.onThrow(c.index, {
677 x: c.from.x - c.dir.x * pull,
678 y: c.from.y - c.dir.y * pull,
679 vx: c.dir.x * speed,
680 vy: c.dir.y * speed,
681 spin: 0,
682 });
683 };
684
685 window.addEventListener('keydown', down);
686 window.addEventListener('keyup', up);
687 return () => {
688 window.removeEventListener('keydown', down);
689 window.removeEventListener('keyup', up);
690 const c = charge.current;
691 charge.current = null;
692 if (c) cancelAnimationFrame(c.frame);
693 };
694 }, []);
695}
696
697/** A tile wound up but not yet let go of. */
698interface Charge {
699 index: number;
700 /** When the key went down. */
701 at: number;
702 from: { x: number; y: number };
703 dir: { x: number; y: number };
704 frame: number;
705 /** Whether there is a line into the middle from where it was lifted. */
706 thrown?: boolean;
707}
708
709/** How long space has to be held for a throw at full stretch. */
710const FULL_CHARGE = 700;
711/** A tap of the key: enough to slide the tile in and no more. */
712const SLIDE_IN = 900;
713
714const power = (since: number) => Math.min(1, Math.max(0, (performance.now() - since) / FULL_CHARGE));
715/** How far the tile is drawn back out of the row, which is the power gauge. */
716const pullOf = (p: number) => 8 + 52 * p;