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;