diff --git a/README.md b/README.md index ac612fb..08704f9 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,101 @@ -# player_settings +# Player Settings + +Allow every players to have their own settings screen and tweak their own settings. Type `/settings` to open the settings GUI. + +## Settings in `minetest.conf` + +* `player_settings_register_example` (bool): Register example settings + * Default: `false` + * If enabled, register setting examples. + * This is always set to true in singleplayer mode regardless of the settings. + +## API + +### Register Settings + +* `player_settings.register_metacategory(name, def)`: Register a settings meta category + * `name`: The ID of the meta category + * `def`: a [meta category definition table](#metacategories) +* `player_settings.register_category(name, def)`: Register a settings category + * `name`: The ID of the category + * `def`: a [category definition table](#categories) +* `player_settings.register_setting(name, def)`: Register a setting + * `name`: The ID of the setting + * `def`: a [setting definition table](#settings) +* `player_settings.unregister_metacategory(name)`: Unregister a metacategory by its name +* `player_settings.unregister_category(name)`: Unregister a category by its name +* `player_settings.unregister_setting(name)`: Unregister a setting by its name + +### Interact with Settings + +* `player_settings.register_on_settings_set(name,key,old_value,new_value)`: Register callacks on settings set + * `name`: Name of a player + * `key`: The ID of the setting + * `old_value`: The value being replaced + * `new_value`: The current setting value + * *Not recommended.* To monitor individual settings, set `after_change` in the [setting definition table](#settings). +* `player_settings.set_setting(name,key,value)`: Set the value of a setting for a player + * `name`: Name or [`ObjectRef`][ObjectRef] of a player + * `key`: The ID of the setting + * `value`: The value to be set to the setting + * Return boolean indicating success + * `after_change` and `player_settings.register_on_settings_set` callbacks are called + * If failed, the second returned value may be one of the following: + * `PLAYER_NOT_EXIST`: The player's auth data does not exist + * `KEY_NOT_EXIST`: The setting does not exist + * `TYPE_CONVERT_FAILED`: The given value cannot be converted to the type specified in the setting definition + * `NUMBER_TOO_SMALL` and `NUMBER_TOO_LARGE` (number only): The given value is below or higher than the required range set in the setting definition + * `SETTING_ENUM_TYPE_INVALID` (enum only): The type of enum values set in the settings is invalid + * `SETTING_VALUE_NOT_IN_ENUM` (enum only): The given value is not in the list of allowed options set in the setting definition + * `SETTING_TYPE_INVALID`: The type of the setting specified in the definition is invalid +* `player_settings.set_default(name,key)`: Set the value of a settings back to its default for a player + * `name`: Name or [`ObjectRef`][ObjectRef] of a player + * `key`: The ID of the setting + * Return boolean indicating success + * `after_change` and `player_settings.register_on_settings_set` callbacks are called + * If failed, the second returned value may be one of the following: + * `PLAYER_NOT_EXIST`: The player's auth data does not exist + * `KEY_NOT_EXIST`: The setting does not exist +* `player_settings.get_setting(name,key)`: Get the value of a setting from a player + * `name`: Name or [`ObjectRef`][ObjectRef] of a player + * `key`: The ID of the setting + * Return boolean indicating success + * If success, the second returned value is the setting value + * If failed, the second returned value may be one of the following: + * `PLAYER_NOT_EXIST`: The player's auth data does not exist + * `KEY_NOT_EXIST`: The setting does not exist + +#### Internal + +These functions are for internal uses. Avoid using them in your code. + +* `player_settings.get_settings_path(name)`: Get the file path of the setting file of a player + * `name`: Name of the player +* `player_settings.get_settings(name)`: Get the table of settings of a player + * `name`: Name or [`ObjectRef`][ObjectRef] of a player +* `player_settings.write_settings(name,tb)`: Write a setting table to the setting file of a player + * `name`: Name or [`ObjectRef`][ObjectRef] of a player + * `tb`: Key-value pair of player settings +* `player_settings.erase_settings(name)`: Erase all the settings of a player + * **WARNING: This is irreversable!** + * `name`: Name or [`ObjectRef`][ObjectRef] of a player +* `player_settings.save_all_settings()`: Save all in-cache changes to settings + +### Constants + +These are the read-only variables. + +* `player_settings.registered_metacategories`: All registered metacategories + * Key: The ID of the metacategory + * Value: The [meta category definition table](#metacategories) +* `player_settings.registered_categories`: All registered categories + * Key: The ID of the category + * Value: The [category definition table](#categories) +* `player_settings.registered_settings`: All registered settings + * Key: The ID of the setting + * Value: The [setting definition table](#settings) +* `player_settings.gui`: A [flow](https://gitlab.com/luk3yx/minetest-flow/) GUI object + * Refer to [the `README.md` of flow mod](https://gitlab.com/luk3yx/minetest-flow/-/blob/main/README.md) for further documentations ## Definition tables @@ -66,6 +163,12 @@ Used by `player_settings.register_setting`. -- Only applies when type == "enum". -- All the avaliable choices. + display_type = "string" / "enum" / "bool", + -- How the setting is displayed in the GUI. + -- string: For all data types (default of int, float and string) + -- enum: for enum only + -- bool: for bool only + validator = function(name, key, value) end, -- Validates the input before it is being stored. -- `name` is the player's name. @@ -88,3 +191,5 @@ Used by `player_settings.register_setting`. -- If returned false, it will be hidden. } ``` + +[ObjectRef]: https://github.com/minetest/minetest/blob/master/doc/lua_api.md#objectref diff --git a/api.lua b/api.lua index 8d8746f..828ef3d 100644 --- a/api.lua +++ b/api.lua @@ -3,6 +3,7 @@ minetest.mkdir(WP .. "/player_settings/") local _ps = player_settings local s = {} +local s_nochg = {} local function RTN_TRUE() return true end @@ -25,6 +26,11 @@ _ps.registered_metacategories, _ps.register_metacategory, _ps.unregister_metacat _ps.registered_categories, _ps.register_category, _ps.unregister_category = do_register() _ps.registered_settings, _ps.register_setting, _ps.unregister_setting = do_register() +_ps.registered_on_settings_set = {} +_ps.register_on_settings_set = function(func) + table.insert(_ps.registered_on_settings_set,func) +end + _ps.get_settings_path = function(name) return (WP .. "/player_settings/" .. name .. ".conf.lua") end @@ -44,19 +50,27 @@ _ps.write_settings = function(name, tb) if type(name) == "userdata" then name = name:get_player_name() end + if not minetest.player_exists(name) then + return false, "PLAYER_NOT_EXIST" + end minetest.safe_file_write(_ps.get_settings_path(name), minetest.serialize(tb)) + return true end _ps.set_setting = function(name,key,value) if type(name) == "userdata" then name = name:get_player_name() end + if not minetest.player_exists(name) then + return false, "PLAYER_NOT_EXIST" + end local setting_entry = _ps.registered_settings[key] if not setting_entry then return false, "KEY_NOT_EXIST" end if not s[name] then s[name] = _ps.get_settings(name) + s_nochg[name] = 0 end -- type = "int" / "string" / "bool" / "float" / "enum", if setting_entry.type == "int" or setting_entry.type == "float" then @@ -67,6 +81,12 @@ _ps.set_setting = function(name,key,value) if setting_entry.type == "int" then value = math.floor(value + 0.5) end + if setting_entry.number_min and setting_entry.number_min > value then + return false, "NUMBER_TOO_SMALL" + elseif setting_entry.number_max and setting_entry.number_max < value then + return false, "NUMBER_TOO_LARGE" + end + elseif setting_entry.type == "string" then value = tostring(value) elseif setting_entry.type == "enum" then @@ -101,7 +121,30 @@ _ps.set_setting = function(name,key,value) else return false, "SETTING_TYPE_INVALID" end + -- validator = function(name, key, value) + if setting_entry.validator then + local status, errmsg = setting_entry.validator(name, key, value) + if not status then + return false, (errmsg or "VALIDATION_FAILED") + end + end + minetest.log("action", string.format( + "[player_settings] %s set setting %s to %s", + name, key, value + )) + local old_value = s[name][key] + if setting_entry.default and setting_entry.default == value then + value = nil -- to save disk space + end s[name][key] = value + s_nochg[name] = nil + -- after_change = function(name, key, old_value, new_value) end, + if setting_entry.after_change then + setting_entry.after_change(name, key, old_value, value or setting_entry.default) + end + for _,func in ipairs(_ps.registered_on_settings_set) do + func(name, key, old_value, value or setting_entry.default) + end return true end @@ -109,37 +152,92 @@ _ps.set_default = function(name,key) if type(name) == "userdata" then name = name:get_player_name() end + if not minetest.player_exists(name) then + return false, "PLAYER_NOT_EXIST" + end local setting_entry = _ps.registered_settings[key] if not setting_entry then return false, "KEY_NOT_EXIST" end if not s[name] then s[name] = _ps.get_settings(name) + s_nochg[name] = 0 + end + local old_value = s[name][key] + minetest.log("action", string.format( + "[player_settings] %s set setting %s to default", + name, key + )) + if setting_entry.after_change then + setting_entry.after_change(name, key, old_value, setting_entry.default) + end + for _,func in ipairs(_ps.registered_on_settings_set) do + func(name, key, old_value, setting_entry.default) end s[name][key] = nil + s_nochg[name] = nil end _ps.get_setting = function(name,key) if type(name) == "userdata" then name = name:get_player_name() end + if not minetest.player_exists(name) then + return false, "PLAYER_NOT_EXIST" + end local setting_entry = _ps.registered_settings[key] if not setting_entry then return false, "KEY_NOT_EXIST" end if not s[name] then s[name] = _ps.get_settings(name) + s_nochg[name] = 0 end local value = s[name][key] if value == nil then value = setting_entry.default end - return value + return true, value +end + +_ps.erase_settings = function(name) + if type(name) == "userdata" then + name = name:get_player_name() + end + minetest.log("action", string.format( + "[player_settings] Erasing %s's data", + name + )) + s[name] = nil + s_nochg[name] = nil + os.remove(_ps.get_settings_path(name)) end _ps.save_all_settings = function() for k,v in pairs(s) do - _ps.write_settings(k, v) + if not s_nochg[k] then + minetest.log("action", string.format( + "[player_settings] Writing %s's data", + k + )) + _ps.write_settings(k, v) + s_nochg[k] = 1 + else + if s_nochg[k] > 2 then + minetest.log("action", string.format( + "[player_settings] %s's data has been idle for 120 seconds. Removing from cache.", + k + )) + s[k] = nil -- Free memory + s_nochg[k] = nil + else + minetest.log("action", string.format( + "[player_settings] %s's data has not been edited. Skipped.", + k + )) + s_nochg[k] = s_nochg[k] + 1 + end + end end end @@ -150,3 +248,14 @@ end minetest.after(5, after_loop) minetest.register_on_shutdown(_ps.save_all_settings) + +do + local old_remove_player = minetest.remove_player + function minetest.remove_player(name) + local success = old_remove_player(name) + if success == 0 then + _ps.erase_settings(name) + end + return success + end +end diff --git a/example.lua b/example.lua index 27edac0..5952f01 100644 --- a/example.lua +++ b/example.lua @@ -40,4 +40,26 @@ _ps.register_setting("ps_example_bool", { long_description = S("Long description. \nExample of @1","bool"), default = true, category = "ps_example", +}) + +_ps.register_setting("ps_example_enum_string", { + type = "enum", + description = S("Example of @1", "enum (string)"), + long_description = S("Long description. \nExample of @1","enum (string)"), + default = "1F616EMO", + category = "ps_example", + enum_type = "string", + enum_choices = { + "lorem", + "ipsum", + "hello", + "world", + "minetest", + "1F616EMO", + } +}) + +_ps.register_category("ps_example_empty",{ + title = S("Empty Examples"), + metacategory = "ps_example_mc" }) \ No newline at end of file diff --git a/gui.lua b/gui.lua index 8b021f7..b9afa8e 100644 --- a/gui.lua +++ b/gui.lua @@ -4,6 +4,7 @@ local S = minetest.get_translator("player_settings") _ps.gui = flow.make_gui(function(player,ctx) ctx.name = player:get_player_name() + if not ctx.showinfo then ctx.showinfo = {} end if not ctx.navbarData then local settings_by_category = {} for k,v in pairs(_ps.registered_settings) do @@ -30,7 +31,6 @@ _ps.gui = flow.make_gui(function(player,ctx) end end ctx.navbarData = categories_by_metacat - print(dump(ctx.navbarData)) end local navbar = {} for k,v in pairs(ctx.navbarData) do @@ -39,7 +39,9 @@ _ps.gui = flow.make_gui(function(player,ctx) table.insert(navbar,gui.Button { label = _ps.registered_categories[k2].title, w = 1, expand = true, + ---@diagnostic disable-next-line: redefined-local, unused-local on_event = function(player,ctx) + if ctx.current_category == k2 then return end ctx.current_metacat = k ctx.current_category = k2 return true @@ -61,22 +63,53 @@ _ps.gui = flow.make_gui(function(player,ctx) local list_settings = ctx.navbarData[ctx.current_metacat][ctx.current_category] local svbox = {} for k,v in pairs(list_settings) do - if v.type == "bool" then + local desc = S("@1 (@2)",v.description,_ps.util.types[v.type] or v.type) + if v.type == "enum" then + desc = S("@1 (Multiple-choice of @2)", + v.description, + _ps.util.types[v.enum_type] or v.enum_type) + end + + local display_type = v.display_type + if display_type == "enum" and v.type ~= "enum" or + display_type == "bool" and v.type ~= "bool" then + display_type = nil + end + + -- TODO: Add support to scrollbar for numbers (waiting for flow mod support) + if not display_type then + if v.type == "bool" then + display_type = "bool" + elseif v.type == "enum" then + display_type = "enum" + else + display_type = "string" + end + end + + + local s_status, selected = _ps.get_setting(ctx.name,k) + if not s_status then + ctx.errmsg = {k, S("Failed to get setting of @1: @2", + k, _ps.util.errmsgs[selected] or selected + )} + elseif display_type == "bool" then table.insert(svbox, gui.HBox { gui.Checkbox { w = 5,h=1, name = "settings_" .. k, - label = v.description, - selected = _ps.get_setting(ctx.name,k), + label = desc, + selected = selected, on_event = function(player,ctx) + ctx.errmsg = nil local form = ctx.form if type(form["settings_" .. k]) == "boolean" then local status, errmsg =_ps.set_setting(ctx.name,k,form["settings_" .. k]) if not status then - print(errmsg) + ctx.errmsg = {k,errmsg} form["settings_" .. k] = _ps.get_setting(ctx.name,k) - return true end + return true end end, expand = true, align_h = "left", @@ -85,91 +118,54 @@ _ps.gui = flow.make_gui(function(player,ctx) w = 1, h = 1, texture_name = "settings_reset.png", name = "settingsReset_" .. k, + ---@diagnostic disable-next-line: redefined-local, unused-local on_event = function(player,ctx) + ctx.errmsg = nil local form = ctx.form _ps.set_default(ctx.name,k) form["settings_" .. k] = v.default return true - end + end, + drawborder = false, }, - gui.Image { + gui.ImageButton { w = 1, h = 1, - name = "settingsInfo_" .. k, texture_name = "settings_info.png", - }, - gui.Tooltip { - gui_element_name = "settingsInfo_" .. k, - tooltip_text = v.long_description or "" + name = "settingsInfo_" .. k, + ---@diagnostic disable-next-line: redefined-local, unused-local + on_event = function(player,ctx) + ctx.showinfo[k] = not ctx.showinfo[k] + return true + end, + drawborder = false, } }) - elseif v.type == "enum" then + elseif display_type == "enum" then + table.insert(svbox, gui.Label { + label = desc + }) table.insert(svbox, gui.HBox { gui.Dropdown { w = 3,h=1, name = "settings_" .. k, - label = v.short_description, items = v.enum_choices, - selected = _ps.util.idx_in_table(v.enum_choices,_ps.get_setting(ctx.name,k)), + selected_idx = _ps.util.idx_in_table(v.enum_choices,selected), }, gui.Button { - w = 2,h=1, - name = "settingsSubmit_" .. k, - label = S("Set"), - on_event = function(player,ctx) - local form = ctx.form - local status, errmsg =_ps.set_setting(ctx.name,k,v.enum_choices[form["settings_" .. k]]) - if not status then - print(errmsg) - form["settings_" .. k] = _ps.util.idx_in_table(v.enum_choices,_ps.get_setting(ctx.name,k)) - return true - end - end, - expand = true, align_h = "left", - }, - gui.ImageButton { - w = 1, h = 1, - texture_name = "settings_reset.png", - name = "settingsReset_" .. k, - on_event = function(player,ctx) - local form = ctx.form - _ps.set_default(ctx.name,k) - form["settings_" .. k] = _ps.util.idx_in_table(v.enum_choices,v.default) - return true - end - }, - gui.Image { - w = 1, h = 1, - name = "settingsInfo_" .. k, - texture_name = "settings_info.png", - }, - gui.Tooltip { - gui_element_name = "settingsInfo_" .. k, - tooltip_text = v.long_description or "" - } - }) - else -- String-like - table.insert(svbox, gui.Label { - label = v.description - }) - print(_ps.get_setting(ctx.name,k)) - table.insert(svbox, gui.HBox { - gui.Field { - w = 3,h=1, - name = "settings_" .. k, - default = _ps.get_setting(ctx.name,k), - }, - gui.Button { - w = 2,h=1, + w = 2, h = 1, name = "settingsSubmit_" .. k, label = S("Set"), + ---@diagnostic disable-next-line: redefined-local, unused-local on_event = function(player,ctx) + ctx.errmsg = nil local form = ctx.form + print(form["settings_" .. k]) local status, errmsg =_ps.set_setting(ctx.name,k,form["settings_" .. k]) if not status then - print(errmsg) + ctx.errmsg = {k,errmsg} form["settings_" .. k] = _ps.get_setting(ctx.name,k) - return true end + return true end, expand = true, align_h = "left", }, @@ -177,23 +173,95 @@ _ps.gui = flow.make_gui(function(player,ctx) w = 1, h = 1, texture_name = "settings_reset.png", name = "settingsReset_" .. k, + ---@diagnostic disable-next-line: redefined-local, unused-local on_event = function(player,ctx) + ctx.errmsg = nil local form = ctx.form _ps.set_default(ctx.name,k) form["settings_" .. k] = v.default return true - end + end, + drawborder = false, }, - gui.Image { + gui.ImageButton { w = 1, h = 1, - name = "settingsInfo_" .. k, texture_name = "settings_info.png", - }, - gui.Tooltip { - gui_element_name = "settingsInfo_" .. k, - tooltip_text = v.long_description or "" + name = "settingsInfo_" .. k, + ---@diagnostic disable-next-line: redefined-local, unused-local + on_event = function(player,ctx) + ctx.showinfo[k] = not ctx.showinfo[k] + return true + end, + drawborder = false, } }) + elseif display_type == "string" then -- String-like + table.insert(svbox, gui.Label { + label = desc + }) + table.insert(svbox, gui.HBox { + gui.Field { + w = 3,h=1, + name = "settings_" .. k, + default = selected, + }, + gui.Button { + w = 2,h=1, + name = "settingsSubmit_" .. k, + label = S("Set"), + ---@diagnostic disable-next-line: redefined-local, unused-local + on_event = function(player,ctx) + ctx.errmsg = nil + local form = ctx.form + local status, errmsg =_ps.set_setting(ctx.name,k,form["settings_" .. k]) + if not status then + ctx.errmsg = {k,errmsg} + form["settings_" .. k] = _ps.get_setting(ctx.name,k) + end + return true + end, + expand = true, align_h = "left", + }, + gui.ImageButton { + w = 1, h = 1, + texture_name = "settings_reset.png", + name = "settingsReset_" .. k, + ---@diagnostic disable-next-line: redefined-local, unused-local + on_event = function(player,ctx) + ctx.errmsg = nil + local form = ctx.form + _ps.set_default(ctx.name,k) + form["settings_" .. k] = v.default + return true + end, + drawborder = false, + }, + gui.ImageButton { + w = 1, h = 1, + texture_name = "settings_info.png", + name = "settingsInfo_" .. k, + ---@diagnostic disable-next-line: redefined-local, unused-local + on_event = function(player,ctx) + ctx.showinfo[k] = not ctx.showinfo[k] + return true + end, + drawborder = false, + } + }) + else + error("[player_settings] Attempt to display setting with unknown display type " .. display_type) + end + if ctx.errmsg and ctx.errmsg[1] == k then + local user_errmsg = _ps.util.errmsgs[ctx.errmsg[2]] or ctx.errmsg[2] + table.insert(svbox, gui.Label { + label = minetest.colorize("#FF0000", S("Error: @1", user_errmsg)), + -- label = ctx.errmsg[2], + }) + end + if ctx.showinfo[k] and v.long_description then + table.insert(svbox, gui.Label { + label = minetest.get_color_escape_sequence("#00FF00") .. v.long_description, + }) end end svbox.w = 10; svbox.h = 10; diff --git a/init.lua b/init.lua index 2d7d858..b45f917 100644 --- a/init.lua +++ b/init.lua @@ -6,4 +6,6 @@ dofile(MP .. "/util.lua") dofile(MP .. "/api.lua") dofile(MP .. "/gui.lua") -dofile(MP .. "/example.lua") +if minetest.is_singleplayer() or minetest.settings:get_bool("player_settings_register_example", false) then + dofile(MP .. "/example.lua") +end diff --git a/settingtypes.txt b/settingtypes.txt new file mode 100644 index 0000000..fc30902 --- /dev/null +++ b/settingtypes.txt @@ -0,0 +1,3 @@ +# If enabled, register setting examples. +# This is always set to true in singleplayer mode regardless of the settings. +player_settings_register_example (Register example settings) bool false \ No newline at end of file diff --git a/util.lua b/util.lua index 0446e09..3b6f1b1 100644 --- a/util.lua +++ b/util.lua @@ -1,3 +1,4 @@ +local S = minetest.get_translator("player_settings") local _ps = player_settings _ps.util = {} @@ -6,4 +7,25 @@ _ps.util.idx_in_table = function(tb,v) if tv == v then return i end end return nil -end \ No newline at end of file +end + +_ps.util.errmsgs = { + PLAYER_NOT_EXIST = S("Player does not exist"), + KEY_NOT_EXIST = S("Key does not exist"), + TYPE_CONVERT_FAILED = S("Value not matching the data type"), + SETTING_ENUM_TYPE_INVALID = S("Invalid enum data type"), + SETTING_VALUE_NOT_IN_ENUM = S("Value not in list of choices"), + SETTING_TYPE_INVALID = S("Invalid setting data type"), + VALIDATION_FAILED = S("Validation failed"), + NUMBER_TOO_SMALL = S("Number too small"), + NUMBER_TOO_LARGE = S("Number too large"), +} + +-- type = "int" / "string" / "bool" / "float" / "enum" +_ps.util.types = { + int = S("Integer"), + string = S("String"), + bool = S("Boolean"), + float = S("Float"), + enum = S("Multiple-choice") +} \ No newline at end of file