import type { Context } from "../engine_context.js"; import { RoomEvents, UserJoinedOrLeftRoomModel } from "../engine_networking.js"; import { getParam } from "../engine_utils.js"; import { NeedleXRSession } from "./NeedleXRSession.js"; const debug = getParam("debugwebxr"); declare type XRControllerType = "hand" | "controller"; declare type XRControllerState = { // adding a guid so it's saved on the server, ideally we have a "room lifetime" store that doesnt save state forever on disc but just until the room is disposed (we have to add support for this in the networking backend tho) // The guid is derived from the handedness (see XRUserState) so it is stable across input-type // switches (hand <-> controller), which reuse/reorder input-source indices. guid: string; handedness: XRHandedness; isTracking: boolean; type: XRControllerType; } class XRUserState { readonly controllerStates: XRControllerState[] = []; readonly userId: string; readonly context: Context; private readonly userStateEvtName: string; constructor(userId: string, context: Context) { this.userId = userId; this.context = context; this.userStateEvtName = "xr-sync-user-state-" + userId; this.context.connection.beginListen(this.userStateEvtName, this.onReceivedControllerState); } dispose() { this.context.connection.stopListen(this.userStateEvtName, this.onReceivedControllerState); } onReceivedControllerState = (state: XRControllerState) => { if (debug) console.log(`XRSync: Received change for ${this.userId}: ${state.type} ${state.handedness}; tracked=${state.isTracking}`); // Identity is the handedness: there is exactly one left and one right hand per user. Keying by // handedness (instead of the input-source index, which is reused/reordered when switching // hand <-> controller) keeps the tracking state correct across input-type switches. for (let i = 0; i < this.controllerStates.length; i++) { if (this.controllerStates[i].handedness === state.handedness) { this.controllerStates[i] = state; return; } } this.controllerStates.push(state); } update(session: NeedleXRSession) { if (this.context.connection.isConnected == false) return; // Remove states whose handedness no longer has ANY input source in the session (the hand/ // controller was fully removed), broadcasting a not-tracking + deleting the persisted state. for (let i = this.controllerStates.length - 1; i >= 0; i--) { const state = this.controllerStates[i]; const stillPresent = session.controllers.some(ctrl => ctrl.side === state.handedness); if (!stillPresent) { if (debug) console.log(`XRSync: ${state.type} ${state.handedness} removed`); this.controllerStates.splice(i, 1); this.sendControllerRemoved(state); } } // Update per-handedness tracking. Calling this once per controller is idempotent: it aggregates // across all sources of that handedness, so a second call for the same side is a no-op. for (const ctrl of session.controllers) { this.updateHandednessState(ctrl.side, session); } } onExitXR(_session: NeedleXRSession) { for (const state of this.controllerStates) { this.sendControllerRemoved(state); } this.controllerStates.length = 0; } private sendControllerRemoved(state: XRControllerState) { state.isTracking = false; // broadcast the not-tracking state to currently connected peers so they hide the hand immediately this.context.connection.send(this.userStateEvtName, state); // and delete the persisted room state using the *correct* guid so newly joining peers don't // replay a stale (still-tracking) controller state. Must use the real guid - clearing it first // would leave the persisted entry untouched on the server. this.context.connection.sendDeleteRemoteState(state.guid); } private updateHandednessState(handedness: XRHandedness, session: NeedleXRSession) { // Aggregate across all input sources of this handedness: the side is "tracking" if ANY of its // sources is tracking. During a hand <-> controller switch both can momentarily coexist; the // avatar has a single hand object per side, so any tracked source keeps it visible. let isTracking = false; let type: XRControllerType | undefined = undefined; let anyOfSide = false; for (const ctrl of session.controllers) { if (ctrl.side !== handedness) continue; anyOfSide = true; const ctrlType: XRControllerType = ctrl.hand ? "hand" : "controller"; if (ctrl.isTracking) { isTracking = true; type = ctrlType; // prefer the type of a currently-tracking source } else if (type === undefined) { type = ctrlType; } } if (!anyOfSide) return; const existing = this.controllerStates.find(x => x.handedness === handedness); if (existing) { if (existing.isTracking !== isTracking || existing.type !== type) { existing.isTracking = isTracking; existing.type = type!; this.context.connection.send(this.userStateEvtName, existing); } } else { const state: XRControllerState = { guid: this.userId + "-" + handedness, isTracking, handedness, type: type!, } this.controllerStates.push(state); this.context.connection.send(this.userStateEvtName, state); if (debug) console.log(`XRSync: ${state.type} ${state.handedness} added`); } } } export class NeedleXRSync { hasState(userId: string | null | undefined) { if (!userId) return false; return this._states.has(userId); } /** Is the left controller or hand tracked */ isTracking(userId: string | null | undefined, handedness: XRHandedness): boolean | undefined { if (!userId) return undefined; const user = this._states.get(userId); if (!user) return undefined; // There is one state per handedness (keyed by side), so this finds that side's state and // reports whether it is currently tracking. return user.controllerStates.some(x => x.handedness === handedness && x.isTracking); } /** Is it hand tracking or a controller */ getDeviceType(userId: string, handedness: XRHandedness): XRControllerType | undefined | "unknown" { if (!userId) return undefined; const user = this._states.get(userId); if (!user) return undefined; // prefer the currently tracking controller for this handedness over any stale entry const ctrl = user.controllerStates.find(x => x.handedness === handedness && x.isTracking) ?? user.controllerStates.find(x => x.handedness === handedness); return ctrl?.type || "unknown"; } private readonly context: Context; constructor(context: Context) { this.context = context; this.context.connection.beginListen(RoomEvents.JoinedRoom, this.onJoinedRoom); this.context.connection.beginListen(RoomEvents.LeftRoom, this.onLeftRoom) this.context.connection.beginListen(RoomEvents.UserJoinedRoom, this.onOtherUserJoinedRoom); this.context.connection.beginListen(RoomEvents.UserLeftRoom, this.onOtherUserLeftRoom); } destroy() { this.context.connection.stopListen(RoomEvents.JoinedRoom, this.onJoinedRoom); this.context.connection.stopListen(RoomEvents.LeftRoom, this.onLeftRoom) this.context.connection.stopListen(RoomEvents.UserJoinedRoom, this.onOtherUserJoinedRoom); this.context.connection.stopListen(RoomEvents.UserLeftRoom, this.onOtherUserLeftRoom); } private onJoinedRoom = () => { if (this.context.connection.connectionId) { if (!this._states.has(this.context.connection.connectionId)) { if (debug) console.log("XRSync: Local user joined room", this.context.connection.connectionId); this._states.set(this.context.connection.connectionId, new XRUserState(this.context.connection.connectionId, this.context)); } for (const user of this.context.connection.usersInRoom()) { if (!this._states.has(user)) { this._states.set(user, new XRUserState(user, this.context)); } } } } private onLeftRoom = () => { if (this.context.connection.connectionId) { if (!this._states.has(this.context.connection.connectionId)) { const state = this._states.get(this.context.connection.connectionId); state?.dispose(); this._states.delete(this.context.connection.connectionId); } } } private onOtherUserJoinedRoom = (evt: UserJoinedOrLeftRoomModel) => { const userId = evt.userId; if (!this._states.has(userId)) { if (debug) console.log("XRSync: Remote user joined room", userId); this._states.set(userId, new XRUserState(userId, this.context)); } } private onOtherUserLeftRoom = (evt: UserJoinedOrLeftRoomModel) => { const userId = evt.userId; if (!this._states.has(userId)) { const state = this._states.get(userId); state?.dispose(); this._states.delete(userId); } } private _states: Map = new Map(); onUpdate(session: NeedleXRSession) { if (this.context.connection.isConnected && this.context.connection.connectionId) { const localState = this._states.get(this.context.connection.connectionId); localState?.update(session); } } onExitXR(session: NeedleXRSession) { if (this.context.connection.isConnected && this.context.connection.connectionId) { const localState = this._states.get(this.context.connection.connectionId); localState?.onExitXR(session); } } }