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