Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions client/src/hooks/__tests__/tripleTap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";

import {
advanceTripleTap,
initialTripleTapState,
type TripleTapState,
} from "../tripleTap";

/** Feed a sequence of (touchCount, now) touchstarts; return the tap index that triggered, or -1. */
function run(events: Array<[number, number]>): number {
let state: TripleTapState = initialTripleTapState();
for (let i = 0; i < events.length; i++) {
const [count, now] = events[i];
const result = advanceTripleTap(state, count, now);
state = result.state;
if (result.triggered) return i;
}
return -1;
}

describe("advanceTripleTap", () => {
it("triggers only on the third 3-finger tap, not the second", () => {
// Three 3-finger taps within the window.
expect(run([[3, 0], [3, 100], [3, 200]])).toBe(2);
});

it("does not let the 1- and 2-finger touchstarts of a gesture reset progress", () => {
// Each real 3-finger tap arrives as 1-, then 2-, then 3-finger touchstarts.
const events: Array<[number, number]> = [
[1, 0], [2, 5], [3, 10], // tap 1
[1, 100], [2, 105], [3, 110], // tap 2
[1, 200], [2, 205], [3, 210], // tap 3 -> trigger
];
expect(run(events)).toBe(8);
});

it("does not trigger on only two 3-finger taps", () => {
expect(run([[3, 0], [3, 100]])).toBe(-1);
});

it("restarts the sequence when a tap falls outside the window", () => {
// 3rd tap is >500ms after the 2nd, so it counts as a fresh first tap.
expect(run([[3, 0], [3, 100], [3, 700]])).toBe(-1);
});

it("resets after a successful trigger so the next gesture starts fresh", () => {
let state = initialTripleTapState();
for (const now of [0, 100, 200]) {
state = advanceTripleTap(state, 3, now).state;
}
expect(state.count).toBe(0);
});
});
52 changes: 52 additions & 0 deletions client/src/hooks/tripleTap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/** Recognizer for the "N-finger tap, repeated K times within a window" gesture. */

export const TRIPLE_TAP_WINDOW_MS = 500;
export const TRIPLE_TAP_FINGERS = 3;
export const TRIPLE_TAP_COUNT = 3;

export interface TripleTapState {
count: number;
lastTap: number;
}

export function initialTripleTapState(): TripleTapState {
return { count: 0, lastTap: 0 };
}

export interface TripleTapOptions {
fingers?: number;
taps?: number;
windowMs?: number;
}

/**
* Advance the recognizer for a single `touchstart`.
*
* Only a touchstart that reaches exactly `fingers` simultaneous touches counts
* as a tap. The 1- and 2-finger touchstarts that necessarily precede every
* 3-finger gesture (fingers never land on the exact same millisecond) are
* *ignored* — crucially, they must NOT reset progress, or the count can never
* climb past 1 on real hardware. A tap more than `windowMs` after the previous
* one starts a fresh sequence. Returns the next state and whether the full
* gesture (`fingers`-finger tap performed `taps` times) just completed.
*/
export function advanceTripleTap(
state: TripleTapState,
touchCount: number,
now: number,
opts: TripleTapOptions = {},
): { state: TripleTapState; triggered: boolean } {
const fingers = opts.fingers ?? TRIPLE_TAP_FINGERS;
const taps = opts.taps ?? TRIPLE_TAP_COUNT;
const windowMs = opts.windowMs ?? TRIPLE_TAP_WINDOW_MS;

if (touchCount !== fingers) return { state, triggered: false };

const withinWindow = now - state.lastTap <= windowMs;
const count = (withinWindow ? state.count : 0) + 1;

if (count >= taps) {
return { state: { count: 0, lastTap: now }, triggered: true };
}
return { state: { count, lastTap: now }, triggered: false };
}
25 changes: 9 additions & 16 deletions client/src/hooks/useKeyboardShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
copyGameStateDebugSnapshot,
exportGameStateDebugZip,
} from "../services/gameStateExport";
import { advanceTripleTap, initialTripleTapState } from "./tripleTap";

/**
* Registers global keyboard shortcuts for the game.
Expand All @@ -34,24 +35,16 @@ export function useKeyboardShortcuts(): void {

// Triple-tap gesture for debug panel on touch devices (no keyboard)
useEffect(() => {
let tapCount = 0;
let lastTap = 0;
const TAP_WINDOW = 500; // ms between taps
const REQUIRED_FINGERS = 3;
let tapState = initialTripleTapState();

const handler = (e: TouchEvent) => {
if (e.touches.length !== REQUIRED_FINGERS) {
tapCount = 0;
return;
}
const now = Date.now();
if (now - lastTap > TAP_WINDOW) tapCount = 0;
tapCount++;
lastTap = now;
if (tapCount >= 2) {
tapCount = 0;
useUiStore.getState().toggleDebugPanel();
}
const { state, triggered } = advanceTripleTap(
tapState,
e.touches.length,
Date.now(),
);
tapState = state;
if (triggered) useUiStore.getState().toggleDebugPanel();
};

window.addEventListener("touchstart", handler);
Expand Down
Loading