455 lines
15 KiB
TypeScript
455 lines
15 KiB
TypeScript
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<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.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();
|
|
});
|
|
}
|
|
}
|
|
}
|