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 lzr_levels.LAST_LEVEL = 0 local level_size = vector.copy(lzr_globals.DEFAULT_LEVEL_SIZE) lzr_levels.get_level_size = function() return level_size end local set_level_size = function(new_size) level_size = vector.copy(new_size) minetest.log("verbose", "[lzr_levels] Level size set to: "..minetest.pos_to_string(new_size)) end 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 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, } 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> 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). 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 if CSV file coult not be read. ]] 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 end local level_list_string = level_list_file:read("*a") local level_list = lzr_csv.parse_csv(level_list_string) level_list_file:close() 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 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}) 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}) 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 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, vector.add(pos, offset)) else if y == 0 then table.insert(posses_floor, vector.add(pos, offset)) elseif y == size.y then table.insert(posses_ceiling, vector.add(pos, offset)) else table.insert(posses_border, vector.add(pos, offset)) 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 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), } lzr_levels.clear_playfield(param.size, inner) set_room_nodes(param.pos, param.size, param.nodes) lzr_laser.full_laser_update(lzr_globals.PLAYFIELD_START, lzr_globals.PLAYFIELD_END) minetest.log("action", "[lzr_levels] Room emerge resize callback done") else lzr_levels.clear_playfield(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 level_ok = lzr_levels.build_level_raw(param.schematic) 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(lzr_globals.PLAYFIELD_START, lzr_globals.PLAYFIELD_END) lzr_gui.update_treasure_status(player, found, get_max_treasures()) if param.parrot_pos then minetest.set_node(param.parrot_pos, {name="air"}) minetest.add_entity(vector.add(param.parrot_pos, lzr_globals.PARROT_SPAWN_OFFSET), "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 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 the playfield with air or water blocks. -- Also clears all objects. -- * room_size: Size of the room (required for water check) -- * protected_room_size: (optional): Nodes within a virtual -- room of the given size will not be emptied. -- If nil, this has no effect function lzr_levels.clear_playfield(room_size, protected_room_size) local posses_air = {} local posses_water = {} local size = lzr_globals.PLAYFIELD_SIZE local prot_min, prot_max if protected_room_size then prot_min = vector.new(0,0,0) prot_max = vector.add(prot_min, protected_room_size) end for z=0, size.z do for y=0, size.y do for x=0, size.x do local pos = vector.new(x,y,z) if not protected_room_size or not vector.in_area(pos, prot_min, prot_max) then pos = vector.add(pos, lzr_globals.PLAYFIELD_START) if pos.y <= lzr_globals.WATER_LEVEL and (x > room_size.x or y > room_size.y or z > room_size.z) then table.insert(posses_water, pos) else table.insert(posses_air, pos) end end end end end minetest.bulk_set_node(posses_water, {name="lzr_core:ocean_water_source"}) minetest.bulk_set_node(posses_air, {name="air"}) -- Also clear objects local objects = minetest.get_objects_in_area( lzr_globals.PLAYFIELD_START, vector.add(lzr_globals.PLAYFIELD_START, size)) for o=1, #objects do local obj = objects[o] if not obj:is_player() then obj:remove() end end 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) 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_globals.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) 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, } lzr_levels.build_room({mode="build", pos=lzr_globals.LEVEL_POS, size=level_data[level].size, level=level, level_data=level_data, spawn_pos=spawn_pos, yaw=yaw, parrot_pos=level_data[level].parrot_pos, nodes=bounding_nodes}) end function lzr_levels.prepare_and_build_custom_level(schematic, spawn_pos, yaw, bounding_nodes) lzr_levels.build_room({mode="build", pos=lzr_globals.LEVEL_POS, size=schematic.size, schematic=schematic, spawn_pos=spawn_pos, yaw=yaw, nodes=bounding_nodes}) end function lzr_levels.build_level_raw(schematic_specifier) local schem = minetest.place_schematic(lzr_globals.LEVEL_POS, schematic_specifier, "0", {}, true, "") if schem then -- Propagate lasers lzr_laser.full_laser_update(lzr_globals.PLAYFIELD_START, lzr_globals.PLAYFIELD_END) else 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 schem = lzr_levels.build_level_raw(schematic_specifier) -- Check for insta-chest-unlock local all_detectors = lzr_laser.check_all_detectors() if all_detectors and lzr_gamestate.get_state() == lzr_gamestate.LEVEL then lzr_laser.unlock_chests(lzr_globals.PLAYFIELD_START, lzr_globals.PLAYFIELD_END) end -- 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_globals.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 size = level_data[level].size local start_pos = get_start_pos(level, level_data) local spawn_pos = vector.add(lzr_globals.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) local needs_rotate = level_data[current_level].contains_rotatable_block reset_inventory(player, needs_rotate) if lzr_gamestate.get_state() ~= lzr_gamestate.EDITOR 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.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) if level_data ~= core_level_data then return end local levels = minetest.deserialize(mod_storage:get_string("lzr_levels:levels")) if not levels then levels = {} end levels[level] = 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")) if not levels then levels = {} end return levels 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) -- Trigger chest treasure particle animation local open_chests = minetest.find_nodes_in_area(lzr_globals.PLAYFIELD_START, lzr_globals.PLAYFIELD_END, {"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(lzr_globals.PLAYFIELD_START, lzr_globals.PLAYFIELD_END) 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 -- 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 treasures = lzr_laser.count_found_treasures(lzr_globals.PLAYFIELD_START, lzr_globals.PLAYFIELD_END) local player = get_singleplayer() lzr_gui.update_treasure_status(player, treasures, get_max_treasures()) 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_player.set_play_inventory(player) lzr_gui.set_play_gui(player) if current_level and current_level_data then 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) end lzr_laser.full_laser_update(lzr_globals.PLAYFIELD_START, lzr_globals.PLAYFIELD_END) end end) local function analyze_core_levels() core_level_data = lzr_levels.analyze_levels( minetest.get_modpath("lzr_levels").."/data/level_data.csv", minetest.get_modpath("lzr_levels").."/schematics" ) assert(core_level_data, "Could not load level_data.csv") lzr_levels.LAST_LEVEL = #core_level_data end analyze_core_levels()