anvilsign in

collin/mahjong

master / src / ui / Hand.tsx
1import { useRef, useState } from 'react';
2import type { Tile } from '../game/tiles';
3import { flickOf, type Sample } from './flick';
4import { useT } from './lang';
5import { TileView } from './TileView';
6
7export 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
17interface 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 */
48export 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. */
63const 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 */
70const 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 */
76const THROW_SPEED = 220;
77const COMMIT_OUT = 70;
78/** Samples kept for reading the speed of a flick. */
79const 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 */
86function nearestIndex(container: HTMLElement, x: number, y: number): number | null {
87 const centers = rowCenters(container);
88 if (!centers) return null;
89 const { points, horizontal, lines } = centers;
90
91 // One line of tiles is a line: the only question a finger is asking is how
92 // far along it, and measuring the other axis as well would fight a drag that
93 // strays off the row on its way past. Broken into lines — the phone held
94 // upright — that is exactly the wrong answer, because the tile above the
95 // finger and the tile below it are the same distance along and only one of
96 // them is being pointed at.
97 const dist = horizontal
98 ? (p: { x: number; y: number }) => Math.abs(p.x - x)
99 : (p: { x: number; y: number }) => Math.abs(p.y - y);
100 const measure = lines > 1 ? (p: { x: number; y: number }) => Math.hypot(p.x - x, p.y - y) : dist;
101
102 let best = 0;
103 let bestD = Infinity;
104 points.forEach((c, i) => {
105 const d = measure(c);
106 if (d < bestD) {
107 bestD = d;
108 best = i;
109 }
110 });
111 return best;
112}
113
114function rowCenters(container: HTMLElement) {
115 const els = Array.from(container.querySelectorAll<HTMLElement>('[data-idx]'));
116 if (els.length < 2) return null;
117 const points = els.map((el) => {
118 const r = el.getBoundingClientRect();
119 return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
120 });
121 const horizontal = Math.abs(points[1].x - points[0].x) >= Math.abs(points[1].y - points[0].y);
122 // How many rows the tiles are laid out in — one at a table, two on a phone
123 // held upright. Counted off the elements rather than off the layout that put
124 // them there, so it is right whoever moves them next.
125 const across = horizontal ? points.map((p) => p.y) : points.map((p) => p.x);
126 const lines = new Set(across.map((v) => Math.round(v / 8))).size;
127 return { points, horizontal, lines };
128}
129
130/**
131 * Which way is "out of the row, towards the middle of the table".
132 *
133 * Read off the elements rather than from the seat's rotation, the same way
134 * `nearestIndex` reads the row's direction: the hand and the centre are both on
135 * screen, so the line between them is the answer for any seat at any angle.
136 */
137function outwardOf(container: HTMLElement) {
138 const table = container.closest<HTMLElement>('.table');
139 const hand = container.getBoundingClientRect();
140 const from = { x: hand.left + hand.width / 2, y: hand.top + hand.height / 2 };
141 if (!table) return { x: 0, y: -1, from, base: { x: 0, y: 0 } };
142 const t = table.getBoundingClientRect();
143 const to = { x: t.left + t.width / 2, y: t.top + t.height / 2 };
144 const dx = to.x - from.x;
145 const dy = to.y - from.y;
146 const d = Math.hypot(dx, dy) || 1;
147 return { x: dx / d, y: dy / d, from, base: { x: t.left, y: t.top } };
148}
149
150/**
151 * How wide the tile is in the hand. A seat's strip is rotated by a right angle,
152 * which swaps its bounding box, and a tile is always taller than it is wide — so
153 * the narrower side is the width whichever edge the player is sitting at.
154 */
155function tileWidth(container: HTMLElement, index: number): number {
156 const el = container.querySelector<HTMLElement>(`[data-idx="${index}"] .tile`);
157 if (!el) return 0;
158 const r = el.getBoundingClientRect();
159 return Math.min(r.width, r.height);
160}
161
162type Mode = 'undecided' | 'sort' | 'carry';
163
164interface Drag {
165 from: number;
166 cur: number;
167 startX: number;
168 startY: number;
169 mode: Mode;
170 outward: ReturnType<typeof outwardOf>;
171 samples: Sample[];
172}
173
174export function Hand({
175 tiles,
176 faceDown,
177 onFaceUp,
178 drawnIndex,
179 selected,
180 canDiscard,
181 onSelect,
182 onDiscard,
183 onMove,
184 carry,
185}: Props) {
186 const t = useT();
187 const ref = useRef<HTMLDivElement>(null);
188 const drag = useRef<Drag | null>(null);
189 const [sorting, setSorting] = useState<number | null>(null);
190 const [carrying, setCarrying] = useState<number | null>(null);
191
192 const down = (e: React.PointerEvent, i: number) => {
193 // Capture is what lets a tile keep following a finger once it has left the
194 // row — without it the events stop the moment the pointer is over something
195 // else. It throws if the pointer has already gone, which is not a reason to
196 // drop the whole gesture.
197 try {
198 (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
199 } catch {
200 // Carry on uncaptured; a drag inside the row still works.
201 }
202 if (!ref.current) return;
203 drag.current = {
204 from: i,
205 cur: i,
206 startX: e.clientX,
207 startY: e.clientY,
208 mode: 'undecided',
209 outward: outwardOf(ref.current),
210 samples: [{ x: e.clientX, y: e.clientY, t: e.timeStamp }],
211 };
212 };
213
214 const move = (e: React.PointerEvent) => {
215 const d = drag.current;
216 if (!d || !ref.current) return;
217
218 d.samples.push({ x: e.clientX, y: e.clientY, t: e.timeStamp });
219 if (d.samples.length > SAMPLES) d.samples.shift();
220
221 const dx = e.clientX - d.startX;
222 const dy = e.clientY - d.startY;
223 // Signed distance out of the row, and distance along it. Both measured off
224 // the elements, so this reads the same from any of the four edges.
225 const out = dx * d.outward.x + dy * d.outward.y;
226 const along = Math.abs(dx * -d.outward.y + dy * d.outward.x);
227 const lift = Math.abs(out);
228 const canLift = !!carry && canDiscard;
229
230 if (d.mode === 'undecided' && Math.hypot(dx, dy) < DRAG_SLOP) return;
231
232 // Which of the two the gesture is is asked *every* move, not decided once
233 // at the start and stuck with. Deciding once meant a drag that set off a few
234 // degrees along the row was welded to the row for the rest of its life, and
235 // the tile could never be got out of the hand at all.
236 if (canLift) {
237 if (lift > LIFT_OUT) {
238 if (d.mode !== 'carry') {
239 d.mode = 'carry';
240 setSorting(null);
241 setCarrying(d.from);
242 carry.onPickUp(
243 d.from,
244 e.clientX - d.outward.base.x,
245 e.clientY - d.outward.base.y,
246 tileWidth(ref.current, d.from),
247 );
248 }
249 carry.onMoveTo(e.clientX - d.outward.base.x, e.clientY - d.outward.base.y);
250 return;
251 }
252 // Brought back over your own hand: put it down, and it is a tile in a row
253 // again. The gap between the two thresholds is what stops it flickering
254 // between the two when a finger sits on the boundary.
255 if (d.mode === 'carry') {
256 if (lift > LIFT_OUT * 0.6) return;
257 carry.onCancel();
258 setCarrying(null);
259 } else if (d.mode === 'undecided' && lift >= along) {
260 // On its way out but not out yet. Nothing should shuffle in the row
261 // underneath a tile that is about to leave it.
262 return;
263 }
264 }
265
266 d.mode = 'sort';
267 const to = nearestIndex(ref.current, e.clientX, e.clientY);
268 if (to !== null && to !== d.cur) {
269 onMove(d.cur, to);
270 d.cur = to;
271 }
272 setSorting(d.cur);
273 };
274
275 const up = (e: React.PointerEvent) => {
276 const d = drag.current;
277 drag.current = null;
278 setSorting(null);
279 setCarrying(null);
280 if (!d) return;
281
282 if (d.mode === 'carry') {
283 d.samples.push({ x: e.clientX, y: e.clientY, t: e.timeStamp });
284 const v = flickOf(d.samples);
285 const dx = e.clientX - d.startX;
286 const dy = e.clientY - d.startY;
287 const out = dx * d.outward.x + dy * d.outward.y;
288 const speedOut = v.vx * d.outward.x + v.vy * d.outward.y;
289 // Thrown if it left the fingers moving towards the table, or if it was
290 // carried far enough out that letting go there plainly means playing it.
291 if (speedOut > THROW_SPEED || out > COMMIT_OUT) {
292 carry?.onThrow(d.from, {
293 x: e.clientX - d.outward.base.x,
294 y: e.clientY - d.outward.base.y,
295 vx: v.vx,
296 vy: v.vy,
297 // A flick that curves puts a turn on the tile, as a real one does.
298 spin: v.spin,
299 });
300 } else {
301 carry?.onCancel();
302 }
303 return;
304 }
305
306 if (d.mode === 'sort' || !canDiscard) return;
307 if (selected === d.from) onDiscard(d.from);
308 else onSelect(d.from);
309 };
310
311 if (faceDown) {
312 // The whole row is the tap target when it is yours to turn over: sixteen
313 // tile-sized buttons would be sixteen ways to do one thing.
314 return (
315 <div
316 className={`hand ${onFaceUp ? 'covered' : ''}`}
317 role={onFaceUp ? 'button' : undefined}
318 tabIndex={onFaceUp ? 0 : undefined}
319 aria-label={onFaceUp ? t('看牌', 'look at this hand', ' · ') : undefined}
320 onClick={onFaceUp}
321 onKeyDown={onFaceUp && ((e) => (e.key === 'Enter' || e.key === ' ') && onFaceUp())}
322 >
323 {tiles.map((_, i) => (
324 <div key={i} className="hand-cell">
325 <TileView faceDown />
326 </div>
327 ))}
328 </div>
329 );
330 }
331
332 // How many tiles go in a row when the row has to become two — a phone held
333 // upright. Half the sorted hand, rounded up, so the two lines come out as
334 // even as an odd hand allows; the tile just drawn is not counted, because it
335 // is given a line of its own above them. Read by the portrait rules in
336 // styles.css and ignored everywhere else.
337 const cols = Math.max(1, Math.ceil((tiles.length - (drawnIndex === null ? 0 : 1)) / 2));
338
339 return (
340 <div
341 className="hand"
342 ref={ref}
343 style={{ '--hand-cols': cols } as React.CSSProperties}
344 onPointerMove={move}
345 onPointerUp={up}
346 onPointerCancel={up}
347 >
348 {tiles.map((tile, i) => (
349 <div
350 key={i}
351 data-idx={i}
352 className={
353 'hand-cell' +
354 (i === drawnIndex ? ' drawn-slot' : '') +
355 (i === sorting ? ' dragging' : '') +
356 // Its slot stays, so the row does not shuffle under the tile you are
357 // holding and you can still put it back where it came from.
358 (i === carrying ? ' carrying' : '')
359 }
360 onPointerDown={(e) => down(e, i)}
361 >
362 {i === drawnIndex && <span className="drawn-tag">{t('摸', 'Drew')}</span>}
363 <TileView tile={tile} raised={selected === i} />
364 </div>
365 ))}
366 </div>
367 );
368}