feature/simple_ui #18
19
assets/dev_icons/LICENSE.md
Normal file
19
assets/dev_icons/LICENSE.md
Normal file
@ -0,0 +1,19 @@
|
||||
Created by [Marvyra](https://www.gamedevmarket.net/member/marvyra)
|
||||
|
||||
4.1. A "Licence" means that the Seller grants to GDN (purely for the purpose of sub-licensing to the Purchaser) and GDN grants (by way of sub-licence thereof) to the Purchaser a non-exclusive perpetual licence to;
|
||||
|
||||
(a) use the Licensed Asset to create Derivative Works; and
|
||||
|
||||
(b) use the Licensed Asset and any Derivative Works as part of both Non-Monetized Media Products and Monetized Media Products, with no restriction on the number of projects the Licensed Asset may be used in. In either case, the Licensed Assets can be used in Media Products that are either:
|
||||
|
||||
i) used for the Purchaser's own personal use; and/or
|
||||
|
||||
ii) used for the Purchaser’s commercial use in which case it may be distributed, sold and supplied by the Purchaser for any fee that the Purchaser may determine.
|
||||
|
||||
4.2. A Licence does not allow the Purchaser to:
|
||||
|
||||
(a) Use the Licensed Asset or Derivative Works in a logo, trademark or service mark;
|
||||
|
||||
(b) Use, sell, share, transfer, give away, sublicense or redistribute the Licensed Asset or Derivate Works other than as part of the relevant Media Product; or
|
||||
|
||||
(c) Allow the user of the Media Product to extract the Licensed Asset or Derivative Works and use them outside of the relevant Media Product.
|
||||
BIN
assets/dev_icons/atlas.png
Normal file
BIN
assets/dev_icons/atlas.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
6
assets/dev_icons/manifest.lua
Normal file
6
assets/dev_icons/manifest.lua
Normal file
@ -0,0 +1,6 @@
|
||||
return {
|
||||
tileSize = 16,
|
||||
["dev_attack"] = { 44 },
|
||||
["dev_mana"] = { 42 },
|
||||
["dev_move"] = { 51 },
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
local easing = require "lib.utils.easing"
|
||||
|
||||
--- @alias voidCallback fun(): nil
|
||||
--- @alias animationRunner fun(node: AnimationNode)
|
||||
|
||||
@ -28,6 +30,10 @@
|
||||
--- @field children AnimationNode[]
|
||||
--- @field finish voidCallback
|
||||
--- @field onEnd voidCallback?
|
||||
--- @field duration number продолжительность в миллисекундах
|
||||
--- @field easing ease функция смягчения
|
||||
--- @field t number прогресс анимации
|
||||
--- @field finished boolean
|
||||
local animation = {}
|
||||
animation.__index = animation
|
||||
|
||||
@ -35,6 +41,7 @@ animation.__index = animation
|
||||
function animation:bubbleUp()
|
||||
self.count = self.count - 1
|
||||
if self.count > 0 then return end
|
||||
self.finished = true
|
||||
if self.onEnd then self.onEnd() end
|
||||
if self.parent then self.parent:bubbleUp() end
|
||||
end
|
||||
@ -50,7 +57,23 @@ function animation:chain(children)
|
||||
return self
|
||||
end
|
||||
|
||||
--- @param data {[1]: animationRunner?, onEnd?: voidCallback, children?: AnimationNode[]}
|
||||
--- Возвращает текущий прогресс анимации с учетом смягчения
|
||||
function animation:getValue()
|
||||
return self.easing(self.t)
|
||||
end
|
||||
|
||||
function animation:update(dt)
|
||||
if self.finished 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)
|
||||
@ -59,9 +82,14 @@ local function new(data)
|
||||
end
|
||||
t.onEnd = data.onEnd
|
||||
t.count = 1 -- своя анимация
|
||||
t.finished = false
|
||||
t.children = {}
|
||||
t:chain(data.children or {})
|
||||
t.duration = data.duration or 1000
|
||||
t.easing = data.easing or easing.linear
|
||||
t.t = 0
|
||||
t.finish = function()
|
||||
if t.finished then return end
|
||||
t:bubbleUp()
|
||||
for _, anim in ipairs(t.children) do
|
||||
anim:run()
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
--- @class Selector
|
||||
--- @field id Id | nil
|
||||
--- @field lastId Id | nil
|
||||
--- @field locked boolean
|
||||
local selector = {}
|
||||
selector.__index = selector
|
||||
@ -15,6 +16,7 @@ function selector:select(characterId)
|
||||
end
|
||||
|
||||
function selector:update(dt)
|
||||
self.lastId = self.id
|
||||
if self.locked or not Tree.controls:isJustPressed("select") then return end
|
||||
|
||||
local mousePosition = Tree.level.camera:toWorldPosition(Vec3 { love.mouse.getX(), love.mouse.getY() }):floor()
|
||||
@ -53,6 +55,16 @@ function selector:unlock()
|
||||
self.locked = false
|
||||
end
|
||||
|
||||
--- If a character was selected during this tick, returns its Id
|
||||
function selector:selected()
|
||||
if self.id and self.id ~= self.lastId then return self.id end
|
||||
end
|
||||
|
||||
--- If a character was **de**selected during this tick, returns its Id
|
||||
function selector:deselected()
|
||||
if not self.id and self.lastId then return self.lastId end
|
||||
end
|
||||
|
||||
return {
|
||||
new = new
|
||||
}
|
||||
|
||||
30
lib/simple_ui/element.lua
Normal file
30
lib/simple_ui/element.lua
Normal file
@ -0,0 +1,30 @@
|
||||
local Rect = require "lib.simple_ui.rect"
|
||||
|
||||
--- @class UIElement
|
||||
--- @field bounds Rect Прямоугольник, в границах которого размещается элемент. Размеры и положение в *локальных* координатах
|
||||
--- @field transform love.Transform Преобразование из локальных координат элемента (bounds) в экранные координаты
|
||||
local uiElement = {}
|
||||
uiElement.__index = uiElement
|
||||
|
||||
function uiElement:update(dt) end
|
||||
|
||||
function uiElement:draw() end
|
||||
|
||||
function uiElement:hitTest(screenX, screenY)
|
||||
local r, g, b, a = love.graphics.getColor()
|
||||
if a == 0 then return false end
|
||||
local lx, ly = self.transform:inverseTransformPoint(screenX, screenY)
|
||||
return self.bounds:hasPoint(lx, ly)
|
||||
end
|
||||
|
||||
--- @generic T : UIElement
|
||||
--- @param values table
|
||||
--- @param self T
|
||||
--- @return T
|
||||
function uiElement.new(self, values)
|
||||
values.bounds = values.bounds or Rect {}
|
||||
values.transform = values.transform or love.math.newTransform()
|
||||
return setmetatable(values, self)
|
||||
end
|
||||
|
||||
return uiElement
|
||||
24
lib/simple_ui/level/layout.lua
Normal file
24
lib/simple_ui/level/layout.lua
Normal file
@ -0,0 +1,24 @@
|
||||
local easing = require "lib.utils.easing"
|
||||
local AnimationNode = require "lib.animation_node"
|
||||
local Element = require "lib.simple_ui.element"
|
||||
local Rect = require "lib.simple_ui.rect"
|
||||
local SkillRow = require "lib.simple_ui.level.skill_row"
|
||||
|
||||
|
||||
local layout = {}
|
||||
function layout:update(dt)
|
||||
local cid = Tree.level.selector:selected()
|
||||
if cid then
|
||||
self.skillRow = SkillRow(cid)
|
||||
self.skillRow:show()
|
||||
elseif Tree.level.selector:deselected() then
|
||||
self.skillRow:hide()
|
||||
end
|
||||
if self.skillRow then self.skillRow:update(dt) end
|
||||
end
|
||||
|
||||
function layout:draw()
|
||||
if self.skillRow then self.skillRow:draw() end
|
||||
end
|
||||
|
||||
return layout
|
||||
161
lib/simple_ui/level/skill_row.lua
Normal file
161
lib/simple_ui/level/skill_row.lua
Normal file
@ -0,0 +1,161 @@
|
||||
local icons = require("lib.utils.sprite_atlas").load(Tree.assets.files.dev_icons)
|
||||
local easing = require "lib.utils.easing"
|
||||
local AnimationNode = require "lib.animation_node"
|
||||
local Element = require "lib.simple_ui.element"
|
||||
local Rect = require "lib.simple_ui.rect"
|
||||
|
||||
--- @class SkillButton : UIElement
|
||||
--- @field hovered boolean
|
||||
--- @field selected boolean
|
||||
--- @field onClick function?
|
||||
--- @field icon string
|
||||
local skillButton = setmetatable({}, Element)
|
||||
skillButton.__index = skillButton
|
||||
|
||||
function skillButton:update(dt)
|
||||
local mx, my = love.mouse.getPosition()
|
||||
if self:hitTest(mx, my) then
|
||||
self.hovered = true
|
||||
if Tree.controls:isJustPressed("select") then
|
||||
if self.onClick then self.onClick() end
|
||||
Tree.controls:consume("select")
|
||||
end
|
||||
else
|
||||
self.hovered = false
|
||||
end
|
||||
end
|
||||
|
||||
function skillButton:draw()
|
||||
love.graphics.push()
|
||||
love.graphics.applyTransform(self.transform)
|
||||
|
||||
local r, g, b, a = love.graphics.getColor()
|
||||
|
||||
if self.selected then
|
||||
love.graphics.setColor(0.3, 1, 0.3, a)
|
||||
elseif self.hovered then
|
||||
love.graphics.setColor(0.7, 1, 0.7, a)
|
||||
else
|
||||
love.graphics.setColor(1, 1, 1, a)
|
||||
end
|
||||
|
||||
love.graphics.translate(0, self.bounds.y)
|
||||
love.graphics.draw(icons.atlas, icons:pickQuad(self.icon))
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
--- @class SkillRow : UIElement
|
||||
--- @field characterId Id
|
||||
--- @field selected SkillButton?
|
||||
--- @field animationNode AnimationNode
|
||||
--- @field state "show" | "idle" | "hide"
|
||||
--- @field children SkillButton[]
|
||||
local skillRow = setmetatable({}, Element)
|
||||
skillRow.__index = skillRow
|
||||
|
||||
--- @param characterId Id
|
||||
--- @return SkillRow
|
||||
function skillRow.new(characterId)
|
||||
local t = {
|
||||
characterId = characterId,
|
||||
state = "show",
|
||||
children = {}
|
||||
}
|
||||
|
||||
setmetatable(t, skillRow)
|
||||
|
||||
local char = Tree.level.characters[characterId]
|
||||
char:try(Tree.behaviors.spellcaster, function(behavior)
|
||||
for i, spell in ipairs(behavior.spellbook) do
|
||||
local skb = skillButton:new { icon = spell.tag }
|
||||
skb.onClick = function()
|
||||
skb.selected = not skb.selected
|
||||
if t.selected then t.selected.selected = false end
|
||||
t.selected = skb
|
||||
|
||||
if not behavior.cast then
|
||||
behavior.cast = behavior.spellbook[i]
|
||||
behavior.state = "casting"
|
||||
else
|
||||
behavior.state = "idle"
|
||||
behavior.cast = nil
|
||||
end
|
||||
end
|
||||
t.children[i] = skb
|
||||
end
|
||||
end)
|
||||
|
||||
return t
|
||||
end
|
||||
|
||||
function skillRow:show()
|
||||
AnimationNode {
|
||||
function(animationNode)
|
||||
if self.animationNode then self.animationNode:finish() end
|
||||
self.animationNode = animationNode
|
||||
self.state = "show"
|
||||
end,
|
||||
duration = 300,
|
||||
onEnd = function()
|
||||
self.state = "idle"
|
||||
end,
|
||||
easing = easing.easeOutCubic
|
||||
}:run()
|
||||
end
|
||||
|
||||
function skillRow:hide()
|
||||
AnimationNode {
|
||||
function(animationNode)
|
||||
if self.animationNode then self.animationNode:finish() end
|
||||
self.animationNode = animationNode
|
||||
self.state = "hide"
|
||||
end,
|
||||
duration = 300,
|
||||
easing = easing.easeOutCubic
|
||||
}:run()
|
||||
end
|
||||
|
||||
function skillRow:update(dt)
|
||||
if self.animationNode then self.animationNode:update(dt) end
|
||||
|
||||
local iconSize = icons.tileSize
|
||||
local scale = (64 / iconSize)
|
||||
local screenW, screenH = love.graphics.getDimensions()
|
||||
local padding = 8
|
||||
local count = #self.children
|
||||
|
||||
self.bounds = Rect {
|
||||
width = count * icons.tileSize + (count - 1) * padding,
|
||||
height = iconSize,
|
||||
y = self.state == "show" and 10 * (1 - self.animationNode:getValue()) or 0
|
||||
}
|
||||
self.transform = love.math.newTransform():translate(screenW / 2,
|
||||
screenH - 16):scale(scale, scale):translate(-self.bounds.width / 2, -iconSize)
|
||||
|
||||
for i, skb in ipairs(self.children) do
|
||||
skb.bounds = Rect { height = iconSize, width = iconSize }
|
||||
skb.transform = self.transform:clone():translate(self.bounds.x + (i - 1) * iconSize +
|
||||
(i - 1) *
|
||||
padding, -- левый край ряда + размер предыдущих иконок + размер предыдущих отступов
|
||||
self.bounds.y -- высота не меняется
|
||||
)
|
||||
skb:update(dt)
|
||||
end
|
||||
end
|
||||
|
||||
function skillRow:draw()
|
||||
local alpha = 1
|
||||
if self.state == "show" then
|
||||
alpha = self.animationNode:getValue()
|
||||
elseif self.state == "hide" then
|
||||
alpha = 1 - self.animationNode:getValue()
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, alpha)
|
||||
for _, skb in ipairs(self.children) do
|
||||
skb:draw()
|
||||
end
|
||||
end
|
||||
|
||||
return skillRow.new
|
||||
24
lib/simple_ui/rect.lua
Normal file
24
lib/simple_ui/rect.lua
Normal file
@ -0,0 +1,24 @@
|
||||
--- @class Rect
|
||||
--- @field x number
|
||||
--- @field y number
|
||||
--- @field width number
|
||||
--- @field height number
|
||||
local rect = {}
|
||||
rect.__index = rect
|
||||
|
||||
--- @param table {x: number, y: number, width: number, height: number}
|
||||
function rect.new(table)
|
||||
local r = {
|
||||
x = table.x or 0,
|
||||
y = table.y or 0,
|
||||
width = table.width or 0,
|
||||
height = table.height or 0
|
||||
}
|
||||
return setmetatable(r, rect)
|
||||
end
|
||||
|
||||
function rect:hasPoint(x, y)
|
||||
return x >= self.x and x < self.width and y >= self.y and y < self.height
|
||||
end
|
||||
|
||||
return rect.new
|
||||
@ -10,11 +10,13 @@
|
||||
local AnimationNode = require "lib.animation_node"
|
||||
|
||||
--- @class Spell Здесь будет много бойлерплейта, поэтому тоже понадобится спеллмейкерский фреймворк, который просто вернет готовый Spell
|
||||
--- @field tag string
|
||||
--- @field update fun(self: Spell, caster: Character, dt: number): nil Изменяет состояние спелла
|
||||
--- @field draw fun(self: Spell): nil Рисует превью каста, ничего не должна изменять в идеальном мире
|
||||
--- @field cast fun(self: Spell, caster: Character, target: Vec3): boolean Вызывается в момент каста, изменяет мир. Возвращает bool в зависимости от того, получилось ли скастовать
|
||||
local spell = {}
|
||||
spell.__index = spell
|
||||
spell.tag = "base"
|
||||
|
||||
function spell:update(caster, dt) end
|
||||
|
||||
@ -26,6 +28,7 @@ local walk = setmetatable({
|
||||
--- @type Deque
|
||||
path = nil
|
||||
}, spell)
|
||||
walk.tag = "dev_move"
|
||||
|
||||
function walk:cast(caster, target)
|
||||
if not caster:try(Tree.behaviors.stats, function(stats)
|
||||
@ -73,6 +76,7 @@ function walk:draw()
|
||||
end
|
||||
|
||||
local regenerateMana = setmetatable({}, spell)
|
||||
regenerateMana.tag = "dev_mana"
|
||||
|
||||
function regenerateMana:cast(caster, target)
|
||||
caster:try(Tree.behaviors.stats, function(stats)
|
||||
@ -92,6 +96,7 @@ function regenerateMana:cast(caster, target)
|
||||
end
|
||||
|
||||
local attack = setmetatable({}, spell)
|
||||
attack.tag = "dev_attack"
|
||||
|
||||
function attack:cast(caster, target)
|
||||
if caster:try(Tree.behaviors.map, function(map)
|
||||
|
||||
569
lib/ui/core.lua
569
lib/ui/core.lua
@ -1,569 +0,0 @@
|
||||
local controls = require "lib.controls"
|
||||
|
||||
---@class UIConstraints
|
||||
---@field min_w number
|
||||
---@field min_h number
|
||||
---@field max_w number
|
||||
---@field max_h number
|
||||
|
||||
local ui = {}
|
||||
|
||||
-- =============== Constraints helpers ===============
|
||||
|
||||
local function make_constraints(min_w, min_h, max_w, max_h)
|
||||
return {
|
||||
min_w = min_w or 0,
|
||||
min_h = min_h or 0,
|
||||
max_w = max_w or math.huge,
|
||||
max_h = max_h or math.huge,
|
||||
}
|
||||
end
|
||||
|
||||
local function clamp(v, lo, hi)
|
||||
if v < lo then return lo end
|
||||
if v > hi then return hi end
|
||||
return v
|
||||
end
|
||||
|
||||
local function clamp_size(w, h, c)
|
||||
return clamp(w, c.min_w, c.max_w), clamp(h, c.min_h, c.max_h)
|
||||
end
|
||||
|
||||
local function loosen(c)
|
||||
-- "loose" constraints (0..max), удобно для wrap-контейнеров/Align
|
||||
return make_constraints(0, 0, c.max_w, c.max_h)
|
||||
end
|
||||
|
||||
local function tighten(c, w, h)
|
||||
-- exact (tight) constraints — зажимают размер ребёнка
|
||||
w, h = clamp_size(w, h, c)
|
||||
return make_constraints(w, h, w, h)
|
||||
end
|
||||
|
||||
-- =============== Base elements ===============
|
||||
|
||||
--- @class Element
|
||||
--- @field parent Element|nil
|
||||
--- @field children Element[]
|
||||
--- @field child Element|nil
|
||||
--- @field origin Vec3
|
||||
--- @field size Vec3
|
||||
--- @field state table
|
||||
local Element = {}
|
||||
Element.__index = Element
|
||||
|
||||
function Element:new(props)
|
||||
local o = setmetatable({}, self)
|
||||
o.parent = nil
|
||||
o.children = {}
|
||||
o.origin = Vec3 { 0, 0, 0 }
|
||||
o.size = Vec3 { 0, 0, 0 }
|
||||
o.state = {}
|
||||
if props then
|
||||
-- Копируем "публичные" поля; Таблицы клонируем, если можно
|
||||
for k, v in pairs(props) do
|
||||
if type(v) == "table" then
|
||||
o[k] = v.copy and v:copy() or v
|
||||
else
|
||||
o[k] = v
|
||||
end
|
||||
end
|
||||
end
|
||||
return o
|
||||
end
|
||||
|
||||
---@param dt number
|
||||
function Element:update(dt)
|
||||
-- По умолчанию спускаем update вниз
|
||||
for _, ch in ipairs(self.children or {}) do
|
||||
ch:update(dt)
|
||||
end
|
||||
end
|
||||
|
||||
---@param constraints UIConstraints
|
||||
function Element:layout(constraints)
|
||||
-- Базовое поведение: размер = объединённый bbox детей (wrap-content)
|
||||
local max_w, max_h = 0, 0
|
||||
for _, ch in ipairs(self.children) do
|
||||
ch:layout(loosen(constraints)) -- по умолчанию не заставляем детей растягиваться
|
||||
max_w = math.max(max_w, ch.size.x)
|
||||
max_h = math.max(max_h, ch.size.y)
|
||||
end
|
||||
local w, h = clamp_size(max_w, max_h, constraints)
|
||||
self.size.x, self.size.y = w, h
|
||||
end
|
||||
|
||||
---@param origin Vec3
|
||||
function Element:arrange(origin)
|
||||
-- Базово: ставим себя, а детей кладём в (0,0) внутри нас
|
||||
self.origin = Vec3 { origin.x, origin.y, origin.z or 0 }
|
||||
for _, ch in ipairs(self.children) do
|
||||
ch:arrange(Vec3 { self.origin.x, self.origin.y, self.origin.z })
|
||||
end
|
||||
end
|
||||
|
||||
function Element:draw()
|
||||
-- По умолчанию — только рисуем детей
|
||||
for _, ch in ipairs(self.children) do
|
||||
ch:draw()
|
||||
end
|
||||
end
|
||||
|
||||
--- calls the callback if clicked inside own bounding box (rectangular)
|
||||
--- @param callback function
|
||||
function Element:onTap(callback)
|
||||
local mx, my = love.mouse.getPosition()
|
||||
if mx > self.origin.x and mx < self.origin.x + self.size.x
|
||||
and my > self.origin.y and my < self.origin.y + self.size.y
|
||||
then
|
||||
if controls:isJustPressed("select") then
|
||||
controls:consume("select")
|
||||
callback()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- =============== SingleChild / MultiChild базовые ===============
|
||||
|
||||
--- @class SingleChildElement: Element
|
||||
local SingleChildElement = setmetatable({}, { __index = Element })
|
||||
SingleChildElement.__index = SingleChildElement
|
||||
|
||||
function SingleChildElement:new(props)
|
||||
local o = Element.new(self, props)
|
||||
if o.child then
|
||||
o.child.parent = o
|
||||
o.children = { o.child }
|
||||
end
|
||||
return o
|
||||
end
|
||||
|
||||
---@class MultiChildElement: Element
|
||||
local MultiChildElement = setmetatable({}, { __index = Element })
|
||||
MultiChildElement.__index = MultiChildElement
|
||||
|
||||
function MultiChildElement:new(props)
|
||||
local o = Element.new(self, props)
|
||||
o.children = o.children or o.children or {}
|
||||
o.children = o.children -- ensure array
|
||||
if props and props.children then
|
||||
for _, ch in ipairs(props.children) do
|
||||
ch.parent = o
|
||||
end
|
||||
end
|
||||
return o
|
||||
end
|
||||
|
||||
-- =============== Root ===============
|
||||
|
||||
---@class Root: SingleChildElement
|
||||
local Root = setmetatable({}, { __index = SingleChildElement })
|
||||
Root.__index = Root
|
||||
|
||||
function Root:new(props)
|
||||
return SingleChildElement.new(self, props)
|
||||
end
|
||||
|
||||
function Root:update(dt)
|
||||
-- Root может делать глобальные обновления, но главное — спустить вниз
|
||||
if self.child then self.child:update(dt) end
|
||||
|
||||
-- На каждом кадре делаем полный проход раскладки:
|
||||
local w, h = love.graphics.getWidth(), love.graphics.getHeight()
|
||||
self.size = Vec3 { w, h, 0 }
|
||||
local constraints = make_constraints(w, h, w, h) -- tight: размер окна
|
||||
if self.child then
|
||||
-- layout с точными ограничениями родителя
|
||||
self.child:layout(loosen(constraints)) -- разрешаем контенту быть меньше окна
|
||||
-- arrange кладём ребёнка в начало (0,0), а Align/др. уже выровняет внутри себя
|
||||
self.child:arrange(Vec3 { 0, 0, 0 })
|
||||
end
|
||||
end
|
||||
|
||||
function Root:draw()
|
||||
if self.child then self.child:draw() end
|
||||
end
|
||||
|
||||
-- =============== Rectangle (leaf) ===============
|
||||
|
||||
---@class Rectangle: Element
|
||||
---@field color number[] @ {r,g,b} 0..1
|
||||
---@field size Vec3
|
||||
local Rectangle = setmetatable({}, { __index = Element })
|
||||
Rectangle.__index = Rectangle
|
||||
|
||||
function Rectangle:new(props)
|
||||
local o = Element.new(self, props)
|
||||
--- @cast o Rectangle
|
||||
o.color = o.color or { 1, 1, 1 }
|
||||
-- size должен быть задан; если нет — size = 0,0
|
||||
o.size = o.size or Vec3 { 0, 0, 0 }
|
||||
return o
|
||||
end
|
||||
|
||||
function Rectangle:layout(constraints)
|
||||
local w = self.size.x or 0
|
||||
local h = self.size.y or 0
|
||||
w, h = clamp_size(w, h, constraints)
|
||||
self.size.x, self.size.y = w, h
|
||||
end
|
||||
|
||||
function Rectangle:draw()
|
||||
love.graphics.setColor(self.color[1], self.color[2], self.color[3], 1)
|
||||
love.graphics.rectangle("fill", self.origin.x, self.origin.y, self.size.x, self.size.y)
|
||||
-- Сброс цвета — по вкусу
|
||||
end
|
||||
|
||||
-- =============== Align (single child) ===============
|
||||
|
||||
---@class Align: SingleChildElement
|
||||
---@field alignment string "center_left" | "center" | "center_right" | "top_left" | "top_center" | "top_right" | "bottom_left" | "bottom_center" | "bottom_right"
|
||||
---@field expand boolean если true — занимает все доступное от родителя
|
||||
local Align = setmetatable({}, { __index = SingleChildElement })
|
||||
Align.__index = Align
|
||||
|
||||
function Align:new(props)
|
||||
local o = SingleChildElement.new(self, props)
|
||||
---@cast o Align
|
||||
o.alignment = o.alignment or "center"
|
||||
o.expand = (o.expand ~= nil) and o.expand or true -- по умолчанию растягиваемся под родителя
|
||||
return o
|
||||
end
|
||||
|
||||
function Align:layout(constraints)
|
||||
if self.child then
|
||||
-- Ребёнка считаем "loose" — пусть занимает сколько хочет
|
||||
self.child:layout(loosen(constraints))
|
||||
if self.expand then
|
||||
-- Сам Align займет максимум
|
||||
self.size.x, self.size.y = clamp_size(constraints.max_w, constraints.max_h, constraints)
|
||||
else
|
||||
-- Или же wrap по ребёнку
|
||||
local w, h = self.child.size.x, self.child.size.y
|
||||
self.size.x, self.size.y = clamp_size(w, h, constraints)
|
||||
end
|
||||
else
|
||||
self.size.x, self.size.y = clamp_size(0, 0, constraints)
|
||||
end
|
||||
end
|
||||
|
||||
function Align:arrange(origin)
|
||||
self.origin = Vec3 { origin.x, origin.y, origin.z or 0 }
|
||||
if not self.child then return end
|
||||
|
||||
local pw, ph = self.size.x, self.size.y
|
||||
local cw, ch = self.child.size.x, self.child.size.y
|
||||
local x, y = self.origin.x, self.origin.y
|
||||
|
||||
--- @todo сделать красиво
|
||||
if self.alignment == "center" then
|
||||
x = x + (pw - cw) / 2
|
||||
y = y + (ph - ch) / 2
|
||||
elseif self.alignment == "center_left" then
|
||||
y = y + (ph - ch) / 2
|
||||
elseif self.alignment == "center_right" then
|
||||
x = x + (pw - cw)
|
||||
y = y + (ph - ch) / 2
|
||||
elseif self.alignment == "top_left" then
|
||||
-- x,y остаются
|
||||
elseif self.alignment == "top_center" then
|
||||
x = x + (pw - cw) / 2
|
||||
elseif self.alignment == "top_right" then
|
||||
x = x + (pw - cw)
|
||||
elseif self.alignment == "bottom_left" then
|
||||
y = y + (ph - ch)
|
||||
elseif self.alignment == "bottom_center" then
|
||||
y = y + (ph - ch)
|
||||
x = x + (pw - cw) / 2
|
||||
elseif self.alignment == "bottom_right" then
|
||||
x = x + (pw - cw)
|
||||
y = y + (ph - ch)
|
||||
else
|
||||
-- неизвестное — по центру
|
||||
x = x + (pw - cw) / 2
|
||||
y = y + (ph - ch) / 2
|
||||
end
|
||||
|
||||
self.child:arrange(Vec3 { x, y, self.origin.z })
|
||||
end
|
||||
|
||||
-- =============== Row / Column (multi) ===============
|
||||
|
||||
-- helper: прочитать flex у ребёнка (если есть)
|
||||
local function get_flex_of(ch)
|
||||
return (ch.get_flex and ch:get_flex()) or 0
|
||||
end
|
||||
|
||||
---@class Row: MultiChildElement
|
||||
---@field gap number расстояние между детьми
|
||||
local Row = setmetatable({}, { __index = MultiChildElement })
|
||||
Row.__index = Row
|
||||
|
||||
function Row:new(props)
|
||||
local o = MultiChildElement.new(self, props)
|
||||
---@cast o Row
|
||||
o.gap = o.gap or 0
|
||||
return o
|
||||
end
|
||||
|
||||
function Row:layout(constraints)
|
||||
local total_gap = (#self.children > 1) and (self.gap * (#self.children - 1)) or 0
|
||||
|
||||
local fixed_w, max_h = 0, 0
|
||||
local flex_children, total_flex = {}, 0
|
||||
local loose = loosen(constraints)
|
||||
|
||||
-- проход 1: меряем нефлекс-детей, собираем флекс-список
|
||||
for _, ch in ipairs(self.children) do
|
||||
local f = get_flex_of(ch)
|
||||
if f > 0 then
|
||||
total_flex = total_flex + f
|
||||
flex_children[#flex_children + 1] = { node = ch, flex = f }
|
||||
else
|
||||
ch:layout(loose)
|
||||
fixed_w = fixed_w + ch.size.x
|
||||
if ch.size.y > max_h then max_h = ch.size.y end
|
||||
end
|
||||
end
|
||||
|
||||
local min_row_w = fixed_w + total_gap
|
||||
local wants_expand = total_flex > 0
|
||||
|
||||
-- КЛЮЧ: если есть флекс → растягиваемся до доступного max_w
|
||||
local candidate = wants_expand and constraints.max_w or min_row_w
|
||||
local target_w = clamp(candidate, math.max(min_row_w, constraints.min_w), constraints.max_w)
|
||||
local remaining = math.max(0, target_w - min_row_w)
|
||||
|
||||
-- проход 2: раздаём остаток флекс-детям (tight по ширине)
|
||||
if total_flex > 0 and remaining > 0 then
|
||||
local acc = 0
|
||||
for i, item in ipairs(flex_children) do
|
||||
local alloc = (i == #flex_children)
|
||||
and (remaining - acc) -- последний добирает остаток для суммирования в точности в target_w
|
||||
or (remaining * item.flex / total_flex)
|
||||
acc = acc + alloc
|
||||
local c = make_constraints(alloc, 0, alloc, loose.max_h)
|
||||
item.node:layout(c)
|
||||
if item.node.size.y > max_h then max_h = item.node.size.y end
|
||||
end
|
||||
else
|
||||
-- даже если remaining==0, флекс-детей всё равно надо промерить (tight нулевой шириной)
|
||||
for _, item in ipairs(flex_children) do
|
||||
local c = make_constraints(0, 0, 0, loose.max_h)
|
||||
item.node:layout(c)
|
||||
if item.node.size.y > max_h then max_h = item.node.size.y end
|
||||
end
|
||||
end
|
||||
|
||||
self.size.x, self.size.y = target_w, clamp(max_h, constraints.min_h, constraints.max_h)
|
||||
end
|
||||
|
||||
function Row:arrange(origin)
|
||||
self.origin = Vec3 { origin.x, origin.y, origin.z or 0 }
|
||||
local cursor_x = self.origin.x
|
||||
local y_base = self.origin.y
|
||||
|
||||
for i, ch in ipairs(self.children) do
|
||||
-- Вертикальное выравнивание: по базовой линии (top). Можно прокачать до "center" / "bottom".
|
||||
ch:arrange(Vec3 { cursor_x, y_base, self.origin.z })
|
||||
cursor_x = cursor_x + ch.size.x + (i < #self.children and self.gap or 0)
|
||||
end
|
||||
end
|
||||
|
||||
---@class Column: MultiChildElement
|
||||
---@field gap number
|
||||
local Column = setmetatable({}, { __index = MultiChildElement })
|
||||
Column.__index = Column
|
||||
|
||||
function Column:new(props)
|
||||
local o = MultiChildElement.new(self, props)
|
||||
--- @cast o Column
|
||||
o.gap = o.gap or 0
|
||||
return o
|
||||
end
|
||||
|
||||
function Column:layout(constraints)
|
||||
local total_gap = (#self.children > 1) and (self.gap * (#self.children - 1)) or 0
|
||||
|
||||
local fixed_h, max_w = 0, 0
|
||||
local flex_children, total_flex = {}, 0
|
||||
local loose = loosen(constraints)
|
||||
|
||||
for _, ch in ipairs(self.children) do
|
||||
local f = get_flex_of(ch)
|
||||
if f > 0 then
|
||||
total_flex = total_flex + f
|
||||
flex_children[#flex_children + 1] = { node = ch, flex = f }
|
||||
else
|
||||
ch:layout(loose)
|
||||
fixed_h = fixed_h + ch.size.y
|
||||
if ch.size.x > max_w then max_w = ch.size.x end
|
||||
end
|
||||
end
|
||||
|
||||
local min_col_h = fixed_h + total_gap
|
||||
local wants_expand = (total_flex > 0)
|
||||
local candidate = wants_expand and constraints.max_h or min_col_h
|
||||
local target_h = clamp(candidate, math.max(min_col_h, constraints.min_h), constraints.max_h)
|
||||
local remaining = math.max(0, target_h - min_col_h)
|
||||
|
||||
if total_flex > 0 and remaining > 0 then
|
||||
local acc = 0
|
||||
for i, item in ipairs(flex_children) do
|
||||
local alloc = (i == #flex_children)
|
||||
and (remaining - acc)
|
||||
or (remaining * item.flex / total_flex)
|
||||
acc = acc + alloc
|
||||
local c = make_constraints(0, alloc, loose.max_w, alloc)
|
||||
item.node:layout(c)
|
||||
if item.node.size.x > max_w then max_w = item.node.size.x end
|
||||
end
|
||||
else
|
||||
for _, item in ipairs(flex_children) do
|
||||
local c = make_constraints(0, 0, loose.max_w, 0)
|
||||
item.node:layout(c)
|
||||
if item.node.size.x > max_w then max_w = item.node.size.x end
|
||||
end
|
||||
end
|
||||
|
||||
self.size.x, self.size.y = clamp(max_w, constraints.min_w, constraints.max_w), target_h
|
||||
end
|
||||
|
||||
function Column:arrange(origin)
|
||||
self.origin = Vec3 { origin.x, origin.y, origin.z or 0 }
|
||||
local x_base = self.origin.x
|
||||
local cursor_y = self.origin.y
|
||||
|
||||
for i, ch in ipairs(self.children) do
|
||||
ch:arrange(Vec3 { x_base, cursor_y, self.origin.z })
|
||||
cursor_y = cursor_y + ch.size.y + (i < #self.children and self.gap or 0)
|
||||
end
|
||||
end
|
||||
|
||||
-----------------------------------------------------------------
|
||||
|
||||
---@class Expanded: SingleChildElement
|
||||
---@field flex integer -- коэффициент флекса (>=1)
|
||||
local Expanded = setmetatable({}, { __index = SingleChildElement })
|
||||
Expanded.__index = Expanded
|
||||
|
||||
---@param props { flex?: integer, child?: Element }|nil
|
||||
---@return Expanded
|
||||
function Expanded:new(props)
|
||||
local o = SingleChildElement.new(self, props)
|
||||
---@cast o Expanded
|
||||
o.flex = (o.flex and math.max(1, math.floor(o.flex))) or 1
|
||||
return o
|
||||
end
|
||||
|
||||
function Expanded:get_flex()
|
||||
return self.flex
|
||||
end
|
||||
|
||||
function Expanded:layout(constraints)
|
||||
if self.child then
|
||||
self.child:layout(constraints)
|
||||
-- Становимся размером с ребёнка с учётом ограничений
|
||||
local w, h = self.child.size.x, self.child.size.y
|
||||
w, h = clamp_size(w, h, constraints)
|
||||
self.size.x, self.size.y = w, h
|
||||
else
|
||||
-- Пустой Expanded — просто занимает выделенное родителем место (можно для Spacer)
|
||||
local w, h = clamp_size(0, 0, constraints)
|
||||
self.size.x, self.size.y = w, h
|
||||
end
|
||||
end
|
||||
|
||||
function Expanded:arrange(origin)
|
||||
self.origin = Vec3 { origin.x, origin.y, origin.z or 0 }
|
||||
if self.child then
|
||||
self.child:arrange(self.origin)
|
||||
end
|
||||
end
|
||||
|
||||
-- =============== Padding (single child) ===============
|
||||
|
||||
---@class Padding: SingleChildElement
|
||||
---@field padding {left?: number, right?: number, top?: number, bottom?: number}
|
||||
local Padding = setmetatable({}, { __index = SingleChildElement })
|
||||
Padding.__index = Padding
|
||||
|
||||
---@param props { padding?: {left?: number, right?: number, top?: number, bottom?: number}, child?: Element }|nil
|
||||
---@return Padding
|
||||
function Padding:new(props)
|
||||
local o = SingleChildElement.new(self, props)
|
||||
---@cast o Padding
|
||||
local p = o.padding or {}
|
||||
o.padding = {
|
||||
left = tonumber(p.left) or 0,
|
||||
right = tonumber(p.right) or 0,
|
||||
top = tonumber(p.top) or 0,
|
||||
bottom = tonumber(p.bottom) or 0,
|
||||
}
|
||||
return o
|
||||
end
|
||||
|
||||
function Padding:layout(constraints)
|
||||
local L = self.padding.left
|
||||
local R = self.padding.right
|
||||
local T = self.padding.top
|
||||
local B = self.padding.bottom
|
||||
local hp = L + R
|
||||
local vp = T + B
|
||||
|
||||
-- Вычитаем паддинги из ограничений для ребёнка
|
||||
local child_min_w = math.max(0, constraints.min_w - hp)
|
||||
local child_min_h = math.max(0, constraints.min_h - vp)
|
||||
local child_max_w = math.max(0, constraints.max_w - hp)
|
||||
local child_max_h = math.max(0, constraints.max_h - vp)
|
||||
local child_c = make_constraints(child_min_w, child_min_h, child_max_w, child_max_h)
|
||||
|
||||
if self.child then
|
||||
self.child:layout(child_c)
|
||||
local w = self.child.size.x + hp
|
||||
local h = self.child.size.y + vp
|
||||
self.size.x, self.size.y = clamp_size(w, h, constraints)
|
||||
else
|
||||
-- Нет ребёнка: размер равен сумме паддингов (с учётом ограничений)
|
||||
self.size.x, self.size.y = clamp_size(hp, vp, constraints)
|
||||
end
|
||||
end
|
||||
|
||||
function Padding:arrange(origin)
|
||||
self.origin = Vec3 { origin.x, origin.y, origin.z or 0 }
|
||||
if not self.child then return end
|
||||
local L = self.padding.left
|
||||
local T = self.padding.top
|
||||
self.child:arrange(Vec3 { self.origin.x + L, self.origin.y + T, self.origin.z })
|
||||
end
|
||||
|
||||
-- =============== Public constructors (callable) ===============
|
||||
|
||||
local function mk_ctor(class)
|
||||
-- Сохраняем существующую метатаблицу (в ней уже есть __index на родителя!)
|
||||
local mt = getmetatable(class) or {}
|
||||
mt.__call = function(_, props)
|
||||
return class:new(props or {})
|
||||
end
|
||||
setmetatable(class, mt)
|
||||
return class
|
||||
end
|
||||
|
||||
ui.Element = Element
|
||||
ui.Root = mk_ctor(Root)
|
||||
ui.Align = mk_ctor(Align)
|
||||
ui.Row = mk_ctor(Row)
|
||||
ui.Column = mk_ctor(Column)
|
||||
ui.Rectangle = mk_ctor(Rectangle)
|
||||
ui.Expanded = mk_ctor(Expanded)
|
||||
ui.Padding = mk_ctor(Padding)
|
||||
|
||||
-- Экспорт вспомогательных, если пригодится
|
||||
ui.constraints = {
|
||||
make = make_constraints,
|
||||
loosen = loosen,
|
||||
tighten = tighten,
|
||||
clamp_size = clamp_size,
|
||||
}
|
||||
|
||||
return ui
|
||||
@ -1,58 +0,0 @@
|
||||
local Vec3 = require "lib.utils.vec3"
|
||||
local ui = require "lib.ui.core"
|
||||
|
||||
|
||||
--- @class SkillButton : Rectangle
|
||||
--- @field owner Character
|
||||
--- @field spellId number
|
||||
local SkillButton = ui.Rectangle {
|
||||
size = Vec3 { 100, 100 },
|
||||
color = { 1, 0, 0 },
|
||||
}
|
||||
function SkillButton:update(dt)
|
||||
ui.Rectangle.update(self, dt)
|
||||
self.owner:try(Tree.behaviors.spellcaster, function(spellcaster)
|
||||
self.color = spellcaster.state == "casting" and { 0, 1, 0 } or { 1, 0, 0 }
|
||||
self:onTap(function()
|
||||
if not spellcaster.cast then
|
||||
spellcaster.cast = spellcaster.spellbook
|
||||
[self.spellId]
|
||||
spellcaster.state = "casting"
|
||||
else
|
||||
spellcaster.state = "idle"
|
||||
spellcaster.cast = nil
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
local skillRows = {}
|
||||
|
||||
local layout = {}
|
||||
function layout:build()
|
||||
return ui.Root {
|
||||
child = ui.Align {
|
||||
alignment = "bottom_center",
|
||||
child =
|
||||
--- для каждого персонажа строим свой ряд скиллов, сохраняем его на потом и возвращаем
|
||||
--- если персонаж не выделен, не возвращаем ничего
|
||||
(function()
|
||||
local id = Tree.level.selector.id
|
||||
if not id then return nil end
|
||||
if skillRows[id] then return skillRows[id] end
|
||||
local r =
|
||||
ui.Row {
|
||||
children = {
|
||||
ui.Padding { padding = { left = 4, right = 4 }, child = setmetatable({ owner = Tree.level.characters[id], spellId = 1 }, { __index = SkillButton }) },
|
||||
ui.Padding { padding = { left = 4, right = 4 }, child = setmetatable({ owner = Tree.level.characters[id], spellId = 2 }, { __index = SkillButton }) },
|
||||
ui.Padding { padding = { left = 4, right = 4 }, child = setmetatable({ owner = Tree.level.characters[id], spellId = 3 }, { __index = SkillButton }) },
|
||||
}
|
||||
}
|
||||
skillRows[id] = r
|
||||
return r
|
||||
end)()
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
return layout
|
||||
57
lib/utils/easing.lua
Normal file
57
lib/utils/easing.lua
Normal file
@ -0,0 +1,57 @@
|
||||
---- Функции смягчения, нормализованные к [0, 1]
|
||||
|
||||
---@alias ease fun(x: number): number
|
||||
|
||||
local easing = {}
|
||||
|
||||
--- @type ease
|
||||
function easing.linear(x)
|
||||
return x
|
||||
end
|
||||
|
||||
--- @type ease
|
||||
function easing.easeInSine(x)
|
||||
return 1 - math.cos((x * math.pi) / 2);
|
||||
end
|
||||
|
||||
--- @type ease
|
||||
function easing.easeOutSine(x)
|
||||
return math.sin((x * math.pi) / 2);
|
||||
end
|
||||
|
||||
--- @type ease
|
||||
function easing.easeInOutSine(x)
|
||||
return -(math.cos(x * math.pi) - 1) / 2;
|
||||
end
|
||||
|
||||
--- @type ease
|
||||
function easing.easeInQuad(x)
|
||||
return x * x
|
||||
end
|
||||
|
||||
--- @type ease
|
||||
function easing.easeOutQuad(x)
|
||||
return 1 - (1 - x) * (1 - x)
|
||||
end
|
||||
|
||||
--- @type ease
|
||||
function easing.easeInOutQuad(x)
|
||||
return x < 0.5 and 2 * x * x or 1 - math.pow(-2 * x + 2, 2) / 2;
|
||||
end
|
||||
|
||||
--- @type ease
|
||||
function easing.easeInCubic(x)
|
||||
return x * x * x
|
||||
end
|
||||
|
||||
--- @type ease
|
||||
function easing.easeOutCubic(x)
|
||||
return 1 - math.pow(1 - x, 3)
|
||||
end
|
||||
|
||||
--- @type ease
|
||||
function easing.easeInOutCubic(x)
|
||||
return x < 0.5 and 4 * x * x * x or 1 - math.pow(-2 * x + 2, 3) / 2;
|
||||
end
|
||||
|
||||
return easing
|
||||
9
main.lua
9
main.lua
@ -2,8 +2,7 @@
|
||||
|
||||
local character = require "lib/character/character"
|
||||
require "lib/tree"
|
||||
local layout = require "lib.ui.layout"
|
||||
|
||||
local testLayout = require "lib.simple_ui.level.layout"
|
||||
|
||||
function love.conf(t)
|
||||
t.console = true
|
||||
@ -19,8 +18,7 @@ local lt = "0"
|
||||
function love.update(dt)
|
||||
local t1 = love.timer.getTime()
|
||||
Tree.controls:poll()
|
||||
Widgets = layout:build()
|
||||
Widgets:update(dt) -- логика UI-слоя должна отработать раньше всех, потому что нужно перехватить жесты и не пустить их дальше
|
||||
testLayout:update(dt) -- логика UI-слоя должна отработать раньше всех, потому что нужно перехватить жесты и не пустить их дальше
|
||||
Tree.panning:update(dt)
|
||||
Tree.level:update(dt)
|
||||
Tree.controls:cache()
|
||||
@ -54,8 +52,7 @@ function love.draw()
|
||||
Tree.level:draw()
|
||||
|
||||
Tree.level.camera:detach()
|
||||
|
||||
Widgets:draw()
|
||||
testLayout:draw()
|
||||
love.graphics.setColor(1, 1, 1)
|
||||
|
||||
local stats = "fps: " .. love.timer.getFPS() .. " lt: " .. lt .. " dt: " .. dt
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user