| 1 | import { useEffect, useRef, useState } from 'react'; |
| 2 | import type { TableCtl } from '../game/ctl'; |
| 3 | import { labelOf, WIND_EN, WIND_NAMES, type Tile } from '../game/tiles'; |
| 4 | import type { SeatId } from '../game/types'; |
| 5 | import { MAX_THROW_SPEED, type TablePool } from '../table/pool'; |
| 6 | import { Bi, BiEm, CLAIM_LABEL } from './Bi'; |
| 7 | import { showingColliders } from './colliders'; |
| 8 | import { useT } from './lang'; |
| 9 | import { Hand, type Carry } from './Hand'; |
| 10 | import { SEAT_ROT, SEAT_SLOT } from './rotation'; |
| 11 | import { 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 | */ |
| 17 | export 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 | |
| 35 | interface 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 | |
| 71 | export 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 | */ |
| 493 | function 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. */ |
| 639 | interface 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. */ |
| 651 | const FULL_CHARGE = 700; |
| 652 | /** A tap of the key: enough to slide the tile in and no more. */ |
| 653 | const SLIDE_IN = 900; |
| 654 | |
| 655 | const 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. */ |
| 657 | const pullOf = (p: number) => 8 + 52 * p; |