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 = vector.new(LIM_MIN, N_Y_MIN, LIM_MIN), pmax = vector.new(LIM_MAX, N_Y_MAX, LIM_MAX), }, overworld = { pmin = vector.new(LIM_MIN, O_Y_MIN, LIM_MIN), pmax = vector.new(LIM_MAX, O_Y_MAX, 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 nn = find_nodes_in_area(vector.offset(pos,-5,-1,-5), vector.offset(pos,5,5,5), {"mcl_portals:portal"}) for _,p in pairs(nn) do local m = minetest.get_meta(p):get_string("target_portal") if m and m ~= "" and minetest.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 = vector.new(x, y, z) if get_node(vector.new(x, y-1, z)).name ~= OBSIDIAN or get_node(p).name ~= PORTAL or get_node(vector.new(x, y+1, 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 = vector.new(x, y, 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(vector.offset(pos, -1, 0, 0)) check_remove(vector.offset(pos, 1, 0, 0)) else check_remove(vector.offset(pos, 0, 0, -1)) check_remove(vector.offset(pos, 0, 0, 1)) end check_remove(vector.offset(pos, 0, -1, 0)) check_remove(vector.offset(pos, 0, 1, 0)) 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({ vector.new(pos.x, pos.y-1, 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 = vector.new(x, pos.y - 1, z) local pp_1 = vector.new(x, pos.y - 2, 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 = vector.new(floor(objpos.x+0.5), ceil(objpos.y), 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 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 = vector.new(node.x, node.y, 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 = vector.new(node.x, node.y, 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 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 = vector.new(target.x, target.y + direction * mcl_vars.chunk_size_in_nodes, 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.copy(target), 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 = {vector.copy(pos)} 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, pos2 = vector.copy(pos), vector.copy(pos) 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] = vector.new(x, y, z) index_list[k] = true stack[i].y = y - 1 stack[i + 1] = vector.new(x, y + 1, z) if orientation == 0 then stack[i + 2] = vector.new(x - 1, y, z) stack[i + 3] = vector.new(x + 1, y, z) else stack[i + 2] = vector.new(x, y, z - 1) stack[i + 3] = vector.new(x, y, z + 1) end if (y < pos1.y) or (y == pos1.y and (x < pos1.x or z < pos1.z)) then pos1 = vector.new(x, y, z) end if (x > pos2.x or z > pos2.z) or (x == pos2.x and z == pos2.z and y > pos2.y) then pos2 = vector.new(x, y, 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(vector.new(floor(objpos.x+0.5), ceil(objpos.y), 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 = vector.new(floor(objpos.x+0.5), ceil(objpos.y), 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 = vector.offset(pos, -0.1, 1.4, -0.1), maxpos = vector.offset(pos, 0.1, 1.6, 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 = vector.new(random() * 0.7 + 0.3, random() - 0.5, random() - 0.5) acceleration = vector.new(random() * 1.1 + 0.3, random() - 0.5, random() - 0.5) else velocity = vector.new(random() - 0.5, random() - 0.5, random() * 0.7 + 0.3) acceleration = vector.new(random() - 0.5, random() - 0.5, 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(vector.offset(pos, -1, 0, 0)) check_remove(vector.offset(pos, 1, 0, 0)) check_remove(vector.offset(pos, 0, 0, -1)) check_remove(vector.offset(pos, 0, 0, 1)) check_remove(vector.offset(pos, 0, -1, 0)) check_remove(vector.offset(pos, 0, 1, 0)) 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(vector.new(x - 1, y, z)) or mcl_portals.light_nether_portal(vector.new(x + 1, y, z)) or mcl_portals.light_nether_portal(vector.new(x, y - 1, z)) or mcl_portals.light_nether_portal(vector.new(x, y + 1, z)) or mcl_portals.light_nether_portal(vector.new(x, y, z - 1)) or mcl_portals.light_nether_portal(vector.new(x, y, z + 1)) if portals_placed then log("verbose", "Nether portal activated at "..pos_to_string(vector.new(x, y, 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(vector.new(x, y, 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, }) vl_structures.register_structure("nether_portal",{ nospawn = true, filenames = { modpath.."/schematics/mcl_portals_nether_portal.mts" } }) vl_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 })