All files / src/components/VirtualWalkthrough TfXrNavigation.ts

66.07% Statements 37/56
44.44% Branches 16/36
55.55% Functions 5/9
66.07% Lines 37/56

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204                                                      1x                                                                       4x   4x 5x 1x     4x         4x 1x 1x 1x       4x 4x 4x 4x         4x 4x 4x                                 4x 4x 4x   4x 4x   5x             5x                   5x 5x 5x                                     6x   6x 6x 2x     4x 4x 4x 2x         2x 2x                                                                  
import { Entity, Vec3, XrInputSource } from 'playcanvas';
import { XrNavigation as PcXrNavigation } from 'playcanvas/scripts/esm/xr/xr-navigation.mjs';
 
import { clampToCircle } from './xr-scene-bounds';
import { TeleportGestureLatch } from './xr-teleport-gesture';
 
interface ArcVisual {
  entity: { enabled: boolean };
  ringEntity: { enabled: boolean };
}
 
/** The base script's per-source aim state, which it exposes only as underscore-private fields. */
interface XrNavigationInternals {
  _inputSources: Set<XrInputSource>;
  _arcVisuals: Map<XrInputSource, ArcVisual>;
  _activePointers: Map<XrInputSource, boolean>;
  _arcHits: Map<XrInputSource, unknown>;
  _inputHandlers: Map<XrInputSource, { handleSelectStart: () => void; handleSelectEnd: () => void }>;
  _cameraEntity: Entity | null;
}
 
/**
 * XrNavigation that never teleports on a select meant for something else: it honours the
 * enableTeleport flag (which the base ignores in tryTeleport) and skips sources whose ray is over
 * interactive UI.
 */
export class TfXrNavigation extends PcXrNavigation {
  static scriptName = 'tfXrNavigation';
 
  /** Assigned by the React wrapper. True when this source's ray is over interactive UI. */
  isTeleportBlocked?: (inputSource: XrInputSource) => boolean;
 
  /**
   * World-space bounds circle. Assigned by the React wrapper rather than passed as a Script prop:
   * boundsCenter is a Vec3, and @playcanvas/react's memo() comparator stops at the first prop with
   * an .equals() method, so any prop after it would never propagate.
   * A boundsRadius of 0 disables clamping entirely.
   */
  boundsCenter = new Vec3();
  boundsRadius = 0;
 
  /** How far the rig was pulled back in the last postUpdate to keep the head inside the bounds. */
  clampDistance = 0;
 
  private _gestures = new TeleportGestureLatch();
 
  private _isBlocked(inputSource: XrInputSource): boolean {
    return !this.enableTeleport || this.isTeleportBlocked?.(inputSource) === true;
  }
 
  /**
   * Tracks controllers the base script never heard about.
   *
   * Movement, turning and teleport all iterate `_inputSources`, which the base fills from the XR
   * input `add` event it subscribes to in initialize. A walkthrough that adopts the host's camera is
   * mounted into a session that is already running, so the controllers present at session start
   * fired `add` before this script existed and every control is inert for the whole session.
   *
   * The sources are picked up by hand rather than by re-firing `add` on the input manager, which
   * would also reach the host's own listeners and have them handle controllers they already know
   * about. The handlers mirror the base's and are stored in its map, so its teardown detaches them.
   */
  private _syncInputSources() {
    const internals = this as unknown as XrNavigationInternals;
 
    for (const inputSource of this.app.xr?.input?.inputSources ?? []) {
      if (internals._inputSources.has(inputSource)) {
        continue;
      }
 
      const handleSelectStart = () => {
        internals._activePointers.set(inputSource, true);
        internals._arcHits.delete(inputSource);
      };
 
      const handleSelectEnd = () => {
        internals._activePointers.set(inputSource, false);
        Eif (this.enableTeleport) {
          this.tryTeleport(inputSource);
        }
      };
 
      inputSource.on('selectstart', handleSelectStart);
      inputSource.on('selectend', handleSelectEnd);
      internals._inputHandlers.set(inputSource, { handleSelectStart, handleSelectEnd });
      internals._inputSources.add(inputSource);
    }
  }
 
  private _resolveCamera() {
    const internals = this as unknown as XrNavigationInternals;
    Eif (!internals._cameraEntity) {
      internals._cameraEntity = this.entity.findComponent('camera')?.entity ?? null;
    }
  }
 
  tryTeleport(inputSource: XrInputSource) {
    // The latch decides on the frames the press actually aimed through, because super.tryTeleport
    // commits the arc hit cached back then rather than re-tracing the release-time ray. The live
    // check stays too: the same release also fires 'select', so a ray that lands on UI at release
    // is operating that UI and must not move the rig as well.
    if (this._gestures.consumeBlocked(inputSource) || this._isBlocked(inputSource)) {
      return;
    }
 
    super.tryTeleport(inputSource);
  }
 
  update(dt: number) {
    this._resolveCamera();
    this._syncInputSources();
    super.update(dt);
 
    const internals = this as unknown as XrNavigationInternals;
    for (const inputSource of internals._inputSources) {
      // Sampled once per source per frame: isTeleportBlocked ray-tests every hotspot.
      const uiBlocked = this.isTeleportBlocked?.(inputSource) === true;
 
      // Tracked unconditionally, and before the arc-hiding work below. super.update has just cached
      // this frame's arc hit, which is what a release would commit, so the latch has to see the same
      // frame. A source that isn't pressed carries no gesture, so any stale entry from a press that
      // ended while teleport was disabled (skipping tryTeleport entirely) is dropped here rather
      // than leaking into the source's next press.
      this._gestures.track(inputSource, {
        pressed: internals._activePointers.get(inputSource) === true,
        teleportDisabled: !this.enableTeleport,
        uiBlocked,
      });
 
      // The base hides the arc only from inside its own teleport handling, which it skips entirely
      // once teleport is off - so an arc raised before the block (gaze dwell can open a panel while
      // the trigger is held) would otherwise stay frozen in the world. These writes are idempotent,
      // and the base re-enables the visuals itself on the first frame a source is unblocked.
      const visual = internals._arcVisuals.get(inputSource);
      Eif (!visual || (!visual.entity.enabled && !visual.ringEntity.enabled)) {
        continue;
      }
 
      if (!this.enableTeleport || uiBlocked) {
        visual.entity.enabled = false;
        visual.ringEntity.enabled = false;
      }
    }
  }
 
  /**
   * Holds the head inside the bounds circle. Runs in postUpdate, after PlayCanvas has written this
   * frame's head pose into the camera's local transform (xr.update runs before app.update) and after
   * the base script's locomotion, so one clamp covers every way the head can move: thumbstick
   * translation of the rig, room-scale walking within the rig, and snap turns pivoting the rig.
   *
   * This script must stay a no-op outside XR regardless of how it's mounted.
   */
  postUpdate() {
    this.clampDistance = 0;
 
    const camera = (this as unknown as XrNavigationInternals)._cameraEntity;
    if (!this.app.xr?.active || !camera || this.boundsRadius <= 0) {
      return;
    }
 
    const head = camera.getPosition();
    const clamped = clampToCircle(head.x, head.z, this.boundsCenter.x, this.boundsCenter.z, this.boundsRadius);
    if (clamped.distance === 0) {
      return;
    }
 
    // World-space translate, not translateLocal: a snap turn has yawed the rig, so a local-space
    // correction would be rotated away from the direction the head actually overshot.
    this.entity.translate(clamped.x - head.x, 0, clamped.z - head.z);
    this.clampDistance = clamped.distance;
  }
 
  /**
   * Clamps the teleport landing point to the bounds circle. Overriding here rather than in
   * tryTeleport covers both paths that produce a landing point — the per-frame aim in
   * _handleTeleportation and tryTeleport's own recompute when no hit is cached — and because
   * _handleTeleportation positions the landing ring from this same record immediately afterwards,
   * the ring previews the clamped destination without any extra work.
   *
   * Only valid hits are clamped: when the base finds no hit it leaves rec.point holding a stale
   * value from an earlier frame, which must not be projected onto the circle and shown as a target.
   * Clamping only ever shortens the throw, so a hit that passed the base's distance check still does.
   */
  _computeArcHit(origin: Vec3, direction: Vec3, rec: { point: Vec3; valid: boolean }) {
    super._computeArcHit(origin, direction, rec);
 
    if (!rec.valid || this.boundsRadius <= 0) {
      return;
    }
 
    // Y is left alone: it is the navigation plane height, and bounds are XZ-only.
    const clamped = clampToCircle(
      rec.point.x,
      rec.point.z,
      this.boundsCenter.x,
      this.boundsCenter.z,
      this.boundsRadius
    );
    rec.point.x = clamped.x;
    rec.point.z = clamped.z;
  }
}