1250 lines
43 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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:
<File name>,<Title>,<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("Cant 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()