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