anvilsign in

collin/mahjong

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