import { Object3D, SkinnedMesh, Vector3 } from 'three'; import { EngineComponent } from '../types/engine-component'; import { World } from '../gameobjects/world.object'; import { getRapier, PhysicsTicking } from '../physics'; import { Humanoid } from '../gameobjects/humanoid.object'; import { PhysicsObject } from '../gameobjects/physics.object'; import { GameObject } from '../types/game-object'; import type Rapier from '@dimforge/rapier3d'; import { PlayerEvent, ServerTransformEvent } from '../types/events'; export class PhysicsWorldComponent extends EngineComponent { public name = PhysicsWorldComponent.name; private world!: World; private physicsWorld!: Rapier.World; private physicsEngine!: typeof Rapier; private characterPhysics!: Rapier.KinematicCharacterController; private cleanUpEvents?: Function; private sceneInitialized = false; private trackedObjects: PhysicsTicking[] = []; private physicsTick?: ReturnType; initialize(): void { this.world = this.renderer.scene.getObjectByName('World') as World; this.cleanUpEvents = this.bindEvents(); } update(delta: number): void { if (!this.physicsWorld) return; // FIXME: physics is tied to the FPS this.physicsWorld.timestep = delta; this.physicsWorld?.step(); for (const object of this.trackedObjects) object.tickPhysics(delta); } dispose(): void { clearInterval(this.physicsTick); this.cleanUpEvents?.call(this); this.physicsWorld.removeCharacterController(this.characterPhysics); for (const object of this.trackedObjects) object.dispose(); this.physicsWorld.free(); } private createRapier() { getRapier().then((physicsEngine) => { const gravity = new Vector3(0, this.world.gravity, 0); const world = new physicsEngine.World(gravity); this.physicsWorld = world; this.physicsWorld.timestep = 1 / 60; this.physicsEngine = physicsEngine; this.initializePhysicsScene(); }); } private bindEvents() { const worldLoadEvent = () => { this.createRapier(); }; const sceneJoinEvent = (object: Object3D) => { this.applyPhysics(object); }; const sceneLeaveEvent = (object: Object3D) => { this.removePhysics(object); }; const serverTransformEvent = (event: ServerTransformEvent) => { const object = this.trackedObjects.find( (obj) => event.object === obj.uuid ) as PhysicsObject; if (!object || !object.rigidBody) return; if (object instanceof Humanoid) { object.synchronize(event); } else { object.rigidBody?.setTranslation(event.position, false); object.rigidBody?.setRotation(event.quaternion, false); event.velocity && object.rigidBody?.setLinvel(event.velocity, false); event.angularVelocity && object.rigidBody?.setAngvel(event.angularVelocity, false); } }; const characterMoveEvent = (event: PlayerEvent) => { const object = this.trackedObjects.find( (obj) => event.playerId === obj.uuid ) as Humanoid; if (!object) return; object.setVelocity(event.velocity); object.setLook(event.lookAt); if (event.jump) object.jump(); }; this.events.addListener('loadComplete', worldLoadEvent); this.events.addListener('sceneJoin', sceneJoinEvent); this.events.addListener('sceneLeave', sceneLeaveEvent); this.events.addListener('serverTransform', serverTransformEvent); this.events.addListener('moveCharacter', characterMoveEvent); return () => { this.events.removeEventListener('loadComplete', worldLoadEvent); this.events.removeEventListener('sceneJoin', sceneJoinEvent); this.events.removeEventListener('sceneLeave', sceneLeaveEvent); this.events.removeEventListener('serverTransform', serverTransformEvent); this.events.removeEventListener('moveCharacter', characterMoveEvent); }; } private applyPhysics(root: Object3D) { if (!this.physicsEngine) return; root.traverse((object) => { // Prevent double-init if (this.trackedObjects.some((entry) => entry.uuid === object.uuid)) return; // Initialize humanoids if (object instanceof Humanoid) { object.initializePhysics( this.physicsEngine, this.physicsWorld, this.characterPhysics ); this.trackedObjects.push(object); return; } // Only track physics object instances if (!(object instanceof PhysicsObject)) return; // Do not add physics to virtual objects if ((object as GameObject).virtual) return; // Do not apply physics to animated objects if ((object.getMesh() as SkinnedMesh).skeleton) return; // Initialize object physics object.initializePhysics(this.physicsEngine, this.physicsWorld); this.trackedObjects.push(object); }); } private removePhysics(root: Object3D) { const trackedObjects = [...this.trackedObjects]; const physicsLeaveUUIDs: string[] = []; root.traverse((object) => { if (trackedObjects.some((item) => item.uuid === object.uuid)) { physicsLeaveUUIDs.push(object.uuid); if ((object as PhysicsObject).dispose) { (object as PhysicsObject).dispose(); } } }); this.trackedObjects = this.trackedObjects.filter( (object) => !physicsLeaveUUIDs.includes(object.uuid) ); } private async initializePhysicsScene() { if (this.sceneInitialized) return; this.characterPhysics = this.physicsWorld.createCharacterController(0.01); this.characterPhysics.setApplyImpulsesToDynamicBodies(true); this.characterPhysics.setMaxSlopeClimbAngle((75 * Math.PI) / 180); this.characterPhysics.enableAutostep(1, 0.5, true); // Automatically slide down on slopes smaller than 30 degrees. // this.characterPhysics.setMinSlopeSlideAngle((30 * Math.PI) / 180); this.applyPhysics(this.world); this.sceneInitialized = true; this.events.emit('physicsComplete'); } }