collin/mahjong · e319e006
A hand played on the keys
Collin Richards · 2026-08-25 07:39 UTC · e319e00600e2daec3fc6e26ff77145909bfa3b57 · parent e2f4495f · browse files
modifiedsrc/styles.css+29 −16
| ⋯ 427 unchanged lines | |||
| 428 | 428 | color: var(--gold); | |
| 429 | 429 | } | |
| 430 | 430 | ||
| 431 | + | /* The tiles and the buttons that act on them, on one row. The hand stays in the | |
| 432 | + | middle of the strip where it has always been, and the bar is taken out of the | |
| 433 | + | flow and hung on the right-hand end — so a button arriving moves nothing at | |
| 434 | + | all, neither the tiles under it nor the sets and flowers above. */ | |
| 435 | + | .hand-row { | |
| 436 | + | position: relative; | |
| 437 | + | display: flex; | |
| 438 | + | justify-content: center; | |
| 439 | + | align-items: flex-end; | |
| 440 | + | align-self: stretch; | |
| 441 | + | flex: none; | |
| 442 | + | } | |
| 431 | 443 | .actionbar { | |
| 444 | + | position: absolute; | |
| 445 | + | right: 0; | |
| 446 | + | bottom: 0; | |
| 447 | + | height: 100%; | |
| 432 | 448 | display: flex; | |
| 433 | 449 | gap: 8px; | |
| 434 | 450 | align-items: center; | |
| 435 | 451 | justify-content: flex-end; | |
| 436 | 452 | padding-right: calc(var(--tile-w) * 0.4); | |
| 437 | - | align-self: stretch; | |
| 438 | - | /* Reserved even when empty — see --bar-h. */ | |
| 439 | - | min-height: var(--bar-h); | |
| 440 | 453 | flex-wrap: wrap; | |
| 441 | 454 | flex: none; | |
| 442 | 455 | } | |
| 443 | - | /* Nothing up: no bar. The reservation is there so the row does not change | |
| 444 | - | height as buttons come and go, and it is worth exactly that much — but a bar | |
| 445 | - | with nothing in it at all is 52px of daylight between a hand and the sets | |
| 446 | - | above it, for most of a hand. Since the strip packs towards its outer edge, | |
| 447 | - | what moves when the buttons do arrive is the melds above them, and never the | |
| 448 | - | hand or the nameplate below. */ | |
| 449 | - | .actionbar:empty { | |
| 450 | - | min-height: 0; | |
| 456 | + | /* A phone has no room beside a hand. There the wrapper steps out of the way | |
| 457 | + | entirely and the bar is a row of its own again, where the grid and the | |
| 458 | + | `order`s further down put it. */ | |
| 459 | + | .compact .hand-row { | |
| 460 | + | display: contents; | |
| 451 | 461 | } | |
| 452 | - | /* Except on the seat whose turn it is. There a button is one tap away — lift a | |
| 453 | - | tile and 打出 arrives — and a bar that springs into being under your hand | |
| 454 | - | shoves the sets and flowers above it up the strip while you are still | |
| 455 | - | deciding. The seat that is about to need the room keeps it. */ | |
| 456 | - | .seat.active .actionbar:empty { | |
| 462 | + | .compact .actionbar { | |
| 463 | + | position: static; | |
| 464 | + | height: auto; | |
| 457 | 465 | min-height: var(--bar-h); | |
| 466 | + | align-self: stretch; | |
| 458 | 467 | } | |
| 459 | 468 | /* Every button on the bar is the same height, so the row is the same height | |
| 460 | 469 | whichever ones are up: without this a 打出 五萬 stands taller than a bare 過 | |
| ⋯ 1919 unchanged lines | |||
| 2380 | 2389 | min-height: 0; | |
| 2381 | 2390 | padding: 3px 9px; | |
| 2382 | 2391 | } | |
| 2392 | + | .dice-row .btn.on { | |
| 2393 | + | border-color: var(--gold); | |
| 2394 | + | color: var(--gold); | |
| 2395 | + | } | |
| 2383 | 2396 | .dice-says { | |
| 2384 | 2397 | opacity: 0.6; | |
| 2385 | 2398 | letter-spacing: 0.02em; | |
| ⋯ 2 unchanged lines | |||
modifiedsrc/table/geometry.ts+8 −6
| 1 | - | import { isBarrier, wallStacks, type WallProgress } from '../game/wall'; | |
| 2 | 1 | import type { SeatId } from '../game/types'; | |
| 3 | 2 | import type { Barrier, Rect } from './physics'; | |
| 4 | 3 | ||
| ⋯ 62 unchanged lines | |||
| 67 | 66 | * Read the table. Returns null when there is no wall square to measure — the | |
| 68 | 67 | * compact layout has none, and neither does a table that has not laid out yet. | |
| 69 | 68 | */ | |
| 70 | - | export function measure(table: HTMLElement, state: WallProgress): TableGeometry | null { | |
| 69 | + | export function measure(table: HTMLElement): TableGeometry | null { | |
| 71 | 70 | const ring = table.querySelector<HTMLElement>('.wall-ring'); | |
| 72 | 71 | if (!ring) return null; | |
| 73 | 72 | ||
| ⋯ 6 unchanged lines | |||
| 80 | 79 | h: r.height, | |
| 81 | 80 | }); | |
| 82 | 81 | ||
| 83 | - | const stacks = wallStacks(state); | |
| 84 | 82 | const walls: Barrier[] = []; | |
| 85 | 83 | const barriers: Barrier[] = []; | |
| 86 | 84 | // A stack element is --ws by --wd (or the other way round on the left and | |
| ⋯ 5 unchanged lines | |||
| 92 | 90 | // Nothing in the square is turned, so a stack's bounding rect *is* the stack: | |
| 93 | 91 | // the box the layout gave it, in the table's own axes. A stack on the left or | |
| 94 | 92 | // right wall is lying the other way about, and its rect says so. | |
| 93 | + | // | |
| 94 | + | // Whether it is a barrier is read off the element too, rather than worked out | |
| 95 | + | // again from the state: a stack of two stands as tall as the tile thrown at | |
| 96 | + | // it (`isBarrier`), and `s2` is `WallRing` saying it drew one. The rule that a | |
| 97 | + | // tile you can see is a tile a thrown one hits, kept by asking the thing on | |
| 98 | + | // screen instead of a second opinion that could differ from it. | |
| 95 | 99 | for (const el of ring.querySelectorAll<HTMLElement>('[data-stack]')) { | |
| 96 | - | const i = Number(el.dataset.stack); | |
| 97 | 100 | const r = local(el.getBoundingClientRect()); | |
| 98 | 101 | short = Math.max(short, Math.min(r.w, r.h)); | |
| 99 | - | const stack = stacks[i]; | |
| 100 | - | if (stack && isBarrier(stack)) { | |
| 102 | + | if (el.classList.contains('s2')) { | |
| 101 | 103 | walls.push({ rect: r }); | |
| 102 | 104 | barriers.push({ rect: r }); | |
| 103 | 105 | } | |
| ⋯ 185 unchanged lines | |||
modifiedsrc/table/pool.ts+1 −1
| ⋯ 612 unchanged lines | |||
| 613 | 613 | )}x${Math.round(table.clientHeight)}`; | |
| 614 | 614 | if (stamp === this.measuredAt) return; | |
| 615 | 615 | ||
| 616 | - | const geo = measure(table, state); | |
| 616 | + | const geo = measure(table); | |
| 617 | 617 | this.measuredAt = geo ? stamp : ''; | |
| 618 | 618 | this.geo = geo; | |
| 619 | 619 | if (!geo) { | |
| ⋯ 187 unchanged lines | |||
modifiedsrc/ui/Center.tsx+9 −2
| 1 | + | import { useState } from 'react'; | |
| 1 | 2 | import type { TableCtl } from '../game/ctl'; | |
| 2 | 3 | import type { Prefs } from '../game/prefs'; | |
| 3 | 4 | import type { SeatId } from '../game/types'; | |
| ⋯ 24 unchanged lines | |||
| 28 | 29 | export function Center({ game, prefs, compact, viewSeat = 0, preferSeat = null, onPrefs, onSettings }: Props) { | |
| 29 | 30 | const t = useT(); | |
| 30 | 31 | const lang = useLang(); | |
| 32 | + | // Dev only, and a look rather than a state: the square drawn with all 144 | |
| 33 | + | // tiles standing, which is what it is built to and what it only ever is for | |
| 34 | + | // the moment before the deal. See the dice panel. | |
| 35 | + | const [whole, setWhole] = useState(false); | |
| 31 | 36 | const s = game.state; | |
| 32 | 37 | const r = s.result; | |
| 33 | 38 | // Point the live readout at whoever can move the game along. | |
| ⋯ 21 unchanged lines | |||
| 55 | 60 | <em>{t('剩牌', 'left')}</em> | |
| 56 | 61 | </div> | |
| 57 | 62 | ) : ( | |
| 58 | - | <WallRing state={s} /> | |
| 63 | + | <WallRing state={s} whole={whole} /> | |
| 59 | 64 | ))} | |
| 60 | 65 | ||
| 61 | 66 | <div | |
| ⋯ 133 unchanged lines | |||
| 195 | 200 | {/* Dev only, and only where there is a square to look at: the dice decide | |
| 196 | 201 | where the wall is broken, and that is a thing you want to be able to | |
| 197 | 202 | step through rather than wait for. */} | |
| 198 | - | {import.meta.env.DEV && !compact && s.phase !== 'gameEnd' && <DiceDebug game={game} />} | |
| 203 | + | {import.meta.env.DEV && !compact && s.phase !== 'gameEnd' && ( | |
| 204 | + | <DiceDebug game={game} whole={whole} onWhole={setWhole} /> | |
| 205 | + | )} | |
| 199 | 206 | ||
| 200 | 207 | <div className="table-controls"> | |
| 201 | 208 | {undoable && ( | |
| ⋯ 25 unchanged lines | |||
modifiedsrc/ui/DiceDebug.tsx+22 −1
| ⋯ 18 unchanged lines | |||
| 19 | 19 | * was built, so nobody's hand changes and the draw order is the draw order. | |
| 20 | 20 | * See `reroll` in game/engine.ts and `breakAt` in game/wall.ts. | |
| 21 | 21 | */ | |
| 22 | - | export function DiceDebug({ game }: { game: TableCtl }) { | |
| 22 | + | export function DiceDebug({ | |
| 23 | + | game, | |
| 24 | + | whole, | |
| 25 | + | onWhole, | |
| 26 | + | }: { | |
| 27 | + | game: TableCtl; | |
| 28 | + | /** Drawing the wall as if nothing had been taken off it — see `WallRing`. */ | |
| 29 | + | whole: boolean; | |
| 30 | + | onWhole: (whole: boolean) => void; | |
| 31 | + | }) { | |
| 23 | 32 | const t = useT(); | |
| 24 | 33 | const s = game.state; | |
| 25 | 34 | if (!game.reroll) return null; | |
| ⋯ 28 unchanged lines | |||
| 54 | 63 | + | |
| 55 | 64 | </button> | |
| 56 | 65 | </div> | |
| 66 | + | <div className="dice-row"> | |
| 67 | + | {/* The wall is only whole for the moment before the deal, and that is | |
| 68 | + | the shape the square was built to. Puts all 144 back to look at, | |
| 69 | + | without touching a tile anybody is holding. */} | |
| 70 | + | <button | |
| 71 | + | className={`btn ghost ${whole ? 'on' : ''}`} | |
| 72 | + | onClick={() => onWhole(!whole)} | |
| 73 | + | aria-pressed={whole} | |
| 74 | + | > | |
| 75 | + | {t('整牆 144', 'Whole wall')} | |
| 76 | + | </button> | |
| 77 | + | </div> | |
| 57 | 78 | <div className="dice-says"> | |
| 58 | 79 | {s.players[whose].name} · {WALL_SIDES[SEAT_WALL_SIDE[whose]]} ·{' '} | |
| 59 | 80 | {t(`第 ${into || STACKS_PER_SIDE} 疊`, `${into || STACKS_PER_SIDE} in`)} ·{' '} | |
| ⋯ 5 unchanged lines | |||
modifiedsrc/ui/Hand.tsx+21 −13
| ⋯ 28 unchanged lines | |||
| 29 | 29 | * cannot be thrown from — the phone layout, where there is no middle of the | |
| 30 | 30 | * table to throw into — and the row only ever reorders. | |
| 31 | 31 | */ | |
| 32 | - | carry?: { | |
| 33 | - | /** | |
| 34 | - | * Lifted out of the row: from here the canvas draws it, not the DOM. `size` | |
| 35 | - | * is the width the tile had in the hand, so it can be drawn the size it just | |
| 36 | - | * was and shrink to its size in the pool rather than popping. | |
| 37 | - | */ | |
| 38 | - | onPickUp: (index: number, x: number, y: number, size: number) => void; | |
| 39 | - | onMoveTo: (x: number, y: number) => void; | |
| 40 | - | /** Let go of over the table. */ | |
| 41 | - | onThrow: (index: number, t: Throw) => void; | |
| 42 | - | /** Put back — the throw was never committed to. */ | |
| 43 | - | onCancel: () => void; | |
| 44 | - | }; | |
| 32 | + | carry?: Carry; | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /** | |
| 36 | + | * Taking a tile out of a hand and putting it on the table. The fingers do it | |
| 37 | + | * through `Hand`; the keys do it through `useKeyboardHand` in ui/Seat, and both | |
| 38 | + | * make the same four calls, so a tile arrives in the middle the same way | |
| 39 | + | * whichever it was. | |
| 40 | + | */ | |
| 41 | + | export interface Carry { | |
| 42 | + | /** | |
| 43 | + | * Lifted out of the row: from here the canvas draws it, not the DOM. `size` | |
| 44 | + | * is the width the tile had in the hand, so it can be drawn the size it just | |
| 45 | + | * was and shrink to its size in the pool rather than popping. | |
| 46 | + | */ | |
| 47 | + | onPickUp: (index: number, x: number, y: number, size: number) => void; | |
| 48 | + | onMoveTo: (x: number, y: number) => void; | |
| 49 | + | /** Let go of over the table. */ | |
| 50 | + | onThrow: (index: number, t: Throw) => void; | |
| 51 | + | /** Put back — the throw was never committed to. */ | |
| 52 | + | onCancel: () => void; | |
| 45 | 53 | } | |
| 46 | 54 | ||
| 47 | 55 | /** How far a pointer must travel before a tap becomes a drag. */ | |
| ⋯ 294 unchanged lines | |||
modifiedsrc/ui/Seat.tsx+209 −16
| 1 | 1 | import { useEffect, useRef, useState } from 'react'; | |
| 2 | 2 | import type { TableCtl } from '../game/ctl'; | |
| 3 | - | import { labelOf, WIND_EN, WIND_NAMES } from '../game/tiles'; | |
| 3 | + | import { labelOf, WIND_EN, WIND_NAMES, type Tile } from '../game/tiles'; | |
| 4 | 4 | import type { SeatId } from '../game/types'; | |
| 5 | - | import type { TablePool } from '../table/pool'; | |
| 5 | + | import { MAX_THROW_SPEED, type TablePool } from '../table/pool'; | |
| 6 | 6 | import { Bi, BiEm, CLAIM_LABEL } from './Bi'; | |
| 7 | 7 | import { showingColliders } from './colliders'; | |
| 8 | 8 | import { useT } from './lang'; | |
| 9 | - | import { Hand } from './Hand'; | |
| 9 | + | import { Hand, type Carry } from './Hand'; | |
| 10 | 10 | import { SEAT_ROT, SEAT_SLOT } from './rotation'; | |
| 11 | 11 | import { TileView } from './TileView'; | |
| 12 | 12 | ||
| ⋯ 150 unchanged lines | |||
| 163 | 163 | } | |
| 164 | 164 | : undefined; | |
| 165 | 165 | ||
| 166 | + | useKeyboardHand({ myTurn, hand: p.hand, sel, setSel, drawnIndex, carry, pool, seat, doDiscard }); | |
| 167 | + | ||
| 166 | 168 | return ( | |
| 167 | 169 | // `data-seat` is how table/geometry.ts finds this seat's hand to work out | |
| 168 | 170 | // where it throws from — see measure(). It carries the seat itself, not the | |
| ⋯ 45 unchanged lines | |||
| 214 | 216 | )} | |
| 215 | 217 | </div> | |
| 216 | 218 | ||
| 217 | - | <div className="actionbar"> | |
| 219 | + | {/* The tiles and the buttons that act on them, on one row: 打出 sits at | |
| 220 | + | the right-hand end of your own hand, where the tile you are about to | |
| 221 | + | throw already is. It used to be a row of its own above the hand, and | |
| 222 | + | a row that is only there sometimes is a row that shoves the sets and | |
| 223 | + | flowers up the strip every time you lift a tile. On a phone there is | |
| 224 | + | no room beside a hand, so the wrapper steps out of the way and the | |
| 225 | + | bar goes back to being its own row — `display: contents`. */} | |
| 226 | + | <div className="hand-row"> | |
| 227 | + | <Hand | |
| 228 | + | tiles={p.hand} | |
| 229 | + | faceDown={concealed} | |
| 230 | + | drawnIndex={drawnIndex} | |
| 231 | + | selected={sel} | |
| 232 | + | canDiscard={myTurn} | |
| 233 | + | onSelect={setSel} | |
| 234 | + | onDiscard={doDiscard} | |
| 235 | + | onMove={(from, to) => game.reorderHand(seat, from, to)} | |
| 236 | + | carry={carry} | |
| 237 | + | /> | |
| 238 | + | ||
| 239 | + | <div className="actionbar"> | |
| 218 | 240 | {claiming && ( | |
| 219 | 241 | <> | |
| 220 | 242 | {opts!.map((o, i) => ( | |
| ⋯ 61 unchanged lines | |||
| 282 | 304 | </> | |
| 283 | 305 | )} | |
| 284 | 306 | {delta !== 0 && <div className={`delta ${delta > 0 ? 'up' : 'down'}`}>{delta > 0 ? `+${delta}` : delta}</div>} | |
| 307 | + | </div> | |
| 285 | 308 | </div> | |
| 286 | - | ||
| 287 | - | <Hand | |
| 288 | - | tiles={p.hand} | |
| 289 | - | faceDown={concealed} | |
| 290 | - | drawnIndex={drawnIndex} | |
| 291 | - | selected={sel} | |
| 292 | - | canDiscard={myTurn} | |
| 293 | - | onSelect={setSel} | |
| 294 | - | onDiscard={doDiscard} | |
| 295 | - | onMove={(from, to) => game.reorderHand(seat, from, to)} | |
| 296 | - | carry={carry} | |
| 297 | - | /> | |
| 298 | 309 | ||
| 299 | 310 | <div className="nameplate"> | |
| 300 | 311 | <span className={`wind ${isDealer ? 'dealer' : ''}`}> | |
| ⋯ 54 unchanged lines | |||
| 355 | 366 | </div> | |
| 356 | 367 | ); | |
| 357 | 368 | } | |
| 369 | + | ||
| 370 | + | /** | |
| 371 | + | * A hand played without letting go of the keys. | |
| 372 | + | * | |
| 373 | + | * ← and → walk the selection along the row, and space throws the tile that is | |
| 374 | + | * lifted. How long space is *held* is how hard it goes: the tile draws back out | |
| 375 | + | * of the hand while the key is down, the way an arm does, and leaves when it is | |
| 376 | + | * let go. A tap of the key is a gentle slide in; a long hold is a tile that | |
| 377 | + | * crosses the square and barges what is lying there. | |
| 378 | + | * | |
| 379 | + | * The same two calls the fingers make — `carry.onPickUp` and `carry.onThrow` in | |
| 380 | + | * ui/Seat — so a thrown tile is thrown by the physics either way, and the throw | |
| 381 | + | * gate applies: from a seat with a standing wall in front of it there is no | |
| 382 | + | * line in, so space places the tile over the wall instead, exactly as a tap | |
| 383 | + | * does. Only ever on the seat whose turn it is, so the four of them never | |
| 384 | + | * argue about a key. | |
| 385 | + | */ | |
| 386 | + | function useKeyboardHand({ | |
| 387 | + | myTurn, | |
| 388 | + | hand, | |
| 389 | + | sel, | |
| 390 | + | setSel, | |
| 391 | + | drawnIndex, | |
| 392 | + | carry, | |
| 393 | + | pool, | |
| 394 | + | seat, | |
| 395 | + | doDiscard, | |
| 396 | + | }: { | |
| 397 | + | myTurn: boolean; | |
| 398 | + | hand: Tile[]; | |
| 399 | + | sel: number | null; | |
| 400 | + | setSel: (to: number | null) => void; | |
| 401 | + | drawnIndex: number | null; | |
| 402 | + | carry: Carry | undefined; | |
| 403 | + | pool?: TablePool; | |
| 404 | + | seat: SeatId; | |
| 405 | + | doDiscard: (i: number) => void; | |
| 406 | + | }) { | |
| 407 | + | // What the listeners read. Kept current without them being torn down and put | |
| 408 | + | // back on every render to see it. | |
| 409 | + | const now = useRef({ myTurn, hand, sel, setSel, drawnIndex, carry, pool, seat, doDiscard }); | |
| 410 | + | now.current = { myTurn, hand, sel, setSel, drawnIndex, carry, pool, seat, doDiscard }; | |
| 411 | + | const charge = useRef<Charge | null>(null); | |
| 412 | + | ||
| 413 | + | useEffect(() => { | |
| 414 | + | /** Where the lifted tile is, and which way the middle of the table lies. */ | |
| 415 | + | const aim = (index: number) => { | |
| 416 | + | const table = document.querySelector<HTMLElement>('.table'); | |
| 417 | + | const cell = document.querySelector<HTMLElement>( | |
| 418 | + | `[data-seat="${now.current.seat}"] .hand-cell:nth-child(${index + 1}) .tile`, | |
| 419 | + | ); | |
| 420 | + | if (!table || !cell) return null; | |
| 421 | + | const base = table.getBoundingClientRect(); | |
| 422 | + | const box = cell.getBoundingClientRect(); | |
| 423 | + | const from = { | |
| 424 | + | x: box.left + box.width / 2 - base.left, | |
| 425 | + | y: box.top + box.height / 2 - base.top, | |
| 426 | + | }; | |
| 427 | + | // At the middle of the square if there is one, and at the middle of the | |
| 428 | + | // table if the wall has been pushed somewhere odd — either way it is the | |
| 429 | + | // line a tile would be slid along. | |
| 430 | + | const ring = table.querySelector<HTMLElement>('.wall-ring')?.getBoundingClientRect(); | |
| 431 | + | const to = ring | |
| 432 | + | ? { x: ring.left + ring.width / 2 - base.left, y: ring.top + ring.height / 2 - base.top } | |
| 433 | + | : { x: base.width / 2, y: base.height / 2 }; | |
| 434 | + | const dx = to.x - from.x; | |
| 435 | + | const dy = to.y - from.y; | |
| 436 | + | const len = Math.hypot(dx, dy) || 1; | |
| 437 | + | return { from, size: Math.min(box.width, box.height), dir: { x: dx / len, y: dy / len } }; | |
| 438 | + | }; | |
| 439 | + | ||
| 440 | + | const drawBack = () => { | |
| 441 | + | const c = charge.current; | |
| 442 | + | if (!c) return; | |
| 443 | + | const p = power(c.at); | |
| 444 | + | now.current.carry?.onMoveTo(c.from.x - c.dir.x * pullOf(p), c.from.y - c.dir.y * pullOf(p)); | |
| 445 | + | c.frame = requestAnimationFrame(drawBack); | |
| 446 | + | }; | |
| 447 | + | ||
| 448 | + | const letGo = () => { | |
| 449 | + | const c = charge.current; | |
| 450 | + | charge.current = null; | |
| 451 | + | if (!c) return; | |
| 452 | + | cancelAnimationFrame(c.frame); | |
| 453 | + | return c; | |
| 454 | + | }; | |
| 455 | + | ||
| 456 | + | const down = (e: KeyboardEvent) => { | |
| 457 | + | const { myTurn, hand, sel, setSel, drawnIndex, carry, pool } = now.current; | |
| 458 | + | // The colliders sandbox has the keys — see ui/colliders. | |
| 459 | + | if (!myTurn || showingColliders() || e.metaKey || e.ctrlKey || e.altKey) return; | |
| 460 | + | if (e.target instanceof HTMLElement && e.target.matches('input, textarea, select')) return; | |
| 461 | + | const n = hand.length; | |
| 462 | + | if (n === 0) return; | |
| 463 | + | ||
| 464 | + | if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { | |
| 465 | + | e.preventDefault(); | |
| 466 | + | const step = e.key === 'ArrowRight' ? 1 : -1; | |
| 467 | + | // Nothing lifted yet: start at the tile just drawn, which is the one | |
| 468 | + | // the decision is actually about. | |
| 469 | + | setSel(sel === null ? drawnIndex ?? (step > 0 ? 0 : n - 1) : (sel + step + n) % n); | |
| 470 | + | return; | |
| 471 | + | } | |
| 472 | + | if (e.key === 'Escape') { | |
| 473 | + | if (letGo()) carry?.onCancel(); | |
| 474 | + | setSel(null); | |
| 475 | + | return; | |
| 476 | + | } | |
| 477 | + | if (e.key !== ' ' || e.repeat) return; | |
| 478 | + | e.preventDefault(); | |
| 479 | + | if (sel === null) return setSel(drawnIndex ?? n - 1); | |
| 480 | + | if (charge.current || !carry) return; | |
| 481 | + | ||
| 482 | + | const at = aim(sel); | |
| 483 | + | if (!at) return; | |
| 484 | + | // Wound up in the fingers. Nothing has been played yet — Escape, or a | |
| 485 | + | // wall in the way, still puts it back in the row. | |
| 486 | + | carry.onPickUp(sel, at.from.x, at.from.y, at.size); | |
| 487 | + | charge.current = { index: sel, at: performance.now(), from: at.from, dir: at.dir, frame: 0 }; | |
| 488 | + | // Whether there is a line in at all is the pool's question, asked from | |
| 489 | + | // where the tile actually is — the same gate a flick meets. | |
| 490 | + | charge.current.thrown = !pool || pool.canThrowFrom(at.from.x, at.from.y); | |
| 491 | + | drawBack(); | |
| 492 | + | }; | |
| 493 | + | ||
| 494 | + | const up = (e: KeyboardEvent) => { | |
| 495 | + | if (e.key !== ' ') return; | |
| 496 | + | const c = letGo(); | |
| 497 | + | if (!c) return; | |
| 498 | + | const { carry, doDiscard } = now.current; | |
| 499 | + | if (!carry) return; | |
| 500 | + | if (!c.thrown) { | |
| 501 | + | // No line in. It goes over the wall instead of through it, which is | |
| 502 | + | // what a tap has always done. | |
| 503 | + | carry.onCancel(); | |
| 504 | + | doDiscard(c.index); | |
| 505 | + | return; | |
| 506 | + | } | |
| 507 | + | const p = power(c.at); | |
| 508 | + | const speed = SLIDE_IN + (MAX_THROW_SPEED - SLIDE_IN) * p; | |
| 509 | + | const pull = pullOf(p); | |
| 510 | + | carry.onThrow(c.index, { | |
| 511 | + | x: c.from.x - c.dir.x * pull, | |
| 512 | + | y: c.from.y - c.dir.y * pull, | |
| 513 | + | vx: c.dir.x * speed, | |
| 514 | + | vy: c.dir.y * speed, | |
| 515 | + | spin: 0, | |
| 516 | + | }); | |
| 517 | + | }; | |
| 518 | + | ||
| 519 | + | window.addEventListener('keydown', down); | |
| 520 | + | window.addEventListener('keyup', up); | |
| 521 | + | return () => { | |
| 522 | + | window.removeEventListener('keydown', down); | |
| 523 | + | window.removeEventListener('keyup', up); | |
| 524 | + | const c = charge.current; | |
| 525 | + | charge.current = null; | |
| 526 | + | if (c) cancelAnimationFrame(c.frame); | |
| 527 | + | }; | |
| 528 | + | }, []); | |
| 529 | + | } | |
| 530 | + | ||
| 531 | + | /** A tile wound up but not yet let go of. */ | |
| 532 | + | interface Charge { | |
| 533 | + | index: number; | |
| 534 | + | /** When the key went down. */ | |
| 535 | + | at: number; | |
| 536 | + | from: { x: number; y: number }; | |
| 537 | + | dir: { x: number; y: number }; | |
| 538 | + | frame: number; | |
| 539 | + | /** Whether there is a line into the middle from where it was lifted. */ | |
| 540 | + | thrown?: boolean; | |
| 541 | + | } | |
| 542 | + | ||
| 543 | + | /** How long space has to be held for a throw at full stretch. */ | |
| 544 | + | const FULL_CHARGE = 700; | |
| 545 | + | /** A tap of the key: enough to slide the tile in and no more. */ | |
| 546 | + | const SLIDE_IN = 900; | |
| 547 | + | ||
| 548 | + | const power = (since: number) => Math.min(1, Math.max(0, (performance.now() - since) / FULL_CHARGE)); | |
| 549 | + | /** How far the tile is drawn back out of the row, which is the power gauge. */ | |
| 550 | + | const pullOf = (p: number) => 8 + 52 * p; | |
modifiedsrc/ui/WallRing.tsx+12 −2
| ⋯ 35 unchanged lines | |||
| 36 | 36 | * measures these elements to build the colliders rather than restating the CSS | |
| 37 | 37 | * that sizes them. | |
| 38 | 38 | */ | |
| 39 | - | export function WallRing({ state }: { state: WallProgress }) { | |
| 39 | + | export function WallRing({ state, whole }: { state: WallProgress; whole?: boolean }) { | |
| 40 | 40 | const t = useT(); | |
| 41 | 41 | const ring = useRef<HTMLDivElement>(null); | |
| 42 | 42 | const square = useSquare(ring); | |
| 43 | - | const sides = ringLayout(wallStacks(state), RING, breakAt(state.dealer, state.dice)); | |
| 43 | + | // `whole` puts every tile back — a hundred and forty-four, the square as it | |
| 44 | + | // is built and before anybody has drawn from it. Dev only, from the dice | |
| 45 | + | // panel: the wall is only whole for the moment before the deal, which is not | |
| 46 | + | // long enough to look at it in. It is a *look*, not a state — the hand is | |
| 47 | + | // untouched, and the physics follows what is drawn (table/geometry.ts reads | |
| 48 | + | // the stacks off the screen), so a tile thrown at this bounces off it. | |
| 49 | + | const sides = ringLayout( | |
| 50 | + | wallStacks(whole ? { ...state, drawnFront: 0, drawnBack: 0 } : state), | |
| 51 | + | RING, | |
| 52 | + | breakAt(state.dealer, state.dice), | |
| 53 | + | ); | |
| 44 | 54 | const { slide, take } = useSliding(ring, square); | |
| 45 | 55 | // Where they have been pushed to, in tiles — the form `WALL_PLACED` is | |
| 46 | 56 | // written in, so what a push found can be pasted straight back into it. For | |
| ⋯ 215 unchanged lines | |||