anvilsign in

collin/mahjong

1import { useRef, useState } from 'react';
2import type { Tile } from '../game/tiles';
3import { TileView } from './TileView';
4
5export interface Throw {
6 /** Where the tile was let go of, in table coordinates. */
7 x: number;
8 y: number;
9 /** How fast, px per second. */
10 vx: number;
11 vy: number;
12 spin: number;
13}
14
15interface Props {
16 tiles: Tile[];
17 /** Somebody else's hand: shown as backs, and not to be poked at. */
18 faceDown?: boolean;
19 /** Index of the tile held apart from the rest, if any. */
20 drawnIndex: number | null;
21 selected: number | null;
22 canDiscard: boolean;
23 onSelect: (i: number) => void;
24 onDiscard: (i: number) => void;
25 onMove: (from: number, to: number) => void;
26 /**
27 * Picking a tile up and carrying it out of the row. Absent means the hand
28 * cannot be thrown from — the phone layout, where there is no middle of the
29 * table to throw into — and the row only ever reorders.
30 */
31 carry?: {
32 /**
33 * Lifted out of the row: from here the canvas draws it, not the DOM. `size`
34 * is the width the tile had in the hand, so it can be drawn the size it just
35 * was and shrink to its size in the pool rather than popping.
36 */
37 onPickUp: (index: number, x: number, y: number, size: number) => void;
38 onMoveTo: (x: number, y: number) => void;
39 /** Let go of over the table. */
40 onThrow: (index: number, t: Throw) => void;
41 /** Put back — the throw was never committed to. */
42 onCancel: () => void;
43 };
44}
45
46/** How far a pointer must travel before a tap becomes a drag. */
47const DRAG_SLOP = 12;
48/** How far out of the row before the tile is genuinely picked up. */
49const LIFT_OUT = 22;
50/**
51 * A release this fast is a throw whatever else is true. Below it, the tile has
52 * to have been carried well clear of the row to count — so a hesitant drag back
53 * over your own hand puts the tile back instead of playing it.
54 */
55const THROW_SPEED = 220;
56const COMMIT_OUT = 70;
57/** Samples kept for reading the speed of a flick. */
58const SAMPLES = 5;
59
60interface Sample {
61 x: number;
62 y: number;
63 t: number;
64}
65
66/**
67 * Finds the tile nearest the pointer. Works whatever the seat's rotation:
68 * bounding rects are always screen-axis-aligned, so we read the row's direction
69 * off the first two tiles rather than assuming it runs left-to-right.
70 */
71function nearestIndex(container: HTMLElement, x: number, y: number): number | null {
72 const centers = rowCenters(container);
73 if (!centers) return null;
74 const { points, horizontal } = centers;
75 const along = (p: { x: number; y: number }) => (horizontal ? p.x : p.y);
76 const p = along({ x, y });
77
78 let best = 0;
79 let bestD = Infinity;
80 points.forEach((c, i) => {
81 const d = Math.abs(along(c) - p);
82 if (d < bestD) {
83 bestD = d;
84 best = i;
85 }
86 });
87 return best;
88}
89
90function rowCenters(container: HTMLElement) {
91 const els = Array.from(container.querySelectorAll<HTMLElement>('[data-idx]'));
92 if (els.length < 2) return null;
93 const points = els.map((el) => {
94 const r = el.getBoundingClientRect();
95 return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
96 });
97 const horizontal = Math.abs(points[1].x - points[0].x) >= Math.abs(points[1].y - points[0].y);
98 return { points, horizontal };
99}
100
101/**
102 * Which way is "out of the row, towards the middle of the table".
103 *
104 * Read off the elements rather than from the seat's rotation, the same way
105 * `nearestIndex` reads the row's direction: the hand and the centre are both on
106 * screen, so the line between them is the answer for any seat at any angle.
107 */
108function outwardOf(container: HTMLElement) {
109 const table = container.closest<HTMLElement>('.table');
110 const hand = container.getBoundingClientRect();
111 const from = { x: hand.left + hand.width / 2, y: hand.top + hand.height / 2 };
112 if (!table) return { x: 0, y: -1, from, base: { x: 0, y: 0 } };
113 const t = table.getBoundingClientRect();
114 const to = { x: t.left + t.width / 2, y: t.top + t.height / 2 };
115 const dx = to.x - from.x;
116 const dy = to.y - from.y;
117 const d = Math.hypot(dx, dy) || 1;
118 return { x: dx / d, y: dy / d, from, base: { x: t.left, y: t.top } };
119}
120
121/**
122 * How wide the tile is in the hand. A seat's strip is rotated by a right angle,
123 * which swaps its bounding box, and a tile is always taller than it is wide — so
124 * the narrower side is the width whichever edge the player is sitting at.
125 */
126function tileWidth(container: HTMLElement, index: number): number {
127 const el = container.querySelector<HTMLElement>(`[data-idx="${index}"] .tile`);
128 if (!el) return 0;
129 const r = el.getBoundingClientRect();
130 return Math.min(r.width, r.height);
131}
132
133type Mode = 'undecided' | 'sort' | 'carry';
134
135interface Drag {
136 from: number;
137 cur: number;
138 startX: number;
139 startY: number;
140 mode: Mode;
141 outward: ReturnType<typeof outwardOf>;
142 samples: Sample[];
143}
144
145export function Hand({
146 tiles,
147 faceDown,
148 drawnIndex,
149 selected,
150 canDiscard,
151 onSelect,
152 onDiscard,
153 onMove,
154 carry,
155}: Props) {
156 const ref = useRef<HTMLDivElement>(null);
157 const drag = useRef<Drag | null>(null);
158 const [sorting, setSorting] = useState<number | null>(null);
159 const [carrying, setCarrying] = useState<number | null>(null);
160
161 const down = (e: React.PointerEvent, i: number) => {
162 (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
163 if (!ref.current) return;
164 drag.current = {
165 from: i,
166 cur: i,
167 startX: e.clientX,
168 startY: e.clientY,
169 mode: 'undecided',
170 outward: outwardOf(ref.current),
171 samples: [{ x: e.clientX, y: e.clientY, t: e.timeStamp }],
172 };
173 };
174
175 const move = (e: React.PointerEvent) => {
176 const d = drag.current;
177 if (!d || !ref.current) return;
178
179 d.samples.push({ x: e.clientX, y: e.clientY, t: e.timeStamp });
180 if (d.samples.length > SAMPLES) d.samples.shift();
181
182 const dx = e.clientX - d.startX;
183 const dy = e.clientY - d.startY;
184 const out = dx * d.outward.x + dy * d.outward.y;
185
186 if (d.mode === 'undecided') {
187 if (Math.hypot(dx, dy) < DRAG_SLOP) return;
188 // Along the row is arranging your hand; out of it is picking the tile up.
189 // Which axis the movement is mostly on decides, once, and it sticks.
190 const along = Math.abs(dx * -d.outward.y + dy * d.outward.x);
191 if (carry && canDiscard && out > along && out > LIFT_OUT) {
192 d.mode = 'carry';
193 setCarrying(d.from);
194 carry.onPickUp(
195 d.from,
196 e.clientX - d.outward.base.x,
197 e.clientY - d.outward.base.y,
198 tileWidth(ref.current, d.from),
199 );
200 return;
201 }
202 if (out > along && out > LIFT_OUT) return; // wants to lift, but cannot
203 d.mode = 'sort';
204 setSorting(d.cur);
205 }
206
207 if (d.mode === 'carry') {
208 carry?.onMoveTo(e.clientX - d.outward.base.x, e.clientY - d.outward.base.y);
209 return;
210 }
211
212 const to = nearestIndex(ref.current, e.clientX, e.clientY);
213 if (to !== null && to !== d.cur) {
214 onMove(d.cur, to);
215 d.cur = to;
216 setSorting(to);
217 }
218 };
219
220 const up = (e: React.PointerEvent) => {
221 const d = drag.current;
222 drag.current = null;
223 setSorting(null);
224 setCarrying(null);
225 if (!d) return;
226
227 if (d.mode === 'carry') {
228 const v = velocityOf(d.samples, e.timeStamp, e.clientX, e.clientY);
229 const dx = e.clientX - d.startX;
230 const dy = e.clientY - d.startY;
231 const out = dx * d.outward.x + dy * d.outward.y;
232 const speedOut = v.vx * d.outward.x + v.vy * d.outward.y;
233 // Thrown if it left the fingers moving towards the table, or if it was
234 // carried far enough out that letting go there plainly means playing it.
235 if (speedOut > THROW_SPEED || out > COMMIT_OUT) {
236 carry?.onThrow(d.from, {
237 x: e.clientX - d.outward.base.x,
238 y: e.clientY - d.outward.base.y,
239 vx: v.vx,
240 vy: v.vy,
241 // A flick that curves puts a turn on the tile, as a real one does.
242 spin: v.spin,
243 });
244 } else {
245 carry?.onCancel();
246 }
247 return;
248 }
249
250 if (d.mode === 'sort' || !canDiscard) return;
251 if (selected === d.from) onDiscard(d.from);
252 else onSelect(d.from);
253 };
254
255 if (faceDown) {
256 return (
257 <div className="hand">
258 {tiles.map((_, i) => (
259 <div key={i} className="hand-cell">
260 <TileView faceDown />
261 </div>
262 ))}
263 </div>
264 );
265 }
266
267 return (
268 <div className="hand" ref={ref} onPointerMove={move} onPointerUp={up} onPointerCancel={up}>
269 {tiles.map((t, i) => (
270 <div
271 key={i}
272 data-idx={i}
273 className={
274 'hand-cell' +
275 (i === drawnIndex ? ' drawn-slot' : '') +
276 (i === sorting ? ' dragging' : '') +
277 // Its slot stays, so the row does not shuffle under the tile you are
278 // holding and you can still put it back where it came from.
279 (i === carrying ? ' carrying' : '')
280 }
281 onPointerDown={(e) => down(e, i)}
282 >
283 {i === drawnIndex && <span className="drawn-tag">摸 Drew</span>}
284 <TileView tile={t} raised={selected === i} />
285 </div>
286 ))}
287 </div>
288 );
289}
290
291/**
292 * How fast the tile was moving when it was let go of, from the last few pointer
293 * positions rather than just the final one — a single event pair is noisy enough
294 * to turn a firm throw into a limp one.
295 */
296function velocityOf(samples: Sample[], t: number, x: number, y: number) {
297 const last = { x, y, t };
298 // Far enough back to be a measurement, near enough to still be the flick.
299 const first = samples.find((s) => last.t - s.t < 90) ?? samples[0];
300 const dt = (last.t - first.t) / 1000;
301 if (!first || dt <= 0.001) return { vx: 0, vy: 0, spin: 0 };
302
303 const vx = (last.x - first.x) / dt;
304 const vy = (last.y - first.y) / dt;
305 // Spin comes from how much the flick bent between its two halves.
306 const mid = samples[Math.floor(samples.length / 2)] ?? first;
307 const bend =
308 (mid.x - first.x) * (last.y - mid.y) - (mid.y - first.y) * (last.x - mid.x);
309 return { vx, vy, spin: Math.max(-9, Math.min(9, bend * 0.004)) };
310}