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
33 changes: 21 additions & 12 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -228,16 +232,19 @@ 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)
- `initXR()` called on DOMContentLoaded to detect support
- `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"`
Expand All @@ -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

Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 13 additions & 3 deletions docs/webxr-validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
91 changes: 64 additions & 27 deletions public/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ import {
getXRControllerInput,
setNormalAnimationLoop,
isXREnabled,
isXRInputReady,
getXRInputStatus,
} from './webxr.js';
import { createVoiceManager } from './voice.js';
import { normalizeShotSlotCount } from './shot-limits.mjs';
Expand Down Expand Up @@ -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');
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,8 @@ <h2>Nearby Voice</h2>
<h2>3D Viewing Modes</h2>
<ul>
<li><strong>Anaglyph 3D</strong> — Red/Cyan glasses for 3D effect</li>
<li><strong>VR Mode</strong> — WebXR-compatible headsets</li>
<li><strong>VR Mode</strong> — Native WebXR-compatible headsets</li>
<li><strong>Thumbstick press</strong> — Exit VR and show Settings</li>
</ul>

<h2>Source Code</h2>
Expand Down
43 changes: 13 additions & 30 deletions public/input.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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}`);
}
}

Expand Down
Loading