anvilsign in

collin/mahjong

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