diff --git a/lua/keymap/debugKeyActions.lua b/lua/keymap/debugKeyActions.lua index 0793e999eb..96ffa010e9 100644 --- a/lua/keymap/debugKeyActions.lua +++ b/lua/keymap/debugKeyActions.lua @@ -221,6 +221,10 @@ local keyActionsDebug = { action = 'UI_ToggleGamePanels', category = 'debug', }, + ['debug_connectivity'] = { + action = 'UI_Lua import("/lua/ui/dialogs/connection/ConnectionDialog.lua").ToggleDialog()', + category = 'ui' + }, } ---@type table diff --git a/lua/shared/Subject.lua b/lua/shared/Subject.lua new file mode 100644 index 0000000000..2354071af7 --- /dev/null +++ b/lua/shared/Subject.lua @@ -0,0 +1,81 @@ +--********************************************************************************** +--** Copyright (c) 2024 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--********************************************************************************** + +-- upvalue scope for performance +local WARN = WARN + +local StringFormat = string.format + +---@class Observable +---@field Name string +---@field Subjects table +Subject = ClassSimple { + + ---@generic T + ---@param self Observable + ---@param name string + __init = function(self, name) + self.Name = name + self.Subjects = {} + end, + + --- Adds an observer. + ---@generic T + ---@param self Observable + ---@param callback fun(entity: T) + ---@param identifier string + AddObserver = function(self, callback, identifier) + if not type(identifier) == "string" then + WARN(StringFormat("Invalid subject identifier %s for observable %s", tostring(identifier), self.Name)) + return + end + + local oldSubject = self.Subjects[identifier] + if oldSubject then + WARN(StringFormat("Overwriting subject with identifier '%s' for observable '%s'", identifier, self.Name)) + end + + self.Subjects[identifier] = callback + end, + + --- Removes an observer. + ---@param self Observable + ---@param identifier string + RemoveObserver = function(self, identifier) + if not type(identifier) == "string" then + WARN(StringFormat("Invalid subject identifier %s for observable %s", tostring(identifier), self.Name)) + return + end + + self.Subjects[identifier] = nil; + end, + + --- + ---@generic T + ---@param self Observable + ---@param value T + Next = function(self, value) + for k, callback in self.Listeners do + callback(value) + end + end, +} diff --git a/lua/ui/dialogs/connection/ConnectionDialog.lua b/lua/ui/dialogs/connection/ConnectionDialog.lua new file mode 100644 index 0000000000..000e0ea76b --- /dev/null +++ b/lua/ui/dialogs/connection/ConnectionDialog.lua @@ -0,0 +1,375 @@ +--****************************************************************************************************** +--** Copyright (c) 2024 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +local Window = import("/lua/maui/window.lua").Window + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local UIConnectionDialogDot = import("/lua/ui/dialogs/connection/ConnectionDialogDot.lua").UIConnectionDialogDot +local SessionClientsOverride = import("/lua/ui/override/sessionclients.lua") + +local ConnectionDialogData = import("/lua/ui/dialogs/connection/ConnectionDialogData.lua") + +---@class UIConnectionDialogMessage : number[] +---@field Identifier string +---@field Sendee number + +---@type UIConnectionDialog | false +local UIConnectionDialogInstance = false + +---@class UIConnectionDialog : Window +---@field Grid Grid +---@field ItemClientAValue Text +---@field ItemClientBValue Text +---@field ItemClientArrow Text +---@field ItemPingLabel Text +---@field ItemPingAvgLabel Text +---@field ItemPingAvgValue Text +---@field ItemPingDevLabel Text +---@field ItemPingDevValue Text +---@field ItemQuietLabel Text +---@field ItemQuietAvgLabel Text +---@field ItemQuietAvgValue Text +---@field ItemQuietDevLabel Text +---@field ItemQuietDevValue Text +UIConnectionDialog = ClassUI(Window) { + + _MessageIdentifier = "Connection", + + --- Called by Lua to initialize the controls + ---@param self UIConnectionDialog + ---@param parent Control + __init = function(self, parent) + + -- fields for the window class + local title = "Connection dialog" + local icon = false + local pin = false + local config = false + local lockSize = true + local lockPosition = false + local identifier = "ConnectionDialog1" + local defaultPosition = { + Left = 10, + Top = 300, + Right = 310, + Bottom = 625 + } + + Window.__init(self, parent, title, icon, pin, config, lockSize, lockPosition, identifier, defaultPosition) + + do + + -- this is where we receive the information from other players. We interpret + -- and update our internal state along with our interface. + + ForkThread(self.TransmitMessageThread, self) + + import("/lua/ui/game/gamemain.lua").RegisterChatFunc( + ---@param sender string + ---@param data UIConnectionDialogMessage + function(sender, data) + self:ReceiveMessage(data) + end, + self._MessageIdentifier + ) + + end + + do + + -- this is where we create the interface that users can use to navigate the data + + local clients = GetSessionClients() + local clientCount = table.getn(clients) + + ---@type Grid + local grid = {} + for x = 1, clientCount do + local clientA = clients[x] + grid[x] = {} + for y = 1, clientCount do + local clientB = clients[y] + local uiDot = UIConnectionDialogDot(self, clientA.name, clientB.name) + grid[x][y] = uiDot + end + end + + self.Grid = grid + + self.ItemClientAValue = UIUtil.CreateText(self.ClientGroup, '...', 12, UIUtil.bodyFont) + self.ItemClientBValue = UIUtil.CreateText(self.ClientGroup, '...', 12, UIUtil.bodyFont) + self.ItemClientArrow = UIUtil.CreateText(self.ClientGroup, ' -> ', 12, UIUtil.bodyFont) + + self.ItemPingLabel = UIUtil.CreateText(self.ClientGroup, 'Ping: ', 14, UIUtil.bodyFont) + self.ItemPingAvgLabel = UIUtil.CreateText(self.ClientGroup, '- average: ', 12, UIUtil.bodyFont) + self.ItemPingAvgValue = UIUtil.CreateText(self.ClientGroup, '...', 12, UIUtil.bodyFont) + self.ItemPingDevLabel = UIUtil.CreateText(self.ClientGroup, '- standard deviation: ', 12, UIUtil.bodyFont) + self.ItemPingDevValue = UIUtil.CreateText(self.ClientGroup, '...', 12, UIUtil.bodyFont) + + self.ItemQuietLabel = UIUtil.CreateText(self.ClientGroup, 'Quiet: ', 14, UIUtil.bodyFont) + self.ItemQuietAvgLabel = UIUtil.CreateText(self.ClientGroup, '- average: ', 12, UIUtil.bodyFont) + self.ItemQuietAvgValue = UIUtil.CreateText(self.ClientGroup, '...', 12, UIUtil.bodyFont) + self.ItemQuietDevLabel = UIUtil.CreateText(self.ClientGroup, '- standard deviation: ', 12, UIUtil.bodyFont) + self.ItemQuietDevValue = UIUtil.CreateText(self.ClientGroup, '...', 12, UIUtil.bodyFont) + end + end, + + --- Called by Lua to position the controls + ---@param self UIConnectionDialog + ---@param parent Control + __post_init = function(self, parent) + + local grid = self.Grid + local clients = GetSessionClients() + local clientCount = table.getn(clients) + + for x = 1, clientCount do + for y = 1, clientCount do + LayoutHelpers.LayoutFor(grid[x][y]) + :AtLeftTopIn(self.ClientGroup, 14 + 26 * (x - 1), 10 + 26 * (y - 1)) + end + end + + -- client information + + LayoutHelpers.LayoutFor(self.ItemClientAValue) + :AtLeftTopIn(self.ClientGroup, 14, 20 + 26 * clientCount) + + LayoutHelpers.LayoutFor(self.ItemClientArrow) + :RightOf(self.ItemClientAValue, 2) + + LayoutHelpers.LayoutFor(self.ItemClientBValue) + :RightOf(self.ItemClientArrow, 2) + + -- ping information + + LayoutHelpers.LayoutFor(self.ItemPingLabel) + :Below(self.ItemClientAValue, 2) + + LayoutHelpers.LayoutFor(self.ItemPingAvgLabel) + :Below(self.ItemPingLabel, 2) + + LayoutHelpers.LayoutFor(self.ItemPingAvgValue) + :RightOf(self.ItemPingAvgLabel, 2) + + LayoutHelpers.LayoutFor(self.ItemPingDevLabel) + :Below(self.ItemPingAvgLabel, 2) + + LayoutHelpers.LayoutFor(self.ItemPingDevValue) + :RightOf(self.ItemPingDevLabel, 2) + + -- quiet information + + LayoutHelpers.LayoutFor(self.ItemQuietLabel) + :Below(self.ItemPingDevLabel, 2) + + LayoutHelpers.LayoutFor(self.ItemQuietAvgLabel) + :Below(self.ItemQuietLabel, 2) + + LayoutHelpers.LayoutFor(self.ItemQuietAvgValue) + :RightOf(self.ItemQuietAvgLabel, 2) + + LayoutHelpers.LayoutFor(self.ItemQuietDevLabel) + :Below(self.ItemQuietAvgLabel, 2) + + LayoutHelpers.LayoutFor(self.ItemQuietDevValue) + :RightOf(self.ItemQuietDevLabel, 2) + + self:SetWindowAlpha(0.8) + end, + + --- Called when the control is destroyed + ---@param self UIConnectionDialog + OnDestroy = function(self) + Window.OnDestroy(self) + end, + + ---@param self UIConnectionDialog + TransmitMessageThread = function(self) + + ---@type number[] + local mCache = {} + + ---@type number[] + local sCache = {} + + ---@type number[] + local recipients = {} + + ---@type UIConnectionDialogMessage + local message = { + Identifier = self._MessageIdentifier, + Sendee = -1, + } + + while not IsDestroyed(self) do + + -- this is where we send the clients information to the other players. We use + -- a 'structure of arrays' as that is cheaper to send without abstracting the + -- information too much. + + local clients = GetSessionClients() + local clientCount = table.getn(clients) + + -- determine recipients + for k = 1, clientCount do + recipients[k] = nil + end + + local recipientHead = 1 + for k = 1, clientCount do + local client = clients[k] + if client.connected then + recipients[recipientHead] = k + recipientHead = recipientHead + 1 + end + + if client["local"] then + message.Sendee = k + end + end + + for k = 1, table.getn(message) do + message[k] = nil + end + + -- populate with ping values + mCache, sCache = ConnectionDialogData.ComputeStatisticsPing(mCache, sCache) + for k = 1, clientCount do + message[k + 0 * clientCount] = mCache[k] + end + + for k = 1, clientCount do + message[k + 1 * clientCount] = sCache[k] + end + + -- populate with quiet values + mCache, sCache = ConnectionDialogData.ComputeStatisticsQuiet(mCache, sCache) + for k = 1, clientCount do + message[k + 2 * clientCount] = mCache[k] + end + + for k = 1, clientCount do + message[k + 3 * clientCount] = sCache[k] + end + + -- send out the message + SessionSendChatMessage(recipients, message) + + -- delay frequency when we're not visible + if self:IsHidden() then + WaitSeconds(16.0) + else + WaitSeconds(2.0) + end + end + end, + + ---@param self UIConnectionDialog + ---@param message UIConnectionDialogMessage + ReceiveMessage = function(self, message) + local clients = GetSessionClients() + local clientCount = table.getn(clients) + + local grid = self.Grid + for k = 1, clientCount do + ---@type UIConnectionDialogDot + local item = grid[message.Sendee][k] + local pingMean = message[k + 0 * clientCount] + local pingDeviation = message[k + 1 * clientCount] + local quietMean = message[k + 2 * clientCount] + local quietDeviation = message[k + 3 * clientCount] + item:Update(pingMean, pingDeviation, quietMean, quietDeviation) + end + end, + + --- Called by the engine when the dialog changes visiblity via `Control:Show()` and `Control:Hide()` + ---@param self UIConnectionDialog + ---@param hidden boolean + ---@return boolean # if true, skips the call to `Control:OnHide` of children of this control + OnHide = function(self, hidden) + return true + end, + + ---@param self UIConnectionDialog + ---@param item UIConnectionDialogDot + ---@param event KeyEvent + OnHover = function(self, item, event) + if event.Type == 'MouseExit' then + self.ItemClientAValue:SetText('...') + self.ItemClientBValue:SetText('...') + self.ItemPingAvgValue:SetText('...') + self.ItemPingDevValue:SetText('...') + self.ItemQuietAvgValue:SetText('...') + self.ItemQuietDevValue:SetText('...') + else + self.ItemClientAValue:SetText(item.ClientA) + self.ItemClientBValue:SetText(item.ClientB) + self.ItemPingAvgValue:SetText(string.format('%.2f', item.ClientPingAvg)) + self.ItemPingDevValue:SetText(string.format('%.2f', item.ClientPingSd)) + self.ItemQuietAvgValue:SetText(string.format('%.2f', item.ClientQuietAvg)) + self.ItemQuietDevValue:SetText(string.format('%.2f', item.ClientQuietSd)) + end + end, + + ---@param self UIConnectionDialog + OnClose = function(self) + self:Hide() + end, +} + +--- Open the dialog +function OpenDialog() + if UIConnectionDialogInstance and not IsDestroyed(UIConnectionDialog) then + UIConnectionDialogInstance:Show() + else + UIConnectionDialogInstance = UIConnectionDialog(GetFrame(0)) + UIConnectionDialogInstance:Show() + end +end + +--- Close the dialog +function CloseDialog() + if UIConnectionDialogInstance then + UIConnectionDialogInstance:Hide() + end +end + +--- Toggle the dialog +function ToggleDialog() + if (not UIConnectionDialogInstance) or IsDestroyed(UIConnectionDialogInstance) or + UIConnectionDialogInstance:IsHidden() then + OpenDialog() + else + CloseDialog() + end +end + +--- Called by the module manager when this module is dirty due to a disk change +function __moduleinfo.OnDirty() + if UIConnectionDialogInstance then + UIConnectionDialogInstance:Destroy() + UIConnectionDialogInstance = false + end +end diff --git a/lua/ui/dialogs/connection/ConnectionDialogData.lua b/lua/ui/dialogs/connection/ConnectionDialogData.lua new file mode 100644 index 0000000000..6fc996a956 --- /dev/null +++ b/lua/ui/dialogs/connection/ConnectionDialogData.lua @@ -0,0 +1,128 @@ +--****************************************************************************************************** +--** Copyright (c) 2024 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +local SessionClientsOverride = import("/lua/ui/override/sessionclients.lua") +local StatisticsMean = import("/lua/shared/statistics.lua").Mean +local StatisticsStandardDeviation = import("/lua/shared/statistics.lua").Deviation + +---@type number +local Samples = 30 + +---@type number +local Current = 1 + +---@type number[][] +local PingSamples = {} + +---@type number[][] +local QuietSamples = {} + +--- Allows for scripts to adjust the number of samples. +---@param requestedSamples number +function SetSamples(requestedSamples) + + -- clear out all exist samples and start over + Current = 1 + for k = 1, table.getn(PingSamples) do + local clientPingSamples = PingSamples[k] + local clientQuietSamples = QuietSamples[k] + for l = 1, Samples do + clientPingSamples[l] = nil + clientQuietSamples[l] = nil + end + end + + Samples = requestedSamples +end + +--- Compute the mean and standard deviation of the ping value of all clients. +---@param mCache? number[] +---@param sCache? number[] +---@return number[] # mean +---@return number[] # standard deviation +function ComputeStatisticsPing(mCache, sCache) + mCache = mCache or {} + sCache = sCache or {} + + for k = 1, table.getn(PingSamples) do + local samples = PingSamples[k] + local sampleCount = table.getn(samples) + local mean = StatisticsMean(samples, sampleCount) + local deviation = StatisticsStandardDeviation(samples, sampleCount, mean) + mCache[k] = mean + sCache[k] = deviation + end + + return mCache, sCache +end + +--- Compute the mean and standard deviation of the quiet value of all clients. +---@param mCache? number[] +---@param sCache? number[] +---@return number[] # mean +---@return number[] # standard deviation +function ComputeStatisticsQuiet(mCache, sCache) + mCache = mCache or {} + sCache = sCache or {} + + for k = 1, table.getn(QuietSamples) do + local samples = QuietSamples[k] + local sampleCount = table.getn(samples) + local mean = StatisticsMean(samples, sampleCount) + local deviation = StatisticsStandardDeviation(samples, sampleCount, mean) + mCache[k] = mean + sCache[k] = deviation + end + + return mCache, sCache +end + +--- Automatically keep track of these values + +SessionClientsOverride.Observable:AddObserver( +---@param clients Client[] + function(clients) + for k = 1, table.getn(clients) do + local client = clients[k] + + local pingSamples = PingSamples[k] or {} + PingSamples[k] = pingSamples + + local quietSamples = QuietSamples[k] or {} + QuietSamples[k] = quietSamples + + if client.connected then + pingSamples[Current] = client.ping + quietSamples[Current] = client.quiet + else + pingSamples[Current] = -1 + quietSamples[Current] = -1 + end + end + + Current = Current + 1 + if Current > Samples then + Current = 1 + end + end, + "ConnectionDialogData.lua" +) diff --git a/lua/ui/dialogs/connection/ConnectionDialogDot.lua b/lua/ui/dialogs/connection/ConnectionDialogDot.lua new file mode 100644 index 0000000000..2710c8df52 --- /dev/null +++ b/lua/ui/dialogs/connection/ConnectionDialogDot.lua @@ -0,0 +1,142 @@ +--****************************************************************************************************** +--** Copyright (c) 2024 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local ColorUtils = import("/lua/shared/color.lua") + +local Group = import("/lua/maui/group.lua").Group + +---@class UIConnectionDialogDot : Group +---@field Background Bitmap +---@field BackgroundHighlight Bitmap +---@field Color number[] +---@field ClientA string # name of client +---@field ClientB string # name of client +---@field ClientPingAvg number # average of ping +---@field ClientPingSd number # standard deviation of ping +---@field ClientQuietAvg number # average of time quiet +---@field ClientQuietSd number # standard deviation of time quiet +UIConnectionDialogDot = ClassUI(Group) { + + ---@param self UIConnectionDialogDot + ---@param parent UIConnectionDialog + ---@param clientA string + ---@param clientB string + __init = function(self, parent, clientA, clientB) + Group.__init(self, parent, 'GridReclaimUIUpdate') + + self:SetNeedsFrameUpdate(true) + + self.Color = { 1, 1, 1 } + self.ClientA = clientA + self.ClientB = clientB + self.ClientPingAvg = 0 + self.ClientPingSd = 0 + self.ClientQuietAvg = 0 + self.ClientQuietSd = 0 + + self.Background = UIUtil.CreateBitmapColor(self, '000000') + self.Background:DisableHitTest(true) + + self.BackgroundHighlight = UIUtil.CreateBitmapColor(self, 'ffffff') + self.BackgroundHighlight:DisableHitTest(true) + self.BackgroundHighlight:SetAlpha(0) + end, + + ---@param self UIConnectionDialogDot + ---@param parent UIConnectionDialog + __post_init = function(self, parent) + LayoutHelpers.LayoutFor(self) + :Over(parent, 10) + :Height(24) + :Width(24) + + LayoutHelpers.LayoutFor(self.BackgroundHighlight) + :AtLeftTopIn(self, -1, -1) + :Height(26) + :Width(26) + + LayoutHelpers.LayoutFor(self.Background) + :Over(self.BackgroundHighlight, 1) + :Fill(self) + end, + + --- Called by the engine on each frame + ---@param self UIConnectionDialogDot + ---@param delta number + OnFrame = function(self, delta) + + -- slowly turn the color black to create a heartbeat-like effect + local color = self.Color + local inverseDelta = math.max(1 - 0.25 * delta, 0) + color[1] = inverseDelta * color[1] + color[2] = inverseDelta * color[2] + color[3] = inverseDelta * color[3] + + self.Background:SetSolidColor(ColorUtils.ColorRGB(color[1], color[2], color[3], 1)) + end, + + ---@param self UIConnectionDialogDot + ---@param pingAvg number + ---@param pingSd number + ---@param quietAvg number + ---@param quietSd number + Update = function(self, pingAvg, pingSd, quietAvg, quietSd) + self.ClientPingAvg = pingAvg + self.ClientPingSd = pingSd + self.ClientQuietAvg = quietAvg + self.ClientQuietSd = quietSd + + -- update the color indicator + local color = self.Color + if pingAvg == -1 and quietAvg == -1 then + color[1] = 0 + color[2] = 0 + color[3] = 0 + self:SetNeedsFrameUpdate(false) + elseif pingAvg == 0 and quietAvg == 0 then + color[1] = 0.25 + color[2] = 0.25 + color[3] = 0.25 + else + color[1] = math.min(quietAvg / 100, 1) + color[2] = 1 - math.min(quietAvg / 100, 1) + color[3] = 0 + end + + self.Background:SetSolidColor(ColorUtils.ColorRGB(color[1], color[2], color[3], 1)) + end, + + ---@param self UIConnectionDialogDot + ---@param event KeyEvent + HandleEvent = function(self, event) + if event.Type == 'MouseEnter' then + self.BackgroundHighlight:SetAlpha(0.8) + elseif event.Type == 'MouseExit' then + self.BackgroundHighlight:SetAlpha(0.0) + end + + local parent = self:GetParent() --[[@as UIConnectionDialog]] + parent:OnHover(self, event) + end, +} diff --git a/lua/ui/game/gamemain.lua b/lua/ui/game/gamemain.lua index 7343ccf8ac..434ff7a6d8 100644 --- a/lua/ui/game/gamemain.lua +++ b/lua/ui/game/gamemain.lua @@ -1018,16 +1018,34 @@ function HideNISBars() end end +---@type table local chatFuncs = {} -function RegisterChatFunc(func, dataTag) - table.insert(chatFuncs, {id = dataTag, func = func}) +---@param func fun(sender: string, data: table) +---@param identifier string +function RegisterChatFunc(func, identifier) + chatFuncs[identifier] = func end +--- Called by the engine as (chat) messages are received. +---@param sender string # username +---@param data table function ReceiveChat(sender, data) - for i, chatFuncEntry in chatFuncs do - if data[chatFuncEntry.id] then - chatFuncEntry.func(sender, data) + if data.Identifier then + + -- we highly encourage to use the 'Identifier' field to quickly identify the correct function + + local func = chatFuncs[data.Identifier] + if func then + func(sender, data) + end + else + -- for legacy support we also search through the chat functions the 'old way' + + for identifier, func in chatFuncs do + if data[identifier] then + func(sender, data) + end end end end diff --git a/lua/ui/override/SessionClients.lua b/lua/ui/override/SessionClients.lua index 3cc14bb1e3..573f915b1d 100644 --- a/lua/ui/override/SessionClients.lua +++ b/lua/ui/override/SessionClients.lua @@ -67,47 +67,48 @@ local Cached = PostprocessClients(GlobalGetSessionClients()) Observable = import("/lua/shared/observable.lua").Create() Observable:Set(Cached) ---- Interval for when we update the cache -local TickInterval = 2.0 - ---- A counter that keeps track of how often the interval was increased, --- allows us to keep track of when we really want to reset it. As an example, --- when FastInterval() is called again before ResetInterval() is. -local TickIntervalResetCounter = 0 +--- Override global function to return our cache +---@return Client[] +_G.GetSessionClients = function() + return Cached +end --- A simple tick thread that updates the cache local function TickThread() while true do -- allows us to be more responsive on tick interval changes - WaitSeconds(0.5 * TickInterval) - WaitSeconds(0.5 * TickInterval) - WaitSeconds(0.5 * TickInterval) - WaitSeconds(0.5 * TickInterval) - + WaitSeconds(0.1) -- update the cache and inform observers Cached = PostprocessClients(GlobalGetSessionClients()) Observable:Set(Cached) end end ---- Override global function to return our cache ----@return Client[] -_G.GetSessionClients = function() - return Cached -end +ForkThread(TickThread) + +------------------------------------------------------------------------------- +--#region Deprecated functionality + +--- Interval for when we update the cache +local TickInterval = 2.0 ---- A getter to return the check interval +--- A counter that keeps track of how often the interval was increased, +-- allows us to keep track of when we really want to reset it. As an example, +-- when FastInterval() is called again before ResetInterval() is. +local TickIntervalResetCounter = 0 + +---@deprecated function GetInterval() return TickInterval end ---- Increases the check interval to every 0.025 seconds or a framerate of 40. +---@deprecated function FastInterval() TickIntervalResetCounter = TickIntervalResetCounter + 1 TickInterval = 0.025 end ---- Resets the interval to every 2.0 seconds or a framerate of 0.5. +---@deprecated function ResetInterval() TickIntervalResetCounter = TickIntervalResetCounter - 1 if TickIntervalResetCounter == 0 then @@ -115,4 +116,4 @@ function ResetInterval() end end -ForkThread(TickThread) +-------------------------------------------------------------------------------