feat #1872: Implemented rune rotations Closes #1872 See merge request Isleward/isleward!576tags/v0.10.6^2
@@ -403,5 +403,9 @@ module.exports = { | |||
clearIgnoreList: function () { | |||
this.ignoreList = []; | |||
}, | |||
isInCombat: function () { | |||
return this.list.length > 0; | |||
} | |||
}; |
@@ -98,6 +98,8 @@ module.exports = { | |||
patrol: null, | |||
patrolTargetNode: 0, | |||
needLos: null, | |||
init: function (blueprint) { | |||
this.physics = this.obj.instance.physics; | |||
@@ -111,6 +113,7 @@ module.exports = { | |||
this.maxChaseDistance = blueprint.maxChaseDistance; | |||
}, | |||
/* eslint-disable-next-line max-lines-per-function */ | |||
update: function () { | |||
let obj = this.obj; | |||
@@ -121,20 +124,22 @@ module.exports = { | |||
//Have we reached home? | |||
if (this.goHome) { | |||
let distanceFromHome = Math.max(abs(this.originX - obj.x), abs(this.originY - obj.y)); | |||
if (!distanceFromHome) | |||
if (!distanceFromHome) { | |||
this.goHome = false; | |||
} | |||
//Are we too far from home? | |||
if ((!this.goHome) && (!obj.follower) && (target)) { | |||
if (!this.canChase(target)) { | |||
obj.clearQueue(); | |||
obj.aggro.unAggro(target); | |||
target = obj.aggro.getHighest(); | |||
obj.spellbook.resetRotation(); | |||
} | |||
} | |||
if (!this.goHome) { | |||
//Are we too far from home? | |||
if (!obj.follower && target) { | |||
if (!this.canChase(target)) { | |||
obj.clearQueue(); | |||
obj.aggro.unAggro(target); | |||
target = obj.aggro.getHighest(); | |||
} | |||
} | |||
if ((target) && (target !== obj) && ((!obj.follower) || (obj.follower.master !== target))) { | |||
//If we just started attacking, patrols need to know where home is | |||
if (!this.target && this.patrol) { | |||
@@ -145,10 +150,11 @@ module.exports = { | |||
//Are we in fight mode? | |||
this.fight(target); | |||
return; | |||
} else if ((!target) && (this.target)) { | |||
} else if (!target && this.target) { | |||
//Is fight mode over? | |||
this.target = null; | |||
obj.clearQueue(); | |||
obj.spellbook.resetRotation(); | |||
if (canPathHome(this)) | |||
this.goHome = true; | |||
@@ -251,8 +257,8 @@ module.exports = { | |||
let ty = ~~target.y; | |||
let distance = max(abs(x - tx), abs(y - ty)); | |||
let furthestAttackRange = obj.spellbook.getFurthestRange(null, true); | |||
let furthestStayRange = obj.spellbook.getFurthestRange(null, false); | |||
let furthestAttackRange = obj.spellbook.getFurthestRange(target, true); | |||
let furthestStayRange = obj.spellbook.getFurthestRange(target, false); | |||
let doesCollide = null; | |||
let hasLos = null; | |||
@@ -263,18 +269,20 @@ module.exports = { | |||
hasLos = this.physics.hasLos(x, y, tx, ty); | |||
//Maybe we don't care if the mob has LoS | |||
if (hasLos || this.needLos === false) { | |||
if (((obj.follower) && (obj.follower.master.player)) || (rnd() < 0.65)) { | |||
let spell = obj.spellbook.getRandomSpell(target); | |||
let success = obj.spellbook.cast({ | |||
spell: spell, | |||
target: target | |||
}); | |||
//null means we don't have LoS | |||
if (success !== null) | |||
return; | |||
hasLos = false; | |||
} else | |||
let spell = obj.spellbook.getSpellToCast(target); | |||
if (!spell) | |||
return; | |||
let success = obj.spellbook.cast({ | |||
spell: spell.id, | |||
target | |||
}); | |||
//null means we don't have LoS | |||
if (success !== null) | |||
return; | |||
hasLos = false; | |||
} | |||
} | |||
} else if (furthestAttackRange === 0) { | |||
@@ -3,6 +3,11 @@ let animations = require('../config/animations'); | |||
let playerSpells = require('../config/spells'); | |||
let playerSpellsConfig = require('../config/spellsConfig'); | |||
//Helpers | |||
const rotationManager = require('./spellbook/rotationManager'); | |||
//Component | |||
module.exports = { | |||
type: 'spellbook', | |||
@@ -16,6 +21,8 @@ module.exports = { | |||
callbacks: [], | |||
rotation: null, | |||
init: function (blueprint) { | |||
this.objects = this.obj.instance.objects; | |||
this.physics = this.obj.instance.physics; | |||
@@ -24,7 +31,22 @@ module.exports = { | |||
(blueprint.spells || []).forEach(s => this.addSpell(s, -1)); | |||
if (blueprint.rotation) { | |||
const { duration, spells } = blueprint.rotation; | |||
this.rotation = { | |||
currentTick: 0, | |||
duration, | |||
spells | |||
}; | |||
} | |||
delete blueprint.spells; | |||
//External helpers that should form part of the component | |||
this.getSpellToCast = rotationManager.getSpellToCast.bind(null, this); | |||
this.getFurthestRange = rotationManager.getFurthestRange.bind(null, this); | |||
this.resetRotation = rotationManager.resetRotation.bind(null, this); | |||
}, | |||
transfer: function () { | |||
@@ -243,17 +265,6 @@ module.exports = { | |||
}); | |||
}, | |||
getRandomSpell: function (target) { | |||
const valid = this.spells.filter(s => { | |||
return (!s.selfCast && !s.procCast && !s.castOnDeath && s.canCast(target)); | |||
}); | |||
if (!valid.length) | |||
return null; | |||
return valid[~~(Math.random() * valid.length)].id; | |||
}, | |||
getTarget: function (spell, action) { | |||
let target = action.target; | |||
@@ -445,25 +456,6 @@ module.exports = { | |||
return this.closestRange; | |||
}, | |||
getFurthestRange: function (spellNum, checkCanCast) { | |||
if (spellNum) | |||
return this.spells[spellNum].range; | |||
let spells = this.spells; | |||
let sLen = spells.length; | |||
let furthest = 0; | |||
for (let i = 0; i < sLen; i++) { | |||
let spell = spells[i]; | |||
if (spell.procCast || spell.castOnDeath) | |||
continue; | |||
if (spell.range > furthest && (!checkCanCast || spell.canCast())) | |||
furthest = spell.range; | |||
} | |||
return furthest; | |||
}, | |||
getCooldowns: function () { | |||
let cds = []; | |||
this.spells.forEach( | |||
@@ -480,6 +472,9 @@ module.exports = { | |||
let didCast = false; | |||
const isCasting = this.isCasting(); | |||
if (this.rotation) | |||
rotationManager.tick(this); | |||
this.spells.forEach(s => { | |||
let auto = s.autoActive; | |||
if (auto) { | |||
@@ -0,0 +1,131 @@ | |||
const getDefaultRotationSpell = rotationSpells => { | |||
const spells = rotationSpells.filter(s => !s.atRotationTicks); | |||
if (!spells.length) | |||
return; | |||
if (spells.length === 1) | |||
return spells[0]; | |||
const randomSpell = spells[~~(Math.random() * spells.length)]; | |||
return randomSpell; | |||
}; | |||
//Mobs that define rotations (normally bosses) use this method to determine their spell choices | |||
const getRotationSpell = (source, target) => { | |||
const { spells, rotation: { currentTick, spells: rotationSpells } } = source; | |||
//Find spell matching current tick | |||
let rotationEntry = rotationSpells.find(s => s.atRotationTicks?.includes(currentTick)); | |||
if (!rotationEntry) | |||
rotationEntry = getDefaultRotationSpell(rotationSpells); | |||
if (!rotationEntry) | |||
return; | |||
//Don't cast anything | |||
if (rotationEntry.spellIndex === -1) | |||
return; | |||
const useSpell = spells[rotationEntry.spellIndex]; | |||
//Todo: We should set cdMax and manaCost to 0 of rotation spells (unless there's a mana drain mechanic) | |||
// later and we want to allow that on bosses | |||
useSpell.cd = 0; | |||
useSpell.manaCost = 0; | |||
if (!useSpell.selfCast && !useSpell.canCast(target)) | |||
return getDefaultRotationSpell(rotationSpells); | |||
return useSpell; | |||
}; | |||
//Mobs without rune rotations (normally the case) simple select any random spell that is valid | |||
const getRandomSpell = (source, target) => { | |||
const valid = source.spells.filter(s => { | |||
return (!s.selfCast && !s.procCast && !s.castOnDeath && s.canCast(target)); | |||
}); | |||
if (!valid.length) | |||
return null; | |||
return valid[~~(Math.random() * valid.length)]; | |||
}; | |||
const getSpellToCast = (source, target) => { | |||
if (source.rotation) | |||
return getRotationSpell(source, target); | |||
const { obj: { follower } } = source; | |||
//Mobs don't cast all the time but player followers do | |||
if (!follower?.master?.player && Math.random() >= 0.65) | |||
return; | |||
return getRandomSpell(source, target); | |||
}; | |||
const tick = source => { | |||
if (!source.obj.aggro.isInCombat()) | |||
return; | |||
const { rotation } = source; | |||
rotation.currentTick++; | |||
if (rotation.currentTick === rotation.duration) | |||
rotation.currentTick = 1; | |||
}; | |||
//Gets the range we need to be at to cast a specific rotation spell | |||
const getFurthestRangeRotation = (source, target, checkCanCast) => { | |||
const spell = getRotationSpell(source, target); | |||
if (!spell) | |||
return 0; | |||
return spell.range; | |||
}; | |||
/* | |||
This is used by mobs when in combat mode, | |||
* When checkCanCast is true, we want to see if we can cast right now | |||
* When checkCanCast is false, we want to see if there is a spell we could cast in the near future | |||
-> This could be a spell that is currently on cooldown, or that the mob has insufficient mana for | |||
---> Even though mobs don't need mana for spells at the moment | |||
-> Ultimately, this means the mob should not move, just wait | |||
*/ | |||
const getFurthestRange = (source, target, checkCanCast) => { | |||
const { spells, rotation } = source; | |||
if (rotation) | |||
return getFurthestRangeRotation(source, target, checkCanCast); | |||
let sLen = spells.length; | |||
let furthest = 0; | |||
for (let i = 0; i < sLen; i++) { | |||
let spell = spells[i]; | |||
if (spell.procCast || spell.castOnDeath) | |||
continue; | |||
if (spell.range > furthest && (!checkCanCast || spell.canCast())) | |||
furthest = spell.range; | |||
} | |||
return furthest; | |||
}; | |||
const resetRotation = source => { | |||
if (!source.rotation) | |||
return; | |||
source.rotation.currentTick = 0; | |||
}; | |||
module.exports = { | |||
tick, | |||
resetRotation, | |||
getSpellToCast, | |||
getFurthestRange | |||
}; |
@@ -277,7 +277,7 @@ module.exports = { | |||
}, | |||
performMove: function (action) { | |||
const { x: xOld, y: yOld, syncer, aggro, mob, instance: { physics } } = this; | |||
const { x: xOld, y: yOld, syncer, aggro, instance: { physics } } = this; | |||
const { maxDistance = 1, force, data } = action; | |||
const { x: xNew, y: yNew } = data; | |||
@@ -309,26 +309,16 @@ module.exports = { | |||
return false; | |||
} | |||
//Don't allow mob overlap during combat | |||
if (mob && mob.target) { | |||
this.x = xNew; | |||
this.y = yNew; | |||
this.x = xNew; | |||
this.y = yNew; | |||
if (physics.addObject(this, xNew, yNew)) | |||
physics.removeObject(this, xOld, yOld); | |||
else { | |||
this.x = xOld; | |||
this.y = yOld; | |||
return false; | |||
} | |||
} else { | |||
physics.removeObject(this, xOld, yOld, xNew, yNew); | |||
this.x = xNew; | |||
this.y = yNew; | |||
if (physics.addObject(this, xNew, yNew)) | |||
physics.removeObject(this, xOld, yOld); | |||
else { | |||
this.x = xOld; | |||
this.y = yOld; | |||
physics.addObject(this, xNew, yNew, xOld, yOld); | |||
return false; | |||
} | |||
//We can't use xNew and yNew because addObject could have changed the position (like entering a building interior with stairs) | |||
@@ -50,6 +50,8 @@ module.exports = { | |||
if (cpnMob.patrol) | |||
cpnMob.walkDistance = 1; | |||
cpnMob.needLos = blueprint.needLos; | |||
let spells = extend([], blueprint.spells); | |||
spells.forEach(s => { | |||
if (!s.animation && mob.sheetName === 'mobs' && animations.mobs[mob.cell]) | |||
@@ -179,21 +181,6 @@ module.exports = { | |||
s.dmgMult = s.name ? dmgMult / 3 : dmgMult; | |||
s.statType = preferStat; | |||
s.manaCost = 0; | |||
/*if (mob.name.toLowerCase().includes('stinktooth')) { | |||
mob.stats.values.critChance = 0; | |||
mob.stats.values.attackCritChance = 0; | |||
mob.stats.values.spellCritChance = 0; | |||
const n = mob.name + '-' + s.type; | |||
if (!track[n]) | |||
track[n] = []; | |||
track[n].push(~~s.getDamage(mob, true).amount); | |||
track[n].sort((a, b) => a - b); | |||
console.log(track); | |||
console.log(''); | |||
}*/ | |||
}); | |||
//Hack to disallow low level mobs from having any lifeOnHit | |||