feature/simple_ui (#18)

#15
Реализовано втупую и всякие выравнивания с текстами надо добавлять вручную.
Зато у нас есть поддержка анимаций и дерева матриц преобразования.
Вообще UI - это просто иерархия прямоугольников на экране.

Reviewed-on: #18
This commit is contained in:
PeaAshMeter 2025-11-08 01:32:46 +03:00
parent c1e5ba880d
commit 538bd1df33
14 changed files with 370 additions and 634 deletions

View 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 Purchasers 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

@ -0,0 +1,6 @@
return {
tileSize = 16,
["dev_attack"] = { 44 },
["dev_mana"] = { 42 },
["dev_move"] = { 51 },
}

View File

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

View File

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

View 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

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

View File

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

View File

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

View File

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

View File

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