Files
garrysmod-coop/gamemode/cl_progression.lua
T

1158 lines
41 KiB
Lua

if not CLIENT then return end
local Progression = CustomCoopProgression
local shopFrame
local shopState = {
score = 0,
credits = 0,
playtime = 0,
equippedModel = Progression.DefaultModelID,
equippedWeapon = Progression.DefaultWeaponID,
equippedTrail = Progression.DefaultTrailID,
ownedModels = {},
ownedWeapons = {},
ownedTrails = {},
ownedMusic = {},
ownedBehaviours = {},
enabledBehaviours = {},
prices = {},
isAdmin = false,
players = {},
playersLoading = false,
playersHaveDetails = false
}
local shopViewState = {
activeTab = "Player Models"
}
local playersList
local playersStatus
local playerDetails
local playerRequestID = 0
local RefreshPlayersView
local RefreshShopFrame
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
})
-- The Base gamemode scoreboard uses these names. Recreate them after Base has
-- loaded so its standard rows, including names and ping, are easier to read.
surface.CreateFont("ScoreboardDefault", {
font = "Helvetica",
size = 24,
weight = 800
})
surface.CreateFont("ScoreboardDefaultTitle", {
font = "Helvetica",
size = 36,
weight = 800
})
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
local function RequestPlayers()
playerRequestID = (playerRequestID + 1) % 65536
shopState.players = {}
shopState.playersLoading = true
if IsValid(playersStatus) then playersStatus:SetText("Loading all-time player records...") end
if IsValid(playersList) then playersList:Clear() end
net.Start("CustomCoopProgressionRequestPlayers")
net.WriteUInt(playerRequestID, 16)
net.SendToServer()
end
local nextShopToggle = 0
local function ToggleShop()
if RealTime() < nextShopToggle then return end
nextShopToggle = RealTime() + 0.25
if IsValid(shopFrame) then
shopFrame:Close()
return
end
RequestShop()
end
concommand.Add("coop_shop", ToggleShop)
hook.Add("ShowSpare1", "CustomCoopProgressionF3", function()
ToggleShop()
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.
hook.Add("PlayerButtonDown", "CustomCoopProgressionF3Fallback", function(ply, button)
if ply ~= LocalPlayer() or button ~= KEY_F3 then return end
ToggleShop()
end)
local escapeWasDown = false
local f3WasDown = false
hook.Add("Think", "CustomCoopProgressionShopEscape", function()
local escapeDown = input.IsKeyDown(KEY_ESCAPE)
local f3Down = input.IsKeyDown(KEY_F3)
if f3Down and not f3WasDown and IsValid(shopFrame) then
ToggleShop()
end
if escapeDown and not escapeWasDown and IsValid(shopFrame) then
shopFrame:Close()
gui.HideGameUI()
timer.Simple(0, function()
if not IsValid(shopFrame) then gui.HideGameUI() end
end)
end
escapeWasDown = escapeDown
f3WasDown = f3Down
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 isBehaviour = itemType == "behaviour"
local isTrail = itemType == "trail"
local isEntity = itemType == "entity"
local isHelper = itemType == "helper"
local isSpawnPurchase = isEntity or isHelper
local isConsumable = isBehaviour and item.consumable == true
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 self.CustomCoopSelected 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)
local humanoidPreview = isModel or (isHelper and item.id ~= "rebel_turret")
preview:SetFOV(humanoidPreview and 30 or 38)
if humanoidPreview 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 isBehaviour and "PLAYER ABILITY"
or isTrail and "PLAYER TRAIL"
or isEntity and "DEPLOYABLE ENTITY"
or isHelper and "FRIENDLY NPC"
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 price = AddText(card, "", "CustomCoopProgressionBody", 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 function RefreshCard(clearPending)
if clearPending then button.CustomCoopPending = false end
local owned = isModel and shopState.ownedModels[item.id]
or isWeapon and shopState.ownedWeapons[item.id]
or isMusic and shopState.ownedMusic[item.id]
or isBehaviour and shopState.ownedBehaviours[item.id]
or isTrail and shopState.ownedTrails[item.id]
local selected = isModel and shopState.equippedModel == item.id
or isWeapon and shopState.equippedWeapon == item.id
or isBehaviour and shopState.enabledBehaviours[item.id]
or isTrail and shopState.equippedTrail == item.id
local currentPrice = CurrentPrice(itemType, item)
local actionText
if isMusic then
actionText = (owned or currentPrice == 0) and "Play" or "Buy"
elseif isSpawnPurchase then
actionText = "Buy & Spawn"
elseif isBehaviour then
actionText = isConsumable and "Activate"
or owned and (selected and "Disable" or "Enable")
or "Buy & Enable"
else
actionText = selected and "Equipped" or (owned or currentPrice == 0) and "Equip" or "Buy"
end
card.CustomCoopSelected = selected == true
price:SetText(currentPrice == 0 and "FREE" or (currentPrice .. " credits"))
price:SetTextColor(currentPrice <= shopState.credits and colors.success or colors.warning)
button.CustomCoopActionText = actionText
if not button.CustomCoopPending then
button:SetText(actionText)
button:SetEnabled(isBehaviour or not selected)
end
end
card.CustomCoopRefresh = RefreshCard
if IsValid(shopFrame) then
shopFrame.CustomCoopCards[#shopFrame.CustomCoopCards + 1] = card
end
button.DoClick = function()
button.CustomCoopPending = true
button:SetEnabled(false)
button:SetText("Please wait...")
SelectItem(itemType, item.id)
timer.Simple(1, function()
if IsValid(button) then
button.CustomCoopPending = false
RefreshCard()
end
end)
end
RefreshCard()
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
page.CustomCoopScroll = scroll
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 },
{ itemType = "behaviour", items = Progression.Behaviours },
{ itemType = "trail", items = Progression.Trails },
{ itemType = "entity", items = Progression.Entities },
{ itemType = "helper", items = Progression.Helpers }
}) 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
row.CustomCoopItem = item
shopFrame.CustomCoopPriceRows[#shopFrame.CustomCoopPriceRows + 1] = row
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
shopFrame.CustomCoopPriceSave = save
shopFrame.CustomCoopSelectedPriceRow = function()
return selectedRow
end
sheet:AddSheet("Admin Prices", page)
end
local function CatalogueItemName(itemType, itemID)
if not itemID or itemID == "" or itemID == "none" then return "None" end
local item = itemType == "model" and Progression.GetModel(itemID)
or itemType == "weapon" and Progression.GetWeapon(itemID)
or itemType == "music" and Progression.GetMusic(itemID)
or itemType == "behaviour" and Progression.GetBehaviour(itemID)
or itemType == "trail" and Progression.GetTrail(itemID)
or itemType == "entity" and Progression.GetEntity(itemID)
or itemType == "helper" and Progression.GetHelper(itemID)
return item and item.name or itemID
end
local function DescribeAdminPlayer(record)
local purchases = {}
for _, purchase in ipairs(record.purchases or {}) do
local purchasedAt = purchase.purchasedAt and purchase.purchasedAt > 0
and os.date("%Y-%m-%d", purchase.purchasedAt)
or "unknown date"
purchases[#purchases + 1] = string.format(
"%s: %s (%s)",
string.upper(string.sub(purchase.itemType or "item", 1, 1)) .. string.sub(purchase.itemType or "item", 2),
CatalogueItemName(purchase.itemType, purchase.itemID),
purchasedAt
)
end
local enabled = {}
for _, itemID in ipairs(record.enabledBehaviours or {}) do
enabled[#enabled + 1] = CatalogueItemName("behaviour", itemID)
end
return string.format(
"Equipped: %s model | %s weapon | %s trail\nEnabled: %s\nPermanent purchases: %s",
CatalogueItemName("model", record.equippedModel),
CatalogueItemName("weapon", record.equippedWeapon),
CatalogueItemName("trail", record.equippedTrail),
#enabled > 0 and table.concat(enabled, ", ") or "None",
#purchases > 0 and table.concat(purchases, ", ") or "None"
)
end
local function OpenSteamProfile(record)
local steamID64 = tostring(record and record.steamID64 or "")
if #steamID64 ~= 17 or not string.match(steamID64, "^%d+$") then
notification.AddLegacy("That player has no valid Steam profile ID.", NOTIFY_ERROR, 4)
return
end
gui.OpenURL("https://steamcommunity.com/profiles/" .. steamID64)
end
RefreshPlayersView = function()
if not IsValid(playersList) then return end
playersList:Clear()
for position, record in ipairs(shopState.players) do
local baseColumns = {
position,
record.playerName,
record.online and "Online" or "Offline",
Progression.GetRank(record.score),
record.score,
record.credits,
Progression.FormatPlaytime(record.playtime)
}
local row
if shopState.playersHaveDetails then
row = playersList:AddLine(
position,
record.playerName,
record.online and "Online" or "Offline",
Progression.GetRank(record.score),
record.score,
record.credits,
Progression.FormatPlaytime(record.playtime),
record.steamID64,
CatalogueItemName("model", record.equippedModel),
CatalogueItemName("weapon", record.equippedWeapon),
CatalogueItemName("trail", record.equippedTrail),
#(record.purchases or {})
)
row:SetTooltip(DescribeAdminPlayer(record))
else
row = playersList:AddLine(unpack(baseColumns))
end
row.CustomCoopPlayer = record
end
if IsValid(playersStatus) then
local suffix = shopState.playersLoading and " (loading...)" or ""
playersStatus:SetText(string.format(
"%d all-time player%s%s — live ranking; right-click a row to open its Steam profile",
#shopState.players,
#shopState.players == 1 and "" or "s",
suffix
))
end
end
local function CreatePlayersPage(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 toolbar = vgui.Create("DPanel", page)
toolbar:Dock(TOP)
toolbar:SetTall(34)
toolbar:DockMargin(0, 0, 0, 8)
toolbar.Paint = nil
playersStatus = AddText(toolbar, "Loading all-time player records...", "CustomCoopProgressionSmall", colors.muted)
playersStatus:Dock(FILL)
playersStatus:SetContentAlignment(4)
if shopState.isAdmin then
local detailsPanel = vgui.Create("DPanel", page)
detailsPanel:Dock(BOTTOM)
detailsPanel:SetTall(92)
detailsPanel:DockMargin(0, 8, 0, 0)
detailsPanel:DockPadding(10, 8, 10, 8)
detailsPanel.Paint = function(_, width, height)
draw.RoundedBox(5, 0, 0, width, height, colors.panel)
end
playerDetails = AddText(
detailsPanel,
"Select a player to inspect equipped items, enabled behaviours, and permanent purchases.",
"CustomCoopProgressionSmall",
colors.muted
)
playerDetails:Dock(FILL)
end
playersList = vgui.Create("DListView", page)
playersList:Dock(FILL)
playersList:SetMultiSelect(false)
playersList:AddColumn("#"):SetFixedWidth(36)
playersList:AddColumn("Name")
playersList:AddColumn("Status"):SetFixedWidth(65)
playersList:AddColumn("Rank"):SetFixedWidth(105)
playersList:AddColumn("XP / Score"):SetFixedWidth(90)
playersList:AddColumn("Credits"):SetFixedWidth(75)
playersList:AddColumn("Played"):SetFixedWidth(90)
if shopState.isAdmin then
playersList:AddColumn("SteamID64"):SetFixedWidth(145)
playersList:AddColumn("Model"):SetFixedWidth(115)
playersList:AddColumn("Weapon"):SetFixedWidth(125)
playersList:AddColumn("Trail"):SetFixedWidth(110)
playersList:AddColumn("Owned"):SetFixedWidth(55)
playersList.OnRowSelected = function(_, _, row)
if IsValid(playerDetails) and row.CustomCoopPlayer then
playerDetails:SetText(DescribeAdminPlayer(row.CustomCoopPlayer))
end
end
end
playersList.OnRowRightClick = function(_, _, row)
local record = row.CustomCoopPlayer
if not record then return end
local menu = DermaMenu()
menu:AddOption("Open Steam profile / add friend", function()
OpenSteamProfile(record)
end)
menu:AddOption("Copy SteamID64", function()
SetClipboardText(record.steamID64)
end)
menu:Open()
end
playersList.DoDoubleClick = function(_, _, row)
if row.CustomCoopPlayer then OpenSteamProfile(row.CustomCoopPlayer) end
end
sheet:AddSheet("Players", page)
RefreshPlayersView()
timer.Simple(0.3, function()
if IsValid(page) then RequestPlayers() end
end)
end
local nextLivePlayerUpdate = 0
hook.Add("Think", "CustomCoopProgressionLivePlayerList", function()
if not IsValid(playersList) or RealTime() < nextLivePlayerUpdate then return end
nextLivePlayerUpdate = RealTime() + 0.25
local recordsBySteamID = {}
for _, record in ipairs(shopState.players) do
recordsBySteamID[record.steamID64] = record
end
local onlineSteamIDs = {}
local changed = false
for _, ply in ipairs(player.GetAll()) do
if IsValid(ply) and not ply:IsBot() then
local steamID64 = ply:SteamID64()
if steamID64 and steamID64 ~= "" and steamID64 ~= "0" then
onlineSteamIDs[steamID64] = true
local record = recordsBySteamID[steamID64]
if not record then
record = {
steamID64 = steamID64,
playerName = ply:Nick(),
online = true,
score = 0,
credits = 0,
playtime = 0
}
shopState.players[#shopState.players + 1] = record
recordsBySteamID[steamID64] = record
changed = true
end
local playerName = ply:Nick()
local score = ply:GetNW2Int("CustomCoopScore", record.score)
local credits = ply:GetNW2Int("CustomCoopCredits", record.credits)
local playtime = ply:GetNW2Int("CustomCoopPlaytime", record.playtime)
if record.playerName ~= playerName or not record.online
or record.score ~= score or record.credits ~= credits or record.playtime ~= playtime then
record.playerName = playerName
record.online = true
record.score = score
record.credits = credits
record.playtime = playtime
changed = true
end
end
end
end
for _, record in ipairs(shopState.players) do
if record.online and not onlineSteamIDs[record.steamID64] then
record.online = false
changed = true
end
end
if not changed then return end
table.sort(shopState.players, function(left, right)
if left.score ~= right.score then return left.score > right.score end
if left.playtime ~= right.playtime then return left.playtime > right.playtime end
return string.lower(left.playerName) < string.lower(right.playerName)
end)
RefreshPlayersView()
end)
RefreshShopFrame = function()
if not IsValid(shopFrame) then return end
for _, card in ipairs(shopFrame.CustomCoopCards or {}) do
if IsValid(card) and card.CustomCoopRefresh then card.CustomCoopRefresh(true) end
end
for _, row in ipairs(shopFrame.CustomCoopPriceRows or {}) do
if IsValid(row) and row.CustomCoopItem then
row:SetColumnText(4, CurrentPrice(row.CustomCoopItemType, row.CustomCoopItem))
end
end
if IsValid(shopFrame.CustomCoopPriceSave) then
shopFrame.CustomCoopPriceSave:SetText("Save selected price")
shopFrame.CustomCoopPriceSave:SetEnabled(
IsValid(shopFrame.CustomCoopSelectedPriceRow and shopFrame.CustomCoopSelectedPriceRow())
)
end
end
local function OpenShop()
if IsValid(shopFrame) then
RefreshShopFrame()
return
end
playersList = nil
playersStatus = nil
playerDetails = nil
shopFrame = vgui.Create("DFrame")
shopFrame:SetSize(math.min(ScrW() - 40, shopState.isAdmin and 1240 or 850), math.min(ScrH() - 40, 650))
shopFrame:Center()
shopFrame:SetTitle("")
shopFrame:ShowCloseButton(true)
shopFrame:MakePopup()
shopFrame.CustomCoopCards = {}
shopFrame.CustomCoopPriceRows = {}
shopFrame.OnKeyCodePressed = function(self, keyCode)
if keyCode == KEY_F3 then
ToggleShop()
elseif keyCode == KEY_ESCAPE then
self:Close()
gui.HideGameUI()
end
end
shopFrame.OnClose = function()
shopFrame = nil
end
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 Played: %s",
shopState.score,
shopState.credits,
Progression.GetRank(shopState.score),
Progression.FormatPlaytime(shopState.playtime)
),
"CustomCoopProgressionBody",
colors.accent
)
summary:SetPos(19, 52)
summary:SetSize(shopFrame:GetWide() - 38, 25)
summary.Think = function(self)
local ply = LocalPlayer()
if not IsValid(ply) then return end
self:SetText(string.format(
"Lifetime Score: %d Credits: %d Rank: %s Played: %s",
ply:GetNW2Int("CustomCoopScore", shopState.score),
ply:GetNW2Int("CustomCoopCredits", shopState.credits),
ply:GetNW2String("CustomCoopRank", Progression.GetRank(shopState.score)),
Progression.FormatPlaytime(ply:GetNW2Int("CustomCoopPlaytime", shopState.playtime))
))
end
local help = AddText(
shopFrame,
string.format(
"Harder enemies pay more. Playing %s earns %d Credits. Purchases and deaths spend Credits only.",
Progression.FormatPlaytime(Progression.PlaytimeRewardInterval),
Progression.PlaytimeCreditsPerInterval
),
"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)
shopFrame.CustomCoopSheet = sheet
CreateCataloguePage(sheet, "Player Models", "model", Progression.Models)
CreateCataloguePage(sheet, "Bonus Weapons", "weapon", Progression.Weapons)
CreateCataloguePage(sheet, "Music", "music", Progression.Music)
CreateCataloguePage(sheet, "Behaviours", "behaviour", Progression.Behaviours)
CreateCataloguePage(sheet, "Player Trails", "trail", Progression.Trails)
CreateCataloguePage(sheet, "Entities", "entity", Progression.Entities)
CreateCataloguePage(sheet, "Helping NPCs", "helper", Progression.Helpers)
CreatePlayersPage(sheet)
if shopState.isAdmin then
CreateAdminPage(sheet)
end
sheet.OnActiveTabChanged = function(_, _, newTab)
if IsValid(newTab) then shopViewState.activeTab = newTab:GetText() end
end
for _, item in ipairs(sheet.Items or {}) do
if IsValid(item.Tab) and item.Tab:GetText() == shopViewState.activeTab then
sheet:SetActiveTab(item.Tab)
break
end
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.playtime = net.ReadUInt(32)
shopState.equippedModel = net.ReadString()
shopState.equippedWeapon = net.ReadString()
shopState.equippedTrail = net.ReadString()
shopState.ownedModels = { [Progression.DefaultModelID] = true }
shopState.ownedWeapons = { [Progression.DefaultWeaponID] = true }
shopState.ownedTrails = { [Progression.DefaultTrailID] = true }
shopState.ownedMusic = {}
shopState.ownedBehaviours = {}
shopState.enabledBehaviours = {}
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
elseif itemType == "behaviour" then
shopState.ownedBehaviours[itemID] = true
elseif itemType == "trail" then
shopState.ownedTrails[itemID] = true
end
end
for _ = 1, net.ReadUInt(8) do
shopState.enabledBehaviours[net.ReadString()] = true
end
shopState.isAdmin = net.ReadBool()
shopState.prices = ReadShopPrices()
if IsValid(shopFrame) then
RefreshShopFrame()
else
OpenShop()
end
end)
net.Receive("CustomCoopProgressionPricesChanged", function()
shopState.prices = ReadShopPrices()
RefreshShopFrame()
notification.AddLegacy("Shop prices were updated by an administrator.", NOTIFY_GENERIC, 4)
end)
net.Receive("CustomCoopProgressionPlayers", function()
local requestID = net.ReadUInt(16)
if requestID ~= playerRequestID then return end
local reset = net.ReadBool()
local finished = net.ReadBool()
local hasDetails = net.ReadBool()
local count = net.ReadUInt(8)
if reset then
shopState.players = {}
shopState.playersHaveDetails = hasDetails
end
for _ = 1, count do
local record = {
steamID64 = net.ReadString(),
playerName = net.ReadString(),
online = net.ReadBool(),
score = net.ReadUInt(32),
credits = net.ReadUInt(32),
playtime = net.ReadUInt(32)
}
if hasDetails then
record.equippedModel = net.ReadString()
record.equippedWeapon = net.ReadString()
record.equippedTrail = net.ReadString()
record.purchases = {}
record.enabledBehaviours = {}
for _ = 1, net.ReadUInt(8) do
record.purchases[#record.purchases + 1] = {
itemType = net.ReadString(),
itemID = net.ReadString(),
purchasedAt = net.ReadUInt(32)
}
end
for _ = 1, net.ReadUInt(8) do
record.enabledBehaviours[#record.enabledBehaviours + 1] = net.ReadString()
end
end
shopState.players[#shopState.players + 1] = record
end
shopState.playersLoading = not finished
RefreshPlayersView()
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 playtime = ply:GetNW2Int("CustomCoopPlaytime", 0)
local rank = ply:GetNW2String("CustomCoopRank", Progression.GetRank(score))
local hoverpackEnabled = ply:GetNW2Bool("CustomCoopHoverpackEnabled", false)
local hoverpackThrusting = ply:GetNW2Bool("CustomCoopHoverpackThrusting", false)
local godmodeSeconds = math.max(math.ceil(ply:GetNW2Float("CustomCoopGodmodeUntil", 0) - CurTime()), 0)
local width = 288
local height = 88 + (hoverpackEnabled and 24 or 0) + (godmodeSeconds > 0 and 24 or 0)
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("Press F3 for Shop", "CustomCoopProgressionSmall", x + width - 12, y + 13,
colors.muted, TEXT_ALIGN_RIGHT)
draw.SimpleText("Score " .. score, "CustomCoopProgressionBody", x + 12, y + 37, colors.text)
draw.SimpleText("Credits " .. credits, "CustomCoopProgressionBody", x + 151, y + 37, colors.text)
draw.SimpleText("Played " .. Progression.FormatPlaytime(playtime), "CustomCoopProgressionSmall",
x + 12, y + 64, colors.muted)
local statusY = y + 86
if hoverpackEnabled then
local suitPower = ply.GetSuitPower and math.Round(ply:GetSuitPower()) or 100
local status = hoverpackThrusting and ("THRUSTING — AUX " .. suitPower .. "%")
or suitPower <= 0 and "EMPTY — release Space to recharge"
or ("READY — AUX " .. suitPower .. "%")
draw.SimpleText("Hoverpack: " .. status, "CustomCoopProgressionSmall", x + 12, statusY,
hoverpackThrusting and colors.success or colors.muted)
statusY = statusY + 24
end
if godmodeSeconds > 0 then
draw.SimpleText("God Mode: " .. godmodeSeconds .. "s remaining", "CustomCoopProgressionSmall",
x + 12, statusY, colors.success)
end
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 AddProgressionToOfficialRow(ply, row)
if row.CustomCoopProgressionVersion == 2 then return end
row.CustomCoopProgressionVersion = 2
row.CustomCoopProgressionAdded = true
if not IsValid(row.CustomCoopScore) then
row.CustomCoopScore = row:Add("DLabel")
row.CustomCoopScore:Dock(RIGHT)
row.CustomCoopScore:SetWide(140)
row.CustomCoopScore:SetFont("ScoreboardDefault")
row.CustomCoopScore:SetTextColor(Color(75, 115, 155))
row.CustomCoopScore:SetContentAlignment(5)
row.CustomCoopScore:SetTooltip("Lifetime Score")
end
if not IsValid(row.CustomCoopRank) then
row.CustomCoopRank = row:Add("DLabel")
row.CustomCoopRank:Dock(RIGHT)
row.CustomCoopRank:SetWide(165)
row.CustomCoopRank:SetFont("ScoreboardDefault")
row.CustomCoopRank:SetTextColor(Color(75, 115, 155))
row.CustomCoopRank:SetContentAlignment(5)
row.CustomCoopRank:SetTooltip("Co-op Rank")
end
row.CustomCoopPlaytime = row:Add("DLabel")
row.CustomCoopPlaytime:Dock(RIGHT)
row.CustomCoopPlaytime:SetWide(145)
row.CustomCoopPlaytime:SetFont("ScoreboardDefault")
row.CustomCoopPlaytime:SetTextColor(Color(75, 115, 155))
row.CustomCoopPlaytime:SetContentAlignment(5)
row.CustomCoopPlaytime:SetTooltip("Total Playtime")
if IsValid(row.Name) then row.Name:SetFont("ScoreboardDefault") end
row:SetTall(42)
local originalThink = row.Think
row.Think = function(self)
if originalThink then originalThink(self) end
if not IsValid(ply) then return end
local score = ply:GetNW2Int("CustomCoopScore", 0)
self.CustomCoopScore:SetText("Score " .. score)
self.CustomCoopRank:SetText(ply:GetNW2String("CustomCoopRank", "Newbie"))
self.CustomCoopPlaytime:SetText("Played " .. Progression.FormatPlaytime(
ply:GetNW2Int("CustomCoopPlaytime", 0)
))
-- Keep the official row behavior, but order players by lifetime score.
self:SetZPos(-math.Clamp(score, 0, 32000) + ply:EntIndex())
end
end
local function ExtendOfficialScoreboard()
if not IsValid(g_Scoreboard) then return end
if g_Scoreboard.CustomCoopProgressionVersion ~= 2 then
g_Scoreboard.CustomCoopProgressionVersion = 2
g_Scoreboard.CustomCoopProgressionAdded = true
g_Scoreboard.PerformLayout = function(self)
local width = math.min(ScrW() - 40, 1120)
local height = math.max(ScrH() - 160, 320)
self:SetSize(width, height)
self:SetPos((ScrW() - width) / 2, (ScrH() - height) / 2)
end
if IsValid(g_Scoreboard.Name) then
g_Scoreboard.Name:SetFont("ScoreboardDefaultTitle")
end
g_Scoreboard:InvalidateLayout(true)
end
for _, ply in ipairs(player.GetAll()) do
if IsValid(ply.ScoreEntry) then
AddProgressionToOfficialRow(ply, ply.ScoreEntry)
end
end
end
local scoreboardRequested = false
local scoreboardShownAt = 0
local nextScoreboardRefresh = 0
-- Returning nil lets Base's normal scoreboard create and manage g_Scoreboard.
hook.Add("ScoreboardShow", "CustomCoopProgressionScoreboardShow", function()
scoreboardRequested = true
scoreboardShownAt = RealTime()
timer.Simple(0, ExtendOfficialScoreboard)
end)
hook.Add("ScoreboardHide", "CustomCoopProgressionScoreboardHide", function()
scoreboardRequested = false
if IsValid(g_Scoreboard) then g_Scoreboard:Hide() end
end)
hook.Add("Think", "CustomCoopProgressionExtendOfficialScoreboard", function()
if not scoreboardRequested or not IsValid(g_Scoreboard) then return end
-- Recover if the key-release event is lost while focus or UI state changes.
if RealTime() > scoreboardShownAt + 0.15 and not input.IsKeyDown(KEY_TAB) then
scoreboardRequested = false
g_Scoreboard:Hide()
return
end
if RealTime() < nextScoreboardRefresh then return end
nextScoreboardRefresh = RealTime() + 0.25
ExtendOfficialScoreboard()
end)