Release Healer Man 0.1.9
This commit is contained in:
1 parent
c245a199b6
commit
d060296ade
74 files changed
+5901
-334
No files matched your search
@@ -1,8 +1,9 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
emitControllerToken,
|
||||
orderedControllerTokenTransitions,
|
||||
resetControllerState,
|
||||
startControllerInput,
|
||||
subscribeControllerToken,
|
||||
type ControllerTokenEvent,
|
||||
} from "./controller";
|
||||
@@ -67,4 +68,85 @@ describe("controller button phases", () => {
|
||||
{ token: "Button0", repeat: false, pressed: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses low-rate discovery until a gamepad connects and suspends while hidden", () => {
|
||||
vi.useFakeTimers();
|
||||
const windowTarget = new EventTarget() as unknown as Window & typeof globalThis;
|
||||
const documentTarget = new EventTarget() as unknown as Document;
|
||||
let visibilityState: DocumentVisibilityState = "visible";
|
||||
let focused = true;
|
||||
Object.defineProperty(documentTarget, "visibilityState", { get: () => visibilityState });
|
||||
Object.defineProperty(documentTarget, "hasFocus", { value: () => focused });
|
||||
|
||||
let gamepads: Array<Gamepad | null> = [];
|
||||
const getGamepads = vi.fn(() => gamepads as unknown as Gamepad[]);
|
||||
const frameCallbacks = new Map<number, FrameRequestCallback>();
|
||||
let nextFrame = 1;
|
||||
const requestFrame = vi.fn((callback: FrameRequestCallback) => {
|
||||
const frame = nextFrame;
|
||||
nextFrame += 1;
|
||||
frameCallbacks.set(frame, callback);
|
||||
return frame;
|
||||
});
|
||||
const cancelFrame = vi.fn((frame: number) => frameCallbacks.delete(frame));
|
||||
const runFrames = (now: number) => {
|
||||
const callbacks = [...frameCallbacks.values()];
|
||||
frameCallbacks.clear();
|
||||
for (const callback of callbacks) callback(now);
|
||||
};
|
||||
|
||||
vi.stubGlobal("window", windowTarget);
|
||||
vi.stubGlobal("document", documentTarget);
|
||||
vi.stubGlobal("navigator", { getGamepads });
|
||||
vi.stubGlobal("requestAnimationFrame", requestFrame);
|
||||
vi.stubGlobal("cancelAnimationFrame", cancelFrame);
|
||||
|
||||
const stop = startControllerInput();
|
||||
try {
|
||||
expect(frameCallbacks.size).toBe(1);
|
||||
runFrames(0);
|
||||
expect(getGamepads).toHaveBeenCalledTimes(1);
|
||||
expect(frameCallbacks.size).toBe(0);
|
||||
|
||||
vi.advanceTimersByTime(499);
|
||||
expect(frameCallbacks.size).toBe(0);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(frameCallbacks.size).toBe(1);
|
||||
runFrames(500);
|
||||
expect(getGamepads).toHaveBeenCalledTimes(2);
|
||||
expect(frameCallbacks.size).toBe(0);
|
||||
|
||||
gamepads = [{
|
||||
axes: [0, 0, 0, 0],
|
||||
buttons: [],
|
||||
index: 0,
|
||||
} as unknown as Gamepad];
|
||||
windowTarget.dispatchEvent(new Event("gamepadconnected"));
|
||||
expect(frameCallbacks.size).toBe(1);
|
||||
runFrames(501);
|
||||
expect(getGamepads).toHaveBeenCalledTimes(3);
|
||||
expect(frameCallbacks.size).toBe(1);
|
||||
runFrames(517);
|
||||
expect(getGamepads).toHaveBeenCalledTimes(4);
|
||||
expect(frameCallbacks.size).toBe(1);
|
||||
|
||||
visibilityState = "hidden";
|
||||
documentTarget.dispatchEvent(new Event("visibilitychange"));
|
||||
expect(frameCallbacks.size).toBe(0);
|
||||
vi.advanceTimersByTime(1_000);
|
||||
expect(getGamepads).toHaveBeenCalledTimes(4);
|
||||
|
||||
visibilityState = "visible";
|
||||
documentTarget.dispatchEvent(new Event("visibilitychange"));
|
||||
expect(frameCallbacks.size).toBe(1);
|
||||
|
||||
focused = false;
|
||||
windowTarget.dispatchEvent(new Event("blur"));
|
||||
expect(frameCallbacks.size).toBe(0);
|
||||
} finally {
|
||||
stop();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
+120
-27
@@ -25,6 +25,8 @@ const INITIAL_REPEAT_MS = 280;
|
||||
const REPEAT_MS = 90;
|
||||
const AXIS_THRESHOLD = 0.55;
|
||||
const AXIS_STEPS = 128;
|
||||
const GAMEPAD_DISCOVERY_INTERVAL_MS = 500;
|
||||
const REPEATABLE_BUTTON_TOKENS = new Set(["Button12", "Button13", "Button14", "Button15"]);
|
||||
|
||||
const listeners = new Set<TokenListener>();
|
||||
const movementListeners = new Set<MovementListener>();
|
||||
@@ -39,26 +41,38 @@ let stopService: (() => void) | null = null;
|
||||
function connectedGamepad(): Gamepad | null {
|
||||
const gamepads = navigator.getGamepads?.();
|
||||
if (!gamepads) return null;
|
||||
return Array.from(gamepads).find((gamepad): gamepad is Gamepad => gamepad !== null) ?? null;
|
||||
for (let index = 0; index < gamepads.length; index += 1) {
|
||||
const gamepad = gamepads[index];
|
||||
if (gamepad) return gamepad;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tokensFor(gamepad: Gamepad, tokens: Set<string>): void {
|
||||
tokens.clear();
|
||||
gamepad.buttons.forEach((button, index) => {
|
||||
for (let index = 0; index < gamepad.buttons.length; index += 1) {
|
||||
const button = gamepad.buttons[index];
|
||||
if (button.pressed || button.value > 0.65) tokens.add(`Button${index}`);
|
||||
});
|
||||
gamepad.axes.forEach((value, index) => {
|
||||
}
|
||||
for (let index = 0; index < gamepad.axes.length; index += 1) {
|
||||
const value = gamepad.axes[index];
|
||||
if (value <= -AXIS_THRESHOLD) tokens.add(`Axis${index}-`);
|
||||
if (value >= AXIS_THRESHOLD) tokens.add(`Axis${index}+`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function canRepeat(token: string): boolean {
|
||||
return ["Button12", "Button13", "Button14", "Button15"].includes(token)
|
||||
return REPEATABLE_BUTTON_TOKENS.has(token)
|
||||
|| token.startsWith("Axis0")
|
||||
|| token.startsWith("Axis1");
|
||||
}
|
||||
|
||||
function tokenSetsMatch(left: ReadonlySet<string>, right: ReadonlySet<string>): boolean {
|
||||
if (left.size !== right.size) return false;
|
||||
for (const token of left) if (!right.has(token)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isLayerModifier(token: string): boolean {
|
||||
return token === "Button4" || token === "Button6";
|
||||
}
|
||||
@@ -114,14 +128,17 @@ function normalizedAxis(value: number): number {
|
||||
}
|
||||
|
||||
export function setExternalControllerMovement(next: ControllerMovement): void {
|
||||
const normalized = {
|
||||
moveX: normalizedAxis(next.moveX),
|
||||
moveY: normalizedAxis(next.moveY),
|
||||
lookX: normalizedAxis(next.lookX),
|
||||
lookY: normalizedAxis(next.lookY),
|
||||
};
|
||||
if (Object.keys(normalized).every((key) => normalized[key as keyof ControllerMovement] === movement[key as keyof ControllerMovement])) return;
|
||||
movement = normalized;
|
||||
const moveX = normalizedAxis(next.moveX);
|
||||
const moveY = normalizedAxis(next.moveY);
|
||||
const lookX = normalizedAxis(next.lookX);
|
||||
const lookY = normalizedAxis(next.lookY);
|
||||
if (
|
||||
moveX === movement.moveX
|
||||
&& moveY === movement.moveY
|
||||
&& lookX === movement.lookX
|
||||
&& lookY === movement.lookY
|
||||
) return;
|
||||
movement = { moveX, moveY, lookX, lookY };
|
||||
for (const listener of movementListeners) listener(movement);
|
||||
}
|
||||
|
||||
@@ -141,6 +158,8 @@ export function resetControllerState(): void {
|
||||
export function startControllerInput(): () => void {
|
||||
if (stopService) return stopService;
|
||||
|
||||
const webGamepadPolling = !Capacitor.isNativePlatform();
|
||||
|
||||
const onNativeToken = (event: Event) => {
|
||||
const detail = (event as CustomEvent<ControllerTokenEvent>).detail;
|
||||
if (!detail?.token) return;
|
||||
@@ -157,25 +176,63 @@ export function startControllerInput(): () => void {
|
||||
if (detail) setExternalControllerMovement(detail);
|
||||
};
|
||||
const onReset = () => resetControllerState();
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState !== "visible") resetControllerState();
|
||||
};
|
||||
window.addEventListener(NATIVE_TOKEN_EVENT, onNativeToken);
|
||||
window.addEventListener(NATIVE_MOTION_EVENT, onNativeMotion);
|
||||
window.addEventListener(NATIVE_RESET_EVENT, onReset);
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
|
||||
let frame = 0;
|
||||
let discoveryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let hadGamepad = false;
|
||||
let activeGamepadIndex: number | null = null;
|
||||
|
||||
const canPollGamepads = () => document.visibilityState === "visible"
|
||||
&& (typeof document.hasFocus !== "function" || document.hasFocus());
|
||||
|
||||
const cancelScheduledPoll = () => {
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
frame = 0;
|
||||
if (discoveryTimer !== null) clearTimeout(discoveryTimer);
|
||||
discoveryTimer = null;
|
||||
};
|
||||
|
||||
const suspendPolling = () => {
|
||||
cancelScheduledPoll();
|
||||
if (hadGamepad) resetControllerState();
|
||||
hadGamepad = false;
|
||||
activeGamepadIndex = null;
|
||||
};
|
||||
|
||||
const scheduleFrame = () => {
|
||||
if (!webGamepadPolling || frame || !canPollGamepads()) return;
|
||||
if (discoveryTimer !== null) clearTimeout(discoveryTimer);
|
||||
discoveryTimer = null;
|
||||
frame = requestAnimationFrame(poll);
|
||||
};
|
||||
|
||||
const scheduleDiscovery = () => {
|
||||
if (!webGamepadPolling || frame || discoveryTimer !== null || !canPollGamepads()) return;
|
||||
discoveryTimer = setTimeout(() => {
|
||||
discoveryTimer = null;
|
||||
scheduleFrame();
|
||||
}, GAMEPAD_DISCOVERY_INTERVAL_MS);
|
||||
};
|
||||
|
||||
const poll = (now: number) => {
|
||||
const gamepad = typeof document.hasFocus === "function" && !document.hasFocus()
|
||||
? null
|
||||
: connectedGamepad();
|
||||
frame = 0;
|
||||
if (!canPollGamepads()) {
|
||||
suspendPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
const gamepad = connectedGamepad();
|
||||
if (!gamepad) {
|
||||
if (hadGamepad) resetControllerState();
|
||||
hadGamepad = false;
|
||||
activeGamepadIndex = null;
|
||||
scheduleDiscovery();
|
||||
} else {
|
||||
hadGamepad = true;
|
||||
activeGamepadIndex = gamepad.index;
|
||||
setExternalControllerMovement({
|
||||
moveX: gamepad.axes[0] ?? 0,
|
||||
moveY: gamepad.axes[1] ?? 0,
|
||||
@@ -183,9 +240,11 @@ export function startControllerInput(): () => void {
|
||||
lookY: gamepad.axes[3] ?? 0,
|
||||
});
|
||||
tokensFor(gamepad, currentTokens);
|
||||
for (const transition of orderedControllerTokenTransitions(previousTokens, currentTokens)) {
|
||||
emitControllerToken(transition);
|
||||
if (transition.pressed) repeatAt.set(transition.token, now + INITIAL_REPEAT_MS);
|
||||
if (!tokenSetsMatch(previousTokens, currentTokens)) {
|
||||
for (const transition of orderedControllerTokenTransitions(previousTokens, currentTokens)) {
|
||||
emitControllerToken(transition);
|
||||
if (transition.pressed) repeatAt.set(transition.token, now + INITIAL_REPEAT_MS);
|
||||
}
|
||||
}
|
||||
for (const token of currentTokens) {
|
||||
if (!previousTokens.has(token) || !canRepeat(token)) continue;
|
||||
@@ -199,17 +258,51 @@ export function startControllerInput(): () => void {
|
||||
const scratch = previousTokens;
|
||||
previousTokens = currentTokens;
|
||||
currentTokens = scratch;
|
||||
scheduleFrame();
|
||||
}
|
||||
frame = requestAnimationFrame(poll);
|
||||
};
|
||||
if (!Capacitor.isNativePlatform()) frame = requestAnimationFrame(poll);
|
||||
|
||||
const onGamepadConnected = () => scheduleFrame();
|
||||
const onGamepadDisconnected = (event: Event) => {
|
||||
const disconnectedIndex = (event as GamepadEvent).gamepad?.index;
|
||||
if (disconnectedIndex === undefined || disconnectedIndex === activeGamepadIndex) {
|
||||
resetControllerState();
|
||||
hadGamepad = false;
|
||||
activeGamepadIndex = null;
|
||||
}
|
||||
scheduleFrame();
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "visible") scheduleFrame();
|
||||
else {
|
||||
suspendPolling();
|
||||
if (!webGamepadPolling) resetControllerState();
|
||||
}
|
||||
};
|
||||
const onFocus = () => scheduleFrame();
|
||||
const onBlur = () => suspendPolling();
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
if (webGamepadPolling) {
|
||||
window.addEventListener("gamepadconnected", onGamepadConnected);
|
||||
window.addEventListener("gamepaddisconnected", onGamepadDisconnected);
|
||||
window.addEventListener("focus", onFocus);
|
||||
window.addEventListener("blur", onBlur);
|
||||
scheduleFrame();
|
||||
}
|
||||
|
||||
stopService = () => {
|
||||
window.removeEventListener(NATIVE_TOKEN_EVENT, onNativeToken);
|
||||
window.removeEventListener(NATIVE_MOTION_EVENT, onNativeMotion);
|
||||
window.removeEventListener(NATIVE_RESET_EVENT, onReset);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
if (webGamepadPolling) {
|
||||
window.removeEventListener("gamepadconnected", onGamepadConnected);
|
||||
window.removeEventListener("gamepaddisconnected", onGamepadDisconnected);
|
||||
window.removeEventListener("focus", onFocus);
|
||||
window.removeEventListener("blur", onBlur);
|
||||
}
|
||||
cancelScheduledPoll();
|
||||
resetControllerState();
|
||||
stopService = null;
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user