unit.lua 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. local g = love.graphics
  2. Unit = class()
  3. Unit.classStats = {'width', 'height', 'health', 'damage', 'range', 'attackSpeed', 'speed', 'spirit', 'haste'}
  4. Unit.width = 64
  5. Unit.height = 64
  6. Unit.depth = -3.5
  7. ----------------
  8. -- Core
  9. ----------------
  10. function Unit:activate()
  11. -- Static canvas variable is shared by all units for outline drawing
  12. Unit.canvas = Unit.canvas or g.newCanvas(400, 400)
  13. Unit.backCanvas = Unit.backCanvas or g.newCanvas(400, 400)
  14. -- Position
  15. self.y = ctx.map.height - ctx.map.groundHeight - self.height
  16. -- Depth
  17. local r = love.math.random(-30, 30)
  18. if ctx.player.totalSummoned == 0 then r = 0 end
  19. self.y = self.y - r / 1.5
  20. self.depth = self.depth + r / 30
  21. -- Scale
  22. self.scale = 1 - r / 300
  23. -- Initialize subsystems
  24. self:initAnimation()
  25. self.buffs = UnitBuffs(self)
  26. -- Elite stat modifiers
  27. if self.elite then
  28. self.health = self.health * config.elites.healthModifier
  29. self.damage = self.damage * config.elites.damageModifier
  30. if ctx.player:hasShruju('slayer') then
  31. self.buffs:add('slayer')
  32. end
  33. end
  34. -- Scale stats
  35. if not self.player then
  36. local function scale(stat)
  37. if self.class[stat .. 'Scaling'] then
  38. local coefficient, exponent = unpack(self.class[stat .. 'Scaling'])
  39. self[stat] = self[stat] + coefficient * ctx.units.level ^ exponent
  40. end
  41. end
  42. scale('health')
  43. scale('damage')
  44. else
  45. self.health = self.health + config.units.baseHealthScaling * (ctx.timer * ls.tickrate / 60)
  46. self.damage = self.damage + config.units.baseDamageScaling * (ctx.timer * ls.tickrate / 60)
  47. end
  48. -- Basic data
  49. self.team = self.player and self.player.team or 0
  50. self.dying = false
  51. self.died = false
  52. self.casting = false
  53. self.channeling = false
  54. self.spawning = true
  55. -- Add abilities
  56. self.abilities = {}
  57. table.each(self.class.startingAbilities, function(ability)
  58. self:addAbility(ability)
  59. end)
  60. if self.player then
  61. -- Apply attributes
  62. table.each(config.attributes.list, function(attribute)
  63. table.each(config.attributes[attribute], function(perLevel, stat)
  64. self[stat] = self[stat] + self.class.attributes[attribute] * perLevel
  65. end)
  66. end)
  67. -- Apply upgrades
  68. if self.player then
  69. for i = 1, #self.class.upgrades do
  70. local upgrade = self.class.upgrades[i]
  71. f.exe(upgrade.apply, upgrade, self)
  72. end
  73. end
  74. end
  75. -- Display-related variables
  76. self.visible = true
  77. self.maxHealth = self.health
  78. self.healthDisplay = self.health
  79. self.prev = {x = self.x, y = self.y, health = self.health, healthDisplay = self.healthDisplay, knockup = 0, glowScale = 1, alpha = 0}
  80. self.alpha = 0
  81. self.glowScale = 1
  82. self.knockup = 0
  83. -- AI
  84. self.ai = (data.ai[self.class.code] or UnitAI)()
  85. self.ai.unit = self
  86. self.target = nil
  87. self:aiCall('activate')
  88. self.range = self.range + love.math.random(-10, 10)
  89. -- Register with View
  90. ctx.event:emit('view.register', {object = self})
  91. end
  92. function Unit:deactivate()
  93. ctx.event:emit('view.unregister', {object = self})
  94. end
  95. function Unit:update()
  96. if not ctx.tutorial:shouldUpdateUnits() then return end
  97. -- For lerping
  98. self.prev.x = self.x
  99. self.prev.y = self.y
  100. self.prev.health = self.health
  101. self.prev.healthDisplay = self.healthDisplay
  102. self.prev.knockup = self.knockup
  103. self.prev.glowScale = self.glowScale
  104. self.prev.alpha = self.alpha
  105. if ctx.player:hasShruju('distort') and ctx.player.dead then return end
  106. -- Dying behavior
  107. if self.dying then
  108. self.channeling = false
  109. self.spawning = false
  110. self.casting = false
  111. self.animation:set('death', {force = true})
  112. self.animation.speed = 1
  113. self.healthDisplay = lume.lerp(self.healthDisplay, 0, math.min(10 * ls.tickrate, 1))
  114. self.alpha = lume.lerp(self.alpha, 0, math.min(6 * ls.tickrate, 1))
  115. self.buffs:update()
  116. return
  117. end
  118. -- Update abilities
  119. self:abilityCall('update')
  120. self:abilityCall('rot')
  121. -- Update buffs
  122. self.buffs:update()
  123. self.buffs:emitParticles()
  124. -- Update AI
  125. if not self.spawning and not self.casting and not self.channeling then
  126. self:aiCall('update')
  127. end
  128. -- Lerps
  129. self.healthDisplay = lume.lerp(self.healthDisplay, self.health, math.min(10 * ls.tickrate, 1))
  130. self.glowScale = lume.lerp(self.glowScale, 1, math.min(6 * ls.tickrate, 1))
  131. self.alpha = lume.lerp(self.alpha, 1, math.min(6 * ls.tickrate, 1))
  132. -- Update animation speed
  133. if self.animation.state.name == 'attack' then
  134. local current = self.animation.spine.animationState:getCurrent(0)
  135. if current then self.animation.speed = current.endTime / self.animation.state.speed / self.attackSpeed end
  136. elseif self.animation.state.name == 'walk' then
  137. self.animation.speed = self.speed / self.class.speed
  138. else
  139. self.animation.speed = 1
  140. end
  141. -- Health decay
  142. if self.player and ctx.tutorial:shouldDecayHealth() then self:hurt(self.maxHealth * .02 * ls.tickrate, self, {'pure'}) end
  143. end
  144. function Unit:draw()
  145. local lerpd = table.interpolate(self.prev, self, ls.accum / ls.tickrate)
  146. local x, y, health = lerpd.x, lerpd.y, lerpd.health
  147. if not self.visible then return end
  148. -- Draw animation
  149. local noupdate = (ctx.player:hasShruju('distort') and ctx.player.dead) or ctx.paused or ctx.hud.upgrades.active or ctx.ded
  150. self.animation:draw(x, y - (lerpd.knockup or 0), {noupdate = noupdate})
  151. -- Fear icon
  152. local buffY = self.y - self.height - 35
  153. if self.buffs:feared() then
  154. local fear = self.buffs:feared()
  155. g.setColor(255, 255, 255, 150 * lerpd.alpha * math.min(fear.timer * 2, 1))
  156. local image = data.media.graphics.spell.fear
  157. local scale = (40 / image:getHeight()) * (1 + math.cos(math.sin(tick) / 3) / 5)
  158. g.draw(image, self.x, buffY, math.cos(tick / 3) / 6, scale, scale, 53, 83)
  159. buffY = buffY - 50
  160. end
  161. -- Stun icon
  162. if self.buffs:stunned() then
  163. local stun = self.buffs:stunned()
  164. g.setColor(255, 255, 255, 150 * lerpd.alpha * math.min(stun.timer * 2, 1))
  165. local image = data.media.graphics.spell.stun
  166. local scale = (30 / image:getHeight())
  167. g.draw(image, self.x, buffY, tick / 8, scale, scale, image:getWidth() / 2, image:getHeight() / 2)
  168. buffY = buffY - 30
  169. end
  170. end
  171. function Unit:getHealthbar()
  172. local lerpd = table.interpolate(self.prev, self, ls.accum / ls.tickrate)
  173. return lerpd.x, lerpd.y, lerpd.health / self.maxHealth, lerpd.healthDisplay / self.maxHealth
  174. end
  175. function Unit:paused()
  176. self.prev.x = self.x
  177. self.prev.y = self.y
  178. self.prev.health = self.health
  179. self.prev.healthDisplay = self.healthDisplay
  180. self.prev.knockup = self.knockup
  181. self.prev.glowScale = self.glowScale
  182. self.prev.alpha = self.alpha
  183. end
  184. ----------------
  185. -- Behavior
  186. ----------------
  187. function Unit:attack(options) -- Called when attack animation event is fired
  188. -- Interpret options
  189. options = options or {}
  190. local target = options.target or self.target
  191. local amount = options.damage or self.damage
  192. if not target then return end
  193. -- Preattack hooks
  194. amount = self:abilityCall('preattack', target, amount) or amount
  195. amount = self.buffs:preattack(target, amount) or amount
  196. -- Actually deal damage
  197. amount = target:hurt(amount, self, {'attack'}) or amount
  198. -- Postattack hooks
  199. self:abilityCall('postattack', target, amount)
  200. self.buffs:postattack(target, amount)
  201. -- Kill event
  202. if target.dying then
  203. self:abilityCall('kill', target)
  204. end
  205. -- Play sound
  206. if not options.nosound then
  207. ctx.sound:play(data.media.sounds[self.class.code].attackHit, function(sound)
  208. sound:setVolume(.4)
  209. end)
  210. end
  211. -- Emit particles
  212. if not options.noparticles and data.particle[self.class.code .. 'attack'] then
  213. local x, y = self:attackParticlePosition(target)
  214. ctx.particles:emit(self.class.code .. 'attack', x, y, self.class.attackParticleCount or 5)
  215. end
  216. end
  217. function Unit:hurt(amount, source, kind)
  218. if self.dying then return end
  219. local pure = kind and table.has(kind, 'pure')
  220. -- Prehurt hooks
  221. if not pure then
  222. self:abilityCall('prehurt', amount, source, kind)
  223. amount = self.buffs:prehurt(amount, source, kind) or amount
  224. end
  225. -- Deal damage
  226. self.health = math.max(self.health - amount, 0)
  227. -- Posthurt hooks
  228. if not pure then
  229. self:abilityCall('posthurt', amount, source, kind)
  230. self.buffs:posthurt(amount, source, kind)
  231. end
  232. -- Die if we are dead
  233. if self.health <= 0 then
  234. self.dying = true
  235. -- Animation
  236. self.animation:set('death', {force = true})
  237. -- Sound
  238. ctx.sound:play(data.media.sounds[self.class.code].death, function(sound)
  239. sound:setVolume(.4)
  240. end)
  241. -- Target reset
  242. ctx.units:each(function(u)
  243. if u.target == self then u.target = nil end
  244. end)
  245. end
  246. return amount
  247. end
  248. function Unit:heal(amount, source)
  249. if self.dying then return end
  250. self.health = math.min(self.health + amount * self.buffs:potency(), self.maxHealth)
  251. end
  252. function Unit:die()
  253. self:abilityCall('deactivate')
  254. ctx.units:remove(self)
  255. end
  256. ----------------
  257. -- Helper
  258. ----------------
  259. function Unit:abilityCall(key, ...)
  260. local arg = {...}
  261. table.each(self.abilities, function(ability)
  262. f.exe(ability[key], ability, unpack(arg))
  263. end)
  264. end
  265. function Unit:aiCall(key, ...)
  266. f.exe(self.ai[key], self.ai, ...)
  267. end
  268. function Unit:contains(...)
  269. return self.animation:contains(...)
  270. end
  271. function Unit:hasRunes()
  272. local runes = self.player and self.player.deck[self.class.code].runes
  273. return runes and #runes > 0
  274. end
  275. function Unit:addAbility(code)
  276. if self:hasAbility(code) then return end
  277. local Ability = data.ability[self.class.code][code]
  278. assert(Ability, 'Added invalid ability ' .. code)
  279. local ability = Ability()
  280. ability.unit = self
  281. table.insert(self.abilities, ability)
  282. f.exe(ability.activate, ability)
  283. return ability
  284. end
  285. function Unit:hasAbility(code)
  286. return next(table.filter(self.abilities, function(ability) return ability.code == code end))
  287. end
  288. function Unit:upgradeLevel(code)
  289. return self.class.upgrades[code] and self.class.upgrades[code].level or 0
  290. end
  291. function Unit:attackParticlePosition(target)
  292. local x, y = target.x + (target.width * .4 * -lume.sign(target.x - self.x)), self.y + self.height * .4
  293. if self.class.attackParticleBone then
  294. local bone = self.animation.spine.skeleton:findBone(self.class.attackParticleBone)
  295. local sign = self.animation.flipped and -1 or 1
  296. x, y = self.animation.spine.skeleton.x + bone.worldX, self.animation.spine.skeleton.y - bone.worldY
  297. end
  298. return x, y
  299. end
  300. function Unit:initAnimation()
  301. self.animation = data.animation[self.class.code]({
  302. scale = data.animation[self.class.code].scale * (self.elite and config.elites.scale or 1) * self.scale
  303. })
  304. if self.player then
  305. self.animation.flipped = not self.player.animation.flipped
  306. else
  307. local _, shrine = next(ctx.shrines:filter(function(s) return s.team == ctx.player.team end))
  308. self.animation.flipped = lume.sign(self.x - shrine.x) > 0
  309. end
  310. self.animation:on('event', function(event)
  311. if event.data.name == 'attack' then
  312. if self.target and (tick - self.attackStart) * ls.tickrate > self.attackSpeed * .25 then
  313. if self.class.attackSpell then
  314. ctx.spells:add(data.spell[self.class.code][self.class.attackSpell], {unit = self, target = self.target})
  315. ctx.sound:play(data.media.sounds[self.class.code].attackStart, function(sound) sound:setVolume(.5) end)
  316. -- Emit particles
  317. if data.particle[self.class.code .. 'attack'] then
  318. local x, y = self:attackParticlePosition(self.target)
  319. ctx.particles:emit(self.class.code .. 'attack', x, y, self.class.attackParticleCount or 5)
  320. end
  321. else
  322. self:attack()
  323. end
  324. end
  325. elseif event.data.name == 'deathjuju' then
  326. if not self.died then
  327. self:abilityCall('die')
  328. self.buffs:die()
  329. if ctx.tutorial:shouldDropJuju() and (not self.player or (self.player:hasShruju('relinquish') and love.math.random() < .5)) then
  330. local juju = config.juju
  331. local minAmount = juju.minimum.base + (ctx.units.level ^ juju.minimum.exponent) * juju.minimum.coefficient
  332. local maxAmount = juju.maximum.base + (ctx.units.level ^ juju.maximum.exponent) * juju.maximum.coefficient
  333. local amount = love.math.random(minAmount, maxAmount) * (self.elite and config.elites.jujuModifier or 1)
  334. local jujus = love.math.random(1, 3)
  335. if ctx.tutorial.active then jujus = 1 end
  336. if ctx.player:hasShruju('harvest') then amount = amount * 1.3 end
  337. if self.elite and love.math.random() < .25 then
  338. ctx.shrujus:add(data.shruju[love.math.random(1, #data.shruju)], {x = self.x, juju = amount})
  339. else
  340. for i = 1, jujus do
  341. ctx.jujus:add({
  342. x = self.x,
  343. y = self.y,
  344. amount = amount / jujus,
  345. vx = love.math.random(-100, 100),
  346. vy = love.math.random(-200, -100)
  347. })
  348. end
  349. end
  350. ctx.particles:emit('jujusex', self.x, self.y, 30)
  351. end
  352. self.died = true
  353. end
  354. elseif event.data.name == 'spawn' then
  355. ctx.sound:play(data.media.sounds[self.class.code].spawn, function(sound) sound:setVolume(.5) end)
  356. self:abilityCall('spawn')
  357. end
  358. end)
  359. self.animation:on('complete', function(data)
  360. if self.dying then return self:die() end
  361. if data.state.name == 'spawn' then
  362. self.spawning = false
  363. self.animation:set('idle', {force = true})
  364. elseif self.casting then
  365. self.casting = false
  366. elseif data.state.name:match('attack') then
  367. self:aiCall('useAbilities')
  368. end
  369. if not data.state.loop then self.animation:set('idle', {force = true}) end
  370. self.attackStart = tick
  371. end)
  372. end
  373. function Unit.getStat(class, stat)
  374. if type(class) == 'string' then class = data.unit[class] end
  375. local amount = class[stat]
  376. if stat == 'attackSpeed' or stat == 'haste' then amount = 0 end
  377. table.each(config.attributes.list, function(attribute)
  378. table.each(config.attributes[attribute], function(perLevel, attributeStat)
  379. if attributeStat == stat then
  380. amount = amount + class.attributes[attribute] * perLevel
  381. end
  382. end)
  383. end)
  384. table.each(ctx.player.deck[class.code].runes, function(rune)
  385. table.each(rune.stats, function(runeAmount, runeStat)
  386. if runeStat == stat then
  387. amount = amount + runeAmount
  388. end
  389. end)
  390. end)
  391. return amount
  392. end