From 5de9d9eb9cdfcde4200c2119fc1c5c1467266cc1 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Mon, 20 Oct 2025 03:36:03 +0300 Subject: [PATCH 1/6] Add AnimationNode system and integrate into character behaviors and spell casting --- lib/animation_node.lua | 51 ++++++++++++++++++++++++++++++ lib/character/behaviors/map.lua | 17 +++++----- lib/character/behaviors/sprite.lua | 21 ++++++++++++ lib/spellbook.lua | 34 +++++++++++++++++--- 4 files changed, 110 insertions(+), 13 deletions(-) create mode 100644 lib/animation_node.lua diff --git a/lib/animation_node.lua b/lib/animation_node.lua new file mode 100644 index 0000000..3b0be94 --- /dev/null +++ b/lib/animation_node.lua @@ -0,0 +1,51 @@ +--- @alias voidCallback fun(): nil +--- @alias animationRunner fun(node: AnimationNode) + +--- @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] + 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/character/behaviors/map.lua b/lib/character/behaviors/map.lua index 7083ac0..07e8708 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 @@ -66,11 +67,11 @@ function mapBehavior:update(dt) 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() + if self.animationNode then + self.animationNode:finish() self.onWalkEnd = nil end end diff --git a/lib/character/behaviors/sprite.lua b/lib/character/behaviors/sprite.lua index 2cb65bf..3b99843 100644 --- a/lib/character/behaviors/sprite.lua +++ b/lib/character/behaviors/sprite.lua @@ -68,4 +68,25 @@ function sprite:play(state, loop) self.state = state end +--- @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, + function() + 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/spellbook.lua b/lib/spellbook.lua index 93d6368..5989514 100644 --- a/lib/spellbook.lua +++ b/lib/spellbook.lua @@ -7,6 +7,8 @@ --- --TODO: каждый каст должен возвращать объект, который позволит отследить момент завершения анимации спелла --- Да, это Future/Promise/await/async +local Animation = require "lib.animation_node" + --- @class Spell Здесь будет много бойлерплейта, поэтому тоже понадобится спеллмейкерский фреймворк, который просто вернет готовый Spell --- @field update fun(self: Spell, caster: Character, dt: number): nil Изменяет состояние спелла --- @field draw fun(self: Spell): nil Рисует превью каста, ничего не должна изменять в идеальном мире @@ -37,11 +39,33 @@ 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: привязка тинькоффа + + Animation { + function(node) caster:has(Tree.behaviors.sprite):animate("hurt", node) end, + onEnd = function() caster:has(Tree.behaviors.spellcaster):endCast() end, + children = { + Animation { + function(node) + caster:has(Tree.behaviors.map):followPath(path, node) + end, + }, + Animation { + function(node) + Tree.level.characters[2]:has(Tree.behaviors.sprite):animate("hurt", node) + end, + children = { + Animation { + function(node) + local from = Tree.level.characters[2]:has(Tree.behaviors.map).position + local p = (require "lib.pathfinder")(from, Vec3 { 10, 10 }) + Tree.level.characters[2]:has(Tree.behaviors.map):followPath(p, node) + end + } + } + } + } + }:run() + caster:try(Tree.behaviors.stats, function(stats) stats.mana = stats.mana - 2 print(stats.mana) -- 2.47.2 From 601766d5e8cbbb100d4f26ea60348d8f6a09b3a2 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Wed, 22 Oct 2025 03:50:01 +0300 Subject: [PATCH 2/6] Add ResidentSleeper behavior for async animation waiting --- dev_utils/annotations.lua | 9 ++++--- lib/character/behaviors/residentsleeper.lua | 28 +++++++++++++++++++++ lib/character/character.lua | 1 + lib/spellbook.lua | 10 +++++++- 4 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 lib/character/behaviors/residentsleeper.lua diff --git a/dev_utils/annotations.lua b/dev_utils/annotations.lua index e8b0fdd..9753b5d 100644 --- a/dev_utils/annotations.lua +++ b/dev_utils/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/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/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 5989514..334c712 100644 --- a/lib/spellbook.lua +++ b/lib/spellbook.lua @@ -46,8 +46,16 @@ function walk:cast(caster, target) children = { Animation { function(node) - caster:has(Tree.behaviors.map):followPath(path, node) + caster:has(Tree.behaviors.sprite):loop('idle') + caster:has(Tree.behaviors.residentsleeper):sleep(1000, node) end, + children = { + Animation { + function(node) + caster:has(Tree.behaviors.map):followPath(path, node) + end, + } + } }, Animation { function(node) -- 2.47.2 From 6faf7f1c1d2420d8068043475820904d2cfa8cd9 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Thu, 23 Oct 2025 02:41:30 +0300 Subject: [PATCH 3/6] refactor MapBehavior:update --- lib/character/behaviors/map.lua | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/lib/character/behaviors/map.lua b/lib/character/behaviors/map.lua index 07e8708..b5c8174 100644 --- a/lib/character/behaviors/map.lua +++ b/lib/character/behaviors/map.lua @@ -63,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:loop("idle") end) self.runTarget = nil - if self.animationNode then - self.animationNode:finish() - self.onWalkEnd = nil - end + if self.animationNode then self.animationNode:finish() end end else -- анимация перемещения не завершена self.displayedPosition = utils.lerp(self.position, self.runTarget, fraction) -- линейный интерполятор -- 2.47.2 From 77e723c4bb0635aefecd3195ae88ad5e854b7346 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Thu, 23 Oct 2025 18:58:34 +0300 Subject: [PATCH 4/6] migrate spells to AnimationNode api --- lib/character/behaviors/sprite.lua | 16 +---- lib/spellbook.lua | 100 +++++++++++++---------------- 2 files changed, 47 insertions(+), 69 deletions(-) diff --git a/lib/character/behaviors/sprite.lua b/lib/character/behaviors/sprite.lua index 3b99843..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,20 +54,6 @@ function sprite:draw() ) end ---- @param state string ---- @param loop nil | boolean | fun(): nil -function sprite:play(state, loop) - 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 - end) - self.state = state -end - --- @param node AnimationNode function sprite:animate(state, node) if not self.animationGrid[state] then diff --git a/lib/spellbook.lua b/lib/spellbook.lua index 334c712..6d33b39 100644 --- a/lib/spellbook.lua +++ b/lib/spellbook.lua @@ -7,7 +7,7 @@ --- --TODO: каждый каст должен возвращать объект, который позволит отследить момент завершения анимации спелла --- Да, это Future/Promise/await/async -local Animation = require "lib.animation_node" +local AnimationNode = require "lib.animation_node" --- @class Spell Здесь будет много бойлерплейта, поэтому тоже понадобится спеллмейкерский фреймворк, который просто вернет готовый Spell --- @field update fun(self: Spell, caster: Character, dt: number): nil Изменяет состояние спелла @@ -40,44 +40,18 @@ function walk:cast(caster, target) for p in path:values() do print(p) end - Animation { - function(node) caster:has(Tree.behaviors.sprite):animate("hurt", node) end, - onEnd = function() caster:has(Tree.behaviors.spellcaster):endCast() end, - children = { - Animation { - function(node) - caster:has(Tree.behaviors.sprite):loop('idle') - caster:has(Tree.behaviors.residentsleeper):sleep(1000, node) - end, - children = { - Animation { - function(node) - caster:has(Tree.behaviors.map):followPath(path, node) - end, - } - } - }, - Animation { - function(node) - Tree.level.characters[2]:has(Tree.behaviors.sprite):animate("hurt", node) - end, - children = { - Animation { - function(node) - local from = Tree.level.characters[2]:has(Tree.behaviors.map).position - local p = (require "lib.pathfinder")(from, Vec3 { 10, 10 }) - Tree.level.characters[2]:has(Tree.behaviors.map):followPath(p, node) - end - } - } - } - } - }:run() - 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 @@ -105,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 @@ -135,18 +110,35 @@ 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 { + function(node) + caster:has(Tree.behaviors.residentsleeper):sleep(0, node) + end, + 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 -- 2.47.2 From f790a4dcf8a983976216bb65ad9ef36ed6e305a5 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Thu, 23 Oct 2025 19:07:16 +0300 Subject: [PATCH 5/6] allow AnimationNode to not have its own animation --- lib/animation_node.lua | 6 ++++-- lib/spellbook.lua | 3 --- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/animation_node.lua b/lib/animation_node.lua index 3b0be94..25b3e19 100644 --- a/lib/animation_node.lua +++ b/lib/animation_node.lua @@ -30,11 +30,13 @@ function animation:chain(children) return self end ---- @param data {[1]: animationRunner, onEnd?: voidCallback, children?: AnimationNode[]} +--- @param data {[1]: animationRunner?, onEnd?: voidCallback, children?: AnimationNode[]} --- @return AnimationNode local function new(data) local t = setmetatable({}, animation) - t.run = data[1] + t.run = data[1] or function(self) + self:finish() + end t.onEnd = data.onEnd t.count = 1 -- своя анимация t.children = {} diff --git a/lib/spellbook.lua b/lib/spellbook.lua index 6d33b39..2b2e373 100644 --- a/lib/spellbook.lua +++ b/lib/spellbook.lua @@ -115,9 +115,6 @@ function attack:cast(caster, target) if not sprite or not targetSprite then return true end AnimationNode { - function(node) - caster:has(Tree.behaviors.residentsleeper):sleep(0, node) - end, onEnd = function() caster:has(Tree.behaviors.spellcaster):endCast() end, children = { AnimationNode { -- 2.47.2 From 70d1d72b8369ec99f0c4130ab5a3513bb5eb3fa8 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Thu, 23 Oct 2025 19:21:06 +0300 Subject: [PATCH 6/6] Add detailed comments to AnimationNode explaining its behavior and usage example --- lib/animation_node.lua | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/lib/animation_node.lua b/lib/animation_node.lua index 25b3e19..f3ef786 100644 --- a/lib/animation_node.lua +++ b/lib/animation_node.lua @@ -1,6 +1,26 @@ --- @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 -- 2.47.2