Compare commits

..

No commits in common. "main" and "turn-order" have entirely different histories.

110 changed files with 1046 additions and 3836 deletions

3
.gitattributes vendored
View File

@ -1,3 +0,0 @@
*.ogg filter=lfs diff=lfs merge=lfs -text
*.png filter=lfs diff=lfs merge=lfs -text
*.ttf filter=lfs diff=lfs merge=lfs -text

View File

@ -6,6 +6,5 @@
"love.filesystem.load": "loadfile"
},
"workspace.ignoreDir": ["dev_utils"],
"diagnostics.ignoredFiles": "Disable",
"hint.enable": true
"diagnostics.ignoredFiles": "Disable"
}

BIN
assets/audio/music/level1/bass/bass.ogg (Stored with Git LFS)

Binary file not shown.

BIN
assets/audio/music/level1/battle.ogg (Stored with Git LFS)

Binary file not shown.

BIN
assets/audio/music/level1/choral.ogg (Stored with Git LFS)

Binary file not shown.

BIN
assets/audio/music/level1/drums.ogg (Stored with Git LFS)

Binary file not shown.

BIN
assets/audio/music/level1/flute.ogg (Stored with Git LFS)

Binary file not shown.

BIN
assets/audio/music/level1/guitar.ogg (Stored with Git LFS)

Binary file not shown.

Binary file not shown.

BIN
assets/audio/music/level1/violin.ogg (Stored with Git LFS)

Binary file not shown.

BIN
assets/audio/sounds/hurt.ogg (Stored with Git LFS)

Binary file not shown.

BIN
assets/audio/sounds/meow.ogg (Stored with Git LFS)

Binary file not shown.

BIN
assets/boar.png (Stored with Git LFS)

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 B

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@ -1,19 +0,0 @@
Created by [Marvyra](https://www.gamedevmarket.net/member/marvyra)
4.1. A "Licence" means that the Seller grants to GDN (purely for the purpose of sub-licensing to the Purchaser) and GDN grants (by way of sub-licence thereof) to the Purchaser a non-exclusive perpetual licence to;
(a) use the Licensed Asset to create Derivative Works; and
(b) use the Licensed Asset and any Derivative Works as part of both Non-Monetized Media Products and Monetized Media Products, with no restriction on the number of projects the Licensed Asset may be used in. In either case, the Licensed Assets can be used in Media Products that are either:
i) used for the Purchaser's own personal use; and/or
ii) used for the Purchasers commercial use in which case it may be distributed, sold and supplied by the Purchaser for any fee that the Purchaser may determine.
4.2. A Licence does not allow the Purchaser to:
(a) Use the Licensed Asset or Derivative Works in a logo, trademark or service mark;
(b) Use, sell, share, transfer, give away, sublicense or redistribute the Licensed Asset or Derivate Works other than as part of the relevant Media Product; or
(c) Allow the user of the Media Product to extract the Licensed Asset or Derivative Works and use them outside of the relevant Media Product.

BIN
assets/dev_icons/atlas.png (Stored with Git LFS)

Binary file not shown.

View File

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

View File

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

BIN
assets/fonts/Roboto_Mono/font.ttf (Stored with Git LFS)

Binary file not shown.

View File

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

BIN
assets/fonts/WDXL_Lubrifont_TC/font.ttf (Stored with Git LFS)

Binary file not shown.

BIN
assets/masks/circle128.png (Stored with Git LFS)

Binary file not shown.

BIN
assets/masks/gradientCircle256.png (Stored with Git LFS)

Binary file not shown.

BIN
assets/masks/rrect32.png (Stored with Git LFS)

Binary file not shown.

BIN
assets/masks/squircle.png (Stored with Git LFS)

Binary file not shown.

BIN
assets/overlay_icons/atlas.png (Stored with Git LFS)

Binary file not shown.

View File

@ -1,6 +0,0 @@
return {
tileSize = 32,
["dev_target"] = { 0 },
["dev_path"] = { 1 },
["dev_path_closed"] = { 2 },
}

View File

@ -1,8 +0,0 @@
vec4 effect(vec4 color, Image tex, vec2 texCoord, vec2 screenCoord)
{
vec4 px = Texel(tex, texCoord);
if (px.a == 0.0) {
discard;
}
return vec4(1.0);
}

View File

@ -1,6 +0,0 @@
vec4 effect(vec4 color, Image tex, vec2 uv, vec2 px)
{
vec4 c = Texel(tex, uv);
float gray = dot(c.rgb, vec3(0.299, 0.587, 0.114)); // магические числа человеческого восприятия
return vec4(vec3(gray), c.a);
}

View File

@ -1,18 +0,0 @@
extern vec2 direction; // (1.0, 0.0) для X, (0.0, 1.0) для Y
extern number radius; // радиус размытия
vec4 effect(vec4 vcolor, Image tex, vec2 texture_coords, vec2 screen_coords)
{
vec4 sum = vec4(0.0);
float weightTotal = 0.0;
for (int i = -10; i <= 10; i++) {
float offset = float(i);
float weight = exp(-offset * offset / (2.0 * radius * radius));
vec2 shift = direction * offset / love_ScreenSize.xy;
sum += Texel(tex, texture_coords + shift) * weight;
weightTotal += weight;
}
return sum / weightTotal;
}

View File

@ -1,47 +0,0 @@
extern vec2 center;
extern number time;
extern number intensity;
extern vec2 screenSize;
// Hexagon grid logic
// Returns distance to nearest hex center
float hexDist(vec2 p) {
p = abs(p);
float c = dot(p, normalize(vec2(1.0, 1.73)));
c = max(c, p.x);
return c;
}
vec4 effect(vec4 color, Image texture, vec2 texture_coords, vec2 screen_coords) {
// Normalize coordinates to -1..1, correcting for aspect ratio
vec2 aspect = vec2(screenSize.x / screenSize.y, 1.0);
vec2 uv = (screen_coords / screenSize.y) - (center / screenSize.y);
// Wave parameters
float dist = length(uv);
float speed = 5.0;
float waveWidth = 0.1;
float decay = 1.0 - smoothstep(0.0, 1.5, dist); // Decay over distance
// Calculate wave pulse
float wavePhase = time * speed;
float pulse = smoothstep(waveWidth, 0.0, abs(dist - mod(wavePhase, 2.0)));
// Hex grid pattern (visual only)
vec2 hexUV = screen_coords * 0.05; // Scale grid
// Basic hex grid approximation
vec2 q = vec2(hexUV.x * 2.0/3.0, hexUV.y);
// Distortion
vec2 distort = normalize(uv) * pulse * intensity * 0.02 * decay;
vec2 finalUV = texture_coords - distort;
// Sample texture with distortion
vec4 texColor = Texel(texture, finalUV);
// Add divine glow at the wavefront
vec4 glowColor = vec4(1.0, 0.8, 0.4, 1.0); // Gold/Kitsune-fire color
texColor += glowColor * pulse * decay * 0.5;
return texColor * color;
}

View File

@ -1,5 +0,0 @@
vec4 effect(vec4 color, Image tex, vec2 uv, vec2 px)
{
vec4 c = Texel(tex, uv);
return vec4(vec3(1.0) - c.rgb, c.a);
}

View File

@ -1,30 +0,0 @@
extern vec3 color;
extern number time;
vec4 effect(vec4 vcolor, Image tex, vec2 texture_coords, vec2 screen_coords)
{
vec4 texColor = Texel(tex, texture_coords);
float mask = texColor.r;
vec2 uv = texture_coords - 0.5;
float dist = length(uv * 2.0);
float t = time;
float wave = sin((uv.x + uv.y) * 6.0 + t * 1.5) * 0.03;
float ripple = sin(length(uv) * 20.0 - t * 2.0) * 0.02;
float flicker = sin(t * 2.5) * 0.02;
dist += wave + ripple + flicker;
float intensity = 1.0 - smoothstep(0.0, 1.0, dist);
intensity = pow(intensity, 2.0);
float colorShift = sin(t * 3.0) * 0.1;
vec3 flickerColor = color + vec3(colorShift, colorShift * 0.5, -colorShift * 0.3);
vec3 finalColor = flickerColor * intensity * mask;
return vec4(finalColor, mask * intensity);
}

View File

@ -1,20 +0,0 @@
extern Image scene;
extern Image light;
extern vec3 ambient;
vec4 effect(vec4 vcolor, Image unused, vec2 uv, vec2 px)
{
vec4 s = Texel(scene, uv);
vec3 l = Texel(light, uv).rgb;
l = clamp(l, 0.0, 1.0);
vec3 a = clamp(ambient, 0.0, 1.0);
// Канальный множитель: от ambient до 1 в зависимости от света
vec3 m = a + (vec3(1.0) - a) * l;
vec3 rgb = s.rgb * m;
return vec4(rgb, s.a);
}

View File

@ -1,24 +0,0 @@
extern float t;
extern float blockSize;
// hash-функция для шума по целочисленным координатам блока
float hash(vec2 p) {
p = vec2(
dot(p, vec2(127.1, 311.7)),
dot(p, vec2(269.5, 183.3))
);
return fract(sin(p.x + p.y) * 43758.5453123);
}
vec4 effect(vec4 color, Image tex, vec2 texCoord, vec2 screenCoord)
{
float blockSize = 4.0;
vec2 cell = floor(screenCoord / blockSize);
float n = hash(cell); // [0..1]
float mask = 1.0 - step(t, n);
vec4 base = Texel(tex, texCoord) * color;
base.a *= mask;
return base;
}

View File

@ -1,17 +0,0 @@
#pragma language glsl3
vec2 hash(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 34.345);
return fract(vec2(p.x * p.y, p.x + p.y));
}
vec4 effect(vec4 color, Image tex, vec2 uv, vec2 px)
{
vec2 cell = floor(px / 2.0); // тут можно размер зерна менять
float n = hash(cell).x; // 0..1
float v = 0.9 + n * 0.1; // 0.9..1.0
return vec4(v, v, v, 1.0);
}

View File

@ -1,85 +0,0 @@
#define MAX_LIGHTS 8
struct Light {
vec2 position;
vec3 color;
float radius;
};
extern Light lights[MAX_LIGHTS];
extern int num_lights;
extern vec2 sprite_pos; // Мировая позиция спрайта (в метрах)
extern vec3 ambient; // Эмбиентное освещение
extern vec3 sky; // Цвет неба
// Параметры выделения
extern bool is_selected;
extern vec2 tex_size;
extern float time;
// Функция для имитации easing.easeInSine
float easeInSine(float x) {
return 1.0 - cos((x * 3.14159) / 2.0);
}
vec4 effect(vec4 vcolor, Image tex, vec2 texture_coords, vec2 screen_coords)
{
vec4 texColor = Texel(tex, texture_coords);
// ЛОГИКА ОБВОДКИ (Outline)
// Мы выполняем ее до расчетов освещения. Если пиксель прозрачный и объект выбран,
// проверяем соседей, чтобы понять, не граница ли это.
// Обводка рисуется "самосветящейся", чтобы выделение было видно даже в полной темноте.
if (is_selected && texColor.a <= 0.0) {
float maxAlpha = 0.0;
// Проверяем соседние пиксели (квадратом 3x3)
for (float x = -1.0; x <= 1.0; x++) {
for (float y = -1.0; y <= 1.0; y++) {
if (x == 0.0 && y == 0.0) continue;
vec2 offset = vec2(x, y) / tex_size;
maxAlpha = max(maxAlpha, Texel(tex, texture_coords + offset).a);
}
}
if (maxAlpha > 0.0) {
// Эффект пульсации и "бегущей волны" из оригинального шейдера outline.glsl
float modY = (0.75 + sin(time) * 0.25) * (0.5 + cos(texture_coords.y * 10.0 + time * 2.0) * 0.5);
return vec4(vec3(modY, 0.2 * sin(time) + 0.5, 0.5), 1.0);
}
}
if (texColor.a == 0.0) {
return vec4(0.0);
}
// Расчет базового освещения мира: персонаж освещается смесью неба (Sky) и отраженного света (Ambient).
vec3 baseLight = ambient + (vec3(1.0) - ambient) * sky;
// Десатурация базового света на 30%.
float luma = dot(baseLight, vec3(0.2126, 0.7152, 0.0722));
vec3 characterBaseLight = mix(baseLight, vec3(luma), 0.3);
vec3 pointLight = vec3(0.0);
for (int i = 0; i < num_lights; i++) {
vec2 lightPos = lights[i].position;
float radius = lights[i].radius;
vec2 lightVec = lightPos - sprite_pos;
float dist = length(lightVec);
// Плавное затухание света по радиусу.
float radiusFalloff = smoothstep(radius, radius * 0.2, dist);
// Реализация псевдо-проекции (3D-эффект):
float frontWeight = smoothstep(-2.0, 0.5, lightVec.y);
float attenuation = radiusFalloff * frontWeight;
pointLight += lights[i].color * attenuation;
}
pointLight = clamp(pointLight, 0.0, 1.0);
// Финальный расчет цвета:
return vec4(texColor.rgb * (characterBaseLight + pointLight), texColor.a);
}

View File

@ -1,9 +0,0 @@
return {
width = 32,
height = 32,
base = {
x = 14,
y = 26
},
size = 0.6 -- условная доля тайла, которую занимает спрайт
}

BIN
assets/sprites/boar/sheets/idle.png (Stored with Git LFS)

Binary file not shown.

BIN
assets/sprites/boar/sheets/run.png (Stored with Git LFS)

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -1,9 +0,0 @@
return {
width = 96,
height = 64,
base = {
x = 38,
y = 47
},
size = 0.4 -- условная доля тайла, которую занимает спрайт
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
assets/sprites/character/sheets/attack.png (Stored with Git LFS)

Binary file not shown.

BIN
assets/sprites/character/sheets/hurt.png (Stored with Git LFS)

Binary file not shown.

BIN
assets/sprites/character/sheets/idle.png (Stored with Git LFS)

Binary file not shown.

BIN
assets/sprites/character/sheets/run.png (Stored with Git LFS)

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 B

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 B

After

Width:  |  Height:  |  Size: 11 KiB

73
lib/animation_node.lua Normal file
View File

@ -0,0 +1,73 @@
--- @alias voidCallback fun(): nil
--- @alias animationRunner fun(node: AnimationNode)
--- Узел дерева анимаций.
---
--- Отслеживает завершение всех анимаций всех дочерних узлов и оповещает вышестоящий узел.
---
--- Дочерние узлы одного уровня запускают свою анимацию одновременно после завершения анимации родителя.
--- Example:
--- ```lua
--- AnimationNode {
--- function (node) residentsleeper:sleep(1000, node) end, -- must pass itself down as the parameter
--- onEnd = function() print("completed") end,
--- children = { -- children run in parallel after the parent animation is completed
--- AnimationNode {
--- function (node) sprite:animate("attack", node) end
--- },
--- AnimationNode {
--- function (node) other_sprite:animate("hurt", node) end
--- },
--- }
--- }:run()
--- ```
--- @class AnimationNode
--- @field count integer
--- @field run animationRunner
--- @field parent AnimationNode?
--- @field children AnimationNode[]
--- @field finish voidCallback
--- @field onEnd voidCallback?
local animation = {}
animation.__index = animation
--- Регистрация завершения дочерней анимации
function animation:bubbleUp()
self.count = self.count - 1
if self.count > 0 then return end
if self.onEnd then self.onEnd() end
if self.parent then self.parent:bubbleUp() end
end
--- @param children AnimationNode[]
--- Запланировать анимации после текущей, которые запустятся одновременно друг с другом
function animation:chain(children)
for _, child in ipairs(children) do
child.parent = self
table.insert(self.children, child)
self.count = self.count + 1
end
return self
end
--- @param data {[1]: animationRunner?, onEnd?: voidCallback, children?: AnimationNode[]}
--- @return AnimationNode
local function new(data)
local t = setmetatable({}, animation)
t.run = data[1] or function(self)
self:finish()
end
t.onEnd = data.onEnd
t.count = 1 -- своя анимация
t.children = {}
t:chain(data.children or {})
t.finish = function()
t:bubbleUp()
for _, anim in ipairs(t.children) do
anim:run()
end
end
return t
end
return new

View File

@ -1,14 +1,6 @@
--- @meta _
Tree.behaviors.map = require "lib.character.behaviors.map"
Tree.behaviors.spellcaster = require "lib.character.behaviors.spellcaster"
Tree.behaviors.sprite = require "lib.character.behaviors.sprite"
Tree.behaviors.stats = require "lib.character.behaviors.stats"
Tree.behaviors.residentsleeper = require "lib.character.behaviors.residentsleeper"
Tree.behaviors.shadowcaster = require "lib.character.behaviors.shadowcaster"
Tree.behaviors.light = require "character.behaviors.light"
Tree.behaviors.positioned = require "character.behaviors.positioned"
Tree.behaviors.tiled = require "character.behaviors.tiled"
Tree.behaviors.cursor = require "character.behaviors.cursor"
Tree.behaviors.ai = require "lib.character.behaviors.ai"
Tree.behaviors.effects = require "lib.character.behaviors.effects"
--- @alias voidCallback fun(): nil

View File

@ -1,99 +0,0 @@
local task = require "lib.utils.task"
local ease = require "lib.utils.easing"
local EFFECTS_SUPPORTED = love.audio.isEffectsSupported()
--- @alias SourceFilter { type: "bandpass"|"highpass"|"lowpass", volume: number, highgain: number, lowgain: number }
--- @class Audio
--- @field musicVolume number
--- @field soundVolume number
--- @field looped boolean
--- @field from love.Source?
--- @field to love.Source?
audio = {}
audio.__index = audio
--- здесь мы должны выгружать значения из файлика с сохранением настроек
local function new(musicVolume, soundVolume)
return setmetatable({
musicVolume = musicVolume,
soundVolume = soundVolume,
looped = true
}, audio)
end
function audio:update(dt)
if self.fader then
local t = self.fader.value
if self.from then self.from:setVolume(self.musicVolume * (1 - t)) end
if self.to then self.to:setVolume(self.musicVolume * t) end
end
end
--- if from is nil, than we have fade in to;
--- if to is nil, than we have fade out from
---
--- also we should guarantee, that from and to have the same volume
--- @param from love.Source
--- @param to love.Source
--- @param ms number? in milliseconds
function audio:crossfade(from, to, ms)
print("[Audio]: Triggered crossfade")
-- Stop previous 'from' if it's dangling to avoid leaks
if self.from and self.from ~= from and self.from ~= to then
self.from:stop()
end
self:play(to)
to:setVolume(0)
self.from = from
self.to = to
-- Reuse fader object to allow task cancellation
if not self.fader then self.fader = { value = 0 } end
self.fader.value = 0
task.tween(self.fader, { value = 1 }, ms or 1000, ease.easeOutCubic)(function()
if self.from then
self.from:setVolume(0)
self.from:stop()
end
if self.to then
self.to:setVolume(self.musicVolume)
end
self.fader = nil
print("[Audio]: Crossfade done")
end)
end
--- @param source love.Source
--- @param settings SourceFilter?
--- @param effectName string?
function audio:play(source, settings, effectName)
if source:getType() == "stream" then
source:setLooping(self.looped)
source:setVolume(self.musicVolume)
source:play()
else
source:setVolume(self.soundVolume)
source:play()
end
if settings and EFFECTS_SUPPORTED then
source.setFilter(source, settings)
end
if effectName and EFFECTS_SUPPORTED then
source:setEffect(effectName, true)
end
end
function audio:setMusicVolume(volume)
self.musicVolume = volume
end
function audio:setSoundVolume(volume)
self.soundVolume = volume
end
return { new = new }

View File

@ -1,177 +0,0 @@
local easing = require "lib.utils.easing"
local pf = require "lib.pathfinder"
local utils = require "lib.utils.utils"
local task = require "lib.utils.task"
--- @alias AIAction fun(self: AIBehavior): Task<nil>
--- @return Character
local function closestCharacter(char)
local caster = Vec3 {}
char:try(Tree.behaviors.positioned, function(b)
caster = b.position
end)
local charTarget
local minDist = 88005553535 -- spooky magic number
for k, v in pairs(Tree.level.characters) do
v:try(Tree.behaviors.positioned, function(b)
local dist = ((caster.x - b.position.x) ^ 2 + (caster.y - b.position.y) ^ 2) ^ 0.5
if dist < minDist and dist ~= 0 then
minDist = dist
charTarget = v
end
-- print(k, b.position)
end)
end
return charTarget
end
-- --- Возвращает все точки в радиусе в виде векторов (должен по крайней мере)
-- --- @param radius integer
-- --- @param center Vec3
-- --- @return Vec3[]
-- local function circleVectors(center, radius)
-- local vecs = {}
-- local res = {}
-- for t = 0, 2 * math.pi, EPSILON do
-- local x = math.cos(t) * radius + center.x
-- local y = math.sin(t) * radius + center.y
-- table.insert(vecs, Vec3 { math.floor(x), math.floor(y) })
-- end
-- for _, v in pairs(vecs) do
-- local i = 1
-- while i <= #res and (res[i].x ~= v.x or res[i].y ~= v.y) do
-- i = i + 1
-- end
-- if i == #res + 1 or #res == 0 then
-- table.insert(res, v)
-- print('[AI]: circle vecs:', v)
-- end
-- end
-- return res
-- end
--- Возвращает все точки в радиусе в виде векторов (должен по крайней мере)
--- @param radius integer
--- @param center Vec3
--- @return Vec3[]
local function circleVectors(center, radius)
local dx, dy, err = radius, 0, 1 - radius
local vecs, res = {}, {}
while dx >= dy do
table.insert(vecs, Vec3 { center.x + dx, center.y + dy })
table.insert(vecs, Vec3 { center.x - dx, center.y + dy })
table.insert(vecs, Vec3 { center.x + dx, center.y - dy })
table.insert(vecs, Vec3 { center.x - dx, center.y - dy })
table.insert(vecs, Vec3 { center.x + dy, center.y + dx })
table.insert(vecs, Vec3 { center.x - dy, center.y + dx })
table.insert(vecs, Vec3 { center.x + dy, center.y - dx })
table.insert(vecs, Vec3 { center.x - dy, center.y - dx })
dy = dy + 1
if err < 0 then
err = err + 2 * dy + 1
else
dx, err = dx - 1, err + 2 * (dy - dx) + 1
end
end
for _, v in pairs(vecs) do
local i = 1
while i <= #res and (res[i].x ~= v.x or res[i].y ~= v.y) and v.x >= 0 and v.y >= 0 do
i = i + 1
end
if i == #res + 1 or #res == 0 then
table.insert(res, v)
print('[AI]: circle vecs:', v)
end
end
return vecs
end
--- ищет пути к ближайшему персу в определённом радиусе
--- @param owner Character
--- @param radius integer здесь мы должны сами определять, сколько должны не доходить до персонажа (1 <= n)
--- @return Vec3|nil
local function pathToClosestCharacter(owner, radius)
local charTarget = closestCharacter(owner)
local targetPosition, ownerPosition = charTarget:has(Tree.behaviors.positioned), owner:has(Tree.behaviors.positioned)
if not targetPosition or not ownerPosition then return end
local circleVecs = circleVectors(targetPosition.position, radius)
local target = circleVecs[#circleVecs]
local path = pf(ownerPosition.position, target)
for i, c in ipairs(circleVecs) do
local newPath = pf(ownerPosition.position, c)
if newPath:size() < path:size() then
path = newPath
target = c
end
end
return target
end
--- @type table<Class, AIAction>
local aiNature = {
dev_warrior = function(self)
return function(callback) -- почему так, описано в Task
self.owner:try(Tree.behaviors.spellcaster, function(spellB)
self.target = pathToClosestCharacter(self.owner, 1)
local attackTarget = closestCharacter(self.owner):has(Tree.behaviors.positioned)
if not attackTarget then return end
local task1 = spellB.spellbook[1]:cast(self.owner, self.target)
if task1 then
task1(
function()
-- здесь мы оказываемся после того, как сходили в первый раз
print('[AI]: я походил')
local task2 = spellB.spellbook[3]:cast(self.owner, attackTarget.position)
if task2 then
-- дергаем функцию после завершения хода
print('[AI]: и ударил')
task2(callback)
else
print('[AI]: чёт не бьётся')
callback()
end
end
)
else
print('рот этого казино')
callback()
end
end)
end
end,
dev_mage = function(self)
return function(callback)
print("etoh... bleh")
callback()
end
end
}
--- @class AIBehavior : Behavior
--- @field target Vec3?
local behavior = {}
behavior.__index = behavior
behavior.id = "ai"
--- Заставляет ИИ сделать ход
---
--- По умолчанию ничего не делает
--- @return Task<nil>
function behavior:makeTurn()
return function(callback)
callback()
end
end
--- @param class Class
function behavior.new(class)
return setmetatable({
makeTurn = aiNature[class]
}, behavior)
end
return behavior

View File

@ -11,11 +11,6 @@ behavior.id = "behavior"
function behavior.new() return setmetatable({}, behavior) end
--- это деструктор с крутым названием
function behavior:die()
end
function behavior:update(dt) end
function behavior:draw() end

View File

@ -1,19 +0,0 @@
--- Добавляет следование за курсором мыши
--- @class CursorBehavior : Behavior
local behavior = {}
behavior.__index = behavior
behavior.id = "cursor"
---@return CursorBehavior
function behavior.new()
return setmetatable({}, behavior)
end
function behavior:update()
self.owner:try(Tree.behaviors.positioned, function(b)
local mx, my = love.mouse.getX(), love.mouse.getY()
b.position = Tree.level.camera:toWorldPosition(Vec3 { mx, my })
end)
end
return behavior

View File

@ -1,187 +0,0 @@
local task = require "lib.utils.task"
local efb = require "lib.effectbook"
local book = efb.book
--- ===========ЛОГИКА ЭФФЕКТОВ И ЧТО С ЭТИМ ЕДЯТ===========
--- читать здесь: https://docs.google.com/document/d/1Hxa5dOLaeRpLQOs5H-oIDDuLLhKbDw40lR9d62Zb4Tg/edit?usp=sharing
--- и здесь: https://docs.google.com/document/d/1jvhuM3mxqYSQTEM8m-WL-uUSie9QRsZOCCUEiw9ZqzM/edit?tab=t.0
--- behavior thats holds all effects that we applied
--- @class EffectsBehavior : Behavior
--- @field effectsPriority EffectTag[] хранит эффекты в порядке их применения
--- @field effectsProperties table<EffectTag, { stacks: integer, intensity: integer }> хранит характеристики эффектов
local behavior = {}
behavior.__index = behavior
behavior.id = "effects"
--- @return EffectsBehavior
function behavior.new()
return setmetatable({
effectsPriority = {},
effectsProperties = {},
}, behavior)
end
--- проверяет, можно ли наложить эффект и при наложении его применяет
--- @param effect EffectTag
--- @param stacks integer
--- @param intensity integer
function behavior:addEffect(effect, stacks, intensity)
local task1, birthStatement = book[effect]:beforeBirth(self.owner, intensity)
if task1 then
task1(function() end)
end
if not birthStatement then return end
-- проверка на сумму, и её применение
for i, ef in ipairs(self.effectsPriority) do
if efb.sums[effect] then
if efb.sums[effect][ef] then
if not efb.sums[effect][ef](self.owner, effect, ef) then return end
end
elseif efb.sums[ef] then
if efb.sums[ef][effect] then
if not efb.sums[ef][effect](self.owner, ef, effect) then return end
end
end
end
book[effect]:onBirth(self.owner, stacks, intensity)
local task3 = book[effect]:afterBirth(self.owner, intensity)
if task3 then
task3(function()
print("[Effects]: мы применили эффект!!")
end)
end
end
--- Удаляет один эффект по порядку
--- @param effect EffectTag
function behavior:deleteEffect(effect)
self.effectsProperties[effect] = nil
for i, ef in ipairs(self.effectsPriority) do
if ef == effect then
table.remove(self.effectsPriority, i)
return
end
end
end
--- О ДААА ЭТА ФУНКЦИЯ МЕНЯЕТ СОСТОЯНИЕ О ДАААААА О ДАААААААААА
--- @param effect EffectTag
--- @param amount integer
function behavior:deleteStacks(effect, amount)
print("[Effects]: удаляем стаки!!")
self.effectsProperties[effect].stacks = self.effectsProperties[effect].stacks -
amount -- !!!!!!!!!!!!!!!! <<<<< 21+ only
if self.effectsProperties[effect].stacks <= 0 then
print("[Effects]:", effect, "ДОЛЖЕН БЫТЬ СТЁРТ")
self:deleteEffect(effect)
print("[Effects]:", effect, "СТЁРТ")
end
end
--- должна вызываться перед смертью персонажа;
---
--- возвращает, убивать ли персонажа
--- @return boolean
function behavior:beforeDeath()
for i, ef in ipairs(self.effectsPriority) do
local task1, deathStatement = book[ef]:beforeDeath(self.owner, self.effectsProperties[ef].intensity)
if task1 then
task1(function() end)
end
if deathStatement == false then return false end
end
return true
end
--- должна вызываться после смерти персонажа (может ли такая ситуация возникнуть вообще?)
function behavior:afterDeath()
for i, ef in ipairs(self.effectsPriority) do
local task1 = book[ef]:afterDeath(self.owner, self.effectsProperties[ef].intensity)
if task1 then
task1(function() end)
end
end
end
--- должен вызываться в начале хода
---
--- возвращает, может ли персонаж сделать ход?
--- @return boolean
function behavior:beforeTurn()
for i, ef in ipairs(self.effectsPriority) do
local task1, turnStatement = book[ef]:beforeTurn(self.owner, self.effectsProperties[ef].intensity)
if task1 then
task1(function() end)
end
if turnStatement == false then return false end
end
return true
end
--- должен вызываться в конце хода
function behavior:afterTurn()
for i, ef in ipairs(self.effectsPriority) do
local task1 = book[ef]:afterTurn(self.owner, self.effectsProperties[ef].intensity)
if task1 then
task1(function() end)
end
end
end
--- должен вызываться перед кастом спелла
---
--- возвращает, может ли персонаж скастовать спелл?
--- @return boolean
function behavior:beforeCast()
for i, ef in ipairs(self.effectsPriority) do
local task1, castStatement = book[ef]:beforeCast(self.owner, self.effectsProperties[ef].intensity)
if task1 then
task1(function() end)
end
if castStatement == false then return false end
end
return true
end
--- должен вызываться после каста спелла
function behavior:afterCast()
for i, ef in ipairs(self.effectsPriority) do
local task1 = book[ef]:afterCast(self.owner, self.effectsProperties[ef].intensity)
if task1 then
task1(function() end)
end
end
end
--- должен вызываться перед получением урона
---
--- возвращает получаемый урон
--- @return integer
function behavior:beforeDamage(damage)
local totalDamage = damage
for i, ef in ipairs(self.effectsPriority) do
local task1
task1, totalDamage = book[ef]:beforeDamage(self.owner, self.effectsProperties[ef].intensity,
totalDamage or damage)
if task1 then
task1(function() end)
end
end
return totalDamage or damage
end
--- должен вызываться после получения урона
function behavior:afterDamage()
for i, ef in ipairs(self.effectsPriority) do
local task1 = book[ef]:afterDamage(self.owner, self.effectsProperties[ef].intensity)
if task1 then
task1(function() end)
end
end
end
return behavior

View File

@ -1,50 +0,0 @@
local task = require "lib.utils.task"
--- @class LightBehavior : Behavior
--- @field intensity number
--- @field color Vec3
--- @field seed integer
--- @field private animateColorTask? Task
local behavior = {}
behavior.__index = behavior
behavior.id = "light"
---@param values {intensity: number?, color: Vec3?, seed: integer?}
---@return LightBehavior
function behavior.new(values)
return setmetatable({
intensity = values.intensity or 1,
color = values.color or Vec3 { 1, 1, 1 },
seed = values.seed or math.random(math.pow(2, 16))
}, behavior)
end
function behavior:update(dt)
-- All logic moved to tasks
end
function behavior:animateColor(targetColor, duration, easing)
-- If there's support for canceling tasks, we should do it here
return task.tween(self, { color = targetColor }, duration or 800, easing)
end
function behavior:draw()
local positioned = self.owner:has(Tree.behaviors.positioned)
if not positioned then return end
Tree.level.render:enqueue(Tree.level.render.LAYERS.LIGHT, positioned.position.y, function()
love.graphics.setBlendMode("add", "premultiplied")
local shader = Tree.assets.files.shaders.light
shader:send("color", { self.color.x, self.color.y, self.color.z })
shader:send("time", love.timer:getTime() + self.seed)
love.graphics.setShader(shader)
love.graphics.draw(Tree.assets.files.masks.circle128, positioned.position.x - self.intensity / 2,
positioned.position.y - self.intensity / 2, 0, self.intensity / 128,
self.intensity / 128)
love.graphics.setShader()
love.graphics.setBlendMode("alpha")
end)
end
return behavior

View File

@ -0,0 +1,80 @@
local utils = require "lib.utils.utils"
--- Отвечает за размещение и перемещение по локации
--- @class MapBehavior : Behavior
--- @field position Vec3
--- @field runTarget Vec3 точка, в которую в данный момент бежит персонаж
--- @field displayedPosition Vec3 точка, в которой персонаж отображается
--- @field t0 number время начала движения для анимациии
--- @field path Deque путь, по которому сейчас бежит персонаж
--- @field animationNode? AnimationNode AnimationNode, с которым связана анимация перемещения
--- @field size Vec3
local mapBehavior = {}
mapBehavior.__index = mapBehavior
mapBehavior.id = "map"
--- @param position? Vec3
--- @param size? Vec3
function mapBehavior.new(position, size)
return setmetatable({
position = position or Vec3({}),
displayedPosition = position or Vec3({}),
size = size or Vec3({ 1, 1 }),
}, mapBehavior)
end
--- @param path Deque
--- @param animationNode AnimationNode
function mapBehavior:followPath(path, animationNode)
if path:is_empty() then return animationNode:finish() end
self.animationNode = animationNode
self.position = self.displayedPosition
self.owner:try(Tree.behaviors.sprite, function(sprite)
sprite:loop("run")
end)
self.path = path;
---@type Vec3
local nextCell = path:peek_front()
self:runTo(nextCell)
path:pop_front()
end
--- @param target Vec3
function mapBehavior:runTo(target)
self.t0 = love.timer.getTime()
self.runTarget = target
self.owner:try(Tree.behaviors.sprite,
function(sprite)
if target.x < self.position.x then
sprite.side = Tree.behaviors.sprite.LEFT
elseif target.x > self.position.x then
sprite.side = Tree.behaviors.sprite.RIGHT
end
end
)
end
function mapBehavior:update(dt)
if self.runTarget then
local delta = love.timer.getTime() - self.t0 or love.timer.getTime()
local fraction = delta /
(0.5 * self.runTarget:subtract(self.position):length()) -- бежим одну клетку за 500 мс, по диагонали больше
if fraction >= 1 then -- анимация перемещена завершена
self.position = self.runTarget
if not self.path:is_empty() then -- еще есть, куда бежать
self:runTo(self.path:pop_front())
else -- мы добежали до финальной цели
self.owner:try(Tree.behaviors.sprite, function(sprite)
sprite:loop("idle")
end)
self.runTarget = nil
if self.animationNode then self.animationNode:finish() end
end
else -- анимация перемещения не завершена
self.displayedPosition = utils.lerp(self.position, self.runTarget, fraction) -- линейный интерполятор
end
end
end
return mapBehavior

View File

@ -1,25 +0,0 @@
--- Отвечает за размещение на уровне
--- @class PositionedBehavior : Behavior
--- @field position Vec3
local behavior = {}
behavior.__index = behavior
behavior.id = "positioned"
--- @param position? Vec3
function behavior.new(position)
return setmetatable({
position = position or Vec3({}),
}, behavior)
end
--- @param position Vec3
function behavior:lookAt(position)
self.owner:try(Tree.behaviors.sprite,
function(sprite)
if position.x > self.position.x then sprite.side = sprite.RIGHT end
if position.x < self.position.x then sprite.side = sprite.LEFT end
end
)
end
return behavior

View File

@ -1,37 +1,28 @@
--- Умеет асинхронно ждать какое-то время (для анимаций)
--- @class ResidentSleeperBehavior : Behavior
--- @field private t0 number?
--- @field private sleepTime number?
--- @field private callback voidCallback?
--- @field private state 'running' | 'finished'
--- @field animationNode? AnimationNode
--- @field endsAt? number
local behavior = {}
behavior.__index = behavior
behavior.id = "residentsleeper"
function behavior.new() return setmetatable({}, behavior) end
function behavior:update(_)
if self.state ~= 'running' then return end
local t = love.timer.getTime()
if t >= self.t0 + self.sleepTime then
self.state = 'finished'
self.callback()
function behavior:update(dt)
if not self.animationNode then return end
if love.timer.getTime() >= self.endsAt then
self.animationNode:finish()
self.animationNode = nil
self.endsAt = nil
end
end
--- @return Task<nil>
function behavior:sleep(ms)
self.sleepTime = ms / 1000
return function(callback)
if self.state == 'running' then
self.callback()
end
self.t0 = love.timer.getTime()
self.callback = callback
self.state = 'running'
end
--- @param ms number time to wait in milliseconds
--- @param node AnimationNode
function behavior:sleep(ms, node)
if self.animationNode then node:finish() end
self.animationNode = node
self.endsAt = love.timer.getTime() + ms / 1000
end
return behavior

View File

@ -1,35 +0,0 @@
local easing = require "lib.utils.easing"
--- @class ShadowcasterBehavior : Behavior
local behavior = {}
behavior.id = "shadowcaster"
behavior.__index = behavior
function behavior.new() return setmetatable({}, behavior) end
function behavior:draw()
local sprite = self.owner:has(Tree.behaviors.sprite)
local positioned = self.owner:has(Tree.behaviors.positioned)
if not positioned then return end
if not sprite then return end
local ppm = Tree.level.camera.pixelsPerMeter
local position = positioned.position + Vec3 { 0.5, 0.5 }
local lightIds = Tree.level.lightGrid:query(position, 5)
--- @type Character[]
local lights = {}
for _, id in ipairs(lightIds) do
table.insert(lights, Tree.level.characters[id])
end
-- 1. Эллипс тени
Tree.level.render:enqueue(Tree.level.render.LAYERS.SHADOW, position.y, function()
love.graphics.push()
love.graphics.setColor(0, 0, 0, 1)
love.graphics.translate(position.x, position.y)
love.graphics.ellipse("fill", 0, 0, sprite.manifest.size / 2, sprite.manifest.size / 2 * math.cos(math.pi / 4))
love.graphics.pop()
end)
end
return behavior

View File

@ -1,13 +1,11 @@
--- @class SpellcasterBehavior : Behavior
--- @field spellbook Spell[] собственный набор спеллов персонажа
--- @field cast Spell | nil ссылка на активный спелл из спеллбука
--- @field cooldowns {[string]: integer} текущий кулдаун спеллов по тегам
--- @field state "idle" | "casting" | "running"
local behavior = {}
behavior.__index = behavior
behavior.id = "spellcaster"
behavior.state = "idle"
behavior.cooldowns = {}
---@param spellbook Spell[] | nil
---@return SpellcasterBehavior
@ -15,33 +13,17 @@ function behavior.new(spellbook)
local spb = require "lib.spellbook" --- @todo временное добавление ходьбы (и читов) всем персонажам
local t = {}
t.spellbook = spellbook or spb.of { spb.walk, spb.regenerateMana, spb.attack }
t.cooldowns = {}
return setmetatable(t, behavior)
end
function behavior:endCast()
self.owner:try(Tree.behaviors.effects, function(effects)
effects:afterCast()
end)
self.state = "idle"
self.cast = nil
Tree.level.turnOrder:reorder()
Tree.level.selector:unlock()
end
function behavior:processCooldowns()
local cds = {}
for tag, cd in pairs(self.cooldowns) do
cds[tag] = (cd - 1) >= 0 and cd - 1 or 0
end
self.cooldowns = cds
end
function behavior:update(dt)
if Tree.level.selector:deselected() then
self.state = "idle"
self.cast = nil
end
if self.cast and self.state == "casting" then self.cast:update(self.owner, dt) end
end

View File

@ -3,8 +3,6 @@ local anim8 = require "lib.utils.anim8"
--- @class SpriteBehavior : Behavior
--- @field animationTable table<string, table>
--- @field animationGrid table
--- @field manifest table
--- @field sheets table
--- @field state "idle"|"run"|"hurt"|"attack"
--- @field side 1|-1
local sprite = {}
@ -19,12 +17,11 @@ function sprite.new(spriteDir)
local anim = setmetatable({}, sprite)
anim.animationTable = {}
anim.animationGrid = {}
anim.manifest = spriteDir.manifest
anim.sheets = spriteDir.sheets
-- n: name; i: image
for n, i in pairs(spriteDir.sheets) do
local aGrid = anim8.newGrid(anim.manifest.width, anim.manifest.height, i:getWidth(), i:getHeight())
local tiles = '1-' .. math.ceil(i:getWidth() / anim.manifest.width)
for n, i in pairs(spriteDir) do
local aGrid = anim8.newGrid(96, 64, i:getWidth(), i:getHeight())
local tiles = '1-' .. math.ceil(i:getWidth() / 96)
anim.animationGrid[n] = aGrid(tiles, 1)
end
@ -42,96 +39,41 @@ function sprite:update(dt)
end
function sprite:draw()
if not self.animationTable[self.state] or not self.sheets[self.state] then return end
if not self.animationTable[self.state] or not Tree.assets.files.sprites.character[self.state] then return end
self.owner:try(Tree.behaviors.positioned,
function(pos)
self.owner:try(Tree.behaviors.map,
function(map)
local ppm = Tree.level.camera.pixelsPerMeter
local position = pos.position + Vec3 { 0.5, 0.5 }
local position = map.displayedPosition
if Tree.level.selector.id == self.owner.id then
local texW, texH = Tree.assets.files.sprites.character[self.state]:getWidth(),
Tree.assets.files.sprites.character[self.state]:getHeight()
local shader = Tree.assets.files.shaders.outline
shader:send("texSize", { texW, texH })
shader:send("time", love.timer:getTime())
love.graphics.setShader(shader)
end
Tree.level.render:enqueue(Tree.level.render.LAYERS.SPRITE, position.y, function()
love.graphics.setColor(1, 1, 1)
-- Собираем источники света для шейдера
local queryRadius = 12 -- Увеличенный радиус для плавности
local lightIds = Tree.level.lightGrid:query(position, queryRadius)
local lightsData = {}
for _, id in ipairs(lightIds) do
local lightChar = Tree.level.characters[id]
local b = lightChar:has(Tree.behaviors.light) --[[@as LightBehavior]]
local lPos = lightChar:has(Tree.behaviors.positioned).position
local dist = (lPos - position):length()
-- Берем только те, что могут дотянуться до нас своим радиусом
if dist < b.intensity + 2 then
table.insert(lightsData, {
x = lPos.x,
y = lPos.y,
r = b.color.x,
g = b.color.y,
b = b.color.z,
radius = b.intensity,
dist = dist
})
end
end
-- Сортируем по дистанции, чтобы выбрать 8 самых влиятельных
table.sort(lightsData, function(a, b) return a.dist < b.dist end)
local lightShader = Tree.assets.files.shaders.sprite_light
local weather = Tree.level.weather
local numLights = math.min(#lightsData, 8)
local isSelected = (Tree.level.selector.id == self.owner.id)
lightShader:send("sprite_pos", { position.x, position.y })
lightShader:send("sky", { weather.skyLight.x, weather.skyLight.y, weather.skyLight.z })
lightShader:send("ambient", { weather.ambientLight.x, weather.ambientLight.y, weather.ambientLight.z })
lightShader:send("num_lights", numLights)
lightShader:send("is_selected", isSelected)
if isSelected then
local sheet = self.sheets[self.state]
lightShader:send("tex_size", { sheet:getWidth(), sheet:getHeight() })
lightShader:send("time", love.timer:getTime())
end
for i = 1, numLights do
local l = lightsData[i]
local idx = i - 1
lightShader:send(string.format("lights[%d].position", idx), { l.x, l.y })
lightShader:send(string.format("lights[%d].color", idx), { l.r, l.g, l.b })
lightShader:send(string.format("lights[%d].radius", idx), l.radius)
end
love.graphics.setShader(lightShader)
self.animationTable[self.state]:draw(self.sheets[self.state],
position.x,
position.y, nil, 1 / ppm * self.side, 1 / ppm, self.manifest.base.x, self.manifest.base.y)
love.graphics.setShader()
end)
self.animationTable[self.state]:draw(Tree.assets.files.sprites.character[self.state],
position.x + 0.5,
position.y + 0.5, nil, 1 / ppm * self.side, 1 / ppm, 38, 47)
love.graphics.setColor(1, 1, 1)
love.graphics.setShader()
end
)
end
--- @return Task<nil>
function sprite:animate(state)
return function(callback)
if not self.animationGrid[state] then
print("[SpriteBehavior]: no animation for '" .. state .. "'")
return callback()
end
self.animationTable[state] = anim8.newAnimation(self.animationGrid[state], self.ANIMATION_SPEED,
function()
self:loop("idle")
callback()
end)
self.state = state
--- @param node AnimationNode
function sprite:animate(state, node)
if not self.animationGrid[state] then
return print("[SpriteBehavior]: no animation for '" .. state .. "'")
end
self.animationTable[state] = anim8.newAnimation(self.animationGrid[state], self.ANIMATION_SPEED,
function()
self:loop("idle")
node:finish()
end)
self.state = state
end
function sprite:loop(state)
@ -139,9 +81,6 @@ function sprite:loop(state)
return print("[SpriteBehavior]: no animation for '" .. state .. "'")
end
self.animationTable[state] = anim8.newAnimation(self.animationGrid[state], self.ANIMATION_SPEED)
if state == 'idle' then
self.animationTable[state]:gotoFrame(love.math.random(#self.animationTable[state].frames))
end
self.state = state
end

View File

@ -1,45 +1,22 @@
--- @alias Class "dev_warrior"|"dev_mage"
--- @class StatsBehavior : Behavior
--- @field hp integer
--- @field mana integer
--- @field initiative integer
--- @field class Class
--- @field isInTurnOrder boolean
--- @field amIAlive boolean
local behavior = {}
behavior.__index = behavior
behavior.id = "stats"
--- план прост, если что-то не так, то мы просто убиваем бехавиор (по крайней мере так должно было быть, но пиаш мне запретил :sob:)
function behavior:checkStats()
-- if self.hp <= 0 then behavior:die() end
if self.hp <= 0 then
self.amIAlive = false
end
end
--- @param damage integer
function behavior:dealDamage(damage)
local effects = self.owner:has(Tree.behaviors.effects)
if effects then damage = effects:beforeDamage(damage) end
self.hp = self.hp - damage
self:checkStats()
end
--- @param hp? integer
--- @param mana? integer
--- @param initiative? integer
--- @param class? Class
--- @param isInTurnOrder? boolean
function behavior.new(hp, mana, initiative, class, isInTurnOrder)
function behavior.new(hp, mana, initiative, isInTurnOrder)
return setmetatable({
hp = hp or 20,
mana = mana or 10,
initiative = initiative or 10,
class = class or "dev_warrior",
isInTurnOrder = isInTurnOrder or true,
amIAlive = true
isInTurnOrder = isInTurnOrder or true
}, behavior)
end

View File

@ -1,68 +0,0 @@
local task = require "lib.utils.task"
--- Отвечает за перемещение по тайлам
--- @class TiledBehavior : Behavior
--- @field size Vec3
local behavior = {}
behavior.__index = behavior
behavior.id = "tiled"
--- @param size? Vec3
function behavior.new(size)
return setmetatable({
size = size or Vec3({ 1, 1 }),
}, behavior)
end
--- @param path Deque
--- @return Task<nil>
function behavior:followPath(path)
if path:is_empty() then return task.fromValue(nil) end
self.owner:try(Tree.behaviors.sprite, function(sprite)
sprite:loop("run")
end)
-- Рекурсивная функция для прохода по пути
local function nextStep()
if path:is_empty() then
self.owner:try(Tree.behaviors.sprite, function(sprite)
sprite:loop("idle")
end)
return task.fromValue(nil)
end
local nextCell = path:pop_front()
return task.chain(self:runTo(nextCell), nextStep)
end
return nextStep()
end
--- @param target Vec3
--- @return Task<nil>
function behavior:runTo(target)
local positioned = self.owner:has(Tree.behaviors.positioned)
if not positioned then return task.fromValue(nil) end
self.owner:try(Tree.behaviors.sprite,
function(sprite)
if target.x < positioned.position.x then
sprite.side = Tree.behaviors.sprite.LEFT
elseif target.x > positioned.position.x then
sprite.side = Tree.behaviors.sprite.RIGHT
end
end
)
local distance = target:subtract(positioned.position):length()
local duration = distance * 500 -- 500ms per unit
return task.tween(positioned, { position = target }, duration)
end
function behavior:update(dt)
-- Logic moved to tasks
end
return behavior

View File

@ -13,7 +13,11 @@ character.__index = character
--- Создаёт персонажа, которым будет управлять или игрок или компьютер
--- @param name string
local function spawn(name)
--- @param spriteDir table
--- @param position? Vec3
--- @param size? Vec3
--- @param initiative? integer
local function spawn(name, spriteDir, position, size, initiative)
local char = {}
char = setmetatable(char, character)
@ -22,6 +26,14 @@ local function spawn(name)
char.behaviors = {}
char._behaviorsIdx = {}
char:addBehavior {
Tree.behaviors.residentsleeper.new(),
Tree.behaviors.stats.new(nil, nil, initiative),
Tree.behaviors.map.new(position, size),
Tree.behaviors.sprite.new(spriteDir),
Tree.behaviors.spellcaster.new()
}
Tree.level.characters[char.id] = char
return char
end
@ -80,18 +92,6 @@ function character:addBehavior(behaviors)
return self
end
--- Добавляет персонажа в очередь на удаление.
--- В конце фрейма он умирает. Ужасной смертью.
---
--- Ещё этот метод должен освобождать ресурсы в поведениях. Мы против утечек памяти!
function character:die()
for _, b in ipairs(self.behaviors) do
if b.die then b:die() end
end
table.insert(Tree.level.deadIds, self.id)
end
function character:update(dt)
for _, b in ipairs(self.behaviors) do
if b.update then b:update(dt) end

View File

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

View File

@ -1,123 +0,0 @@
local task = require "lib.utils.task"
local effect = require "lib.spell.effect"
local easing = require "lib.utils.easing"
--- некое уникальное строковое значение
--- @alias EffectTag string
--- Кровотечение.
---
--- Наносит `intensity` урона перед началом каждого хода.
local bleeding = effect.new({
tag = "bleeding"
})
function bleeding:afterBirth(owner, intensity)
local light = require "lib/character/character".spawn("Bleeding Light Effect")
light:addBehavior {
Tree.behaviors.light.new { color = Vec3 { 1, 0., 0. }, intensity = 4 },
Tree.behaviors.positioned.new(owner:has(Tree.behaviors.positioned).position + Vec3 { 0.5, 0.5 }),
}
return task.wait({ task.chain(task.tween(light:has(Tree.behaviors.light) --[[@as LightBehavior]],
{ intensity = 1, color = Vec3 { 0, 0., 0. } }, 800, easing.easeInCubic), function()
light:die()
return task.fromValue()
end) })
end
function bleeding:beforeTurn(owner, intensity)
local stats = owner:has(Tree.behaviors.stats)
local sprite = owner:has(Tree.behaviors.sprite)
if not stats or not sprite then return end
stats:dealDamage(intensity)
return task.wait({ sprite:animate("hurt") }), true
end
function bleeding:afterTurn(owner, intensity)
local behavior = owner:has(Tree.behaviors.effects)
if not behavior then
print('[EffectBook]: yo man what the hell wheres your behavior how thats possible please stop thats not normal')
else
behavior:deleteStacks("bleeding", 1)
end
return task.wait {}
end
--- meow
function bleeding:afterCast(owner, intensity)
Tree.audio:play(Tree.assets.files.audio.sounds.meow)
return task.wait {}
end
function bleeding:beforeCast(owner, intensity)
Tree.audio:play(Tree.assets.files.audio.sounds.meow)
return task.wait {}, true
end
--- Отвращение к смерти.
---
--- Спасает от смертельного урона, оставляя одно очко здоровья. Персонаж не может ходить до тех пор, пока эффект не сработает.
local aversionToDeath = effect.new {
tag = "aversionToDeath"
}
function aversionToDeath:beforeDamage(owner, intensity, damage)
local stats = owner:has(Tree.behaviors.stats)
local effects = owner:has(Tree.behaviors.effects)
if not stats or not effects then return end
if stats.hp <= damage then
effects:deleteStacks("aversionToDeath", 1)
-- тут должен быть какой-нибудь классный спецэффект, но я не умею в шейдеры
return task.wait({}), stats.hp - 1
end
return task.wait {}, damage
end
function aversionToDeath:beforeTurn(owner, intensity)
local sprite = owner:has(Tree.behaviors.sprite)
if not sprite then
return task.wait {}, false
end
return task.wait {
sprite:animate("hurt")
}, false
end
----------------- Effectbook & Sum -----------------
--- @alias EffectSumFunc fun(owner: Character, effect1: EffectTag, effect2: EffectTag): boolean
--- Принимает таблицу, в ключах которых тэги эффектов, которые мы хотим просуммировать, и в значениях которых функция,
--- возвращающая булево значение: применять ли эффект после суммирования.
--- @type table<EffectTag, table<EffectTag, EffectSumFunc>>
local sums = {}
--- Сумма кровотечения и отвращения к смерти, (в целях разработки) удаляет оба эффекта, не позволяя дальше применять эффект
sums.bleeding = {
aversionToDeath = function(owner, effect1, effect2)
print("[EffectBook]: применяем сумму, удаляем оба эффекта")
local behaviorEffect = owner:has(Tree.behaviors.effects)
if not behaviorEffect then
print(
"[EffectBook]: yo man what the hell wheres your behavior how thats possible please stop thats not normal")
return true
end
behaviorEffect:deleteEffect(effect1)
behaviorEffect:deleteEffect(effect2)
return false
end
}
--- @class EffectBook
--- @field sums table<EffectTag, table<EffectTag, EffectSumFunc>>
--- @field book table<EffectTag, Effect>
local effectbook = {
sums = sums,
book = {
bleeding = bleeding,
aversionToDeath = aversionToDeath
}
}
return effectbook

View File

@ -1,7 +1,5 @@
local Vec3 = require "lib.utils.vec3"
local utils = require "lib.utils.utils"
local task = require "lib.utils.task"
local easing = require "lib.utils.easing"
local EPSILON = 0.001
@ -16,7 +14,7 @@ local camera = {
velocity = Vec3 {},
acceleration = 0.2,
speed = 5,
pixelsPerMeter = 32,
pixelsPerMeter = 24,
}
function camera:getDefaultScale()
@ -29,6 +27,12 @@ camera.scale = camera:getDefaultScale()
---------------------------------------------------
love.wheelmoved = function(x, y)
if camera.scale > camera:getDefaultScale() * 5 and y > 0 then return end;
if camera.scale < camera:getDefaultScale() / 5 and y < 0 then return end;
camera.scale = camera.scale + (camera.scale * 0.1 * y)
end
local controlMap = {
cameraMoveUp = Vec3({ 0, -1 }),
cameraMoveLeft = Vec3({ -1 }),
@ -37,13 +41,6 @@ local controlMap = {
}
function camera:update(dt)
-------------------- зум на колесо ---------------------
local y = Tree.controls.mouseWheelY
if camera.scale > camera:getDefaultScale() * 5 and y > 0 then return end;
if camera.scale < camera:getDefaultScale() / 5 and y < 0 then return end;
camera.scale = camera.scale + (camera.scale * 0.1 * y)
--------------------------------------------------------
local ps = Tree.panning
if ps.delta:length() > 0 then
local worldDelta = ps.delta:scale(1 / (self.pixelsPerMeter * self.scale)):scale(dt):scale(self.speed)
@ -90,16 +87,6 @@ function camera:detach()
love.graphics.pop()
end
--- Плавно перемещает камеру к указанной точке.
--- @param position Vec3
--- @param duration number?
--- @param easing function?
--- @return Task
function camera:animateTo(position, duration, easing)
self.velocity = Vec3 {} -- Сбрасываем инерцию перед началом анимации
return task.tween(self, { position = position }, duration or 1000, easing)
end
--- @return Camera
local function new()
return setmetatable({

View File

@ -8,7 +8,7 @@ grid.__index = grid
--- adds a value to the grid
--- @param value any
function grid:add(value)
self.__grid[tostring(value.position)] = value
grid[tostring(value.position)] = value
end
--- @param position Vec3

View File

@ -1,6 +1,8 @@
local utils = require "lib.utils.utils"
local pQueue = require "lib.utils.priority_queue"
--- @class CharacterGrid : Grid
--- @field __grid {string: Id|nil}
--- @field yOrderQueue PriorityQueue<Character> очередь отрисовки сверху вниз
local grid = setmetatable({}, require "lib.level.grid.base")
grid.__index = grid
@ -10,15 +12,12 @@ function grid:add(id)
local character = Tree.level.characters[id]
if not character then return end
local positioned = character:has(Tree.behaviors.positioned)
if not positioned then return end
local mapB = character:has(Tree.behaviors.map)
if not mapB then return end
local tiled = character:has(Tree.behaviors.tiled)
if not tiled then return end
local centerX, centerY = math.floor(positioned.position.x + 0.5),
math.floor(positioned.position.y + 0.5)
local sizeX, sizeY = tiled.size.x, tiled.size.y
local centerX, centerY = math.floor(mapB.displayedPosition.x + 0.5),
math.floor(mapB.displayedPosition.y + 0.5)
local sizeX, sizeY = mapB.size.x, mapB.size.y
for y = centerY, centerY + sizeY - 1 do
for x = centerX, centerX + sizeX - 1 do
@ -27,13 +26,24 @@ function grid:add(id)
end
end
--- @param a Character
--- @param b Character
local function drawCmp(a, b)
-- здесь персонажи гарантированно имеют нужное поведение
return a:has(Tree.behaviors.map).displayedPosition.y
<
b:has(Tree.behaviors.map).displayedPosition.y
end
--- fills the grid with the actual data
---
--- should be called as early as possible during every tick
function grid:reload()
self:reset()
self.yOrderQueue = pQueue.new(drawCmp)
utils.each(Tree.level.characters, function(c)
self:add(c.id)
self.yOrderQueue:insert(c)
end)
end

View File

@ -1,65 +0,0 @@
local utils = require "lib.utils.utils"
--- Пометровая сетка источников света, чтобы быстро искать ближайшие для некоторого объекта
--- @class LightGrid : Grid
--- @field __grid {string: [Id]}
local grid = setmetatable({}, require "lib.level.grid.base")
grid.__index = grid
--- Adds a character id to the grid
--- @private
--- @param id Id
function grid:add(id)
local character = Tree.level.characters[id]
if not character then return end
local lightB = character:has(Tree.behaviors.light)
if not lightB then return end
local positioned = character:has(Tree.behaviors.positioned)
if not positioned then return end
local key = tostring(Vec3 { positioned.position.x, positioned.position.y }:floor())
if not self.__grid[key] then self.__grid[key] = {} end
table.insert(self.__grid[key], character.id)
end
--- fills the grid with the actual data
---
--- should be called as early as possible during every tick
function grid:reload()
self:reset()
utils.each(Tree.level.characters, function(c)
self:add(c.id)
end)
end
--- Возвращает все источники света, которые находятся в пределах круга с диаметром [distance] в [метрике Чебышёва](https://ru.wikipedia.org/wiki/Расстояниеебышёва)
--- @param position Vec3
--- @param distance integer
function grid:query(position, distance)
--- @type Id[]
local res = {}
local topLeft = position:subtract(Vec3 { distance / 2, distance / 2 }):floor()
for i = 0, distance, 1 do
for j = 0, distance, 1 do
--- @type Id[]?
local lights = self:get(topLeft:add(Vec3 { i, j }))
if lights then
for _, lightChar in ipairs(lights) do
table.insert(res, lightChar)
end
end
end
end
return res
end
--- Generates an empty grid
--- @return LightGrid
local function new()
return setmetatable({
__grid = {}
}, grid)
end
return { new = new }

View File

@ -9,31 +9,12 @@ map.__index = map
--- @param size? Vec3
local function new(type, template, size)
local tMap = require('lib.level.' .. type).new(template, size)
local grid = setmetatable({ __grid = tMap }, map)
grid:refreshBatch()
return grid
end
function map:refreshBatch()
-- Находим атлас первого попавшегося тайла (предполагаем, что он один для всех)
local _, firstTile = next(self.__grid)
if not firstTile then return end
local atlas = firstTile.atlasData.atlas
local count = 0
for _ in pairs(self.__grid) do count = count + 1 end
self.batch = love.graphics.newSpriteBatch(atlas, count)
for _, tile in pairs(self.__grid) do
-- 1/32 это масштаб, так как размер тайла в мире 1x1 метр, а в атласе 32x32 пикселя
self.batch:add(tile.atlasData.quad, tile.position.x, tile.position.y, 0, 1 / 32, 1 / 32)
end
return setmetatable({ __grid = tMap }, map)
end
function map:draw()
if not self.batch then return end
Tree.level.render:enqueue(Tree.level.render.LAYERS.FLOOR, 0, function()
love.graphics.draw(self.batch)
utils.each(self.__grid, function(el)
el:draw()
end)
end

View File

@ -3,64 +3,47 @@ local utils = require "lib.utils.utils"
--- @class Level
--- @field size Vec3
--- @field characters Character[]
--- @field deadIds Id[]
--- @field characterGrid CharacterGrid
--- @field lightGrid LightGrid
--- @field selector Selector
--- @field camera Camera
--- @field tileGrid TileGrid
--- @field turnOrder TurnOrder
--- @field render Render
local level = {}
level.__index = level
local path = nil
--- @param type "procedural"|"handmaded"
--- @param template Procedural|Handmaded
local function new(type, template)
local size = Vec3 { 30, 30 } -- magic numbers for testing purposes only
print(type, template, size)
Tree.audio:play(Tree.assets.files.audio.music.level1.progressive_plains)
return setmetatable({
size = size,
characters = {},
deadIds = {},
characterGrid = (require "lib.level.grid.character_grid").new(),
lightGrid = (require "lib.level.grid.light_grid").new(),
tileGrid = (require "lib.level.grid.tile_grid").new(type, template, size),
selector = (require "lib.level.selector").new(),
camera = (require "lib.level.camera").new(),
turnOrder = (require "lib.level.turn_order").new(),
render = (require "lib.level.render").new {},
weather = (require "lib.level.weather").new { ambientLight = Vec3 { 0.5, 0.5, 0.5 }, skyLight = Vec3 { 0.85, 0.55, 0.40 } }
}, level)
end
function level:update(dt)
utils.each(self.deadIds, function(id)
self.characters[id] = nil
self.turnOrder:remove(id)
end)
self.deadIds = {}
self.characterGrid:reload()
self.lightGrid:reload()
utils.each(self.characters, function(el)
el:update(dt)
end)
self.camera:update(dt)
self.selector:update(dt)
end
function level:draw()
self.render:clear()
self.tileGrid:draw()
utils.each(self.characters, function(char)
char:draw()
end)
self.render:draw()
while not self.characterGrid.yOrderQueue:is_empty() do -- по сути это сортировка кучей за n log n
self.characterGrid.yOrderQueue:pop():draw()
end
end
return {

View File

@ -1,174 +0,0 @@
--- @class Render
--- @field textures table<string, love.Canvas>
--- @field queue table[]
--- @field lowResScale number
--- @field LAYERS table<string, integer>
local render = {
textures = {},
queue = {},
lowResScale = 1.0,
LAYERS = {
FLOOR = 1,
SHADOW = 2,
LIGHT = 3,
SPRITE = 4,
OVERLAY = 5
}
}
function render:clear()
local weather = Tree.level.weather
local txs = self.textures
self.queue = {}
love.graphics.setCanvas(txs.shadowLayer)
love.graphics.clear()
love.graphics.setCanvas(txs.spriteLayer)
love.graphics.clear()
love.graphics.setCanvas(txs.floorLayer)
love.graphics.clear()
love.graphics.setCanvas(txs.lightLayer)
love.graphics.clear(weather.skyLight.x, weather.skyLight.y, weather.skyLight.z)
love.graphics.setCanvas(txs.overlayLayer)
love.graphics.clear()
end
function render:enqueue(layer, z, func)
table.insert(self.queue, { layer = layer, z = z, func = func })
end
function render:free()
for _, tx in pairs(self.textures) do
tx:release()
end
self.textures = nil
end
--- TODO: это используется для блюра, должно кэшироваться и поддерживать ресайз
function render:applyBlur(input, radius)
local blurShader = Tree.assets.files.shaders.blur
-- Горизонтальный проход
blurShader:send("direction", { 1.0, 0.0 })
blurShader:send("radius", radius)
self.textures.tmp1:renderTo(function()
love.graphics.clear()
love.graphics.setShader(blurShader)
love.graphics.draw(input)
love.graphics.setShader()
end)
-- Вертикальный проход
self.textures.tmp2:renderTo(
function()
love.graphics.clear()
love.graphics.setShader(blurShader)
blurShader:send("direction", { 0.0, 1.0 })
love.graphics.draw(self.textures.tmp1)
love.graphics.setShader()
end
)
return self.textures.tmp2
end
---@param params {w: number?, h: number?}
---@return table|Render
local function new(params)
local w = params.w or love.graphics.getWidth()
local h = params.h or love.graphics.getHeight()
local lowResScale = 0.5
return setmetatable({
lowResScale = lowResScale,
queue = {},
textures = {
shadowLayer = love.graphics.newCanvas(w * lowResScale, h * lowResScale),
spriteLayer = love.graphics.newCanvas(w, h),
floorLayer = love.graphics.newCanvas(w, h),
overlayLayer = love.graphics.newCanvas(w, h),
lightLayer = love.graphics.newCanvas(w * lowResScale, h * lowResScale),
tmp1 = love.graphics.newCanvas(w * lowResScale, h * lowResScale),
tmp2 = love.graphics.newCanvas(w * lowResScale, h * lowResScale),
}
}, { __index = render })
end
function render:draw()
local weather = Tree.level.weather
local txs = self.textures
-- 1. Сортировка очереди
table.sort(self.queue, function(a, b)
if a.layer ~= b.layer then
return a.layer < b.layer
end
return a.z < b.z
end)
-- 2. Рендеринг очереди в соответствующие Canvas
local currentLayer = nil
for _, entry in ipairs(self.queue) do
if entry.layer ~= currentLayer then
if currentLayer then
Tree.level.camera:detach()
local wasLowRes = currentLayer == self.LAYERS.SHADOW or currentLayer == self.LAYERS.LIGHT
if wasLowRes then
love.graphics.pop()
end
end
currentLayer = entry.layer
local isLowRes = currentLayer == self.LAYERS.SHADOW or currentLayer == self.LAYERS.LIGHT
if currentLayer == self.LAYERS.FLOOR then
love.graphics.setCanvas(txs.floorLayer)
elseif currentLayer == self.LAYERS.SHADOW then
love.graphics.setCanvas(txs.shadowLayer)
elseif currentLayer == self.LAYERS.LIGHT then
love.graphics.setCanvas(txs.lightLayer)
elseif currentLayer == self.LAYERS.SPRITE then
love.graphics.setCanvas(txs.spriteLayer)
elseif currentLayer == self.LAYERS.OVERLAY then
love.graphics.setCanvas(txs.overlayLayer)
end
if isLowRes then
love.graphics.push()
love.graphics.scale(self.lowResScale)
end
Tree.level.camera:attach()
end
entry.func()
end
if currentLayer then
Tree.level.camera:detach()
local wasLowRes = currentLayer == self.LAYERS.SHADOW or currentLayer == self.LAYERS.LIGHT
if wasLowRes then
love.graphics.pop()
end
end
love.graphics.setCanvas()
-- 3. Пост-процессинг и композиция
love.graphics.setCanvas(txs.lightLayer)
-- Радиус блюра тоже масштабируем, так как текстура меньше
love.graphics.draw(self:applyBlur(txs.shadowLayer, 4 * Tree.level.camera.scale * self.lowResScale))
love.graphics.setCanvas()
local lightShader = Tree.assets.files.shaders.light_postprocess
lightShader:send("scene", txs.floorLayer)
lightShader:send("light", self:applyBlur(txs.lightLayer, 2 * self.lowResScale))
lightShader:send("ambient", { weather.ambientLight.x, weather.ambientLight.y, weather.ambientLight.z })
love.graphics.setShader(lightShader)
love.graphics.draw(txs.floorLayer)
love.graphics.setShader()
love.graphics.draw(txs.overlayLayer)
-- Спрайты уже полностью освещены в SpriteBehavior (с учетом ambient и point lights)
-- Поэтому рисуем их "как есть"
love.graphics.draw(txs.spriteLayer)
end
return { new = new }

View File

@ -1,6 +1,5 @@
--- @class Selector
--- @field id Id | nil
--- @field lastId Id | nil
--- @field locked boolean
local selector = {}
selector.__index = selector
@ -16,7 +15,6 @@ 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()
@ -34,21 +32,16 @@ function selector:update(dt)
char:try(Tree.behaviors.spellcaster, function(b)
if not b.cast then
if not selectedId then self:select(nil) end
return
-- тут какая-то страшная дичь, я даже не уверен что оно работает
-- зато я точно уверен, что это надо было писать не так
if not selectedId then self:select(selectedId) end
if selectedId ~= Tree.level.turnOrder.current and Tree.level.turnOrder.isTurnsEnabled then return end
return self:select(selectedId)
end
if b.cast:cast(char, mousePosition) then
self:lock()
b.state = "running"
end
local task = b.cast:cast(char, mousePosition) -- в task функция, которая запускает анимацию спелла
if not task then return end -- не получилось скастовать
self:lock()
b.state = "running"
task(
function(_) -- это коллбэк, который сработает по окончании анимации спелла
b:endCast()
if not char:has(Tree.behaviors.ai) then self:select(char.id) end -- выделяем персонажа обратно после того, как посмотрели на каст
end
)
end)
end
end
@ -64,16 +57,6 @@ function selector:unlock()
self.locked = false
end
--- If a character was selected during this tick, returns its Id
function selector:selected()
if self.id and self.id ~= self.lastId then return self.id end
end
--- If a character was **de**selected during this tick, returns its Id
function selector:deselected()
if not self.id and self.lastId then return self.lastId end
end
return {
new = new
}

View File

@ -1,5 +1,4 @@
local PriorityQueue = require "lib.utils.priority_queue"
local easing = require "lib.utils.easing"
local PriorityQueue = require "lib.utils.priority_queue"
local initiativeComparator = function(id_a, id_b)
local res = Tree.level.characters[id_a]:try(Tree.behaviors.stats, function(astats)
@ -16,8 +15,8 @@ end
--- @field pendingQueue PriorityQueue Очередь тех, кто ждет своего хода в текущем раунде
--- @field current? Id Считаем того, кто сейчас ходит, отдельно, т.к. он ВСЕГДА первый в списке
--- @field isTurnsEnabled boolean
local turnOrder = {}
turnOrder.__index = turnOrder
local turnOrder = {}
turnOrder.__index = turnOrder
local function new()
return setmetatable({
@ -30,59 +29,18 @@ end
--- Перемещаем активного персонажа в очередь сходивших
---
--- Если в очереди на ход больше никого нет, заканчиваем раунд
---
--- Анимируем камеру к следующему персонажу. Если это ИИ, то активируем его логику.
function turnOrder:next()
Tree.level.selector.id = nil
self.actedQueue:insert(self.current)
local next = self.pendingQueue:peek()
if not next then self:endRound() else self.current = self.pendingQueue:pop() end
local char = Tree.level.characters[self.current]
Tree.level.selector:lock()
char:try(Tree.behaviors.positioned, function(positioned)
Tree.level.camera:animateTo(positioned.position, 1500, easing.easeInOutCubic)(
function()
-- проверяем, позволяют ли эффекты нам сходить
if char:try(Tree.behaviors.effects, function(effects)
-- print("[TurnOrder]: ну мы пытаемся применить эффект к", char.id)
return effects:beforeTurn()
end) == false then
self:next()
return
end
if char:has(Tree.behaviors.ai) then
char:has(Tree.behaviors.ai):makeTurn()(
function()
self:next()
end)
else
Tree.level.selector:unlock()
Tree.level.selector:select(self.current)
end
end
)
end)
if not next then return self:endRound() end
self.current = self.pendingQueue:pop()
end
--- Производим действия в конце раунда
---
--- Меняем местами очередь сходивших и не сходивших (пустую)
function turnOrder:endRound()
assert(self.pendingQueue:size() == 0, "[TurnOrder]: tried to end the round before everyone had a turn")
print("[TurnOrder]: end of the round")
for _, id in ipairs(self.actedQueue.data) do
local char = Tree.level.characters[id]
char:try(Tree.behaviors.spellcaster, function(spellcaster)
spellcaster:processCooldowns()
end)
char:try(Tree.behaviors.effects, function(effects)
effects:afterTurn()
end)
end
self.actedQueue, self.pendingQueue = self.pendingQueue, self.actedQueue
self.current = self.pendingQueue:pop()
end
@ -151,29 +109,4 @@ function turnOrder:add(id)
self.actedQueue:insert(id) -- новые персонажи по умолчанию попадают в очередь следующего хода
end
--- Удалить персонажа из очереди хода (например, при смерти)
--- @param id Id
function turnOrder:remove(id)
if self.current == id then
self.current = self.pendingQueue:pop()
if not self.current then
self:endRound()
end
return
end
local function filterQueue(q, targetId)
local newQ = PriorityQueue.new(initiativeComparator)
for _, val in ipairs(q.data) do
if val ~= targetId then
newQ:insert(val)
end
end
return newQ
end
self.actedQueue = filterQueue(self.actedQueue, id)
self.pendingQueue = filterQueue(self.pendingQueue, id)
end
return { new = new }

View File

@ -1,12 +0,0 @@
--- @class Weather
--- @field skyLight Vec3
--- @field ambientLight Vec3
local weather = {}
--- @param proto Weather
--- @return Weather
local function new(proto)
return setmetatable(proto, { __index = weather })
end
return { new = new }

View File

@ -1,52 +0,0 @@
-- --- @class Music
-- --- @field source table<string, love.Source> audio streams, that supports multitrack (kind of)
-- --- @field offset number
-- music = {}
-- music.__index = music
-- --- @param path string accepts path to dir with some music files (example: "main_ambient"; "player/theme1" and etc etc)
-- local function new(path)
-- local dir = Tree.assets.files.audio.music[path]
-- --- @type table<string, love.Source>
-- local source = {}
-- print(dir)
-- for _, v in pairs(dir) do
-- print(v.filename)
-- source[v.filename] = v.source
-- print(v.filename)
-- end
-- print('[music]: new source: ', table.concat(source, ' '))
-- return setmetatable({ source = source, offset = 0 }, music)
-- end
-- function music:update()
-- for _, v in ipairs(self.source) do
-- v:seek()
-- end
-- end
-- --- pause stemfile or music at all
-- --- @param filename? string
-- function music:pause(filename)
-- if filename then
-- self.source[filename]:pause()
-- else
-- for _, v in pairs(self.source) do
-- v:pause()
-- end
-- end
-- end
-- --- play music stemfile by his name
-- --- @param filename string
-- --- @return boolean
-- function music:play(filename)
-- print('[music]: ', table.concat(self.source, ' '))
-- self.source[filename]:seek(self.offset, "seconds")
-- return self.source[filename]:play()
-- end
-- return { new = new }

View File

@ -1,20 +0,0 @@
--- @class Color
--- @field r number
--- @field g number
--- @field b number
--- @field a number
local color = {
r = 1,
g = 1,
b = 1,
a = 1
}
color.__index = color
--- @param rgba {r?: number, g?: number, b?: number, a?: number}
--- @return Color
function color.new(rgba)
return setmetatable(rgba, color)
end
return color.new

View File

@ -1,109 +0,0 @@
local Rect = require "lib.simple_ui.rect"
local function makeGradientMesh(w, h, topColor, bottomColor)
local vertices = {
{ 0, 0, 0, 0, topColor[1], topColor[2], topColor[3], topColor[4] }, -- левый верх
{ w, 0, 1, 0, topColor[1], topColor[2], topColor[3], topColor[4] }, -- правый верх
{ w, h, 1, 1, bottomColor[1], bottomColor[2], bottomColor[3], bottomColor[4] }, -- правый низ
{ 0, h, 0, 1, bottomColor[1], bottomColor[2], bottomColor[3], bottomColor[4] }, -- левый низ
}
local mesh = love.graphics.newMesh(vertices, "fan", "static")
return mesh
end
--- @class UIElement
--- @field bounds Rect Прямоугольник, в границах которого размещается элемент. Размеры и положение в экранных координатах
--- @field overlayGradientMesh love.Mesh Общий градиент поверх элемента (интерполированный меш)
local uiElement = {}
uiElement.bounds = Rect {}
uiElement.overlayGradientMesh = makeGradientMesh(1, 1, { 0, 0, 0, 0 }, { 0, 0, 0, 0.4 });
uiElement.__index = uiElement
function uiElement:update(dt) end
function uiElement:draw() end
function uiElement:hitTest(screenX, screenY)
return self.bounds:hasPoint(screenX, screenY)
end
--- @generic T : UIElement
--- @param values table
--- @param self T
--- @return T
function uiElement.new(self, values)
values.bounds = values.bounds or Rect {}
values.overlayGradientMesh = values.overlayGradientMesh or uiElement.overlayGradientMesh;
return setmetatable(values, self)
end
--- Рисует границу вокруг элемента (с псевдо-затенением)
--- @param type "outer" | "inner"
--- @param width? number
function uiElement:drawBorder(type, width)
local w = width or 4
love.graphics.setLineWidth(w)
if type == "inner" then
love.graphics.setColor(0.2, 0.2, 0.2)
love.graphics.line({
self.bounds.x, self.bounds.y + self.bounds.height,
self.bounds.x, self.bounds.y,
self.bounds.x + self.bounds.width, self.bounds.y,
})
love.graphics.setColor(0.3, 0.3, 0.3)
love.graphics.line({
self.bounds.x + self.bounds.width, self.bounds.y,
self.bounds.x + self.bounds.width, self.bounds.y + self.bounds.height,
self.bounds.x, self.bounds.y + self.bounds.height,
})
else
love.graphics.setColor(0.3, 0.3, 0.3)
-- love.graphics.line({
-- self.bounds.x, self.bounds.y + self.bounds.height,
-- self.bounds.x, self.bounds.y,
-- self.bounds.x + self.bounds.width, self.bounds.y,
-- })
love.graphics.line({
self.bounds.x, self.bounds.y + self.bounds.height - w,
self.bounds.x, self.bounds.y + w,
})
love.graphics.line({
self.bounds.x + w, self.bounds.y,
self.bounds.x + self.bounds.width - w, self.bounds.y,
})
love.graphics.setColor(0.2, 0.2, 0.2)
-- love.graphics.line({
-- self.bounds.x + self.bounds.width, self.bounds.y,
-- self.bounds.x + self.bounds.width, self.bounds.y + self.bounds.height,
-- self.bounds.x, self.bounds.y + self.bounds.height,
-- })
love.graphics.line({
self.bounds.x + self.bounds.width, self.bounds.y + w,
self.bounds.x + self.bounds.width, self.bounds.y + self.bounds.height - w,
})
love.graphics.line({
self.bounds.x + self.bounds.width - w, self.bounds.y + self.bounds.height,
self.bounds.x + w, self.bounds.y + self.bounds.height,
})
end
love.graphics.setColor(1, 1, 1)
end
--- рисует градиент поверх элемента
function uiElement:drawGradientOverlay()
love.graphics.push()
love.graphics.translate(self.bounds.x, self.bounds.y)
love.graphics.scale(self.bounds.width, self.bounds.height)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.overlayGradientMesh)
love.graphics.pop()
end
return uiElement

View File

@ -1,71 +0,0 @@
local Element = require "lib.simple_ui.element"
--- @class BarElement : UIElement
--- @field getter fun() : number
--- @field value number
--- @field maxValue number
--- @field color Color
--- @field useDividers boolean
--- @field drawText boolean
local barElement = setmetatable({}, Element)
barElement.__index = barElement
barElement.useDividers = false
barElement.drawText = false
function barElement:update(dt)
local val = self.getter()
self.value = val < 0 and 0 or val > self.maxValue and self.maxValue or val
end
function barElement:draw()
local valueWidth = self.bounds.width * self.value / self.maxValue
local emptyWidth = self.bounds.width - valueWidth
--- шум
love.graphics.setShader(Tree.assets.files.shaders.soft_uniform_noise)
love.graphics.rectangle("fill", self.bounds.x, self.bounds.y, self.bounds.width, self.bounds.height)
love.graphics.setShader()
--- закраска пустой части
love.graphics.setColor(0.05, 0.05, 0.05)
love.graphics.setBlendMode("multiply", "premultiplied")
love.graphics.rectangle("fill", self.bounds.x + valueWidth, self.bounds.y, emptyWidth,
self.bounds.height)
love.graphics.setBlendMode("alpha")
--- закраска значимой части её цветом
love.graphics.setColor(self.color.r, self.color.g, self.color.b)
love.graphics.setBlendMode("multiply", "premultiplied")
love.graphics.rectangle("fill", self.bounds.x, self.bounds.y, valueWidth,
self.bounds.height)
love.graphics.setBlendMode("alpha")
--- мерки
love.graphics.setColor(38 / 255, 50 / 255, 56 / 255)
if self.useDividers then
local count = self.maxValue - 1
local measureWidth = self.bounds.width / self.maxValue
for i = 1, count, 1 do
love.graphics.line(self.bounds.x + i * measureWidth, self.bounds.y, self.bounds.x + i * measureWidth,
self.bounds.y + self.bounds.height)
end
end
love.graphics.setColor(1, 1, 1)
--- текст поверх
if self.drawText then
local font = Tree.fonts:getDefaultTheme():getVariant("small")
local t = love.graphics.newText(font, tostring(self.value) .. "/" .. tostring(self.maxValue))
love.graphics.draw(t, math.floor(self.bounds.x + self.bounds.width / 2 - t:getWidth() / 2),
math.floor(self.bounds.y + self.bounds.height / 2 - t:getHeight() / 2))
end
self:drawBorder("inner")
self:drawGradientOverlay()
end
return function(values) return barElement:new(values) end

View File

@ -1,86 +0,0 @@
local Element = require "lib.simple_ui.element"
local Rect = require "lib.simple_ui.rect"
local Color = require "lib.simple_ui.color"
local Bar = require "lib.simple_ui.level.bar"
--- @class BottomBars : UIElement
--- @field hpBar BarElement
--- @field manaBar BarElement
local bottomBars = setmetatable({}, Element)
bottomBars.__index = bottomBars;
--- @param cid Id
function bottomBars.new(cid)
local t = setmetatable({}, bottomBars)
t.hpBar =
Bar {
getter = function()
local char = Tree.level.characters[cid]
return char:try(Tree.behaviors.stats, function(stats)
return stats.hp or 0
end)
end,
color = Color { r = 130 / 255, g = 8 / 255, b = 8 / 255 },
drawText = true,
maxValue = 20
}
t.manaBar =
Bar {
getter = function()
local char = Tree.level.characters[cid]
return char:try(Tree.behaviors.stats, function(stats)
return stats.mana or 0
end)
end,
color = Color { r = 51 / 255, g = 105 / 255, b = 30 / 255 },
useDividers = true,
maxValue = 10
}
return t
end
function bottomBars:update(dt)
local height = 16
local margin = 2
self.bounds.height = height
self.bounds.y = self.bounds.y - height
self.hpBar.bounds = Rect {
width = -2 * margin + self.bounds.width / 2,
height = height - margin,
x = self.bounds.x + margin,
y = self.bounds.y + margin
}
self.manaBar.bounds = Rect {
width = -2 * margin + self.bounds.width / 2,
height = height - margin,
x = self.bounds.x + margin + self.bounds.width / 2,
y = self.bounds.y + margin
}
self.hpBar:update(dt)
self.manaBar:update(dt)
end
function bottomBars:draw()
-- шум
love.graphics.setShader(Tree.assets.files.shaders.soft_uniform_noise)
love.graphics.rectangle("fill", self.bounds.x, self.bounds.y, self.bounds.width, self.bounds.height)
love.graphics.setShader()
love.graphics.setColor(38 / 255, 50 / 255, 56 / 255)
love.graphics.setBlendMode("multiply", "premultiplied")
love.graphics.rectangle("fill", self.bounds.x, self.bounds.y, self.bounds.width, self.bounds.height)
love.graphics.setBlendMode("alpha")
self.hpBar:draw()
self.manaBar:draw()
end
return bottomBars.new

View File

@ -1,108 +0,0 @@
local task = require "lib.utils.task"
local easing = require "lib.utils.easing"
local Element = require "lib.simple_ui.element"
local Rect = require "lib.simple_ui.rect"
local SkillRow = require "lib.simple_ui.level.skill_row"
local Bars = require "lib.simple_ui.level.bottom_bars"
local EndTurnButton = require "lib.simple_ui.level.end_turn"
--- @class CharacterPanel : UIElement
--- @field animationTask Task
--- @field alpha number
--- @field state "show" | "idle" | "hide"
--- @field skillRow SkillRow
--- @field bars BottomBars
--- @field endTurnButton EndTurnButton
local characterPanel = setmetatable({}, Element)
characterPanel.__index = characterPanel
function characterPanel.new(characterId)
local t = {}
t.state = "show"
t.skillRow = SkillRow(characterId)
t.bars = Bars(characterId)
t.endTurnButton = EndTurnButton {}
t.alpha = 0 -- starts hidden/animating
return setmetatable(t, characterPanel)
end
function characterPanel:show()
self.state = "show"
self.animationTask = task.tween(self, { alpha = 1 }, 300, easing.easeOutCubic)
self.animationTask(function() self.state = "idle" end)
end
function characterPanel:hide()
self.state = "hide"
self.animationTask = task.tween(self, { alpha = 0 }, 300, easing.easeOutCubic)
end
--- @type love.Canvas
local characterPanelCanvas;
function characterPanel:update(dt)
-- Tasks update automatically via task.update(dt) in main.lua
self.skillRow:update(dt)
self.bars.bounds = Rect {
width = self.skillRow.bounds.width,
x = self.skillRow.bounds.x,
y = self.skillRow.bounds.y
}
self.bars:update(dt)
self.bounds = Rect {
x = self.bars.bounds.x,
y = self.bars.bounds.y,
width = self.bars.bounds.width,
height = self.bars.bounds.height + self.skillRow.bounds.height
}
self.endTurnButton:layout()
self.endTurnButton.bounds.x = self.bounds.x + self.bounds.width + 32
self.endTurnButton.bounds.y = self.bounds.y + self.bounds.height / 2 - self.endTurnButton.bounds.height / 2
self.endTurnButton:update(dt)
if not characterPanelCanvas then
characterPanelCanvas = love.graphics.newCanvas(self.bounds.width, self.bounds.height)
end
--- анимация появления
local revealShader = Tree.assets.files.shaders.reveal
revealShader:send("t", self.alpha)
end
function characterPanel:draw()
self.skillRow:draw()
--- @TODO: переписать этот ужас с жонглированием координатами, а то слишком хардкод (skillRow рисуется относительно нуля и не закрывает канвас)
love.graphics.push()
local canvas = love.graphics.getCanvas()
love.graphics.translate(0, self.bars.bounds.height)
love.graphics.setCanvas(characterPanelCanvas)
love.graphics.clear()
love.graphics.draw(canvas)
love.graphics.pop()
love.graphics.push()
love.graphics.translate(-self.bounds.x, -self.bounds.y)
self.bars:draw()
self:drawBorder("outer")
love.graphics.pop()
--- рисуем текстуру шейдером появления
love.graphics.setCanvas()
love.graphics.setShader(Tree.assets.files.shaders.reveal)
love.graphics.setColor(1, 1, 1, 1)
self.endTurnButton:draw()
love.graphics.push()
love.graphics.translate(self.bounds.x, self.bounds.y)
love.graphics.draw(characterPanelCanvas)
love.graphics.setColor(1, 1, 1)
love.graphics.pop()
love.graphics.setShader()
end
return characterPanel.new

View File

@ -1,53 +0,0 @@
local Element = require "lib.simple_ui.element"
local task = require "lib.utils.task"
local easing = require "lib.utils.easing"
--- @class EndTurnButton : UIElement
--- @field hovered boolean
--- @field onClick function?
local endTurnButton = setmetatable({}, Element)
endTurnButton.__index = endTurnButton
function endTurnButton:update(dt)
local mx, my = love.mouse.getPosition()
if self:hitTest(mx, my) then
self.hovered = true
if Tree.controls:isJustPressed("select") then
if self.onClick then self.onClick() end
Tree.controls:consume("select")
end
else
self.hovered = false
end
end
function endTurnButton:layout()
local font = Tree.fonts:getDefaultTheme():getVariant("large")
self.text = love.graphics.newText(font, "Завершить ход")
self.bounds.width = self.text:getWidth() + 32
self.bounds.height = self.text:getHeight() + 16
end
function endTurnButton:draw()
love.graphics.setColor(38 / 255, 50 / 255, 56 / 255, 0.9)
love.graphics.rectangle("fill", self.bounds.x, self.bounds.y, self.bounds.width, self.bounds.height)
if self.hovered then
love.graphics.setColor(0.1, 0.1, 0.1)
love.graphics.rectangle("fill", self.bounds.x, self.bounds.y, self.bounds.width, self.bounds.height)
end
love.graphics.setColor(0.95, 0.95, 0.95)
love.graphics.draw(self.text, self.bounds.x + 16, self.bounds.y + 8)
self:drawBorder("outer")
love.graphics.setColor(1, 1, 1)
end
function endTurnButton:onClick()
Tree.level.turnOrder:next()
end
return function(values)
return endTurnButton:new(values)
end

View File

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

View File

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

View File

@ -1,213 +0,0 @@
local icons = require("lib.utils.sprite_atlas").load(Tree.assets.files.dev_icons)
local Element = require "lib.simple_ui.element"
local Rect = require "lib.simple_ui.rect"
local UI_SCALE = require "lib.simple_ui.level.scale"
--- @class SkillButton : UIElement
--- @field hovered boolean
--- @field selected boolean
--- @field onClick function?
--- @field getCooldown function?
--- @field icon? string
local skillButton = setmetatable({}, Element)
skillButton.__index = skillButton
function skillButton:update(dt)
if not self.icon then return end
local mx, my = love.mouse.getPosition()
if self:hitTest(mx, my) then
self.hovered = true
if Tree.controls:isJustPressed("select") then
local cd = self.getCooldown and self.getCooldown() or 0
if cd == 0 then
if self.onClick then self.onClick() end
end
Tree.controls:consume("select")
end
else
self.hovered = false
end
end
function skillButton:draw()
love.graphics.setLineWidth(2)
local cd = self.getCooldown and self.getCooldown() or 0
if not self.icon then
love.graphics.setColor(0.05, 0.05, 0.05)
love.graphics.rectangle("fill", self.bounds.x, self.bounds.y, self.bounds.width, self.bounds.height)
self:drawBorder("inner")
return
end
local quad = icons:pickQuad(self.icon)
love.graphics.push()
love.graphics.translate(self.bounds.x, self.bounds.y)
love.graphics.scale(self.bounds.width / icons.tileSize, self.bounds.height / icons.tileSize)
love.graphics.draw(icons.atlas, quad)
love.graphics.pop()
self:drawBorder("inner")
if self.selected then
love.graphics.setColor(0.3, 1, 0.3, 0.5)
love.graphics.rectangle("fill", self.bounds.x, self.bounds.y, self.bounds.width, self.bounds.height)
elseif self.hovered then
love.graphics.setColor(0.7, 1, 0.7, 0.5)
love.graphics.rectangle("fill", self.bounds.x, self.bounds.y, self.bounds.width, self.bounds.height)
end
if cd > 0 then
love.graphics.setColor(0, 0, 0, 0.5)
love.graphics.rectangle("fill", self.bounds.x, self.bounds.y, self.bounds.width, self.bounds.height)
local font = Tree.fonts:getDefaultTheme():getVariant("headline")
love.graphics.setColor(0, 0, 0)
local t = love.graphics.newText(font, tostring(cd))
love.graphics.draw(t, math.floor(self.bounds.x + 2 + self.bounds.width / 2 - t:getWidth() / 2),
math.floor(self.bounds.y + 2 + self.bounds.height / 2 - t:getHeight() / 2))
love.graphics.setColor(1, 1, 1)
love.graphics.draw(t, math.floor(self.bounds.x + self.bounds.width / 2 - t:getWidth() / 2),
math.floor(self.bounds.y + self.bounds.height / 2 - t:getHeight() / 2))
else
end
love.graphics.setColor(1, 1, 1)
end
--------------------------------------------------------------------------------
--- @class SkillRow : UIElement
--- @field characterId Id
--- @field selected SkillButton?
--- @field children SkillButton[]
local skillRow = setmetatable({}, Element)
skillRow.__index = skillRow
--- @param characterId Id
--- @return SkillRow
function skillRow.new(characterId)
local t = {
characterId = characterId,
children = {}
}
setmetatable(t, skillRow)
local char = Tree.level.characters[characterId]
char:try(Tree.behaviors.spellcaster, function(behavior)
for i, spell in ipairs(behavior.spellbook) do
local skb = skillButton:new { icon = spell.tag }
skb.onClick = function()
skb.selected = not skb.selected
if t.selected then t.selected.selected = false end
t.selected = skb
if not behavior.cast then
behavior.cast = behavior.spellbook[i]
behavior.state = "casting"
behavior.spellbook[i]:onSelected(char)
else
behavior.state = "idle"
behavior.cast = nil
end
end
skb.getCooldown = function()
return behavior.cooldowns[spell.tag] or 0
end
t.children[i] = skb
end
end)
for i = #t.children + 1, 7, 1 do
t.children[i] = skillButton:new {}
end
return t
end
--- @type love.Canvas
local c;
function skillRow:update(dt)
local iconSize = math.floor(64 * UI_SCALE)
local screenW, screenH = love.graphics.getDimensions()
local padding, margin = 8, 4
local count = #self.children -- слоты под скиллы
self.bounds = Rect {
width = iconSize * count + (count + 1) * margin,
height = iconSize + 2 * margin,
}
self.bounds.y = screenH - self.bounds.height - padding -- отступ снизу
self.bounds.x = screenW / 2 - self.bounds.width / 2
for i, skb in ipairs(self.children) do
skb.bounds = Rect { x = self.bounds.x + margin + (i - 1) * (iconSize + margin), -- друг за другом, включая первый отступ от границы
y = self.bounds.y + margin, height = iconSize, width = iconSize }
skb:update(dt)
end
if not c then
c = love.graphics.newCanvas(self.bounds.width, self.bounds.height)
end
end
function skillRow:draw()
love.graphics.setCanvas({ c, stencil = true })
love.graphics.clear()
love.graphics.setColor(1, 1, 1)
do
--- рисуем в локальных координатах текстурки
love.graphics.push()
love.graphics.translate(-self.bounds.x, -self.bounds.y)
-- сначала иконки скиллов
for _, skb in ipairs(self.children) do
skb:draw()
end
-- маска для вырезов под иконки
love.graphics.setShader(Tree.assets.files.shaders.alpha_mask)
love.graphics.stencil(function()
local mask = Tree.assets.files.masks.rrect32
local maskSize = mask:getWidth()
for _, skb in ipairs(self.children) do
love.graphics.draw(mask, skb.bounds.x, skb.bounds.y, 0,
skb.bounds.width / maskSize, skb.bounds.height / maskSize)
end
end, "replace", 1)
love.graphics.setShader()
-- дальше рисуем панель, перекрывая иконки
love.graphics.setStencilTest("less", 1)
-- шум
love.graphics.setShader(Tree.assets.files.shaders.soft_uniform_noise)
love.graphics.rectangle("fill", self.bounds.x, self.bounds.y, self.bounds.width, self.bounds.height)
love.graphics.setShader()
-- фон
love.graphics.setColor(38 / 255, 50 / 255, 56 / 255)
love.graphics.setBlendMode("multiply", "premultiplied")
love.graphics.rectangle("fill", self.bounds.x, self.bounds.y, self.bounds.width, self.bounds.height)
love.graphics.setBlendMode("alpha")
love.graphics.setStencilTest()
--затенение
self:drawGradientOverlay()
love.graphics.pop()
end
love.graphics.setColor(1, 1, 1)
end
return skillRow.new

View File

@ -1,24 +0,0 @@
--- @class Rect
--- @field x number
--- @field y number
--- @field width number
--- @field height number
local rect = {}
rect.__index = rect
--- @param table {x: number, y: number, width: number, height: number}
function rect.new(table)
local r = {
x = table.x or 0,
y = table.y or 0,
width = table.width or 0,
height = table.height or 0
}
return setmetatable(r, rect)
end
function rect:hasPoint(x, y)
return x >= self.x and x < self.x + self.width and y >= self.y and y < self.y + self.height
end
return rect.new

View File

@ -1,9 +0,0 @@
-- --- @class Sound
-- --- @field source love.Source just a sound
-- sound = {}
-- local function new()
-- return setmetatable({}, sound)
-- end
-- return { new }

View File

@ -1,169 +0,0 @@
local utils = require "lib.utils.utils"
local taskUtils = require "lib.utils.task"
--- Некоторое свойство, что можно наложить на персонажа. Позволяет реализовать такие вещи как DOT'ы
--- и вообще, что душа поживает.
---
--- У каждого эффекта есть тэг и функции триггеры (например, `beforeTurn`, что срабатывает перед началом хода персонажа и так далее).
--- Каждая функция триггер делится на два типа, `before...` и `after...`. Каждая из них возвращает `task`, для того чтобы
--- проиграть анимацию, например. Функции типа `before...` также возвращают по мимо таска некоторое значение, зависящее от
--- конкретной функции.
--- @class Effect
--- @field tag string
local effect = {}
effect.__index = effect
--- Предполагается, что в каждую функцию будет передаваться `Character` (владелец эффекта) и параметр `intensity`, который отвечает за силу эффекта
--- @alias EffectFunc fun(owner: Character, intensity: integer): Task<nil>, nil бред конечно, но иначе всё в жёлтом
--- @alias EffectStatementFunc fun(owner: Character, intensity: integer): Task<nil>, boolean
--- @alias EffectDamageFunc fun(owner: Character, intensity: integer, damage: integer): Task<nil>, integer
--- @alias EffectRegenFunc fun(owner: Character, intensity: integer, amountHp: integer): Task<nil>, integer
--- @alias EffectData { tag: string }
--- Срабатывает перед применением эффекта
---
--- Возвращает, а можно ли применить эффект?
--- @param owner Character
--- @param intensity integer
--- @return Task<nil>, boolean
function effect:beforeBirth(owner, intensity) return taskUtils.fromValue(), true end
--- Срабатывает после применения эффекта
--- @param owner Character
--- @param intensity integer
--- @return Task<nil>
function effect:afterBirth(owner, intensity) return taskUtils.fromValue() end
--- Срабатывает перед смертью владельца эффекта
---
--- Возвращает, умирает ли персонаж?
--- @param owner Character
--- @param intensity integer
--- @return Task<nil>, boolean
function effect:beforeDeath(owner, intensity) return taskUtils.fromValue(), true end
--- Срабатывает после смерти владельца эффекта
--- @param owner Character
--- @param intensity integer
--- @return Task<nil>
function effect:afterDeath(owner, intensity) return taskUtils.fromValue() end
--- Срабатывает перед ходом владельца эффекта
---
--- Возвращает, будет ли персонаж ходить?
--- @param owner Character
--- @param intensity integer
--- @return Task<nil>, boolean
function effect:beforeTurn(owner, intensity) return taskUtils.fromValue(), true end
--- Срабатывает после хода владельца эффекта
--- @param owner Character
--- @param intensity integer
--- @return Task<nil>
function effect:afterTurn(owner, intensity) return taskUtils.fromValue() end
--- Срабатывает перед кастом заклинания владельцем эффекта
---
--- Возвращает, произойдёт ли каст?
--- @param owner Character
--- @param intensity integer
--- @return Task<nil>, boolean
function effect:beforeCast(owner, intensity) return taskUtils.fromValue(), true end
--- Срабатывает после каста заклинания владельцем эффекта
--- @param owner Character
--- @param intensity integer
--- @return Task<nil>
function effect:afterCast(owner, intensity) return taskUtils.fromValue() end
--- Срабатывает перед нанесением урона владельцем эффекта
---
--- Возвращает урон, который собираются нанести
--- @param owner Character
--- @param intensity integer
--- @param damage integer
--- @return Task<nil>, integer
function effect:beforeAttack(owner, intensity, damage) return taskUtils.fromValue(), damage end
--- Срабатывает после нанесения урона владельцем эффекта
--- @param owner Character
--- @param intensity integer
--- @return Task<nil>
function effect:afterAttack(owner, intensity) return taskUtils.fromValue() end
--- Срабатывает перед получением урона владельцем эффекта
---
--- Возвращает урон, который должны получить
--- @param owner Character
--- @param intensity integer
--- @param damage integer
--- @return Task<nil>, integer
function effect:beforeDamage(owner, intensity, damage) return taskUtils.fromValue(), damage end
--- Срабатывает после получения урона владельцем эффекта
--- @param owner Character
--- @param intensity integer
--- @return Task<nil>
function effect:afterDamage(owner, intensity) return taskUtils.fromValue() end
--- Срабатывает перед регенерацией здоровья владельцем эффекта
---
--- Возвращает количество здоровья, которое должно быть восстановлено
--- @param owner Character
--- @param intensity integer
--- @param amountHp integer кол-во хп для регена
--- @return Task<nil>, integer
function effect:beforeRegeneration(owner, intensity, amountHp) return taskUtils.fromValue(), amountHp end
--- Срабатывает после регенерации здоровья владельцем эффекта
--- @param owner Character
--- @param intensity integer
--- @return Task<nil>
function effect:afterRegeneration(owner, intensity) return taskUtils.fromValue() end
--- Функция, что задаёт правила присвоения эффекта
--- @param owner Character
--- @param stacks integer
--- @param intensity integer
function effect:onBirth(owner, stacks, intensity)
local effects = owner:has(Tree.behaviors.effects)
if not effects then return end
-- проверяем на наличие такого же эффекта
if effects.effectsProperties[self.tag] then
local i = 1
while i < #effects.effectsPriority and effects.effectsPriority[i] ~= self.tag do
i = i + 1
end
local ef = table.remove(effects.effectsPriority, i)
effects.effectsPriority[#effects.effectsPriority + 1] = ef
else
effects.effectsPriority[#effects.effectsPriority + 1] = self.tag
end
effects.effectsProperties[self.tag] = {
stacks = stacks,
intensity = intensity
}
end
function effect:update(dt) end
function effect:draw() end
--- дип сравнение эффектов
--- @param other Effect
--- @return boolean
function effect:__eq(other)
return utils.deepComparison(self, other)
end
--- @param data EffectData
--- @return Effect
local function new(data)
local newEffect = setmetatable({
tag = data.tag,
}, effect)
return newEffect
end
return { new = new }

View File

@ -1,148 +0,0 @@
local Query = require "lib.spell.target_query"
local targetTest = require "lib.spell.target_test"
local task = require "lib.utils.task"
local easing = require "lib.utils.easing"
local pf = require "lib.pathfinder"
--- @alias SpellPreview "default" Подсветка возможных целей
--- | "path" Подсветка пути до цели
--- @class Spell
--- @field tag string
--- @field baseCost integer Базовые затраты маны на каст
--- @field baseCooldown integer Базовый кулдаун в ходах
--- @field targetQuery SpellTargetQuery Селектор возможных целей
--- @field targetType SpellPreview Вид превью во время каста
--- @field distance? integer Сторона квадрата с центром в позиции кастера, в пределах которого должна находиться цель, либо отсутствие ограничения
--- @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): Task<nil>? Вызывается в момент каста, изменяет мир.
local spell = {}
spell.__index = spell
spell.tag = "spell_base"
spell.baseCost = 1
spell.baseCooldown = 1
spell.targetQuery = Query(targetTest.any)
spell.targetType = "default"
--- Вызывается, когда игрок выбирает спелл на панели заклинаний
--- @param caster Character
function spell:onSelected(caster)
self.targets = self.targetQuery:asSet(caster)
self.tSize = 0.67 -- анимация появления таргетов
task.tween(self, { tSize = 1 }, 200, easing.easeOutQuad)
end
function spell:update(caster, dt)
if self.targetType == "path" then
local charPos = caster:has(Tree.behaviors.positioned).position:floor()
--- @type Vec3
local mpos = Tree.level.camera:toWorldPosition(Vec3 { love.mouse.getX(), love.mouse.getY() }):floor()
if self.targetQuery.test(caster, mpos) then
self.path = require "lib.pathfinder" (charPos, mpos)
else
self.path = nil
end
end
end
local icons = require("lib.utils.sprite_atlas").load(Tree.assets.files.overlay_icons)
function spell:draw()
if self.targetType == "path" then
local path = self.path --[[@as Deque?]]
if not path then return end
--- Это отрисовка пути персонажа к мышке
Tree.level.render:enqueue(Tree.level.render.LAYERS.OVERLAY, 0, function()
local i = 0
path:pop_front()
for p in path:values() do
i = i + 1
local s = 1 / Tree.level.camera.pixelsPerMeter
local quad = i > self.distance and icons:pickQuad('dev_path_closed') or icons:pickQuad('dev_path')
love.graphics.draw(icons.atlas, quad, p.x, p.y, 0, s, s)
end
love.graphics.setColor(1, 1, 1)
end)
else
Tree.level.render:enqueue(Tree.level.render.LAYERS.OVERLAY, 0, function()
love.graphics.setColor(1, 1, 1, 0.5)
for _, p in pairs(self.targets) do
local s = self.tSize / Tree.level.camera.pixelsPerMeter
local quad = icons:pickQuad('dev_target')
love.graphics.draw(icons.atlas, quad, p.x + 0.5 - self.tSize / 2, p.y + 0.5 - self.tSize / 2, 0, s, s)
end
love.graphics.setShader()
love.graphics.setColor(1, 1, 1)
end)
end
end
function spell:cast(caster, target) return task.fromValue() end
--- Конструктор [Spell]
--- @param data {tag: string, baseCost: integer, baseCooldown: integer, targetQuery: SpellTargetQuery?, targetType: SpellPreview?, distance: integer?, onCast: fun(caster: Character, target: Vec3): Task<nil>?}
--- @return Spell
function spell.new(data)
local newSpell = setmetatable({
tag = data.tag,
baseCost = data.baseCost,
baseCooldown = data.baseCooldown,
targetQuery = data.targetQuery,
targetType = data.targetType,
distance = data.distance
}, spell)
newSpell.targetQuery = (newSpell.distance and newSpell.targetType ~= "path")
and newSpell.targetQuery:intersect(Query(targetTest.distance(newSpell.distance)))
or newSpell.targetQuery
function newSpell:cast(caster, target)
if caster:try(Tree.behaviors.spellcaster, function(spellcaster) -- проверка на кулдаун
return (spellcaster.cooldowns[self.tag] or 0) > 0
end) then
return
end
if not self.targetQuery.test(caster, target) then return end -- проверка корректности цели
if self.targetType == "path" then
-- дополнительное условие для спеллов с путями (количество шагов)
if not caster:has(Tree.behaviors.tiled) or not caster:has(Tree.behaviors.positioned) then return end
local i = 1
for _ in pf(caster:has(Tree.behaviors.positioned).position, target):values() do
if i > self.distance + 1 then return end -- учитывается начальная точка, где находится кастер
i = i + 1
end
end
-- проверка на достаточное количество маны
if caster:try(Tree.behaviors.stats, function(stats)
return stats.mana < self.baseCost
end) then
return
end
-- проверка на возможность каста
if not caster:try(Tree.behaviors.effects, function(effects)
return effects:beforeCast()
end) then
return
end
caster:try(Tree.behaviors.stats, function(stats)
stats.mana = stats.mana - self.baseCost
end)
caster:try(Tree.behaviors.spellcaster, function(spellcaster)
spellcaster.cooldowns[self.tag] = self.baseCooldown
end)
return data.onCast(caster, target)
end
return newSpell
end
return spell

View File

@ -1,67 +0,0 @@
--- Тип, отвечающий за выбор и фильтрацию подходящих тайлов как цели спелла
--- теория множеств my beloved?
--- @class SpellTargetQuery
local query = {}
query.__index = query
--- Проверяет координаты на соответствие внутреннему условию
--- @param caster Character
--- @param position Vec3
--- @return boolean
function query.test(caster, position)
return true
end
--- Объединение
--- @param q SpellTargetQuery
function query:join(q)
return setmetatable({
test = function(caster, pos)
return self.test(caster, pos) or q.test(caster, pos)
end
}, query)
end
--- Пересечение
--- @param q SpellTargetQuery
function query:intersect(q)
return setmetatable({
test = function(caster, pos)
return self.test(caster, pos) and q.test(caster, pos)
end
}, query)
end
--- Исключение (не коммутативное, "те, что есть в query, но нет в q")
--- @param q SpellTargetQuery
function query:exclude(q)
return setmetatable({
test = function(caster, pos)
return self.test(caster, pos) and not q.test(caster, pos)
end
}, query)
end
--- Находит все соответствующие условиям координаты тайлов и возвращает их в виде списка
--- @param caster Character
--- @return Vec3[]
function query:asSet(caster)
--- @TODO: оптимизировать и брать не всю карту для выборки
local res = {}
for _, tile in pairs(Tree.level.tileGrid.__grid) do
if self.test(caster, tile.position) then
table.insert(res, tile.position)
end
end
return res
end
--- @param test SpellTargetTest
local function new(test)
return setmetatable({
test = test
}, query)
end
return new

View File

@ -1,27 +0,0 @@
--- @alias SpellTargetTest fun(caster: Character, targetPosition: Vec3) : boolean
return {
-- любой тайл
any = function() return true end,
-- тайл, где находится кастующий
caster = function(caster, targetPosition)
local targetCharacterId = Tree.level.characterGrid:get(targetPosition)
return caster.id == targetCharacterId
end,
-- тайл, где находится любой персонаж
character = function(caster, targetPosition)
local targetCharacterId = Tree.level.characterGrid:get(targetPosition)
return not not targetCharacterId
end,
-- тайл в пределах окружности в нашей кривой метрике
--- @param radius number
distance = function(radius)
return function(caster, targetPosition)
return caster:try(Tree.behaviors.positioned, function(p)
local dist = math.max(math.abs(p.position.x - targetPosition.x),
math.abs(p.position.y - targetPosition.y))
return dist <= radius
end)
end
end
}

View File

@ -7,125 +7,146 @@
--- --TODO: каждый каст должен возвращать объект, который позволит отследить момент завершения анимации спелла
--- Да, это Future/Promise/await/async
local task = require 'lib.utils.task'
local spell = require 'lib.spell.spell'
local targetTest = require 'lib.spell.target_test'
local Query = require "lib.spell.target_query"
local easing = require "lib.utils.easing"
local AnimationNode = require "lib.animation_node"
local walk = spell.new {
tag = "dev_move",
targetType = "path",
baseCooldown = 1,
baseCost = 2,
targetQuery = Query(targetTest.any):exclude(Query(targetTest.character)),
distance = 3,
onCast = function(caster, target)
local initialPos = caster:has(Tree.behaviors.positioned).position:floor()
local path = require "lib.pathfinder" (initialPos, target)
path:pop_front()
if path:is_empty() then
print("[Walk]: the path is empty", initialPos, target)
return
end
--- @class Spell Здесь будет много бойлерплейта, поэтому тоже понадобится спеллмейкерский фреймворк, который просто вернет готовый Spell
--- @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
local sprite = caster:has(Tree.behaviors.sprite)
assert(sprite, "[Walk]", "WTF DUDE WHERE'S YOUR SPRITE")
if not sprite then
return
end
function spell:update(caster, dt) end
return caster:has(Tree.behaviors.tiled):followPath(path)
function spell:draw() end
function spell:cast(caster, target) return true end
local walk = setmetatable({
--- @type Deque
path = nil
}, spell)
function walk:cast(caster, target)
if not caster:try(Tree.behaviors.stats, function(stats)
return stats.mana >= 2
end) then
return false
end
}
local regenerateMana = spell.new {
tag = "dev_mana",
baseCooldown = 2,
baseCost = 0,
targetQuery = Query(targetTest.caster),
distance = 0,
onCast = function(caster, target)
caster:try(Tree.behaviors.stats, function(stats)
stats.mana = 10
stats.initiative = stats.initiative + 10
end)
local path = self.path
path:pop_front()
if path:is_empty() then return false end
local sprite = caster:has(Tree.behaviors.sprite)
local effects = caster:has(Tree.behaviors.effects)
if not sprite or not effects then return end -- и тут возможно на эффекты проверять не стоит
print(caster.id, "has regenerated mana and gained initiative")
for p in path:values() do print(p) end
local light = require "lib/character/character".spawn("Light Effect")
light:addBehavior {
Tree.behaviors.light.new { color = Vec3 { 0.3, 0.3, 0.6 }, intensity = 4 },
Tree.behaviors.positioned.new(caster:has(Tree.behaviors.positioned).position + Vec3 { 0.5, 0.5 }),
}
caster:try(Tree.behaviors.stats, function(stats)
stats.mana = stats.mana - 2
print(stats.mana)
end)
return task.wait {
task.chain(task.tween(light:has(Tree.behaviors.light) --[[@as LightBehavior]],
{ intensity = 1, color = Vec3 {} }, 800, easing.easeInCubic), function()
light:die()
return task.fromValue()
end),
sprite:animate("hurt"),
effects:addEffect("aversionToDeath", 1, 1),
}
local sprite = caster:has(Tree.behaviors.sprite)
if not sprite then return true end
AnimationNode {
function(node) caster:has(Tree.behaviors.map):followPath(path, node) end,
onEnd = function() caster:has(Tree.behaviors.spellcaster):endCast() end,
}:run()
return true
end
function walk:update(caster, dt)
local charPos = caster:has(Tree.behaviors.map).position:floor()
--- @type Vec3
local mpos = Tree.level.camera:toWorldPosition(Vec3 { love.mouse.getX(), love.mouse.getY() }):floor()
self.path = require "lib.pathfinder" (charPos, mpos)
end
function walk:draw()
if not self.path then return end
--- Это отрисовка пути персонажа к мышке
love.graphics.setColor(0.6, 0.75, 0.5)
for p in self.path:values() do
love.graphics.circle("fill", p.x + 0.45, p.y + 0.45, 0.1)
end
}
love.graphics.setColor(1, 1, 1)
end
local attack = spell.new {
tag = "dev_attack",
baseCooldown = 1,
baseCost = 2,
targetQuery = Query(targetTest.character):exclude(Query(targetTest.caster)),
distance = 1,
onCast = function(caster, target)
--- @type Character
local targetCharacterId = Tree.level.characterGrid:get(target)
local targetCharacter = Tree.level.characters[targetCharacterId]
targetCharacter:try(Tree.behaviors.stats, function(stats)
stats.hp = stats.hp - 4
end)
local regenerateMana = setmetatable({}, spell)
local targetEffects = targetCharacter:has(Tree.behaviors.effects)
local sprite = caster:has(Tree.behaviors.sprite)
local targetSprite = targetCharacter:has(Tree.behaviors.sprite)
if not sprite or not targetSprite or not targetEffects then return end -- проверять на эффект может и не стоит
function regenerateMana:cast(caster, target)
caster:try(Tree.behaviors.stats, function(stats)
stats.mana = 10
stats.initiative = stats.initiative + 10
end)
print(caster.id, "has regenerated mana and gained initiative")
local sprite = caster:has(Tree.behaviors.sprite)
if not sprite then return true end
AnimationNode {
function(node)
sprite:animate("hurt", node)
end,
onEnd = function() caster:has(Tree.behaviors.spellcaster):endCast() end
}:run()
caster:try(Tree.behaviors.positioned, function(b) b:lookAt(target) end)
return true
end
return
task.wait {
sprite:animate("attack"),
task.wait {
task.chain(targetCharacter:has(Tree.behaviors.residentsleeper):sleep(500),
function()
local light = require "lib/character/character".spawn("Light Effect")
light:addBehavior {
Tree.behaviors.light.new { color = Vec3 { 0.6, 0.3, 0.3 }, intensity = 4 },
Tree.behaviors.positioned.new(targetCharacter:has(Tree.behaviors.positioned).position + Vec3 { 0.5, 0.5 }),
}
Tree.audio:play(Tree.assets.files.audio.sounds.hurt)
return
task.wait {
task.chain(task.tween(light:has(Tree.behaviors.light) --[[@as LightBehavior]],
{ intensity = 1, color = Vec3 {} }, 1000, easing.easeInCubic), function()
light:die()
return task.fromValue()
end),
targetSprite:animate("hurt"),
targetEffects:addEffect("bleeding", 3, 3)
}
local attack = setmetatable({}, spell)
function attack:cast(caster, target)
if caster:try(Tree.behaviors.map, function(map)
local dist = math.max(math.abs(map.position.x - target.x), math.abs(map.position.y - target.y))
print("dist:", dist)
return dist > 2
end) then
return false
end
caster:try(Tree.behaviors.stats, function(stats)
stats.mana = stats.mana - 2
end)
--- @type Character
local targetCharacterId = Tree.level.characterGrid:get(target)
if not targetCharacterId or targetCharacterId == caster.id then return false end
local targetCharacter = Tree.level.characters[targetCharacterId]
targetCharacter:try(Tree.behaviors.stats, function(stats)
stats.hp = stats.hp - 4
end)
local sprite = caster:has(Tree.behaviors.sprite)
local targetSprite = targetCharacter:has(Tree.behaviors.sprite)
if not sprite or not targetSprite then return true end
AnimationNode {
onEnd = function() caster:has(Tree.behaviors.spellcaster):endCast() end,
children = {
AnimationNode {
function(node)
sprite:animate("attack", node)
end
},
AnimationNode {
function(node)
targetCharacter:has(Tree.behaviors.residentsleeper):sleep(200, node)
end,
children = {
AnimationNode {
function(node)
targetSprite:animate("hurt", node)
end
),
}
}
}
end
}
}
}:run()
return true
end
----------------------------------------
local spellbook = {
local spellbook = {
walk = walk,
regenerateMana = regenerateMana,
attack = attack
@ -136,7 +157,6 @@ local spellbook = {
function spellbook.of(list)
local spb = {}
for i, sp in ipairs(list) do
print(i)
spb[i] = setmetatable({}, { __index = sp })
end
return spb

View File

@ -6,14 +6,11 @@
Tree = {
assets = (require "lib.utils.asset_bundle"):load()
}
Tree.fonts = (require "lib.utils.font_manager"):load("WDXL_Lubrifont_TC"):loadTheme("Roboto_Mono") -- дефолтный шрифт
Tree.panning = require "lib/panning"
Tree.controls = require "lib.controls"
Tree.audio = (require "lib.audio").new(1, 1)
Tree.level = (require "lib.level.level").new("procedural", "flower_plains") -- для теста у нас только один уровень, который сразу же загружен
Tree.behaviors = (require "lib.utils.behavior_loader")("lib/character/behaviors") --- @todo написать нормальную загрузку поведений
-- Tree.audio = (require "lib.audio").new(1, 1)
-- Tree.behaviors.map = require "lib.character.behaviors.map"
-- Tree.behaviors.spellcaster = require "lib.character.behaviors.spellcaster"
-- Tree.behaviors.sprite = require "lib.character.behaviors.sprite"

569
lib/ui/core.lua Normal file
View File

@ -0,0 +1,569 @@
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

58
lib/ui/layout.lua Normal file
View File

@ -0,0 +1,58 @@
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

Some files were not shown because too many files have changed in this diff Show More