1533 lines
54 KiB
Lua
1533 lines
54 KiB
Lua
local S = minetest.get_translator("lzr_editor")
|
||
local FS = function(...) return minetest.formspec_escape(S(...)) end
|
||
local NS = function(s) return end
|
||
|
||
local F = minetest.formspec_escape
|
||
|
||
local select_item = dofile(minetest.get_modpath("lzr_editor").."/select_item.lua")
|
||
|
||
-- Text color the special autosave level will be highlighted
|
||
-- in the save/load dialog
|
||
local AUTOSAVE_HIGHLIGHT = "#FF8060"
|
||
|
||
-- List of items to give to player on editor entry.
|
||
-- This must be compatible with inventory:set_list().
|
||
local INITIAL_EDITOR_ITEMS = {
|
||
-- tools
|
||
"lzr_tools:ultra_pickaxe",
|
||
"lzr_tools:ultra_bucket",
|
||
"lzr_hook:hook",
|
||
"lzr_laser:block_state_toggler",
|
||
"lzr_laser:trigger_tool",
|
||
"lzr_laser:color_changer",
|
||
"lzr_tools:variant_changer",
|
||
"lzr_laser:screw_changer",
|
||
-- important nodes
|
||
"lzr_laser:emitter_red_fixed_on",
|
||
"lzr_laser:detector_fixed",
|
||
"lzr_teleporter:teleporter_off",
|
||
"lzr_treasure:chest_wood_locked",
|
||
"lzr_treasure:chest_dark_locked",
|
||
"lzr_laser:mirror_fixed",
|
||
"lzr_laser:double_mirror_00_fixed",
|
||
"lzr_laser:transmissive_mirror_00_fixed",
|
||
}
|
||
|
||
-- If the player is this far away out of the level bounds,
|
||
-- teleport back to level on level resize, level move
|
||
-- or level load.
|
||
local OUT_OF_BOUNDS_TOLERANCE = 1
|
||
|
||
lzr_editor = {}
|
||
-- Remember if player has already entered the level editor in this session
|
||
lzr_editor.first_time = true
|
||
-- Remember if the WorldEdit usage warning has already been shown in this session
|
||
-- (a warning is shown when a WorldEdit command is used because some commands
|
||
-- might break the triggers)
|
||
lzr_editor.worldedit_warning = false
|
||
|
||
-- The level state holds the metadata for
|
||
-- the level that is being currently edited.
|
||
-- INITIAL_LEVEL_STATE is the default state
|
||
-- and the state when the editor starts.
|
||
-- NOTE: level size and position are stored by
|
||
-- the lzr_world mod.
|
||
local INITIAL_LEVEL_STATE = {
|
||
name = "",
|
||
file_name = "",
|
||
size = lzr_globals.DEFAULT_LEVEL_SIZE,
|
||
wall = lzr_globals.DEFAULT_WALL_NODE,
|
||
ceiling = lzr_globals.DEFAULT_CEILING_NODE,
|
||
floor = lzr_globals.DEFAULT_FLOOR_NODE,
|
||
ambient = lzr_ambience.DEFAULT_AMBIENCE,
|
||
sky = lzr_globals.DEFAULT_SKY,
|
||
npc_texts = lzr_globals.DEFAULT_NPC_TEXTS,
|
||
weather = lzr_globals.DEFAULT_WEATHER,
|
||
backdrop = lzr_globals.DEFAULT_BACKDROP,
|
||
backdrop_pos = lzr_globals.DEFAULT_BACKDROP_POS,
|
||
triggers = "",
|
||
}
|
||
local level_state = table.copy(INITIAL_LEVEL_STATE)
|
||
|
||
local temp_settings_state = {}
|
||
|
||
-- Used to store the current contents for the
|
||
-- textlist contents of the custom level file list.
|
||
-- (this assumes singleplayer)
|
||
local level_file_textlist_state = {}
|
||
|
||
-- Error and warning texts for the level checker
|
||
local error_warning_texts = {
|
||
no_teleporter = S("No teleporter"),
|
||
too_many_teleporters = S("More than one teleporter"),
|
||
barriers = S("Barrier or rain membrane in level area"),
|
||
gold_block = S("Bare gold block in level area"),
|
||
plant_on_ground = S("Rooted plant in level area"),
|
||
too_many_parrot_spawners = S("More than one parrot spawner"),
|
||
too_many_hidden_parrot_spawners = S("More than one hidden parrot spawner"),
|
||
--~ param2 is an internal value used by blocks to store some state
|
||
bad_hidden_parrot_spawner = S("Bad param2 value for hidden parrot spawner"),
|
||
trigger_out_of_bounds = S("Trigger is out of bounds"),
|
||
trigger_moved = S("Trigger ID does not match location"),
|
||
laser_incompatible = S("Laser-incompatible node found"),
|
||
no_treasures = S("No treasures to collect"),
|
||
--~ Warning shown when a level has an incomplete door
|
||
half_door = S("Incomplete door"),
|
||
--~ Warning shown when a level has an invalid door. param2 is an internal value used by blocks to store some state
|
||
incompatible_door_segments_param2 = S("Mismatching param2 value for door segments"),
|
||
--~ Warning shown when a level has an invalid door
|
||
incompatible_door_segments_type = S("Mismatching type for door segments"),
|
||
}
|
||
-- Add error messages from lzr_triggers as well
|
||
for id, text in pairs(lzr_triggers.CHECK_ERROR_TEXTS) do
|
||
error_warning_texts["lzr_triggers_"..id] = text
|
||
end
|
||
|
||
-- Creates a nice-looking string listing all level warnings and errors.
|
||
-- Then returns it.
|
||
-- If everything is OK, returns the empty string.
|
||
local list_warnings_and_errors = function()
|
||
local result_str = ""
|
||
local check1_ok, errors = lzr_editor.check_level_errors()
|
||
if not check1_ok then
|
||
-- Level error found.
|
||
local error_texts = {}
|
||
for e=1, #errors do
|
||
table.insert(error_texts, S("• Error: @1", error_warning_texts[errors[e]] or errors[e]))
|
||
end
|
||
local errors_str = table.concat(error_texts, "\n")
|
||
result_str = result_str .. minetest.colorize("#FF8000", errors_str)
|
||
end
|
||
local check2_ok, warnings = lzr_editor.check_level_warnings()
|
||
if not check2_ok then
|
||
local warning_texts = {}
|
||
for w=1, #warnings do
|
||
table.insert(warning_texts, S("• Warning: @1", error_warning_texts[warnings[w]] or warnings[w]))
|
||
end
|
||
local warnings_str = table.concat(warning_texts, "\n")
|
||
if not check1_ok then
|
||
result_str = result_str .. "\n"
|
||
end
|
||
result_str = result_str .. minetest.colorize("#FFFF00", warnings_str)
|
||
end
|
||
return result_str
|
||
end
|
||
|
||
-- Move player to the level if they're too far away
|
||
local function move_player_to_level_if_neccessary(player)
|
||
local lsoffset = vector.new(OUT_OF_BOUNDS_TOLERANCE, OUT_OF_BOUNDS_TOLERANCE, OUT_OF_BOUNDS_TOLERANCE)
|
||
local lsminpos = vector.subtract(lzr_world.get_level_pos(), lsoffset)
|
||
local lsmaxpos = vector.add(vector.add(lzr_world.get_level_pos(), lzr_world.get_level_size()), lsoffset)
|
||
if not vector.in_area(player:get_pos(), lsminpos, lsmaxpos) then
|
||
player:set_pos(lzr_world.get_level_pos())
|
||
end
|
||
end
|
||
|
||
-- Give useful starter items to player
|
||
local function give_initial_items(player)
|
||
local inv = player:get_inventory()
|
||
-- Set all items at once
|
||
inv:set_list("main", INITIAL_EDITOR_ITEMS)
|
||
|
||
-- Check if we actually have those items because we're paranoid
|
||
for i=1, #INITIAL_EDITOR_ITEMS do
|
||
local item = inv:get_stack("main", i)
|
||
if item:get_name() ~= INITIAL_EDITOR_ITEMS[i] then
|
||
minetest.log("error", "[lzr_editor] Wrong or missing initial item at position "..i..": expected "..INITIAL_EDITOR_ITEMS[i].." but got "..item:get_name())
|
||
break
|
||
end
|
||
end
|
||
end
|
||
|
||
-- Returns true if pos is within the current bounds
|
||
-- of the actively edited level.
|
||
function lzr_editor.is_in_level_bounds(pos)
|
||
local offset = table.copy(lzr_world.get_level_size())
|
||
offset = vector.offset(offset, -1, -1, -1)
|
||
return vector.in_area(pos, lzr_world.get_level_pos(), vector.add(lzr_world.get_level_pos(), offset))
|
||
end
|
||
|
||
-- Enter editor state (if not already in it).
|
||
-- Returns true if state changed, false if already in editor.
|
||
function lzr_editor.enter_editor(player)
|
||
local state = lzr_gamestate.get_state()
|
||
if state == lzr_gamestate.EDITOR then
|
||
return false
|
||
else
|
||
level_state = table.copy(INITIAL_LEVEL_STATE)
|
||
|
||
local pos = lzr_world.get_level_pos()
|
||
local size = lzr_world.get_level_size()
|
||
lzr_levels.reset_level_area(false, pos, size)
|
||
|
||
lzr_world.set_level_pos(lzr_globals.DEFAULT_LEVEL_POS)
|
||
lzr_world.set_level_size(lzr_globals.DEFAULT_LEVEL_SIZE)
|
||
pos = lzr_world.get_level_pos()
|
||
size = lzr_world.get_level_size()
|
||
lzr_triggers.reset_triggers()
|
||
|
||
lzr_gamestate.set_state(lzr_gamestate.EDITOR)
|
||
|
||
lzr_ambience.set_ambience(lzr_ambience.DEFAULT_AMBIENCE)
|
||
lzr_sky.set_sky(lzr_globals.DEFAULT_SKY)
|
||
lzr_weather.set_weather(lzr_globals.DEFAULT_WEATHER)
|
||
|
||
|
||
lzr_gui.set_loading_gui(player)
|
||
lzr_player.set_editor_inventory(player, true)
|
||
lzr_player.set_loading_inventory_formspec(player)
|
||
lzr_gamestate.set_loading(true)
|
||
|
||
-- Stuff to do after the editor was loaded
|
||
local done = function()
|
||
|
||
lzr_player.set_editor_inventory(player)
|
||
|
||
give_initial_items(player)
|
||
|
||
lzr_privs.grant_edit_privs(player)
|
||
|
||
if lzr_editor.first_time then
|
||
local message = S("Welcome to the Level Editor!").."\n"..
|
||
S("See LEVEL_EDITOR.md for instructions.")
|
||
minetest.chat_send_player("singleplayer", message)
|
||
lzr_editor.first_time = false
|
||
end
|
||
|
||
lzr_gui.set_editor_gui(player)
|
||
|
||
lzr_gui.show_level_bounds(player, pos, size)
|
||
|
||
lzr_gamestate.set_loading(false)
|
||
|
||
minetest.log("action", "[lzr_editor] Entered and initialized level editor")
|
||
end
|
||
|
||
lzr_levels.build_room({pos=pos, size=size, spawn_pos=pos, yaw=0, clear=true, callback_done=done})
|
||
minetest.log("action", "[lzr_editor] Entering level editor ...")
|
||
|
||
return true
|
||
end
|
||
end
|
||
|
||
local check_for_slash = function(str)
|
||
if string.match(str, "[/\\]") then
|
||
return true
|
||
else
|
||
return false
|
||
end
|
||
end
|
||
|
||
local save_level = function(level_name, is_autosave)
|
||
if lzr_gamestate.get_state() ~= lzr_gamestate.EDITOR then
|
||
return false
|
||
end
|
||
if check_for_slash(level_name) then
|
||
return false
|
||
end
|
||
|
||
-- Show level errors and warnings when saving manually
|
||
if not is_autosave then
|
||
local result_str = list_warnings_and_errors()
|
||
if result_str ~= "" then
|
||
minetest.chat_send_player("singleplayer", S("The following problems were found in this level:") .. "\n" .. result_str)
|
||
end
|
||
end
|
||
|
||
minetest.mkdir(minetest.get_worldpath().."/levels")
|
||
|
||
local filename = minetest.get_worldpath().."/levels/"..level_name..".mts"
|
||
local size = vector.subtract(lzr_world.get_level_size(), vector.new(1, 1, 1))
|
||
|
||
--[[ <<<The file is saved here >>> ]]
|
||
local ok = minetest.create_schematic(lzr_world.get_level_pos(), vector.add(lzr_world.get_level_pos(), size), {}, filename, {})
|
||
--[[ ^ Note: This saves the level exactly as-is, including the current state with
|
||
all active lasers and active laser blocks. This is intended. The game will
|
||
automatically clear lasers, reset laser blocks and recalculate the lasers on each level
|
||
start anyway, so it shouldn't matter in which 'state' the level is stored.
|
||
Saving the level with the active lasers pre-calculated tho has the slight benefit that
|
||
the level instantly loads with the correct lasers. ]]
|
||
|
||
-- Save level data into CSV
|
||
local csv_filename = minetest.get_worldpath().."/levels/"..level_name..".csv"
|
||
local npc_texts_csv = ""
|
||
if level_state.npc_texts and level_state.npc_texts.goldie then
|
||
npc_texts_csv = level_state.npc_texts.goldie or ""
|
||
end
|
||
local csv_contents = lzr_csv.write_csv({{
|
||
level_name..".mts",
|
||
level_state.name,
|
||
-- level boundaries syntax: wall|<unused>|floor|ceiling
|
||
-- (the <unused> used to be a node, but it has been removed from the game)
|
||
level_state.wall .. "||" .. level_state.floor .. "|" .. level_state.ceiling,
|
||
level_state.ambient,
|
||
level_state.sky,
|
||
npc_texts_csv,
|
||
level_state.weather,
|
||
level_state.backdrop,
|
||
minetest.pos_to_string(level_state.backdrop_pos),
|
||
lzr_levels.serialize_triggers(),
|
||
}})
|
||
local ok_csv = minetest.safe_file_write(csv_filename, csv_contents)
|
||
|
||
if ok and ok_csv then
|
||
minetest.log("action", "[lzr_editor] Level written to "..filename.." and "..csv_filename)
|
||
if not is_autosave then
|
||
level_state.file_name = level_name
|
||
end
|
||
return true, filename, csv_filename
|
||
elseif ok then
|
||
minetest.log("error", "[lzr_editor] Level written to "..filename..", but failed to write CSV!")
|
||
return false, filename, csv_filename
|
||
else
|
||
minetest.log("error", "[lzr_editor] Failed to write level to "..filename)
|
||
return false, filename, csv_filename
|
||
end
|
||
end
|
||
|
||
local autosave_level = function()
|
||
save_level(lzr_globals.AUTOSAVE_NAME, true)
|
||
end
|
||
|
||
-- Exit editor state (if not already outside of editor).
|
||
-- Returns true if state changed, false if could not exit
|
||
local function exit_editor(player)
|
||
if lzr_gamestate.is_loading() then
|
||
return false
|
||
end
|
||
level_state.file_name = ""
|
||
local state = lzr_gamestate.get_state()
|
||
if state ~= lzr_gamestate.EDITOR then
|
||
return false
|
||
else
|
||
lzr_levels.go_to_menu()
|
||
local pos = lzr_world.get_level_pos()
|
||
local size = lzr_world.get_level_size()
|
||
lzr_levels.reset_level_area(false, pos, size)
|
||
return true
|
||
end
|
||
end
|
||
|
||
minetest.register_chatcommand("editor_save", {
|
||
description = S("Save current level"),
|
||
params = S("<level name>"),
|
||
func = function(name, param)
|
||
if lzr_gamestate.get_state() ~= lzr_gamestate.EDITOR then
|
||
return false, S("Not in editor mode!")
|
||
elseif lzr_gamestate.is_loading() then
|
||
return false, S("Can’t do this while loading!")
|
||
end
|
||
if param == "" then
|
||
return false, S("No level name provided.")
|
||
end
|
||
local level_name = param
|
||
if check_for_slash(level_name) then
|
||
return false, S("Level name must not contain slash or backslash!")
|
||
end
|
||
local ok, filename, filename2 = save_level(level_name)
|
||
if ok and filename and filename2 then
|
||
--~ @1 and @2 are file locations
|
||
return true, S("Level saved to @1 and @2.", filename, filename2)
|
||
elseif of and filename then
|
||
--~ @1 and @2 are file locations
|
||
return true, S("Level saved to @1, but could not write metadata to @2.", filename, filename2)
|
||
else
|
||
return false, S("Error writing level file!")
|
||
end
|
||
end,
|
||
})
|
||
|
||
local load_level = function(level_name, player)
|
||
if lzr_gamestate.get_state() ~= lzr_gamestate.EDITOR then
|
||
return false
|
||
end
|
||
|
||
if check_for_slash(level_name) then
|
||
return false
|
||
end
|
||
local filename = level_name..".mts"
|
||
local ok = lzr_util.file_exists(minetest.get_worldpath().."/levels", filename)
|
||
if not ok then
|
||
return false
|
||
end
|
||
local schem = minetest.read_schematic(minetest.get_worldpath().."/levels/"..filename, {write_yslice_prob="none"})
|
||
if schem then
|
||
local csv_file = io.open(minetest.get_worldpath().."/levels/"..level_name..".csv", "r")
|
||
local triggers_str = ""
|
||
if csv_file then
|
||
local csv_string = csv_file:read("*a")
|
||
csv_file:close()
|
||
local csv_parsed = lzr_csv.parse_csv(csv_string)
|
||
if csv_parsed and #csv_parsed >= 1 then
|
||
level_state.name = csv_parsed[1][2]
|
||
local bounds = csv_parsed[1][3]
|
||
local exploded_bounds = string.split(bounds, "|", true)
|
||
if exploded_bounds then
|
||
level_state.wall = exploded_bounds[1]
|
||
local legacy_window = exploded_bounds[2]
|
||
level_state.floor = exploded_bounds[3]
|
||
-- Force ceiling to be barrier if legacy window
|
||
-- node is used so that the level still gets
|
||
-- sunlight
|
||
if legacy_window == "lzr_decor:woodframed_glass" then
|
||
level_state.ceiling = "lzr_core:barrier"
|
||
--~ The "window boundary" refers to a special block used for the boundaries of the level to create windows in the walls. It has been removed in later versions of the game.
|
||
minetest.chat_send_player(player:get_player_name(), S("Note: This level uses a legacy window boundary, which is no longer supported."))
|
||
else
|
||
level_state.ceiling = exploded_bounds[4]
|
||
end
|
||
end
|
||
level_state.ambient = csv_parsed[1][4]
|
||
level_state.sky = csv_parsed[1][5] or lzr_globals.DEFAULT_SKY
|
||
level_state.npc_texts = csv_parsed[1][6]
|
||
if level_state.npc_texts then
|
||
level_state.npc_texts = { goldie = level_state.npc_texts }
|
||
else
|
||
level_state.npc_texts = lzr_globals.DEFAULT_NPC_TEXTS
|
||
end
|
||
level_state.weather = csv_parsed[1][7] or lzr_globals.DEFAULT_WEATHER
|
||
level_state.backdrop = csv_parsed[1][8] or lzr_globals.DEFAULT_BACKDROP
|
||
local parsed_backdrop_pos = csv_parsed[1][9]
|
||
local backdrop_pos
|
||
if parsed_backdrop_pos then
|
||
backdrop_pos = minetest.string_to_pos(parsed_backdrop_pos)
|
||
end
|
||
if backdrop_pos then
|
||
level_state.backdrop_pos = backdrop_pos
|
||
else
|
||
level_state.backdrop_pos = lzr_globals.DEFAULT_BACKDROP_POS
|
||
end
|
||
triggers_str = csv_parsed[1][10] or ""
|
||
else
|
||
minetest.log("error", "[lzr_editor] Error parsing CSV file for "..level_name)
|
||
end
|
||
else
|
||
minetest.log("error", "[lzr_editor] No CSV file found for "..level_name)
|
||
end
|
||
|
||
lzr_world.set_level_size(table.copy(schem.size))
|
||
level_state.size = lzr_world.get_level_size()
|
||
level_state.name = level_state.name or ""
|
||
level_state.wall = level_state.wall or lzr_globals.DEFAULT_WALL_NODE
|
||
level_state.ceiling = level_state.ceiling or lzr_globals.DEFAULT_CEILING_NODE
|
||
level_state.floor = level_state.floor or lzr_globals.DEFAULT_FLOOR_NODE
|
||
level_state.ambient = level_state.ambient or lzr_ambience.DEFAULT_AMBIENCE
|
||
level_state.sky = level_state.sky or lzr_globals.DEFAULT_SKY
|
||
level_state.npc_texts = level_state.npc_texts or lzr_globals.DEFAULT_NPC_TEXTS
|
||
level_state.weather = level_state.weather or lzr_globals.DEFAULT_WEATHER
|
||
level_state.backdrop = level_state.backdrop or lzr_globals.DEFAULT_BACKDROP
|
||
level_state.backdrop_pos = level_state.backdrop_pos or lzr_globals.DEFAULT_BACKDROP_POS
|
||
|
||
if level_state.backdrop == "ocean" then
|
||
lzr_world.set_level_pos(table.copy(lzr_globals.BACKDROP_POS_OCEAN))
|
||
elseif level_state.backdrop == "islands" then
|
||
lzr_world.set_level_pos(table.copy(level_state.backdrop_pos))
|
||
elseif level_state.backdrop == "underground" then
|
||
lzr_world.set_level_pos(table.copy(lzr_globals.BACKDROP_POS_UNDERGROUND))
|
||
elseif level_state.backdrop == "sky" then
|
||
lzr_world.set_level_pos(table.copy(lzr_globals.BACKDROP_POS_SKY))
|
||
end
|
||
|
||
local bounding_nodes = {
|
||
node_wall = level_state.wall,
|
||
node_ceiling = level_state.ceiling,
|
||
node_floor = level_state.floor,
|
||
}
|
||
|
||
lzr_gamestate.set_loading(true)
|
||
lzr_gui.set_loading_gui(player)
|
||
lzr_player.set_loading_inventory_formspec(player)
|
||
lzr_player.set_editor_inventory(player, true)
|
||
local done = function()
|
||
lzr_gui.show_level_bounds(player, lzr_world.get_level_pos(), schem.size)
|
||
|
||
if lzr_ambience.ambience_exists(level_state.ambient) then
|
||
lzr_ambience.set_ambience(level_state.ambient)
|
||
else
|
||
level_state.ambient = "none"
|
||
lzr_ambience.set_ambience("none")
|
||
end
|
||
if lzr_sky.sky_exists(level_state.sky) then
|
||
lzr_sky.set_sky(level_state.sky)
|
||
else
|
||
lzr_sky.set_sky(lzr_globals.DEFAULT_SKY)
|
||
end
|
||
if lzr_weather.weather_exists(level_state.weather) then
|
||
lzr_weather.set_weather(level_state.weather)
|
||
else
|
||
lzr_weather.set_weather(lzr_globals.DEFAULT_WEATHER)
|
||
end
|
||
|
||
lzr_player.set_editor_inventory(player)
|
||
lzr_gui.set_editor_gui(player)
|
||
|
||
lzr_gamestate.set_loading(false)
|
||
minetest.log("action", "[lzr_editor] Loaded level "..filename)
|
||
end
|
||
lzr_levels.prepare_and_build_custom_level(lzr_world.get_level_pos(), schem, "start", nil, bounding_nodes, triggers_str, done)
|
||
|
||
level_state.file_name = level_name
|
||
minetest.log("action", "[lzr_editor] Started loading level from "..filename.." ...")
|
||
return true
|
||
else
|
||
minetest.log("error", "[lzr_editor] Failed to read level from "..filename)
|
||
return false
|
||
end
|
||
end
|
||
|
||
|
||
minetest.register_chatcommand("editor_load", {
|
||
description = S("Load level"),
|
||
params = S("<level name>"),
|
||
func = function(name, param)
|
||
local player = minetest.get_player_by_name(name)
|
||
if lzr_gamestate.get_state() ~= lzr_gamestate.EDITOR then
|
||
return false, S("Not in editor mode!")
|
||
elseif lzr_gamestate.is_loading() then
|
||
return false, S("The editor is already loading a level!")
|
||
end
|
||
if param == "" then
|
||
return false, S("No level name provided.")
|
||
end
|
||
local level_name = param
|
||
if check_for_slash(level_name) then
|
||
return false, S("Level name must not contain slash or backslash!")
|
||
end
|
||
local ok = lzr_util.file_exists(minetest.get_worldpath().."/levels", level_name..".mts")
|
||
if not ok then
|
||
return false, S("Level file does not exist!")
|
||
end
|
||
|
||
local ok = load_level(level_name, player)
|
||
if ok then
|
||
return true, S("Level loaded.")
|
||
else
|
||
return false, S("Error reading level file!")
|
||
end
|
||
end,
|
||
})
|
||
|
||
minetest.register_chatcommand("editor", {
|
||
description = S("Start or exit level editor"),
|
||
params = S("[ enter | exit ]"),
|
||
func = function(name, param)
|
||
local player = minetest.get_player_by_name(name)
|
||
if param == "" or param == "enter" then
|
||
if lzr_gamestate.get_state() == lzr_gamestate.EDITOR then
|
||
return false, S("Already in level editor!")
|
||
elseif lzr_gamestate.is_loading() then
|
||
return false, S("Can’t do this while loading!")
|
||
end
|
||
local ok = lzr_editor.enter_editor(player)
|
||
if ok then
|
||
return true
|
||
else
|
||
minetest.log("error", "[lzr_editor] Can't enter editor for unknown reason")
|
||
return false
|
||
end
|
||
elseif param == "exit" then
|
||
if lzr_gamestate.get_state() ~= lzr_gamestate.EDITOR then
|
||
return false, S("Not in level editor!")
|
||
elseif lzr_gamestate.is_loading() then
|
||
return false, S("Can’t do this while loading!")
|
||
end
|
||
local ok = exit_editor(player)
|
||
if ok then
|
||
return true
|
||
else
|
||
minetest.log("error", "[lzr_editor] Can't exit editor for unknown reason")
|
||
return false
|
||
end
|
||
end
|
||
return false
|
||
end,
|
||
})
|
||
|
||
minetest.register_chatcommand("reset_triggers", {
|
||
description = S("Remove all triggers and reset them to their initial state"),
|
||
params = "",
|
||
func = function(name, param)
|
||
local state = lzr_gamestate.get_state()
|
||
if state ~= lzr_gamestate.EDITOR then
|
||
return false, S("Not in editor mode!")
|
||
elseif lzr_gamestate.is_loading() then
|
||
return false, S("Can’t do this while loading!")
|
||
end
|
||
lzr_levels.init_triggers()
|
||
return true, S("Triggers have been reset.")
|
||
end
|
||
})
|
||
|
||
-- Unlimited node placement in editor mode
|
||
minetest.register_on_placenode(function(pos, newnode, placer, oldnode, itemstack)
|
||
if placer and placer:is_player() then
|
||
return lzr_gamestate.get_state() == lzr_gamestate.EDITOR
|
||
end
|
||
end)
|
||
|
||
-- Don't pick node up if the item is already in the inventory
|
||
local old_handle_node_drops = minetest.handle_node_drops
|
||
function minetest.handle_node_drops(pos, drops, digger)
|
||
if not digger or not digger:is_player() or
|
||
lzr_gamestate.get_state() ~= lzr_gamestate.EDITOR then
|
||
return old_handle_node_drops(pos, drops, digger)
|
||
end
|
||
local inv = digger:get_inventory()
|
||
if inv then
|
||
for _, item in ipairs(drops) do
|
||
if not inv:contains_item("main", item, true) then
|
||
inv:add_item("main", item)
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
lzr_gamestate.register_on_enter_state(function(state)
|
||
if state == lzr_gamestate.EDITOR then
|
||
local player = minetest.get_player_by_name("singleplayer")
|
||
|
||
local inv = player:get_inventory()
|
||
inv:set_list("main", {})
|
||
end
|
||
end)
|
||
|
||
lzr_gamestate.register_on_exit_state(function(state)
|
||
if state == lzr_gamestate.EDITOR then
|
||
minetest.log("action", "[lzr_editor] Autosaving level on editor exit")
|
||
autosave_level()
|
||
local player = minetest.get_player_by_name("singleplayer")
|
||
if player then
|
||
lzr_privs.revoke_edit_privs(player)
|
||
end
|
||
end
|
||
end)
|
||
|
||
local show_settings_dialog = function(player, settings_state)
|
||
if not settings_state then
|
||
settings_state = level_state
|
||
end
|
||
|
||
-- Shorthand function to get the current dropdown index of
|
||
-- a multiple-choice item (ambience, sky, weather) as well
|
||
-- as a string to insert in the dropdown[] formspec element
|
||
local get_current_thing = function(thing_type, thing_getter, description_getter)
|
||
local thing_list = ""
|
||
local current_thing = 1
|
||
local things = thing_getter()
|
||
for t=1, #things do
|
||
local description
|
||
if description_getter then
|
||
description = description_getter(things[t])
|
||
else
|
||
description = things[t]
|
||
end
|
||
-- Construct string for dropdown[]
|
||
thing_list = thing_list .. F(description)
|
||
-- Current thing found!
|
||
if things[t] == settings_state[thing_type] then
|
||
current_thing = t
|
||
end
|
||
-- Append comma except at end
|
||
if t < #things then
|
||
thing_list = thing_list .. ","
|
||
end
|
||
end
|
||
return thing_list, current_thing
|
||
end
|
||
|
||
local ambient_list, current_ambient = get_current_thing("ambient", lzr_ambience.get_ambiences, lzr_ambience.get_ambience_description)
|
||
local sky_list, current_sky = get_current_thing("sky", lzr_sky.get_skies, lzr_sky.get_sky_description)
|
||
local weather_list, current_weather = get_current_thing("weather", lzr_weather.get_weathers, lzr_weather.get_weather_description)
|
||
local backdrop_list, current_backdrop = get_current_thing("backdrop", lzr_mapgen.get_backdrops, lzr_mapgen.get_backdrop_description)
|
||
|
||
local level_size = lzr_world.get_level_size()
|
||
|
||
local boundary_button = function(boundaryname, nodename, y)
|
||
if minetest.registered_nodes[nodename] then
|
||
return "item_image_button[7.75,"..y..";0.6,0.6;"..F(nodename)..";level_"..boundaryname.."_select;]"
|
||
else
|
||
local btn = "image_button[7.75,"..y..";0.6,0.6;"..F("[inventorycube{unknown_node.png{unknown_node.png{unknown_node.png")..";level_"..boundaryname.."_select;]"
|
||
if type(nodename) == "string" then
|
||
btn = btn .. "tooltip[level_"..boundaryname.."_select;"..F(nodename).."]"
|
||
end
|
||
return btn
|
||
end
|
||
end
|
||
|
||
local form = "formspec_version[7]"..
|
||
"size[9,11.7]"..
|
||
"box[0,0;9,0.8;#00000080]"..
|
||
"label[0.4,0.4;"..FS("Level settings").."]"..
|
||
"field[0.5,1.3;8,0.6;level_name;"..FS("Name")..";"..F(settings_state.name).."]"..
|
||
"label[0.5,2.3;"..FS("Size").."]"..
|
||
"field[1.6,2.3;1,0.6;level_size_x;"..FS("X")..";"..F(tostring(settings_state.size.x)).."]"..
|
||
"field[2.62,2.3;1,0.6;level_size_y;"..FS("Y")..";"..F(tostring(settings_state.size.y)).."]"..
|
||
"field[3.63,2.3;1,0.6;level_size_z;"..FS("Z")..";"..F(tostring(settings_state.size.z)).."]"..
|
||
"field[0.5,3.3;7,0.6;level_wall;"..FS("Wall node")..";"..F(settings_state.wall).."]"..
|
||
"field[0.5,4.3;7,0.6;level_floor;"..FS("Floor node")..";"..F(settings_state.floor).."]"..
|
||
"field[0.5,5.3;7,0.6;level_ceiling;"..FS("Ceiling node")..";"..F(settings_state.ceiling).."]"..
|
||
boundary_button("wall", settings_state.wall, 3.3)..
|
||
boundary_button("floor", settings_state.floor, 4.3)..
|
||
boundary_button("ceiling", settings_state.ceiling, 5.3)..
|
||
|
||
"field[0.5,6.3;8,0.6;level_npc_goldie;"..FS("Goldie speech")..";"..F(settings_state.npc_texts.goldie).."]"..
|
||
|
||
"label[0.5,7.1;"..FS("Music").."]"..
|
||
"dropdown[0.5,7.3;8,0.6;level_ambient;"..ambient_list..";"..current_ambient..";true]"..
|
||
|
||
"label[0.5,8.2;"..FS("Sky").."]"..
|
||
"dropdown[0.5,8.4;3.5,0.6;level_sky;"..sky_list..";"..current_sky..";true]"..
|
||
|
||
"label[5,8.2;"..FS("Weather").."]"..
|
||
"dropdown[5,8.4;3.5,0.6;level_weather;"..weather_list..";"..current_weather..";true]"..
|
||
|
||
"label[0.5,9.3;"..FS("Backdrop").."]"..
|
||
"dropdown[0.5,9.5;3.5,0.6;level_backdrop;"..backdrop_list..";"..current_backdrop..";true]"
|
||
|
||
-- backdrop_pos is only relevant for islands backdrop
|
||
if settings_state.backdrop == "islands" then
|
||
form = form .. "field[5,9.5;1,0.6;level_backdrop_pos_x;"..FS("X")..";"..F(tostring(settings_state.backdrop_pos.x)).."]"..
|
||
"field[6.02,9.5;1,0.6;level_backdrop_pos_y;"..FS("Y")..";"..F(tostring(settings_state.backdrop_pos.y)).."]"..
|
||
"field[7.04,9.5;1,0.6;level_backdrop_pos_z;"..FS("Z")..";"..F(tostring(settings_state.backdrop_pos.z)).."]"..
|
||
"tooltip[level_backdrop_pos_x;"..FS("X coordinate of backdrop position").."]"..
|
||
"tooltip[level_backdrop_pos_y;"..FS("Y coordinate of backdrop position").."]"..
|
||
"tooltip[level_backdrop_pos_z;"..FS("Z coordinate of backdrop position").."]"
|
||
end
|
||
|
||
form = form .. "tooltip[level_name;"..FS("Level name as shown to the player").."]"..
|
||
"tooltip[level_size_x;"..FS("Level size along the X axis").."]"..
|
||
"tooltip[level_size_y;"..FS("Level size along the Y axis").."]"..
|
||
"tooltip[level_size_z;"..FS("Level size along the Z axis").."]"..
|
||
"tooltip[level_wall;"..FS("Itemstring of node to be placed on the left, front, back and right level borders").."]"..
|
||
"tooltip[level_floor;"..FS("Itemstring of node to be placed at the bottom of the level").."]"..
|
||
"tooltip[level_ceiling;"..FS("Itemstring of node to be placed at the top of the level").."]"..
|
||
"tooltip[level_npc_goldie;"..FS("Text to be shown when player interacts with Goldie the Parrot").."]"..
|
||
"tooltip[level_ambient;"..FS("Which audio ambience to play").."]"..
|
||
"tooltip[level_sky;"..FS("How the sky looks like. Affects color, sun, moon, stars, clouds and the time of day").."]"..
|
||
"tooltip[level_weather;"..FS("Visual weather effects (no audio)").."]"..
|
||
"tooltip[level_backdrop;"..FS("The world that surrounds the level").."]"..
|
||
|
||
"button_exit[0.5,10.5;3.5,0.7;level_ok;"..FS("OK").."]"..
|
||
"button_exit[5,10.5;3.5,0.7;level_cancel;"..FS("Cancel").."]"..
|
||
"field_close_on_enter[level_ok;true]"
|
||
minetest.show_formspec(player:get_player_name(), "lzr_editor:level_settings", form)
|
||
end
|
||
|
||
local show_save_load_dialog = function(player, save, level_name)
|
||
if not level_name then
|
||
level_name = ""
|
||
end
|
||
local txt_caption
|
||
local txt_action
|
||
local action
|
||
if save then
|
||
txt_caption = S("Save level as …")
|
||
txt_action = S("Save")
|
||
action = "save"
|
||
else
|
||
txt_caption = S("Load level …")
|
||
txt_action = S("Load")
|
||
action = "load"
|
||
end
|
||
|
||
local levels_files = minetest.get_dir_list(minetest.get_worldpath().."/levels", false)
|
||
local levels = {}
|
||
for l=1, #levels_files do
|
||
if string.lower(string.sub(levels_files[l], -4, -1)) == ".mts" then
|
||
local name = string.sub(levels_files[l], 1, -5)
|
||
table.insert(levels, name)
|
||
end
|
||
end
|
||
table.sort(levels)
|
||
level_file_textlist_state = levels
|
||
local levels_str = ""
|
||
for l=1, #levels do
|
||
local entry = levels[l]
|
||
if entry == lzr_globals.AUTOSAVE_NAME then
|
||
entry = AUTOSAVE_HIGHLIGHT .. entry
|
||
end
|
||
levels_str = levels_str .. entry
|
||
if l < #levels then
|
||
levels_str = levels_str .. ","
|
||
end
|
||
end
|
||
|
||
local level_list_elem = ""
|
||
if #levels > 0 then
|
||
level_list_elem = "label[0.5,1.5;"..FS("File list:").."]" ..
|
||
"textlist[0.5,2;8,4;level_list;"..levels_str.."]"
|
||
end
|
||
|
||
local form = "formspec_version[7]"..
|
||
"size[9,9]"..
|
||
"box[0,0;9,0.8;#00000080]"..
|
||
"label[0.4,0.4;"..F(txt_caption).."]"..
|
||
level_list_elem..
|
||
"field[0.5,6.6;7,0.6;file_name;"..FS("File name")..";"..minetest.formspec_escape(level_name).."]"..
|
||
"label[7.7,6.9;.mts]"..
|
||
"button_exit[0.5,7.7;3.5,0.7;"..action..";"..F(txt_action).."]"..
|
||
"button_exit[5,7.7;3.5,0.7;cancel;"..FS("Cancel").."]"..
|
||
"field_close_on_enter["..action..";true]"
|
||
|
||
local formname
|
||
if save then
|
||
formname = "lzr_editor:level_save"
|
||
else
|
||
formname = "lzr_editor:level_load"
|
||
end
|
||
minetest.show_formspec(player:get_player_name(), formname, form)
|
||
end
|
||
|
||
-- Remove all triggers that are out of level bounds
|
||
local clear_out_of_bounds_triggers = function()
|
||
local cleared = 0
|
||
local minpos, maxpos = lzr_world.get_level_bounds()
|
||
for trigger_id, trigger in pairs(lzr_triggers.get_triggers()) do
|
||
local trigger_pos = minetest.string_to_pos(trigger_id)
|
||
if not vector.in_area(trigger_pos, minpos, maxpos) then
|
||
lzr_triggers.remove_trigger(trigger_id)
|
||
cleared = cleared + 1
|
||
end
|
||
end
|
||
if cleared > 0 then
|
||
minetest.log("info", "[lzr_editor] Removed "..cleared.." triggers that were out of bounds")
|
||
end
|
||
end
|
||
|
||
-- Check the level area for errors
|
||
lzr_editor.check_level_errors = function()
|
||
local minpos, maxpos = lzr_world.get_level_bounds()
|
||
local errors = {}
|
||
|
||
-- Test: Count teleporters (must have exactly 1)
|
||
local teleporters = minetest.find_nodes_in_area(minpos, maxpos, "lzr_teleporter:teleporter_off")
|
||
if #teleporters == 0 then
|
||
table.insert(errors, "no_teleporter")
|
||
elseif #teleporters > 1 then
|
||
table.insert(errors, "too_many_teleporters")
|
||
end
|
||
|
||
-- Test: Barriers in level area (none allowed, they're only allowed for the level bounds)
|
||
local barriers = minetest.find_nodes_in_area(minpos, maxpos, "group:barrier")
|
||
if #barriers > 0 then
|
||
table.insert(errors, "barriers")
|
||
end
|
||
|
||
-- Test: Bare gold block (none allowed)
|
||
local gold_block = minetest.find_nodes_in_area(minpos, maxpos, "lzr_treasure:gold_block")
|
||
if #gold_block > 0 then
|
||
table.insert(errors, "gold_block")
|
||
end
|
||
|
||
-- Test: Rooted plants (none allowed, should use normal plants instead)
|
||
local plants_on_ground = minetest.find_nodes_in_area(minpos, maxpos, "group:plant_on_ground")
|
||
if #plants_on_ground > 0 then
|
||
table.insert(errors, "plant_on_ground")
|
||
end
|
||
|
||
-- Test: More than one parrot spawner (max. 1 allowed)
|
||
local parrot_spawners = minetest.find_nodes_in_area(minpos, maxpos, "lzr_parrot_npc:parrot_spawner")
|
||
if #parrot_spawners > 1 then
|
||
table.insert(errors, "too_many_parrot_spawners")
|
||
end
|
||
|
||
local hidden_parrot_spawners = minetest.find_nodes_in_area(minpos, maxpos, "lzr_parrot_npc:hidden_parrot_spawner")
|
||
if #hidden_parrot_spawners > 1 then
|
||
table.insert(errors, "too_many_hidden_parrot_spawners")
|
||
end
|
||
for h=1, #hidden_parrot_spawners do
|
||
local node = minetest.get_node(hidden_parrot_spawners[h])
|
||
local num = (node.param2 % 4) + 1
|
||
local parrot_name = lzr_parrot_npc.get_hidden_parrot_name(num)
|
||
if not parrot_name then
|
||
table.insert(errors, "bad_hidden_parrot_spawner")
|
||
break
|
||
end
|
||
end
|
||
|
||
local hidden_parrot_spawners = minetest.find_nodes_in_area(minpos, maxpos, "lzr_parrot_npc:hidden_parrot_spawner")
|
||
if #hidden_parrot_spawners > 1 then
|
||
table.insert(errors, "too_many_hidden_parrot_spawners")
|
||
end
|
||
for h=1, #hidden_parrot_spawners do
|
||
local node = minetest.get_node(hidden_parrot_spawners[h])
|
||
local num = (node.param2 % 4) + 1
|
||
local parrot_name = lzr_parrot_npc.get_hidden_parrot_name(num)
|
||
if not parrot_name then
|
||
table.insert(errors, "bad_hidden_parrot_spawner")
|
||
break
|
||
end
|
||
end
|
||
|
||
-- Test: Trigger validity check from lzr_triggers
|
||
local trigger_check_ok, trigger_errors = lzr_triggers.check_triggers(true)
|
||
if not trigger_check_ok then
|
||
for e=1, #trigger_errors do
|
||
table.insert(errors, "lzr_triggers_"..trigger_errors[e])
|
||
end
|
||
end
|
||
|
||
-- Test: Additional editor-specific trigger checks
|
||
for trigger_id, trigger in pairs(lzr_triggers.get_triggers()) do
|
||
local trigger_pos = minetest.string_to_pos(trigger_id)
|
||
-- Out of level bounds
|
||
if not vector.in_area(trigger_pos, minpos, maxpos) then
|
||
table.insert(errors, "trigger_out_of_bounds")
|
||
break
|
||
end
|
||
-- Trigger is not at its expected location
|
||
-- (in the editor, all triggers must have their ID
|
||
-- match the location)
|
||
if not vector.equals(trigger_pos, trigger.location) then
|
||
table.insert(errors, "trigger_moved")
|
||
break
|
||
end
|
||
end
|
||
|
||
if #errors > 0 then
|
||
return false, errors
|
||
else
|
||
return true
|
||
end
|
||
end
|
||
|
||
-- Check the level area for non-serious problems
|
||
lzr_editor.check_level_warnings = function()
|
||
local warnings = {}
|
||
local minpos, maxpos = lzr_world.get_level_bounds()
|
||
|
||
-- Test: Laser-incompatible nodes (should have none).
|
||
-- Will look a bit ugly if a laser touches those.
|
||
local incompatible = minetest.find_nodes_in_area(minpos, maxpos, "group:laser_incompatible")
|
||
if #incompatible > 0 then
|
||
table.insert(warnings, "laser_incompatible")
|
||
end
|
||
|
||
-- Test: Incompatible door segments
|
||
local doors = minetest.find_nodes_in_area(minpos, maxpos, "group:door")
|
||
if #doors > 0 then
|
||
local half_door, inc_p2, inc_type = false, false, false
|
||
for d=1, #doors do
|
||
local dpos = doors[d]
|
||
local dnode = minetest.get_node(dpos)
|
||
local ddir = minetest.facedir_to_dir(dnode.param2)
|
||
-- Only test upright door segments. Door segments that lie flat
|
||
-- don't trigger warnings.
|
||
if ddir.y == 0 then
|
||
local altpos
|
||
local g = minetest.get_item_group(dnode.name, "door")
|
||
if g == 1 then
|
||
altpos = vector.offset(dpos, 0, 1, 0)
|
||
elseif g == 2 then
|
||
altpos = vector.offset(dpos, 0, -1, 0)
|
||
else
|
||
minetest.log("error", "[lzr_editor] Door node '"..dnode.name.."' has bad door group rating: "..g)
|
||
break
|
||
end
|
||
local altnode = minetest.get_node(altpos)
|
||
local ga = minetest.get_item_group(altnode.name, "door")
|
||
if ga == 0 then
|
||
half_door = true
|
||
elseif altnode.param2 ~= dnode.param2 then
|
||
inc_p2 = true
|
||
else
|
||
local g_exit = minetest.get_item_group(dnode.name, "door_exit")
|
||
local ga_exit = minetest.get_item_group(altnode.name, "door_exit")
|
||
local g_locked = minetest.get_item_group(dnode.name, "door_locked")
|
||
local ga_locked = minetest.get_item_group(altnode.name, "door_locked")
|
||
if g_exit ~= ga_exit or g_locked ~= ga_locked then
|
||
inc_type = true
|
||
end
|
||
end
|
||
end
|
||
end
|
||
if half_door then
|
||
table.insert(warnings, "half_door")
|
||
end
|
||
if inc_p2 then
|
||
table.insert(warnings, "incompatible_door_segments_param2")
|
||
end
|
||
if inc_type then
|
||
table.insert(warnings, "incompatible_door_segments_type")
|
||
end
|
||
end
|
||
|
||
-- Test: No treasures to collect (should have at least 1).
|
||
-- The level is still playable, but it will be an instant win.
|
||
local no_treasures = true
|
||
local chests = minetest.find_nodes_in_area(minpos, maxpos, "group:chest")
|
||
for c=1, #chests do
|
||
local node = minetest.get_node(chests[c])
|
||
local g = minetest.get_item_group(node.name, "chest")
|
||
-- Count locked and unlocked chests
|
||
-- Opened chests with gold blocks are not counted here,
|
||
-- they’re immediately counted as collected when the
|
||
-- level starts.
|
||
if g == 1 or g == 2 then
|
||
no_treasures = false
|
||
end
|
||
end
|
||
if no_treasures then
|
||
table.insert(warnings, "no_treasures")
|
||
end
|
||
|
||
if #warnings > 0 then
|
||
return false, warnings
|
||
else
|
||
return true
|
||
end
|
||
end
|
||
|
||
minetest.register_chatcommand("editor_check", {
|
||
description = S("Check current level for problems"),
|
||
params = "",
|
||
func = function(name, param)
|
||
if lzr_gamestate.get_state() ~= lzr_gamestate.EDITOR then
|
||
return false, S("Not in editor mode!")
|
||
elseif lzr_gamestate.is_loading() then
|
||
return false, S("Can’t do this while loading!")
|
||
end
|
||
local check1_ok, errors = lzr_editor.check_level_errors()
|
||
local result_str = list_warnings_and_errors()
|
||
if result_str == "" then
|
||
return true, S("No problems found.")
|
||
else
|
||
-- Note: We return true because the *command* itself
|
||
-- succeeded, it's the level that has errors.
|
||
return true, S("The following problems were found:").."\n"..result_str
|
||
end
|
||
end,
|
||
})
|
||
|
||
minetest.register_on_player_receive_fields(function(player, formname, fields)
|
||
if lzr_gamestate.get_state() ~= lzr_gamestate.EDITOR then
|
||
return
|
||
elseif lzr_gamestate.is_loading() then
|
||
minetest.chat_send_player(player:get_player_name(), S("Can’t do this while loading!"))
|
||
return
|
||
end
|
||
|
||
if formname ~= "lzr_editor:level_settings" and string.sub(formname, 1, 27) ~= "lzr_editor:select_item_page" then
|
||
temp_settings_state = {}
|
||
end
|
||
|
||
local get_current_thing = function(thing_type, selected_thing, thing_getter)
|
||
local thing_list = ""
|
||
local current_thing = 1
|
||
local things = thing_getter()
|
||
selected_thing = tonumber(selected_thing)
|
||
if not selected_thing then
|
||
return nil
|
||
end
|
||
for t=1, #things do
|
||
if t == selected_thing then
|
||
return things[t]
|
||
end
|
||
end
|
||
return nil
|
||
end
|
||
|
||
local pname = player:get_player_name()
|
||
|
||
-- Read settings fields into target table (without validation)
|
||
local parse_settings_fields = function(target_table, fields)
|
||
if fields.level_name then
|
||
target_table.name = fields.level_name
|
||
end
|
||
|
||
if not target_table.size then
|
||
target_table.size = {}
|
||
end
|
||
target_table.size.x = fields.level_size_x or target_table.size.x
|
||
target_table.size.y = fields.level_size_y or target_table.size.y
|
||
target_table.size.z = fields.level_size_z or target_table.size.z
|
||
|
||
target_table.floor = fields.level_floor or target_table.floor
|
||
target_table.ceiling = fields.level_ceiling or target_table.ceiling
|
||
target_table.wall = fields.level_wall or target_table.wall
|
||
if fields.level_npc_goldie then
|
||
target_table.npc_texts = {}
|
||
target_table.npc_texts.goldie = fields.level_npc_goldie
|
||
end
|
||
|
||
target_table.ambient = get_current_thing("ambience", fields.level_ambient, lzr_ambience.get_ambiences) or target_table.ambient
|
||
target_table.sky = get_current_thing("sky", fields.level_sky, lzr_sky.get_skies) or target_table.sky
|
||
target_table.weather = get_current_thing("weather", fields.level_weather, lzr_weather.get_weathers) or target_table.weather
|
||
target_table.backdrop = get_current_thing("backdrop", fields.level_backdrop, lzr_mapgen.get_backdrops) or target_table.backdrop
|
||
|
||
if not target_table.backdrop_pos then
|
||
target_table.backdrop_pos = {}
|
||
end
|
||
target_table.backdrop_pos.x = fields.level_backdrop_pos_x or target_table.backdrop_pos.x or "0"
|
||
target_table.backdrop_pos.y = fields.level_backdrop_pos_y or target_table.backdrop_pos.y or "0"
|
||
target_table.backdrop_pos.z = fields.level_backdrop_pos_z or target_table.backdrop_pos.z or "0"
|
||
|
||
end
|
||
|
||
local fix_settings_fields = function(target_table)
|
||
-- Enforce size bounds
|
||
for _, axis in pairs({"x","y","z"}) do
|
||
target_table.size[axis] = math.floor(tonumber(target_table.size[axis]) or lzr_globals.DEFAULT_LEVEL_SIZE[axis])
|
||
if target_table.size[axis] > lzr_globals.MAX_LEVEL_SIZE[axis] then
|
||
target_table.size[axis] = lzr_globals.MAX_LEVEL_SIZE[axis]
|
||
end
|
||
if target_table.size[axis] < lzr_globals.MIN_LEVEL_SIZE[axis] then
|
||
target_table.size[axis] = lzr_globals.MIN_LEVEL_SIZE[axis]
|
||
end
|
||
end
|
||
|
||
-- Enforce backdrop limits
|
||
if level_state.backdrop == "islands" then
|
||
local mg_edge_min, mg_edge_max = minetest.get_mapgen_edges()
|
||
mg_edge_max = vector.subtract(mg_edge_max, lzr_globals.MAX_LEVEL_SIZE)
|
||
local EDGE_BUFFER = 1000 -- make sure the level is far away from the zone boundaries
|
||
-- enforce we're in the map
|
||
for _, axis in pairs({"x","y","z"}) do
|
||
-- Non-numeric values get converted to 0
|
||
target_table.backdrop_pos[axis] = math.floor(tonumber(fields["level_backdrop_pos_"..axis]) or 0)
|
||
target_table.backdrop_pos[axis] = math.min(mg_edge_max[axis]-EDGE_BUFFER-lzr_globals.MAX_LEVEL_SIZE[axis], math.max(mg_edge_min[axis]+EDGE_BUFFER, target_table.backdrop_pos[axis]))
|
||
end
|
||
-- enforce that we're in the islands zone
|
||
if target_table.backdrop_pos.z < lzr_globals.DEEP_OCEAN_Z + EDGE_BUFFER + lzr_globals.MAX_LEVEL_SIZE.z then
|
||
target_table.backdrop_pos.z = lzr_globals.DEEP_OCEAN_Z + EDGE_BUFFER + lzr_globals.MAX_LEVEL_SIZE.z
|
||
end
|
||
end
|
||
end
|
||
|
||
local merge_settings = function(target_table, merge_table)
|
||
for k,v in pairs(merge_table) do
|
||
if type(v) == "table" then
|
||
target_table[k] = table.copy(v)
|
||
else
|
||
target_table[k] = v
|
||
end
|
||
end
|
||
end
|
||
|
||
if formname == "lzr_editor:level_settings" then
|
||
|
||
if not fields.level_cancel and not fields.quit and not fields.level_ok then
|
||
-- select_item button clicked
|
||
if fields.level_wall_select or fields.level_ceiling_select or fields.level_floor_select then
|
||
parse_settings_fields(temp_settings_state, fields)
|
||
local state = table.copy(level_state)
|
||
merge_settings(state, temp_settings_state)
|
||
fix_settings_fields(state)
|
||
temp_settings_state = state
|
||
|
||
local bname
|
||
if fields.level_wall_select then
|
||
bname = "wall"
|
||
elseif fields.level_ceiling_select then
|
||
bname = "ceiling"
|
||
elseif fields.level_floor_select then
|
||
bname = "floor"
|
||
end
|
||
select_item.show_dialog(pname, bname)
|
||
return
|
||
end
|
||
-- Dropdown changed
|
||
if (fields.level_sky or fields.level_backdrop or fields.level_ambient or fields.level_weather) then
|
||
parse_settings_fields(temp_settings_state, fields)
|
||
local state = table.copy(level_state)
|
||
merge_settings(state, temp_settings_state)
|
||
fix_settings_fields(level_state)
|
||
|
||
show_settings_dialog(player, state)
|
||
return
|
||
end
|
||
|
||
end
|
||
|
||
if fields.level_cancel or (fields.quit and not fields.level_ok) then
|
||
temp_settings_state = {}
|
||
return
|
||
end
|
||
|
||
if fields.level_ok then
|
||
local old_level_pos = table.copy(lzr_world.get_level_pos())
|
||
local old_level_size = table.copy(lzr_world.get_level_size())
|
||
local old_floor = level_state.floor
|
||
local old_ceiling = level_state.ceiling
|
||
local old_wall = level_state.wall
|
||
|
||
parse_settings_fields(temp_settings_state, fields)
|
||
parse_settings_fields(level_state, fields)
|
||
if temp_settings_state then
|
||
merge_settings(level_state, temp_settings_state)
|
||
end
|
||
fix_settings_fields(level_state)
|
||
|
||
lzr_world.set_level_size(level_state.size)
|
||
|
||
if level_state.backdrop == "ocean" then
|
||
lzr_world.set_level_pos(table.copy(lzr_globals.BACKDROP_POS_OCEAN))
|
||
elseif level_state.backdrop == "islands" then
|
||
lzr_world.set_level_pos(table.copy(level_state.backdrop_pos))
|
||
elseif level_state.backdrop == "underground" then
|
||
lzr_world.set_level_pos(table.copy(lzr_globals.BACKDROP_POS_UNDERGROUND))
|
||
elseif level_state.backdrop == "sky" then
|
||
lzr_world.set_level_pos(table.copy(lzr_globals.BACKDROP_POS_SKY))
|
||
end
|
||
|
||
local rebuild_room, relocate_room = false, false
|
||
local nodes = {}
|
||
nodes.node_floor = level_state.floor
|
||
nodes.node_ceiling = level_state.ceiling
|
||
nodes.node_wall = level_state.wall
|
||
if old_floor ~= nodes.node_floor or old_ceiling ~= nodes.node_ceiling or old_wall ~= nodes.node_wall then
|
||
for k,v in pairs(nodes) do
|
||
if not minetest.registered_nodes[v] then
|
||
nodes[k] = lzr_globals.DEFAULT_WALL_NODE
|
||
level_state[string.sub(k, 6)] = lzr_globals.DEFAULT_WALL_NODE
|
||
end
|
||
end
|
||
rebuild_room = true
|
||
elseif not vector.equals(lzr_world.get_level_size(), old_level_size) then
|
||
rebuild_room = true
|
||
end
|
||
if not vector.equals(lzr_world.get_level_pos(), old_level_pos) then
|
||
relocate_room = true
|
||
rebuild_room = true
|
||
end
|
||
|
||
if relocate_room or rebuild_room then
|
||
minetest.log("action", "[lzr_editor] Autosaving level on level rebuild")
|
||
save_level(lzr_globals.AUTOSAVE_NAME, true)
|
||
end
|
||
|
||
if relocate_room then
|
||
-- Relocating the room will clear the level.
|
||
-- TODO: Allow to move the room non-destructively.
|
||
lzr_levels.reset_level_area(false, old_level_pos, old_level_size)
|
||
lzr_triggers.reset_triggers()
|
||
end
|
||
if rebuild_room then
|
||
lzr_levels.resize_room(old_level_size, lzr_world.get_level_size(), nodes)
|
||
lzr_gui.show_level_bounds(player, lzr_world.get_level_pos(), lzr_world.get_level_size())
|
||
-- Some trigger nodes might have been removed if the level
|
||
-- size was reduced, so we must clear some triggers
|
||
clear_out_of_bounds_triggers()
|
||
end
|
||
|
||
-- Move player if too far out of new level area
|
||
move_player_to_level_if_neccessary(player)
|
||
|
||
if lzr_ambience.ambience_exists(level_state.ambient) then
|
||
lzr_ambience.set_ambience(level_state.ambient)
|
||
else
|
||
lzr_ambience.set_ambience("none")
|
||
end
|
||
if lzr_sky.sky_exists(level_state.sky) then
|
||
lzr_sky.set_sky(level_state.sky)
|
||
else
|
||
lzr_sky.set_sky(lzr_globals.DEFAULT_SKY)
|
||
end
|
||
if lzr_weather.weather_exists(level_state.weather) then
|
||
lzr_weather.set_weather(level_state.weather)
|
||
else
|
||
lzr_weather.set_weather(lzr_globals.DEFAULT_WEATHER)
|
||
end
|
||
end
|
||
return
|
||
-- Select level boundary
|
||
elseif string.sub(formname, 1, 27) == "lzr_editor:select_item_page" then
|
||
local rest = string.sub(formname, 28, string.len(formname))
|
||
local split = string.split(rest, "%%", true, 2)
|
||
local page = tonumber(split[1])
|
||
local boundaryname = split[2]
|
||
|
||
-- Change page in select_item dialog
|
||
if page ~= nil then
|
||
if fields.previous and page > 1 then
|
||
select_item.show_dialog_page(player:get_player_name(), boundaryname, page - 1)
|
||
return
|
||
elseif fields.next then
|
||
select_item.show_dialog_page(pname, boundaryname, page + 1)
|
||
return
|
||
end
|
||
end
|
||
|
||
local item
|
||
for field,_ in pairs(fields) do
|
||
if string.sub(field, 1, 5) == "item_" then
|
||
item = string.sub(field, 6, string.len(field))
|
||
break
|
||
end
|
||
end
|
||
if item then
|
||
temp_settings_state[boundaryname] = item
|
||
local state = table.copy(level_state)
|
||
merge_settings(state, temp_settings_state)
|
||
show_settings_dialog(player, state)
|
||
return
|
||
elseif fields.quit or fields.cancel then
|
||
select_item.reset_player_info(pname)
|
||
|
||
local state = table.copy(level_state)
|
||
merge_settings(state, temp_settings_state)
|
||
show_settings_dialog(player, state)
|
||
end
|
||
|
||
elseif formname == "lzr_editor:level_save" or formname == "lzr_editor:level_load" then
|
||
if fields.level_list then
|
||
local evnt = minetest.explode_textlist_event(fields.level_list)
|
||
if evnt.type == "CHG" or evnt.type == "DBL" then
|
||
local file = level_file_textlist_state[evnt.index]
|
||
if file then
|
||
if formname == "lzr_editor:level_save" then
|
||
show_save_load_dialog(player, true, file)
|
||
else
|
||
show_save_load_dialog(player, false, file)
|
||
end
|
||
return
|
||
end
|
||
end
|
||
elseif (fields.load or (formname == "lzr_editor:level_load" and fields.key_enter)) and fields.file_name then
|
||
if fields.file_name == "" then
|
||
minetest.chat_send_player(pname, S("No level name provided."))
|
||
return
|
||
end
|
||
if check_for_slash(fields.file_name) then
|
||
minetest.chat_send_player(pname, S("File name must not contain slash or backslash!"))
|
||
return false
|
||
end
|
||
local exists = lzr_util.file_exists(minetest.get_worldpath().."/levels", fields.file_name..".mts")
|
||
if not exists then
|
||
minetest.chat_send_player(pname, S("Level file does not exist!"))
|
||
return
|
||
end
|
||
local ok, filename = load_level(fields.file_name, player)
|
||
if ok then
|
||
minetest.chat_send_player(pname, S("Level loaded."))
|
||
else
|
||
minetest.chat_send_player(pname, S("Error reading level file!"))
|
||
end
|
||
elseif (fields.save or (formname == "lzr_editor:level_save" and fields.key_enter)) and fields.file_name then
|
||
if fields.file_name == "" then
|
||
minetest.chat_send_player(pname, S("No level name provided."))
|
||
return
|
||
end
|
||
if check_for_slash(fields.file_name) then
|
||
minetest.chat_send_player(pname, S("File name must not contain slash or backslash!"))
|
||
return false
|
||
end
|
||
local ok, filename, filename2 = save_level(fields.file_name)
|
||
if ok and filename and filename2 then
|
||
minetest.chat_send_player(pname, S("Level saved to @1 and @2.", filename, filename2))
|
||
elseif ok and filename then
|
||
minetest.chat_send_player(pname, S("Level saved to @1, but could not write metadata to @2.", filename, filename2))
|
||
else
|
||
minetest.chat_send_player(pname, S("Error writing level file!"))
|
||
end
|
||
end
|
||
return
|
||
end
|
||
|
||
if fields.__lzr_level_editor_exit then
|
||
exit_editor()
|
||
return
|
||
elseif fields.__lzr_level_editor_settings then
|
||
show_settings_dialog(player)
|
||
return
|
||
elseif fields.__lzr_level_editor_save then
|
||
show_save_load_dialog(player, true, level_state.file_name)
|
||
return
|
||
elseif fields.__lzr_level_editor_load then
|
||
show_save_load_dialog(player, false, level_state.file_name)
|
||
return
|
||
elseif fields.__lzr_level_editor_get_item then
|
||
lzr_getitem.show_formspec(pname)
|
||
return
|
||
end
|
||
end)
|
||
|
||
-- Returns list of all custom level file names, sorted.
|
||
-- The file name suffix is omitted.
|
||
lzr_editor.get_custom_levels = function()
|
||
local levels_files = minetest.get_dir_list(minetest.get_worldpath().."/levels", false)
|
||
local levels = {}
|
||
for l=1, #levels_files do
|
||
if string.lower(string.sub(levels_files[l], -4, -1)) == ".mts" then
|
||
local name = string.sub(levels_files[l], 1, -5)
|
||
table.insert(levels, name)
|
||
end
|
||
end
|
||
table.sort(levels)
|
||
return levels
|
||
end
|
||
|
||
-- Returns proper level name of a custom level.
|
||
-- The proper level name is read from a matching CSV file.
|
||
-- If no such file is found, the empty string is returned.
|
||
-- * level_name: Level file name without suffix
|
||
-- * with_fallback: If true, will return "Untitled (<file name>)"
|
||
-- instead of the empty string if the level name is empty or undefined
|
||
lzr_editor.get_custom_level_name = function(level_name, with_fallback)
|
||
local csv_file = io.open(minetest.get_worldpath().."/levels/"..level_name..".csv", "r")
|
||
local pname
|
||
if csv_file then
|
||
local csv_string = csv_file:read("*a")
|
||
csv_file:close()
|
||
local csv_parsed = lzr_csv.parse_csv(csv_string)
|
||
if csv_parsed and #csv_parsed >= 1 then
|
||
pname = csv_parsed[1][2]
|
||
end
|
||
end
|
||
if not pname then
|
||
pname = ""
|
||
end
|
||
if pname == "" and with_fallback then
|
||
return S("Untitled (@1)", level_name)
|
||
end
|
||
return pname
|
||
end
|
||
|
||
-- Expose invisible blocks in editor mode
|
||
local invis_display_timer = 0
|
||
local INVIS_DISPLAY_UPDATE_TIME = 1
|
||
local INVIS_DISPLAY_RANGE = 8
|
||
minetest.register_globalstep(function(dtime)
|
||
local state = lzr_gamestate.get_state()
|
||
if state ~= lzr_gamestate.EDITOR then
|
||
return
|
||
end
|
||
invis_display_timer = invis_display_timer + dtime
|
||
if invis_display_timer < INVIS_DISPLAY_UPDATE_TIME then
|
||
return
|
||
end
|
||
invis_display_timer = 0
|
||
|
||
local player = minetest.get_player_by_name("singleplayer")
|
||
if not player then
|
||
return
|
||
end
|
||
local pos = vector.round(player:get_pos())
|
||
local r = INVIS_DISPLAY_RANGE
|
||
local vm = minetest.get_voxel_manip()
|
||
local emin, emax = vm:read_from_map(vector.offset(pos, -r, -r, -r), vector.offset(pos, r, r, r))
|
||
local area = VoxelArea:new({MinEdge = emin, MaxEdge = emax})
|
||
local data = vm:get_data()
|
||
for x=pos.x-r, pos.x+r do
|
||
for y=pos.y-r, pos.y+r do
|
||
for z=pos.z-r, pos.z+r do
|
||
local vi = area:index(x,y,z)
|
||
local nodename = minetest.get_name_from_content_id(data[vi])
|
||
local texture
|
||
-- TODO: Find a way to mark water source barriers.
|
||
-- (as of MT 5.9.1, particles are invisible when underwater)
|
||
if minetest.get_item_group(nodename, "rain_membrane") > 0 then
|
||
texture = "lzr_core_rain_membrane.png"
|
||
elseif minetest.get_item_group(nodename, "barrier") > 0 then
|
||
texture = "lzr_core_barrier.png"
|
||
end
|
||
if texture then
|
||
minetest.add_particle({
|
||
pos = {x=x, y=y, z=z},
|
||
texture = texture,
|
||
glow = minetest.LIGHT_MAX,
|
||
size = 8,
|
||
expirationtime = 1.1,
|
||
})
|
||
end
|
||
end
|
||
end
|
||
end
|
||
end)
|
||
|
||
minetest.register_chatcommand("editor_clear", {
|
||
privs = {},
|
||
params = "[ clear | regenerate ]",
|
||
description = S("Remove all blocks in the current level area or regenerate the map"),
|
||
func = function(name, param)
|
||
local state = lzr_gamestate.get_state()
|
||
if lzr_gamestate.get_state() ~= lzr_gamestate.EDITOR then
|
||
return false, S("Not in editor mode!")
|
||
elseif lzr_gamestate.is_loading() then
|
||
return false, S("Can’t do this while loading!")
|
||
end
|
||
local pos = lzr_world.get_level_pos()
|
||
local size = lzr_world.get_level_size()
|
||
local bounding_nodes = {
|
||
node_wall = level_state.wall,
|
||
node_ceiling = level_state.ceiling,
|
||
node_floor = level_state.floor,
|
||
}
|
||
local done = function()
|
||
lzr_gamestate.set_loading(false)
|
||
minetest.chat_send_player(name, S("Level cleared."))
|
||
end
|
||
-- Clear level by removing all blocks
|
||
if param == "" or param == "clear" then
|
||
minetest.log("action", "[lzr_editor] Autosaving level before clearing it")
|
||
autosave_level()
|
||
minetest.log("action", "[lzr_editor] Clearing level ...")
|
||
|
||
-- Clearing the level counts as "loading". This blocks all
|
||
-- other commands until the operation is complete.
|
||
lzr_gamestate.set_loading(true)
|
||
|
||
lzr_triggers.reset_triggers()
|
||
lzr_levels.build_room({pos=pos, size=size, nodes=bounding_nodes, clear=true, clear_border=false, callback_done=done})
|
||
return true, S("Clearing level …")
|
||
-- Clear level by regenerating the map
|
||
elseif param == "regenerate" then
|
||
minetest.log("action", "[lzr_editor] Autosaving level before clearing it")
|
||
autosave_level()
|
||
minetest.log("action", "[lzr_editor] Clearing level ...")
|
||
|
||
lzr_gamestate.set_loading(true)
|
||
|
||
lzr_triggers.reset_triggers()
|
||
lzr_levels.reset_level_area(true, pos, size)
|
||
lzr_levels.build_room({pos=pos, size=size, nodes=bounding_nodes, callback_done=done})
|
||
return true, S("Clearing level …")
|
||
else
|
||
-- Invalid parameter
|
||
return false
|
||
end
|
||
end,
|
||
})
|
||
|
||
-- Show a warning when issuing a WorldEdit command in the editor.
|
||
-- Using WorldEdit to change blocks can lead to an invalid level state.
|
||
-- In particular, it might break the trigger definitions
|
||
minetest.register_on_chatcommand(function(name, command, params)
|
||
local cdef = minetest.registered_chatcommands[command]
|
||
if not cdef then
|
||
return
|
||
end
|
||
if lzr_editor.worldedit_warning then
|
||
return
|
||
end
|
||
if lzr_gamestate.get_state() ~= lzr_gamestate.EDITOR then
|
||
return
|
||
end
|
||
local origin = cdef.mod_origin
|
||
if cdef.mod_origin == "worldedit" or cdef.mod_origin == "worldedit_commands" or cdef.mod_origin == "worldedit_shortcommands" then
|
||
local message = S("WARNING: Changing a trigger block with a WorldEdit command may break the triggers. You may need to call /reset_triggers after doing so.")
|
||
minetest.chat_send_player(name, minetest.colorize("#ffff00", message))
|
||
lzr_editor.worldedit_warning = true
|
||
end
|
||
end)
|