sorcery  spell.lua at tip

File spell.lua from the latest check-in


-- 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)