anvilsign in

collin/mahjong

master / src / ui / flick.ts
1/**
2 * Reading a throw off a finger.
3 *
4 * Two places let go of a tile: out of a hand (`ui/Hand`) and off the pile in
5 * the middle (`ui/Pool`). They are the same gesture, so they read it the same
6 * way. What comes out is the whole of a throw: how fast it left, which way, and
7 * how hard it was turning as it went.
8 */
9
10export interface Sample {
11 x: number;
12 y: number;
13 /** `event.timeStamp`, ms. */
14 t: number;
15}
16
17/** What a throw is, once the flick has been read. Speeds in px/s, spin rad/s. */
18export interface Flick {
19 vx: number;
20 vy: number;
21 spin: number;
22}
23
24/**
25 * How far back a flick is measured, in ms. Far enough back to be a measurement,
26 * near enough that it is still the flick and not the drag before it: a single
27 * event pair is noisy enough to turn a firm throw into a limp one.
28 */
29const WINDOW = 90;
30
31/**
32 * Displacement under this is not a direction, it is jitter. Both halves of the
33 * flick have to clear it before the angle between them means anything.
34 */
35const STRAIGHT = 6;
36
37/**
38 * How much of the flick's own turn the tile keeps.
39 *
40 * A wrist that curves through a throw is turning the tile as well as sending
41 * it, but not with the whole of that rate: the tile is being let go of over the
42 * back half of the curve, not carried round the whole of it. Rather less than
43 * half, by eye.
44 */
45const FROM_CURVE = 0.55;
46
47/** Fastest turn a flick will be read as putting on a tile, rad/s. Beyond about
48 * this the tile is a blur and nobody can see which one it is. */
49const MAX_SPIN = 9;
50
51/**
52 * How fast the tile was moving when it was let go of, and how hard it was
53 * turning, from the last few pointer positions rather than just the final pair.
54 *
55 * The turn is the flick's *curvature*: the angle between the first half of the
56 * stroke and the second, over the time it took. That is a rate, in rad/s, and
57 * is the thing a wrist actually does. It used to be read off the raw area
58 * between the two halves, which has no time in it at all, so a lazy curl and a
59 * hard hooked flick came out the same. A straight flick turns nothing, whatever
60 * speed it was thrown at.
61 */
62export function flickOf(samples: Sample[]): Flick {
63 const last = samples[samples.length - 1];
64 if (!last) return { vx: 0, vy: 0, spin: 0 };
65 const first = samples.find((s) => last.t - s.t < WINDOW) ?? samples[0];
66 const dt = (last.t - first.t) / 1000;
67 if (dt <= 0.001) return { vx: 0, vy: 0, spin: 0 };
68
69 const vx = (last.x - first.x) / dt;
70 const vy = (last.y - first.y) / dt;
71
72 // The stroke in two halves, and the angle it swung through between them.
73 const stroke = samples.slice(samples.indexOf(first));
74 const mid = stroke[Math.floor(stroke.length / 2)] ?? first;
75 const ax = mid.x - first.x;
76 const ay = mid.y - first.y;
77 const bx = last.x - mid.x;
78 const by = last.y - mid.y;
79 if (Math.hypot(ax, ay) < STRAIGHT || Math.hypot(bx, by) < STRAIGHT) {
80 return { vx, vy, spin: 0 };
81 }
82 const swung = Math.atan2(ax * by - ay * bx, ax * bx + ay * by);
83 const spin = (swung / dt) * FROM_CURVE;
84 return { vx, vy, spin: Math.max(-MAX_SPIN, Math.min(MAX_SPIN, spin)) };
85}