diff --git a/exp_legacy/module/config/_file_loader.lua b/exp_legacy/module/config/_file_loader.lua index 91be3f15..45461057 100644 --- a/exp_legacy/module/config/_file_loader.lua +++ b/exp_legacy/module/config/_file_loader.lua @@ -24,7 +24,7 @@ return { --- GUI "modules.gui.rocket-info", "modules.gui.warp-list", - "modules.gui.player-list", + -- "modules.gui.player-list", -- migrated to exp_scenario/gui/player_list "modules.gui.vlayer", "modules.gui._role_updates", diff --git a/exp_legacy/module/config/gui/player_list_actions.lua b/exp_legacy/module/config/gui/player_list_actions.lua index 3c1d675c..2e5e2094 100644 --- a/exp_legacy/module/config/gui/player_list_actions.lua +++ b/exp_legacy/module/config/gui/player_list_actions.lua @@ -14,9 +14,10 @@ local Jail = require("modules.exp_legacy.modules.control.jail") --- @dep modules local Colors = require("modules/exp_util/include/color") local format_player_name = ExpUtil.format_player_name_locale -local SelectedPlayer, SelectedAction -local function set_datastores(player, action) - SelectedPlayer, SelectedAction = player, action +--- Accessors injected by the gui so the actions can read the selected player and set the selected action +local get_selected_player, set_selected_action +local function set_accessors(player_getter, action_setter) + get_selected_player, set_selected_action = player_getter, action_setter end -- auth that will only allow when on player's of lower roles @@ -30,7 +31,7 @@ end -- gets the action player and a coloured name for the action to be used on local function get_action_player_name(player) - local selected_player_name = SelectedPlayer:get(player) + local selected_player_name = get_selected_player(player) local selected_player = game.players[selected_player_name] local selected_player_color = format_player_name(selected_player) return selected_player_name, selected_player_color @@ -63,7 +64,7 @@ end --- Teleports the user to the action player -- @element goto_player -local goto_player = new_button("utility/export", { "player-list.goto-player" }) +local goto_player = new_button("utility/export", { "exp-gui_player-list.goto-player" }) :on_click(function(def, player, element) local selected_player_name = get_action_player_name(player) local selected_player = game.players[selected_player_name] @@ -76,7 +77,7 @@ local goto_player = new_button("utility/export", { "player-list.goto-player" }) --- Teleports the action player to the user -- @element bring_player -local bring_player = new_button("utility/import", { "player-list.bring-player" }) +local bring_player = new_button("utility/import", { "exp-gui_player-list.bring-player" }) :on_click(function(def, player, element) local selected_player_name = get_action_player_name(player) local selected_player = game.players[selected_player_name] @@ -89,13 +90,13 @@ local bring_player = new_button("utility/import", { "player-list.bring-player" } --- Reports the action player, requires a reason to be given -- @element report_player -local report_player = new_button("utility/spawn_flag", { "player-list.report-player" }) +local report_player = new_button("utility/spawn_flag", { "exp-gui_player-list.report-player" }) :on_click(function(def, player, element) local selected_player_name = get_action_player_name(player) if Reports.is_reported(selected_player_name, player.name) then player.print({ "exp-commands_report.already-reported" }, Colors.orange_red) else - SelectedAction:set(player, "command/report") + set_selected_action(player, "command/report") end end) @@ -109,9 +110,9 @@ end --- Gives the action player a warning, requires a reason -- @element warn_player -local warn_player = new_button("utility/spawn_flag", { "player-list.warn-player" }) +local warn_player = new_button("utility/spawn_flag", { "exp-gui_player-list.warn-player" }) :on_click(function(def, player, element) - SelectedAction:set(player, "command/give-warning") + set_selected_action(player, "command/give-warning") end) local function warn_player_callback(player, reason) @@ -123,13 +124,13 @@ end --- Jails the action player, requires a reason -- @element jail_player -local jail_player = new_button("utility/multiplayer_waiting_icon", { "player-list.jail-player" }) +local jail_player = new_button("utility/multiplayer_waiting_icon", { "exp-gui_player-list.jail-player" }) :on_click(function(def, player, element) local selected_player_name, selected_player_color = get_action_player_name(player) if Jail.is_jailed(selected_player_name) then player.print({ "exp-commands_jail.already-jailed", selected_player_color }, Colors.orange_red) else - SelectedAction:set(player, "command/jail") + set_selected_action(player, "command/jail") end end) @@ -142,9 +143,9 @@ end --- Kicks the action player, requires a reason -- @element kick_player -local kick_player = new_button("utility/warning_icon", { "player-list.kick-player" }) +local kick_player = new_button("utility/warning_icon", { "exp-gui_player-list.kick-player" }) :on_click(function(def, player, element) - SelectedAction:set(player, "command/kick") + set_selected_action(player, "command/kick") end) local function kick_player_callback(player, reason) @@ -154,9 +155,9 @@ end --- Bans the action player, requires a reason -- @element ban_player -local ban_player = new_button("utility/danger_icon", { "player-list.ban-player" }) +local ban_player = new_button("utility/danger_icon", { "exp-gui_player-list.ban-player" }) :on_click(function(def, player, element) - SelectedAction:set(player, "command/ban") + set_selected_action(player, "command/ban") end) local function ban_player_callback(player, reason) @@ -165,7 +166,7 @@ local function ban_player_callback(player, reason) end return { - set_datastores = set_datastores, + set_accessors = set_accessors, buttons = { ["command/teleport"] = { auth = function(player, selected_player) diff --git a/exp_scenario/module/control.lua b/exp_scenario/module/control.lua index 65e35d78..83fe6e25 100644 --- a/exp_scenario/module/control.lua +++ b/exp_scenario/module/control.lua @@ -73,6 +73,7 @@ add(require("modules/exp_scenario/gui/elements")) add(require("modules/exp_scenario/gui/landfill_blueprint")) add(require("modules/exp_scenario/gui/module_inserter")) add(require("modules/exp_scenario/gui/player_bonus")) +add(require("modules/exp_scenario/gui/player_list")) add(require("modules/exp_scenario/gui/player_stats")) add(require("modules/exp_scenario/gui/production_stats")) add(require("modules/exp_scenario/gui/quick_actions")) diff --git a/exp_scenario/module/gui/player_list.lua b/exp_scenario/module/gui/player_list.lua new file mode 100644 index 00000000..0dc4583d --- /dev/null +++ b/exp_scenario/module/gui/player_list.lua @@ -0,0 +1,518 @@ +--[[-- Gui - Player List +Adds a player list to show names and play time; also includes action buttons which can perform actions on players. +The selected player and action used to be stored in a datastore; they are now kept on the container's player data. +]] + +local ExpUtil = require("modules/exp_util") +local Gui = require("modules/exp_gui") +local Roles = require("modules/exp_legacy/expcore/roles") +local config = require("modules/exp_legacy/config/gui/player_list_actions") + +--- @class ExpGui_PlayerList.elements +local Elements = {} + +local online_time_format = ExpUtil.format_time_factory_locale{ format = "short", hours = true, minutes = true } +local afk_time_format = ExpUtil.format_time_factory_locale{ format = "long", minutes = true } + +--[[ +The selected player (the player an action will be performed on) and the selected action (which opens the reason bar) +are stored per viewing player on the container's player data. The setters drive the gui updates directly, replacing +the reactive datastore on_update callbacks used by the legacy version. +]] + +--- @class ExpGui_PlayerList.selection +--- @field selected_player string? Name of the player actions will be performed on +--- @field selected_action string? Name of the action awaiting a reason + +--- Get the selection state for a player, creating it if needed +--- @param player LuaPlayer +--- @return ExpGui_PlayerList.selection +local function get_selection(player) + local selection = Elements.container.data[player] + if not selection then + selection = {} + Elements.container.data[player] = selection + end + return selection +end + +--- Get the name of the player that actions will be performed on +--- @param player LuaPlayer +--- @return string? +local function get_selected_player(player) + return get_selection(player).selected_player +end + +--- Get the name of the action awaiting a reason +--- @param player LuaPlayer +--- @return string? +local function get_selected_action(player) + return get_selection(player).selected_action +end + +--- Style an open action bar button to show if it is the selected player +--- @param button LuaGuiElement +--- @param selected boolean +local function style_open_button(button, selected) + button.style = selected and "tool_button" or "frame_button" + local style = button.style + style.padding = -2 + style.width = 8 + style.height = 14 +end + +--- Update the action bar buttons to match the current selection for a player +--- @param action_bar LuaGuiElement +--- @param player LuaPlayer +--- @param selected_player LuaPlayer +local function update_action_bar(action_bar, player, selected_player) + action_bar.visible = true + for action_name, buttons in pairs(config.buttons) do + local flow = action_bar[action_name] + if buttons.auth and not buttons.auth(player, selected_player) then + flow.visible = false + else + flow.visible = Roles.player_allowed(player, action_name) + end + end +end + +--- Refresh all gui elements for a player to match their current selection +--- @param player LuaPlayer +local function update_player_ui(player) + local selection = get_selection(player) + + -- Clear the selection if the selected player has gone offline + local selected_player = selection.selected_player and game.players[selection.selected_player] + if selected_player and not selected_player.connected then + selection.selected_player = nil + selection.selected_action = nil + selected_player = nil + end + + for _, player_table in Elements.player_table:online_elements(player) do + local refs = Elements.player_table.data[player_table] + + -- Update the action bar and reason bar visibility + if selected_player then + update_action_bar(refs.action_bar, player, selected_player) + else + refs.action_bar.visible = false + end + refs.reason_bar.visible = selected_player ~= nil and selection.selected_action ~= nil + + -- Highlight the open button of the selected player + for player_name, row in pairs(refs.rows) do + style_open_button(row.open_button, player_name == selection.selected_player) + end + end +end + +--- Set the player that actions will be performed on, nil to clear the selection +--- @param player LuaPlayer +--- @param selected_player_name string? +local function set_selected_player(player, selected_player_name) + local selection = get_selection(player) + selection.selected_player = selected_player_name + if not selected_player_name then + selection.selected_action = nil + end + update_player_ui(player) +end + +--- Toggle the player that actions will be performed on +--- @param player LuaPlayer +--- @param selected_player_name string +local function toggle_selected_player(player, selected_player_name) + if get_selected_player(player) == selected_player_name then + set_selected_player(player, nil) + else + set_selected_player(player, selected_player_name) + end +end + +--- Set the action awaiting a reason, nil to clear it +--- @param player LuaPlayer +--- @param selected_action string? +local function set_selected_action(player, selected_action) + get_selection(player).selected_action = selected_action + update_player_ui(player) +end + +-- Inject the accessors the actions config needs to read the selected player and set the selected action +config.set_accessors(get_selected_player, set_selected_action) + +--- Button used to open the action bar for a player +--- @class ExpGui_PlayerList.elements.open_action_bar: ExpElement +--- @field data table +--- @overload fun(parent: LuaGuiElement, player_name: string): LuaGuiElement +Elements.open_action_bar = Gui.define("player_list/open_action_bar") + :draw{ + type = "sprite-button", + sprite = "utility/expand_dots", + tooltip = { "exp-gui_player-list.open-action-bar" }, + style = "frame_button", + } + :style{ + padding = -2, + width = 8, + height = 14, + } + :element_data( + Gui.from_argument(1) + ) + :on_click(function(def, player, element) + --- @cast def ExpGui_PlayerList.elements.open_action_bar + toggle_selected_player(player, def.data[element]) + end) --[[ @as any ]] + +--- Button used to close the action bar +--- @class ExpGui_PlayerList.elements.close_action_bar: ExpElement +--- @overload fun(parent: LuaGuiElement): LuaGuiElement +Elements.close_action_bar = Gui.define("player_list/close_action_bar") + :draw{ + type = "sprite-button", + sprite = "utility/close_black", + tooltip = { "exp-gui_player-list.close-action-bar" }, + style = "slot_sized_button_red", + } + :style(Gui.styles.sprite{ + size = 20, + padding = -1, + top_margin = -1, + right_margin = -1, + }) + :on_click(function(def, player, element) + set_selected_player(player, nil) + end) --[[ @as any ]] + +--- Button used to confirm a reason +--- @class ExpGui_PlayerList.elements.reason_confirm: ExpElement +--- @overload fun(parent: LuaGuiElement): LuaGuiElement +Elements.reason_confirm = Gui.define("player_list/reason_confirm") + :draw{ + type = "sprite-button", + sprite = "utility/confirm_slot", + tooltip = { "exp-gui_player-list.reason-confirm" }, + style = "slot_sized_button_green", + } + :style(Gui.styles.sprite{ + size = 30, + padding = -1, + left_margin = -2, + right_margin = -1, + }) + :on_click(function(def, player, element) + local action_name = get_selected_action(player) + local button_data = action_name and config.buttons[action_name] + if button_data and button_data.reason_callback then + local reason = element.parent.entry.text + if reason == nil or not reason:find("%S") then reason = "no reason given" end + button_data.reason_callback(player, reason) + end + element.parent.entry.text = "" + set_selected_player(player, nil) + end) --[[ @as any ]] + +--- Clickable player name label, left click opens the map and right click toggles the action bar +--- @class ExpGui_PlayerList.elements.player_name_label: ExpElement +--- @field data table +--- @overload fun(parent: LuaGuiElement, opts: { name: string, tooltip: LocalisedString }): LuaGuiElement +Elements.player_name_label = Gui.define("player_list/player_name_label") + :draw{ + type = "label", + caption = Gui.from_argument("name"), + tooltip = Gui.from_argument("tooltip"), + } + :style{ + padding = { 0, 2, 0, 0 }, + } + :element_data( + Gui.from_argument("name") + ) + :on_click(function(def, player, element, event) + --- @cast def ExpGui_PlayerList.elements.player_name_label + local selected_player_name = def.data[element] + local selected_player = game.players[selected_player_name] + if event.button == defines.mouse_button_type.left then + -- Left click opens remote view at the player + player.set_controller{ + type = defines.controllers.remote, + position = selected_player.physical_position, + surface = selected_player.physical_surface, + } + else + -- Right click toggles the action bar + toggle_selected_player(player, selected_player_name) + end + end) --[[ @as any ]] + +--- @class ExpGui_PlayerList.player_data +--- @field name string +--- @field tag string +--- @field role_name string +--- @field chat_color Color +--- @field caption LocalisedString +--- @field tooltip LocalisedString + +--- Get the caption and tooltip for a player time +--- @param online_time number +--- @param afk_time number +--- @return LocalisedString, LocalisedString +local function get_time_formats(online_time, afk_time) + local tick = game.tick > 0 and game.tick or 1 + local percent = math.round(online_time / tick, 3) * 100 + local caption = online_time_format(online_time) + local tooltip = { "exp-gui_player-list.afk-time", percent, afk_time_format(afk_time) } + return caption, tooltip +end + +--- Get a list of all online players sorted by their highest role +--- @return ExpGui_PlayerList.player_data[] +local function get_player_list_order() + -- Sort all the online players into roles + local players = {} + for _, player in pairs(game.connected_players) do + local highest_role = Roles.get_player_highest_role(player) + if not players[highest_role.name] then + players[highest_role.name] = {} + end + table.insert(players[highest_role.name], player) + end + + -- Flatten the roles into a single ordered list + local count = 0 + local player_list_order = {} + for _, role_name in pairs(Roles.config.order) do + if players[role_name] then + for _, player in pairs(players[role_name]) do + count = count + 1 + local caption, tooltip = get_time_formats(player.online_time, player.afk_time) + player_list_order[count] = { + name = player.name, + tag = player.tag, + role_name = role_name, + chat_color = player.chat_color, + caption = caption, + tooltip = tooltip, + } + end + end + end + + return player_list_order +end + +--- @class ExpGui_PlayerList.elements.player_table.row +--- @field open_button LuaGuiElement +--- @field name_label LuaGuiElement +--- @field time_label LuaGuiElement + +--- @class ExpGui_PlayerList.elements.player_table.data +--- @field rows table +--- @field action_bar LuaGuiElement +--- @field reason_bar LuaGuiElement + +--- Scroll table containing a row for each online player +--- @class ExpGui_PlayerList.elements.player_table: ExpElement +--- @field data table +--- @overload fun(parent: LuaGuiElement): LuaGuiElement +Elements.player_table = Gui.define("player_list/player_table") + :track_all_elements() + :draw(function(def, parent) + local scroll_table = Gui.elements.scroll_table(parent, 184, 3, "scroll") + scroll_table.style.padding = { 1, 0, 1, 2 } + return scroll_table + end) --[[ @as any ]] + +--- Add a player row to the table and store its elements +--- @param player_table LuaGuiElement +--- @param player_data ExpGui_PlayerList.player_data +function Elements.player_table.add_row(player_table, player_data) + local rows = Elements.player_table.data[player_table].rows + + -- Add the button used to open the action bar + local open_flow = player_table.add{ type = "flow" } + local open_button = Elements.open_action_bar(open_flow, player_data.name) + + -- Add the clickable player name + local name_label = Elements.player_name_label(player_table, { + name = player_data.name, + tooltip = { "exp-gui_player-list.open-map", player_data.name, player_data.tag, player_data.role_name }, + }) + name_label.style.font_color = player_data.chat_color + + -- Add the time played label + local alignment = Gui.elements.aligned_flow(player_table) + local time_label = alignment.add{ + type = "label", + caption = player_data.caption, + tooltip = player_data.tooltip, + } + time_label.style.padding = 0 + + rows[player_data.name] = { open_button = open_button, name_label = name_label, time_label = time_label } +end + +--- Rebuild all rows of a player table, used when the sort order changes +--- @param player_table LuaGuiElement +function Elements.player_table.rebuild(player_table) + player_table.clear() + Elements.player_table.data[player_table].rows = {} + for _, player_data in ipairs(get_player_list_order()) do + Elements.player_table.add_row(player_table, player_data) + end +end + +--- Remove a single player row from the table +--- @param player_table LuaGuiElement +--- @param player_name string +function Elements.player_table.remove_row(player_table, player_name) + local rows = Elements.player_table.data[player_table].rows + local row = rows[player_name] + if not row then return end + rows[player_name] = nil + Gui.destroy_if_valid(row.open_button.parent) + Gui.destroy_if_valid(row.name_label) + Gui.destroy_if_valid(row.time_label.parent) +end + +--- Refresh the time labels for all rows of a player table +--- @param player_table LuaGuiElement +function Elements.player_table.refresh_times(player_table) + for player_name, row in pairs(Elements.player_table.data[player_table].rows) do + local listed_player = game.players[player_name] + if listed_player and listed_player.connected then + local caption, tooltip = get_time_formats(listed_player.online_time, listed_player.afk_time) + row.time_label.caption = caption + row.time_label.tooltip = tooltip + end + end +end + +--- Build the action bar footer containing the close button and the per action button flows +--- @param container LuaGuiElement +--- @return LuaGuiElement +local function build_action_bar(container) + local action_bar = Gui.elements.subframe_base(container, "subfooter_frame", "action_bar") + local style = action_bar.style + style.height = 35 + style.padding = { 1, 3 } + action_bar.visible = false + + Elements.close_action_bar(action_bar) + for action_name, button_data in pairs(config.buttons) do + local permission_flow = action_bar.add{ type = "flow", name = action_name } + permission_flow.visible = false + for _, button in ipairs(button_data) do + button(permission_flow) + end + end + + return action_bar +end + +--- Build the reason bar footer containing the reason entry and confirm button +--- @param container LuaGuiElement +--- @return LuaGuiElement +local function build_reason_bar(container) + local reason_bar = Gui.elements.subframe_base(container, "subfooter_frame", "reason_bar") + local style = reason_bar.style + style.height = 35 + style.padding = { -1, 3 } + reason_bar.visible = false + + local reason_field = reason_bar.add{ + name = "entry", + type = "textfield", + style = "stretchable_textfield", + tooltip = { "exp-gui_player-list.reason-entry" }, + } + local entry_style = reason_field.style + entry_style.padding = 0 + entry_style.height = 28 + entry_style.minimal_width = 160 + + Elements.reason_confirm(reason_bar) + + return reason_bar +end + +--- Container added to the left gui flow +--- @class ExpGui_PlayerList.elements.container: ExpElement +--- @field data table +Elements.container = Gui.define("player_list/container") + :draw(function(def, parent) + local container = Gui.elements.container(parent) + + local player = Gui.get_player(parent) + def.data[player] = def.data[player] or {} -- Selection state for this player + + local player_table = Elements.player_table(container) + local action_bar = build_action_bar(container) + local reason_bar = build_reason_bar(container) + + Elements.player_table.data[player_table] = { + rows = {}, + action_bar = action_bar, + reason_bar = reason_bar, + } + Elements.player_table.rebuild(player_table) + + return Gui.elements.container.get_root_element(container) + end) --[[ @as any ]] + +--- Add the element to the left flow with a toolbar button +Gui.add_left_element(Elements.container, true) +Gui.toolbar.create_button{ + name = "toggle_player_list", + left_element = Elements.container, + sprite = "entity/character", + tooltip = { "exp-gui_player-list.main-tooltip" }, + visible = function(player, element) + return Roles.player_allowed(player, "gui/player-list") + end +} + +--- Refresh the play times for all online players +local function refresh_times() + for _, player_table in Elements.player_table:online_elements() do + Elements.player_table.refresh_times(player_table) + end +end + +--- Rebuild the player list for all online players, used when the sort order changes +local function redraw_player_list() + for _, player_table in Elements.player_table:online_elements() do + Elements.player_table.rebuild(player_table) + update_player_ui(Gui.get_player(player_table)) + end +end + +--- Remove a player from the list when they leave and clear any selection pointing at them +--- @param event EventData.on_player_left_game +local function on_player_left_game(event) + local left_player = game.players[event.player_index] + for _, player_table in Elements.player_table:online_elements() do + Elements.player_table.remove_row(player_table, left_player.name) + local viewing_player = Gui.get_player(player_table) + if get_selected_player(viewing_player) == left_player.name then + set_selected_player(viewing_player, nil) + end + end +end + +local e = defines.events + +return { + elements = Elements, + events = { + [e.on_player_joined_game] = redraw_player_list, + [e.on_player_left_game] = on_player_left_game, + [Roles.events.on_role_assigned] = redraw_player_list, + [Roles.events.on_role_unassigned] = redraw_player_list, + }, + on_nth_tick = { + [1800] = refresh_times, + } +} diff --git a/exp_scenario/module/locale/en.cfg b/exp_scenario/module/locale/en.cfg index d2c5cf99..6a02d27a 100644 --- a/exp_scenario/module/locale/en.cfg +++ b/exp_scenario/module/locale/en.cfg @@ -324,6 +324,22 @@ tooltip-character_reach_distance_bonus=Character reach distance bonus caption-personal_battery_recharge=Battery tooltip-personal_battery_recharge=Armor battery recharge +[exp-gui_player-list] +main-tooltip=Player List +open-action-bar=Options +close-action-bar=Close Options +reason-confirm=Confirm Reason +reason-entry=Enter Reason +goto-player=Goto player +bring-player=Bring player +report-player=Report player +warn-player=Warn player +jail-player=Jail player +kick-player=Kick player +ban-player=Ban player +afk-time=__1__% of total map time\nLast moved __2__ ago +open-map=__1__ __2__\n__3__\nClick to open map + [exp-gui_player-stats] tooltip-main=Player Stats caption-main=Player Stats