From 7516aae0e4d79cd0681b40cf5a71246b8ebab2d3 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 14:12:50 +0200 Subject: [PATCH] feat: add Spells.knows and Spells.learn to spells --- src/spells.lua | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/src/spells.lua b/src/spells.lua index f11f978..6a24034 100644 --- a/src/spells.lua +++ b/src/spells.lua @@ -156,3 +156,94 @@ Spells.catalog = { -- Saps the target's fighting strength for a few turns (see status.lua's -- Status.WEAKEN). Until now WEAKEN only ever flowed toward the player - a -- shaman's ranged curse, a cursed shrine's backfired prayer - with no way + -- for the player to return the favor against a monster; this spell is + -- that missing direction, shaped exactly like confuse/blind above (no + -- direct damage, just a debuff riding on Status.apply). + weaken = { + name = "withering hex", cost = 4, targeted = true, range = 5, + cast = function(_, _, target) + Status.apply(target, Status.WEAKEN, 6, 1) + return { string.format("%s's strength withers", target.name) } + end, + }, + -- Rewrites the target's body outright rather than damaging or debuffing + -- it (see polymorph.lua). A pricier cast than any other targeted spell + -- here, since a lucky roll can turn a real threat into something the + -- caster can safely ignore - and an unlucky one can just as easily make + -- it worse, the same double-edged risk a shrine's backfired prayer carries. + polymorph = { + name = "polymorph", cost = 9, targeted = true, range = 5, + cast = function(game, _, target) + local oldName = target.name + local pool = Polymorph.pool(game.depth) + local spec = Polymorph.transform(target, game.rng, pool) + if not spec then + return { string.format("the magic washes over %s, changing nothing", oldName) } + end + return { string.format("%s convulses and reforms into a %s", oldName, spec.name) } + end, + }, + -- A self-buff shaped exactly like shield, but see status.lua's + -- Status.LEVITATE for the payoff: for as long as it lasts, the caster + -- drifts clear of lava, a chasm's drop, an ice tile's slide, and any + -- ground-pressure trap (Game:checkTerrainFor/checkTrapFor) instead of + -- soaking a hit. + levitate = { + name = "levitation", cost = 5, targeted = false, + cast = function(_, caster) + Status.apply(caster, Status.LEVITATE, 20, 1) + return { "you rise off the ground" } + end, + }, + blink = { + name = "blink", cost = 6, targeted = false, + cast = function(game, caster) + local free = {} + for _, cell in ipairs(reachableWithin(game, caster, 5)) do + if not game.level:entityAt(cell.x, cell.y) then + free[#free + 1] = cell + end + end + if #free == 0 then return { "there is nowhere to blink" } end + local pick = free[game.rng:range(1, #free)] + caster.x, caster.y = pick.x, pick.y + return { "you blink through space" } + end, + }, +} + +function Spells.knows(caster, key) + for _, known in ipairs(caster.spellsKnown) do + if known == key then return true end + end + return false +end + +function Spells.learn(caster, key) + if not Spells.catalog[key] then return false end + if Spells.knows(caster, key) then return false end + caster.spellsKnown[#caster.spellsKnown + 1] = key + return true +end + +-- Attempts to cast `key` on behalf of `caster`. Returns (true, lines) on a +-- successful cast, or (false, lines) with an explanatory line and no state +-- changed - no mana spent, no cooldown, nothing - on failure. +function Spells.cast(game, caster, key, target) + local spell = Spells.catalog[key] + if not spell then return false, { "you don't know that spell" } end + if not Spells.knows(caster, key) then return false, { "you haven't learned that spell" } end + if caster.mana < spell.cost then return false, { "not enough mana" } end + if spell.targeted then + if not target or not target:isAlive() then return false, { "no target" } end + local dist = math.max(math.abs(target.x - caster.x), math.abs(target.y - caster.y)) + if dist > spell.range then return false, { "out of range" } end + if not Los.clear(game.level.map, caster.x, caster.y, target.x, target.y) then + return false, { "no clear shot" } + end + end + caster.mana = caster.mana - spell.cost + return true, spell.cast(game, caster, target) or {} +end + +return Spells