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