Compare commits

...

25 Commits

Author SHA1 Message Date
93521d2b8b Merge pull request 'feature/task-tweens' (#30) from feature/task-tweens into main
Reviewed-on: #30
2026-02-12 00:07:34 +03:00
08cfcca756 Remove unused utils require and clean trailing whitespace in tiled
behavior
2026-02-12 00:04:57 +03:00
2fc9bdf6a6 Clean up trailing whitespace in audio crossfade function 2026-02-12 00:01:43 +03:00
00f1c7f7ee fix(audio): remove duplicated logic and fix fader lifecycle in crossfade 2026-02-08 04:33:13 +01:00
78776ec0dd Refactor mana regeneration to add flash effect callback 2026-02-08 06:28:24 +03:00
163906c289 Import lerp from utils module and remove duplicate function definition 2026-02-08 06:20:22 +03:00
7695fe7698 Fix formatting and add type annotations to task completer function 2026-02-08 06:18:26 +03:00
52db521107 Animate camera and activate AI on turn change in turnOrder.next() 2026-02-08 06:16:11 +03:00
4277c6c310 refactor: replace AnimationNode with Task system (tweens/async) 2026-02-05 09:06:51 +01:00
0017b6e104 refactor: integrate tweens into Task system and simplify camera animations 2026-02-02 02:39:28 +01:00
7f1c31f67e Merge pull request 'feature/task' (#29) from feature/task into main
Reviewed-on: #29
2026-02-02 02:36:30 +03:00
c0bab85b2f revert "completion.autoRequire": false 2026-02-02 02:33:23 +03:00
4f436a3d3f Merge pull request 'fix/death-logic' (#28) from fix/death-logic into main
Reviewed-on: #28
2026-02-02 01:56:51 +03:00
752fe00910 refactor: implement turnOrder:remove by filtering/rebuilding queues instead of O(n) PriorityQueue:remove 2026-02-01 23:36:09 +01:00
2d29d35f96 fix: implement character death cleanup in turn order and queues 2026-02-01 23:26:14 +01:00
ec290eb18b Merge pull request 'fix: correctly add values to self.__grid instead of the grid class table' (#27) from fix/grid-add-to-self into main
Reviewed-on: #27
2026-02-02 01:06:22 +03:00
1986228670 Refactor spell casts to use task utilities and simplify callbacks
Add task.chain utility for chaining asynchronous tasks
2026-02-01 03:56:18 +03:00
403ba5a03f Add Task.wait to combine multiple tasks into one
Add TestRunner for running asynchronous tests with update support

Add test for Task.wait to verify concurrent task completion

Add set method to Counter for explicit value assignment
2026-01-31 02:11:55 +03:00
e02c221e31 make all 3 demo spells work with tasks 2026-01-30 00:55:08 +03:00
59cc0fba0b rewrite sprite:animate, residentsleeper:sleep, attack:cast to use
callback trees
2026-01-30 00:32:05 +03:00
86a599723e TLDR: higher-order functions my beloved
Add counter utility and chain async animations in spell cast

Introduce a Counter module to coordinate multiple asynchronous
animation callbacks and update spellbook cast to run chained
animations sequentially. Also lock selector during AI turns.
2026-01-29 02:32:58 +03:00
93a4961419 high-order functions my beloved 2026-01-25 07:05:45 +03:00
3f694ccec9 attempt to save our souls (callback hell my beloved)
Co-authored-by: Ivan Yuriev <ivanyr44@gmail.com>
2026-01-24 01:46:05 +03:00
d48f1af1af micro refactoring 2026-01-23 15:57:10 +03:00
9489cd0488 we can move under another (closest!!!!!!!!!) character
cool!!!!
2026-01-23 15:56:36 +03:00
21 changed files with 665 additions and 340 deletions

View File

@ -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;
}

View File

@ -1,102 +0,0 @@
local easing = require "lib.utils.easing"
--- @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?
--- @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
--- @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

@ -8,3 +8,6 @@ Tree.behaviors.light = require "character.behaviors.light"
Tree.behaviors.positioned = require "character.behaviors.positioned" Tree.behaviors.positioned = require "character.behaviors.positioned"
Tree.behaviors.tiled = require "character.behaviors.tiled" Tree.behaviors.tiled = require "character.behaviors.tiled"
Tree.behaviors.cursor = require "character.behaviors.cursor" Tree.behaviors.cursor = require "character.behaviors.cursor"
Tree.behaviors.ai = require "lib.character.behaviors.ai"
--- @alias voidCallback fun(): nil

View File

@ -1,5 +1,5 @@
local task = require "lib.utils.task"
local ease = require "lib.utils.easing" local ease = require "lib.utils.easing"
local AnimationNode = require "lib.animation_node"
local EFFECTS_SUPPORTED = love.audio.isEffectsSupported() local EFFECTS_SUPPORTED = love.audio.isEffectsSupported()
@ -9,7 +9,6 @@ local EFFECTS_SUPPORTED = love.audio.isEffectsSupported()
--- @field musicVolume number --- @field musicVolume number
--- @field soundVolume number --- @field soundVolume number
--- @field looped boolean --- @field looped boolean
--- @field animationNode AnimationNode?
--- @field from love.Source? --- @field from love.Source?
--- @field to love.Source? --- @field to love.Source?
audio = {} audio = {}
@ -25,11 +24,11 @@ local function new(musicVolume, soundVolume)
end end
function audio:update(dt) function audio:update(dt)
if not self.animationNode then return end if self.fader then
self.from:setVolume(self.musicVolume - self.animationNode:getValue() * self.musicVolume) local t = self.fader.value
self.to:setVolume(self.animationNode:getValue() * self.musicVolume) if self.from then self.from:setVolume(self.musicVolume * (1 - t)) end
self.animationNode:update(dt) if self.to then self.to:setVolume(self.musicVolume * t) end
-- print(self.animationNode.t) end
end end
--- if from is nil, than we have fade in to; --- if from is nil, than we have fade in to;
@ -41,23 +40,32 @@ end
--- @param ms number? in milliseconds --- @param ms number? in milliseconds
function audio:crossfade(from, to, ms) function audio:crossfade(from, to, ms)
print("[Audio]: Triggered crossfade") 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) self:play(to)
to:setVolume(0) to:setVolume(0)
self.from = from self.from = from
self.to = to self.to = to
self.animationNode = AnimationNode {
function(node) end, -- Reuse fader object to allow task cancellation
onEnd = function() 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) self.from:setVolume(0)
self.to:setVolume(self.musicVolume)
self.from:stop() self.from:stop()
self.animationNode = nil end
if self.to then
self.to:setVolume(self.musicVolume)
end
self.fader = nil
print("[Audio]: Crossfade done") print("[Audio]: Crossfade done")
end, end)
duration = ms or 1000,
easing = ease.easeOutCubic,
}
self.animationNode:run()
end end
--- @param source love.Source --- @param source love.Source

View File

@ -0,0 +1,57 @@
local easing = require "lib.utils.easing"
local function closestCharacter(char)
local caster = Vec3 {}
char:try(Tree.behaviors.positioned, function(b)
caster = b.position
end)
local charTarget
local minDist = 88005553535 -- spooky magic number
for k, v in pairs(Tree.level.characters) do
v:try(Tree.behaviors.positioned, function(b)
local dist = ((caster.x - b.position.x) ^ 2 + (caster.y - b.position.y) ^ 2) ^ 0.5
if dist < minDist and dist ~= 0 then
minDist = dist
charTarget = v
end
-- print(k, b.position)
end)
end
return charTarget
end
--- @class AIBehavior : Behavior
--- @field target Vec3?
local behavior = {}
behavior.__index = behavior
behavior.id = "ai"
function behavior.new()
return setmetatable({}, behavior)
end
--- @return Task<nil>
function behavior:makeTurn()
return function(callback) -- почему так, описано в Task
self.owner:try(Tree.behaviors.spellcaster, function(spellB)
local charTarget = closestCharacter(self.owner)
charTarget:try(Tree.behaviors.positioned, function(b)
self.target = Vec3 { b.position.x, b.position.y + 1 } --- @todo тут захардкожено + 1, но мы должны как-то хитро определять с какой стороны обойти
end)
spellB.spellbook[1]:cast(self.owner, self.target)(function()
-- здесь мы оказываемся после того, как сходили в первый раз
print("[AI]: finished move 1")
local newTarget = Vec3 { 1, 1 }
-- поэтому позиция персонажа для нового каста пересчитается динамически
spellB.spellbook[1]:cast(self.owner, newTarget)(function()
print("[AI]: finished move 2")
-- дергаем функцию после завершения хода
callback()
end)
end)
end)
end
end
return behavior

View File

@ -1,10 +1,10 @@
local task = require "lib.utils.task"
--- @class LightBehavior : Behavior --- @class LightBehavior : Behavior
--- @field intensity number --- @field intensity number
--- @field color Vec3 --- @field color Vec3
--- @field seed integer --- @field seed integer
--- @field colorAnimationNode? AnimationNode --- @field private animateColorTask? Task
--- @field targetColor? Vec3
--- @field sourceColor? Vec3
local behavior = {} local behavior = {}
behavior.__index = behavior behavior.__index = behavior
behavior.id = "light" behavior.id = "light"
@ -20,17 +20,12 @@ function behavior.new(values)
end end
function behavior:update(dt) function behavior:update(dt)
if not self.colorAnimationNode then return end -- All logic moved to tasks
local delta = self.targetColor - self.sourceColor
self.color = self.sourceColor + delta * self.colorAnimationNode:getValue()
self.colorAnimationNode:update(dt)
end end
function behavior:animateColor(targetColor, animationNode) function behavior:animateColor(targetColor, duration, easing)
if self.colorAnimationNode then self.colorAnimationNode:finish() end -- If there's support for canceling tasks, we should do it here
self.colorAnimationNode = animationNode return task.tween(self, { color = targetColor }, duration or 800, easing)
self.sourceColor = self.color
self.targetColor = targetColor
end end
function behavior:draw() function behavior:draw()

View File

@ -1,21 +1,37 @@
--- Умеет асинхронно ждать какое-то время (для анимаций) --- Умеет асинхронно ждать какое-то время (для анимаций)
--- @class ResidentSleeperBehavior : Behavior --- @class ResidentSleeperBehavior : Behavior
--- @field animationNode? AnimationNode --- @field private t0 number?
--- @field private sleepTime number?
--- @field private callback voidCallback?
--- @field private state 'running' | 'finished'
local behavior = {} local behavior = {}
behavior.__index = behavior behavior.__index = behavior
behavior.id = "residentsleeper" behavior.id = "residentsleeper"
function behavior.new() return setmetatable({}, behavior) end function behavior.new() return setmetatable({}, behavior) end
function behavior:update(dt) function behavior:update(_)
if not self.animationNode then return end if self.state ~= 'running' then return end
self.animationNode:update(dt)
local t = love.timer.getTime()
if t >= self.t0 + self.sleepTime then
self.state = 'finished'
self.callback()
end
end end
--- @param node AnimationNode --- @return Task<nil>
function behavior:sleep(node) function behavior:sleep(ms)
if self.animationNode then self.animationNode:finish() end self.sleepTime = ms / 1000
self.animationNode = node return function(callback)
if self.state == 'running' then
self.callback()
end
self.t0 = love.timer.getTime()
self.callback = callback
self.state = 'running'
end
end end
return behavior return behavior

View File

@ -69,18 +69,21 @@ function sprite:draw()
) )
end end
--- @param node AnimationNode --- @return Task<nil>
function sprite:animate(state, node) function sprite:animate(state)
return function(callback)
if not self.animationGrid[state] then if not self.animationGrid[state] then
return print("[SpriteBehavior]: no animation for '" .. state .. "'") print("[SpriteBehavior]: no animation for '" .. state .. "'")
callback()
end end
self.animationTable[state] = anim8.newAnimation(self.animationGrid[state], self.ANIMATION_SPEED, self.animationTable[state] = anim8.newAnimation(self.animationGrid[state], self.ANIMATION_SPEED,
function() function()
self:loop("idle") self:loop("idle")
node:finish() callback()
end) end)
self.state = state self.state = state
end end
end
function sprite:loop(state) function sprite:loop(state)
if not self.animationGrid[state] then if not self.animationGrid[state] then

View File

@ -1,12 +1,7 @@
local utils = require "lib.utils.utils" local task = require "lib.utils.task"
--- Отвечает за перемещение по тайлам --- Отвечает за перемещение по тайлам
--- @class TiledBehavior : Behavior --- @class TiledBehavior : Behavior
--- @field private runSource? Vec3 точка, из которой бежит персонаж
--- @field private runTarget? Vec3 точка, в которую в данный момент бежит персонаж
--- @field private path? Deque путь, по которому сейчас бежит персонаж
--- @field private animationNode? AnimationNode AnimationNode, с которым связана анимация перемещения
--- @field private t0 number время начала движения
--- @field size Vec3 --- @field size Vec3
local behavior = {} local behavior = {}
behavior.__index = behavior behavior.__index = behavior
@ -20,29 +15,35 @@ function behavior.new(size)
end end
--- @param path Deque --- @param path Deque
--- @param animationNode AnimationNode --- @return Task<nil>
function behavior:followPath(path, animationNode) function behavior:followPath(path)
if path:is_empty() then return animationNode:finish() end if path:is_empty() then return task.fromValue(nil) end
self.animationNode = animationNode
self.owner:try(Tree.behaviors.sprite, function(sprite) self.owner:try(Tree.behaviors.sprite, function(sprite)
sprite:loop("run") sprite:loop("run")
end) end)
self.path = path;
---@type Vec3 -- Рекурсивная функция для прохода по пути
local nextCell = path:peek_front() local function nextStep()
self:runTo(nextCell) if path:is_empty() then
path:pop_front() 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 end
--- @param target Vec3 --- @param target Vec3
--- @return Task<nil>
function behavior:runTo(target) function behavior:runTo(target)
local positioned = self.owner:has(Tree.behaviors.positioned) local positioned = self.owner:has(Tree.behaviors.positioned)
if not positioned then return end if not positioned then return task.fromValue(nil) end
self.t0 = love.timer.getTime()
self.runTarget = target
self.runSource = positioned.position
self.owner:try(Tree.behaviors.sprite, self.owner:try(Tree.behaviors.sprite,
function(sprite) function(sprite)
@ -53,31 +54,15 @@ function behavior:runTo(target)
end end
end end
) )
local distance = target:subtract(positioned.position):length()
local duration = distance * 500 -- 500ms per unit
return task.tween(positioned, { position = target }, duration)
end end
function behavior:update(dt) function behavior:update(dt)
if self.runTarget then -- Logic moved to tasks
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.animationNode then self.animationNode:finish() end
end
else -- анимация перемещения не завершена
positioned.position = utils.lerp(self.runSource, self.runTarget, fraction) -- линейный интерполятор
end
end
end end
return behavior return behavior

View File

@ -1,5 +1,7 @@
local Vec3 = require "lib.utils.vec3" local Vec3 = require "lib.utils.vec3"
local utils = require "lib.utils.utils" local utils = require "lib.utils.utils"
local task = require "lib.utils.task"
local easing = require "lib.utils.easing"
local EPSILON = 0.001 local EPSILON = 0.001
@ -9,9 +11,6 @@ local EPSILON = 0.001
--- @field speed number --- @field speed number
--- @field pixelsPerMeter integer --- @field pixelsPerMeter integer
--- @field scale number --- @field scale number
--- @field animationNode AnimationNode?
--- @field animationEndPosition Vec3
--- @field animationBeginPosition Vec3
local camera = { local camera = {
position = Vec3 {}, position = Vec3 {},
velocity = Vec3 {}, velocity = Vec3 {},
@ -38,12 +37,6 @@ local controlMap = {
} }
function camera:update(dt) 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 local y = Tree.controls.mouseWheelY
if camera.scale > camera:getDefaultScale() * 5 and y > 0 then return end; if camera.scale > camera:getDefaultScale() * 5 and y > 0 then return end;
@ -97,14 +90,14 @@ function camera:detach()
love.graphics.pop() love.graphics.pop()
end end
--- Плавно перемещает камеру к указанной точке.
--- @param position Vec3 --- @param position Vec3
--- @param animationNode AnimationNode --- @param duration number?
function camera:animateTo(position, animationNode) --- @param easing function?
if self.animationNode and self.animationNode.state ~= "finished" then self.animationNode:finish() end --- @return Task
self.animationNode = animationNode function camera:animateTo(position, duration, easing)
self.animationEndPosition = position self.velocity = Vec3 {} -- Сбрасываем инерцию перед началом анимации
self.animationBeginPosition = self.position return task.tween(self, { position = position }, duration or 1000, easing)
self.velocity = Vec3 {}
end end
--- @return Camera --- @return Camera

View File

@ -40,6 +40,7 @@ end
function level:update(dt) function level:update(dt)
utils.each(self.deadIds, function(id) utils.each(self.deadIds, function(id)
self.characters[id] = nil self.characters[id] = nil
self.turnOrder:remove(id)
end) end)
self.deadIds = {} self.deadIds = {}

View File

@ -37,9 +37,16 @@ function selector:update(dt)
if not selectedId then self:select(nil) end if not selectedId then self:select(nil) end
return return
end end
if b.cast:cast(char, mousePosition) then local task = b.cast:cast(char, mousePosition) -- в task функция, которая запускает анимацию спелла
if task then
self:lock() self:lock()
b.state = "running" b.state = "running"
task(
function(_) -- это коллбэк, который сработает по окончании анимации спелла
b:endCast()
end
)
end end
end) end)
end end

View File

@ -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 initiativeComparator = function(id_a, id_b)
local res = Tree.level.characters[id_a]:try(Tree.behaviors.stats, function(astats) local res = Tree.level.characters[id_a]:try(Tree.behaviors.stats, function(astats)
@ -29,11 +30,31 @@ end
--- Перемещаем активного персонажа в очередь сходивших --- Перемещаем активного персонажа в очередь сходивших
--- ---
--- Если в очереди на ход больше никого нет, заканчиваем раунд --- Если в очереди на ход больше никого нет, заканчиваем раунд
---
--- Анимируем камеру к следующему персонажу. Если это ИИ, то активируем его логику.
function turnOrder:next() function turnOrder:next()
self.actedQueue:insert(self.current) self.actedQueue:insert(self.current)
local next = self.pendingQueue:peek() local next = self.pendingQueue:peek()
if not next then return self:endRound() end if not next then self:endRound() else self.current = self.pendingQueue:pop() end
self.current = self.pendingQueue:pop()
local char = Tree.level.characters[self.current]
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 end
--- Меняем местами очередь сходивших и не сходивших (пустую) --- Меняем местами очередь сходивших и не сходивших (пустую)
@ -108,4 +129,29 @@ function turnOrder:add(id)
self.actedQueue:insert(id) -- новые персонажи по умолчанию попадают в очередь следующего хода self.actedQueue:insert(id) -- новые персонажи по умолчанию попадают в очередь следующего хода
end end
--- Удалить персонажа из очереди хода (например, при смерти)
--- @param id Id
function turnOrder:remove(id)
if self.current == id then
self.current = self.pendingQueue:pop()
if not self.current then
self:endRound()
end
return
end
local function filterQueue(q, targetId)
local newQ = PriorityQueue.new(initiativeComparator)
for _, val in ipairs(q.data) do
if val ~= targetId then
newQ:insert(val)
end
end
return newQ
end
self.actedQueue = filterQueue(self.actedQueue, id)
self.pendingQueue = filterQueue(self.pendingQueue, id)
end
return { new = new } return { new = new }

View File

@ -1,5 +1,5 @@
local task = require "lib.utils.task"
local easing = require "lib.utils.easing" local easing = require "lib.utils.easing"
local AnimationNode = require "lib.animation_node"
local Element = require "lib.simple_ui.element" local Element = require "lib.simple_ui.element"
local Rect = require "lib.simple_ui.rect" local Rect = require "lib.simple_ui.rect"
local SkillRow = require "lib.simple_ui.level.skill_row" 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" local EndTurnButton = require "lib.simple_ui.level.end_turn"
--- @class CharacterPanel : UIElement --- @class CharacterPanel : UIElement
--- @field animationNode AnimationNode --- @field animationTask Task
--- @field alpha number
--- @field state "show" | "idle" | "hide" --- @field state "show" | "idle" | "hide"
--- @field skillRow SkillRow --- @field skillRow SkillRow
--- @field bars BottomBars --- @field bars BottomBars
@ -21,41 +22,26 @@ function characterPanel.new(characterId)
t.skillRow = SkillRow(characterId) t.skillRow = SkillRow(characterId)
t.bars = Bars(characterId) t.bars = Bars(characterId)
t.endTurnButton = EndTurnButton {} t.endTurnButton = EndTurnButton {}
t.alpha = 0 -- starts hidden/animating
return setmetatable(t, characterPanel) return setmetatable(t, characterPanel)
end end
function characterPanel:show() function characterPanel:show()
AnimationNode {
function(animationNode)
if self.animationNode then self.animationNode:finish() end
self.animationNode = animationNode
self.state = "show" self.state = "show"
end, self.animationTask = task.tween(self, { alpha = 1 }, 300, easing.easeOutCubic)
duration = 300, self.animationTask(function() self.state = "idle" end)
onEnd = function()
self.state = "idle"
end,
easing = easing.easeOutCubic
}:run()
end end
function characterPanel:hide() function characterPanel:hide()
AnimationNode {
function(animationNode)
if self.animationNode then self.animationNode:finish() end
self.animationNode = animationNode
self.state = "hide" self.state = "hide"
end, self.animationTask = task.tween(self, { alpha = 0 }, 300, easing.easeOutCubic)
duration = 300,
easing = easing.easeOutCubic
}:run()
end end
--- @type love.Canvas --- @type love.Canvas
local characterPanelCanvas; local characterPanelCanvas;
function characterPanel:update(dt) 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.skillRow:update(dt)
self.bars.bounds = Rect { self.bars.bounds = Rect {
width = self.skillRow.bounds.width, width = self.skillRow.bounds.width,
@ -82,14 +68,8 @@ function characterPanel:update(dt)
end 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 local revealShader = Tree.assets.files.shaders.reveal
revealShader:send("t", alpha) revealShader:send("t", self.alpha)
end end
function characterPanel:draw() function characterPanel:draw()

View File

@ -1,5 +1,5 @@
local Element = require "lib.simple_ui.element" 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" local easing = require "lib.utils.easing"
--- @class EndTurnButton : UIElement --- @class EndTurnButton : UIElement
@ -46,19 +46,6 @@ end
function endTurnButton:onClick() function endTurnButton:onClick()
Tree.level.turnOrder:next() 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() Tree.level.selector:select(cid) end
}:run()
end end
return function(values) return function(values)

View File

@ -7,14 +7,13 @@
--- --TODO: каждый каст должен возвращать объект, который позволит отследить момент завершения анимации спелла --- --TODO: каждый каст должен возвращать объект, который позволит отследить момент завершения анимации спелла
--- Да, это Future/Promise/await/async --- Да, это Future/Promise/await/async
local AnimationNode = require "lib.animation_node" local task = require 'lib.utils.task'
local easing = require "lib.utils.easing"
--- @class Spell Здесь будет много бойлерплейта, поэтому тоже понадобится спеллмейкерский фреймворк, который просто вернет готовый Spell --- @class Spell Здесь будет много бойлерплейта, поэтому тоже понадобится спеллмейкерский фреймворк, который просто вернет готовый Spell
--- @field tag string --- @field tag string
--- @field update fun(self: Spell, caster: Character, dt: number): nil Изменяет состояние спелла --- @field update fun(self: Spell, caster: Character, dt: number): nil Изменяет состояние спелла
--- @field draw fun(self: Spell): nil Рисует превью каста, ничего не должна изменять в идеальном мире --- @field draw fun(self: Spell): nil Рисует превью каста, ничего не должна изменять в идеальном мире
--- @field cast fun(self: Spell, caster: Character, target: Vec3): boolean Вызывается в момент каста, изменяет мир. Возвращает bool в зависимости от того, получилось ли скастовать --- @field cast fun(self: Spell, caster: Character, target: Vec3): Task<nil> | nil Вызывается в момент каста, изменяет мир.
local spell = {} local spell = {}
spell.__index = spell spell.__index = spell
spell.tag = "base" spell.tag = "base"
@ -23,7 +22,7 @@ function spell:update(caster, dt) end
function spell:draw() end function spell:draw() end
function spell:cast(caster, target) return true end function spell:cast(caster, target) return end
local walk = setmetatable({ local walk = setmetatable({
--- @type Deque --- @type Deque
@ -35,30 +34,28 @@ function walk:cast(caster, target)
if not caster:try(Tree.behaviors.stats, function(stats) if not caster:try(Tree.behaviors.stats, function(stats)
return stats.mana >= 2 return stats.mana >= 2
end) then end) then
return false return
end end
local path = require "lib.pathfinder" (caster:has(Tree.behaviors.positioned).position:floor(), target) local initialPos = caster:has(Tree.behaviors.positioned).position:floor()
local path = require "lib.pathfinder" (initialPos, target)
path:pop_front() path:pop_front()
if path:is_empty() then return false end if path:is_empty() then
print("[Walk]: the path is empty", initialPos, target)
for p in path:values() do print(p) end return
end
caster:try(Tree.behaviors.stats, function(stats) caster:try(Tree.behaviors.stats, function(stats)
stats.mana = stats.mana - 2 stats.mana = stats.mana - 2
print(stats.mana)
end) end)
local sprite = caster:has(Tree.behaviors.sprite) local sprite = caster:has(Tree.behaviors.sprite)
if not sprite then return true end assert(sprite, "[Walk]", "WTF DUDE WHERE'S YOUR SPRITE")
AnimationNode { if not sprite then
function(node) return
caster:has(Tree.behaviors.tiled):followPath(path, node) end
end,
onEnd = function() caster:has(Tree.behaviors.spellcaster):endCast() end,
}:run()
return true return caster:has(Tree.behaviors.tiled):followPath(path)
end end
function walk:update(caster, dt) function walk:update(caster, dt)
@ -90,9 +87,10 @@ function regenerateMana:cast(caster, target)
stats.mana = 10 stats.mana = 10
stats.initiative = stats.initiative + 10 stats.initiative = stats.initiative + 10
end) end)
print(caster.id, "has regenerated mana and gained initiative")
local sprite = caster:has(Tree.behaviors.sprite) local sprite = caster:has(Tree.behaviors.sprite)
if not sprite then return true end if not sprite then return nil end
print(caster.id, "has regenerated mana and gained initiative")
local light = require "lib/character/character".spawn("Light Effect") local light = require "lib/character/character".spawn("Light Effect")
light:addBehavior { light:addBehavior {
@ -100,26 +98,20 @@ function regenerateMana:cast(caster, target)
Tree.behaviors.residentsleeper.new(), Tree.behaviors.residentsleeper.new(),
Tree.behaviors.positioned.new(caster:has(Tree.behaviors.positioned).position + Vec3 { 0.5, 0.5 }), Tree.behaviors.positioned.new(caster:has(Tree.behaviors.positioned).position + Vec3 { 0.5, 0.5 }),
} }
AnimationNode {
function(node)
local audioPath = Tree.assets.files.audio
sprite:animate("hurt", node)
Tree.audio:crossfade(audioPath.music.level1.battle,
audioPath.music.level1.choral, 5000)
end,
onEnd = function() caster:has(Tree.behaviors.spellcaster):endCast() end
}:run()
AnimationNode { local flash = function(callback)
function(node) light:has(Tree.behaviors.light):animateColor(Vec3 {})(
light:has(Tree.behaviors.light):animateColor(Vec3 {}, node) function()
end, light:die()
easing = easing.easeInQuad, callback()
duration = 800, end
onEnd = function() light:die() end )
}:run() end
return true return task.wait {
flash,
sprite:animate("hurt")
}
end end
local attack = setmetatable({}, spell) local attack = setmetatable({}, spell)
@ -131,7 +123,7 @@ function attack:cast(caster, target)
print("dist:", dist) print("dist:", dist)
return dist > 2 return dist > 2
end) then end) then
return false return
end end
caster:try(Tree.behaviors.stats, function(stats) caster:try(Tree.behaviors.stats, function(stats)
@ -140,7 +132,7 @@ function attack:cast(caster, target)
--- @type Character --- @type Character
local targetCharacterId = Tree.level.characterGrid:get(target) local targetCharacterId = Tree.level.characterGrid:get(target)
if not targetCharacterId or targetCharacterId == caster.id then return false end if not targetCharacterId or targetCharacterId == caster.id then return end
local targetCharacter = Tree.level.characters[targetCharacterId] local targetCharacter = Tree.level.characters[targetCharacterId]
targetCharacter:try(Tree.behaviors.stats, function(stats) targetCharacter:try(Tree.behaviors.stats, function(stats)
stats.hp = stats.hp - 4 stats.hp = stats.hp - 4
@ -148,43 +140,20 @@ function attack:cast(caster, target)
local sprite = caster:has(Tree.behaviors.sprite) local sprite = caster:has(Tree.behaviors.sprite)
local targetSprite = targetCharacter:has(Tree.behaviors.sprite) local targetSprite = targetCharacter:has(Tree.behaviors.sprite)
if not sprite or not targetSprite then return true end if not sprite or not targetSprite then return end
caster:try(Tree.behaviors.positioned, function(b) b:lookAt(target) end) caster:try(Tree.behaviors.positioned, function(b) b:lookAt(target) end)
AnimationNode { return
onEnd = function() caster:has(Tree.behaviors.spellcaster):endCast() end, task.wait {
children = { sprite:animate("attack"),
AnimationNode { task.wait {
function(node) task.chain(targetCharacter:has(Tree.behaviors.residentsleeper):sleep(200),
sprite:animate("attack", node) function() return targetSprite:animate("hurt") end
end ),
}, Tree.audio:play(Tree.assets.files.audio.sounds.hurt)
AnimationNode {
function(node)
targetCharacter:has(Tree.behaviors.residentsleeper):sleep(node)
end,
duration = 200,
children = {
AnimationNode {
function(node)
local audioPath = Tree.assets.files.audio
targetSprite:animate("hurt", node)
--- @type SourceFilter
local settings = {
type = "highpass",
volume = 1,
lowgain = 0.1
}
Tree.audio:play(audioPath.sounds.hurt, settings)
end
} }
} }
}
}
}:run()
return true
end end
---------------------------------------- ----------------------------------------
@ -199,6 +168,7 @@ local spellbook = {
function spellbook.of(list) function spellbook.of(list)
local spb = {} local spb = {}
for i, sp in ipairs(list) do for i, sp in ipairs(list) do
print(i)
spb[i] = setmetatable({}, { __index = sp }) spb[i] = setmetatable({}, { __index = sp })
end end
return spb return spb

40
lib/utils/counter.lua Normal file
View File

@ -0,0 +1,40 @@
--- @class Counter
--- @field private count integer
--- @field private onFinish fun(): nil
--- @field private isAlive boolean
--- @field push fun():nil добавить 1 к счетчику
--- @field pop fun():nil убавить 1 у счетчика
--- @field set fun(count: integer): nil установить значение на счетчике
local counter = {}
counter.__index = counter
--- @private
function counter:_push()
self.count = self.count + 1
end
--- @private
function counter:_pop()
self.count = self.count - 1
if self.count == 0 and self.isAlive then
self.isAlive = false
self.onFinish()
end
end
--- @param onFinish fun(): nil
local function new(onFinish)
local t = {
count = 0,
onFinish = onFinish,
isAlive = true,
}
t.push = function() counter._push(t) end
t.pop = function() counter._pop(t) end
t.set = function(count) t.count = count end
return setmetatable(t, counter)
end
return new

142
lib/utils/task.lua Normal file
View File

@ -0,0 +1,142 @@
local easing_lib = require "lib.utils.easing"
local lerp = require "lib.utils.utils".lerp
--- Обобщенная асинхронная функция (Task).
--- По сути это функция, принимающая коллбэк: `fun(callback: fun(value: T): nil): nil`
--- @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
--- Возвращает новый Task, который завершится после завершения всех переданных `tasks`.
--- @param tasks Task[]
--- @return Task<any[]>
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` в один.
--- @param t Task
--- @param onCompleted fun(value: any): Task
--- @return Task
function task.chain(t, onCompleted)
return function(callback)
t(function(value)
local t2 = onCompleted(value)
t2(callback)
end)
end
end
return task

View File

@ -2,6 +2,8 @@
local character = require "lib/character/character" local character = require "lib/character/character"
local testLayout local testLayout
local TestRunner = require "test.runner"
TestRunner:register(require "test.task")
function love.conf(t) function love.conf(t)
t.console = true t.console = true
@ -23,16 +25,37 @@ function love.load()
Tree.behaviors.shadowcaster.new(), Tree.behaviors.shadowcaster.new(),
Tree.behaviors.spellcaster.new() Tree.behaviors.spellcaster.new()
}, },
character.spawn("Baris") character.spawn("Foodor")
:addBehavior { :addBehavior {
Tree.behaviors.residentsleeper.new(), Tree.behaviors.residentsleeper.new(),
Tree.behaviors.stats.new(nil, nil, 1), Tree.behaviors.stats.new(nil, nil, 1),
Tree.behaviors.positioned.new(Vec3 { 5, 5 }), Tree.behaviors.positioned.new(Vec3 { 4, 3 }),
Tree.behaviors.tiled.new(), Tree.behaviors.tiled.new(),
Tree.behaviors.sprite.new(Tree.assets.files.sprites.character), Tree.behaviors.sprite.new(Tree.assets.files.sprites.character),
Tree.behaviors.shadowcaster.new(), Tree.behaviors.shadowcaster.new(),
Tree.behaviors.spellcaster.new() Tree.behaviors.spellcaster.new()
}, },
character.spawn("Foodor")
:addBehavior {
Tree.behaviors.residentsleeper.new(),
Tree.behaviors.stats.new(nil, nil, 3),
Tree.behaviors.positioned.new(Vec3 { 5, 3 }),
Tree.behaviors.tiled.new(),
Tree.behaviors.sprite.new(Tree.assets.files.sprites.character),
Tree.behaviors.shadowcaster.new(),
Tree.behaviors.spellcaster.new()
},
character.spawn("Baris")
:addBehavior {
Tree.behaviors.residentsleeper.new(),
Tree.behaviors.stats.new(nil, nil, 2),
Tree.behaviors.positioned.new(Vec3 { 5, 5 }),
Tree.behaviors.tiled.new(),
Tree.behaviors.sprite.new(Tree.assets.files.sprites.character),
Tree.behaviors.shadowcaster.new(),
Tree.behaviors.spellcaster.new(),
Tree.behaviors.ai.new()
},
} }
for id, _ in pairs(chars) do for id, _ in pairs(chars) do
@ -46,7 +69,10 @@ end
local lt = "0" local lt = "0"
function love.update(dt) function love.update(dt)
TestRunner:update(dt) -- закомментировать для отключения тестов
local t1 = love.timer.getTime() local t1 = love.timer.getTime()
require('lib.utils.task').update(dt)
Tree.controls:poll() Tree.controls:poll()
Tree.level.camera:update(dt) -- сначала логика камеры, потому что на нее завязан UI Tree.level.camera:update(dt) -- сначала логика камеры, потому что на нее завязан UI
testLayout:update(dt) -- потом UI, потому что нужно перехватить жесты и не пустить их дальше testLayout:update(dt) -- потом UI, потому что нужно перехватить жесты и не пустить их дальше

46
test/runner.lua Normal file
View File

@ -0,0 +1,46 @@
--- @class Test
local test = {}
function test:run(complete) end
function test:update(dt) end
--- @class TestRunner
--- @field private tests Test[]
--- @field private state "loading" | "running" | "completed"
--- @field private completedCount integer
local runner = {}
runner.tests = {}
runner.state = "loading"
runner.completedCount = 0
--- глобальный update для тестов, нужен для тестирования фич, зависимых от времени
function runner:update(dt)
if self.state == "loading" then
print("[TestRunner]: running " .. #self.tests .. " tests")
for _, t in ipairs(self.tests) do
t:run(
function()
self.completedCount = self.completedCount + 1
if self.completedCount == #self.tests then
self.state = "completed"
print("[TestRunner]: tests completed")
end
end
)
end
self.state = "running"
end
for _, t in ipairs(self.tests) do
if t.update then t:update(dt) end
end
end
--- добавляет тест для прохождения
--- @param t Test
function runner:register(t)
table.insert(self.tests, t)
end
return runner

75
test/task.lua Normal file
View File

@ -0,0 +1,75 @@
local task = require "lib.utils.task"
local test = {}
local t0
local task1Start, task2Start
local task1Callback, task2Callback
--- @return Task<number>
local function task1()
return function(callback)
task1Start = love.timer.getTime()
task1Callback = callback
end
end
--- @return Task<number>
local function task2()
return function(callback)
task2Start = love.timer.getTime()
task2Callback = callback
end
end
function test:run(complete)
t0 = love.timer.getTime()
task.wait {
task1(),
task2()
} (function(values)
local tWait = love.timer.getTime()
local dt = tWait - t0
local t1 = values[1]
local t2 = values[2]
assert(type(t1) == "number" and type(t2) == "number")
assert(t2 > t1)
assert(dt >= 2, "dt = " .. dt)
print("task.wait completed in " .. dt .. " sec", "t1 = " .. t1 - t0, "t2 = " .. t2 - t0)
t0 = love.timer.getTime()
task.chain(task1(), function(value)
t1 = value
assert(t1 - t0 >= 1)
return task2()
end)(
function(value)
t2 = value
assert(t2 - t0 >= 2)
print("task.chain completed in " .. t2 - t0 .. " sec")
complete()
end
)
end)
end
function test:update(dt)
local t = love.timer.getTime()
if task1Start and t - task1Start >= 1 then
task1Callback(t)
task1Start = nil
end
if task2Start and t - task2Start >= 2 then
task2Callback(t)
task2Start = nil
end
end
return test