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