anvilsign in

collin/mahjong

1import { useRef, useState } from 'react';
2import type { Tile } from '../game/tiles';
3import { TileView } from './TileView';
4
5interface Props {
6 tiles: Tile[];
7 /** Index of the tile held apart from the rest, if any. */
8 drawnIndex: number | null;
9 selected: number | null;
10 canDiscard: boolean;
11 onSelect: (i: number) => void;
12 onDiscard: (i: number) => void;
13 onMove: (from: number, to: number) => void;
14}
15
16/** How far a pointer must travel before a tap becomes a drag. */
17const DRAG_SLOP = 12;
18
19/**
20 * Finds the tile nearest the pointer. Works whatever the seat's rotation:
21 * bounding rects are always screen-axis-aligned, so we read the row's direction
22 * off the first two tiles rather than assuming it runs left-to-right.
23 */
24function nearestIndex(container: HTMLElement, x: number, y: number): number | null {
25 const els = Array.from(container.querySelectorAll<HTMLElement>('[data-idx]'));
26 if (els.length < 2) return null;
27 const centers = els.map((el) => {
28 const r = el.getBoundingClientRect();
29 return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
30 });
31 const horizontal = Math.abs(centers[1].x - centers[0].x) >= Math.abs(centers[1].y - centers[0].y);
32 const along = (p: { x: number; y: number }) => (horizontal ? p.x : p.y);
33 const p = along({ x, y });
34
35 let best = 0;
36 let bestD = Infinity;
37 centers.forEach((c, i) => {
38 const d = Math.abs(along(c) - p);
39 if (d < bestD) {
40 bestD = d;
41 best = i;
42 }
43 });
44 return best;
45}
46
47export function Hand({
48 tiles,
49 drawnIndex,
50 selected,
51 canDiscard,
52 onSelect,
53 onDiscard,
54 onMove,
55}: Props) {
56 const ref = useRef<HTMLDivElement>(null);
57 const drag = useRef<{ from: number; cur: number; x: number; y: number; moved: boolean } | null>(null);
58 const [dragging, setDragging] = useState<number | null>(null);
59
60 const down = (e: React.PointerEvent, i: number) => {
61 (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
62 drag.current = { from: i, cur: i, x: e.clientX, y: e.clientY, moved: false };
63 };
64
65 const move = (e: React.PointerEvent) => {
66 const d = drag.current;
67 if (!d || !ref.current) return;
68 if (!d.moved && Math.hypot(e.clientX - d.x, e.clientY - d.y) < DRAG_SLOP) return;
69 if (!d.moved) {
70 d.moved = true;
71 setDragging(d.cur);
72 }
73 const to = nearestIndex(ref.current, e.clientX, e.clientY);
74 if (to !== null && to !== d.cur) {
75 onMove(d.cur, to);
76 d.cur = to;
77 setDragging(to);
78 }
79 };
80
81 const up = () => {
82 const d = drag.current;
83 drag.current = null;
84 setDragging(null);
85 if (!d || d.moved || !canDiscard) return;
86 if (selected === d.from) onDiscard(d.from);
87 else onSelect(d.from);
88 };
89
90 return (
91 <div className="hand" ref={ref} onPointerMove={move} onPointerUp={up} onPointerCancel={up}>
92 {tiles.map((t, i) => (
93 <div
94 key={i}
95 data-idx={i}
96 className={
97 'hand-cell' +
98 (i === drawnIndex ? ' drawn-slot' : '') +
99 (i === dragging ? ' dragging' : '')
100 }
101 onPointerDown={(e) => down(e, i)}
102 >
103 {i === drawnIndex && <span className="drawn-tag">摸 Drew</span>}
104 <TileView tile={t} raised={selected === i} />
105 </div>
106 ))}
107 </div>
108 );
109}