From 73bc8c133bb3045cbc4d299ee2ae8238e5e9bcfc Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 13:50:39 +0200 Subject: [PATCH] feat: add reachableWithin --- src/spells.lua | 158 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 src/spells.lua diff --git a/src/spells.lua b/src/spells.lua new file mode 100644 index 0000000..f11f978 --- /dev/null +++ b/src/spells.lua @@ -0,0 +1,158 @@ +-- Mana-fuelled spellbook. Spells are learned permanently from tomes (see +-- Items.TOME) and cast by spending mana from a pool that regenerates each +-- player turn. Targeted spells additionally need line of sight and range, +-- checked here before any mana is spent so a bad cast is free. Damage and +-- teleport spells reach into the game the same way potion/scroll effects do. + +local Los = require("los") +local Status = require("status") +local Skills = require("skills") +local Resistances = require("resistances") +local Conductivity = require("conductivity") +local Polymorph = require("polymorph") +local Disease = require("disease") + +local Spells = {} + +local DIRS4 = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } } + +-- Every walkable, unoccupied cell within `radius` orthogonal steps of the +-- caster, excluding the caster's own cell. Used by blink so a short-range +-- teleport only ever lands somewhere the caster could in principle walk to. +local function reachableWithin(game, caster, radius) + local map = game.level.map + local start = map:index(caster.x, caster.y) + local dist = { [start] = 0 } + local queue = { { caster.x, caster.y, start } } + local head = 1 + local cells = {} + while head <= #queue do + local cur = queue[head] + head = head + 1 + local cx, cy, idx = cur[1], cur[2], cur[3] + local d = dist[idx] + if idx ~= start then cells[#cells + 1] = { x = cx, y = cy } end + if d < radius then + for _, dir in ipairs(DIRS4) do + local nx, ny = cx + dir[1], cy + dir[2] + if map:inBounds(nx, ny) and map:isWalkable(nx, ny) then + local nIdx = map:index(nx, ny) + if not dist[nIdx] then + dist[nIdx] = d + 1 + queue[#queue + 1] = { nx, ny, nIdx } + end + end + end + end + end + return cells +end + +Spells.catalog = { + missile = { + name = "magic missile", cost = 3, targeted = true, range = 6, + cast = function(game, caster, target) + local raw = math.floor((6 + (caster.level or 1)) * Skills.spellDamageMultiplier(caster)) + local dmg = Resistances.apply(target, raw, Resistances.ARCANE) + target.hp = target.hp - dmg + if dmg <= 0 then + game.log:add(string.format("a bolt of force washes over %s harmlessly", target.name)) + else + game.log:add(string.format("a bolt of force hits %s for %d", target.name, dmg)) + end + if not target:isAlive() then game:awardKill(target) end + return {} + end, + }, + heal = { + name = "minor heal", cost = 5, targeted = false, + cast = function(_, caster) + local amount = 10 + (caster.level or 1) * 2 + local before = caster.hp + caster.hp = math.min(caster.maxHp, caster.hp + amount) + return { string.format("you mend for %d", caster.hp - before) } + end, + }, + -- Mirrors effects.lua's "cure" potion handler (both lift disease and every + -- Status.HOSTILE debuff) but reachable without needing the item on hand - + -- until now the only way to shed poison/burning/stun/slow/weaken/discord/ + -- confuse/blind was to survive until they expired on their own. + cleanse = { + name = "cleansing ward", cost = 5, targeted = false, + cast = function(_, caster) + local diseaseCured = Disease.cure(caster) + local lifted = Status.cureHostile(caster) + if diseaseCured and #lifted > 0 then + return { string.format("your affliction lifts, and the %s fades", table.concat(lifted, ", ")) } + elseif diseaseCured then + return { "your affliction lifts" } + elseif #lifted > 0 then + return { string.format("the %s fades", table.concat(lifted, ", ")) } + end + return { "you feel a faint tingle, but had nothing to cleanse" } + end, + }, + shield = { + name = "arcane shield", cost = 4, targeted = false, + cast = function(_, caster) + Status.apply(caster, Status.SHIELD, 15, 1) + return { "a shimmering ward surrounds you" } + end, + }, + shock = { + name = "shock bolt", cost = 4, targeted = true, range = 5, + cast = function(game, caster, target) + local raw = math.floor((5 + (caster.level or 1)) * Skills.spellDamageMultiplier(caster)) + local dmg = Conductivity.applyLightning(target, raw) + target.hp = target.hp - dmg + if dmg <= 0 then + game.log:add(string.format("a crackle of lightning washes over %s harmlessly", target.name)) + elseif Conductivity.isWet(target) then + game.log:add(string.format("lightning arcs through the water clinging to %s for %d", target.name, dmg)) + else + game.log:add(string.format("a bolt of lightning strikes %s for %d", target.name, dmg)) + end + if not target:isAlive() then game:awardKill(target) end + return {} + end, + }, + -- Fractures the target's mind for a few turns: while it lasts, the + -- target attacks whatever creature stands nearest it - ally or foe, + -- including the caster's own companion/hireling/thrall if one strays + -- close enough - instead of chasing the caster (see infighting.lua). No + -- direct damage of its own; the value is entirely in redirecting a + -- threat, or turning a cluster of monsters against itself. + discord = { + name = "discord", cost = 5, targeted = true, range = 6, + cast = function(_, _, target) + Status.apply(target, Status.DISCORD, 8, 1) + return { string.format("%s's mind fractures with discord", target.name) } + end, + }, + -- Scrambles the target's own sense of direction for a few turns (see + -- status.lua's Status.CONFUSE, and its mirror in Game:movePlayer/Ai.act) - + -- a lucky roll can send a dangerous chaser stumbling into a wall, a lava + -- pool, or one of its own allies instead of at the caster. + confuse = { + name = "confusing hex", cost = 4, targeted = true, range = 5, + cast = function(_, _, target) + Status.apply(target, Status.CONFUSE, 6, 1) + return { string.format("%s's senses reel with confusion", target.name) } + end, + }, + -- Snuffs the target's sight down to melee range for a few turns (see + -- status.lua's Status.BLIND, folded into Game:effectiveRadius/canSeePlayer) + -- - unlike confuse, which garbles the target's own movement, a blinded + -- monster keeps walking straight at its last known heading and simply + -- stops noticing anything past arm's reach, useful against archers and + -- casters whose whole threat depends on seeing the caster from a distance. + blind = { + name = "veil of shadow", cost = 4, targeted = true, range = 5, + cast = function(_, _, target) + Status.apply(target, Status.BLIND, 6, 1) + return { string.format("a veil of shadow falls over %s's eyes", target.name) } + end, + }, + -- 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