reldens
Version:
Reldens - MMORPG Platform
523 lines (498 loc) • 20.3 kB
JavaScript
/**
*
* Reldens - EnemyObject
*
* Enemy NPC with combat abilities, AI behavior, skills, and respawn mechanics.
*
*/
const { NpcObject } = require('./npc-object');
const { Pve } = require('../../../../actions/server/pve');
const { SkillsExtraDataMapper } = require('../../../../actions/server/skills-extra-data-mapper');
const { ObjectsConst } = require('../../../constants');
const { GameConst } = require('../../../../game/constants');
const { SkillConst } = require('@reldens/skills');
const { Logger, sc } = require('@reldens/utils');
/**
* @typedef {import('../../../../rooms/server/scene').RoomScene} RoomScene
*/
class EnemyObject extends NpcObject
{
/**
* @param {Object} props
*/
constructor(props)
{
super(props);
this.hasState = true;
let configStats = sc.get(props, 'initialStats', this.config.get('server/enemies/initialStats'));
this.initialStats = Object.assign({}, configStats);
this.stats = Object.assign({}, configStats);
this.statsBase = Object.assign({}, configStats);
this.type = ObjectsConst.TYPE_ENEMY;
this.eventsPrefix = this.uid+'.'+ObjectsConst.EVENT_PREFIX.ENEMY;
// @NOTE: we could run different actions and enemies reactions based on the player action.
// this.runOnAction = true;
// run on hit will make the enemy aggressive when the player enters the enemy-object interactive area.
this.runOnHit = sc.get(props, 'runOnHit', true);
this.roomVisible = sc.get(props, 'roomVisible', true);
this.randomMovement = sc.get(props, 'randomMovement', true);
this.startBattleOnHit = sc.get(props, 'startBattleOnHit', true);
this.isAggressive = sc.get(props, 'isAggressive', false);
this.interactionRadio = sc.get(props, 'interactionRadio', 0);
this.updateInitialPosition = sc.get(
props,
'updateInitialPosition',
this.config.getWithoutLogs('server/enemies/updateInitialPosition', true)
);
this.battle = new Pve({
battleTimeOff: sc.get(props, 'battleTimeOff', 20000),
chaseMultiple: sc.get(props, 'chaseMultiple', false),
events: this.events
});
// enemy created, setting broadcastKey:
this.broadcastKey = this.client_key;
this.battle.setTargetObject(this);
this.actionsKeys = [];
this.actionsTargets = {};
this.actions = {};
this.enemiesDefaults = this.config.getWithoutLogs('server/enemies/default', {});
this.defaultSkillKey = sc.get(props, 'defaultSkillKey', sc.get(this.enemiesDefaults, 'skillKey', ''));
this.defaultSkillTarget = sc.get(
props,
'defaultSkillTarget',
sc.get(this.enemiesDefaults, 'skillTarget', ObjectsConst.DEFAULTS.TARGETS.PLAYER)
);
this.defaultAffectedProperty = sc.get(
props,
'defaultAffectedProperty',
sc.get(this.enemiesDefaults, 'affectedProperty', '')
);
this.setupDefaultAction();
this.respawnTime = false;
this.respawnTimer = false;
this.respawnTimerInterval = false;
this.respawnStateTime = sc.get(props, 'battleTimeOff', 1000);
this.respawnStateTimer = false;
this.respawnLayer = false;
this.postBroadPhaseListener = [];
this.mapClientParams(props);
this.mapPrivateParams(props);
this.skillsExtraDataMapper = new SkillsExtraDataMapper();
}
/**
* @returns {Promise<void>}
*/
async runAdditionalRespawnSetup()
{
// @NOTE: this will load the object skills every time the instance is created, it can be refactored for
// performance, but at the same time it could make easier to hot-plug new skills on an object.
await this.setupActions();
this.setupAggressiveBehavior();
this.events.onWithKey(
this.getBattleEndEvent(),
await this.onBattleEnd.bind(this),
this.eventUniqueKey('battleEnd'),
// @NOTE: objects use their uid as a master key for the event listeners.
this.uid
);
}
setupAggressiveBehavior()
{
if(!this.isAggressive){
return;
}
this.events.onWithKey(
'reldens.sceneRoomOnCreate',
this.attachAggressiveBehaviorEvent.bind(this),
this.eventUniqueKey('attachAggressiveBehavior'),
// @NOTE: objects use their uid as a master key for the event listeners.
this.uid
);
}
/**
* @param {RoomScene} room
*/
attachAggressiveBehaviorEvent(room)
{
let newPostBroadPhaseListener = (event) => {
if(0 === Object.keys(this.battle.inBattleWithPlayers).length){
this.waitForPlayersToEnterRespawnArea(event, room);
}
};
this.postBroadPhaseListener.push(newPostBroadPhaseListener);
room.roomWorld.on('postBroadphase', newPostBroadPhaseListener);
}
/**
* @param {Object} event
* @param {RoomScene} room
*/
waitForPlayersToEnterRespawnArea(event, room)
{
if(0 === event.target.bodies.length){
return;
}
for(let body of event.target.bodies){
if(!body.playerId){
continue;
}
if(!body.world){
Logger.error('Body world is null.', body.id);
continue;
}
if(!body.world.respawnAreas){
// none respawn areas in the current world:
continue;
}
let respawnArea = sc.get(body.world.respawnAreas, this.respawnLayer);
if(!respawnArea){
continue;
}
let {currentCol, currentRow} = body.positionToTiles(body.position[0], body.position[1]);
let tileIndex = currentRow * body.worldWidth + currentCol;
if(!sc.hasOwn(respawnArea.respawnTilesData, tileIndex)){
// tile is not part of the respawn area:
continue;
}
if(!this.playerIsOnInteractionArea(body.position)){
// the interaction area is active and the distance to the player is too long:
continue;
}
this.startBattleWithPlayer({bodyA: body, room: room});
}
}
/**
* @param {Array<number>} playerPosition
* @returns {boolean}
*/
playerIsOnInteractionArea(playerPosition)
{
if(!playerPosition[0] || !playerPosition[1]){
// if player position is invalid then we don't start the battle:
return false;
}
if(0 === this.interactionRadio){
// if none interaction radio was specified, we allow the battle to start:
return true;
}
if(!this.objectBody || !this.objectBody?.position){
// if the objectBody is not present, then we allow the battle to start:
return true;
}
let distX = playerPosition[0] - this.objectBody?.position[0];
let distY = playerPosition[1] - this.objectBody?.position[1];
let distance = Math.sqrt(distX * distX + distY * distY);
// check if the distance is less than or equal to the interactionRadio:
return distance <= this.interactionRadio;
}
setupDefaultAction()
{
if('' === this.defaultSkillKey){
return;
}
this.addSkillByKey(this.defaultSkillKey, this.defaultSkillTarget);
}
/**
* @returns {Promise<void>}
*/
async setupActions()
{
let objectSkills = await this.dataServer.getEntity('objectsSkills').loadByWithRelations(
'object_id',
this.id,
['related_skills_skill']
);
if(!objectSkills){
return;
}
for(let objectSkill of objectSkills){
if(!objectSkill.related_skills_skill?.key){
Logger.error('Object skill not found.', objectSkill);
continue;
}
let addSkillResult = this.addSkillByKey(objectSkill.related_skills_skill.key, objectSkill.target);
if(false === addSkillResult){
Logger.error('Could not add a "'+objectSkill.related_skills_skill.key+'" skill to object id: '+this.id);
}
}
await this.events.emit('reldens.setupActions', {enemyObject: this});
}
/**
* @param {Object} target
* @param {Object} executedSkill
* @returns {Promise<boolean|undefined>}
*/
async executePhysicalSkill(target, executedSkill)
{
let targetBody = target.physicalBody || target.objectBody;
if(!targetBody){
Logger.info('Target body is missing or do not have a body to be hit by a physical object.');
return false;
}
if(!targetBody.world){
Logger.error('Target body world is missing. Body ID: '+ targetBody.id);
return false;
}
let thisWorldKey = this.objectBody?.world?.worldKey;
let targetWorldKey = targetBody?.world?.worldKey;
let enemyObjectUid = this.uid;
if(thisWorldKey && targetWorldKey && thisWorldKey !== targetWorldKey){
Logger.critical('Garbage enemy instance found.', {
enemyObjectUid,
thisWorldKey,
targetWorldKey
});
return false;
}
let messageData = Object.assign({skillKey: executedSkill.key}, executedSkill.owner.getPosition());
if(sc.isObjectFunction(executedSkill.owner, 'getSkillExtraData')){
let params = {skill: executedSkill, target};
Object.assign(messageData, {extraData: executedSkill.owner.getSkillExtraData(params)});
}
await target.skillsServer.client.runBehaviors(
messageData,
SkillConst.ACTION_SKILL_AFTER_CAST,
SkillConst.BEHAVIOR_BROADCAST,
target.player_id
);
let from = this.getPosition();
executedSkill.initialPosition = from;
let to = {x: target.state.x, y: target.state.y};
let animData = sc.get(this.config.client.skills.animations, executedSkill.key+'_bullet', false);
if(animData){
executedSkill.animDir = sc.get(animData.animationData, 'dir', false);
}
targetBody.world.shootBullet(from, to, executedSkill);
}
/**
* @param {Object} params
* @returns {Object}
*/
getSkillExtraData(params)
{
return this.skillsExtraDataMapper.extractSkillExtraData(params);
}
/**
* @param {string} skillKey
* @param {string} skillTarget
* @returns {boolean}
*/
addSkillByKey(skillKey, skillTarget)
{
let skillData = this.config.skills.skillsList[skillKey];
if(!skillData){
return false;
}
let skillOwnerData = Object.assign({
owner: this,
ownerIdProperty: 'uid',
eventsPrefix: this.eventsPrefix,
affectedProperty: this.defaultAffectedProperty,
events: this.events
}, skillData['data']);
let skillInstance = new skillData['class'](skillOwnerData);
this.actionsKeys.push(skillKey);
this.actions[skillKey] = skillInstance;
this.actionsTargets[skillKey] = skillTarget;
return true;
}
/**
* @returns {string}
*/
getBattleEndEvent()
{
return this.eventUniqueKey()+'emittedBattleEnded';
}
/**
* @param {RoomScene} room
* @returns {Promise<Object|undefined>}
*/
async respawn(room)
{
// @TODO - BETA - Add respawn to the other object types as well, we could have normal NPCs with respawn.
// @NOTE: here we move the body to some place where it can't be reach so it doesn't collide with anything, this
// will also make it invisible because the update in the client will move the sprite outside the view.
this.objectBody.resetAuto();
this.objectBody.stopMove();
this.objectBody.collisionResponse = false;
this.originalType = this.objectBody.type;
this.objectBody.type = this.objectBody.world.bodyTypes.STATIC;
if(this.respawnTime){
return this.restoreOnTimeOut(room);
}
return await this.restoreObject(room);
}
/**
* @param {RoomScene} room
* @returns {number}
*/
restoreOnTimeOut(room)
{
// let respawnStartTime = Date.now();
let intervalId = this.respawnTimerInterval = setInterval(async () => {
/*
let elapsedTime = Date.now() - respawnStartTime;
let remainingTime = Math.max(0, (this.respawnTime - elapsedTime) / 1000);
Logger.debug('Respawn object "'+this.uid+'" in: '+remainingTime.toFixed(2)+' seconds.');
*/
}, 1000);
this.respawnTimer = setTimeout(async () => {
//Logger.debug('Clearing respawn object "'+this.uid+'" interval.');
clearInterval(intervalId);
await this.restoreObject(room);
}, this.respawnTime);
}
/**
* @param {RoomScene} room
* @returns {Promise<void>}
*/
async restoreObject(room)
{
this.objectBody.collisionResponse = true;
this.objectBody.type = this.originalType || this.objectBody.world.bodyTypes.DYNAMIC;
this.stats = Object.assign({}, this.initialStats);
if(!this.objectBody.world){
Logger.warning('Expected on server shutdown, Object world is null on restoreObject method.', this.uid);
return;
}
let interpolationStatus = GameConst.STATUS.AVOID_INTERPOLATION;
this.objectBody.bodyState.inState = interpolationStatus;
if(interpolationStatus !== this.battle.targetObject.objectBody.bodyState.inState){
Logger.warning('Battle target object state miss match, set it to avoid interpolation.');
this.battle.targetObject.objectBody.bodyState.inState = interpolationStatus;
}
if(interpolationStatus !== room.objectsManager.roomObjects[this.objectIndex].objectBody.bodyState.inState){
Logger.warning('Objects Manager room object state miss match, set it to avoid interpolation.');
room.objectsManager.roomObjects[this.objectIndex].objectBody.bodyState.inState = interpolationStatus;
}
let respawnArea = this.objectBody.world.respawnAreas[this.respawnLayer];
delete respawnArea.usedTiles[this.randomTileIndex];
let {randomTileIndex, tileData} = respawnArea.getRandomTile(this.objectIndex);
this.randomTileIndex = randomTileIndex;
Object.assign(this, tileData);
let { x, y } = tileData;
this.objectBody.position = [x, y];
this.objectBody.bodyState.x = x;
this.objectBody.bodyState.y = y;
this.updateBodyPositionInitialData(room, x, y);
let {currentCol, currentRow} = this.objectBody.positionToTiles(x, y);
this.objectBody.originalCol = currentCol;
this.objectBody.originalRow = currentRow;
await this.events.emit('reldens.restoreObjectAfter', {enemyObject: this, room});
//Logger.debug('Respawn: '+this.uid+ ' - Time: '+(this.respawnTime || 1000)+' - Position x/y: '+x+' / '+y);
if(0 === this.respawnStateTime){
this.setActiveObjectState(room);
return;
}
this.respawnStateTimer = setTimeout(() => {
this.setActiveObjectState(room);
}, this.respawnStateTime);
}
/**
* @param {RoomScene} room
* @param {number} x
* @param {number} y
*/
updateBodyPositionInitialData(room, x, y)
{
if(!this.updateInitialPosition){
return;
}
if(!room.state.roomData?.objectsAnimationsData){
//Logger.debug('Missing sceneData.objectsAnimationsData: '+this.objectIndex, room.state.roomData);
return false;
}
if(!room.state.roomData.objectsAnimationsData[this.objectIndex]){
//Logger.debug('Body not found by ID: '+this.objectIndex);
return false;
}
room.state.roomData.objectsAnimationsData[this.objectIndex].x = x;
room.state.roomData.objectsAnimationsData[this.objectIndex].y = y;
room.state.mapRoomData();
}
/**
* @param {RoomScene} room
*/
setActiveObjectState(room)
{
try {
//Logger.debug('Activated object after respawn: '+this.uid);
let activeStatus = GameConst.STATUS.ACTIVE;
this.objectBody.bodyState.inState = activeStatus;
if(this.battle.targetObject && activeStatus !== this.battle.targetObject.objectBody?.bodyState?.inState){
Logger.warning('Battle target object state miss match, set it to active.');
this.battle.targetObject.objectBody.bodyState.inState = activeStatus;
}
if(
room?.objectsManager?.roomObjects
&& room.objectsManager.roomObjects[this.objectIndex]
&& room.objectsManager.roomObjects[this.objectIndex].objectBody.bodyState
&& activeStatus !== room.objectsManager.roomObjects[this.objectIndex].objectBody.bodyState.inState
){
Logger.warning('Objects Manager room object state miss match, set it to active.');
room.objectsManager.roomObjects[this.objectIndex].objectBody.bodyState.inState = activeStatus;
}
} catch (error) {
Logger.debug('Expected if users disconnects while in battle.');
}
}
/**
* @param {Object} props
* @returns {Promise<boolean>|boolean}
*/
onHit(props)
{
if(!this.startBattleOnHit){
return false;
}
return this.startBattleWithPlayer(props);
}
/**
* @param {Object} props
* @returns {Promise<Object>|boolean}
*/
startBattleWithPlayer(props)
{
let room = props.room;
if(!room){
Logger.error('Required room not found to start battle in Object "'+this.uid+'".');
return false;
}
let playerBody = sc.hasOwn(props.bodyA, 'playerId') ? props.bodyA : props.bodyB;
if(!playerBody){
// expected when an object hits object on CollisionsManager, if a player wasn't hit don't start the battle:
return false;
}
let playerSchema = room.playerBySessionIdFromState(playerBody.playerId);
if(!playerSchema){
return false;
}
let affectedProperty = room.config.get('client/actions/skills/affectedProperty', this.defaultAffectedProperty);
if(0 === this.stats[affectedProperty]){
//Logger.debug('Object is death, do not run battle.', this.uid);
// do not start the battle if the object is death:
return false;
}
return this.battle.startBattleWith(playerSchema, props.room).catch((error) => {
Logger.error(error);
});
}
/**
* @returns {Object}
*/
getPosition()
{
// @TODO - BETA - Check if we need to update and return this.x, this.y or these are just the initial position.
return {
x: this.state.x,
y: this.state.y
};
}
/**
* @returns {Promise<void>}
*/
async onBattleEnd()
{
Logger.notice('BattleEnd method not implemented for EnemyObject.', this.uid, this.title);
}
}
module.exports.EnemyObject = EnemyObject;