1import { browserPointerLockLossRequiresControlExit } from './input-state.mjs';2import { ControllerActivationGate, neutralGamepadInputState } from './controller-state.mjs';34export const BROWSER_POINTER_LOCK_TIMEOUT_MS = 500;5export const NATIVE_CURSOR_RELEASE_RETRY_DELAYS_MS = Object.freeze([0, 16, 50]);67export function createControlRuntime({8 getConnection,9 inputRuntime,10 invokeCursorGrab,11 pointerLock,12 publishController,13 resetControllerEscape,14 onChange,15 onReleaseFailure,16 pointerLockTimeoutMs = BROWSER_POINTER_LOCK_TIMEOUT_MS,17 scheduleTimeout = globalThis.setTimeout,18 cancelTimeout = globalThis.clearTimeout,19 logger = console,20} = {}) {21 if (typeof getConnection !== 'function') throw new TypeError('getConnection must be a function');22 if (!inputRuntime || typeof inputRuntime.send !== 'function') {23 throw new TypeError('inputRuntime must provide input queue operations');24 }25 if (26 typeof inputRuntime.clear !== 'function'27 || typeof inputRuntime.releaseHeld !== 'function'28 || typeof inputRuntime.drain !== 'function'29 ) {30 throw new TypeError('inputRuntime must provide clear, release, and drain operations');31 }32 if (typeof invokeCursorGrab !== 'function') throw new TypeError('invokeCursorGrab must be a function');33 if (!pointerLock || typeof pointerLock.getOwner !== 'function') {34 throw new TypeError('pointerLock must provide an ownership surface');35 }36 if (37 typeof pointerLock.eventTarget?.addEventListener !== 'function'38 || typeof pointerLock.eventTarget?.removeEventListener !== 'function'39 ) {40 throw new TypeError('pointerLock must provide an event target');41 }42 if (typeof publishController !== 'function') throw new TypeError('publishController must be a function');43 if (typeof resetControllerEscape !== 'function') {44 throw new TypeError('resetControllerEscape must be a function');45 }46 if (typeof onChange !== 'function') throw new TypeError('onChange must be a function');47 if (typeof onReleaseFailure !== 'function') {48 throw new TypeError('onReleaseFailure must be a function');49 }5051 const controllerActivationGate = new ControllerActivationGate();52 let controlMode = false;53 let controlTransitionInProgress = false;54 let controlTransitionGeneration = 0;55 let nativeCursorCommand = Promise.resolve();56 let browserPointerLockRequired = true;5758 function connection() {59 return getConnection();60 }6162 function queueNeutralControllerState() {63 const state = connection();64 if (!state.connected || !state.capabilities.gamepad) return false;65 return inputRuntime.send({ t: 'gp', state: neutralGamepadInputState() });66 }6768 function setNativeCursorGrab(grab) {69 const command = nativeCursorCommand.then(() => invokeCursorGrab(grab));70 nativeCursorCommand = command.catch(() => {});71 return command;72 }7374 function waitForBrowserPointerLockOwnership(timeoutMs = pointerLockTimeoutMs) {75 if (pointerLock.getOwner() === pointerLock.target) return Promise.resolve();76 return new Promise((resolve, reject) => {77 let timeoutId;78 const cleanup = () => {79 cancelTimeout(timeoutId);80 pointerLock.eventTarget.removeEventListener('pointerlockchange', handleChange);81 pointerLock.eventTarget.removeEventListener('pointerlockerror', handleError);82 };83 const settle = (callback, value) => {84 cleanup();85 callback(value);86 };87 const handleChange = () => {88 if (pointerLock.getOwner() === pointerLock.target) {89 settle(resolve);90 } else {91 settle(reject, new Error('browser Pointer Lock ownership was lost'));92 }93 };94 const handleError = () => {95 settle(reject, new Error('browser Pointer Lock request was rejected'));96 };97 pointerLock.eventTarget.addEventListener('pointerlockchange', handleChange);98 pointerLock.eventTarget.addEventListener('pointerlockerror', handleError);99 timeoutId = scheduleTimeout(() => {100 settle(reject, new Error('browser Pointer Lock ownership timed out'));101 }, timeoutMs);102 });103 }104105 async function releaseNativeCursorGrab(releaseGeneration) {106 let lastError = null;107 for (const delayMs of NATIVE_CURSOR_RELEASE_RETRY_DELAYS_MS) {108 if (releaseGeneration !== controlTransitionGeneration || controlMode) return false;109 if (delayMs > 0) {110 await new Promise((resolve) => scheduleTimeout(resolve, delayMs));111 if (releaseGeneration !== controlTransitionGeneration || controlMode) return false;112 }113 try {114 await setNativeCursorGrab(false);115 return true;116 } catch (error) {117 lastError = error;118 }119 }120 logger.error('native cursor release failed after bounded retries:', lastError);121 if (!controlMode && releaseGeneration === controlTransitionGeneration) {122 onReleaseFailure(lastError);123 }124 return false;125 }126127 function releasePointerLock() {128 const releaseGeneration = controlTransitionGeneration;129 void releaseNativeCursorGrab(releaseGeneration);130 if (131 pointerLock.getOwner() !== pointerLock.target132 || typeof pointerLock.exit !== 'function'133 ) return;134 try { pointerLock.exit(); } catch (_) {}135 }136137 function exit({ resetEscape = false } = {}) {138 controlTransitionGeneration += 1;139 controlTransitionInProgress = false;140 controllerActivationGate.reset();141 if (resetEscape) resetControllerEscape();142 queueNeutralControllerState();143 inputRuntime.releaseHeld();144 controlMode = false;145 releasePointerLock();146 onChange();147 }148149 function exitAfterBrowserPointerLockFailure() {150 if (!browserPointerLockRequired || (!controlMode && !controlTransitionInProgress)) {151 return false;152 }153 exit({ resetEscape: true });154 return true;155 }156157 async function requestBrowserPointerLock() {158 if (!browserPointerLockRequired) return;159 try {160 if (typeof pointerLock.request !== 'function') {161 throw new Error('browser Pointer Lock is unavailable');162 }163 const request = pointerLock.request();164 if (request && typeof request.then === 'function') await request;165 await waitForBrowserPointerLockOwnership();166 // Pointer Lock and the native window grab are separate on Linux. Reassert167 // the native state only after the browser proves ownership.168 await setNativeCursorGrab(true);169 } catch (error) {170 exitAfterBrowserPointerLockFailure();171 throw error;172 }173 }174175 async function requestRelativePointerLock() {176 if (!connection().capabilities.relativePointer) return true;177 try {178 const nativeResult = await setNativeCursorGrab(true);179 // Older commands returned no value, so only an explicit false disables180 // the browser fallback.181 browserPointerLockRequired = nativeResult !== false;182 await requestBrowserPointerLock();183 return true;184 } catch (error) {185 logger.warn('relative pointer lock unavailable:', error);186 return false;187 }188 }189190 async function toggle({ controllerInitiated = false } = {}) {191 const initial = connection();192 if (!initial.connected || !initial.capabilities.control || controlTransitionInProgress) return;193 if (controlMode) {194 exit();195 return;196 }197198 const transitionGeneration = controlTransitionGeneration + 1;199 controlTransitionGeneration = transitionGeneration;200 controlTransitionInProgress = true;201 if (controllerInitiated) controllerActivationGate.arm();202 const acquired = await requestRelativePointerLock();203 const ownsTransition = transitionGeneration === controlTransitionGeneration;204 if (!ownsTransition) {205 // A cancellation may have released before a late browser request took206 // ownership. Do not disturb a newer transition, but do not leave an207 // orphaned lock behind when control is otherwise idle.208 if (209 !controlMode210 && !controlTransitionInProgress211 && pointerLock.getOwner() === pointerLock.target212 ) {213 releasePointerLock();214 }215 return;216 }217 const current = connection();218 if (!acquired219 || current.disconnecting220 || !current.connected221 || !current.capabilities.control) {222 exitAfterBrowserPointerLockFailure();223 return;224 }225 controlTransitionInProgress = false;226 controlMode = true;227 // Preserve the distinct controller branch: it adds a neutral state before228 // republishing the current controller snapshot through the activation gate.229 if (controllerInitiated) {230 queueNeutralControllerState();231 publishController();232 } else {233 publishController();234 }235 onChange();236 }237238 function handleBrowserPointerLockChange() {239 if (!browserPointerLockLossRequiresControlExit({240 browserPointerLockRequired,241 pointerLockElement: pointerLock.getOwner(),242 expectedElement: pointerLock.target,243 controlMode,244 controlTransitionInProgress,245 })) return false;246 return exitAfterBrowserPointerLockFailure();247 }248249 async function prepareDisconnect(timeoutMs = 250) {250 exit();251 await inputRuntime.drain(timeoutMs);252 }253254 function resetAcceptedConnection() {255 controlMode = false;256 inputRuntime.clear();257 }258259 function resetDisconnected() {260 controlMode = false;261 inputRuntime.clear();262 onChange();263 }264265 function setInactiveIfUnavailable(available) {266 if (!available) controlMode = false;267 return controlMode;268 }269270 return Object.freeze({271 acceptsControllerInput: (state) => controllerActivationGate.accepts(state),272 exit,273 handleBrowserPointerLockChange,274 prepareDisconnect,275 queueNeutralControllerState,276 reassertNativeGrab: () => setNativeCursorGrab(true),277 releasePointerLock,278 requestBrowserPointerLock,279 resetAcceptedConnection,280 resetControllerActivation: () => controllerActivationGate.reset(),281 resetDisconnected,282 setInactiveIfUnavailable,283 toggle,284 get active() { return controlMode; },285 get activationGateActive() { return controllerActivationGate.active; },286 get browserPointerLockRequired() { return browserPointerLockRequired; },287 get generation() { return controlTransitionGeneration; },288 get transitioning() { return controlTransitionInProgress; },289 });290}