ghosts/init.lua

348 lines
12 KiB
Lua

-- Imports
local mlvec = modlib.vector
local media_paths = modlib.minetest.media.paths
-- TODO consider moving this to modlib (or perhaps even copying it here?) to remove moblib dependency
local get_rotation = moblib.get_rotation
-- Utilities
-- TODO consider move to modlib
-- Random from -1 to 1
local function signed_random()
return math.random() * 2 - 1
end
local function random_vector()
return vector.new(signed_random(), signed_random(), signed_random())
end
local function random_dir_vector()
return vector.normalize(random_vector())
end
-- Configuration
local conf = modlib.mod.configuration()
-- Persistence
local data_dir = minetest.get_worldpath() .. "/data"
minetest.mkdir(data_dir)
local data = modlib.persistence.lua_log_file.new(data_dir .. "/ghosts.lua", {players = {}, night = 0}, false)
data:init()
modlib.minetest.register_globalstep(60 * 60 * 24, function()
-- Rewrite persistence file every 24 hours
data:rewrite()
end)
minetest.register_on_joinplayer(function(player)
local name = player:get_player_name()
data:set(data.root.players, name, data.root.players[name] or {})
end)
-- Cached model & texture data
-- TODO clean these caches, most importantly the model cache (perhaps based on last access?)
local b3d_triangle_sets = setmetatable({}, {__index = function(self, filename)
local _, ext = modlib.file.get_extension(filename)
if not ext or ext:lower() ~= "b3d" then
-- Only B3D support currently
return
end
local path = assert(media_paths[filename], filename)
local model = io.open(path, "rb")
local character = assert(modlib.b3d.read(model))
assert(not model:read(1))
model:close()
local mesh = assert(character.node.mesh)
local vertices = assert(mesh.vertices)
for _, vertex in ipairs(vertices) do
-- Minetest hardcodes a blocksize of 10 model units
vertex.pos = mlvec.divide_scalar(vertex.pos, 10)
end
local triangle_sets = assert(mesh.triangle_sets)
local func = modlib.func
-- Triangle sets by texture index
local tris_by_tex = {}
for _, set in pairs(triangle_sets) do
local tris = set.vertex_ids
for _, tri in pairs(tris) do
modlib.table.map(tri, func.curry(func.index, vertices))
end
local brush_id = tris.brush_id or mesh.brush_id
local tex_id
if brush_id then
tex_id = assert(character.brushes[brush_id].texture_id[1])
else
-- No brush, default to first texture
tex_id = 1
end
tris_by_tex[tex_id] = tris_by_tex[tex_id] and modlib.table.append(tris_by_tex[tex_id], tris) or tris
end
self[filename] = tris_by_tex
return tris_by_tex
end})
local png_dimensions = setmetatable({}, {__index = function(self, filename)
local _, ext = modlib.file.get_extension(filename)
if ext:lower() ~= "png" then
-- Only PNG support currently
return
end
local media_path = media_paths[filename]
if not media_path then
return
end
local file = io.open(media_path, "rb")
if not file then
return
end
assert(file:read(8) == "\137PNG\r\n\26\10", "invalid PNG header")
-- Skip file length
file:read(4)
assert(file:read(4) == "IHDR", "IHDR chunk expected")
local width, height = file:read(4), file:read(4)
file:close()
local function read(dimension)
local index = 5
return modlib.binary.read_uint(function()
index = index - 1
return dimension:byte(index)
end, 4)
end
local dimension = modlib.vector.new{read(width), read(height)}
self[filename] = dimension
return dimension
end})
-- Tries to make a reasonable guess regarding texture dimensions
local function guess_texture_dimensions(texture)
-- If it is a combined texture, dimensions are right at the beginning
-- It may still be overlayed later, as in "[combine:...x...:...,...=...^otherimg.png"
-- It is reasonable to assume that the overlayed image is a multiple or a fraction of the [combine dimensions though
local width, height = texture:match"^%[combine:(%d+)x(%d+):"
if width then
return modlib.vector.new{tonumber(width), tonumber(height)}
end
if texture:match"^%[" then
-- Nontrivial texture modifier like [inventorycube or [lowpart
return
end
-- Overlayed / modified texture. Usually, dimensions of the resulting texture will be a multiple or a fraction (if overlayed),
-- or even the same if merely the colors are changed
local base_image = texture:match"^(.-)%^" or texture
if base_image and base_image ~= "" then
return png_dimensions[base_image]
end
end
-- Invisible entity used for attaching the sound to it: Visuals are particle-based
local modname = minetest.get_current_modname()
local sound_entity_name = modname .. ":sound"
minetest.register_entity(sound_entity_name, {
initial_properties = {
physical = false,
collide_with_objects = false,
pointable = false,
visual_size = {x = 0, y = 0, z = 0},
is_visible = false,
backface_culling = true,
static_save = false,
damage_texture_modifier = "",
shaded = false
},
on_activate = function(self, _staticdata, _dtime)
self.timer = 0
end,
on_step = function(self, dtime, _moveresult)
self.timer = self.timer + dtime
if self.timer > (self.lifetime or math.huge) then
self.object:remove()
end
end
})
local steps = conf.particles_per_metre
local function spawn_ghost(params)
local expiration_time = assert(params.expiration_time)
local pos = mlvec.from_minetest(params.pos)
local velocity = assert(params.velocity)
local triangle_sets = b3d_triangle_sets[assert(params.model)]
if not triangle_sets then
-- Unsupported model
return
end
local rotation = get_rotation(vector.normalize(velocity))
-- TODO as modlib doesn't have matrix support yet, we have to use axis-angle rotation
-- which we obtain from Euler angles over quaternion representations
local axis, angle = modlib.quaternion.to_axis_angle(
modlib.quaternion.from_euler_rotation(vector.multiply(rotation, -1)))
local disperse = params.disperse or 0
if params.sound then
local sound_object = assert(minetest.add_entity(pos, sound_entity_name))
sound_object:set_velocity(velocity)
minetest.sound_play({
name = "ghosts_ghost",
gain = 0.6 + math.random() * 0.4,
pitch = 0.6 + math.random() * 0.4,
}, {
to_player = params.playername,
object = sound_object,
max_hear_distance = 40
}, true)
end
for tex, triangles in pairs(triangle_sets) do
local texture = assert(params.textures[tex])
local dim = conf.fallback_resolution
if conf.force_fallback_resolution then
texture = texture .. "^[resize:" .. table.concat(dim, "x")
else
dim = guess_texture_dimensions(texture) or dim
end
local width, height = unpack(dim)
-- The texture not being cached might make this "laggy" for the client the first time
-- TODO better filtering (this is pretty much nearest neighbor)
local pixel = texture .. "^[sheet:" .. table.concat(dim, "x") .. ":%d,%d^[resize:1x1"
for _, tri in pairs(triangles) do
local function transform(tri_pos)
return mlvec.add(mlvec.rotate3(tri_pos, axis, angle), pos)
end
local base_pos = transform(tri[1].pos)
local tex_base_pos = tri[1].tex_coords[1]
local vec_x = mlvec.subtract(transform(tri[2].pos), base_pos)
local len_x = mlvec.length(vec_x)
local tex_vec_x = mlvec.subtract(tri[2].tex_coords[1], tex_base_pos)
local vec_y = mlvec.subtract(transform(tri[3].pos), base_pos)
local len_y = mlvec.length(vec_y)
local tex_vec_y = mlvec.subtract(tri[3].tex_coords[1], tex_base_pos)
-- Small bias of 1e-6 to avoid artifacts at triangle edges
local bias = 1e-6
for x = 0 + bias, 1 - bias, 1/(len_x*steps) do
for y = 0 + bias, 1 - bias, 1/(len_y*steps) do
if x + y > 1 then
-- Point is not on triangle and later ones in this "scanline" can't be either
break
end
-- Triangle fragment position
local frag_pos = mlvec.add(base_pos, mlvec.add(mlvec.multiply_scalar(vec_x, x), mlvec.multiply_scalar(vec_y, y)))
local dirvec = mlvec.subtract(pos, frag_pos)
-- Texture coordinates
local tex_pos = mlvec.add(tex_base_pos, mlvec.add(mlvec.multiply_scalar(tex_vec_x, x), mlvec.multiply_scalar(tex_vec_y, y)))
local tex_x = math.floor(math.min(tex_pos[1] * width, width - 1))
local tex_y = math.floor(math.min(tex_pos[2] * height, height - 1))
minetest.add_particle{
-- TODO leverage Minetest's metatable support: Omit :to_minetest()
expirationtime = expiration_time,
pos = frag_pos:to_minetest(),
velocity = vector.add(velocity, vector.multiply(random_vector(), disperse)),
acceleration = mlvec.divide_scalar(dirvec, expiration_time^2 / 2 / params.implode):to_minetest(),
texture = pixel:format(tex_x, tex_y),
size = 0.25,
glow = math.random(6, 8),
playername = params.playername
}
end
end
end
end
return true
end
-- Easter eggs
local is_halloween
do
local function snap()
local players = modlib.table.shuffle(minetest.get_connected_players())
for index = 1, math.ceil(#players / 2) do
local player = players[index]
player:set_hp(0, "snap")
spawn_ghost{
expiration_time = 5,
pos = player:get_pos(),
velocity = random_vector(),
implode = -1,
disperse = 0.2 + math.random() * 0.1,
-- (Audio-)visuals
model = player:get_properties().mesh,
textures = player:get_properties().textures,
sound = false
}
end
end
minetest.register_on_chat_message(function(name, message)
if modlib.text.trim_spacing(message) == "*snap*" and minetest.get_player_privs(name).server then
snap()
end
end)
local date = os.date"*t"
is_halloween = date.day == 31 and date.month == 10
end
local function spawn_ghosts()
for player in modlib.minetest.connected_players() do
local name = player:get_player_name()
for ghostname, ghost in pairs(data.root.players[name] or {}) do
local nights_passed = data.root.night - ghost.night - 1
if nights_passed >= conf.forget_duration_nights then
data:set(data.root.players[name], ghostname, nil)
elseif is_halloween or (math.random() <= conf.spawn_chance * conf.chance_reduction_per_night^nights_passed) then
-- TODO nothing happens the very first night? 3d_armor support doesn't seem to work?
-- Spread ghost spawning out across 10 seconds
-- TODO make this configurable
modlib.minetest.after(math.random() * 10, function()
spawn_ghost{
expiration_time = 10,
pos = vector.add(player:get_pos(), vector.multiply(random_vector(), 3)),
velocity = vector.multiply(random_dir_vector(), 1 + math.random() * 2),
-- Either implode to a point or explode to 10x the size
implode = signed_random() < 0 and 1 or -10,
-- (Audio-)visuals
textures = ghost.textures,
model = ghost.model,
sound = true,
-- Ghosts are also publicly visible on Halloween
playername = (not is_halloween) and player:get_player_name() or nil,
}
end)
end
end
end
end
local last_timeofday
-- Spawn after midnight in this threshold if exactly spawning at midnight isn't possible
local midnight_duration = 0.1
minetest.register_globalstep(function()
-- HACK first globalstep run must set last_timeofday, as it isn't available at load time
last_timeofday = last_timeofday or assert(minetest.get_timeofday())
local timeofday = minetest.get_timeofday()
-- Detect midnight as a decrease in timeofday (jump from 1 to 0)
if timeofday < last_timeofday and timeofday < midnight_duration then
data:set_root("night", data.root.night + 1)
spawn_ghosts()
end
last_timeofday = timeofday
end)
minetest.register_on_player_hpchange(function(victim, hp_change, reason)
if victim:get_hp() + hp_change > 0 then
-- Player survives the hit, don't haunt
return
end
local hitter = (reason or {}).object
if not (hitter and hitter:is_player()) then
return
end
local props = victim:get_properties()
data:set(data.root.players[hitter:get_player_name()], victim:get_player_name(), {
textures = props.textures,
model = props.mesh,
night = data.root.night
})
end)
-- Export API as a global variable
ghosts = {spawn_ghost = spawn_ghost}