sorcery  Artifact [cfeba0d2de]

Artifact cfeba0d2deabc95c44d54cc8af9a3203cb97b730fbcdddcae2ed3262d0100972:

  • File spell.lua — part of check-in [147592b8e9] at 2020-10-26 03:58:08 on branch trunk — add over-time spellcasting abstraction to enable metamagic and in particular disjunction, add more animations and sound effects, add excavation spell, possibly some others, forget when the last commit was, edit a bunch of magitech to make it subject to the disjunction mechanism (throw up a disjunction aura and waltz right through those force fields bby, wheee), also illumination spells, tweak runeforge and rune frequence to better the balance and also limit player frustration, move some math functions into their own library category, various tweaks and bugfixes, probably other shit i don't remember (user: lexi, size: 12913) [annotate] [blame] [check-ins using]

-- 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 = function(...) sorcery.log('spell',...) end

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

-- 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]
		print('releasing against',si,t)
		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)
		local t if type(when) == 'number' then
			t = s.duration * when
		else
			t = (s.duration * (when.whence or 0)) + when.secs
		end
		if t then return math.min(s.duration,math.max(0,t)) end

		log('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('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('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