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