From ada9afc48d919ac494518ed677f022839cd83ead Mon Sep 17 00:00:00 2001 From: raizenxd Date: Sat, 31 May 2025 10:13:27 +0200 Subject: [PATCH] init commit --- LICENSE | 29 +- README.md | 123 +++- addon.json | 13 + lua/autorun/sh_squad_menu.lua | 230 ++++++ .../core/custom/cl_squad_menu.lua | 8 + .../core/custom/squad_menu.lua | 59 ++ lua/includes/modules/styled_theme.lua | 681 ++++++++++++++++++ .../modules/styled_theme_tabbed_frame.lua | 160 ++++ lua/squad_menu/client/config.lua | 63 ++ lua/squad_menu/client/hud.lua | 344 +++++++++ lua/squad_menu/client/main.lua | 380 ++++++++++ lua/squad_menu/client/menu.lua | 598 +++++++++++++++ lua/squad_menu/client/vgui/member_status.lua | 100 +++ lua/squad_menu/client/vgui/squad_list_row.lua | 199 +++++ lua/squad_menu/player.lua | 5 + lua/squad_menu/server/main.lua | 196 +++++ lua/squad_menu/server/network.lua | 182 +++++ lua/squad_menu/server/squad.lua | 278 +++++++ lua/starfall/libs_sh/squad_menu.lua | 152 ++++ materials/squad_menu/ping.png | Bin 0 -> 2655 bytes materials/squad_menu/ring.png | Bin 0 -> 7343 bytes materials/squad_menu/squad_menu.png | Bin 0 -> 9038 bytes materials/styledstrike/icons/bullet_list.png | Bin 0 -> 1156 bytes materials/styledstrike/icons/cog.png | Bin 0 -> 3530 bytes .../styledstrike/icons/flag_two_tone.png | Bin 0 -> 1111 bytes materials/styledstrike/icons/user_add.png | Bin 0 -> 2017 bytes materials/styledstrike/icons/users.png | Bin 0 -> 2065 bytes .../localization/en/squad_menu.properties | 57 ++ .../localization/pt-br/squad_menu.properties | 57 ++ .../localization/tr/squad_menu.properties | 57 ++ 30 files changed, 3955 insertions(+), 16 deletions(-) create mode 100644 addon.json create mode 100644 lua/autorun/sh_squad_menu.lua create mode 100644 lua/entities/gmod_wire_expression2/core/custom/cl_squad_menu.lua create mode 100644 lua/entities/gmod_wire_expression2/core/custom/squad_menu.lua create mode 100644 lua/includes/modules/styled_theme.lua create mode 100644 lua/includes/modules/styled_theme_tabbed_frame.lua create mode 100644 lua/squad_menu/client/config.lua create mode 100644 lua/squad_menu/client/hud.lua create mode 100644 lua/squad_menu/client/main.lua create mode 100644 lua/squad_menu/client/menu.lua create mode 100644 lua/squad_menu/client/vgui/member_status.lua create mode 100644 lua/squad_menu/client/vgui/squad_list_row.lua create mode 100644 lua/squad_menu/player.lua create mode 100644 lua/squad_menu/server/main.lua create mode 100644 lua/squad_menu/server/network.lua create mode 100644 lua/squad_menu/server/squad.lua create mode 100644 lua/starfall/libs_sh/squad_menu.lua create mode 100644 materials/squad_menu/ping.png create mode 100644 materials/squad_menu/ring.png create mode 100644 materials/squad_menu/squad_menu.png create mode 100644 materials/styledstrike/icons/bullet_list.png create mode 100644 materials/styledstrike/icons/cog.png create mode 100644 materials/styledstrike/icons/flag_two_tone.png create mode 100644 materials/styledstrike/icons/user_add.png create mode 100644 materials/styledstrike/icons/users.png create mode 100644 resource/localization/en/squad_menu.properties create mode 100644 resource/localization/pt-br/squad_menu.properties create mode 100644 resource/localization/tr/squad_menu.properties diff --git a/LICENSE b/LICENSE index b579509..7ffc21f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,18 +1,21 @@ MIT License -Copyright (c) 2025 raizen +Copyright (c) 2024 StyledStrike -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: +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 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. +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. diff --git a/README.md b/README.md index 57b231a..04ddbac 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,121 @@ -# Simple-Squad-Menu +# Simple Squad menu -Forked version of Simple Squad Menu -(https://steamcommunity.com/sharedfiles/filedetails/?id=3207278246) \ No newline at end of file +[![GLuaLint](https://github.com/StyledStrike/gmod-squad-menu/actions/workflows/glualint.yml/badge.svg)](https://github.com/FPtje/GLuaFixer) +[![Workshop Page](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Fshieldsio-steam-workshop.jross.me%2F3207278246%2Fsubscriptions-text)](https://steamcommunity.com/sharedfiles/filedetails/?id=3207278246) + +A basic squad/party creation addon for Garry's Mod. Forked because I needed one hook.Run call. + +## Features + +* Create a private or public squad with a custom name, icon and color +* Players can view all available squads and join/request to join them +* Squad leaders can choose if your members can damage eachother (friendly fire) +* Squad members can see the health/armor of all members +* Squad members have indicators in the world so you can tell who is on your squad + +## For developers + +You can check a Player's squad by calling this function: + +```lua +-- Available both on SERVER and CLIENT. +-- Will be -1 if this player is not in a squad. +local id = Player:GetSquadID() +``` + +**On the server only**, you can use this function to get a specific squad instance: + +```lua +local squad = SquadMenu:GetSquad( id ) + +--[[ + This allows you to access things like: + + squad.name - string + squad.icon - string + squad.leader - Player + + squad.enableRings - boolean + squad.friendlyFire - boolean + squad.isPublic - boolean +]] + +-- You can get the player entities that are part of the squad with: +local players = squad:GetActiveMembers() + +-- "p" represents a player Entity or a string you can get from SquadMenu.GetPlayerId: +squad:AddMember( p ) +squad:RemoveMember( p, reason ) -- reason is a number from SquadMenu.LEAVE_REASON_* +``` + +### Hook: `ShouldAllowSquadName` + +You can also filter the squad name before it's assigned by using the `ShouldAllowSquadName` hook **on the server**. + +```lua +hook.Add( "ShouldAllowSquadName", "BlockWordsExample", function( name, leader ) + -- When you only return false, the squad will be "Unamed". + if string.find( name, "amogus" ) then + return false + end + + -- Optionally you can also return a second value for the name + if string.find( name, "sus" ) then + return false, string.Replace( name, "sus", "nope" ) + end +end ) +``` + +### Hook: `SquadPlayerSay` + +You can also override/filter squad-only messages by using the `SquadPlayerSay` hook **on the server**. + +```lua +hook.Add( "SquadPlayerSay", "BlockMessagesExample", function( sender, text ) + -- When you return false, the message will not be sent. + if string.find( text, "amogus" ) then + return false + end + + -- You can return a string to override the message. + if string.find( text, "sus" ) then + return string.Replace( text, "sus", "nope" ) + end +end ) +``` + +### Hooks: `SquadMenu_OnJoinedSquad` and `SquadMenu_OnLeftSquad` + +You can use these hooks to detect when a player has joined/left a squad **on the server**. + +```lua +hook.Add( "SquadMenu_OnJoinedSquad", "JoinedSquadExample", function( squadId, ply, plySteamID ) + -- Get the squad instance's name + local squadName = SquadMenu:GetSquad( squadId ).name + + -- Get the player name, if the player is not valid then just use their SteamID + local playerName = IsValid( ply ) and ply:Nick() or plySteamID + + -- Print a message on everyone's chat + for _, p in ipairs( player.GetAll() ) do + p:ChatPrint( playerName .. " joined the squad: " .. squadName ) + end +end ) + +hook.Add( "SquadMenu_OnLeftSquad", "LeftSquadExample", function( squadId, ply, plySteamID ) + -- Get the squad instance's name + local squadName = SquadMenu:GetSquad( squadId ).name + + -- Get the player name, if the player is not valid then just use their SteamID + local playerName = IsValid( ply ) and ply:Nick() or plySteamID + + -- Print a message on everyone's chat + for _, p in ipairs( player.GetAll() ) do + p:ChatPrint( playerName .. " left the squad: " .. squadName ) + end +end ) +``` + +# Contributing + +Before you open a pull request, if it deals with Lua code, please read [this](https://github.com/StyledStrike/gmod-squad-menu/blob/main/.github/pull_request_template.md). diff --git a/addon.json b/addon.json new file mode 100644 index 0000000..2d7bc5b --- /dev/null +++ b/addon.json @@ -0,0 +1,13 @@ +{ + "title": "Simple Squad Menu", + "type": "ServerContent", + "tags": ["realism"], + "ignore": [ + ".git*", + "*.txt", + "*.md", + "glualint.json", + ".editorconfig", + "LICENSE" + ] +} \ No newline at end of file diff --git a/lua/autorun/sh_squad_menu.lua b/lua/autorun/sh_squad_menu.lua new file mode 100644 index 0000000..411fda2 --- /dev/null +++ b/lua/autorun/sh_squad_menu.lua @@ -0,0 +1,230 @@ +SquadMenu = SquadMenu or {} + +if CLIENT then + -- Settings file + SquadMenu.DATA_FILE = "squad_menu.json" +end + +-- Chat prefixes that allow messaging squad members only +SquadMenu.CHAT_PREFIXES = { "/s", "!s", "/p", "!pchat" } + +-- Primary color used for the UI theme +SquadMenu.THEME_COLOR = Color( 34, 52, 142 ) + +-- Max. length of a squad name +SquadMenu.MAX_NAME_LENGTH = 30 + +-- Size limit for JSON data +SquadMenu.MAX_JSON_SIZE = 49152 -- 48 kibibytes + +-- Used on net.WriteUInt for the command ID +SquadMenu.COMMAND_SIZE = 4 + +-- Command IDs (Max. ID when COMMAND_SIZE = 4 is 15) +SquadMenu.BROADCAST_EVENT = 0 +SquadMenu.SQUAD_LIST = 1 +SquadMenu.SETUP_SQUAD = 2 +SquadMenu.JOIN_SQUAD = 3 +SquadMenu.LEAVE_SQUAD = 4 +SquadMenu.ACCEPT_REQUESTS = 5 +SquadMenu.REQUESTS_LIST = 6 +SquadMenu.KICK = 7 +SquadMenu.PING = 8 + +-- Reasons given when a member is removed from a squad +SquadMenu.LEAVE_REASON_DELETED = 0 +SquadMenu.LEAVE_REASON_LEFT = 1 +SquadMenu.LEAVE_REASON_KICKED = 2 + +-- Server settings +local maxMembersCvar = CreateConVar( + "squad_max_members", + "10", + FCVAR_ARCHIVE + FCVAR_REPLICATED + FCVAR_NOTIFY, + "Limits how many members a single squad can have.", + 1, 100 +) + +local squadListPosCvar = CreateConVar( + "squad_members_position", + "6", + FCVAR_ARCHIVE + FCVAR_REPLICATED + FCVAR_NOTIFY, + "Sets the position of the squad members on the screen. Takes numbers betweek 1-9 and uses the same positions as a numpad.", + 1, 9 +) + +local broadcastCvar = CreateConVar( + "squad_broadcast_creation_message", + "1", + FCVAR_ARCHIVE + FCVAR_REPLICATED + FCVAR_NOTIFY, + "When set to 1, Squad Menu will print when a new squad is created on the chat.", + 0, 1 +) + +local friendlyfireCvar = CreateConVar( + "squad_force_friendly_fire", + "0", + FCVAR_ARCHIVE + FCVAR_REPLICATED + FCVAR_NOTIFY, + "Makes so squads always have friendly fire enabled.", + 0, 1 +) + +function SquadMenu.PrintF( str, ... ) + MsgC( SquadMenu.THEME_COLOR, "[Squad Menu] ", Color( 255, 255, 255 ), string.format( str, ... ), "\n" ) +end + +function SquadMenu.TableToJSON( t ) + return util.TableToJSON( t, false ) +end + +function SquadMenu.JSONToTable( s ) + if type( s ) ~= "string" or s == "" then + return {} + end + + return util.JSONToTable( s ) or {} +end + +function SquadMenu.GetMemberLimit() + return maxMembersCvar:GetInt() +end + +function SquadMenu.GetMembersPosition() + return squadListPosCvar:GetInt() +end + +function SquadMenu.GetShowCreationMessage() + return broadcastCvar:GetInt() > 0 +end + +function SquadMenu.GetForceFriendlyFire() + return friendlyfireCvar:GetInt() > 0 +end + +function SquadMenu.GetPlayerId( ply ) + if ply:IsBot() then + return "BOT_" .. ply:AccountID() + end + + return ply:SteamID() +end + +local PID = SquadMenu.GetPlayerId + +function SquadMenu.AllPlayersById() + local all = player.GetAll() + local byId = {} + + for _, ply in ipairs( all ) do + byId[PID( ply )] = ply + end + + return byId +end + +function SquadMenu.FindPlayerById( id ) + local all = player.GetAll() + + for _, ply in ipairs( all ) do + if id == PID( ply ) then return ply end + end +end + +function SquadMenu.ValidateNumber( n, default, min, max ) + return math.Clamp( tonumber( n ) or default, min, max ) +end + +function SquadMenu.ValidateString( s, default, maxLength ) + if type( s ) ~= "string" then + return default + end + + s = string.Trim( s ) + + if s == "" then + return default + end + + if s:len() > maxLength then + return string.Left( s, maxLength - 3 ) .. "..." + end + + return s +end + +function SquadMenu.StartCommand( id ) + net.Start( "squad_menu.command", false ) + net.WriteUInt( id, SquadMenu.COMMAND_SIZE ) +end + +function SquadMenu.WriteTable( t ) + local data = util.Compress( SquadMenu.TableToJSON( t ) ) + local bytes = #data + + net.WriteUInt( bytes, 16 ) + + if bytes > SquadMenu.MAX_JSON_SIZE then + SquadMenu.PrintF( "Tried to write JSON that was too big! (%d/%d)", bytes, SquadMenu.MAX_JSON_SIZE ) + return + end + + net.WriteData( data ) +end + +function SquadMenu.ReadTable() + local bytes = net.ReadUInt( 16 ) + + if bytes > SquadMenu.MAX_JSON_SIZE then + SquadMenu.PrintF( "Tried to read JSON that was too big! (%d/%d)", bytes, SquadMenu.MAX_JSON_SIZE ) + return {} + end + + local data = net.ReadData( bytes ) + return SquadMenu.JSONToTable( util.Decompress( data ) ) +end + +if SERVER then + -- Shared files + include( "squad_menu/player.lua" ) + AddCSLuaFile( "squad_menu/player.lua" ) + + -- Server files + include( "squad_menu/server/main.lua" ) + include( "squad_menu/server/squad.lua" ) + include( "squad_menu/server/network.lua" ) + + -- Client files + AddCSLuaFile( "includes/modules/styled_theme.lua" ) + AddCSLuaFile( "includes/modules/styled_theme_tabbed_frame.lua" ) + + AddCSLuaFile( "squad_menu/client/main.lua" ) + AddCSLuaFile( "squad_menu/client/config.lua" ) + AddCSLuaFile( "squad_menu/client/menu.lua" ) + AddCSLuaFile( "squad_menu/client/hud.lua" ) + + AddCSLuaFile( "squad_menu/client/vgui/member_status.lua" ) + AddCSLuaFile( "squad_menu/client/vgui/squad_list_row.lua" ) +end + +if CLIENT then + -- Shared files + include( "squad_menu/player.lua" ) + + -- Setup UI theme + require( "styled_theme" ) + require( "styled_theme_tabbed_frame" ) + + StyledTheme.RegisterFont( "SquadMenuInfo", 0.016, { + font = "Roboto-Condensed", + weight = 600, + } ) + + -- Client files + include( "squad_menu/client/main.lua" ) + include( "squad_menu/client/config.lua" ) + include( "squad_menu/client/menu.lua" ) + include( "squad_menu/client/hud.lua" ) + + include( "squad_menu/client/vgui/member_status.lua" ) + include( "squad_menu/client/vgui/squad_list_row.lua" ) +end diff --git a/lua/entities/gmod_wire_expression2/core/custom/cl_squad_menu.lua b/lua/entities/gmod_wire_expression2/core/custom/cl_squad_menu.lua new file mode 100644 index 0000000..036388f --- /dev/null +++ b/lua/entities/gmod_wire_expression2/core/custom/cl_squad_menu.lua @@ -0,0 +1,8 @@ +E2Helper.Descriptions["isSquadMember(e:)"] = "Returns 1 is this player is part of a squad." +E2Helper.Descriptions["getSquadID(e:)"] = "Get the ID of the squad this player is part of. Returns -1 if this player is not in one." +E2Helper.Descriptions["doesSquadExist(n)"] = "Returns 1 if the given ID points to a valid squad." +E2Helper.Descriptions["getSquadName(n)"] = "Finds a squad by it's ID and returns the name. Returns a empty string if the squad does not exist." +E2Helper.Descriptions["getSquadColor(n)"] = "Finds a squad by it's ID and returns the color. Returns vec(0) if the squad does not exist." +E2Helper.Descriptions["getSquadMemberCount(n)"] = "Returns the number of members in a squad, or 0 if the squad does not exist." +E2Helper.Descriptions["getSquadMembers(n)"] = "Returns an array of active players associated with the squad ID, or a empty array if the squad does not exist." +E2Helper.Descriptions["getAllSquadIDs()"] = "Returns an array of all available squad IDs." diff --git a/lua/entities/gmod_wire_expression2/core/custom/squad_menu.lua b/lua/entities/gmod_wire_expression2/core/custom/squad_menu.lua new file mode 100644 index 0000000..2bd694f --- /dev/null +++ b/lua/entities/gmod_wire_expression2/core/custom/squad_menu.lua @@ -0,0 +1,59 @@ +E2Lib.RegisterExtension( "squad_menu", true, "Add player functions related to the Squad Menu" ) + +local function ValidatePlayer( self, ent ) + if not IsValid( ent ) then self:throw( "Invalid entity!", 0 ) end + if not ent:IsPlayer() then self:throw( "Not a player entity!", 0 ) end +end + +__e2setcost( 5 ) + +e2function number entity:isSquadMember() + ValidatePlayer( self, this ) + return this:GetSquadID() == -1 and 0 or 1 +end + +e2function number entity:getSquadID() + ValidatePlayer( self, this ) + return this:GetSquadID() +end + +e2function number doesSquadExist( number id ) + return SquadMenu:GetSquad( id ) == nil and 0 or 1 +end + +e2function string getSquadName( number id ) + local squad = SquadMenu:GetSquad( id ) + return squad and squad.name or "" +end + +e2function vector getSquadColor( number id ) + local squad = SquadMenu:GetSquad( id ) + return squad and Vector( squad.r, squad.g, squad.b ) or Vector() +end + +e2function number getSquadMemberCount( number id ) + local squad = SquadMenu:GetSquad( id ) + if not squad then return 0 end + + local _, count = squad:GetActiveMembers() + return count +end + +e2function array getSquadMembers( number id ) + local squad = SquadMenu:GetSquad( id ) + if not squad then return {} end + + local members = squad:GetActiveMembers() + return members +end + +e2function array getAllSquadIDs() + local all, i = {}, 0 + + for id, _ in pairs( SquadMenu.squads ) do + i = i + 1 + all[i] = id + end + + return all +end diff --git a/lua/includes/modules/styled_theme.lua b/lua/includes/modules/styled_theme.lua new file mode 100644 index 0000000..bd97200 --- /dev/null +++ b/lua/includes/modules/styled_theme.lua @@ -0,0 +1,681 @@ +--[[ + StyledStrike's VGUI theme utilities + + A collection of functions to create common + UI panels and to apply a custom theme to them. +]] + +StyledTheme = StyledTheme or {} + +--[[ + Setup color constants +]] +do + StyledTheme.colors = StyledTheme.colors or {} + + local colors = StyledTheme.colors or {} + + colors.accent = Color( 56, 113, 179 ) + colors.panelBackground = Color( 46, 46, 46, 240 ) + colors.panelDisabledBackground = Color( 90, 90, 90, 255 ) + colors.scrollBackground = Color( 0, 0, 0, 200 ) + + colors.labelText = Color( 255, 255, 255, 255 ) + colors.labelTextDisabled = Color( 180, 180, 180, 255 ) + + colors.buttonHover = Color( 150, 150, 150, 50 ) + colors.buttonPress = colors.accent + colors.buttonBorder = Color( 32, 32, 32, 255 ) + colors.buttonText = Color( 255, 255, 255, 255 ) + colors.buttonTextDisabled = Color( 180, 180, 180, 255 ) + + colors.entryBackground = Color( 20, 20, 20, 255 ) + colors.entryBorder = Color( 80, 80, 80, 255 ) + colors.entryHighlight = colors.accent + colors.entryPlaceholder = Color( 150, 150, 150, 255 ) + colors.entryText = Color( 255, 255, 255, 255 ) +end + +--[[ + Setup dimensions +]] +StyledTheme.dimensions = StyledTheme.dimensions or {} + +hook.Add( "StyledTheme_OnResolutionChange", "StyledTheme.UpdateDimensions", function() + local dimensions = StyledTheme.dimensions + local ScaleSize = StyledTheme.ScaleSize + + dimensions.framePadding = ScaleSize( 10 ) + dimensions.frameButtonSize = ScaleSize( 36 ) + + dimensions.buttonHeight = ScaleSize( 40 ) + dimensions.headerHeight = ScaleSize( 32 ) + + dimensions.scrollBarWidth = ScaleSize( 16 ) + dimensions.scrollPadding = ScaleSize( 8 ) + + dimensions.formPadding = ScaleSize( 20 ) + dimensions.formSeparator = ScaleSize( 6 ) + dimensions.formLabelWidth = ScaleSize( 300 ) + + dimensions.menuPadding = ScaleSize( 6 ) + dimensions.indicatorSize = ScaleSize( 20 ) +end ) + +--[[ + Setup fonts +]] +StyledTheme.BASE_FONT_NAME = "Roboto" +StyledTheme.fonts = StyledTheme.fonts or {} + +function StyledTheme.RegisterFont( name, screenSize, data ) + data = data or {} + + data.screenSize = screenSize + data.font = data.font or StyledTheme.BASE_FONT_NAME + data.extended = true + + StyledTheme.fonts[name] = data + StyledTheme.forceUpdateResolution = true +end + +StyledTheme.RegisterFont( "StyledTheme_Small", 0.018, { + weight = 500, +} ) + +StyledTheme.RegisterFont( "StyledTheme_Tiny", 0.013, { + weight = 500, +} ) + +hook.Add( "StyledTheme_OnResolutionChange", "StyledTheme.UpdateFonts", function( _, screenH ) + for name, data in pairs( StyledTheme.fonts ) do + data.size = math.floor( screenH * data.screenSize ) + surface.CreateFont( name, data ) + end +end ) + +--[[ + Watch for changes in screen resolution +]] +do + local screenW, screenH = ScrW(), ScrH() + local Floor = math.floor + + --- Scales the given size (in pixels) from a 1080p resolution to + --- the resolution currently being used by the game. + function StyledTheme.ScaleSize( size ) + return Floor( ( size / 1080 ) * screenH ) + end + + local function UpdateResolution() + screenW, screenH = ScrW(), ScrH() + StyledTheme.forceUpdateResolution = false + hook.Run( "StyledTheme_OnResolutionChange", screenW, screenH ) + end + + -- Only update resolution on gamemode initialization. + hook.Add( "Initialize", "StyledTheme.UpdateResolution", UpdateResolution ) + + local ScrW, ScrH = ScrW, ScrH + + timer.Create( "StyledTheme.CheckResolution", 2, 0, function() + if ScrW() ~= screenW or ScrH() ~= screenH or StyledTheme.forceUpdateResolution then + UpdateResolution() + end + end ) +end + +--[[ + Misc. utility functions +]] +do + --- Gets a localized language string, with the first character being in uppercase. + function StyledTheme.GetUpperLanguagePhrase( text ) + text = language.GetPhrase( text ) + return text:sub( 1, 1 ):upper() .. text:sub( 2 ) + end + + local SetDrawColor = surface.SetDrawColor + local DrawRect = surface.DrawRect + + --- Draw box, using the specified background color. + --- It allows overriding the alpha while keeping the supplied color table intact. + function StyledTheme.DrawRect( x, y, w, h, color, alpha ) + alpha = alpha or 1 + + SetDrawColor( color.r, color.g, color.b, color.a * alpha ) + DrawRect( x, y, w, h ) + end + + local SetMaterial = surface.SetMaterial + local MAT_BLUR = Material( "pp/blurscreen" ) + + --- Blur the background of a panel. + function StyledTheme.BlurPanel( panel, alpha, density ) + SetDrawColor( 255, 255, 255, alpha or panel:GetAlpha() ) + SetMaterial( MAT_BLUR ) + + MAT_BLUR:SetFloat( "$blur", density or 4 ) + MAT_BLUR:Recompute() + + render.UpdateScreenEffectTexture() + + local x, y = panel:LocalToScreen( 0, 0 ) + surface.DrawTexturedRect( -x, -y, ScrW(), ScrH() ) + end + + local cache = {} + + -- Get a material given a path to a material or .png file. + function StyledTheme.GetMaterial( path ) + if cache[path] then + return cache[path] + end + + cache[path] = Material( path, "smooth ignorez" ) + + return cache[path] + end + + local GetMaterial = StyledTheme.GetMaterial + local DrawTexturedRect = surface.DrawTexturedRect + local COLOR_WHITE = Color( 255, 255, 255, 255 ) + + --- Draw a icon, using the specified image file path and color. + --- It allows overriding the alpha while keeping the supplied color table intact. + function StyledTheme.DrawIcon( path, x, y, w, h, alpha, color ) + color = color or COLOR_WHITE + alpha = alpha or 1 + + SetMaterial( GetMaterial( path ) ) + SetDrawColor( color.r, color.g, color.b, 255 * alpha ) + DrawTexturedRect( x, y, w, h ) + end +end + +--[[ + Utility function to apply the theme to existing VGUI panels +]] +do + local ClassFunctions = {} + + function StyledTheme.Apply( panel, classOverride ) + local funcs = ClassFunctions[classOverride or panel.ClassName] + if not funcs then return end + + if funcs.Prepare then + funcs.Prepare( panel ) + end + + if funcs.Paint then + panel.Paint = funcs.Paint + end + + if funcs.UpdateColours then + panel.UpdateColours = funcs.UpdateColours + end + + if funcs.Close then + panel.Close = funcs.Close + end + end + + local colors = StyledTheme.colors + local dimensions = StyledTheme.dimensions + local DrawRect = StyledTheme.DrawRect + + ClassFunctions["DLabel"] = { + Prepare = function( self ) + self:SetColor( colors.labelText ) + self:SetFont( "StyledTheme_Small" ) + end + } + + ClassFunctions["DPanel"] = { + Paint = function( self, w, h ) + DrawRect( 0, 0, w, h, self:GetBackgroundColor() or colors.panelBackground ) + end + } + + local function CustomMenuAdd( self, class ) + local pnl = self:OriginalAdd( class ) + + if class == "DButton" then + StyledTheme.Apply( pnl ) + + timer.Simple( 0, function() + if not IsValid( pnl ) then return end + + pnl:SetPaintBackground( true ) + pnl:SizeToContentsX( StyledTheme.ScaleSize( 20 ) ) + pnl:DockMargin( 0, 0, dimensions.menuPadding, 0 ) + end ) + end + + return pnl + end + + ClassFunctions["DMenuBar"] = { + Prepare = function( self ) + self:SetTall( dimensions.buttonHeight ) + self:DockMargin( 0, 0, 0, 0 ) + self:DockPadding( dimensions.menuPadding, dimensions.menuPadding, dimensions.menuPadding, dimensions.menuPadding ) + + self.OriginalAdd = self.Add + self.Add = CustomMenuAdd + end, + Paint = function( self, w, h ) + DrawRect( 0, 0, w, h, self:GetBackgroundColor() or colors.accent ) + end + } + + local Lerp = Lerp + local FrameTime = FrameTime + + ClassFunctions["DButton"] = { + Prepare = function( self ) + self:SetFont( "StyledTheme_Small" ) + self:SetTall( dimensions.buttonHeight ) + self.animHover = 0 + self.animPress = 0 + end, + + Paint = function( self, w, h ) + local dt = FrameTime() * 10 + local enabled = self:IsEnabled() + + self.animHover = Lerp( dt, self.animHover, ( enabled and self.Hovered ) and 1 or 0 ) + self.animPress = Lerp( dt, self.animPress, ( enabled and ( self:IsDown() or self.m_bSelected ) ) and 1 or 0 ) + + DrawRect( 0, 0, w, h, ( self.isToggle and self.isChecked ) and colors.buttonPress or colors.buttonBorder ) + DrawRect( 1, 1, w - 2, h - 2, enabled and colors.panelBackground or colors.panelDisabledBackground ) + DrawRect( 1, 1, w - 2, h - 2, colors.buttonHover, self.animHover ) + DrawRect( 1, 1, w - 2, h - 2, colors.buttonPress, self.animPress ) + end, + + UpdateColours = function( self ) + if self:IsEnabled() then + self:SetTextStyleColor( colors.buttonText ) + else + self:SetTextStyleColor( colors.buttonTextDisabled ) + end + end + } + + ClassFunctions["DBinder"] = ClassFunctions["DButton"] + + ClassFunctions["DTextEntry"] = { + Prepare = function( self ) + self:SetFont( "StyledTheme_Small" ) + self:SetTall( dimensions.buttonHeight ) + self:SetDrawBorder( false ) + self:SetPaintBackground( false ) + + self:SetTextColor( colors.entryText ) + self:SetCursorColor( colors.entryText ) + self:SetHighlightColor( colors.entryHighlight ) + self:SetPlaceholderColor( colors.entryPlaceholder ) + end, + + Paint = function( self, w, h ) + local enabled = self:IsEnabled() + + DrawRect( 0, 0, w, h, ( self:IsEditing() and enabled ) and colors.entryHighlight or colors.entryBorder ) + DrawRect( 1, 1, w - 2, h - 2, enabled and colors.entryBackground or colors.panelDisabledBackground ) + + derma.SkinHook( "Paint", "TextEntry", self, w, h ) + end + } + + ClassFunctions["DComboBox"] = { + Prepare = function( self ) + self:SetFont( "StyledTheme_Small" ) + self:SetTall( dimensions.buttonHeight ) + self:SetTextColor( colors.entryText ) + self.animHover = 0 + end, + + Paint = function( self, w, h ) + local dt = FrameTime() * 10 + local enabled = self:IsEnabled() + + self.animHover = Lerp( dt, self.animHover, ( enabled and self.Hovered ) and 1 or 0 ) + + DrawRect( 0, 0, w, h, ( self:IsMenuOpen() and enabled ) and colors.entryHighlight or colors.buttonBorder ) + DrawRect( 1, 1, w - 2, h - 2, enabled and colors.panelBackground or colors.panelDisabledBackground ) + DrawRect( 1, 1, w - 2, h - 2, colors.buttonHover, self.animHover ) + end + } + + ClassFunctions["DNumSlider"] = { + Prepare = function( self ) + StyledTheme.Apply( self.TextArea ) + StyledTheme.Apply( self.Label ) + end + } + + ClassFunctions["DScrollPanel"] = { + Prepare = function( self ) + StyledTheme.Apply( self.VBar ) + + local padding = dimensions.scrollPadding + self.pnlCanvas:DockPadding( padding, padding, padding, padding ) + self:SetPaintBackground( true ) + end, + + Paint = function( self, w, h ) + if self:GetPaintBackground() then + DrawRect( 0, 0, w, h, colors.scrollBackground ) + end + end + } + + local Clamp = math.Clamp + + local function AddScroll( self, delta ) + local oldScroll = self.animTargetScroll or self:GetScroll() + local newScroll = Clamp( oldScroll + delta * 40, 0, self.CanvasSize ) + + if oldScroll == newScroll then + return false + end + + self:Stop() + self.animTargetScroll = newScroll + + local anim = self:NewAnimation( 0.4, 0, 0.25, function( _, pnl ) + pnl.animTargetScroll = nil + end ) + + anim.StartPos = oldScroll + anim.TargetPos = newScroll + + anim.Think = function( a, pnl, fraction ) + pnl:SetScroll( Lerp( fraction, a.StartPos, a.TargetPos ) ) + end + + return true + end + + local function DrawGrip( self, w, h ) + local dt = FrameTime() * 10 + + self.animHover = Lerp( dt, self.animHover, self.Hovered and 1 or 0 ) + self.animPress = Lerp( dt, self.animPress, self.Depressed and 1 or 0 ) + + DrawRect( 0, 0, w, h, colors.buttonBorder ) + DrawRect( 1, 1, w - 2, h - 2, colors.panelBackground ) + DrawRect( 1, 1, w - 2, h - 2, colors.buttonHover, self.animHover ) + DrawRect( 1, 1, w - 2, h - 2, colors.buttonPress, self.animPress ) + end + + ClassFunctions["DVScrollBar"] = { + Prepare = function( self ) + self:SetWide( dimensions.scrollBarWidth ) + self:SetHideButtons( true ) + self.AddScroll = AddScroll + + self.btnGrip.animHover = 0 + self.btnGrip.animPress = 0 + self.btnGrip.Paint = DrawGrip + end, + + Paint = function( _, w, h ) + DrawRect( 0, 0, w, h, colors.scrollBackground ) + end + } + + local function FrameSlideAnim( anim, panel, fraction ) + if not anim.StartPos then + anim.StartPos = Vector( panel.x, panel.y + anim.StartOffset, 0 ) + anim.TargetPos = Vector( panel.x, panel.y + anim.EndOffset, 0 ) + end + + local pos = LerpVector( fraction, anim.StartPos, anim.TargetPos ) + panel:SetPos( pos.x, pos.y ) + panel:SetAlpha( 255 * Lerp( fraction, anim.StartAlpha, anim.EndAlpha ) ) + end + + local function FramePerformLayout( self, w ) + local padding = dimensions.framePadding + local buttonSize = dimensions.frameButtonSize + + self.btnClose:SetSize( buttonSize, buttonSize ) + self.btnClose:SetPos( w - self.btnClose:GetWide() - padding, padding ) + + local iconMargin = 0 + + if IsValid( self.imgIcon ) then + local iconSize = buttonSize * 0.6 + + self.imgIcon:SetPos( padding, padding + ( buttonSize * 0.5 ) - ( iconSize * 0.5 ) ) + self.imgIcon:SetSize( iconSize, iconSize ) + + iconMargin = iconSize + padding * 0.5 + end + + self.lblTitle:SetPos( padding + iconMargin, padding ) + self.lblTitle:SetSize( w - ( padding * 2 ) - iconMargin, buttonSize ) + end + + ClassFunctions["DFrame"] = { + Prepare = function( self ) + self._OriginalClose = self.Close + self.PerformLayout = FramePerformLayout + + StyledTheme.Apply( self.btnClose ) + StyledTheme.Apply( self.lblTitle ) + + local padding = dimensions.framePadding + local buttonSize = dimensions.frameButtonSize + + self:DockPadding( padding, buttonSize + padding * 2, padding, padding ) + self.btnClose:SetText( "X" ) + + if IsValid( self.btnMaxim ) then + self.btnMaxim:Remove() + end + + if IsValid( self.btnMinim ) then + self.btnMinim:Remove() + end + + local anim = self:NewAnimation( 0.4, 0, 0.25 ) + anim.StartOffset = -80 + anim.EndOffset = 0 + anim.StartAlpha = 0 + anim.EndAlpha = 1 + anim.Think = FrameSlideAnim + end, + + Close = function( self ) + self:SetMouseInputEnabled( false ) + self:SetKeyboardInputEnabled( false ) + + if self.OnStartClosing then + self.OnStartClosing() + end + + local anim = self:NewAnimation( 0.2, 0, 0.5, function() + self:_OriginalClose() + end ) + + anim.StartOffset = 0 + anim.EndOffset = -80 + anim.StartAlpha = 1 + anim.EndAlpha = 0 + anim.Think = FrameSlideAnim + end, + + Paint = function( self, w, h ) + if self.m_bBackgroundBlur then + Derma_DrawBackgroundBlur( self, self.m_fCreateTime ) + else + StyledTheme.BlurPanel( self ) + end + + DrawRect( 0, 0, w, h, colors.panelBackground, self:GetAlpha() / 255 ) + end + } +end + +--[[ + Utility functions to create "form" panels. +]] +do + local colors = StyledTheme.colors + local dimensions = StyledTheme.dimensions + + function StyledTheme.CreateFormHeader( parent, text, mtop, mbottom ) + mtop = mtop or dimensions.formSeparator + mbottom = mbottom or dimensions.formSeparator + + local panel = vgui.Create( "DPanel", parent ) + panel:SetTall( dimensions.headerHeight ) + panel:Dock( TOP ) + panel:DockMargin( -dimensions.formPadding, mtop, -dimensions.formPadding, mbottom ) + panel:SetBackgroundColor( colors.scrollBackground ) + + StyledTheme.Apply( panel ) + + local label = vgui.Create( "DLabel", panel ) + label:SetText( text ) + label:SetContentAlignment( 5 ) + label:SizeToContents() + label:Dock( FILL ) + + StyledTheme.Apply( label ) + + return panel + end + + function StyledTheme.CreateFormLabel( parent, text ) + local label = vgui.Create( "DLabel", parent ) + label:Dock( TOP ) + label:DockMargin( 0, 0, 0, dimensions.formSeparator ) + label:SetText( text ) + label:SetTall( dimensions.buttonHeight ) + + StyledTheme.Apply( label ) + + return label + end + + function StyledTheme.CreateFormButton( parent, label, callback ) + local button = vgui.Create( "DButton", parent ) + button:SetText( label ) + button:Dock( TOP ) + button:DockMargin( 0, 0, 0, dimensions.formSeparator ) + button.DoClick = callback + + StyledTheme.Apply( button ) + + return button + end + + function StyledTheme.CreateFormToggle( parent, label, isChecked, callback ) + local button = vgui.Create( "DButton", parent ) + button:SetIcon( isChecked and "icon16/accept.png" or "icon16/cancel.png" ) + button:SetText( label ) + button:Dock( TOP ) + button:DockMargin( 0, 0, 0, dimensions.formSeparator ) + button.isToggle = true + button.isChecked = isChecked + + StyledTheme.Apply( button ) + + button.SetChecked = function( s, value ) + value = value == true + s.isChecked = value + button:SetIcon( value and "icon16/accept.png" or "icon16/cancel.png" ) + callback( value ) + end + + button.DoClick = function( s ) + s:SetChecked( not s.isChecked ) + end + + return button + end + + function StyledTheme.CreateFormSlider( parent, label, default, min, max, decimals, callback ) + local slider = vgui.Create( "DNumSlider", parent ) + slider:SetText( label ) + slider:SetMin( min ) + slider:SetMax( max ) + slider:SetValue( default ) + slider:SetDecimals( decimals ) + slider:Dock( TOP ) + slider:DockMargin( 0, 0, 0, dimensions.formSeparator ) + + slider.PerformLayout = function( s ) + s.Label:SetWide( dimensions.formLabelWidth ) + end + + StyledTheme.Apply( slider ) + + slider.OnValueChanged = function( _, value ) + callback( decimals == 0 and math.floor( value ) or math.Round( value, decimals ) ) + end + + return slider + end + + function StyledTheme.CreateFormCombo( parent, text, options, defaultIndex, callback ) + local panel = vgui.Create( "DPanel", parent ) + panel:SetTall( dimensions.buttonHeight ) + panel:SetPaintBackground( false ) + panel:Dock( TOP ) + panel:DockMargin( 0, 0, 0, dimensions.formSeparator ) + + local label = vgui.Create( "DLabel", panel ) + label:Dock( LEFT ) + label:DockMargin( 0, 0, 0, 0 ) + label:SetText( text ) + label:SetWide( dimensions.formLabelWidth ) + + StyledTheme.Apply( label ) + + local combo = vgui.Create( "DComboBox", panel ) + combo:Dock( FILL ) + combo:SetSortItems( false ) + + for _, v in ipairs( options ) do + combo:AddChoice( v ) + end + + if defaultIndex then + combo:ChooseOptionID( defaultIndex ) + end + + StyledTheme.Apply( combo ) + + combo.OnSelect = function( _, index ) + callback( index ) + end + end + + function StyledTheme.CreateFormBinder( parent, text, defaultKey ) + local panel = vgui.Create( "DPanel", parent ) + panel:SetTall( dimensions.buttonHeight ) + panel:SetPaintBackground( false ) + panel:Dock( TOP ) + panel:DockMargin( 0, 0, 0, dimensions.formSeparator ) + + local label = vgui.Create( "DLabel", panel ) + label:Dock( LEFT ) + label:DockMargin( 0, 0, 0, 0 ) + label:SetText( text ) + label:SetWide( dimensions.formLabelWidth ) + + StyledTheme.Apply( label ) + + local binder = vgui.Create( "DBinder", panel ) + binder:SetValue( defaultKey or KEY_NONE ) + binder:Dock( FILL ) + + StyledTheme.Apply( binder ) + + return binder + end +end diff --git a/lua/includes/modules/styled_theme_tabbed_frame.lua b/lua/includes/modules/styled_theme_tabbed_frame.lua new file mode 100644 index 0000000..8be3806 --- /dev/null +++ b/lua/includes/modules/styled_theme_tabbed_frame.lua @@ -0,0 +1,160 @@ +--[[ + StyledStrike's VGUI theme utilities + + This file adds a new panel class: the tabbed frame +]] + +if not StyledTheme then + error( "styled_theme.lua must be included first!" ) +end + +local colors = StyledTheme.colors +local dimensions = StyledTheme.dimensions + +local TAB_BUTTON = {} + +AccessorFunc( TAB_BUTTON, "iconPath", "Icon", FORCE_STRING ) + +function TAB_BUTTON:Init() + self:SetCursor( "hand" ) + self:SetIcon( "icon16/bricks.png" ) + + self.isSelected = false + self.notificationCount = 0 + self.animHover = 0 +end + +function TAB_BUTTON:OnMousePressed( keyCode ) + if keyCode == MOUSE_LEFT then + self:GetParent():GetParent():SetActiveTab( self.tab ) + end +end + +local Lerp = Lerp +local FrameTime = FrameTime +local DrawRect = StyledTheme.DrawRect +local DrawIcon = StyledTheme.DrawIcon + +local COLOR_INDICATOR = Color( 200, 0, 0, 255 ) + +function TAB_BUTTON:Paint( w, h ) + self.animHover = Lerp( FrameTime() * 10, self.animHover, self:IsHovered() and 1 or 0 ) + + DrawRect( 0, 0, w, h, colors.buttonBorder ) + DrawRect( 1, 1, w - 2, h - 2, colors.panelBackground ) + DrawRect( 1, 1, w - 2, h - 2, colors.buttonHover, self.animHover ) + + if self.isSelected then + DrawRect( 1, 1, w - 2, h - 2, colors.buttonPress ) + end + + local iconSize = math.floor( math.max( w, h ) * 0.5 ) + DrawIcon( self.iconPath, ( w * 0.5 ) - ( iconSize * 0.5 ), ( h * 0.5 ) - ( iconSize * 0.5 ), iconSize, iconSize ) + + if self.notificationCount > 0 then + local size = dimensions.indicatorSize + local margin = math.floor( h * 0.05 ) + local x = w - size - margin + local y = h - size - margin + + draw.RoundedBox( size * 0.5, x, y, size, size, COLOR_INDICATOR ) + draw.SimpleText( self.notificationCount, "StyledTheme_Tiny", x + size * 0.5, y + size * 0.5, colors.buttonText, 1, 1 ) + end +end + +vgui.Register( "Styled_TabButton", TAB_BUTTON, "DPanel" ) + +local TABBED_FRAME = {} +local ScaleSize = StyledTheme.ScaleSize + +function TABBED_FRAME:Init() + StyledTheme.Apply( self, "DFrame" ) + + local w = ScaleSize( 850 ) + local h = ScaleSize( 600 ) + + self:SetSize( w, h ) + self:SetSizable( true ) + self:SetDraggable( true ) + self:SetDeleteOnClose( true ) + self:SetScreenLock( true ) + self:SetMinWidth( w ) + self:SetMinHeight( h ) + + self.tabList = vgui.Create( "DPanel", self ) + self.tabList:SetWide( ScaleSize( 64 ) ) + self.tabList:Dock( LEFT ) + self.tabList:DockPadding( 0, 0, 0, 0 ) + self.tabList:SetPaintBackground( false ) + --StyledTheme.Apply( self.tabList ) + + self.contentContainer = vgui.Create( "DPanel", self ) + self.contentContainer:Dock( FILL ) + self.contentContainer:DockMargin( ScaleSize( 4 ), 0, 0, 0 ) + self.contentContainer:DockPadding( 0, 0, 0, 0 ) + self.contentContainer:SetPaintBackground( false ) + + self.tabs = {} +end + +function TABBED_FRAME:AddTab( icon, tooltip, panelClass ) + panelClass = panelClass or "DScrollPanel" + + local tab = {} + + tab.button = vgui.Create( "Styled_TabButton", self.tabList ) + tab.button:SetIcon( icon ) + tab.button:SetTall( ScaleSize( 64 ) ) + tab.button:SetTooltip( tooltip ) + tab.button:Dock( TOP ) + tab.button:DockMargin( 0, 0, 0, 2 ) + tab.button.tab = tab + + tab.panel = vgui.Create( panelClass, self.contentContainer ) + tab.panel:Dock( FILL ) + tab.panel:DockMargin( 0, 0, 0, 0 ) + tab.panel:DockPadding( 0, 0, 0, 0 ) + tab.panel:SetVisible( false ) + + StyledTheme.Apply( tab.panel ) + + if panelClass == "DScrollPanel" then + local padding = dimensions.formPadding + tab.panel.pnlCanvas:DockPadding( padding, 0, padding, padding ) + end + + self.tabs[#self.tabs + 1] = tab + + if #self.tabs == 1 then + self:SetActiveTab( tab ) + end + + return tab.panel +end + +function TABBED_FRAME:SetActiveTab( tab ) + for i, t in ipairs( self.tabs ) do + local isThisOne = t == tab + + t.button.isSelected = isThisOne + t.panel:SetVisible( isThisOne ) + + if isThisOne then + self.lastTabIndex = i + end + end +end + +function TABBED_FRAME:SetActiveTabByIndex( index ) + if self.tabs[index] then + self:SetActiveTab( self.tabs[index] ) + end +end + +function TABBED_FRAME:SetTabNotificationCountByIndex( index, count ) + if self.tabs[index] then + self.tabs[index].button.notificationCount = count + end +end + +vgui.Register( "Styled_TabbedFrame", TABBED_FRAME, "DFrame" ) diff --git a/lua/squad_menu/client/config.lua b/lua/squad_menu/client/config.lua new file mode 100644 index 0000000..73d98f9 --- /dev/null +++ b/lua/squad_menu/client/config.lua @@ -0,0 +1,63 @@ +local Config = SquadMenu.Config or {} + +SquadMenu.Config = Config + +function Config:Reset() + self.showMembers = true + self.showRings = true + self.showHalos = false + self.enableSounds = true + + self.nameDistance = 3000 + self.haloDistance = 8000 + self.pingKey = KEY_B +end + +function Config:Load() + self:Reset() + + local data = file.Read( SquadMenu.DATA_FILE, "DATA" ) + if not data then return end + + data = SquadMenu.JSONToTable( data ) + + self.showMembers = data.showMembers == true + self.showRings = data.showRings == true + self.showHalos = data.showHalos == true + self.enableSounds = data.enableSounds == true + + self.nameDistance = SquadMenu.ValidateNumber( data.nameDistance, 3000, 500, 50000 ) + self.haloDistance = SquadMenu.ValidateNumber( data.haloDistance, 8000, 500, 50000 ) + self.pingKey = math.floor( SquadMenu.ValidateNumber( data.pingKey, KEY_B, 1, 159 ) ) +end + +function Config:Save( immediate ) + if not immediate then + -- avoid spamming the file system + timer.Remove( "SquadMenu.SaveConfigDelay" ) + timer.Create( "SquadMenu.SaveConfigDelay", 0.5, 1, function() + self:Save( true ) + end ) + + return + end + + local path = SquadMenu.DATA_FILE + + local data = SquadMenu.TableToJSON( { + showMembers = self.showMembers, + showRings = self.showRings, + showHalos = self.showHalos, + enableSounds = self.enableSounds, + pingKey = self.pingKey + } ) + + SquadMenu.PrintF( "%s: writing %s", path, string.NiceSize( string.len( data ) ) ) + file.Write( path, data ) + + if SquadMenu.mySquad then + SquadMenu:UpdateMembersHUD() + end +end + +Config:Load() diff --git a/lua/squad_menu/client/hud.lua b/lua/squad_menu/client/hud.lua new file mode 100644 index 0000000..3a499b2 --- /dev/null +++ b/lua/squad_menu/client/hud.lua @@ -0,0 +1,344 @@ +local squad +local nameDistance, haloDistance + +function SquadMenu:RemoveMembersHUD() + if self.membersPanel then + self.membersPanel:Remove() + end + + self.membersPanel = nil + + hook.Remove( "PrePlayerDraw", "SquadMenu.DrawRing" ) + hook.Remove( "PreDrawHalos", "SquadMenu.DrawHalos" ) + hook.Remove( "HUDPaint", "SquadMenu.DrawHUD" ) + hook.Remove( "HUDDrawTargetID", "SquadMenu.HideTargetInfo" ) + hook.Remove( "PlayerButtonDown", "SquadMenu.PingKey" ) +end + +function SquadMenu:UpdateMembersHUD() + local config = self.Config + + squad = self.mySquad + nameDistance = config.nameDistance * config.nameDistance + haloDistance = config.haloDistance * config.haloDistance + + if config.showRings and self.mySquad.enableRings then + hook.Add( "PrePlayerDraw", "SquadMenu.DrawRing", self.DrawRing ) + else + hook.Remove( "PrePlayerDraw", "SquadMenu.DrawRing" ) + end + + if config.showHalos then + hook.Add( "PreDrawHalos", "SquadMenu.DrawHalos", self.DrawHalos ) + else + hook.Remove( "PreDrawHalos", "SquadMenu.DrawHalos" ) + end + + hook.Add( "HUDPaint", "SquadMenu.DrawHUD", self.DrawHUD ) + hook.Add( "HUDDrawTargetID", "SquadMenu.HideTargetInfo", self.HideTargetInfo ) + hook.Add( "PlayerButtonDown", "SquadMenu.PingKey", self.PingKey ) + + if self.membersPanel then + self.membersPanel:SetVisible( config.showMembers ) + return + end + + local panel = vgui.Create( "DPanel" ) + panel:SetVisible( config.showMembers ) + panel:SetPaintBackground( false ) + panel:ParentToHUD() + panel._OriginalInvalidateLayout = panel.InvalidateLayout + + panel.InvalidateLayout = function( s, layoutNow ) + local screenW, screenH = ScrW(), ScrH() + + local childCount = #s:GetChildren() + local childH = math.Round( screenH * 0.04 ) + local childOffset = math.max( 1, math.Round( screenH * 0.002 ) ) + + local w = screenH * 0.22 + local h = ( childH + childOffset ) * childCount + + s.childH = childH + s.childOffset = childOffset + s:SetSize( w, h ) + + local position = math.Clamp( SquadMenu.GetMembersPosition(), 1, 9 ) + local offset = screenH * 0.02 + local x, y + + if position == 1 or position == 4 or position == 7 then + -- left + x = offset + + elseif position == 2 or position == 5 or position == 8 then + -- center + x = ( screenW * 0.5 ) - ( w * 0.5 ) + else + -- right + x = screenW - w - offset + end + + if position == 7 or position == 8 or position == 9 then + -- top + y = offset + + elseif position == 4 or position == 5 or position == 6 then + -- center + y = ( screenH * 0.5 ) - ( h * 0.5 ) + else + -- bottom + y = screenH - h - offset + end + + s:SetPos( x, y ) + s:_OriginalInvalidateLayout( layoutNow ) + end + + panel.PerformLayout = function( s, w ) + local children = s:GetChildren() + if #children == 0 then return end + + local offset = s.childOffset or 1 + local height = s.childH or 24 + local y = 0 + + for _, p in ipairs( children ) do + p:SetSize( w, height ) + p:SetPos( 0, y ) + + y = y + height + offset + end + end + + self.membersPanel = panel +end + +function SquadMenu:AddMemberToHUD( member ) + member.panel = vgui.Create( "Squad_MemberInfo", self.membersPanel ) + member.panel:SetPlayer( member.id, member.name, squad ) + + self.membersPanel:InvalidateLayout() +end + +function SquadMenu:RemoveMemberFromHUD( member ) + if member.panel then + member.panel:Remove() + member.panel = nil + end +end + +---------- + +local COLORS = { + WHITE = Color( 255, 255, 255, 255 ), + HEALTH = Color( 94, 253, 255, 255 ), + LOW_HEALTH = Color( 250, 20, 20, 255 ), + BOX_BG = Color( 0, 0, 0, 200 ) +} + +local SetColor = surface.SetDrawColor +local DrawRect = surface.DrawRect +local DrawOutlinedRect = surface.DrawOutlinedRect + +local DrawHealthBar = function( x, y, w, h, health, armor ) + if armor > 0 then + SetColor( 255, 255, 255, 255 ) + DrawOutlinedRect( x - 1, y - 1, ( w + 2 ) * armor, h + 2, 1 ) + end + + SetColor( 20, 20, 20, 255 ) + DrawRect( x, y, w, h ) + + x, y = x + 1, y + 1 + w, h = w - 2, h - 2 + + local color = health < 0.3 and COLORS.LOW_HEALTH or COLORS.HEALTH + + SetColor( color:Unpack() ) + DrawRect( x, y, w * health, h ) +end + +SquadMenu.DrawHealthBar = DrawHealthBar + +---------- + +local EyePos = EyePos +local Clamp = math.Clamp +local SetMaterial = surface.SetMaterial +local DrawTexturedRect = surface.DrawTexturedRect +local LocalPlayer = LocalPlayer +local PID = SquadMenu.GetPlayerId + +do + local Start3D2D = cam.Start3D2D + local End3D2D = cam.End3D2D + + local ringMaxDist = 3000 * 3000 + local ringAngle = Angle( 0, 0, 0 ) + local ringOffset = Vector( 0, 0, 5 ) + local ringMat = Material( "squad_menu/ring.png" ) + + SquadMenu.DrawRing = function( ply, flags ) + if flags == 1 then return end + if ply == LocalPlayer() then return end + if not squad.membersById[PID( ply )] then return end + + local pos = ply:GetPos() + local mult = Clamp( pos:DistToSqr( EyePos() ) / ringMaxDist, 0, 1 ) + local size = 300 + 1000 * mult + + Start3D2D( pos + ringOffset * mult, ringAngle, 0.08 ) + + SetMaterial( ringMat ) + SetColor( squad.color:Unpack() ) + DrawTexturedRect( -size * 0.5, -size * 0.5, size, size ) + + End3D2D() + end +end + +---------- + +local AllPlayersById = SquadMenu.AllPlayersById + +SquadMenu.DrawHalos = function() + local origin = EyePos() + local me = LocalPlayer() + local byId = AllPlayersById() + local i, t, dist = 0, {} + + for _, member in ipairs( squad.members ) do + local ply = byId[member.id] + + if ply and ply ~= me and ply:Alive() and not ply:IsDormant() then + dist = origin:DistToSqr( ply:EyePos() ) + + if dist < haloDistance then + i = i + 1 + t[i] = ply + end + end + end + + if i > 0 then + halo.Add( t, squad.color, 2, 2, 1, true, true ) + end +end + +---------- + +SquadMenu.HideTargetInfo = function() + local trace = util.TraceLine( util.GetPlayerTrace( LocalPlayer() ) ) + if not trace.Hit or not trace.HitNonWorld then return end + + local ply = trace.Entity + if not ply:IsPlayer() then return end + + if squad.membersById[PID( ply )] and EyePos():DistToSqr( ply:EyePos() ) < nameDistance then + return false + end +end + +---------- + +local DrawSimpleText = draw.SimpleText +local GetTextSize = surface.GetTextSize +local SetAlphaMultiplier = surface.SetAlphaMultiplier + +local function DrawTag( ply ) + local nick = ply:Nick() + local isAlive = ply:Alive() + + local pos = ply:EyePos():ToScreen() + local boxW, boxH = GetTextSize( nick ) + + local x = pos.x - boxW * 0.5 + local y = pos.y - boxH + + SetColor( COLORS.BOX_BG:Unpack() ) + DrawRect( x - 2, y - 2, boxW + 4, boxH + 6 ) + + if isAlive then + DrawSimpleText( nick, "SquadMenuInfo", pos.x, y, COLORS.WHITE, 1, 0 ) + DrawHealthBar( x - 2, y + boxH + 4, boxW + 4, 4, Clamp( ply:Health() / 100, 0, 1 ), ply:Armor() / 100 ) + else + DrawSimpleText( nick, "SquadMenuInfo", pos.x, y, COLORS.LOW_HEALTH, 1, 0 ) + end +end + +local DrawOutlinedText = draw.SimpleTextOutlined +local pingMat = Material( "squad_menu/ping.png", "smooth ignorez nocull" ) +local pings = SquadMenu.pings or {} + +SquadMenu.pings = pings + +SquadMenu.DrawHUD = function() + surface.SetFont( "SquadMenuInfo" ) + + -- Draw player tags + local origin = EyePos() + local me = LocalPlayer() + local byId = AllPlayersById() + local dist + + for _, member in ipairs( squad.members ) do + local ply = byId[member.id] + + if ply and ply ~= me and not ply:IsDormant() then + dist = origin:DistToSqr( ply:EyePos() ) + + if dist < nameDistance then + SetAlphaMultiplier( 1 - dist / nameDistance ) + DrawTag( ply ) + end + end + end + + -- Draw pings + local t = RealTime() + local sw, sh = ScrW(), ScrH() + local r, g, b = squad.color:Unpack() + local size = sh * 0.025 + local border = sh * 0.01 + + SetMaterial( pingMat ) + + for id, p in pairs( pings ) do + if t > p.start + p.lifetime then + pings[id] = nil + else + local showAnim = Clamp( ( t - p.start ) * 6, 0, 1 ) + local hideAnim = Clamp( p.start + p.lifetime - t, 0, 1 ) + + SetAlphaMultiplier( showAnim * hideAnim ) + + local pos = p.pos:ToScreen() + local x = Clamp( pos.x, border, sw - border ) + local y = Clamp( pos.y, sh * 0.05, sh - border ) + local iconY = y - size - ( size * ( 1 - showAnim ) ) + + SetColor( 0, 0, 0 ) + DrawTexturedRect( 1 + x - size * 0.5, 1 + iconY, size, size ) + + SetColor( r, g, b ) + DrawTexturedRect( x - size * 0.5, iconY, size, size ) + DrawOutlinedText( p.label, "SquadMenuInfo", x, y - size - sh * 0.006, squad.color, 1, 4, 1, color_black ) + end + end + + SetAlphaMultiplier( 1 ) +end + +SquadMenu.PingKey = function( ply, button ) + if ply ~= LocalPlayer() then return end + if not IsFirstTimePredicted() then return end + if button ~= SquadMenu.Config.pingKey then return end + + local tr = ply:GetEyeTrace() + if not tr.Hit then return end + + SquadMenu.StartCommand( SquadMenu.PING ) + net.WriteVector( tr.HitPos ) + net.SendToServer() +end diff --git a/lua/squad_menu/client/main.lua b/lua/squad_menu/client/main.lua new file mode 100644 index 0000000..f8ca2df --- /dev/null +++ b/lua/squad_menu/client/main.lua @@ -0,0 +1,380 @@ +function SquadMenu.GetLanguageText( id ) + return language.GetPhrase( "squad_menu." .. id ):Trim() +end + +function SquadMenu:PlayUISound( path ) + if self.Config.enableSounds then + sound.Play( path, Vector(), 0, 120, 0.75 ) + end +end + +local L = SquadMenu.GetLanguageText + +function SquadMenu.GlobalMessage( ... ) + chat.AddText( SquadMenu.THEME_COLOR, "[" .. L( "title" ) .. "] ", Color( 255, 255, 255 ), ... ) +end + +function SquadMenu.SquadMessage( ... ) + local squad = SquadMenu.mySquad + if not squad then return end + + local contents = { color_white, "[", squad.color, squad.name, color_white, "] ", ... } + + if CustomChat then + CustomChat:AddMessage( contents, "squad" ) + else + chat.AddText( unpack( contents ) ) + end +end + +function SquadMenu.LeaveMySquad( buttonToBlank, leaveNow ) + local squad = SquadMenu.mySquad + if not squad then return end + + if not leaveNow and squad.leaderId == SquadMenu.GetPlayerId( LocalPlayer() ) then + Derma_Query( L"leave_leader", L"leave_squad", L"yes", function() + SquadMenu.LeaveMySquad( buttonToBlank, true ) + end, L"no" ) + + return + end + + if IsValid( buttonToBlank ) then + buttonToBlank:SetEnabled( false ) + buttonToBlank:SetText( "..." ) + end + + SquadMenu.StartCommand( SquadMenu.LEAVE_SQUAD ) + net.SendToServer() +end + +--- If GMinimap is installed, update squad members' blips. +function SquadMenu:UpdatePlayerBlips( icon, color ) + if not self.mySquad then return end + + local me = LocalPlayer() + local byId = self.AllPlayersById() + + for _, member in ipairs( self.mySquad.members ) do + local ply = byId[member.id] + + if ply and ply ~= me then + ply:SetBlipIcon( icon ) + ply:SetBlipColor( color ) + end + end +end + +--- Set the current members of the local player's squad. +--- Updates the HUD and shows join/leave messages (if `printMessages` is `true`). +--- +--- `newMembers` is an array where items are also arrays +--- with a number (player id) and a string (player name). +function SquadMenu:SetMembers( newMembers, printMessages ) + local members = self.mySquad.members + local membersById = self.mySquad.membersById + local keep = {} + + -- Add new members that we do not have on our end + for _, m in ipairs( newMembers ) do + local id = m[1] + local member = { id = id, name = m[2] } + + keep[id] = true + + if not membersById[id] then + membersById[id] = member + members[#members + 1] = member + + self:AddMemberToHUD( member ) + + if printMessages then + self.SquadMessage( string.format( L"member_joined", member.name ) ) + end + end + end + + local byId = self.AllPlayersById() + + -- Remove members that we have locally but do not exist on `newMembers`. + -- Backwards loop because we use `table.remove` + for i = #members, 1, -1 do + local member = members[i] + local id = member.id + + if not keep[id] then + membersById[id] = nil + table.remove( members, i ) + + self:RemoveMemberFromHUD( member ) + + if printMessages then + self.SquadMessage( string.format( L"member_left", member.name ) ) + end + + local ply = byId[id] + + if IsValid( ply ) and GMinimap then + ply:SetBlipIcon( nil ) + ply:SetBlipColor( nil ) + end + end + end +end + +--- Set the local player's squad. +--- `data` is a table that comes from `squad:GetBasicInfo`. +function SquadMenu:SetupSquad( data ) + local squad = self.mySquad or { id = -1 } + local isUpdate = data.id == squad.id + + self.mySquad = squad + + squad.id = data.id + squad.name = data.name + squad.icon = data.icon + + squad.leaderId = data.leaderId + squad.leaderName = data.leaderName or "" + + squad.enableRings = data.enableRings + squad.friendlyFire = data.friendlyFire + squad.isPublic = data.isPublic + + squad.color = Color( data.r, data.g, data.b ) + + if CustomChat and squad.name then + CustomChat:CreateCustomChannel( "squad", squad.name, squad.icon ) + end + + if not isUpdate then + squad.requests = {} + squad.members = {} + squad.membersById = {} + + self:PlayUISound( "buttons/combine_button3.wav" ) + self.SquadMessage( L"squad_welcome", squad.color, " " .. squad.name ) + self.SquadMessage( L"chat_tip", " " .. table.concat( self.CHAT_PREFIXES, ", " ) ) + end + + self:UpdateMembersHUD() + self:SetMembers( data.members, isUpdate ) + + if IsValid( self.frame ) then + self:RequestSquadListUpdate() + self:UpdateSquadStatePanel() + self:UpdateRequestsPanel() + self:UpdateSquadMembersPanel() + self:UpdateSquadPropertiesPanel() + + self.frame:SetActiveTabByIndex( 3 ) -- squad members + end + + if GMinimap then + self:UpdatePlayerBlips( "gminimap/blips/npc_default.png", squad.color ) + + hook.Add( "CanSeePlayerBlip", "ShowSquadBlips", function( ply ) + if ply:GetSquadID() == squad.id then return true, 50000 end + end ) + end +end + +function SquadMenu:OnLeaveSquad( reason ) + if GMinimap then + self:UpdatePlayerBlips( nil, nil ) + hook.Remove( "CanSeePlayerBlip", "ShowSquadBlips" ) + end + + local reasonText = { + [self.LEAVE_REASON_DELETED] = "deleted_squad", + [self.LEAVE_REASON_KICKED] = "kicked_from_squad" + } + + if self.mySquad then + self.GlobalMessage( L( reasonText[reason] or "left_squad" ) ) + self:PlayUISound( "buttons/combine_button2.wav" ) + end + + self.mySquad = nil + self:RemoveMembersHUD() + + if IsValid( self.frame ) then + self:RequestSquadListUpdate() + self:UpdateSquadStatePanel() + self:UpdateRequestsPanel() + self:UpdateSquadMembersPanel() + self:UpdateSquadPropertiesPanel() + + if self.frame.lastTabIndex ~= 5 then -- not in settings + self.frame:SetActiveTabByIndex( 1 ) -- squad list + end + end + + if CustomChat then + CustomChat:RemoveCustomChannel( "squad" ) + end +end + +---------- + +local commands = {} + +commands[SquadMenu.SQUAD_LIST] = function() + SquadMenu:UpdateSquadList( SquadMenu.ReadTable() ) +end + +commands[SquadMenu.SETUP_SQUAD] = function() + local data = SquadMenu.ReadTable() + SquadMenu:SetupSquad( data ) +end + +commands[SquadMenu.LEAVE_SQUAD] = function() + local reason = net.ReadUInt( 3 ) + SquadMenu:OnLeaveSquad( reason ) +end + +commands[SquadMenu.REQUESTS_LIST] = function() + local squad = SquadMenu.mySquad + if not squad then return end + + local requests = squad.requests + + -- Remember which players have requested before + local alreadyRequested = {} + + for _, member in ipairs( requests ) do + alreadyRequested[member.id] = true + end + + -- Compare the new requests against what we already got + local requestsById = SquadMenu.ReadTable() + local newCount = 0 + + for id, name in pairs( requestsById ) do + if not alreadyRequested[id] then + -- This is a new request for us + requests[#requests + 1] = { id = id, name = name } + newCount = newCount + 1 + + SquadMenu.SquadMessage( string.format( L"request_message", name ) ) + end + end + + if newCount > 0 then + SquadMenu:PlayUISound( "buttons/combine_button1.wav" ) + end + + -- Remove requests we already got if they aren't on the new requests list + for i = #requests, 1, -1 do + local member = requests[i] + + if not requestsById[member.id] then + table.remove( requests, i ) + end + end + + SquadMenu:UpdateRequestsPanel() +end + +commands[SquadMenu.PING] = function() + local pos = net.ReadVector() + local label = net.ReadString() + local id = net.ReadString() + + local ping = SquadMenu.pings[id] + + if not ping then + ping = {} + end + + ping.pos = pos + ping.label = label + ping.start = RealTime() + ping.lifetime = 5 + + SquadMenu.pings[id] = ping + + if not SquadMenu.Config.enableSounds then return end + + local eyePos = EyePos() + local soundDir = pos - eyePos + soundDir:Normalize() + + sound.Play( "friends/friend_join.wav", eyePos + soundDir * 500, 100, 120, 1 ) +end + +commands[SquadMenu.BROADCAST_EVENT] = function() + local data = SquadMenu.ReadTable() + local event = data.event + + SquadMenu.PrintF( "Event received: %s", event ) + + if event == "open_menu" then + SquadMenu:OpenFrame() + + elseif event == "squad_position_changed" then + if SquadMenu.membersPanel then + SquadMenu.membersPanel:InvalidateLayout() + end + + elseif event == "player_joined_squad" then + local squad = SquadMenu.mySquad + if not squad then return end + + -- Remove this player from my requests list + local requests = squad.requests + + for i, member in ipairs( requests ) do + if data.playerId == member.id then + table.remove( requests, i ) + break + end + end + + SquadMenu:UpdateRequestsPanel() + + elseif event == "squad_created" or event == "squad_deleted" then + SquadMenu:RequestSquadListUpdate() + + if event == "squad_created" and data.name and SquadMenu.GetShowCreationMessage() then + local color = Color( data.r, data.g, data.b ) + SquadMenu.GlobalMessage( string.format( L"squad_created", data.leaderName ), color, " " .. data.name ) + end + + elseif event == "members_chat" then + local squad = SquadMenu.mySquad + if not squad then return end + + SquadMenu.SquadMessage( squad.color, data.senderName, color_white, ": ", data.text ) + end +end + +net.Receive( "squad_menu.command", function() + local cmd = net.ReadUInt( SquadMenu.COMMAND_SIZE ) + + if not commands[cmd] then + SquadMenu.PrintF( "Received a unknown network command! (%d)", cmd ) + return + end + + commands[cmd]( ply, ent ) +end ) + +concommand.Add( + "squad_menu", + function() SquadMenu:OpenFrame() end, + nil, + "Opens the squad menu." +) + +if engine.ActiveGamemode() == "sandbox" then + list.Set( + "DesktopWindows", + "SquadMenuDesktopIcon", + { + title = SquadMenu.GetLanguageText( "title" ), + icon = "materials/squad_menu/squad_menu.png", + init = function() SquadMenu:OpenFrame() end + } + ) +end diff --git a/lua/squad_menu/client/menu.lua b/lua/squad_menu/client/menu.lua new file mode 100644 index 0000000..80ffd8c --- /dev/null +++ b/lua/squad_menu/client/menu.lua @@ -0,0 +1,598 @@ +local PID = SquadMenu.GetPlayerId +local L = SquadMenu.GetLanguageText +local ScaleSize = StyledTheme.ScaleSize + +function SquadMenu:CloseFrame() + if IsValid( self.frame ) then + self.frame:Close() + end +end + +function SquadMenu:OpenFrame() + if IsValid( self.frame ) then + self:CloseFrame() + return + end + + local frame = vgui.Create( "Styled_TabbedFrame" ) + frame:SetTitle( L"title" ) + frame:Center() + frame:MakePopup() + + frame.OnClose = function() + self.frame = nil + end + + local h = ScaleSize( 550 ) + frame:SetTall( h ) + frame:SetMinHeight( h ) + + self.frame = frame + + local panels = {} + frame._panels = panels + + -- Squad state + local separation = ScaleSize( 4 ) + + panels.squadState = vgui.Create( "DPanel", frame ) + panels.squadState:SetTall( ScaleSize( 40 ) ) + panels.squadState:Dock( BOTTOM ) + panels.squadState:DockMargin( separation, separation, 0, 0 ) + panels.squadState:DockPadding( separation, separation, separation, separation ) + + -- Tabs + panels.squadList = frame:AddTab( "styledstrike/icons/bullet_list.png", L"tab.squad_list" ) + panels.squadProperties = frame:AddTab( "styledstrike/icons/flag_two_tone.png", L"tab.squad_properties", "DPanel" ) + panels.squadMembers = frame:AddTab( "styledstrike/icons/users.png", L"tab.squad_members", "DPanel" ) + panels.joinRequests = frame:AddTab( "styledstrike/icons/user_add.png", L"tab.join_requests", "DPanel" ) + panels.settings = frame:AddTab( "styledstrike/icons/cog.png", L"tab.settings" ) + + self:RequestSquadListUpdate() + self:UpdateSquadStatePanel() + self:UpdateRequestsPanel() + self:UpdateSquadMembersPanel() + self:UpdateSquadPropertiesPanel() + + local squad = self.mySquad + + if squad then + if #squad.members < 2 then + frame:SetActiveTabByIndex( 4 ) -- Join requests + else + frame:SetActiveTabByIndex( 3 ) -- Squad members + end + end + + -- Settings + StyledTheme.CreateFormHeader( panels.settings, L"tab.settings", 0 ) + + StyledTheme.CreateFormSlider( panels.settings, L"settings.name_draw_distance", self.Config.nameDistance, 500, 50000, 0, function( value ) + self.Config.nameDistance = self.ValidateNumber( value, 2000, 500, 50000 ) + self.Config:Save() + end ) + + StyledTheme.CreateFormSlider( panels.settings, L"settings.halo_draw_distance", self.Config.haloDistance, 500, 50000, 0, function( value ) + self.Config.haloDistance = self.ValidateNumber( value, 8000, 500, 50000 ) + self.Config:Save() + end ) + + local binderPing = StyledTheme.CreateFormBinder( panels.settings, L"settings.ping_key", self.Config.pingKey ) + + binderPing.OnChange = function( _, key ) + self.Config.pingKey = key + self.Config:Save() + end + + StyledTheme.CreateFormToggle( panels.settings, L"settings.show_members", self.Config.showMembers, function( checked ) + self.Config.showMembers = checked + self.Config:Save() + end ) + + StyledTheme.CreateFormToggle( panels.settings, L"settings.show_rings", self.Config.showRings, function( checked ) + self.Config.showRings = checked + self.Config:Save() + end ) + + StyledTheme.CreateFormToggle( panels.settings, L"settings.show_halos", self.Config.showHalos, function( checked ) + self.Config.showHalos = checked + self.Config:Save() + end ) + + StyledTheme.CreateFormToggle( panels.settings, L"settings.enable_sounds", self.Config.enableSounds, function( checked ) + self.Config.enableSounds = checked + self.Config:Save() + end ) +end + +function SquadMenu:GetPanel( id ) + if IsValid( self.frame ) then + return self.frame._panels[id] + end +end + +function SquadMenu:UpdateSquadStatePanel() + local statePanel = self:GetPanel( "squadState" ) + if not statePanel then return end + + statePanel:Clear() + + local squad = self.mySquad + local squadColor = squad and squad.color or Color( 0, 0, 0 ) + + statePanel.Paint = function( _, w, h ) + surface.SetDrawColor( 20, 20, 20 ) + surface.DrawRect( 0, 0, w, h ) + + surface.SetDrawColor( squadColor:Unpack() ) + surface.DrawOutlinedRect( 0, 0, w, h, 1 ) + end + + local imageIcon = vgui.Create( "DImage", statePanel ) + imageIcon:Dock( LEFT ) + imageIcon:SetWide( ScaleSize( 32 ) ) + imageIcon:SetImage( squad and squad.icon or "vgui/avatar_default" ) + + local labelName = vgui.Create( "DLabel", statePanel ) + labelName:Dock( FILL ) + labelName:DockMargin( ScaleSize( 8 ), 0, 0, 0 ) + labelName:SetText( squad and squad.name or L"not_in_a_squad" ) + + StyledTheme.Apply( labelName ) + + if not squad then return end + + local buttonLeave = vgui.Create( "DButton", statePanel ) + buttonLeave:SetText( L"leave_squad" ) + buttonLeave:SetWide( ScaleSize( 180 ) ) + buttonLeave:Dock( RIGHT ) + + StyledTheme.Apply( buttonLeave ) + + buttonLeave.DoClick = function() + SquadMenu.LeaveMySquad( buttonLeave ) + end +end + +function SquadMenu:RequestSquadListUpdate( immediate ) + timer.Remove( "SquadMenu.RequestListUpdate" ) + + local listPanel = self:GetPanel( "squadList" ) + if not listPanel then return end + + listPanel:Clear() + + StyledTheme.CreateFormHeader( listPanel, L"fetching_data", 0 ) + + if not immediate then + -- Don't spam when this function gets called in quick succession + timer.Create( "SquadMenu.RequestListUpdate", 1, 1, function() + SquadMenu:RequestSquadListUpdate( true ) + end ) + + return + end + + self.StartCommand( self.SQUAD_LIST ) + net.SendToServer() +end + +function SquadMenu:UpdateSquadList( squads ) + local listPanel = self:GetPanel( "squadList" ) + if not listPanel then return end + + listPanel:Clear() + + if #squads == 0 then + StyledTheme.CreateFormHeader( listPanel, L"no_available_squads", 0 ) + return + end + + StyledTheme.CreateFormHeader( listPanel, L"tab.squad_list", 0 ) + + local separation = ScaleSize( 6 ) + + for _, squad in ipairs( squads ) do + local line = vgui.Create( "Squad_ListRow", listPanel ) + line:SetSquad( squad ) + line:Dock( TOP ) + line:DockMargin( 0, 0, 0, separation ) + end +end + +function SquadMenu:UpdateRequestsPanel() + local requestsPanel = self:GetPanel( "joinRequests" ) + if not requestsPanel then return end + + requestsPanel:Clear() + + local padding = StyledTheme.dimensions.scrollPadding + requestsPanel:DockPadding( padding, 0, padding, padding ) + requestsPanel:SetPaintBackground( true ) + requestsPanel:SetBackgroundColor( StyledTheme.colors.scrollBackground ) + + self.frame:SetTabNotificationCountByIndex( 4, 0 ) -- Join requests tab + + local squad = self.mySquad + + if not squad then + StyledTheme.CreateFormHeader( requestsPanel, L"not_in_a_squad", 0 ) + return + end + + if squad.leaderId ~= PID( LocalPlayer() ) then + StyledTheme.CreateFormHeader( requestsPanel, L"not_squad_leader", 0 ) + return + end + + local memberLimit = self.GetMemberLimit() - #squad.members + + if memberLimit < 1 then + StyledTheme.CreateFormHeader( requestsPanel, L"member_limit_reached", 0 ) + return + end + + local requestsHeaderLabel = StyledTheme.CreateFormHeader( requestsPanel, L"requests_list", 0 ):GetChildren()[1] + + local function UpdateMemberCount( current ) + requestsHeaderLabel:SetText( L( "slots" ) .. ": " .. current .. "/" .. self.GetMemberLimit() ) + end + + UpdateMemberCount( #squad.members ) + + if squad.isPublic then + StyledTheme.CreateFormHeader( requestsPanel, L"no_requests_needed", 0 ) + return + end + + if #squad.requests == 0 then + StyledTheme.CreateFormHeader( requestsPanel, L"no_requests_yet", 0 ) + return + end + + self.frame:SetTabNotificationCountByIndex( 4, #squad.requests ) -- Join requests tab + + local scrollRequests = vgui.Create( "DScrollPanel", requestsPanel ) + scrollRequests:Dock( FILL ) + scrollRequests:SetPaintBackground( false ) + + local buttonAccept + local acceptedPlayers = {} + + local function OnClickAccept() + local ids = table.GetKeys( acceptedPlayers ) + + self.StartCommand( self.ACCEPT_REQUESTS ) + self.WriteTable( ids ) + net.SendToServer() + end + + local function UpdateAcceptedCount( count ) + UpdateMemberCount( #squad.members + count ) + + if buttonAccept then + buttonAccept:Remove() + buttonAccept = nil + end + + if count == 0 then return end + + buttonAccept = vgui.Create( "DButton", requestsPanel ) + buttonAccept:SetText( L"accept" ) + buttonAccept:Dock( BOTTOM ) + buttonAccept.DoClick = OnClickAccept + buttonAccept._themeHighlight = true + + StyledTheme.Apply( buttonAccept ) + end + + UpdateAcceptedCount( 0 ) + + local function OnClickRow( row ) + local id = row._id + local count = #table.GetKeys( acceptedPlayers ) + + if acceptedPlayers[id] then + acceptedPlayers[id] = nil + count = count - 1 + else + if count < memberLimit then + acceptedPlayers[id] = true + count = count + 1 + else + Derma_Message( L"cannot_accept_more", L"title", L"ok" ) + end + end + + row.isChecked = acceptedPlayers[id] ~= nil + UpdateAcceptedCount( count ) + end + + local rowHeight = ScaleSize( 48 ) + local rowPadding = ScaleSize( 6 ) + local nameColor = Color( 255, 255, 255 ) + + local function OnPaintRow( row, w, h ) + row._OriginalPaint( row, w, h ) + draw.SimpleText( row._name, "StyledTheme_Small", rowHeight + rowPadding, h * 0.5, nameColor, 0, 1 ) + end + + local byId = SquadMenu.AllPlayersById() + local dimensions = StyledTheme.dimensions + + for _, member in ipairs( squad.requests ) do + local row = vgui.Create( "DButton", scrollRequests ) + row:SetText( "" ) + row:Dock( TOP ) + row:DockMargin( dimensions.formPadding, 0, dimensions.formPadding, dimensions.formSeparator ) + row:DockPadding( rowPadding, rowPadding, rowPadding, rowPadding ) + + StyledTheme.Apply( row ) + + row._id = member.id + row._name = member.name + row.isToggle = true + row.isChecked = false + row.DoClick = OnClickRow + row:SetTall( rowHeight ) + + row._OriginalPaint = row.Paint + row.Paint = OnPaintRow + + local avatar = vgui.Create( "AvatarImage", row ) + avatar:Dock( LEFT ) + avatar:SetWide( rowHeight - rowPadding * 2 ) + + if byId[member.id] then + avatar:SetPlayer( byId[member.id], 64 ) + end + end +end + +function SquadMenu:UpdateSquadMembersPanel() + local membersPanel = self:GetPanel( "squadMembers" ) + if not membersPanel then return end + + membersPanel:Clear() + + local padding = StyledTheme.dimensions.scrollPadding + membersPanel:DockPadding( padding, 0, padding, padding ) + membersPanel:SetPaintBackground( true ) + membersPanel:SetBackgroundColor( StyledTheme.colors.scrollBackground ) + + local squad = self.mySquad + + if not squad then + StyledTheme.CreateFormHeader( membersPanel, L"not_in_a_squad", 0 ) + return + end + + local memberCount = #squad.members + + StyledTheme.CreateFormHeader( membersPanel, L( "slots" ) .. ": " .. memberCount .. "/" .. self.GetMemberLimit(), 0 ) + + if memberCount < 2 then + StyledTheme.CreateFormHeader( membersPanel, L"no_members", 0 ) + return + end + + local localId = PID( LocalPlayer() ) + local isLocalPlayerLeader = squad.leaderId == localId + + local membersScroll = vgui.Create( "DScrollPanel", membersPanel ) + membersScroll:Dock( FILL ) + membersScroll:DockMargin( 0, padding, 0, padding ) + + local OnClickKick = function( s ) + s:SetEnabled( false ) + s:SetText( "..." ) + + self.StartCommand( self.KICK ) + net.WriteString( s._id ) + net.SendToServer() + end + + local rowHeight = ScaleSize( 48 ) + local rowPadding = ScaleSize( 6 ) + + local colors = StyledTheme.colors + local DrawRect = StyledTheme.DrawRect + + local function OnPaintRow( row, w, h ) + DrawRect( 0, 0, w, h, colors.buttonBorder ) + DrawRect( 1, 1, w - 2, h - 2, colors.panelBackground ) + + draw.SimpleText( row._name, "StyledTheme_Small", rowHeight + rowPadding, h * 0.5, colors.labelText, 0, 1 ) + end + + local byId = SquadMenu.AllPlayersById() + local dimensions = StyledTheme.dimensions + + for _, member in ipairs( squad.members ) do + local row = vgui.Create( "Panel", membersScroll ) + row:Dock( TOP ) + row:DockMargin( dimensions.formPadding, 0, dimensions.formPadding, dimensions.formSeparator ) + row:DockPadding( rowPadding, rowPadding, rowPadding, rowPadding ) + + row._name = member.name + row.Paint = OnPaintRow + row:SetTall( rowHeight ) + + local avatar = vgui.Create( "AvatarImage", row ) + avatar:Dock( LEFT ) + avatar:SetWide( rowHeight - rowPadding * 2 ) + + if byId[member.id] then + avatar:SetPlayer( byId[member.id], 64 ) + end + + if isLocalPlayerLeader and member.id ~= localId then + local kick = vgui.Create( "DButton", row ) + kick:SetText( L"kick" ) + kick:SetWide( ScaleSize( 100 ) ) + kick:Dock( RIGHT ) + + kick._id = member.id + kick.DoClick = OnClickKick + + StyledTheme.Apply( kick ) + end + end +end + +function SquadMenu:UpdateSquadPropertiesPanel() + local propertiesPanel = self:GetPanel( "squadProperties" ) + if not propertiesPanel then return end + + propertiesPanel:Clear() + + local padding = StyledTheme.dimensions.scrollPadding + propertiesPanel:DockPadding( padding, 0, padding, padding ) + propertiesPanel:SetPaintBackground( true ) + propertiesPanel:SetBackgroundColor( StyledTheme.colors.scrollBackground ) + + local squad = self.mySquad + + if squad and squad.leaderId ~= PID( LocalPlayer() ) then + StyledTheme.CreateFormHeader( propertiesPanel, L"leave_first_create", 0 ) + return + end + + local isNew = squad == nil + local oldName = squad and squad.name or nil + local oldColor = squad and squad.color or nil + + if not oldColor then + local c = HSVToColor( math.random( 0, 360 ), 1, 1 ) + oldColor = Color( c.r, c.g, c.b ) -- Reconstruct color instance to avoid a bug + end + + squad = squad or { + enableRings = true + } + + StyledTheme.CreateFormHeader( propertiesPanel, L( isNew and "create_squad" or "edit_squad" ), 0 ) + + local data = { + name = squad.name or string.format( L"default_squad_name", LocalPlayer():Nick() ), + icon = squad.icon or "icon16/flag_blue.png", + + enableRings = squad.enableRings == true, + friendlyFire = squad.friendlyFire == true, + isPublic = squad.isPublic == true, + + r = oldColor.r, + g = oldColor.g, + b = oldColor.b + } + + local buttonCreate = vgui.Create( "DButton", propertiesPanel ) + buttonCreate:SetTall( 36 ) + buttonCreate:SetText( L( isNew and "create_squad" or "edit_squad" ) ) + buttonCreate:Dock( BOTTOM ) + buttonCreate:DockMargin( 0, ScaleSize( 8 ), 0, 0 ) + + StyledTheme.Apply( buttonCreate ) + + buttonCreate.DoClick = function( s ) + s:SetEnabled( false ) + s:SetText( "..." ) + + self.StartCommand( self.SETUP_SQUAD ) + self.WriteTable( data ) + net.SendToServer() + end + + local leftPanel = vgui.Create( "DPanel", propertiesPanel ) + leftPanel:Dock( FILL ) + + StyledTheme.Apply( leftPanel ) + StyledTheme.CreateFormHeader( leftPanel, L"squad_name", 0, 0 ) + + local separator = ScaleSize( 6 ) + local rowHeight = StyledTheme.dimensions.buttonHeight + + local entryName = vgui.Create( "DTextEntry", leftPanel ) + entryName:SetTall( rowHeight ) + entryName:Dock( TOP ) + entryName:DockMargin( separator, separator, separator, separator ) + entryName:SetMaximumCharCount( self.MAX_NAME_LENGTH ) + entryName:SetValue( data.name ) + + entryName.OnChange = function() + local value = entryName:GetValue() + data.name = value:Trim() == "" and oldName or value + end + + StyledTheme.Apply( entryName ) + StyledTheme.CreateFormHeader( leftPanel, L"tab.squad_properties", 0, 0 ) + + local buttonIcon = vgui.Create( "DButton", leftPanel ) + buttonIcon:SetTall( rowHeight ) + buttonIcon:SetIcon( data.icon ) + buttonIcon:SetText( L"choose_icon" ) + buttonIcon:Dock( TOP ) + buttonIcon:DockMargin( separator, separator, separator, 0 ) + + StyledTheme.Apply( buttonIcon ) + + buttonIcon.DoClick = function() + local iconBrowser = vgui.Create( "DIconBrowser" ) + iconBrowser:SetSize( 300, 200 ) + + local m = DermaMenu() + m:AddPanel( iconBrowser ) + m:SetPaintBackground( false ) + m:Open( gui.MouseX() + 8, gui.MouseY() + 10 ) + + iconBrowser.OnChange = function( s ) + local iconPath = s:GetSelectedIcon() + + buttonIcon:SetIcon( iconPath ) + data.icon = iconPath + + CloseDermaMenus() + end + end + + StyledTheme.CreateFormToggle( leftPanel, L"squad_is_public", data.isPublic, function( checked ) + data.isPublic = checked + end ):DockMargin( separator, separator, separator, 0 ) + + local ffButton = StyledTheme.CreateFormToggle( leftPanel, L"squad_friendly_fire", data.friendlyFire, function( checked ) + data.friendlyFire = checked + end ) + + ffButton:DockMargin( separator, separator, separator, 0 ) + + if SquadMenu.GetForceFriendlyFire() then + ffButton:SetEnabled( false ) + ffButton:SetIcon( "icon16/accept.png" ) + ffButton:SetText( L"squad_force_friendly_fire" ) + end + + StyledTheme.CreateFormToggle( leftPanel, L"squad_rings", data.enableRings, function( checked ) + data.enableRings = checked + end ):DockMargin( separator, separator, separator, 0 ) + + local rightPanel = vgui.Create( "DPanel", propertiesPanel ) + rightPanel:SetWide( ScaleSize( 260 ) ) + rightPanel:Dock( RIGHT ) + rightPanel:DockMargin( separator, 0, 0, 0 ) + + StyledTheme.Apply( rightPanel ) + StyledTheme.CreateFormHeader( rightPanel, L"squad_color", 0, 0 ) + + local colorPicker = vgui.Create( "DColorMixer", rightPanel ) + colorPicker:Dock( FILL ) + colorPicker:SetPalette( true ) + colorPicker:SetAlphaBar( false ) + colorPicker:SetWangs( true ) + colorPicker:SetColor( oldColor ) + + colorPicker.ValueChanged = function( _, color ) + data.r = color.r + data.g = color.g + data.b = color.b + end +end \ No newline at end of file diff --git a/lua/squad_menu/client/vgui/member_status.lua b/lua/squad_menu/client/vgui/member_status.lua new file mode 100644 index 0000000..911a074 --- /dev/null +++ b/lua/squad_menu/client/vgui/member_status.lua @@ -0,0 +1,100 @@ +local IsValid = IsValid +local Clamp = math.Clamp +local RealTime = RealTime +local FrameTime = FrameTime +local Approach = math.Approach + +local SetColor = surface.SetDrawColor +local SetMaterial = surface.SetMaterial +local DrawOutlinedText = draw.SimpleTextOutlined +local DrawTexturedRect = surface.DrawTexturedRect +local DrawRect = surface.DrawRect + +local DrawHealthBar = SquadMenu.DrawHealthBar +local matGradient = Material( "vgui/gradient-r" ) + +local COLORS = { + WHITE = Color( 255, 255, 255, 255 ), + LOW_HEALTH = Color( 250, 20, 20, 255 ), + OUTLINE = Color( 0, 0, 0, 255 ) +} + +local PANEL = {} + +function PANEL:Init() + self.avatar = vgui.Create( "AvatarImage", self ) + self:InvalidateLayout() + self:SetPlayer() +end + +function PANEL:SetPlayer( id, name, squad ) + self.squad = squad + self.playerId = id + self.validateTimer = 0 + + self.name = SquadMenu.ValidateString( name, "", 20 ) + self.health = 1 + self.armor = 0 + self.alive = true + + self.healthAnim = 0 + self.armorAnim = 0 +end + +function PANEL:Think() + if IsValid( self.ply ) then + self.health = Clamp( self.ply:Health() / 100, 0, 1 ) + self.armor = Clamp( self.ply:Armor() / 100, 0, 1 ) + self.alive = self.ply:Alive() + + return + end + + -- Keep trying to get the player entity periodically + if RealTime() < self.validateTimer then return end + + self.validateTimer = RealTime() + 1 + + local ply = SquadMenu.FindPlayerById( self.playerId ) + + if ply then + self.ply = ply + self.name = SquadMenu.ValidateString( ply:Nick(), "", 20 ) + self.avatar:SetPlayer( ply, 64 ) + end +end + +function PANEL:Paint( w, h ) + local split = h + + if self.squad then + SetColor( self.squad.color:Unpack() ) + DrawRect( w - split, 0, split, h ) + end + + SetColor( 0, 0, 0, 240 ) + SetMaterial( matGradient ) + DrawTexturedRect( 0, 0, w - split, h ) + + local dt = FrameTime() + + self.healthAnim = Approach( self.healthAnim, self.health, dt * 2 ) + self.armorAnim = Approach( self.armorAnim, self.armor, dt ) + + if self.alive then + local barH = h * 0.2 + DrawHealthBar( 2, h - barH - 5, w - split - 6, barH, self.healthAnim, self.armorAnim ) + end + + DrawOutlinedText( self.name, "SquadMenuInfo", 2, 2 + h * 0.5, + self.alive and COLORS.WHITE or COLORS.LOW_HEALTH, 0, self.alive and 4 or 1, 1, COLORS.OUTLINE ) +end + +function PANEL:PerformLayout( w, h ) + local size = h - 4 + + self.avatar:SetSize( size, size ) + self.avatar:SetPos( w - size - 2, 2 ) +end + +vgui.Register( "Squad_MemberInfo", PANEL, "DPanel" ) diff --git a/lua/squad_menu/client/vgui/squad_list_row.lua b/lua/squad_menu/client/vgui/squad_list_row.lua new file mode 100644 index 0000000..1e77415 --- /dev/null +++ b/lua/squad_menu/client/vgui/squad_list_row.lua @@ -0,0 +1,199 @@ +local L = SquadMenu.GetLanguageText +local ScaleSize = StyledTheme.ScaleSize + +local UpdateButton = function( button, text, enabled ) + button:SetEnabled( enabled ) + button:SetText( L( text ) ) + button:SizeToContentsX( ScaleSize( 12 ) ) + button:GetParent():InvalidateLayout() +end + +local PANEL = {} +local COLOR_BLACK = Color( 0, 0, 0, 255 ) + +function PANEL:Init() + self.squad = { + id = 0, + name = "-", + leaderName = "-", + color = COLOR_BLACK + } + + self:SetCursor( "hand" ) + self:SetExpanded( false ) + self.animHover = 0 + + self.collapsedHeight = ScaleSize( 52 ) + self.padding = ScaleSize( 6 ) + self.iconSize = self.collapsedHeight - self.padding * 2 + + self.icon = vgui.Create( "DImage", self ) + self.icon:SetSize( self.iconSize, self.iconSize ) + + self.buttonJoin = vgui.Create( "DButton", self ) + + StyledTheme.Apply( self.buttonJoin ) + self.buttonJoin:SetTall( self.collapsedHeight - self.padding * 2 ) + + self.buttonJoin.DoClick = function() + if self.leaveOnClick then + SquadMenu.LeaveMySquad( self.buttonJoin ) + else + UpdateButton( self.buttonJoin, "waiting_response", false ) + + SquadMenu.StartCommand( SquadMenu.JOIN_SQUAD ) + net.WriteUInt( self.squad.id, 16 ) + net.SendToServer() + end + end + + self.memberCount = vgui.Create( "DPanel", self ) + self.memberCount:SetTall( self.collapsedHeight - self.padding * 2 ) + self.memberCount:SetPaintBackground( false ) + + self:SetTall( self.collapsedHeight ) +end + +function PANEL:PerformLayout( w ) + self.icon:SetPos( self.padding, self.padding ) + + local joinWidth = self.buttonJoin:GetWide() + + self.buttonJoin:SetPos( w - joinWidth - self.padding, self.padding ) + self.memberCount:SetPos( w - joinWidth - self.memberCount:GetWide() - self.padding * 2, self.padding ) +end + +local colors = StyledTheme.colors +local DrawRect = StyledTheme.DrawRect + +function PANEL:Paint( w, h ) + self.animHover = Lerp( FrameTime() * 10, self.animHover, self:IsHovered() and 1 or 0 ) + + DrawRect( 0, 0, w, h, self.squad.color ) + DrawRect( 1, 1, w - 2, h - 2, COLOR_BLACK ) + DrawRect( 1, 1, w - 2, h - 2, colors.buttonHover, self.animHover ) + + local x = self.iconSize + self.padding * 2 + local y = self.collapsedHeight * 0.5 + + draw.SimpleText( self.squad.name, "StyledTheme_Small", x, y, colors.labelText, 0, 4 ) + draw.SimpleText( self.squad.leaderName or "", "StyledTheme_Tiny", x, y, colors.buttonTextDisabled, 0, 3 ) +end + +function PANEL:OnMousePressed( keyCode ) + if keyCode == MOUSE_LEFT then + self:SetExpanded( not self.isExpanded, true ) + end +end + +--- Set the squad data. +--- `squad` is a table that comes from `squad:GetBasicInfo`. +function PANEL:SetSquad( squad ) + squad.color = Color( squad.r, squad.g, squad.b ) + + self.squad = squad + self.icon:SetImage( squad.icon ) + + local maxMembers = SquadMenu.GetMemberLimit() + local count = #squad.members + + self.leaveOnClick = squad.id == ( SquadMenu.mySquad and SquadMenu.mySquad.id or -1 ) + + if self.leaveOnClick then + UpdateButton( self.buttonJoin, "leave_squad", true ) + + elseif count < maxMembers then + UpdateButton( self.buttonJoin, squad.isPublic and "join" or "request_to_join", true ) + + else + UpdateButton( self.buttonJoin, "full_squad", false ) + end + + self.memberCount:Clear() + + local labelCount = vgui.Create( "DLabel", self.memberCount ) + labelCount:SetText( count .. "/" .. maxMembers ) + labelCount:SizeToContents() + labelCount:Dock( FILL ) + + local labelWide = labelCount:GetWide() + + local iconCount = vgui.Create( "DImage", self.memberCount ) + iconCount:SetImage( "styledstrike/icons/users.png" ) + iconCount:SetWide( self.collapsedHeight - self.padding * 4 ) + iconCount:Dock( LEFT ) + iconCount:DockMargin( 0, self.padding, 0, self.padding ) + + self.memberCount:SetWide( labelWide + iconCount:GetWide() ) +end + +function PANEL:SetExpanded( expanded, scroll ) + self.isExpanded = expanded + + local height = self.collapsedHeight + local memberHeight = ScaleSize( 32 ) + + if expanded then + height = height + self.padding + memberHeight * math.min( #self.squad.members, 5 ) + end + + self:SetTall( height ) + self:InvalidateLayout() + + if expanded and scroll then + self:GetParent():GetParent():ScrollToChild( self ) + end + + if self.membersScroll then + self.membersScroll:Remove() + self.membersScroll = nil + end + + if not expanded then return end + + local membersScroll = vgui.Create( "DScrollPanel", self ) + membersScroll:Dock( FILL ) + membersScroll:DockMargin( 0, self.collapsedHeight, 0, 0 ) + membersScroll.pnlCanvas:DockPadding( 0, 0, 0, 0 ) + + self.membersScroll = membersScroll + + local byId = SquadMenu.AllPlayersById() + local separation = ScaleSize( 2 ) + local padding = ScaleSize( 4 ) + + for _, m in ipairs( self.squad.members ) do + local id = m[1] + + local row = vgui.Create( "DPanel", membersScroll ) + row:SetBackgroundColor( colors.panelBackground ) + row:SetTall( memberHeight - separation ) + row:Dock( TOP ) + row:DockMargin( self.padding, 0, self.padding, separation ) + + local name = vgui.Create( "DLabel", row ) + name:SetText( m[2] ) + name:Dock( FILL ) + + local avatar = vgui.Create( "AvatarImage", row ) + avatar:SetWide( memberHeight - padding * 2 ) + avatar:Dock( LEFT ) + avatar:DockMargin( padding, padding, padding, padding ) + + if byId[id] then + avatar:SetPlayer( byId[id], 64 ) + end + + if id == self.squad.leaderId then + row:SetZPos( -1 ) + + local leaderIcon = vgui.Create( "DImage", row ) + leaderIcon:SetWide( memberHeight - padding * 2 ) + leaderIcon:SetImage( "icon16/award_star_gold_3.png" ) + leaderIcon:Dock( RIGHT ) + leaderIcon:DockMargin( 0, padding, padding, padding ) + end + end +end + +vgui.Register( "Squad_ListRow", PANEL, "DPanel" ) diff --git a/lua/squad_menu/player.lua b/lua/squad_menu/player.lua new file mode 100644 index 0000000..c8d06a3 --- /dev/null +++ b/lua/squad_menu/player.lua @@ -0,0 +1,5 @@ +local PlayerMeta = FindMetaTable( "Player" ) + +function PlayerMeta:GetSquadID() + return self:GetNWInt( "squad_menu.id", -1 ) +end diff --git a/lua/squad_menu/server/main.lua b/lua/squad_menu/server/main.lua new file mode 100644 index 0000000..a18005c --- /dev/null +++ b/lua/squad_menu/server/main.lua @@ -0,0 +1,196 @@ +resource.AddWorkshop( "3207278246" ) + +SquadMenu.blockDamage = SquadMenu.blockDamage or {} +SquadMenu.squads = SquadMenu.squads or {} +SquadMenu.lastSquadId = SquadMenu.lastSquadId or 0 + +--- Find a squad by it's ID. +function SquadMenu:GetSquad( id ) + return self.squads[id] +end + +--- Find and remove a squad by it's ID. +function SquadMenu:DeleteSquad( id ) + local squad = self.squads[id] + + if squad then + squad:Delete() + end +end + +--- Remove a player id from join requests on all squads. +function SquadMenu:CleanupRequests( id, dontSync ) + for _, squad in pairs( self.squads ) do + if squad.requestsById[id] then + squad.requestsById[id] = nil + + if not dontSync then + squad:SyncRequests() + end + end + end +end + +-- Updates the player name everywhere it appears. +function SquadMenu:UpdatePlayerName( ply ) + if not IsValid( ply ) then return end + + local id = self.GetPlayerId( ply ) + local name = ply:Nick() + + for _, squad in pairs( self.squads ) do + -- Update leader name + if squad.leaderId == id then + squad.leaderName = name + end + + -- Update join request + if squad.requestsById[id] then + squad.requestsById[id] = name + squad:SyncRequests() + end + + -- Update member name + if squad.membersById[id] then + squad.membersById[id] = name + squad:SyncWithMembers() + end + end +end + +--- Create a new squad. +function SquadMenu:CreateSquad() + local id = self.lastSquadId + 1 + self.lastSquadId = id + + self.squads[id] = setmetatable( { + id = id, + name = "", + icon = "", + + enableRings = false, + friendlyFire = false, + isPublic = false, + + r = 255, + g = 255, + b = 255, + + -- Members key-value table. Do not modify directly, + -- instead use squad:AddMember/squad:RemoveMember. + -- + -- Each key is a player id from SquadMenu.GetPlayerId, + -- and each value is the player name. + membersById = {}, + + -- Join Requests key-value table. + -- + -- Each key is a player id from SquadMenu.GetPlayerId, + -- and each value is the player name. + requestsById = {} + }, self.Squad ) + + self.PrintF( "Created squad #%d", id ) + + return self.squads[id] +end + +-- Callbacks on FCVAR_REPLICATED cvars don't work clientside so we need this +cvars.AddChangeCallback( "squad_members_position", function() + SquadMenu.StartEvent( "squad_position_changed" ) + net.Broadcast() +end, "changed_squad_members_position" ) + +--- Update player names on change +gameevent.Listen( "player_changename" ) +hook.Add( "player_changename", "SquadMenu.UpdatePlayerName", function( data ) + SquadMenu:UpdatePlayerName( Player( data.userid ) ) +end ) + +--- Update player names on first spawn +hook.Add( "PlayerInitialSpawn", "SquadMenu.UpdatePlayerName", function( ply ) + SquadMenu:UpdatePlayerName( ply ) +end ) + +-- On disconnect, remove join requests and this player from their squad, if they have one +hook.Add( "PlayerDisconnected", "SquadMenu.PlayerCleanup", function( ply ) + SquadMenu:CleanupRequests( SquadMenu.GetPlayerId( ply ) ) + + local squad = SquadMenu:GetSquad( ply:GetSquadID() ) + + if squad then + squad:RemoveMember( ply ) + end +end ) + +--- Block damage between squad members +local blockDamage = SquadMenu.blockDamage + +hook.Add( "PlayerShouldTakeDamage", "SquadMenu.BlockFriendlyFire", function( ply, attacker ) + if not attacker.GetSquadID then return end + + local id = ply:GetSquadID() + + if id ~= -1 and ply ~= attacker and blockDamage[id] and id == attacker:GetSquadID() then + return false + end +end ) + +--- Chat commands and squad-only chat messages +local prefixes = {} + +for _, prefix in ipairs( SquadMenu.CHAT_PREFIXES ) do + prefixes[prefix] = true +end + +hook.Add( "PlayerSay", "SquadMenu.RemovePrefix", function( sender, text, _, channel ) + -- Check for commands to open the menu + if text[1] == "!" then + text = string.lower( string.Trim( text ) ) + + if text == "!squad" or text == "!party" then + SquadMenu.StartEvent( "open_menu" ) + net.Send( sender ) + + return "" + end + end + + -- Check if this is supposed to be a members-only message + local isCustomChatSquadChannel = channel == "squad" + local parts = string.Explode( " ", text, false ) + + if not isCustomChatSquadChannel and ( not parts[1] or not prefixes[parts[1]] ) then return end + + local id = sender:GetSquadID() + + if id == -1 then + sender:ChatPrint( "You're not in a squad." ) + return "" + end + + if not isCustomChatSquadChannel then + table.remove( parts, 1 ) + end + + text = table.concat( parts, " " ) + + if text:len() == 0 then + sender:ChatPrint( "Please type a message to send to your squad members." ) + return "" + end + + local override = hook.Run( "SquadPlayerSay", sender, text ) + if override ~= nil and ( not override or override == "" ) then return "" end + if type( override ) == "string" then text = override end + + local members = SquadMenu:GetSquad( id ):GetActiveMembers() + + SquadMenu.StartEvent( "members_chat", { + senderName = sender:Nick(), + text = text + } ) + net.Send( members ) + + return "" +end, HOOK_HIGH ) diff --git a/lua/squad_menu/server/network.lua b/lua/squad_menu/server/network.lua new file mode 100644 index 0000000..d2a368b --- /dev/null +++ b/lua/squad_menu/server/network.lua @@ -0,0 +1,182 @@ +util.AddNetworkString( "squad_menu.command" ) + +function SquadMenu.StartEvent( event, data ) + data = data or {} + data.event = event + + SquadMenu.StartCommand( SquadMenu.BROADCAST_EVENT ) + SquadMenu.WriteTable( data ) +end + +local commands = {} +local PID = SquadMenu.GetPlayerId + +commands[SquadMenu.SQUAD_LIST] = function( ply ) + local data = {} + + for _, squad in pairs( SquadMenu.squads ) do + data[#data + 1] = squad:GetBasicInfo() + end + + SquadMenu.StartCommand( SquadMenu.SQUAD_LIST ) + SquadMenu.WriteTable( data ) + net.Send( ply ) +end + +commands[SquadMenu.SETUP_SQUAD] = function( ply ) + local squadId = ply:GetSquadID() + local plyId = PID( ply ) + local data = SquadMenu.ReadTable() + + if type( data.name ) == "string" then + local shouldAllow, name = hook.Run( "ShouldAllowSquadName", data.name, ply ) + + if shouldAllow == false then + data.name = name or "?" + end + end + + -- Update existing squad if this ply is the leader. + if squadId ~= -1 then + local squad = SquadMenu:GetSquad( squadId ) + if not squad then return end + if squad.leaderId ~= plyId then return end + + squad:SetBasicInfo( data ) + squad:SyncWithMembers() + + -- Litterally all I need is that lol + hook.Run("SquadMenu_SquadUpdated", squadId, ply, ply:SteamID()) + + SquadMenu.PrintF( "Edited squad #%d for %s", squadId, ply:SteamID() ) + + SquadMenu.StartEvent( "squad_created", { id = squadId } ) + net.Broadcast() + + return + end + + local squad = SquadMenu:CreateSquad() + squad:SetBasicInfo( data ) + squad:SetLeader( ply ) + squad:AddMember( ply ) + + SquadMenu.StartEvent( "squad_created", { + id = squadId, + name = squad.name, + leaderName = squad.leaderName, + r = squad.r, + g = squad.g, + b = squad.b + } ) + net.Broadcast() +end + +commands[SquadMenu.JOIN_SQUAD] = function( ply ) + local squadId = net.ReadUInt( 16 ) + local squad = SquadMenu:GetSquad( squadId ) + + if squad then + squad:RequestToJoin( ply ) + end +end + +commands[SquadMenu.LEAVE_SQUAD] = function( ply ) + local squadId = ply:GetSquadID() + if squadId == -1 then return end + + local squad = SquadMenu:GetSquad( squadId ) + + if squad then + squad:RemoveMember( ply, SquadMenu.LEAVE_REASON_LEFT ) + end +end + +commands[SquadMenu.ACCEPT_REQUESTS] = function( ply ) + local squadId = ply:GetSquadID() + if squadId == -1 then return end + + local squad = SquadMenu:GetSquad( squadId ) + local ids = SquadMenu.ReadTable() + + if squad and squad.leaderId == PID( ply ) then + squad:AcceptRequests( ids ) + end +end + +commands[SquadMenu.KICK] = function( ply ) + local squadId = ply:GetSquadID() + if squadId == -1 then return end + + local plyId = PID( ply ) + local squad = SquadMenu:GetSquad( squadId ) + + if squad and squad.leaderId == plyId then + local targetId = net.ReadString() + if targetId == plyId then return end + + local byId = SquadMenu.AllPlayersById() + if not byId[targetId] then return end + + squad:RemoveMember( byId[targetId], SquadMenu.LEAVE_REASON_KICKED ) + end +end + +commands[SquadMenu.PING] = function( ply ) + local squadId = ply:GetSquadID() + if squadId == -1 then return end + + local squad = SquadMenu:GetSquad( squadId ) + if not squad then return end + + local pos = net.ReadVector() + + local members, count = squad:GetActiveMembers() + if count == 0 then return end + + SquadMenu.StartCommand( SquadMenu.PING ) + net.WriteVector( pos ) + net.WriteString( ply:Nick() ) + net.WriteString( ply:SteamID() ) + net.Send( members ) +end + +-- Safeguard against spam +local cooldowns = { + [SquadMenu.SQUAD_LIST] = { interval = 0.5, players = {} }, + [SquadMenu.SETUP_SQUAD] = { interval = 1, players = {} }, + [SquadMenu.JOIN_SQUAD] = { interval = 0.1, players = {} }, + [SquadMenu.LEAVE_SQUAD] = { interval = 1, players = {} }, + [SquadMenu.ACCEPT_REQUESTS] = { interval = 0.2, players = {} }, + [SquadMenu.KICK] = { interval = 0.1, players = {} }, + [SquadMenu.PING] = { interval = 0.5, players = {} } +} + +net.Receive( "squad_menu.command", function( _, ply ) + local id = ply:SteamID() + local cmd = net.ReadUInt( SquadMenu.COMMAND_SIZE ) + + if not commands[cmd] then + SquadMenu.PrintF( "%s <%s> sent a unknown network command! (%d)", ply:Nick(), id, cmd ) + return + end + + local t = RealTime() + local players = cooldowns[cmd].players + + if players[id] and players[id] > t then + SquadMenu.PrintF( "%s <%s> sent network commands too fast!", ply:Nick(), id ) + return + end + + players[id] = t + cooldowns[cmd].interval + commands[cmd]( ply ) +end ) + +hook.Add( "PlayerDisconnected", "SquadMenu.NetCleanup", function( ply ) + local id = ply:SteamID() + + for _, c in pairs( cooldowns ) do + c.players[id] = nil + end +end ) diff --git a/lua/squad_menu/server/squad.lua b/lua/squad_menu/server/squad.lua new file mode 100644 index 0000000..963a98c --- /dev/null +++ b/lua/squad_menu/server/squad.lua @@ -0,0 +1,278 @@ +local IsValid = IsValid +local PID = SquadMenu.GetPlayerId +local FindByPID = SquadMenu.FindPlayerById + +local Squad = SquadMenu.Squad or {} +SquadMenu.Squad = Squad +Squad.__index = Squad + +--- Set the leader of this squad. `ply` can either be +--- a player entity, a ID string that came from `SquadMenu.GetPlayerId`, +--- or `nil` if you want to unset the current squad leader. +function Squad:SetLeader( ply, name ) + if type( ply ) == "string" then + self.leaderId = ply -- this is a player ID + self.leaderName = name or "?" + + elseif IsValid( ply ) then + self.leaderId = PID( ply ) + self.leaderName = ply:Nick() + + else + self.leaderId = nil + self.leaderName = nil + + SquadMenu.PrintF( "Removed leader from squad #%d", self.id ) + return + end + + SquadMenu.PrintF( "New leader for squad #%d: %s <%s>", self.id, self.leaderName, self.leaderId ) +end + +--- Get a table containing a list of active squad members. +--- Returns an array of player entities that are currently on the server. +function Squad:GetActiveMembers() + local members, count = {}, 0 + local byId = SquadMenu.AllPlayersById() + + for id, _ in pairs( self.membersById ) do + if byId[id] then + count = count + 1 + members[count] = byId[id] + end + end + + return members, count +end + +--- Get a ready-to-be-stringified table containing details from this squad. +function Squad:GetBasicInfo() + local info = { + id = self.id, + name = self.name, + icon = self.icon, + members = {}, + + enableRings = self.enableRings, + friendlyFire = self.friendlyFire, + isPublic = self.isPublic, + + r = self.r, + g = self.g, + b = self.b + } + + if self.leaderId then + info.leaderId = self.leaderId + info.leaderName = self.leaderName + end + + local count = 0 + + for id, name in pairs( self.membersById ) do + count = count + 1 + info.members[count] = { id, name } + end + + return info +end + +local ValidateString = SquadMenu.ValidateString +local ValidateNumber = SquadMenu.ValidateNumber + +--- Set the details of this squad using a table. +function Squad:SetBasicInfo( info ) + if SquadMenu.GetForceFriendlyFire() then + info.friendlyFire = true + end + + self.name = ValidateString( info.name, "Unnamed", SquadMenu.MAX_NAME_LENGTH ) + self.icon = ValidateString( info.icon, "icon16/flag_blue.png", 256 ) + + self.enableRings = info.enableRings == true + self.friendlyFire = info.friendlyFire == true + self.isPublic = info.isPublic == true + + self.r = ValidateNumber( info.r, 255, 0, 255 ) + self.g = ValidateNumber( info.g, 255, 0, 255 ) + self.b = ValidateNumber( info.b, 255, 0, 255 ) + + SquadMenu.blockDamage[self.id] = Either( self.friendlyFire, nil, true ) +end + +--- Send details and a list of members to all squad members. +--- You should never set `immediate` to `true` unless you know what you're doing. +function Squad:SyncWithMembers( immediate ) + if not immediate then + -- avoid spamming the networking system + timer.Remove( "SquadMenu.Sync" .. self.id ) + timer.Create( "SquadMenu.Sync" .. self.id, 0.5, 1, function() + self:SyncWithMembers( true ) + end ) + + return + end + + local members, count = self:GetActiveMembers() + if count == 0 then return end + + local data = self:GetBasicInfo() + + SquadMenu.StartCommand( SquadMenu.SETUP_SQUAD ) + SquadMenu.WriteTable( data ) + net.Send( members ) +end + +--- Send the requests list to the squad leader. +function Squad:SyncRequests() + local leader = FindByPID( self.leaderId ) + if not IsValid( leader ) then return end + + SquadMenu.StartCommand( SquadMenu.REQUESTS_LIST ) + SquadMenu.WriteTable( self.requestsById ) + net.Send( leader ) +end + +--- Turns `p` into a id and player entity depending on what `p` is. +local function ParsePlayerArg( p ) + if type( p ) == "string" then + return p, FindByPID( p ) + end + + return PID( p ), p +end + +--- Add a player as a new member. +--- `p` can be a player id from `SquadMenu.GetPlayerId` or a player entity. +function Squad:AddMember( p ) + local id, ply = ParsePlayerArg( p ) + if self.membersById[id] then return end + + local count = table.Count( self.membersById ) + if count >= SquadMenu.GetMemberLimit() then return end + + local name = id + + if IsValid( ply ) then + local oldSquad = SquadMenu:GetSquad( ply:GetSquadID() ) + + if oldSquad then + oldSquad:RemoveMember( ply, SquadMenu.LEAVE_REASON_LEFT ) + end + + ply:SetNWInt( "squad_menu.id", self.id ) + name = ply:Nick() + end + + self.membersById[id] = name + self:SyncWithMembers() + + -- We don't send the requests list to the squad leaders (2nd "true" parameter) + -- because "player_joined_squad" will already tell them to remove + -- this player from their own copies of the join requests list. + SquadMenu:CleanupRequests( id, true ) + + SquadMenu.StartEvent( "player_joined_squad", { + squadId = self.id, + playerId = id + } ) + net.Broadcast() + + hook.Run( "SquadMenu_OnJoinedSquad", self.id, ply, id ) +end + +--- Remove a player from this squad's members list. +--- `ply` can be a player id from `SquadMenu.GetPlayerId` or a player entity. +--- `reasonId` can be `nil` or one of the values from `SquadMenu.LEAVE_REASON_*`. +function Squad:RemoveMember( p, reasonId ) + local id, ply = ParsePlayerArg( p ) + if not self.membersById[id] then return end + + if id == self.leaderId then + pcall( hook.Run, "SquadMenu_OnLeftSquad", self.id, ply, id ) + + self:Delete() + + return + end + + self.membersById[id] = nil + self:SyncWithMembers() + + if not IsValid( ply ) then return end + + ply:SetNWInt( "squad_menu.id", -1 ) + + if reasonId ~= nil then + SquadMenu.StartCommand( SquadMenu.LEAVE_SQUAD ) + net.WriteUInt( reasonId, 3 ) + net.Send( ply ) + end + + hook.Run( "SquadMenu_OnLeftSquad", self.id, ply, id ) +end + +--- Add a player to the list of players that +--- requested to join and notify the squad leader. +function Squad:RequestToJoin( ply ) + if self.isPublic then + self:AddMember( ply ) + return + end + + local plyId = PID( ply ) + if self.requestsById[plyId] then return end + + self.requestsById[plyId] = ply:Nick() + self:SyncRequests() +end + +--- Accept all the join requests from a list of player IDs. +function Squad:AcceptRequests( ids ) + if not table.IsSequential( ids ) then return end + + local limit = SquadMenu.GetMemberLimit() + local count = table.Count( self.membersById ) + + for _, id in ipairs( ids ) do + if count >= limit then + break + + elseif self.requestsById[id] then + count = count + 1 + + self.requestsById[id] = nil + self:AddMember( id ) + end + end +end + +--- Remove all members from this squad and delete it. +function Squad:Delete() + timer.Remove( "SquadMenu.Sync" .. self.id ) + + local members, count = self:GetActiveMembers() + + if count > 0 then + for _, ply in ipairs( members ) do + ply:SetNWInt( "squad_menu.id", -1 ) + end + + SquadMenu.StartCommand( SquadMenu.LEAVE_SQUAD ) + net.WriteUInt( SquadMenu.LEAVE_REASON_DELETED, 3 ) + net.Send( members ) + end + + self.membersById = nil + self.requestsById = nil + + local id = self.id + + SquadMenu.squads[id] = nil + SquadMenu.blockDamage[id] = nil + + SquadMenu.PrintF( "Deleted squad #%d", id ) + + SquadMenu.StartEvent( "squad_deleted", { id = id } ) + net.Broadcast() +end diff --git a/lua/starfall/libs_sh/squad_menu.lua b/lua/starfall/libs_sh/squad_menu.lua new file mode 100644 index 0000000..471873f --- /dev/null +++ b/lua/starfall/libs_sh/squad_menu.lua @@ -0,0 +1,152 @@ +--- Squad Menu library. Contains functions for getting more information about squads. +-- @name squad +-- @class library +-- @libtbl squad_library +SF.RegisterLibrary( "squad" ) + +return function( instance ) + +local CheckType = instance.CheckType +local player_methods = instance.Types.Player.Methods +local ply_meta, punwrap = instance.Types.Player, instance.Types.Player.Unwrap + +local function GetPlayer( this ) + local ent = punwrap( this ) + + if ent:IsValid() then + return ent + end + + SF.Throw( "Player is not valid.", 3 ) +end + +--- Returns true is this player is part of a squad. +-- @shared +-- @return boolean +function player_methods:isSquadMember() + CheckType( self, ply_meta ) + + local ply = GetPlayer( self ) + return ply:GetSquadID() ~= -1 +end + +--- Get the ID of the squad this player is part of. +-- @shared +-- @return number Squad ID. -1 if this player is not in one. +function player_methods:getSquadID() + CheckType( self, ply_meta ) + + local ply = GetPlayer( self ) + return ply:GetSquadID() +end + +if SERVER then + local FindByPID = SquadMenu.FindPlayerById + local CheckLuaType = SF.CheckLuaType + local WrapColor = instance.Types.Color.Wrap + local WrapPlayer = instance.Types.Player.Wrap + local squad_library = instance.Libraries.squad + + --- Returns true if the given ID points to a valid squad. + -- @server + -- @param number id The squad ID + -- @return boolean + function squad_library.exists( id ) + CheckLuaType( id, TYPE_NUMBER ) + return SquadMenu:GetSquad( id ) ~= nil + end + + --- Returns a table with all available squads. Each item contains these keys: + --- - number id + --- - string name + --- - string icon + --- - Player? leader + --- - Color color + --- - boolean isPublic + --- - boolean friendlyFire + -- @server + -- @return table + function squad_library.getAll() + local all, count = {}, 0 + + for id, squad in pairs( SquadMenu.squads ) do + count = count + 1 + + local leader = FindByPID( squad.leaderId ) + + if IsValid( leader ) then + leader = WrapPlayer( leader ) + end + + all[count] = { + id = id, + name = squad.name, + icon = squad.icon, + leader = leader, + color = WrapColor( Color( squad.r, squad.g, squad.b ) ), + + isPublic = squad.isPublic, + friendlyFire = squad.friendlyFire + } + end + + return all + end + + --- Finds a squad by it's ID and returns the name. Returns nil if the squad does not exist. + -- @server + -- @param number id The squad ID + -- @return string? + function squad_library.getName( id ) + CheckLuaType( id, TYPE_NUMBER ) + + local squad = SquadMenu:GetSquad( id ) + return squad and squad.name or nil + end + + --- Finds a squad by it's ID and returns the color. Returns nil if the squad does not exist. + -- @server + -- @param number id The squad ID + -- @return Color? + function squad_library.getColor( id ) + CheckLuaType( id, TYPE_NUMBER ) + + local squad = SquadMenu:GetSquad( id ) + return squad and WrapColor( Color( squad.r, squad.g, squad.b ) ) or nil + end + + --- Returns the number of members in a squad. Returns nil if the squad does not exist. + -- @server + -- @param number id The squad ID + -- @return number? + function squad_library.getMemberCount( id ) + CheckLuaType( id, TYPE_NUMBER ) + + local squad = SquadMenu:GetSquad( id ) + if not squad then return end + + local _, count = squad:GetActiveMembers() + return count + end + + --- Returns an table of players in the squad. Returns nil if the squad does not exist. + -- @server + -- @param number id The squad ID + -- @return table? + function squad_library.getMembers( id ) + CheckLuaType( id, TYPE_NUMBER ) + + local squad = SquadMenu:GetSquad( id ) + if not squad then return end + + local members, count = squad:GetActiveMembers() + + for i = 1, count do + members[i] = WrapPlayer( members[i] ) + end + + return members + end +end + +end diff --git a/materials/squad_menu/ping.png b/materials/squad_menu/ping.png new file mode 100644 index 0000000000000000000000000000000000000000..7eb74bbe66b76cc1f31b8b6c350738327063bf10 GIT binary patch literal 2655 zcmV-l3ZV6gP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!2kdb!2!6DYwZ943G_)sK~#8N?VU-C zRYeqrc{t;QV?;#>4n#M|LX8_uB+;nsG-^yV>c*8DW1@*`qYHPg+!&+Am|)_BBhEr( zFibKCf&l?>M4XTY?e_o3sZOWuZu;J;Q&qR|PyW2l>vvz*sWa3$r|RDBO{x$B0|S%w za;!L2oF+~e&lArPPZv)Xj}wm)!2$6v@mKLDai{o=xLw>O?h*I(dcA(VR)uO5ph;&e z$B0buEOCxFTf9g-OFT(LDMyMZX!zI%L?-xeai92`_?`HbxLRB(ZWK{apOnGpi=(&# zY~rVgGsLUJYeXg=Yd>CO!rP9MNk$=mig*P|_*nc*+$!!Zt%za@u!+Z_XNq&h8$>Mr zG?CDfdTdNIq3UN5k9bRbU;J7mJQYzy5d~=SkI)OT{aW!B@d}ZN$J%oMuRsyY#FxYm z#9dOtKYGoI>FWK)2JoKwW>#e2mo z#S_FLUN{7Vovq z#QZKzegI=;?01XTnM}!%Tmc#=MHYEJ?n}1g>1Ezv?8tyDvrV?7NIE$dD~bCQS>*Y( zrJ8Mk49J2^$=8F_)=6)N_5u|yMb<s_ncDfq(M+35)E8Z;5 zOz8zFJ%B|D3!$+#5kk*!AQQ47W3mM~i2}?b=;zZ?rmL9?6iqqX$jF9_Gg5g$Di2^M zi_H|WAMchjt>jvEQ$cU`S9E~Jelr<0^9}{mCkLEoG(5wGJTrPeh zt`~QUfI{g~@oMpM@k}v!%4pax&KDo<^?J0j%R-YsS)41b75haG>=##w4~VnGDI$Nj zXXIzz<9B?9&vK0ikP%stIXZ%*2tlM}VGlQ1b}$53KGyz(NQZKhERVh1esRFa&upY@ zvd{2YuJPF6LPlgo=IE3(g#tJ_LR@46-2;RjI+F`D$$OaagYS+2QwB#+7KmKKwH`p$ z%S2?4LX-mJf>;AhD~^tMEWRwN#C*04IrB4K@L8^5&(~vNM%D|(=*l?O1Gt_8Q(oQ9 z-$fSO?PFQ~aM)+rSm9c}BQ=8IA$!yu#wq|yU=SRd@&H*5v7CceO~ExA#E>ba1~OCe zM|pm%0?38W7L)cz3|KMN%LKM5wz!6C`HnW%9}39KVjNWuq)@;aqDTC%9mtIA#TCFo z+1T$H3B=hGMT)F8>kHR#E#Kj>J|QzmTS1S>h&pB z0hIcjDDwo3HBMH{lVwjG%M>+b96c%6xkfV zSd%_~*rkRC)h+@SPTjRRC$&BjWuc*-#GP_Mt!vZHADp@|r8=+>O}U0wRG2M|p7skQv#d zx~;Ja;M6dKz!Kcg(pLmzWjm4^{i5WKQh>F6QNUu6I}r~Tu=ac;a&$c^1B~?mz-a__ zpvX!N2QMHSTPw2R!|q!Yq7@(uU_cxVX}W9GRbX(hiR&bLw85qD0KkHT0=N-6sxqt` zEVyiuzAc6nI0{J=V3xoQZm){1i;@oDd+`-5MVMPWmx$i|p%UOg7Rd%T zbyI{TK{5rHMUa-gEK-~$v5-oDENt>4TcZO4heApZ091uM41*LcszR&`RJ|XHi!}L3 zwv8Dr;M((9tj`63qu$Wo{UR*7$lT`tbTIny;JQ*wBD0AS)7JXLZRi?d)v zJlM&t+v}zPSq$z=e~99YkkmC$P%`$7CW{L^JphE_j77Oi#TjFzpma|pTooY8A%!Cy zB`qaA?Lt}_QfaJztI6O7Zw~+zE)-@IXk94IvKEu>3NQl9JP1=pg7~;D}|9qaaQG?L^TR9FQ72O3%Knmr5EsBgS&F} zx2ix*9)J?U5%AQl0SYbJ9^)fFaxj!T*3v5vQ^C!DI6^SwE;w0h%B(mCwag z#aZ@!-X^Hg0|2=?#Tj=a#^maF5PQ&MK2@I1t5N~7R3@FtXX0YE2+8%y`B#`{Rfk$V z01$pC&Im_sCNJ#svIiY~Kd%?6Re&v)n?*X7ZSG8=tw9)DukWg&=o$$%djJSMds5RT z#Tkn&Z4Epq`#x_IRILE>0*W$HNbXV`ZweVw*&_SAO;EcBpoFITw&C;aIp=(iQ7crh z00Rv=ESfc8EQ{uMF8A|#p%V(wM3M!K=NS!n2o1f(x~Al5i*chX9st5qN=7Truy#BI zPf311uNOL_09kY>&PL1YtJ&vmf-ZRg2q&1?)fzgFyXMiI3qg1Ri!H|)Nj2F)3-|N#@2#qBIgEzR zDZoIA`KidFOTkt9y=SAM)I(3Bp>V^qs`+PXJTiHytiSrI_rI7l>0eL2=&s2e*slNp N002ovPDHLkV1le(zxn_G literal 0 HcmV?d00001 diff --git a/materials/squad_menu/ring.png b/materials/squad_menu/ring.png new file mode 100644 index 0000000000000000000000000000000000000000..483bf78a4378fae22e772d9b2deff93625563c9c GIT binary patch literal 7343 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 literal 0 HcmV?d00001 diff --git a/materials/squad_menu/squad_menu.png b/materials/squad_menu/squad_menu.png new file mode 100644 index 0000000000000000000000000000000000000000..954060c692443067f5998a8cec40d173e8b79946 GIT binary patch literal 9038 zcmV-UBeC3xP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!2kdb!2!6DYwZ94BI8L!K~#8N?VSgh zR7KW?4{3%BLl8v~!Hnp-3K)^3f{N?vy85daSaU*hR8fL}x|p-B*;QO4K~O|dKvZ-e zv!bv@MBNocMNtO+^H$$8)bw)!9HQ>RXyx?N2Q6m2_9-by#^ zw6xYzqJ_FzsUGz@?6Fwyp{&=kF4+2|GRG*tYWYgb;-!lQzNg!Q0~cRVP~x}I&2TLz zXlcT+jR3!Ec|pr06~Y{Mq(D$m0#Nb~*3JD|wsZ$JPF%00zX|~hk#}Gc3jzW8PiWbg z$p`(k+!5K$KWYTqyh6$Ep&Q6=;0|p3*sG*v&uvyLn>96foj2rN0GlBB21KxRzD1B% z0Vw(1b@QZ_O^tlR-uV_m-kd-szngBJ(vp1y?v3U17j09w~pO>0O|jcRT@ zm;|@0Q+Rf;pHt-JmImb0@*BDX39xkeS~>0T=CW0bda`a^K{(DyQBCh^Y3X`-a?aPX z^5;@%Si5={5!|LCI49W88FDTFCBLg~X!*&>U$jQXOlT(;o_nw~Zd6y+udf;c0M%3l z3l=Vx&ZkY4l3F5-3seL-6+l2fEx(aFkN}W>`4#QtqVo@tMh)u(N2?wX!2zdEm8M0Z zMR2Q%;M`z8>&Te^l>9Ebq2;GV{=4tKEC2i7|D<~Lf(c#3#kzItWapiCmMyo8gftc` z{76MGO`53)8ifWZg8i%_CjwCN57N!kS`v~^WtJ%UmyO?5`d@Hp8Tl{0^pc!$ z#u@Vc_uotH+O^%WLdI8LeI+NKe6mcKFhRE7dTV!VzKY<$Q&a@Sg(ZTl3m_n$mfzSN zhyx>kw30u-BY*DPxpKh;7f9pAjiqMIn(kPkgZ>|}{^1XQkSngZVuJ`?2}Gb84k?10 zRRre;`&mI&;{Zy2XWh{9!;wEu$-l6C%fIl#3#%0QhPrj@%BP=x>bkB@ojOUwh7D!g zR?Xy)j!ospDT}3Mwe?cFX43Z8v7}|s<|~%Xni0ItII<>yRLQ?|+%7Wk!k*>GKmYvm zt0egb6Ty4$y(b+zb}SRY!5x~&jgNjDRs@|?1kF?g&jhbCimV7g$v;pxkk34RT*M%h6hX!XpyVH*8$z4m$RDfZ_w&hrsmk^F$>58?w*);@1UEgR zB2X4WilAwxMUYVeq)PrJV|J0j7nN`M8yoqCSR!b>Eg~p!BB-TI6ekzKJ}QDHDuS88 z>q;YI0#Nez*A3(+%;MQV{?1B%FTD}jC!Yx7Z@>K}zyA8GD{I!QNmzdWodh0nF$X;Q zOd+-inoD;?@Nj-akddoPmHdm9{2@6Yf9=|}vV8e+cjMc&YbRT6wUrbX>p(mSe);7W zH50-+lL;v})B#PCW5M*>~T49f^=1CXz@b4-qU|woK;D zn8_xhjILC#eV;g%-iJDuMyQep;xM0F?ZFbu&#%V)FG{ zmb|`3Mk)D2b3pzVUwk2d``h27Z{NPI)mIjn?OSfSMgIBEe@ff7Z8Op>#1+BI?@QM{ zGo*!zpneWSkhTvHkWb5R<_^Sxkw5aXon+X6!*W9YuwldGyz|a;=Qhx<|M0^PvTD^T z`T6Ic6BgcEt5&TtYr%OAIN$*1OU#@(Q<^ksk`WQa@(H$U-CVkLXe>89^syAx3gr{* zt3E*^^$BJLuTw%=SE1zZqZ?X&L6MKyKjDNEWXO;qQdCsrj;&t3+I`=&Y17<|<~8KS zBG9IrA>xmJ{G%Lk#1XRp{`;4m>!+W7l2M~Z$?Vy)Ws5DgaK|c(c$VNrwFKQxoGvYz zRG0cip)A25ejd(BU}=^3ZR0?%Y`h4<20R z^fCN<`|Yrc6z2%6?6ykLUFU);Ux}P{hwro~I>J^2` zF}zy4gCW6wLWB~4lHXA`)3t;qe}s~6E&s(AU(8x;zqGW}nd5u!y;oYcY#AKQJ{B)t zEJq%Br1J$bZOstD?z`{q@(Id`-~<&x^Uxx=N<}a<*pG{(gJLDWgKk2%{9%_wTE1`g zvpKPY0OSQ`9+5hF&(mtTJAj(6*_n>_!}VY2+&Qu+C} z^5asU5U_UMro+lummY>k&EhGQp9c09iBg)KvHe0@d5HGESzP?eTM(zf_|Ni^V zOw;!x5S+I#0ibB`SqR1lAUL$kF1yH%JMQTIM;C(y;JozTnTFpTj30l6v}zTx1an?| zS9+ZAjBL3@XiIRVT7qH0eg(vJ0+sx|bp!b=+<`bS@`ozc z0fhD0XP-I8kkJ9x*=D;fq;rQxa`Qdv7}g2p7#^UGVI6f0X9us#A!f5knfy@|$in?GBcg%4+QnLSUG3Ix*NUhYGdVH}0jI(Buz+{GkOnRb1m%{rD*5Wyz;p?xzmK$!k!C9YdU!(66loLU> z_VV1rz2xgZ$Ja#~*(zJMFZSOr1Ja zdiU<_ju8nwzC^I=W19=|fprJb zmf+y-yUVj!f-l#GwFD!S>(Rmf1`D7EaS~Xn9T$%LH`d6YVcW|k!y!N7{pPdf8;GJk z@x&9d=bn4I*Jc4Q*YxQCKY<&W2>c%*S^WQ#Pd;&;zi$D!-frExx!_YW(PbKD33l6U zHy0-`OYq$DZ_A;_t0mZ`W*AGrO*lv`!8~UHl>8%glbn1t`-6tHl}lX9kC6Yu3oqoI zd=QUV|2U%r%zvUn$k@mSe&1gRFiSwDv2O_u>9L2*dPFV3=WFHH6v-Q3!w;j(4@h zOr2S}vQ!2i*IGvDP_IzQ2TPVLanZnDy?VLVv=Rz8$@WtPpQX_H8BUsox&QwA<&;xS zk%JC8D64URFi0eVlLkI7^=s)+wt8Ft(>{drC>;`tD0kI_V@Am?$Lj0Z}?U zb}RwOo3wc5^?mb?FR*gu%CdR?=)(I+8N>8*Qvt~#_yQa!hH&}im%C&UMi!j65Cj0I zFEf-}A8`ysJ8!{m&e|C=>3UCIQkfG%l8u>nG`6<8& zV50$MoNj9S?YDQ?K?H^@iwD>~=F*Bk{-;1R{*8gw|LCKSy7RG5H-oq~5~OAU3Keuq z5Rd79=%I&P4u8LX{p8Po{&Ts-9OMgP=KuTO{{}}QR?P~>nFFwyM8Lxxjy?8RCxE-| zx=S8>@Ig1oso`X^4D=$t;ofBy3y z7sds6#?LZ`B1V}KC_mcqcI z5vG{=&MMj+6t(~$nejI>ee}6FU~C!h@(3AL0I_Jp-b>t{AsS<01FaSBp3ENlZ$twc zG-x2FopzebCO+=CJgz7I@TAN4Xo5Es&N4 zLFb%vj{7cddSzk{1a;`I_t|G3Cj?>xgiD#i#+%1NFnnZX5#0&G!WpC@;1LvWOKonRO3nFC$)R$|+NyU1N9{ZS#xpj%OZ5cOdbxc8Z0kl?H2@yNi^R;Qy zrqVa-FMs)q>&GFV@%s)t?BH_ku`IM)gur`9@usDcMT}F&hyjaJ8S()kYDOJQ+cH^Y zmaRZg4GYjlciz@gVP!=wEdc!_Q8$KZv{nPAmhYuMMd1d7Vi7qr(;TBklO6`qengLn z5}p}A%b@UHMgSB`6d|?`IXHu~d|ImkD}nEUc&HQK3#bEikM8H`S`aYj@Dog)d+s?m zlSr0Prl*qAumIys0CWM7IH`wR3V{6gNs}hI;h2Gh2fS=Dep3Yw9fj{O+Uegd zWNQ5~gtE`r{_eZ)E}Q!YIGl|8i7ciH0!Vac3_lHY1xzZj(8Pq438W!_@dyB!BY>FG z!MP9st&oQ+GaNIgj7c4aS%4%DE{K~I=7!N4A(E+El0tCA04;amz=4j?SdV*Q_=o@* z5}H8Fhrq$4)tc1_QxArQtWv@n5us$x9?%W&90M$#Z>1`WGz2gK0U%AffV>HSU=8DR z&JCEhWob7l_n=Ta83=pGg#cL0!8n?@y{)`~IO}K4 zM5hZrZ+$#~Xwt<74I1RG15$Yp-a4ZIoIoQ4%f#_a)q~b<*U0rTRHWNOoS6_o8UmP< z*HHjuFlMI}_{*-SJRx3M3){*RTCb50X!ShoVcTuDbtC|`JVuUKflma%Ad25yRf)9Dv6nW&k<4NAAzdDwDZYsG94g57vtn>P>GMt?r~++hpf zYu94?2CPr45kwB*hJmbirVFSrlalQ&00J=2I7sG{5vXAZX3zA&i^l;2Va+gt!hA1U zw5V)Y$Mo>7yYA}#&v+jv3}=ki2`Pk>F`cw}zUR$1-*lco)`9Qm|5zpvrXJLRLKnxm zAQ>xj?z!idO*j!C!r$Pyox3vNI#TQc{*C~4(j5d)p$54S02O`s;fKp6VgNDwK7IN) z$B@-HzJ*8yICRYa-*nSW?y+$OoJ->WIGerz5S}0V$2Vb$nLrXl$FKtQ!8H=bCI*4; zL46FwHduDfWLBO8FgdSX049)xONL55EOm?FAEui3rBiI!WfsIhK!+HE-E>H|$r~T^ z{RE|mGSymA6ZiIO|`A5RO3 z^}T*!9)X#`M4%1;pCjEaE(2I^0?4ZfFty2A0V&z6_`UDG`{EWtxQ88fSi&1>|Kt0j z-)CJ)Oc5KaJYC)gGgd{HoF=H~NrZ1D>5e*|Ek z%fyZK&w4buy)wYugLDbtbIH1eFfempfGCVE zAQ~2+A%;(;4dZ#7#IS|;FIcd^eID5PKr%7-|0fKL0GOmAVIhW$ev#E@qecen<>kct8C$PYB^Weo}}3J<*`0L%M-r(QB{0=H@9t ztnW%xI2S|Ev17-&df2cOSQt9bSYHs+!Hfdo-6UP8*3Kq;nB0B3}?wPS-+K5%K**trijUJ(i6`%%7YG>Vde( z4J5Qo3?NgjAEaRcTuK-Zy{8Mvs{jB_5~Q(G2e1y`p87=^l^@TK2U4+z4#<}vu6$R_*tw(G7t!2$Ou@OWLZgM6*2FDurf(LNJ9W) z3!)1!&^K}sq=(@!S+XavTz~p*TdN;k$hzVAz6J>6Ttwy&7bO|_04qRYmJSEf>Vefj z{FFrXAk0jN+dw^-2S9{a2P{FR$xjpc9|l4O#Pg6RS_duo@y8!`!zVwQW+`2E^I{cD ztvw?Lkci38zG1eR+NEnD&CB8gKBx!t0XTvzFeUWNY$P+4{x6SlPUaR>X9vfCk2!Z1M>oIA|{`kMdmLz9S@wA$1%*90a9=jA=I}FfSb-F z4~s<2)B?;b=9cVR%rTA^Px!_n_-p(CubC2NDjHvbX=ntAMe+R(hJ=We1tfg059)=0 z$oJ!st_J2ASaFO1i6Y*4=bbM7o%->Br#zx85}0!f0QoGo#Kb~8^W>H=X|TL~fca%1 z2g|~kM&&r?pcT=YAdPXqiH31@bW11J&+=~iaD+x54~fj)F%!r+5SpJt2CxhkKfsjZ z2M}c9$1wPQ>Op-V69*5ckz;_*(w+FAi$F*S0UyVIj7^S_pDuu#Lox;qCBN!r88X0c z-3*tQDz;zE;B%yCGk%5$TlUQYPh#-aQZdnt^=XL)`e6gvN37I=bVy`O&il#R1Bk-j z&z49Y!$%8EBM=-?QpT}h)Pohr)Wc8sAnyR8Sqx(EuaEA8q)-B1{(8k6B0pUKtpzhr zj=tS=S_!FP=6M5JFnur?yneC<4j8j;_zH9j+;nSkw5Fj$hq~6~TL4*^_2olKx zVx^Ad29PdBD>gz91H>Z&ekjTwNsrigcqEHO@D&)P_=`{&b|PLDllsv|o@mYkElpr_ z4P+i+74i@r%#Q`2_0!_aQ9=-`+Qr2CEd-|u(@*vet7~{nn*pbgm;s)@;lmF4EPt(Lu2M4@Hy}Y7!7b8 z287Q0@pTJkV76HJ^<(NLCc|A zV7WL6n5-zmOq*3O(AqG$%-7q*jERI+iU_e14CSK1xd;qFF4NA8Qlh zl$&kiCX(?>dKEX@qIZ6)^MR8`yx)RN1|oEb12c|!k8X+fnwCyCf}@8Y!4pWB`I|Cj zinA~{qMU=eBCdG#U=0F}q&a{@C&^{te&o;!q;3H!Q#n2h@fMu}tN{e{$^LN5hkYl+ zlB7w55a)_;D#JBm{3KvtZn^2vE#JU-AOuT8XxV@@fJg+yC?ekH4b&A2Mtp&16G0}U z3PvMXAI_7g9)LttRxDEt_(g=F%{rxn6!{4u0Ingp4lalmoURD?94*KkKQ5Gh8`Es2 zI2ot|L}R9L1}XRh5SQ$x8*sR2Y37{KqKP9!`wfYJkZ9$8J$RkS`b2|!K|KsaJNf=} z1wfa?XYCfaZDOiIi~`oIS7#+sA^J4>NfvLUBLY5y^3CI>vh?S%`u=3#CUdhW?1v#9 zPzT-*`F5l6$ax*l0>uO0nQlD*qV@e5?hDo>oqLOOiFIYcX{}n3Sjca`>-8o6Sve;F znnBE%q{H3A^I5jP64eMt?uYT^~ROTgN>bSweb-0IVZk8-Wda|M2bK3Ynastqu8M=9v{djO9=vXXQPgo*jC#eX2EZ-fZV+l4}j`RTe zG#*;Mg`Z!Vr{td$ntZ%p_ba0QD4?H~gws-GBDi9SjEfRMWh}vFghwp15BXha`He%7 z4=&e6zkr=G0bmYv!!IK)cL(CYL~zx3L@-@`jIspjj1Cx%@tIiTUpp7ubczo=>{!PE z$E+T){_p=Um15s%uzmY~DzKK1+4oz1my@*54^KWn9Xlr2FJ}P|0Y4wd^H;xd2jYNw zM?~<6jJvYD2vQv)GXCcMOc0UL=8Zl#(12`8~z`i zq(L32TdeO%R>&vxg>Bcz^%9N_n-vt#?7NoVs9IR^Co0EfzZ(op9sRuV44>)cpo>4lrBpQ;pPz<_0JI&oQnVxd3b?Rxz>(uI;WM3RUlwt%?g z_tBQ$Ff{p-l>A7xtcSQ)l(GmeQ4tK?AOdFic;22b0*-TG(N6@~j0g}KNv!nkCV-ga z&;KCRmLHRRP_7H`p&xidH~0kKx&v{5MWZ{IH1-p@g6`m_UtM>=+&+$>-yP7}NVn$L zW>f@|Npv^dClHJLmm$ATO5|S|k9?5Srl}>s)x;7cTnu2AVECmw%BUensAK?D__uj;`>Fk&%|sn0?4+sAmtCa^#<^Y`9U7Y@7!lvs^kw2_Cp~h0f1CR@Dw5_ULl=2L`1+i7fY~N1RD^` z>^t&LPKo@e5j^;f)KC%Zqar8{M6lTsL}27&_A8KISIMs(iu`Mo z{6WEfN=QopKt=FnAcCW{gcCvSh$S!)Z0eREF8QaVMEQs|P;mIEm?5BlF2>=8ln5Vl~f(k!6;lo5wv(8Gj z1Z~QRU{kULzQgB}f1sNE6v)4>vgCtG3jnAHzDP*~55yM1reFzTIehb9`A7~tWqK;) zU$5l%5B4jCj0gY(BH%YwLyMqR-G~T^HW5n@%j_?3q< zraXZN>ir;{_U5r=s4p07t!G1=O6#;-$Mez9jDgx~eIy#?VDs+&I%<~HDIfn}a@*67o zB?Ux2$f-?L5p>nflUhQFVBEyrWMIFZP6SmI3t#(Kx}5s7)K}exB>z?=|J-0d>&R(y zQlH>c^$8a0?lD>_{7|J2e1gaB`&#NYTqS$$xt;R~s#5k4{tD!GKW(bi3s3%Sxg;Ot z)B+gP5_HoImY|_K5C_f@EL$t5^=>X(wWz25K-CZczG|t~^{MB-mhXQq4Li<<{PK?{ z%K`Et01$`(pP>BXL1W;vRi#q8T5n^ zR!hK7gRq$K1TB%@iK+VFApktvo>jW|Jq3e;6M#X5!0)uQ)6!Z?i5BW=1nAmtPE0&tPG4mmKP99L)jqLXfQH^#hHL?Lq;YB0U#X( z#F_0ZVDT&<8w3)77^ELYqgl$p%)l^#oq+|Y!obMbfN=rDRFDqV1rU>_0NEhG1T=>U ztTM>b0?2~uGBhv%$zFf9TXFBR_$>?!%*LKBjv*CsZ)fez7IBnlFP`;7)U+wVM9E1& zRC$qt(v1}gVQe~AgSfS&tyi09aU68Iu-{=)iJ6dUtD~d9Y>6u(oQv23x$QqFAIs=C z|2}Qj?v(WBJC4Xct$lFZ+SuCo`@ZVU+qb{>YfhACgQ5Qts`-m&%$yn7m#H(=D?ZlS zrYE*JGi2kP02$#Q{|`?u?zD1bS$<&G183p$t0Wz|Yo}X2{K00j?i^>)&E1jmK396;rY)b-8|JPiWB+)U>dEXa zmv+zUoK_>}o3T>+T++<#k!wDfxqb;=IKNL}r+UZhFUHUItqwJ4o3|@T?bq60Z@W%@ zJa9bBUgxM;iT~?M#%+8*4m-Xmm#V$8phdz(dT+PmqWsT3E1vFjN4{XtlKWsUid|_|yC724Q3m!SPw%fnO%KB_ zi^Se1O_-&}sCKs}aMN$r^!ijN)ld_H9SXa0V7Ii?)f@(U(H=P%mTTX6jKTew|n3P;yNy{xZ# z*Z3#jy>ceBXTbw8v%c~Lo~3>htE3+NQT2*5S@FZU?vIG|H%KyOWdd z;})3NyXNht;CpEcSt?g)EW6_yVkqgF%JMsAEz?{3nnztTzbwA9_X_L9=gHhcrQ*q& zTApQ`a~-s@gO|t6-gA&G@lBnN`K`7ruM4^iziRH*er=6E@PB?5PvGRkEUULOR2BX> zxb?{48>KTI9Q(ZgNco@n^JBKa5-uwfS1dERHd*ZbG=rr21Sa`OZM7PfbnK^|50OZXtfCd7eq3BKl zw2347k3etq-J2m^fXz+c0JNf&iJjO*MP)_RhwZ9j^q3|uM{{p;b6J3eZZH8tASM7r zS6Ao|{;&RZ8#svJA3vC`l|f*Di_Xq;_$D#_!v9&r^4YtS@MjAESdm48o#pl5{_#dpZD_#k=H0;gz0D%QXt(XoKW1Y#{{HI^ z3kUo8X1_BQj<40~b(SvoBytG%%f=pTFAGZ?oPwCV*@9oRz|NpaJb9{pBT*T$ZIzL| zv67YtpHh%Hu{JKNBH24*tg0Lta_|EIWU-{k`G5=IOv&A38CMys8RoQ2IZlnorar@( ze~XK@Xhogyqlh!uKijn<`S%tW2bGuwMg}oX8bHYsDpJA)@A|8M*KOV}=tcC>`jS$z z_1sJ_iIBD>Oku=x7Vo!d-h5)jKXGhM4(a+YmET~}&yW~(!>%Iq*%O{Tk-1aS+HRCX zyx}4@$0yZkH4;?ZiMk^#{Tki6n(29U)-Dy%nsBxv|L?J ziJOT^w#Bzy`r?f+N+N8cb!v;8xI3)XPz$2 znt|gy=1#CNh&Nesku;# z%b+RUa-W34<>RBmf`Z6adeVNEYBpXjsoHXFLMdW58@n7X*(Jtw!Tm0AG>8pm6d_;~ z!y8_AOa*@n=y}+7?Udii$&=`7TXp^}&lWewc2kbE6J{U0*_^`(;iR#N-d6GOAtrf= zL!TC%pIv#D8O0y^-ggmOnSl{2is`$XiU}Y!sgRXkxh2LBW&JMrJt5so`Y>ECiyCm5 z=IoVQ&EjZyY{vXeDA-qUY$k5)K|o{EwZrnV%wha?UWym-chbh} z$+XYZ%||U^Yd_qKWgkb4J4p<1M7Vt; zJvNytp?zM`6WlwDR=N(wOyU$m&T=VU4Xf6ZBNaQv;wT--_;5b0!UdKWUCy$-A1;1j zxGj;81X;R4V4f3c^DYT5+ZUn6EDHBZ_W2uDj`mNy(ojOxNVZ;K{CTd$YB}0Gq}m!k6@1|njTssosTsBTe#2+!qlwn&_Z+hI?%C2B z<|4t!POzOba@*IDtBv1<9P3Y#g3mf7mzb-d{ct1|ZDf4+GU>;|x=F!`qpQBT(yj(m z%hO3d4>kw8IrRl$6}28F&zhkDprHCSfSp|iA5|G-uHH81v6PAf=Y%r3@iZk08SOo7 z$Z>rXB1U6%$*WD+%ovyad^HQlTgwk(I^Y0w)Bx=xqmVhFwk?J_-5%wTS_Qcv5{!%1 z-u#5~W5_rOktMTes~H#wFTa7kL$xUTyy@Fw^V5={*3x{h+9Az7Ye8U))>r3y>cVgO zsyPL(C`}vD%ym;aPQ3oePk+5R<#K>v)y$Q@t=heu4&$|2Vix9xxn|z|G|B68rr;!Q z?QnK5Ao07e<2(nstXdYWp|#NaY(JIS<I--5Rm{ zyR@)!;21M`H$>K_%TP1t4ruZ(=(rp6hC|iHz!ovV#yP6#3Y!r-e^)ixFYyQI7O(r10g^=0T~pP zh(ZD8{%tL)F86VtkT>y+@APJXI0tR8s^qvFQbcQ>6I05RvJ}7k>CUco*`?$@5NQ#= zgfp%asKs3W5;fDc))1k6@Yt>(c4@dIwSPmbs19V#dQx}`t-!0P{qf@Vv@bEyn!m?G zp7#Pr0rpk9Jb&uRk?Oo5HG22{+s91if=IYtLiw^?9q=#2F?{Rt>@pM z+?sT^dgkvlaoE~?p!y?vS#3O9*BtF0s<~-z#$Ue%x#%Zd@pY)ffw7gzg^Nazc6Ni! zSWdDX*iFt;KNAKunYf7m(`oy!rLsFoQ$iW4;Ei)*kWcd;?cNnQu2$T_<_IF7HX`ZruO+knON(|4}Q7 zVHb9_qR11pcjOTbhHup`UQp&8^g_)8x-p@On%^&@MPi9t^HD0O?wv`_dfAtJzkNj& zvv6!KvjzqAjV}*jZ|j=*SW^`it&KhHG!LA9PU^Dz+0$P+Gg>`2!9`5WUGr9V`iH&N zv4i!K_M~H-2V^+x{o#4g;3w}!1$Xjz8+L3Fz2!0%E$~-2wSt|>HYrNkKAWj?H2=T= zm^};g4DlFv@+T#vMABL`FvJqOi4fcV)>$3=K!;PqlJnDb<2;?EQLkX!ogoQMuhoWX zwy!&w=ffP&-?5T12MCf3ymh7TdSg>N#Nk}b;_0nmjVRo{3{}nplKMFczbjU%pbv3{ zum?Gj+g&QEqfCZYbWf(GD^4+qsuj58d$crWlMW3y>F$q<9>UYuLdBIS1)BJ&N|V2? z24eRq>8-E%^^e_c-RdV2UC&Wm+1{i5f%$FGUyNUyDhCwyC9rFobsWwsVv(rrmFl(q zGTw=>S#&P6yj^BXLEX4F?F~@00%sB!Dw(fuUTLg}NEl4Af~@bOU4I7auakM8 zqUEd;JKoC7*@}7rSvr>)2^Ez@Twb)nrCvUXXFD+A@!=_3{O$yQ)o)svFyT(s-U`d} z&$wP7FDwlWIlxa-xi-2VONK(9F}RFn6kS+5cwSsmIMwu|gDt!2Xq-hC6>FHgP|mYa z011+pI$g<*9b27J)a&;+Suj0&qzxipC!6k4eLwiT(g~{8w!GR2$co!2JD&fM>f)n#iNTUeFN>y?gdHj47bETK)DMQqy*LdPdkll_E(`l(KtvJfjFl% z{)ijqXLV7-eFMbWQYdG*a*-Qf=1-qgw;mMcY`tSP5?=~0n=*!#t9*{au1jd0*bAN3 zsLmXW{t2-ktPL0|&%Z^BL(HIv(qxx~RP#;6kbpPE$)^gcnBksioM?6Vr$|MF|42u{ zwVo$+=8aXJgXn`EotM`Sy%chGbSp|z(_Mae<1LC?3T4zcjH|BT>`&xfSLw6=S#@x( b0!{R;R^$89^VY9_eMrnrtc(eU?s5MC{YP$c literal 0 HcmV?d00001 diff --git a/materials/styledstrike/icons/flag_two_tone.png b/materials/styledstrike/icons/flag_two_tone.png new file mode 100644 index 0000000000000000000000000000000000000000..4469191817e6b8e528060735229220efbab0619d GIT binary patch literal 1111 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 literal 0 HcmV?d00001 diff --git a/materials/styledstrike/icons/users.png b/materials/styledstrike/icons/users.png new file mode 100644 index 0000000000000000000000000000000000000000..5c8a031231decbce2993cfb516c0f9929428aded GIT binary patch literal 2065 zcmZ8ic|6o>7ypgHEwW?@6Bk#DK@6rwwu~`mq`^#6*D~1+L)SL8EcHr->=$WJ&5YE@ zQW>&EBul0+L$oMl$=I@2Zp-^q_xg#k`ES>vF-p*~V>^i~p>^T5y9j%sJ818f5wF#rY; z10bL}3S#%adh0gY5a`di2&fGqB7iK&t{`a1qCew-px!c15L@;Cfb6u1Y&Dj#7Kx0 z6G^nvkliK<(wy*K4YIMZu~+l3`Z^kCW@`HV#d2($W60lV{MpR04=I@i1h~|b4vJN{ zOyQ+YwijM$L;dx|p^}4?NveRdRF`4{yF7`@cnD3nY`VOvMq3}ZHtpA%QOfIr52jYt z%{!PJvFlV(#gzAJ^GF2F7>=1r{|uh%V1A_QMKagxz#eg1B(q#@b)=-FUp0+ zi_ zzHtgVhPqX0xx+c$1q!Nv_SN8QrCV%26w>rx>Su(PzDh5i`lL)AtK{g=4rz4gx|^H} z@nzV){gFYvBKbh#faUQ{SY}BWbqFC`LIn0;^~*YWj%_)L?Y8sG{aDTRR%_kij#x4^j!H5fyT7q@d&n=k{ZbrEZ#auPdwJ za1+ld(r&Ky?EBm$xJ0W@ zjM-Q({|5WGw;za!dMHeeeyO-8`bix8ftzcd%9Ww&=Yow^tYrp86@C2Kv+W;7Q}qVs zagACMi3`pVPAWl8p0Nx@?Qc2==`yoOmgKc0xoG%5?fj#YKKYF4^OZfLu&JY5!L!$r zif2$&Sp|3db!Sv+U}fXg^)D#klh3_kD)C3VFM5}`>DkAz{wT@01#@kapwC@cUCsWO zDeMjBk)$|IL)j(TmoGI#kM$nNtXOeLrdYVfu}G_6GR(`O@d>4LjN-9YvV-2eHW0-b zA>S;!#4fhgb(%Kl{gN11!nciBGaCwO5T6HD_!J}zE_iqO(1gDXO~Z-kx{?z1&!_zj zok$UySj~*dN2GumzF+W?R+O|J5?`QPd{D=X&Y;E_cpkaf(ax$W7t^L?-=&Tw)1jRVWU@=mFiGxC!d zgEpmpJWqi5To1Fzsva7zoxXBdrSA(7zlv>nYR*cmWUo%QK26FoL&YxFe#JoI9c<5W z02#^WNcs6-jEh=NJN31)$xL!fDy5Xa8B0sDiFdR~CTBxCPF=KJd`V-xL9KF}h33YU z>PCGjBlRw)sikNYm*(kX#yqh%*sxvu%VgOI-?IiAIo?`puf)KH5+H1peW^1$ z)Wqw68JEi#Nn@L|lxT}6u7IWI5yJvjNL%F%#42PQ@f$bWx6z{Kx^2d-#Tp#5qHPAH zIN$mOTPS%tnK@cQp)Df03NBfx-f~JFvPb6Y7Qh~a>Mxez+U8oR>V#_e6?z!A6kZL( zkD{8E6iNG9j1(1ylJ2tZa*^1R(8D-B^GyUGD5$j1JRr7^ndUIQTNva{x$#wMZeNB- z`~bHlC{hL*dTYtUtA0*NFj4AAI9k+Kg~rHXd#gUZa||%gazwV-HXj^`*##Hnobt(4 z$M#~_&3m>VY2eiI9mtV`Ud7Fx4^HW-WAUGbnICr!sO>qS_I4+~K3#H9ZD6Kg!~fgh z`yh>ky-`N*Dv)>lb*Hbb_k?bS9g^*1?|U@d8JBPSL)rAE6p2t*F%$)@u$Z{L>tos# zx06Y2es~{6i(uCTj>sl)J?K%^_4)7KrM