house/src/engine.ts

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