From 55787a664397d436a1a2dc1d6d6c5da5c3c68801 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Sun, 26 Oct 2025 02:37:15 +0300 Subject: [PATCH 01/24] add test icons --- assets/dev_icons/LICENSE.md | 19 +++++++++++++++++++ assets/dev_icons/atlas.png | Bin 0 -> 5190 bytes assets/dev_icons/manifest.lua | 6 ++++++ 3 files changed, 25 insertions(+) create mode 100644 assets/dev_icons/LICENSE.md create mode 100644 assets/dev_icons/atlas.png create mode 100644 assets/dev_icons/manifest.lua diff --git a/assets/dev_icons/LICENSE.md b/assets/dev_icons/LICENSE.md new file mode 100644 index 0000000..c2d8090 --- /dev/null +++ b/assets/dev_icons/LICENSE.md @@ -0,0 +1,19 @@ +Created by [Marvyra](https://www.gamedevmarket.net/member/marvyra) + +4.1. A "Licence" means that the Seller grants to GDN (purely for the purpose of sub-licensing to the Purchaser) and GDN grants (by way of sub-licence thereof) to the Purchaser a non-exclusive perpetual licence to; + +(a) use the Licensed Asset to create Derivative Works; and + +(b) use the Licensed Asset and any Derivative Works as part of both Non-Monetized Media Products and Monetized Media Products, with no restriction on the number of projects the Licensed Asset may be used in. In either case, the Licensed Assets can be used in Media Products that are either: + +i) used for the Purchaser's own personal use; and/or + +ii) used for the Purchaser’s commercial use in which case it may be distributed, sold and supplied by the Purchaser for any fee that the Purchaser may determine. + +4.2. A Licence does not allow the Purchaser to: + +(a) Use the Licensed Asset or Derivative Works in a logo, trademark or service mark; + +(b) Use, sell, share, transfer, give away, sublicense or redistribute the Licensed Asset or Derivate Works other than as part of the relevant Media Product; or + +(c) Allow the user of the Media Product to extract the Licensed Asset or Derivative Works and use them outside of the relevant Media Product. diff --git a/assets/dev_icons/atlas.png b/assets/dev_icons/atlas.png new file mode 100644 index 0000000000000000000000000000000000000000..f6dc572476b54ed4c8c51791deb058bbecf72993 GIT binary patch literal 5190 zcmV-M6uIk(P)Px}07*naRCt{2T}^0Q*A+gapQzHpg&NX$BCAjkvUgpzyHW&Qg@P$9$llqD8V3w6 zPLaR_V}xN6$5ch2VlQO6QM3rbP_zn&&{dt4yNb*}7Vf8c=PB#7&;g8w0#m1cy>_GBz zAS&7-*BWz-LX+jRC5&&s^SodK+Mxl!pX0l@>6ih(D|RjV)!S!%E~BvY!Grl0cB5KPfu-x&8m)ns%w#`BQp zW83dMFQfz{F$YTEovHwYm1x+|NmKi@5R`IUitRLP>l@>6EYpANo#${Y^LoA*001Lg zkW@H;fT>Mji-kuBI6jt6Qc4vZ5XlC<*d69M^^I}ZQo_GrLk1A{zGImjZnu~Nxq({V z;k=zlg~Jfg^3W;L_;Wg$P##9WI6oK`pd;ixAYLuav8J7cV^vY3PUfX`;S;iuunw6xa&Jfl z5j}|Wa?hjV_;|dhEnZ2A#= zxBeBp`TmQ-+%!9mO+SJsi=#O|>cjgt-+v+UMOV@4eRMoe#C(M{e#t8=wrIJHrxqH% zT((w~lR~&m$nek&g>WpdS}LfOS6fUC7%k=SG>`;f3W(A~LeDg>4|!z>L@oDm&|=#Y zZH$704Ls{nPTOZ$6TfCd3W$8sXvkXUqn%m=;KSghZh~V+r<4$ujAX6`gkg9~40=?S=CW>X&=B z?Pc&}Qym)RAZD^eTj)f&4q`{h2Fd0aPhL^7mG|4cbJ^;rRVl((Z#f6^VSbcf7(egy z`&3&k_OLMq`@;Z;*9QPza3K{CExJ9u^8p#8PXkhzN5pu`1eGyVX&E3TP)uGJTflkQ z7Hu3~JRQKYp&Idk%HRb&Bo;N)2C*;Z7)&RhLvb7PK@HmA7my_4$+|=pue<}mBACsm zez|K){WA3ChjS%__7;d@p;Y7es+S1evfd_zzSSGl@C1^Auku96ES}qNj!cU;VP+X;~!oi^P`+hfe%R zmU4zAd{|`*)tB^@Ne>cLO8ST09xY9}n`e6WH_vpR;dAW6=X`8c{jbIQz(4$E5^kO; zKKtbJ7X0BilZ7#qZl3X<#fa;G*Xe~Sf^qG>4<4m!_lx5#^k}KOK*&ph^Z=xt650xEln0=0*z{!K$LO- z`ATaOlt958ltQ5-&L@aAp>CX%1P%iZrANKm(*O;+~3)E9S(*Jv2GW zPd$%<$ZZD!{Fy7QN`(<4Q34MrfD}Bcgo0;X%~0q9tDG81VTPU{fvDwK5K=)%r<4Xl z^UeXJIgfjx1}+%DxOQLmJk2O7A6K4xBLy+UOP^7FnipcOo4L~R_LAEChP0eVmC%Yr zS5q1zk)~YYk-SA3CxNJWngpg+h;E+gE;wB{ZMOgbFhr{i2yQER9IZ$pZ{mDlEI4$6 zd@L>^2SM{R$+p-@1#QIZP}mP~rIh%72tY6A)Hq27j;Hb@AbF(?8LypnIhfp5%{h?h zRwtw%0APr^H+OrUR5^G&|Aof#30SUjAi4mS zs;%|Y+(vCeq6iN|gX8p3aA2Pk8ToplOLM9|ndacxN@U+EGTxdn;MHT>G@! zzCKd8j`4(^qa50{uaD^47yy7b&P@XV!1rs-Qn{oGZC@{%Hg#jal$?_nrz_bobz>j4 zuaAgwPF|conqTpi4Bm!jX%NfO$&1qfH@*yx@y^0~AL*Ee`eNkTr}GAahapk$YV|Ln zR_{*WgtfnQ8;3>pa}9Nfj;gts%^lNYCRV=0I!o+W|b58`rh z;V}Phv-wZm*oWHQMV=xO zgg{3gNiOw7&QtkY`qWSfJemXP7&?}E9|sRe?voaSQ^=F8>4sCS{$(CH*w9;R*(SL_ zS_n(~!I<~*{1xjDjgRF*Y2SuXL35eriO3i8R!-t6gzvhw(}=V?r@MCC$@6j8XrH#@ z*JMey;3qwSa9!3@jOEnhZDU;fG>pBsU+^}pz^;8dx{aYLPgfGRFtz%Zg@O~}N5X4u zuSUZTL!GAzObrqbrKubHxz}0>TVcf5d;4OgMbfr18fjrLbz?uT&=N@iO9hG(t(sDJ zYehn`ZyUP$+{JJK@*oZYr1jCsS@v8K4+n=A5e_Q7#|lk1>e2!sc+#Ga)6|W9fE&Gn zM&(i6mACa}zyq?%z&tV(-W)Va2pm2BNn`H~-Z z&aL;J;k8xx+Fafcgj66J4q&0yZLbtWI&X;t47|9f_cZ=v`5+%tqE8%0^+)vbc@)O; z`#$DCNHWAa@qhtQ5D8!&zr_~LX9vPtytE?1U2|;?8fVv`adutoi@AB@Y%df<83#dv z-c&mlYGf=FuccHnL^$afNWDWPb?l9FP9S$ijGP{TXfI)#ZQeWH4LQk;d46Fu>u%*^X5FnqI=FDarfXt(+gPH}4(q8lRGXG@X&f z1L)?t&3nhYpb;Po^D+>fX9#@X11Wf%!8iex4E)*xB`pH+-dtXpO1!!Xo3+36qEWD4 z-NwwGe+rwhS^zZUeZJxgdH7-lo<0Axd!0xH9!of1Ai5Jh&$Ivht>8{1xDT8?{}kXn z=CG&!WFk};d*K3Xmf4Q2u7;yR(@n!TK`(|qf*7<%I?BzuTlvI!0f>T^38%R*q+F9h zJw0E2C=rGw_K^k8m59lb&%&u2UV+yGKP8kT5KHE;GHFBFIfQKJ-(K%1wtntqg*JE_ zQ;`O5V=7X&3g7PjAmQ+(2@})HzwdT0u1>=6?oRjqYTAD-@w!mK19M;kB#jxqG?6a? z+-~)*e*o0Wfn?H}Clv1w{i@u4$v#hw_6Twx7_gVj#sXScX#rB&NIBbQ)u28 zW21Tt`iMNqHcduKUp{{_C+}KrvJejxszBfoK_t(k07gLaz>U};@mm*{&m}SkeWfU4 z5CpbzlCjp+FO1_JFO5+ak@J}&Bou*?P2xc2g;7^F7;EujKF*tP2tWyR4t~u0m{q~j7B!2qq z8mYgu(HQ>>7M}k$`b?sgm6gK%rAwCz_qAH_-*AWkKFmIUq1?x4YUu@h^XWg~r@#MQ z;h6@y`GNVCSuG3(xO9`?ndyci(+ie9pnlZKmxZyM9PEjSmg`rm3YD zZtT~ex~ZiXZffa8&Wrak{?Kn$S$@x|G5%S8z4O+$Zs)CU#XhaAtgPgpD=RCx{dmpC zt2yw`0Pv6O`aiX_Up22n_&YSx?_61b=M=p#_N*UdE+SSCzxni^u=lUu=l4JR+dlm4 zZ)v|asr`PSg)l^E2;;RSd{fB-@E2Lm`>Qb6ngbyNGISpz>V>J^hjjPZzs2i=ATbv_ zB%hYtN88x1KP_BiPPFh^em~ICXuh-LJ}nQ1@*ke!R6%9HlYp~=7XmQ_JPKSYcp8X- zvE=~zAv6PcgRi$$rNL3IA`JknNCN;X(g47UGyt$74FIf2Q2_BMmGu93@L#_y`D0;k zFSg+A#cE#{-u*D`J)}mBMgy|HVtS;W20-|Eb<+u;>`yL{_V!}S{qeuMrTKS194**i zrO_`L3H@QN%j7}!1)?+nDNtp?*!wBF1Q01m6vQ%Kh`^#T}2IEhrG0e}^00ANM>AAr->SW2r})Bpeg07*qoM6N<$g4J)* AHUIzs literal 0 HcmV?d00001 diff --git a/assets/dev_icons/manifest.lua b/assets/dev_icons/manifest.lua new file mode 100644 index 0000000..8115bea --- /dev/null +++ b/assets/dev_icons/manifest.lua @@ -0,0 +1,6 @@ +return { + tileSize = 16, + ["dev_attack"] = { 44 }, + ["dev_mana"] = { 42 }, + ["dev_move"] = { 51 }, +} -- 2.47.2 From a8c188b24e355a6a91d8d40b6250613a5790ca87 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Sun, 2 Nov 2025 05:48:09 +0300 Subject: [PATCH 02/24] Add pickQuad method to spriteAtlas for random quad selection --- lib/level/procedural.lua | 3 +-- lib/utils/sprite_atlas.lua | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/level/procedural.lua b/lib/level/procedural.lua index 0722ca1..80e80f5 100644 --- a/lib/level/procedural.lua +++ b/lib/level/procedural.lua @@ -8,8 +8,7 @@ local function new(template, size) local tileMap = require("lib.utils.sprite_atlas").load(Tree.assets.files.tiles.grass) for y = 0, size.y - 1 do for x = 0, size.x - 1 do - local type = tileMap.map["flower_grass"] - local tile = require('lib.level.tile').new { quad = type[math.random(1, #type)], atlas = tileMap.atlas } + local tile = require('lib.level.tile').new { quad = tileMap:pickQuad("flower_grass"), atlas = tileMap.atlas } :copyWith({ position = Vec3 { x, y } }) map[tostring(tile.position)] = tile end diff --git a/lib/utils/sprite_atlas.lua b/lib/utils/sprite_atlas.lua index c51ae5c..25da58a 100644 --- a/lib/utils/sprite_atlas.lua +++ b/lib/utils/sprite_atlas.lua @@ -52,4 +52,18 @@ local function load(path) return setmetatable(_spriteAtlas, spriteAtlas) end +--- Returns a random quad of the group +--- +--- @param tag string +--- @return love.Quad +function spriteAtlas:pickQuad(tag) + local group = self.map[tag] + if group then + return group[math.random(1, #group)] + end + + print("[SpriteAtlas]: no tile with a tag '" .. tag .. "'") + return love.graphics.newQuad(0, 0, self.tileSize, self.tileSize, self.atlas) --fallback +end + return { load = load } -- 2.47.2 From 72eb93baf704439340621c70676ebe4fb5cd4923 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Tue, 4 Nov 2025 01:15:42 +0300 Subject: [PATCH 03/24] Add selection tracking to selector with selected and deselected methods --- lib/level/selector.lua | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/level/selector.lua b/lib/level/selector.lua index fb9f9f6..b175151 100644 --- a/lib/level/selector.lua +++ b/lib/level/selector.lua @@ -1,5 +1,6 @@ --- @class Selector --- @field id Id | nil +--- @field lastId Id | nil --- @field locked boolean local selector = {} selector.__index = selector @@ -15,6 +16,7 @@ function selector:select(characterId) end function selector:update(dt) + self.lastId = self.id if self.locked or not Tree.controls:isJustPressed("select") then return end local mousePosition = Tree.level.camera:toWorldPosition(Vec3 { love.mouse.getX(), love.mouse.getY() }):floor() @@ -53,6 +55,16 @@ function selector:unlock() self.locked = false end +--- If a character was selected during this tick, returns its Id +function selector:selected() + if self.id and self.id ~= self.lastId then return self.id end +end + +--- If a character was **de**selected during this tick, returns its Id +function selector:deselected() + if not self.id and self.lastId then return self.lastId end +end + return { new = new } -- 2.47.2 From 21dbf994351eadfd56f43d15b321dca3a3480ad0 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Tue, 4 Nov 2025 01:15:49 +0300 Subject: [PATCH 04/24] Add tag field to Spell and assign tags to spells --- lib/spellbook.lua | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/spellbook.lua b/lib/spellbook.lua index 7fca275..83b3d6a 100644 --- a/lib/spellbook.lua +++ b/lib/spellbook.lua @@ -10,11 +10,13 @@ local AnimationNode = require "lib.animation_node" --- @class Spell Здесь будет много бойлерплейта, поэтому тоже понадобится спеллмейкерский фреймворк, который просто вернет готовый Spell +--- @field tag string --- @field update fun(self: Spell, caster: Character, dt: number): nil Изменяет состояние спелла --- @field draw fun(self: Spell): nil Рисует превью каста, ничего не должна изменять в идеальном мире --- @field cast fun(self: Spell, caster: Character, target: Vec3): boolean Вызывается в момент каста, изменяет мир. Возвращает bool в зависимости от того, получилось ли скастовать local spell = {} spell.__index = spell +spell.tag = "base" function spell:update(caster, dt) end @@ -26,6 +28,7 @@ local walk = setmetatable({ --- @type Deque path = nil }, spell) +walk.tag = "dev_move" function walk:cast(caster, target) if not caster:try(Tree.behaviors.stats, function(stats) @@ -73,6 +76,7 @@ function walk:draw() end local regenerateMana = setmetatable({}, spell) +regenerateMana.tag = "dev_mana" function regenerateMana:cast(caster, target) caster:try(Tree.behaviors.stats, function(stats) @@ -92,6 +96,7 @@ function regenerateMana:cast(caster, target) end local attack = setmetatable({}, spell) +attack.tag = "dev_attack" function attack:cast(caster, target) if caster:try(Tree.behaviors.map, function(map) @@ -102,7 +107,7 @@ function attack:cast(caster, target) return false end - caster:try(Tree.behaviors.stats, function (stats) + caster:try(Tree.behaviors.stats, function(stats) stats.mana = stats.mana - 2 end) -- 2.47.2 From 14225002e25db543ddc95fe7341cdb740e8f376f Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Tue, 4 Nov 2025 01:16:29 +0300 Subject: [PATCH 05/24] Add naive ui v2 implementation --- lib/simple_ui/level_layout.lua | 189 +++++++++++++++++++++++++++++++++ main.lua | 11 +- 2 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 lib/simple_ui/level_layout.lua diff --git a/lib/simple_ui/level_layout.lua b/lib/simple_ui/level_layout.lua new file mode 100644 index 0000000..fc0249a --- /dev/null +++ b/lib/simple_ui/level_layout.lua @@ -0,0 +1,189 @@ +local icons = require("lib.utils.sprite_atlas").load(Tree.assets.files.dev_icons) +local AnimationNode = require "lib.animation_node" + +--- @class UIElement +local uiElement = { + x = 0, + y = 0, + width = 0, + height = 0, + transform = love.math.newTransform() +} +uiElement.__index = uiElement + +function uiElement:update(dt) end + +function uiElement:draw() end + +function uiElement:show(animationNode) end + +function uiElement:hide(animationNode) end + +function uiElement:hitTest(screenX, screenY) + local lx, ly = self.transform:inverseTransformPoint(screenX, screenY) + local belongs = function(value, lower, upper) + return value >= lower and value < upper + end + return belongs(lx, self.x, self.x + self.width) and belongs(ly, self.y, self.y + self.height) +end + +--- @class _SkillButton : UIElement +--- @field showNode AnimationNode? +--- @field showT number +--- @field hovered boolean +--- @field selected boolean +--- @field onClick function? +--- @field icon string +local skillButton = setmetatable({}, uiElement) +skillButton.__index = skillButton + +function skillButton.new(icon) + return setmetatable({ + icon = icon + }, skillButton) +end + +local function easeIn(x) + return x * x * x +end + +function skillButton:update(dt) + local mx, my = love.mouse.getPosition() + if self:hitTest(mx, my) then + self.hovered = true + if Tree.controls:isJustPressed("select") then + if self.onClick then self.onClick() end + Tree.controls:consume("select") + end + else + self.hovered = false + end + + if self.showT < 300 then + self.showT = self.showT + dt * 1000 + self.y = 10 * easeIn(-1 + self.showT / 300) + return + end + if self.showNode then + self.showNode:finish() + self.showNode = nil + end +end + +--- @param animationNode AnimationNode +function skillButton:show(animationNode) + if self.showNode then + self.showNode:finish() + end + self.showT = 0 + self.showNode = animationNode +end + +function skillButton:draw() + love.graphics.push() + love.graphics.applyTransform(self.transform) + local alpha = self.showT / 300 + + if self.selected then + love.graphics.setColor(0.3, 1, 0.3, alpha) + elseif self.hovered then + love.graphics.setColor(0.7, 1, 0.7, alpha) + else + love.graphics.setColor(1, 1, 1, alpha) + end + + love.graphics.translate(0, self.y) + love.graphics.draw(icons.atlas, icons:pickQuad(self.icon)) + love.graphics.setColor(1, 1, 1) + love.graphics.pop() +end + +--- @class _SkillRow : UIElement +--- @field characterId Id +--- @field children _SkillButton[] +local skillRow = setmetatable({}, uiElement) +skillRow.__index = skillRow + +--- @param characterId Id +function skillRow.new(characterId) + local t = { + characterId = characterId, + children = {} + } + 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(spell.tag) + skb.onClick = function() + skb.selected = not skb.selected + + if not behavior.cast then + behavior.cast = behavior.spellbook[i] + behavior.state = "casting" + else + behavior.state = "idle" + behavior.cast = nil + end + end + t.children[i] = skb + end + end) + + return setmetatable(t, skillRow) +end + +function skillRow:show() + for _, skb in ipairs(self.children) do + AnimationNode { + function(animationNode) + skb:show(animationNode) + end, + }:run() + end +end + +function skillRow:update(dt) + local iconSize = icons.tileSize + local scale = (64 / iconSize) + local screenW, screenH = love.graphics.getDimensions() + local padding = 8 + local count = #self.children + self.width, self.height, self.x, self.y = count * icons.tileSize + (count - 1) * padding, iconSize, 0, + 0 -- в локальных координатах + + self.transform = love.math.newTransform():translate(screenW / 2, + screenH - 16):scale(scale, scale):translate(-self.width / 2, -iconSize) + + for i, skb in ipairs(self.children) do + skb.width, skb.height = iconSize, iconSize + skb.transform = self.transform:clone():translate(self.x + (i - 1) * iconSize + + (i - 1) * + padding, -- левый край ряда + размер предыдущих иконок + размер предыдущих отступов + 0 -- высота не меняется + ) + skb:update(dt) + end +end + +function skillRow:draw() + for _, skb in ipairs(self.children) do + skb:draw() + end +end + +local layout = {} +function layout:update(dt) + local cid = Tree.level.selector:selected() + if cid then + self.skillRow = skillRow.new(cid) + self.skillRow:show() + end + if Tree.level.selector:deselected() then self.skillRow = nil end + if self.skillRow then self.skillRow:update(dt) end +end + +function layout:draw() + if self.skillRow then self.skillRow:draw() end +end + +return layout diff --git a/main.lua b/main.lua index 9c740fa..4ec8c8c 100644 --- a/main.lua +++ b/main.lua @@ -3,7 +3,7 @@ local character = require "lib/character/character" require "lib/tree" local layout = require "lib.ui.layout" - +local testLayout = require "lib.simple_ui.level_layout" function love.conf(t) t.console = true @@ -19,8 +19,9 @@ local lt = "0" function love.update(dt) local t1 = love.timer.getTime() Tree.controls:poll() - Widgets = layout:build() - Widgets:update(dt) -- логика UI-слоя должна отработать раньше всех, потому что нужно перехватить жесты и не пустить их дальше + testLayout:update(dt) + --Widgets = layout:build() + --Widgets:update(dt) -- логика UI-слоя должна отработать раньше всех, потому что нужно перехватить жесты и не пустить их дальше Tree.panning:update(dt) Tree.level:update(dt) Tree.controls:cache() @@ -54,8 +55,8 @@ function love.draw() Tree.level:draw() Tree.level.camera:detach() - - Widgets:draw() + testLayout:draw() + --Widgets:draw() love.graphics.setColor(1, 1, 1) local stats = "fps: " .. love.timer.getFPS() .. " lt: " .. lt .. " dt: " .. dt -- 2.47.2 From 5ba653509a3081fc794a5ae84ecf9eaec873aab0 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Tue, 4 Nov 2025 01:29:45 +0300 Subject: [PATCH 06/24] Remove deprecated UI system --- lib/ui/core.lua | 569 ---------------------------------------------- lib/ui/layout.lua | 58 ----- main.lua | 6 +- 3 files changed, 1 insertion(+), 632 deletions(-) delete mode 100644 lib/ui/core.lua delete mode 100644 lib/ui/layout.lua diff --git a/lib/ui/core.lua b/lib/ui/core.lua deleted file mode 100644 index 153f3e2..0000000 --- a/lib/ui/core.lua +++ /dev/null @@ -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 diff --git a/lib/ui/layout.lua b/lib/ui/layout.lua deleted file mode 100644 index 3a3b544..0000000 --- a/lib/ui/layout.lua +++ /dev/null @@ -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 diff --git a/main.lua b/main.lua index 4ec8c8c..ede2b2d 100644 --- a/main.lua +++ b/main.lua @@ -2,7 +2,6 @@ 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) @@ -19,9 +18,7 @@ local lt = "0" function love.update(dt) local t1 = love.timer.getTime() Tree.controls:poll() - testLayout:update(dt) - --Widgets = layout:build() - --Widgets:update(dt) -- логика UI-слоя должна отработать раньше всех, потому что нужно перехватить жесты и не пустить их дальше + testLayout:update(dt) -- логика UI-слоя должна отработать раньше всех, потому что нужно перехватить жесты и не пустить их дальше Tree.panning:update(dt) Tree.level:update(dt) Tree.controls:cache() @@ -56,7 +53,6 @@ function love.draw() Tree.level.camera:detach() testLayout:draw() - --Widgets:draw() love.graphics.setColor(1, 1, 1) local stats = "fps: " .. love.timer.getFPS() .. " lt: " .. lt .. " dt: " .. dt -- 2.47.2 From a5c9ca93f6447e41e9f043692292aedfe986b56b Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Tue, 4 Nov 2025 02:03:34 +0300 Subject: [PATCH 07/24] Add easing functions normalized to [0, 1] range --- lib/utils/easing.lua | 52 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 lib/utils/easing.lua diff --git a/lib/utils/easing.lua b/lib/utils/easing.lua new file mode 100644 index 0000000..5d3092a --- /dev/null +++ b/lib/utils/easing.lua @@ -0,0 +1,52 @@ +---- Функции смягчения, нормализованные к [0, 1] + +---@alias ease fun(x: number): number + +local easing = {} + +--- @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 -- 2.47.2 From 175062a45282174dd4da53ffa8997e9156537f5b Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Tue, 4 Nov 2025 04:43:52 +0300 Subject: [PATCH 08/24] Improve skill button animation timing and selection logic --- lib/simple_ui/level_layout.lua | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/lib/simple_ui/level_layout.lua b/lib/simple_ui/level_layout.lua index fc0249a..fc94c67 100644 --- a/lib/simple_ui/level_layout.lua +++ b/lib/simple_ui/level_layout.lua @@ -1,4 +1,5 @@ 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" --- @class UIElement @@ -27,7 +28,7 @@ function uiElement:hitTest(screenX, screenY) return belongs(lx, self.x, self.x + self.width) and belongs(ly, self.y, self.y + self.height) end ---- @class _SkillButton : UIElement +--- @class SkillButton : UIElement --- @field showNode AnimationNode? --- @field showT number --- @field hovered boolean @@ -43,10 +44,6 @@ function skillButton.new(icon) }, skillButton) end -local function easeIn(x) - return x * x * x -end - function skillButton:update(dt) local mx, my = love.mouse.getPosition() if self:hitTest(mx, my) then @@ -59,9 +56,9 @@ function skillButton:update(dt) self.hovered = false end - if self.showT < 300 then - self.showT = self.showT + dt * 1000 - self.y = 10 * easeIn(-1 + self.showT / 300) + if self.showT < 1 then + self.showT = self.showT + dt * 1 / 0.3 -- в знаменателе продолжительность анимации в секундах + self.y = 10 * easing.easeInCubic(1 - self.showT) return end if self.showNode then @@ -82,7 +79,7 @@ end function skillButton:draw() love.graphics.push() love.graphics.applyTransform(self.transform) - local alpha = self.showT / 300 + local alpha = easing.easeInSine(self.showT) if self.selected then love.graphics.setColor(0.3, 1, 0.3, alpha) @@ -98,9 +95,10 @@ function skillButton:draw() love.graphics.pop() end ---- @class _SkillRow : UIElement +--- @class SkillRow : UIElement --- @field characterId Id ---- @field children _SkillButton[] +--- @field selected SkillButton? +--- @field children SkillButton[] local skillRow = setmetatable({}, uiElement) skillRow.__index = skillRow @@ -110,12 +108,17 @@ function skillRow.new(characterId) 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(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] @@ -129,7 +132,7 @@ function skillRow.new(characterId) end end) - return setmetatable(t, skillRow) + return t end function skillRow:show() -- 2.47.2 From d4e351b0804da67a74ca4634cd7e14edd27239e8 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Tue, 4 Nov 2025 06:59:03 +0300 Subject: [PATCH 09/24] Introduce Rect class and UIElement base class for layout Refactor skill button and row to use UIElement with Rect bounds and transform Update layout references and coordinate calculations accordingly --- lib/simple_ui/element.lua | 21 +++++++++ .../{level_layout.lua => level/layout.lua} | 44 +++++-------------- lib/simple_ui/rect.lua | 24 ++++++++++ main.lua | 2 +- 4 files changed, 57 insertions(+), 34 deletions(-) create mode 100644 lib/simple_ui/element.lua rename lib/simple_ui/{level_layout.lua => level/layout.lua} (79%) create mode 100644 lib/simple_ui/rect.lua diff --git a/lib/simple_ui/element.lua b/lib/simple_ui/element.lua new file mode 100644 index 0000000..228d9ce --- /dev/null +++ b/lib/simple_ui/element.lua @@ -0,0 +1,21 @@ +local Rect = require "lib.simple_ui.rect" + +--- @class UIElement +--- @field bounds Rect Прямоугольник, в границах которого размещается элемент. Размеры и положение в *локальных* координатах +--- @field transform love.Transform Преобразование из локальных координат элемента (bounds) в экранные координаты +local uiElement = { + bounds = Rect {}, + transform = love.math.newTransform() +} +uiElement.__index = uiElement + +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) +end + +return uiElement diff --git a/lib/simple_ui/level_layout.lua b/lib/simple_ui/level/layout.lua similarity index 79% rename from lib/simple_ui/level_layout.lua rename to lib/simple_ui/level/layout.lua index fc94c67..300a957 100644 --- a/lib/simple_ui/level_layout.lua +++ b/lib/simple_ui/level/layout.lua @@ -1,32 +1,8 @@ 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" - ---- @class UIElement -local uiElement = { - x = 0, - y = 0, - width = 0, - height = 0, - transform = love.math.newTransform() -} -uiElement.__index = uiElement - -function uiElement:update(dt) end - -function uiElement:draw() end - -function uiElement:show(animationNode) end - -function uiElement:hide(animationNode) end - -function uiElement:hitTest(screenX, screenY) - local lx, ly = self.transform:inverseTransformPoint(screenX, screenY) - local belongs = function(value, lower, upper) - return value >= lower and value < upper - end - return belongs(lx, self.x, self.x + self.width) and belongs(ly, self.y, self.y + self.height) -end +local Element = require "lib.simple_ui.element" +local Rect = require "lib.simple_ui.rect" --- @class SkillButton : UIElement --- @field showNode AnimationNode? @@ -35,7 +11,7 @@ end --- @field selected boolean --- @field onClick function? --- @field icon string -local skillButton = setmetatable({}, uiElement) +local skillButton = setmetatable({}, Element) skillButton.__index = skillButton function skillButton.new(icon) @@ -99,7 +75,7 @@ end --- @field characterId Id --- @field selected SkillButton? --- @field children SkillButton[] -local skillRow = setmetatable({}, uiElement) +local skillRow = setmetatable({}, Element) skillRow.__index = skillRow --- @param characterId Id @@ -151,15 +127,17 @@ function skillRow:update(dt) local screenW, screenH = love.graphics.getDimensions() local padding = 8 local count = #self.children - self.width, self.height, self.x, self.y = count * icons.tileSize + (count - 1) * padding, iconSize, 0, - 0 -- в локальных координатах + self.bounds = Rect { + width = count * icons.tileSize + (count - 1) * padding, + height = iconSize + } self.transform = love.math.newTransform():translate(screenW / 2, - screenH - 16):scale(scale, scale):translate(-self.width / 2, -iconSize) + screenH - 16):scale(scale, scale):translate(-self.bounds.width / 2, -iconSize) for i, skb in ipairs(self.children) do - skb.width, skb.height = iconSize, iconSize - skb.transform = self.transform:clone():translate(self.x + (i - 1) * iconSize + + skb.bounds = Rect { height = iconSize, width = iconSize } + skb.transform = self.transform:clone():translate(self.bounds.x + (i - 1) * iconSize + (i - 1) * padding, -- левый край ряда + размер предыдущих иконок + размер предыдущих отступов 0 -- высота не меняется diff --git a/lib/simple_ui/rect.lua b/lib/simple_ui/rect.lua new file mode 100644 index 0000000..3f3931f --- /dev/null +++ b/lib/simple_ui/rect.lua @@ -0,0 +1,24 @@ +--- @class Rect +--- @field x number +--- @field y number +--- @field width number +--- @field height number +local rect = {} +rect.__index = rect + +--- @param table {x: number, y: number, width: number, height: number} +function rect.new(table) + local r = { + x = table.x or 0, + y = table.y or 0, + width = table.width or 0, + height = table.height or 0 + } + return setmetatable(r, rect) +end + +function rect:hasPoint(x, y) + return x >= self.x and x < self.width and y >= self.y and y < self.height +end + +return rect.new diff --git a/main.lua b/main.lua index ede2b2d..ca7093d 100644 --- a/main.lua +++ b/main.lua @@ -2,7 +2,7 @@ local character = require "lib/character/character" require "lib/tree" -local testLayout = require "lib.simple_ui.level_layout" +local testLayout = require "lib.simple_ui.level.layout" function love.conf(t) t.console = true -- 2.47.2 From b9d2b469c8fcdadaf3f04e551166ec9b37664527 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Sun, 26 Oct 2025 02:37:15 +0300 Subject: [PATCH 10/24] add test icons --- assets/dev_icons/LICENSE.md | 19 +++++++++++++++++++ assets/dev_icons/atlas.png | Bin 0 -> 5190 bytes assets/dev_icons/manifest.lua | 6 ++++++ 3 files changed, 25 insertions(+) create mode 100644 assets/dev_icons/LICENSE.md create mode 100644 assets/dev_icons/atlas.png create mode 100644 assets/dev_icons/manifest.lua diff --git a/assets/dev_icons/LICENSE.md b/assets/dev_icons/LICENSE.md new file mode 100644 index 0000000..c2d8090 --- /dev/null +++ b/assets/dev_icons/LICENSE.md @@ -0,0 +1,19 @@ +Created by [Marvyra](https://www.gamedevmarket.net/member/marvyra) + +4.1. A "Licence" means that the Seller grants to GDN (purely for the purpose of sub-licensing to the Purchaser) and GDN grants (by way of sub-licence thereof) to the Purchaser a non-exclusive perpetual licence to; + +(a) use the Licensed Asset to create Derivative Works; and + +(b) use the Licensed Asset and any Derivative Works as part of both Non-Monetized Media Products and Monetized Media Products, with no restriction on the number of projects the Licensed Asset may be used in. In either case, the Licensed Assets can be used in Media Products that are either: + +i) used for the Purchaser's own personal use; and/or + +ii) used for the Purchaser’s commercial use in which case it may be distributed, sold and supplied by the Purchaser for any fee that the Purchaser may determine. + +4.2. A Licence does not allow the Purchaser to: + +(a) Use the Licensed Asset or Derivative Works in a logo, trademark or service mark; + +(b) Use, sell, share, transfer, give away, sublicense or redistribute the Licensed Asset or Derivate Works other than as part of the relevant Media Product; or + +(c) Allow the user of the Media Product to extract the Licensed Asset or Derivative Works and use them outside of the relevant Media Product. diff --git a/assets/dev_icons/atlas.png b/assets/dev_icons/atlas.png new file mode 100644 index 0000000000000000000000000000000000000000..f6dc572476b54ed4c8c51791deb058bbecf72993 GIT binary patch literal 5190 zcmV-M6uIk(P)Px}07*naRCt{2T}^0Q*A+gapQzHpg&NX$BCAjkvUgpzyHW&Qg@P$9$llqD8V3w6 zPLaR_V}xN6$5ch2VlQO6QM3rbP_zn&&{dt4yNb*}7Vf8c=PB#7&;g8w0#m1cy>_GBz zAS&7-*BWz-LX+jRC5&&s^SodK+Mxl!pX0l@>6ih(D|RjV)!S!%E~BvY!Grl0cB5KPfu-x&8m)ns%w#`BQp zW83dMFQfz{F$YTEovHwYm1x+|NmKi@5R`IUitRLP>l@>6EYpANo#${Y^LoA*001Lg zkW@H;fT>Mji-kuBI6jt6Qc4vZ5XlC<*d69M^^I}ZQo_GrLk1A{zGImjZnu~Nxq({V z;k=zlg~Jfg^3W;L_;Wg$P##9WI6oK`pd;ixAYLuav8J7cV^vY3PUfX`;S;iuunw6xa&Jfl z5j}|Wa?hjV_;|dhEnZ2A#= zxBeBp`TmQ-+%!9mO+SJsi=#O|>cjgt-+v+UMOV@4eRMoe#C(M{e#t8=wrIJHrxqH% zT((w~lR~&m$nek&g>WpdS}LfOS6fUC7%k=SG>`;f3W(A~LeDg>4|!z>L@oDm&|=#Y zZH$704Ls{nPTOZ$6TfCd3W$8sXvkXUqn%m=;KSghZh~V+r<4$ujAX6`gkg9~40=?S=CW>X&=B z?Pc&}Qym)RAZD^eTj)f&4q`{h2Fd0aPhL^7mG|4cbJ^;rRVl((Z#f6^VSbcf7(egy z`&3&k_OLMq`@;Z;*9QPza3K{CExJ9u^8p#8PXkhzN5pu`1eGyVX&E3TP)uGJTflkQ z7Hu3~JRQKYp&Idk%HRb&Bo;N)2C*;Z7)&RhLvb7PK@HmA7my_4$+|=pue<}mBACsm zez|K){WA3ChjS%__7;d@p;Y7es+S1evfd_zzSSGl@C1^Auku96ES}qNj!cU;VP+X;~!oi^P`+hfe%R zmU4zAd{|`*)tB^@Ne>cLO8ST09xY9}n`e6WH_vpR;dAW6=X`8c{jbIQz(4$E5^kO; zKKtbJ7X0BilZ7#qZl3X<#fa;G*Xe~Sf^qG>4<4m!_lx5#^k}KOK*&ph^Z=xt650xEln0=0*z{!K$LO- z`ATaOlt958ltQ5-&L@aAp>CX%1P%iZrANKm(*O;+~3)E9S(*Jv2GW zPd$%<$ZZD!{Fy7QN`(<4Q34MrfD}Bcgo0;X%~0q9tDG81VTPU{fvDwK5K=)%r<4Xl z^UeXJIgfjx1}+%DxOQLmJk2O7A6K4xBLy+UOP^7FnipcOo4L~R_LAEChP0eVmC%Yr zS5q1zk)~YYk-SA3CxNJWngpg+h;E+gE;wB{ZMOgbFhr{i2yQER9IZ$pZ{mDlEI4$6 zd@L>^2SM{R$+p-@1#QIZP}mP~rIh%72tY6A)Hq27j;Hb@AbF(?8LypnIhfp5%{h?h zRwtw%0APr^H+OrUR5^G&|Aof#30SUjAi4mS zs;%|Y+(vCeq6iN|gX8p3aA2Pk8ToplOLM9|ndacxN@U+EGTxdn;MHT>G@! zzCKd8j`4(^qa50{uaD^47yy7b&P@XV!1rs-Qn{oGZC@{%Hg#jal$?_nrz_bobz>j4 zuaAgwPF|conqTpi4Bm!jX%NfO$&1qfH@*yx@y^0~AL*Ee`eNkTr}GAahapk$YV|Ln zR_{*WgtfnQ8;3>pa}9Nfj;gts%^lNYCRV=0I!o+W|b58`rh z;V}Phv-wZm*oWHQMV=xO zgg{3gNiOw7&QtkY`qWSfJemXP7&?}E9|sRe?voaSQ^=F8>4sCS{$(CH*w9;R*(SL_ zS_n(~!I<~*{1xjDjgRF*Y2SuXL35eriO3i8R!-t6gzvhw(}=V?r@MCC$@6j8XrH#@ z*JMey;3qwSa9!3@jOEnhZDU;fG>pBsU+^}pz^;8dx{aYLPgfGRFtz%Zg@O~}N5X4u zuSUZTL!GAzObrqbrKubHxz}0>TVcf5d;4OgMbfr18fjrLbz?uT&=N@iO9hG(t(sDJ zYehn`ZyUP$+{JJK@*oZYr1jCsS@v8K4+n=A5e_Q7#|lk1>e2!sc+#Ga)6|W9fE&Gn zM&(i6mACa}zyq?%z&tV(-W)Va2pm2BNn`H~-Z z&aL;J;k8xx+Fafcgj66J4q&0yZLbtWI&X;t47|9f_cZ=v`5+%tqE8%0^+)vbc@)O; z`#$DCNHWAa@qhtQ5D8!&zr_~LX9vPtytE?1U2|;?8fVv`adutoi@AB@Y%df<83#dv z-c&mlYGf=FuccHnL^$afNWDWPb?l9FP9S$ijGP{TXfI)#ZQeWH4LQk;d46Fu>u%*^X5FnqI=FDarfXt(+gPH}4(q8lRGXG@X&f z1L)?t&3nhYpb;Po^D+>fX9#@X11Wf%!8iex4E)*xB`pH+-dtXpO1!!Xo3+36qEWD4 z-NwwGe+rwhS^zZUeZJxgdH7-lo<0Axd!0xH9!of1Ai5Jh&$Ivht>8{1xDT8?{}kXn z=CG&!WFk};d*K3Xmf4Q2u7;yR(@n!TK`(|qf*7<%I?BzuTlvI!0f>T^38%R*q+F9h zJw0E2C=rGw_K^k8m59lb&%&u2UV+yGKP8kT5KHE;GHFBFIfQKJ-(K%1wtntqg*JE_ zQ;`O5V=7X&3g7PjAmQ+(2@})HzwdT0u1>=6?oRjqYTAD-@w!mK19M;kB#jxqG?6a? z+-~)*e*o0Wfn?H}Clv1w{i@u4$v#hw_6Twx7_gVj#sXScX#rB&NIBbQ)u28 zW21Tt`iMNqHcduKUp{{_C+}KrvJejxszBfoK_t(k07gLaz>U};@mm*{&m}SkeWfU4 z5CpbzlCjp+FO1_JFO5+ak@J}&Bou*?P2xc2g;7^F7;EujKF*tP2tWyR4t~u0m{q~j7B!2qq z8mYgu(HQ>>7M}k$`b?sgm6gK%rAwCz_qAH_-*AWkKFmIUq1?x4YUu@h^XWg~r@#MQ z;h6@y`GNVCSuG3(xO9`?ndyci(+ie9pnlZKmxZyM9PEjSmg`rm3YD zZtT~ex~ZiXZffa8&Wrak{?Kn$S$@x|G5%S8z4O+$Zs)CU#XhaAtgPgpD=RCx{dmpC zt2yw`0Pv6O`aiX_Up22n_&YSx?_61b=M=p#_N*UdE+SSCzxni^u=lUu=l4JR+dlm4 zZ)v|asr`PSg)l^E2;;RSd{fB-@E2Lm`>Qb6ngbyNGISpz>V>J^hjjPZzs2i=ATbv_ zB%hYtN88x1KP_BiPPFh^em~ICXuh-LJ}nQ1@*ke!R6%9HlYp~=7XmQ_JPKSYcp8X- zvE=~zAv6PcgRi$$rNL3IA`JknNCN;X(g47UGyt$74FIf2Q2_BMmGu93@L#_y`D0;k zFSg+A#cE#{-u*D`J)}mBMgy|HVtS;W20-|Eb<+u;>`yL{_V!}S{qeuMrTKS194**i zrO_`L3H@QN%j7}!1)?+nDNtp?*!wBF1Q01m6vQ%Kh`^#T}2IEhrG0e}^00ANM>AAr->SW2r})Bpeg07*qoM6N<$g4J)* AHUIzs literal 0 HcmV?d00001 diff --git a/assets/dev_icons/manifest.lua b/assets/dev_icons/manifest.lua new file mode 100644 index 0000000..8115bea --- /dev/null +++ b/assets/dev_icons/manifest.lua @@ -0,0 +1,6 @@ +return { + tileSize = 16, + ["dev_attack"] = { 44 }, + ["dev_mana"] = { 42 }, + ["dev_move"] = { 51 }, +} -- 2.47.2 From f1d181fb64153c5fbb5ac11fb2eae5da03723c8f Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Tue, 4 Nov 2025 01:15:42 +0300 Subject: [PATCH 11/24] Add selection tracking to selector with selected and deselected methods --- lib/level/selector.lua | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/level/selector.lua b/lib/level/selector.lua index fb9f9f6..b175151 100644 --- a/lib/level/selector.lua +++ b/lib/level/selector.lua @@ -1,5 +1,6 @@ --- @class Selector --- @field id Id | nil +--- @field lastId Id | nil --- @field locked boolean local selector = {} selector.__index = selector @@ -15,6 +16,7 @@ function selector:select(characterId) end function selector:update(dt) + self.lastId = self.id if self.locked or not Tree.controls:isJustPressed("select") then return end local mousePosition = Tree.level.camera:toWorldPosition(Vec3 { love.mouse.getX(), love.mouse.getY() }):floor() @@ -53,6 +55,16 @@ function selector:unlock() self.locked = false end +--- If a character was selected during this tick, returns its Id +function selector:selected() + if self.id and self.id ~= self.lastId then return self.id end +end + +--- If a character was **de**selected during this tick, returns its Id +function selector:deselected() + if not self.id and self.lastId then return self.lastId end +end + return { new = new } -- 2.47.2 From 660edc5ef81eafe62f990a12bab82801cfdd3df1 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Tue, 4 Nov 2025 01:15:49 +0300 Subject: [PATCH 12/24] Add tag field to Spell and assign tags to spells --- lib/spellbook.lua | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/spellbook.lua b/lib/spellbook.lua index e117065..9c41b01 100644 --- a/lib/spellbook.lua +++ b/lib/spellbook.lua @@ -10,11 +10,13 @@ local AnimationNode = require "lib.animation_node" --- @class Spell Здесь будет много бойлерплейта, поэтому тоже понадобится спеллмейкерский фреймворк, который просто вернет готовый Spell +--- @field tag string --- @field update fun(self: Spell, caster: Character, dt: number): nil Изменяет состояние спелла --- @field draw fun(self: Spell): nil Рисует превью каста, ничего не должна изменять в идеальном мире --- @field cast fun(self: Spell, caster: Character, target: Vec3): boolean Вызывается в момент каста, изменяет мир. Возвращает bool в зависимости от того, получилось ли скастовать local spell = {} spell.__index = spell +spell.tag = "base" function spell:update(caster, dt) end @@ -26,6 +28,7 @@ local walk = setmetatable({ --- @type Deque path = nil }, spell) +walk.tag = "dev_move" function walk:cast(caster, target) if not caster:try(Tree.behaviors.stats, function(stats) @@ -73,6 +76,7 @@ function walk:draw() end local regenerateMana = setmetatable({}, spell) +regenerateMana.tag = "dev_mana" function regenerateMana:cast(caster, target) caster:try(Tree.behaviors.stats, function(stats) @@ -92,6 +96,7 @@ function regenerateMana:cast(caster, target) end local attack = setmetatable({}, spell) +attack.tag = "dev_attack" function attack:cast(caster, target) if caster:try(Tree.behaviors.map, function(map) -- 2.47.2 From 35a7a69bf7670dbb736a231ee411db9a6a7a4d34 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Tue, 4 Nov 2025 01:16:29 +0300 Subject: [PATCH 13/24] Add naive ui v2 implementation --- lib/simple_ui/level_layout.lua | 189 +++++++++++++++++++++++++++++++++ main.lua | 11 +- 2 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 lib/simple_ui/level_layout.lua diff --git a/lib/simple_ui/level_layout.lua b/lib/simple_ui/level_layout.lua new file mode 100644 index 0000000..fc0249a --- /dev/null +++ b/lib/simple_ui/level_layout.lua @@ -0,0 +1,189 @@ +local icons = require("lib.utils.sprite_atlas").load(Tree.assets.files.dev_icons) +local AnimationNode = require "lib.animation_node" + +--- @class UIElement +local uiElement = { + x = 0, + y = 0, + width = 0, + height = 0, + transform = love.math.newTransform() +} +uiElement.__index = uiElement + +function uiElement:update(dt) end + +function uiElement:draw() end + +function uiElement:show(animationNode) end + +function uiElement:hide(animationNode) end + +function uiElement:hitTest(screenX, screenY) + local lx, ly = self.transform:inverseTransformPoint(screenX, screenY) + local belongs = function(value, lower, upper) + return value >= lower and value < upper + end + return belongs(lx, self.x, self.x + self.width) and belongs(ly, self.y, self.y + self.height) +end + +--- @class _SkillButton : UIElement +--- @field showNode AnimationNode? +--- @field showT number +--- @field hovered boolean +--- @field selected boolean +--- @field onClick function? +--- @field icon string +local skillButton = setmetatable({}, uiElement) +skillButton.__index = skillButton + +function skillButton.new(icon) + return setmetatable({ + icon = icon + }, skillButton) +end + +local function easeIn(x) + return x * x * x +end + +function skillButton:update(dt) + local mx, my = love.mouse.getPosition() + if self:hitTest(mx, my) then + self.hovered = true + if Tree.controls:isJustPressed("select") then + if self.onClick then self.onClick() end + Tree.controls:consume("select") + end + else + self.hovered = false + end + + if self.showT < 300 then + self.showT = self.showT + dt * 1000 + self.y = 10 * easeIn(-1 + self.showT / 300) + return + end + if self.showNode then + self.showNode:finish() + self.showNode = nil + end +end + +--- @param animationNode AnimationNode +function skillButton:show(animationNode) + if self.showNode then + self.showNode:finish() + end + self.showT = 0 + self.showNode = animationNode +end + +function skillButton:draw() + love.graphics.push() + love.graphics.applyTransform(self.transform) + local alpha = self.showT / 300 + + if self.selected then + love.graphics.setColor(0.3, 1, 0.3, alpha) + elseif self.hovered then + love.graphics.setColor(0.7, 1, 0.7, alpha) + else + love.graphics.setColor(1, 1, 1, alpha) + end + + love.graphics.translate(0, self.y) + love.graphics.draw(icons.atlas, icons:pickQuad(self.icon)) + love.graphics.setColor(1, 1, 1) + love.graphics.pop() +end + +--- @class _SkillRow : UIElement +--- @field characterId Id +--- @field children _SkillButton[] +local skillRow = setmetatable({}, uiElement) +skillRow.__index = skillRow + +--- @param characterId Id +function skillRow.new(characterId) + local t = { + characterId = characterId, + children = {} + } + 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(spell.tag) + skb.onClick = function() + skb.selected = not skb.selected + + if not behavior.cast then + behavior.cast = behavior.spellbook[i] + behavior.state = "casting" + else + behavior.state = "idle" + behavior.cast = nil + end + end + t.children[i] = skb + end + end) + + return setmetatable(t, skillRow) +end + +function skillRow:show() + for _, skb in ipairs(self.children) do + AnimationNode { + function(animationNode) + skb:show(animationNode) + end, + }:run() + end +end + +function skillRow:update(dt) + local iconSize = icons.tileSize + local scale = (64 / iconSize) + local screenW, screenH = love.graphics.getDimensions() + local padding = 8 + local count = #self.children + self.width, self.height, self.x, self.y = count * icons.tileSize + (count - 1) * padding, iconSize, 0, + 0 -- в локальных координатах + + self.transform = love.math.newTransform():translate(screenW / 2, + screenH - 16):scale(scale, scale):translate(-self.width / 2, -iconSize) + + for i, skb in ipairs(self.children) do + skb.width, skb.height = iconSize, iconSize + skb.transform = self.transform:clone():translate(self.x + (i - 1) * iconSize + + (i - 1) * + padding, -- левый край ряда + размер предыдущих иконок + размер предыдущих отступов + 0 -- высота не меняется + ) + skb:update(dt) + end +end + +function skillRow:draw() + for _, skb in ipairs(self.children) do + skb:draw() + end +end + +local layout = {} +function layout:update(dt) + local cid = Tree.level.selector:selected() + if cid then + self.skillRow = skillRow.new(cid) + self.skillRow:show() + end + if Tree.level.selector:deselected() then self.skillRow = nil end + if self.skillRow then self.skillRow:update(dt) end +end + +function layout:draw() + if self.skillRow then self.skillRow:draw() end +end + +return layout diff --git a/main.lua b/main.lua index 9c740fa..4ec8c8c 100644 --- a/main.lua +++ b/main.lua @@ -3,7 +3,7 @@ local character = require "lib/character/character" require "lib/tree" local layout = require "lib.ui.layout" - +local testLayout = require "lib.simple_ui.level_layout" function love.conf(t) t.console = true @@ -19,8 +19,9 @@ local lt = "0" function love.update(dt) local t1 = love.timer.getTime() Tree.controls:poll() - Widgets = layout:build() - Widgets:update(dt) -- логика UI-слоя должна отработать раньше всех, потому что нужно перехватить жесты и не пустить их дальше + testLayout:update(dt) + --Widgets = layout:build() + --Widgets:update(dt) -- логика UI-слоя должна отработать раньше всех, потому что нужно перехватить жесты и не пустить их дальше Tree.panning:update(dt) Tree.level:update(dt) Tree.controls:cache() @@ -54,8 +55,8 @@ function love.draw() Tree.level:draw() Tree.level.camera:detach() - - Widgets:draw() + testLayout:draw() + --Widgets:draw() love.graphics.setColor(1, 1, 1) local stats = "fps: " .. love.timer.getFPS() .. " lt: " .. lt .. " dt: " .. dt -- 2.47.2 From 2802570a50442abcd2df37952cc10aebfb12d062 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Tue, 4 Nov 2025 01:29:45 +0300 Subject: [PATCH 14/24] Remove deprecated UI system --- lib/ui/core.lua | 569 ---------------------------------------------- lib/ui/layout.lua | 58 ----- main.lua | 6 +- 3 files changed, 1 insertion(+), 632 deletions(-) delete mode 100644 lib/ui/core.lua delete mode 100644 lib/ui/layout.lua diff --git a/lib/ui/core.lua b/lib/ui/core.lua deleted file mode 100644 index 153f3e2..0000000 --- a/lib/ui/core.lua +++ /dev/null @@ -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 diff --git a/lib/ui/layout.lua b/lib/ui/layout.lua deleted file mode 100644 index 3a3b544..0000000 --- a/lib/ui/layout.lua +++ /dev/null @@ -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 diff --git a/main.lua b/main.lua index 4ec8c8c..ede2b2d 100644 --- a/main.lua +++ b/main.lua @@ -2,7 +2,6 @@ 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) @@ -19,9 +18,7 @@ local lt = "0" function love.update(dt) local t1 = love.timer.getTime() Tree.controls:poll() - testLayout:update(dt) - --Widgets = layout:build() - --Widgets:update(dt) -- логика UI-слоя должна отработать раньше всех, потому что нужно перехватить жесты и не пустить их дальше + testLayout:update(dt) -- логика UI-слоя должна отработать раньше всех, потому что нужно перехватить жесты и не пустить их дальше Tree.panning:update(dt) Tree.level:update(dt) Tree.controls:cache() @@ -56,7 +53,6 @@ function love.draw() Tree.level.camera:detach() testLayout:draw() - --Widgets:draw() love.graphics.setColor(1, 1, 1) local stats = "fps: " .. love.timer.getFPS() .. " lt: " .. lt .. " dt: " .. dt -- 2.47.2 From 99fe4c0556eaa22732b2d4a35fa3cc7f17808c8d Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Tue, 4 Nov 2025 02:03:34 +0300 Subject: [PATCH 15/24] Add easing functions normalized to [0, 1] range --- lib/utils/easing.lua | 52 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 lib/utils/easing.lua diff --git a/lib/utils/easing.lua b/lib/utils/easing.lua new file mode 100644 index 0000000..5d3092a --- /dev/null +++ b/lib/utils/easing.lua @@ -0,0 +1,52 @@ +---- Функции смягчения, нормализованные к [0, 1] + +---@alias ease fun(x: number): number + +local easing = {} + +--- @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 -- 2.47.2 From da2f6d03a396931be1707c1ad299bf8d55209188 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Tue, 4 Nov 2025 04:43:52 +0300 Subject: [PATCH 16/24] Improve skill button animation timing and selection logic --- lib/simple_ui/level_layout.lua | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/lib/simple_ui/level_layout.lua b/lib/simple_ui/level_layout.lua index fc0249a..fc94c67 100644 --- a/lib/simple_ui/level_layout.lua +++ b/lib/simple_ui/level_layout.lua @@ -1,4 +1,5 @@ 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" --- @class UIElement @@ -27,7 +28,7 @@ function uiElement:hitTest(screenX, screenY) return belongs(lx, self.x, self.x + self.width) and belongs(ly, self.y, self.y + self.height) end ---- @class _SkillButton : UIElement +--- @class SkillButton : UIElement --- @field showNode AnimationNode? --- @field showT number --- @field hovered boolean @@ -43,10 +44,6 @@ function skillButton.new(icon) }, skillButton) end -local function easeIn(x) - return x * x * x -end - function skillButton:update(dt) local mx, my = love.mouse.getPosition() if self:hitTest(mx, my) then @@ -59,9 +56,9 @@ function skillButton:update(dt) self.hovered = false end - if self.showT < 300 then - self.showT = self.showT + dt * 1000 - self.y = 10 * easeIn(-1 + self.showT / 300) + if self.showT < 1 then + self.showT = self.showT + dt * 1 / 0.3 -- в знаменателе продолжительность анимации в секундах + self.y = 10 * easing.easeInCubic(1 - self.showT) return end if self.showNode then @@ -82,7 +79,7 @@ end function skillButton:draw() love.graphics.push() love.graphics.applyTransform(self.transform) - local alpha = self.showT / 300 + local alpha = easing.easeInSine(self.showT) if self.selected then love.graphics.setColor(0.3, 1, 0.3, alpha) @@ -98,9 +95,10 @@ function skillButton:draw() love.graphics.pop() end ---- @class _SkillRow : UIElement +--- @class SkillRow : UIElement --- @field characterId Id ---- @field children _SkillButton[] +--- @field selected SkillButton? +--- @field children SkillButton[] local skillRow = setmetatable({}, uiElement) skillRow.__index = skillRow @@ -110,12 +108,17 @@ function skillRow.new(characterId) 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(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] @@ -129,7 +132,7 @@ function skillRow.new(characterId) end end) - return setmetatable(t, skillRow) + return t end function skillRow:show() -- 2.47.2 From d6a57a9727001bf31ec1a8c445eeb1aed20f5b2e Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Tue, 4 Nov 2025 06:59:03 +0300 Subject: [PATCH 17/24] Introduce Rect class and UIElement base class for layout Refactor skill button and row to use UIElement with Rect bounds and transform Update layout references and coordinate calculations accordingly --- lib/simple_ui/element.lua | 21 +++++++++ .../{level_layout.lua => level/layout.lua} | 44 +++++-------------- lib/simple_ui/rect.lua | 24 ++++++++++ main.lua | 2 +- 4 files changed, 57 insertions(+), 34 deletions(-) create mode 100644 lib/simple_ui/element.lua rename lib/simple_ui/{level_layout.lua => level/layout.lua} (79%) create mode 100644 lib/simple_ui/rect.lua diff --git a/lib/simple_ui/element.lua b/lib/simple_ui/element.lua new file mode 100644 index 0000000..228d9ce --- /dev/null +++ b/lib/simple_ui/element.lua @@ -0,0 +1,21 @@ +local Rect = require "lib.simple_ui.rect" + +--- @class UIElement +--- @field bounds Rect Прямоугольник, в границах которого размещается элемент. Размеры и положение в *локальных* координатах +--- @field transform love.Transform Преобразование из локальных координат элемента (bounds) в экранные координаты +local uiElement = { + bounds = Rect {}, + transform = love.math.newTransform() +} +uiElement.__index = uiElement + +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) +end + +return uiElement diff --git a/lib/simple_ui/level_layout.lua b/lib/simple_ui/level/layout.lua similarity index 79% rename from lib/simple_ui/level_layout.lua rename to lib/simple_ui/level/layout.lua index fc94c67..300a957 100644 --- a/lib/simple_ui/level_layout.lua +++ b/lib/simple_ui/level/layout.lua @@ -1,32 +1,8 @@ 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" - ---- @class UIElement -local uiElement = { - x = 0, - y = 0, - width = 0, - height = 0, - transform = love.math.newTransform() -} -uiElement.__index = uiElement - -function uiElement:update(dt) end - -function uiElement:draw() end - -function uiElement:show(animationNode) end - -function uiElement:hide(animationNode) end - -function uiElement:hitTest(screenX, screenY) - local lx, ly = self.transform:inverseTransformPoint(screenX, screenY) - local belongs = function(value, lower, upper) - return value >= lower and value < upper - end - return belongs(lx, self.x, self.x + self.width) and belongs(ly, self.y, self.y + self.height) -end +local Element = require "lib.simple_ui.element" +local Rect = require "lib.simple_ui.rect" --- @class SkillButton : UIElement --- @field showNode AnimationNode? @@ -35,7 +11,7 @@ end --- @field selected boolean --- @field onClick function? --- @field icon string -local skillButton = setmetatable({}, uiElement) +local skillButton = setmetatable({}, Element) skillButton.__index = skillButton function skillButton.new(icon) @@ -99,7 +75,7 @@ end --- @field characterId Id --- @field selected SkillButton? --- @field children SkillButton[] -local skillRow = setmetatable({}, uiElement) +local skillRow = setmetatable({}, Element) skillRow.__index = skillRow --- @param characterId Id @@ -151,15 +127,17 @@ function skillRow:update(dt) local screenW, screenH = love.graphics.getDimensions() local padding = 8 local count = #self.children - self.width, self.height, self.x, self.y = count * icons.tileSize + (count - 1) * padding, iconSize, 0, - 0 -- в локальных координатах + self.bounds = Rect { + width = count * icons.tileSize + (count - 1) * padding, + height = iconSize + } self.transform = love.math.newTransform():translate(screenW / 2, - screenH - 16):scale(scale, scale):translate(-self.width / 2, -iconSize) + screenH - 16):scale(scale, scale):translate(-self.bounds.width / 2, -iconSize) for i, skb in ipairs(self.children) do - skb.width, skb.height = iconSize, iconSize - skb.transform = self.transform:clone():translate(self.x + (i - 1) * iconSize + + skb.bounds = Rect { height = iconSize, width = iconSize } + skb.transform = self.transform:clone():translate(self.bounds.x + (i - 1) * iconSize + (i - 1) * padding, -- левый край ряда + размер предыдущих иконок + размер предыдущих отступов 0 -- высота не меняется diff --git a/lib/simple_ui/rect.lua b/lib/simple_ui/rect.lua new file mode 100644 index 0000000..3f3931f --- /dev/null +++ b/lib/simple_ui/rect.lua @@ -0,0 +1,24 @@ +--- @class Rect +--- @field x number +--- @field y number +--- @field width number +--- @field height number +local rect = {} +rect.__index = rect + +--- @param table {x: number, y: number, width: number, height: number} +function rect.new(table) + local r = { + x = table.x or 0, + y = table.y or 0, + width = table.width or 0, + height = table.height or 0 + } + return setmetatable(r, rect) +end + +function rect:hasPoint(x, y) + return x >= self.x and x < self.width and y >= self.y and y < self.height +end + +return rect.new diff --git a/main.lua b/main.lua index ede2b2d..ca7093d 100644 --- a/main.lua +++ b/main.lua @@ -2,7 +2,7 @@ local character = require "lib/character/character" require "lib/tree" -local testLayout = require "lib.simple_ui.level_layout" +local testLayout = require "lib.simple_ui.level.layout" function love.conf(t) t.console = true -- 2.47.2 From 12d57892bef9efcbfd771e2132bff5784e83e5b4 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Wed, 5 Nov 2025 01:32:16 +0300 Subject: [PATCH 18/24] Move SkillButtton animations to the SkillRow. Implement naive state management --- lib/simple_ui/level/layout.lua | 101 ++++++++++++++++++++------------- 1 file changed, 61 insertions(+), 40 deletions(-) diff --git a/lib/simple_ui/level/layout.lua b/lib/simple_ui/level/layout.lua index 300a957..27162b4 100644 --- a/lib/simple_ui/level/layout.lua +++ b/lib/simple_ui/level/layout.lua @@ -5,8 +5,6 @@ local Element = require "lib.simple_ui.element" local Rect = require "lib.simple_ui.rect" --- @class SkillButton : UIElement ---- @field showNode AnimationNode? ---- @field showT number --- @field hovered boolean --- @field selected boolean --- @field onClick function? @@ -31,49 +29,35 @@ function skillButton:update(dt) else self.hovered = false end - - if self.showT < 1 then - self.showT = self.showT + dt * 1 / 0.3 -- в знаменателе продолжительность анимации в секундах - self.y = 10 * easing.easeInCubic(1 - self.showT) - return - end - if self.showNode then - self.showNode:finish() - self.showNode = nil - end -end - ---- @param animationNode AnimationNode -function skillButton:show(animationNode) - if self.showNode then - self.showNode:finish() - end - self.showT = 0 - self.showNode = animationNode end function skillButton:draw() love.graphics.push() love.graphics.applyTransform(self.transform) - local alpha = easing.easeInSine(self.showT) + + local r, g, b, a = love.graphics.getColor() if self.selected then - love.graphics.setColor(0.3, 1, 0.3, alpha) + love.graphics.setColor(0.3, 1, 0.3, a) elseif self.hovered then - love.graphics.setColor(0.7, 1, 0.7, alpha) + love.graphics.setColor(0.7, 1, 0.7, a) else - love.graphics.setColor(1, 1, 1, alpha) + love.graphics.setColor(1, 1, 1, a) end - love.graphics.translate(0, self.y) + love.graphics.translate(0, self.bounds.y) love.graphics.draw(icons.atlas, icons:pickQuad(self.icon)) - love.graphics.setColor(1, 1, 1) love.graphics.pop() end --- @class SkillRow : UIElement --- @field characterId Id --- @field selected SkillButton? +--- @field showNode AnimationNode? +--- @field showT number +--- @field hideNode AnimationNode? +--- @field hideT number +--- @field state "show" | "hide" --- @field children SkillButton[] local skillRow = setmetatable({}, Element) skillRow.__index = skillRow @@ -82,6 +66,9 @@ skillRow.__index = skillRow function skillRow.new(characterId) local t = { characterId = characterId, + showT = 0, + hideT = 0, + state = "show", children = {} } @@ -111,17 +98,35 @@ function skillRow.new(characterId) return t end -function skillRow:show() - for _, skb in ipairs(self.children) do - AnimationNode { - function(animationNode) - skb:show(animationNode) - end, - }:run() - end +function skillRow:show(animationNode) + self.state = "show" + if self.showNode then self.showNode:finish() end + self.showT = 0 + self.showNode = animationNode +end + +function skillRow:hide(animationNode) + self.state = "hide" + if self.hideNode then self.hideNode:finish() end + self.hideT = 0 + self.hideNode = animationNode end function skillRow:update(dt) + if self.showT < 1 and self.state == "show" then + self.showT = self.showT + dt / 0.3 -- в знаменателе продолжительность анимации в секундах + elseif self.showNode then + self.showNode:finish() + self.showNode = nil + end + + if self.hideT < 1 and self.state == "hide" then + self.hideT = self.hideT + dt / 0.3 + elseif self.hideNode then + self.hideNode:finish() + self.hideNode = nil + end + local iconSize = icons.tileSize local scale = (64 / iconSize) local screenW, screenH = love.graphics.getDimensions() @@ -130,7 +135,8 @@ function skillRow:update(dt) self.bounds = Rect { width = count * icons.tileSize + (count - 1) * padding, - height = iconSize + height = iconSize, + y = 10 * easing.easeInCubic(1 - self.showT) } self.transform = love.math.newTransform():translate(screenW / 2, screenH - 16):scale(scale, scale):translate(-self.bounds.width / 2, -iconSize) @@ -139,14 +145,16 @@ function skillRow:update(dt) skb.bounds = Rect { height = iconSize, width = iconSize } skb.transform = self.transform:clone():translate(self.bounds.x + (i - 1) * iconSize + (i - 1) * - padding, -- левый край ряда + размер предыдущих иконок + размер предыдущих отступов - 0 -- высота не меняется + padding, -- левый край ряда + размер предыдущих иконок + размер предыдущих отступов + self.bounds.y -- высота не меняется ) skb:update(dt) end end function skillRow:draw() + local alpha = self.state == "show" and easing.easeInSine(self.showT) or 1 - easing.easeInSine(self.hideT) + love.graphics.setColor(1, 1, 1, alpha) for _, skb in ipairs(self.children) do skb:draw() end @@ -157,9 +165,22 @@ function layout:update(dt) local cid = Tree.level.selector:selected() if cid then self.skillRow = skillRow.new(cid) - self.skillRow:show() + AnimationNode { + function(animationNode) + self.skillRow:show(animationNode) + end, + }:run() + end + if Tree.level.selector:deselected() then + AnimationNode { + function(animationNode) + self.skillRow:hide(animationNode) + end, + onEnd = function() + self.skillRow = nil + end + }:run() end - if Tree.level.selector:deselected() then self.skillRow = nil end if self.skillRow then self.skillRow:update(dt) end end -- 2.47.2 From c741bf3952181e398278c38082fbb942d9103274 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Fri, 7 Nov 2025 09:04:02 +0300 Subject: [PATCH 19/24] implement a generic UIElement constructor --- lib/simple_ui/element.lua | 16 ++++++++++++---- lib/simple_ui/level/layout.lua | 8 +------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/lib/simple_ui/element.lua b/lib/simple_ui/element.lua index 228d9ce..62c38cf 100644 --- a/lib/simple_ui/element.lua +++ b/lib/simple_ui/element.lua @@ -3,10 +3,8 @@ local Rect = require "lib.simple_ui.rect" --- @class UIElement --- @field bounds Rect Прямоугольник, в границах которого размещается элемент. Размеры и положение в *локальных* координатах --- @field transform love.Transform Преобразование из локальных координат элемента (bounds) в экранные координаты -local uiElement = { - bounds = Rect {}, - transform = love.math.newTransform() -} +--- +local uiElement = {} uiElement.__index = uiElement function uiElement:update(dt) end @@ -18,4 +16,14 @@ function uiElement:hitTest(screenX, screenY) return self.bounds:hasPoint(lx, ly) end +--- @generic T : UIElement +--- @param values table +--- @param self T +--- @return T +function uiElement.new(self, values) + values.bounds = values.bounds or Rect {} + values.transform = values.transform or love.math.newTransform() + return setmetatable(values, self) +end + return uiElement diff --git a/lib/simple_ui/level/layout.lua b/lib/simple_ui/level/layout.lua index 27162b4..623afc5 100644 --- a/lib/simple_ui/level/layout.lua +++ b/lib/simple_ui/level/layout.lua @@ -12,12 +12,6 @@ local Rect = require "lib.simple_ui.rect" local skillButton = setmetatable({}, Element) skillButton.__index = skillButton -function skillButton.new(icon) - return setmetatable({ - icon = icon - }, skillButton) -end - function skillButton:update(dt) local mx, my = love.mouse.getPosition() if self:hitTest(mx, my) then @@ -77,7 +71,7 @@ function skillRow.new(characterId) 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(spell.tag) + local skb = skillButton:new { icon = spell.tag } skb.onClick = function() skb.selected = not skb.selected if t.selected then t.selected.selected = false end -- 2.47.2 From 0eb06dce3f2daad71afaedd982ff348d4142c49f Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Fri, 7 Nov 2025 09:04:23 +0300 Subject: [PATCH 20/24] Add linear easing (aka no easing) --- lib/utils/easing.lua | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/utils/easing.lua b/lib/utils/easing.lua index 5d3092a..28544f3 100644 --- a/lib/utils/easing.lua +++ b/lib/utils/easing.lua @@ -4,6 +4,11 @@ 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); -- 2.47.2 From eb45ffade42c008c6b8ee03c403e31a8cf635df0 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Fri, 7 Nov 2025 09:19:27 +0300 Subject: [PATCH 21/24] Make the AnimationNode handle its own state --- lib/animation_node.lua | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/lib/animation_node.lua b/lib/animation_node.lua index f3ef786..a5c159f 100644 --- a/lib/animation_node.lua +++ b/lib/animation_node.lua @@ -1,3 +1,5 @@ +local easing = require "lib.utils.easing" + --- @alias voidCallback fun(): nil --- @alias animationRunner fun(node: AnimationNode) @@ -28,6 +30,9 @@ --- @field children AnimationNode[] --- @field finish voidCallback --- @field onEnd voidCallback? +--- @field duration number продолжительность в миллисекундах +--- @field easing ease функция смягчения +--- @field t number прогресс анимации local animation = {} animation.__index = animation @@ -50,7 +55,21 @@ 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.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,6 +80,9 @@ 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.finish = function() t:bubbleUp() for _, anim in ipairs(t.children) do -- 2.47.2 From f32393b9782c053423d6a9a07cc07fdf55eed585 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Sat, 8 Nov 2025 01:27:43 +0300 Subject: [PATCH 22/24] Make transparent elements untargetable --- lib/simple_ui/element.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/simple_ui/element.lua b/lib/simple_ui/element.lua index 62c38cf..ba0f4e3 100644 --- a/lib/simple_ui/element.lua +++ b/lib/simple_ui/element.lua @@ -3,7 +3,6 @@ local Rect = require "lib.simple_ui.rect" --- @class UIElement --- @field bounds Rect Прямоугольник, в границах которого размещается элемент. Размеры и положение в *локальных* координатах --- @field transform love.Transform Преобразование из локальных координат элемента (bounds) в экранные координаты ---- local uiElement = {} uiElement.__index = uiElement @@ -12,6 +11,8 @@ function uiElement:update(dt) end function uiElement:draw() end function uiElement:hitTest(screenX, screenY) + local r, g, b, a = love.graphics.getColor() + if a == 0 then return false end local lx, ly = self.transform:inverseTransformPoint(screenX, screenY) return self.bounds:hasPoint(lx, ly) end -- 2.47.2 From a3853ceac8ba9c40e124f17a162fe78c322db81b Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Sat, 8 Nov 2025 01:28:01 +0300 Subject: [PATCH 23/24] Refactor layout --- lib/simple_ui/level/layout.lua | 171 +----------------------------- lib/simple_ui/level/skill_row.lua | 161 ++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 166 deletions(-) create mode 100644 lib/simple_ui/level/skill_row.lua diff --git a/lib/simple_ui/level/layout.lua b/lib/simple_ui/level/layout.lua index 623afc5..38c5707 100644 --- a/lib/simple_ui/level/layout.lua +++ b/lib/simple_ui/level/layout.lua @@ -1,179 +1,18 @@ -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 SkillRow = require "lib.simple_ui.level.skill_row" ---- @class SkillButton : UIElement ---- @field hovered boolean ---- @field selected boolean ---- @field onClick function? ---- @field icon string -local skillButton = setmetatable({}, Element) -skillButton.__index = skillButton - -function skillButton:update(dt) - local mx, my = love.mouse.getPosition() - if self:hitTest(mx, my) then - self.hovered = true - if Tree.controls:isJustPressed("select") then - if self.onClick then self.onClick() end - Tree.controls:consume("select") - end - else - self.hovered = false - end -end - -function skillButton:draw() - love.graphics.push() - love.graphics.applyTransform(self.transform) - - local r, g, b, a = love.graphics.getColor() - - if self.selected then - love.graphics.setColor(0.3, 1, 0.3, a) - elseif self.hovered then - love.graphics.setColor(0.7, 1, 0.7, a) - else - love.graphics.setColor(1, 1, 1, a) - end - - love.graphics.translate(0, self.bounds.y) - love.graphics.draw(icons.atlas, icons:pickQuad(self.icon)) - love.graphics.pop() -end - ---- @class SkillRow : UIElement ---- @field characterId Id ---- @field selected SkillButton? ---- @field showNode AnimationNode? ---- @field showT number ---- @field hideNode AnimationNode? ---- @field hideT number ---- @field state "show" | "hide" ---- @field children SkillButton[] -local skillRow = setmetatable({}, Element) -skillRow.__index = skillRow - ---- @param characterId Id -function skillRow.new(characterId) - local t = { - characterId = characterId, - showT = 0, - hideT = 0, - state = "show", - children = {} - } - - setmetatable(t, skillRow) - - local char = Tree.level.characters[characterId] - char:try(Tree.behaviors.spellcaster, function(behavior) - for i, spell in ipairs(behavior.spellbook) do - local skb = skillButton:new { icon = spell.tag } - skb.onClick = function() - skb.selected = not skb.selected - if t.selected then t.selected.selected = false end - t.selected = skb - - if not behavior.cast then - behavior.cast = behavior.spellbook[i] - behavior.state = "casting" - else - behavior.state = "idle" - behavior.cast = nil - end - end - t.children[i] = skb - end - end) - - return t -end - -function skillRow:show(animationNode) - self.state = "show" - if self.showNode then self.showNode:finish() end - self.showT = 0 - self.showNode = animationNode -end - -function skillRow:hide(animationNode) - self.state = "hide" - if self.hideNode then self.hideNode:finish() end - self.hideT = 0 - self.hideNode = animationNode -end - -function skillRow:update(dt) - if self.showT < 1 and self.state == "show" then - self.showT = self.showT + dt / 0.3 -- в знаменателе продолжительность анимации в секундах - elseif self.showNode then - self.showNode:finish() - self.showNode = nil - end - - if self.hideT < 1 and self.state == "hide" then - self.hideT = self.hideT + dt / 0.3 - elseif self.hideNode then - self.hideNode:finish() - self.hideNode = nil - end - - local iconSize = icons.tileSize - local scale = (64 / iconSize) - local screenW, screenH = love.graphics.getDimensions() - local padding = 8 - local count = #self.children - - self.bounds = Rect { - width = count * icons.tileSize + (count - 1) * padding, - height = iconSize, - y = 10 * easing.easeInCubic(1 - self.showT) - } - self.transform = love.math.newTransform():translate(screenW / 2, - screenH - 16):scale(scale, scale):translate(-self.bounds.width / 2, -iconSize) - - for i, skb in ipairs(self.children) do - skb.bounds = Rect { height = iconSize, width = iconSize } - skb.transform = self.transform:clone():translate(self.bounds.x + (i - 1) * iconSize + - (i - 1) * - padding, -- левый край ряда + размер предыдущих иконок + размер предыдущих отступов - self.bounds.y -- высота не меняется - ) - skb:update(dt) - end -end - -function skillRow:draw() - local alpha = self.state == "show" and easing.easeInSine(self.showT) or 1 - easing.easeInSine(self.hideT) - love.graphics.setColor(1, 1, 1, alpha) - for _, skb in ipairs(self.children) do - skb:draw() - end -end local layout = {} function layout:update(dt) local cid = Tree.level.selector:selected() if cid then - self.skillRow = skillRow.new(cid) - AnimationNode { - function(animationNode) - self.skillRow:show(animationNode) - end, - }:run() - end - if Tree.level.selector:deselected() then - AnimationNode { - function(animationNode) - self.skillRow:hide(animationNode) - end, - onEnd = function() - self.skillRow = nil - end - }:run() + self.skillRow = SkillRow(cid) + self.skillRow:show() + elseif Tree.level.selector:deselected() then + self.skillRow:hide() end if self.skillRow then self.skillRow:update(dt) end end diff --git a/lib/simple_ui/level/skill_row.lua b/lib/simple_ui/level/skill_row.lua new file mode 100644 index 0000000..d8219f2 --- /dev/null +++ b/lib/simple_ui/level/skill_row.lua @@ -0,0 +1,161 @@ +local icons = require("lib.utils.sprite_atlas").load(Tree.assets.files.dev_icons) +local easing = require "lib.utils.easing" +local AnimationNode = require "lib.animation_node" +local Element = require "lib.simple_ui.element" +local Rect = require "lib.simple_ui.rect" + +--- @class SkillButton : UIElement +--- @field hovered boolean +--- @field selected boolean +--- @field onClick function? +--- @field icon string +local skillButton = setmetatable({}, Element) +skillButton.__index = skillButton + +function skillButton:update(dt) + local mx, my = love.mouse.getPosition() + if self:hitTest(mx, my) then + self.hovered = true + if Tree.controls:isJustPressed("select") then + if self.onClick then self.onClick() end + Tree.controls:consume("select") + end + else + self.hovered = false + end +end + +function skillButton:draw() + love.graphics.push() + love.graphics.applyTransform(self.transform) + + local r, g, b, a = love.graphics.getColor() + + if self.selected then + love.graphics.setColor(0.3, 1, 0.3, a) + elseif self.hovered then + love.graphics.setColor(0.7, 1, 0.7, a) + else + love.graphics.setColor(1, 1, 1, a) + end + + love.graphics.translate(0, self.bounds.y) + love.graphics.draw(icons.atlas, icons:pickQuad(self.icon)) + love.graphics.pop() +end + +-------------------------------------------------------------------------------- + +--- @class SkillRow : UIElement +--- @field characterId Id +--- @field selected SkillButton? +--- @field animationNode AnimationNode +--- @field state "show" | "idle" | "hide" +--- @field children SkillButton[] +local skillRow = setmetatable({}, Element) +skillRow.__index = skillRow + +--- @param characterId Id +--- @return SkillRow +function skillRow.new(characterId) + local t = { + characterId = characterId, + state = "show", + children = {} + } + + setmetatable(t, skillRow) + + local char = Tree.level.characters[characterId] + char:try(Tree.behaviors.spellcaster, function(behavior) + for i, spell in ipairs(behavior.spellbook) do + local skb = skillButton:new { icon = spell.tag } + skb.onClick = function() + skb.selected = not skb.selected + if t.selected then t.selected.selected = false end + t.selected = skb + + if not behavior.cast then + behavior.cast = behavior.spellbook[i] + behavior.state = "casting" + else + behavior.state = "idle" + behavior.cast = nil + end + end + t.children[i] = skb + end + end) + + return t +end + +function skillRow:show() + AnimationNode { + function(animationNode) + if self.animationNode then self.animationNode:finish() end + self.animationNode = animationNode + self.state = "show" + end, + duration = 300, + onEnd = function() + self.state = "idle" + end, + easing = easing.easeOutCubic + }:run() +end + +function skillRow:hide() + AnimationNode { + function(animationNode) + if self.animationNode then self.animationNode:finish() end + self.animationNode = animationNode + self.state = "hide" + end, + duration = 300, + easing = easing.easeOutCubic + }:run() +end + +function skillRow:update(dt) + if self.animationNode then self.animationNode:update(dt) end + + local iconSize = icons.tileSize + local scale = (64 / iconSize) + local screenW, screenH = love.graphics.getDimensions() + local padding = 8 + local count = #self.children + + self.bounds = Rect { + width = count * icons.tileSize + (count - 1) * padding, + height = iconSize, + y = self.state == "show" and 10 * (1 - self.animationNode:getValue()) or 0 + } + self.transform = love.math.newTransform():translate(screenW / 2, + screenH - 16):scale(scale, scale):translate(-self.bounds.width / 2, -iconSize) + + for i, skb in ipairs(self.children) do + skb.bounds = Rect { height = iconSize, width = iconSize } + skb.transform = self.transform:clone():translate(self.bounds.x + (i - 1) * iconSize + + (i - 1) * + padding, -- левый край ряда + размер предыдущих иконок + размер предыдущих отступов + self.bounds.y -- высота не меняется + ) + skb:update(dt) + end +end + +function skillRow:draw() + local alpha = 1 + if self.state == "show" then + alpha = self.animationNode:getValue() + elseif self.state == "hide" then + alpha = 1 - self.animationNode:getValue() + end + love.graphics.setColor(1, 1, 1, alpha) + for _, skb in ipairs(self.children) do + skb:draw() + end +end + +return skillRow.new -- 2.47.2 From 6a15c6ed83fce9934b9e44571c38dd3674dc6452 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Sat, 8 Nov 2025 01:28:40 +0300 Subject: [PATCH 24/24] Fix AnimationNode:finish() called twice --- lib/animation_node.lua | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/animation_node.lua b/lib/animation_node.lua index a5c159f..8f560f3 100644 --- a/lib/animation_node.lua +++ b/lib/animation_node.lua @@ -33,6 +33,7 @@ local easing = require "lib.utils.easing" --- @field duration number продолжительность в миллисекундах --- @field easing ease функция смягчения --- @field t number прогресс анимации +--- @field finished boolean local animation = {} animation.__index = animation @@ -40,6 +41,7 @@ animation.__index = animation function animation:bubbleUp() self.count = self.count - 1 if self.count > 0 then return end + self.finished = true if self.onEnd then self.onEnd() end if self.parent then self.parent:bubbleUp() end end @@ -61,6 +63,8 @@ function animation:getValue() end function animation:update(dt) + if self.finished then return end + if self.t < 1 then self.t = self.t + dt * 1000 / self.duration -- в знаменателе продолжительность анимации в секундах else @@ -78,12 +82,14 @@ local function new(data) end t.onEnd = data.onEnd t.count = 1 -- своя анимация + t.finished = false t.children = {} t:chain(data.children or {}) t.duration = data.duration or 1000 t.easing = data.easing or easing.linear t.t = 0 t.finish = function() + if t.finished then return end t:bubbleUp() for _, anim in ipairs(t.children) do anim:run() -- 2.47.2