Compare commits

..

No commits in common. "main" and "feature/shadows" have entirely different histories.

42 changed files with 412 additions and 1132 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

102
lib/animation_node.lua Normal file
View File

@ -0,0 +1,102 @@
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,6 +8,3 @@ 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,99 +0,0 @@
local task = require "lib.utils.task"
local ease = require "lib.utils.easing"
local EFFECTS_SUPPORTED = love.audio.isEffectsSupported()
--- @alias SourceFilter { type: "bandpass"|"highpass"|"lowpass", volume: number, highgain: number, lowgain: number }
--- @class Audio
--- @field musicVolume number
--- @field soundVolume number
--- @field looped boolean
--- @field from love.Source?
--- @field to love.Source?
audio = {}
audio.__index = audio
--- здесь мы должны выгружать значения из файлика с сохранением настроек
local function new(musicVolume, soundVolume)
return setmetatable({
musicVolume = musicVolume,
soundVolume = soundVolume,
looped = true
}, audio)
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
end
--- if from is nil, than we have fade in to;
--- if to is nil, than we have fade out from
---
--- also we should guarantee, that from and to have the same volume
--- @param from love.Source
--- @param to love.Source
--- @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.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
--- @param source love.Source
--- @param settings SourceFilter?
--- @param effectName string?
function audio:play(source, settings, effectName)
if source:getType() == "stream" then
source:setLooping(self.looped)
source:setVolume(self.musicVolume)
source:play()
else
source:setVolume(self.soundVolume)
source:play()
end
if settings and EFFECTS_SUPPORTED then
source.setFilter(source, settings)
end
if effectName and EFFECTS_SUPPORTED then
source:setEffect(effectName, true)
end
end
function audio:setMusicVolume(volume)
self.musicVolume = volume
end
function audio:setSoundVolume(volume)
self.soundVolume = volume
end
return { new = new }

View File

@ -1,64 +0,0 @@
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)
local task1 = spellB.spellbook[1]:cast(self.owner, self.target)
if task1 then
task1(
function()
-- здесь мы оказываемся после того, как сходили в первый раз
local newTarget = Vec3 { 1, 1 }
local task2 = spellB.spellbook[1]:cast(self.owner, newTarget)
if task2 then
-- дергаем функцию после завершения хода
task2(callback)
else
callback()
end
end
)
else
callback()
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 private animateColorTask? Task --- @field colorAnimationNode? AnimationNode
--- @field targetColor? Vec3
--- @field sourceColor? Vec3
local behavior = {} local behavior = {}
behavior.__index = behavior behavior.__index = behavior
behavior.id = "light" behavior.id = "light"
@ -20,12 +20,17 @@ function behavior.new(values)
end end
function behavior:update(dt) 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 end
function behavior:animateColor(targetColor, duration, easing) function behavior:animateColor(targetColor, animationNode)
-- If there's support for canceling tasks, we should do it here if self.colorAnimationNode then self.colorAnimationNode:finish() end
return task.tween(self, { color = targetColor }, duration or 800, easing) self.colorAnimationNode = animationNode
self.sourceColor = self.color
self.targetColor = targetColor
end end
function behavior:draw() function behavior:draw()

View File

@ -1,37 +1,21 @@
--- Умеет асинхронно ждать какое-то время (для анимаций) --- Умеет асинхронно ждать какое-то время (для анимаций)
--- @class ResidentSleeperBehavior : Behavior --- @class ResidentSleeperBehavior : Behavior
--- @field private t0 number? --- @field animationNode? AnimationNode
--- @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(_) function behavior:update(dt)
if self.state ~= 'running' then return end if not self.animationNode 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
--- @return Task<nil> --- @param node AnimationNode
function behavior:sleep(ms) function behavior:sleep(node)
self.sleepTime = ms / 1000 if self.animationNode then self.animationNode:finish() end
return function(callback) self.animationNode = node
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

@ -1,13 +1,11 @@
--- @class SpellcasterBehavior : Behavior --- @class SpellcasterBehavior : Behavior
--- @field spellbook Spell[] собственный набор спеллов персонажа --- @field spellbook Spell[] собственный набор спеллов персонажа
--- @field cast Spell | nil ссылка на активный спелл из спеллбука --- @field cast Spell | nil ссылка на активный спелл из спеллбука
--- @field cooldowns {[string]: integer} текущий кулдаун спеллов по тегам
--- @field state "idle" | "casting" | "running" --- @field state "idle" | "casting" | "running"
local behavior = {} local behavior = {}
behavior.__index = behavior behavior.__index = behavior
behavior.id = "spellcaster" behavior.id = "spellcaster"
behavior.state = "idle" behavior.state = "idle"
behavior.cooldowns = {}
---@param spellbook Spell[] | nil ---@param spellbook Spell[] | nil
---@return SpellcasterBehavior ---@return SpellcasterBehavior
@ -15,7 +13,6 @@ function behavior.new(spellbook)
local spb = require "lib.spellbook" --- @todo временное добавление ходьбы (и читов) всем персонажам local spb = require "lib.spellbook" --- @todo временное добавление ходьбы (и читов) всем персонажам
local t = {} local t = {}
t.spellbook = spellbook or spb.of { spb.walk, spb.regenerateMana, spb.attack } t.spellbook = spellbook or spb.of { spb.walk, spb.regenerateMana, spb.attack }
t.cooldowns = {}
return setmetatable(t, behavior) return setmetatable(t, behavior)
end end
@ -26,14 +23,6 @@ function behavior:endCast()
Tree.level.selector:unlock() Tree.level.selector:unlock()
end end
function behavior:processCooldowns()
local cds = {}
for tag, cd in pairs(self.cooldowns) do
cds[tag] = (cd - 1) >= 0 and cd - 1 or 0
end
self.cooldowns = cds
end
function behavior:update(dt) function behavior:update(dt)
if Tree.level.selector:deselected() then if Tree.level.selector:deselected() then
self.state = "idle" self.state = "idle"

View File

@ -69,20 +69,17 @@ function sprite:draw()
) )
end end
--- @return Task<nil> --- @param node AnimationNode
function sprite:animate(state) function sprite:animate(state, node)
return function(callback)
if not self.animationGrid[state] then if not self.animationGrid[state] then
print("[SpriteBehavior]: no animation for '" .. state .. "'") return 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")
callback() node:finish()
end) end)
self.state = state self.state = state
end
end end
function sprite:loop(state) function sprite:loop(state)

View File

@ -1,7 +1,12 @@
local task = require "lib.utils.task" local utils = require "lib.utils.utils"
--- Отвечает за перемещение по тайлам --- Отвечает за перемещение по тайлам
--- @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
@ -15,35 +20,29 @@ function behavior.new(size)
end end
--- @param path Deque --- @param path Deque
--- @return Task<nil> --- @param animationNode AnimationNode
function behavior:followPath(path) function behavior:followPath(path, animationNode)
if path:is_empty() then return task.fromValue(nil) end if path:is_empty() then return animationNode:finish() 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 function nextStep() local nextCell = path:peek_front()
if path:is_empty() then self:runTo(nextCell)
self.owner:try(Tree.behaviors.sprite, function(sprite) path:pop_front()
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 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, self.owner:try(Tree.behaviors.sprite,
function(sprite) function(sprite)
@ -54,15 +53,31 @@ 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)
-- 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.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,7 +1,5 @@
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
@ -11,6 +9,9 @@ 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 {},
@ -37,6 +38,12 @@ 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;
@ -90,14 +97,14 @@ function camera:detach()
love.graphics.pop() love.graphics.pop()
end end
--- Плавно перемещает камеру к указанной точке.
--- @param position Vec3 --- @param position Vec3
--- @param duration number? --- @param animationNode AnimationNode
--- @param easing function? function camera:animateTo(position, animationNode)
--- @return Task if self.animationNode and self.animationNode.state ~= "finished" then self.animationNode:finish() end
function camera:animateTo(position, duration, easing) self.animationNode = animationNode
self.velocity = Vec3 {} -- Сбрасываем инерцию перед началом анимации self.animationEndPosition = position
return task.tween(self, { position = position }, duration or 1000, easing) self.animationBeginPosition = self.position
self.velocity = Vec3 {}
end end
--- @return Camera --- @return Camera

View File

@ -8,7 +8,7 @@ grid.__index = grid
--- adds a value to the grid --- adds a value to the grid
--- @param value any --- @param value any
function grid:add(value) function grid:add(value)
self.__grid[tostring(value.position)] = value grid[tostring(value.position)] = value
end end
--- @param position Vec3 --- @param position Vec3

View File

@ -19,9 +19,6 @@ level.__index = level
local function new(type, template) local function new(type, template)
local size = Vec3 { 30, 30 } -- magic numbers for testing purposes only local size = Vec3 { 30, 30 } -- magic numbers for testing purposes only
print(type, template, size) print(type, template, size)
Tree.audio:play(Tree.assets.files.audio.music.level1.battle)
return setmetatable({ return setmetatable({
size = size, size = size,
characters = {}, characters = {},
@ -40,7 +37,6 @@ 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,18 +37,10 @@ function selector:update(dt)
if not selectedId then self:select(nil) end if not selectedId then self:select(nil) end
return return
end end
local task = b.cast:cast(char, mousePosition) -- в task функция, которая запускает анимацию спелла if b.cast:cast(char, mousePosition) then
if not task then return end -- не получилось скастовать
self:lock() self:lock()
b.state = "running" b.state = "running"
task(
function(_) -- это коллбэк, который сработает по окончании анимации спелла
b:endCast()
if not char:has(Tree.behaviors.ai) then self:select(char.id) end -- выделяем персонажа обратно после того, как посмотрели на каст
end end
)
end) end)
end end
end end

View File

@ -1,5 +1,4 @@
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)
@ -30,47 +29,17 @@ 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 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]
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
--- Производим действия в конце раунда
---
--- Меняем местами очередь сходивших и не сходивших (пустую) --- Меняем местами очередь сходивших и не сходивших (пустую)
function turnOrder:endRound() function turnOrder:endRound()
assert(self.pendingQueue:size() == 0, "[TurnOrder]: tried to end the round before everyone had a turn") assert(self.pendingQueue:size() == 0, "[TurnOrder]: tried to end the round before everyone had a turn")
print("[TurnOrder]: end of the round") print("[TurnOrder]: end of the round")
for _, id in ipairs(self.actedQueue.data) do
local char = Tree.level.characters[id]
char:try(Tree.behaviors.spellcaster, function(spellcaster)
spellcaster:processCooldowns()
end)
end
self.actedQueue, self.pendingQueue = self.pendingQueue, self.actedQueue self.actedQueue, self.pendingQueue = self.pendingQueue, self.actedQueue
self.current = self.pendingQueue:pop() self.current = self.pendingQueue:pop()
end end
@ -139,29 +108,4 @@ 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,52 +0,0 @@
-- --- @class Music
-- --- @field source table<string, love.Source> audio streams, that supports multitrack (kind of)
-- --- @field offset number
-- music = {}
-- music.__index = music
-- --- @param path string accepts path to dir with some music files (example: "main_ambient"; "player/theme1" and etc etc)
-- local function new(path)
-- local dir = Tree.assets.files.audio.music[path]
-- --- @type table<string, love.Source>
-- local source = {}
-- print(dir)
-- for _, v in pairs(dir) do
-- print(v.filename)
-- source[v.filename] = v.source
-- print(v.filename)
-- end
-- print('[music]: new source: ', table.concat(source, ' '))
-- return setmetatable({ source = source, offset = 0 }, music)
-- end
-- function music:update()
-- for _, v in ipairs(self.source) do
-- v:seek()
-- end
-- end
-- --- pause stemfile or music at all
-- --- @param filename? string
-- function music:pause(filename)
-- if filename then
-- self.source[filename]:pause()
-- else
-- for _, v in pairs(self.source) do
-- v:pause()
-- end
-- end
-- end
-- --- play music stemfile by his name
-- --- @param filename string
-- --- @return boolean
-- function music:play(filename)
-- print('[music]: ', table.concat(self.source, ' '))
-- self.source[filename]:seek(self.offset, "seconds")
-- return self.source[filename]:play()
-- end
-- return { new = new }

View File

@ -57,7 +57,7 @@ function barElement:draw()
love.graphics.setColor(1, 1, 1) love.graphics.setColor(1, 1, 1)
--- текст поверх --- текст поверх
if self.drawText then if self.drawText then
local font = Tree.fonts:getDefaultTheme():getVariant("small") local font = Tree.fonts:getDefaultTheme():getVariant("medium")
local t = love.graphics.newText(font, tostring(self.value) .. "/" .. tostring(self.maxValue)) local t = love.graphics.newText(font, tostring(self.value) .. "/" .. tostring(self.maxValue))
love.graphics.draw(t, math.floor(self.bounds.x + self.bounds.width / 2 - t:getWidth() / 2), love.graphics.draw(t, math.floor(self.bounds.x + self.bounds.width / 2 - t:getWidth() / 2),
math.floor(self.bounds.y + self.bounds.height / 2 - t:getHeight() / 2)) math.floor(self.bounds.y + self.bounds.height / 2 - t:getHeight() / 2))

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,8 +7,7 @@ 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 animationTask Task --- @field animationNode AnimationNode
--- @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
@ -22,26 +21,41 @@ 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"
self.animationTask = task.tween(self, { alpha = 1 }, 300, easing.easeOutCubic) end,
self.animationTask(function() self.state = "idle" end) duration = 300,
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"
self.animationTask = task.tween(self, { alpha = 0 }, 300, easing.easeOutCubic) end,
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)
-- Tasks update automatically via task.update(dt) in main.lua if self.animationNode then self.animationNode:update(dt) end
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,
@ -68,8 +82,14 @@ 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", self.alpha) revealShader:send("t", 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 task = require "lib.utils.task" local AnimationNode = require "lib.animation_node"
local easing = require "lib.utils.easing" local easing = require "lib.utils.easing"
--- @class EndTurnButton : UIElement --- @class EndTurnButton : UIElement
@ -22,7 +22,7 @@ function endTurnButton:update(dt)
end end
function endTurnButton:layout() function endTurnButton:layout()
local font = Tree.fonts:getDefaultTheme():getVariant("large") local font = Tree.fonts:getDefaultTheme():getVariant("headline")
self.text = love.graphics.newText(font, "Завершить ход") self.text = love.graphics.newText(font, "Завершить ход")
self.bounds.width = self.text:getWidth() + 32 self.bounds.width = self.text:getWidth() + 32
self.bounds.height = self.text:getHeight() + 16 self.bounds.height = self.text:getHeight() + 16
@ -46,6 +46,19 @@ 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

@ -8,7 +8,6 @@ local UI_SCALE = require "lib.simple_ui.level.scale"
--- @field hovered boolean --- @field hovered boolean
--- @field selected boolean --- @field selected boolean
--- @field onClick function? --- @field onClick function?
--- @field getCooldown function?
--- @field icon? string --- @field icon? string
local skillButton = setmetatable({}, Element) local skillButton = setmetatable({}, Element)
skillButton.__index = skillButton skillButton.__index = skillButton
@ -19,11 +18,7 @@ function skillButton:update(dt)
if self:hitTest(mx, my) then if self:hitTest(mx, my) then
self.hovered = true self.hovered = true
if Tree.controls:isJustPressed("select") then if Tree.controls:isJustPressed("select") then
local cd = self.getCooldown and self.getCooldown() or 0
if cd == 0 then
if self.onClick then self.onClick() end if self.onClick then self.onClick() end
end
Tree.controls:consume("select") Tree.controls:consume("select")
end end
else else
@ -34,7 +29,6 @@ end
function skillButton:draw() function skillButton:draw()
love.graphics.setLineWidth(2) love.graphics.setLineWidth(2)
local cd = self.getCooldown and self.getCooldown() or 0
if not self.icon then if not self.icon then
love.graphics.setColor(0.05, 0.05, 0.05) love.graphics.setColor(0.05, 0.05, 0.05)
@ -59,25 +53,6 @@ function skillButton:draw()
love.graphics.setColor(0.7, 1, 0.7, 0.5) love.graphics.setColor(0.7, 1, 0.7, 0.5)
love.graphics.rectangle("fill", self.bounds.x, self.bounds.y, self.bounds.width, self.bounds.height) love.graphics.rectangle("fill", self.bounds.x, self.bounds.y, self.bounds.width, self.bounds.height)
end end
if cd > 0 then
love.graphics.setColor(0, 0, 0, 0.5)
love.graphics.rectangle("fill", self.bounds.x, self.bounds.y, self.bounds.width, self.bounds.height)
local font = Tree.fonts:getDefaultTheme():getVariant("headline")
love.graphics.setColor(0, 0, 0)
local t = love.graphics.newText(font, tostring(cd))
love.graphics.draw(t, math.floor(self.bounds.x + 2 + self.bounds.width / 2 - t:getWidth() / 2),
math.floor(self.bounds.y + 2 + self.bounds.height / 2 - t:getHeight() / 2))
love.graphics.setColor(1, 1, 1)
love.graphics.draw(t, math.floor(self.bounds.x + self.bounds.width / 2 - t:getWidth() / 2),
math.floor(self.bounds.y + self.bounds.height / 2 - t:getHeight() / 2))
else
end
love.graphics.setColor(1, 1, 1) love.graphics.setColor(1, 1, 1)
end end
@ -117,9 +92,6 @@ function skillRow.new(characterId)
behavior.cast = nil behavior.cast = nil
end end
end end
skb.getCooldown = function()
return behavior.cooldowns[spell.tag] or 0
end
t.children[i] = skb t.children[i] = skb
end end
end) end)

View File

@ -1,9 +0,0 @@
-- --- @class Sound
-- --- @field source love.Source just a sound
-- sound = {}
-- local function new()
-- return setmetatable({}, sound)
-- end
-- return { new }

View File

@ -1,104 +0,0 @@
local Query = require "lib.spell.target_query"
local targetTest = require "lib.spell.target_test"
local task = require "lib.utils.task"
--- @alias SpellPreview "default" Подсветка возможных целей
--- | "path" Подсветка пути до цели
--- @class Spell
--- @field tag string
--- @field baseCost integer Базовые затраты маны на каст
--- @field baseCooldown integer Базовый кулдаун в ходах
--- @field targetQuery SpellTargetQuery Селектор возможных целей
--- @field previewType SpellPreview Вид превью во время каста
--- @field distance? integer Сторона квадрата с центром в позиции кастера, в пределах которого должна находиться цель, либо отсутствие ограничения
--- @field update fun(self: Spell, caster: Character, dt: number): nil Изменяет состояние спелла
--- @field draw fun(self: Spell): nil Рисует превью каста, ничего не должна изменять в идеальном мире
--- @field cast fun(self: Spell, caster: Character, target: Vec3): Task<nil>? Вызывается в момент каста, изменяет мир.
local spell = {}
spell.__index = spell
spell.tag = "spell_base"
spell.baseCost = 1
spell.baseCooldown = 1
spell.targetQuery = Query(targetTest.any)
spell.previewType = "default"
function spell:update(caster, dt)
if self.previewType == "path" then
local charPos = caster:has(Tree.behaviors.positioned).position:floor()
--- @type Vec3
local mpos = Tree.level.camera:toWorldPosition(Vec3 { love.mouse.getX(), love.mouse.getY() }):floor()
if self.targetQuery.test(caster, mpos) then
self.path = require "lib.pathfinder" (charPos, mpos)
else
self.path = nil
end
end
end
function spell:draw()
if self.previewType == "path" then
local path = self.path --[[@as Deque?]]
if not path then return end
--- Это отрисовка пути персонажа к мышке
Tree.level.camera:attach()
love.graphics.setCanvas(Tree.level.render.textures.overlayLayer)
love.graphics.setColor(0.6, 0.75, 0.5)
for p in path:values() do
love.graphics.circle("fill", p.x + 0.45, p.y + 0.45, 0.1)
end
love.graphics.setCanvas()
Tree.level.camera:detach()
love.graphics.setColor(1, 1, 1)
end
end
function spell:cast(caster, target) return task.fromValue() end
--- Конструктор [Spell]
--- @param data {tag: string, baseCost: integer, baseCooldown: integer, targetQuery: SpellTargetQuery?, previewType: SpellPreview?, distance: integer?, onCast: fun(caster: Character, target: Vec3): Task<nil>?}
--- @return Spell
function spell.new(data)
local newSpell = setmetatable({
tag = data.tag,
baseCost = data.baseCost,
baseCooldown = data.baseCooldown,
targetQuery = data.targetQuery,
previewType = data.previewType,
distance = data.distance
}, spell)
newSpell.targetQuery = newSpell.distance
and newSpell.targetQuery:intersect(Query(targetTest.distance(newSpell.distance)))
or newSpell.targetQuery
function newSpell:cast(caster, target)
if caster:try(Tree.behaviors.spellcaster, function(spellcaster) -- проверка на кулдаун
return (spellcaster.cooldowns[self.tag] or 0) > 0
end) then
return
end
if not self.targetQuery.test(caster, target) then return end -- проверка корректности цели
-- проверка на достаточное количество маны
if caster:try(Tree.behaviors.stats, function(stats)
return stats.mana < self.baseCost
end) then
return
end
caster:try(Tree.behaviors.stats, function(stats)
stats.mana = stats.mana - self.baseCost
end)
caster:try(Tree.behaviors.spellcaster, function(spellcaster)
spellcaster.cooldowns[self.tag] = self.baseCooldown
end)
return data.onCast(caster, target)
end
return newSpell
end
return spell

View File

@ -1,67 +0,0 @@
--- Тип, отвечающий за выбор и фильтрацию подходящих тайлов как цели спелла
--- теория множеств my beloved?
--- @class SpellTargetQuery
local query = {}
query.__index = query
--- Проверяет координаты на соответствие внутреннему условию
--- @param caster Character
--- @param position Vec3
--- @return boolean
function query.test(caster, position)
return true
end
--- Объединение
--- @param q SpellTargetQuery
function query:join(q)
return setmetatable({
test = function(caster, pos)
return self.test(caster, pos) or q.test(caster, pos)
end
}, query)
end
--- Пересечение
--- @param q SpellTargetQuery
function query:intersect(q)
return setmetatable({
test = function(caster, pos)
return self.test(caster, pos) and q.test(caster, pos)
end
}, query)
end
--- Исключение (не коммутативное, "те, что есть в query, но нет в q")
--- @param q SpellTargetQuery
function query:exclude(q)
return setmetatable({
test = function(caster, pos)
return self.test(caster, pos) and not q.test(caster, pos)
end
}, query)
end
--- Находит все соответствующие условиям координаты тайлов и возвращает их в виде списка
--- @param caster Character
--- @return Vec3[]
function query:asSet(caster)
--- @TODO: оптимизировать и брать не всю карту для выборки
local res = {}
for _, tile in pairs(Tree.level.tileGrid) do
if self.test(caster, tile.position) then
table.insert(res, tile.position)
end
end
return res
end
--- @param test SpellTargetTest
local function new(test)
return setmetatable({
test = test
}, query)
end
return new

View File

@ -1,27 +0,0 @@
--- @alias SpellTargetTest fun(caster: Character, targetPosition: Vec3) : boolean
return {
-- любой тайл
any = function() return true end,
-- тайл, где находится кастующий
caster = function(caster, targetPosition)
local targetCharacterId = Tree.level.characterGrid:get(targetPosition)
return caster.id == targetCharacterId
end,
-- тайл, где находится любой персонаж
character = function(caster, targetPosition)
local targetCharacterId = Tree.level.characterGrid:get(targetPosition)
return not not targetCharacterId
end,
-- тайл в пределах окружности в нашей кривой метрике
--- @param radius number
distance = function(radius)
return function(caster, targetPosition)
return caster:try(Tree.behaviors.positioned, function(p)
local dist = math.max(math.abs(p.position.x - targetPosition.x),
math.abs(p.position.y - targetPosition.y))
return dist <= radius
end)
end
end
}

View File

@ -7,80 +7,135 @@
--- --TODO: каждый каст должен возвращать объект, который позволит отследить момент завершения анимации спелла --- --TODO: каждый каст должен возвращать объект, который позволит отследить момент завершения анимации спелла
--- Да, это Future/Promise/await/async --- Да, это Future/Promise/await/async
local task = require 'lib.utils.task' local AnimationNode = require "lib.animation_node"
local spell = require 'lib.spell.spell'
local targetTest = require 'lib.spell.target_test'
local Query = require "lib.spell.target_query"
local easing = require "lib.utils.easing" local easing = require "lib.utils.easing"
local walk = spell.new { --- @class Spell Здесь будет много бойлерплейта, поэтому тоже понадобится спеллмейкерский фреймворк, который просто вернет готовый Spell
tag = "dev_move", --- @field tag string
previewType = "path", --- @field update fun(self: Spell, caster: Character, dt: number): nil Изменяет состояние спелла
baseCooldown = 1, --- @field draw fun(self: Spell): nil Рисует превью каста, ничего не должна изменять в идеальном мире
baseCost = 2, --- @field cast fun(self: Spell, caster: Character, target: Vec3): boolean Вызывается в момент каста, изменяет мир. Возвращает bool в зависимости от того, получилось ли скастовать
targetQuery = Query(targetTest.any):exclude(Query(targetTest.character)), local spell = {}
distance = 3, spell.__index = spell
onCast = function(caster, target) spell.tag = "base"
local initialPos = caster:has(Tree.behaviors.positioned).position:floor()
local path = require "lib.pathfinder" (initialPos, target) function spell:update(caster, dt) end
path:pop_front()
if path:is_empty() then function spell:draw() end
print("[Walk]: the path is empty", initialPos, target)
return function spell:cast(caster, target) return true end
local walk = setmetatable({
--- @type Deque
path = nil
}, spell)
walk.tag = "dev_move"
function walk:cast(caster, target)
if not caster:try(Tree.behaviors.stats, function(stats)
return stats.mana >= 2
end) then
return false
end end
local path = self.path
path:pop_front()
if path:is_empty() then return false end
for p in path:values() do print(p) end
caster:try(Tree.behaviors.stats, function(stats)
stats.mana = stats.mana - 2
print(stats.mana)
end)
local sprite = caster:has(Tree.behaviors.sprite) local sprite = caster:has(Tree.behaviors.sprite)
assert(sprite, "[Walk]", "WTF DUDE WHERE'S YOUR SPRITE") if not sprite then return true end
if not sprite then AnimationNode {
return function(node) caster:has(Tree.behaviors.tiled):followPath(path, node) end,
end onEnd = function() caster:has(Tree.behaviors.spellcaster):endCast() end,
}:run()
return caster:has(Tree.behaviors.tiled):followPath(path) return true
end end
}
local regenerateMana = spell.new { function walk:update(caster, dt)
tag = "dev_mana", local charPos = caster:has(Tree.behaviors.positioned).position:floor()
baseCooldown = 2, --- @type Vec3
baseCost = 0, local mpos = Tree.level.camera:toWorldPosition(Vec3 { love.mouse.getX(), love.mouse.getY() }):floor()
targetQuery = Query(targetTest.caster), self.path = require "lib.pathfinder" (charPos, mpos)
distance = 0, end
onCast = function(caster, target)
function walk:draw()
if not self.path then return end
--- Это отрисовка пути персонажа к мышке
Tree.level.camera:attach()
love.graphics.setCanvas(Tree.level.render.textures.overlayLayer)
love.graphics.setColor(0.6, 0.75, 0.5)
for p in self.path:values() do
love.graphics.circle("fill", p.x + 0.45, p.y + 0.45, 0.1)
end
love.graphics.setCanvas()
Tree.level.camera:detach()
love.graphics.setColor(1, 1, 1)
end
local regenerateMana = setmetatable({}, spell)
regenerateMana.tag = "dev_mana"
function regenerateMana:cast(caster, target)
caster:try(Tree.behaviors.stats, function(stats) caster:try(Tree.behaviors.stats, function(stats)
stats.mana = 10 stats.mana = 10
stats.initiative = stats.initiative + 10 stats.initiative = stats.initiative + 10
end) end)
local sprite = caster:has(Tree.behaviors.sprite)
if not sprite then return end
print(caster.id, "has regenerated mana and gained initiative") print(caster.id, "has regenerated mana and gained initiative")
local sprite = caster:has(Tree.behaviors.sprite)
if not sprite then return true end
local light = require "lib/character/character".spawn("Light Effect") local light = require "lib/character/character".spawn("Light Effect")
light:addBehavior { light:addBehavior {
Tree.behaviors.light.new { color = Vec3 { 0.3, 0.3, 0.6 }, intensity = 4 }, Tree.behaviors.light.new { color = Vec3 { 0.6, 0.3, 0.3 }, intensity = 4 },
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)
sprite:animate("hurt", node)
end,
onEnd = function() caster:has(Tree.behaviors.spellcaster):endCast() end
}:run()
return task.wait { AnimationNode {
task.chain(task.tween(light:has(Tree.behaviors.light) --[[@as LightBehavior]], function(node)
{ intensity = 1, color = Vec3 {} }, 800, easing.easeInCubic), function() light:has(Tree.behaviors.light):animateColor(Vec3 {}, node)
light:die() end,
return task.fromValue() easing = easing.easeInQuad,
end), duration = 800,
sprite:animate("hurt") onEnd = function() light:die() end
} }:run()
return true
end
local attack = setmetatable({}, spell)
attack.tag = "dev_attack"
function attack:cast(caster, target)
if caster:try(Tree.behaviors.positioned, function(p)
local dist = math.max(math.abs(p.position.x - target.x), math.abs(p.position.y - target.y))
print("dist:", dist)
return dist > 2
end) then
return false
end end
}
local attack = spell.new { caster:try(Tree.behaviors.stats, function(stats)
tag = "dev_attack", stats.mana = stats.mana - 2
baseCooldown = 1, end)
baseCost = 2,
targetQuery = Query(targetTest.character):exclude(Query(targetTest.caster)),
distance = 1,
onCast = function(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
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
@ -88,38 +143,36 @@ local attack = spell.new {
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 end if not sprite or not targetSprite then return true end
caster:try(Tree.behaviors.positioned, function(b) b:lookAt(target) end) caster:try(Tree.behaviors.positioned, function(b) b:lookAt(target) end)
return AnimationNode {
task.wait { onEnd = function() caster:has(Tree.behaviors.spellcaster):endCast() end,
sprite:animate("attack"), children = {
task.wait { AnimationNode {
task.chain(targetCharacter:has(Tree.behaviors.residentsleeper):sleep(500), function(node)
function() sprite:animate("attack", node)
local light = require "lib/character/character".spawn("Light Effect")
light:addBehavior {
Tree.behaviors.light.new { color = Vec3 { 0.6, 0.3, 0.3 }, intensity = 4 },
Tree.behaviors.positioned.new(targetCharacter:has(Tree.behaviors.positioned).position + Vec3 { 0.5, 0.5 }),
}
return
task.wait {
task.chain(task.tween(light:has(Tree.behaviors.light) --[[@as LightBehavior]],
{ intensity = 1, color = Vec3 {} }, 1000, easing.easeInCubic), function()
light:die()
return task.fromValue()
end),
targetSprite:animate("hurt")
}
end end
), },
AnimationNode {
function(node)
targetCharacter:has(Tree.behaviors.residentsleeper):sleep(node)
end,
duration = 200,
children = {
AnimationNode {
function(node)
targetSprite:animate("hurt", node)
end
}
}
}
}
}:run()
Tree.audio:play(Tree.assets.files.audio.sounds.hurt) return true
} end
}
end
}
---------------------------------------- ----------------------------------------
local spellbook = { local spellbook = {
@ -133,7 +186,6 @@ 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

View File

@ -9,11 +9,9 @@ Tree = {
Tree.fonts = (require "lib.utils.font_manager"):load("WDXL_Lubrifont_TC"):loadTheme("Roboto_Mono") -- дефолтный шрифт Tree.fonts = (require "lib.utils.font_manager"):load("WDXL_Lubrifont_TC"):loadTheme("Roboto_Mono") -- дефолтный шрифт
Tree.panning = require "lib/panning" Tree.panning = require "lib/panning"
Tree.controls = require "lib.controls" Tree.controls = require "lib.controls"
Tree.audio = (require "lib.audio").new(1, 1)
Tree.level = (require "lib.level.level").new("procedural", "flower_plains") -- для теста у нас только один уровень, который сразу же загружен Tree.level = (require "lib.level.level").new("procedural", "flower_plains") -- для теста у нас только один уровень, который сразу же загружен
Tree.behaviors = (require "lib.utils.behavior_loader")("lib/character/behaviors") --- @todo написать нормальную загрузку поведений Tree.behaviors = (require "lib.utils.behavior_loader")("lib/character/behaviors") --- @todo написать нормальную загрузку поведений
-- Tree.audio = (require "lib.audio").new(1, 1)
-- Tree.behaviors.map = require "lib.character.behaviors.map" -- Tree.behaviors.map = require "lib.character.behaviors.map"
-- Tree.behaviors.spellcaster = require "lib.character.behaviors.spellcaster" -- Tree.behaviors.spellcaster = require "lib.character.behaviors.spellcaster"
-- Tree.behaviors.sprite = require "lib.character.behaviors.sprite" -- Tree.behaviors.sprite = require "lib.character.behaviors.sprite"

View File

@ -50,10 +50,6 @@ function AssetBundle.loadFile(path)
return love.graphics.newShader(path); return love.graphics.newShader(path);
elseif (ext == "lua") then elseif (ext == "lua") then
return require(string.gsub(path, ".lua", "")) return require(string.gsub(path, ".lua", ""))
elseif (ext == "ogg") and string.find(path, "sounds") then
return love.audio.newSource(path, 'static')
elseif (ext == "ogg") and string.find(path, "music") then
return love.audio.newSource(path, 'stream')
end end
return filedata return filedata
end end

View File

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

View File

@ -4,11 +4,11 @@
--- @field private _sizes {[FontVariant]: integer} --- @field private _sizes {[FontVariant]: integer}
local theme = { local theme = {
_sizes = { _sizes = {
smallest = 12, smallest = 10,
small = 14, small = 12,
medium = 16, medium = 14,
large = 22, large = 16,
headline = 32, headline = 20,
} }
} }
theme.__index = theme theme.__index = theme

View File

@ -1,5 +1,5 @@
---@class PriorityQueue ---@class PriorityQueue
---@field data any[] внутренний массив-куча (индексация с 1) ---@field private data any[] внутренний массив-куча (индексация с 1)
---@field private cmp fun(a:any, b:any):boolean компаратор: true, если a выше по приоритету, чем b ---@field private cmp fun(a:any, b:any):boolean компаратор: true, если a выше по приоритету, чем b
local PriorityQueue = {} local PriorityQueue = {}
PriorityQueue.__index = PriorityQueue PriorityQueue.__index = PriorityQueue

View File

@ -1,142 +0,0 @@
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,8 +2,6 @@
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
@ -25,36 +23,15 @@ function love.load()
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, 1),
Tree.behaviors.positioned.new(Vec3 { 4, 3 }),
Tree.behaviors.tiled.new(),
Tree.behaviors.sprite.new(Tree.assets.files.sprites.character),
Tree.behaviors.shadowcaster.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") character.spawn("Baris")
:addBehavior { :addBehavior {
Tree.behaviors.residentsleeper.new(), Tree.behaviors.residentsleeper.new(),
Tree.behaviors.stats.new(nil, nil, 2), Tree.behaviors.stats.new(nil, nil, 1),
Tree.behaviors.positioned.new(Vec3 { 5, 5 }), Tree.behaviors.positioned.new(Vec3 { 5, 5 }),
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()
Tree.behaviors.ai.new()
}, },
} }
@ -69,16 +46,12 @@ 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, потому что нужно перехватить жесты и не пустить их дальше
Tree.panning:update(dt) Tree.panning:update(dt)
Tree.level:update(dt) Tree.level:update(dt)
Tree.audio:update(dt)
Tree.controls:cache() Tree.controls:cache()
@ -111,7 +84,7 @@ function love.draw()
testLayout:draw() testLayout:draw()
love.graphics.setColor(1, 1, 1) love.graphics.setColor(1, 1, 1)
love.graphics.setFont(Tree.fonts:getTheme("Roboto_Mono"):getVariant("small")) love.graphics.setFont(Tree.fonts:getTheme("Roboto_Mono"):getVariant("medium"))
local stats = "fps: " .. local stats = "fps: " ..
love.timer.getFPS() .. love.timer.getFPS() ..
" lt: " .. lt .. " dt: " .. dt .. " mem: " .. string.format("%.2f MB", collectgarbage("count") / 1000) " lt: " .. lt .. " dt: " .. dt .. " mem: " .. string.format("%.2f MB", collectgarbage("count") / 1000)

View File

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

View File

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