VoxeLibre/mods/ITEMS/mcl_portals/portal_nether.lua

1625 lines
53 KiB
Lua

local modname = minetest.get_current_modname()
local S = minetest.get_translator(modname)
local modpath = minetest.get_modpath(modname)
-- Localize functions for better performance
local abs = math.abs
local ceil = math.ceil
local floor = math.floor
local max = math.max
local min = math.min
local random = math.random
local dist = vector.distance
local add = vector.add
local mul = vector.multiply
local sub = vector.subtract
local log = function(level, message)
minetest.log(level, string.format("[mcl_portals] %s", message))
end
-- Resources
-- Issue that has a lot of context: https://git.minetest.land/VoxeLibre/VoxeLibre/issues/4120
-- Minecraft portal mechanics: https://minecraft.fandom.com/wiki/Tutorials/Nether_portals
-- Flow diagram: https://docs.google.com/drawings/d/1WIl4pVuxgOxI3Ncxk4g6D1pL4Fyll3bQ-fX6L9yyiLw/edit
-- Useful boundaries: https://git.minetest.land/VoxeLibre/VoxeLibre/wiki/World-structure%3A-positions%2C-boundaries%2C-blocks%2C-chunks%2C-dimensions%2C-barriers-and-the-void
-- Setup
-- === CAUTION ===
-- the following values: SEARCH_DISTANCE_OVERWORLD, SEARCH_DISTANCE_NETHER,
-- BUILD_DISTANCE_XY, W_MAX, and NETHER_COMPRESSION have been set to together
-- guarantee that standard 2-wide portals will never split into two exits.
-- Splitting will still occur rarely for portals in the overworld wider than 2,
-- will still be likely for portals in the nether wider than 16, and guaranteed
-- for portals in the nether wider than 18.
-- Changing one value without changing the others might have uninteded
-- consequences. You have been warned :-)
-- Distance compression factor for the nether. If you change this, you might
-- want to recalculate SEARCH_DISTANCE_* and W_MAX too.
local NETHER_COMPRESSION = 8
-- Maximum distance from the ideal build spot where active parts of portals are
-- allowed to be placed.
-- There is a tradeoff here between the "walking" distance (distance to walk in
-- the overworld to get a new nether portal exit, which we want to be as similar
-- to Minecraft as possible, which is 136 [16*8+8]), and the area available for
-- exit search (which we want to maximise).
-- For our mapgen performance reasons, our search area is clipped by chunk, so
-- in the unlucky corner case the worst-case build area could be a quarter of
-- the expected size.
-- For MC build distance of 16, which gives a build area of 1,089 X-Z blocks
-- [(16+16+1)*(16+16+1)]. To guarantee this area here, we'd need to pick a build
-- distance of 32 [(32+32+1)*(32+32+1)/4]. But build distance of 32 implies
-- walking distance of 264 [32*8+8], which is already quite far, so need to pick
-- the right tradeoff:
--
-- Build dist Minimum build area Minimum walk distance
-- 48 2,401 392
-- 32 1,089 264
-- 24 625 200
-- 16 289 136
local BUILD_DISTANCE_XZ = 24
-- The following two values define distance to search existing portal exits for.
-- For context, Minecraft search is "8 chunks away" for the overworld (17
-- chunks centered on the ideal position), and "1 chunk away" for the nether (3
-- chunks centered on the ideal position).
-- To prevent portal splitting on spawned portals, we add one to the build
-- distance: spawned portals are 2-wide, so we need to make sure that we can
-- reach the second exit block (which can spawn in the direction away from the
-- player).
-- The search is boundary-inclusive, meaning for position 0 in the overworld,
-- search will be from -N to N.
-- If you change this, keep in mind our exits table keying divisor is 256, so
-- small changes might have outsize performance impact. At <=128, max 4 buckets
-- are searched, at 200 max 9 buckets are searched.
local SEARCH_DISTANCE_NETHER = BUILD_DISTANCE_XZ + 1 -- 25
local SEARCH_DISTANCE_OVERWORLD = SEARCH_DISTANCE_NETHER * NETHER_COMPRESSION -- 200
-- Limits of portal sizes (includes the frame)
local W_MIN, W_MAX = 4, 23
local H_MIN, H_MAX = 5, 23
-- Limits to active nodes (mcl_portals:portal)
local N_MIN, N_MAX = 6, (W_MAX-2) * (H_MAX-2)
local LIM_MIN, LIM_MAX = mcl_vars.mapgen_edge_min, mcl_vars.mapgen_edge_max
local PLAYER_COOLOFF, MOB_COOLOFF = 3, 14 -- for this many seconds they won't teleported again
local TOUCH_CHATTER_TIME = 1 -- prevent multiple teleportation attempts caused by multiple portal touches, for this number of seconds
local CHATTER_US = TOUCH_CHATTER_TIME * 1000000
local DELAY = 3 -- seconds before teleporting in Nether portal in Survival mode (4 minus ABM interval time)
-- Speeds up the search by allowing some non-air nodes to be replaced when
-- looking for acceptable portal locations. Setting this lower means the
-- algorithm will do more searching. Even at 0, there is no risk of finding
-- nothing - the most airy location will be used as fallback anyway.
local ACCEPTABLE_PORTAL_REPLACES = 2
local PORTAL = "mcl_portals:portal"
local OBSIDIAN = "mcl_core:obsidian"
-- Dimension-specific Y boundaries for portal exit search. Portal search
-- algorithm will only ever look at two vertically offset chunks. It will
-- select whether the second chunk is up or down based on which side has more
-- "valid" Y-space (where valid is defined as space between *_Y_MIN and
-- *_Y_MAX).
-- For nether, selection of the boundaries doesn't matter, because it fits
-- entirely within two chunks.
local N_Y_MIN = mcl_vars.mg_lava_nether_max + 1
local N_Y_MAX = mcl_vars.mg_bedrock_nether_top_min
local N_Y_SPAN = N_Y_MAX - N_Y_MIN
-- Overworld however is much taller. Let's select the boundaries so that we
-- maximise the Y-space (so align with chunk boundary), and also pick an area
-- that has a good chance of having "find_nodes_in_area_under_air" return
-- something (so ideally caves, or surface, not just sky).
-- For the bottom bound, we try for the first chunk boundary in the negative Ys (-32).
local O_Y_MIN = max(mcl_vars.mg_lava_overworld_max + 1, mcl_vars.central_chunk_offset_in_nodes)
-- Since O_Y_MIN is also used as a base for converting coordinates, we need to
-- make sure the top bound is high enough to encompass entire nether height. In
-- v7 mapgen nether is flatter than overworld, so this results in a span of
-- exactly two chunks.
-- Due to the O_Y_MIN being used as a base, the preferred build locations will
-- be in the bottom part of the overworld range (in v7 around -32 to 61), and
-- the high locations will be used as a fallback. If we see too many
-- underground portals, we may need to shift just this base upwards (new setting).
local O_Y_SPAN = max(N_Y_SPAN, 2 * mcl_vars.chunk_size_in_nodes - 1)
local O_Y_MAX = min(mcl_vars.mg_overworld_max_official, O_Y_MIN + O_Y_SPAN)
log("verbose", string.format("N_Y_MIN=%.1f, N_Y_MAX=%.1f, O_Y_MIN=%.1f, O_Y_MAX=%.1f", N_Y_MIN, N_Y_MAX, O_Y_MIN, O_Y_MAX))
log("verbose", string.format("Nether span is %.1f, overworld span is %.1f", N_Y_MAX-N_Y_MIN+1, O_Y_MAX-O_Y_MIN+1))
-- Alpha and particles
local node_particles_allowed = minetest.settings:get("mcl_node_particles") or "none"
local node_particles_levels = { none=0, low=1, medium=2, high=3 }
local PARTICLES = node_particles_levels[node_particles_allowed]
-- Table of objects (including players) which recently teleported by a
-- Nether portal. Those objects have a brief cooloff period before they
-- can teleport again. This prevents annoying back-and-forth teleportation.
local cooloff = {}
function mcl_portals.nether_portal_cooloff(object)
return cooloff[object]
end
local chatter = {}
-- Searching for portal exits, finding locations and building portals can be time consuming.
-- We can queue origins together, on the assumption they will all go to the same place in the end.
local origin_queue = {}
-- At the same time, we never want to build two portals in the same area at the
-- same time, because they will interfere and we can end up with broken portals,
-- possibly stranding the player. We won't use queue here - double queueing can
-- lead to deadlocks. We will instead interrupt the portal building, and rely
-- on the ABM to try again later.
local chunk_building = {}
local storage = mcl_portals.storage
-- `exits` is a table storing portal exits (both nether or overworld) bucketed
-- by 256x256 areas (with each bucket containing many exits, and covering entire range of Y).
-- An exit is a location of ignited PORTAL block, with another PORTAL block above
-- and obsidian below. Thus each portal will register at minimum two exits, but more
-- for bigger portals up to the maximum of W_MAX-2.
-- This table should be maintained using `add_exits`, `remove_exits` and `find_exit`.
local exits = {}
local keys = minetest.deserialize(storage:get_string("nether_exits_keys") or "return {}") or {}
for _, key in pairs(keys) do
local n = tonumber(key)
if n then
exits[key] = minetest.deserialize(storage:get_string("nether_exits_"..key) or "return {}") or {}
end
end
local get_node = mcl_vars.get_node
local set_node = minetest.set_node
local registered_nodes = minetest.registered_nodes
local is_protected = minetest.is_protected
local find_nodes_in_area = minetest.find_nodes_in_area
local find_nodes_in_area_under_air = minetest.find_nodes_in_area_under_air
local pos_to_string = minetest.pos_to_string
local is_area_protected = minetest.is_area_protected
local get_us_time = minetest.get_us_time
local dimension_to_teleport = { nether = "overworld", overworld = "nether" }
local limits = {
nether = {
pmin = {x=LIM_MIN, y = N_Y_MIN, z = LIM_MIN},
pmax = {x=LIM_MAX, y = N_Y_MAX, z = LIM_MAX},
},
overworld = {
pmin = {x=LIM_MIN, y = O_Y_MIN, z = LIM_MIN},
pmax = {x=LIM_MAX, y = O_Y_MAX, z = LIM_MAX},
},
}
-- Deletes exit from this portal's node storage.
local function delete_portal_pos(pos)
local p1 = vector.offset(pos,-5,-1,-5)
local p2 = vector.offset(pos,5,5,5)
local nn = find_nodes_in_area(p1,p2,{"mcl_portals:portal"})
for _,p in pairs(nn) do
minetest.get_meta(p):set_string("target_portal","")
end
end
-- Gets exit for this portal from node storage. After Jan 2024 this is only used
-- for old portals, so that players don't get surprises. New portals, or portals that lost
-- node storage due to destruction should use the lookup table.
local function get_portal_pos(pos)
local p1 = vector.offset(pos,-5,-1,-5)
local p2 = vector.offset(pos,5,5,5)
local nn = find_nodes_in_area(p1,p2,{"mcl_portals:portal"})
for _,p in pairs(nn) do
local m = minetest.get_meta(p):get_string("target_portal")
if m and m ~= "" and mcl_vars.get_node(p).name == "mcl_portals:portal" then
return minetest.get_position_from_hash(m)
end
end
end
-- `exits` is a lookup table bucketing exits by 256x256 areas. This function
-- returns a key for provided position p.
local function get_exit_key(p)
local x, z = floor(p.x), floor(p.z)
return floor(z/256) * 256 + floor(x/256)
end
-- Add an exit to the exits table without writing to disk. Returns the exits
-- table key if modification was done, and specifies whether the key was new.
local function add_exit(p)
local retval = {key=false, new=false}
if not p or not p.y or not p.z or not p.x then
return retval
end
local x, y, z = floor(p.x), floor(p.y), floor(p.z)
local p = {x = x, y = y, z = z}
if get_node({x=x,y=y-1,z=z}).name ~= OBSIDIAN
or get_node(p).name ~= PORTAL
or get_node({x=x,y=y+1,z=z}).name ~= PORTAL
then
return retval
end
local k = get_exit_key(p)
if not exits[k] then
exits[k]={}
retval.new = true
end
local e = exits[k]
for i = 1, #e do
local t = e[i]
if t and t.x == p.x and t.y == p.y and t.z == p.z then
return retval
end
end
e[#e+1] = p
retval.key = k
return retval
end
-- This function registers one or more exits from Nether portals and writes
-- updated table buckets to disk.
-- Exit position must be an ignited PORTAL block that sits on top of obsidian,
-- and has additional PORTAL block above it.
-- This function will silently skip exits that are invalid during the call-time.
-- If the verification passes, a new exit is added to the table of exits and
-- saved to mod storage later. Duplicate exits will be skipped and won't cause
-- writes.
local function add_exits(positions)
local keys_to_write = {}
local new_key_present = false
for _, p in ipairs(positions) do
local r = add_exit(p)
if r.key ~= false then
if r.new then
new_key_present = true
keys[#keys+1] = r.key
end
keys_to_write[#keys_to_write+1] = r.key
log("verbose", "Exit added at " .. pos_to_string(p))
end
end
for _, key in ipairs(keys_to_write) do
storage:set_string("nether_exits_"..tostring(key), minetest.serialize(exits[key]))
end
if new_key_present then
storage:set_string("nether_exits_keys", minetest.serialize(keys))
end
end
-- Removes one portal exit from the exits table without writing to disk.
-- Returns the false or bucket key if change was made.
local function remove_exit(p)
if not p or not p.y or not p.z or not p.x then
return false
end
local x, y, z = floor(p.x), floor(p.y), floor(p.z)
local p = {x = x, y = y, z = z}
local k = get_exit_key(p)
if not exits[k] then
return false
end
local e = exits[k]
if e then
for i, t in pairs(e) do
if t and t.x == x and t.y == y and t.z == z then
e[i] = nil
return k
end
end
end
return false
end
-- Removes one or more portal exits and writes updated table buckets to disk.
local function remove_exits(positions)
local keys_to_write = {}
for _, p in ipairs(positions) do
r = remove_exit(p)
if r ~= false then
keys_to_write[#keys_to_write+1] = r
log("verbose", "Exit removed from " .. pos_to_string(p))
end
end
for _, key in ipairs(keys_to_write) do
storage:set_string("nether_exits_"..tostring(key), minetest.serialize(exits[key]))
end
end
-- Searches for portal exits nearby point p within the distances specified by dx
-- and dz (but only in the same dimension). Otherwise as in Minecraft, the
-- search is bounded by X and Z, but not Y.
-- If multiple exits are found, use Euclidean distance to find the nearest. This
-- uses all three coordinates.
local function find_exit(p, dx, dz)
local dim = mcl_worlds.pos_to_dimension(p)
if not p or not p.y or not p.z or not p.x then
log("warning", "Corrupt position passed to find_exit: "..pos_to_string(p)..".")
return
end
if dx < 1 or dz < 1 then return false end
local x = floor(p.x)
local z = floor(p.z)
local x1 = x-dx
local z1 = z-dz
local x2 = x+dx
local z2 = z+dz
-- Scan the relevant hash table keys for viable exits. Dimension's entire Y is scanned.
local k1x, k2x = floor(x1/256), floor(x2/256)
local k1z, k2z = floor(z1/256), floor(z2/256)
local nearest_exit, nearest_distance
for kx = k1x, k2x do
for kz = k1z, k2z do
local k = kz*256 + kx
local e = exits[k]
if e then
for _, t0 in pairs(e) do
if mcl_worlds.pos_to_dimension(t0) == dim then
-- exit is in the correct dimension
if abs(t0.x-p.x) <= dx and abs(t0.z-p.z) <= dz then
-- exit is within the search range
local d0 = dist(p, t0)
if not nearest_distance or nearest_distance>d0 then
-- it is the only exit so far, or it is the Euclidean-closest exit
nearest_distance = d0
nearest_exit = t0
if nearest_distance==0 then return nearest_exit end
end
end
end
end
end
end
end
return nearest_exit
end
minetest.register_chatcommand("dumpportalkeys", {
description = S("Dump all portal keys"),
privs = { debug = true },
func = function(name, param)
keys = {}
for k, _ in pairs(exits) do
keys[#keys+1] = k
end
output = string.format("Nether portal exits keys: %s", table.concat(keys, ", "))
return true, output
end,
})
local function dump_key(key)
output = string.format("[%d] => ", tonumber(key))
for _,p in pairs(exits[tonumber(key)]) do
output = output .. minetest.pos_to_string(p) .. " "
end
output = output .. "\n"
return output
end
minetest.register_chatcommand("dumpportalexits", {
description = S("Dump coordinates of registered nether portal exits"),
privs = { debug = true },
params = "[key]",
func = function(name, param)
local key = param
if not key or key == "" then
output = "Nether portal exit locations (all dimensions):\n"
else
output = string.format("Nether portal exit locations for key [%s] (all dimensions):\n", key)
end
if not key or key == "" then
local count = 0
for k, _ in pairs(exits) do
count = count + 1
if count>100 then
output = output .. "The list exceeds 100 keys, truncated. Try /dumpportalkeys, then /dumpportalexits KEY"
break
end
output = output .. dump_key(k)
end
else
-- key specified, no limits
if not exits[tonumber(key)] then
output = output .. string.format("No exits in key [%s]\n", key)
else
dump_key(key)
end
end
return true, output
end,
})
-- Map coordinates between dimensions. Distances in X and Z are scaled by NETHER_COMPRESSION.
-- Distances in Y are mapped directly. Caller should check the return value, this function
-- returns nil if there is no valid mapping - for example if the target would be out of world.
local function get_target(p)
if not p or not p.y or not p.x or not p.z then
return
end
local _, dim = mcl_worlds.y_to_layer(p.y)
local x,y,z
if dim=="nether" then
-- traveling to OW
x = floor(p.x * NETHER_COMPRESSION)
z = floor(p.z * NETHER_COMPRESSION)
if x>=LIM_MAX or x<=LIM_MIN or z>=LIM_MAX or z<=LIM_MIN then
-- Traveling out of bounds is forbidden.
return
end
y = max(min(p.y, N_Y_MAX), N_Y_MIN)
y = y - N_Y_MIN
y = O_Y_MIN + y
y = max(min(y, O_Y_MAX), O_Y_MIN)
else
-- traveling to the nether
x = floor(p.x / NETHER_COMPRESSION)
z = floor(p.z / NETHER_COMPRESSION)
if x>=LIM_MAX or x<=LIM_MIN or z>=LIM_MAX or z<=LIM_MIN then
-- Traveling out of bounds is forbidden.
return
end
y = max(min(p.y, O_Y_MAX), O_Y_MIN)
y = y - O_Y_MIN
y = N_Y_MIN + y
y = max(min(y, N_Y_MAX), N_Y_MIN)
end
return vector.new(x,y,z)
end
-- Destroy a nether portal. Connected portal nodes are searched and removed
-- using 'bulk_set_node'. This function is called from 'after_destruct' of
-- nether portal nodes. The flag 'destroying_portal' is used to avoid this
-- function being called recursively through callbacks in 'bulk_set_node'.
-- To maintain portal integrity, it is permitted to destroy protected portal
-- blocks if the portal structure is only partly protected, and the player
-- destroys the part that is sticking out.
local destroying_portal = false
local function destroy_nether_portal(pos, node)
if destroying_portal then
return
end
destroying_portal = true
local orientation = node.param2
local checked_tab = { [minetest.hash_node_position(pos)] = true }
local nodes = { pos }
local function check_remove(pos)
local h = minetest.hash_node_position(pos)
if checked_tab[h] then
return
end
local node = minetest.get_node(pos)
if node and node.name == PORTAL and (orientation == nil or node.param2 == orientation) then
table.insert(nodes, pos)
checked_tab[h] = true
end
end
local i = 1
while i <= #nodes do
pos = nodes[i]
if orientation == 0 then
check_remove({x = pos.x - 1, y = pos.y, z = pos.z})
check_remove({x = pos.x + 1, y = pos.y, z = pos.z})
else
check_remove({x = pos.x, y = pos.y, z = pos.z - 1})
check_remove({x = pos.x, y = pos.y, z = pos.z + 1})
end
check_remove({x = pos.x, y = pos.y - 1, z = pos.z})
check_remove({x = pos.x, y = pos.y + 1, z = pos.z})
remove_exits({pos})
i = i + 1
end
minetest.bulk_set_node(nodes, { name = "air" })
destroying_portal = false
end
local on_rotate
if minetest.get_modpath("screwdriver") then
-- Presumably because it messes with the placement of exits.
on_rotate = screwdriver.disallow
end
minetest.register_node(PORTAL, {
description = S("Nether Portal"),
_doc_items_longdesc = S("A Nether portal teleports creatures and objects to the hot and dangerous Nether dimension (and back!). Enter at your own risk!"),
_doc_items_usagehelp = S("Stand in the portal for a moment to activate the teleportation. Entering a Nether portal for the first time will also create a new portal in the other dimension. If a Nether portal has been built in the Nether, it will lead to the Overworld. A Nether portal is destroyed if the any of the obsidian which surrounds it is destroyed, or if it was caught in an explosion."),
tiles = {
"blank.png",
"blank.png",
"blank.png",
"blank.png",
{
name = "mcl_portals_portal.png",
animation = {
type = "vertical_frames",
aspect_w = 16,
aspect_h = 16,
length = 1.25,
},
},
{
name = "mcl_portals_portal.png",
animation = {
type = "vertical_frames",
aspect_w = 16,
aspect_h = 16,
length = 1.25,
},
},
},
drawtype = "nodebox",
paramtype = "light",
paramtype2 = "facedir",
sunlight_propagates = true,
use_texture_alpha = minetest.features.use_texture_alpha_string_modes and "blend" or true,
walkable = false,
buildable_to = false,
is_ground_content = false,
drop = "",
light_source = 11,
post_effect_color = {a = 180, r = 51, g = 7, b = 89},
node_box = {
type = "fixed",
fixed = {
{-0.5, -0.5, -0.1, 0.5, 0.5, 0.1},
},
},
groups = { creative_breakable = 1, portal = 1, not_in_creative_inventory = 1 },
sounds = mcl_sounds.node_sound_glass_defaults(),
after_destruct = destroy_nether_portal,
on_rotate = on_rotate,
_mcl_hardness = -1,
_mcl_blast_resistance = 0,
})
local function build_and_light_frame(x1, y1, z1, x2, y2, z2, name)
local orientation = 0
if x1 == x2 then
orientation = 1
end
local pos = {}
for x = x1 - 1 + orientation, x2 + 1 - orientation do
pos.x = x
for z = z1 - orientation, z2 + orientation do
pos.z = z
for y = y1 - 1, y2 + 1 do
pos.y = y
local frame = (x < x1) or (x > x2) or (y < y1) or (y > y2) or (z < z1) or (z > z2)
if frame then
set_node(pos, {name = OBSIDIAN})
else
set_node(pos, {name = PORTAL, param2 = orientation})
add_exits({
{x=pos.x, y=pos.y-1, z=pos.z}
})
end
end
end
end
end
-- Create a portal, where cube_pos1 is the "bottom-left" coordinate of the
-- W_MINxH_MIN cube to contain the portal - i.e. the coordinate with the
-- smallest X, Y and Z. We will be placing the portal in the middle-ish, so
-- that block +1,+1,+1 is guaranteed to be a portal block. The build area
-- includes frame.
-- Small obsidian platform will be created on either side of the exit if there
-- is no nodes there, or nodes underneath (one step down is permitted).
-- Orientation 0 is portal alongside X axis, 1 alongside Z.
function build_nether_portal(cube_pos1, width, height, orientation, name, clear_before_build)
local width, height, orientation = width or W_MIN, height or H_MIN, orientation or random(0, 1)
local width_inner = width-2
local height_inner = height-2
local cube_pos2 = add(cube_pos1, vector.new(width-1, height-1, width-1))
if is_area_protected(cube_pos1, cube_pos2, name) then
if name then
minetest.chat_send_player(name, "Unable to build portal, area is protected.")
end
return
end
-- Calculate "bottom-left" position of the PORTAL blocks.
-- Offset Y because we don't want it in the floor.
-- Offset X and Z to fit the frame and place the portal in the middle-ish.
local pos = add(cube_pos1, vector.new(1,1,1))
if clear_before_build then
local clear1, clear2
if orientation == 0 then
clear1 = vector.new(
cube_pos1.x,
cube_pos1.y + 1, -- do not delete floor
cube_pos1.z
)
clear2 = vector.new(
cube_pos1.x + width - 1,
cube_pos1.y + height - 2,
cube_pos1.z + 2 -- both sides of the entrance, so player has somewhere to step.
)
else
clear1 = vector.new(
cube_pos1.x,
cube_pos1.y + 1, -- do not delete floor
cube_pos1.z
)
clear2 = vector.new(
cube_pos1.x + 2, -- both sides of the entrance, so player has somewhere to step.
cube_pos1.y + height - 2,
cube_pos1.z + width - 1
)
end
log("verbose", "Clearing between "..pos_to_string(clear1).." and "..pos_to_string(clear2))
local airs = {}
for x = clear1.x, clear2.x do
for y = clear1.y, clear2.y do
for z = clear1.z, clear2.z do
airs[#airs+1] = vector.new(x,y,z)
end
end
end
minetest.bulk_set_node(airs, {name="air"})
end
build_and_light_frame(pos.x, pos.y, pos.z, pos.x + (1 - orientation) * (width_inner - 1), pos.y + height_inner - 1, pos.z + orientation * (width_inner - 1), name)
-- Build obsidian platform:
for x = pos.x - orientation, pos.x + orientation + (width_inner - 1) * (1 - orientation), 1 + orientation do
for z = pos.z - 1 + orientation, pos.z + 1 - orientation + (width_inner - 1) * orientation, 2 - orientation do
local pp = {x = x, y = pos.y - 1, z = z}
local pp_1 = {x = x, y = pos.y - 2, z = z}
local nn = get_node(pp).name
local nn_1 = get_node(pp_1).name
if ((nn=="air" and nn_1 == "air") or not registered_nodes[nn].is_ground_content) and not is_protected(pp, name) then
set_node(pp, {name = OBSIDIAN})
end
end
end
log("verbose", "Portal generated at "..pos_to_string(pos).."!")
return pos
end
-- Spawn portal at location - spawning is not guaranteed if target area is protected.
function mcl_portals.spawn_nether_portal(pos, rot, pr, name)
if not pos then return end
local o = 0
if rot then
if rot == "270" or rot=="90" then
o = 1
elseif rot == "random" then
o = random(0,1)
end
end
return build_nether_portal(pos, W_MIN, H_MIN, o, name, true)
end
-- Useful for testing - it lets player point to a block and create the portal in
-- the exact spot using the block as floor, with correct orientation (facing the
-- player). Alternatively, portals can be created at exact locations and
-- orientations (which spawn structure doesn't support).
minetest.register_chatcommand("spawnportal", {
description = S("Spawn a new nether portal at pointed thing, or at [x],[y],[z]. "
.."The portal will either face the player, or use the passed [orientation]. "
.."Orientation 0 means alongside X axis."),
privs = { debug = true },
params = "[x] [y] [z] [orientation]",
func = function(name, param)
local params = {}
for p in param:gmatch("-?[0-9]+") do table.insert(params, p) end
local exit
if #params==0 then
local player = minetest.get_player_by_name(name)
if not player then return false, "Player not found" end
local yaw = player:get_look_horizontal()
local player_rotation = yaw / (math.pi*2)
local orientation
if (player_rotation<=0.875 and player_rotation>0.625)
or (player_rotation<=0.375 and player_rotation>0.125)
then
orientation = "90"
else
orientation = "0"
end
local pointed_thing = mcl_util.get_pointed_thing(player, false)
if not pointed_thing then return false, "Not pointing to anything" end
if pointed_thing.type~="node" then return false, "Not pointing to a node" end
local pos = pointed_thing.under
-- Portal node will appear above the pointed node. The pointed node will turn into obsidian.
exit = mcl_portals.spawn_nether_portal(add(pos, vector.new(-1,0,-1)), orientation, nil, name)
elseif #params==3 or #params==4 then
pos = {
x=tonumber(params[1]),
y=tonumber(params[2]),
z=tonumber(params[3])
}
local orientation = 0
if #params==4 then
if tonumber(params[4])==1 then orientation = "90" else orientation = "0" end
end
-- Portal will be placed so that the first registered exit is at requested location.
exit = mcl_portals.spawn_nether_portal(add(pos, vector.new(-1,-1,-1)), orientation, nil, name)
else
return false, "Invalid parameters. Pass either zero, three or four"
end
if exit then
return true, "Spawned!"
else
return false, "Unable to spawn portal, area could be protected"
end
end,
})
-- Teleportation cooloff for some seconds, to prevent back-and-forth teleportation
local function stop_teleport_cooloff(o)
cooloff[o] = nil
chatter[o] = nil
end
local function teleport_cooloff(obj)
cooloff[obj] = true
if obj:is_player() then
minetest.after(PLAYER_COOLOFF, stop_teleport_cooloff, obj)
else
minetest.after(MOB_COOLOFF, stop_teleport_cooloff, obj)
end
end
local function finalize_teleport(obj, exit)
if not obj or not exit or not exit.x or not exit.y or not exit.z then return end
local objpos = obj:get_pos()
if not objpos then return end
local is_player = obj:is_player()
local name
if is_player then
name = obj:get_player_name()
end
local _, dim = mcl_worlds.y_to_layer(exit.y)
-- If player stands, player is at ca. something+0.5 which might cause precision problems, so we used ceil for objpos.y
objpos = {x = floor(objpos.x+0.5), y = ceil(objpos.y), z = floor(objpos.z+0.5)}
if get_node(objpos).name ~= PORTAL then
log("action", "Entity no longer standing in portal")
return
end
-- In case something went wrong, make sure the origin portal is always added as a viable exit.
-- In the ideal case, this is not needed, as the exits are added upon ignition. Before Jan 2024 this
-- was broken, so there will be a lot of half-added portals out there!
add_exits({objpos})
-- Enable teleportation cooloff for some seconds, to prevent back-and-forth teleportation
teleport_cooloff(obj)
obj:set_pos(exit)
local lua_entity = obj:get_luaentity()
if is_player then
mcl_worlds.dimension_change(obj, dim)
minetest.sound_play("mcl_portals_teleport", {pos=exit, gain=0.5, max_hear_distance = 16}, true)
log("action", "Player "..name.." teleported to portal at "..pos_to_string(exit)..".")
if dim == "nether" then
awards.unlock(obj:get_player_name(), "mcl:theNether")
end
elseif lua_entity then
log("verbose", string.format(
"Entity %s teleported to portal at %s",
lua_entity.name,
pos_to_string(exit)
))
end
end
local function is_origin_queued(origin)
local key = pos_to_string(origin)
if origin_queue[key] then
return true
else
return false
end
end
local function is_entity_queued()
for _, q in pairs(origin_queue) do
if q[obj] then
return true
end
end
return false
end
local function origin_enqueue(obj, origin)
local key = pos_to_string(origin)
log("verbose", string.format("Queueing entity for origin %s", key))
local q = origin_queue[key] or {}
if not q[obj] then
q[obj] = true
origin_queue[key] = q
end
end
-- Flush the queue of entities waiting for specific origin.
-- Pass nil/false exit to purge the queue without teleporting.
local function origin_flush(origin, exit)
local key = pos_to_string(origin)
local count_teleported = 0
local count_removed = 0
if origin_queue[key] then
for obj, value in pairs(origin_queue[key]) do
if value and exit then
finalize_teleport(obj, exit)
count_teleported = count_teleported + 1
else
count_removed = count_removed + 1
end
end
origin_queue[key] = nil
end
log("verbose", string.format(
"Finished flushing entities waiting for origin %s: removed %d, proceeded to teleport %d",
key,
count_removed,
count_teleported
))
end
local function find_build_limits(pos, target_dim)
-- Find node extremes of the pos's chunk.
-- According to what people said in minetest discord and couple of docs, mapgen
-- works on entire chunks, so we need to limit the search to chunk boundary.
-- The goal is to emerge at most two chunks.
local chunk_limit1 = add(
mul(
mcl_vars.pos_to_chunk(pos),
mcl_vars.chunk_size_in_nodes
),
vector.new(
mcl_vars.central_chunk_offset_in_nodes,
mcl_vars.central_chunk_offset_in_nodes,
mcl_vars.central_chunk_offset_in_nodes
)
)
local chunk_limit2 = add(
chunk_limit1,
vector.new(
mcl_vars.chunk_size_in_nodes - 1,
mcl_vars.chunk_size_in_nodes - 1,
mcl_vars.chunk_size_in_nodes - 1
)
)
-- Limit search area by using search distances. There is no Y build limit.
local build_limit1 = add(
pos,
-- minus 1 to account for the pos block being included.
-- plus 1 to account for the portal block offset (ignore frame)
vector.new(-BUILD_DISTANCE_XZ-1+1, 0, -BUILD_DISTANCE_XZ-1+1)
)
local build_limit2 = add(
pos,
-- plus 1 to account for the portal block offset (ignore frame)
-- minus potential portal width, so that the generated portal doesn't "stick out"
vector.new(BUILD_DISTANCE_XZ+1-(W_MIN-1), 0, BUILD_DISTANCE_XZ+1-(W_MIN-1))
)
-- Start with chunk limits
pos1 = vector.new(chunk_limit1.x, chunk_limit1.y, chunk_limit1.z)
pos2 = vector.new(chunk_limit2.x, chunk_limit2.y, chunk_limit2.z)
-- Make sure the portal is not built beyond chunk boundary
-- (we will be searching for the node with lowest X, Y and Z)
pos2.x = pos2.x-(W_MIN-1)
pos2.y = pos2.y-(H_MIN-1)
pos2.z = pos2.z-(W_MIN-1)
-- Avoid negative volumes
if pos2.x < pos1.x then pos2.x = pos1.x end
if pos2.y < pos1.y then pos2.y = pos1.y end
if pos2.z < pos1.z then pos2.z = pos1.z end
-- Apply build distances.
pos1 = {
x = max(pos1.x, build_limit1.x),
y = pos1.y,
z = max(pos1.z, build_limit1.z)
}
pos2 = {
x = min(pos2.x, build_limit2.x),
y = pos2.y,
z = min(pos2.z, build_limit2.z)
}
-- Apply dimension-specific distances, so that player does not end up in void or in lava.
local limit1, limit2 = limits[target_dim].pmin, limits[target_dim].pmax
pos1 = {
x = max(pos1.x, limit1.x),
y = max(pos1.y, limit1.y),
z = max(pos1.z, limit1.z)
}
pos2 = {
x = min(pos2.x, limit2.x),
y = min(pos2.y, limit2.y),
z = min(pos2.z, limit2.z)
}
local diff = add(pos2, mul(pos1, -1))
local area = diff.x * diff.z
local msg = string.format(
"Portal build area between %s-%s, a %dx%dx%d cuboid with floor area of %d nodes. "
.."Chunk limit was at [%s,%s]. "
.."Ideal build area was at [(%d,*,%d),(%d,*,%d)].",
pos_to_string(pos1),
pos_to_string(pos2),
diff.x,
diff.y,
diff.z,
area,
pos_to_string(chunk_limit1),
pos_to_string(chunk_limit2),
build_limit1.x,
build_limit1.z,
build_limit2.x,
build_limit2.z
)
log("verbose", msg)
return pos1, pos2
end
local function get_lava_level(pos, pos1, pos2)
if pos.y > -1000 then
return mcl_vars.mg_lava_overworld_max
end
return mcl_vars.mg_lava_nether_max
end
local function search_for_build_location(blockpos, action, calls_remaining, param)
if calls_remaining and calls_remaining > 0 then return end
local target, pos1, pos2, name, obj = param.target, param.pos1, param.pos2, param.name or "", param.obj
local chunk = mcl_vars.get_chunk_number(target)
local pos0, distance
local most_airy_count, most_airy_pos, most_airy_distance = param.most_airy_count, param.most_airy_pos, param.most_airy_distance
local lava = get_lava_level(target, pos1, pos2)
-- Portal might still exist in the area even though nothing was found in the table.
-- This could be due to bugs, or old worlds (portals added before the exits table).
-- Handle gracefully by searching and adding exits as appropriate.
local exit
local portals = find_nodes_in_area(pos1, pos2, {PORTAL})
if portals and #portals>0 then
for _, p in pairs(portals) do
-- This will only add legitimate exits that are not on the list,
-- and will only save if there was any changes.
add_exits({p})
end
if param.target_dim=="nether" then
exit = find_exit(target, SEARCH_DISTANCE_NETHER, SEARCH_DISTANCE_NETHER)
else
exit = find_exit(target, SEARCH_DISTANCE_OVERWORLD, SEARCH_DISTANCE_OVERWORLD)
end
end
if exit then
log("verbose", "Using a newly found exit at "..pos_to_string(exit))
origin_flush(param.origin, exit)
chunk_building[chunk] = false
return
end
-- No suitable portal was found, look for a suitable space and build a new one in the emerged blocks.
local nodes = find_nodes_in_area_under_air(pos1, pos2, {"group:building_block"})
-- Sort by distance so that we are checking the nearest nodes first.
-- This can speed up the search considerably if there is space around the ideal X and Z.
table.sort(nodes, function(a,b)
local da = dist(param.ideal_target, add(a, vector.new(1,1,1)))
local db = dist(param.ideal_target, add(b, vector.new(1,1,1)))
return da<db
end)
if nodes then
local nc = #nodes
if nc > 0 then
for i=1,nc do
local node = nodes[i]
local portal_node = add(node, vector.new(1,1,1)) -- Skip the frame
local node1 = add(node, vector.new(0,1,0)) -- Floor can be solid
local node2 = add(node, vector.new(W_MIN-1,H_MIN-1,W_MIN-1))
local nodes2 = find_nodes_in_area(node1, node2, {"air"})
if nodes2 then
local nc2 = #nodes2
if not is_area_protected(node, node2, name) and node.y > lava then
local distance0 = dist(param.ideal_target, portal_node)
if nc2 >= (W_MIN*(H_MIN-1)*W_MIN) - ACCEPTABLE_PORTAL_REPLACES then
-- We have sorted the candidates by distance, this is the best location.
distance = distance0
pos0 = {x=node.x, y=node.y, z=node.z}
log("verbose", "Found acceptable location at "..pos_to_string(pos0)..", distance "..distance0..", air nodes "..nc2)
break
elseif not most_airy_pos or nc2>most_airy_count then
-- Remember the cube with the most amount of air as a fallback.
most_airy_count = nc2
most_airy_distance = distance0
most_airy_pos = {x=node.x, y=node.y, z=node.z}
log("verbose", "Found fallback location at "..pos_to_string(most_airy_pos)..", distance "..distance0..", air nodes "..nc2)
elseif most_airy_pos and nc2==most_airy_count and distance0<most_airy_distance then
-- Use distance as a tiebreaker.
most_airy_distance = distance0
most_airy_pos = {x=node.x, y=node.y, z=node.z}
log("verbose", "Found fallback location at "..pos_to_string(most_airy_pos)..", distance "..distance0..", air nodes "..nc2)
end
end
end
end
end
end
if pos0 then
log("verbose", "Building portal at "..pos_to_string(pos0)..", distance "..distance)
local exit = build_nether_portal(pos0, W_MIN, H_MIN, random(0,1), name)
origin_flush(param.origin, exit)
chunk_building[chunk] = false
return
end
-- Look in chunks above or below, depending on which side has more
-- space. Since our Y map distance is quite short due to the flatness of
-- the non-lava nether, falling back like this should cover entire Y range.
if param.chunk_counter==1 then
if limits[param.target_dim].pmax.y-target.y > target.y-limits[param.target_dim].pmin.y then
-- Look up
direction = 1
log("verbose", "No space found, emerging one chunk above")
else
-- Look down
direction = -1
log("verbose", "No space found, emerging one chunk below")
end
local new_target = {x=target.x, y=target.y + direction * mcl_vars.chunk_size_in_nodes, z=target.z}
pos1, pos2 = find_build_limits(new_target, param.target_dim)
local diff = add(pos2, mul(pos1, -1))
-- Only emerge if there is sufficient headroom to actually fit entire portal.
if diff.y+1>=H_MIN then
local new_chunk = mcl_vars.get_chunk_number(new_target)
if chunk_building[new_chunk] then
log("verbose", string.format("Secondary chunk %s is currently busy, backing off", new_chunk))
origin_flush(param.origin, nil)
return
end
chunk_building[new_chunk] = true
minetest.emerge_area(pos1, pos2, search_for_build_location, {
origin=param.origin,
target = new_target,
target_dim = target_dim,
ideal_target = param.ideal_target,
pos1 = pos1,
pos2 = pos2,
name=name,
obj=obj,
chunk_counter=param.chunk_counter+1,
most_airy_count=most_airy_count,
most_airy_pos=most_airy_pos,
most_airy_distance=most_airy_distance
})
chunk_building[chunk] = false
return
end
end
-- Fall back to the most airy position in previous chunk, in this chunk,
-- or if all else fails to ideal position. This could replace a lot of blocks.
local fallback = param.ideal_target
if most_airy_pos then
log(
"verbose",
string.format(
"Falling back to the most airy position at %s, distance %d",
pos_to_string(most_airy_pos),
most_airy_distance
)
)
fallback = most_airy_pos
end
if fallback.y <= lava then
fallback.y = lava + 1
end
log("verbose", "Forcing portal at "..pos_to_string(fallback)..", lava at "..lava)
local exit = build_nether_portal(fallback, W_MIN, H_MIN, random(0,1), name, true)
origin_flush(param.origin, exit)
chunk_building[chunk] = false
end
local function create_portal(origin, target, target_dim, name, obj)
local chunk = mcl_vars.get_chunk_number(target)
if chunk_building[chunk] then
log("verbose", string.format("Primary chunk %s is currently busy, backing off", chunk))
origin_flush(origin, nil)
return
end
chunk_building[chunk] = true
local pos1, pos2 = find_build_limits(target, target_dim)
minetest.emerge_area(pos1, pos2, search_for_build_location, {
origin = origin,
target = target,
target_dim = target_dim,
ideal_target = vector.new(target.x, target.y, target.z), -- copy
pos1 = pos1,
pos2 = pos2,
name=name,
obj=obj,
chunk_counter=1
})
end
local function available_for_nether_portal(p)
-- No need to check for protected - non-owner can't ignite blocks anyway.
local nn = get_node(p).name
local obsidian = nn == OBSIDIAN
if nn ~= "air" and minetest.get_item_group(nn, "fire") ~= 1 then
return false, obsidian
end
return true, obsidian
end
local function check_and_light_shape(pos, orientation)
local stack = {{x = pos.x, y = pos.y, z = pos.z}}
local node_list = {}
local index_list = {}
local node_counter = 0
-- Search most low node from the left (pos1) and most right node from the top (pos2)
local pos1 = {x = pos.x, y = pos.y, z = pos.z}
local pos2 = {x = pos.x, y = pos.y, z = pos.z}
local kx, ky, kz = pos.x - 1999, pos.y - 1999, pos.z - 1999
while #stack > 0 do
local i = #stack
local x, y, z = stack[i].x, stack[i].y, stack[i].z
local k = (x-kx)*16000000 + (y-ky)*4000 + z-kz
if index_list[k] then
stack[i] = nil -- Already checked, skip it
else
local good, obsidian = available_for_nether_portal(stack[i])
if obsidian then
stack[i] = nil
else
if (not good) or (node_counter >= N_MAX) then
return false
end
node_counter = node_counter + 1
node_list[node_counter] = {x = x, y = y, z = z}
index_list[k] = true
stack[i].y = y - 1
stack[i + 1] = {x = x, y = y + 1, z = z}
if orientation == 0 then
stack[i + 2] = {x = x - 1, y = y, z = z}
stack[i + 3] = {x = x + 1, y = y, z = z}
else
stack[i + 2] = {x = x, y = y, z = z - 1}
stack[i + 3] = {x = x, y = y, z = z + 1}
end
if (y < pos1.y) or (y == pos1.y and (x < pos1.x or z < pos1.z)) then
pos1 = {x = x, y = y, z = z}
end
if (x > pos2.x or z > pos2.z) or (x == pos2.x and z == pos2.z and y > pos2.y) then
pos2 = {x = x, y = y, z = z}
end
end
end
end
if node_counter < N_MIN then
return false
end
-- Limit rectangles width and height
if abs(pos2.x - pos1.x + pos2.z - pos1.z) + 3 > W_MAX or abs(pos2.y - pos1.y) + 3 > H_MAX then
return false
end
-- Light the portal
for i = 1, node_counter do
minetest.set_node(node_list[i], {name = PORTAL, param2 = orientation})
end
-- Register valid portal exits (each portal has at least two!)
-- Before Jan 2024, there was a bug that did not register all exits upon ignition.
-- This means portals lit before that time will only become live as people use them
-- (and only specific portal blocks).
for i = 1, node_counter do
-- Improvement possible: we are only interested in the bottom
-- blocks as exits, but here all ignited blocks are passed in.
-- This can cause a lot of failed validations on very large
-- portals that we know can be skipped.
add_exits({node_list[i]})
end
return true
end
-- Attempts to light a Nether portal at pos
-- Pos can be any of the inner part.
-- The frame MUST be filled only with air or any fire, which will be replaced with Nether portal blocks.
-- If no Nether portal can be lit, nothing happens.
-- Returns true if portal created
function mcl_portals.light_nether_portal(pos)
-- Only allow to make portals in Overworld and Nether
local dim = mcl_worlds.pos_to_dimension(pos)
if dim ~= "overworld" and dim ~= "nether" then
return false
end
if not get_target(pos) then
-- Prevent ignition of portals that would lead to out of bounds positions.
log("verbose", string.format(
"No target found for position %s - portal would lead to invalid exit",
pos_to_string(pos)
))
return
end
local orientation = random(0, 1)
for orientation_iteration = 1, 2 do
if check_and_light_shape(pos, orientation) then
return true
end
orientation = 1 - orientation
end
return false
end
local function check_portal_then_teleport(obj, origin, exit)
-- Check we are not sending the player on a one-way trip.
minetest.emerge_area(exit, exit, function (blockpos, action, calls_remaining, param)
if calls_remaining and calls_remaining > 0 then return end
if get_node(exit).name ~= PORTAL then
-- Bogus exit! Break the teleportation so we don't strand the player.
-- The process will begin again after cooloff through the ABM, and might either
-- find another exit, or build a new portal. This will manifest to the
-- player as a teleportation that takes longer than usual.
log("warning", "removing bogus portal exit encountered at "..pos_to_string(exit)..", exit no longer exists")
remove_exits({exit})
-- Also remove from structure storage, otherwise ABM will try the same bad exit again.
local objpos = obj:get_pos()
delete_portal_pos({x = floor(objpos.x+0.5), y = ceil(objpos.y), z = floor(objpos.z+0.5)})
origin_flush(origin, nil)
return
end
origin_flush(origin, exit)
end)
end
-- Teleport function
local function teleport_no_delay(obj, portal_pos)
local is_player = obj:is_player()
if (not is_player and not obj:get_luaentity()) or cooloff[obj] then return end
local objpos = obj:get_pos()
if not objpos then return end
local _, current_dim = mcl_worlds.y_to_layer(objpos.y)
local target_dim = dimension_to_teleport[current_dim]
-- If player stands, player is at ca. something+0.5 which might cause precision problems, so we used ceil for objpos.y
origin = {x = floor(objpos.x+0.5), y = ceil(objpos.y), z = floor(objpos.z+0.5)}
if get_node(origin).name ~= PORTAL then return end
local target = get_target(origin)
if not target then
log("verbose", string.format(
"No target found for position %s - no valid exit found",
pos_to_string(origin)
))
return
end
local name
if is_player then
name = obj:get_player_name()
end
if is_entity_queued(obj) then
-- Let's not allow one entity to generate a lot of work by just moving around in a portal.
log("verbose", "Entity already queued")
return
end
log("verbose", string.format("Target calculated as %s", pos_to_string(target)))
local already_queued = is_origin_queued(origin)
origin_enqueue(obj, origin)
if already_queued then
-- Origin is already being processed, so wait in queue for the result.
log("verbose", string.format("Origin %s already queued", pos_to_string(origin)))
return
end
local exit
local saved_portal_position = get_portal_pos(origin)
if saved_portal_position then
-- Before Jan 2024, portal exits were sticky - they were stored
-- in nodes. If such a position is found, look for the exit
-- there, so that the players don't get any surprises.
-- Sticky exit can be removed by destroying and rebuilding the portal.
log("verbose", "Using block-saved portal exit: "..pos_to_string(saved_portal_position)..".")
exit = find_exit(saved_portal_position, 10, 10)
end
if not exit then
-- Search for nearest suitable exit in the lookup table.
if target_dim=="nether" then
exit = find_exit(target, SEARCH_DISTANCE_NETHER, SEARCH_DISTANCE_NETHER)
else
exit = find_exit(target, SEARCH_DISTANCE_OVERWORLD, SEARCH_DISTANCE_OVERWORLD)
end
end
if exit then
log("verbose", "Exit found at "..pos_to_string(exit).." for target "..pos_to_string(target).." traveling from "..pos_to_string(origin))
check_portal_then_teleport(obj, origin, exit)
else
create_portal(origin, target, target_dim, name, obj)
end
end
local function prevent_portal_chatter(obj)
local time_us = get_us_time()
local ch = chatter[obj] or 0
chatter[obj] = time_us
minetest.after(TOUCH_CHATTER_TIME, function(o)
if o and chatter[o] and get_us_time() - chatter[o] >= CHATTER_US then
chatter[o] = nil
end
end, obj)
return time_us - ch > CHATTER_US
end
local function animation(player, playername)
local ch = chatter[player] or 0
if cooloff[player] or get_us_time() - ch < CHATTER_US then
local pos = player:get_pos()
if not pos then
return
end
minetest.add_particlespawner({
amount = 1,
minpos = {x = pos.x - 0.1, y = pos.y + 1.4, z = pos.z - 0.1},
maxpos = {x = pos.x + 0.1, y = pos.y + 1.6, z = pos.z + 0.1},
minvel = 0,
maxvel = 0,
minacc = 0,
maxacc = 0,
minexptime = 0.1,
maxexptime = 0.2,
minsize = 5,
maxsize = 15,
collisiondetection = false,
texture = "mcl_particles_nether_portal_t.png",
playername = playername,
})
minetest.after(0.3, animation, player, playername)
end
end
local function teleport(obj, portal_pos)
local name = ""
if obj:is_player() then
name = obj:get_player_name()
animation(obj, name)
end
if cooloff[obj] then return end
if minetest.is_creative_enabled(name) then
teleport_no_delay(obj, portal_pos)
return
end
minetest.after(DELAY, teleport_no_delay, obj, portal_pos)
end
minetest.register_abm({
label = "Nether portal teleportation and particles",
nodenames = {PORTAL},
interval = 1,
chance = 1,
action = function(pos, node)
local o = node.param2 -- orientation
local d = random(0, 1) -- direction
local time = random() * 1.9 + 0.5
local velocity, acceleration
if o == 1 then
velocity = {x = random() * 0.7 + 0.3, y = random() - 0.5, z = random() - 0.5}
acceleration = {x = random() * 1.1 + 0.3, y = random() - 0.5, z = random() - 0.5}
else
velocity = {x = random() - 0.5, y = random() - 0.5, z = random() * 0.7 + 0.3}
acceleration = {x = random() - 0.5, y = random() - 0.5, z = random() * 1.1 + 0.3}
end
local distance = add(mul(velocity, time), mul(acceleration, time * time / 2))
if d == 1 then
if o == 1 then
distance.x = -distance.x
velocity.x = -velocity.x
acceleration.x = -acceleration.x
else
distance.z = -distance.z
velocity.z = -velocity.z
acceleration.z = -acceleration.z
end
end
distance = sub(pos, distance)
for _, obj in pairs(minetest.get_objects_inside_radius(pos, 15)) do
if obj:is_player() then
minetest.add_particlespawner({
amount = PARTICLES + 1,
minpos = distance,
maxpos = distance,
minvel = velocity,
maxvel = velocity,
minacc = acceleration,
maxacc = acceleration,
minexptime = time,
maxexptime = time,
minsize = 0.3,
maxsize = 1.8,
collisiondetection = false,
texture = "mcl_particles_nether_portal.png",
playername = obj:get_player_name(),
})
end
end
for _, obj in pairs(minetest.get_objects_inside_radius(pos, 1)) do
-- Teleport players, mobs, boats etc.
local lua_entity = obj:get_luaentity()
if (obj:is_player() or lua_entity) and prevent_portal_chatter(obj) then
teleport(obj, pos)
end
end
end,
})
--[[ ITEM OVERRIDES ]]
local longdesc = registered_nodes[OBSIDIAN]._doc_items_longdesc
longdesc = longdesc .. "\n" .. S("Obsidian is also used as the frame of Nether portals.")
local usagehelp = S("To open a Nether portal, place an upright frame of obsidian with a width of at least 4 blocks and a height of 5 blocks, leaving only air in the center. After placing this frame, light a fire in the obsidian frame. Nether portals only work in the Overworld and the Nether.")
minetest.override_item(OBSIDIAN, {
_doc_items_longdesc = longdesc,
_doc_items_usagehelp = usagehelp,
on_destruct = function(pos, node)
-- Permit extinguishing of protected portals if the frame is
-- sticking out of the protected area to maintain integrity.
local function check_remove(pos, orientation)
local node = get_node(pos)
if node and node.name == PORTAL then
minetest.remove_node(pos)
end
end
-- check each of 6 sides of it and destroy every portal
check_remove({x = pos.x - 1, y = pos.y, z = pos.z})
check_remove({x = pos.x + 1, y = pos.y, z = pos.z})
check_remove({x = pos.x, y = pos.y, z = pos.z - 1})
check_remove({x = pos.x, y = pos.y, z = pos.z + 1})
check_remove({x = pos.x, y = pos.y - 1, z = pos.z})
check_remove({x = pos.x, y = pos.y + 1, z = pos.z})
end,
_on_ignite = function(user, pointed_thing)
local x, y, z = pointed_thing.under.x, pointed_thing.under.y, pointed_thing.under.z
-- Check empty spaces around obsidian and light all frames found.
-- Permit igniting of portals that are partly protected to maintain integrity.
local portals_placed =
mcl_portals.light_nether_portal({x = x - 1, y = y, z = z}) or mcl_portals.light_nether_portal({x = x + 1, y = y, z = z}) or
mcl_portals.light_nether_portal({x = x, y = y - 1, z = z}) or mcl_portals.light_nether_portal({x = x, y = y + 1, z = z}) or
mcl_portals.light_nether_portal({x = x, y = y, z = z - 1}) or mcl_portals.light_nether_portal({x = x, y = y, z = z + 1})
if portals_placed then
log("verbose", "Nether portal activated at "..pos_to_string({x=x,y=y,z=z})..".")
if minetest.get_modpath("doc") then
doc.mark_entry_as_revealed(user:get_player_name(), "nodes", PORTAL)
-- Achievement for finishing a Nether portal TO the Nether
local dim = mcl_worlds.pos_to_dimension({x=x, y=y, z=z})
if minetest.get_modpath("awards") and dim ~= "nether" and user:is_player() then
awards.unlock(user:get_player_name(), "mcl:buildNetherPortal")
end
end
return true
else
return false
end
end,
})
mcl_structures.register_structure("nether_portal",{
nospawn = true,
filenames = {
modpath.."/schematics/mcl_portals_nether_portal.mts"
}
})
mcl_structures.register_structure("nether_portal_open",{
nospawn = true,
filenames = {
modpath.."/schematics/mcl_portals_nether_portal_open.mts"
},
after_place = function(pos,def,pr,blockseed)
-- The mts is the smallest portal (two wide) and places the first PORTAL block
-- above the location of the caller (y+1). The second one is either at x+1 or z+1.
local portals = find_nodes_in_area(add(pos, vector.new(0,1,0)), add(pos, vector.new(1,1,1)), {PORTAL})
if portals and #portals>0 then
for _, p in pairs(portals) do
add_exits({p})
end
end
end
})