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