import { AbstractMesh, DualShockPad, Engine, FreeCamera, FreeCameraGamepadInput, Gamepad as b_Gamepad, KeyboardEventTypes, PerformanceMonitor, Quaternion, Ray, Scene, Vector3, WebXRCamera, WebXRDefaultExperience, WebXRSessionManager, WebXRState, } from '@babylonjs/core/Legacy/legacy'; import { log } from './log.js'; if ((navigator as any).oscpu?.startsWith('Linux')) { // ^ oscpu is undefined on chrome on Android, at least... DualShockPad.prototype.update = function () { b_Gamepad.prototype.update.call(this); (window as any).G = this; (this as any)._rightStickAxisX = 3; (this as any)._rightStickAxisY = 4; this.buttonCross = this.browserGamepad.buttons[0].value; this.buttonCircle = this.browserGamepad.buttons[1].value; this.buttonTriangle = this.browserGamepad.buttons[2].value; this.buttonSquare = this.browserGamepad.buttons[3].value; this.buttonL1 = this.browserGamepad.buttons[4].value; this.buttonR1 = this.browserGamepad.buttons[5].value; this.leftTrigger = this.browserGamepad.buttons[6].value; this.rightTrigger = this.browserGamepad.buttons[7].value; this.buttonShare = this.browserGamepad.buttons[8].value; this.buttonOptions = this.browserGamepad.buttons[9].value; this.buttonLeftStick = this.browserGamepad.buttons[11].value; this.buttonRightStick = this.browserGamepad.buttons[12].value; this.dPadUp = this.browserGamepad.axes[7].value < 0 ? 1 : 0; this.dPadDown = this.browserGamepad.axes[7].value > 0 ? 1 : 0; this.dPadLeft = this.browserGamepad.axes[6].value < 0 ? 1 : 0; this.dPadRight = this.browserGamepad.axes[6].value > 0 ? 1 : 0; }; } export type Interactivity = { floorMeshes: () => AbstractMesh[], touchableMeshes: () => AbstractMesh[], }; export type EngineOptions = { initialPos: Vector3, initialRotation: Vector3, canvas: HTMLCanvasElement | null, }; export type ButtonState = { checkTime: number, isDown: boolean, wasPressed: boolean, wasReleased: boolean, }; export class GamepadState { buttons: { [button: number]: ButtonState } = {}; checkTime = 0; constructor ( public gp: Gamepad, // NB. browser's Gamepad class, not Babylon's Gamepad class. ) {} tick() { this.checkTime++; } b(button: number): ButtonState { if (!(button in this.buttons)) { this.buttons[button] = { checkTime: -1, isDown: false, wasPressed: false, wasReleased: false, }; } const result = this.buttons[button]; const b = this.gp.buttons[button]; if (b && result.checkTime !== this.checkTime) { result.checkTime = this.checkTime; const wasDown = result.isDown; result.isDown = b.pressed; result.wasPressed = result.isDown && !wasDown; result.wasReleased = !result.isDown && wasDown; } return result; } } export class RunningEngine { // The active camera - plainCamera or xrCamera, depending. camera: FreeCamera; plainCamera: FreeCamera; gamepadInput: FreeCameraGamepadInput; xrCamera: WebXRCamera | null = null; xrTeleportTimer: any = null; leanBase: { position: Vector3 } | null = null; recenterBase: { rotation: Quaternion } | null = null; padStates = new Map(); keysDown = new Set(); keysChanged = new Map(); gravity = new Vector3(0, 0, 0); performanceMonitor = new PerformanceMonitor(); static async start( interactivity: Interactivity, options0: Partial = {}, ): Promise { const options = Object.assign({ initialPos: new Vector3(0, 1.6, 0), initialRotation: new Vector3(0, 0, 0).scaleInPlace(2 * Math.PI), canvas: document.getElementById("renderCanvas") as HTMLCanvasElement, }, options0); const engine = new Engine(options.canvas, true); const scene = new Scene(engine); return new RunningEngine( options, engine, interactivity, scene, (await WebXRSessionManager.IsSessionSupportedAsync('immersive-vr') ? await scene.createDefaultXRExperienceAsync({}) : null)); } private constructor ( public options: EngineOptions, public engine: Engine, public interactivity: Interactivity, public scene: Scene, public xr: WebXRDefaultExperience | null, ) { this.plainCamera = new FreeCamera("camera", this.options.initialPos.clone(), this.scene); this.plainCamera.rotation = this.options.initialRotation; this.plainCamera.minZ = 0.1; this.plainCamera.inertia = 0.75; this.plainCamera.speed = 0.5; this.plainCamera.keysUp.push(87 /* W */); this.plainCamera.keysLeft.push(65 /* A */); this.plainCamera.keysDown.push(83 /* S */); this.plainCamera.keysRight.push(68 /* D */); this.plainCamera.keysUpward.push(69 /* E */); this.plainCamera.keysDownward.push(81 /* Q */); this.plainCamera.attachControl(true); scene.onKeyboardObservable.add(info => { if (info.event.metaKey) return; this.keysChanged.set(info.event.keyCode, info.type === KeyboardEventTypes.KEYDOWN); }); this._setupCamera(this.plainCamera); if (this.xr) { this.xr.baseExperience.onStateChangedObservable.add(state => this.xrStateChanged(state)); this.xrCamera = this.xr.baseExperience.camera; this._setupCamera(this.xrCamera); } this.camera = this.plainCamera; this.gamepadInput = new FreeCameraGamepadInput(); this.gamepadInput.gamepadMoveSensibility = 320; this.gamepadInput.gamepadAngularSensibility = 100; this.plainCamera.inputs.add(this.gamepadInput); this.gamepadInput.attachControl(); this.scene.collisionsEnabled = true; this.scene.audioPositioningRefreshRate = 100; if (this.options.canvas) { const canvas = this.options.canvas; if (this.xrAvailable) { canvas.onclick = () => this.xrToggle(); } else { canvas.onclick = () => canvas.requestPointerLock?.(); } } this.performanceMonitor.enable(); this.engine.runRenderLoop(() => { this.performanceMonitor.sampleFrame(); this.scene.gravity = this.gravity.scale(this.frameRateScale); try { Array.from(navigator.getGamepads()).forEach(gp => { if (gp !== null) this.checkGamepadInput(gp); }); this.checkKeys(); this.scene.render(); } catch (e) { console.error('Error in render loop', e); throw e; } }); window.addEventListener("resize", () => this.engine.resize()); } _setupCamera(c: FreeCamera) { c.checkCollisions = true; c.applyGravity = false; (c as any)._needMoveForGravity = true; c.ellipsoid = new Vector3(0.25, this.options.initialPos.y / 2, 0.25); } get frameRateScale(): number { return this.performanceMonitor.averageFrameTime / 1000.0; } get inXR(): boolean { return (this.xr !== null) && (this.xr.baseExperience.state === WebXRState.IN_XR); } get xrAvailable(): boolean { return this.xr !== null; } set applyGravity(b: boolean) { this.plainCamera.applyGravity = b; if (this.xrCamera) this.xrCamera.applyGravity = b; } set onCollide(c: (m: AbstractMesh) => void) { this.plainCamera.onCollide = c; if (this.xrCamera) this.xrCamera.onCollide = c; } get position(): Vector3 { return this.camera.position; } get rotation(): Vector3 { return this.inXR ? this.camera.rotationQuaternion.toEulerAngles() : this.camera.rotation; } padStateFor(gp: Gamepad): GamepadState { const state = this.padStates.get(gp.index); if (state) { // Apparently reusing Gamepad instances across frames doesn't work, // so we update (!) the stored instance here: state.gp = gp; return state; } const newState = new GamepadState(gp); this.padStates.set(gp.index, newState); return newState; } checkGamepadInput(gp: Gamepad) { const state = this.padStateFor(gp); if (state.b(9 /* options */).wasPressed) location.reload(); if (state.b(2 /* square */).wasPressed) this.jump(); if (state.b(3 /* triangle */).wasPressed) this.turn180(); if (this.xrAvailable) { if (state.b(16 /* ps */).wasPressed) this.xrToggle(); } if (this.inXR) { this.xrUpdateLean(gp); const actionButton = state.b(0 /* cross */); if (actionButton.wasPressed) { this.clearTeleportTimer(); this.xrTeleportTimer = setTimeout(() => { this.clearTeleportTimer(); this.xrTeleport(); }, 1000); } if (actionButton.wasReleased && this.xrTeleportTimer !== null) { this.clearTeleportTimer(); let a = 0; if (state.b(13 /* dPadDown */).isDown) a = 1; if (state.b(14 /* dPadLeft */).isDown) a = -0.5; if (state.b(15 /* dPadRight */).isDown) a = 0.5; this.xrStepOrTouch(Quaternion.RotationYawPitchRoll(a * Math.PI, 0, 0)); } const shareButton = state.b(8 /* share */); if (shareButton.wasPressed) { this.recenterBase = { rotation: this.xrCamera!.rotationQuaternion.clone(), }; } if (shareButton.isDown && this.recenterBase) { this.xrCamera!.rotationQuaternion.copyFrom( this.recenterBase.rotation); } } state.tick(); } clearTeleportTimer() { if (this.xrTeleportTimer !== null) { clearTimeout(this.xrTeleportTimer); this.xrTeleportTimer = null; } } checkKeys() { for (const [keyCode, state] of this.keysChanged.entries()) { if (state) { this.keysDown.add(keyCode); switch (keyCode) { case 32 /* space */: this.jump(); break; default: break; } } else { this.keysDown.delete(keyCode); } } this.keysChanged.clear(); } jump() { if (Math.abs(this.camera.cameraDirection.y) < 0.1) { this.camera.cameraDirection.y += 2 * 9.81 * this.frameRateScale; } } xrUpdateLean(gp: Gamepad) { const pos = new Vector3((gp.axes[0]), (-gp.axes[1]), (-gp.axes[3])); if (pos.length() > 0.0625) { if (this.leanBase === null) { this.leanBase = { position: this.xrCamera!.position }; } this.xrCamera!.position = pos.applyRotationQuaternion(this.xrCamera!.absoluteRotation) .scale(0.25) .add(this.leanBase.position); } else { if (this.leanBase !== null) { this.xrCamera!.position = this.leanBase.position; } this.leanBase = null; } } turn180() { const r = this.inXR ? this.xrCamera!.rotationQuaternion.toEulerAngles() : this.camera.rotation; r.y += Math.PI; r.y %= 2 * Math.PI; if (this.inXR) { this.xrCamera!.rotationQuaternion.copyFrom(r.toQuaternion()); } } xrStepOrTouch(a: Quaternion) { if (!this.inXR) return; const ray = new Ray(this.xrCamera!.position, new Vector3(0, 0, 1)); ray.direction.applyRotationQuaternionInPlace(this.xrCamera!.absoluteRotation.multiply(a)); const hit = this.scene.pickWithRay(ray); if (hit !== null && hit.distance <= 1.5 && this.interactivity.touchableMeshes().indexOf(hit.pickedMesh as any) !== -1) { this.camera.onCollide?.(hit.pickedMesh!); return; } const stepDistance = hit && hit.hit && hit.distance <= 1 ? hit.distance * 0.5 : 1; if (stepDistance < 0.5) return; const pos = this.xrCamera!.position.add(ray.direction.normalizeToNew().scale(stepDistance)); const downRay = new Ray(pos, new Vector3(0, -1, 0)); const downHit = this.scene.pickWithRay(downRay); if (downHit !== null && downHit.distance <= 1.6 && this.interactivity.floorMeshes().indexOf(downHit.pickedMesh as any) !== -1) { pos.addInPlace(new Vector3(0, 1.6 - downHit.distance, 0)); } this.xrCamera!.position = pos; if (this.leanBase !== null) this.leanBase.position = pos; } xrTeleport() { if (!this.inXR) return; const ray = this.xrCamera!.getForwardRay(); const hit = this.scene.pickWithRay(ray); if (hit === null) return; if (hit.pickedPoint === null) return; if (this.interactivity.floorMeshes().indexOf(hit.pickedMesh as any) === -1) return; const pos = hit.pickedPoint.add(new Vector3(0, 1.6, 0)); this.xrCamera!.position = pos; this.xrCamera!.rotation = Vector3.Zero(); if (this.leanBase !== null) this.leanBase.position = pos; } xrStateChanged(state: WebXRState) { switch (state) { case WebXRState.IN_XR: break; case WebXRState.ENTERING_XR: this.camera = this.xrCamera!; this.camera.position = this.plainCamera.position.clone(); this.camera.rotationQuaternion = this.options.initialRotation.toQuaternion(); break; case WebXRState.EXITING_XR: this.camera = this.plainCamera; this.camera.position = this.xrCamera!.position.clone(); this.camera.rotation = this.xrCamera!.rotationQuaternion.toEulerAngles(); break; case WebXRState.NOT_IN_XR: break; } } xrToggle() { if (this.inXR) { this.xr!.baseExperience.exitXRAsync(); } else { this.xr!.baseExperience.enterXRAsync('immersive-vr', 'local').then(() => { this.xr!.baseExperience.sessionManager.session.onselect = () => this.xrTeleport(); }); } } }