| 1 | import { useRef, useState } from 'react'; |
| 2 | import type { Tile } from '../game/tiles'; |
| 3 | import { useT } from './lang'; |
| 4 | import { TileView } from './TileView'; |
| 5 | |
| 6 | export interface Throw { |
| 7 | /** Where the tile was let go of, in table coordinates. */ |
| 8 | x: number; |
| 9 | y: number; |
| 10 | /** How fast, px per second. */ |
| 11 | vx: number; |
| 12 | vy: number; |
| 13 | spin: number; |
| 14 | } |
| 15 | |
| 16 | interface Props { |
| 17 | tiles: Tile[]; |
| 18 | /** Somebody else's hand: shown as backs, and not to be poked at. */ |
| 19 | faceDown?: boolean; |
| 20 | /** |
| 21 | * Face down but yours to turn over — the shared table, where a hand starts |
| 22 | * covered and a tap on the backs lifts the card off it. Absent on a hand |
| 23 | * that is face down because it is not yours to see. |
| 24 | */ |
| 25 | onFaceUp?: () => void; |
| 26 | /** Index of the tile held apart from the rest, if any. */ |
| 27 | drawnIndex: number | null; |
| 28 | selected: number | null; |
| 29 | canDiscard: boolean; |
| 30 | onSelect: (i: number) => void; |
| 31 | onDiscard: (i: number) => void; |
| 32 | onMove: (from: number, to: number) => void; |
| 33 | /** |
| 34 | * Picking a tile up and carrying it out of the row. Absent means the hand |
| 35 | * cannot be thrown from — the phone layout, where there is no middle of the |
| 36 | * table to throw into — and the row only ever reorders. |
| 37 | */ |
| 38 | carry?: Carry; |
| 39 | } |
| 40 | |
| 41 | /** |
| 42 | * Taking a tile out of a hand and putting it on the table. The fingers do it |
| 43 | * through `Hand`; the keys do it through `useKeyboardHand` in ui/Seat, and both |
| 44 | * make the same four calls, so a tile arrives in the middle the same way |
| 45 | * whichever it was. |
| 46 | */ |
| 47 | export interface Carry { |
| 48 | /** |
| 49 | * Lifted out of the row: from here the canvas draws it, not the DOM. `size` |
| 50 | * is the width the tile had in the hand, so it can be drawn the size it just |
| 51 | * was and shrink to its size in the pool rather than popping. |
| 52 | */ |
| 53 | onPickUp: (index: number, x: number, y: number, size: number) => void; |
| 54 | onMoveTo: (x: number, y: number) => void; |
| 55 | /** Let go of over the table. */ |
| 56 | onThrow: (index: number, t: Throw) => void; |
| 57 | /** Put back — the throw was never committed to. */ |
| 58 | onCancel: () => void; |
| 59 | } |
| 60 | |
| 61 | /** How far a pointer must travel before a tap becomes a drag. */ |
| 62 | const DRAG_SLOP = 12; |
| 63 | /** |
| 64 | * How far out of the row before the tile is genuinely picked up — in either |
| 65 | * direction, so it comes up whichever way you pull it off the row rather than |
| 66 | * only towards the table. It is put back down again at 0.6 of this, and the gap |
| 67 | * between the two is what stops it flickering when a finger rests on the line. |
| 68 | */ |
| 69 | const LIFT_OUT = 22; |
| 70 | /** |
| 71 | * A release this fast is a throw whatever else is true. Below it, the tile has |
| 72 | * to have been carried well clear of the row to count — so a hesitant drag back |
| 73 | * over your own hand puts the tile back instead of playing it. |
| 74 | */ |
| 75 | const THROW_SPEED = 220; |
| 76 | const COMMIT_OUT = 70; |
| 77 | /** Samples kept for reading the speed of a flick. */ |
| 78 | const SAMPLES = 5; |
| 79 | |
| 80 | interface Sample { |
| 81 | x: number; |
| 82 | y: number; |
| 83 | t: number; |
| 84 | } |
| 85 | |
| 86 | /** |
| 87 | * Finds the tile nearest the pointer. Works whatever the seat's rotation: |
| 88 | * bounding rects are always screen-axis-aligned, so we read the row's direction |
| 89 | * off the first two tiles rather than assuming it runs left-to-right. |
| 90 | */ |
| 91 | function nearestIndex(container: HTMLElement, x: number, y: number): number | null { |
| 92 | const centers = rowCenters(container); |
| 93 | if (!centers) return null; |
| 94 | const { points, horizontal } = centers; |
| 95 | const along = (p: { x: number; y: number }) => (horizontal ? p.x : p.y); |
| 96 | const p = along({ x, y }); |
| 97 | |
| 98 | let best = 0; |
| 99 | let bestD = Infinity; |
| 100 | points.forEach((c, i) => { |
| 101 | const d = Math.abs(along(c) - p); |
| 102 | if (d < bestD) { |
| 103 | bestD = d; |
| 104 | best = i; |
| 105 | } |
| 106 | }); |
| 107 | return best; |
| 108 | } |
| 109 | |
| 110 | function rowCenters(container: HTMLElement) { |
| 111 | const els = Array.from(container.querySelectorAll<HTMLElement>('[data-idx]')); |
| 112 | if (els.length < 2) return null; |
| 113 | const points = els.map((el) => { |
| 114 | const r = el.getBoundingClientRect(); |
| 115 | return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; |
| 116 | }); |
| 117 | const horizontal = Math.abs(points[1].x - points[0].x) >= Math.abs(points[1].y - points[0].y); |
| 118 | return { points, horizontal }; |
| 119 | } |
| 120 | |
| 121 | /** |
| 122 | * Which way is "out of the row, towards the middle of the table". |
| 123 | * |
| 124 | * Read off the elements rather than from the seat's rotation, the same way |
| 125 | * `nearestIndex` reads the row's direction: the hand and the centre are both on |
| 126 | * screen, so the line between them is the answer for any seat at any angle. |
| 127 | */ |
| 128 | function outwardOf(container: HTMLElement) { |
| 129 | const table = container.closest<HTMLElement>('.table'); |
| 130 | const hand = container.getBoundingClientRect(); |
| 131 | const from = { x: hand.left + hand.width / 2, y: hand.top + hand.height / 2 }; |
| 132 | if (!table) return { x: 0, y: -1, from, base: { x: 0, y: 0 } }; |
| 133 | const t = table.getBoundingClientRect(); |
| 134 | const to = { x: t.left + t.width / 2, y: t.top + t.height / 2 }; |
| 135 | const dx = to.x - from.x; |
| 136 | const dy = to.y - from.y; |
| 137 | const d = Math.hypot(dx, dy) || 1; |
| 138 | return { x: dx / d, y: dy / d, from, base: { x: t.left, y: t.top } }; |
| 139 | } |
| 140 | |
| 141 | /** |
| 142 | * How wide the tile is in the hand. A seat's strip is rotated by a right angle, |
| 143 | * which swaps its bounding box, and a tile is always taller than it is wide — so |
| 144 | * the narrower side is the width whichever edge the player is sitting at. |
| 145 | */ |
| 146 | function tileWidth(container: HTMLElement, index: number): number { |
| 147 | const el = container.querySelector<HTMLElement>(`[data-idx="${index}"] .tile`); |
| 148 | if (!el) return 0; |
| 149 | const r = el.getBoundingClientRect(); |
| 150 | return Math.min(r.width, r.height); |
| 151 | } |
| 152 | |
| 153 | type Mode = 'undecided' | 'sort' | 'carry'; |
| 154 | |
| 155 | interface Drag { |
| 156 | from: number; |
| 157 | cur: number; |
| 158 | startX: number; |
| 159 | startY: number; |
| 160 | mode: Mode; |
| 161 | outward: ReturnType<typeof outwardOf>; |
| 162 | samples: Sample[]; |
| 163 | } |
| 164 | |
| 165 | export function Hand({ |
| 166 | tiles, |
| 167 | faceDown, |
| 168 | onFaceUp, |
| 169 | drawnIndex, |
| 170 | selected, |
| 171 | canDiscard, |
| 172 | onSelect, |
| 173 | onDiscard, |
| 174 | onMove, |
| 175 | carry, |
| 176 | }: Props) { |
| 177 | const t = useT(); |
| 178 | const ref = useRef<HTMLDivElement>(null); |
| 179 | const drag = useRef<Drag | null>(null); |
| 180 | const [sorting, setSorting] = useState<number | null>(null); |
| 181 | const [carrying, setCarrying] = useState<number | null>(null); |
| 182 | |
| 183 | const down = (e: React.PointerEvent, i: number) => { |
| 184 | // Capture is what lets a tile keep following a finger once it has left the |
| 185 | // row — without it the events stop the moment the pointer is over something |
| 186 | // else. It throws if the pointer has already gone, which is not a reason to |
| 187 | // drop the whole gesture. |
| 188 | try { |
| 189 | (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); |
| 190 | } catch { |
| 191 | // Carry on uncaptured; a drag inside the row still works. |
| 192 | } |
| 193 | if (!ref.current) return; |
| 194 | drag.current = { |
| 195 | from: i, |
| 196 | cur: i, |
| 197 | startX: e.clientX, |
| 198 | startY: e.clientY, |
| 199 | mode: 'undecided', |
| 200 | outward: outwardOf(ref.current), |
| 201 | samples: [{ x: e.clientX, y: e.clientY, t: e.timeStamp }], |
| 202 | }; |
| 203 | }; |
| 204 | |
| 205 | const move = (e: React.PointerEvent) => { |
| 206 | const d = drag.current; |
| 207 | if (!d || !ref.current) return; |
| 208 | |
| 209 | d.samples.push({ x: e.clientX, y: e.clientY, t: e.timeStamp }); |
| 210 | if (d.samples.length > SAMPLES) d.samples.shift(); |
| 211 | |
| 212 | const dx = e.clientX - d.startX; |
| 213 | const dy = e.clientY - d.startY; |
| 214 | // Signed distance out of the row, and distance along it. Both measured off |
| 215 | // the elements, so this reads the same from any of the four edges. |
| 216 | const out = dx * d.outward.x + dy * d.outward.y; |
| 217 | const along = Math.abs(dx * -d.outward.y + dy * d.outward.x); |
| 218 | const lift = Math.abs(out); |
| 219 | const canLift = !!carry && canDiscard; |
| 220 | |
| 221 | if (d.mode === 'undecided' && Math.hypot(dx, dy) < DRAG_SLOP) return; |
| 222 | |
| 223 | // Which of the two the gesture is is asked *every* move, not decided once |
| 224 | // at the start and stuck with. Deciding once meant a drag that set off a few |
| 225 | // degrees along the row was welded to the row for the rest of its life, and |
| 226 | // the tile could never be got out of the hand at all. |
| 227 | if (canLift) { |
| 228 | if (lift > LIFT_OUT) { |
| 229 | if (d.mode !== 'carry') { |
| 230 | d.mode = 'carry'; |
| 231 | setSorting(null); |
| 232 | setCarrying(d.from); |
| 233 | carry.onPickUp( |
| 234 | d.from, |
| 235 | e.clientX - d.outward.base.x, |
| 236 | e.clientY - d.outward.base.y, |
| 237 | tileWidth(ref.current, d.from), |
| 238 | ); |
| 239 | } |
| 240 | carry.onMoveTo(e.clientX - d.outward.base.x, e.clientY - d.outward.base.y); |
| 241 | return; |
| 242 | } |
| 243 | // Brought back over your own hand: put it down, and it is a tile in a row |
| 244 | // again. The gap between the two thresholds is what stops it flickering |
| 245 | // between the two when a finger sits on the boundary. |
| 246 | if (d.mode === 'carry') { |
| 247 | if (lift > LIFT_OUT * 0.6) return; |
| 248 | carry.onCancel(); |
| 249 | setCarrying(null); |
| 250 | } else if (d.mode === 'undecided' && lift >= along) { |
| 251 | // On its way out but not out yet. Nothing should shuffle in the row |
| 252 | // underneath a tile that is about to leave it. |
| 253 | return; |
| 254 | } |
| 255 | } |
| 256 | |
| 257 | d.mode = 'sort'; |
| 258 | const to = nearestIndex(ref.current, e.clientX, e.clientY); |
| 259 | if (to !== null && to !== d.cur) { |
| 260 | onMove(d.cur, to); |
| 261 | d.cur = to; |
| 262 | } |
| 263 | setSorting(d.cur); |
| 264 | }; |
| 265 | |
| 266 | const up = (e: React.PointerEvent) => { |
| 267 | const d = drag.current; |
| 268 | drag.current = null; |
| 269 | setSorting(null); |
| 270 | setCarrying(null); |
| 271 | if (!d) return; |
| 272 | |
| 273 | if (d.mode === 'carry') { |
| 274 | const v = velocityOf(d.samples, e.timeStamp, e.clientX, e.clientY); |
| 275 | const dx = e.clientX - d.startX; |
| 276 | const dy = e.clientY - d.startY; |
| 277 | const out = dx * d.outward.x + dy * d.outward.y; |
| 278 | const speedOut = v.vx * d.outward.x + v.vy * d.outward.y; |
| 279 | // Thrown if it left the fingers moving towards the table, or if it was |
| 280 | // carried far enough out that letting go there plainly means playing it. |
| 281 | if (speedOut > THROW_SPEED || out > COMMIT_OUT) { |
| 282 | carry?.onThrow(d.from, { |
| 283 | x: e.clientX - d.outward.base.x, |
| 284 | y: e.clientY - d.outward.base.y, |
| 285 | vx: v.vx, |
| 286 | vy: v.vy, |
| 287 | // A flick that curves puts a turn on the tile, as a real one does. |
| 288 | spin: v.spin, |
| 289 | }); |
| 290 | } else { |
| 291 | carry?.onCancel(); |
| 292 | } |
| 293 | return; |
| 294 | } |
| 295 | |
| 296 | if (d.mode === 'sort' || !canDiscard) return; |
| 297 | if (selected === d.from) onDiscard(d.from); |
| 298 | else onSelect(d.from); |
| 299 | }; |
| 300 | |
| 301 | if (faceDown) { |
| 302 | // The whole row is the tap target when it is yours to turn over: sixteen |
| 303 | // tile-sized buttons would be sixteen ways to do one thing. |
| 304 | return ( |
| 305 | <div |
| 306 | className={`hand ${onFaceUp ? 'covered' : ''}`} |
| 307 | role={onFaceUp ? 'button' : undefined} |
| 308 | tabIndex={onFaceUp ? 0 : undefined} |
| 309 | aria-label={onFaceUp ? t('看牌', 'look at your hand', ' · ') : undefined} |
| 310 | onClick={onFaceUp} |
| 311 | onKeyDown={onFaceUp && ((e) => (e.key === 'Enter' || e.key === ' ') && onFaceUp())} |
| 312 | > |
| 313 | {tiles.map((_, i) => ( |
| 314 | <div key={i} className="hand-cell"> |
| 315 | <TileView faceDown /> |
| 316 | </div> |
| 317 | ))} |
| 318 | </div> |
| 319 | ); |
| 320 | } |
| 321 | |
| 322 | return ( |
| 323 | <div className="hand" ref={ref} onPointerMove={move} onPointerUp={up} onPointerCancel={up}> |
| 324 | {tiles.map((tile, i) => ( |
| 325 | <div |
| 326 | key={i} |
| 327 | data-idx={i} |
| 328 | className={ |
| 329 | 'hand-cell' + |
| 330 | (i === drawnIndex ? ' drawn-slot' : '') + |
| 331 | (i === sorting ? ' dragging' : '') + |
| 332 | // Its slot stays, so the row does not shuffle under the tile you are |
| 333 | // holding and you can still put it back where it came from. |
| 334 | (i === carrying ? ' carrying' : '') |
| 335 | } |
| 336 | onPointerDown={(e) => down(e, i)} |
| 337 | > |
| 338 | {i === drawnIndex && <span className="drawn-tag">{t('摸', 'Drew')}</span>} |
| 339 | <TileView tile={tile} raised={selected === i} /> |
| 340 | </div> |
| 341 | ))} |
| 342 | </div> |
| 343 | ); |
| 344 | } |
| 345 | |
| 346 | /** |
| 347 | * How fast the tile was moving when it was let go of, from the last few pointer |
| 348 | * positions rather than just the final one — a single event pair is noisy enough |
| 349 | * to turn a firm throw into a limp one. |
| 350 | */ |
| 351 | function velocityOf(samples: Sample[], t: number, x: number, y: number) { |
| 352 | const last = { x, y, t }; |
| 353 | // Far enough back to be a measurement, near enough to still be the flick. |
| 354 | const first = samples.find((s) => last.t - s.t < 90) ?? samples[0]; |
| 355 | const dt = (last.t - first.t) / 1000; |
| 356 | if (!first || dt <= 0.001) return { vx: 0, vy: 0, spin: 0 }; |
| 357 | |
| 358 | const vx = (last.x - first.x) / dt; |
| 359 | const vy = (last.y - first.y) / dt; |
| 360 | // Spin comes from how much the flick bent between its two halves. |
| 361 | const mid = samples[Math.floor(samples.length / 2)] ?? first; |
| 362 | const bend = |
| 363 | (mid.x - first.x) * (last.y - mid.y) - (mid.y - first.y) * (last.x - mid.x); |
| 364 | return { vx, vy, spin: Math.max(-9, Math.min(9, bend * 0.004)) }; |
| 365 | } |