local S = minetest.get_translator("lzr_levels") lzr_levels = {} local WINDOW_HEIGHT = 3 local WINDOW_DIST = 3 -- Time the level title/complete message is shown (seconds) local LEVEL_CAPTION_TIME = 3.0 local FINAL_LEVEL_CAPTION_TIME = 5.0 local current_level = nil local core_level_data = {} local current_level_data = nil local legacy_levels lzr_levels.LAST_LEVEL = 0 local get_max_treasures = function() if current_level and current_level_data then return current_level_data[current_level].treasures end end -- Mod storage for game progress local mod_storage = minetest.get_mod_storage() local flat_index_to_pos = function(index, size) local d = index-1 local x = d % size.x local y = math.floor((d / size.x) % size.y) local z = math.floor((d / (size.x*size.y)) % size.z) return vector.new(x,y,z) end local analyze_level_schematic = function(filename, levels_path, level_data_entry) local filepath = levels_path .. "/" ..filename local schem = minetest.read_schematic(filepath, {write_yslice_prob="none"}) assert(schem, "Could not load level file: "..filename) level_data_entry.contains_rotatable_block = false level_data_entry.treasures = 0 level_data_entry.size = schem.size local size = level_data_entry.size local teleporters = 0 local parrot_spawners = 0 -- Find rotatable blocks, treasures, parrot spawners and find the start position for d=1, #schem.data do local nodename = schem.data[d].name local is_rotatable = minetest.get_item_group(nodename, "rotatable") == 1 or minetest.get_item_group(nodename, "takable") == 1 local treasure = minetest.get_item_group(nodename, "chest_closed") > 0 or minetest.get_item_group(nodename, "chest_open_treasure") > 0 if is_rotatable then level_data_entry.contains_rotatable_block = true end if treasure then level_data_entry.treasures = level_data_entry.treasures + 1 end if nodename == "lzr_teleporter:teleporter_off" then -- Player spawn pos on teleporter teleporters = teleporters + 1 local start = flat_index_to_pos(d, size) start = vector.add(start, vector.new(0, 0.5, 0)) level_data_entry.start_pos = start end if nodename == "lzr_parrot_npc:parrot_spawner" then -- Parrot spawn pos parrot_spawners = parrot_spawners + 1 local ppos = flat_index_to_pos(d, size) level_data_entry.parrot_pos = ppos end end -- Print warnings about level problems if teleporters == 0 then minetest.log("warning", "[lzr_levels] Level "..filename.." in "..levels_path.." doesn't have a teleporter!") end if teleporters > 1 then minetest.log("warning", "[lzr_levels] Level "..filename.." in "..levels_path.." has more than one teleporter!") end if parrot_spawners > 1 then minetest.log("warning", "[lzr_levels] Level "..filename.." in "..levels_path.." has more than one parrot spawner!") end end -- Create a level_data table for a single level -- with default settings. Used for levels where -- no metadata is available. lzr_levels.create_fallback_level_data = function(level, levels_path) local local_level_data = {} local filename = level .. ".mts" local_level_data.levels_path = levels_path local local_level = { filename = filename, name = "", node_wall = lzr_globals.DEFAULT_WALL_NODE, node_floor = lzr_globals.DEFAULT_FLOOR_NODE, node_ceiling = lzr_globals.DEFAULT_CEILING_NODE, node_window = lzr_globals.DEFAULT_WINDOW_NODE, ambience = 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 = "", } analyze_level_schematic(filename, levels_path, local_level) local_level_data[1] = local_level return local_level_data end --[[ Read the level schematics to find out some metadata about them. Returns a level_data table. A CSV file (given by `level_list_path` is used for metadata. Syntax of a single record in the CSV file: ,,<Border nodes>,<Ambience>,<Sky>,<NPC texts>,<Weather>,<Backdrop>,<Node meta> Border nodes is a list of nodenames for the level border, separated by the pipe symbol (“|”), in this order: wall, window, floor, ceiling wall is mandatory, the rest is optional (will default to the wall node) Ambience is an ambience ID for the background noise (see lzr_ambience). Sky and weather are IDs for sky and weather (see lzr_sky, lzr_weather). NPC texts is a *single* text used by NPC like the information block. (multiple NPC texts are not supported yet). Backdrop is the environment around the playable field. One of "ocean", "islands", "underground", "sky". Node meta is a serialized string containing node metadata All entries up to ambience are mandatory, but sky, NPC texts and Weather can be omitted. Parameters: * level_list_path: Path to CSV file file containing the level list * levels_path: Path in which the levels are stored (.mts files) Returns `nil, "load_error" if CSV file could not be read Returns `nil, "csv_error", <csv_error_message>` if CSV file could not be parsed correctly. `<csv_error_message>` contains an error message that describes the parse error. ]] lzr_levels.analyze_levels = function(level_list_path, levels_path) local level_list_file = io.open(level_list_path, "r") if not level_list_file then return nil, "load_error" end local level_list_string = level_list_file:read("*a") local level_list, csv_error = lzr_csv.parse_csv(level_list_string) level_list_file:close() if not level_list then return nil, "csv_error", csv_error end local local_level_data = {} local_level_data.levels_path = levels_path for ll=1, #level_list do local level_list_row = level_list[ll] local filename = level_list_row[1] local lname = level_list_row[2] local nodes = level_list_row[3] local ambience = level_list_row[4] local sky = level_list_row[5] or lzr_globals.DEFAULT_SKY local npc_texts_raw = level_list_row[6] local npc_texts if npc_texts_raw then npc_texts = { goldie = npc_texts_raw } else npc_texts = lzr_globals.DEFAULT_NPC_TEXTS end local weather = level_list_row[7] or lzr_globals.DEFAULT_WEATHER local backdrop = level_list_row[8] or lzr_globals.DEFAULT_BACKDROP local backdrop_pos local parsed_backdrop_pos = level_list_row[9] if parsed_backdrop_pos then backdrop_pos = minetest.string_to_pos(parsed_backdrop_pos) end if not backdrop_pos then backdrop_pos = lzr_globals.DEFAULT_BACKDROP_POS end local triggers = level_list_row[10] or "" local node_matches = string.split(nodes, "|") local node_wall = node_matches[1] local node_window = node_matches[2] or node_wall local node_floor = node_matches[3] or node_wall local node_ceiling = node_matches[4] or node_wall table.insert(local_level_data, {filename=filename, name=lname, node_wall=node_wall, node_window=node_window, node_floor=node_floor, node_ceiling=node_ceiling, ambience=ambience, sky=sky, npc_texts=npc_texts, weather=weather, backdrop=backdrop, backdrop_pos=backdrop_pos, triggers=triggers}) end -- Scan the level schematics for intersting blocks, treasures, etc. -- and write it into local_level_data for l=1, #local_level_data do local filename = local_level_data[l].filename analyze_level_schematic(filename, levels_path, local_level_data[l]) end return local_level_data end -- Set the basic nodes of the room local set_room_nodes = function(pos, size, nodes) local psize = size local posses_border = {} local posses_window = {} local posses_floor = {} local posses_ceiling = {} local size = vector.add(psize, {x=1,y=1,z=1}) lzr_world.set_level_size(psize) for x=0,size.x do for z=0,size.z do for y=0,size.y do local offset = {x=x-1, y=y-1, z=z-1} if not ((x >= 1 and x < size.x) and (y >= 1 and y < size.y) and (z >= 1 and z < size.z)) then local lpos = vector.add(pos, offset) local node = minetest.get_node(lpos) if node.name == "air" or minetest.get_item_group(node.name, "water") ~= 0 then if y == WINDOW_HEIGHT and ((x >= 1 and x < size.x and x % WINDOW_DIST == 0) or (z >= 1 and z < size.z and z % WINDOW_DIST == 0)) then table.insert(posses_window, lpos) else if y == 0 then table.insert(posses_floor, lpos) elseif y == size.y then table.insert(posses_ceiling, lpos) else table.insert(posses_border, lpos) end end end end end end end minetest.bulk_set_node(posses_floor, {name=nodes.node_floor}) minetest.bulk_set_node(posses_border, {name=nodes.node_wall}) minetest.bulk_set_node(posses_window, {name=nodes.node_window}) minetest.bulk_set_node(posses_ceiling, {name=nodes.node_ceiling}) end local get_singleplayer = function() return minetest.get_player_by_name("singleplayer") end local emerge_callback = function(blockpos, action, calls_remaining, param) minetest.log("verbose", "[lzr_levels] emerge_callback() ...") if action == minetest.EMERGE_ERRORED then minetest.log("error", "[lzr_levels] Room emerging error.") elseif action == minetest.EMERGE_CANCELLED then minetest.log("error", "[lzr_levels] Room emerging cancelled.") elseif calls_remaining == 0 and (action == minetest.EMERGE_GENERATED or action == minetest.EMERGE_FROM_DISK or action == minetest.EMERGE_FROM_MEMORY) then local maxpos = vector.add(param.pos, param.size) if param.mode == "resize" then local inner = { x = math.min(param.old_size.x, param.size.x), y = math.min(param.old_size.y, param.size.y), z = math.min(param.old_size.z, param.size.z), } local outer = { x = math.max(param.old_size.x, param.size.x), y = math.max(param.old_size.y, param.size.y), z = math.max(param.old_size.z, param.size.z), } lzr_laser.clear_out_of_bounds_lasers() lzr_levels.reset_level_area(false, param.pos, outer, inner) lzr_levels.reset_level_area(true, param.pos, outer, inner) lzr_levels.reset_level_area(false, param.pos, param.old_size, param.size) set_room_nodes(param.pos, param.size, param.nodes) lzr_laser.full_laser_update(param.pos, maxpos) minetest.log("action", "[lzr_levels] Room emerge resize callback done") else -- Reset old level area if param.old_pos then lzr_levels.reset_level_area(false, param.old_pos, param.old_size) end -- Reset new level area lzr_laser.clear_out_of_bounds_lasers() lzr_levels.reset_level_area(param.clear == true, param.pos, param.size) set_room_nodes(param.pos, param.size, param.nodes) local level_ok = false if param.level then level_ok = lzr_levels.build_level(param.level, param.level_data) elseif param.schematic then local maxpos = vector.add(param.pos, param.size) level_ok = lzr_levels.build_level_raw(param.schematic, param.pos, maxpos) if level_ok and param.triggers then lzr_levels.init_triggers() lzr_levels.deserialize_triggers(param.triggers) end lzr_laser.full_laser_update(param.pos, maxpos) else local player = get_singleplayer() if player then if param.spawn_pos then player:set_pos(param.spawn_pos) end if param.yaw then player:set_look_horizontal(param.yaw) player:set_look_vertical(0) end end minetest.log("action", "[lzr_levels] Empty room emerge callback done") return end if not level_ok then minetest.log("error", "[lzr_levels] Room emerge callback done with error") else local player = get_singleplayer() if player then if param.spawn_pos then player:set_pos(param.spawn_pos) end if param.yaw then player:set_look_horizontal(param.yaw) player:set_look_vertical(0) end local gs = lzr_gamestate.get_state() if gs == lzr_gamestate.LEVEL then local found = lzr_laser.count_found_treasures(param.pos, maxpos) lzr_gui.update_treasure_status(player, found, get_max_treasures()) if param.parrot_pos then local parrot_node_pos = vector.add(param.parrot_pos, param.pos) minetest.set_node(parrot_node_pos, {name="air"}) local parrot_entity_pos = vector.add(parrot_node_pos, lzr_globals.PARROT_SPAWN_OFFSET) minetest.add_entity(parrot_entity_pos, "lzr_parrot_npc:parrot") end end if param.level then local lname = lzr_levels.get_level_name(param.level, param.level_data, true) if lname ~= "" then lzr_messages.show_message(player, lname, LEVEL_CAPTION_TIME) end lzr_player.set_play_inventory(player, lname) minetest.sound_play({name = "lzr_levels_level_enter", gain = 1}, {to_player=player:get_player_name()}, true) end end minetest.log("action", "[lzr_levels] Room emerge callback done") end end end end local prepare_room = function(room_data) minetest.emerge_area(room_data.pos, vector.add(room_data.pos, room_data.size), emerge_callback, room_data) end --[[ Resets blocks within an area by either putting it back to the original mapgen state or deleting it all. Also clears all objects. * clear: If true, will set all nodes in area to air instead of regenerating the map * start_pos: Minimum position of area to clear * size: Size of area to clean * start_pos_protected: If set, this is the minimum position of the protected area, i.e. an area in which nodes will NOT be reset. If nil, this has no effect * size_protected: Size of protected area. Use in combination with `start_pos_protected`. ]] function lzr_levels.reset_area(clear, start_pos, size, start_pos_protected, size_protected) local aprot_min, aprot_max if start_pos_protected then aprot_min = start_pos_protected aprot_max = vector.add(start_pos_protected, size_protected) end if clear then lzr_levels.clear_area(start_pos, vector.add(start_pos, size), aprot_min, aprot_max) else lzr_mapgen.generate_piece(start_pos, vector.add(start_pos, size), nil, aprot_min, aprot_max) end -- Also clear objects local objects = minetest.get_objects_in_area( start_pos, vector.add(start_pos, size)) for o=1, #objects do local obj = objects[o] if not obj:is_player() then obj:remove() end end local msg = "Reset area at "..minetest.pos_to_string(start_pos)..", size "..minetest.pos_to_string(size) if start_pos_protected then msg = msg .." (start_pos_protected="..minetest.pos_to_string(start_pos_protected)..", size_protected="..minetest.pos_to_string(size_protected)..")" end minetest.log("action", "[lzr_levels] "..msg) end --[[ Resets the specified level area including the border around it by putting it back to the original mapgen state, or by deleting all blocks. Also clears all objects. * clear: If true, will set all nodes in area to air instead of regenerating the map * level_pos: Minimum position of level contents (NOT the level border!) * level_size: Size of level contents * protected_size: If specified, no nodes from level_pos to level_pos + size_protected will be touched. ]] function lzr_levels.reset_level_area(clear, level_pos, level_size, protected_size) local real_start_pos = vector.subtract(level_pos, vector.new(1,1,1)) local real_size = vector.add(level_size, vector.new(1,1,1)) if protected_size then protected_size = vector.subtract(protected_size, vector.new(1,1,1)) lzr_levels.reset_area(clear, real_start_pos, real_size, level_pos, protected_size) else lzr_levels.reset_area(clear, real_start_pos, real_size) end end -- Deletes all nodes with in an area from the position start_pos -- to start_pos+size. function lzr_levels.clear_area(start_pos, end_pos, protected_min, protected_max) local posses = {} for z=start_pos.z, end_pos.z do for y=start_pos.y, end_pos.y do for x=start_pos.x, end_pos.x do local pos = vector.new(x,y,z) if not protected_min or not vector.in_area(pos, protected_min, protected_max) then table.insert(posses, vector.new(x,y,z)) end end end end minetest.bulk_set_node(posses, { name = "air" }) minetest.log("action", "[lzr_levels] Cleared area from "..minetest.pos_to_string(start_pos).." to "..minetest.pos_to_string(end_pos)) end -- room_data: -- - pos: Room pos -- - size: Room size vector -- - spawn_pos: Relative player spawn position (optional) -- - yaw: Initial player yaw (optional) -- Either one of these (or none of them for empty room): -- - level: level ID (for builtin level) -- - schematic: Path to schematic -- - nodes (optional): Table containing node names of level border nodes: -- - node_floor, node_ceiling, node_wall, node_window function lzr_levels.build_room(room_data) lzr_laser.clear_out_of_bounds_lasers() if not room_data.nodes then room_data.nodes = { node_floor = lzr_globals.DEFAULT_FLOOR_NODE, node_wall = lzr_globals.DEFAULT_WALL_NODE, node_ceiling = lzr_globals.DEFAULT_CEILING_NODE, node_window = lzr_globals.DEFAULT_WINDOW_NODE, } end prepare_room(room_data) end -- Resize room while preserving the inner contents of the old room -- as much as the new size permits. function lzr_levels.resize_room(old_size, new_size, nodes) prepare_room({mode="resize", pos=lzr_world.get_level_pos(), old_size=old_size, size=new_size, nodes=nodes}) end function lzr_levels.prepare_and_build_level(level, level_data, spawn_pos, yaw, old_pos, old_size) if not level_data then level_data = core_level_data end local bounding_nodes = { node_floor = level_data[level].node_floor, node_wall = level_data[level].node_wall, node_ceiling = level_data[level].node_ceiling, node_window = level_data[level].node_window, } local lpos if level_data[level].backdrop == "ocean" then lpos = lzr_globals.BACKDROP_POS_OCEAN elseif level_data[level].backdrop == "islands" then lpos = level_data[level].backdrop_pos elseif level_data[level].backdrop == "underground" then lpos = lzr_globals.BACKDROP_POS_UNDERGROUND elseif level_data[level].backdrop == "sky" then lpos = lzr_globals.BACKDROP_POS_SKY end lzr_levels.build_room({mode="build", pos=lpos, size=level_data[level].size, old_pos=old_pos, old_size=old_size, level=level, level_data=level_data, spawn_pos=spawn_pos, yaw=yaw, parrot_pos=level_data[level].parrot_pos, nodes=bounding_nodes, backdrop=level_data[level].backdrop, backdrop_pos=level_data[level].backdrop_pos, triggers=level_data[level].triggers}) end function lzr_levels.prepare_and_build_custom_level(level_pos, schematic, spawn_pos, yaw, bounding_nodes, triggers) lzr_levels.build_room({mode="build", pos=level_pos, size=schematic.size, schematic=schematic, spawn_pos=spawn_pos, yaw=yaw, nodes=bounding_nodes, triggers=triggers}) end function lzr_levels.build_level_raw(schematic_specifier, lminpos, lmaxpos) local schem = minetest.place_schematic(lminpos, schematic_specifier, "0", {}, true, "") if not schem then minetest.log("error", "[lzr_levels] lzr_levels.build_level_raw failed to build level") end return schem end function lzr_levels.build_level(level, level_data) if not level_data then level_data = core_level_data end local filepath = level_data.levels_path .. "/" .. level_data[level].filename local schematic_specifier if level_data == core_level_data then -- Will provide file name to place_schematic, causing Minetest -- to cache it for better performance. schematic_specifier = filepath else -- Custom levels must be read uncached because custom levels -- may be edited in the level editor frequently. -- Once a schematic was cached by Minetest, it is "locked" -- in this state forever. -- Reading a schematic uncached is done by first getting a -- specifier with read_schematic and then passing it to -- place_schematic. schematic_specifier = minetest.read_schematic(filepath, {write_yslice_prob="none"}) end local lminpos, lmaxpos if level_data[level].backdrop == "islands" then lminpos = level_data[level].backdrop_pos elseif level_data[level].backdrop == "ocean" then lminpos = lzr_globals.BACKDROP_POS_OCEAN elseif level_data[level].backdrop == "underground" then lminpos = lzr_globals.BACKDROP_POS_UNDERGROUND elseif level_data[level].backdrop == "sky" then lminpos = lzr_globals.BACKDROP_POS_SKY else minetest.log("error", "[lzr_levels] Unknown level backdrop type: "..tostring(level_data[level].backdrop)) return end -- Reset out-of-bounds lasers lzr_laser.clear_out_of_bounds_lasers() lmaxpos = vector.add(lminpos, level_data[level].size) lzr_world.set_level_pos(table.copy(lminpos)) local schem = lzr_levels.build_level_raw(schematic_specifier, lminpos, lmaxpos) lzr_levels.init_triggers() lzr_levels.deserialize_triggers(level_data[level].triggers) lzr_laser.full_laser_update(lminpos, lmaxpos) -- Check for insta-win (if the level has no treasure, the -- player has found "all" treasure of this level) local done = lzr_laser.check_level_won() if done and lzr_gamestate.get_state() == lzr_gamestate.LEVEL then minetest.after(3, function(param) if lzr_gamestate.get_state() == lzr_gamestate.LEVEL and param.level == current_level and param.level_data == current_level_data then lzr_levels.level_complete() end end, {level=level, level_data=level_data}) end return schem end local function clear_inventory(player) local inv = player:get_inventory() for i=1,inv:get_size("main") do inv:set_stack("main", i, "") end end local function reset_inventory(player, needs_rotate) clear_inventory(player) if needs_rotate then local inv = player:get_inventory() inv:add_item("main", "screwdriver2:screwdriver") end end local function get_start_pos(level, level_data) if not level_data then level_data = core_level_data end local start_pos -- player start position, relative to level local size = level_data[level].size if level_data[level].start_pos then start_pos = level_data[level].start_pos else -- Fallback start pos start_pos = vector.new(math.floor(size.x/2), -0.5, math.floor(size.z/2)) end return start_pos end function lzr_levels.get_npc_texts() if not current_level_data then return nil end local level_data = current_level_data[current_level] if not level_data then return nil end local texts = level_data.npc_texts -- Translate NPC texts in core level set if current_level_data == core_level_data then local translated_texts = {} if texts then for npc, text in pairs(texts) do local tt = minetest.translate("_lzr_levels_npc_texts", text) translated_texts[npc] = tt end end return translated_texts else return level_data.npc_texts end end function lzr_levels.get_current_spawn_pos() if not current_level then return nil end local start_pos = get_start_pos(current_level, current_level_data) -- absolute spawn position local spawn_pos = vector.add(lzr_world.get_level_pos(), start_pos) return spawn_pos end function lzr_levels.start_level(level, level_data) if not level_data then level_data = core_level_data end current_level = level current_level_data = level_data local player = get_singleplayer() local old_pos = lzr_world.get_level_pos() local old_size = lzr_world.get_level_size() if level_data[level].backdrop == "ocean" then lzr_world.set_level_pos(lzr_globals.BACKDROP_POS_OCEAN) elseif level_data[level].backdrop == "islands" then lzr_world.set_level_pos(table.copy(level_data[level].backdrop_pos)) elseif level_data[level].backdrop == "underground" then lzr_world.set_level_pos(lzr_globals.BACKDROP_POS_UNDERGROUND) elseif level_data[level].backdrop == "sky" then lzr_world.set_level_pos(lzr_globals.BACKDROP_POS_SKY) else minetest.log("error", "[lzr_levels] Could not start level "..level..": unknown backdrop '"..tostring(level_data[level].backdrop).."'") return end lzr_triggers.reset_triggers() local size = level_data[level].size lzr_world.set_level_size(size) local start_pos = get_start_pos(level, level_data) local spawn_pos = vector.add(lzr_world.get_level_pos(), start_pos) local yaw = 0 if start_pos.z > size.z/2 then yaw = yaw + math.pi end lzr_levels.prepare_and_build_level(level, level_data, spawn_pos, yaw, old_pos, old_size) local needs_rotate = level_data[current_level].contains_rotatable_block reset_inventory(player, needs_rotate) local state = lzr_gamestate.get_state() if state ~= lzr_gamestate.EDITOR and state ~= lzr_gamestate.DEV then lzr_gamestate.set_state(lzr_gamestate.LEVEL) end lzr_ambience.set_ambience(level_data[level].ambience) lzr_sky.set_sky(level_data[level].sky) lzr_weather.set_weather(level_data[level].weather) minetest.close_formspec(player:get_player_name(), "lzr_parrot_npc:speech") minetest.log("action", "[lzr_levels] Starting level "..level) end function lzr_levels.clear_level_progress() mod_storage:set_string("lzr_levels:levels", "") minetest.log("action", "[lzr_levels] Level progress was cleared") end function lzr_levels.mark_level_as_complete(level, level_data) -- Only core levels are supported if level_data ~= core_level_data then return end local levels = minetest.deserialize(mod_storage:get_string("lzr_levels:levels"), true) if not levels then levels = { __core = {} } end local levelname = level_data[level].filename levelname = string.sub(levelname, 1, -5) -- remove .mts suffix levels.__core[levelname] = true mod_storage:set_string("lzr_levels:levels", minetest.serialize(levels)) end function lzr_levels.get_completed_levels() local levels = minetest.deserialize(mod_storage:get_string("lzr_levels:levels"), true) if not levels then levels = { __core = {} } end -- Only core levels are supported return levels.__core end --[[ Checks the data format of the completed levels list in mod storage and updates it to the new format if neccessary. To be executed at mod load time. Old legacy format: A table with numbers for keys and booleans as values. The keys ar level numbers, the position of how they appeared in the levels list up to version 1.4.0. The booleans are true. Levels in the table with `true` as value count as completed. Levels not in the table count as incomplete. Example: { [1] = true, [3] = true, [5] = true } This means: Levels 1, 3 and 5 are completed. New format: A table. The keys are always strings, they represent level set names. (Currently, there is only one level set: "__core", for the built-in levels) The value of each key is an inner table. The inner table has level names as keys (always string) and the values is set to true to represent level completion. Levels in the table with `true` as value count as completed. Levels not in the table count as incomplete. Example: { __core = { lzr_levels_dripstone_cave = true, lzr_levels_barriers = true, }, } This means: The two built-in levels `lzr_levels_dripstone_cave` and `lzr_levels_barriers` are completed. ]] local function update_legacy_completed_levels_format() local levels = minetest.deserialize(mod_storage:get_string("lzr_levels:levels"), true) if not levels or type(levels) ~= "table" then levels = {} end -- Check any single key of the levels table. -- If it's a number, the table is in legacy format. local is_legacy_format = false for k,v in pairs(levels) do if type(k) == "number" then is_legacy_format = true end break end if is_legacy_format then -- This CSV file is an 1:1 mapping from legacy level number to new level name -- Column 1 is the legacy level number, column 2 the new name. local level_list_file = io.open(minetest.get_modpath("lzr_levels").."/data/legacy_level_names.csv", "r") local level_list_string = level_list_file:read("*a") local legacy_levels_csv = lzr_csv.parse_csv(level_list_string) level_list_file:close() local new_levels = { __core = {} } -- Match new level name identifiers with old-style level numbers -- and construct a new levels table. for l=1, #legacy_levels_csv do local level_number = tonumber(legacy_levels_csv[l][1]) local level_name = legacy_levels_csv[l][2] if levels[level_number] == true then new_levels.__core[level_name] = true end end mod_storage:set_string("lzr_levels:levels", minetest.serialize(new_levels)) minetest.log("action", "[lzr_levels] Converted level completion data in mod storage (lzr_levels:levels) from pre-1.4.0 format to new format") end end function lzr_levels.level_complete() if lzr_gamestate.get_state() ~= lzr_gamestate.LEVEL then return false end lzr_levels.mark_level_as_complete(current_level, current_level_data) local minpos, maxpos = lzr_world.get_level_bounds() -- Trigger chest treasure particle animation local open_chests = minetest.find_nodes_in_area(minpos, maxpos, {"group:chest_open"}) for c=1, #open_chests do local pos = open_chests[c] local node = minetest.get_node(pos) local def = minetest.registered_nodes[node.name] if def._lzr_send_treasure then def._lzr_send_treasure(pos, node) end end if #open_chests > 0 then lzr_laser.full_laser_update(minpos, maxpos) end local player = get_singleplayer() minetest.close_formspec(player:get_player_name(), "lzr_teleporter:level") local has_treasure = current_level_data[current_level].treasures > 0 if has_treasure then lzr_messages.show_message(player, S("Level complete!"), LEVEL_CAPTION_TIME) else -- Level had no treasures and thus was insta-won; -- show special message lzr_messages.show_message(player, S("There are no treasures here!"), LEVEL_CAPTION_TIME) end if current_level_data == core_level_data then minetest.log("action", "[lzr_levels] Level "..current_level.." completed") else minetest.log("action", "[lzr_levels] Level completed") end -- Victory fanare if has_treasure then minetest.sound_play({name = "lzr_levels_level_complete", gain = 1}, {to_player=player:get_player_name()}, true) end lzr_gamestate.set_state(lzr_gamestate.LEVEL_COMPLETE) -- Go to next level (only for core levels) minetest.after(3, function(completed_level) if lzr_gamestate.get_state() == lzr_gamestate.LEVEL_COMPLETE and current_level == completed_level then lzr_levels.next_level() end end, current_level) end function lzr_levels.next_level() if current_level_data ~= core_level_data then lzr_levels.leave_level() return end local player = get_singleplayer() current_level = current_level + 1 if current_level > lzr_levels.LAST_LEVEL then lzr_messages.show_message(player, S("Final level completed!"), FINAL_LEVEL_CAPTION_TIME) lzr_levels.leave_level() else lzr_levels.start_level(current_level, current_level_data) end end function lzr_levels.go_to_menu() current_level = nil current_level_data = nil local player = get_singleplayer() clear_inventory(player) player:set_pos(vector.add(lzr_globals.MENU_SHIP_POS, lzr_globals.MENU_SHIP_PLAYER_SPAWN_OFFSET)) player:set_look_horizontal(0) player:set_look_vertical(0) lzr_gamestate.set_state(lzr_gamestate.MENU) end function lzr_levels.leave_level() current_level = nil current_level_data = nil lzr_levels.go_to_menu() end function lzr_levels.get_current_level() return current_level end function lzr_levels.get_current_level_data() return current_level_data end function lzr_levels.get_core_level_data() return core_level_data end -- Returns the name of the level with the given level number, translated -- (translation only available for core levels). -- Note that levels may have an empty name. -- If with_fallback is true and the level's name is empty, it will return -- "Untitled (<file name>)" (translated) function lzr_levels.get_level_name(level, level_data, with_fallback) if not level_data then level_data = core_level_data end local name = level_data[level].name if name and name ~= "" then if level_data == core_level_data then return minetest.translate("_lzr_levels_level_names", level_data[level].name) else return name end else if with_fallback then local fname = level_data[level].filename fname = string.sub(fname, 1, -5) return S("Untitled (@1)", fname) else return "" end end end function lzr_levels.restart_level() local state = lzr_gamestate.get_state() if state == lzr_gamestate.LEVEL then lzr_levels.start_level(current_level, current_level_data) return true else return false end end -- To be called when a treasure has been found (only in game mode LEVEL!) function lzr_levels.found_treasure() local minpos, maxpos = lzr_world.get_level_bounds() local treasures = lzr_laser.count_found_treasures(minpos, maxpos) local player = get_singleplayer() lzr_gui.update_treasure_status(player, treasures, get_max_treasures()) end --[[ THE SERIALIZED TRIGGER FORMAT The serialized triggers is the specification of triggers in a levels as a single string. There are 3 format types: * (empty string): This means the level uses the default triggers: all chest locks will break open when all detectors are active at once. Mostly exists to support old levels. * the string "none": There are no triggers in the level * CSV format (RFC 4180): Used when there are triggers in the level. This is a sequence of CSV records, where each record specifies the following values: * First value is always the trigger position * The following values are pairs of metadata keys and values, e.g. "signal_type" followed by "4". This means the signal type is set to 4 for this trigger. Positions are stored in the minetest.pos_to_string format relative to the level origin (!). Example: "(0,1,2)",send_to,"(1,2,3);(4,5,6)",signal_type,0,receiver_type,0 This means a trigger at (0,1,2) sends to locations (1,2,3) and (4,5,6) and is of signal type 0 and receiver type 0. ]] -- Serialize triggers of the current level function lzr_levels.serialize_triggers() local triggers = lzr_triggers.internal_trigger_export() local entries = {} for trigger_id, trigger in pairs(triggers) do local new_entry = {} local pos = minetest.string_to_pos(trigger_id) local send_to = trigger.send_to local send_to_level = {} if send_to then for s=1, #send_to do local spos = minetest.string_to_pos(send_to[s]) local lpos = lzr_world.world_pos_to_level_pos(spos) table.insert(send_to_level, minetest.pos_to_string(lpos)) end end local send_to_str = table.concat(send_to_level, ";") -- Note: We always store signal and receiver type, -- even for nodes where this doesn't apply. local signal_type = trigger.signal_type local receiver_type = trigger.receiver_type -- <<< THE CSV RECORDS ARE CONSTRUCTED HERE >>> -- new_entry is equivalent to a single CSV record local new_entry = { -- Entry format is: First value is always pos, then the -- following values alternate between metadata keys and values. -- First value is pos minetest.pos_to_string(lzr_world.world_pos_to_level_pos(pos)), -- receivers (key, value) "send_to", send_to_str, -- signal type (key, value) "signal_type", tostring(signal_type), -- receiver type (key, value) "receiver_type", tostring(receiver_type), } table.insert(entries, new_entry) end -- SPECIAL CASE: No triggers in the map, so we pass -- the special string "none". if #entries == 0 then return "none" end -- Internally, we format the data as CSV (RFC 4180). -- We do NOT use minetest.serialize because it's unsafe -- to use minetest.deserialize on arbitrary user data -- (level files in our case). local entries_s = lzr_csv.write_csv(entries) return entries_s end function lzr_levels.init_triggers() lzr_triggers.reset_triggers() local minpos, maxpos = lzr_world.get_level_bounds() local trigger_nodes = minetest.find_nodes_in_area(minpos, maxpos, {"group:sender", "group:receiver"}) for t=1, #trigger_nodes do local tpos = trigger_nodes[t] local trigger_id = lzr_triggers.add_trigger(tpos) local meta = minetest.get_meta(tpos) meta:set_string("trigger_id", trigger_id) end end -- Default triggers: All chests locks break open when all detectors -- are active. local set_default_triggers = function() minetest.log("info", "[lzr_levels] Setting up the default triggers") local triggers = lzr_triggers.get_triggers() local locked_chests = {} -- Pass 1: Set receiver type for locked chests to SYNC_AND for trigger_id, trigger in pairs(triggers) do if type(trigger.location) == "table" then local pos = trigger.location local node = minetest.get_node(pos) if minetest.get_item_group(node.name, "chest") == 2 then lzr_triggers.set_trigger_receiver_type(trigger_id, lzr_triggers.RECEIVER_TYPE_SYNC_AND) table.insert(locked_chests, trigger_id) end end end -- Pass 2: Set detector signals to every locked chest for trigger_id, trigger in pairs(triggers) do if type(trigger.location) == "table" then local pos = trigger.location local node = minetest.get_node(pos) if minetest.get_item_group(node.name, "detector") ~= 0 then lzr_triggers.set_trigger_signal_type(trigger_id, lzr_triggers.SIGNAL_TYPE_SYNC) lzr_triggers.set_signals(trigger_id, table.copy(locked_chests)) end end end end function lzr_levels.deserialize_triggers(serialized_triggers) if serialized_triggers == "" then -- Special case: Use default triggers set_default_triggers() return true elseif serialized_triggers == "none" then -- Special case: No triggers return true end -- Normal deserialization of triggers: Triggers are stored in CSV format (RFC-4180), -- defined in lzr_levels.serialize_triggers. local entries, csv_error = lzr_csv.parse_csv(serialized_triggers) if not entries then lzr_triggers.reset_triggers() minetest.log("error", "[lzr_levels] Could not parse CSV-serialized triggers: "..tostring(csv_error)) return false end for e=1, #entries do local entry = entries[e] local pos_str = entry[1] if not pos_str then lzr_triggers.reset_triggers() minetest.log("error", "[lzr_levels] deserialize_triggers: Missing position. Row="..e) return false end local pos = minetest.string_to_pos(pos_str) pos = lzr_world.level_pos_to_world_pos(pos) local trigger_id = minetest.pos_to_string(pos) if not pos then lzr_triggers.reset_triggers() minetest.log("error", "[lzr_levels] deserialize_triggers: Invalid position. Row="..e) return false end for i=2,#entry,2 do local key = entry[i] local value = entry[i+1] if not key or not value then lzr_triggers.reset_triggers() minetest.log("error", "[lzr_levels] deserialize_triggers: Key/value mismatch. Row="..e.."; row length="..#entry) return false end if key == "send_to" then local send_to = lzr_laser.pos_string_to_positions(value) local send_to_world = {} for s=1, #send_to do local rpos = send_to[s] if type(rpos) == "table" and rpos.x and rpos.y and rpos.z then local rlpos = lzr_world.level_pos_to_world_pos(rpos) table.insert(send_to_world, minetest.pos_to_string(rlpos)) else lzr_triggers.reset_triggers() minetest.log("error", "[lzr_levels] deserialize_triggers: send_to["..s.."] is not a position vector! Row="..e) return false end end lzr_triggers.set_signals(trigger_id, send_to_world) elseif key == "signal_type" then local signal_type = tonumber(value) if signal_type then if not lzr_triggers.trigger_exists(trigger_id) then lzr_triggers.reset_triggers() minetest.log("error", "[lzr_levels] deserialize_triggers: Attempting to add signal_type for non-existing trigger '"..tostring(trigger_id).."'") return false end lzr_triggers.set_trigger_signal_type(trigger_id, signal_type) end elseif key == "receiver_type" then local receiver_type = tonumber(value) if receiver_type then if not lzr_triggers.trigger_exists(trigger_id) then lzr_triggers.reset_triggers() minetest.log("error", "[lzr_levels] deserialize_triggers: Attempting to add receiver_type for non-existing trigger '"..tostring(trigger_id).."'") return false end lzr_triggers.set_trigger_receiver_type(trigger_id, receiver_type) end else lzr_triggers.reset_triggers() minetest.log("error", "[lzr_levels] deserialize_triggers: Invalid key: "..tostring(key).."; Row="..e) return false end end end return true end minetest.register_chatcommand("restart", { privs = {}, params = "", description = S("Restart current level"), func = function(name, param) local state = lzr_gamestate.get_state() if state == lzr_gamestate.LEVEL then lzr_levels.restart_level() return true elseif state == lzr_gamestate.LEVEL_COMPLETE then return false, S("Can’t restart level right now.") else return false, S("Not playing in a level!") end end, }) minetest.register_chatcommand("leave", { privs = {}, params = "", description = S("Leave current level"), func = function(name, param) local state = lzr_gamestate.get_state() if state == lzr_gamestate.LEVEL or state == lzr_gamestate.LEVEL_COMPLETE or state == lzr_gamestate.EDITOR then lzr_levels.leave_level(current_level) return true else return false, S("Not playing in a level!") end end, }) minetest.register_chatcommand("reset_progress", { privs = {}, params = "yes", description = S("Reset level progress"), func = function(name, param) if param == "yes" then lzr_levels.clear_level_progress() return true, S("Level progress resetted.") else return false, S("To reset level progress, use “/reset_progress yes”") end end, }) lzr_gamestate.register_on_enter_state(function(state) if state == lzr_gamestate.LEVEL then local player = minetest.get_player_by_name("singleplayer") lzr_gui.set_play_gui(player) local level_name if current_level and current_level_data then level_name = lzr_levels.get_level_name(current_level, current_level_data, true) lzr_ambience.set_ambience(current_level_data[current_level].ambience) lzr_sky.set_sky(current_level_data[current_level].sky) lzr_weather.set_weather(current_level_data[current_level].weather) local backdrop = current_level_data[current_level].backdrop if backdrop == "ocean" then lzr_world.set_level_pos(lzr_globals.BACKDROP_POS_OCEAN) elseif backdrop == "islands" then lzr_world.set_level_pos(table.copy(current_level_data[current_level].backdrop_pos)) elseif backdrop == "underground" then lzr_world.set_level_pos(lzr_globals.BACKDROP_POS_UNDERGROUND) elseif backdrop == "sky" then lzr_world.set_level_pos(lzr_globals.BACKDROP_POS_SKY) end end lzr_player.set_play_inventory(player, level_name) end end) minetest.register_on_player_receive_fields(function(player, formname, fields) if lzr_gamestate.get_state() == lzr_gamestate.LEVEL then -- Fields from inventory formspec, set in lzr_player if fields.__lzr_levels_leave then lzr_levels.leave_level() elseif fields.__lzr_levels_restart then lzr_levels.restart_level() end end end) local function analyze_core_levels() local error_type, error_msg core_level_data, error_type, error_msg = lzr_levels.analyze_levels( minetest.get_modpath("lzr_levels").."/data/level_data.csv", minetest.get_modpath("lzr_levels").."/schematics" ) if not core_level_data then if error_type == "csv_error" then error("Error while parsing level_data.csv: "..tostring(error_msg)) elseif error_type == "load_error" then error("Could not load level_data.csv") else error("Error while loading or parsing level_data.csv") end end lzr_levels.LAST_LEVEL = #core_level_data end -- Stuff to do on mod load time: analyze_core_levels() update_legacy_completed_levels_format()