if not CLIENT then return end local Progression = CustomCoopProgression local shopFrame local scoreboardFrame local shopState = { score = 0, credits = 0, equippedModel = Progression.DefaultModelID, equippedWeapon = Progression.DefaultWeaponID, ownedModels = {}, ownedWeapons = {}, ownedMusic = {}, prices = {}, isAdmin = false } local activeMusicChannel local activeMusicSource local activeMusicIsSpatial = false local previousMusicVolume = 0.7 local musicVolume = CreateClientConVar( "coop_music_volume", "0.7", true, false, "Volume for music bought in the custom co-op shop (0 to 1).", 0, 1 ) surface.CreateFont("CustomCoopProgressionTitle", { font = "Roboto", size = 28, weight = 700 }) surface.CreateFont("CustomCoopProgressionHeading", { font = "Roboto", size = 20, weight = 700 }) surface.CreateFont("CustomCoopProgressionBody", { font = "Roboto", size = 17, weight = 500 }) surface.CreateFont("CustomCoopProgressionSmall", { font = "Roboto", size = 15, weight = 400 }) local colors = { background = Color(20, 24, 29, 245), panel = Color(31, 37, 44, 250), panelHover = Color(39, 47, 56, 250), accent = Color(75, 176, 255), success = Color(92, 205, 122), warning = Color(244, 190, 74), text = Color(240, 244, 248), muted = Color(170, 181, 191) } local function RequestShop() net.Start("CustomCoopProgressionRequestShop") net.SendToServer() end concommand.Add("coop_shop", RequestShop) hook.Add("ShowSpare1", "CustomCoopProgressionF3", function() RequestShop() return true end) -- Some client configurations do not route F3 through ShowSpare1. This fallback -- keeps the advertised key reliable while the server-side rate limit absorbs a -- possible duplicate request. local nextF3Request = 0 hook.Add("PlayerButtonDown", "CustomCoopProgressionF3Fallback", function(ply, button) if ply ~= LocalPlayer() or button ~= KEY_F3 or RealTime() < nextF3Request then return end nextF3Request = RealTime() + 0.5 RequestShop() end) local function SelectItem(itemType, itemID) net.Start("CustomCoopProgressionSelectItem") net.WriteString(itemType) net.WriteString(itemID) net.SendToServer() end local function CurrentPrice(itemType, item) local price = shopState.prices[itemType .. ":" .. item.id] if price == nil then price = item.price or 0 end return math.max(math.floor(tonumber(price) or 0), 0) end local function AddText(parent, text, font, color) local label = vgui.Create("DLabel", parent) label:SetText(text) label:SetFont(font) label:SetTextColor(color) label:SetWrap(true) return label end local function AddItemCard(layout, itemType, item) local isModel = itemType == "model" local isWeapon = itemType == "weapon" local isMusic = itemType == "music" local owned = isModel and shopState.ownedModels[item.id] or isWeapon and shopState.ownedWeapons[item.id] or isMusic and shopState.ownedMusic[item.id] local equipped = isModel and shopState.equippedModel == item.id or isWeapon and shopState.equippedWeapon == item.id local currentPrice = CurrentPrice(itemType, item) local card = layout:Add("DPanel") card:SetSize(252, 292) card.Paint = function(self, width, height) local color = self:IsHovered() and colors.panelHover or colors.panel draw.RoundedBox(8, 0, 0, width, height, color) if equipped then surface.SetDrawColor(colors.success) surface.DrawOutlinedRect(0, 0, width, height, 2) end end local previewModel = item.model if isWeapon and item.weaponClass ~= "" then local storedWeapon = weapons.GetStored(item.weaponClass) previewModel = storedWeapon and storedWeapon.WorldModel end if previewModel and previewModel ~= "" then local preview = vgui.Create("DModelPanel", card) preview:SetPos(8, 8) preview:SetSize(236, 166) preview:SetModel(previewModel) preview:SetFOV(isModel and 30 or 38) if isModel then preview:SetCamPos(Vector(68, 0, 64)) preview:SetLookAt(Vector(0, 0, 64)) else preview:SetCamPos(Vector(45, 45, 28)) preview:SetLookAt(Vector(0, 0, 0)) end preview.LayoutEntity = function(_, entity) if IsValid(entity) then entity:SetAngles(Angle(0, RealTime() * 18 % 360, 0)) end end else local previewText = isMusic and "SERVER MUSIC" or isWeapon and "MAP LOADOUT" or "NO PREVIEW" local noPreview = AddText(card, previewText, "CustomCoopProgressionHeading", colors.muted) noPreview:SetContentAlignment(5) noPreview:SetPos(8, 8) noPreview:SetSize(236, 166) end local title = AddText(card, item.name, "CustomCoopProgressionHeading", colors.text) title:SetPos(12, 177) title:SetSize(228, 25) local description = AddText(card, item.description, "CustomCoopProgressionSmall", colors.muted) description:SetPos(12, 202) description:SetSize(228, 38) local priceText = currentPrice == 0 and "FREE" or (currentPrice .. " credits") local price = AddText(card, priceText, "CustomCoopProgressionBody", currentPrice <= shopState.credits and colors.success or colors.warning) price:SetPos(12, 239) price:SetSize(100, 22) local button = vgui.Create("DButton", card) button:SetPos(112, 244) button:SetSize(128, 36) button:SetFont("CustomCoopProgressionBody") local actionText if isMusic then actionText = (owned or currentPrice == 0) and "Play" or "Buy" else actionText = equipped and "Equipped" or (owned or currentPrice == 0) and "Equip" or "Buy" end button:SetText(actionText) button:SetEnabled(not equipped) button.DoClick = function() button:SetEnabled(false) button:SetText("Please wait...") SelectItem(itemType, item.id) timer.Simple(1, function() if IsValid(button) then button:SetEnabled(not equipped) button:SetText(actionText) end end) end end local function CreateCataloguePage(sheet, title, itemType, items) local page = vgui.Create("DPanel", sheet) page:DockPadding(10, 10, 10, 10) page.Paint = function(_, width, height) draw.RoundedBox(6, 0, 0, width, height, Color(24, 29, 35)) end local scroll = vgui.Create("DScrollPanel", page) scroll:Dock(FILL) local layout = vgui.Create("DIconLayout", scroll) layout:Dock(TOP) layout:SetSpaceX(12) layout:SetSpaceY(12) layout:SetTall(math.ceil(#items / 3) * 304) for _, item in ipairs(items) do AddItemCard(layout, itemType, item) end sheet:AddSheet(title, page) end local function SendPriceChange(itemType, itemID, price) local cleanPrice = math.Clamp(math.floor(tonumber(price) or 0), 0, Progression.MaximumShopPrice) net.Start("CustomCoopProgressionSetPrice") net.WriteString(itemType) net.WriteString(itemID) net.WriteUInt(cleanPrice, 32) net.SendToServer() end local function CreateAdminPage(sheet) local page = vgui.Create("DPanel", sheet) page:DockPadding(10, 10, 10, 10) page.Paint = function(_, width, height) draw.RoundedBox(6, 0, 0, width, height, Color(24, 29, 35)) end local controls = vgui.Create("DPanel", page) controls:Dock(BOTTOM) controls:SetTall(70) controls:DockMargin(0, 8, 0, 0) controls.Paint = nil local instruction = AddText( controls, "Select an item, enter its new Credit price, then save. All connected clients refresh immediately.", "CustomCoopProgressionSmall", colors.muted ) instruction:Dock(TOP) instruction:SetTall(24) local priceInput = vgui.Create("DNumberWang", controls) priceInput:Dock(LEFT) priceInput:SetWide(180) priceInput:SetMinMax(0, Progression.MaximumShopPrice) priceInput:SetDecimals(0) priceInput:SetValue(0) local save = vgui.Create("DButton", controls) save:Dock(LEFT) save:DockMargin(8, 0, 0, 0) save:SetWide(180) save:SetText("Save selected price") save:SetFont("CustomCoopProgressionBody") save:SetEnabled(false) local list = vgui.Create("DListView", page) list:Dock(FILL) list:AddColumn("Type"):SetFixedWidth(85) list:AddColumn("Item ID"):SetFixedWidth(190) list:AddColumn("Item") list:AddColumn("Credits"):SetFixedWidth(100) for _, catalogue in ipairs({ { itemType = "model", items = Progression.Models }, { itemType = "weapon", items = Progression.Weapons }, { itemType = "music", items = Progression.Music } }) do for _, item in ipairs(catalogue.items) do local row = list:AddLine(catalogue.itemType, item.id, item.name, CurrentPrice(catalogue.itemType, item)) row.CustomCoopItemType = catalogue.itemType row.CustomCoopItemID = item.id end end local selectedRow list.OnRowSelected = function(_, _, row) selectedRow = row priceInput:SetValue(tonumber(row:GetColumnText(4)) or 0) save:SetEnabled(true) end save.DoClick = function() if not IsValid(selectedRow) then return end SendPriceChange(selectedRow.CustomCoopItemType, selectedRow.CustomCoopItemID, priceInput:GetValue()) save:SetEnabled(false) save:SetText("Saving...") end sheet:AddSheet("Admin Prices", page) end local function OpenShop() if IsValid(shopFrame) then shopFrame:Remove() end shopFrame = vgui.Create("DFrame") shopFrame:SetSize(math.min(ScrW() - 40, 850), math.min(ScrH() - 40, 650)) shopFrame:Center() shopFrame:SetTitle("") shopFrame:ShowCloseButton(true) shopFrame:MakePopup() shopFrame.Paint = function(_, width, height) draw.RoundedBox(10, 0, 0, width, height, colors.background) draw.SimpleText("CO-OP REWARDS SHOP", "CustomCoopProgressionTitle", 18, 16, colors.text) end local summary = AddText( shopFrame, string.format( "Lifetime Score: %d Credits: %d Rank: %s", shopState.score, shopState.credits, Progression.GetRank(shopState.score) ), "CustomCoopProgressionBody", colors.accent ) summary:SetPos(19, 52) summary:SetSize(shopFrame:GetWide() - 38, 25) local help = AddText( shopFrame, "Kills add to both totals. Purchases and deaths spend Credits only; Lifetime Score and rank never decrease.", "CustomCoopProgressionSmall", colors.muted ) help:SetPos(19, 77) help:SetSize(shopFrame:GetWide() - 38, 20) local sheet = vgui.Create("DPropertySheet", shopFrame) sheet:SetPos(14, 104) sheet:SetSize(shopFrame:GetWide() - 28, shopFrame:GetTall() - 118) CreateCataloguePage(sheet, "Player Models", "model", Progression.Models) CreateCataloguePage(sheet, "Bonus Weapons", "weapon", Progression.Weapons) CreateCataloguePage(sheet, "Music", "music", Progression.Music) if shopState.isAdmin then CreateAdminPage(sheet) end end local function ReadShopPrices() local prices = {} for _ = 1, net.ReadUInt(8) do local itemType = net.ReadString() local itemID = net.ReadString() prices[itemType .. ":" .. itemID] = net.ReadUInt(32) end return prices end net.Receive("CustomCoopProgressionOpenShop", function() shopState.score = net.ReadUInt(32) shopState.credits = net.ReadUInt(32) shopState.equippedModel = net.ReadString() shopState.equippedWeapon = net.ReadString() shopState.ownedModels = { [Progression.DefaultModelID] = true } shopState.ownedWeapons = { [Progression.DefaultWeaponID] = true } shopState.ownedMusic = {} for _ = 1, net.ReadUInt(8) do local itemType = net.ReadString() local itemID = net.ReadString() if itemType == "model" then shopState.ownedModels[itemID] = true elseif itemType == "weapon" then shopState.ownedWeapons[itemID] = true elseif itemType == "music" then shopState.ownedMusic[itemID] = true end end shopState.isAdmin = net.ReadBool() shopState.prices = ReadShopPrices() OpenShop() end) net.Receive("CustomCoopProgressionPricesChanged", function() shopState.prices = ReadShopPrices() if IsValid(shopFrame) then OpenShop() end notification.AddLegacy("Shop prices were updated by an administrator.", NOTIFY_GENERIC, 4) end) net.Receive("CustomCoopProgressionNotice", function() local success = net.ReadBool() local message = net.ReadString() notification.AddLegacy(message, success and NOTIFY_GENERIC or NOTIFY_ERROR, 4) surface.PlaySound(success and "buttons/button15.wav" or "buttons/button10.wav") end) local function ToggleMusicMute() local current = musicVolume:GetFloat() if current > 0.001 then previousMusicVolume = current RunConsoleCommand("coop_music_volume", "0") notification.AddLegacy("Shop music muted. Use !musicmute to turn it back on.", NOTIFY_GENERIC, 4) else RunConsoleCommand("coop_music_volume", tostring(math.max(previousMusicVolume, 0.1))) notification.AddLegacy("Shop music enabled.", NOTIFY_GENERIC, 4) end end concommand.Add("coop_music_mute", ToggleMusicMute) cvars.AddChangeCallback("coop_music_volume", function(_, _, newValue) if IsValid(activeMusicChannel) then activeMusicChannel:SetVolume(math.Clamp(tonumber(newValue) or 0, 0, 1)) end end, "CustomCoopProgressionMusicVolume") net.Receive("CustomCoopProgressionToggleMusicMute", ToggleMusicMute) net.Receive("CustomCoopProgressionPlayMusic", function() local itemID = net.ReadString() local sourcePlayer = net.ReadEntity() local buyerName = net.ReadString() local item = Progression.GetMusic(itemID) if not item then return end local soundPath = "sound/" .. item.sound if not file.Exists(soundPath, "GAME") then notification.AddLegacy( "Music file missing on this client. Reconnect once to download server music.", NOTIFY_ERROR, 8 ) return end if IsValid(activeMusicChannel) then activeMusicChannel:Stop() activeMusicChannel = nil end activeMusicSource = nil activeMusicIsSpatial = item.spatial == true local flags = activeMusicIsSpatial and "3d noplay" or "noplay" sound.PlayFile(soundPath, flags, function(channel, errorID, errorName) if not IsValid(channel) then notification.AddLegacy( "Could not play " .. item.name .. ": " .. tostring(errorName or errorID), NOTIFY_ERROR, 6 ) return end activeMusicChannel = channel activeMusicSource = sourcePlayer local volume = math.Clamp(musicVolume:GetFloat(), 0, 1) channel:SetVolume(volume) if activeMusicIsSpatial and IsValid(activeMusicSource) then local minimumDistance = math.max(tonumber(item.minimumDistance) or 120, 1) local maximumDistance = math.max(tonumber(item.maximumDistance) or 2200, minimumDistance + 1) channel:Set3DFadeDistance(minimumDistance, maximumDistance) channel:SetPos(activeMusicSource:WorldSpaceCenter()) end channel:Play() if volume <= 0.001 then notification.AddLegacy( buyerName .. " is playing " .. item.name .. ", but your shop music is muted. Type !musicmute to hear it.", NOTIFY_ERROR, 7 ) else notification.AddLegacy(buyerName .. " is playing " .. item.name .. ".", NOTIFY_GENERIC, 5) end end) end) hook.Add("Think", "CustomCoopProgressionMoveMusicSource", function() if not activeMusicIsSpatial or not IsValid(activeMusicChannel) then return end if not IsValid(activeMusicSource) then return end activeMusicChannel:SetPos(activeMusicSource:WorldSpaceCenter()) end) local previousScore local gainedScore = 0 local gainExpires = 0 hook.Add("HUDPaint", "CustomCoopProgressionHUD", function() local ply = LocalPlayer() if not IsValid(ply) then return end local score = ply:GetNW2Int("CustomCoopScore", -1) if score < 0 then return end if previousScore and score > previousScore then gainedScore = score - previousScore gainExpires = CurTime() + 2 surface.PlaySound("buttons/button14.wav") end previousScore = score local credits = ply:GetNW2Int("CustomCoopCredits", 0) local rank = ply:GetNW2String("CustomCoopRank", Progression.GetRank(score)) local width, height = 288, 66 local x, y = ScrW() - width - 18, 18 draw.RoundedBox(8, x, y, width, height, colors.background) draw.SimpleText(rank, "CustomCoopProgressionHeading", x + 12, y + 9, colors.accent) draw.SimpleText("Score " .. score, "CustomCoopProgressionBody", x + 12, y + 37, colors.text) draw.SimpleText("Credits " .. credits, "CustomCoopProgressionBody", x + 151, y + 37, colors.text) if CurTime() < gainExpires then draw.SimpleText("+" .. gainedScore, "CustomCoopProgressionHeading", x - 10, y + height / 2, colors.success, TEXT_ALIGN_RIGHT, TEXT_ALIGN_CENTER) end end) local function RefreshScoreboard(list) if not IsValid(list) then return end list:Clear() local players = player.GetAll() table.sort(players, function(left, right) return left:GetNW2Int("CustomCoopScore", 0) > right:GetNW2Int("CustomCoopScore", 0) end) for _, ply in ipairs(players) do list:AddLine( ply:Nick(), ply:GetNW2String("CustomCoopRank", "Newbie"), ply:GetNW2Int("CustomCoopScore", 0), ply:Ping() ) end end hook.Add("ScoreboardShow", "CustomCoopProgressionScoreboardShow", function() if IsValid(scoreboardFrame) then return true end scoreboardFrame = vgui.Create("DFrame") scoreboardFrame:SetSize(math.min(ScrW() - 60, 760), math.min(ScrH() - 80, 600)) scoreboardFrame:Center() scoreboardFrame:SetTitle("CO-OP LIFETIME SCORES") scoreboardFrame:ShowCloseButton(false) scoreboardFrame:SetDraggable(false) scoreboardFrame:SetKeyboardInputEnabled(false) scoreboardFrame:SetMouseInputEnabled(false) scoreboardFrame.Paint = function(_, width, height) draw.RoundedBox(8, 0, 0, width, height, colors.background) end local list = vgui.Create("DListView", scoreboardFrame) list:Dock(FILL) list:DockMargin(10, 8, 10, 10) list:AddColumn("Player") list:AddColumn("Rank") list:AddColumn("Lifetime Score") list:AddColumn("Ping") RefreshScoreboard(list) scoreboardFrame.NextRefresh = RealTime() + 0.5 scoreboardFrame.Think = function(self) if RealTime() < self.NextRefresh then return end self.NextRefresh = RealTime() + 0.5 RefreshScoreboard(list) end return true end) hook.Add("ScoreboardHide", "CustomCoopProgressionScoreboardHide", function() if IsValid(scoreboardFrame) then scoreboardFrame:Remove() end scoreboardFrame = nil return true end)