-- this file is used to track active spells, for the purposes of metamagic
-- like disjunction. a "spell" is a table consisting of several properties:
-- a "disjoin" function that, if present, is called when the spell is
-- abnormally interrupted, a "terminate" function that calls when the spell
-- completes, a "duration" property specifying how long the spell lasts in
-- seconds, and a "timeline" table that maps floats to functions called at
-- specific points during the function's activity. it can also have a
-- 'delay' property that specifies how long to wait until the spell sequence
-- starts; the spell is however still vulnerable to disjunction during this
-- period. there can also be a sounds table that maps timepoints to sounds
-- the same way timeline does. each value should be a table of form {sound,
-- where}. the `where` field may contain one of 'pos', 'caster', 'subjects', or
-- a vector specifying a position in the world, and indicate where the sound
-- should be played. by default 'caster' and 'subjects' sounds will be attached
-- to the objects they reference; 'attach=false' can be added to prevent this.
-- by default sounds will be faded out quickly when disjunction occurs; this
-- can be controlled by the fade parameter.
--
-- spells can have various other properties, for instance 'disjunction', which
-- when true prevents other spells from being cast in its radius while it is
-- still in effect. disjunction is absolute; there is no way to overwhelm it.
--
-- the spell also needs at least one of "anchor", "subjects", or "caster".
-- * an anchor is a position that, in combination with 'range', specifies the area
-- where a spell is in effect; this is used for determining whether it
-- is affected by a disjunction that incorporates part of that position
-- * subjects is an array of individuals affected by the spell. when
-- disjunction is cast on one of them, they will be removed from the
-- table. each entry should have at least a 'player' field; they can
-- also contain any other data useful to the spell. if a subject has
-- a 'disjoin' field it must be a function called when they are removed
-- from the list of spell targets.
-- * caster is the individual who cast the spell, if any. a disjunction
-- against their person will totally disrupt the spell.
local log = sorcery.logger 'spell'
-- FIXME saving object refs is iffy, find a better alternative
sorcery.spell = {
active = {}
}
local get_spell_positions = function(spell)
local spellpos
if spell.anchor then
spellpos = {spell.anchor}
elseif spell.attach then
if spell.attach == 'caster' then
spellpos = {spell.caster:get_pos()}
elseif spell.attach == 'subjects' or spell.attach == 'both' then
if spell.attach == 'both' then
spellpos = {spell.caster:get_pos()}
else spellpos = {} end
for _,s in pairs(spell.subjects) do
spellpos[#spellpos+1] = s.player:get_pos()
end
else spellpos = {spell.attach:get_pos()} end
else assert(false) end
return spellpos
end
local inspellrange = function(spell,pos,range)
local spellpos = get_spell_positions(spell)
for _,p in pairs(spellpos) do
if vector.equals(pos,p) or
(range and sorcery.lib.math.vdcomp(range, pos,p)<=1) or
(spell.range and sorcery.lib.math.vdcomp(spell.range,p,pos)<=1) then
return true
end
end
return false
end
sorcery.spell.probe = function(pos,range)
-- this should be called before any magical effects are performed.
-- other mods can overlay their own functions to e.g. protect areas
-- from magic
local result = {}
-- first we need to check if any active injunctions are in effect
-- injunctions are registered as spells with a 'disjunction = true'
-- property
for id,spell in pairs(sorcery.spell.active) do
if not (spell.disjunction and (spell.anchor or spell.attach)) then goto skip end
if inspellrange(spell,pos,range) then
result.disjunction = true
break
end
::skip::end
-- at some point we might also check to see if certain anti-magic
-- blocks are nearby or suchlike. there should also be regions where
-- perhaps certain kinds of magic are unusually empowered or weak
-- (perhaps drawing on leyline affinity)
return result
end
sorcery.spell.disjoin = function(d)
local spells,targets = {},{}
if d.spell then spells = {{v=d.spell}}
elseif d.target then targets = {d.target}
elseif d.pos then -- find spells anchored here and people in range
for id,spell in pairs(sorcery.spell.active) do
if not spell.anchor then goto skip end -- this intentionally excludes attached spells
if inspellrange(spell,d.pos,d.range) then
spells[#spells+1] = {v=spell,i=id}
end
::skip::end
local ppl = minetest.get_objects_inside_radius(d.pos,d.range)
if #targets == 0 then targets = ppl else
for _,p in pairs(ppl) do targets[#targets+1] = p end
end
end
-- iterate over targets to remove from any spell's influence
for _,t in pairs(targets) do
for id,spell in pairs(sorcery.spell.active) do
if spell.caster == t then spells[#spells+1] = {v=spell,i=id} else
for si, sub in pairs(spell.subjects) do
if sub.player == t then
if sub.disjoin then sub:disjoin(spell) end
spell.release_subject(si)
break
end
end
end
end
end
-- spells to disjoin entirely
for _,s in pairs(spells) do local spell = s.v
if spell.disjoin then spell:disjoin() end
spell.abort()
if s.i then sorcery.spell.active[s.i] = nil else
for k,v in pairs(sorcery.spell.active) do
if v == spell then sorcery.spell.active[k] = nil break end
end
end
end
end
sorcery.spell.ensorcelled = function(player,spell)
if type(player) == 'string' then player = minetest.get_player_by_name(player) end
for _,s in pairs(sorcery.spell.active) do
if spell and (s.name ~= spell) then goto skip end
for _,sub in pairs(s.subjects) do
if sub.player == player then return s end
end
::skip::end
return false
end
sorcery.spell.each = function(player,spell)
local idx = 0
return function()
repeat idx = idx + 1
local sp = sorcery.spell.active[idx]
if sp == nil then return nil end
if spell == nil or sp.name == spell then
for _,sub in pairs(sp.subjects) do
if sub.player == player then return sp end
end
end
until idx >= #sorcery.spell.active
end
end
-- when a new spell is created, we analyze it and make the appropriate calls
-- to minetest.after to queue up the events. each job returned needs to be
-- saved in 'jobs' so they can be canceled if the spell is disjoined. no polling
-- necessary :D
sorcery.spell.cast = function(proto)
local s = table.copy(proto)
s.jobs = s.jobs or {} s.vfx = s.vfx or {} s.sfx = s.sfx or {}
s.impacts = s.impacts or {} s.subjects = s.subjects or {}
s.delay = s.delay or 0
s.visual = function(def,subj)
s.vfx[#s.vfx + 1] = {
handle = minetest.add_particlespawner(def);
subject = subj;
}
end
s.visual_caster = function(def) -- convenience function
local d = table.copy(def)
d.attached = s.caster
s.visual(d)
end
s.visual_subjects = function(def)
for _,sub in pairs(s.subjects) do
local d = table.copy(def)
d.attached = sub.player
s.visual(d,sub)
end
end
s.affect = function(i)
local etbl = {}
for _,sub in pairs(s.subjects) do
local eff = late.new_effect(sub.player, i)
local rec = {
effect = eff;
subject = sub;
}
s.impacts[#s.impacts+1] = rec
etbl[#etbl+1] = rec
end
return etbl
end
s.abort = function()
for _,j in ipairs(s.jobs) do j:cancel() end
for _,v in ipairs(s.vfx) do minetest.delete_particlespawner(v.handle) end
for _,i in ipairs(s.sfx) do s.silence(i) end
for _,i in ipairs(s.impacts) do i.effect:stop() end
end
s.release_subject = function(si)
local t = s.subjects[si]
for _,f in pairs(s.sfx) do if f.subject == t then s.silence(f) end end
for _,f in pairs(s.impacts) do if f.subject == t then f.effect:stop() end end
for _,f in pairs(s.vfx) do
if f.subject == t then minetest.delete_particlespawner(f.handle) end
end
s.subjects[si] = nil
end
local interpret_timespec = function(when)
if when == nil then return 0 end
local t if type(when) == 'number' then
t = s.duration * when
else
t = (s.duration * (when.whence or 0)) + (when.secs or 0)
end
if t then return math.min(s.duration,math.max(0,t)) end
log.err('invalid timespec ' .. dump(when))
return 0
end
s.queue = function(when,fn)
local elapsed = s.starttime and minetest.get_server_uptime() - s.starttime or 0
local timepast = interpret_timespec(when)
if not timepast then timepast = 0 end
local timeleft = s.duration - timepast
local howlong = (s.delay + timepast) - elapsed
if howlong < 0 then
log.err('cannot time-travel! queue() called with `when` specifying timepoint that has already passed')
howlong = 0
end
s.jobs[#s.jobs+1] = minetest.after(howlong, function()
-- this is somewhat awkward. since we're using a non-polling approach, we
-- need to find a way to account for a caster or subject walking into an
-- existing antimagic field, or someone with an existing antimagic aura
-- walking into range of the anchor. so every time a spell effect would
-- take place, we first check to see if it's in range of something nasty
if not s.disjunction and -- avoid self-disjunction
((s.caster and sorcery.spell.probe(s.caster:get_pos()).disjunction) or
(s.anchor and sorcery.spell.probe(s.anchor,s.range).disjunction)) then
sorcery.spell.disjoin{spell=s}
else
if not s.disjunction then for _,sub in pairs(s.subjects) do
local sp = sub.player:get_pos()
if sorcery.spell.probe(sp).disjunction then
sorcery.spell.disjoin{pos=sp}
end
end end
-- spell still exists and we've removed any subjects who have been
-- affected by a disjunction spell, it's now time to actually perform
-- the queued-up action
fn(s,timepast,timeleft)
end
end)
end
s.play_now = function(spec)
local specs, stbl = {}, {}
local addobj = function(obj,sub)
if spec.attach == false then specs[#specs+1] = {
spec = { pos = obj:get_pos() };
obj = obj, subject = sub;
} else specs[#specs+1] = {
spec = { object = obj };
obj = obj, subject = sub;
} end
end
if spec.where == 'caster' then addobj(s.caster)
elseif spec.where == 'subjects' then
for _,sub in pairs(s.subjects) do addobj(sub.player,sub) end
elseif spec.where == 'pos' then specs[#specs+1] = { spec = {pos = s.anchor} }
else specs[#specs+1] = { spec = {pos = spec.where} } end
for _,sp in pairs(specs) do
sp.spec.gain = spec.gain
local so = {
handle = minetest.sound_play(spec.sound, sp.spec, spec.ephemeral);
ctl = spec;
-- object = sp.obj;
subject = sp.subject;
}
stbl[#stbl+1] = so
s.sfx[#s.sfx+1] = so
end
return stbl
end
s.play = function(when,spec)
s.queue(when, function()
local snds = s.play_now(spec)
if spec.stop then
s.queue(spec.stop, function()
for _,snd in pairs(snds) do s.silence(snd) end
end)
end
end)
end
s.silence = function(sound)
if sound.ctl.fade == 0 then minetest.sound_stop(sound.handle)
else minetest.sound_fade(sound.handle,sound.ctl.fade or 1,0) end
end
local startqueued, termqueued = false, false
local myid = #sorcery.spell.active+1
s.cancel = function()
s.abort()
sorcery.spell.active[myid] = nil
end
local perform_disjunction_calls = function()
local positions = get_spell_positions(s)
for _,p in pairs(positions) do
sorcery.spell.disjoin{pos = p, range = s.range}
end
end
if s.timeline then
for when_raw,what in pairs(s.timeline) do
local when = interpret_timespec(when_raw)
if s.delay == 0 and when == 0 then
startqueued = true
if s.disjunction then perform_disjunction_calls() end
what(s,0,s.duration)
elseif when_raw == 1 or when >= s.duration then -- avoid race conditions
if not termqueued then
termqueued = true
s.queue(1,function(s,...)
what(s,...)
if s.terminate then s:terminate() end
sorcery.spell.active[myid] = nil
end)
else
log.warn('multiple final timeline events not possible, ignoring')
end
elseif when == 0 and s.disjunction then
startqueued = true
s.queue(when_raw,function(...)
perform_disjunction_calls()
what(...)
end)
else s.queue(when_raw,what) end
end
end
if s.intervals then
for _,int in pairs(s.intervals) do
local timeleft = s.duration - interpret_timespec(int.after)
local iteration, itercount = 0, timeleft / int.period
local function iterate(lastreturn)
iteration = iteration + 1
local nr = int.fn {
spell = s;
iteration = iteration;
iterationcount = itercount;
timeleft = timeleft;
timeelapsed = s.duration - timeleft;
lastreturn = lastreturn;
}
if nr ~= false and iteration < itercount then
s.jobs[#s.jobs+1] = minetest.after(int.period,
function() iterate(nr) end)
end
end
if int.after
then s.queue(int.after, iterate)
else s.queue({whence=0, secs=s.period}, iterate)
end
end
end
if s.disjunction and not startqueued then
if s.delay == 0 then perform_disjunction_calls() else
s.queue(0, function() perform_disjunction_calls() end)
end
end
if s.sounds then
for when,what in pairs(s.sounds) do s.play(when,what) end
end
sorcery.spell.active[myid] = s
if not termqueued then
s.jobs[#s.jobs+1] = minetest.after(s.delay + s.duration, function()
if s.terminate then s:terminate() end
sorcery.spell.active[myid] = nil
end)
end
s.starttime = minetest.get_server_uptime()
return s
end
minetest.register_on_dieplayer(function(player)
sorcery.spell.disjoin{target=player}
end)