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 <Bi
347 zh={s.phase === 'robkong' ? '繼續' : claiming ? '過.摸牌' : '摸牌'}
348 en={s.phase === 'robkong' ? 'Continue' : claiming ? 'Pass & draw' : 'Draw'}
349 />
350 </button>
351 )}
352 {!claiming && !canProceed && responded && s.phase !== 'turn' && (
353 <div className="waiting">{t('已回應', 'Responded')}</div>
354 )}
355 {deserted && (
356 <button className="btn ghost" onClick={remote!.onHandToBot}>
357 <Bi zh="電腦代打" en="Hand to computer" />
358 </button>
359 )}
360 {myTurn && (
361 <>
362 {canHu && (
363 <button className="btn claim hu" onClick={() => game.declareSelfDraw(seat)}>
364 <Bi zh="自摸" en="Self-draw" />
365 </button>
366 )}
367 {concealedKongs.map((t) => (
368 <button key={`ak${t}`} className="btn claim kong" onClick={() => game.declareConcealedKong(seat, t)}>
369 <Bi zh="暗槓" en="Concealed kong" sub={labelOf(t)} />
370 </button>
371 ))}
372 {addedKongs.map((t) => (
373 <button key={`gk${t}`} className="btn claim kong" onClick={() => game.declareAddedKong(seat, t)}>
374 <Bi zh="加槓" en="Added kong" sub={labelOf(t)} />
375 </button>
376 ))}
377 {selTile !== null && sel !== null && (
378 <button className="btn discard" onClick={() => doDiscard(sel)}>
379 <Bi zh="打出" en="Discard" sub={labelOf(selTile)} />
380 </button>
381 )}
382 </>
383 )}
384 {delta !== 0 && <div className={`delta ${delta > 0 ? 'up' : 'down'}`}>{delta > 0 ? `+${delta}` : delta}</div>}
385 </div>
386 </div>
387
388 <div className="nameplate">
389 <span className={`wind ${isDealer ? 'dealer' : ''}`}>
390 <BiEm zh={WIND_NAMES[seatWind]} en={WIND_EN[seatWind]} />
391 </span>
392 <span className="pname">{p.name}</span>
393 {remote?.holder != null && (
394 <span
395 className="tag phone"
396 title={`${t('這手牌在手機上', 'played from a phone', ' · ')}. ${t(
397 '按住看 QR',
398 'hold to show the QR again',
399 ' · ',
400 )}`}
401 onPointerDown={() => remote.onPeek?.(true)}
402 onPointerUp={() => remote.onPeek?.(false)}
403 onPointerCancel={() => remote.onPeek?.(false)}
404 onPointerLeave={() => remote.onPeek?.(false)}
405 >
406 📱 {remote.holder}
407 </span>
408 )}
409 {remote?.speaks && (
410 <span
411 className="tag voice"
412 title={t('喊牌從這支手機出來', 'this hand calls from its own phone', ' · ')}
413 >
414 🔊
415 </span>
416 )}
417 {deserted && <span className="tag sacred">{t('已離線', 'offline')}</span>}
418 {isDealer && (
419 <span className="tag">
420 {t('莊', 'Dealer')}
421 {s.dealerStreak > 0 ? ` ${t(`連${s.dealerStreak}`, `×${s.dealerStreak + 1}`)}` : ''}
422 </span>
423 )}
424 {tenpai.length > 0 && (
425 <span className="tag ting">
426 {t('聽', 'Ready')} {tenpai.map(labelOf).join('·')}
427 </span>
428 )}
429 {/* Without this the 胡 button just quietly fails to appear. */}
430 {sacred.length > 0 && (
431 <span
432 className="tag sacred"
433 title={t(
434 '過水 — 放棄過這幾張的胡,到自己再摸牌前不能胡',
435 '過水 — passed on winning with these until your next draw',
436 )}
437 >
438 {t('過水', 'Sacred')} {sacred.map(labelOf).join('·')}
439 </span>
440 )}
441 <span className="chips">{p.score}</span>
442 {p.hand.length > 0 && s.phase !== 'handEnd' && acts && (
443 <button className="btn ghost sort" onClick={() => game.sortHand(seat)}>
444 {t('理牌', 'Sort')}
445 </button>
446 )}
447 {/* Put it back yourself, without waiting for the turn to end —
448 somebody has walked round behind you, or you are done looking. */}
449 {shared && plays && shown && s.phase !== 'handEnd' && s.phase !== 'gameEnd' && (
450 <button className="btn ghost sort" onClick={() => setShown(false)}>
451 {t('蓋牌', 'Cover')}
452 </button>
453 )}
454 <button className="helpbtn" onClick={onHelp} aria-label={t('規則', 'rules')}>
455 ?
456 </button>
457 {/* This player's own copy of the table's controls — sound, undo, the
458 settings — turned to face their own edge. See ui/TableMenu. */}
459 {onMenu && (
460 <button className="helpbtn gear" onClick={onMenu} aria-label={t('桌面設定', 'table controls')}>
461 ⚙
462 </button>
463 )}
464 </div>
465 </div>
466 </div>
467 );
468}
469
470/**
471 * A hand played without letting go of the keys.
472 *
473 * ← and → walk the selection along the row, and space throws the tile that is
474 * lifted. How long space is *held* is how hard it goes: the tile draws back out
475 * of the hand while the key is down, the way an arm does, and leaves when it is
476 * let go. A tap of the key is a gentle slide in; a long hold is a tile that
477 * crosses the square and barges what is lying there.
478 *
479 * The same two calls the fingers make — `carry.onPickUp` and `carry.onThrow` in
480 * ui/Seat — so a thrown tile is thrown by the physics either way, and the throw
481 * gate applies: from a seat with a standing wall in front of it there is no
482 * line in, so space places the tile over the wall instead, exactly as a tap
483 * does. Only ever on the seat whose turn it is, so the four of them never
484 * argue about a key.
485 */
486function useKeyboardHand({
487 myTurn,
488 hand,
489 sel,
490 setSel,
491 drawnIndex,
492 carry,
493 pool,
494 seat,
495 doDiscard,
496}: {
497 myTurn: boolean;
498 hand: Tile[];
499 sel: number | null;
500 setSel: (to: number | null) => void;
501 drawnIndex: number | null;
502 carry: Carry | undefined;
503 pool?: TablePool;
504 seat: SeatId;
505 doDiscard: (i: number) => void;
506}) {
507 // What the listeners read. Kept current without them being torn down and put
508 // back on every render to see it.
509 const now = useRef({ myTurn, hand, sel, setSel, drawnIndex, carry, pool, seat, doDiscard });
510 now.current = { myTurn, hand, sel, setSel, drawnIndex, carry, pool, seat, doDiscard };
511 const charge = useRef<Charge | null>(null);
512
513 useEffect(() => {
514 /** Where the lifted tile is, and which way the middle of the table lies. */
515 const aim = (index: number) => {
516 const table = document.querySelector<HTMLElement>('.table');
517 const cell = document.querySelector<HTMLElement>(
518 `[data-seat="${now.current.seat}"] .hand-cell:nth-child(${index + 1}) .tile`,
519 );
520 if (!table || !cell) return null;
521 const base = table.getBoundingClientRect();
522 const box = cell.getBoundingClientRect();
523 const from = {
524 x: box.left + box.width / 2 - base.left,
525 y: box.top + box.height / 2 - base.top,
526 };
527 // At the middle of the square if there is one, and at the middle of the
528 // table if the wall has been pushed somewhere odd — either way it is the
529 // line a tile would be slid along.
530 const ring = table.querySelector<HTMLElement>('.wall-ring')?.getBoundingClientRect();
531 const to = ring
532 ? { x: ring.left + ring.width / 2 - base.left, y: ring.top + ring.height / 2 - base.top }
533 : { x: base.width / 2, y: base.height / 2 };
534 const dx = to.x - from.x;
535 const dy = to.y - from.y;
536 const len = Math.hypot(dx, dy) || 1;
537 return { from, size: Math.min(box.width, box.height), dir: { x: dx / len, y: dy / len } };
538 };
539
540 const drawBack = () => {
541 const c = charge.current;
542 if (!c) return;
543 const p = power(c.at);
544 now.current.carry?.onMoveTo(c.from.x - c.dir.x * pullOf(p), c.from.y - c.dir.y * pullOf(p));
545 c.frame = requestAnimationFrame(drawBack);
546 };
547
548 const letGo = () => {
549 const c = charge.current;
550 charge.current = null;
551 if (!c) return;
552 cancelAnimationFrame(c.frame);
553 return c;
554 };
555
556 const down = (e: KeyboardEvent) => {
557 const { myTurn, hand, sel, setSel, drawnIndex, carry, pool } = now.current;
558 // The colliders sandbox has the keys — see ui/colliders.
559 if (!myTurn || showingColliders() || e.metaKey || e.ctrlKey || e.altKey) return;
560 if (e.target instanceof HTMLElement && e.target.matches('input, textarea, select')) return;
561 const n = hand.length;
562 if (n === 0) return;
563
564 if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
565 e.preventDefault();
566 const step = e.key === 'ArrowRight' ? 1 : -1;
567 // Nothing lifted yet: start at the tile just drawn, which is the one
568 // the decision is actually about.
569 setSel(sel === null ? drawnIndex ?? (step > 0 ? 0 : n - 1) : (sel + step + n) % n);
570 return;
571 }
572 if (e.key === 'Escape') {
573 if (letGo()) carry?.onCancel();
574 setSel(null);
575 return;
576 }
577 if (e.key !== ' ' || e.repeat) return;
578 e.preventDefault();
579 if (sel === null) return setSel(drawnIndex ?? n - 1);
580 if (charge.current || !carry) return;
581
582 const at = aim(sel);
583 if (!at) return;
584 // Wound up in the fingers. Nothing has been played yet — Escape, or a
585 // wall in the way, still puts it back in the row.
586 carry.onPickUp(sel, at.from.x, at.from.y, at.size);
587 charge.current = { index: sel, at: performance.now(), from: at.from, dir: at.dir, frame: 0 };
588 // Whether there is a line in at all is the pool's question, asked from
589 // where the tile actually is — the same gate a flick meets.
590 charge.current.thrown = !pool || pool.canThrowFrom(at.from.x, at.from.y);
591 drawBack();
592 };
593
594 const up = (e: KeyboardEvent) => {
595 if (e.key !== ' ') return;
596 const c = letGo();
597 if (!c) return;
598 const { carry, doDiscard } = now.current;
599 if (!carry) return;
600 if (!c.thrown) {
601 // No line in. It goes over the wall instead of through it, which is
602 // what a tap has always done.
603 carry.onCancel();
604 doDiscard(c.index);
605 return;
606 }
607 const p = power(c.at);
608 const speed = SLIDE_IN + (MAX_THROW_SPEED - SLIDE_IN) * p;
609 const pull = pullOf(p);
610 carry.onThrow(c.index, {
611 x: c.from.x - c.dir.x * pull,
612 y: c.from.y - c.dir.y * pull,
613 vx: c.dir.x * speed,
614 vy: c.dir.y * speed,
615 spin: 0,
616 });
617 };
618
619 window.addEventListener('keydown', down);
620 window.addEventListener('keyup', up);
621 return () => {
622 window.removeEventListener('keydown', down);
623 window.removeEventListener('keyup', up);
624 const c = charge.current;
625 charge.current = null;
626 if (c) cancelAnimationFrame(c.frame);
627 };
628 }, []);
629}
630
631/** A tile wound up but not yet let go of. */
632interface Charge {
633 index: number;
634 /** When the key went down. */
635 at: number;
636 from: { x: number; y: number };
637 dir: { x: number; y: number };
638 frame: number;
639 /** Whether there is a line into the middle from where it was lifted. */
640 thrown?: boolean;
641}
642
643/** How long space has to be held for a throw at full stretch. */
644const FULL_CHARGE = 700;
645/** A tap of the key: enough to slide the tile in and no more. */
646const SLIDE_IN = 900;
647
648const power = (since: number) => Math.min(1, Math.max(0, (performance.now() - since) / FULL_CHARGE));
649/** How far the tile is drawn back out of the row, which is the power gauge. */
650const pullOf = (p: number) => 8 + 52 * p;