356 lines
9.6 KiB
TypeScript
356 lines
9.6 KiB
TypeScript
import {
|
|
AnimationAction,
|
|
AnimationClip,
|
|
AnimationMixer,
|
|
LoopOnce,
|
|
Matrix4,
|
|
Quaternion,
|
|
Skeleton,
|
|
SkinnedMesh,
|
|
Vector3,
|
|
} from 'three';
|
|
import { GameObject } from '../types/game-object';
|
|
import { ExposeProperty } from '../decorators/property';
|
|
import { MeshPart } from './mesh.object';
|
|
import { clamp } from 'three/src/math/MathUtils.js';
|
|
import { NameTag } from './nametag.object';
|
|
import { CanvasUtils } from '../canvas/utils';
|
|
import { PhysicsTicking } from '../physics';
|
|
import type Rapier from '@dimforge/rapier3d';
|
|
import { ServerTransformEvent } from '../types/events';
|
|
|
|
export class Humanoid extends GameObject implements PhysicsTicking {
|
|
public isTickingObject = true;
|
|
public objectType = 'Humanoid';
|
|
public name = 'Humanoid';
|
|
protected ready = false;
|
|
protected skeleton!: Skeleton;
|
|
private _health = 100;
|
|
private _maxHealth = 100;
|
|
private _velocity = new Vector3(0, 0, 0);
|
|
private _serverSet = false;
|
|
private _serverVelocity = new Vector3(0, 0, 0);
|
|
private _serverPosition = new Vector3(0, 0, 0);
|
|
private _serverRotation = new Quaternion();
|
|
private _appliedGravity = new Vector3(0, 0, 0);
|
|
private _grounded = true;
|
|
private _lookAt = new Vector3(0, 0, 1);
|
|
private _currentLookAt = new Vector3(0, 0, 1);
|
|
private _animState = 0;
|
|
public bodyPartNames = [
|
|
'Head',
|
|
'Torso',
|
|
'ArmLeft',
|
|
'ArmRight',
|
|
'LegRight',
|
|
'LegLeft',
|
|
] as string[];
|
|
|
|
public bodyBoneNames = [
|
|
'BHead',
|
|
'BTorso',
|
|
'BArmL',
|
|
'BArmR',
|
|
'BLegR',
|
|
'BLegL',
|
|
] as string[];
|
|
|
|
public static bodyAnimationNames = ['Idle', 'Walk'];
|
|
|
|
public static nameTagBuilder = new CanvasUtils({
|
|
fill: false,
|
|
textBorderColor: '#000',
|
|
foregroundColor: '#fff',
|
|
textShadowBlur: 2,
|
|
textBorderSize: 1,
|
|
});
|
|
|
|
public characterHeight = 5.5;
|
|
public characterHalfHeight = this.characterHeight / 2;
|
|
|
|
protected shouldJump = false;
|
|
|
|
protected mixer!: AnimationMixer;
|
|
private idleAction!: AnimationAction;
|
|
private walkAction!: AnimationAction;
|
|
private jumpAction!: AnimationAction;
|
|
|
|
protected nameTag?: NameTag;
|
|
|
|
protected collider?: Rapier.Collider;
|
|
protected rigidBody?: Rapier.RigidBody;
|
|
protected physicsWorldRef?: Rapier.World;
|
|
protected characterControllerRef?: Rapier.KinematicCharacterController;
|
|
|
|
@ExposeProperty({ type: Number })
|
|
public mass = -8;
|
|
|
|
@ExposeProperty({ type: Boolean })
|
|
public jumpPower = 8;
|
|
|
|
@ExposeProperty({ type: Number })
|
|
get health() {
|
|
return this._health;
|
|
}
|
|
set health(value: number) {
|
|
const health = clamp(Math.floor(value), 0, this.maxHealth);
|
|
if (health === 0) this.die();
|
|
this.health = health;
|
|
}
|
|
|
|
@ExposeProperty({ type: Number })
|
|
get maxHealth() {
|
|
return this._maxHealth;
|
|
}
|
|
set maxHealth(value: number) {
|
|
const maxHealth = Math.floor(value);
|
|
if (this.health > maxHealth) {
|
|
this.health = maxHealth;
|
|
}
|
|
this._maxHealth = maxHealth;
|
|
}
|
|
|
|
get alive() {
|
|
return this.health > 0;
|
|
}
|
|
|
|
get grounded() {
|
|
return this._grounded;
|
|
}
|
|
set grounded(value: boolean) {
|
|
this._grounded = value;
|
|
}
|
|
|
|
private get weight() {
|
|
return (this.physicsWorldRef?.gravity.y || -9.81) + this.mass;
|
|
}
|
|
|
|
private get bodyParts() {
|
|
return this.bodyPartNames.map((key) => this.getBodyPartByName(key));
|
|
}
|
|
|
|
getBodyPartByName(name: string) {
|
|
return this.parent?.getObjectByName(name) as MeshPart;
|
|
}
|
|
|
|
getBoneForBodyPart(part: string) {
|
|
return this.skeleton.getBoneByName(
|
|
this.bodyBoneNames[this.bodyPartNames.indexOf(part)]
|
|
);
|
|
}
|
|
|
|
initializePhysics(
|
|
physicsEngine: typeof Rapier,
|
|
physicsWorld: Rapier.World,
|
|
characterController: Rapier.KinematicCharacterController
|
|
): void {
|
|
if (!this.parent)
|
|
throw new Error('Cannot initialize Humanoid to empty parent');
|
|
|
|
this.skeleton = (this.bodyParts[0]?.getMesh() as SkinnedMesh).skeleton;
|
|
|
|
if (!this.skeleton) throw new Error('Could not find Skeleton for Humanoid');
|
|
this.skeleton.pose();
|
|
|
|
if (FREEBLOX_SIDE === 'client') {
|
|
// Clip actions
|
|
this.mixer = new AnimationMixer(this.parent);
|
|
|
|
const idleClip = AnimationClip.findByName(this.parent.animations, 'Idle');
|
|
const walkClip = AnimationClip.findByName(this.parent.animations, 'Walk');
|
|
const jumpClip = AnimationClip.findByName(this.parent.animations, 'Jump');
|
|
this.idleAction = this.mixer.clipAction(idleClip);
|
|
this.walkAction = this.mixer.clipAction(walkClip);
|
|
this.jumpAction = this.mixer.clipAction(jumpClip);
|
|
this.jumpAction.setLoop(LoopOnce, 0);
|
|
this.idleAction.play();
|
|
|
|
// Create name tag
|
|
this.createNameTag();
|
|
}
|
|
|
|
this.ready = true;
|
|
|
|
if (this.virtual) return;
|
|
this.physicsWorldRef = physicsWorld;
|
|
this.characterControllerRef = characterController;
|
|
|
|
// Character Physics
|
|
const halfVec = new Vector3(0, this.characterHalfHeight, 0);
|
|
const colliderDesc = physicsEngine.ColliderDesc.cuboid(
|
|
1,
|
|
this.characterHalfHeight,
|
|
0.5
|
|
);
|
|
const rigidBodyDesc = physicsEngine.RigidBodyDesc.kinematicPositionBased()
|
|
.setTranslation(...this.parent!.position.toArray())
|
|
.setRotation(this.parent!.quaternion);
|
|
const rigidBody = physicsWorld.createRigidBody(rigidBodyDesc);
|
|
const collider = physicsWorld.createCollider(colliderDesc, rigidBody);
|
|
collider.setTranslationWrtParent(halfVec);
|
|
|
|
this.rigidBody = rigidBody;
|
|
this.collider = collider;
|
|
}
|
|
|
|
setVelocity(velocity: Vector3) {
|
|
this._velocity.copy(velocity);
|
|
|
|
this.setWalkAnimationState(!!this._velocity.length() ? 1 : 0);
|
|
}
|
|
|
|
setLook(vector: Vector3) {
|
|
this._lookAt.lerp(vector, 0.15);
|
|
}
|
|
|
|
jump() {
|
|
if (!this.grounded || this.shouldJump) return;
|
|
this.shouldJump = true;
|
|
}
|
|
|
|
tickPhysics(dt: number): void {
|
|
if (!this.ready) return;
|
|
|
|
// Stick to ground
|
|
if (this.grounded) {
|
|
this._appliedGravity.y = 0;
|
|
}
|
|
|
|
if (this._serverSet) {
|
|
this.rigidBody!.setRotation(this._serverRotation, false);
|
|
this.rigidBody!.setTranslation(this._serverPosition, false);
|
|
this._serverSet = false;
|
|
}
|
|
|
|
// Apply rigidbody transforms to object from last process tick
|
|
if (this.rigidBody) {
|
|
this.parent!.position.copy(this.rigidBody.translation() as any);
|
|
this.parent?.quaternion.copy(this.rigidBody.rotation() as any);
|
|
}
|
|
|
|
// Run animation
|
|
this.mixer.update(dt);
|
|
|
|
// Apply jumping
|
|
if (this.shouldJump) {
|
|
this._appliedGravity.y = Math.sqrt(-2 * this.weight * this.jumpPower);
|
|
this.grounded = false;
|
|
this.shouldJump = false;
|
|
}
|
|
|
|
// Apply gravity
|
|
this._appliedGravity.y += this.weight * dt;
|
|
|
|
// Apply velocity
|
|
this.applyVelocity(
|
|
this._velocity.clone().add(this._appliedGravity).multiplyScalar(dt)
|
|
);
|
|
|
|
// Apply look direction to the physics engine
|
|
const sink = new Vector3();
|
|
const rotQuat = new Quaternion();
|
|
new Matrix4()
|
|
.lookAt(
|
|
this.parent!.position,
|
|
this.parent!.position.clone().sub(this._lookAt),
|
|
new Vector3(0, 1, 0)
|
|
)
|
|
.decompose(sink, rotQuat, sink);
|
|
this.rigidBody?.setRotation(rotQuat, false);
|
|
}
|
|
|
|
setWalkAnimationState(index: number) {
|
|
const previousState = this._animState;
|
|
this._animState = index;
|
|
if (previousState === index) return;
|
|
|
|
if (index === 1) {
|
|
this.walkAction.reset().crossFadeFrom(this.idleAction, 0.5, false).play();
|
|
} else {
|
|
this.walkAction.crossFadeTo(this.idleAction.reset(), 0.5, false).play();
|
|
}
|
|
}
|
|
|
|
detach(bodyPart?: string) {
|
|
if (!bodyPart) {
|
|
this.bodyPartNames.forEach((part) => this.detach(part));
|
|
return;
|
|
}
|
|
|
|
const part = this.getBodyPartByName(bodyPart);
|
|
if (!part) return;
|
|
const partMesh = part.getMesh() as SkinnedMesh;
|
|
|
|
if (partMesh.bindMode === 'detached') return;
|
|
partMesh.bindMode = 'detached';
|
|
partMesh.updateMatrixWorld(true);
|
|
|
|
const bone = this.getBoneForBodyPart(bodyPart);
|
|
if (!bone) return;
|
|
bone.removeFromParent();
|
|
this.skeleton.update();
|
|
}
|
|
|
|
die() {
|
|
if (!this.alive) return;
|
|
this.health = 0;
|
|
this.detach();
|
|
}
|
|
|
|
synchronize(event: ServerTransformEvent) {
|
|
if (!this.rigidBody) return;
|
|
this._serverRotation.copy(event.quaternion);
|
|
this._serverPosition.copy(event.position);
|
|
this._serverVelocity.copy(event.velocity);
|
|
this._serverSet = true;
|
|
}
|
|
|
|
private createNameTag() {
|
|
if (this.nameTag) {
|
|
this.nameTag.dispose();
|
|
}
|
|
|
|
this.nameTag = NameTag.create(Humanoid.nameTagBuilder, this.parent!.name);
|
|
this.nameTag.position.set(0, 1.5, 0);
|
|
this.add(this.nameTag);
|
|
}
|
|
|
|
private applyVelocity(velocity: Vector3) {
|
|
if (!this.characterControllerRef || !this.parent || !this.rigidBody) return;
|
|
|
|
const vec3 = this.parent.position.clone();
|
|
this.characterControllerRef.computeColliderMovement(
|
|
this.collider!,
|
|
velocity
|
|
);
|
|
const computed = this.characterControllerRef.computedMovement();
|
|
const grounded = this.characterControllerRef.computedGrounded();
|
|
vec3.copy(computed as Vector3);
|
|
vec3.add(this.parent.position);
|
|
// vec3.lerp(this._serverPosition, 0.05);
|
|
this.rigidBody?.setNextKinematicTranslation(vec3);
|
|
|
|
// After the collider movement calculation is done, we can read the
|
|
// collision events.
|
|
// for (let i = 0; i < this.controller.numComputedCollisions(); i++) {
|
|
// let collision = this.controller.computedCollision(i);
|
|
// // Do something with that collision information.
|
|
// console.log(collision);
|
|
// }
|
|
|
|
this._grounded = grounded;
|
|
}
|
|
|
|
dispose(): void {
|
|
this.nameTag?.dispose();
|
|
|
|
if (this.collider && !this.rigidBody) {
|
|
this.physicsWorldRef?.removeCollider(this.collider, false);
|
|
}
|
|
|
|
if (this.rigidBody) {
|
|
this.physicsWorldRef?.removeRigidBody(this.rigidBody);
|
|
}
|
|
}
|
|
}
|