initial implementation of character behavior

This commit is contained in:
PeaAshMeter 2025-09-16 23:53:16 +03:00
parent 86b3d8030a
commit 1b12b2c470
13 changed files with 181 additions and 155 deletions

View File

@ -14,7 +14,7 @@ RIGHT = 1
local animation = {} local animation = {}
animation.__index = animation animation.__index = animation
local function new(id, spriteDir) local function new(spriteDir)
local anim = { local anim = {
animationTable = {}, animationTable = {},
animationGrid = {} animationGrid = {}

View File

@ -0,0 +1,7 @@
--- Поведение персонажа. Их можно комбинировать как угодно, добавлять и заменять на лету...
--- @class Behavior
--- @field id string
--- @field owner Character
--- @field dependencies Behavior[]
--- @field update fun(self, dt): nil
--- @field draw fun(self): nil

View File

@ -0,0 +1,72 @@
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:setState("run")
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
local charPos = self.position
local render = self.owner:has(Tree.behaviors.render)
if not render then return end
if target.x < charPos.x then
render.animation.side = LEFT
elseif target.x > charPos.x then
render.animation.side = RIGHT
end
end
function mapBehavior:update(dt)
if self.owner.state == "run" and 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:setState("idle")
self.runTarget = nil
end
else -- анимация перемещения не завершена
self.displayedPosition = utils.lerp(self.position, self.runTarget, fraction) -- линейный интерполятор
end
end
end
return mapBehavior

View File

@ -1,25 +1,26 @@
--- @class Graphics --- @class DrawBehavior : Behavior
--- @field owner Character
--- @field animation Animation --- @field animation Animation
local graphics = {} local renderBehavior = {}
graphics.__index = graphics renderBehavior.__index = renderBehavior
renderBehavior.id = "render"
renderBehavior.dependencies = { Tree.behaviors.map }
--- @param id Id
--- @param spriteDir table --- @param spriteDir table
local function new(id, spriteDir) function renderBehavior.new(spriteDir)
return setmetatable({ return setmetatable({
id = id, animation = (require 'lib.character.animation').new(spriteDir)
animation = (require 'lib.character.animation').new(id, spriteDir) }, renderBehavior)
}, graphics)
end end
function graphics:update(dt) function renderBehavior:update(dt)
self.animation.animationTable[self.owner:getState()]:update(dt) self.animation.animationTable[self.owner:getState()]:update(dt)
end end
function graphics:draw() function renderBehavior:draw()
local ppm = Tree.level.camera.pixelsPerMeter local ppm = Tree.level.camera.pixelsPerMeter
local position = self.owner.logic.mapLogic.displayedPosition if not self.owner:has(Tree.behaviors.map) then return end
local position = self.owner:has(Tree.behaviors.map).displayedPosition
local state = self.owner:getState() local state = self.owner:getState()
if Tree.level.selector.id == self.owner.id then love.graphics.setColor(0.5, 1, 0.5) end if Tree.level.selector.id == self.owner.id then love.graphics.setColor(0.5, 1, 0.5) end
@ -30,4 +31,4 @@ function graphics:draw()
love.graphics.setColor(1, 1, 1) love.graphics.setColor(1, 1, 1)
end end
return { new = new } return renderBehavior

View File

@ -1,5 +1,6 @@
require 'lib.utils.vec3' require 'lib.utils.vec3'
--- @alias CharacterState "idle"|"run"|"attack"|"hurt"
--- @alias Id integer --- @alias Id integer
--- @type Id --- @type Id
@ -8,9 +9,8 @@ local characterId = 1
--- @todo Композиция лучше наследования, но не до такой же степени! Надо отрефакторить и избавиться от сотни полей в таблице --- @todo Композиция лучше наследования, но не до такой же степени! Надо отрефакторить и избавиться от сотни полей в таблице
--- @class Character --- @class Character
--- @field id Id --- @field id Id
--- @field info Info --- @field behaviors Behavior[]
--- @field graphics Graphics --- @field _behaviorsIdx {string: integer}
--- @field logic Logic
--- @field spellbook Spell[] собственный набор спеллов персонажа --- @field spellbook Spell[] собственный набор спеллов персонажа
--- @field cast Spell | nil ссылка на активный спелл из спеллбука --- @field cast Spell | nil ссылка на активный спелл из спеллбука
local character = {} local character = {}
@ -26,50 +26,94 @@ character.__index = character
local function spawn(name, template, spriteDir, position, size, level) local function spawn(name, template, spriteDir, position, size, level)
local char = {} local char = {}
char = setmetatable(char, character)
char.id = characterId char.id = characterId
characterId = characterId + 1 characterId = characterId + 1
char = setmetatable(char, character) char.behaviors = {}
char._behaviorsIdx = {}
char:addBehavior {
Tree.behaviors.map.new(position, size),
Tree.behaviors.render.new(spriteDir),
}
char:setState("idle") --- @todo сделать это отдельным модулем
local spb = require "lib.spellbook" --- @todo это тоже
char.spellbook = spb.of { spb.walk }
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 Tree.level.characters[char.id] = char
return char return char
end 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
--- usage: --- usage:
--- addModules( {logic = logic.new(), graphics = graphics.new(), ...} ) --- addModules( {logic = logic.new(), graphics = graphics.new(), ...} )
function character:addModules(modules) ---
for key, module in pairs(modules) do --- or you may chain this if you are a wannabe haskell kiddo
function character:addBehavior(modules)
for _, module in ipairs(modules) do
if module.dependencies then
for _, dep in ipairs(module.dependencies) do
if not self:has(dep) then
return print("[Character]: cannot add \"" .. module.id ..
"\" for a character (Id = " .. self.id .. "): needs \"" .. dep.id .. "\"!")
end
end
end
module.owner = self module.owner = self
self[key] = module table.insert(self.behaviors, module)
self._behaviorsIdx[module.id] = #self.behaviors
end end
return self
end end
--- геттеры и сеттеры для "внешних" данных --- геттеры и сеттеры для "внешних" данных
--- забей, это в поведения
--- @deprecated
--- @return CharacterState --- @return CharacterState
function character:getState() function character:getState()
return self.logic.state or "idle" return self.state or "idle"
end
--- @param state CharacterState
--- @deprecated
function character:setState(state) --- @todo это вообще должно быть отдельное поведение
self.state = state
self:has(Tree.behaviors.render).animation:setState(state, (state ~= "idle" and state ~= "run") and function()
self:setState("idle")
end or nil)
end end
--- @param path Deque --- @param path Deque
function character:followPath(path) function character:followPath(path)
self.logic:followPath(path) self:has(Tree.behaviors.map):followPath(path)
end end
function character:update(dt) function character:update(dt)
self.logic:update(dt) --- @todo ну ты понел
-- for _, b in ipairs(self.behaviors) do
-- if b.update then b:update(dt) end
-- end
self:has(Tree.behaviors.map):update(dt)
if self.cast then self.cast:update(self, dt) end if self.cast then self.cast:update(self, dt) end
self.graphics:update(dt) self:has(Tree.behaviors.render):update(dt)
end end
function character:draw() function character:draw()
self.graphics:draw() for _, b in ipairs(self.behaviors) do
if self.cast then self.cast:draw() end if b.draw then b:draw() end
end
if self.cast then self.cast:draw() end --- @todo 🤡
end end
return { spawn = spawn } return { spawn = spawn }

View File

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

View File

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

View File

@ -12,9 +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 centerX, centerY = math.floor(character.logic.mapLogic.position.x + 0.5), local mapB = character:has(Tree.behaviors.map)
math.floor(character.logic.mapLogic.position.y + 0.5) if not mapB then return end
local sizeX, sizeY = character.logic.mapLogic.size.x, character.logic.mapLogic.size.y
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 y = centerY, centerY + sizeY - 1 do
for x = centerX, centerX + sizeX - 1 do for x = centerX, centerX + sizeX - 1 do
@ -26,7 +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)
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 end
--- fills the grid with the actual data --- fills the grid with the actual data

View File

@ -39,7 +39,7 @@ end
function level:draw() function level:draw()
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
end end

View File

@ -26,7 +26,7 @@ function walk:cast(caster, target)
end end
function walk:update(caster, dt) function walk:update(caster, dt)
local charPos = caster.logic.mapLogic.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)

View File

@ -3,9 +3,13 @@
--- В love.update обновлять, в love.draw читать --- В love.update обновлять, в love.draw читать
Tree = { Tree = {
assets = (require "lib.utils.asset_bundle"):load() assets = (require "lib.utils.asset_bundle"):load()
} }
Tree.panning = require "lib/panning" Tree.panning = require "lib/panning"
Tree.controls = require "lib.controls" Tree.controls = require "lib.controls"
Tree.level = (require "lib.level.level").new("procedural", "flower_plains") -- для теста у нас только один уровень, который сразу же загружен Tree.level = (require "lib.level.level").new("procedural", "flower_plains") -- для теста у нас только один уровень, который сразу же загружен
Tree.behaviors = {} --- @todo написать нормальную загрузку поведений
Tree.behaviors.map = require "lib.character.behaviors.map"
Tree.behaviors.render = require "lib.character.behaviors.render"

View File

@ -13,6 +13,7 @@ function SkillButton:update(dt)
ui.Rectangle.update(self, dt) ui.Rectangle.update(self, dt)
self.color = self.owner.cast and { 0, 1, 0 } or { 1, 0, 0 } self.color = self.owner.cast and { 0, 1, 0 } or { 1, 0, 0 }
self:onTap(function() self:onTap(function()
print(self.owner.spellbook[self.spellId])
self.owner.cast = self.owner.cast and nil or self.owner.spellbook[self.spellId] self.owner.cast = self.owner.cast and nil or self.owner.spellbook[self.spellId]
end) end)
end end

View File

@ -3,25 +3,14 @@
local character = require "lib/character/character" local character = require "lib/character/character"
require "lib/tree" require "lib/tree"
local layout = require "lib.ui.layout" local layout = require "lib.ui.layout"
local spellbook = require "lib.spellbook"
function love.conf(t) function love.conf(t)
t.console = true t.console = true
end end
function love.load() function love.load()
for x = 0, 29, 1 do character.spawn("Hero", "warrior", Tree.assets.files.sprites.character)
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
love.window.setMode(1080, 720, { resizable = true, msaa = 4, vsync = true }) love.window.setMode(1080, 720, { resizable = true, msaa = 4, vsync = true })
end end