2024-12-14 05:03:47 +01:00

1880 lines
64 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 = {}
-- Set to true to print a list of translatable
-- level strings to console. Helpful to update
-- the level name and NPC text locale files
local PRINT_TRANSLATABLE_LEVEL_STRINGS = false
-- Time the level title/complete message is shown (seconds)
local LEVEL_CAPTION_TIME = 3.0
local FINAL_LEVEL_CAPTION_TIME = 5.0
-- Delay in seconds before the next level starts
local NEXT_LEVEL_DELAY = 3.0
-- Time in seconds for which to reduce the ambience sound
-- volume when one of the level event sounds plays
local SOUND_TIME_LEVEL_ENTER = 0.43
local SOUND_TIME_LEVEL_LEAVE = 0.50
local SOUND_TIME_LEVEL_COMPLETE = 2.34
local SOUND_TIME_LEVEL_SET_COMPLETE = 20.0
local SILENT_SET_POS_TIME = 0.19
local current_level = nil
local current_level_data = nil
local registered_level_packs = {}
local level_packs_completed = {}
local registered_on_level_start_loadings = {}
local registered_on_level_starts = {}
-- Store jobs for minetest.after to avoid
-- running them twice
local job_next_level, job_insta_win
-- Allow to register a callback function that is called
-- when a new level has started loading but is not ready to play yet.
lzr_levels.register_on_level_start_loading = function(callback)
table.insert(registered_on_level_start_loadings, callback)
end
-- Allow to register a callback function that is called
-- when a new level has started and is ready to play.
lzr_levels.register_on_level_start = function(callback)
table.insert(registered_on_level_starts, callback)
end
local legacy_levels
local get_max_treasures = function()
if current_level and current_level_data then
return current_level_data[current_level].treasures
end
end
-- Count number of all gold blocks the player has found in
-- the a level pack. Only completed levels add to the
-- total.
function lzr_levels.count_total_collected_treasures(level_data)
local total = 0
local completed = lzr_levels.get_completed_levels(level_data)
local level_nums = {}
for level_num=1, #level_data do
local levelname = level_data[level_num].filename
levelname = string.sub(levelname, 1, -5) -- remove .mts suffix
level_nums[levelname] = level_num
end
for levelname, _ in pairs(completed) do
local level_num = level_nums[levelname]
local level = level_data[level_num]
if level then
total = total + level.treasures
end
end
return total
end
-- Count number of all available gold blocks in a level pack.
function lzr_levels.count_total_treasures(level_data)
local total = 0
for level_num=1, #level_data do
local level = level_data[level_num]
if level then
total = total + level.treasures
end
end
return total
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)
if not lzr_util.file_exists(levels_path, filename) then
return false, "file_nonexistant"
end
local filepath = levels_path .. "/" ..filename
local schem = minetest.read_schematic(filepath, {write_yslice_prob="none"})
if not schem then
return false, "schematic_load_error"
end
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
local hidden_parrot_spawners = 0
local bad_hidden_parrot_spawners = 0
local barriers = 0
local gold_blocks = 0
local plants_on_ground = 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
local p2 = schem.data[d].param2 % 4
if p2 == 0 then
level_data_entry.start_yaw = math.pi
elseif p2 == 1 then
level_data_entry.start_yaw = math.pi/2
elseif p2 == 2 then
level_data_entry.start_yaw = 0
else
level_data_entry.start_yaw = 3*(math.pi/2)
end
elseif 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
elseif nodename == "lzr_parrot_npc:hidden_parrot_spawner" then
-- Hidden parrot spawn pos
hidden_parrot_spawners = hidden_parrot_spawners + 1
local ppos = flat_index_to_pos(d, size)
level_data_entry.hidden_parrot_pos = ppos
-- Check param2 for hidden parrot spawner (must map
-- to a valid hidden parrot name)
local num = (schem.data[d].param2 % 4) + 1
local parrot_name = lzr_parrot_npc.get_hidden_parrot_name(num)
if not parrot_name then
bad_hidden_parrot_spawners = bad_hidden_parrot_spawners + 1
end
elseif nodename == "lzr_treasure:gold_block" then
gold_blocks = gold_blocks + 1
elseif minetest.get_item_group(nodename, "barrier") ~= 0 then
barriers = barriers + 1
elseif minetest.get_item_group(nodename, "plant_on_ground") ~= 0 then
plants_on_ground = plants_on_ground + 1
end
end
-- return false when level problems were detected
if teleporters == 0 then
return false, "no_teleporter"
elseif teleporters > 1 then
return false, "too_many_teleporters"
elseif parrot_spawners > 1 then
return false, "too_many_parrot_spawners"
elseif hidden_parrot_spawners > 1 then
return false, "too_many_hidden_parrot_spawners"
elseif bad_hidden_parrot_spawners > 0 then
return false, "bad_hidden_parrot_spawner"
elseif barriers > 0 then
return false, "barriers"
elseif gold_blocks > 0 then
return false, "gold_block"
elseif plants_on_ground > 0 then
return false, "plant_on_ground"
end
return true
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,
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 = "",
}
local ok = analyze_level_schematic(filename, levels_path, local_level)
if not ok then
return nil
end
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, ignored, floor, ceiling
wall is mandatory, the rest is optional (will default to the wall node)
ignored can be any string and will just be ignored (this was the window in old versions)
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)
* solutions_path: (optional) Path in which the level solutions are stored (.sol.csv 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.
Returns `nil, "bad_schematic", <schematic_file_name>, <problem ID> if level schematic contains error (e.g. missing teleporter).
]]
lzr_levels.analyze_levels = function(level_list_path, levels_path, solutions_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
local_level_data.solutions_path = solutions_path
local tr_level_names, tr_npc_texts = {}, {}
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, "|", true)
local node_wall = node_matches[1]
local node_legacy_window = node_matches[2]
local node_floor = node_matches[3] or node_wall
local node_ceiling = node_matches[4] or node_wall
-- If the level uses a legacy window, force the ceiling to
-- be a barrier so that light can enter the level
if node_legacy_window == "lzr_decor:woodframed_glass" then
node_ceiling = "lzr_core:barrier"
end
local filename_solution
if solutions_path then
filename_solution = string.sub(filename, 1, -5) .. ".sol.csv"
end
table.insert(local_level_data, {filename=filename, filename_solution=filename_solution, name=lname, node_wall=node_wall, 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})
if PRINT_TRANSLATABLE_LEVEL_STRINGS then
if lname and lname ~= "" then
table.insert(tr_level_names, lname)
end
if npc_texts and npc_texts.goldie and npc_texts.goldie ~= "" then
table.insert(tr_npc_texts, npc_texts.goldie)
end
end
end
if PRINT_TRANSLATABLE_LEVEL_STRINGS then
print("# TRANSLATABLE LEVEL STRINGS #")
print("## level_names.lua: ##")
for l=1, #tr_level_names do
print(string.format("S(%q)", tr_level_names[l]))
end
print("## npc_texts.lua: ##")
for t=1, #tr_npc_texts do
print(string.format("S(%q)", tr_npc_texts[t]))
end
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
local ok, problem = analyze_level_schematic(filename, levels_path, local_level_data[l])
if not ok then
return false, "bad_schematic", filename, problem
end
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_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 or minetest.get_item_group(node.name, "plant") ~= 0 then
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
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_ceiling, {name=nodes.node_ceiling})
end
local get_singleplayer = function()
return minetest.get_player_by_name("singleplayer")
end
local silent_set_pos_job
-- HACK: Call player:set_pos(pos) but briefly deactivates
-- the player footstep sounds. This is used as a workaround
-- for an issue that sometimes the client plays a water footstep
-- sound when teleporting into a level in the ocean.
-- The workaround works, with the small price of no footstep sounds
-- for the fraction of a second after entering a level.
-- TODO: Find a better solution.
local silent_set_pos = function(player, pos)
-- Deaktivate footstep sounds right before set_pos.
-- IMPORTANT: This assumes that makes_footstep_sound
-- isn't manipulated anywhere else!
player:set_properties({makes_footstep_sound = false})
player:set_pos(pos)
-- We only want one minetest.after job at a time
if silent_set_pos_job then
silent_set_pos_job:cancel()
end
-- Re-activate footstep sound briefly afterwards
silent_set_pos_job = minetest.after(SILENT_SET_POS_TIME, function(player)
if player and player:is_player() then
player:set_properties({makes_footstep_sound = true})
end
silent_set_pos_job = nil
end, player)
end
local emerge_callback = function(blockpos, action, calls_remaining, param)
minetest.log("verbose", "[lzr_levels] emerge_callback() called ...")
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
minetest.log("info", "[lzr_levels] emerge_callback() complete at blockpos "..minetest.pos_to_string(blockpos))
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")
if param.callback_done then
param.callback_done()
end
return
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()
if param.clear then
if param.clear_border then
lzr_levels.reset_level_area(true, param.pos, param.size)
else
lzr_levels.reset_area(true, param.pos, vector.subtract(param.size, vector.new(1,1,1)))
end
else
lzr_levels.reset_level_area(false, param.pos, param.size)
end
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
if param.spawn_pos == "start" then
silent_set_pos(player, param.pos)
else
silent_set_pos(player, param.spawn_pos)
end
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")
if param.callback_done then
param.callback_done()
end
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
local spawn_pos
if param.spawn_pos == "start" then
-- special "start" spawn pos puts player on teleporter
-- (meant for editor use)
local teleporters = minetest.find_nodes_in_area(param.pos, maxpos, "lzr_teleporter:teleporter_off")
local spos
if #teleporters > 0 then
spos = teleporters[1]
else
-- Alternatively, put player in the lowest air block
-- in the Y column of the level position
spos = table.copy(param.pos)
repeat
local snode = minetest.get_node(spos)
if snode.name == "air" then
if spos.y > param.pos.y then
spos.y = spos.y - 1
end
break
else
spos.y = spos.y + 1
end
until spos.y >= maxpos.y - 3
end
silent_set_pos(player, vector.offset(spos, 0, 0.5, 0))
else
-- normal spawn pos provides spawn position directly
silent_set_pos(player, param.spawn_pos)
end
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 or gs == lzr_gamestate.LEVEL_TEST then
local found = lzr_laser.count_found_treasures(param.pos, maxpos)
lzr_gui.update_treasure_status(player, found, get_max_treasures())
-- Spawn parrot
if param.parrot_pos then
local parrot_node_pos = vector.add(param.parrot_pos, param.pos)
local parrot_node = minetest.get_node(parrot_node_pos)
minetest.set_node(parrot_node_pos, {name="air"})
local parrot_entity_pos = vector.add(parrot_node_pos, lzr_globals.PARROT_SPAWN_OFFSET)
local obj = minetest.add_entity(parrot_entity_pos, "lzr_parrot_npc:parrot")
if obj then
local ent = obj:get_luaentity()
if ent then
-- set initial parrot yaw
local degrotate = parrot_node.param2
degrotate = (degrotate + 120) % 240
local yaw = (degrotate / 240) * (math.pi*2)
obj:set_yaw(yaw)
end
end
end
-- Spawn hidden parrot
if param.hidden_parrot_pos then
local hidden_parrot_node_pos = vector.add(param.hidden_parrot_pos, param.pos)
local pnode = minetest.get_node(hidden_parrot_node_pos)
minetest.set_node(hidden_parrot_node_pos, {name="air"})
local hidden_parrot_entity_pos = vector.add(hidden_parrot_node_pos, lzr_globals.PARROT_SPAWN_OFFSET)
local obj = minetest.add_entity(hidden_parrot_entity_pos, "lzr_parrot_npc:hidden_parrot")
if obj then
local ent = obj:get_luaentity()
if ent then
local p2 = pnode.param2 % 4
local dir = minetest.fourdir_to_dir(p2)
local yaw = minetest.dir_to_yaw(dir)
obj:set_yaw(yaw)
local parrot_name = lzr_parrot_npc.get_hidden_parrot_name(p2)
if parrot_name then
ent:_init(parrot_name)
else
minetest.log("error", "[lzr_levels] Could not set _hidden_id for hidden parrot!")
end
end
end
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, gs == lzr_gamestate.LEVEL_TEST)
lzr_ambience.reduce_ambience(SOUND_TIME_LEVEL_ENTER)
minetest.sound_play({name = "lzr_levels_level_enter", gain = 0.4}, {to_player=player:get_player_name()}, true)
-- Report level start to callback functions
for i=1, #registered_on_level_starts do
local callback = registered_on_level_starts[i]
callback()
end
end
end
if param.callback_done then
param.callback_done()
end
minetest.log("action", "[lzr_levels] Room emerge callback done")
end
end
end
end
local prepare_room = function(room_data)
local real_pos, real_size
-- When resizing, make sure we emerge the maximum size of either
-- old size or new size because we later might want to operate inside
-- the old size area as well.
if room_data.mode == "resize" and room_data.old_size then
real_size = {
x = math.max(room_data.old_size.x, room_data.size.x),
y = math.max(room_data.old_size.y, room_data.size.y),
z = math.max(room_data.old_size.z, room_data.size.z),
}
else
real_size = room_data.size
end
-- We have to emerge a few nodes extra for the level boundary nodes
-- which the area reset functions depend on
real_size = vector.offset(real_size, 2, 2, 2)
real_pos = vector.offset(room_data.pos, -1, -1, -1)
local real_max_pos = vector.add(real_pos, real_size)
minetest.log("info", "[lzr_mapgen] Starting to emerge from "..minetest.pos_to_string(real_pos).." to "..minetest.pos_to_string(real_max_pos).." ...")
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
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,
}
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, callback_done)
local bounding_nodes = {
node_floor = level_data[level].node_floor,
node_wall = level_data[level].node_wall,
node_ceiling = level_data[level].node_ceiling,
}
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,
hidden_parrot_pos=level_data[level].hidden_parrot_pos,
nodes=bounding_nodes,
backdrop=level_data[level].backdrop,
backdrop_pos=level_data[level].backdrop_pos,
triggers=level_data[level].triggers,
callback_done=callback_done
})
end
function lzr_levels.prepare_and_build_custom_level(level_pos, schematic, spawn_pos, yaw, bounding_nodes, triggers, callback_done)
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,
callback_done=callback_done
})
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)
local filepath = level_data.levels_path .. "/" .. level_data[level].filename
local schematic_specifier
if level_data.is_builtin then
-- Will provide file name to place_schematic, causing Luanti
-- 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 Luanti, 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
if job_insta_win then
job_insta_win:cancel()
end
job_insta_win = 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
job_insta_win = nil
end, {level=level, level_data=level_data})
end
return schem
end
local function clear_inventory(player)
local inv = player:get_inventory()
inv:set_list("main", {})
end
local function reset_inventory(player, needs_rotate)
clear_inventory(player)
if needs_rotate then
local inv = player:get_inventory()
inv:add_item("main", "lzr_hook:hook")
end
end
local function get_start_pos(level, level_data)
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
local function get_start_yaw(level, level_data, start_pos)
local start_yaw -- player start yaw
if level_data[level].start_yaw then
start_yaw = level_data[level].start_yaw
else
-- Fallback start yaw
start_yaw = 0
local size = level_data[level].size
if start_pos.z > size.z/2 then
start_yaw = start_yaw + math.pi
end
end
return start_yaw
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
if current_level_data.textdomain_npc_texts then
local translated_texts = {}
if texts then
for npc, text in pairs(texts) do
local tt = minetest.translate(current_level_data.textdomain_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 job_next_level then
job_next_level:cancel()
job_next_level = nil
end
if job_insta_win then
job_insta_win:cancel()
job_insta_win = nil
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_laser.reset_destroy_events()
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 = get_start_yaw(level, level_data, start_pos)
-- Report level loading start to callback functions
for i=1, #registered_on_level_start_loadings do
local callback = registered_on_level_start_loadings[i]
callback()
end
local needs_rotate = level_data[current_level].contains_rotatable_block
clear_inventory(player)
lzr_damage.reset_player_damage(player)
lzr_slowdown.stop_slowdown(player)
local state = lzr_gamestate.get_state()
if state ~= lzr_gamestate.LEVEL and state ~= lzr_gamestate.LEVEL_TEST then
lzr_gamestate.set_state(lzr_gamestate.LEVEL)
end
minetest.close_formspec(player:get_player_name(), "lzr_parrot_npc:speech")
lzr_gui.set_loading_gui(player)
lzr_player.set_loading_inventory_formspec(player)
lzr_gamestate.set_loading(true)
-- Stuff do do when the level was started
local done = function()
reset_inventory(player, needs_rotate)
lzr_ambience.set_ambience(level_data[level].ambience)
lzr_sky.set_sky(level_data[level].sky)
lzr_weather.set_weather(level_data[level].weather)
lzr_gui.set_play_gui(player, lzr_gamestate.get_state() == lzr_gamestate.LEVEL_TEST)
lzr_gamestate.set_loading(false)
minetest.log("action", "[lzr_levels] Started level "..level)
end
-- Start loading and building the level (async)
lzr_levels.prepare_and_build_level(level, level_data, spawn_pos, yaw, old_pos, old_size, done)
minetest.log("action", "[lzr_levels] Starting level "..level.." ...")
end
local registered_on_collected_treasures = {}
function lzr_levels.register_on_collected_treasure(func)
table.insert(registered_on_collected_treasures, func)
end
function lzr_levels.clear_level_progress(level_data)
if level_data.is_singleton then
return
end
level_packs_completed[level_data.name] = nil
local levels = minetest.deserialize(mod_storage:get_string("lzr_levels:levels"), true)
levels[level_data.name] = {}
mod_storage:set_string("lzr_levels:levels", minetest.serialize(levels))
if level_data.name == "__core" then
lzr_menu.remove_painting("perfect_plunderer")
-- Also needs to notify mods about changed
-- treasure count
local treasures = lzr_levels.count_total_collected_treasures(level_data)
for i=1, #registered_on_collected_treasures do
registered_on_collected_treasures[i](treasures)
end
end
minetest.log("action", "[lzr_levels] Level progress for level pack '"..level_data.name.."' was cleared")
end
function lzr_levels.mark_level_as_complete(level, level_data)
if level_data.is_singleton then
return
end
local levels = minetest.deserialize(mod_storage:get_string("lzr_levels:levels"), true)
if not levels then
levels = { [level_data.name] = {} }
end
if not level_data[level] then
return false
end
local levelname = level_data[level].filename
levelname = string.sub(levelname, 1, -5) -- remove .mts suffix
levels[level_data.name][levelname] = true
mod_storage:set_string("lzr_levels:levels", minetest.serialize(levels))
-- Notify mods that the treasure count has changed
local treasures = lzr_levels.count_total_collected_treasures(level_data)
for i=1, #registered_on_collected_treasures do
registered_on_collected_treasures[i](treasures)
end
-- Place 'Perfect Plunderer' painting in ship
-- as some kind of "mini-achievement"
if level_data.name == "__core" and lzr_levels.are_all_levels_completed(level_data) then
lzr_menu.place_painting("perfect_plunderer")
end
return true
end
-- Returns true if all levels of a level pack are completed
function lzr_levels.are_all_levels_completed(level_data)
local completed = lzr_levels.get_completed_levels(level_data)
for l=1, #level_data do
local levelname = string.sub(level_data[l].filename, 1, -5) -- remove .mts suffix
if not completed[levelname] then
return false
end
end
return true
end
-- Returns list of completed levels of a level pack in the form
-- { levelname1 = true, levelname2 = true, ... }
function lzr_levels.get_completed_levels(level_data)
if level_data.is_singleton then
return {}
end
local levels = minetest.deserialize(mod_storage:get_string("lzr_levels:levels"), true)
if not levels then
return {}
end
if levels[level_data.name] then
return levels[level_data.name]
else
return {}
end
end
-- Returns number of completed levels in level pack
-- or nil if not applicable
function lzr_levels.count_completed_levels(level_data)
if level_data.is_singleton then
return nil
end
local levels = minetest.deserialize(mod_storage:get_string("lzr_levels:levels"), true)
if not levels then
return 0
end
if levels[level_data.name] then
local count = 0
for k,v in pairs(levels[level_data.name]) do
count = count + 1
end
return count
else
return 0
end
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 pack names.
This transforms to core levels only, as the legacy completed levels
list were only for core levels as well.
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()
local gs = lzr_gamestate.get_state()
if gs ~= 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.is_singleton then
minetest.log("action", "[lzr_levels] Level completed")
else
minetest.log("action", "[lzr_levels] Level "..current_level.." of level pack '"..current_level_data.name.."' completed")
end
-- Victory fanfare
if has_treasure then
lzr_ambience.reduce_ambience(SOUND_TIME_LEVEL_COMPLETE)
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)
if job_next_level then
job_next_level:cancel()
end
-- Go to next level
job_next_level = minetest.after(NEXT_LEVEL_DELAY, function(completed_level)
if lzr_gamestate.get_state() == lzr_gamestate.LEVEL_COMPLETE and current_level == completed_level then
lzr_levels.next_level()
end
job_next_level = nil
end, current_level)
end
function lzr_levels.game_completed()
if not level_packs_completed.__core then
local player = minetest.get_player_by_name("singleplayer")
if not player then
return false
end
lzr_parrot_npc.speak(player, S("Yarr! You did it! Our ships full of gold now. Were rich!").."\n"..
S("Youve collected every treasure in the known world!").."\n"..
S("No puzzle was too hard, no security mechanism could stop you.").."\n\n"..
S("You have become the Perfect Plunderer!"), "goldie")
lzr_ambience.reduce_ambience(SOUND_TIME_LEVEL_SET_COMPLETE)
minetest.sound_play({name = "lzr_levels_level_set_complete", gain = 1.0}, nil, true)
lzr_levels.leave_level(nil, false)
lzr_menu.teleport_player_to_ship(player, "victory")
minetest.log("action", "[lzr_levels] Game completed!")
return true
else
return false
end
end
function lzr_levels.next_level()
local state = lzr_gamestate.get_state()
if state ~= lzr_gamestate.LEVEL and state ~= lzr_gamestate.LEVEL_COMPLETE and state ~= lzr_gamestate.LEVEL_TEST then
return
end
if not current_level_data then
return
end
if current_level_data.is_singleton then
lzr_levels.leave_level()
return
end
if lzr_levels.are_all_levels_completed(current_level_data) then
if current_level_data.name == "__core" then
local celebrated = lzr_levels.game_completed()
if celebrated then
level_packs_completed["__core"] = true
return
end
end
level_packs_completed[current_level_data.name] = true
end
local player = get_singleplayer()
current_level = current_level + 1
if current_level > #current_level_data 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(fallout)
current_level = nil
current_level_data = nil
local player = get_singleplayer()
clear_inventory(player)
-- Select respawn position
if fallout then
-- Respawn pos when fallout was triggered (i.e. fell out of level or crushed)
lzr_menu.teleport_player_to_ship(player, "skulls")
else
-- Normal player spawn pos in ship
lzr_menu.teleport_player_to_ship(player, "captain")
end
lzr_gamestate.set_state(lzr_gamestate.MENU)
end
function lzr_levels.leave_level(fallout, play_sound)
if lzr_gamestate.is_loading() then
return false
end
local state = lzr_gamestate.get_state()
if state ~= lzr_gamestate.LEVEL and state ~= lzr_gamestate.LEVEL_COMPLETE and state ~= lzr_gamestate.LEVEL_TEST then
return false
end
current_level = nil
current_level_data = nil
lzr_levels.go_to_menu(fallout)
-- Clean up level area
local pos = lzr_world.get_level_pos()
local size = lzr_world.get_level_size()
lzr_levels.reset_level_area(false, pos, size)
if play_sound ~= false then
lzr_ambience.reduce_ambience(SOUND_TIME_LEVEL_LEAVE)
minetest.sound_play({name = "lzr_levels_level_leave", gain = 0.4}, {to_player="singleplayer"}, true)
end
return true
end
function lzr_levels.get_current_level()
return current_level
end
function lzr_levels.get_current_level_data()
return current_level_data
end
--[[
Register a level pack. A level pack is a collection of levels that belong together.
For this to work, the level-related data must be present in the locations defined at
level_data_file, schematic_path and (optionally) solutions_path described below.
If successful, the level pack will appear in the game under the custom levels menu.
Parameters:
* name: level pack ID (string, allowed characters are a-z, A-Z, 0-9 and _ (underscore))
* info: Table of optional additional information, with these fields:
* title: human-readable level pack title
* description: short description/explanation about this level pack. 1-3 sentences.
* textdomain_level_names: textdomain of the translation file containing the translated level names (default: no translation)
* textdomain_npc_texts: textdomain of the translation file containing the translated texts for NPCs like Goldie the Parrot (default: no translation)
* level_data_file: Path to CSV file containing metadata of all levels (default: <modpath>/data/level_data.csv)
* schematic_path: Path to directory containing the level '.mts' schematic files (default: <modpath>/schematics)
* solutions_path: Path to directory containing the OPTIONAL level '.sol.csv' solution files (default: <modpath>/solutions)
]]
function lzr_levels.register_level_pack(name, info)
local mod = minetest.get_current_modname()
local level_data_file = info.level_data_path or minetest.get_modpath(mod).."/data/level_data.csv"
local schematic_path = info.schematic_path or minetest.get_modpath(mod).."/schematics"
local solutions_path = info.solutions_path or minetest.get_modpath(mod).."/solutions"
local error_type, error_msg, error_detail
local level_data
level_data, error_type, error_msg, error_detail = lzr_levels.analyze_levels(
level_data_file, schematic_path, solutions_path)
if not level_data then
if error_type == "csv_error" then
error("Error while parsing "..tostring(level_data_file)..": "..tostring(error_msg))
elseif error_type == "load_error" then
error("Could not load "..tostring(level_data_file))
elseif error_type == "bad_schematic" then
error("Invalid level schematic in core level file: "..tostring(error_msg)..", problem type: "..tostring(error_detail))
else
error("Error while loading or parsing "..tostring(level_data_file))
end
end
level_data.name = name
level_data.mod_origin = minetest.get_current_modname()
level_data.textdomain_npc_texts = info.textdomain_npc_texts
level_data.textdomain_level_names = info.textdomain_level_names
level_data.title = info.title
level_data.description = info.description
registered_level_packs[name] = level_data
if lzr_levels.are_all_levels_completed(level_data) then
level_packs_completed[name] = true
end
end
-- Returns level packs by name
function lzr_levels.get_level_pack(name)
return registered_level_packs[name]
end
-- Returns list of all registered level packs (by name)
function lzr_levels.get_level_pack_names()
local packs = {}
for k,v in pairs(registered_level_packs) do
table.insert(packs, k)
end
return packs
end
-- Returns the name of the level with the given level number, translated
-- (if available in level pack).
-- 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)
local name = level_data[level].name
if name and name ~= "" then
if level_data.textdomain_level_names then
return minetest.translate(level_data.textdomain_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)
--~ Fallback name for untitled levels. @1 = technical level name based on file name
return S("Untitled (@1)", fname)
else
return ""
end
end
end
function lzr_levels.restart_level()
if job_next_level then
job_next_level:cancel()
job_next_level = nil
end
if job_insta_win then
job_insta_win:cancel()
job_insta_win = nil
end
local state = lzr_gamestate.get_state()
if state == lzr_gamestate.LEVEL or state == lzr_gamestate.LEVEL_COMPLETE or state == lzr_gamestate.LEVEL_TEST then
if not lzr_gamestate.is_loading() then
lzr_levels.start_level(current_level, current_level_data)
return true
else
return false
end
else
return false
end
end
-- Called when a treasure has been found
lzr_treasure.register_after_found_treasure(function(pos)
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
-- Only save trigger if it either sends to anywhere
-- or has a non-default receiver type.
if send_to_str ~= "" or receiver_type ~= 0 then
-- <<< 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
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
minetest.log("error", "[lzr_levels] Could not parse CSV-serialized triggers: "..tostring(csv_error))
lzr_levels.init_triggers()
return false
end
-- Parse entries.
-- ANY error leads to a cancellation and the function
-- will revert to the triggers in init state so the
-- game doesnt break.
for e=1, #entries do
local entry = entries[e]
local pos_str = entry[1]
if not pos_str then
minetest.log("error", "[lzr_levels] deserialize_triggers: Missing position. Row="..e)
lzr_levels.init_triggers()
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 lzr_triggers.trigger_exists(trigger_id) then
minetest.log("error", "[lzr_levels] deserialize_triggers: Found information to trigger '"..tostring(trigger_id).."' which doesnt exist")
lzr_levels.init_triggers()
return false
end
if not pos then
minetest.log("error", "[lzr_levels] deserialize_triggers: Invalid position. Row="..e)
lzr_levels.init_triggers()
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
minetest.log("error", "[lzr_levels] deserialize_triggers: Key/value mismatch. Row="..e.."; row length="..#entry)
lzr_levels.init_triggers()
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
minetest.log("error", "[lzr_levels] deserialize_triggers: send_to["..s.."] is not a position vector! Row="..e)
lzr_levels.init_triggers()
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
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
lzr_triggers.set_trigger_receiver_type(trigger_id, receiver_type)
end
else
minetest.log("error", "[lzr_levels] deserialize_triggers: Invalid key: "..tostring(key).."; Row="..e)
lzr_levels.init_triggers()
return false
end
end
end
return true
end
minetest.register_chatcommand("level_info", {
privs = {},
params = "",
description = S("Display information about the identity of the 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.LEVEL_TEST then
local fail = true
if current_level and current_level_data then
local level = current_level_data[current_level]
if level then
fail = false
local lname = lzr_levels.get_level_name(current_level, current_level_data, true)
local fname = level.filename
local out = ""
local pinfo = minetest.get_player_information(name)
local lang_code = pinfo.lang_code
local VALUE_COLOR = "#ffc0c0"
if lang_code and lang_code == "en" then
out = out .. S("• Level name: @1", minetest.colorize(VALUE_COLOR, lname)) .. "\n"
else
out = out .. S("• Level name (in your language): @1", minetest.colorize(VALUE_COLOR, lname)).."\n"
out = out .. S("• Level name (in English): @1", minetest.colorize(VALUE_COLOR, minetest.get_translated_string("en", lname))).."\n"
end
out = out .. S("• File name: @1", minetest.colorize(VALUE_COLOR, fname))
if current_level_data.name == "__core" then
--~ Level type can be "core" or "custom"
out = out .. "\n" .. S("• Level type: @1", minetest.colorize(VALUE_COLOR,
--~ A level type (core levels)
S("core")))
if minetest.settings:get_bool("lzr_debug", false) then
out = out .. "\n" .. S("• Level number: @1", minetest.colorize(VALUE_COLOR, current_level))
end
else
out = out .. "\n" .. S("• Level type: @1", minetest.colorize(VALUE_COLOR,
--~ A level type (custom levels)
S("custom")))
end
return true, out
end
end
if fail then
minetest.log("error", "[lzr_levels] /levelinfo command could not get level information!")
return false, S("Could not get level information.")
end
elseif state == lzr_gamestate.EDITOR then
return false, S("Youre in the level editor.")
else
return false, S("Not playing in a level!")
end
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 or state == lzr_gamestate.LEVEL_COMPLETE then
if not lzr_gamestate.is_loading() then
lzr_levels.restart_level()
return true
else
return false, S("Cant restart while loading!")
end
elseif state == lzr_gamestate.LEVEL_TEST then
return false, S("Cant restart during the level solution test!")
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 then
if lzr_gamestate.is_loading() then
return false, S("Cant leave while loading!")
else
lzr_levels.leave_level()
return true
end
elseif state == lzr_gamestate.LEVEL_TEST then
-- during the level test, /leave aborts the level test
lzr_levels.leave_level()
return true
else
return false, S("Not playing in a level!")
end
end,
})
lzr_gamestate.register_on_enter_state(function(state)
if state == lzr_gamestate.LEVEL or state == lzr_gamestate.LEVEL_TEST then
local player = minetest.get_player_by_name("singleplayer")
lzr_gui.set_play_gui(player, state == lzr_gamestate.LEVEL_TEST)
local level_name
if current_level and current_level_data then
level_name = lzr_levels.get_level_name(current_level, current_level_data, true)
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, state == lzr_gamestate.LEVEL_TEST)
elseif state == lzr_gamestate.LEVEL_COMPLETE then
local player = minetest.get_player_by_name("singleplayer")
local level_name
if current_level and current_level_data then
level_name = lzr_levels.get_level_name(current_level, current_level_data, true)
end
lzr_player.set_play_inventory(player, level_name)
end
end)
minetest.register_on_player_receive_fields(function(player, formname, fields)
local state = lzr_gamestate.get_state()
if state == lzr_gamestate.LEVEL or state == lzr_gamestate.LEVEL_COMPLETE or state == lzr_gamestate.LEVEL_TEST then
-- Fields from inventory formspec, set in lzr_player
if fields.__lzr_levels_leave then
if lzr_gamestate.is_loading() then
minetest.chat_send_player(player:get_player_name(), S("Cant leave while loading!"))
return
end
lzr_levels.leave_level()
elseif fields.__lzr_levels_restart and state ~= lzr_gamestate.LEVEL_TEST then
if lzr_gamestate.is_loading() then
minetest.chat_send_player(player:get_player_name(), S("Cant restart while loading!"))
return
end
lzr_levels.restart_level()
end
end
end)
update_legacy_completed_levels_format()