Compare commits

..

38 Commits

Author SHA1 Message Date
284f2ac204 Increase camera pixelsPerMeter from 24 to 32 2025-12-22 03:53:41 +03:00
970047bded format .glsl 2025-12-15 03:28:06 +03:00
47dea647a0 make hp bar text more clear 2025-12-15 03:23:34 +03:00
af00e8abd4 Merge pull request 'feature-fonts' (#23) from feature-fonts into main
Reviewed-on: #23
2025-12-14 23:26:57 +03:00
4161044dcc make actual use of fonts 2025-12-14 23:24:17 +03:00
5c1a0b0c19 Refactor FontManager theme loading and add default theme getter 2025-12-14 23:23:52 +03:00
75550148f7 add Roboto Mono font 2025-12-14 23:23:33 +03:00
29e001e20f implement FontManager 2025-12-14 23:04:52 +03:00
00f3db4ff9 allow loading arbitrary files as FileData 2025-12-14 23:04:41 +03:00
f5e5bce3ef Add WDXL Lubrifont TC font 2025-12-14 22:54:05 +03:00
9ce9b85dfa update outer border and end turn button visuals 2025-12-13 02:18:22 +03:00
e952d22d7f Add optional width parameter to drawBorder method 2025-12-13 01:22:05 +03:00
586ea68d2b minimal end turn button implementation 2025-12-12 05:20:11 +03:00
2012035eb6 refactor level layout 2025-12-12 02:36:51 +03:00
615738d06a allow window resizing again 2025-12-08 03:53:43 +03:00
7394249cb8 Merge pull request 'hp-bar-the-dumb-way' (#22) from hp-bar-the-dumb-way into main
Reviewed-on: #22
2025-12-07 20:35:58 +03:00
3d0d52438f add a shader for a cool pixel reveal effect 2025-12-07 20:27:48 +03:00
bc730ef48c implement hp and mana bars 2025-12-07 20:08:07 +03:00
73ba99734c add better skill hover & select visuals 2025-12-07 02:04:08 +03:00
bc1c6cfd6a add gradient like in windows aero 2025-12-07 01:57:45 +03:00
f2169d333c Add alpha mask shader and stencil-based skill icon masking 2025-12-06 21:41:43 +03:00
bcc376030c make ui use screen space dimensions add some fancy looks to the skill
row
2025-12-06 20:13:55 +03:00
c61c1875e7 Display memory usage in performance stats overlay 2025-11-19 00:00:08 +03:00
411c435e7a straightforward hp/mana bars implementation 2025-11-14 01:18:34 +03:00
a9bb7df188 fix look at the attack target 2025-11-12 03:20:21 +03:00
1376cf7041 Revert "fix sprite side on attack"
This reverts commit 59f122703302eedf5065110c4e22403eaee0242e.
2025-11-12 03:17:31 +03:00
cd8d2768e0 fix drawing spell preview after the end of a turn 2025-11-12 02:55:49 +03:00
de24808a82 fix AnimationNode state management 2025-11-12 01:46:35 +03:00
aecc9acde0 fix hidden buttons register clicks 2025-11-12 01:32:48 +03:00
187b8b3c74 Merge pull request 'feature/animateTo' (#21) from feature/animateTo into main
Reviewed-on: #21
2025-11-11 17:45:15 +03:00
e7e4071931 deleted tests 2025-11-11 00:01:42 +03:00
59f1227033 fix sprite side on attack 2025-11-10 07:20:46 +03:00
123885b2b3 Reset camera velocity when starting animation 2025-11-10 05:09:56 +03:00
c566d1669e Add mouse wheel support for zoom control in camera module (the dumb way) 2025-11-10 05:07:40 +03:00
331aefb0f6 i love easing4d
Co-authored-by: Ivan Yuriev <ivanyr44@gmail.com>
2025-11-09 22:16:40 +03:00
cdffff59c3 init camera:animateTo 2025-11-09 18:58:01 +03:00
c16870102b Merge pull request 'turn-order' (#17) from turn-order into main
Reviewed-on: #17
2025-11-09 17:56:09 +03:00
538bd1df33 feature/simple_ui (#18)
#15
Реализовано втупую и всякие выравнивания с текстами надо добавлять вручную.
Зато у нас есть поддержка анимаций и дерева матриц преобразования.
Вообще UI - это просто иерархия прямоугольников на экране.

Reviewed-on: #18
2025-11-08 01:32:46 +03:00
38 changed files with 1234 additions and 664 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

@ -0,0 +1,91 @@
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

View File

@ -0,0 +1,94 @@
Copyright 2025 The WDXL Lubrifont Project Authors (https://github.com/NightFurySL2001/WD-XL-font)
Copyright 2018-2020 The ZCOOL QingKe HuangYou Project Authors (https://www.github.com/googlefonts/zcool-qingke-huangyou)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

BIN
assets/masks/rrect32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
assets/masks/squircle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

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

View File

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

View File

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

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 state "running" | "waiting" | "finished"
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.state = "finished"
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.state ~= "running" 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)
@ -61,7 +84,12 @@ local function new(data)
t.count = 1 -- своя анимация
t.children = {}
t:chain(data.children or {})
t.duration = data.duration or 1000
t.easing = data.easing or easing.linear
t.t = 0
t.state = "running"
t.finish = function()
t.state = "waiting"
t:bubbleUp()
for _, anim in ipairs(t.children) do
anim:run()

View File

@ -24,6 +24,17 @@ function mapBehavior.new(position, size)
}, mapBehavior)
end
--- @param position Vec3
function mapBehavior:lookAt(position)
self.owner:try(Tree.behaviors.sprite,
function(sprite)
if position.x > self.displayedPosition.x then sprite.side = sprite.RIGHT end
-- (sic!)
if position.x < self.displayedPosition.x then sprite.side = sprite.LEFT end
end
)
end
--- @param path Deque
--- @param animationNode AnimationNode
function mapBehavior:followPath(path, animationNode)

View File

@ -24,6 +24,10 @@ function behavior:endCast()
end
function behavior:update(dt)
if Tree.level.selector:deselected() then
self.state = "idle"
self.cast = nil
end
if self.cast and self.state == "casting" then self.cast:update(self.owner, dt) end
end

View File

@ -1,5 +1,3 @@
local utils = require "lib.utils.utils"
--- @alias Device "mouse" | "key" | "pad"
--- @param device Device
@ -17,6 +15,7 @@ controls.keymap = {
cameraMoveRight = control("key", "d"),
cameraMoveDown = control("key", "s"),
cameraMoveScroll = control("mouse", "3"),
cameraAnimateTo = control('key', 't'),
fullMana = control("key", "m"),
select = control("mouse", "1"),
endTurnTest = control("key", "e"),
@ -26,6 +25,12 @@ controls.keymap = {
local currentKeys = {}
local cachedKeys = {}
--- @type number
controls.mouseWheelY = 0
love.wheelmoved = function(x, y)
controls.mouseWheelY = y
end
--- polling controls in O(n)
--- should be called at the beginning of every frame
function controls:poll()
@ -49,6 +54,7 @@ function controls:cache()
for k, v in pairs(currentKeys) do
cachedKeys[k] = v
end
controls.mouseWheelY = 0
end
--- marks a control consumed for the current frame

View File

@ -9,12 +9,15 @@ local EPSILON = 0.001
--- @field speed number
--- @field pixelsPerMeter integer
--- @field scale number
--- @field animationNode AnimationNode?
--- @field animationEndPosition Vec3
--- @field animationBeginPosition Vec3
local camera = {
position = Vec3 {},
velocity = Vec3 {},
acceleration = 0.2,
speed = 5,
pixelsPerMeter = 24,
pixelsPerMeter = 32,
}
function camera:getDefaultScale()
@ -27,12 +30,6 @@ camera.scale = camera:getDefaultScale()
---------------------------------------------------
love.wheelmoved = function(x, y)
if camera.scale > camera:getDefaultScale() * 5 and y > 0 then return end;
if camera.scale < camera:getDefaultScale() / 5 and y < 0 then return end;
camera.scale = camera.scale + (camera.scale * 0.1 * y)
end
local controlMap = {
cameraMoveUp = Vec3({ 0, -1 }),
cameraMoveLeft = Vec3({ -1 }),
@ -41,6 +38,19 @@ local controlMap = {
}
function camera:update(dt)
if self.animationNode and self.animationNode.state == "running" then
self.animationNode:update(dt) -- тик анимации
self.position = utils.lerp(self.animationBeginPosition, self.animationEndPosition, self.animationNode:getValue())
return
end
-------------------- зум на колесо ---------------------
local y = Tree.controls.mouseWheelY
if camera.scale > camera:getDefaultScale() * 5 and y > 0 then return end;
if camera.scale < camera:getDefaultScale() / 5 and y < 0 then return end;
camera.scale = camera.scale + (camera.scale * 0.1 * y)
--------------------------------------------------------
local ps = Tree.panning
if ps.delta:length() > 0 then
local worldDelta = ps.delta:scale(1 / (self.pixelsPerMeter * self.scale)):scale(dt):scale(self.speed)
@ -87,6 +97,16 @@ function camera:detach()
love.graphics.pop()
end
--- @param position Vec3
--- @param animationNode AnimationNode
function camera:animateTo(position, animationNode)
if self.animationNode and self.animationNode.state ~= "finished" then self.animationNode:finish() end
self.animationNode = animationNode
self.animationEndPosition = position
self.animationBeginPosition = self.position
self.velocity = Vec3 {}
end
--- @return Camera
local function new()
return setmetatable({

View File

@ -35,7 +35,6 @@ function level:update(dt)
el:update(dt)
end)
self.camera:update(dt)
self.selector:update(dt)
end

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()
@ -32,11 +34,8 @@ function selector:update(dt)
char:try(Tree.behaviors.spellcaster, function(b)
if not b.cast then
-- тут какая-то страшная дичь, я даже не уверен что оно работает
-- зато я точно уверен, что это надо было писать не так
if not selectedId then self:select(selectedId) end
if selectedId ~= Tree.level.turnOrder.current and Tree.level.turnOrder.isTurnsEnabled then return end
return self:select(selectedId)
if not selectedId then self:select(nil) end
return
end
if b.cast:cast(char, mousePosition) then
self:lock()
@ -57,6 +56,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
}

View File

@ -30,7 +30,6 @@ end
---
--- Если в очереди на ход больше никого нет, заканчиваем раунд
function turnOrder:next()
Tree.level.selector.id = nil
self.actedQueue:insert(self.current)
local next = self.pendingQueue:peek()
if not next then return self:endRound() end

20
lib/simple_ui/color.lua Normal file
View File

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

109
lib/simple_ui/element.lua Normal file
View File

@ -0,0 +1,109 @@
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 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
function uiElement:draw() end
function uiElement:hitTest(screenX, screenY)
return self.bounds:hasPoint(screenX, screenY)
end
--- @generic T : UIElement
--- @param values table
--- @param self T
--- @return T
function uiElement.new(self, values)
values.bounds = values.bounds or Rect {}
values.overlayGradientMesh = values.overlayGradientMesh or uiElement.overlayGradientMesh;
return setmetatable(values, self)
end
--- Рисует границу вокруг элемента (с псевдо-затенением)
--- @param type "outer" | "inner"
--- @param width? number
function uiElement:drawBorder(type, width)
local w = width or 4
love.graphics.setLineWidth(w)
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.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,
-- })
love.graphics.line({
self.bounds.x, self.bounds.y + self.bounds.height - w,
self.bounds.x, self.bounds.y + w,
})
love.graphics.line({
self.bounds.x + w, self.bounds.y,
self.bounds.x + self.bounds.width - w, self.bounds.y,
})
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.line({
self.bounds.x + self.bounds.width, self.bounds.y + w,
self.bounds.x + self.bounds.width, self.bounds.y + self.bounds.height - w,
})
love.graphics.line({
self.bounds.x + self.bounds.width - w, self.bounds.y + self.bounds.height,
self.bounds.x + w, self.bounds.y + self.bounds.height,
})
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

View File

@ -0,0 +1,71 @@
local Element = require "lib.simple_ui.element"
--- @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
local font = Tree.fonts:getDefaultTheme():getVariant("medium")
local t = love.graphics.newText(font, tostring(self.value) .. "/" .. tostring(self.maxValue))
love.graphics.draw(t, math.floor(self.bounds.x + self.bounds.width / 2 - t:getWidth() / 2),
math.floor(self.bounds.y + self.bounds.height / 2 - t:getHeight() / 2))
end
self:drawBorder("inner")
self:drawGradientOverlay()
end
return function(values) return barElement:new(values) end

View File

@ -0,0 +1,86 @@
local Element = require "lib.simple_ui.element"
local Rect = require "lib.simple_ui.rect"
local Color = require "lib.simple_ui.color"
local Bar = require "lib.simple_ui.level.bar"
--- @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 =
Bar {
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 =
Bar {
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 = 16
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 - margin,
x = self.bounds.x + margin,
y = self.bounds.y + margin
}
self.manaBar.bounds = Rect {
width = -2 * margin + self.bounds.width / 2,
height = height - margin,
x = self.bounds.x + margin + self.bounds.width / 2,
y = self.bounds.y + margin
}
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
return bottomBars.new

View File

@ -0,0 +1,128 @@
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 Bars = require "lib.simple_ui.level.bottom_bars"
local EndTurnButton = require "lib.simple_ui.level.end_turn"
--- @class CharacterPanel : UIElement
--- @field animationNode AnimationNode
--- @field state "show" | "idle" | "hide"
--- @field skillRow SkillRow
--- @field bars BottomBars
--- @field endTurnButton EndTurnButton
local characterPanel = setmetatable({}, Element)
characterPanel.__index = characterPanel
function characterPanel.new(characterId)
local t = {}
t.state = "show"
t.skillRow = SkillRow(characterId)
t.bars = Bars(characterId)
t.endTurnButton = EndTurnButton {}
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
--- @type love.Canvas
local characterPanelCanvas;
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
}
self.endTurnButton:layout()
self.endTurnButton.bounds.x = self.bounds.x + self.bounds.width + 32
self.endTurnButton.bounds.y = self.bounds.y + self.bounds.height / 2 - self.endTurnButton.bounds.height / 2
self.endTurnButton:update(dt)
if not characterPanelCanvas then
characterPanelCanvas = love.graphics.newCanvas(self.bounds.width, self.bounds.height)
end
--- анимация появления
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()
self.skillRow:draw()
--- @TODO: переписать этот ужас с жонглированием координатами, а то слишком хардкод (skillRow рисуется относительно нуля и не закрывает канвас)
love.graphics.push()
local canvas = love.graphics.getCanvas()
love.graphics.translate(0, self.bars.bounds.height)
love.graphics.setCanvas(characterPanelCanvas)
love.graphics.clear()
love.graphics.draw(canvas)
love.graphics.pop()
love.graphics.push()
love.graphics.translate(-self.bounds.x, -self.bounds.y)
self.bars:draw()
self:drawBorder("outer")
love.graphics.pop()
--- рисуем текстуру шейдером появления
love.graphics.setCanvas()
love.graphics.setShader(Tree.assets.files.shaders.reveal)
love.graphics.setColor(1, 1, 1, 1)
self.endTurnButton:draw()
love.graphics.push()
love.graphics.translate(self.bounds.x, self.bounds.y)
love.graphics.draw(characterPanelCanvas)
love.graphics.setColor(1, 1, 1)
love.graphics.pop()
love.graphics.setShader()
end
return characterPanel.new

View File

@ -0,0 +1,66 @@
local Element = require "lib.simple_ui.element"
local AnimationNode = require "lib.animation_node"
local easing = require "lib.utils.easing"
--- @class EndTurnButton : UIElement
--- @field hovered boolean
--- @field onClick function?
local endTurnButton = setmetatable({}, Element)
endTurnButton.__index = endTurnButton
function endTurnButton: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 endTurnButton:layout()
local font = Tree.fonts:getDefaultTheme():getVariant("headline")
self.text = love.graphics.newText(font, "Завершить ход")
self.bounds.width = self.text:getWidth() + 32
self.bounds.height = self.text:getHeight() + 16
end
function endTurnButton:draw()
love.graphics.setColor(38 / 255, 50 / 255, 56 / 255, 0.9)
love.graphics.rectangle("fill", self.bounds.x, self.bounds.y, self.bounds.width, self.bounds.height)
if self.hovered then
love.graphics.setColor(0.1, 0.1, 0.1)
love.graphics.rectangle("fill", self.bounds.x, self.bounds.y, self.bounds.width, self.bounds.height)
end
love.graphics.setColor(0.95, 0.95, 0.95)
love.graphics.draw(self.text, self.bounds.x + 16, self.bounds.y + 8)
self:drawBorder("outer")
love.graphics.setColor(1, 1, 1)
end
function endTurnButton:onClick()
Tree.level.turnOrder:next()
Tree.level.selector:select(nil)
local cid = Tree.level.turnOrder.current
local playing = Tree.level.characters[cid]
if not playing:has(Tree.behaviors.map) then return end
AnimationNode {
function(node)
Tree.level.camera:animateTo(playing:has(Tree.behaviors.map).displayedPosition, node)
end,
duration = 1500,
easing = easing.easeInOutCubic,
onEnd = function() Tree.level.selector:select(cid) end
}:run()
end
return function(values)
return endTurnButton:new(values)
end

View File

@ -0,0 +1,23 @@
local CPanel = require "lib.simple_ui.level.cpanel"
local build
local layout = {}
function layout:update(dt)
if self.characterPanel then self.characterPanel:update(dt) end
local cid = Tree.level.selector:selected()
if cid then
self.characterPanel = CPanel(cid)
self.characterPanel:show()
self.characterPanel:update(dt)
elseif Tree.level.selector:deselected() then
self.characterPanel:hide()
end
end
function layout:draw()
if self.characterPanel then self.characterPanel:draw() end
end
return layout

View File

@ -0,0 +1,2 @@
local UI_SCALE = 0.75 -- выдуманное значение для dependency injection, надо подбирать так, чтобы UI_SCALE * 64 было целым числом
return UI_SCALE

View File

@ -0,0 +1,184 @@
local icons = require("lib.utils.sprite_atlas").load(Tree.assets.files.dev_icons)
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
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
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.setLineWidth(2)
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
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
--------------------------------------------------------------------------------
--- @class SkillRow : UIElement
--- @field characterId Id
--- @field selected SkillButton?
--- @field children SkillButton[]
local skillRow = setmetatable({}, Element)
skillRow.__index = skillRow
--- @param characterId Id
--- @return SkillRow
function skillRow.new(characterId)
local t = {
characterId = characterId,
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)
for i = #t.children + 1, 7, 1 do
t.children[i] = skillButton:new {}
end
return t
end
--- @type love.Canvas
local c;
function skillRow:update(dt)
local iconSize = math.floor(64 * UI_SCALE)
local screenW, screenH = love.graphics.getDimensions()
local padding, margin = 8, 4
local count = #self.children -- слоты под скиллы
self.bounds = Rect {
width = iconSize * count + (count + 1) * margin,
height = iconSize + 2 * margin,
}
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 { x = self.bounds.x + margin + (i - 1) * (iconSize + margin), -- друг за другом, включая первый отступ от границы
y = self.bounds.y + margin, height = iconSize, width = iconSize }
skb:update(dt)
end
if not c then
c = love.graphics.newCanvas(self.bounds.width, self.bounds.height)
end
end
function skillRow:draw()
love.graphics.setCanvas({ c, stencil = true })
love.graphics.clear()
love.graphics.setColor(1, 1, 1)
do
--- рисуем в локальных координатах текстурки
love.graphics.push()
love.graphics.translate(-self.bounds.x, -self.bounds.y)
-- сначала иконки скиллов
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.pop()
end
love.graphics.setColor(1, 1, 1)
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.x + self.width and y >= self.y and y < self.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)
@ -93,6 +97,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)
@ -119,6 +124,8 @@ function attack:cast(caster, target)
local targetSprite = targetCharacter:has(Tree.behaviors.sprite)
if not sprite or not targetSprite then return true end
caster:try(Tree.behaviors.map, function(map) map:lookAt(target) end)
AnimationNode {
onEnd = function() caster:has(Tree.behaviors.spellcaster):endCast() end,
children = {

View File

@ -6,6 +6,7 @@
Tree = {
assets = (require "lib.utils.asset_bundle"):load()
}
Tree.fonts = (require "lib.utils.font_manager"):load("WDXL_Lubrifont_TC"):loadTheme("Roboto_Mono") -- дефолтный шрифт
Tree.panning = require "lib/panning"
Tree.controls = require "lib.controls"
Tree.level = (require "lib.level.level").new("procedural", "flower_plains") -- для теста у нас только один уровень, который сразу же загружен

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

View File

@ -51,7 +51,7 @@ function AssetBundle.loadFile(path)
elseif (ext == "lua") then
return require(string.gsub(path, ".lua", ""))
end
return nil
return filedata
end
function AssetBundle.cutExtension(filename)

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

@ -0,0 +1,94 @@
--- @alias FontVariant "smallest" | "small" | "medium" | "large" | "headline"
--- @class TextTheme
--- @field private _sizes {[FontVariant]: integer}
local theme = {
_sizes = {
smallest = 10,
small = 12,
medium = 14,
large = 16,
headline = 20,
}
}
theme.__index = theme
--- @param loader fun(size: integer): love.Font?
function theme.new(loader)
local t = {}
for tag, size in pairs(theme._sizes) do
local f = loader(size)
if not f then return nil end
t[tag] = f
end
return setmetatable(t, theme)
end
--- @param variant FontVariant
--- @return love.Font
function theme:getVariant(variant)
return self[variant]
end
----------------------------------------------------------
--- A singleton to handle fonts
--- @class FontManager
--- @field private _themes table
--- @field defaultTheme string?
local FontManager = {
_themes = {}
}
--- @param name string
--- @param size integer
function FontManager.newFont(name, size)
local err = function()
print("[FontManager]: font " .. name .. " not found!")
end
local fontDir = Tree.assets.files.fonts[name]
if not fontDir then return err() end
local font = fontDir.font
if not font then return err() end
return love.graphics.newFont(font, size)
end
--- @param name string
--- @return FontManager
function FontManager:loadTheme(name)
self._themes[name] = theme.new(
function(size)
return self.newFont(name, size)
end
)
return self
end
--- @param name string
--- @return TextTheme?
function FontManager:getTheme(name)
return self._themes[name]
end
--- @return TextTheme
function FontManager:getDefaultTheme()
return self._themes[self.defaultTheme]
end
--- initial setup
--- @param defaultFontName string
function FontManager:load(defaultFontName)
local t = self:loadTheme(defaultFontName):getTheme(defaultFontName)
if not t then
print("[FontManager]: default font " .. defaultFontName .. " is missing")
return self
end
self.defaultTheme = defaultFontName
local f = t:getVariant("medium")
love.graphics.setFont(f)
return self
end
return FontManager

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,29 +18,18 @@ 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 = true, msaa = 4, vsync = true })
end
local lt = "0"
function love.update(dt)
local t1 = love.timer.getTime()
Tree.controls:poll()
Widgets = layout:build()
Widgets:update(dt) -- логика UI-слоя должна отработать раньше всех, потому что нужно перехватить жесты и не пустить их дальше
Tree.level.camera:update(dt) -- сначала логика камеры, потому что на нее завязан UI
testLayout:update(dt) -- потом UI, потому что нужно перехватить жесты и не пустить их дальше
Tree.panning:update(dt)
Tree.level:update(dt)
-- для тестов очереди ходов
-- удалить как только появится ui для людей
if Tree.controls:isJustPressed("endTurnTest") then
Tree.level.turnOrder:next()
print("Now playing:", Tree.level.turnOrder.current)
end
if Tree.controls:isJustPressed("toggleTurns") then
print('toggle turns')
Tree.level.turnOrder:toggleTurns()
end
Tree.controls:cache()
local t2 = love.timer.getTime()
@ -73,11 +61,13 @@ 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
love.graphics.setFont(Tree.fonts:getTheme("Roboto_Mono"):getVariant("medium"))
local stats = "fps: " ..
love.timer.getFPS() ..
" lt: " .. lt .. " dt: " .. dt .. " mem: " .. string.format("%.2f MB", collectgarbage("count") / 1000)
love.graphics.print(stats, 10, 10)
local t2 = love.timer.getTime()