late/effects.lua
2018-09-05 20:07:20 +02:00

706 lines
19 KiB
Lua

--[[
Late library for Minetest - Library adding temporary effects.
(c) Pierre-Yves Rollo
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Lesser General Public License as published
by the Free Software Foundation, either version 2.1 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
--]]
-- Mod internal data
--------------------
-- Name of the players meta in which is saved effects data
local save_meta_key = "late:active_effects"
-- Interval in seconds at which effects data is saved into players meta (only
-- usefull in case of abdnormal server termination)
-- TODO:Move into a mod settings
local save_interval = 1
-- Interval in seconds of ABM checking for targets being near nodes with effect
-- TODO:Move into a mod settings
local abm_interval = 1
-- Effect phases
----------------
local phase_raise = 1
-- Effects starts in this phase. It stops after effect.raise seconds or when
-- effect conditions are no longer fulfilled. Intensity of effect grows from 0
-- to 1 during this phase
local phase_still = 2
-- Once raise phase is completed, effects enters the still phase. Intensity is
-- full and the phases lasts as long as conditions are fulfilled.
local phase_fall = 3
-- When conditions are no longer fulfilled, effect enters fall phase. This
-- phase lasts effect.fall seconds (if 0, effects gets to next phase
-- instantly).
local phase_end = 4
-- This is the terminal phase. Effect in this phase are deleted.
-- Helper
---------
local function calliffunc(fct, ...)
if type(fct) == 'function' then
return fct(...)
end
end
local function nvl(var, val)
if var == nil then return val else return var end
end
-- Targets
----------
local target_data = {}
-- Automatically clean unused data by using a weak key table
setmetatable(target_data, {__mode = "k"})
-- Return data storage for target
local function data(target)
if target_data[target] then return target_data[target] end
-- Create a data entry for new target
local target_type, target_desc
if target.is_player and target:is_player() then
target_type = 'player'
target_desc = 'Player "'..target:get_player_name()..'"'
elseif target.get_luaentity then
local entity = target:get_luaentity()
if entity and entity.type then
target_type = 'mob'
target_desc = 'Mob "'..entity.name..'"'
end
end
if target_type then
target_data[target] = {
effects={}, impacts={},
type = target_type, string = target_desc,
defaults = {} }
end
return target_data[target]
end
-- Explose data function to API
late.get_storage_for_target = data
-- Item effects
---------------
function late.set_equip_effect(target, item_name)
local definition = minetest.registered_items[item_name] and
minetest.registered_items[item_name].effect_equip or nil
if definition then
definition.id = 'equip:'..item_name
local effect = late.get_effect_by_id(target, definition.id)
if effect == nil then
effect = late.new_effect(target, definition)
effect:set_conditions({ equiped_with = item_name })
effect:start()
end
-- Restart effect in case it was in fall phase
effect:restart()
end
end
function late.on_use_tool_callback(itemstack, user, pointed_thing)
local def = minetest.registered_items[itemstack:get_name()]
if def then
if def.effect_use_on and pointed_thing.type == "object" then
--TODO: if using Id, should restart existing item
late.new_effect(pointed_thing.ref, def.effect_use_on)
end
if def.effect_use then
late.new_effect(user, def.effect_use)
end
end
end
-- Node effects
---------------
-- ABM to detect if player gets nearby a nodes with effect (belonging to
-- group:effect_trigger and having an effect in node definition)
minetest.register_abm({
label = "late player detection",
nodenames="group:effect_trigger",
interval=abm_interval,
chance=1,
catch_up=true,
action = function(pos, node)
local ndef = minetest.registered_nodes[node.name]
local effect_def = ndef.effect_near
if effect_def then
for _, target in pairs(minetest.get_objects_inside_radius(
pos, (effect_def.distance or 0) + (effect_def.spread or 0))) do
effect_def.id = 'near:'..node.name
local effect = late.get_effect_by_id(target, effect_def.id)
if effect == nil then
effect = late.new_effect(target, effect_def)
if effect == nil then return end
effect:set_conditions({ near_node = {
node_name = node.name,
radius = effect_def.distance,
active_pos = {}
} } )
end
-- Register node position as an active position
effect.conditions.near_node
.active_pos[minetest.hash_node_position(pos)] = true
-- Restart effect in case it was in fall phase
effect:restart()
end
end
end,
})
-- Effect object
----------------
local Effect = {}
Effect.__index = Effect
--- new
-- Creates an effect and affects it to a target
-- @param target Target of the effect (player, mob or world)
-- @param effect_definition Definition of the effect
-- @return effect affecting the player
--
-- effect_definition = {
-- groups = {}, -- Permet d'agir de l'exterieur sur l'effet
-- impacts = {}, -- Impacts effect has (pair of impact name / impact parameters
-- raise = x, -- Time it takes in seconds to raise to its full intensity
-- fall = x, -- Time it takes to fall, after end to no intensity
-- duration = x, -- Duration of maximum intensity in seconds (default always)
-- distance = x, -- In case of effect associated to a node, distance of action
-- stopondeath = true, --?
--}
-- impacts = { impactname = parameter, impactname2 = { param1, param2 }, ... }
function Effect:new(target, definition)
-- Verify target
local data = data(target)
if data == nil then return nil end
-- Check for existing ID
if definition.id and data.effects[definition.id] then
minetest.log('error', '[late] Effect ID "'..definition.id..
'" already exists for '..data.string..'.')
return nil
end
-- Instanciation
self = table.copy(definition)
setmetatable(self, Effect)
-- Default values
self.elapsed_time = self.elapsed_time or 0
self.time_intensity = self.time_intensity or 0
self.phase = self.phase or phase_raise
self.target = target
-- Duration condition
if self.duration then
self:set_conditions( { duration = self.duration } ) -- - ( effect.fall or 0 )
end
-- Affect to target
if self.id then
data.effects[self.id] = self
else
table.insert(data.effects, self)
end
-- Create impacts
local impacts = self.impacts
self.impacts = {}
if impacts then
for type_name, params in pairs(impacts) do
self:add_impact(type_name, params)
end
end
return self
end
-- Explose new method to API
function late.new_effect(...)
return Effect:new(...)
end
-- TODO: Clip value to 0-1
function Effect:change_intensity(intensity)
if self.intensity ~= intensity then
self.intensity = intensity
self.changed = true
end
end
--- add_impact
-- Add a new impact to effect
-- @param type_name Impact type name
-- @param params Parameters of the impact
function Effect:add_impact(type_name, params)
local data = data(self.target)
local impact_type = late.get_impact_type(data.type, type_name)
-- Impact type unknown or not for this type of target
if not impact_type then return end
-- Add impact to effect
if type(params) == 'table' then
self.impacts[type_name] = table.copy(params)
else
self.impacts[type_name] = { params }
end
-- Link effect to target impact
local impact = data.impacts[type_name]
if not impact then
-- First effect having this impact on target : create impact
impact = {
vars = table.copy(impact_type.vars or {}),
params = {},
target = self.target,
type = type_name,
}
data.impacts[type_name] = impact
end
-- Link effect params to impact params
impact.changed = true
table.insert(impact.params, self.impacts[type_name])
end
--- remove_impact
-- Remove impact from effect
-- @param type_name Impact type name
function Effect:remove_impact(type_name)
if not self.impacts[type_name] then return end
local data = data(self.target)
-- Mark target impact as ended for this effect
self.impacts[type_name].ended = true
data.impacts[type_name].changed = true
-- Detach impact params from effect
self.impacts[type_name] = nil
end
--- stop
-- Stops effect, with optional fall phase
function Effect:stop()
if self.phase == phase_raise or
self.phase == phase_still then
self.phase = phase_fall
end
end
--- start
-- Starts or restarts effect if it's in fall or end phase
function Effect:start()
if self.phase == phase_fall or
self.phase == phase_end then
self.phase = phase_raise
end
end
-- Restart is the same
Effect.restart = Effect.start
-- Effect step
--------------
--- step
-- Performs a step of calculation for the effect
-- @param dtime Time elapsed since last step
-- TODO: For a while after reconnecting, it seems that step runs and conditions
-- are not in place for effect conservation.
function Effect:step(dtime)
-- Internal time
self.elapsed_time = self.elapsed_time + dtime
-- End effects that have no impact
if not next(self.impacts, nil) then self.phase = phase_end end
if (self.phase ~= phase_end) then
self:update_distance_intensity()
end
-- Check effect conditions
if (self.phase == phase_raise or self.phase == phase_still)
and not self:check_conditions() then self.phase = phase_fall end
-- Time intensity and phases
if self.phase == phase_raise then
self.time_intensity = self.time_intensity
+ (self.raise and dtime/self.raise or 1)
if self.time_intensity >= 1 then self.phase = phase_still end
end
if self.phase == phase_still then self.time_intensity = 1 end
if self.phase == phase_fall then
self.time_intensity = self.time_intensity
- (self.fall and dtime/self.fall or 1)
if self.time_intensity <= 0 then self.phase = phase_end end
end
if self.phase == phase_end then self.time_intensity = 0 end
-- Commpute total intensity
local intensity = nvl(self.time_intensity, 1) * nvl(self.distance_intensity, 1)
if intensity and data(self.target).modifiers then
for _, group in ipairs(self.groups or {}) do
intensity = intensity * nvl(data(self.target).modifiers[group], 1)
end
end
-- Propagate to impacts (intensity and end)
for impact_name, impact in pairs(self.impacts) do
if impact.intensity ~= intensity then
impact.intensity = intensity
data(self.target).impacts[impact_name].changed = true
end
if self.phase == phase_end then
impact.ended = true
end
end
end
-- Effect conditions check
--------------------------
--- set_conditions
-- Add or replace conditions on the effect.
-- @param conditions A table of key/values describing the conditions
function Effect:set_conditions(conditions)
self.conditions = self.conditions or {}
for key, value in pairs(conditions) do
self.conditions[key] = value
end
end
-- Is the target equiped with item_name?
function late.is_equiped(target, item_name)
-- Check wielded item
local stack = target:get_wielded_item()
if stack and stack:get_name() == item_name then
return true
end
return false -- Item not found in equipment
end
-- Discard too far or not uptodate nodes from near_nodes list and compute min
-- distance and intensity according to it
function Effect:update_distance_intensity()
local distance, min_distance
if self.conditions and self.conditions.near_node then
for hash, _ in pairs(self.conditions.near_node.active_pos) do
local pos = minetest.get_position_from_hash(hash)
distance = vector.distance(self.target:get_pos(), pos)
if distance < (self.distance or 0) + (self.spread or 0) and
minetest.get_node(pos).name ==
(self.conditions.near_node.node_name or "")
then min_distance = math.min(min_distance or distance, distance)
else self.conditions.near_node.active_pos[hash] = nil end
end
end
if min_distance == nil then self.distance_intensity = nil
else
self.distance_intensity = self.spread and math.min(1, ((self.distance
or 0) + self.spread - min_distance) / self.spread) or 1
end
end
-- Check if conditions on effect are all ok
function Effect:check_conditions()
if not self.conditions then
return true -- no condition, permanent effect
end
-- Check effect duration
if self.conditions.duration ~= nil
and self.elapsed_time > self.conditions.duration then
return false
end
-- Check nearby nodes
if self.conditions.near_node and self.distance_intensity == nil then
return false
end
-- Check equipment
if self.conditions.equiped_with and
not late.is_equiped(self.target, self.conditions.equiped_with) then
return false
end
-- All conditions fulfilled
return true
end
-- On die player : stop effects that are marked stopondeath = true
minetest.register_on_dieplayer(function(player)
local data = data(player)
if data then
for index, effect in pairs(data.effects) do
if effect.stopondeath then
effect:stop()
end
end
end
end)
-- TODO:
--- cancel_player_effects
-- Cancels all effects belonging to a group affecting a player
--function late.cancel_player_effects(player_name, effect_group)
-- Main globalstep loop
-----------------------
minetest.register_globalstep(function(dtime)
-- Loop over all known targets
for target, data in pairs(target_data) do
-- Check target existence
if target:get_properties() == nil then
target_data[target] = nil
else
-- Wield item change check
-- TODO: work only if target is known, what about mobs ?
local stack = target:get_wielded_item()
local item_name = stack and stack:get_name() or nil
if data.wielded_item ~= item_name then
data.wielded_item = item_name
if item_name then
late.set_equip_effect(target, item_name)
end
end
-- Effects
for index, effect in pairs(data.effects) do
-- Compute effect elapsed_time, phase and intensity
effect:step(dtime)
-- Effect ends ?
if effect.phase == phase_end then
-- Inform observers
late.trigger_event("on_effect_end", effect)
-- Delete effect
data.effects[index] = nil
end
end
-- Impacts
for impact_name, impact in pairs(data.impacts) do
local impact_type = late.get_impact_type(
data.type, impact_name)
-- Check if there are still effects using this impact
local remains = false
for key, params in pairs(impact.params) do
if params.ended then
impact.params[key] = nil
else
remains = true
end
end
if remains then
-- Update impact if changed (effect intensity changed)
if impact.changed then
calliffunc(impact_type.update, impact)
end
-- Step
calliffunc(impact_type.step, impact, dtime)
impact.changed = false
else
-- Ends impact
calliffunc(impact_type.reset, impact)
data.impacts[impact_name] = nil
end
end
end
end
end)
-- Effects persistance
----------------------
-- How effect data are stored:
-- Player: Serialized in a player attribute (In V5, it will be possible to use
-- StorageRef for players and entities)
-- Mob: (:TODO:)
-- World: minetest.get_mod_storage() (:TODO:)
-- Periodically, players and world effect are saved in case of server crash
-- TODO:Check that attributes are saved in case of server crash
-- TODO:Manage entity persistance with get_staticdata and on_activate
-- serialize_effects
function serialize_effects(target)
local data = data(target)
if not data then return end -- Not a suitable target
local effects = table.copy(data.effects)
-- remove target references from data to be serialized (not serializable)
for _, effect in pairs(effects) do effect.target = nil end
return minetest.serialize(effects)
end
-- deserialize_effects
function deserialize_effects(target, serialized)
if serialized == "" then return end
local data = data(target)
if not data then return end -- Not a suitable target
if data.effects and next(data.effects, nil) then
minetest.log('error', '[late] Trying to deserialize effects for '
..data.string..' which already has effects.')
return
end
-- Deseralization
local effects = minetest.deserialize(serialized) or {}
for _, fields in pairs(effects) do
local effect = Effect:new(target, fields)
effect.break_time = true
end
end
local function periodic_save()
for _,player in ipairs(minetest.get_connected_players()) do
player:set_attribute(save_meta_key, serialize_effects(player))
end
minetest.after(save_interval, periodic_save)
end
minetest.after(save_interval, periodic_save)
minetest.register_on_joinplayer(function(player)
-- deserialize_effects(player, player:get_attribute(save_meta_key))
end)
minetest.register_on_leaveplayer(function(player)
player:set_attribute(save_meta_key, serialize_effects(player))
end)
minetest.register_on_shutdown(function()
for _,player in ipairs(minetest.get_connected_players()) do
player:set_attribute(save_meta_key, serialize_effects(player))
end
end)
-- Effects management
---------------------
--- get_effect_by_id
-- Retrieves an effect by its ID for a given target
-- @param target Concerned target (player, mob, world)
-- @param id Id of the effect researched
-- @returns The Effect object or nil if not found
function late.get_effect_by_id(target, id)
local data = data(target)
if data then return data.effects[id] end
end
-- Hacks
--------
-- Awful hack for integration with other mods dealing with player physics
local physic_impacts =
{ jump = 'jump', gravity = 'gravity', speed = 'speed' }
local function set_physics_override(player, table)
-- Separate physics managed by impacts from those still managed by
-- core api set_physics_override
local impacts = {}
local physics = {}
for physic, impact in pairs(physic_impacts) do
if table[physic] then
impacts[impact] = table[physic]
else
physics[physic] = table[physic]
end
end
-- If impact managed physics, update or create specific effect
if next(impacts, nil) then
local effect = late.get_effect_by_id(player, 'core:physics')
or Effect:new(player, { id = 'core:physics' })
for impact, value in pairs(impacts) do
if value == 1 then
effect:remove_impact(impact)
else
effect:add_impact(impact, { value })
end
end
end
-- If core api managed physics, call core api
if next(physics, nil) then
late.set_physics_override(player, physics)
end
end
minetest.register_on_joinplayer(function(player)
if late.set_physics_override == nil then
print('[effect_api] Hacking Player:set_physics_override')
local meta = getmetatable(player)
late.set_physics_override = meta.set_physics_override
meta.set_physics_override = set_physics_override
end
-- Create effect if there are already physic changes
local physics = player:get_physics_override()
set_physics_override(player, physics)
end)