From 4431934e6b0ec71e54dc352b84a3e9f0d9501b47 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Tue, 17 Mar 2026 23:09:36 +0300 Subject: [PATCH] Refactor spell target selection to use composable queries Introduce SpellTargetQuery abstraction for flexible target filtering. Replace fixed target types with query-based system supporting union, intersection, and exclusion of target conditions. Update spells accordingly. --- lib/spell/spell.lua | 54 ++++++++++-------------------- lib/spell/target_query.lua | 67 ++++++++++++++++++++++++++++++++++++++ lib/spell/target_test.lua | 13 ++++++++ lib/spellbook.lua | 14 ++++---- 4 files changed, 106 insertions(+), 42 deletions(-) create mode 100644 lib/spell/target_query.lua create mode 100644 lib/spell/target_test.lua diff --git a/lib/spell/spell.lua b/lib/spell/spell.lua index ec162a9..43f8a65 100644 --- a/lib/spell/spell.lua +++ b/lib/spell/spell.lua @@ -1,24 +1,26 @@ ---- @alias SpellTarget "any" Любой тайл ---- | "caster" Сам кастующий ---- | "enemy" Противники ---- | "ally" Союзники ---- | "character" Любой персонаж +local Query = require "lib.spell.target_query" +local targetTest = require "lib.spell.target_test" ---- @class Spell Здесь будет много бойлерплейта, поэтому тоже понадобится спеллмейкерский фреймворк, который просто вернет готовый Spell +--- @alias SpellPreview "default" Подсветка возможных целей +--- | "path" Подсветка пути до цели + +--- @class Spell --- @field tag string --- @field baseCost integer Базовые затраты маны на каст --- @field baseCooldown integer Базовый кулдаун в ходах ---- @field possibleTarget SpellTarget Возможная цель +--- @field targetQuery SpellTargetQuery Селектор возможных целей +--- @field previewType 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.possibleTarget = "any" +local spell = {} +spell.__index = spell +spell.tag = "spell_base" +spell.baseCost = 1 +spell.baseCooldown = 1 +spell.targetQuery = Query(targetTest.any) +spell.previewType = "default" function spell:update(caster, dt) end @@ -26,34 +28,15 @@ function spell:draw() end function spell:cast(caster, target) return end ---- @param caster Character ---- @param spellTarget SpellTarget ---- @param targetPosition Vec3 -local function checkTarget(caster, spellTarget, targetPosition) - --- @TODO имплементировать все варианты SpellTarget - local targetCharacterId = Tree.level.characterGrid:get(targetPosition) - if spellTarget == "caster" then - return targetCharacterId == caster.id - end - if spellTarget == "character" then - return not not targetCharacterId - end - if spellTarget == "enemy" then - return targetCharacterId and targetCharacterId ~= caster.id - end - - return true -end - --- Конструктор [Spell] ---- @param data {tag: string, baseCost: integer, baseCooldown: integer, possibleTarget: SpellTarget, distance: integer?, onCast: fun(caster: Character, target: Vec3): Task} +--- @param data {tag: string, baseCost: integer, baseCooldown: integer, targetQuery: SpellTargetQuery, distance: integer?, onCast: fun(caster: Character, target: Vec3): Task} --- @return Spell function spell.new(data) local newSpell = { tag = data.tag, baseCost = data.baseCost, baseCooldown = data.baseCooldown, - possibleTarget = data.possibleTarget, + targetQuery = data.targetQuery, distance = data.distance } @@ -67,8 +50,7 @@ function spell.new(data) return end - -- проверка корректности цели - if not checkTarget(caster, self.possibleTarget, target) then return end + if not self.targetQuery.test(caster, target) then return end -- проверка корректности цели -- проверка на достаточное количество маны if caster:try(Tree.behaviors.stats, function(stats) diff --git a/lib/spell/target_query.lua b/lib/spell/target_query.lua new file mode 100644 index 0000000..6460c47 --- /dev/null +++ b/lib/spell/target_query.lua @@ -0,0 +1,67 @@ +--- Тип, отвечающий за выбор и фильтрацию подходящих тайлов как цели спелла +--- теория множеств 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 + }, q) +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 + }, q) +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 + }, q) +end + +--- Находит все соответствующие условиям координаты тайлов и возвращает их в виде списка +--- @param caster Character +--- @return Vec3[] +function query:asSet(caster) + --- @TODO: оптимизировать и брать не всю карту для выборки + local res = {} + for _, tile in pairs(Tree.level.tileGrid) 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 diff --git a/lib/spell/target_test.lua b/lib/spell/target_test.lua new file mode 100644 index 0000000..f7646a7 --- /dev/null +++ b/lib/spell/target_test.lua @@ -0,0 +1,13 @@ +--- @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 +} diff --git a/lib/spellbook.lua b/lib/spellbook.lua index 3ce45f1..302338e 100644 --- a/lib/spellbook.lua +++ b/lib/spellbook.lua @@ -7,14 +7,16 @@ --- --TODO: каждый каст должен возвращать объект, который позволит отследить момент завершения анимации спелла --- Да, это Future/Promise/await/async -local task = require 'lib.utils.task' -local spell = require 'lib.spell.spell' +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 walk = setmetatable({ +local walk = setmetatable({ --- @type Deque path = nil }, spell) -walk.tag = "dev_move" +walk.tag = "dev_move" function walk:cast(caster, target) if not caster:try(Tree.behaviors.stats, function(stats) @@ -69,7 +71,7 @@ local regenerateMana = spell.new { tag = "dev_mana", baseCooldown = 2, baseCost = 0, - possibleTarget = "caster", + targetQuery = Query(targetTest.caster), distance = 0, onCast = function(caster, target) caster:try(Tree.behaviors.stats, function(stats) @@ -107,7 +109,7 @@ local attack = spell.new { tag = "dev_attack", baseCooldown = 1, baseCost = 2, - possibleTarget = "enemy", + targetQuery = Query(targetTest.character):exclude(Query(targetTest.caster)), distance = 1, onCast = function(caster, target) --- @type Character