diff --git a/lib/ui/core.lua b/lib/ui/core.lua new file mode 100644 index 0000000..3fe6fe5 --- /dev/null +++ b/lib/ui/core.lua @@ -0,0 +1,553 @@ +---@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 + +-- =============== 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 diff --git a/lib/ui/layout.lua b/lib/ui/layout.lua new file mode 100644 index 0000000..554df3e --- /dev/null +++ b/lib/ui/layout.lua @@ -0,0 +1,36 @@ +local Vec3 = require "lib.utils.vec3" +local ui = require "lib.ui.core" + +--- @type Rectangle +local ReactiveRectangle = ui.Rectangle { + size = Vec3 { 100, 100 }, + color = { 1, 0, 0 }, + state = { tick = 0 } +} +function ReactiveRectangle:update(dt) + getmetatable(self):update(dt) + + 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 + self.color = { 0, 1, 0 } + else + self.color = { 1, 0, 0 } + end + + self.state.tick = self.state.tick + 1 +end + +local Layout = ui.Root { + child = ui.Align { + alignment = "bottom_center", + child = ui.Row { + children = { + ReactiveRectangle + } + } + } +} + +return Layout diff --git a/main.lua b/main.lua index b636276..2577671 100644 --- a/main.lua +++ b/main.lua @@ -1,5 +1,6 @@ -- CameraLoader = require 'lib/camera' +local Widgets = require "lib.ui.layout" local character = require "lib/character/character" require "lib/tree" @@ -18,9 +19,11 @@ local lt = "0" function love.update(dt) local t1 = love.timer.getTime() Tree.controls:poll() + Widgets:update(dt) -- логика UI-слоя должна отработать раньше всех, потому что нужно перехватить жесты и не пустить их дальше Tree.panning:update(dt) Tree.level:update(dt) Tree.controls:cache() + local t2 = love.timer.getTime() lt = string.format("%.3f", (t2 - t1) * 1000) end @@ -51,6 +54,9 @@ function love.draw() Tree.level.camera:detach() + Widgets:draw() + love.graphics.setColor(1, 1, 1) + local stats = "fps: " .. love.timer.getFPS() .. " lt: " .. lt .. " dt: " .. dt love.graphics.print(stats, 10, 10)