diff --git a/vmf/scripts/mods/vmf/modules/core/options.lua b/vmf/scripts/mods/vmf/modules/core/options.lua index aa2867d..4b804ed 100644 --- a/vmf/scripts/mods/vmf/modules/core/options.lua +++ b/vmf/scripts/mods/vmf/modules/core/options.lua @@ -1,20 +1,33 @@ local vmf = get_mod("VMF") -vmf.options_widgets_data = {} +-- This variable is defined here and not in widget data initialization function because some error messages +-- require it to be dumped to game log. +local _unfolded_raw_widgets_data + +-- Saves used setting_ids for initializable mod. Is used to detect if 2 widgets use the same setting_id +local _defined_mod_settings -- ##################################################################################################################### --- ##### Local functions (Default Collapsed Widgets) ################################################################### +-- ##### Local functions ############################################################################################### -- ##################################################################################################################### +local function throw_error(error_message, ...) + error(string.format(error_message, ...), 0) +end + +-- ############################# +-- # Default collapsed widgets # +-- ############################# + -- @BUG: you can set it for disabled checkbox and it will be displayed as collapsed @TODO: fix it for new mod options local function initialize_collapsed_widgets(mod, collapsed_widgets) local new_collapsed_widgets = {} - for _, collapsed_widget_name in ipairs(collapsed_widgets) do + for i, collapsed_widget_name in ipairs(collapsed_widgets) do if type(collapsed_widget_name) == "string" then new_collapsed_widgets[collapsed_widget_name] = true else - -- @TODO: throw an error + throw_error("'collapsed_widgets[%d]' is not a string", i) end end @@ -23,47 +36,218 @@ local function initialize_collapsed_widgets(mod, collapsed_widgets) vmf:set("options_menu_collapsed_widgets", options_menu_collapsed_widgets) end --- ##################################################################################################################### --- ##### Local functions (Initializaing Widget Data) ################################################################### --- ##################################################################################################################### +-- ################ +-- # Widgets data # +-- ################ ---------------- -- VALIDATION -- ---------------- -local function validate_generic_widget_data(mod, data) - --[[ - string: - data.setting_id - data.title (optional if localize) - data.tooltip (optional) - ]] +local function validate_generic_widget_data(data) + local setting_id = data.setting_id + + if type(setting_id) ~= "string" then + vmf:dump(_unfolded_raw_widgets_data, "widgets", 1) + throw_error("[widget#%d (%s)]: 'setting_id' field is required and must have 'string' type. " .. + "See dumped table in game log for reference.", data.index, data.type) + end + + if not data.localize and not data.title then + throw_error("[widget \"%s\" (%s)]: lacks 'title' field (localization is disabled)", setting_id, data.type) + end + + if data.title and type(data.title) ~= "string" then + throw_error("[widget \"%s\" (%s)]: 'title' field must have 'string' type", setting_id, data.type) + end + + if data.tooltip and type(data.tooltip) ~= "string" then + throw_error("[widget \"%s\" (%s)]: 'tooltip' field must have 'string' type", setting_id, data.type) + end + + if _defined_mod_settings[setting_id] then + vmf:dump(_unfolded_raw_widgets_data, "widgets", 1) + throw_error("Widgets %d and %d have the same setting_id (\"%s\"). See dumped table in game log for reference.", + _defined_mod_settings[setting_id], data.index, setting_id) + else + _defined_mod_settings[setting_id] = data.index + end end -local function validate_checkbox_data(mod, data) - +local function validate_checkbox_data(data) + if type(data.default_value) ~= "boolean" then + throw_error("[widget \"%s\" (checkbox)]: 'default_value' field is required and must have 'boolean' type", + data.setting_id) + end end -local function validate_dropdown_data(mod, data) +local allowed_dropdown_values = { + boolean = true, + string = true, + number = true +} +local function validate_dropdown_data(data) - -- default value - something? - -- options - table - -- options.title - string - -- options.value - something + some of them is default value - -- options.show_widgets - table + if not allowed_dropdown_values[type(data.default_value)] then + throw_error("[widget \"%s\" (dropdown)]: 'default_value' field is required and must have 'string', " .. + "'number' or 'boolean' type", data.setting_id) + end + if type(data.options) ~= "table" then + throw_error("[widget \"%s\" (dropdown)]: 'options' field is required and must have 'table' type", data.setting_id) + end + + local default_value = data.default_value + local default_value_match = false + local used_values = {} + for i, option in ipairs(data.options) do + local option_value = option.value + + if type(option.text) ~= "string" then + throw_error("[widget \"%s\" (dropdown)]: 'options[%d]'-> 'text' field is required and must have 'string' type", + data.setting_id, i) + end + + if not allowed_dropdown_values[type(option_value)] then + throw_error("[widget \"%s\" (dropdown)]: 'options[%d]'-> 'value' field is required and must have 'string', " .. + "'number' or 'boolean' type", data.setting_id, i) + end + + if option.show_widgets and type(option.show_widgets) ~= "table" then + throw_error("[widget \"%s\" (dropdown)]: 'options[%d]'-> 'show_widgets' field must have 'table' type", + data.setting_id, i) + end + + if used_values[option_value] then + throw_error("[widget \"%s\" (dropdown)]: 'options[%d]' has 'value' field set to the same value " .. + "as one of previous options", data.setting_id, i) + end + + used_values[option_value] = true + + if default_value == option_value then + default_value_match = true + end + end + + if not default_value_match then + throw_error("[widget \"%s\" (dropdown)]: 'default_value' field contains value not defined in 'options' field", + data.setting_id) + end end -local function validate_keybind_data(mod, data) +local allowed_keybind_triggers = { + pressed = true, + released = true, + held = true +} +local allowed_keybind_types = { + action_call = true, + view_toggle = true, + mod_toggle = true +} +local allowed_special_keys = { + ctrl = true, + alt = true, + shift = true +} +local function validate_keybind_data(data) + if data.keybind_global and type(data.keybind_global) ~= "boolean" then + throw_error("[widget \"%s\" (keybind)]: 'keybind_global' field must have 'boolean' type", data.setting_id) + end + if not allowed_keybind_triggers[data.keybind_trigger] then + throw_error("[widget \"%s\" (keybind)]: 'keybind_trigger' field is required and must contain string " .. + "\"action_call\", \"view_toggle\" or \"mod_toggle\"", data.setting_id) + end + + local keybind_type = data.keybind_type + if not allowed_keybind_types[keybind_type] then + throw_error("[widget \"%s\" (keybind)]: 'keybind_type' field is required and must contain string " .. + "\"pressed\", \"released\" or \"held\"", data.setting_id) + end + if keybind_type == "action_call" and type(data.action_name) ~= "string" then + throw_error("[widget \"%s\" (keybind)]: 'keybind_type' is set to \"action_call\" so 'action_name' " .. + "field is required and must have 'string' type", data.setting_id) + end + if keybind_type == "view_toggle" and type(data.view_name) ~= "string" then + throw_error("[widget \"%s\" (keybind)]: 'keybind_type' is set to \"view_toggle\" so 'view_name' " .. + "field is required and must have 'string' type", data.setting_id) + end + + local default_value = data.default_value + if type(default_value) ~= "table" then + throw_error("[widget \"%s\" (keybind)]: 'default_value' field is required and must have 'table' type", + data.setting_id) + end + if #default_value > 4 then + throw_error("[widget \"%s\" (keybind)]: table stored in 'default_value' field can't exceed 4 elements", + data.setting_id) + end + if default_value[1] and (not vmf.readable_key_names[default_value[1]] or allowed_special_keys[default_value[1]]) then + throw_error("[widget \"%s\" (keybind)]: 'default_value[1]' must be a valid key name", data.setting_id) + end + if default_value[2] and not allowed_special_keys[default_value[2]] or + default_value[3] and not allowed_special_keys[default_value[3]] or + default_value[4] and not allowed_special_keys[default_value[4]] + then + throw_error("[widget \"%s\" (keybind)]: 'default_value [2], [3] and [4]' can be only strings: \"ctrl\", " .. + "\"alt\" and \"shift\" (in no particular order)", data.setting_id) + end + + local used_keys = {} + for _, key in ipairs(default_value) do + if used_keys[key] then + throw_error("[widget \"%s\" (keybind)]: you can't define the same key in 'default_value' table twice", + data.setting_id) + end + used_keys[key] = true + end end -local function validate_numeric_data(mod, data) +local function validate_numeric_data(data) + if data.unit_text and type(data.unit_text) ~= "string" then + throw_error("[widget \"%s\" (numeric)]: 'unit_text' field must have 'string' type", data.setting_id) + end + if type(data.decimals_number) ~= "number" then + throw_error("[widget \"%s\" (numeric)]: 'decimals_number' field must have 'number' type", data.setting_id) + end + if data.decimals_number < 0 then -- @TODO: eventually do max cap as well + throw_error("[widget \"%s\" (numeric)]: 'decimals_number' value can't be lower than zero", data.setting_id) + end + + local range = data.range + if type(range) ~= "table" then + throw_error("[widget \"%s\" (numeric)]: 'range' field is required and must have 'table' type", data.setting_id) + end + if #range ~= 2 then + throw_error("[widget \"%s\" (numeric)]: 'range' field must contain an array-like table with 2 elements", + data.setting_id) + end + local range_min = range[1] + local range_max = range[2] + if type(range_min) ~= "number" or type(range_max) ~= "number" then + throw_error("[widget \"%s\" (numeric)]: table stored in 'range' field must contain only numbers", + data.setting_id) + end + if range_min > range_max then + throw_error("[widget \"%s\" (numeric)]: 'range[2]' must be bigger than 'range[1]'", data.setting_id) + end + + local default_value = data.default_value + if type(default_value) ~= "number" then + throw_error("[widget \"%s\" (numeric)]: 'default_value' field is required and must have 'number' type", + data.setting_id) + end + if default_value < range_min or default_value > range_max then + throw_error("[widget \"%s\" (numeric)]: 'default_value' field must contain number fitting set 'range'", + data.setting_id) + end end ------------------ @@ -95,6 +279,13 @@ local function localize_dropdown_data(mod, data) end end + +local function localize_numeric_data(mod, data) + if data.localize and data.unit_text then + data.unit_text = mod:localize(data.unit_text) + end +end + -------------------- -- INITIALIZATION -- -------------------- @@ -143,7 +334,7 @@ local function initialize_generic_widget_data(mod, data, localize) new_data.localize = data.localize end - validate_generic_widget_data(mod, new_data) + validate_generic_widget_data(new_data) localize_generic_widget_data(mod, new_data) new_data.tooltip = new_data.tooltip and (new_data.title .. "\n" .. new_data.tooltip) @@ -166,7 +357,7 @@ local function initialize_checkbox_data(mod, data, localize, collapsed_widgets) new_data.is_collapsed = collapsed_widgets[data.setting_id] - validate_checkbox_data(mod, new_data) + validate_checkbox_data(new_data) return new_data end @@ -178,7 +369,7 @@ local function initialize_dropdown_data(mod, data, localize, collapsed_widgets) new_data.is_collapsed = collapsed_widgets[data.setting_id] new_data.options = data.options - validate_dropdown_data(mod, new_data) + validate_dropdown_data(new_data) localize_dropdown_data(mod, new_data) -- Converting show_widgets from human-readable form to vmf-options-readable @@ -192,8 +383,8 @@ local function initialize_dropdown_data(mod, data, localize, collapsed_widgets) if data.sub_widgets[sub_widget_index] then new_show_widgets[data.sub_widgets[sub_widget_index].index] = true else - error(string.format("'widget \"%s\" (dropdown) -> options -> [%d] -> show_widgets -> [%d] \"%s\"' points" .. - " to non-existing sub_widget", data.setting_id, i, j, sub_widget_index)) + throw_error("[widget \"%s\" (dropdown)]: 'options -> [%d] -> show_widgets -> [%d] \"%s\"' points" .. + " to non-existing sub_widget", data.setting_id, i, j, sub_widget_index) end end option.show_widgets = new_show_widgets @@ -214,7 +405,7 @@ local function initialize_keybind_data(mod, data, localize) new_data.action_name = data.action_name new_data.view_name = data.view_name - validate_keybind_data(mod, new_data) + validate_keybind_data(new_data) return new_data end @@ -225,9 +416,10 @@ local function initialize_numeric_data(mod, data, localize) new_data.unit_text = data.unit_text new_data.range = data.range - new_data.decimals_number = data.decimals_number + new_data.decimals_number = data.decimals_number or 0 - validate_numeric_data(mod, new_data) + validate_numeric_data(new_data) + localize_numeric_data(mod, new_data) return new_data end @@ -246,16 +438,14 @@ local function initialize_widget_data(mod, data, localize, collapsed_widgets) return initialize_keybind_data(mod, data, localize) elseif data.type == "numeric" then return initialize_numeric_data(mod, data, localize) - else - -- @TODO: throw an error or something end + -- if data.type is incorrect, returns nil end ----------- -- OTHER -- ----------- --- unfold nested table? local function unfold_table(unfolded_table, unfoldable_table, parent_index, depth) for i = 1, #unfoldable_table do local nested_table = unfoldable_table[i] @@ -270,15 +460,14 @@ local function unfold_table(unfolded_table, unfoldable_table, parent_index, dept unfold_table(unfolded_table, nested_table_sub_widgets, #unfolded_table, depth + 1) else vmf:dump(unfolded_table, "widgets", 1) - error(string.format("'sub_widgets' field of widget [%d] is not a table, it's %s. " .. - "See dumped table in game log for reference.", #unfolded_table, - type(nested_table_sub_widgets)), 0) + throw_error("'sub_widgets' field of widget [%d] is not a table, it's %s. " .. + "See dumped table in game log for reference.", #unfolded_table, type(nested_table_sub_widgets)) end end else vmf:dump(unfolded_table, "widgets", 1) - error(string.format("sub_widget#%d of widget [%d] is not a table, it's %s. " .. - "See dumped table in game log for reference.", i, parent_index, type(nested_table)), 0) + throw_error("sub_widget#%d of widget [%d] is not a table, it's %s. " .. + "See dumped table in game log for reference.", i, parent_index, type(nested_table)) end end return unfolded_table @@ -286,23 +475,46 @@ end local function initialize_mod_options_widgets_data(mod, widgets_data, localize) + widgets_data = widgets_data or {} + -- Override global localize option if it's set for widgets data + if widgets_data.localize ~= nil then + localize = widgets_data.localize + end + local initialized_data = {} - -- Define widget data for header, because it's not up to modders to define it. - local header_widget_data = {type = "header", widget_index = 1, sub_widgets = widgets_data} + -- Define widget data for header widget, because it's not up to modders to define it. + local header_widget_data = {type = "header", sub_widgets = widgets_data} -- Put data of all widgets in one-dimensional array in order they will be displayed in mod options. - local unfolded_raw_widgets_data = unfold_table({header_widget_data}, widgets_data, 1, 1) + _unfolded_raw_widgets_data = unfold_table({header_widget_data}, widgets_data, 1, 1) -- Load info about widgets previously collapsed by user local collapsed_widgets = vmf:get("options_menu_collapsed_widgets")[mod:get_name()] or {} - for _, widget_data in ipairs(unfolded_raw_widgets_data) do - table.insert(initialized_data, initialize_widget_data(mod, widget_data, localize, collapsed_widgets)) + -- Before starting widgets data initialization, clear this table. It's used to detect if 2 widgets + -- defined the same setting_id. + _defined_mod_settings = {} + -- Initialize widgets' data. + for _, widget_data in ipairs(_unfolded_raw_widgets_data) do + local initialized_widget_data = initialize_widget_data(mod, widget_data, localize, collapsed_widgets) + if initialized_widget_data then + table.insert(initialized_data, initialized_widget_data) + else + vmf:dump(_unfolded_raw_widgets_data, "widgets", 1) + throw_error("[widget#%d]: 'type' field must contain valid widget type name. " .. + "See dumped table in game log for reference.", widget_data.index, widget_data.type) + end end - -- Set setting to default value that were not set before (skipping header) - -- Also, initialize keybinds - for i = 2, #initialized_data do - local data = initialized_data[i] + return initialized_data +end + +-- ################################################ +-- # Default settings and keybinds initialization # +-- ################################################ + +local function initialize_default_settings_and_keybinds(mod, initialized_widgets_data) + for i = 2, #initialized_widgets_data do + local data = initialized_widgets_data[i] if mod:get(data.setting_id) == nil then mod:set(data.setting_id, data.default_value) end @@ -310,42 +522,35 @@ local function initialize_mod_options_widgets_data(mod, widgets_data, localize) mod:keybind(data.setting_id, data.action_name, mod:get(data.setting_id)) end end - - table.insert(vmf.options_widgets_data, initialized_data) - - -- @DEBUG: - mod:dump(unfolded_raw_widgets_data, "unfolded_raw_widgets_data", 1) end -- ##################################################################################################################### -- ##### VMF internal functions and variables ########################################################################## -- ##################################################################################################################### -vmf.initialize_mod_options = function (mod, options) +-- Is used in Mod Options to create options widgets +vmf.options_widgets_data = {} - -- If this is the first time user launches this mod, set collapsed widgets list to default - if options.collapsed_widgets and not Application.user_setting("mods_settings", mod:get_name()) then +-- Initializes mod's options data. If this function is called with 'options.widgets' not specified, it just creates +-- widget data with single header with checkbox. +function vmf.initialize_mod_options(mod, options) + options = options or {} + + -- If this is the first time user launches this mod, set collapsed widgets list to default one. + if options.collapsed_widgets and not vmf.mod_has_settings(mod) then initialize_collapsed_widgets(mod, options.collapsed_widgets) end - -- Global localization (for all options elements) ('true' by defualt) - local localize_options_global = options.localize ~= false - -- Options widgets localization (inherits from global one, unless defined in 'widgets' table) - local localize_options_widgets = localize_options_global - if options.widgets.localize ~= nil then - localize_options_widgets = options.widgets.localize - end + -- Initialize mod's options widgets data. + local initialized_widgets_data = initialize_mod_options_widgets_data(mod, options.widgets, options.localize ~= false) - -- @TODO: remove "vmf" - local success, value = vmf:pcall(initialize_mod_options_widgets_data, mod, options.widgets, localize_options_widgets) - if not success then - mod:error("Could not initialize options widgets, options initialization aborted: %s", value) - return - end + -- Initialize mod's settings that were not initialized before by setting them to their default values. + -- Also, initialize mod's keybinds. + initialize_default_settings_and_keybinds(mod, initialized_widgets_data) - -- @DEBUG: - mod:echo("INITIALIZE OPTIONS") - return true + -- Insert initialized widgets data to the table which will be used by Mod Options to built options widgets list + -- for this mod. + table.insert(vmf.options_widgets_data, initialized_widgets_data) end -- ##################################################################################################################### diff --git a/vmf/scripts/mods/vmf/modules/core/settings.lua b/vmf/scripts/mods/vmf/modules/core/settings.lua index 018ae79..9691aa9 100644 --- a/vmf/scripts/mods/vmf/modules/core/settings.lua +++ b/vmf/scripts/mods/vmf/modules/core/settings.lua @@ -80,4 +80,10 @@ end function vmf.save_unsaved_settings_to_file() save_all_settings() +end + +function vmf.mod_has_settings(mod) + if _mods_settings[mod:get_name()] then + return true + end end \ No newline at end of file diff --git a/vmf/scripts/mods/vmf/modules/vmf_mod_manager.lua b/vmf/scripts/mods/vmf/modules/vmf_mod_manager.lua index 1d107e7..33fd992 100644 --- a/vmf/scripts/mods/vmf/modules/vmf_mod_manager.lua +++ b/vmf/scripts/mods/vmf/modules/vmf_mod_manager.lua @@ -29,7 +29,7 @@ end function new_mod(mod_name, mod_resources) - -- Checking for correct arguments + -- Checking if all arguments are correct if type(mod_name) ~= "string" then vmf:error("(new_mod): the mod name should be the string, not '%s'.", type(mod_name)) return @@ -56,7 +56,7 @@ function new_mod(mod_name, mod_resources) local success, localization_table = vmf.xpcall_dofile(mod, "(new_mod)('mod_localization' initialization)", mod_resources.mod_localization) if success then - vmf.load_mod_localization(mod, localization_table) -- @TODO: return here if not sucessful?, rename to "initialize_" + vmf.load_mod_localization(mod, localization_table) -- @TODO: return here if not sucessful? rename to "initialize_" else return end @@ -101,32 +101,38 @@ vmf = create_mod("VMF") function vmf.initialize_mod_data(mod, mod_data) + -- Checking if all arguments are correct if type(mod_data) ~= "table" then mod:error("(new_mod)(mod_data initialization): mod_data file should return a 'table' value.") return end + -- Set internal mod data if mod_data.name then vmf.set_internal_data(mod, "readable_name", mod_data.name) end - vmf.set_internal_data(mod, "description", mod_data.description) - vmf.set_internal_data(mod, "is_togglable", mod_data.is_togglable or mod_data.is_mutator) - vmf.set_internal_data(mod, "is_mutator", mod_data.is_mutator) - vmf.set_internal_data(mod, "allow_rehooking", mod_data.allow_rehooking) + vmf.set_internal_data(mod, "description", mod_data.description) + vmf.set_internal_data(mod, "is_togglable", mod_data.is_togglable or mod_data.is_mutator) + vmf.set_internal_data(mod, "is_mutator", mod_data.is_mutator) + vmf.set_internal_data(mod, "allow_rehooking", mod_data.allow_rehooking) + -- Register mod as mutator @TODO: calling this after options initialization would be better, I guess? if mod_data.is_mutator then vmf.register_mod_as_mutator(mod, mod_data.mutator_settings) end - if mod_data.options then - if not vmf.initialize_mod_options(mod, mod_data.options) then + -- Mod's options initialization (with legacy widget definitions support) + if mod_data.options or ((mod_data.is_togglable and not mod_data.is_mutator) and not mod_data.options_widgets) then + local success, error_message = pcall(vmf.initialize_mod_options, mod, mod_data.options) + if not success then + mod:error("Could not initialize mod's options. %s", error_message) return end - -- @TODO: move the 2nd block to the upper statement - elseif mod_data.options_widgets or (mod_data.is_togglable and not mod_data.is_mutator) then + elseif mod_data.options_widgets then vmf.initialize_mod_options_legacy(mod, mod_data.options_widgets) end + -- Textures initialization @TODO: move to a separate function if type(mod_data.custom_gui_textures) == "table" then local custom_gui_textures = mod_data.custom_gui_textures