diff --git a/lib/character/animation.lua b/lib/character/animation.lua deleted file mode 100644 index 94621d0..0000000 --- a/lib/character/animation.lua +++ /dev/null @@ -1,46 +0,0 @@ -local anim8 = require "lib.utils.anim8" - ---- Скорость между кадрами в анимации -local ANIMATION_SPEED = 0.1 - -LEFT = -1 -RIGHT = 1 - ---- @class Animation ---- @field animationTable table ---- @field animationGrid table ---- @field state "idle"|"run"|"hurt"|"attack" ---- @field side 1|-1 -local animation = {} -animation.__index = animation - -local function new(id, spriteDir) - local anim = { - animationTable = {}, - animationGrid = {} - } - - -- n: name; i: image - for n, i in pairs(spriteDir) do - local aGrid = anim8.newGrid(96, 64, i:getWidth(), i:getHeight()) - local tiles = '1-' .. math.ceil(i:getWidth() / 96) - anim.animationGrid[n] = aGrid(tiles, 1) - end - - anim.state = "idle" - anim.side = RIGHT - - return setmetatable(anim, animation) -end - -function animation:getState() - return self.state -end - ---- @param state CharacterState -function animation:setState(state, onLoop) - self.animationTable[state] = anim8.newAnimation(self.animationGrid[state], ANIMATION_SPEED, onLoop) - self.state = state -end - -return { new = new } diff --git a/lib/character/behaviors/behavior.lua b/lib/character/behaviors/behavior.lua new file mode 100644 index 0000000..2282389 --- /dev/null +++ b/lib/character/behaviors/behavior.lua @@ -0,0 +1,20 @@ +--- Поведение персонажа. Их можно комбинировать как угодно, добавлять и заменять на лету... +--- @class Behavior +--- @field id string +--- @field owner Character +--- @field dependencies Behavior[] +--- @field new fun(...) : self +--- @field update fun(self, dt): nil +--- @field draw fun(self): nil +local behavior = {} +behavior.__index = behavior +behavior.id = "behavior" +behavior.dependencies = {} + +function behavior.new() return setmetatable({}, behavior) end + +function behavior:update(dt) end + +function behavior:draw() end + +return behavior diff --git a/lib/character/behaviors/map.lua b/lib/character/behaviors/map.lua new file mode 100644 index 0000000..535087e --- /dev/null +++ b/lib/character/behaviors/map.lua @@ -0,0 +1,77 @@ +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 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 path Deque +function mapBehavior:followPath(path) + if path:is_empty() then return end + self.position = self.displayedPosition + self.owner:try(Tree.behaviors.sprite, function(sprite) + sprite:play("run", true) + 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:peek_front()) + self.path:pop_front() + else -- мы добежали до финальной цели + self.owner:try(Tree.behaviors.sprite, function(sprite) + sprite:play("idle", true) + end) + self.runTarget = nil + end + else -- анимация перемещения не завершена + self.displayedPosition = utils.lerp(self.position, self.runTarget, fraction) -- линейный интерполятор + end + end +end + +return mapBehavior diff --git a/lib/character/behaviors/spellcaster.lua b/lib/character/behaviors/spellcaster.lua new file mode 100644 index 0000000..2195b8f --- /dev/null +++ b/lib/character/behaviors/spellcaster.lua @@ -0,0 +1,25 @@ +--- @class SpellcasterBehavior : Behavior +--- @field spellbook Spell[] собственный набор спеллов персонажа +--- @field cast Spell | nil ссылка на активный спелл из спеллбука +local behavior = {} +behavior.__index = behavior +behavior.id = "spellcaster" + +---@param spellbook Spell[] | nil +---@return SpellcasterBehavior +function behavior.new(spellbook) + local spb = require "lib.spellbook" --- @todo временное добавление ходьбы всем персонажам + local t = {} + t.spellbook = spellbook or spb.of { spb.walk } + return setmetatable(t, behavior) +end + +function behavior:update(dt) + if self.cast then self.cast:update(self.owner, dt) end +end + +function behavior:draw() + if self.cast then self.cast:draw() end +end + +return behavior diff --git a/lib/character/behaviors/sprite.lua b/lib/character/behaviors/sprite.lua new file mode 100644 index 0000000..89eecb7 --- /dev/null +++ b/lib/character/behaviors/sprite.lua @@ -0,0 +1,70 @@ +local anim8 = require "lib.utils.anim8" + +--- @class SpriteBehavior : Behavior +--- @field animationTable table +--- @field animationGrid table +--- @field state "idle"|"run"|"hurt"|"attack" +--- @field side 1|-1 +local sprite = {} +sprite.__index = sprite +sprite.id = "sprite" +sprite.dependencies = { Tree.behaviors.map } +sprite.LEFT = -1 +sprite.RIGHT = 1 +--- Скорость между кадрами в анимации +sprite.ANIMATION_SPEED = 0.1 + +function sprite.new(spriteDir) + local anim = setmetatable({}, sprite) + anim.animationTable = {} + anim.animationGrid = {} + + -- n: name; i: image + for n, i in pairs(spriteDir) do + local aGrid = anim8.newGrid(96, 64, i:getWidth(), i:getHeight()) + local tiles = '1-' .. math.ceil(i:getWidth() / 96) + anim.animationGrid[n] = aGrid(tiles, 1) + end + + anim.state = "idle" + anim.side = sprite.RIGHT + anim:play("idle") + return anim +end + +function sprite:update(dt) + local anim = self.animationTable[self.state] or self.animationTable["idle"] or nil + + if not anim then return end + anim:update(dt) +end + +function sprite:draw() + if not self.animationTable[self.state] or not Tree.assets.files.sprites.character[self.state] then return end + + self.owner:try(Tree.behaviors.map, + function(map) + local ppm = Tree.level.camera.pixelsPerMeter + local position = map.displayedPosition + if Tree.level.selector.id == self.owner.id then love.graphics.setColor(0.5, 1, 0.5) end + 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) + end + ) +end + +--- @param state string +--- @param loop boolean | nil +function sprite:play(state, loop) + if not self.animationGrid[state] then + return print("[SpriteBehavior]: no animation for '" .. state .. "'") + end + self.animationTable[state] = anim8.newAnimation(self.animationGrid[state], self.ANIMATION_SPEED, function() + if not loop then self:play("idle", true) end + end) + self.state = state +end + +return sprite diff --git a/lib/character/character.lua b/lib/character/character.lua index 98d0a29..fd14504 100644 --- a/lib/character/character.lua +++ b/lib/character/character.lua @@ -5,14 +5,10 @@ require 'lib.utils.vec3' --- @type Id local characterId = 1 ---- @todo Композиция лучше наследования, но не до такой же степени! Надо отрефакторить и избавиться от сотни полей в таблице --- @class Character --- @field id Id ---- @field info Info ---- @field graphics Graphics ---- @field logic Logic ---- @field spellbook Spell[] собственный набор спеллов персонажа ---- @field cast Spell | nil ссылка на активный спелл из спеллбука +--- @field behaviors Behavior[] +--- @field _behaviorsIdx {string: integer} local character = {} character.__index = character @@ -26,50 +22,86 @@ character.__index = character local function spawn(name, template, spriteDir, position, size, level) local char = {} + char = setmetatable(char, character) char.id = characterId characterId = characterId + 1 - char = setmetatable(char, character) + char.behaviors = {} + char._behaviorsIdx = {} + + char:addBehavior { + Tree.behaviors.map.new(position, size), + Tree.behaviors.sprite.new(spriteDir), + Tree.behaviors.spellcaster.new() + } - char:addModules( - { - logic = (require 'lib.character.logic').new(char.id, position, size), - graphics = (require 'lib.character.graphics').new(char.id, spriteDir), - info = (require "lib/character/info").new(name, template, level) - } - ) Tree.level.characters[char.id] = char return char end +--- Проверяет, есть ли у персонажа поведение [behavior]. +--- @generic T : Behavior +--- @param behavior T +--- @return T | nil +function character:has(behavior) + --- @cast behavior Behavior + local idx = self._behaviorsIdx[behavior.id] + if not idx then return nil end + return self.behaviors[idx] or nil +end + +--- Если у персонажа есть поведение [behavior], применяет к нему [fn] +--- +--- Дальше meme для интеллектуалов +--- +--- *Я: мам купи мне >>=* +--- +--- *Мама: у нас дома есть >>=* +--- +--- *Дома:* +--- @generic T : Behavior +--- @generic V +--- @param behavior T +--- @param fn fun(behavior: T) : V | nil +--- @return V | nil +function character:try(behavior, fn) + local b = self:has(behavior) + if not b then return end + return fn(b) +end + --- usage: ---- addModules( {logic = logic.new(), graphics = graphics.new(), ...} ) -function character:addModules(modules) - for key, module in pairs(modules) do - module.owner = self - self[key] = module +--- addBehavior( {logic.new(), graphics.new(), ...} ) +--- +--- or you may chain this if you are a wannabe haskell kiddo +--- @param behaviors Behavior[] +--- @return Character | nil +function character:addBehavior(behaviors) + for _, b in ipairs(behaviors) do + if b.dependencies then + for _, dep in ipairs(b.dependencies) do + if not self:has(dep) then + return print("[Character]: cannot add \"" .. b.id .. + "\" for a character (Id = " .. self.id .. "): needs \"" .. dep.id .. "\"!") + end + end + end + b.owner = self + table.insert(self.behaviors, b) + self._behaviorsIdx[b.id] = #self.behaviors end -end - ---- геттеры и сеттеры для "внешних" данных ---- @return CharacterState -function character:getState() - return self.logic.state or "idle" -end - ---- @param path Deque -function character:followPath(path) - self.logic:followPath(path) + return self end function character:update(dt) - self.logic:update(dt) - if self.cast then self.cast:update(self, dt) end - self.graphics:update(dt) + for _, b in ipairs(self.behaviors) do + if b.update then b:update(dt) end + end end function character:draw() - self.graphics:draw() - if self.cast then self.cast:draw() end + for _, b in ipairs(self.behaviors) do + if b.draw then b:draw() end + end end return { spawn = spawn } diff --git a/lib/character/graphics.lua b/lib/character/graphics.lua deleted file mode 100644 index f966431..0000000 --- a/lib/character/graphics.lua +++ /dev/null @@ -1,33 +0,0 @@ ---- @class Graphics ---- @field owner Character ---- @field animation Animation -local graphics = {} -graphics.__index = graphics - ---- @param id Id ---- @param spriteDir table -local function new(id, spriteDir) - return setmetatable({ - id = id, - animation = (require 'lib.character.animation').new(id, spriteDir) - }, graphics) -end - -function graphics:update(dt) - self.animation.animationTable[self.owner:getState()]:update(dt) -end - -function graphics:draw() - local ppm = Tree.level.camera.pixelsPerMeter - local position = self.owner.logic.mapLogic.displayedPosition - local state = self.owner:getState() - - if Tree.level.selector.id == self.owner.id then love.graphics.setColor(0.5, 1, 0.5) end - - self.animation.animationTable[state]:draw(Tree.assets.files.sprites.character[state], - position.x + 0.5, - position.y + 0.5, nil, 1 / ppm * self.animation.side, 1 / ppm, 38, 47) - love.graphics.setColor(1, 1, 1) -end - -return { new = new } diff --git a/lib/character/logic.lua b/lib/character/logic.lua deleted file mode 100644 index e5e0564..0000000 --- a/lib/character/logic.lua +++ /dev/null @@ -1,74 +0,0 @@ -local utils = require "lib.utils.utils" - ---- @alias CharacterState "idle"|"run"|"attack"|"hurt" - ---- @class Logic ---- @field owner Character ---- @field mapLogic MapLogic ---- @field state CharacterState -local logic = {} -logic.__index = logic - ---- @param id Id ---- @param position? Vec3 ---- @param size? Vec3 -local function new(id, position, size) - return setmetatable({ - id = id, - mapLogic = (require 'lib.character.map_logic').new(id, position, size), - state = "idle" - }, logic) -end - ---- @param state CharacterState -function logic:setState(state) - self.state = state - self.owner.graphics.animation:setState(state, (state ~= "idle" and state ~= "run") and function() - self:setState("idle") - end or nil) -end - ---- @param path Deque -function logic:followPath(path) - if path:is_empty() then return end - self:setState("run") - self.mapLogic.path = path; - ---@type Vec3 - local nextCell = path:peek_front() - self:runTo(nextCell) - path:pop_front() -end - ---- @param target Vec3 -function logic:runTo(target) - self.mapLogic.t0 = love.timer.getTime() - self.mapLogic.runTarget = target - local charPos = self.mapLogic.position - if target.x < charPos.x then - self.owner.graphics.animation.side = LEFT - elseif target.x > charPos.x then - self.owner.graphics.animation.side = RIGHT - end -end - -function logic:update(dt) - if self.state == "run" and self.mapLogic.runTarget then - local delta = love.timer.getTime() - self.mapLogic.t0 or love.timer.getTime() - local fraction = delta / - (0.5 * self.mapLogic.runTarget:subtract(self.mapLogic.position):length()) -- бежим одну клетку за 500 мс, по диагонали больше - if fraction >= 1 then -- анимация перемещена завершена - self.mapLogic.position = self.mapLogic.runTarget - if not self.mapLogic.path:is_empty() then -- еще есть, куда бежать - self:runTo(self.mapLogic.path:peek_front()) - self.mapLogic.path:pop_front() - else -- мы добежали до финальной цели - self:setState("idle") - self.mapLogic.runTarget = nil - end - else -- анимация перемещения не завершена - self.mapLogic.displayedPosition = utils.lerp(self.mapLogic.position, self.mapLogic.runTarget, fraction) -- линейный интерполятор - end - end -end - -return { new = new } diff --git a/lib/character/map_logic.lua b/lib/character/map_logic.lua deleted file mode 100644 index 805af01..0000000 --- a/lib/character/map_logic.lua +++ /dev/null @@ -1,24 +0,0 @@ ---- @class MapLogic ---- @field id Id ---- @field position Vec3 ---- @field runTarget Vec3 точка, в которую в данный момент бежит персонаж ---- @field displayedPosition Vec3 точка, в которой персонаж отображается ---- @field t0 number время начала движения для анимациии ---- @field path Deque путь, по которому сейчас бежит персонаж ---- @field size Vec3 -local mapLogic = {} - ---- @param id Id ---- @param position? Vec3 ---- @param size? Vec3 -local function new(id, position, size) - return setmetatable({ - id = id, - position = position or Vec3({}), - displayedPosition = position or Vec3({}), - size = size or Vec3({ 1, 1 }), - path = (require "lib.utils.deque").new() - }, mapLogic) -end - -return { new = new } diff --git a/lib/level/grid/character_grid.lua b/lib/level/grid/character_grid.lua index 0192f1e..b21772b 100644 --- a/lib/level/grid/character_grid.lua +++ b/lib/level/grid/character_grid.lua @@ -12,9 +12,12 @@ function grid:add(id) local character = Tree.level.characters[id] if not character then return end - local centerX, centerY = math.floor(character.logic.mapLogic.position.x + 0.5), - math.floor(character.logic.mapLogic.position.y + 0.5) - local sizeX, sizeY = character.logic.mapLogic.size.x, character.logic.mapLogic.size.y + local mapB = character:has(Tree.behaviors.map) + if not mapB then return end + + local centerX, centerY = math.floor(mapB.displayedPosition.x + 0.5), + math.floor(mapB.displayedPosition.y + 0.5) + local sizeX, sizeY = mapB.size.x, mapB.size.y for y = centerY, centerY + sizeY - 1 do for x = centerX, centerX + sizeX - 1 do @@ -26,7 +29,10 @@ end --- @param a Character --- @param b Character local function drawCmp(a, b) - return a.logic.mapLogic.displayedPosition.y < b.logic.mapLogic.displayedPosition.y + -- здесь персонажи гарантированно имеют нужное поведение + return a:has(Tree.behaviors.map).displayedPosition.y + < + b:has(Tree.behaviors.map).displayedPosition.y end --- fills the grid with the actual data diff --git a/lib/level/level.lua b/lib/level/level.lua index 6362402..eb182e9 100644 --- a/lib/level/level.lua +++ b/lib/level/level.lua @@ -39,7 +39,7 @@ end function level: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() end end diff --git a/lib/level/selector.lua b/lib/level/selector.lua index 2814ea3..8305a3e 100644 --- a/lib/level/selector.lua +++ b/lib/level/selector.lua @@ -25,10 +25,11 @@ function selector:update(dt) if not characterId and self.id then -- Когда кликаем по тайлу за персонажа в режиме каста, кастуем спелл local char = Tree.level.characters[self.id] - if char.cast then - char.cast:cast(char, mousePosition) - char.cast = nil - end + char:try(Tree.behaviors.spellcaster, function(b) + if not b.cast then return end + b.cast:cast(char, mousePosition) + b.cast = nil + end) end self:select(characterId) diff --git a/lib/spellbook.lua b/lib/spellbook.lua index 67d7981..0854045 100644 --- a/lib/spellbook.lua +++ b/lib/spellbook.lua @@ -17,16 +17,15 @@ local walk = setmetatable({ }, spell) function walk:cast(caster, target) - caster.cast = nil local path = self.path path:pop_front() print("Following path: ") for p in path:values() do print(p) end - caster:followPath(path) + caster:has(Tree.behaviors.map):followPath(path) end function walk:update(caster, dt) - local charPos = caster.logic.mapLogic.position:floor() + local charPos = caster:has(Tree.behaviors.map).position:floor() --- @type Vec3 local mpos = Tree.level.camera:toWorldPosition(Vec3 { love.mouse.getX(), love.mouse.getY() }):floor() self.path = require "lib.pathfinder" (charPos, mpos) diff --git a/lib/tree.lua b/lib/tree.lua index ff01584..07a06b4 100644 --- a/lib/tree.lua +++ b/lib/tree.lua @@ -3,9 +3,14 @@ --- В love.update обновлять, в love.draw читать -Tree = { +Tree = { assets = (require "lib.utils.asset_bundle"):load() } -Tree.panning = require "lib/panning" -Tree.controls = require "lib.controls" -Tree.level = (require "lib.level.level").new("procedural", "flower_plains") -- для теста у нас только один уровень, который сразу же загружен +Tree.panning = require "lib/panning" +Tree.controls = require "lib.controls" +Tree.level = (require "lib.level.level").new("procedural", "flower_plains") -- для теста у нас только один уровень, который сразу же загружен + +Tree.behaviors = {} --- @todo написать нормальную загрузку поведений +Tree.behaviors.map = require "lib.character.behaviors.map" +Tree.behaviors.spellcaster = require "lib.character.behaviors.spellcaster" +Tree.behaviors.sprite = require "lib.character.behaviors.sprite" diff --git a/lib/ui/layout.lua b/lib/ui/layout.lua index dcd5ea0..3f0e03f 100644 --- a/lib/ui/layout.lua +++ b/lib/ui/layout.lua @@ -11,9 +11,11 @@ local SkillButton = ui.Rectangle { } function SkillButton:update(dt) ui.Rectangle.update(self, dt) - self.color = self.owner.cast and { 0, 1, 0 } or { 1, 0, 0 } - self:onTap(function() - self.owner.cast = self.owner.cast and nil or self.owner.spellbook[self.spellId] + self.owner:try(Tree.behaviors.spellcaster, function(spellcaster) + self.color = spellcaster.cast and { 0, 1, 0 } or { 1, 0, 0 } + self:onTap(function() + spellcaster.cast = spellcaster.cast and nil or spellcaster.spellbook[self.spellId] + end) end) end diff --git a/main.lua b/main.lua index e98f845..89f65f5 100644 --- a/main.lua +++ b/main.lua @@ -3,25 +3,15 @@ local character = require "lib/character/character" require "lib/tree" local layout = require "lib.ui.layout" -local spellbook = require "lib.spellbook" + function love.conf(t) t.console = true end function love.load() - for x = 0, 29, 1 do - for y = 0, 29, 1 do - if math.random() > 0.8 then - local c = character.spawn("Hero", "warrior", Tree.assets.files.sprites.character) - c.logic.mapLogic.position = Vec3 { x, y } - c.logic.mapLogic.displayedPosition = Vec3 { x, y } - c.spellbook = spellbook.of { spellbook.walk } - c.logic:setState("attack") - end - end - end - + character.spawn("Hero", "warrior", Tree.assets.files.sprites.character) + character.spawn("Hero", "warrior", Tree.assets.files.sprites.character, Vec3 { 3, 3 }) love.window.setMode(1080, 720, { resizable = true, msaa = 4, vsync = true }) end