Skip to content

Commit e0391d2

Browse files
authored
feat: add snap vertical movement to XrNavigation script (playcanvas#8420)
* feat: add snap vertical movement to XrNavigation script Add snap vertical movement using right thumbstick Y-axis for VR navigation. This allows users to move up/down in discrete steps (like snap turning but for height), which is more comfortable than continuous smooth vertical movement. New features: - Right thumbstick Y: Snap up/down by 0.5m (configurable) - Right grip + thumbstick Y: Snap up/down by 2.0m (boost mode) - Uses same hysteresis system as snap turning * fix: lint operator-linebreak errors * docs: fix snapVerticalHeight comment
1 parent 3dfea37 commit e0391d2

1 file changed

Lines changed: 104 additions & 5 deletions

File tree

scripts/esm/xr-navigation.mjs

Lines changed: 104 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import { Color, Script, Vec2, Vec3 } from 'playcanvas';
33
/** @import { XrInputSource } from 'playcanvas' */
44

55
/**
6-
* Handles VR navigation with support for both teleportation and smooth locomotion.
7-
* Both methods can be enabled simultaneously, allowing users to choose their preferred
6+
* Handles VR navigation with support for teleportation, smooth locomotion, and snap vertical movement.
7+
* All methods can be enabled simultaneously, allowing users to choose their preferred
88
* navigation method on the fly.
99
*
1010
* Teleportation: Point and teleport using trigger/pinch gestures
11-
* Smooth Locomotion: Use left thumbstick for movement and right thumbstick for snap turning
11+
* Smooth Locomotion: Use left thumbstick for XZ movement
12+
* Snap Turn: Use right thumbstick X-axis for snap turning
13+
* Snap Vertical: Use right thumbstick Y-axis to snap up/down (right grip for larger jumps)
1214
*
1315
* This script should be attached to a parent entity of the camera entity used for the XR
1416
* session. The entity hierarchy should be: XrNavigationEntity > CameraEntity for proper
@@ -118,6 +120,48 @@ class XrNavigation extends Script {
118120
*/
119121
controllerRayColor = new Color(1, 1, 1);
120122

123+
/**
124+
* Enable snap vertical movement using right thumbstick Y (controllers only).
125+
* @attribute
126+
*/
127+
enableSnapVertical = true;
128+
129+
/**
130+
* Height in meters for each vertical snap.
131+
* @attribute
132+
* @range [0.1, 2]
133+
* @precision 0.1
134+
* @enabledif {enableSnapVertical}
135+
*/
136+
snapVerticalHeight = 0.5;
137+
138+
/**
139+
* Height in meters for each vertical snap when holding right grip (boost).
140+
* @attribute
141+
* @range [0.5, 10]
142+
* @precision 0.5
143+
* @enabledif {enableSnapVertical}
144+
*/
145+
snapVerticalBoostHeight = 2.0;
146+
147+
/**
148+
* Thumbstick Y threshold to trigger vertical snap.
149+
* @attribute
150+
* @range [0.1, 1]
151+
* @precision 0.01
152+
* @enabledif {enableSnapVertical}
153+
*/
154+
snapVerticalThreshold = 0.5;
155+
156+
/**
157+
* Thumbstick Y threshold to reset vertical snap state.
158+
* @attribute
159+
* @range [0.05, 0.5]
160+
* @precision 0.01
161+
* @enabledif {enableSnapVertical}
162+
*/
163+
snapVerticalResetThreshold = 0.25;
164+
121165
/** @type {Set<XrInputSource>} */
122166
inputSources = new Set();
123167

@@ -130,6 +174,9 @@ class XrNavigation extends Script {
130174
// Rotation state for snap turning
131175
lastRotateValue = 0;
132176

177+
// Vertical state for snap vertical movement
178+
lastVerticalValue = 0;
179+
133180
// Pre-allocated objects for performance (object pooling)
134181
tmpVec2A = new Vec2();
135182

@@ -160,10 +207,11 @@ class XrNavigation extends Script {
160207
const methods = [];
161208
if (this.enableTeleport) methods.push('teleportation');
162209
if (this.enableMove) methods.push('smooth movement');
210+
if (this.enableSnapVertical) methods.push('snap vertical');
163211
console.log(`XrNavigation: Enabled methods - ${methods.join(', ')}`);
164212

165-
if (!this.enableTeleport && !this.enableMove) {
166-
console.warn('XrNavigation: Both teleportation and movement are disabled. Navigation will not work.');
213+
if (!this.enableTeleport && !this.enableMove && !this.enableSnapVertical) {
214+
console.warn('XrNavigation: All navigation methods are disabled. Navigation will not work.');
167215
}
168216

169217
// Initialize color objects from Color attributes
@@ -268,6 +316,11 @@ class XrNavigation extends Script {
268316
this.handleSmoothLocomotion(dt);
269317
}
270318

319+
// Handle snap vertical movement (controllers only)
320+
if (this.enableSnapVertical) {
321+
this.handleSnapVertical();
322+
}
323+
271324
// Handle teleportation
272325
if (this.enableTeleport) {
273326
this.handleTeleportation();
@@ -344,6 +397,52 @@ class XrNavigation extends Script {
344397
}
345398
}
346399

400+
/**
401+
* Handles snap vertical movement using right thumbstick Y.
402+
* Uses hysteresis to prevent multiple snaps from a single gesture.
403+
* Hold right grip for larger snap height (boost).
404+
*
405+
* @private
406+
*/
407+
handleSnapVertical() {
408+
// Find right controller
409+
let rightController = null;
410+
411+
for (const inputSource of this.inputSources) {
412+
if (!inputSource.gamepad) continue;
413+
if (inputSource.handedness === 'right') {
414+
rightController = inputSource;
415+
break;
416+
}
417+
}
418+
419+
if (!rightController || !rightController.gamepad) return;
420+
421+
// Get vertical input from right thumbstick Y axis (negative = up on stick)
422+
const vertical = -rightController.gamepad.axes[3];
423+
424+
// Hysteresis system to prevent multiple snaps from single gesture
425+
if (this.lastVerticalValue > 0 && vertical < this.snapVerticalResetThreshold) {
426+
this.lastVerticalValue = 0;
427+
} else if (this.lastVerticalValue < 0 && vertical > -this.snapVerticalResetThreshold) {
428+
this.lastVerticalValue = 0;
429+
}
430+
431+
// Only snap when thumbstick crosses threshold from neutral position
432+
if (this.lastVerticalValue === 0 && Math.abs(vertical) > this.snapVerticalThreshold) {
433+
this.lastVerticalValue = Math.sign(vertical);
434+
435+
// Check if right grip is held for boost
436+
const rightGripPressed = rightController.gamepad.buttons[1]?.pressed;
437+
const snapHeight = rightGripPressed ?
438+
this.snapVerticalBoostHeight :
439+
this.snapVerticalHeight;
440+
441+
// Apply vertical snap (positive = up, negative = down)
442+
this.entity.translate(0, Math.sign(vertical) * snapHeight, 0);
443+
}
444+
}
445+
347446
handleTeleportation() {
348447
for (const inputSource of this.inputSources) {
349448
// Only show teleportation ray when trigger/select is pressed

0 commit comments

Comments
 (0)