156 lines
5.3 KiB
JavaScript
156 lines
5.3 KiB
JavaScript
// Pointer input. Fingers are tracked by pointerId; pointercancel counts as
|
|
// a lift (Android cancels touches it thinks are palms or system gestures).
|
|
// Browsers may recycle pointerIds, while the game keeps lifted fingers on
|
|
// screen after a round, so every touch gets its own never-reused finger id.
|
|
//
|
|
// Debug mode (?debug=1): mouse clicks place persistent fake fingers.
|
|
// Click a fake finger again to remove it; drag it to move it.
|
|
// Keys: A adds a fake finger at a random spot, C clears all fake fingers.
|
|
|
|
const DRAG_THRESHOLD = 6;
|
|
|
|
export function createInput(canvas, { debug, onDown, onMove, onUp }) {
|
|
const active = new Map(); // pointerId -> finger id, for touches currently down
|
|
let touchSeq = 0;
|
|
const fakes = new Map(); // fake id -> {x, y}
|
|
let fakeSeq = 0;
|
|
let mouse = null; // {pointerId, fakeId, startX, startY, moved, created}
|
|
|
|
const now = () => performance.now();
|
|
const hitRadius = () => Math.max(40, Math.min(64, Math.min(innerWidth, innerHeight) * 0.115));
|
|
|
|
function release(pointerId) {
|
|
const id = active.get(pointerId);
|
|
if (id == null) return;
|
|
active.delete(pointerId);
|
|
onUp(id, now());
|
|
}
|
|
|
|
canvas.addEventListener('pointerdown', (e) => {
|
|
if (e.pointerType === 'mouse') {
|
|
if (debug && e.button === 0) debugDown(e);
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
try {
|
|
canvas.setPointerCapture(e.pointerId);
|
|
} catch {
|
|
/* not fatal */
|
|
}
|
|
release(e.pointerId); // a recycled id whose lift we never saw
|
|
const id = `t${++touchSeq}`;
|
|
active.set(e.pointerId, id);
|
|
onDown(id, e.clientX, e.clientY, now());
|
|
});
|
|
|
|
canvas.addEventListener('pointermove', (e) => {
|
|
if (mouse && e.pointerId === mouse.pointerId) return debugMove(e);
|
|
const id = active.get(e.pointerId);
|
|
if (id != null) onMove(id, e.clientX, e.clientY);
|
|
});
|
|
|
|
const up = (e) => {
|
|
if (mouse && e.pointerId === mouse.pointerId) return debugUp(e);
|
|
release(e.pointerId);
|
|
};
|
|
canvas.addEventListener('pointerup', up);
|
|
canvas.addEventListener('pointercancel', up);
|
|
// Safety net: if capture is lost without an up/cancel, treat it as a lift.
|
|
canvas.addEventListener('lostpointercapture', (e) => {
|
|
if (!mouse || e.pointerId !== mouse.pointerId) release(e.pointerId);
|
|
});
|
|
|
|
// ---- debug: fake fingers with the mouse ----
|
|
|
|
function fakeAt(x, y) {
|
|
const r = hitRadius();
|
|
for (const [id, f] of fakes) if (Math.hypot(f.x - x, f.y - y) < r) return id;
|
|
return null;
|
|
}
|
|
|
|
function addFake(x, y) {
|
|
const id = `fake-${++fakeSeq}`;
|
|
fakes.set(id, { x, y });
|
|
onDown(id, x, y, now());
|
|
return id;
|
|
}
|
|
|
|
function removeFake(id) {
|
|
if (!fakes.delete(id)) return;
|
|
onUp(id, now());
|
|
}
|
|
|
|
function debugDown(e) {
|
|
const hit = fakeAt(e.clientX, e.clientY);
|
|
const fakeId = hit ?? addFake(e.clientX, e.clientY);
|
|
mouse = { pointerId: e.pointerId, fakeId, startX: e.clientX, startY: e.clientY, moved: false, created: !hit };
|
|
try {
|
|
canvas.setPointerCapture(e.pointerId);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
function debugMove(e) {
|
|
if (!mouse.moved && Math.hypot(e.clientX - mouse.startX, e.clientY - mouse.startY) > DRAG_THRESHOLD) mouse.moved = true;
|
|
if (mouse.moved && fakes.has(mouse.fakeId)) {
|
|
fakes.set(mouse.fakeId, { x: e.clientX, y: e.clientY });
|
|
onMove(mouse.fakeId, e.clientX, e.clientY);
|
|
}
|
|
}
|
|
|
|
function debugUp() {
|
|
const m = mouse;
|
|
mouse = null;
|
|
if (!m.created && !m.moved) removeFake(m.fakeId);
|
|
}
|
|
|
|
if (debug) {
|
|
window.addEventListener('keydown', (e) => {
|
|
if (e.target instanceof HTMLInputElement) return;
|
|
if (e.key === 'a' || e.key === 'A') {
|
|
const m = 80;
|
|
addFake(m + Math.random() * (innerWidth - 2 * m), m + Math.random() * (innerHeight - 2 * m));
|
|
} else if (e.key === 'c' || e.key === 'C') {
|
|
for (const id of [...fakes.keys()]) removeFake(id);
|
|
}
|
|
});
|
|
}
|
|
|
|
return {
|
|
/** Forget all touches (page hidden). Fake fingers are kept. */
|
|
clearReal() {
|
|
active.clear();
|
|
},
|
|
/** Place fake fingers in a circle (debug ?fingers=N). */
|
|
addFakeCircle(n) {
|
|
const cx = innerWidth / 2;
|
|
const cy = innerHeight / 2;
|
|
const rad = Math.min(innerWidth, innerHeight) * 0.32;
|
|
for (let i = 0; i < n; i++) {
|
|
const a = -Math.PI / 2 + (i / n) * Math.PI * 2;
|
|
addFake(cx + Math.cos(a) * rad, cy + Math.sin(a) * rad);
|
|
}
|
|
},
|
|
fakeCount: () => fakes.size,
|
|
};
|
|
}
|
|
|
|
/** Stop the browser from scrolling, zooming, selecting or opening menus. */
|
|
export function blockBrowserGestures(canvas) {
|
|
const opts = { passive: false };
|
|
// iOS ignores touch-action for some gestures; cancel touches on the canvas.
|
|
canvas.addEventListener('touchstart', (e) => e.preventDefault(), opts);
|
|
canvas.addEventListener('touchmove', (e) => e.preventDefault(), opts);
|
|
canvas.addEventListener('touchend', (e) => e.preventDefault(), opts);
|
|
// iOS pinch-zoom
|
|
for (const t of ['gesturestart', 'gesturechange', 'gestureend']) document.addEventListener(t, (e) => e.preventDefault(), opts);
|
|
// No long-press menus or text selection anywhere except form fields.
|
|
document.addEventListener('contextmenu', (e) => {
|
|
if (!(e.target instanceof HTMLInputElement)) e.preventDefault();
|
|
});
|
|
document.addEventListener('selectstart', (e) => {
|
|
if (!(e.target instanceof HTMLInputElement)) e.preventDefault();
|
|
});
|
|
document.addEventListener('dblclick', (e) => e.preventDefault(), opts);
|
|
}
|