Files
2026-08-24 22:20:36 +02:00

763 lines
20 KiB
JavaScript

const Entity = require("./Entity");
const Utility = require("./Utility");
const Cannon = require("./Cannon");
const ammunitions = require("../Data/ammunitions");
const cannonsToEquip = require("../Data/cannonsSchemeNPC");
const cannons = require("../Data/cannons");
const OVERTIME_HEALTH_EFFECTS = require("../Data/overtime");
const constants = require("../Data/constants");
const { EVENTS, TIMING_EVENTS, TYPES, EFFECTS_TERMINATE_MOVEMENT, EVENTS_BY_TIMING_EVENTS_ID, AFFECTS, MAXIMUM } = constants;
const { EVENT_DESTINATION_REACHED, EVENT_DESTINATION_NEW, EVENT_SPEED_UPDATE, EVENT_ATTACK_STARTED, EVENT_DIE, EVENT_ATTACKING, EVENT_EFFECT_ADD, EVENT_ATTACK_BAR, EVENT_HEALTH_CHANGED } = EVENTS;
const { ENTITY_TYPE_PLAYER, ENTITY_TYPE_NPC } = TYPES;
const { TIMING_EVENT_MOVE, TIMING_EVENT_RESPAWN } = TIMING_EVENTS;
const { AFFECT_CANNON_DAMAGE } = AFFECTS;
const { MAX_SAFE_INTEGER } = MAXIMUM;
const EVENTS_REMOVE_DEATH_NPC = [9, 11, 17];
class NPC extends Entity {
constructor(info) {
super({
type: ENTITY_TYPE_NPC,
id: info.id,
map: info.map,
isAlive: true,
position: info.position
});
this.hp = {
maximum: info.maximumHitpoint,
current: info.currentHitpoint
};
this.entityTypeID = info.entityTypeID ?? 0;
this.parentEntityTypeID = info.parentEntityTypeID ?? 0;
this.respawnTime = info.respawnTime * 1000;
this.isAdmiral = info.isAdmiral ?? false;
this.speed = info.speed;
this.size = info.size ?? 128;
this.cannonRange = 0;
this.i = 0;
this.path = [];
this.attackers = [];
this.isSpeedLowered = false;
this.damageReceived = {};
// this.behaviour = info.behaviour;
this.totalDamageReceived = 0;
this.lastShotTickTimestamp = 0;
this.activeOvertimeHealthEffects = {};
this.rewardCallback = info.rewardCallback;
this.attackCallback = info.attackCallback;
this.dieCallback = info.dieCallback;
this.reward = info.reward;
this.activeEffects = {};
this.cannonTypesOutOfRange = [];
this.searchedTarget = null;
this.group = info.group;
this.inventory = {
cannons: [],
selectedAmmunition: 1,
equipCannons() {
const cannonsEquippedScheme = cannonsToEquip[info.entityTypeID];
if (!cannonsEquippedScheme) return;
for (let i = 0, length = cannonsEquippedScheme.length; i < length; i++) {
const [id, amount] = cannonsEquippedScheme[i];
const cannonScheme = cannons[id];
if (this.cannonRange < cannonScheme.range) this.cannonRange = cannonScheme.range;
const cannon = new Cannon({
id,
damage: cannonScheme.damage,
range: cannonScheme.range,
reload: cannonScheme.reload,
scatter: 0,
amount,
amountEquipped: amount
});
this.cannons.push(cannon);
};
},
getCannons() {
return this.cannons;
}
};
this.inventory.equipCannons();
const cannonsStatistics = this.getCannonsStatistics();
this.cannonRange = cannonsStatistics.maximumCannonRange;
this.cannonTypesInUse = cannonsStatistics.distinctCannonTypes;
this.behaviour = {
stopIfAttacked: !!this.parentEntityTypeID,
attackIfAttacked: true,
isAggressive: !this.parentEntityTypeID,
isChaser: false,
stopAttackIfAbandoned: true,
selectTargetType: 1
};
this.behaviourBits = Object.values(this.behaviour).reduce((prev, cur, i) => cur ? prev + Math.pow(2, i) : prev + 0, 0);
if (this.behaviour.isChaser) {
this.chasingBehaviour = {
range: 750,
recalculateAccumulator: 0,
position: {
x: 0,
y: 0
},
target: null
};
};
const { map, id } = this;
map.npcs.set(id, this);
const nearbyPlayersID = map.getNearbyPlayersGameID(this);
for (let i = 0; i < nearbyPlayersID.length; i++) {
const player = map.getEntityFromSubgroup(nearbyPlayersID[i]);
player.inDistance[id] = true;
};
}
trackOvertimeHealthEffect(id) {
const effect = this.activeOvertimeHealthEffects[id];
if (effect) effect.count++;
else this.activeOvertimeHealthEffects[id] = { count: 0 };
return effect ? effect.count : 0;
}
overtimeDecreaseCurrentHitpoint(event) {
if (!event) return;
const { id, authorTypeID, authorID, damage, iid } = event;
const { amount, ticks, time } = OVERTIME_HEALTH_EFFECTS[id];
const decrease = this.decreaseCurrentHitpoint(damage * amount, {
authorTypeID,
authorID
});
if (!decrease) return;
const currentCount = this.trackOvertimeHealthEffect(id);
if (currentCount < ticks && decrease.currentHitpoint) this.setIndependentEvent(iid, time, event);
else delete this.activeOvertimeHealthEffects[id];
const data = {
targetTypeID: this.typeID,
targetID: this.id,
amount: decrease.normalizedAmount,
type: 0,
isCritical: false
};
this.emit(EVENT_HEALTH_CHANGED, data, this.id);
}
effectAdd(effectID) {
this.activeEffects[effectID] = true;
this.emit(EVENT_EFFECT_ADD, {
typeID: this.typeID,
id: this.id,
effectID
});
}
effectRemove(effectID) {
this.activeEffects[effectID] = false;
this.emit(EVENT_EFFECT_ADD, {
typeID: this.typeID,
id: this.id,
effectID
});
this.removeEvent(17);
switch (effectID) {
case 1:
this.determineMovementState();
break;
};
}
isAtMaximumHitpoints() {
return this.hp.current === this.hp.maximum;
}
increaseCurrentHitpoint(amount) {
amount = Math.abs(amount);
const normalizedAmount = this.hp.current + amount > this.hp.maximum ? amount - (this.hp.current + amount - this.hp.maximum) : amount;
this.hp.current += normalizedAmount;
if (this.isSpeedLowered && !this.isHPBelowPercentageThreshold(35)) {
this.changeSpeed(-this.speed / 2);
this.isSpeedLowered = false;
};
return {
normalizedAmount,
currentHitpoint: this.hp.current
};
}
decreaseCurrentHitpoint(amount, author) {
if (this.hp.current === 0) return;
const { authorID } = author;
amount = Math.abs(amount);
const damage = Math.min(this.hp.current, amount);
if (this.damageReceived[authorID]) this.damageReceived[authorID] += damage;
else this.damageReceived[authorID] = damage
this.totalDamageReceived += damage;
this.hp.current -= damage;
if (!this.isSpeedLowered && this.isHPBelowPercentageThreshold(35)) {
this.changeSpeed(this.speed);
this.isSpeedLowered = true;
};
this.attackCallback({
typeID: this.typeID,
entityTypeID: this.entityTypeID,
authorID,
damage
});
const isOverSafeLimit = this.totalDamageReceived >= MAX_SAFE_INTEGER;
if (isOverSafeLimit) this.totalDamageReceived = MAX_SAFE_INTEGER;
if (this.hp.current === 0 || isOverSafeLimit) {
const die = {
typeID: this.typeID,
lastShotTypeID: 1,
lastShotID: author.authorID,
targetID: this.id,
targetEntityTypeID: this.entityTypeID
};
this.die(die);
//this.setSingleEvent(11, 0, die);
};
return {
normalizedAmount: damage,
currentHitpoint: this.hp.current
};
}
isHPBelowPercentageThreshold(percentageThreshold) {
return this.hp.maximum / 100 * percentageThreshold > this.hp.current;
}
changeSpeed(speed) {
this.speed += speed;
const eSpeed = {
typeID: this.typeID,
id: this.id,
speed: this.speed
};
this.emit(EVENT_SPEED_UPDATE, eSpeed, this.id);
}
die(eventDeath) {
if (!this.isAlive) return;
this.emit(EVENT_DIE, eventDeath, this.id);
super.die();
this.hp.current = 0;
this.attackers.forEach(data => {
const attackerEntity = this.map.getEntityFromSubgroup(data.attackerEntry);
attackerEntity.wipeAttack();
});
this.attackers.length = 0;
if (this.group)
this.emit("miniMapDotRemoveTest", {
id: this.id
});
const admiralProperties = {
typeID: this.typeID,
entityTypeID: this.entityTypeID,
parentEntityTypeID: this.parentEntityTypeID,
id: this.id,
isAdmiral: this.isAdmiral,
authorID: eventDeath.lastShotID
};
this.stopMovement();
this.abortAttack();
this.untrackChasedTarget();
this.dieCallback(admiralProperties);
this.damageReceived = {};
this.activeEffects = {};
EVENTS_REMOVE_DEATH_NPC.forEach(e => this.removeEvent(e));
if (this.group && !this.group.isLeaderAlive()) this.group.cancelRespawn();
if ((!this.isAdmiral && !this.group) || (this.group && !this.group.isLeader(this.id) && this.group.isLeaderAlive())) this.setEvent(TIMING_EVENT_RESPAWN, this.respawnTime);
}
respawn() {
if (this.isAlive) return;
this.hp.current = this.hp.maximum;
this.isSpeedLowered = false;
this.speed /= 2;
super.respawn({
position: this.group ? this.map.grid.randomNodeInArea(this.group.predefinedArea) : null
});
this.determineMovementState();
this.removeEvent(TIMING_EVENT_RESPAWN);
}
haltMovement() {
this.isDirty = this.isDirty || this.isMoving && this.behaviour.stopIfAttacked;
}
stopMovement() {
this.isDirty = false;
this.isMoving = false;
this.path = [];
this.i = 0;
this.removeEvent(TIMING_EVENT_MOVE);
}
startMovement(path) {
if (!Array.isArray(path)) return;
if (!path.length) return;
this.path = path;
this.isMoving = true;
const [x, y] = this.path[this.i];
this.emit(EVENT_DESTINATION_NEW, {
id: this.id,
typeID: this.typeID,
mapID: this.map.id,
x,
y
});
this.setEvent(TIMING_EVENT_MOVE, this.speed);
}
getCannonsStatistics() {
const cannons = this.inventory.getCannons();
let maximumCannonRange = 0;
const distinctCannonTypes = cannons.reduce((acc, cur) => {
const alreadyAddedType = acc.find(c => c.id === cur.id);
if (!alreadyAddedType && cur.amountEquipped) {
if (cur.range > maximumCannonRange) maximumCannonRange = cur.range;
acc.push({
id: cur.id,
reload: cur.reload,
range: cur.range,
eventID: cur.eventID
});
};
return acc;
}, []);
return {
distinctCannonTypes,
maximumCannonRange
};
}
calculateCannonDamage(id) {
const { inventory } = this;
const ammunitionScheme = ammunitions[inventory.selectedAmmunition];
let scaleSum = 1;
let damage = 0;
let k = 0;
/*inventory.useAvailableItemsByAffect(AFFECT_CANNON_DAMAGE, (scale, itemID, statusID, isStatusActive) => {
scaleSum += scale;
if (!isStatusActive) this.disableStatusEffect(statusID);
});*/
const cannons = this.inventory.getCannons();
for (let i = 0, length = cannons.length; i < length; i++) {
const cannonType = cannons[i];
for (let j = 0, length = cannonType.amountEquipped; j < length; j++) {
const minimum = ((cannonType.damage * ((100 + cannonType.scatter) / 100) + 1) * scaleSum) - 1;
const maximum = cannonType.damage * scaleSum;
if (id === cannonType.id) {
damage += Math.ceil((Math.random() * (maximum - minimum) + minimum) * ammunitionScheme.damage);
k += cannonType.amountEquipped;
};
};
};
return {
usedCannonCount: k,
value: damage,
critical: false,
usedAmmunition: ammunitionScheme
};
}
initializeAttackWithCannon(target) {
if (!target) return;
if (this.isAttacking()) return false;
const isInShootingRange = Utility.IsInDistance(this.position, target.position, this.cannonRange);
if (!isInShootingRange) return false;
if (target.id === this.id) return false;
if (!target.isAlive) return false;
if (this.hp.current <= 0) return false;
if (!this.behaviour.attackIfAttacked) return false;
if (!this.inventory.getCannons().some(c => c.amountEquipped)) return false;
if (target.typeID === ENTITY_TYPE_PLAYER) target.cancelDisconnect();
this.haltMovement();
this.target = target;
this.searchedTarget = null;
const attackerInformation = {
attackerEntry: this.id
};
target.attackers.push(attackerInformation);
const eventAttackBar = {
authorID: this.id,
authorTypeID: this.typeID,
isTarget: false,
source: 1
};
target.emit(EVENT_ATTACK_BAR, eventAttackBar);
this.cannonTypesInUse.forEach(c => this.setReusableSingleEvent(c.eventID, 0, { id: c.id }));
}
flush(timingEvent) {
const currentFlush = this.map.eventBuffer[this.id];
if (!currentFlush) return
this.emit("attacking", currentFlush.data, currentFlush.author);
delete this.map.eventBuffer[this.id];
}
attackWithCannon(event) {
if (!this.isAlive) return;
const { target } = this;
const cannonType = this.cannonTypesInUse.find(c => c.id === event.id);
const recheckShootingRange = Utility.IsInDistance(this.position, target.position, cannonType.range);
if (!recheckShootingRange) {
this.cannonTypesOutOfRange.push({
cannonID: cannonType.id,
lastFiredAtTimestamp: performance.now(),
event
});
if (this.cannonRange === cannonType.range) {
this.abortAttack();
} else {
this.setReusableSingleEvent(cannonType.eventID, cannonType.reload, event);
};
return true;
};
if (this.behaviour.stopAttackIfAbandoned && !this.behaviour.isAggressive && !this.attackers.length) return this.abortAttack();
if (target.isRepairing()) target.cancelRepair();
const damage = this.calculateCannonDamage(cannonType.id);
this.lastShotTickTimestamp = performance.now();
const healthUpdate = target.decreaseCurrentHitpoint(damage.value, {
authorTypeID: this.typeID,
authorID: this.id
});
// this.emit("attacking", attacking, this.id);
const flushBuffer = this.map.eventBuffer[this.id];
if (flushBuffer) flushBuffer.data.amount += healthUpdate.normalizedAmount;
else {
const attacking = {
authorID: this.id,
authorTypeID: this.typeID,
targetID: target.id,
targetTypeID: target.typeID,
amount: healthUpdate.normalizedAmount,
isCritical: damage.critical
};
this.map.eventBuffer[this.id] = {
data: attacking,
author: target.id,
target: this.id
};
this.map.eventAuthors.push(this.id);
/*this.flushBuffer[EVENT_ATTACKING] = {
data: attacking,
author: target.id
};*/
};
//this.flush(1);
if (healthUpdate.currentHitpoint <= 0) return;
this.setReusableSingleEvent(cannonType.eventID, cannonType.reload, event);
return true;
}
wipeAttack() {
const { target } = this;
if (!target) return
this.target = null;
this.untrackChasedTarget();
this.stopMovement();
this.determineMovementState();
this.cannonTypesInUse.forEach(c => this.cancelEvent(c.eventID));
}
abortAttack() {
const { target } = this;
if (!target) return;
if (this.behaviour.isChaser) {
this.chasingBehaviour.target = this.target;
this.chasingBehaviour.position = Object.assign({}, this.target.position);
};
this.target.attackers = target.attackers.filter(data => data.attackerEntry !== this.id);
this.target = null;
this.cannonTypesInUse.forEach(c => this.cancelEvent(c.eventID));
this.determineMovementState();
this.markActive();
this.map.lookingForTarget.push(this);
}
determineMovementState(preSelectedDestinationNode) {
if (!this.isAlive) return;
if (this.isDirty) return;
if (this.isMoving) return;
if (this.behaviour.stopIfAttacked && this.isAttacking()) return;
if (!this.behaviour.stopIfAttacked && this.isMoving) return;
if (this.behaviour.stopIfAttacked && this.attackers.length) return;
if (EFFECTS_TERMINATE_MOVEMENT.some(effect => this.activeEffects[effect])) return;
if (!this.isDirty && this.chasingBehaviour && this.chasingBehaviour.target
&& this.chasingBehaviour.position.x === this.position.x && this.chasingBehaviour.position.y === this.position.y) {
this.chasingBehaviour.position = {
x: this.chasingBehaviour.target.position.x,
y: this.chasingBehaviour.target.position.y
};
};
let destination = null;
if (this.group) destination = this.map.grid.randomNodeInArea(this.group.predefinedArea);
else if (preSelectedDestinationNode) destination = preSelectedDestinationNode;
else if (this.chasingBehaviour && this.chasingBehaviour.target) destination = this.chasingBehaviour.position;
else destination = null;
const predefinedArea = this.group ? this.group.predefinedArea : null;
this.emit(EVENT_DESTINATION_REACHED, {
destination,
predefinedArea
});
}
seekEnemy(searchedTarget) {
if (!searchedTarget) return;
if (!this.isAlive) return;
if (this.isAttacking()) return;
if (!this.behaviour.isAggressive) return;
if (!this.searchedTarget) return this.searchedTarget = searchedTarget;
let isBehaviourSatisfied = false;
switch (this.behaviour.selectTargetType) {
case 1:
isBehaviourSatisfied = searchedTarget.hp.current - this.searchedTarget.hp.current <= 0;
break;
case 2:
isBehaviourSatisfied = this.searchedTarget.hp.current - searchedTarget.hp.current <= 0;
break;
case 3:
isBehaviourSatisfied = searchedTarget.hp.maximum - this.searchedTarget.hp.maximum < 0;
break;
case 4:
isBehaviourSatisfied = this.searchedTarget.hp.maximum - searchedTarget.hp.maximum < 0;
break;
case 5:
isBehaviourSatisfied = searchedTarget.speed - this.searchedTarget.speed < 0;
break;
case 6:
isBehaviourSatisfied = this.searchedTarget.speed - searchedTarget.speed < 0;
break;
case 7:
isBehaviourSatisfied = searchedTarget.sight - this.searchedTarget.sight < 0;
break;
case 8:
isBehaviourSatisfied = this.searchedTarget.sight - searchedTarget.sight < 0;
break;
};
const isInShootingRange = Utility.IsInDistance(this.position, searchedTarget.position, this.cannonRange);
const isSelectedInShootingRange = Utility.IsInDistance(this.position, this.searchedTarget.position, this.cannonRange);
if ((isBehaviourSatisfied && isInShootingRange) || !isSelectedInShootingRange) return this.searchedTarget = searchedTarget;
}
trackChasedTarget() {
if (!this.chasingBehaviour || !this.chasingBehaviour.target) return;
const isInRange = Utility.IsInDistance(this.position, this.chasingBehaviour.target.position, this.chasingBehaviour.range);
if (!isInRange) {
this.untrackChasedTarget();
return this.isDirty = true;
};
const chasedPosition = this.chasingBehaviour.target.position;
if (chasedPosition.x === this.chasingBehaviour.position.x && chasedPosition.y === this.chasingBehaviour.position.y) return;
const threshold = Math.ceil((1000 - this.speed) / 150);
if (++this.chasingBehaviour.recalculateAccumulator === threshold) {
const isChasedTargetInShootingRange = Utility.IsInDistance(this.chasingBehaviour.position, chasedPosition, this.range);
if (!isChasedTargetInShootingRange) {
this.chasingBehaviour.position = {
x: chasedPosition.x,
y: chasedPosition.y
};
this.isDirty = true;
};
this.chasingBehaviour.recalculateAccumulator = 0;
};
}
untrackChasedTarget() {
if (!this.chasingBehaviour || !this.chasingBehaviour.target) return;
this.chasingBehaviour.target = null;
this.chasingBehaviour.position.x = 0;
this.chasingBehaviour.position.y = 0;
this.chasingBehaviour.recalculateAccumulator = 0;
}
move() {
const { position } = this;
const [currentX, currentY] = this.path[this.i];
const dx = currentX - position.x;
const dy = currentY - position.y;
position.x += dx;
position.y += dy;
if (this.group) {
this.emit("miniMapMove", {
id: this.id,
position: this.position
});
};
this.updateChunk();
this.markActive();
if (this.searchedTarget) {
const target = this.map.getEntityFromSubgroup(this.searchedTarget.id);
if (target) this.initializeAttackWithCannon(target);
else this.searchedTarget = null;
};
if (this.i === this.path.length - 1 || this.isDirty) {
this.untrackChasedTarget();
this.stopMovement();
this.determineMovementState();
} else {
const nextNode = this.path[++this.i];
const [x, y] = nextNode;
this.trackChasedTarget();
this.setEvent(TIMING_EVENT_MOVE, this.speed);
this.emit(EVENT_DESTINATION_NEW, {
id: this.id,
typeID: this.typeID,
x,
y
});
};
}
isAttacking = () => this.target ? true : false;
getProperties() {
const { hp, position, id, speed, size, typeID, entityTypeID, path, behaviourBits, isMoving, isAdmiral, isSpeedLowered } = this;
const activeEffectsBits = Object.values(this.activeEffects).reduce((prev, cur, i) => cur ? prev + Math.pow(2, i) : prev + 0, 0);
return { hp, position, id, speed, size, typeID, entityTypeID, path: path.length ? path[this.i] : [], behaviourBits, activeEffectsBits, isMoving, isAdmiral, isSpeedLowered };
}
};
module.exports = NPC;