From 7884b13b70e1b0b90da9ce0810e934bec5d641b1 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Thu, 23 Oct 2025 19:37:17 +0300 Subject: [PATCH] implement AnimationNode --- lib/animation_node.lua | 73 +++++++++++++++++++++ lib/annotations.lua | 9 +-- lib/character/behaviors/map.lua | 23 +++---- lib/character/behaviors/residentsleeper.lua | 28 ++++++++ lib/character/behaviors/sprite.lua | 19 ++++-- lib/character/character.lua | 1 + lib/spellbook.lua | 69 ++++++++++++------- 7 files changed, 175 insertions(+), 47 deletions(-) create mode 100644 lib/animation_node.lua create mode 100644 lib/character/behaviors/residentsleeper.lua diff --git a/lib/animation_node.lua b/lib/animation_node.lua new file mode 100644 index 0000000..f3ef786 --- /dev/null +++ b/lib/animation_node.lua @@ -0,0 +1,73 @@ +--- @alias voidCallback fun(): nil +--- @alias animationRunner fun(node: AnimationNode) + +--- Узел дерева анимаций. +--- +--- Отслеживает завершение всех анимаций всех дочерних узлов и оповещает вышестоящий узел. +--- +--- Дочерние узлы одного уровня запускают свою анимацию одновременно после завершения анимации родителя. +--- Example: +--- ```lua +--- AnimationNode { +--- function (node) residentsleeper:sleep(1000, node) end, -- must pass itself down as the parameter +--- onEnd = function() print("completed") end, +--- children = { -- children run in parallel after the parent animation is completed +--- AnimationNode { +--- function (node) sprite:animate("attack", node) end +--- }, +--- AnimationNode { +--- function (node) other_sprite:animate("hurt", node) end +--- }, +--- } +--- }:run() +--- ``` +--- @class AnimationNode +--- @field count integer +--- @field run animationRunner +--- @field parent AnimationNode? +--- @field children AnimationNode[] +--- @field finish voidCallback +--- @field onEnd voidCallback? +local animation = {} +animation.__index = animation + +--- Регистрация завершения дочерней анимации +function animation:bubbleUp() + self.count = self.count - 1 + if self.count > 0 then return end + if self.onEnd then self.onEnd() end + if self.parent then self.parent:bubbleUp() end +end + +--- @param children AnimationNode[] +--- Запланировать анимации после текущей, которые запустятся одновременно друг с другом +function animation:chain(children) + for _, child in ipairs(children) do + child.parent = self + table.insert(self.children, child) + self.count = self.count + 1 + end + return self +end + +--- @param data {[1]: animationRunner?, onEnd?: voidCallback, children?: AnimationNode[]} +--- @return AnimationNode +local function new(data) + local t = setmetatable({}, animation) + t.run = data[1] or function(self) + self:finish() + end + t.onEnd = data.onEnd + t.count = 1 -- своя анимация + t.children = {} + t:chain(data.children or {}) + t.finish = function() + t:bubbleUp() + for _, anim in ipairs(t.children) do + anim:run() + end + end + return t +end + +return new diff --git a/lib/annotations.lua b/lib/annotations.lua index e8b0fdd..9753b5d 100644 --- a/lib/annotations.lua +++ b/lib/annotations.lua @@ -1,5 +1,6 @@ --- @meta _ -Tree.behaviors.map = require "lib.character.behaviors.map" -Tree.behaviors.spellcaster = require "lib.character.behaviors.spellcaster" -Tree.behaviors.sprite = require "lib.character.behaviors.sprite" -Tree.behaviors.stats = require "lib.character.behaviors.stats" +Tree.behaviors.map = require "lib.character.behaviors.map" +Tree.behaviors.spellcaster = require "lib.character.behaviors.spellcaster" +Tree.behaviors.sprite = require "lib.character.behaviors.sprite" +Tree.behaviors.stats = require "lib.character.behaviors.stats" +Tree.behaviors.residentsleeper = require "lib.character.behaviors.residentsleeper" diff --git a/lib/character/behaviors/map.lua b/lib/character/behaviors/map.lua index 7083ac0..b5c8174 100644 --- a/lib/character/behaviors/map.lua +++ b/lib/character/behaviors/map.lua @@ -7,7 +7,7 @@ local utils = require "lib.utils.utils" --- @field displayedPosition Vec3 точка, в которой персонаж отображается --- @field t0 number время начала движения для анимациии --- @field path Deque путь, по которому сейчас бежит персонаж ---- @field onWalkEnd nil | fun() : nil Функция, которая будет вызвана по завершению [followPath] +--- @field animationNode? AnimationNode AnimationNode, с которым связана анимация перемещения --- @field size Vec3 local mapBehavior = {} mapBehavior.__index = mapBehavior @@ -25,12 +25,13 @@ function mapBehavior.new(position, size) end --- @param path Deque -function mapBehavior:followPath(path, onEnd) - if path:is_empty() then return onEnd() end - self.onWalkEnd = onEnd +--- @param animationNode AnimationNode +function mapBehavior:followPath(path, animationNode) + if path:is_empty() then return animationNode:finish() end + self.animationNode = animationNode self.position = self.displayedPosition self.owner:try(Tree.behaviors.sprite, function(sprite) - sprite:play("run", true) + sprite:loop("run") end) self.path = path; ---@type Vec3 @@ -62,17 +63,13 @@ function mapBehavior:update(dt) if fraction >= 1 then -- анимация перемещена завершена self.position = self.runTarget if not self.path:is_empty() then -- еще есть, куда бежать - self:runTo(self.path:peek_front()) - self.path:pop_front() - else -- мы добежали до финальной цели + self:runTo(self.path:pop_front()) + else -- мы добежали до финальной цели self.owner:try(Tree.behaviors.sprite, function(sprite) - sprite:play("idle", true) + sprite:loop("idle") end) self.runTarget = nil - if self.onWalkEnd then - self.onWalkEnd() - self.onWalkEnd = nil - end + if self.animationNode then self.animationNode:finish() end end else -- анимация перемещения не завершена self.displayedPosition = utils.lerp(self.position, self.runTarget, fraction) -- линейный интерполятор diff --git a/lib/character/behaviors/residentsleeper.lua b/lib/character/behaviors/residentsleeper.lua new file mode 100644 index 0000000..6c5af5f --- /dev/null +++ b/lib/character/behaviors/residentsleeper.lua @@ -0,0 +1,28 @@ +--- Умеет асинхронно ждать какое-то время (для анимаций) +--- @class ResidentSleeperBehavior : Behavior +--- @field animationNode? AnimationNode +--- @field endsAt? number +local behavior = {} +behavior.__index = behavior +behavior.id = "residentsleeper" + +function behavior.new() return setmetatable({}, behavior) end + +function behavior:update(dt) + if not self.animationNode then return end + if love.timer.getTime() >= self.endsAt then + self.animationNode:finish() + self.animationNode = nil + self.endsAt = nil + end +end + +--- @param ms number time to wait in milliseconds +--- @param node AnimationNode +function behavior:sleep(ms, node) + if self.animationNode then node:finish() end + self.animationNode = node + self.endsAt = love.timer.getTime() + ms / 1000 +end + +return behavior diff --git a/lib/character/behaviors/sprite.lua b/lib/character/behaviors/sprite.lua index 2cb65bf..c0db6b6 100644 --- a/lib/character/behaviors/sprite.lua +++ b/lib/character/behaviors/sprite.lua @@ -27,7 +27,7 @@ function sprite.new(spriteDir) anim.state = "idle" anim.side = sprite.RIGHT - anim:play("idle") + anim:loop("idle") return anim end @@ -54,18 +54,25 @@ function sprite:draw() ) end ---- @param state string ---- @param loop nil | boolean | fun(): nil -function sprite:play(state, loop) +--- @param node AnimationNode +function sprite:animate(state, node) if not self.animationGrid[state] then return print("[SpriteBehavior]: no animation for '" .. state .. "'") end self.animationTable[state] = anim8.newAnimation(self.animationGrid[state], self.ANIMATION_SPEED, - type(loop) == "function" and loop or function() - if not loop then self:play("idle", true) end + self:loop("idle") + node:finish() end) self.state = state end +function sprite:loop(state) + if not self.animationGrid[state] then + return print("[SpriteBehavior]: no animation for '" .. state .. "'") + end + self.animationTable[state] = anim8.newAnimation(self.animationGrid[state], self.ANIMATION_SPEED) + self.state = state +end + return sprite diff --git a/lib/character/character.lua b/lib/character/character.lua index 4be416f..4e7aec1 100644 --- a/lib/character/character.lua +++ b/lib/character/character.lua @@ -27,6 +27,7 @@ local function spawn(name, spriteDir, position, size, level) char._behaviorsIdx = {} char:addBehavior { + Tree.behaviors.residentsleeper.new(), Tree.behaviors.stats.new(), Tree.behaviors.map.new(position, size), Tree.behaviors.sprite.new(spriteDir), diff --git a/lib/spellbook.lua b/lib/spellbook.lua index eb74ffe..7fca275 100644 --- a/lib/spellbook.lua +++ b/lib/spellbook.lua @@ -7,6 +7,8 @@ --- --TODO: каждый каст должен возвращать объект, который позволит отследить момент завершения анимации спелла --- Да, это Future/Promise/await/async +local AnimationNode = require "lib.animation_node" + --- @class Spell Здесь будет много бойлерплейта, поэтому тоже понадобится спеллмейкерский фреймворк, который просто вернет готовый Spell --- @field update fun(self: Spell, caster: Character, dt: number): nil Изменяет состояние спелла --- @field draw fun(self: Spell): nil Рисует превью каста, ничего не должна изменять в идеальном мире @@ -37,15 +39,19 @@ function walk:cast(caster, target) if path:is_empty() then return false end for p in path:values() do print(p) end - caster:has(Tree.behaviors.map):followPath(path, function() - caster:has(Tree.behaviors.spellcaster):endCast() - end) - -- TODO: списать деньги за каст (антиутопия какая-то) - -- TODO: привязка тинькоффа + caster:try(Tree.behaviors.stats, function(stats) stats.mana = stats.mana - 2 print(stats.mana) end) + + local sprite = caster:has(Tree.behaviors.sprite) + if not sprite then return true end + AnimationNode { + function(node) caster:has(Tree.behaviors.map):followPath(path, node) end, + onEnd = function() caster:has(Tree.behaviors.spellcaster):endCast() end, + }:run() + return true end @@ -73,14 +79,15 @@ function regenerateMana:cast(caster, target) stats.mana = 10 end) print(caster.id, "has regenerated mana") - caster:try(Tree.behaviors.sprite, function(sprite) -- бойлерплейт (временный) - -- В данный момент заклинание не позволяет отслеживать состояние последствий своего применения, так что надо повесить хоть какую-то анимашку просто для того, чтобы отложить завершение каста куда-то в будущее - -- См. также https://learn.javascript.ru/settimeout-setinterval - sprite:play("hurt", function() - sprite:play("idle") - caster:has(Tree.behaviors.spellcaster):endCast() - end) - end) + local sprite = caster:has(Tree.behaviors.sprite) + if not sprite then return true end + AnimationNode { + function(node) + sprite:animate("hurt", node) + end, + onEnd = function() caster:has(Tree.behaviors.spellcaster):endCast() end + }:run() + return true end @@ -107,18 +114,32 @@ function attack:cast(caster, target) stats.hp = stats.hp - 4 end) - caster:try(Tree.behaviors.sprite, function(sprite) - sprite:play("attack", function() - sprite:play("idle") - targetCharacter:try(Tree.behaviors.sprite, function(targetSprite) - targetSprite:play("hurt", function() - targetSprite:play("idle") - caster:has(Tree.behaviors.spellcaster):endCast() - end) - end) - end) - end) + local sprite = caster:has(Tree.behaviors.sprite) + local targetSprite = targetCharacter:has(Tree.behaviors.sprite) + if not sprite or not targetSprite then return true end + AnimationNode { + onEnd = function() caster:has(Tree.behaviors.spellcaster):endCast() end, + children = { + AnimationNode { + function(node) + sprite:animate("attack", node) + end + }, + AnimationNode { + function(node) + targetCharacter:has(Tree.behaviors.residentsleeper):sleep(200, node) + end, + children = { + AnimationNode { + function(node) + targetSprite:animate("hurt", node) + end + } + } + } + } + }:run() return true end