diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a01951a..527a386 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -214,8 +214,12 @@ WebXR support enables VR gameplay on headsets like Meta Quest 2. Phase 1 impleme - **initXR()** - Detects WebXR support (`navigator.xr.isSessionSupported('immersive-vr')`) - **toggleXRSession(renderer)** - Creates/ends XR session and updates renderer - **updateXRControllerInput()** - Reads gamepad data from input sources each frame -- **getXRControllerInput()** - Returns { leftThumbstick, rightThumbstick, rightTrigger } -- **xrState** - Global state tracking: enabled flag, head pose, controller map +- **getXRControllerInput()** - Returns thumbsticks, thumbstick-button state, trigger, + grip, A, and B inputs +- **getXRActionInput()** - Returns the capability-gated movement, turn, fire, and + jump actions used by gameplay +- **xrState** - Global state tracking: enabled flag, head pose, controller map, + visibility, and physical-input readiness ### Integration Points @@ -228,9 +232,10 @@ WebXR support enables VR gameplay on headsets like Meta Quest 2. Phase 1 impleme **input.js:** - `updateVirtualInputFromXR()` - Maps controller input to virtualInput: - - Left thumbstick Y → forward/backward - - Right thumbstick X → turn left/right - - Right trigger → fire (Phase 2) + - Right thumbstick Y → forward/backward, with left-stick fallback + - Right thumbstick X → turn left/right, with left-stick fallback + - Right trigger or A button → fire + - B button or grip → jump **client.js:** - XR button added to settings HUD (enabled/disabled based on support) @@ -238,6 +243,8 @@ WebXR support enables VR gameplay on headsets like Meta Quest 2. Phase 1 impleme - `updateXRControllerInput()` called each frame before handleInputEvents - `toggleXRSession()` triggered by XR button click - First-person camera mode automatically enabled when entering VR +- XR gameplay ignores keyboard, mouse, touch, and desktop-gamepad input until + a physical device covers all required actions **index.html:** - XR button added to settings: `id="xrBtn" title="Enter WebXR VR Mode"` @@ -246,25 +253,27 @@ WebXR support enables VR gameplay on headsets like Meta Quest 2. Phase 1 impleme | Input | Binding | Effect | |-------|---------|--------| -| Left Thumbstick Up/Down | Axes 1 | Forward/Backward movement | -| Right Thumbstick Left/Right | Axes 2 | Tank rotation | -| Right Trigger | Button 0 | Fire (Phase 2) | +| Right Thumbstick Up/Down | Axes 3 (fallback: left axes 1) | Forward/Backward movement | +| Right Thumbstick Left/Right | Axes 2 (fallback: left axes 0) | Tank rotation | +| Right Trigger or A | Buttons 0 or 4 | Fire | +| B or grip | Buttons 5 or 1 | Jump | +| Either thumbstick press | Button 3 | Exit VR and open Settings | ## How It Works (Phase 1) -1. User clicks VR Mode button on Quest 2 -2. Browser requests immersive-vr session +1. User clicks VR Mode button on a supported headset +2. Browser requests the native XR session 3. Renderer switches to stereo rendering 4. Each frame: - Controller thumbstick positions read from gamepad input sources - - Converted to virtualInput (forward, turn) + - Converted to capability-gated virtualInput (forward, turn, fire, jump) - Used by handleInputEvents for movement - Tank rotation independent of head direction - Three.js automatically positions camera for stereo view + head tracking ## Future Work (Phase 2+) -- **Phase 2:** Trigger button for firing (direction = tank facing, not head) +- **Phase 2:** Cross-device controller mapping and comfort settings - **Phase 3:** Hand tracking, comfort settings, snap turning option - **Phase 4:** VR-optimized UI, voice commands, controller haptics feedback diff --git a/README.md b/README.md index af7a927..ded9c51 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,12 @@ For remote access, terminate TLS at the reverse proxy and open the game over `https://`; the client automatically uses `wss://` for its WebSocket connection when the page is served over HTTPS. +XR gameplay uses physical input. Upstream Quest bindings use the right +thumbstick for movement and turning, with the left stick as a fallback; the +right trigger or A fires, B or grip jumps, and pressing either thumbstick exits +VR and opens Settings. One controller is accepted when it covers movement, +turning, firing, and jumping; a two-controller setup remains preferred. + If the deployment sets a restrictive `Permissions-Policy` header, allow `xr-spatial-tracking=(self)`. The Node.js server does not terminate TLS itself, so HTTPS and the corresponding WebSocket proxy configuration are deployment diff --git a/docs/webxr-validation.md b/docs/webxr-validation.md index 754e13d..bede5dc 100644 --- a/docs/webxr-validation.md +++ b/docs/webxr-validation.md @@ -29,13 +29,21 @@ or deployment configuration. ## Controller mapping -- Move the left thumbstick forward and backward; the tank should move along its - current heading. +- Connect a physical controller before expecting XR gameplay input. A complete + single controller is accepted; otherwise the preferred pair is left/right. +- Move the right thumbstick forward and backward; the tank should move along + its current heading. The left stick is the fallback when the right axis is + unavailable. - Move the right thumbstick left and right; the tank should rotate without - changing its heading from head movement. + changing its heading from head movement. The left stick is the fallback when + the right axis is unavailable. - Press the right trigger or A button to fire. - Press the B button or grip button to jump. +- Press either controller thumbstick button to exit VR and show the Settings + HUD. - Release every control and confirm that no stale input continues to act. +- Remove a required input device and confirm gameplay remains blocked until + complete action coverage is restored. ## Session lifecycle @@ -45,6 +53,8 @@ or deployment configuration. normal desktop loop resumes without a page reload. - Hide and restore the headset view, then confirm that controllers and movement continue to work after visibility returns. +- While the headset view is hidden, confirm movement, firing, and jumping are + neutralized rather than latched. - Enter and exit VR Mode a second time and confirm that no duplicate input or animation callbacks are active. diff --git a/package.json b/package.json index ec96173..5288d7f 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,10 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "check:server": "node --check server.js", - "check": "npm run check:server && npm run lint && npm run check:controls-docs && npm run test:shot-limits", + "check": "npm run check:server && npm run lint && npm run check:controls-docs && npm run test:shot-limits && npm run test:webxr-capabilities", "check:controls-docs": "node scripts/check-controls-docs.mjs", "test:shot-limits": "node scripts/test-shot-limits.mjs", + "test:webxr-capabilities": "node scripts/test-webxr-capabilities.mjs", "release:prepare": "node scripts/prepare-release.mjs", "release:check": "node scripts/check-release.mjs", "release:check:increment": "node scripts/check-tag-increment.mjs", diff --git a/public/client.js b/public/client.js index 6e8acb1..7fe724d 100644 --- a/public/client.js +++ b/public/client.js @@ -51,6 +51,8 @@ import { getXRControllerInput, setNormalAnimationLoop, isXREnabled, + isXRInputReady, + getXRInputStatus, } from './webxr.js'; import { createVoiceManager } from './voice.js'; import { normalizeShotSlotCount } from './shot-limits.mjs'; @@ -1901,6 +1903,19 @@ window.addEventListener('DOMContentLoaded', () => { } // Initialize WebXR support + window.addEventListener('webxrinputchange', event => { + if (!isXREnabled() || !event.detail?.message) return; + showMessage(`WebXR input: ${event.detail.message}`); + }); + window.addEventListener('webxrsessionchange', event => { + Object.keys(keys).forEach(code => { + keys[code] = false; + }); + if (event.detail?.enabled) return; + xrSettingsShortcutLatched = false; + setXRButtonState(false); + }); + initXR().then(mode => { showMessage(`WebXR: ${mode}`, 'info'); const xrBtn = document.getElementById('xrBtn'); @@ -1924,7 +1939,10 @@ window.addEventListener('DOMContentLoaded', () => { setXRButtonState(true); // Force first-person camera when entering VR cameraMode = 'first-person'; - showMessage('✓ WebXR VR Mode: ON'); + const inputStatus = getXRInputStatus(); + showMessage(inputStatus.ready + ? `✓ WebXR VR Mode: ON (${inputStatus.mode} controller input ready)` + : `WebXR VR Mode: ON — ${inputStatus.message}`); } else { setXRButtonState(false); if (!wasEnabled) { @@ -4472,35 +4490,50 @@ function handleInputEvents() { intendedForward = myTank.userData.jumpForwardSpeed || 0; intendedRotation = myTank.userData.rotationSpeed || 0; } else { - // Use virtual input if gamepad connected, XR enabled, or virtual controls enabled - if (isGamepadConnected() || virtualControlsEnabled || isXREnabled()) { - intendedForward = virtualInput.forward; - intendedRotation = virtualInput.turn; - if (jumpDirection === null && virtualInput.jump) { + const xrActive = isXREnabled(); + const xrInputReady = isXRInputReady(); + + if (xrActive) { + // Immersive XR is controller-only. Desktop keyboard, mouse, touch, and + // gamepad paths must not bypass the physical-input capability gate. + if (xrInputReady) { + intendedForward = virtualInput.forward; + intendedRotation = virtualInput.turn; + if (jumpDirection === null && virtualInput.jump) { + intendedY = 1; + jumpTriggered = true; + } + } + } else { + if (isGamepadConnected() || virtualControlsEnabled) { + intendedForward = virtualInput.forward; + intendedRotation = virtualInput.turn; + if (jumpDirection === null && virtualInput.jump) { + intendedY = 1; + jumpTriggered = true; + } + } + const wasdKeys = ['ArrowUp', 'ArrowLeft', 'ArrowDown', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD']; + let wasdPressed = false; + for (const code of wasdKeys) { + if (keys[code]) { + intendedForward += (code === 'KeyW' || code === 'ArrowUp') ? 1 : (code === 'KeyS' || code === 'ArrowDown') ? -1 : 0; + intendedRotation += (code === 'KeyA' || code === 'ArrowLeft') ? 1 : (code === 'KeyD' || code === 'ArrowRight') ? -1 : 0; + wasdPressed = true; + } + } + if (wasdPressed && mouseControlEnabled) { + toggleMouseMode(); + } + if ((keys['Tab']) && jumpDirection === null) { intendedY = 1; jumpTriggered = true; } - } - const wasdKeys = ['ArrowUp', 'ArrowLeft', 'ArrowDown', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD']; - let wasdPressed = false; - for (const code of wasdKeys) { - if (keys[code]) { - intendedForward += (code === 'KeyW' || code === 'ArrowUp') ? 1 : (code === 'KeyS' || code === 'ArrowDown') ? -1 : 0; - intendedRotation += (code === 'KeyA' || code === 'ArrowLeft') ? 1 : (code === 'KeyD' || code === 'ArrowRight') ? -1 : 0; - wasdPressed = true; + if (mouseControlEnabled) { + if (typeof mouseY !== 'undefined') intendedForward = -mouseY; + if (typeof mouseX !== 'undefined') intendedRotation = -mouseX; } } - if (wasdPressed && mouseControlEnabled) { - toggleMouseMode(); - } - if ((keys['Tab']) && jumpDirection === null) { - intendedY = 1; - jumpTriggered = true; - } - if (mouseControlEnabled) { - if (typeof mouseY !== 'undefined') intendedForward = -mouseY; - if (typeof mouseX !== 'undefined') intendedRotation = -mouseX; - } } const reverseSpeedRatio = Number.isFinite(gameConfig?.REVERSE_SPEED_RATIO) ? gameConfig.REVERSE_SPEED_RATIO @@ -4906,8 +4939,12 @@ function handleMotion(deltaTime) { lastSentTime = now; } - // Fire button: keyboard Space, mobile/XR/gamepad virtualInput.fire - const firePressed = (!isMobile && keys['Space']) || ((isMobile || isXREnabled() || isGamepadConnected()) && virtualInput.fire); + // Fire button: keyboard Space outside XR, or a valid physical XR/gamepad + // action. Keyboard and mouse paths cannot bypass the XR capability gate. + const xrActive = isXREnabled(); + const firePressed = xrActive + ? (isXRInputReady() && virtualInput.fire) + : ((!isMobile && keys['Space']) || ((isMobile || isGamepadConnected()) && virtualInput.fire)); const fireNow = performance.now(); if (firePressed && fireNow >= nextAllowedShotAt) { const maxActiveShots = normalizeShotSlotCount(gameConfig?.SHOT_MAX_ACTIVE); diff --git a/public/index.html b/public/index.html index 336072f..fcc3016 100644 --- a/public/index.html +++ b/public/index.html @@ -241,7 +241,8 @@

Nearby Voice

3D Viewing Modes

Source Code

diff --git a/public/input.js b/public/input.js index 48e049a..8647ff6 100644 --- a/public/input.js +++ b/public/input.js @@ -8,7 +8,7 @@ // Handles keyboard, mouse, and touch input for the game. // Exports: setupInputHandlers, virtualInput, keys -import { getXRControllerInput, xrState } from './webxr.js'; +import { getXRActionInput, xrState } from './webxr.js'; // Shared virtual input state exposed to the game loop. export let virtualInput = { forward: 0, turn: 0, fire: false, jump: false }; @@ -415,41 +415,24 @@ export function updateVirtualInputFromXR() { return; } - const controllerInput = getXRControllerInput(); - const leftThumbstick = controllerInput.leftThumbstick || { x: 0, y: 0 }; - const rightThumbstick = controllerInput.rightThumbstick || { x: 0, y: 0 }; - - const deadzone = 0.15; - const applyDeadzone = (value) => { - if (!Number.isFinite(value) || Math.abs(value) < deadzone) return 0; - const sign = value > 0 ? 1 : -1; - return sign * ((Math.abs(value) - deadzone) / (1 - deadzone)); - }; - - const leftX = applyDeadzone(leftThumbstick.x || 0); - const leftY = applyDeadzone(leftThumbstick.y || 0); - const rightX = applyDeadzone(rightThumbstick.x || 0); - const rightY = applyDeadzone(rightThumbstick.y || 0); - - // Right-stick-primary locomotion for Quest ergonomics. - const forwardAxis = Math.abs(rightY) > 0 ? rightY : leftY; - const newForward = -forwardAxis; - xrInputState.forward = newForward; - - // Prefer right-stick X for turning, with left-stick X fallback. - xrInputState.turn = -(Math.abs(rightX) > 0 ? rightX : leftX); - - // Right trigger OR A button: fire - xrInputState.fire = controllerInput.rightTrigger > 0.5 || controllerInput.buttonA; + const actionInput = getXRActionInput(); + if (!actionInput.ready) { + // Do not allow a disconnected or insufficient device to leave stale + // gameplay input active while the headset session is still running. + resetXRInput(); + return; + } - // B button OR side grip button: jump - xrInputState.jump = controllerInput.buttonB || controllerInput.buttonGrip; + xrInputState.forward = actionInput.forward; + xrInputState.turn = actionInput.turn; + xrInputState.fire = actionInput.fire; + xrInputState.jump = actionInput.jump; syncVirtualInput(); // Debug logging every 60 frames vxrFrameCounter++; if (vxrFrameCounter % 60 === 0) { - //debugLog(`virtualInput: forward=${newForward.toFixed(2)}, turn=${xrInputState.turn.toFixed(2)}, fire=${xrInputState.fire}, jump=${xrInputState.jump}`); + //debugLog(`virtualInput: forward=${xrInputState.forward.toFixed(2)}, turn=${xrInputState.turn.toFixed(2)}, fire=${xrInputState.fire}, jump=${xrInputState.jump}`); } } diff --git a/public/webxr-capabilities.mjs b/public/webxr-capabilities.mjs new file mode 100644 index 0000000..f1fd364 --- /dev/null +++ b/public/webxr-capabilities.mjs @@ -0,0 +1,308 @@ +/* + * Copyright (C) 2025-2026 Tim Riker + * Licensed under the GNU Affero General Public License v3.0. + * Source: https://github.com/timriker/bzo + * See LICENSE or https://www.gnu.org/licenses/agpl-3.0.html + */ + +// Keep WebXR input decisions separate from the session and renderer lifecycle. +// The browser-facing webxr.js module uses these helpers to expose the current +// physical-input status without changing the upstream controller bindings. + +export const XR_REQUIRED_ACTIONS = Object.freeze([ + 'move', + 'turn', + 'fire', + 'jump', +]); + +const DEFAULT_DEADZONE = 0.15; + +function toArray(value) { + return value ? Array.from(value) : []; +} + +function finiteNumber(value, fallback = 0) { + return Number.isFinite(value) ? value : fallback; +} + +export function applyDeadzone(value, deadzone = DEFAULT_DEADZONE) { + const normalized = finiteNumber(value); + const threshold = Math.max(0, Math.min(0.99, finiteNumber(deadzone, DEFAULT_DEADZONE))); + if (Math.abs(normalized) <= threshold) return 0; + const sign = normalized < 0 ? -1 : 1; + return sign * ((Math.abs(normalized) - threshold) / (1 - threshold)); +} + +function readButton(buttons, index) { + const button = buttons[index]; + if (!button) { + return { available: false, pressed: false, value: 0 }; + } + + return { + available: true, + pressed: Boolean(button.pressed || finiteNumber(button.value) > 0.5), + value: Math.max(0, Math.min(1, finiteNumber(button.value, button.pressed ? 1 : 0))), + }; +} + +function anyAvailableButton(buttons, indices) { + const candidates = indices + .map(index => readButton(buttons, index)) + .filter(button => button.available); + + if (candidates.length === 0) { + return { available: false, pressed: false, value: 0 }; + } + + return { + available: true, + pressed: candidates.some(button => button.pressed), + value: Math.max(...candidates.map(button => button.value)), + }; +} + +function hasEventButton(eventButtons, name) { + return Boolean(eventButtons && typeof eventButtons[name] === 'boolean'); +} + +function readStick(axes, { sourceType, handedness }) { + // The owner’s current Quest mapping prefers the right-hand [2,3] pair for + // a right controller, while the left-hand controller uses [0,1]. Generic + // Gamepad devices use the standard [0,1] pair. + const preferredPair = sourceType === 'xr' && handedness === 'right' + ? [2, 3] + : [0, 1]; + const pairs = [preferredPair, [0, 1], [2, 3]]; + let firstAvailable = null; + + for (const [xIndex, yIndex] of pairs) { + const xValue = axes[xIndex]; + const yValue = axes[yIndex]; + if (Number.isFinite(xValue) || Number.isFinite(yValue)) { + const candidate = { + available: true, + x: applyDeadzone(xValue), + y: applyDeadzone(yValue), + }; + if (!firstAvailable) firstAvailable = candidate; + // Some runtimes expose an unused axis pair as finite zeroes while the + // actual stick is reported in the other pair. Prefer non-zero input and + // retain the preferred pair only when all available pairs are neutral. + if (candidate.x !== 0 || candidate.y !== 0) return candidate; + } + } + + return firstAvailable || { available: false, x: 0, y: 0 }; +} + +/** + * Convert an XRInputSource.Gamepad or standard Gamepad into a stable, + * hardware-neutral action device. + */ +export function createSemanticDevice({ + id = 'unknown', + sourceType = 'xr', + handedness = 'none', + profiles = [], + targetRayMode = null, + gamepad = null, + eventButtons = null, +} = {}) { + const axes = toArray(gamepad?.axes); + const buttons = toArray(gamepad?.buttons); + const normalizedHandedness = handedness || 'none'; + const xrDevice = sourceType === 'xr'; + const stick = readStick(axes, { + sourceType, + handedness: normalizedHandedness, + }); + const xrStandard = gamepad?.mapping === 'xr-standard'; + + // XR action buttons follow the owner’s current bindings. For a generic + // standard Gamepad, include the conventional trigger alternatives so one + // Bluetooth/Xbox-like device can cover the complete action set. + const fireIndices = xrDevice ? [0, 4] : [0, 7]; + const jumpIndices = xrDevice ? [1, 5] : [1, 6]; + const trigger = readButton(buttons, 0); + const squeeze = readButton(buttons, 1); + const primary = readButton(buttons, xrStandard ? 4 : 0); + const secondary = readButton(buttons, xrStandard ? 5 : 1); + const fireButton = anyAvailableButton(buttons, fireIndices); + const jumpButton = anyAvailableButton(buttons, jumpIndices); + const firePressed = fireButton.pressed || ( + hasEventButton(eventButtons, 'select') && eventButtons.select + ); + const jumpPressed = jumpButton.pressed || ( + hasEventButton(eventButtons, 'squeeze') && eventButtons.squeeze + ); + + return { + id, + sourceType, + handedness: normalizedHandedness, + profiles: Array.isArray(profiles) ? [...profiles] : [], + targetRayMode, + mapping: gamepad?.mapping || 'unknown', + gamepad, + axes, + buttons, + stick: { + x: stick.x, + y: stick.y, + }, + trigger, + squeeze, + primary, + secondary, + firePressed, + jumpPressed, + fireValue: Math.max(fireButton.value, trigger.value), + jumpValue: Math.max(jumpButton.value, squeeze.value), + capabilities: { + move: stick.available, + turn: stick.available, + fire: fireButton.available || hasEventButton(eventButtons, 'select'), + jump: jumpButton.available || hasEventButton(eventButtons, 'squeeze'), + }, + }; +} + +function hasAllActions(device) { + return XR_REQUIRED_ACTIONS.every(action => Boolean(device?.capabilities?.[action])); +} + +function missingActionsForDevices(devices) { + return XR_REQUIRED_ACTIONS.filter(action => !devices.some(device => device.capabilities[action])); +} + +/** + * Decide whether one physical device or the preferred left/right pair can + * cover all four BZO actions. Device brand and user-agent strings are + * deliberately ignored: only advertised input capability matters. + */ +export function evaluateInputCoverage(devices = []) { + const availableDevices = devices.filter(Boolean); + if (availableDevices.length === 0) { + return { + ready: false, + status: 'waiting', + mode: null, + deviceId: null, + missing: [...XR_REQUIRED_ACTIONS], + reason: 'Connect a physical controller with movement, turn, fire, and jump inputs.', + }; + } + + const left = availableDevices.find(device => device.handedness === 'left'); + const right = availableDevices.find(device => device.handedness === 'right'); + const rightHasActions = Boolean( + right?.capabilities.move && + right?.capabilities.turn && + right?.capabilities.fire && + right?.capabilities.jump, + ); + const dualReady = Boolean( + left && + right && + (left.capabilities.move || right.capabilities.move) && + (right.capabilities.turn || left.capabilities.turn) && + (right.capabilities.fire || availableDevices.some(device => device.capabilities.fire)) && + (right.capabilities.jump || availableDevices.some(device => device.capabilities.jump)), + ); + + if (dualReady) { + return { + ready: true, + status: 'ready', + mode: 'dual', + deviceId: null, + missing: [], + reason: 'Left and right controller inputs are ready.', + }; + } + + const single = availableDevices.find(hasAllActions); + if (single || rightHasActions) { + const device = single || right; + return { + ready: true, + status: 'ready', + mode: 'single', + deviceId: device.id, + missing: [], + reason: 'Single-controller input is ready.', + }; + } + + const missing = missingActionsForDevices(availableDevices); + return { + ready: false, + status: 'insufficient', + mode: null, + deviceId: null, + missing, + reason: missing.length > 0 + ? `Required input missing: ${missing.join(', ')}.` + : 'The connected controllers do not cover the BZO action set.', + }; +} + +function neutralInput(coverage) { + return { + forward: 0, + turn: 0, + fire: false, + jump: false, + ready: Boolean(coverage?.ready), + mode: coverage?.mode || null, + }; +} + +/** Resolve semantic devices into BZO actions while preserving upstream keybinds. */ +export function resolveSemanticInput(devices = [], coverage = evaluateInputCoverage(devices)) { + const input = neutralInput(coverage); + if (!coverage.ready) return input; + + if (coverage.mode === 'dual') { + const left = devices.find(device => device.handedness === 'left'); + const right = devices.find(device => device.handedness === 'right'); + const rightStick = right?.capabilities.move ? right.stick : null; + const leftStick = left?.capabilities.move ? left.stick : null; + const rightY = rightStick?.y || 0; + const rightX = rightStick?.x || 0; + input.forward = -((Math.abs(rightY) > 0 ? rightY : leftStick?.y) || 0); + input.turn = -((Math.abs(rightX) > 0 ? rightX : leftStick?.x) || 0); + + const fireDevice = right?.capabilities.fire + ? right + : devices.find(device => device.capabilities.fire); + const jumpDevice = right?.capabilities.jump + ? right + : devices.find(device => device.capabilities.jump); + input.fire = Boolean(fireDevice?.firePressed); + input.jump = Boolean(jumpDevice?.jumpPressed); + return input; + } + + const device = devices.find(candidate => candidate.id === coverage.deviceId) || devices.find(hasAllActions); + if (!device) return neutralInput({ ready: false }); + input.forward = -device.stick.y; + input.turn = -device.stick.x; + input.fire = device.firePressed; + input.jump = device.jumpPressed; + return input; +} + +export function describeDevice(device) { + return { + id: device.id, + sourceType: device.sourceType, + handedness: device.handedness, + profiles: [...device.profiles], + targetRayMode: device.targetRayMode, + mapping: device.mapping, + capabilities: { ...device.capabilities }, + }; +} diff --git a/public/webxr.js b/public/webxr.js index 2d64ab7..d5744ea 100644 --- a/public/webxr.js +++ b/public/webxr.js @@ -7,14 +7,28 @@ // WebXR Manager for VR/AR support (Quest 2, Viture Luma Ultra, etc.) +import { + createSemanticDevice, + describeDevice, + evaluateInputCoverage, + resolveSemanticInput, +} from './webxr-capabilities.mjs'; + let xrSession = null; let xrSupported = false; let xrEnabled = false; let xrMode = null; // 'immersive-vr' or 'immersive-ar' let xrInputSources = new Map(); // Map of controller input source ID -> controller state +let xrEventButtons = new Map(); // Map of input source -> select/squeeze event state let xrSessionLifecycle = null; let xrStartPromise = null; let xrEndPromise = null; +let semanticDevices = []; +let inputCoverage = evaluateInputCoverage(); +let nextInputSourceId = 1; +let xrInputSourceIds = new Map(); + +const DEFAULT_INPUT_MESSAGE = 'Connect a physical controller with movement, turn, fire, and jump inputs.'; export const xrState = { enabled: false, @@ -22,6 +36,13 @@ export const xrState = { headPose: null, // { position: THREE.Vector3, quaternion: THREE.Quaternion } controllers: new Map(), // input source ID -> { pose, grip, select } frameCounter: 0, + visibilityState: 'visible', + inputReady: false, + inputStatus: 'waiting', + inputMessage: DEFAULT_INPUT_MESSAGE, + inputMode: null, + inputDevices: [], + inputStatusKey: '', }; // Send debug message through app-wide logger in client.js @@ -31,6 +52,74 @@ export function debugLog(message) { } } +function getInputSourceId(inputSource) { + if (!inputSource || typeof inputSource !== 'object') return `device-${nextInputSourceId++}`; + if (!xrInputSourceIds.has(inputSource)) { + xrInputSourceIds.set(inputSource, `xr-${nextInputSourceId++}`); + } + return xrInputSourceIds.get(inputSource); +} + +function getEventButtons(inputSource) { + if (!inputSource || typeof inputSource !== 'object') return null; + let buttons = xrEventButtons.get(inputSource); + if (!buttons) { + buttons = { select: false, squeeze: false }; + xrEventButtons.set(inputSource, buttons); + } + return buttons; +} + +function setEventButton(inputSource, name, pressed) { + const buttons = getEventButtons(inputSource); + if (buttons && (name === 'select' || name === 'squeeze')) { + buttons[name] = Boolean(pressed); + } +} + +function clearEventButtons() { + xrEventButtons.forEach(buttons => { + buttons.select = false; + buttons.squeeze = false; + }); +} + +function publishInputStatus() { + const status = inputCoverage; + const nextStatus = `${status.status}:${status.mode || ''}:${status.reason}`; + const changed = nextStatus !== xrState.inputStatusKey; + + xrState.inputReady = Boolean(status.ready); + xrState.inputStatus = status.status; + xrState.inputMessage = status.reason; + xrState.inputMode = status.mode; + xrState.inputDevices = semanticDevices.map(describeDevice); + xrState.inputStatusKey = nextStatus; + + if (changed && typeof window !== 'undefined' && typeof window.dispatchEvent === 'function') { + const detail = { + ready: xrState.inputReady, + status: xrState.inputStatus, + message: xrState.inputMessage, + mode: xrState.inputMode, + missing: [...(status.missing || [])], + devices: xrState.inputDevices, + }; + if (typeof window.CustomEvent === 'function') { + window.dispatchEvent(new window.CustomEvent('webxrinputchange', { detail })); + } + } +} + +function publishSessionState(enabled) { + if (typeof window === 'undefined' || typeof window.dispatchEvent !== 'function') return; + if (typeof window.CustomEvent === 'function') { + window.dispatchEvent(new window.CustomEvent('webxrsessionchange', { + detail: { enabled: Boolean(enabled), mode: xrMode }, + })); + } +} + // Check if WebXR is available async function checkXRSupport() { debugLog('Checking XR support... navigator.xr=' + (navigator.xr ? 'YES' : 'NO')); @@ -131,6 +220,9 @@ async function startXRSession(renderer, animationCallback) { xrEnabled = true; xrState.enabled = true; + xrState.visibilityState = session.visibilityState || 'visible'; + publishInputStatus(); + publishSessionState(true); // Set up the XR animation loop if (animationCallback) { @@ -147,6 +239,7 @@ async function startXRSession(renderer, animationCallback) { } else { resetXRState(); } + publishSessionState(false); return false; } } @@ -159,6 +252,10 @@ function createXRSessionLifecycle(session, renderer) { endRequested: false, inputSourcesChangeHandler: null, visibilityChangeHandler: null, + selectStartHandler: null, + selectEndHandler: null, + squeezeStartHandler: null, + squeezeEndHandler: null, endHandler: null, }; @@ -173,6 +270,7 @@ function createXRSessionLifecycle(session, renderer) { event.added?.forEach(inputSource => { addXRInputSource(inputSource); }); + updateXRControllerInput(); }; lifecycle.visibilityChangeHandler = () => { @@ -184,9 +282,22 @@ function createXRSessionLifecycle(session, renderer) { xrState.headPose = null; resetXRControllerStates(); xrState.controllers.clear(); + clearEventButtons(); + xrState.visibilityState = 'hidden'; + semanticDevices = []; + inputCoverage = evaluateInputCoverage(); + publishInputStatus(); + } else { + xrState.visibilityState = session.visibilityState || 'visible'; + updateXRControllerInput(); } }; + lifecycle.selectStartHandler = event => setEventButton(event.inputSource, 'select', true); + lifecycle.selectEndHandler = event => setEventButton(event.inputSource, 'select', false); + lifecycle.squeezeStartHandler = event => setEventButton(event.inputSource, 'squeeze', true); + lifecycle.squeezeEndHandler = event => setEventButton(event.inputSource, 'squeeze', false); + lifecycle.endHandler = () => { if (xrSessionLifecycle !== lifecycle) { return; @@ -199,6 +310,10 @@ function createXRSessionLifecycle(session, renderer) { session.addEventListener('inputsourceschange', lifecycle.inputSourcesChangeHandler); session.addEventListener('visibilitychange', lifecycle.visibilityChangeHandler); + session.addEventListener('selectstart', lifecycle.selectStartHandler); + session.addEventListener('selectend', lifecycle.selectEndHandler); + session.addEventListener('squeezestart', lifecycle.squeezeStartHandler); + session.addEventListener('squeezeend', lifecycle.squeezeEndHandler); session.addEventListener('end', lifecycle.endHandler); setupXRInput(session); @@ -245,6 +360,10 @@ function cleanupXRSession(lifecycle, session) { if (lifecycle) { session.removeEventListener('inputsourceschange', lifecycle.inputSourcesChangeHandler); session.removeEventListener('visibilitychange', lifecycle.visibilityChangeHandler); + session.removeEventListener('selectstart', lifecycle.selectStartHandler); + session.removeEventListener('selectend', lifecycle.selectEndHandler); + session.removeEventListener('squeezestart', lifecycle.squeezeStartHandler); + session.removeEventListener('squeezeend', lifecycle.squeezeEndHandler); session.removeEventListener('end', lifecycle.endHandler); if (typeof lifecycle.renderer?.setAnimationLoop === 'function') { @@ -266,8 +385,21 @@ function resetXRState() { xrState.enabled = false; xrState.headPose = null; xrState.frameCounter = 0; + xrState.visibilityState = 'visible'; + xrState.inputReady = false; + xrState.inputStatus = 'waiting'; + xrState.inputMessage = DEFAULT_INPUT_MESSAGE; + xrState.inputMode = null; + xrState.inputDevices = []; + xrState.inputStatusKey = ''; xrInputSources.clear(); + xrEventButtons.clear(); + xrInputSourceIds.clear(); xrState.controllers.clear(); + semanticDevices = []; + inputCoverage = evaluateInputCoverage(); + publishInputStatus(); + publishSessionState(false); } // Store reference to reset animation loop @@ -318,6 +450,12 @@ function createXRControllerState(inputSource) { } function addXRInputSource(inputSource) { + if (!inputSource) { + return; + } + + getInputSourceId(inputSource); + getEventButtons(inputSource); const handedness = inputSource?.handedness; if (!handedness) { return; @@ -329,6 +467,12 @@ function addXRInputSource(inputSource) { } function removeXRInputSource(inputSource) { + if (!inputSource) { + return; + } + + xrEventButtons.delete(inputSource); + xrInputSourceIds.delete(inputSource); const handedness = inputSource?.handedness; if (!handedness) { return; @@ -388,6 +532,60 @@ function setupXRInput(session = xrSession) { } } +function collectStandardGamepads() { + if (typeof navigator === 'undefined' || typeof navigator.getGamepads !== 'function') { + return []; + } + + try { + return Array.from(navigator.getGamepads() || []).filter(gamepad => ( + gamepad && gamepad.connected !== false + )); + } catch (err) { + debugLog('Could not read standard Gamepad API: ' + err.message); + return []; + } +} + +function collectSemanticDevices() { + const devices = []; + const xrGamepads = new Set(); + + for (const inputSource of xrSession?.inputSources || []) { + if (inputSource?.gamepad) { + xrGamepads.add(inputSource.gamepad); + } + + devices.push(createSemanticDevice({ + id: getInputSourceId(inputSource), + sourceType: 'xr', + handedness: inputSource?.handedness || 'none', + profiles: inputSource?.profiles || [], + targetRayMode: inputSource?.targetRayMode || null, + gamepad: inputSource?.gamepad || null, + eventButtons: xrEventButtons.get(inputSource) || null, + })); + } + + for (const gamepad of collectStandardGamepads()) { + // An XRInputSource already represents this controller. Avoid counting it + // twice when a runtime also exposes it through navigator.getGamepads(). + if (xrGamepads.has(gamepad) || gamepad.mapping === 'xr-standard') { + continue; + } + + const gamepadId = gamepad.index ?? gamepad.id ?? devices.length; + devices.push(createSemanticDevice({ + id: `gamepad-${gamepadId}`, + sourceType: 'gamepad', + handedness: 'none', + gamepad, + })); + } + + return devices; +} + // Update XR controller input each frame export function updateXRControllerInput() { if (!xrSession || !xrEnabled) { @@ -397,9 +595,15 @@ export function updateXRControllerInput() { if (xrSession.visibilityState === 'hidden') { resetXRControllerStates(); xrState.controllers.clear(); + clearEventButtons(); + xrState.visibilityState = 'hidden'; + semanticDevices = []; + inputCoverage = evaluateInputCoverage(); + publishInputStatus(); return; } + xrState.visibilityState = xrSession.visibilityState || 'visible'; const frameCounter = xrState.frameCounter || 0; xrState.frameCounter = frameCounter + 1; const activeHandedness = new Set(); @@ -483,6 +687,10 @@ export function updateXRControllerInput() { xrInputSources.delete(handedness); } } + + semanticDevices = collectSemanticDevices(); + inputCoverage = evaluateInputCoverage(semanticDevices); + publishInputStatus(); } @@ -520,6 +728,38 @@ export function getXRControllerInput() { return input; } +// New gameplay code consumes semantic actions so the upstream Quest bindings +// stay centralized and a partially connected controller cannot leave stale +// movement or button state active. +export function getXRActionInput() { + if (!xrState.enabled || xrState.visibilityState === 'hidden') { + return { + forward: 0, + turn: 0, + fire: false, + jump: false, + ready: false, + mode: null, + }; + } + return resolveSemanticInput(semanticDevices, inputCoverage); +} + +export function getXRInputStatus() { + return { + ready: xrState.inputReady, + status: xrState.inputStatus, + message: xrState.inputMessage, + mode: xrState.inputMode, + missing: [...(inputCoverage.missing || [])], + devices: xrState.inputDevices.map(device => ({ + ...device, + profiles: [...device.profiles], + capabilities: { ...device.capabilities }, + })), + }; +} + // Export API export async function initXR() { return await checkXRSupport(); @@ -545,3 +785,11 @@ export async function toggleXRSession(renderer, animationCallback) { export function isXREnabled() { return xrEnabled; } + +export function isXRInputReady() { + return Boolean( + xrState.enabled && + xrState.inputReady && + xrState.visibilityState !== 'hidden' + ); +} diff --git a/scripts/test-webxr-capabilities.mjs b/scripts/test-webxr-capabilities.mjs new file mode 100644 index 0000000..34a4080 --- /dev/null +++ b/scripts/test-webxr-capabilities.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +/* + * Copyright (C) 2025-2026 Tim Riker + * Licensed under the GNU Affero General Public License v3.0. + * Source: https://github.com/timriker/bzo + * See LICENSE or https://www.gnu.org/licenses/agpl-3.0.html + */ + +import assert from 'node:assert/strict'; +import { + applyDeadzone, + createSemanticDevice, + describeDevice, + evaluateInputCoverage, + resolveSemanticInput, +} from '../public/webxr-capabilities.mjs'; + +const button = (pressed = false, value = pressed ? 1 : 0) => ({ pressed, value }); +const closeTo = (actual, expected) => assert.ok(Math.abs(actual - expected) < 0.000001); + +assert.equal(applyDeadzone(0.1), 0); +closeTo(applyDeadzone(1), 1); +assert.ok(applyDeadzone(-0.5) < 0); + +const left = createSemanticDevice({ + id: 'left-xr', + sourceType: 'xr', + handedness: 'left', + profiles: ['oculus-touch-v3'], + targetRayMode: 'tracked-pointer', + gamepad: { + mapping: 'xr-standard', + axes: [0.9, 0.8, 0.4, -0.6], + buttons: [button(), button(), button(), button(), button(), button()], + }, +}); + +const right = createSemanticDevice({ + id: 'right-xr', + sourceType: 'xr', + handedness: 'right', + profiles: ['oculus-touch-v3'], + targetRayMode: 'tracked-pointer', + gamepad: { + mapping: 'xr-standard', + axes: [0.1, 0.1, -0.7, 0.2], + buttons: [button(true), button(true), button(), button(), button(true), button(true)], + }, +}); + +const dualCoverage = evaluateInputCoverage([left, right]); +assert.equal(dualCoverage.ready, true); +assert.equal(dualCoverage.mode, 'dual'); +const dualInput = resolveSemanticInput([left, right], dualCoverage); +// Upstream keybind: the right stick is primary for both movement and turning. +closeTo(dualInput.forward, -0.05882352941176472); +closeTo(dualInput.turn, 0.6470588235294117); +assert.equal(dualInput.fire, true); +assert.equal(dualInput.jump, true); + +const bluetoothGamepad = createSemanticDevice({ + id: 'bluetooth-xbox-like', + sourceType: 'gamepad', + handedness: 'none', + gamepad: { + mapping: 'standard', + axes: [0.25, -0.8], + buttons: [button(true), button(true), button(), button(), button(), button(), button(true), button(true)], + }, +}); +const singleCoverage = evaluateInputCoverage([bluetoothGamepad]); +assert.equal(singleCoverage.ready, true); +assert.equal(singleCoverage.mode, 'single'); +const singleInput = resolveSemanticInput([bluetoothGamepad], singleCoverage); +closeTo(singleInput.forward, 0.7647058823529411); +closeTo(singleInput.turn, -0.11764705882352941); +assert.equal(singleInput.fire, true); +assert.equal(singleInput.jump, true); + +const eventButtons = createSemanticDevice({ + id: 'event-buttons', + sourceType: 'xr', + handedness: 'right', + gamepad: { + mapping: 'xr-standard', + axes: [0, 0, 0, 0], + buttons: [button(), button(), button(), button(), button(), button()], + }, + eventButtons: { select: true, squeeze: true }, +}); +assert.equal(eventButtons.firePressed, true); +assert.equal(eventButtons.jumpPressed, true); + +const incomplete = createSemanticDevice({ + id: 'incomplete', + sourceType: 'xr', + handedness: 'right', + gamepad: { mapping: 'xr-standard', axes: [0, 0, 0, 0], buttons: [] }, +}); +const incompleteCoverage = evaluateInputCoverage([incomplete]); +assert.equal(incompleteCoverage.ready, false); +assert.equal(incompleteCoverage.status, 'insufficient'); +assert.deepEqual(incompleteCoverage.missing, ['fire', 'jump']); + +assert.deepEqual(describeDevice(bluetoothGamepad), { + id: 'bluetooth-xbox-like', + sourceType: 'gamepad', + handedness: 'none', + profiles: [], + targetRayMode: null, + mapping: 'standard', + capabilities: { move: true, turn: true, fire: true, jump: true }, +}); + +console.log('WebXR capability tests passed');