UNPKG

@matematrolii/sketchbook

Version:

3D matematrolii playground built on three.js and cannon.js

916 lines (809 loc) 28.6 kB
import * as THREE from "three"; // @ts-ignore import * as CANNON from "cannon"; import * as _ from "lodash"; import * as Utils from "../core/FunctionLibrary"; import { KeyBinding } from "../core/KeyBinding"; import { VectorSpringSimulator } from "../physics/spring_simulation/VectorSpringSimulator"; import { RelativeSpringSimulator } from "../physics/spring_simulation/RelativeSpringSimulator"; import { Idle } from "./character_states/Idle"; import { ICharacterAI } from "../interfaces/ICharacterAI"; import { World } from "../world/World"; import { ICharacterState } from "../interfaces/ICharacterState"; import { IWorldEntity } from "../interfaces/IWorldEntity"; import { Item } from "../items/Item"; import { CollisionGroups } from "../enums/CollisionGroups"; import { CapsuleCollider } from "../physics/colliders/CapsuleCollider"; import { GroundImpactData } from "./GroundImpactData"; import { ClosestObjectFinder } from "../core/ClosestObjectFinder"; import { Bullet } from "./Bullet"; import { EntityType } from "../enums/EntityType"; import { EventType } from "../enums/EventType"; import { ModelType } from "../enums/WorldType"; export class Character extends THREE.Object3D implements IWorldEntity { public isHit: boolean = false; public updateOrder: number = 1; public entityType: EntityType = EntityType.Character; public height: number = 0; public tiltContainer: THREE.Group; public modelContainer: THREE.Group; public materials: THREE.Material[] = []; public mixer: THREE.AnimationMixer; public animations: any[]; // Movement public acceleration: THREE.Vector3 = new THREE.Vector3(); public velocity: THREE.Vector3 = new THREE.Vector3(); public arcadeVelocityInfluence: THREE.Vector3 = new THREE.Vector3(); public velocityTarget: THREE.Vector3 = new THREE.Vector3(); public arcadeVelocityIsAdditive: boolean = false; public defaultVelocitySimulatorDamping: number = 0.8; public defaultVelocitySimulatorMass: number = 50; public velocitySimulator: VectorSpringSimulator; public moveSpeed: number = 4; public angularVelocity: number = 0; public orientation: THREE.Vector3 = new THREE.Vector3(0, 0, 1); public orientationTarget: THREE.Vector3 = new THREE.Vector3(0, 0, 1); public defaultRotationSimulatorDamping: number = 0.5; public defaultRotationSimulatorMass: number = 10; public rotationSimulator: RelativeSpringSimulator; public viewVector: THREE.Vector3; public actions: { [action: string]: KeyBinding }; public characterCapsule: CapsuleCollider; // Ray casting public rayResult: CANNON.RaycastResult = new CANNON.RaycastResult(); public rayHasHit: boolean = false; public rayCastLength: number = 0.57; public raySafeOffset: number = 0.03; public wantsToJump: boolean = false; public initJumpSpeed: number = -1; public groundImpactData: GroundImpactData = new GroundImpactData(); public world: World; public charState: ICharacterState; public behaviour: ICharacterAI; private physicsEnabled: boolean = true; public shootDirection: THREE.Vector3 = new THREE.Vector3(); public shootVelo: number = 15; public raycaster = new THREE.Raycaster(); public type = ModelType.PLAYER; private gltf: any = null; private bulletTimePassed: boolean = true; constructor(gltf: any, texture: string, type: ModelType, name: string) { super(); this.type = type; this.gltf = gltf; this.readCharacterData(gltf, texture); this.setAnimations(gltf.animations); // The visuals group is centered for easy character tilting this.tiltContainer = new THREE.Group(); this.add(this.tiltContainer); // Model container is used to reliably ground the character, as animation can alter the position of the model itself this.modelContainer = new THREE.Group(); this.modelContainer.position.y = -0.57; this.tiltContainer.add(this.modelContainer); this.modelContainer.add(gltf.scene); this.mixer = new THREE.AnimationMixer(gltf.scene); this.velocitySimulator = new VectorSpringSimulator( 60, this.defaultVelocitySimulatorMass, this.defaultVelocitySimulatorDamping ); this.rotationSimulator = new RelativeSpringSimulator( 60, this.defaultRotationSimulatorMass, this.defaultRotationSimulatorDamping ); this.viewVector = new THREE.Vector3(); // Actions this.actions = { pick: new KeyBinding("KeyE"), respawn: new KeyBinding("KeyR"), up: new KeyBinding("KeyW"), down: new KeyBinding("KeyS"), left: new KeyBinding("KeyA"), right: new KeyBinding("KeyD"), run: new KeyBinding("ShiftLeft"), jump: new KeyBinding("Space"), primary: new KeyBinding("Mouse0"), secondary: new KeyBinding("Mouse1"), }; const radius = this.type === ModelType.ENEMY ? 0.35 : 0.2; // Physics // Player Capsule this.characterCapsule = new CapsuleCollider({ mass: 1, position: new CANNON.Vec3(), height: 0.4, radius, segments: 3, friction: 0.0, name }); // capsulePhysics.physical.collisionFilterMask = ~CollisionGroups.Trimesh; this.characterCapsule.body.shapes.forEach((shape) => { // tslint:disable-next-line: no-bitwise shape.collisionFilterMask = ~CollisionGroups.TrimeshColliders; }); this.characterCapsule.body.allowSleep = false; (this.characterCapsule.body as any).userData = { type, name, isHit: false }; // Move character to different collision group for raycasting this.characterCapsule.body.collisionFilterGroup = 2; // Disable character rotation this.characterCapsule.body.fixedRotation = true; this.characterCapsule.body.updateMassProperties(); // Physics pre/post step callback bindings this.characterCapsule.body.preStep = (body: CANNON.Body) => { this.physicsPreStep(body, this); }; this.characterCapsule.body.postStep = (body: CANNON.Body) => { this.physicsPostStep(body, this); }; // States this.setState(new Idle(this)); } public setAnimations(animations: []): void { this.animations = animations; } public setArcadeVelocityInfluence( x: number, y: number = x, z: number = x ): void { this.arcadeVelocityInfluence.set(x, y, z); } public setViewVector(vector: THREE.Vector3): void { this.viewVector.copy(vector).normalize(); } /** * Set state to the player. Pass state class (function) name. * @param {function} State */ public setState(state: ICharacterState): void { this.charState = state; this.charState.onInputChange(); } public setPosition(x: number, y: number, z: number): void { if (this.physicsEnabled) { this.characterCapsule.body.previousPosition = new CANNON.Vec3(x, y, z); this.characterCapsule.body.position = new CANNON.Vec3(x, y, z); this.characterCapsule.body.interpolatedPosition = new CANNON.Vec3( x, y, z ); } else { this.position.x = x; this.position.y = y; this.position.z = z; } } public resetVelocity(): void { this.velocity.x = 0; this.velocity.y = 0; this.velocity.z = 0; this.characterCapsule.body.velocity.x = 0; this.characterCapsule.body.velocity.y = 0; this.characterCapsule.body.velocity.z = 0; this.velocitySimulator.init(); } public setArcadeVelocityTarget( velZ: number, velX: number = 0, velY: number = 0 ): void { this.velocityTarget.z = velZ; this.velocityTarget.x = velX; this.velocityTarget.y = velY; } public setOrientation( vector: THREE.Vector3, instantly: boolean = false ): void { let lookVector = new THREE.Vector3().copy(vector).setY(0).normalize(); this.orientationTarget.copy(lookVector); if (instantly) { this.orientation.copy(lookVector); } } public resetOrientation(): void { const forward = Utils.getForward(this); this.setOrientation(forward, true); } public setBehaviour(behaviour: ICharacterAI): void { behaviour.character = this; this.behaviour = behaviour; } public setPhysicsEnabled(value: boolean): void { this.physicsEnabled = value; if (value === true) { this.world.physicsWorld.addBody(this.characterCapsule.body); } else { this.world.physicsWorld.remove(this.characterCapsule.body); } } public readCharacterData(gltf: any, textureUrl: string): void { const image = new Image(); image.crossOrigin = "Anonymous"; const texture = new THREE.Texture(image); image.onload = () => { texture.needsUpdate = true; }; image.src = textureUrl; texture.flipY = false; const material = new THREE.MeshPhongMaterial({ shininess: 0, map: texture, }); //material.map.encoding = THREE.sRGBEncoding; material.skinning = true; material.map.flipY = false; gltf.scene.traverse((child) => { if (child.isMesh) { //Utils.setupMeshProperties(child); if (child.material.isGLTFSpecularGlossinessMaterial) { child.onBeforeRender = function () {}; } child.material = material; child.castShadow = true; child.receiveShadow = true; this.materials.push(material); } }); } public handleKeyboardEvent( event: KeyboardEvent, code: string, pressed: boolean ): void { // Free camera if (code === "KeyC" && pressed === true && event.shiftKey === true) { this.resetControls(); this.world.cameraOperator.characterCaller = this; this.world.inputManager.setInputReceiver(this.world.cameraOperator); } else if (code === "KeyR" && pressed === true) { this.world.outOfBoundsRespawn(this.characterCapsule.body); } else { for (const action in this.actions) { if (this.actions.hasOwnProperty(action)) { const binding = this.actions[action]; if (_.includes(binding.eventCodes, code)) { this.triggerAction(action, pressed); } } } } } public handleMouseButton( event: MouseEvent, code: string, pressed: boolean ): void { if (code === "mouse0" && pressed === true && this.bulletTimePassed) { // Shoot balls const bullet = new Bullet(); this.world.add(bullet); let x = this.position.x; let y = this.position.y; let z = this.position.z; const cameraDirection = this.getCameraRelativeVector(); this.shootDirection.copy(cameraDirection); this.setOrientation(cameraDirection, true); bullet.bulletBox.body.velocity.set( this.shootDirection.x * this.shootVelo, this.shootDirection.y * this.shootVelo, this.shootDirection.z * this.shootVelo ); // Move the ball outside the player sphere x += this.shootDirection.x * (this.characterCapsule.options.radius + bullet.bulletBox.options.radius); y += this.shootDirection.y; z += this.shootDirection.z * (this.characterCapsule.options.radius + bullet.bulletBox.options.radius); bullet.bulletBox.body.position.set(x, y, z); const that = this; this.bulletTimePassed = false; const timeout = setTimeout(() => { that.bulletTimePassed = true; clearTimeout(timeout); }, 1000); } else { for (const action in this.actions) { if (this.actions.hasOwnProperty(action)) { const binding = this.actions[action]; if (_.includes(binding.eventCodes, code)) { this.triggerAction(action, pressed); } } } } } public handleMouseMove( event: MouseEvent, deltaX: number, deltaY: number ): void { this.world.cameraOperator.move(deltaX, deltaY); } public handleMouseWheel(event: WheelEvent, value: number): void { //this.world.scrollTheTimeScale(value); } public triggerAction(actionName: string, value: boolean): void { // Get action and set it's parameters let action = this.actions[actionName]; if (action && action.isPressed !== value) { // Set value action.isPressed = value; // Reset the 'just' attributes action.justPressed = false; action.justReleased = false; // Set the 'just' attributes if (value) action.justPressed = true; else action.justReleased = true; // Tell player to handle states according to new input this.charState.onInputChange(); // Reset the 'just' attributes action.justPressed = false; action.justReleased = false; } } public takeControl(): void { if (this.world !== undefined) { this.world.inputManager.setInputReceiver(this); } else { console.warn( "Attempting to take control of a character that doesn't belong to a world." ); } } public resetControls(): void { for (const action in this.actions) { if (this.actions.hasOwnProperty(action)) { this.triggerAction(action, false); } } } public update(timeStep: number): void { if (this.behaviour) this.behaviour?.update(timeStep); if(this.charState) this.charState?.update(timeStep); if (this.physicsEnabled) this.springMovement(timeStep); if (this.physicsEnabled) this.springRotation(timeStep); if (this.physicsEnabled) this.rotateModel(); if (this.mixer !== undefined) this.mixer.update(timeStep); // Sync physics/graphics if (this.physicsEnabled) { this.position.set( this.characterCapsule.body.interpolatedPosition.x, this.characterCapsule.body.interpolatedPosition.y, this.characterCapsule.body.interpolatedPosition.z ); } else if (this.characterCapsule) { let newPos = new THREE.Vector3(); this.getWorldPosition(newPos); this.characterCapsule.body.position.copy(Utils.cannonVector(newPos)); this.characterCapsule.body.interpolatedPosition.copy( Utils.cannonVector(newPos) ); } this.updateMatrixWorld(); } public inputReceiverInit(): void { this.world.cameraOperator.setRadius(1.6, true); this.world.cameraOperator.followMode = false; // this.world.dirLight.target = this; } public inputReceiverUpdate(timeStep: number): void { // Look in camera's direction this.viewVector = new THREE.Vector3().subVectors( this.position, this.world.camera.position ); this.getWorldPosition(this.world.cameraOperator.target); } public setAnimation(clipName: string, fadeIn: number): number { if (this.mixer !== undefined) { // gltf let clip = THREE.AnimationClip.findByName(this.animations, clipName); let action = this.mixer.clipAction(clip); if (action === null) { //console.error(`Animation ${clipName} not found!`); return 0; } action.setEffectiveWeight(1); action.enabled = true; this.mixer.stopAllAction(); action.fadeIn(fadeIn); action.play(); return action.getClip().duration; } } public springMovement(timeStep: number): void { // Simulator this.velocitySimulator.target.copy(this.velocityTarget); this.velocitySimulator.simulate(timeStep); // Update values this.velocity.copy(this.velocitySimulator.position); this.acceleration.copy(this.velocitySimulator.velocity); } public springRotation(timeStep: number): void { // Spring rotation // Figure out angle between current and target orientation let angle = Utils.getSignedAngleBetweenVectors( this.orientation, this.orientationTarget ); // Simulator this.rotationSimulator.target = angle; this.rotationSimulator.simulate(timeStep); let rot = this.rotationSimulator.position; // Updating values this.orientation.applyAxisAngle(new THREE.Vector3(0, 1, 0), rot); this.angularVelocity = this.rotationSimulator.velocity; } public getLocalMovementDirection(): THREE.Vector3 { const positiveX = this.actions.right.isPressed ? -1 : 0; const negativeX = this.actions.left.isPressed ? 1 : 0; const positiveZ = this.actions.up.isPressed ? 1 : 0; const negativeZ = this.actions.down.isPressed ? -1 : 0; return new THREE.Vector3( positiveX + negativeX, 0, positiveZ + negativeZ ).normalize(); } public getCameraRelativeMovementVector(): THREE.Vector3 { const localDirection = this.getLocalMovementDirection(); const flatViewVector = new THREE.Vector3( this.viewVector.x, 0, this.viewVector.z ).normalize(); return Utils.appplyVectorMatrixXZ(flatViewVector, localDirection); } public getCameraRelativeVector(): THREE.Vector3 { const localDirection = new THREE.Vector3(0, 0, 1).normalize(); const flatViewVector = new THREE.Vector3( this.viewVector.x, 0, this.viewVector.z ).normalize(); return Utils.appplyVectorMatrixXZ(flatViewVector, localDirection); } public setCameraRelativeOrientationTarget(): void { let moveVector = this.getCameraRelativeMovementVector(); if (moveVector.x === 0 && moveVector.y === 0 && moveVector.z === 0) { this.setOrientation(this.orientation); } else { this.setOrientation(moveVector); } } public rotateModel(): void { this.lookAt( this.position.x + this.orientation.x, this.position.y + this.orientation.y, this.position.z + this.orientation.z ); this.tiltContainer.rotation.z = -this.angularVelocity * 2.3 * this.velocity.length(); this.tiltContainer.position.setY( Math.cos(Math.abs(this.angularVelocity * 2.3 * this.velocity.length())) / 2 - 0.5 ); } public jump(initJumpSpeed: number = -1): void { this.wantsToJump = true; this.initJumpSpeed = initJumpSpeed; } public findItemToPick(): void { let itemFinder = new ClosestObjectFinder<Item>(this.position, 1); if (this.world) { this.world.items.forEach((item) => { itemFinder.consider(item, item.position); }); if (itemFinder.closestObject !== undefined) { let item = itemFinder.closestObject; if (!item.isPicked) { item.isPicked = true; item.removeFromWorld(this.world); this.world.triggerEvent({ type: EventType.ITEM, targets: [], }); } } } } public physicsPreStep(body: CANNON.Body, character: Character): void { character.feetRaycast(); } public feetRaycast(): void { // Player ray casting // Create ray let body = this.characterCapsule.body; const start = new CANNON.Vec3( body.position.x, body.position.y, body.position.z ); const end = new CANNON.Vec3( body.position.x, body.position.y - this.rayCastLength - this.raySafeOffset, body.position.z ); // Raycast options const rayCastOptions = { collisionFilterMask: CollisionGroups.Default, skipBackfaces: true /* ignore back faces */, }; // Cast the ray this.rayHasHit = this.world.physicsWorld.raycastClosest( start, end, rayCastOptions, this.rayResult ); } public physicsPostStep(body: CANNON.Body, character: Character): void { // Get velocities let simulatedVelocity = new THREE.Vector3( body.velocity.x, body.velocity.y, body.velocity.z ); // Take local velocity let arcadeVelocity = new THREE.Vector3() .copy(character.velocity) .multiplyScalar(character.moveSpeed); // Turn local into global arcadeVelocity = Utils.appplyVectorMatrixXZ( character.orientation, arcadeVelocity ); let newVelocity = new THREE.Vector3(); // Additive velocity mode if (character.arcadeVelocityIsAdditive) { newVelocity.copy(simulatedVelocity); let globalVelocityTarget = Utils.appplyVectorMatrixXZ( character.orientation, character.velocityTarget ); let add = new THREE.Vector3() .copy(arcadeVelocity) .multiply(character.arcadeVelocityInfluence); if ( Math.abs(simulatedVelocity.x) < Math.abs(globalVelocityTarget.x * character.moveSpeed) || Utils.haveDifferentSigns(simulatedVelocity.x, arcadeVelocity.x) ) { newVelocity.x += add.x; } if ( Math.abs(simulatedVelocity.y) < Math.abs(globalVelocityTarget.y * character.moveSpeed) || Utils.haveDifferentSigns(simulatedVelocity.y, arcadeVelocity.y) ) { newVelocity.y += add.y; } if ( Math.abs(simulatedVelocity.z) < Math.abs(globalVelocityTarget.z * character.moveSpeed) || Utils.haveDifferentSigns(simulatedVelocity.z, arcadeVelocity.z) ) { newVelocity.z += add.z; } } else { newVelocity = new THREE.Vector3( THREE.MathUtils.lerp( simulatedVelocity.x, arcadeVelocity.x, character.arcadeVelocityInfluence.x ), THREE.MathUtils.lerp( simulatedVelocity.y, arcadeVelocity.y, character.arcadeVelocityInfluence.y ), THREE.MathUtils.lerp( simulatedVelocity.z, arcadeVelocity.z, character.arcadeVelocityInfluence.z ) ); } // If we're hitting the ground, stick to ground if (character.rayHasHit) { // Flatten velocity newVelocity.y = 0; // Move on top of moving objects if (character.rayResult.body.mass > 0) { let pointVelocity = new CANNON.Vec3(); character.rayResult.body.getVelocityAtWorldPoint( character.rayResult.hitPointWorld, pointVelocity ); newVelocity.add(Utils.threeVector(pointVelocity)); } // Measure the normal vector offset from direct "up" vector // and transform it into a matrix let up = new THREE.Vector3(0, 1, 0); let normal = new THREE.Vector3( character.rayResult.hitNormalWorld.x, character.rayResult.hitNormalWorld.y, character.rayResult.hitNormalWorld.z ); let q = new THREE.Quaternion().setFromUnitVectors(up, normal); let m = new THREE.Matrix4().makeRotationFromQuaternion(q); // Rotate the velocity vector newVelocity.applyMatrix4(m); // Compensate for gravity // newVelocity.y -= body.world.physicsWorld.gravity.y / body.character.world.physicsFrameRate; // Apply velocity body.velocity.x = newVelocity.x; body.velocity.y = newVelocity.y; body.velocity.z = newVelocity.z; // Ground character body.position.y = character.rayResult.hitPointWorld.y + character.rayCastLength + newVelocity.y / character.world.physicsFrameRate; } else { // If we're in air body.velocity.x = newVelocity.x; body.velocity.y = newVelocity.y; body.velocity.z = newVelocity.z; // Save last in-air information character.groundImpactData.velocity.x = body.velocity.x; character.groundImpactData.velocity.y = body.velocity.y; character.groundImpactData.velocity.z = body.velocity.z; } // Jumping if (character.wantsToJump) { // If initJumpSpeed is set if (character.initJumpSpeed > -1) { // Flatten velocity body.velocity.y = 0; let speed = Math.max( character.velocitySimulator.position.length() * 4, character.initJumpSpeed ); body.velocity = Utils.cannonVector( character.orientation.clone().multiplyScalar(speed) ); } else { // Moving objects compensation let add = new CANNON.Vec3(); character.rayResult.body.getVelocityAtWorldPoint( character.rayResult.hitPointWorld, add ); body.velocity.vsub(add, body.velocity); } // Add positive vertical velocity body.velocity.y += 4; // Move above ground by 2x safe offset value body.position.y += character.raySafeOffset * 2; // Reset flag character.wantsToJump = false; } } public collideListener(colider: any) { //console.log('Character colider: ', colider.body.characterType); const that = this; //console.log('Character colider 2: ', colider.body.characterType); if (colider.body?.userData?.type === ModelType.ENEMY) { // TODO: should work once I put isHit into userData const enemyIsHit = colider.body?.userData?.isHit; //console.log('Character Enemy found') setTimeout(function() { if (that && that.world && that.characterCapsule && !that.isHit && !enemyIsHit) { that.isHit = true; that.characterCapsule.body.removeEventListener("collide", that.collideListener); // Player dies that.world.triggerEvent({ type: EventType.PLAYER, targets: [], }); that.world.remove(that); } }, 0); } } public addToWorld(world: World): void { if (_.includes(world.characters, this)) { console.warn("Adding character to a world in which it already exists."); } else { // Set world this.world = world; // Register character world.characters.push(this); // Register for bullet collision world.characterNames[(this.characterCapsule.body as any).userData.name] = this; // Register physics world.physicsWorld.addBody(this.characterCapsule.body); // Add to graphicsWorld world.graphicsWorld.add(this); if (this.type === ModelType.PLAYER) { this.characterCapsule.body.addEventListener("collide", this.collideListener.bind(this)); } // Shadow cascades this.materials.forEach((mat) => { world.sky.csm.setupMaterial(mat); }); } } public removeFromWorld(world: World): void { // TODO: dealocate memory properly https://stackoverflow.com/questions/20997669/memory-leak-in-three-js if (!_.includes(world.characters, this)) { console.warn( "Removing character from a world in which it isn't present." ); } else { if (world.inputManager.inputReceiver === this) { world.inputManager.inputReceiver = undefined; } this.world = undefined; // Remove from characters _.pull(world.characters, this); // Remove physics world.physicsWorld.remove(this.characterCapsule.body); // Remove visuals world.graphicsWorld.remove(this); this.modelContainer.remove(this.gltf.scene); this.tiltContainer.remove(this.modelContainer); this.remove(this.tiltContainer); this.materials.forEach(material => material.dispose()); if (this.behaviour) { this.behaviour.character = undefined; this.behaviour = undefined; } this.materials = undefined; this.modelContainer = undefined; this.tiltContainer = undefined; this.mixer = undefined; this.velocitySimulator = undefined; this.rotationSimulator = undefined; this.viewVector = undefined; this.characterCapsule = undefined; this.actions = undefined; this.isHit = undefined; this.updateOrder = undefined; this.entityType = undefined; this.height = undefined; this.animations = undefined; this.acceleration = undefined; this.velocity = undefined; this.arcadeVelocityInfluence = undefined; this.velocityTarget = undefined; this.arcadeVelocityIsAdditive = undefined; this.defaultVelocitySimulatorDamping = undefined; this.defaultVelocitySimulatorMass = undefined; this.moveSpeed = undefined; this.angularVelocity = undefined; this.orientation = undefined; this.orientationTarget = undefined; this.defaultRotationSimulatorDamping = undefined; this.defaultRotationSimulatorMass = undefined; this.rayResult = undefined; this.rayHasHit = undefined; this.rayCastLength = undefined; this.raySafeOffset = undefined; this.wantsToJump = undefined; this.initJumpSpeed = undefined; this.groundImpactData = undefined; this.charState = undefined; this.behaviour = undefined; this.physicsEnabled = undefined; this.shootDirection = undefined; this.shootVelo = undefined; this.raycaster = undefined; this.type = undefined; } } }