house/src/engine.ts

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();
});
}
}
}