Compare commits

..

No commits in common. "feature/task-tweens" and "main" have entirely different histories.

13 changed files with 332 additions and 282 deletions

View File

@ -1,47 +0,0 @@
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;
}

103
lib/animation_node.lua Normal file
View File

@ -0,0 +1,103 @@
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

View File

@ -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,6 +9,7 @@ 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 = {}
@ -24,11 +25,11 @@ local function new(musicVolume, soundVolume)
end
function audio:update(dt)
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
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)
end
--- if from is nil, than we have fade in to;
@ -40,32 +41,23 @@ 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
-- 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.animationNode = AnimationNode {
function(node) end,
onEnd = function()
self.from:setVolume(0)
self.from:stop()
end
if self.to then
self.to:setVolume(self.musicVolume)
end
self.fader = nil
self.from:stop()
self.animationNode = nil
print("[Audio]: Crossfade done")
end)
end,
duration = ms or 1000,
easing = ease.easeOutCubic,
}
self.animationNode:run()
end
--- @param source love.Source

View File

@ -1,3 +1,4 @@
local AnimationNode = require "lib.animation_node"
local easing = require "lib.utils.easing"
local function closestCharacter(char)
@ -21,6 +22,7 @@ local function closestCharacter(char)
end
--- @class AIBehavior : Behavior
--- @field animationNode AnimationNode?
--- @field target Vec3?
local behavior = {}
behavior.__index = behavior

View File

@ -1,10 +1,14 @@
local task = require "lib.utils.task"
local AnimationNode = require "lib.animation_node"
local easing = require "lib.utils.easing"
--- @class LightBehavior : Behavior
--- @field intensity number
--- @field color Vec3
--- @field seed integer
--- @field private animateColorTask? Task
--- @field colorAnimationNode? AnimationNode
--- @field private animateColorCallback? fun(): nil
--- @field targetColor? Vec3
--- @field sourceColor? Vec3
local behavior = {}
behavior.__index = behavior
behavior.id = "light"
@ -20,12 +24,30 @@ function behavior.new(values)
end
function behavior:update(dt)
-- All logic moved to tasks
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)
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)
--- @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
end
function behavior:draw()

View File

@ -1,8 +1,12 @@
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
@ -18,33 +22,29 @@ end
--- @param path Deque
--- @return Task<nil>
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()
-- Рекурсивная функция для прохода по пути
local function nextStep()
if path:is_empty() then
self.owner:try(Tree.behaviors.sprite, function(sprite)
sprite:loop("idle")
end)
return task.fromValue(nil)
return function(callback)
self.followPathCallback = callback
end
local nextCell = path:pop_front()
return task.chain(self:runTo(nextCell), nextStep)
end
return nextStep()
end
--- @param target Vec3
--- @return Task<nil>
function behavior:runTo(target)
local positioned = self.owner:has(Tree.behaviors.positioned)
if not positioned then return task.fromValue(nil) end
if not positioned then return end
self.t0 = love.timer.getTime()
self.runTarget = target
self.runSource = positioned.position
self.owner:try(Tree.behaviors.sprite,
function(sprite)
@ -55,15 +55,34 @@ 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)
-- Logic moved to tasks
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
end
return behavior

View File

@ -1,7 +1,5 @@
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
@ -11,6 +9,9 @@ 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 {},
@ -37,6 +38,12 @@ 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;
@ -90,14 +97,14 @@ function camera:detach()
love.graphics.pop()
end
--- Плавно перемещает камеру к указанной точке.
--- @param position 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)
--- @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 {}
end
--- @return Camera

View File

@ -1,5 +1,4 @@
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)
@ -30,30 +29,19 @@ end
--- Перемещаем активного персонажа в очередь сходивших
---
--- Если в очереди на ход больше никого нет, заканчиваем раунд
---
--- Анимируем камеру к следующему персонажу. Если это ИИ, то активируем его логику.
function turnOrder:next()
self.actedQueue:insert(self.current)
local next = self.pendingQueue:peek()
if not next then self:endRound() else self.current = self.pendingQueue:pop() end
if not next then return self:endRound() end
self.current = self.pendingQueue:pop()
local char = Tree.level.characters[self.current]
char:try(Tree.behaviors.ai, function(ai)
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()
ai:makeTurn()(function()
Tree.level.selector:unlock()
self:next()
end)
else
Tree.level.selector:unlock()
Tree.level.selector:select(self.current)
end
end
)
end)
end

View File

@ -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,8 +7,7 @@ local Bars = require "lib.simple_ui.level.bottom_bars"
local EndTurnButton = require "lib.simple_ui.level.end_turn"
--- @class CharacterPanel : UIElement
--- @field animationTask Task
--- @field alpha number
--- @field animationNode AnimationNode
--- @field state "show" | "idle" | "hide"
--- @field skillRow SkillRow
--- @field bars BottomBars
@ -22,26 +21,41 @@ 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"
self.animationTask = task.tween(self, { alpha = 1 }, 300, easing.easeOutCubic)
self.animationTask(function() self.state = "idle" end)
end,
duration = 300,
onEnd = function()
self.state = "idle"
end,
easing = easing.easeOutCubic
}:run()
end
function characterPanel:hide()
AnimationNode {
function(animationNode)
if self.animationNode then self.animationNode:finish() end
self.animationNode = animationNode
self.state = "hide"
self.animationTask = task.tween(self, { alpha = 0 }, 300, easing.easeOutCubic)
end,
duration = 300,
easing = easing.easeOutCubic
}:run()
end
--- @type love.Canvas
local characterPanelCanvas;
function characterPanel:update(dt)
-- Tasks update automatically via task.update(dt) in main.lua
if self.animationNode then self.animationNode:update(dt) end
self.skillRow:update(dt)
self.bars.bounds = Rect {
width = self.skillRow.bounds.width,
@ -68,8 +82,14 @@ 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", self.alpha)
revealShader:send("t", alpha)
end
function characterPanel:draw()

View File

@ -1,5 +1,5 @@
local Element = require "lib.simple_ui.element"
local task = require "lib.utils.task"
local AnimationNode = require "lib.animation_node"
local easing = require "lib.utils.easing"
--- @class EndTurnButton : UIElement
@ -46,6 +46,19 @@ 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
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()
end
return function(values)

View File

@ -99,17 +99,8 @@ 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 {
flash,
light:has(Tree.behaviors.light):animateColor(Vec3 {}),
sprite:animate("hurt")
}
end

View File

@ -1,142 +1,83 @@
local easing_lib = require "lib.utils.easing"
local lerp = require "lib.utils.utils".lerp
--- Обобщенная асинхронная функция (Task).
--- По сути это функция, принимающая коллбэк: `fun(callback: fun(value: T): nil): nil`
--- Обобщенная асинхронная функция
---
--- Использование в общих чертах выглядит так:
--- ```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
--- )
---
--- ```
--- @generic T
--- @alias Task fun(callback: fun(value: T): nil): nil
local task = {}
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.
--- Обновление всех активных анимаций (твинов).
--- Нужно вызывать в love.update(dt)
function task.update(dt)
for i = #activeTweens, 1, -1 do
local t = activeTweens[i]
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)
end
end
end
--- Возвращает Completer — объект, который позволяет вручную завершить таску.
--- @generic T
--- @return { complete: fun(val: T) }, Task<T> 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
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<nil>
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]
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
--- @alias Task<T> 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<any[]>
function task.wait(tasks)
if #tasks == 0 then return task.fromValue({}) end
--- @return Task<T[]>
local function wait(tasks)
local count = #tasks
local results = {}
return function(callback)
for i, t in ipairs(tasks) do
t(function(result)
for i, task in ipairs(tasks) do
task(
function(result)
results[i] = result
count = count - 1
if count == 0 then callback(results) end
end)
end
)
end
end
end
--- Последовательно объединяет два `Task` в один.
--- @param t Task
--- @param onCompleted fun(value: any): Task
--- @return Task
function task.chain(t, onCompleted)
--- @generic T
--- @generic R
--- @param task Task<T> `Task`, который выполнится первым
--- @param onCompleted fun(value: T): Task<R> Конструктор второго `Task`. Принимает результат выполнения первого `Task`
--- @return Task<R>
local function chain(task, onCompleted)
return function(callback)
t(function(value)
local t2 = onCompleted(value)
t2(callback)
task(function(value)
local task2 = onCompleted(value)
task2(callback)
end)
end
end
return task
return {
wait = wait,
chain = chain
}

View File

@ -72,7 +72,6 @@ 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, потому что нужно перехватить жесты и не пустить их дальше