From 0017b6e104d1948384769c3ad04255f7a4c0a0fa Mon Sep 17 00:00:00 2001 From: Kitsune-Jesus Date: Mon, 2 Feb 2026 02:39:28 +0100 Subject: [PATCH 1/9] refactor: integrate tweens into Task system and simplify camera animations --- lib/level/camera.lua | 24 ++---- lib/utils/task.lua | 178 ++++++++++++++++++++++++++----------------- main.lua | 1 + 3 files changed, 119 insertions(+), 84 deletions(-) diff --git a/lib/level/camera.lua b/lib/level/camera.lua index 439330c..f2bb368 100644 --- a/lib/level/camera.lua +++ b/lib/level/camera.lua @@ -1,5 +1,6 @@ local Vec3 = require "lib.utils.vec3" local utils = require "lib.utils.utils" +local task = require "lib.utils.task" local EPSILON = 0.001 @@ -9,9 +10,6 @@ local EPSILON = 0.001 --- @field speed number --- @field pixelsPerMeter integer --- @field scale number ---- @field animationNode AnimationNode? ---- @field animationEndPosition Vec3 ---- @field animationBeginPosition Vec3 local camera = { position = Vec3 {}, velocity = Vec3 {}, @@ -38,12 +36,6 @@ local controlMap = { } function camera:update(dt) - if self.animationNode and self.animationNode.state == "running" then - self.animationNode:update(dt) -- тик анимации - self.position = utils.lerp(self.animationBeginPosition, self.animationEndPosition, self.animationNode:getValue()) - return - end - -------------------- зум на колесо --------------------- local y = Tree.controls.mouseWheelY if camera.scale > camera:getDefaultScale() * 5 and y > 0 then return end; @@ -97,14 +89,14 @@ function camera:detach() love.graphics.pop() end +--- Плавно перемещает камеру к указанной точке. --- @param position Vec3 ---- @param animationNode AnimationNode -function camera:animateTo(position, animationNode) - if self.animationNode and self.animationNode.state ~= "finished" then self.animationNode:finish() end - self.animationNode = animationNode - self.animationEndPosition = position - self.animationBeginPosition = self.position - self.velocity = Vec3 {} +--- @param duration number? +--- @param easing function? +--- @return Task +function camera:animateTo(position, duration, easing) + self.velocity = Vec3 {} -- Сбрасываем инерцию перед началом анимации + return task.tween(self, { position = position }, duration or 1000, easing) end --- @return Camera diff --git a/lib/utils/task.lua b/lib/utils/task.lua index 04ef2ae..ae71632 100644 --- a/lib/utils/task.lua +++ b/lib/utils/task.lua @@ -1,83 +1,125 @@ ---- Обобщенная асинхронная функция ---- ---- Использование в общих чертах выглядит так: ---- ```lua ---- local multiplyByTwoCallback = nil ---- local n = nil ---- local function multiplyByTwoAsync(number) ---- -- императивно сохраняем/обрабатываем параметр ---- n = number ---- return function(callback) -- это функция, которая запускает задачу ---- multiplyByTwoCallback = callback ---- end ---- end ---- ---- local function update(dt) ---- --- ждем нужного момента времени... ---- ---- if multiplyByTwoCallback then -- завершаем вычисление ---- local result = n * 2 ---- multiplyByTwoCallback(result) -- результат асинхронного вычисления идет в параметр коллбека! ---- multiplyByTwoCallback = nil ---- end ---- end ---- ---- ---- --- потом это можно вызывать так: ---- local task = multiplyByTwoAsync(21) ---- -- это ленивое вычисление, так что в этот момент ничего не произойдет ---- -- запускаем ---- task( ---- function(result) print(result) end -- выведет 42 после завершения вычисления, т.е. аналогично `task.then((res) => print(res))` на JS ---- ) ---- ---- ``` +local easing_lib = require "lib.utils.easing" + +--- Обобщенная асинхронная функция (Task). +--- По сути это функция, принимающая коллбэк: `fun(callback: fun(value: T): nil): nil` --- @generic T ---- @alias Task fun(callback: fun(value: T): nil): nil +--- @alias Task fun(callback: fun(value: T): nil): nil ---- Возвращает новый Task, который завершится после завершения всех переданных `tasks`. ---- ---- Значение созданного Task будет содержать список значений `tasks` в том же порядке. ---- ---- См. также https://api.dart.dev/dart-async/Future/wait.html ---- @generic T ---- @param tasks Task[] ---- @return Task -local function wait(tasks) - local count = #tasks - local results = {} +local task = {} +local activeTweens = {} - return function(callback) - for i, task in ipairs(tasks) do - task( - function(result) - results[i] = result +--- Внутренний хелпер для интерполяции (числа или Vec3) +local function lerp(a, b, t) + if type(a) == "number" then + return a + (b - a) * t + elseif type(a) == "table" and getmetatable(a) then + -- Предполагаем, что это Vec3 или другой объект с операторами +, -, * + return a + (b - a) * t + end + return b +end - count = count - 1 - if count == 0 then callback(results) end - end - ) +--- Обновление всех активных анимаций (твинов). +--- Нужно вызывать в love.update(dt) +function task.update(dt) + for i = #activeTweens, 1, -1 do + local t = activeTweens[i] + t.elapsed = t.elapsed + dt * 1000 + local progress = math.min(t.elapsed / t.duration, 1) + local value = t.easing(progress) + + for key, targetValue in pairs(t.properties) do + t.target[key] = lerp(t.initial[key], targetValue, value) + end + + if progress >= 1 then + table.remove(activeTweens, i) + if t.resolve then t.resolve() end end end end +--- Возвращает Completer — объект, который позволяет вручную завершить таску. +--- @return table completer { complete: fun(val: T) }, Task future +function task.completer() + local c = { completed = false, value = nil, cb = nil } + function c:complete(val) + if self.completed then return end + self.completed = true + self.value = val + if self.cb then self.cb(val) end + end + + local future = function(callback) + if c.completed then callback(c.value) + else c.cb = callback end + end + return c, future +end + +--- Создает таску, которая плавно меняет свойства объекта. +--- @param target table Объект, свойства которого меняем +--- @param properties table Набор конечных значений { key = value } +--- @param duration number Длительность в мс +--- @param easing function? Функция смягчения (по умолчанию linear) +--- @return Task +function task.tween(target, properties, duration, easing) + local initial = {} + for k, _ in pairs(properties) do + initial[k] = target[k] + if type(initial[k]) == "table" and initial[k].copy then + initial[k] = initial[k]:copy() -- Для Vec3 + end + end + + local comp, future = task.completer() + table.insert(activeTweens, { + target = target, + initial = initial, + properties = properties, + duration = duration or 1000, + easing = easing or easing_lib.linear, + elapsed = 0, + resolve = function() comp:complete() end + }) + return future +end + +--- Возвращает таску, которая завершится сразу с переданным значением. +function task.fromValue(val) + return function(callback) callback(val) end +end + +--- Возвращает новый Task, который завершится после завершения всех переданных `tasks`. +--- @param tasks Task[] +--- @return Task +function task.wait(tasks) + if #tasks == 0 then return task.fromValue({}) end + local count = #tasks + local results = {} + + return function(callback) + for i, t in ipairs(tasks) do + t(function(result) + results[i] = result + count = count - 1 + if count == 0 then callback(results) end + end) + end + end +end --- Последовательно объединяет два `Task` в один. ---- @generic T ---- @generic R ---- @param task Task `Task`, который выполнится первым ---- @param onCompleted fun(value: T): Task Конструктор второго `Task`. Принимает результат выполнения первого `Task` ---- @return Task -local function chain(task, onCompleted) +--- @param t Task +--- @param onCompleted fun(value: any): Task +--- @return Task +function task.chain(t, onCompleted) return function(callback) - task(function(value) - local task2 = onCompleted(value) - task2(callback) + t(function(value) + local t2 = onCompleted(value) + t2(callback) end) end end -return { - wait = wait, - chain = chain -} +return task diff --git a/main.lua b/main.lua index b5064d8..a0127ce 100644 --- a/main.lua +++ b/main.lua @@ -72,6 +72,7 @@ function love.update(dt) TestRunner:update(dt) -- закомментировать для отключения тестов local t1 = love.timer.getTime() + require('lib.utils.task').update(dt) Tree.controls:poll() Tree.level.camera:update(dt) -- сначала логика камеры, потому что на нее завязан UI testLayout:update(dt) -- потом UI, потому что нужно перехватить жесты и не пустить их дальше From 4277c6c3100360d9f85658580c51d7cef00200e1 Mon Sep 17 00:00:00 2001 From: Kitsune-Jesus Date: Thu, 5 Feb 2026 09:06:51 +0100 Subject: [PATCH 2/9] refactor: replace AnimationNode with Task system (tweens/async) --- assets/shaders/divine_ripple.glsl | 47 ++++++++++++++ lib/animation_node.lua | 103 ------------------------------ lib/audio.lua | 59 +++++++++++------ lib/character/behaviors/ai.lua | 2 - lib/character/behaviors/light.lua | 34 ++-------- lib/character/behaviors/tiled.lua | 67 +++++++------------ lib/level/camera.lua | 1 + lib/simple_ui/level/cpanel.lua | 42 ++++-------- lib/simple_ui/level/end_turn.lua | 13 ++-- lib/utils/task.lua | 45 +++++++++---- 10 files changed, 166 insertions(+), 247 deletions(-) create mode 100644 assets/shaders/divine_ripple.glsl delete mode 100644 lib/animation_node.lua diff --git a/assets/shaders/divine_ripple.glsl b/assets/shaders/divine_ripple.glsl new file mode 100644 index 0000000..d45debe --- /dev/null +++ b/assets/shaders/divine_ripple.glsl @@ -0,0 +1,47 @@ +extern vec2 center; +extern number time; +extern number intensity; +extern vec2 screenSize; + +// Hexagon grid logic +// Returns distance to nearest hex center +float hexDist(vec2 p) { + p = abs(p); + float c = dot(p, normalize(vec2(1.0, 1.73))); + c = max(c, p.x); + return c; +} + +vec4 effect(vec4 color, Image texture, vec2 texture_coords, vec2 screen_coords) { + // Normalize coordinates to -1..1, correcting for aspect ratio + vec2 aspect = vec2(screenSize.x / screenSize.y, 1.0); + vec2 uv = (screen_coords / screenSize.y) - (center / screenSize.y); + + // Wave parameters + float dist = length(uv); + float speed = 5.0; + float waveWidth = 0.1; + float decay = 1.0 - smoothstep(0.0, 1.5, dist); // Decay over distance + + // Calculate wave pulse + float wavePhase = time * speed; + float pulse = smoothstep(waveWidth, 0.0, abs(dist - mod(wavePhase, 2.0))); + + // Hex grid pattern (visual only) + vec2 hexUV = screen_coords * 0.05; // Scale grid + // Basic hex grid approximation + vec2 q = vec2(hexUV.x * 2.0/3.0, hexUV.y); + + // Distortion + vec2 distort = normalize(uv) * pulse * intensity * 0.02 * decay; + vec2 finalUV = texture_coords - distort; + + // Sample texture with distortion + vec4 texColor = Texel(texture, finalUV); + + // Add divine glow at the wavefront + vec4 glowColor = vec4(1.0, 0.8, 0.4, 1.0); // Gold/Kitsune-fire color + texColor += glowColor * pulse * decay * 0.5; + + return texColor * color; +} diff --git a/lib/animation_node.lua b/lib/animation_node.lua deleted file mode 100644 index ece3aa7..0000000 --- a/lib/animation_node.lua +++ /dev/null @@ -1,103 +0,0 @@ -local easing = require "lib.utils.easing" - ---- @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() ---- ``` ---- @deprecated ---- @class AnimationNode ---- @field count integer ---- @field run animationRunner ---- @field parent AnimationNode? ---- @field children AnimationNode[] ---- @field finish voidCallback ---- @field onEnd voidCallback? ---- @field duration number продолжительность в миллисекундах ---- @field easing ease функция смягчения ---- @field t number прогресс анимации ---- @field state "running" | "waiting" | "finished" -local animation = {} -animation.__index = animation - ---- Регистрация завершения дочерней анимации -function animation:bubbleUp() - self.count = self.count - 1 - if self.count > 0 then return end - self.state = "finished" - 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 - ---- Возвращает текущий прогресс анимации с учетом смягчения -function animation:getValue() - return self.easing(self.t) -end - -function animation:update(dt) - if self.state ~= "running" then return end - - if self.t < 1 then - self.t = self.t + dt * 1000 / self.duration -- в знаменателе продолжительность анимации в секундах - else - self.t = 1 - self:finish() - end -end - ---- @deprecated ---- @param data {[1]: animationRunner?, onEnd?: voidCallback, duration: number?, easing: ease?, 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.duration = data.duration or 1000 - t.easing = data.easing or easing.linear - t.t = 0 - t.state = "running" - t.finish = function() - if t.state ~= "running" then return end - t.state = "waiting" - t:bubbleUp() - for _, anim in ipairs(t.children) do - anim:run() - end - end - return t -end - -return new diff --git a/lib/audio.lua b/lib/audio.lua index a480272..f6fa637 100644 --- a/lib/audio.lua +++ b/lib/audio.lua @@ -1,5 +1,5 @@ +local task = require "lib.utils.task" local ease = require "lib.utils.easing" -local AnimationNode = require "lib.animation_node" local EFFECTS_SUPPORTED = love.audio.isEffectsSupported() @@ -9,7 +9,6 @@ local EFFECTS_SUPPORTED = love.audio.isEffectsSupported() --- @field musicVolume number --- @field soundVolume number --- @field looped boolean ---- @field animationNode AnimationNode? --- @field from love.Source? --- @field to love.Source? audio = {} @@ -25,11 +24,11 @@ local function new(musicVolume, soundVolume) end function audio:update(dt) - if not self.animationNode then return end - self.from:setVolume(self.musicVolume - self.animationNode:getValue() * self.musicVolume) - self.to:setVolume(self.animationNode:getValue() * self.musicVolume) - self.animationNode:update(dt) - -- print(self.animationNode.t) + if self.fader then + local t = self.fader.value + if self.from then self.from:setVolume(self.musicVolume * (1 - t)) end + if self.to then self.to:setVolume(self.musicVolume * t) end + end end --- if from is nil, than we have fade in to; @@ -45,19 +44,39 @@ function audio:crossfade(from, to, ms) to:setVolume(0) self.from = from self.to = to - self.animationNode = AnimationNode { - function(node) end, - onEnd = function() - self.from:setVolume(0) - self.to:setVolume(self.musicVolume) - self.from:stop() - self.animationNode = nil - print("[Audio]: Crossfade done") - end, - duration = ms or 1000, - easing = ease.easeOutCubic, - } - self.animationNode:run() + + -- Using a dummy object to tween a value from 0 to 1 + local fade = { value = 0 } + + task.tween(fade, { value = 1 }, ms or 1000, ease.easeOutCubic)(function() + self.from:setVolume(0) + self.to:setVolume(self.musicVolume) + self.from:stop() + print("[Audio]: Crossfade done") + end) + + -- We need a custom update loop for the volume during the tween + -- Since task.tween updates properties, we can use a "monitor" task or just rely on `update` if we expose the task? + -- Actually, task.tween updates `fade.value` every frame. + -- We need to apply this value to the audio sources every frame. + -- BUT task.tween doesn't provide an "onUpdate" callback easily. + -- Workaround: create a task that runs every frame? Or modify task.tween? + + -- Let's check task.lua... it updates properties on the target object. + -- So `fade.value` will change. We need to apply it. + -- We can hook into `update` or use a metatable on `fade`? + + -- Let's use a "reactive" table with metatable setters? Overkill. + -- Simplest way: Add `onUpdate` to task.tween or just use `audio:update` to poll `fade.value`? + -- But `fade` is local. + + -- Better approach: Tween `self` properties? No, volume is on Source userdate. + -- Let's add a `fader` object to `self` that we tween, and in `update` we apply it. + + self.fader = { value = 0 } + task.tween(self.fader, { value = 1 }, ms or 1000, ease.easeOutCubic)(function() + self.fader = nil + end) end --- @param source love.Source diff --git a/lib/character/behaviors/ai.lua b/lib/character/behaviors/ai.lua index 7c52624..5aea6d3 100644 --- a/lib/character/behaviors/ai.lua +++ b/lib/character/behaviors/ai.lua @@ -1,4 +1,3 @@ -local AnimationNode = require "lib.animation_node" local easing = require "lib.utils.easing" local function closestCharacter(char) @@ -22,7 +21,6 @@ local function closestCharacter(char) end --- @class AIBehavior : Behavior ---- @field animationNode AnimationNode? --- @field target Vec3? local behavior = {} behavior.__index = behavior diff --git a/lib/character/behaviors/light.lua b/lib/character/behaviors/light.lua index 16301e4..acf67f0 100644 --- a/lib/character/behaviors/light.lua +++ b/lib/character/behaviors/light.lua @@ -1,14 +1,10 @@ -local AnimationNode = require "lib.animation_node" -local easing = require "lib.utils.easing" +local task = require "lib.utils.task" --- @class LightBehavior : Behavior --- @field intensity number --- @field color Vec3 --- @field seed integer ---- @field colorAnimationNode? AnimationNode ---- @field private animateColorCallback? fun(): nil ---- @field targetColor? Vec3 ---- @field sourceColor? Vec3 +--- @field private animateColorTask? Task local behavior = {} behavior.__index = behavior behavior.id = "light" @@ -24,30 +20,12 @@ function behavior.new(values) end function behavior:update(dt) - if not self.colorAnimationNode then return end - local delta = self.targetColor - self.sourceColor - self.color = self.sourceColor + delta * self.colorAnimationNode:getValue() - self.colorAnimationNode:update(dt) + -- All logic moved to tasks end ---- @TODO: refactor -function behavior:animateColor(targetColor) - if self.colorAnimationNode then self.colorAnimationNode:finish() end - self.colorAnimationNode = AnimationNode { - function(_) end, - easing = easing.easeInQuad, - duration = 800, - onEnd = function() - if self.animateColorCallback then self.animateColorCallback() end - end - } - self.colorAnimationNode:run() - self.sourceColor = self.color - self.targetColor = targetColor - - return function(callback) - self.animateColorCallback = callback - end +function behavior:animateColor(targetColor, duration, easing) + -- If there's support for canceling tasks, we should do it here + return task.tween(self, { color = targetColor }, duration or 800, easing) end function behavior:draw() diff --git a/lib/character/behaviors/tiled.lua b/lib/character/behaviors/tiled.lua index b7b937b..420ccbd 100644 --- a/lib/character/behaviors/tiled.lua +++ b/lib/character/behaviors/tiled.lua @@ -1,12 +1,8 @@ local utils = require "lib.utils.utils" +local task = require "lib.utils.task" --- Отвечает за перемещение по тайлам --- @class TiledBehavior : Behavior ---- @field private runSource? Vec3 точка, из которой бежит персонаж ---- @field private runTarget? Vec3 точка, в которую в данный момент бежит персонаж ---- @field private path? Deque путь, по которому сейчас бежит персонаж ---- @field private followPathCallback? fun() ---- @field private t0 number время начала движения --- @field size Vec3 local behavior = {} behavior.__index = behavior @@ -22,29 +18,33 @@ end --- @param path Deque --- @return Task function behavior:followPath(path) + if path:is_empty() then return task.fromValue(nil) end + self.owner:try(Tree.behaviors.sprite, function(sprite) sprite:loop("run") end) - self.path = path; - ---@type Vec3 - local nextCell = path:peek_front() - self:runTo(nextCell) - path:pop_front() - return function(callback) - self.followPathCallback = callback + -- Рекурсивная функция для прохода по пути + local function nextStep() + if path:is_empty() then + self.owner:try(Tree.behaviors.sprite, function(sprite) + sprite:loop("idle") + end) + return task.fromValue(nil) + end + + local nextCell = path:pop_front() + return task.chain(self:runTo(nextCell), nextStep) end + + return nextStep() end --- @param target Vec3 +--- @return Task function behavior:runTo(target) local positioned = self.owner:has(Tree.behaviors.positioned) - if not positioned then return end - - self.t0 = love.timer.getTime() - self.runTarget = target - - self.runSource = positioned.position + if not positioned then return task.fromValue(nil) end self.owner:try(Tree.behaviors.sprite, function(sprite) @@ -55,34 +55,15 @@ function behavior:runTo(target) end end ) + + local distance = target:subtract(positioned.position):length() + local duration = distance * 500 -- 500ms per unit + + return task.tween(positioned, { position = target }, duration) end function behavior:update(dt) - if self.runTarget then - local positioned = self.owner:has(Tree.behaviors.positioned) - if not positioned then return end - - local delta = love.timer.getTime() - self.t0 or love.timer.getTime() - local fraction = delta / - (0.5 * self.runTarget:subtract(self.runSource):length()) -- бежим одну клетку за 500 мс, по диагонали больше - if fraction >= 1 then -- анимация перемещена завершена - positioned.position = self.runTarget - if not self.path:is_empty() then -- еще есть, куда бежать - self:runTo(self.path:pop_front()) - else -- мы добежали до финальной цели - self.owner:try(Tree.behaviors.sprite, function(sprite) - sprite:loop("idle") - end) - self.runTarget = nil - - if self.followPathCallback then - self.followPathCallback() - end - end - else -- анимация перемещения не завершена - positioned.position = utils.lerp(self.runSource, self.runTarget, fraction) -- линейный интерполятор - end - end + -- Logic moved to tasks end return behavior diff --git a/lib/level/camera.lua b/lib/level/camera.lua index f2bb368..20bb0e2 100644 --- a/lib/level/camera.lua +++ b/lib/level/camera.lua @@ -1,6 +1,7 @@ local Vec3 = require "lib.utils.vec3" local utils = require "lib.utils.utils" local task = require "lib.utils.task" +local easing = require "lib.utils.easing" local EPSILON = 0.001 diff --git a/lib/simple_ui/level/cpanel.lua b/lib/simple_ui/level/cpanel.lua index c04fe58..2e489f9 100644 --- a/lib/simple_ui/level/cpanel.lua +++ b/lib/simple_ui/level/cpanel.lua @@ -1,5 +1,5 @@ +local task = require "lib.utils.task" local easing = require "lib.utils.easing" -local AnimationNode = require "lib.animation_node" local Element = require "lib.simple_ui.element" local Rect = require "lib.simple_ui.rect" local SkillRow = require "lib.simple_ui.level.skill_row" @@ -7,7 +7,8 @@ local Bars = require "lib.simple_ui.level.bottom_bars" local EndTurnButton = require "lib.simple_ui.level.end_turn" --- @class CharacterPanel : UIElement ---- @field animationNode AnimationNode +--- @field animationTask Task +--- @field alpha number --- @field state "show" | "idle" | "hide" --- @field skillRow SkillRow --- @field bars BottomBars @@ -21,41 +22,26 @@ function characterPanel.new(characterId) t.skillRow = SkillRow(characterId) t.bars = Bars(characterId) t.endTurnButton = EndTurnButton {} + t.alpha = 0 -- starts hidden/animating return setmetatable(t, characterPanel) end function characterPanel:show() - AnimationNode { - function(animationNode) - if self.animationNode then self.animationNode:finish() end - self.animationNode = animationNode - self.state = "show" - end, - duration = 300, - onEnd = function() - self.state = "idle" - end, - easing = easing.easeOutCubic - }:run() + self.state = "show" + self.animationTask = task.tween(self, { alpha = 1 }, 300, easing.easeOutCubic) + self.animationTask(function() self.state = "idle" end) end function characterPanel:hide() - AnimationNode { - function(animationNode) - if self.animationNode then self.animationNode:finish() end - self.animationNode = animationNode - self.state = "hide" - end, - duration = 300, - easing = easing.easeOutCubic - }:run() + self.state = "hide" + self.animationTask = task.tween(self, { alpha = 0 }, 300, easing.easeOutCubic) end --- @type love.Canvas local characterPanelCanvas; function characterPanel:update(dt) - if self.animationNode then self.animationNode:update(dt) end + -- Tasks update automatically via task.update(dt) in main.lua self.skillRow:update(dt) self.bars.bounds = Rect { width = self.skillRow.bounds.width, @@ -82,14 +68,8 @@ function characterPanel:update(dt) end --- анимация появления - local alpha = 1 - if self.state == "show" then - alpha = self.animationNode:getValue() - elseif self.state == "hide" then - alpha = 1 - self.animationNode:getValue() - end local revealShader = Tree.assets.files.shaders.reveal - revealShader:send("t", alpha) + revealShader:send("t", self.alpha) end function characterPanel:draw() diff --git a/lib/simple_ui/level/end_turn.lua b/lib/simple_ui/level/end_turn.lua index ddc0c0b..53a072f 100644 --- a/lib/simple_ui/level/end_turn.lua +++ b/lib/simple_ui/level/end_turn.lua @@ -1,5 +1,5 @@ local Element = require "lib.simple_ui.element" -local AnimationNode = require "lib.animation_node" +local task = require "lib.utils.task" local easing = require "lib.utils.easing" --- @class EndTurnButton : UIElement @@ -51,14 +51,9 @@ function endTurnButton:onClick() local playing = Tree.level.characters[cid] if not playing:has(Tree.behaviors.positioned) then return end - AnimationNode { - function(node) - Tree.level.camera:animateTo(playing:has(Tree.behaviors.positioned).position, node) - end, - duration = 1500, - easing = easing.easeInOutCubic, - onEnd = function() if not playing:has(Tree.behaviors.ai) then Tree.level.selector:select(cid) end end - }:run() + Tree.level.camera:animateTo(playing:has(Tree.behaviors.positioned).position, 1500, easing.easeInOutCubic)( + function() if not playing:has(Tree.behaviors.ai) then Tree.level.selector:select(cid) end end + ) end return function(values) diff --git a/lib/utils/task.lua b/lib/utils/task.lua index ae71632..2593342 100644 --- a/lib/utils/task.lua +++ b/lib/utils/task.lua @@ -6,7 +6,12 @@ local easing_lib = require "lib.utils.easing" --- @alias Task fun(callback: fun(value: T): nil): nil local task = {} -local activeTweens = {} +local activeTweens = {} -- list of tweens +-- We also need a way to track tweens by target to support cancellation/replacement +-- Let's stick to a simple list for update, but maybe add a helper to find by target? +-- Or, just allow multiple tweens per target (which is valid for different properties). +-- But if we animate the SAME property, we have a conflict. +-- For now, let's just add task.cancel(target) which kills ALL tweens for that target. --- Внутренний хелпер для интерполяции (числа или Vec3) local function lerp(a, b, t) @@ -24,17 +29,22 @@ end function task.update(dt) for i = #activeTweens, 1, -1 do local t = activeTweens[i] - t.elapsed = t.elapsed + dt * 1000 - local progress = math.min(t.elapsed / t.duration, 1) - local value = t.easing(progress) - - for key, targetValue in pairs(t.properties) do - t.target[key] = lerp(t.initial[key], targetValue, value) - end - - if progress >= 1 then + if not t.cancelled then + t.elapsed = t.elapsed + dt * 1000 + local progress = math.min(t.elapsed / t.duration, 1) + local value = t.easing(progress) + + for key, targetValue in pairs(t.properties) do + t.target[key] = lerp(t.initial[key], targetValue, value) + end + + if progress >= 1 then + t.completed = true + table.remove(activeTweens, i) + if t.resolve then t.resolve() end + end + else table.remove(activeTweens, i) - if t.resolve then t.resolve() end end end end @@ -57,13 +67,26 @@ function task.completer() return c, future end +--- Отменяет все активные твины для указанного объекта. +--- @param target table +function task.cancel(target) + for _, t in ipairs(activeTweens) do + if t.target == target then + t.cancelled = true + end + end +end + --- Создает таску, которая плавно меняет свойства объекта. +--- Автоматически отменяет предыдущие твины для этого объекта (чтобы не было конфликтов). --- @param target table Объект, свойства которого меняем --- @param properties table Набор конечных значений { key = value } --- @param duration number Длительность в мс --- @param easing function? Функция смягчения (по умолчанию linear) --- @return Task function task.tween(target, properties, duration, easing) + task.cancel(target) -- Cancel previous animations on this target + local initial = {} for k, _ in pairs(properties) do initial[k] = target[k] From 52db521107d6ac8f93f9429105e5c793eb32fe97 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Sun, 8 Feb 2026 06:16:11 +0300 Subject: [PATCH 3/9] Animate camera and activate AI on turn change in turnOrder.next() --- lib/level/turn_order.lua | 34 +++++++++++++++++++++----------- lib/simple_ui/level/end_turn.lua | 8 -------- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/lib/level/turn_order.lua b/lib/level/turn_order.lua index 569398a..a059197 100644 --- a/lib/level/turn_order.lua +++ b/lib/level/turn_order.lua @@ -1,4 +1,5 @@ -local PriorityQueue = require "lib.utils.priority_queue" +local PriorityQueue = require "lib.utils.priority_queue" +local easing = require "lib.utils.easing" local initiativeComparator = function(id_a, id_b) local res = Tree.level.characters[id_a]:try(Tree.behaviors.stats, function(astats) @@ -15,8 +16,8 @@ end --- @field pendingQueue PriorityQueue Очередь тех, кто ждет своего хода в текущем раунде --- @field current? Id Считаем того, кто сейчас ходит, отдельно, т.к. он ВСЕГДА первый в списке --- @field isTurnsEnabled boolean -local turnOrder = {} -turnOrder.__index = turnOrder +local turnOrder = {} +turnOrder.__index = turnOrder local function new() return setmetatable({ @@ -29,19 +30,30 @@ end --- Перемещаем активного персонажа в очередь сходивших --- --- Если в очереди на ход больше никого нет, заканчиваем раунд +--- +--- Анимируем камеру к следующему персонажу. Если это ИИ, то активируем его логику. function turnOrder:next() self.actedQueue:insert(self.current) local next = self.pendingQueue:peek() - if not next then return self:endRound() end - self.current = self.pendingQueue:pop() + if not next then self:endRound() else self.current = self.pendingQueue:pop() end local char = Tree.level.characters[self.current] - char:try(Tree.behaviors.ai, function(ai) - Tree.level.selector:lock() - ai:makeTurn()(function() - Tree.level.selector:unlock() - self:next() - end) + + Tree.level.selector:lock() + char:try(Tree.behaviors.positioned, function(positioned) + Tree.level.camera:animateTo(positioned.position, 1500, easing.easeInOutCubic)( + function() + if char:has(Tree.behaviors.ai) then + char:has(Tree.behaviors.ai):makeTurn()( + function() + self:next() + end) + else + Tree.level.selector:unlock() + Tree.level.selector:select(self.current) + end + end + ) end) end diff --git a/lib/simple_ui/level/end_turn.lua b/lib/simple_ui/level/end_turn.lua index 53a072f..5621311 100644 --- a/lib/simple_ui/level/end_turn.lua +++ b/lib/simple_ui/level/end_turn.lua @@ -46,14 +46,6 @@ end function endTurnButton:onClick() Tree.level.turnOrder:next() - Tree.level.selector:select(nil) - local cid = Tree.level.turnOrder.current - local playing = Tree.level.characters[cid] - if not playing:has(Tree.behaviors.positioned) then return end - - Tree.level.camera:animateTo(playing:has(Tree.behaviors.positioned).position, 1500, easing.easeInOutCubic)( - function() if not playing:has(Tree.behaviors.ai) then Tree.level.selector:select(cid) end end - ) end return function(values) From 7695fe76985eba9169e21e7836e0a38b18bb148f Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Sun, 8 Feb 2026 06:18:26 +0300 Subject: [PATCH 4/9] Fix formatting and add type annotations to task completer function --- lib/utils/task.lua | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/utils/task.lua b/lib/utils/task.lua index 2593342..ec65b2b 100644 --- a/lib/utils/task.lua +++ b/lib/utils/task.lua @@ -33,11 +33,11 @@ function task.update(dt) t.elapsed = t.elapsed + dt * 1000 local progress = math.min(t.elapsed / t.duration, 1) local value = t.easing(progress) - + for key, targetValue in pairs(t.properties) do t.target[key] = lerp(t.initial[key], targetValue, value) end - + if progress >= 1 then t.completed = true table.remove(activeTweens, i) @@ -50,7 +50,8 @@ function task.update(dt) end --- Возвращает Completer — объект, который позволяет вручную завершить таску. ---- @return table completer { complete: fun(val: T) }, Task future +--- @generic T +--- @return { complete: fun(val: T) }, Task future function task.completer() local c = { completed = false, value = nil, cb = nil } function c:complete(val) @@ -61,8 +62,11 @@ function task.completer() end local future = function(callback) - if c.completed then callback(c.value) - else c.cb = callback end + if c.completed then + callback(c.value) + else + c.cb = callback + end end return c, future end @@ -86,7 +90,7 @@ end --- @return Task function task.tween(target, properties, duration, easing) task.cancel(target) -- Cancel previous animations on this target - + local initial = {} for k, _ in pairs(properties) do initial[k] = target[k] From 163906c289bc4033dd77f840984f26035222e924 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Sun, 8 Feb 2026 06:20:22 +0300 Subject: [PATCH 5/9] Import lerp from utils module and remove duplicate function definition --- lib/utils/task.lua | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/lib/utils/task.lua b/lib/utils/task.lua index ec65b2b..610e7a8 100644 --- a/lib/utils/task.lua +++ b/lib/utils/task.lua @@ -1,4 +1,5 @@ local easing_lib = require "lib.utils.easing" +local lerp = require "lib.utils.utils".lerp --- Обобщенная асинхронная функция (Task). --- По сути это функция, принимающая коллбэк: `fun(callback: fun(value: T): nil): nil` @@ -13,17 +14,6 @@ local activeTweens = {} -- list of tweens -- But if we animate the SAME property, we have a conflict. -- For now, let's just add task.cancel(target) which kills ALL tweens for that target. ---- Внутренний хелпер для интерполяции (числа или Vec3) -local function lerp(a, b, t) - if type(a) == "number" then - return a + (b - a) * t - elseif type(a) == "table" and getmetatable(a) then - -- Предполагаем, что это Vec3 или другой объект с операторами +, -, * - return a + (b - a) * t - end - return b -end - --- Обновление всех активных анимаций (твинов). --- Нужно вызывать в love.update(dt) function task.update(dt) From 78776ec0ddeaa038d6345e3c29b96bf330f8ba16 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Sun, 8 Feb 2026 06:28:24 +0300 Subject: [PATCH 6/9] Refactor mana regeneration to add flash effect callback --- lib/spellbook.lua | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/spellbook.lua b/lib/spellbook.lua index 0843174..7c95ea5 100644 --- a/lib/spellbook.lua +++ b/lib/spellbook.lua @@ -99,8 +99,17 @@ function regenerateMana:cast(caster, target) Tree.behaviors.positioned.new(caster:has(Tree.behaviors.positioned).position + Vec3 { 0.5, 0.5 }), } + local flash = function(callback) + light:has(Tree.behaviors.light):animateColor(Vec3 {})( + function() + light:die() + callback() + end + ) + end + return task.wait { - light:has(Tree.behaviors.light):animateColor(Vec3 {}), + flash, sprite:animate("hurt") } end From 00f1c7f7ee03ca5fda7d5615a04871882cbd45d2 Mon Sep 17 00:00:00 2001 From: Kitsune-Jesus Date: Sun, 8 Feb 2026 04:33:13 +0100 Subject: [PATCH 7/9] fix(audio): remove duplicated logic and fix fader lifecycle in crossfade --- lib/audio.lua | 45 +++++++++++++++++---------------------------- 1 file changed, 17 insertions(+), 28 deletions(-) diff --git a/lib/audio.lua b/lib/audio.lua index f6fa637..465f72a 100644 --- a/lib/audio.lua +++ b/lib/audio.lua @@ -40,42 +40,31 @@ end --- @param ms number? in milliseconds function audio:crossfade(from, to, ms) print("[Audio]: Triggered crossfade") + + -- Stop previous 'from' if it's dangling to avoid leaks + if self.from and self.from ~= from and self.from ~= to then + self.from:stop() + end + self:play(to) to:setVolume(0) self.from = from self.to = to - -- Using a dummy object to tween a value from 0 to 1 - local fade = { value = 0 } + -- Reuse fader object to allow task cancellation + if not self.fader then self.fader = { value = 0 } end + self.fader.value = 0 - task.tween(fade, { value = 1 }, ms or 1000, ease.easeOutCubic)(function() - self.from:setVolume(0) - self.to:setVolume(self.musicVolume) - self.from:stop() - print("[Audio]: Crossfade done") - end) - - -- We need a custom update loop for the volume during the tween - -- Since task.tween updates properties, we can use a "monitor" task or just rely on `update` if we expose the task? - -- Actually, task.tween updates `fade.value` every frame. - -- We need to apply this value to the audio sources every frame. - -- BUT task.tween doesn't provide an "onUpdate" callback easily. - -- Workaround: create a task that runs every frame? Or modify task.tween? - - -- Let's check task.lua... it updates properties on the target object. - -- So `fade.value` will change. We need to apply it. - -- We can hook into `update` or use a metatable on `fade`? - - -- Let's use a "reactive" table with metatable setters? Overkill. - -- Simplest way: Add `onUpdate` to task.tween or just use `audio:update` to poll `fade.value`? - -- But `fade` is local. - - -- Better approach: Tween `self` properties? No, volume is on Source userdate. - -- Let's add a `fader` object to `self` that we tween, and in `update` we apply it. - - self.fader = { value = 0 } task.tween(self.fader, { value = 1 }, ms or 1000, ease.easeOutCubic)(function() + if self.from then + self.from:setVolume(0) + self.from:stop() + end + if self.to then + self.to:setVolume(self.musicVolume) + end self.fader = nil + print("[Audio]: Crossfade done") end) end From 2fc9bdf6a6c5da8b9e74c9105c1c1627c9c3a786 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Thu, 12 Feb 2026 00:01:43 +0300 Subject: [PATCH 8/9] Clean up trailing whitespace in audio crossfade function --- lib/audio.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/audio.lua b/lib/audio.lua index 465f72a..58d0e22 100644 --- a/lib/audio.lua +++ b/lib/audio.lua @@ -40,7 +40,7 @@ end --- @param ms number? in milliseconds function audio:crossfade(from, to, ms) print("[Audio]: Triggered crossfade") - + -- Stop previous 'from' if it's dangling to avoid leaks if self.from and self.from ~= from and self.from ~= to then self.from:stop() @@ -54,7 +54,7 @@ function audio:crossfade(from, to, ms) -- Reuse fader object to allow task cancellation if not self.fader then self.fader = { value = 0 } end self.fader.value = 0 - + task.tween(self.fader, { value = 1 }, ms or 1000, ease.easeOutCubic)(function() if self.from then self.from:setVolume(0) From 08cfcca75614083cc8d8cc9c81b242ca279851b7 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Thu, 12 Feb 2026 00:04:57 +0300 Subject: [PATCH 9/9] Remove unused utils require and clean trailing whitespace in tiled behavior --- lib/character/behaviors/tiled.lua | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/character/behaviors/tiled.lua b/lib/character/behaviors/tiled.lua index 420ccbd..6085104 100644 --- a/lib/character/behaviors/tiled.lua +++ b/lib/character/behaviors/tiled.lua @@ -1,4 +1,3 @@ -local utils = require "lib.utils.utils" local task = require "lib.utils.task" --- Отвечает за перемещение по тайлам @@ -55,7 +54,7 @@ function behavior:runTo(target) end end ) - + local distance = target:subtract(positioned.position):length() local duration = distance * 500 -- 500ms per unit