mirror of
https://github.com/lhns/steam-frame-nix.git
synced 2026-10-06 08:00:23 +02:00
- gesture-input.js: drop the unused mode getter and release(). - relay.mjs: both sides have a binding; no condition. - hub.js: the frame cadence is documented once (bridge-patch.js); errors listed. - docs: the bridge's idle cost and "one word at a time" stated once; twoHanded's README row points to the docs; rewrapped lines.
177 lines
8.4 KiB
JavaScript
177 lines
8.4 KiB
JavaScript
// gesture-input.js: which input events belong to the gesture in progress
|
|
// (patch.js), and its path from the controller bridge. Evaluates to
|
|
// { VERSION, create }.
|
|
//
|
|
// SteamVR's lasers reach the keyboard page as touches (a trigger press: one
|
|
// touch with a new identifier per press) and as mouse hover (pointerId 1,
|
|
// no hand distinguisher; Chromium's pointer events for touches are cancelled
|
|
// after a few moves). With both controllers on the keyboard the idle one's
|
|
// hover keeps arriving during the other's press. A gesture is therefore owned
|
|
// by the contact that started it: a touch identifier, a pointerId or the
|
|
// mouse. Moves, the release and a cancel of other contacts are ignored, so
|
|
// they never enter the swipe path or end it. A second press while a gesture
|
|
// runs is ignored (Steam handles it as stock); one whose owner is gone
|
|
// (missed release) replaces it.
|
|
//
|
|
// Controller bridge (frame(f): vr-keyboard-controllers' hub.js frames): with
|
|
// both lasers on the keyboard SteamVR forwards per poll only one laser's
|
|
// movement, so the pressing one may get no touchmoves at all. A press is
|
|
// attributed to the hand whose laser hit (ray x/y) was within TOL px of it in
|
|
// the last STALE ms (nearest; trigger as a tiebreak). Without such frames
|
|
// (the bridge sends full rate only on demand, patch.js asks at the press)
|
|
// the first usable frame within WAIT ms decides: the hand nearest to the
|
|
// press or the touch's last move, within LATE px. The path then comes
|
|
// from runs of one source: the owner's touchmoves while they flow (or
|
|
// Chromium's pointer moves of the touch, which precede them), the hand's
|
|
// ray per frame once they pause for GAP ms, and touchmoves again once frames
|
|
// pause. The release is the touch's; its point is the hand's last ray point
|
|
// if the touchend's disagrees (by more than AGREE px) during a bridge run.
|
|
// Touchmoves far from the hand's ray but on the other hand's move the
|
|
// attribution. No fresh frames at the press: touch events only, as without
|
|
// the bridge.
|
|
(() => {
|
|
const VERSION = 3;
|
|
const STALE = 150, GAP = 60, TOL = 30, AGREE = 40, FAR = 60, WAIT = 250, LATE = 60;
|
|
|
|
function create({ now = () => performance.now() } = {}) {
|
|
let owner = null, sawPointer = false;
|
|
// Bridge: recent frames [{ at, f }]; the gesture's hand and source.
|
|
let frames = [];
|
|
let hand = null, mode = 'touch', touchAt = 0, bridgeAt = -Infinity, bridgePt = null;
|
|
let waitUntil = -Infinity, refs = []; // a press not attributed yet: its points
|
|
const stats = { touch: 0, bridge: 0, switches: 0, late: false };
|
|
|
|
// The contacts of an event: [{ id, x, y, target }].
|
|
function contacts(e) {
|
|
if (e.type.startsWith('touch')) {
|
|
const list = e.type === 'touchmove' ? e.touches : e.changedTouches;
|
|
return [...(list || [])].map((t) => ({ id: `t${t.identifier}`, x: t.clientX, y: t.clientY, target: t.target }));
|
|
}
|
|
if (e.type.startsWith('pointer')) {
|
|
sawPointer = true;
|
|
if (e.pointerType === 'touch') return []; // touch events carry these
|
|
return [{ id: `p${e.pointerId}`, x: e.clientX, y: e.clientY, target: e.target }];
|
|
}
|
|
if (sawPointer) return []; // pointer events carry the mouse
|
|
return [{ id: 'm', x: e.clientX, y: e.clientY, target: e.target }];
|
|
}
|
|
const PHASE = {
|
|
touchstart: 'down', pointerdown: 'down', mousedown: 'down',
|
|
touchmove: 'move', pointermove: 'move', mousemove: 'move',
|
|
touchend: 'up', pointerup: 'up', mouseup: 'up',
|
|
touchcancel: 'cancel', pointercancel: 'cancel',
|
|
};
|
|
|
|
const usable = (f) => !!(f?.keyboard && !f.moving);
|
|
const ray = (f, h) => { const r = f?.hands?.[h]?.ray; return r?.onKeyboard ? r : null; };
|
|
const dist = (r, x, y) => Math.hypot(r.x - x, r.y - y);
|
|
const recent = (t) => frames.filter((e) => t - e.at <= STALE && usable(e.f));
|
|
// The hand whose ray in frames fs was nearest to one of the points pts, within tol.
|
|
function attribute(fs, pts, tol) {
|
|
if (!fs.length) return null;
|
|
const near = ['left', 'right'].map((h) => ({
|
|
h, d: Math.min(...fs.flatMap((e) => { const r = ray(e.f, h); return r ? pts.map(([x, y]) => dist(r, x, y)) : [Infinity]; })),
|
|
trig: fs[fs.length - 1].f.hands?.[h]?.trigger ?? null,
|
|
})).filter((c) => c.d <= tol).sort((a, b) => a.d - b.d);
|
|
if (near.length === 2 && near[1].d - near[0].d < 10) {
|
|
const pulled = near.filter((c) => c.trig !== null && c.trig >= 0.5);
|
|
if (pulled.length === 1) return pulled[0].h;
|
|
}
|
|
return near[0]?.h ?? null;
|
|
}
|
|
function lastRay(h, t) {
|
|
const e = frames[frames.length - 1];
|
|
return e && t - e.at <= STALE && usable(e.f) ? ray(e.f, h) : null;
|
|
}
|
|
function reset() { owner = null; hand = null; mode = 'touch'; bridgePt = null; bridgeAt = -Infinity; waitUntil = -Infinity; refs = []; }
|
|
|
|
return {
|
|
VERSION,
|
|
get owner() { return owner; },
|
|
get hand() { return hand; },
|
|
stats,
|
|
// { phase, id, x, y, target } if the event concerns the gesture (or may
|
|
// start one: phase 'down'), else null. An 'up' or 'cancel' ends the
|
|
// ownership. A move during a bridge run: null (the frames carry the path).
|
|
read(e) {
|
|
const phase = PHASE[e.type];
|
|
if (!phase) return null;
|
|
const cs = contacts(e);
|
|
// Chromium's pointer moves of a touch (before its pointercancel): the touch is moving.
|
|
if (e.type === 'pointermove' && e.pointerType === 'touch' && owner?.[0] === 't' && mode === 'touch') touchAt = now();
|
|
if (phase === 'down') {
|
|
// One touch at a time starts a gesture (a multi-touch press is Steam's).
|
|
if (e.type === 'touchstart' && (e.touches?.length ?? 0) > 1) {
|
|
if (owner?.[0] === 't' && ![...e.touches].some((t) => `t${t.identifier}` === owner)) reset();
|
|
return null;
|
|
}
|
|
const c = cs[0];
|
|
if (!c) return null;
|
|
// Busy: ignored, unless the owner is gone (a lone touch, or its own id again).
|
|
if (owner && !(e.type === 'touchstart' || c.id === owner)) return null;
|
|
reset();
|
|
return { phase, ...c };
|
|
}
|
|
if (!owner) return null;
|
|
const c = cs.find((x) => x.id === owner);
|
|
if (!c) return null;
|
|
const t = now();
|
|
if (phase === 'move') {
|
|
if (t < waitUntil) refs[1] = [c.x, c.y];
|
|
if (hand) {
|
|
// A touchmove far from the hand's laser but on the other's: the other hand pressed.
|
|
const other = hand === 'left' ? 'right' : 'left', r = lastRay(hand, t), o = lastRay(other, t);
|
|
if (r && o && dist(r, c.x, c.y) > FAR && dist(o, c.x, c.y) <= TOL) { hand = other; stats.switches++; }
|
|
}
|
|
if (mode === 'bridge') {
|
|
if (t - bridgeAt <= GAP) return null; // frames flowing: they carry the path
|
|
mode = 'touch';
|
|
}
|
|
touchAt = t;
|
|
stats.touch++;
|
|
return { phase, ...c };
|
|
}
|
|
let r = { phase, ...c };
|
|
if (phase === 'up' && mode === 'bridge' && bridgePt && t - bridgeAt <= STALE && Math.hypot(bridgePt[0] - c.x, bridgePt[1] - c.y) > AGREE) {
|
|
r = { ...r, x: bridgePt[0], y: bridgePt[1] };
|
|
}
|
|
reset();
|
|
return r;
|
|
},
|
|
// The 'down' contact starts a gesture; returns the hand it is attributed to (or null).
|
|
claim(c) {
|
|
owner = c.id;
|
|
touchAt = now();
|
|
Object.assign(stats, { touch: 0, bridge: 0, switches: 0, late: false });
|
|
hand = attribute(recent(touchAt), [[c.x, c.y]], TOL);
|
|
if (!hand) { waitUntil = touchAt + WAIT; refs = [[c.x, c.y]]; }
|
|
return hand;
|
|
},
|
|
// A bridge frame (null: bridge lost). Returns [x, y] to add to the path, or null.
|
|
frame(f) {
|
|
const t = now();
|
|
if (!f) { frames = []; return null; }
|
|
frames.push({ at: t, f });
|
|
while (frames.length && t - frames[0].at > STALE) frames.shift();
|
|
if (!owner || !usable(f)) return null;
|
|
if (!hand && t < waitUntil) {
|
|
hand = attribute([{ at: t, f }], refs, LATE);
|
|
if (hand) { waitUntil = -Infinity; stats.late = true; }
|
|
}
|
|
if (!hand) return null;
|
|
const r = f.hands?.[hand]?.ray;
|
|
if (!r) return null;
|
|
bridgeAt = t;
|
|
if (!r.onKeyboard) return null;
|
|
bridgePt = [r.x, r.y];
|
|
if (mode === 'touch' && t - touchAt > GAP) mode = 'bridge';
|
|
if (mode !== 'bridge') return null;
|
|
stats.bridge++;
|
|
return bridgePt;
|
|
},
|
|
};
|
|
}
|
|
|
|
return { VERSION, create };
|
|
})()
|