| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481 |
- local g = love.graphics
- Unit = class()
- Unit.classStats = {'width', 'height', 'health', 'damage', 'range', 'attackSpeed', 'speed', 'spirit', 'haste'}
- Unit.width = 64
- Unit.height = 64
- Unit.depth = -3.5
- ----------------
- -- Core
- ----------------
- function Unit:activate()
- -- Static canvas variable is shared by all units for outline drawing
- Unit.canvas = Unit.canvas or g.newCanvas(400, 400)
- Unit.backCanvas = Unit.backCanvas or g.newCanvas(400, 400)
- -- Position
- self.y = ctx.map.height - ctx.map.groundHeight - self.height
- -- Depth
- local r = love.math.random(-30, 30)
- if ctx.player.totalSummoned == 0 then r = 0 end
- self.y = self.y - r / 1.5
- self.depth = self.depth + r / 30
- -- Scale
- self.scale = 1 - r / 300
- -- Initialize subsystems
- self:initAnimation()
- self.buffs = UnitBuffs(self)
- -- Elite stat modifiers
- if self.elite then
- self.health = self.health * config.elites.healthModifier
- self.damage = self.damage * config.elites.damageModifier
- if ctx.player:hasShruju('slayer') then
- self.buffs:add('slayer')
- end
- end
- -- Scale stats
- if not self.player then
- local function scale(stat)
- if self.class[stat .. 'Scaling'] then
- local coefficient, exponent = unpack(self.class[stat .. 'Scaling'])
- self[stat] = self[stat] + coefficient * ctx.units.level ^ exponent
- end
- end
- scale('health')
- scale('damage')
- else
- self.health = self.health + config.units.baseHealthScaling * (ctx.timer * ls.tickrate / 60)
- self.damage = self.damage + config.units.baseDamageScaling * (ctx.timer * ls.tickrate / 60)
- end
- -- Basic data
- self.team = self.player and self.player.team or 0
- self.dying = false
- self.died = false
- self.casting = false
- self.channeling = false
- self.spawning = true
- -- Add abilities
- self.abilities = {}
- table.each(self.class.startingAbilities, function(ability)
- self:addAbility(ability)
- end)
- if self.player then
- -- Apply attributes
- table.each(config.attributes.list, function(attribute)
- table.each(config.attributes[attribute], function(perLevel, stat)
- self[stat] = self[stat] + self.class.attributes[attribute] * perLevel
- end)
- end)
- -- Apply upgrades
- if self.player then
- for i = 1, #self.class.upgrades do
- local upgrade = self.class.upgrades[i]
- f.exe(upgrade.apply, upgrade, self)
- end
- end
- end
- -- Display-related variables
- self.visible = true
- self.maxHealth = self.health
- self.healthDisplay = self.health
- self.prev = {x = self.x, y = self.y, health = self.health, healthDisplay = self.healthDisplay, knockup = 0, glowScale = 1, alpha = 0}
- self.alpha = 0
- self.glowScale = 1
- self.knockup = 0
- -- AI
- self.ai = (data.ai[self.class.code] or UnitAI)()
- self.ai.unit = self
- self.target = nil
- self:aiCall('activate')
- self.range = self.range + love.math.random(-10, 10)
- -- Register with View
- ctx.event:emit('view.register', {object = self})
- end
- function Unit:deactivate()
- ctx.event:emit('view.unregister', {object = self})
- end
- function Unit:update()
- if not ctx.tutorial:shouldUpdateUnits() then return end
- -- For lerping
- self.prev.x = self.x
- self.prev.y = self.y
- self.prev.health = self.health
- self.prev.healthDisplay = self.healthDisplay
- self.prev.knockup = self.knockup
- self.prev.glowScale = self.glowScale
- self.prev.alpha = self.alpha
- if ctx.player:hasShruju('distort') and ctx.player.dead then return end
- -- Dying behavior
- if self.dying then
- self.channeling = false
- self.spawning = false
- self.casting = false
- self.animation:set('death', {force = true})
- self.animation.speed = 1
- self.healthDisplay = lume.lerp(self.healthDisplay, 0, math.min(10 * ls.tickrate, 1))
- self.alpha = lume.lerp(self.alpha, 0, math.min(6 * ls.tickrate, 1))
- self.buffs:update()
- return
- end
- -- Update abilities
- self:abilityCall('update')
- self:abilityCall('rot')
- -- Update buffs
- self.buffs:update()
- self.buffs:emitParticles()
- -- Update AI
- if not self.spawning and not self.casting and not self.channeling then
- self:aiCall('update')
- end
- -- Lerps
- self.healthDisplay = lume.lerp(self.healthDisplay, self.health, math.min(10 * ls.tickrate, 1))
- self.glowScale = lume.lerp(self.glowScale, 1, math.min(6 * ls.tickrate, 1))
- self.alpha = lume.lerp(self.alpha, 1, math.min(6 * ls.tickrate, 1))
- -- Update animation speed
- if self.animation.state.name == 'attack' then
- local current = self.animation.spine.animationState:getCurrent(0)
- if current then self.animation.speed = current.endTime / self.animation.state.speed / self.attackSpeed end
- elseif self.animation.state.name == 'walk' then
- self.animation.speed = self.speed / self.class.speed
- else
- self.animation.speed = 1
- end
- -- Health decay
- if self.player and ctx.tutorial:shouldDecayHealth() then self:hurt(self.maxHealth * .02 * ls.tickrate, self, {'pure'}) end
- end
- function Unit:draw()
- local lerpd = table.interpolate(self.prev, self, ls.accum / ls.tickrate)
- local x, y, health = lerpd.x, lerpd.y, lerpd.health
- if not self.visible then return end
- -- Draw animation
- local noupdate = (ctx.player:hasShruju('distort') and ctx.player.dead) or ctx.paused or ctx.hud.upgrades.active or ctx.ded
- self.animation:draw(x, y - (lerpd.knockup or 0), {noupdate = noupdate})
- -- Fear icon
- local buffY = self.y - self.height - 35
- if self.buffs:feared() then
- local fear = self.buffs:feared()
- g.setColor(255, 255, 255, 150 * lerpd.alpha * math.min(fear.timer * 2, 1))
- local image = data.media.graphics.spell.fear
- local scale = (40 / image:getHeight()) * (1 + math.cos(math.sin(tick) / 3) / 5)
- g.draw(image, self.x, buffY, math.cos(tick / 3) / 6, scale, scale, 53, 83)
- buffY = buffY - 50
- end
- -- Stun icon
- if self.buffs:stunned() then
- local stun = self.buffs:stunned()
- g.setColor(255, 255, 255, 150 * lerpd.alpha * math.min(stun.timer * 2, 1))
- local image = data.media.graphics.spell.stun
- local scale = (30 / image:getHeight())
- g.draw(image, self.x, buffY, tick / 8, scale, scale, image:getWidth() / 2, image:getHeight() / 2)
- buffY = buffY - 30
- end
- end
- function Unit:getHealthbar()
- local lerpd = table.interpolate(self.prev, self, ls.accum / ls.tickrate)
- return lerpd.x, lerpd.y, lerpd.health / self.maxHealth, lerpd.healthDisplay / self.maxHealth
- end
- function Unit:paused()
- self.prev.x = self.x
- self.prev.y = self.y
- self.prev.health = self.health
- self.prev.healthDisplay = self.healthDisplay
- self.prev.knockup = self.knockup
- self.prev.glowScale = self.glowScale
- self.prev.alpha = self.alpha
- end
- ----------------
- -- Behavior
- ----------------
- function Unit:attack(options) -- Called when attack animation event is fired
- -- Interpret options
- options = options or {}
- local target = options.target or self.target
- local amount = options.damage or self.damage
- if not target then return end
- -- Preattack hooks
- amount = self:abilityCall('preattack', target, amount) or amount
- amount = self.buffs:preattack(target, amount) or amount
- -- Actually deal damage
- amount = target:hurt(amount, self, {'attack'}) or amount
- -- Postattack hooks
- self:abilityCall('postattack', target, amount)
- self.buffs:postattack(target, amount)
- -- Kill event
- if target.dying then
- self:abilityCall('kill', target)
- end
- -- Play sound
- if not options.nosound then
- ctx.sound:play(data.media.sounds[self.class.code].attackHit, function(sound)
- sound:setVolume(.4)
- end)
- end
- -- Emit particles
- if not options.noparticles and data.particle[self.class.code .. 'attack'] then
- local x, y = self:attackParticlePosition(target)
- ctx.particles:emit(self.class.code .. 'attack', x, y, self.class.attackParticleCount or 5)
- end
- end
- function Unit:hurt(amount, source, kind)
- if self.dying then return end
- local pure = kind and table.has(kind, 'pure')
- -- Prehurt hooks
- if not pure then
- self:abilityCall('prehurt', amount, source, kind)
- amount = self.buffs:prehurt(amount, source, kind) or amount
- end
- -- Deal damage
- self.health = math.max(self.health - amount, 0)
- -- Posthurt hooks
- if not pure then
- self:abilityCall('posthurt', amount, source, kind)
- self.buffs:posthurt(amount, source, kind)
- end
- -- Die if we are dead
- if self.health <= 0 then
- self.dying = true
- -- Animation
- self.animation:set('death', {force = true})
- -- Sound
- ctx.sound:play(data.media.sounds[self.class.code].death, function(sound)
- sound:setVolume(.4)
- end)
- -- Target reset
- ctx.units:each(function(u)
- if u.target == self then u.target = nil end
- end)
- end
- return amount
- end
- function Unit:heal(amount, source)
- if self.dying then return end
- self.health = math.min(self.health + amount * self.buffs:potency(), self.maxHealth)
- end
- function Unit:die()
- self:abilityCall('deactivate')
- ctx.units:remove(self)
- end
- ----------------
- -- Helper
- ----------------
- function Unit:abilityCall(key, ...)
- local arg = {...}
- table.each(self.abilities, function(ability)
- f.exe(ability[key], ability, unpack(arg))
- end)
- end
- function Unit:aiCall(key, ...)
- f.exe(self.ai[key], self.ai, ...)
- end
- function Unit:contains(...)
- return self.animation:contains(...)
- end
- function Unit:hasRunes()
- local runes = self.player and self.player.deck[self.class.code].runes
- return runes and #runes > 0
- end
- function Unit:addAbility(code)
- if self:hasAbility(code) then return end
- local Ability = data.ability[self.class.code][code]
- assert(Ability, 'Added invalid ability ' .. code)
- local ability = Ability()
- ability.unit = self
- table.insert(self.abilities, ability)
- f.exe(ability.activate, ability)
- return ability
- end
- function Unit:hasAbility(code)
- return next(table.filter(self.abilities, function(ability) return ability.code == code end))
- end
- function Unit:upgradeLevel(code)
- return self.class.upgrades[code] and self.class.upgrades[code].level or 0
- end
- function Unit:attackParticlePosition(target)
- local x, y = target.x + (target.width * .4 * -lume.sign(target.x - self.x)), self.y + self.height * .4
- if self.class.attackParticleBone then
- local bone = self.animation.spine.skeleton:findBone(self.class.attackParticleBone)
- local sign = self.animation.flipped and -1 or 1
- x, y = self.animation.spine.skeleton.x + bone.worldX, self.animation.spine.skeleton.y - bone.worldY
- end
- return x, y
- end
- function Unit:initAnimation()
- self.animation = data.animation[self.class.code]({
- scale = data.animation[self.class.code].scale * (self.elite and config.elites.scale or 1) * self.scale
- })
- if self.player then
- self.animation.flipped = not self.player.animation.flipped
- else
- local _, shrine = next(ctx.shrines:filter(function(s) return s.team == ctx.player.team end))
- self.animation.flipped = lume.sign(self.x - shrine.x) > 0
- end
- self.animation:on('event', function(event)
- if event.data.name == 'attack' then
- if self.target and (tick - self.attackStart) * ls.tickrate > self.attackSpeed * .25 then
- if self.class.attackSpell then
- ctx.spells:add(data.spell[self.class.code][self.class.attackSpell], {unit = self, target = self.target})
- ctx.sound:play(data.media.sounds[self.class.code].attackStart, function(sound) sound:setVolume(.5) end)
- -- Emit particles
- if data.particle[self.class.code .. 'attack'] then
- local x, y = self:attackParticlePosition(self.target)
- ctx.particles:emit(self.class.code .. 'attack', x, y, self.class.attackParticleCount or 5)
- end
- else
- self:attack()
- end
- end
- elseif event.data.name == 'deathjuju' then
- if not self.died then
- self:abilityCall('die')
- self.buffs:die()
- if ctx.tutorial:shouldDropJuju() and (not self.player or (self.player:hasShruju('relinquish') and love.math.random() < .5)) then
- local juju = config.juju
- local minAmount = juju.minimum.base + (ctx.units.level ^ juju.minimum.exponent) * juju.minimum.coefficient
- local maxAmount = juju.maximum.base + (ctx.units.level ^ juju.maximum.exponent) * juju.maximum.coefficient
- local amount = love.math.random(minAmount, maxAmount) * (self.elite and config.elites.jujuModifier or 1)
- local jujus = love.math.random(1, 3)
- if ctx.tutorial.active then jujus = 1 end
- if ctx.player:hasShruju('harvest') then amount = amount * 1.3 end
- if self.elite and love.math.random() < .25 then
- ctx.shrujus:add(data.shruju[love.math.random(1, #data.shruju)], {x = self.x, juju = amount})
- else
- for i = 1, jujus do
- ctx.jujus:add({
- x = self.x,
- y = self.y,
- amount = amount / jujus,
- vx = love.math.random(-100, 100),
- vy = love.math.random(-200, -100)
- })
- end
- end
- ctx.particles:emit('jujusex', self.x, self.y, 30)
- end
- self.died = true
- end
- elseif event.data.name == 'spawn' then
- ctx.sound:play(data.media.sounds[self.class.code].spawn, function(sound) sound:setVolume(.5) end)
- self:abilityCall('spawn')
- end
- end)
- self.animation:on('complete', function(data)
- if self.dying then return self:die() end
- if data.state.name == 'spawn' then
- self.spawning = false
- self.animation:set('idle', {force = true})
- elseif self.casting then
- self.casting = false
- elseif data.state.name:match('attack') then
- self:aiCall('useAbilities')
- end
- if not data.state.loop then self.animation:set('idle', {force = true}) end
- self.attackStart = tick
- end)
- end
- function Unit.getStat(class, stat)
- if type(class) == 'string' then class = data.unit[class] end
- local amount = class[stat]
- if stat == 'attackSpeed' or stat == 'haste' then amount = 0 end
- table.each(config.attributes.list, function(attribute)
- table.each(config.attributes[attribute], function(perLevel, attributeStat)
- if attributeStat == stat then
- amount = amount + class.attributes[attribute] * perLevel
- end
- end)
- end)
- table.each(ctx.player.deck[class.code].runes, function(rune)
- table.each(rune.stats, function(runeAmount, runeStat)
- if runeStat == stat then
- amount = amount + runeAmount
- end
- end)
- end)
- return amount
- end
|