mirror of
https://git.minetest.land/VoxeLibre/VoxeLibre.git
synced 2024-11-25 20:11:06 +01:00
1624 lines
53 KiB
Lua
1624 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
|
|
})
|