diff --git a/assets/masks/rrect32.png b/assets/masks/rrect32.png new file mode 100644 index 0000000..dff5102 Binary files /dev/null and b/assets/masks/rrect32.png differ diff --git a/assets/masks/squircle.png b/assets/masks/squircle.png new file mode 100644 index 0000000..8bd5a6d Binary files /dev/null and b/assets/masks/squircle.png differ diff --git a/assets/shaders/alpha_mask.glsl b/assets/shaders/alpha_mask.glsl new file mode 100644 index 0000000..5339bf7 --- /dev/null +++ b/assets/shaders/alpha_mask.glsl @@ -0,0 +1,8 @@ +vec4 effect(vec4 color, Image tex, vec2 texCoord, vec2 screenCoord) +{ + vec4 px = Texel(tex, texCoord); + if (px.a == 0.0) { + discard; + } + return vec4(1.0); +} diff --git a/assets/shaders/reveal.glsl b/assets/shaders/reveal.glsl new file mode 100644 index 0000000..2af557e --- /dev/null +++ b/assets/shaders/reveal.glsl @@ -0,0 +1,24 @@ +extern float t; +extern float blockSize; + +// hash-функция для шума по целочисленным координатам блока +float hash(vec2 p) { + p = vec2( + dot(p, vec2(127.1, 311.7)), + dot(p, vec2(269.5, 183.3)) + ); + return fract(sin(p.x + p.y) * 43758.5453123); +} + +vec4 effect(vec4 color, Image tex, vec2 texCoord, vec2 screenCoord) +{ + float blockSize = 4.0; + + vec2 cell = floor(screenCoord / blockSize); + float n = hash(cell); // [0..1] + float mask = 1.0 - step(t, n); + + vec4 base = Texel(tex, texCoord) * color; + base.a *= mask; + return base; +} diff --git a/assets/shaders/soft_uniform_noise.glsl b/assets/shaders/soft_uniform_noise.glsl new file mode 100644 index 0000000..e843952 --- /dev/null +++ b/assets/shaders/soft_uniform_noise.glsl @@ -0,0 +1,17 @@ +#pragma language glsl3 + +vec2 hash(vec2 p) { + p = fract(p * vec2(123.34, 456.21)); + p += dot(p, p + 34.345); + return fract(vec2(p.x * p.y, p.x + p.y)); +} + +vec4 effect(vec4 color, Image tex, vec2 uv, vec2 px) +{ + vec2 cell = floor(px / 2.0); // тут можно размер зерна менять + + float n = hash(cell).x; // 0..1 + float v = 0.9 + n * 0.1; // 0.9..1.0 + + return vec4(v, v, v, 1.0); +} diff --git a/lib/simple_ui/color.lua b/lib/simple_ui/color.lua new file mode 100644 index 0000000..f97788d --- /dev/null +++ b/lib/simple_ui/color.lua @@ -0,0 +1,20 @@ +--- @class Color +--- @field r number +--- @field g number +--- @field b number +--- @field a number +local color = { + r = 1, + g = 1, + b = 1, + a = 1 +} +color.__index = color + +--- @param rgba {r?: number, g?: number, b?: number, a?: number} +--- @return Color +function color.new(rgba) + return setmetatable(rgba, color) +end + +return color.new diff --git a/lib/simple_ui/element.lua b/lib/simple_ui/element.lua index b2433c0..d73e462 100644 --- a/lib/simple_ui/element.lua +++ b/lib/simple_ui/element.lua @@ -1,9 +1,22 @@ local Rect = require "lib.simple_ui.rect" +local function makeGradientMesh(w, h, topColor, bottomColor) + local vertices = { + { 0, 0, 0, 0, topColor[1], topColor[2], topColor[3], topColor[4] }, -- левый верх + { w, 0, 1, 0, topColor[1], topColor[2], topColor[3], topColor[4] }, -- правый верх + { w, h, 1, 1, bottomColor[1], bottomColor[2], bottomColor[3], bottomColor[4] }, -- правый низ + { 0, h, 0, 1, bottomColor[1], bottomColor[2], bottomColor[3], bottomColor[4] }, -- левый низ + } + local mesh = love.graphics.newMesh(vertices, "fan", "static") + return mesh +end + --- @class UIElement ---- @field bounds Rect Прямоугольник, в границах которого размещается элемент. Размеры и положение в *локальных* координатах ---- @field transform love.Transform Преобразование из локальных координат элемента (bounds) в экранные координаты +--- @field bounds Rect Прямоугольник, в границах которого размещается элемент. Размеры и положение в экранных координатах +--- @field overlayGradientMesh love.Mesh Общий градиент поверх элемента (интерполированный меш) local uiElement = {} +uiElement.bounds = Rect {} +uiElement.overlayGradientMesh = makeGradientMesh(1, 1, { 0, 0, 0, 0 }, { 0, 0, 0, 0.4 }); uiElement.__index = uiElement function uiElement:update(dt) end @@ -11,8 +24,7 @@ function uiElement:update(dt) end function uiElement:draw() end function uiElement:hitTest(screenX, screenY) - local lx, ly = self.transform:inverseTransformPoint(screenX, screenY) - return self.bounds:hasPoint(lx, ly) + return self.bounds:hasPoint(screenX, screenY) end --- @generic T : UIElement @@ -21,8 +33,54 @@ end --- @return T function uiElement.new(self, values) values.bounds = values.bounds or Rect {} - values.transform = values.transform or love.math.newTransform() + values.overlayGradientMesh = values.overlayGradientMesh or uiElement.overlayGradientMesh; return setmetatable(values, self) end +--- Рисует границу вокруг элемента (с псевдо-затенением) +--- @param type "outer" | "inner" +function uiElement:drawBorder(type) + if type == "inner" then + love.graphics.setColor(0.2, 0.2, 0.2) + love.graphics.line({ + self.bounds.x, self.bounds.y + self.bounds.height, + self.bounds.x, self.bounds.y, + self.bounds.x + self.bounds.width, self.bounds.y, + }) + + love.graphics.setColor(0.3, 0.3, 0.3) + love.graphics.line({ + self.bounds.x + self.bounds.width, self.bounds.y, + self.bounds.x + self.bounds.width, self.bounds.y + self.bounds.height, + self.bounds.x, self.bounds.y + self.bounds.height, + }) + else + love.graphics.setColor(0.2, 0.2, 0.2) + love.graphics.line({ + self.bounds.x + self.bounds.width, self.bounds.y, + self.bounds.x + self.bounds.width, self.bounds.y + self.bounds.height, + self.bounds.x, self.bounds.y + self.bounds.height, + }) + + love.graphics.setColor(0.3, 0.3, 0.3) + love.graphics.line({ + self.bounds.x, self.bounds.y + self.bounds.height, + self.bounds.x, self.bounds.y, + self.bounds.x + self.bounds.width, self.bounds.y, + }) + end + + love.graphics.setColor(1, 1, 1) +end + +--- рисует градиент поверх элемента +function uiElement:drawGradientOverlay() + love.graphics.push() + love.graphics.translate(self.bounds.x, self.bounds.y) + love.graphics.scale(self.bounds.width, self.bounds.height) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(self.overlayGradientMesh) + love.graphics.pop() +end + return uiElement diff --git a/lib/simple_ui/level/layout.lua b/lib/simple_ui/level/layout.lua index 38c5707..0ace175 100644 --- a/lib/simple_ui/level/layout.lua +++ b/lib/simple_ui/level/layout.lua @@ -3,22 +3,256 @@ 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 Color = require "lib.simple_ui.color" + +--- @class BarElement : UIElement +--- @field getter fun() : number +--- @field value number +--- @field maxValue number +--- @field color Color +--- @field useDividers boolean +--- @field drawText boolean +local barElement = setmetatable({}, Element) +barElement.__index = barElement +barElement.useDividers = false +barElement.drawText = false + +function barElement:update(dt) + local val = self.getter() + self.value = val < 0 and 0 or val > self.maxValue and self.maxValue or val +end + +function barElement:draw() + local valueWidth = self.bounds.width * self.value / self.maxValue + local emptyWidth = self.bounds.width - valueWidth + + --- шум + love.graphics.setShader(Tree.assets.files.shaders.soft_uniform_noise) + love.graphics.rectangle("fill", self.bounds.x, self.bounds.y, self.bounds.width, self.bounds.height) + love.graphics.setShader() + + --- закраска пустой части + love.graphics.setColor(0.05, 0.05, 0.05) + love.graphics.setBlendMode("multiply", "premultiplied") + love.graphics.rectangle("fill", self.bounds.x + valueWidth, self.bounds.y, emptyWidth, + self.bounds.height) + love.graphics.setBlendMode("alpha") + + --- закраска значимой части её цветом + love.graphics.setColor(self.color.r, self.color.g, self.color.b) + love.graphics.setBlendMode("multiply", "premultiplied") + love.graphics.rectangle("fill", self.bounds.x, self.bounds.y, valueWidth, + self.bounds.height) + love.graphics.setBlendMode("alpha") + + --- мерки + love.graphics.setColor(38 / 255, 50 / 255, 56 / 255) + if self.useDividers then + local count = self.maxValue - 1 + local measureWidth = self.bounds.width / self.maxValue + + for i = 1, count, 1 do + love.graphics.line(self.bounds.x + i * measureWidth, self.bounds.y, self.bounds.x + i * measureWidth, + self.bounds.y + self.bounds.height) + end + end + love.graphics.setColor(1, 1, 1) + --- текст поверх + if self.drawText then + love.graphics.printf(tostring(self.value) .. "/" .. tostring(self.maxValue), self.bounds.x, + self.bounds.y, self.bounds.width, "center") + end + + self:drawGradientOverlay() +end + +--- @class BottomBars : UIElement +--- @field hpBar BarElement +--- @field manaBar BarElement +local bottomBars = setmetatable({}, Element) +bottomBars.__index = bottomBars; + +--- @param cid Id +function bottomBars.new(cid) + local t = setmetatable({}, bottomBars) + + t.hpBar = + barElement:new { + getter = function() + local char = Tree.level.characters[cid] + return char:try(Tree.behaviors.stats, function(stats) + return stats.hp or 0 + end) + end, + color = Color { r = 130 / 255, g = 8 / 255, b = 8 / 255 }, + drawText = true, + maxValue = 20 + } + + t.manaBar = + barElement:new { + getter = function() + local char = Tree.level.characters[cid] + return char:try(Tree.behaviors.stats, function(stats) + return stats.mana or 0 + end) + end, + color = Color { r = 51 / 255, g = 105 / 255, b = 30 / 255 }, + useDividers = true, + maxValue = 10 + } + + + return t +end + +function bottomBars:update(dt) + local height = 14 + local margin = 2 + + self.bounds.height = height + self.bounds.y = self.bounds.y - height + + self.hpBar.bounds = Rect { + width = -2 * margin + self.bounds.width / 2, + height = height, + x = self.bounds.x + margin, + y = self.bounds.y + } + + self.manaBar.bounds = Rect { + width = -2 * margin + self.bounds.width / 2, + height = height, + x = self.bounds.x + margin + self.bounds.width / 2, + y = self.bounds.y + } + + self.hpBar:update(dt) + self.manaBar:update(dt) +end + +function bottomBars:draw() + -- шум + love.graphics.setShader(Tree.assets.files.shaders.soft_uniform_noise) + love.graphics.rectangle("fill", self.bounds.x, self.bounds.y, self.bounds.width, self.bounds.height) + love.graphics.setShader() + + love.graphics.setColor(38 / 255, 50 / 255, 56 / 255) + love.graphics.setBlendMode("multiply", "premultiplied") + love.graphics.rectangle("fill", self.bounds.x, self.bounds.y, self.bounds.width, self.bounds.height) + love.graphics.setBlendMode("alpha") + + self.hpBar:draw() + self.manaBar:draw() +end + +local c = love.graphics.newCanvas(1280, 720) --- @TODO: выставлять канвасу правильный размер в зависимости от окна + +--- @class CharacterPanel : UIElement +--- @field animationNode AnimationNode +--- @field state "show" | "idle" | "hide" +--- @field skillRow SkillRow +--- @field bars BottomBars +local characterPanel = setmetatable({}, Element) +characterPanel.__index = characterPanel + +function characterPanel.new(characterId) + local t = {} + t.state = "show" + t.skillRow = SkillRow(characterId) + t.bars = bottomBars.new(characterId) + return setmetatable(t, characterPanel) +end + +function characterPanel: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 characterPanel: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 characterPanel:update(dt) + if self.animationNode then self.animationNode:update(dt) end + self.skillRow:update(dt) + self.bars.bounds = Rect { + width = self.skillRow.bounds.width, + x = self.skillRow.bounds.x, + y = self.skillRow.bounds.y + } + self.bars:update(dt) + + self.bounds = Rect { + x = self.bars.bounds.x, + y = self.bars.bounds.y, + width = self.bars.bounds.width, + height = self.bars.bounds.height + self.skillRow.bounds.height + } + + --- анимация появления + local alpha = 1 + if self.state == "show" then + alpha = self.animationNode:getValue() + elseif self.state == "hide" then + alpha = 1 - self.animationNode:getValue() + end + local revealShader = Tree.assets.files.shaders.reveal + revealShader:send("t", alpha) +end + +function characterPanel:draw() + love.graphics.setCanvas(c) + love.graphics.clear() + self.skillRow:draw() + self.bars:draw() + + self:drawBorder("outer") + + --- рисуем текстуру шейдером появления + love.graphics.setCanvas() + love.graphics.setShader(Tree.assets.files.shaders.reveal) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(c) + love.graphics.setColor(1, 1, 1) + love.graphics.setShader() +end + +----------------------------------- local layout = {} function layout:update(dt) local cid = Tree.level.selector:selected() if cid then - self.skillRow = SkillRow(cid) - self.skillRow:show() + self.characterPanel = characterPanel.new(cid) + self.characterPanel:show() elseif Tree.level.selector:deselected() then - self.skillRow:hide() + self.characterPanel:hide() end - if self.skillRow then self.skillRow:update(dt) end + if self.characterPanel then self.characterPanel:update(dt) end end function layout:draw() - if self.skillRow then self.skillRow:draw() end + if self.characterPanel then self.characterPanel:draw() end end return layout diff --git a/lib/simple_ui/level/scale.lua b/lib/simple_ui/level/scale.lua new file mode 100644 index 0000000..c2b6807 --- /dev/null +++ b/lib/simple_ui/level/scale.lua @@ -0,0 +1,2 @@ +local UI_SCALE = 0.8 -- выдуманное значение для dependency injection +return UI_SCALE diff --git a/lib/simple_ui/level/skill_row.lua b/lib/simple_ui/level/skill_row.lua index ab9a9c9..ebb01bd 100644 --- a/lib/simple_ui/level/skill_row.lua +++ b/lib/simple_ui/level/skill_row.lua @@ -1,18 +1,19 @@ 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" +local UI_SCALE = require "lib.simple_ui.level.scale" + --- @class SkillButton : UIElement --- @field hovered boolean --- @field selected boolean --- @field onClick function? ---- @field icon string +--- @field icon? string local skillButton = setmetatable({}, Element) skillButton.__index = skillButton function skillButton:update(dt) + if not self.icon then return end local mx, my = love.mouse.getPosition() if self:hitTest(mx, my) then self.hovered = true @@ -26,22 +27,33 @@ function skillButton:update(dt) end function skillButton:draw() - love.graphics.push() - love.graphics.applyTransform(self.transform) + love.graphics.setLineWidth(2) - 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) + if not self.icon then + love.graphics.setColor(0.05, 0.05, 0.05) + love.graphics.rectangle("fill", self.bounds.x, self.bounds.y, self.bounds.width, self.bounds.height) + self:drawBorder("inner") + return end - love.graphics.translate(0, self.bounds.y) - love.graphics.draw(icons.atlas, icons:pickQuad(self.icon)) + local quad = icons:pickQuad(self.icon) + love.graphics.push() + love.graphics.translate(self.bounds.x, self.bounds.y) + love.graphics.scale(self.bounds.width / icons.tileSize, self.bounds.height / icons.tileSize) + love.graphics.draw(icons.atlas, quad) love.graphics.pop() + + self:drawBorder("inner") + + if self.selected then + love.graphics.setColor(0.3, 1, 0.3, 0.5) + love.graphics.rectangle("fill", self.bounds.x, self.bounds.y, self.bounds.width, self.bounds.height) + elseif self.hovered then + love.graphics.setColor(0.7, 1, 0.7, 0.5) + love.graphics.rectangle("fill", self.bounds.x, self.bounds.y, self.bounds.width, self.bounds.height) + end + love.graphics.setColor(1, 1, 1) end -------------------------------------------------------------------------------- @@ -49,8 +61,6 @@ 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 @@ -60,7 +70,6 @@ skillRow.__index = skillRow function skillRow.new(characterId) local t = { characterId = characterId, - state = "show", children = {} } @@ -71,7 +80,6 @@ function skillRow.new(characterId) for i, spell in ipairs(behavior.spellbook) do local skb = skillButton:new { icon = spell.tag } skb.onClick = function() - if t.state ~= "idle" then return end skb.selected = not skb.selected if t.selected then t.selected.selected = false end t.selected = skb @@ -88,75 +96,79 @@ function skillRow.new(characterId) end end) + for i = #t.children + 1, 7, 1 do + t.children[i] = skillButton:new {} + 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 iconSize = 64 * UI_SCALE local screenW, screenH = love.graphics.getDimensions() - local padding = 8 - local count = #self.children + local padding, margin = 8, 4 + 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 + width = iconSize * count + (count + 1) * margin, + height = iconSize + 2 * margin, } - self.transform = love.math.newTransform():translate(screenW / 2, - screenH - 16):scale(scale, scale):translate(-self.bounds.width / 2, -iconSize) + self.bounds.y = screenH - self.bounds.height - padding -- отступ снизу + self.bounds.x = screenW / 2 - self.bounds.width / 2 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.bounds = Rect { x = self.bounds.x + margin + (i - 1) * (iconSize + margin), -- друг за другом, включая первый отступ от границы + y = self.bounds.y + margin, height = iconSize, width = iconSize } skb:update(dt) end end +local c = love.graphics.newCanvas(1280, 720) --- @TODO: выставлять канвасу правильный размер в зависимости от окна + 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) + local oldCanvas = love.graphics.getCanvas() + love.graphics.setCanvas({ c, stencil = true }) + love.graphics.clear() + love.graphics.setColor(1, 1, 1) + + -- сначала иконки скиллов for _, skb in ipairs(self.children) do skb:draw() end + + -- маска для вырезов под иконки + love.graphics.setShader(Tree.assets.files.shaders.alpha_mask) + love.graphics.stencil(function() + local mask = Tree.assets.files.masks.rrect32 + local maskSize = mask:getWidth() + for _, skb in ipairs(self.children) do + love.graphics.draw(mask, skb.bounds.x, skb.bounds.y, 0, + skb.bounds.width / maskSize, skb.bounds.height / maskSize) + end + end, "replace", 1) + love.graphics.setShader() + + -- дальше рисуем панель, перекрывая иконки + love.graphics.setStencilTest("less", 1) + -- шум + love.graphics.setShader(Tree.assets.files.shaders.soft_uniform_noise) + love.graphics.rectangle("fill", self.bounds.x, self.bounds.y, self.bounds.width, self.bounds.height) + love.graphics.setShader() + + -- фон + love.graphics.setColor(38 / 255, 50 / 255, 56 / 255) + love.graphics.setBlendMode("multiply", "premultiplied") + love.graphics.rectangle("fill", self.bounds.x, self.bounds.y, self.bounds.width, self.bounds.height) + love.graphics.setBlendMode("alpha") + + love.graphics.setStencilTest() + + --затенение + self:drawGradientOverlay() + + love.graphics.setColor(1, 1, 1) + love.graphics.setCanvas(oldCanvas) + love.graphics.draw(c) end return skillRow.new diff --git a/lib/simple_ui/rect.lua b/lib/simple_ui/rect.lua index 3f3931f..932a0ad 100644 --- a/lib/simple_ui/rect.lua +++ b/lib/simple_ui/rect.lua @@ -18,7 +18,7 @@ function rect.new(table) end function rect:hasPoint(x, y) - return x >= self.x and x < self.width and y >= self.y and y < self.height + return x >= self.x and x < self.x + self.width and y >= self.y and y < self.y + self.height end return rect.new diff --git a/main.lua b/main.lua index cc58b33..3656037 100644 --- a/main.lua +++ b/main.lua @@ -18,7 +18,7 @@ function love.load() end Tree.level.turnOrder:endRound() print("Now playing:", Tree.level.turnOrder.current) - love.window.setMode(1080, 720, { resizable = true, msaa = 4, vsync = true }) + love.window.setMode(1280, 720, { resizable = false, msaa = 0, vsync = true }) end local lt = "0"