Files
factorio-expcluster/exp_scenario/module/gui/warp_list.lua
T
bbassie 68b2dda190 Move the warp list into exp_scenario
The warp system was split across a legacy control module and a legacy gui,
both built on expcore.datastore. They are now one exp_scenario gui,
exp_scenario/module/gui/warp_list.lua, in the shape of the task list: per
force warps and per player cooldown and proximity state live on the
container's element data, rows are element defines which carry their warp in
element data, and refreshes compute the player's status once and apply it to
every row. Warps are referenced as objects rather than string ids, the last
editor is a LuaPlayer, and the cooldown counts ticks.

The locale moves to [exp-gui_warp-list] in en, zh-CN and zh-TW with keys
named by region and use, and the legacy [warp-list] sections are removed
along with the two legacy modules and the file loader entry. The config
stays in exp_legacy/config/gui/warps.lua for now like the other gui configs.
2026-09-26 22:12:21 +01:00

1219 lines
47 KiB
Lua

--[[-- Gui - Warp List
Adds a list of warp points which players can travel between, add, and edit
]]
local Gui = require("modules/exp_gui")
local Roles = require("modules/exp_roles")
local Colors = require("modules/exp_util/include/color")
local config = require("modules.exp_legacy.config.gui.warps")
local ExpUtil = require("modules/exp_util")
local format_time = ExpUtil.format_time_factory_locale{ format = "short", hours = true, minutes = true }
local floor = math.floor
local ceil = math.ceil
--- @class ExpGui_WarpList.elements
local Elements = {}
local Styles = {
sprite22 = { height = 22, width = 22, padding = -2 },
sprite32 = { height = 32, width = 32, left_margin = 1 },
}
--- Rich text icons shown next to a warp name to describe why it can or can not be used
local status_icons = {
cooldown = "[img=utility/multiplayer_waiting_icon]",
not_available = "[img=utility/set_bar_slot]",
bypass = "[img=utility/side_menu_bonus_icon]",
current = "[img=utility/side_menu_map_icon]",
connected = "[img=utility/logistic_network_panel_white]",
different = "[img=utility/warning_white]",
}
local update_interval = floor(60 / config.update_smoothing)
local cooldown_ticks = config.cooldown_duration * 60
local proximity_radius_sq = config.standard_proximity_radius ^ 2
local spawn_radius_sq = config.spawn_proximity_radius ^ 2
local minimum_distance_sq = config.minimum_distance ^ 2
--- Names of the entities which make up a warp area, used to find them when the area is removed
local area_entity_names = {} --- @type string[]
for _, entity in ipairs(config.entities) do
area_entity_names[#area_entity_names + 1] = entity[1]
end
--- @class ExpGui_WarpList.Warp
--- @field id number
--- @field force LuaForce
--- @field name string
--- @field icon SignalID
--- @field surface LuaSurface
--- @field position MapPosition.struct The tile the warp is centred on
--- @field last_user LuaPlayer? Nil for warps made by the scenario
--- @field last_edit_tick number
--- @field editing table<number, string> Names of the players editing the warp keyed by their index
--- @field tag LuaCustomChartTag?
--- @field electric_pole LuaEntity? Pole within the warp area, warps on the same network can be travelled between
--- @field old_tile string? The tile the warp area replaced, nil when no area was made
--- @field new boolean? True until the first edit is confirmed, discarding that edit removes the warp
--- @class ExpGui_WarpList.PlayerStatus
--- @field in_range ExpGui_WarpList.Warp? The warp the player is standing on
--- @field on_cooldown boolean
--- @field bypass_proximity boolean
--- Check a permission from the config, which says who an action applies to
--- @param player LuaPlayer
--- @param action "bypass_warp_cooldown" | "bypass_warp_proximity" | "allow_add_warp" | "allow_edit_warp"
--- @return boolean
local function has_config_permission(player, action)
local setting = config[action]
if setting == "all" then
return true
elseif setting == "admin" then
return player.admin
elseif setting == "exp_roles" then
return Roles.player_has_permission(player, config["exp_roles_" .. action])
end
return false
end
--- Check if a player can edit a warp, the spawn warp can never be edited
--- @param player LuaPlayer
--- @param warp ExpGui_WarpList.Warp
--- @return boolean
local function has_permission_edit_warp(player, warp)
if Elements.container.get_spawn_warp(warp.force) == warp then
return false
end
if config.user_can_edit_own_warps and warp.last_user == player then
return true
end
return has_config_permission(player, "allow_edit_warp")
end
--- The sprite path for a warp icon, signal ids and sprite paths name virtual signals differently
--- @param icon SignalID
--- @return SpritePath
local function icon_sprite(icon)
if icon.type == "virtual" then
return "virtual-signal/" .. icon.name
end
return (icon.type or "item") .. "/" .. icon.name
end
--- Draw a cross over something that stops a warp being added
--- @param surface LuaSurface
--- @param player LuaPlayer
--- @param target LuaEntity | MapPosition
local function draw_blocker(surface, player, target)
rendering.draw_sprite{
sprite = "utility/rail_path_not_possible",
x_scale = 0.5,
y_scale = 0.5,
target = target,
surface = surface,
players = { player },
time_to_live = 60,
}
end
--- Button in the header which adds a warp at the player
--- @class ExpGui_WarpList.elements.add_warp_button: ExpElement
--- @overload fun(parent: LuaGuiElement): LuaGuiElement
Elements.add_warp_button = Gui.define("warp_list/add_warp_button")
:track_all_elements()
:draw{
type = "sprite-button",
sprite = "utility/add",
tooltip = { "exp-gui_warp-list.tooltip-add" },
style = "shortcut_bar_button",
}
:style(Styles.sprite22)
:on_click(function(_, player)
if player.controller_type ~= defines.controllers.character then return end
local surface = player.physical_surface
local position = player.physical_position
local water_tiles = surface.find_tiles_filtered{
collision_mask = "water_tile",
radius = config.standard_proximity_radius + 1,
position = position,
}
if #water_tiles > 0 then
player.print({ "exp-gui_warp-list.add-warning-water", config.standard_proximity_radius + 1 }, Colors.orange_red)
player.play_sound{ path = "utility/wire_pickup" }
for _, tile in pairs(water_tiles) do
draw_blocker(surface, player, tile.position)
end
return
end
-- A larger radius than the area because the entities a player can place are larger than tiles
local entities = surface.find_entities_filtered{
radius = config.standard_proximity_radius + 2.5,
position = position,
collision_mask = { "item", "object", "player", "water_tile" },
}
if #entities > 1 then -- The player's own character is always found
player.print({ "exp-gui_warp-list.add-warning-entities", config.standard_proximity_radius + 2.5 }, Colors.orange_red)
player.play_sound{ path = "utility/wire_pickup" }
local character = player.character
for _, entity in pairs(entities) do
if entity ~= character then
draw_blocker(surface, player, entity)
end
end
return
end
local force = player.force --[[@as LuaForce]]
local warp = Elements.container.add_warp(force, surface, position, player)
Elements.container.set_editing(warp, player, true)
end) --[[@as any]]
--- Refresh whether the add button is enabled based on the closest warp
--- @param player LuaPlayer
--- @param blocking ExpGui_WarpList.Warp? The warp too close to add another, nil when a warp can be added
function Elements.add_warp_button.refresh_player_blocking(player, blocking)
local tooltip = blocking and { "exp-gui_warp-list.tooltip-add-too-close", blocking.name } or { "exp-gui_warp-list.tooltip-add" }
for _, add_warp_button in Elements.add_warp_button:tracked_elements(player) do
add_warp_button.enabled = blocking == nil
add_warp_button.tooltip = tooltip
end
end
--- @class ExpGui_WarpList.elements.warp_button.element_data
--- @field warp ExpGui_WarpList.Warp
--- Button which travels to the warp, enabled when the player is allowed to use it
--- @class ExpGui_WarpList.elements.warp_button: ExpElement
--- @field data table<LuaGuiElement, ExpGui_WarpList.elements.warp_button.element_data>
--- @overload fun(parent: LuaGuiElement, warp: ExpGui_WarpList.Warp): LuaGuiElement
Elements.warp_button = Gui.define("warp_list/warp_button")
:draw(function(_, parent, warp)
--- @cast warp ExpGui_WarpList.Warp
return parent.add{
type = "sprite-button",
sprite = icon_sprite(warp.icon),
style = "slot_button",
}
end)
:style(Styles.sprite32)
:element_data{
warp = Gui.from_argument(1),
}
:on_click(function(def, player, warp_button)
--- @cast def ExpGui_WarpList.elements.warp_button
Elements.container.teleport_player(player, def.data[warp_button].warp)
end) --[[@as any]]
--- @class ExpGui_WarpList.elements.icon_picker.element_data
--- @field warp ExpGui_WarpList.Warp
--- Signal picker shown in place of the warp button while editing
--- @class ExpGui_WarpList.elements.icon_picker: ExpElement
--- @field data table<LuaGuiElement, ExpGui_WarpList.elements.icon_picker.element_data>
--- @overload fun(parent: LuaGuiElement, warp: ExpGui_WarpList.Warp): LuaGuiElement
Elements.icon_picker = Gui.define("warp_list/icon_picker")
:draw(function(_, parent, warp)
--- @cast warp ExpGui_WarpList.Warp
return parent.add{
type = "choose-elem-button",
elem_type = "signal",
signal = warp.icon,
tooltip = { "exp-gui_warp-list.tooltip-icon" },
}
end)
:style(Styles.sprite32)
:element_data{
warp = Gui.from_argument(1),
} --[[@as any]]
--- Get the icon chosen in a picker, the game omits the type for items
--- @param icon_picker LuaGuiElement
--- @return SignalID
function Elements.icon_picker.get_icon(icon_picker)
local icon = icon_picker.elem_value --[[@as SignalID?]]
if not icon then
return Elements.icon_picker.data[icon_picker].warp.icon
end
icon.type = icon.type or "item"
return icon
end
--- Icon describing whether the warp can be used
--- @class ExpGui_WarpList.elements.status_label: ExpElement
--- @overload fun(parent: LuaGuiElement): LuaGuiElement
Elements.status_label = Gui.define("warp_list/status_label")
:draw{
type = "label",
caption = status_icons.not_available,
}
:style{
top_padding = 1, -- Keeps the icon in place when the taller textfield is shown
single_line = false,
} --[[@as any]]
--- @class ExpGui_WarpList.elements.name_label.element_data
--- @field warp ExpGui_WarpList.Warp
--- Warp name, clicking it opens the map at the warp
--- @class ExpGui_WarpList.elements.name_label: ExpElement
--- @field data table<LuaGuiElement, ExpGui_WarpList.elements.name_label.element_data>
--- @overload fun(parent: LuaGuiElement, warp: ExpGui_WarpList.Warp): LuaGuiElement
Elements.name_label = Gui.define("warp_list/name_label")
:draw(function(_, parent, warp)
--- @cast warp ExpGui_WarpList.Warp
return parent.add{
type = "label",
caption = warp.name,
}
end)
:style{
single_line = true,
left_padding = 2,
right_padding = 2,
horizontally_stretchable = true,
}
:element_data{
warp = Gui.from_argument(1),
}
:on_click(function(def, player, name_label)
--- @cast def ExpGui_WarpList.elements.name_label
local warp = def.data[name_label].warp
player.set_controller{ type = defines.controllers.remote, position = warp.position, surface = warp.surface }
end) --[[@as any]]
--- Refresh the name and last edit of the label
--- @param name_label LuaGuiElement
--- @param warp ExpGui_WarpList.Warp
function Elements.name_label.refresh(name_label, warp)
local last_user = warp.last_user and warp.last_user.name or "<server>"
name_label.caption = warp.name
name_label.tooltip = { "exp-gui_warp-list.tooltip-last-edit", last_user, format_time(warp.last_edit_tick) }
end
--- @class ExpGui_WarpList.elements.name_field.element_data
--- @field warp ExpGui_WarpList.Warp
--- @field icon_picker LuaGuiElement
--- Textfield shown in place of the name label while editing, confirming it saves the edit
--- @class ExpGui_WarpList.elements.name_field: ExpElement
--- @field data table<LuaGuiElement, ExpGui_WarpList.elements.name_field.element_data>
--- @overload fun(parent: LuaGuiElement, warp: ExpGui_WarpList.Warp, icon_picker: LuaGuiElement): LuaGuiElement
Elements.name_field = Gui.define("warp_list/name_field")
:draw(function(_, parent, warp)
--- @cast warp ExpGui_WarpList.Warp
return parent.add{
type = "textfield",
text = warp.name,
clear_and_focus_on_right_click = true,
}
end)
:style{
minimal_width = 10,
maximal_width = 300,
horizontally_squashable = true,
horizontally_stretchable = true,
height = 22,
padding = -2,
left_margin = 2,
right_margin = 2,
}
:element_data{
warp = Gui.from_argument(1),
icon_picker = Gui.from_argument(2),
}
:on_confirmed(function(def, player, name_field)
--- @cast def ExpGui_WarpList.elements.name_field
local elements = def.data[name_field]
local icon = Elements.icon_picker.get_icon(elements.icon_picker)
Elements.container.update_warp(elements.warp, name_field.text, icon, player)
end) --[[@as any]]
--- @class ExpGui_WarpList.elements.confirm_edit_button.element_data
--- @field warp ExpGui_WarpList.Warp
--- @field name_field LuaGuiElement
--- @field icon_picker LuaGuiElement
--- Button which saves the name and icon being edited
--- @class ExpGui_WarpList.elements.confirm_edit_button: ExpElement
--- @field data table<LuaGuiElement, ExpGui_WarpList.elements.confirm_edit_button.element_data>
--- @overload fun(parent: LuaGuiElement, warp: ExpGui_WarpList.Warp, name_field: LuaGuiElement, icon_picker: LuaGuiElement): LuaGuiElement
Elements.confirm_edit_button = Gui.define("warp_list/confirm_edit_button")
:draw{
type = "sprite-button",
sprite = "utility/confirm_slot",
tooltip = { "exp-gui_warp-list.tooltip-confirm" },
style = "shortcut_bar_button_green",
}
:style(Styles.sprite22)
:element_data{
warp = Gui.from_argument(1),
name_field = Gui.from_argument(2),
icon_picker = Gui.from_argument(3),
}
:on_click(function(def, player, confirm_edit_button)
--- @cast def ExpGui_WarpList.elements.confirm_edit_button
local elements = def.data[confirm_edit_button]
local icon = Elements.icon_picker.get_icon(elements.icon_picker)
Elements.container.update_warp(elements.warp, elements.name_field.text, icon, player)
end) --[[@as any]]
--- @class ExpGui_WarpList.elements.discard_edit_button.element_data
--- @field warp ExpGui_WarpList.Warp
--- Button which discards an edit, a warp which was never confirmed is removed
--- @class ExpGui_WarpList.elements.discard_edit_button: ExpElement
--- @field data table<LuaGuiElement, ExpGui_WarpList.elements.discard_edit_button.element_data>
--- @overload fun(parent: LuaGuiElement, warp: ExpGui_WarpList.Warp): LuaGuiElement
Elements.discard_edit_button = Gui.define("warp_list/discard_edit_button")
:draw{
type = "sprite-button",
sprite = "utility/close_black",
tooltip = { "exp-gui_warp-list.tooltip-discard" },
style = "shortcut_bar_button_red",
}
:style(Styles.sprite22)
:element_data{
warp = Gui.from_argument(1),
}
:on_click(function(def, player, discard_edit_button)
--- @cast def ExpGui_WarpList.elements.discard_edit_button
local warp = def.data[discard_edit_button].warp
if warp.new then
Elements.container.remove_warp(warp)
else
Elements.container.set_editing(warp, player, false)
end
end) --[[@as any]]
--- @class ExpGui_WarpList.elements.edit_warp_button.element_data
--- @field warp ExpGui_WarpList.Warp
--- Button which starts editing a warp
--- @class ExpGui_WarpList.elements.edit_warp_button: ExpElement
--- @field data table<LuaGuiElement, ExpGui_WarpList.elements.edit_warp_button.element_data>
--- @overload fun(parent: LuaGuiElement, warp: ExpGui_WarpList.Warp): LuaGuiElement
Elements.edit_warp_button = Gui.define("warp_list/edit_warp_button")
:draw{
type = "sprite-button",
sprite = "utility/rename_icon",
tooltip = { "exp-gui_warp-list.tooltip-edit-none" },
style = "shortcut_bar_button",
}
:style(Styles.sprite22)
:element_data{
warp = Gui.from_argument(1),
}
:on_click(function(def, player, edit_warp_button)
--- @cast def ExpGui_WarpList.elements.edit_warp_button
Elements.container.set_editing(def.data[edit_warp_button].warp, player, true)
end) --[[@as any]]
--- Refresh the tooltip which lists who is editing the warp
--- @param edit_warp_button LuaGuiElement
--- @param warp ExpGui_WarpList.Warp
function Elements.edit_warp_button.refresh(edit_warp_button, warp)
if next(warp.editing) then
local player_names = table.get_values(warp.editing)
edit_warp_button.hovered_sprite = "utility/warning_icon"
edit_warp_button.tooltip = { "exp-gui_warp-list.tooltip-edit", table.concat(player_names, ", ") }
else
edit_warp_button.hovered_sprite = "utility/rename_icon"
edit_warp_button.tooltip = { "exp-gui_warp-list.tooltip-edit-none" }
end
end
--- @class ExpGui_WarpList.elements.remove_warp_button.element_data
--- @field warp ExpGui_WarpList.Warp
--- Button which removes a warp along with its area and map tag
--- @class ExpGui_WarpList.elements.remove_warp_button: ExpElement
--- @field data table<LuaGuiElement, ExpGui_WarpList.elements.remove_warp_button.element_data>
--- @overload fun(parent: LuaGuiElement, warp: ExpGui_WarpList.Warp): LuaGuiElement
Elements.remove_warp_button = Gui.define("warp_list/remove_warp_button")
:draw{
type = "sprite-button",
sprite = "utility/trash",
tooltip = { "exp-gui_warp-list.tooltip-remove" },
style = "shortcut_bar_button_red",
}
:style(Styles.sprite22)
:element_data{
warp = Gui.from_argument(1),
}
:on_click(function(def, _, remove_warp_button)
--- @cast def ExpGui_WarpList.elements.remove_warp_button
Elements.container.remove_warp(def.data[remove_warp_button].warp)
end) --[[@as any]]
--- @class ExpGui_WarpList.elements.warp_table.row
--- @field warp ExpGui_WarpList.Warp
--- @field icon_flow LuaGuiElement
--- @field name_flow LuaGuiElement
--- @field action_flow LuaGuiElement
--- @field warp_button LuaGuiElement
--- @field icon_picker LuaGuiElement
--- @field status_label LuaGuiElement
--- @field name_label LuaGuiElement
--- @field name_field LuaGuiElement
--- @field confirm_edit_button LuaGuiElement
--- @field discard_edit_button LuaGuiElement
--- @field edit_warp_button LuaGuiElement
--- @field remove_warp_button LuaGuiElement
--- @class ExpGui_WarpList.elements.warp_table.element_data
--- @field rows table<number, ExpGui_WarpList.elements.warp_table.row> Keyed by warp id
--- Table with a row of icon, name, and actions for each warp of the player's force
--- @class ExpGui_WarpList.elements.warp_table: ExpElement
--- @field data table<LuaGuiElement, ExpGui_WarpList.elements.warp_table.element_data>
--- @overload fun(parent: LuaGuiElement): LuaGuiElement
Elements.warp_table = Gui.define("warp_list/warp_table")
:track_all_elements()
:element_data{ rows = {} }
:draw(function(_, parent)
local warp_table = Gui.elements.scroll_table(parent, 250, 3, "scroll")
-- Always showing the scrollbar stops the gui changing width as warps are added
assert(warp_table.parent).vertical_scroll_policy = "always"
return warp_table
end)
:style{
top_cell_padding = 3,
bottom_cell_padding = 3,
} --[[@as any]]
--- Add a row for a warp
--- @param warp_table LuaGuiElement
--- @param warp ExpGui_WarpList.Warp
--- @return ExpGui_WarpList.elements.warp_table.row
function Elements.warp_table.add_row(warp_table, warp)
local icon_flow = warp_table.add{ type = "flow" }
icon_flow.style.padding = 0
local warp_button = Elements.warp_button(icon_flow, warp)
local icon_picker = Elements.icon_picker(icon_flow, warp)
local name_flow = warp_table.add{ type = "flow" }
name_flow.style.padding = 0
local status_label = Elements.status_label(name_flow)
local name_label = Elements.name_label(name_flow, warp)
local name_field = Elements.name_field(name_flow, warp, icon_picker)
local action_flow = warp_table.add{ type = "flow" }
action_flow.style.padding = 0
local confirm_edit_button = Elements.confirm_edit_button(action_flow, warp, name_field, icon_picker)
local discard_edit_button = Elements.discard_edit_button(action_flow, warp)
local edit_warp_button = Elements.edit_warp_button(action_flow, warp)
local remove_warp_button = Elements.remove_warp_button(action_flow, warp)
local row = {
warp = warp,
icon_flow = icon_flow,
name_flow = name_flow,
action_flow = action_flow,
warp_button = warp_button,
icon_picker = icon_picker,
status_label = status_label,
name_label = name_label,
name_field = name_field,
confirm_edit_button = confirm_edit_button,
discard_edit_button = discard_edit_button,
edit_warp_button = edit_warp_button,
remove_warp_button = remove_warp_button,
}
Elements.warp_table.data[warp_table].rows[warp.id] = row
return row
end
--- Remove the row for a warp
--- @param warp_table LuaGuiElement
--- @param warp ExpGui_WarpList.Warp
function Elements.warp_table.remove_row(warp_table, warp)
local rows = Elements.warp_table.data[warp_table].rows
local row = rows[warp.id]
if not row then return end
rows[warp.id] = nil
row.icon_flow.destroy()
row.name_flow.destroy()
row.action_flow.destroy()
end
--- Refresh whether a warp can be used from where the player is
--- @param row ExpGui_WarpList.elements.warp_table.row
--- @param status ExpGui_WarpList.PlayerStatus
function Elements.warp_table._refresh_row_status(row, status)
local warp = row.warp
local in_range = status.in_range
local position = warp.position
local tooltip, icon, enabled
if not in_range then
if status.bypass_proximity then
tooltip, icon, enabled = { "exp-gui_warp-list.tooltip-goto-bypass", position.x, position.y }, status_icons.bypass, true
else
tooltip, icon, enabled = { "exp-gui_warp-list.tooltip-goto-disabled" }, status_icons.not_available, false
end
elseif in_range == warp then
tooltip, icon, enabled = { "exp-gui_warp-list.tooltip-goto-same-warp" }, status_icons.current, false
elseif status.on_cooldown then
tooltip, icon, enabled = { "exp-gui_warp-list.tooltip-goto-cooldown" }, status_icons.cooldown, false
else
-- Warps are connected when their poles share an electric network, missing poles never match
local network_id = warp.electric_pole and warp.electric_pole.valid and warp.electric_pole.electric_network_id or -1
local in_range_network_id = in_range.electric_pole and in_range.electric_pole.valid and in_range.electric_pole.electric_network_id or -2
if network_id == in_range_network_id then
tooltip, icon, enabled = { "exp-gui_warp-list.tooltip-goto", position.x, position.y }, status_icons.connected, true
elseif status.bypass_proximity then
tooltip, icon, enabled = { "exp-gui_warp-list.tooltip-goto-bypass-different-network", position.x, position.y }, status_icons.bypass, true
else
tooltip, icon, enabled = { "exp-gui_warp-list.tooltip-goto-different-network" }, status_icons.different, false
end
end
row.warp_button.tooltip = tooltip
row.warp_button.enabled = enabled
row.status_label.tooltip = tooltip
row.status_label.caption = icon
row.name_label.style.font = enabled and "default-semibold" or "default"
end
--- Refresh a row to match its warp, the player's permissions, and where the player is
--- @param warp_table LuaGuiElement
--- @param row ExpGui_WarpList.elements.warp_table.row
--- @param status ExpGui_WarpList.PlayerStatus
function Elements.warp_table.refresh_row(warp_table, row, status)
local player = Gui.get_player(warp_table)
local warp = row.warp
local can_edit = has_permission_edit_warp(player, warp)
local editing = warp.editing[player.index] ~= nil
Elements.edit_warp_button.refresh(row.edit_warp_button, warp)
Elements.name_label.refresh(row.name_label, warp)
row.warp_button.visible = not editing
row.icon_picker.visible = editing
row.name_label.visible = not editing
row.name_field.visible = editing
row.confirm_edit_button.visible = editing
row.discard_edit_button.visible = editing
row.edit_warp_button.visible = can_edit and not editing
row.remove_warp_button.visible = can_edit and not editing
if editing then
row.name_field.focus()
assert(warp_table.parent).scroll_to_element(row.name_field, "top-third")
else
row.warp_button.sprite = icon_sprite(warp.icon)
row.icon_picker.elem_value = warp.icon
row.name_field.text = warp.name
end
Elements.warp_table._refresh_row_status(row, status)
end
--- Rebuild every row in the order of the warps
--- @param warp_table LuaGuiElement
--- @param warps ExpGui_WarpList.Warp[]
--- @param status ExpGui_WarpList.PlayerStatus
function Elements.warp_table.rebuild(warp_table, warps, status)
warp_table.clear()
Elements.warp_table.data[warp_table].rows = {}
for _, warp in ipairs(warps) do
local row = Elements.warp_table.add_row(warp_table, warp)
Elements.warp_table.refresh_row(warp_table, row, status)
end
end
--- Refresh the row of one warp for the online players of its force
--- @param warp ExpGui_WarpList.Warp
function Elements.warp_table.refresh_row_force(warp)
for player, warp_table in Elements.warp_table:online_elements(warp.force) do
--- @cast player -nil
local row = Elements.warp_table.data[warp_table].rows[warp.id]
if row then
Elements.warp_table.refresh_row(warp_table, row, Elements.container.get_status(player))
end
end
end
--- Refresh whether each warp can be used from where the player is
--- @param player LuaPlayer
function Elements.warp_table.refresh_status_player(player)
local status = Elements.container.get_status(player)
for _, warp_table in Elements.warp_table:tracked_elements(player) do
for _, row in pairs(Elements.warp_table.data[warp_table].rows) do
Elements.warp_table._refresh_row_status(row, status)
end
end
end
--- Rebuild the rows for a player, used when their force or permissions change
--- @param player LuaPlayer
function Elements.warp_table.rebuild_player(player)
local warps = Elements.container.get_warps(player.force --[[@as LuaForce]])
local status = Elements.container.get_status(player)
for _, warp_table in Elements.warp_table:tracked_elements(player) do
Elements.warp_table.rebuild(warp_table, warps, status)
end
end
--- Rebuild the rows for the online players of a force, used when the warps change
--- @param force LuaForce
function Elements.warp_table.rebuild_force(force)
local warps = Elements.container.get_warps(force)
for player, warp_table in Elements.warp_table:online_elements(force) do
--- @cast player -nil
Elements.warp_table.rebuild(warp_table, warps, Elements.container.get_status(player))
end
end
--- Progress bar which fills as the warp cooldown runs out
--- @class ExpGui_WarpList.elements.cooldown_bar: ExpElement
--- @overload fun(parent: LuaGuiElement): LuaGuiElement
Elements.cooldown_bar = Gui.define("warp_list/cooldown_bar")
:track_all_elements()
:draw{
type = "progressbar",
value = 1,
tooltip = { "exp-gui_warp-list.tooltip-cooldown-none", config.cooldown_duration },
}
:style{
horizontally_stretchable = true,
color = Colors.light_blue,
} --[[@as any]]
--- Refresh the bar to show the ticks left on the cooldown
--- @param cooldown_bar LuaGuiElement
--- @param cooldown number
function Elements.cooldown_bar.refresh(cooldown_bar, cooldown)
if cooldown > 0 then
cooldown_bar.value = 1 - cooldown / cooldown_ticks
cooldown_bar.tooltip = { "exp-gui_warp-list.tooltip-cooldown", ceil(cooldown / 60) }
else
cooldown_bar.value = 1
cooldown_bar.tooltip = { "exp-gui_warp-list.tooltip-cooldown-none", config.cooldown_duration }
end
end
--- Refresh the bars of a player
--- @param player LuaPlayer
--- @param cooldown number
function Elements.cooldown_bar.refresh_player(player, cooldown)
for _, cooldown_bar in Elements.cooldown_bar:tracked_elements(player) do
Elements.cooldown_bar.refresh(cooldown_bar, cooldown)
end
end
--- @class ExpGui_WarpList.elements.container.elements
--- @field frame LuaGuiElement
--- @field add_warp_button LuaGuiElement
--- @field warp_table LuaGuiElement
--- @field cooldown_bar LuaGuiElement
--- @class ExpGui_WarpList.elements.container.force_data
--- @field warps ExpGui_WarpList.Warp[] The spawn warp first, then by name
--- @field spawn ExpGui_WarpList.Warp?
--- @field next_warp_id number
--- @class ExpGui_WarpList.elements.container.player_data
--- @field cooldown number Ticks until the player can warp again
--- @field in_range ExpGui_WarpList.Warp? The warp the player is standing on
--- @field blocking ExpGui_WarpList.Warp? The warp too close for another to be added
--- @field keep_open boolean True once the player toggles the list themselves, which stops it hiding when they leave a warp
--- @class ExpGui_WarpList.elements.container.data
--- @field [LuaGuiElement] ExpGui_WarpList.elements.container.elements
--- @field [LuaForce] ExpGui_WarpList.elements.container.force_data
--- @field [LuaPlayer] ExpGui_WarpList.elements.container.player_data
--- Container added to the left gui flow
--- @class ExpGui_WarpList.elements.container: ExpElement
--- @field data ExpGui_WarpList.elements.container.data
Elements.container = Gui.define("warp_list/container")
:draw(function(def, parent)
--- @cast def ExpGui_WarpList.elements.container
local player = Gui.get_player(parent)
local allow_add_warp = has_config_permission(player, "allow_add_warp")
local container = Gui.elements.container(parent, allow_add_warp and 268 or 220)
local header = Gui.elements.header(container, {
caption = { "exp-gui_warp-list.caption-main" },
tooltip = {
"exp-gui_warp-list.tooltip-sub",
config.cooldown_duration,
config.standard_proximity_radius,
{ "exp-gui_warp-list.tooltip-sub-current", status_icons.current },
{ "exp-gui_warp-list.tooltip-sub-connected", status_icons.connected },
{ "exp-gui_warp-list.tooltip-sub-different", status_icons.different },
{ "exp-gui_warp-list.tooltip-sub-cooldown", status_icons.cooldown },
{ "exp-gui_warp-list.tooltip-sub-not-available", status_icons.not_available },
{ "exp-gui_warp-list.tooltip-sub-bypass", status_icons.bypass },
},
})
local add_warp_button = Elements.add_warp_button(header)
add_warp_button.visible = allow_add_warp
local warp_table = Elements.warp_table(container)
local force = player.force --[[@as LuaForce]]
Elements.warp_table.rebuild(warp_table, def.get_warps(force), def.get_status(player))
local cooldown_bar = Elements.cooldown_bar(container)
Elements.cooldown_bar.refresh(cooldown_bar, def._get_player_data(player).cooldown)
local root = Gui.elements.container.get_root_element(container)
def.data[root] = {
frame = container,
add_warp_button = add_warp_button,
warp_table = warp_table,
cooldown_bar = cooldown_bar,
}
return root
end) --[[@as any]]
--- Get or create the data for a force
--- @param force LuaForce
--- @return ExpGui_WarpList.elements.container.force_data
function Elements.container._get_force_data(force)
local force_data = Elements.container.data[force]
if not force_data then
force_data = { warps = {}, next_warp_id = 1 }
Elements.container.data[force] = force_data
end
return force_data
end
--- Get or create the data for a player
--- @param player LuaPlayer
--- @return ExpGui_WarpList.elements.container.player_data
function Elements.container._get_player_data(player)
local player_data = Elements.container.data[player]
if not player_data then
player_data = { cooldown = 0, keep_open = false }
Elements.container.data[player] = player_data
end
return player_data
end
--- Get the warps of a force, the spawn warp first and then by name
--- @param force LuaForce
--- @return ExpGui_WarpList.Warp[]
function Elements.container.get_warps(force)
return Elements.container._get_force_data(force).warps
end
--- Get the spawn warp of a force
--- @param force LuaForce
--- @return ExpGui_WarpList.Warp?
function Elements.container.get_spawn_warp(force)
return Elements.container._get_force_data(force).spawn
end
--- Get what decides which warps a player can use
--- @param player LuaPlayer
--- @return ExpGui_WarpList.PlayerStatus
function Elements.container.get_status(player)
local player_data = Elements.container._get_player_data(player)
return {
in_range = player_data.in_range,
on_cooldown = player_data.cooldown > 0,
bypass_proximity = has_config_permission(player, "bypass_warp_proximity"),
}
end
--- Keep the warps of a force in display order
--- @param force_data ExpGui_WarpList.elements.container.force_data
function Elements.container._sort_warps(force_data)
local spawn = force_data.spawn
table.sort(force_data.warps, function(a, b)
if a == spawn then return true end
if b == spawn then return false end
if a.name == b.name then return a.id < b.id end
return a.name < b.name
end)
end
--- Add or update the map tag of a warp
--- @param warp ExpGui_WarpList.Warp
function Elements.container._make_tag(warp)
local tag = warp.tag
if tag and tag.valid then
tag.text = "Warp: " .. warp.name
tag.icon = warp.icon
return
end
local position = warp.position
warp.tag = warp.force.add_chart_tag(warp.surface, {
position = { x = position.x + 0.5, y = position.y + 0.5 },
text = "Warp: " .. warp.name,
icon = warp.icon,
})
end
--- Remove the map tag of a warp
--- @param warp ExpGui_WarpList.Warp
function Elements.container._remove_tag(warp)
local tag = warp.tag
if tag and tag.valid then
tag.destroy()
end
warp.tag = nil
end
--- Place the tiles and entities which mark a warp on the ground
--- @param warp ExpGui_WarpList.Warp
function Elements.container._make_area(warp)
local surface = warp.surface
local position = warp.position
warp.old_tile = surface.get_tile(position.x, position.y).name
local tiles = {}
for index, tile in ipairs(config.tiles) do
tiles[index] = { name = tile[1], position = { x = tile[2] + position.x, y = tile[3] + position.y } }
end
surface.set_tiles(tiles)
for _, entity_config in ipairs(config.entities) do
local entity = assert(surface.create_entity{
name = entity_config[1],
position = { x = entity_config[2] + position.x, y = entity_config[3] + position.y },
force = "neutral",
})
entity.destructible = false
entity.health = 0
entity.minable_flag = false
entity.rotatable = false
if entity.type == "electric-pole" then
warp.electric_pole = entity
end
end
end
--- Restore the ground under a warp and remove its entities
--- @param warp ExpGui_WarpList.Warp
function Elements.container._remove_area(warp)
local old_tile = warp.old_tile
if not old_tile then return end
local surface = warp.surface
local position = warp.position
local tiles = {}
for index, tile in ipairs(config.tiles) do
tiles[index] = { name = old_tile, position = { x = tile[2] + position.x, y = tile[3] + position.y } }
end
surface.set_tiles(tiles)
local radius = config.standard_proximity_radius
local area = {
left_top = { x = position.x - radius, y = position.y - radius },
right_bottom = { x = position.x + radius, y = position.y + radius },
}
local entities = surface.find_entities_filtered{ force = "neutral", area = area, name = area_entity_names }
for _, entity in pairs(entities) do
if entity.destructible == false then
entity.destructible = true
entity.die(entity.force)
end
end
-- The area may not be covered by a radar
warp.force.chart(surface, area)
end
--- Add a warp for a force, when added by a player it is new until they confirm their first edit
--- @param force LuaForce
--- @param surface LuaSurface
--- @param position MapPosition.struct
--- @param player LuaPlayer?
--- @param name string?
--- @return ExpGui_WarpList.Warp
function Elements.container.add_warp(force, surface, position, player, name)
local force_data = Elements.container._get_force_data(force)
local id = force_data.next_warp_id
force_data.next_warp_id = id + 1
--- @type ExpGui_WarpList.Warp
local warp = {
id = id,
force = force,
name = name or "New warp",
icon = { type = config.default_icon.type, name = config.default_icon.name },
surface = surface,
position = { x = floor(position.x), y = floor(position.y) },
last_user = player,
last_edit_tick = game.tick,
editing = {},
}
-- Warps added by a player start in edit mode, discarding that edit removes them again
if player then
warp.new = true
Elements.container._make_area(warp)
end
force_data.warps[#force_data.warps + 1] = warp
Elements.container._sort_warps(force_data)
Elements.container._make_tag(warp)
Elements.warp_table.rebuild_force(force)
return warp
end
--- Remove a warp along with its area and map tag
--- @param warp ExpGui_WarpList.Warp
function Elements.container.remove_warp(warp)
local force_data = Elements.container._get_force_data(warp.force)
table.remove_element(force_data.warps, warp)
if force_data.spawn == warp then
force_data.spawn = nil
end
Elements.container._remove_tag(warp)
Elements.container._remove_area(warp)
-- Offline players are corrected by their next proximity update
for _, player in pairs(warp.force.connected_players) do
local player_data = Elements.container._get_player_data(player)
if player_data.in_range == warp then player_data.in_range = nil end
if player_data.blocking == warp then player_data.blocking = nil end
end
for _, warp_table in Elements.warp_table:online_elements(warp.force) do
Elements.warp_table.remove_row(warp_table, warp)
end
end
--- Save a new name and icon for a warp, which also ends the player's edit
--- @param warp ExpGui_WarpList.Warp
--- @param name string
--- @param icon SignalID
--- @param player LuaPlayer
function Elements.container.update_warp(warp, name, icon, player)
warp.name = name
warp.icon = icon
warp.last_user = player
warp.last_edit_tick = game.tick
warp.editing[player.index] = nil
warp.new = nil
Elements.container._sort_warps(Elements.container._get_force_data(warp.force))
Elements.container._make_tag(warp)
Elements.warp_table.rebuild_force(warp.force)
end
--- Start or stop a player editing a warp
--- @param warp ExpGui_WarpList.Warp
--- @param player LuaPlayer
--- @param editing boolean
function Elements.container.set_editing(warp, player, editing)
warp.editing[player.index] = editing and player.name or nil
Elements.warp_table.refresh_row_force(warp)
end
--- Make a warp the spawn of its force, the force respawns there
--- @param warp ExpGui_WarpList.Warp
function Elements.container.set_spawn_warp(warp)
local force_data = Elements.container._get_force_data(warp.force)
force_data.spawn = warp
Elements.container._sort_warps(force_data)
warp.force.set_spawn_position(warp.position, warp.surface)
end
--- Move a player to a warp and start their cooldown
--- @param player LuaPlayer
--- @param warp ExpGui_WarpList.Warp
function Elements.container.teleport_player(player, warp)
local surface = warp.surface
local position = { x = warp.position.x + 0.5, y = warp.position.y + 0.5 }
local vehicle = player.vehicle
if vehicle then
local vehicle_position = surface.find_non_colliding_position(vehicle.name, position, 32, 1)
-- Only cars can be teleported between surfaces
if vehicle.type == "car" then
vehicle.teleport(vehicle_position, surface)
elseif surface == vehicle.surface then
if not vehicle.teleport(vehicle_position) then
player.driving = false
player.teleport(surface.find_non_colliding_position("character", position, 32, 1), surface)
end
end
else
player.teleport(surface.find_non_colliding_position("character", position, 32, 1), surface)
end
local player_data = Elements.container._get_player_data(player)
if not has_config_permission(player, "bypass_warp_cooldown") then
player_data.cooldown = cooldown_ticks
Elements.cooldown_bar.refresh_player(player, cooldown_ticks)
end
player_data.in_range = warp
Elements.warp_table.refresh_status_player(player)
end
--- Refresh the parts of a player's gui which depend on their permissions
--- @param player LuaPlayer
function Elements.container.refresh_player(player)
local allow_add_warp = has_config_permission(player, "allow_add_warp")
for _, container in Elements.container:tracked_elements(player) do
local elements = Elements.container.data[container]
elements.frame.style.minimal_width = allow_add_warp and 268 or 220
elements.add_warp_button.visible = allow_add_warp
end
Elements.warp_table.rebuild_player(player)
end
--- Count down the cooldown of a player
--- @param player LuaPlayer
--- @param player_data ExpGui_WarpList.elements.container.player_data
function Elements.container._update_cooldown(player, player_data)
if player_data.cooldown == 0 then return end
local cooldown = player_data.cooldown - update_interval
if cooldown < 0 then cooldown = 0 end
player_data.cooldown = cooldown
Elements.cooldown_bar.refresh_player(player, cooldown)
if cooldown == 0 then
Elements.warp_table.refresh_status_player(player)
end
end
--- Find which warp a player is standing on and whether one can be added where they are
--- @param player LuaPlayer
--- @param player_data ExpGui_WarpList.elements.container.player_data
function Elements.container._update_proximity(player, player_data)
local force_data = Elements.container._get_force_data(player.force --[[@as LuaForce]])
local surface = player.physical_surface
local position = player.physical_position
local px, py = position.x, position.y
local closest, closest_distance = nil, nil --- @type ExpGui_WarpList.Warp?, number?
for _, warp in ipairs(force_data.warps) do
if warp.surface == surface then
local dx, dy = px - warp.position.x, py - warp.position.y
local distance = dx * dx + dy * dy
if not closest_distance or distance < closest_distance then
closest, closest_distance = warp, distance
end
end
end
local in_range = nil --- @type ExpGui_WarpList.Warp?
if closest and closest_distance < (closest == force_data.spawn and spawn_radius_sq or proximity_radius_sq) then
in_range = closest
end
if in_range ~= player_data.in_range then
player_data.in_range = in_range
if not player_data.keep_open then
Gui.toolbar.set_left_element_visible_state(Elements.container, player, in_range ~= nil)
end
Elements.warp_table.refresh_status_player(player)
end
local blocking = nil --- @type ExpGui_WarpList.Warp?
if closest and closest_distance <= minimum_distance_sq then
blocking = closest
end
if blocking ~= player_data.blocking then
player_data.blocking = blocking
Elements.add_warp_button.refresh_player_blocking(player, blocking)
end
end
--- Add the element to the left flow with a toolbar button
Gui.add_left_element(Elements.container, false)
Gui.toolbar.create_button{
name = "toggle_warp_list",
left_element = Elements.container,
sprite = config.default_icon.type .. "/" .. config.default_icon.name,
tooltip = { "exp-gui_warp-list.tooltip-main" },
visible = function(player, element)
return Roles.player_has_permission(player, "exp_scenario.gui.warp_list")
end
}:on_click(function(def, player)
-- A list the player opened themselves stays open when they leave a warp
Elements.container._get_player_data(player).keep_open = Gui.toolbar.get_button_toggled_state(def, player)
end)
--- Give a force a spawn warp when its first player is created
--- @param event EventData.on_player_created
local function on_player_created(event)
local player = Gui.get_player(event)
local force = player.force --[[@as LuaForce]]
if Elements.container.get_spawn_warp(force) then return end
local surface = player.physical_surface
local position = force.get_spawn_position(surface)
local warp = Elements.container.add_warp(force, surface, position, nil, "Spawn")
Elements.container.set_spawn_warp(warp)
-- Spawn has no warp area, so any pole nearby connects it to the network
local poles = surface.find_entities_filtered{ type = "electric-pole", position = position, radius = 20, limit = 1 }
warp.electric_pole = poles[1]
end
--- Rebuild the gui for a player as it may be outdated
--- @param event EventData.on_player_joined_game | EventData.on_player_changed_force
local function refresh_player_warps(event)
local player = Gui.get_player(event)
Elements.container.refresh_player(player)
Elements.cooldown_bar.refresh_player(player, Elements.container._get_player_data(player).cooldown)
end
--- Rebuild the gui for a player as their permissions may have changed
--- @param event EventData.ExpRoles.on_player_roles_changed
local function refresh_player_permissions(event)
Elements.container.refresh_player(Gui.get_player(event))
end
--- Restore the map tag of a warp when a player edits or removes it
--- @param event EventData.on_chart_tag_modified | EventData.on_chart_tag_removed
local function on_chart_tag_changed(event)
if not event.player_index then return end
for _, warp in ipairs(Elements.container.get_warps(event.force)) do
local tag = warp.tag
if not tag or not tag.valid or tag == event.tag then
if event.name == defines.events.on_chart_tag_removed then
warp.tag = nil
end
Elements.container._make_tag(warp)
end
end
end
--- Count down cooldowns and check where each player is
local function update_players()
for _, player in pairs(game.connected_players) do
local player_data = Elements.container._get_player_data(player)
Elements.container._update_cooldown(player, player_data)
Elements.container._update_proximity(player, player_data)
end
end
local e = defines.events
return {
elements = Elements,
events = {
[e.on_player_created] = on_player_created,
[e.on_player_joined_game] = refresh_player_warps,
[e.on_player_changed_force] = refresh_player_warps,
[e.on_chart_tag_modified] = on_chart_tag_changed,
[e.on_chart_tag_removed] = on_chart_tag_changed,
[Roles.events.on_player_roles_changed] = refresh_player_permissions,
},
on_nth_tick = {
[update_interval] = update_players,
}
}