hytopia
Version:
The HYTOPIA SDK makes it easy for developers to create massively multiplayer games using JavaScript or TypeScript.
208 lines (170 loc) • 6.35 kB
text/typescript
import {
Audio,
Entity,
EntityEvent,
EventPayloads,
ModelEntityOptions,
PathfindingEntityController,
} from 'hytopia';
import type { QuaternionLike, Vector3Like, World } from 'hytopia';
import GamePlayerEntity from './GamePlayerEntity';
const RETARGET_ACCUMULATOR_THRESHOLD_MS = 5000;
const PATHFIND_ACCUMULATOR_THRESHOLD_MS = 3000;
export interface EnemyEntityOptions extends ModelEntityOptions {
damage: number;
damageAudioUri?: string;
health: number;
idleAudioUri?: string;
idleAudioReferenceDistance?: number;
idleAudioVolume?: number;
jumpHeight?: number
preferJumping?: boolean;
reward: number;
speed: number;
}
export default class EnemyEntity extends Entity {
public damage: number;
public health: number;
public jumpHeight: number;
public maxHealth: number;
public preferJumping: boolean;
public reward: number;
public speed: number;
private _damageAudio: Audio | undefined;
private _idleAudio: Audio | undefined;
private _isPathfinding = false;
private _pathfindAccumulatorMs = 0;
private _retargetAccumulatorMs = 0;
private _targetEntity: Entity | undefined;
public constructor(options: EnemyEntityOptions) {
super({ ...options, tag: 'enemy' });
this.damage = options.damage;
this.health = options.health;
this.jumpHeight = options.jumpHeight ?? 1;
this.maxHealth = options.health;
this.preferJumping = options.preferJumping ?? false;
this.reward = options.reward;
this.speed = options.speed;
if (options.damageAudioUri) {
this._damageAudio = new Audio({
attachedToEntity: this,
uri: options.damageAudioUri,
volume: 1,
loop: false,
});
}
if (options.idleAudioUri) {
this._idleAudio = new Audio({
attachedToEntity: this,
uri: options.idleAudioUri,
volume: options.idleAudioVolume ?? 0.5,
loop: true,
referenceDistance: options.idleAudioReferenceDistance ?? 1, // low reference distance so its only heard when the enemy is very near
});
}
this.on(EntityEvent.ENTITY_COLLISION, this._onEntityCollision);
this.on(EntityEvent.TICK, this._onTick);
this.setCcdEnabled(true);
}
public override spawn(world: World, position: Vector3Like, rotation?: QuaternionLike) {
super.spawn(world, position, rotation);
if (this._idleAudio) {
this._idleAudio.play(world, true);
}
}
public takeDamage(damage: number, fromPlayer?: GamePlayerEntity) {
if (!this.world) {
return;
}
this.health -= damage;
if (this._damageAudio) {
this._damageAudio.play(this.world, true);
}
// Give reward based on damage as % of health
if (fromPlayer) {
fromPlayer.addMoney((this.damage / this.maxHealth) * this.reward);
}
if (this.health <= 0 && this.isSpawned) {
// Enemy is dead, give half reward & despawn
this.despawn();
} else {
// Apply red tint for 75ms to indicate damage
this.setTintColor({ r: 255, g: 0, b: 0 });
// Reset tint after 75ms, make sure to check if the entity is still
// spawned to prevent setting tint on a despawned entity
setTimeout(() => this.isSpawned ? this.setTintColor({ r: 255, g: 255, b: 255 }) : undefined, 75);
}
}
private _onEntityCollision = (payload: EventPayloads[EntityEvent.ENTITY_COLLISION]) => {
const { otherEntity, started } = payload;
if (!started || !(otherEntity instanceof GamePlayerEntity)) {
return;
}
otherEntity.takeDamage(this.damage);
}
/*
* Pathfinding is handled on an accumulator basis to prevent excessive pathfinding
* or movement calculations. It defers to dumb movements
*/
private _onTick = (payload: EventPayloads[EntityEvent.TICK]) => {
const { tickDeltaMs } = payload;
if (!this.isSpawned) {
return;
}
this._pathfindAccumulatorMs += tickDeltaMs;
this._retargetAccumulatorMs += tickDeltaMs;
// Acquire a target to hunt
if (!this._targetEntity || !this._targetEntity.isSpawned || this._retargetAccumulatorMs > RETARGET_ACCUMULATOR_THRESHOLD_MS) {
this._targetEntity = this._getNearestTarget();
this._retargetAccumulatorMs = 0;
}
// No target, do nothing
if (!this._targetEntity) {
return;
}
const targetDistance = this._getTargetDistance(this._targetEntity);
const pathfindingController = this.controller as PathfindingEntityController;
if (targetDistance < 8 || (!this._isPathfinding && this._pathfindAccumulatorMs < PATHFIND_ACCUMULATOR_THRESHOLD_MS)) {
pathfindingController.move(this._targetEntity.position, this.speed);
pathfindingController.face(this._targetEntity.position, this.speed * 2);
} else if (this._pathfindAccumulatorMs > PATHFIND_ACCUMULATOR_THRESHOLD_MS) {
this._isPathfinding = pathfindingController.pathfind(this._targetEntity.position, this.speed, {
maxFall: this.jumpHeight,
maxJump: this.jumpHeight,
maxOpenSetIterations: 200,
verticalPenalty: this.preferJumping ? -1 : 1,
pathfindAbortCallback: () => this._isPathfinding = false,
pathfindCompleteCallback: () => this._isPathfinding = false,
waypointMoveSkippedCallback: () => this._isPathfinding = false,
});
this._pathfindAccumulatorMs = 0;
}
}
private _getNearestTarget(): Entity | undefined {
if (!this.world) {
return undefined;
}
let nearestTarget: Entity | undefined;
let nearestDistance = Infinity;
const targetableEntities = this.world.entityManager.getAllPlayerEntities();
targetableEntities.forEach(target => {
if (target instanceof GamePlayerEntity && target.downed) { // skip downed players
return;
}
const distance = this._getTargetDistance(target);
if (distance < nearestDistance) {
nearestTarget = target;
nearestDistance = distance;
}
});
return nearestTarget;
}
private _getTargetDistance(target: Entity) {
const targetDistance = {
x: target.position.x - this.position.x,
y: target.position.y - this.position.y,
z: target.position.z - this.position.z,
};
return Math.sqrt(targetDistance.x * targetDistance.x + targetDistance.y * targetDistance.y + targetDistance.z * targetDistance.z);
}
}