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