Compare commits

..

No commits in common. "main" and "feature-fonts" have entirely different histories.

53 changed files with 392 additions and 1376 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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 572 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View File

@ -1,18 +0,0 @@
extern vec2 direction; // (1.0, 0.0) для X, (0.0, 1.0) для Y
extern number radius; // радиус размытия
vec4 effect(vec4 vcolor, Image tex, vec2 texture_coords, vec2 screen_coords)
{
vec4 sum = vec4(0.0);
float weightTotal = 0.0;
for (int i = -10; i <= 10; i++) {
float offset = float(i);
float weight = exp(-offset * offset / (2.0 * radius * radius));
vec2 shift = direction * offset / love_ScreenSize.xy;
sum += Texel(tex, texture_coords + shift) * weight;
weightTotal += weight;
}
return sum / weightTotal;
}

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

View File

@ -1,30 +0,0 @@
extern vec3 color;
extern number time;
vec4 effect(vec4 vcolor, Image tex, vec2 texture_coords, vec2 screen_coords)
{
vec4 texColor = Texel(tex, texture_coords);
float mask = texColor.r;
vec2 uv = texture_coords - 0.5;
float dist = length(uv * 2.0);
float t = time;
float wave = sin((uv.x + uv.y) * 6.0 + t * 1.5) * 0.03;
float ripple = sin(length(uv) * 20.0 - t * 2.0) * 0.02;
float flicker = sin(t * 2.5) * 0.02;
dist += wave + ripple + flicker;
float intensity = 1.0 - smoothstep(0.0, 1.0, dist);
intensity = pow(intensity, 2.0);
float colorShift = sin(t * 3.0) * 0.1;
vec3 flickerColor = color + vec3(colorShift, colorShift * 0.5, -colorShift * 0.3);
vec3 finalColor = flickerColor * intensity * mask;
return vec4(finalColor, mask * intensity);
}

View File

@ -1,20 +0,0 @@
extern Image scene;
extern Image light;
extern vec3 ambient;
vec4 effect(vec4 vcolor, Image unused, vec2 uv, vec2 px)
{
vec4 s = Texel(scene, uv);
vec3 l = Texel(light, uv).rgb;
l = clamp(l, 0.0, 1.0);
vec3 a = clamp(ambient, 0.0, 1.0);
// Канальный множитель: от ambient до 1 в зависимости от света
vec3 m = a + (vec3(1.0) - a) * l;
vec3 rgb = s.rgb * m;
return vec4(rgb, s.a);
}

101
lib/animation_node.lua Normal file
View File

@ -0,0 +1,101 @@
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()
t.state = "waiting"
t:bubbleUp()
for _, anim in ipairs(t.children) do
anim:run()
end
end
return t
end
return new

View File

@ -1,13 +1,6 @@
--- @meta _ --- @meta _
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"
Tree.behaviors.stats = require "lib.character.behaviors.stats" Tree.behaviors.stats = require "lib.character.behaviors.stats"
Tree.behaviors.residentsleeper = require "lib.character.behaviors.residentsleeper" Tree.behaviors.residentsleeper = require "lib.character.behaviors.residentsleeper"
Tree.behaviors.shadowcaster = require "lib.character.behaviors.shadowcaster"
Tree.behaviors.light = require "character.behaviors.light"
Tree.behaviors.positioned = require "character.behaviors.positioned"
Tree.behaviors.tiled = require "character.behaviors.tiled"
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,57 +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)
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

@ -11,11 +11,6 @@ behavior.id = "behavior"
function behavior.new() return setmetatable({}, behavior) end function behavior.new() return setmetatable({}, behavior) end
--- это деструктор с крутым названием
function behavior:die()
end
function behavior:update(dt) end function behavior:update(dt) end
function behavior:draw() end function behavior:draw() end

View File

@ -1,19 +0,0 @@
--- Добавляет следование за курсором мыши
--- @class CursorBehavior : Behavior
local behavior = {}
behavior.__index = behavior
behavior.id = "cursor"
---@return CursorBehavior
function behavior.new()
return setmetatable({}, behavior)
end
function behavior:update()
self.owner:try(Tree.behaviors.positioned, function(b)
local mx, my = love.mouse.getX(), love.mouse.getY()
b.position = Tree.level.camera:toWorldPosition(Vec3 { mx, my })
end)
end
return behavior

View File

@ -1,52 +0,0 @@
local task = require "lib.utils.task"
--- @class LightBehavior : Behavior
--- @field intensity number
--- @field color Vec3
--- @field seed integer
--- @field private animateColorTask? Task
local behavior = {}
behavior.__index = behavior
behavior.id = "light"
---@param values {intensity: number?, color: Vec3?, seed: integer?}
---@return LightBehavior
function behavior.new(values)
return setmetatable({
intensity = values.intensity or 1,
color = values.color or Vec3 { 1, 1, 1 },
seed = values.seed or math.random(math.pow(2, 16))
}, behavior)
end
function behavior:update(dt)
-- All logic moved to tasks
end
function behavior:animateColor(targetColor, duration, easing)
-- If there's support for canceling tasks, we should do it here
return task.tween(self, { color = targetColor }, duration or 800, easing)
end
function behavior:draw()
local positioned = self.owner:has(Tree.behaviors.positioned)
if not positioned then return end
Tree.level.camera:attach()
love.graphics.setCanvas(Tree.level.render.textures.lightLayer)
local shader = Tree.assets.files.shaders.light
shader:send("color", { self.color.x, self.color.y, self.color.z })
shader:send("time", love.timer.getTime() + self.seed)
love.graphics.setShader(shader)
love.graphics.draw(Tree.assets.files.masks.circle128, positioned.position.x - self.intensity / 2,
positioned.position.y - self.intensity / 2, 0, self.intensity / 128,
self.intensity / 128)
love.graphics.setBlendMode("alpha")
love.graphics.setShader()
love.graphics.setCanvas()
Tree.level.camera:detach()
end
return behavior

View File

@ -0,0 +1,91 @@
local utils = require "lib.utils.utils"
--- Отвечает за размещение и перемещение по локации
--- @class MapBehavior : Behavior
--- @field position Vec3
--- @field runTarget Vec3 точка, в которую в данный момент бежит персонаж
--- @field displayedPosition Vec3 точка, в которой персонаж отображается
--- @field t0 number время начала движения для анимациии
--- @field path Deque путь, по которому сейчас бежит персонаж
--- @field animationNode? AnimationNode AnimationNode, с которым связана анимация перемещения
--- @field size Vec3
local mapBehavior = {}
mapBehavior.__index = mapBehavior
mapBehavior.id = "map"
--- @param position? Vec3
--- @param size? Vec3
function mapBehavior.new(position, size)
return setmetatable({
position = position or Vec3({}),
displayedPosition = position or Vec3({}),
size = size or Vec3({ 1, 1 }),
}, mapBehavior)
end
--- @param position Vec3
function mapBehavior:lookAt(position)
self.owner:try(Tree.behaviors.sprite,
function(sprite)
if position.x > self.displayedPosition.x then sprite.side = sprite.RIGHT end
-- (sic!)
if position.x < self.displayedPosition.x then sprite.side = sprite.LEFT end
end
)
end
--- @param path Deque
--- @param animationNode AnimationNode
function mapBehavior:followPath(path, animationNode)
if path:is_empty() then return animationNode:finish() end
self.animationNode = animationNode
self.position = self.displayedPosition
self.owner:try(Tree.behaviors.sprite, function(sprite)
sprite:loop("run")
end)
self.path = path;
---@type Vec3
local nextCell = path:peek_front()
self:runTo(nextCell)
path:pop_front()
end
--- @param target Vec3
function mapBehavior:runTo(target)
self.t0 = love.timer.getTime()
self.runTarget = target
self.owner:try(Tree.behaviors.sprite,
function(sprite)
if target.x < self.position.x then
sprite.side = Tree.behaviors.sprite.LEFT
elseif target.x > self.position.x then
sprite.side = Tree.behaviors.sprite.RIGHT
end
end
)
end
function mapBehavior:update(dt)
if self.runTarget then
local delta = love.timer.getTime() - self.t0 or love.timer.getTime()
local fraction = delta /
(0.5 * self.runTarget:subtract(self.position):length()) -- бежим одну клетку за 500 мс, по диагонали больше
if fraction >= 1 then -- анимация перемещена завершена
self.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 -- анимация перемещения не завершена
self.displayedPosition = utils.lerp(self.position, self.runTarget, fraction) -- линейный интерполятор
end
end
end
return mapBehavior

View File

@ -1,25 +0,0 @@
--- Отвечает за размещение на уровне
--- @class PositionedBehavior : Behavior
--- @field position Vec3
local behavior = {}
behavior.__index = behavior
behavior.id = "positioned"
--- @param position? Vec3
function behavior.new(position)
return setmetatable({
position = position or Vec3({}),
}, behavior)
end
--- @param position Vec3
function behavior:lookAt(position)
self.owner:try(Tree.behaviors.sprite,
function(sprite)
if position.x > self.position.x then sprite.side = sprite.RIGHT end
if position.x < self.position.x then sprite.side = sprite.LEFT end
end
)
end
return behavior

View File

@ -1,37 +1,28 @@
--- Умеет асинхронно ждать какое-то время (для анимаций) --- Умеет асинхронно ждать какое-то время (для анимаций)
--- @class ResidentSleeperBehavior : Behavior --- @class ResidentSleeperBehavior : Behavior
--- @field private t0 number? --- @field animationNode? AnimationNode
--- @field private sleepTime number? --- @field endsAt? 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
if love.timer.getTime() >= self.endsAt then
local t = love.timer.getTime() self.animationNode:finish()
if t >= self.t0 + self.sleepTime then self.animationNode = nil
self.state = 'finished' self.endsAt = nil
self.callback()
end end
end end
--- @return Task<nil> --- @param ms number time to wait in milliseconds
function behavior:sleep(ms) --- @param node AnimationNode
self.sleepTime = ms / 1000 function behavior:sleep(ms, node)
return function(callback) if self.animationNode then node:finish() end
if self.state == 'running' then self.animationNode = node
self.callback() self.endsAt = love.timer.getTime() + ms / 1000
end
self.t0 = love.timer.getTime()
self.callback = callback
self.state = 'running'
end
end end
return behavior return behavior

View File

@ -1,63 +0,0 @@
local easing = require "lib.utils.easing"
--- @class ShadowcasterBehavior : Behavior
local behavior = {}
behavior.id = "shadowcaster"
behavior.__index = behavior
function behavior.new() return setmetatable({}, behavior) end
function behavior:draw()
local sprite = self.owner:has(Tree.behaviors.sprite)
local positioned = self.owner:has(Tree.behaviors.positioned)
if not positioned then return end
local ppm = Tree.level.camera.pixelsPerMeter
local position = positioned.position + Vec3 { 0.5, 0.5 }
local lightIds = Tree.level.lightGrid:query(position, 5)
--- @type Character[]
local lights = {}
for _, id in ipairs(lightIds) do
table.insert(lights, Tree.level.characters[id])
end
Tree.level.camera:attach()
love.graphics.setCanvas(Tree.level.render.textures.shadowLayer)
love.graphics.push()
love.graphics.setColor(0, 0, 0, 1)
love.graphics.translate(position.x, position.y)
love.graphics.ellipse("fill", 0, 0, 0.2, 0.2 * math.cos(math.pi / 4))
love.graphics.pop()
if not sprite then
love.graphics.setCanvas()
return
end
love.graphics.setCanvas(Tree.level.render.textures.spriteLightLayer)
love.graphics.setBlendMode("add")
for _, light in ipairs(lights) do
local lightPos = light:has(Tree.behaviors.positioned).position
local lightVec = lightPos - position
local lightColor = light:has(Tree.behaviors.light).color
if lightPos.y > position.y then
love.graphics.setColor(lightColor.x, lightColor.y, lightColor.z,
1 - 0.3 * lightVec:length())
elseif position.y - lightPos.y < 3 then
love.graphics.setColor(lightColor.x, lightColor.y, lightColor.z,
(1 - easing.easeInSine((position.y - lightPos.y))) - 0.3 * lightVec:length())
end
sprite.animationTable[sprite.state]:draw(Tree.assets.files.sprites.character[sprite.state],
position.x,
position.y, nil, 1 / ppm * sprite.side, 1 / ppm, 38, 47)
end
love.graphics.setBlendMode("alpha")
Tree.level.camera:detach()
love.graphics.setColor(1, 1, 1)
love.graphics.setCanvas()
end
return behavior

View File

@ -41,15 +41,10 @@ end
function sprite:draw() function sprite:draw()
if not self.animationTable[self.state] or not Tree.assets.files.sprites.character[self.state] then return end if not self.animationTable[self.state] or not Tree.assets.files.sprites.character[self.state] then return end
self.owner:try(Tree.behaviors.positioned, self.owner:try(Tree.behaviors.map,
function(pos) function(map)
local ppm = Tree.level.camera.pixelsPerMeter local ppm = Tree.level.camera.pixelsPerMeter
local position = pos.position + Vec3 { 0.5, 0.5 } local position = map.displayedPosition
love.graphics.setCanvas(Tree.level.render.textures.spriteLayer)
Tree.level.camera:attach()
love.graphics.setColor(1, 1, 1)
if Tree.level.selector.id == self.owner.id then if Tree.level.selector.id == self.owner.id then
local texW, texH = Tree.assets.files.sprites.character[self.state]:getWidth(), local texW, texH = Tree.assets.files.sprites.character[self.state]:getWidth(),
Tree.assets.files.sprites.character[self.state]:getHeight() Tree.assets.files.sprites.character[self.state]:getHeight()
@ -58,31 +53,27 @@ function sprite:draw()
shader:send("time", love.timer:getTime()) shader:send("time", love.timer:getTime())
love.graphics.setShader(shader) love.graphics.setShader(shader)
end end
self.animationTable[self.state]:draw(Tree.assets.files.sprites.character[self.state],
position.x,
position.y, nil, 1 / ppm * self.side, 1 / ppm, 38, 47)
self.animationTable[self.state]:draw(Tree.assets.files.sprites.character[self.state],
position.x + 0.5,
position.y + 0.5, nil, 1 / ppm * self.side, 1 / ppm, 38, 47)
love.graphics.setColor(1, 1, 1)
love.graphics.setShader() love.graphics.setShader()
Tree.level.camera:detach()
love.graphics.setCanvas()
end end
) )
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,68 +0,0 @@
local task = require "lib.utils.task"
--- Отвечает за перемещение по тайлам
--- @class TiledBehavior : Behavior
--- @field size Vec3
local behavior = {}
behavior.__index = behavior
behavior.id = "tiled"
--- @param size? Vec3
function behavior.new(size)
return setmetatable({
size = size or Vec3({ 1, 1 }),
}, behavior)
end
--- @param path Deque
--- @return Task<nil>
function behavior:followPath(path)
if path:is_empty() then return task.fromValue(nil) end
self.owner:try(Tree.behaviors.sprite, function(sprite)
sprite:loop("run")
end)
-- Рекурсивная функция для прохода по пути
local function nextStep()
if path:is_empty() then
self.owner:try(Tree.behaviors.sprite, function(sprite)
sprite:loop("idle")
end)
return task.fromValue(nil)
end
local nextCell = path:pop_front()
return task.chain(self:runTo(nextCell), nextStep)
end
return nextStep()
end
--- @param target Vec3
--- @return Task<nil>
function behavior:runTo(target)
local positioned = self.owner:has(Tree.behaviors.positioned)
if not positioned then return task.fromValue(nil) end
self.owner:try(Tree.behaviors.sprite,
function(sprite)
if target.x < positioned.position.x then
sprite.side = Tree.behaviors.sprite.LEFT
elseif target.x > positioned.position.x then
sprite.side = Tree.behaviors.sprite.RIGHT
end
end
)
local distance = target:subtract(positioned.position):length()
local duration = distance * 500 -- 500ms per unit
return task.tween(positioned, { position = target }, duration)
end
function behavior:update(dt)
-- Logic moved to tasks
end
return behavior

View File

@ -13,7 +13,11 @@ character.__index = character
--- Создаёт персонажа, которым будет управлять или игрок или компьютер --- Создаёт персонажа, которым будет управлять или игрок или компьютер
--- @param name string --- @param name string
local function spawn(name) --- @param spriteDir table
--- @param position? Vec3
--- @param size? Vec3
--- @param initiative? integer
local function spawn(name, spriteDir, position, size, initiative)
local char = {} local char = {}
char = setmetatable(char, character) char = setmetatable(char, character)
@ -22,6 +26,14 @@ local function spawn(name)
char.behaviors = {} char.behaviors = {}
char._behaviorsIdx = {} char._behaviorsIdx = {}
char:addBehavior {
Tree.behaviors.residentsleeper.new(),
Tree.behaviors.stats.new(nil, nil, initiative),
Tree.behaviors.map.new(position, size),
Tree.behaviors.sprite.new(spriteDir),
Tree.behaviors.spellcaster.new()
}
Tree.level.characters[char.id] = char Tree.level.characters[char.id] = char
return char return char
end end
@ -80,18 +92,6 @@ function character:addBehavior(behaviors)
return self return self
end end
--- Добавляет персонажа в очередь на удаление.
--- В конце фрейма он умирает. Ужасной смертью.
---
--- Ещё этот метод должен освобождать ресурсы в поведениях. Мы против утечек памяти!
function character:die()
for _, b in ipairs(self.behaviors) do
if b.die then b:die() end
end
table.insert(Tree.level.deadIds, self.id)
end
function character:update(dt) function character:update(dt)
for _, b in ipairs(self.behaviors) do for _, b in ipairs(self.behaviors) do
if b.update then b:update(dt) end if b.update then b:update(dt) end

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,12 +9,15 @@ 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 {},
acceleration = 0.2, acceleration = 0.2,
speed = 5, speed = 5,
pixelsPerMeter = 32, pixelsPerMeter = 24,
} }
function camera:getDefaultScale() function camera:getDefaultScale()
@ -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

@ -12,15 +12,12 @@ function grid:add(id)
local character = Tree.level.characters[id] local character = Tree.level.characters[id]
if not character then return end if not character then return end
local positioned = character:has(Tree.behaviors.positioned) local mapB = character:has(Tree.behaviors.map)
if not positioned then return end if not mapB then return end
local tiled = character:has(Tree.behaviors.tiled) local centerX, centerY = math.floor(mapB.displayedPosition.x + 0.5),
if not tiled then return end math.floor(mapB.displayedPosition.y + 0.5)
local sizeX, sizeY = mapB.size.x, mapB.size.y
local centerX, centerY = math.floor(positioned.position.x + 0.5),
math.floor(positioned.position.y + 0.5)
local sizeX, sizeY = tiled.size.x, tiled.size.y
for y = centerY, centerY + sizeY - 1 do for y = centerY, centerY + sizeY - 1 do
for x = centerX, centerX + sizeX - 1 do for x = centerX, centerX + sizeX - 1 do
@ -32,8 +29,10 @@ end
--- @param a Character --- @param a Character
--- @param b Character --- @param b Character
local function drawCmp(a, b) local function drawCmp(a, b)
--- @TODO: это захардкожено, надо разделить поведения -- здесь персонажи гарантированно имеют нужное поведение
return a:has(Tree.behaviors.positioned).position.y < b:has(Tree.behaviors.positioned).position.y return a:has(Tree.behaviors.map).displayedPosition.y
<
b:has(Tree.behaviors.map).displayedPosition.y
end end
--- fills the grid with the actual data --- fills the grid with the actual data

View File

@ -1,65 +0,0 @@
local utils = require "lib.utils.utils"
--- Пометровая сетка источников света, чтобы быстро искать ближайшие для некоторого объекта
--- @class LightGrid : Grid
--- @field __grid {string: [Id]}
local grid = setmetatable({}, require "lib.level.grid.base")
grid.__index = grid
--- Adds a character id to the grid
--- @private
--- @param id Id
function grid:add(id)
local character = Tree.level.characters[id]
if not character then return end
local lightB = character:has(Tree.behaviors.light)
if not lightB then return end
local positioned = character:has(Tree.behaviors.positioned)
if not positioned then return end
local key = tostring(Vec3 { positioned.position.x, positioned.position.y }:floor())
if not self.__grid[key] then self.__grid[key] = {} end
table.insert(self.__grid[key], character.id)
end
--- fills the grid with the actual data
---
--- should be called as early as possible during every tick
function grid:reload()
self:reset()
utils.each(Tree.level.characters, function(c)
self:add(c.id)
end)
end
--- Возвращает все источники света, которые находятся в пределах круга с диаметром [distance] в [метрике Чебышёва](https://ru.wikipedia.org/wiki/Расстояниеебышёва)
--- @param position Vec3
--- @param distance integer
function grid:query(position, distance)
--- @type Id[]
local res = {}
local topLeft = position:subtract(Vec3 { distance / 2, distance / 2 }):floor()
for i = 0, distance, 1 do
for j = 0, distance, 1 do
--- @type Id[]?
local lights = self:get(topLeft:add(Vec3 { i, j }))
if lights then
for _, lightChar in ipairs(lights) do
table.insert(res, lightChar)
end
end
end
end
return res
end
--- Generates an empty grid
--- @return LightGrid
local function new()
return setmetatable({
__grid = {}
}, grid)
end
return { new = new }

View File

@ -13,13 +13,9 @@ local function new(type, template, size)
end end
function map:draw() function map:draw()
love.graphics.setCanvas(Tree.level.render.textures.floorLayer)
Tree.level.camera:attach()
utils.each(self.__grid, function(el) utils.each(self.__grid, function(el)
el:draw() el:draw()
end) end)
Tree.level.camera:detach()
love.graphics.setCanvas()
end end
return { new = new } return { new = new }

View File

@ -3,49 +3,34 @@ local utils = require "lib.utils.utils"
--- @class Level --- @class Level
--- @field size Vec3 --- @field size Vec3
--- @field characters Character[] --- @field characters Character[]
--- @field deadIds Id[]
--- @field characterGrid CharacterGrid --- @field characterGrid CharacterGrid
--- @field lightGrid LightGrid
--- @field selector Selector --- @field selector Selector
--- @field camera Camera --- @field camera Camera
--- @field tileGrid TileGrid --- @field tileGrid TileGrid
--- @field turnOrder TurnOrder --- @field turnOrder TurnOrder
--- @field render Render
local level = {} local level = {}
level.__index = level level.__index = level
local path = nil
--- @param type "procedural"|"handmaded" --- @param type "procedural"|"handmaded"
--- @param template Procedural|Handmaded --- @param template Procedural|Handmaded
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 = {},
deadIds = {},
characterGrid = (require "lib.level.grid.character_grid").new(), characterGrid = (require "lib.level.grid.character_grid").new(),
lightGrid = (require "lib.level.grid.light_grid").new(),
tileGrid = (require "lib.level.grid.tile_grid").new(type, template, size), tileGrid = (require "lib.level.grid.tile_grid").new(type, template, size),
selector = (require "lib.level.selector").new(), selector = (require "lib.level.selector").new(),
camera = (require "lib.level.camera").new(), camera = (require "lib.level.camera").new(),
turnOrder = (require "lib.level.turn_order").new(), turnOrder = (require "lib.level.turn_order").new(),
render = (require "lib.level.render").new {},
weather = (require "lib.level.weather").new { ambientLight = Vec3 { 0.36, 0.42, 0.6 }, skyLight = Vec3 {} }
}, level) }, level)
end end
function level:update(dt) function level:update(dt)
utils.each(self.deadIds, function(id)
self.characters[id] = nil
self.turnOrder:remove(id)
end)
self.deadIds = {}
self.characterGrid:reload() self.characterGrid:reload()
self.lightGrid:reload()
utils.each(self.characters, function(el) utils.each(self.characters, function(el)
el:update(dt) el:update(dt)
end) end)
@ -54,13 +39,10 @@ function level:update(dt)
end end
function level:draw() function level:draw()
self.render:clear()
self.tileGrid:draw() self.tileGrid:draw()
while not self.characterGrid.yOrderQueue:is_empty() do -- по сути это сортировка кучей за n log n while not self.characterGrid.yOrderQueue:is_empty() do -- по сути это сортировка кучей за n log n
self.characterGrid.yOrderQueue:pop():draw() self.characterGrid.yOrderQueue:pop():draw()
end end
self.render:draw()
end end
return { return {

View File

@ -1,106 +0,0 @@
--- @class Render
--- @field textures table<string, love.Canvas>
local render = {
textures = {}
}
function render:clear()
local weather = Tree.level.weather
local txs = self.textures
love.graphics.setCanvas(txs.shadowLayer)
love.graphics.clear()
love.graphics.setCanvas(txs.spriteLayer)
love.graphics.clear()
love.graphics.setCanvas(txs.spriteLightLayer)
love.graphics.clear(weather.skyLight.x, weather.skyLight.y, weather.skyLight.z)
love.graphics.setCanvas(txs.floorLayer)
love.graphics.clear()
love.graphics.setCanvas(txs.lightLayer)
love.graphics.clear(weather.skyLight.x, weather.skyLight.y, weather.skyLight.z)
love.graphics.setCanvas(txs.overlayLayer)
love.graphics.clear()
end
function render:free()
for _, tx in pairs(self.textures) do
tx:release()
end
self.textures = nil
end
--- TODO: это используется для блюра, должно кэшироваться и поддерживать ресайз
function render:applyBlur(input, radius)
local blurShader = Tree.assets.files.shaders.blur
-- Горизонтальный проход
blurShader:send("direction", { 1.0, 0.0 })
blurShader:send("radius", radius)
self.textures.tmp1:renderTo(function()
love.graphics.clear()
love.graphics.setShader(blurShader)
love.graphics.draw(input)
love.graphics.setShader()
end)
-- Вертикальный проход
self.textures.tmp2:renderTo(
function()
love.graphics.clear()
love.graphics.setShader(blurShader)
blurShader:send("direction", { 0.0, 1.0 })
love.graphics.draw(self.textures.tmp1)
love.graphics.setShader()
end
)
return self.textures.tmp2
end
function render:draw()
-- пол -> тени -> спрайты -> свет -> оверлей
local weather = Tree.level.weather
local txs = self.textures
love.graphics.setCanvas(txs.lightLayer)
love.graphics.draw(self:applyBlur(txs.shadowLayer, 4 * Tree.level.camera.scale))
love.graphics.setCanvas()
-- self.lightLayer:newImageData():encode("png", "lightLayer.png")
-- os.exit(0)
local lightShader = Tree.assets.files.shaders.light_postprocess
lightShader:send("scene", txs.floorLayer)
lightShader:send("light", self:applyBlur(txs.lightLayer, 2))
lightShader:send("ambient", { weather.ambientLight.x, weather.ambientLight.y, weather.ambientLight.z })
love.graphics.setShader(lightShader)
love.graphics.draw(txs.floorLayer)
lightShader:send("scene", txs.spriteLayer)
lightShader:send("light", txs.spriteLightLayer)
love.graphics.draw(txs.spriteLayer)
love.graphics.setShader()
love.graphics.draw(txs.overlayLayer)
end
---@param params {w: number?, h: number?}
---@return table|Render
local function new(params)
local w = params.w or love.graphics.getWidth()
local h = params.h or love.graphics.getHeight()
return setmetatable({
textures = {
shadowLayer = love.graphics.newCanvas(w, h),
spriteLayer = love.graphics.newCanvas(w, h),
spriteLightLayer = love.graphics.newCanvas(w, h),
floorLayer = love.graphics.newCanvas(w, h),
overlayLayer = love.graphics.newCanvas(w, h),
lightLayer = love.graphics.newCanvas(w, h),
tmp1 = love.graphics.newCanvas(w, h),
tmp2 = love.graphics.newCanvas(w, h),
}
}, { __index = render })
end
return { new = new }

View File

@ -37,16 +37,9 @@ 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 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,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,31 +29,11 @@ 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
--- Меняем местами очередь сходивших и не сходивших (пустую) --- Меняем местами очередь сходивших и не сходивших (пустую)
@ -129,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,12 +0,0 @@
--- @class Weather
--- @field skyLight Vec3
--- @field ambientLight Vec3
local weather = {}
--- @param proto Weather
--- @return Weather
local function new(proto)
return setmetatable(proto, { __index = weather })
end
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,10 +57,10 @@ 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("medium") local font = Tree.fonts:getDefaultTheme():getVariant("small")
local t = love.graphics.newText(font, tostring(self.value) .. "/" .. tostring(self.maxValue)) love.graphics.setFont(font)
love.graphics.draw(t, math.floor(self.bounds.x + self.bounds.width / 2 - t:getWidth() / 2), love.graphics.printf(tostring(self.value) .. "/" .. tostring(self.maxValue), self.bounds.x,
math.floor(self.bounds.y + self.bounds.height / 2 - t:getHeight() / 2)) self.bounds.y, self.bounds.width, "center")
end end
self:drawBorder("inner") self:drawBorder("inner")

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
@ -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.map) then return end
AnimationNode {
function(node)
Tree.level.camera:animateTo(playing:has(Tree.behaviors.map).displayedPosition, 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

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

@ -7,13 +7,13 @@
--- --TODO: каждый каст должен возвращать объект, который позволит отследить момент завершения анимации спелла --- --TODO: каждый каст должен возвращать объект, который позволит отследить момент завершения анимации спелла
--- Да, это Future/Promise/await/async --- Да, это Future/Promise/await/async
local task = require 'lib.utils.task' local AnimationNode = require "lib.animation_node"
--- @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): Task<nil> | nil Вызывается в момент каста, изменяет мир. --- @field cast fun(self: Spell, caster: Character, target: Vec3): boolean Вызывается в момент каста, изменяет мир. Возвращает bool в зависимости от того, получилось ли скастовать
local spell = {} local spell = {}
spell.__index = spell spell.__index = spell
spell.tag = "base" spell.tag = "base"
@ -22,7 +22,7 @@ function spell:update(caster, dt) end
function spell:draw() end function spell:draw() end
function spell:cast(caster, target) return end function spell:cast(caster, target) return true end
local walk = setmetatable({ local walk = setmetatable({
--- @type Deque --- @type Deque
@ -34,32 +34,32 @@ 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 return false
end end
local initialPos = caster:has(Tree.behaviors.positioned).position:floor() local path = self.path
local path = require "lib.pathfinder" (initialPos, target)
path:pop_front() path:pop_front()
if path:is_empty() then if path:is_empty() then return false end
print("[Walk]: the path is empty", initialPos, target)
return for p in path:values() do print(p) end
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)
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.map):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
function walk:update(caster, dt) function walk:update(caster, dt)
local charPos = caster:has(Tree.behaviors.positioned).position:floor() local charPos = caster:has(Tree.behaviors.map).position:floor()
--- @type Vec3 --- @type Vec3
local mpos = Tree.level.camera:toWorldPosition(Vec3 { love.mouse.getX(), love.mouse.getY() }):floor() local mpos = Tree.level.camera:toWorldPosition(Vec3 { love.mouse.getX(), love.mouse.getY() }):floor()
self.path = require "lib.pathfinder" (charPos, mpos) self.path = require "lib.pathfinder" (charPos, mpos)
@ -68,14 +68,10 @@ end
function walk:draw() function walk:draw()
if not self.path then return end 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) love.graphics.setColor(0.6, 0.75, 0.5)
for p in self.path:values() do for p in self.path:values() do
love.graphics.circle("fill", p.x + 0.45, p.y + 0.45, 0.1) love.graphics.circle("fill", p.x + 0.45, p.y + 0.45, 0.1)
end end
love.graphics.setCanvas()
Tree.level.camera:detach()
love.graphics.setColor(1, 1, 1) love.graphics.setColor(1, 1, 1)
end end
@ -87,43 +83,29 @@ function regenerateMana:cast(caster, target)
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 nil 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
AnimationNode {
function(node)
sprite:animate("hurt", node)
end,
onEnd = function() caster:has(Tree.behaviors.spellcaster):endCast() end
}:run()
local light = require "lib/character/character".spawn("Light Effect") return true
light:addBehavior {
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 }),
}
local flash = function(callback)
light:has(Tree.behaviors.light):animateColor(Vec3 {})(
function()
light:die()
callback()
end
)
end
return task.wait {
flash,
sprite:animate("hurt")
}
end end
local attack = setmetatable({}, spell) local attack = setmetatable({}, spell)
attack.tag = "dev_attack" attack.tag = "dev_attack"
function attack:cast(caster, target) function attack:cast(caster, target)
if caster:try(Tree.behaviors.positioned, function(p) if caster:try(Tree.behaviors.map, function(map)
local dist = math.max(math.abs(p.position.x - target.x), math.abs(p.position.y - target.y)) local dist = math.max(math.abs(map.position.x - target.x), math.abs(map.position.y - target.y))
print("dist:", dist) print("dist:", dist)
return dist > 2 return dist > 2
end) then end) then
return return false
end end
caster:try(Tree.behaviors.stats, function(stats) caster:try(Tree.behaviors.stats, function(stats)
@ -132,7 +114,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 end 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
@ -140,20 +122,34 @@ 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 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.map, function(map) map: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(200), function(node)
function() return targetSprite:animate("hurt") end sprite:animate("attack", node)
), end
Tree.audio:play(Tree.assets.files.audio.sounds.hurt) },
AnimationNode {
function(node)
targetCharacter:has(Tree.behaviors.residentsleeper):sleep(200, node)
end,
children = {
AnimationNode {
function(node)
targetSprite:animate("hurt", node)
end
} }
} }
}
}
}:run()
return true
end end
---------------------------------------- ----------------------------------------
@ -168,7 +164,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

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

@ -1,84 +1,34 @@
-- CameraLoader = require 'lib/camera' -- CameraLoader = require 'lib/camera'
local character = require "lib/character/character" local character = require "lib/character/character"
local testLayout require "lib/tree"
local TestRunner = require "test.runner" local testLayout = require "lib.simple_ui.level.layout"
TestRunner:register(require "test.task")
function love.conf(t) function love.conf(t)
t.console = true t.console = true
end end
function love.load() function love.load()
love.window.setMode(1280, 720, { resizable = true, msaa = 4, vsync = true }) character.spawn("Foodor", Tree.assets.files.sprites.character, nil, nil, 1)
require "lib/tree" -- важно это сделать после настройки окна character.spawn("Baris", Tree.assets.files.sprites.character, Vec3 { 3, 3 }, nil, 2)
testLayout = require "lib.simple_ui.level.layout" character.spawn("Foodor Jr", Tree.assets.files.sprites.character, Vec3 { 0, 3 }, nil, 3)
character.spawn("Baris Jr", Tree.assets.files.sprites.character, Vec3 { 0, 6 }, nil, 4)
local chars = { for id, _ in pairs(Tree.level.characters) do
character.spawn("Foodor")
:addBehavior {
Tree.behaviors.residentsleeper.new(),
Tree.behaviors.stats.new(nil, nil, 1),
Tree.behaviors.positioned.new(Vec3 { 3, 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, 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")
: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
Tree.level.turnOrder:add(id) Tree.level.turnOrder:add(id)
end end
Tree.level.turnOrder:endRound() Tree.level.turnOrder:endRound()
print("Now playing:", Tree.level.turnOrder.current) print("Now playing:", Tree.level.turnOrder.current)
love.window.setMode(1280, 720, { resizable = true, msaa = 4, vsync = true })
end 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()
@ -106,8 +56,11 @@ function love.draw()
love.graphics.draw(Tree.assets.files.cats, 0, 0) love.graphics.draw(Tree.assets.files.cats, 0, 0)
love.graphics.pop() love.graphics.pop()
Tree.level.camera:attach()
Tree.level:draw() Tree.level:draw()
Tree.level.camera:detach()
testLayout:draw() testLayout:draw()
love.graphics.setColor(1, 1, 1) love.graphics.setColor(1, 1, 1)
@ -120,10 +73,3 @@ function love.draw()
local t2 = love.timer.getTime() local t2 = love.timer.getTime()
dt = string.format("%.3f", (t2 - t1) * 1000) dt = string.format("%.3f", (t2 - t1) * 1000)
end end
function love.resize(w, h)
local render = Tree.level.render
if not render then return end
render:free()
Tree.level.render = (require "lib.level.render").new { w, h }
end

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