@@ -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