392 lines
13 KiB
TypeScript
392 lines
13 KiB
TypeScript
import {
|
|
AbstractMesh,
|
|
DualShockPad,
|
|
Engine,
|
|
FreeCamera,
|
|
FreeCameraGamepadInput,
|
|
Gamepad as b_Gamepad,
|
|
KeyboardEventTypes,
|
|
PerformanceMonitor,
|
|
Quaternion,
|
|
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 = { [button: number]: boolean };
|
|
|
|
export class GamepadState {
|
|
buttons: ButtonState = {};
|
|
|
|
constructor (
|
|
public gp: Gamepad, // NB. browser's Gamepad class, not Babylon's Gamepad class.
|
|
) {}
|
|
|
|
latch(button: number): boolean {
|
|
let result = false;
|
|
const b = this.gp.buttons[button];
|
|
if (b) {
|
|
if (b.pressed) {
|
|
if (!this.isDown(button)) result = true;
|
|
this.buttons[button] = true;
|
|
} else {
|
|
this.buttons[button] = false;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
isDown(button: number): boolean {
|
|
return this.buttons[button] ?? false;
|
|
}
|
|
}
|
|
|
|
export class RunningEngine {
|
|
// The active camera - plainCamera or xrCamera, depending.
|
|
camera: FreeCamera;
|
|
|
|
plainCamera: FreeCamera;
|
|
gamepadInput: FreeCameraGamepadInput;
|
|
|
|
xrCamera: WebXRCamera | null = null;
|
|
|
|
leanBase: { position: Vector3 } | null = null;
|
|
recenterBase: { rotation: Quaternion } | null = null;
|
|
|
|
padStates = new Map<number, GamepadState>();
|
|
keysDown = new Set<number>();
|
|
keysChanged = new Map<number, boolean>();
|
|
|
|
gravity = new Vector3(0, 0, 0);
|
|
performanceMonitor = new PerformanceMonitor();
|
|
|
|
static async start(
|
|
interactivity: Interactivity,
|
|
options0: Partial<EngineOptions> = {},
|
|
): Promise<RunningEngine> {
|
|
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.latch(9 /* options */)) location.reload();
|
|
if (state.latch(2 /* square */)) this.jump();
|
|
if (state.latch(3 /* triangle */)) this.turn180();
|
|
|
|
if (this.xrAvailable) {
|
|
if (state.latch(16 /* ps */)) this.xrToggle();
|
|
}
|
|
|
|
if (this.inXR) {
|
|
this.xrUpdateLean(gp);
|
|
if (state.latch(0 /* cross */)) this.xrTeleport();
|
|
if (state.latch(1 /* circle */)) this.xrTouch();
|
|
|
|
if (state.latch(8 /* share */)) {
|
|
this.recenterBase = {
|
|
rotation: this.xrCamera!.rotationQuaternion.clone(),
|
|
};
|
|
}
|
|
if (state.isDown(8 /* share */) && this.recenterBase) {
|
|
this.xrCamera!.rotationQuaternion.copyFrom(
|
|
this.recenterBase.rotation);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 += 1 * 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());
|
|
}
|
|
}
|
|
|
|
xrTouch() {
|
|
const ray = this.xrCamera!.getForwardRay();
|
|
const meshes = this.interactivity.touchableMeshes();
|
|
const hit = this.scene.pickWithRay(ray, m => meshes.indexOf(m as any) !== -1);
|
|
|
|
if (hit === null) return;
|
|
if (meshes.indexOf(hit.pickedMesh as any) === -1) return;
|
|
|
|
this.camera.onCollide?.(hit.pickedMesh!);
|
|
}
|
|
|
|
xrTeleport() {
|
|
if (!this.inXR) return;
|
|
|
|
const ray = this.xrCamera!.getForwardRay();
|
|
const meshes = this.interactivity.floorMeshes();
|
|
const hit = this.scene.pickWithRay(ray, m => meshes.indexOf(m as any) !== -1);
|
|
|
|
if (hit === null) return;
|
|
if (meshes.indexOf(hit.pickedMesh as any) === -1) return;
|
|
if (hit.pickedPoint === null) 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();
|
|
});
|
|
}
|
|
}
|
|
}
|