From 160517f9b6213ead4bbc95d0929e523357d75873 Mon Sep 17 00:00:00 2001 From: 0xFEEDC0DE64 Date: Mon, 7 Sep 2026 08:03:24 +0200 Subject: [PATCH] Add Trails to shop --- README.md | 29 +++-- gamemode/cl_progression.lua | 112 ++++++++++++---- gamemode/progression_shared.lua | 207 ++++++++++++++++++++++-------- gamemode/sv_progression.lua | 220 +++++++++++++++++++++++++++++--- 4 files changed, 465 insertions(+), 103 deletions(-) diff --git a/README.md b/README.md index 3e6abfb..f983a9b 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,12 @@ ## Persistent progression and rewards shop -Players earn one Lifetime Score and one spendable Credit for every eligible -monster kill. Lifetime Score determines rank and never decreases. Shop -purchases deduct Credits only. Data is committed immediately to Garry's Mod's -SQLite database at `garrysmod/sv.db`. +Players earn Lifetime Score and spendable Credits for eligible monster kills; +harder enemies award more. Lifetime Score determines rank and never decreases. +Every ten connected minutes also awards five Credits. Shop purchases deduct +Credits only. Changes remain in memory during active play and are written to +Garry's Mod's SQLite database at `garrysmod/sv.db` only when the server is +empty or shutting down, avoiding gameplay stalls from synchronous SQLite I/O. Dying deducts up to five Credits, stopping at zero. It never deducts Lifetime Score and therefore cannot lower a player's rank. @@ -13,11 +15,11 @@ Score and therefore cannot lower a player's rank. Player controls: - `F3`, chat `!shop`, or client console `coop_shop`: open the rewards shop -- Chat `!points`: print personal Score, Credits, and rank +- Chat `!points`: print personal Score, Credits, rank, and total playtime - Chat `!musicmute` or client console `coop_music_mute`: toggle purchased server music locally - Client console `coop_music_volume 0.0` through `coop_music_volume 1.0`: set music volume - With Hoverpack enabled, jump normally and continue holding `Space` to hover -- Hold `TAB`: compare all connected players' Lifetime Scores and ranks +- Hold `TAB`: compare connected players' Lifetime Scores, ranks, and total playtime The shop contains persistent Half-Life 2 player models and permanent bonus weapon unlocks. Only one bonus weapon is equipped at a time. It also contains a @@ -46,6 +48,15 @@ Credits by default and immediately grants 90 seconds of server-enforced damage immunity. It creates no ownership record. Attempting to activate it while it is already running reports the remaining time and does not spend Credits. +A cheaper 30-second God Mode is available for 400 Credits by default. It follows +the same rules and does not stack with an already active God Mode timer. + +The Player Trails category contains permanent trail unlocks with one equipped at +a time. The included choices use Garry's Mod's built-in LOL, love, plasma, +electric, laser, and smoke materials, so they require no extra downloads. The +rainbow plasma trail cycles colour continuously. Equipping `No Trail` disables +the effect without removing purchased trails. + The server checks all kills, balances, live prices, ownership, equipment, and music playback. A purchase request contains only the item type and ID: clients cannot award points, submit a purchase price, or debit their own Credits. @@ -59,7 +70,7 @@ their current non-negative value. Set `Progression.AdminShopTestingBypass` to `false` to disable this behavior. The equivalent server/admin console command is: -`coop_setprice ` +`coop_setprice ` For example: `coop_setprice music song_id 500`. The maximum accepted price is 100,000,000 Credits. Existing owners retain their unlocks when a price changes. @@ -71,9 +82,11 @@ Configuration is in `gamemode/progression_shared.lua`: - `Progression.Weapons`: bonus-weapon catalogue and prices - `Progression.Music`: music catalogue, default prices, paths, and durations - `Progression.Behaviours`: toggleable ability catalogue and default prices +- `Progression.Trails`: player-trail materials, colours, properties, and prices - `Progression.Hoverpack*`: hover delay, rise speed, AUX drain/recharge rates, and thruster sound settings -- `Progression.MonsterClasses`: NPC classes that award points +- `Progression.Playtime*`: in-memory update rate, reward interval, and Credits +- `Progression.MonsterClasses`: NPC classes and their Score/Credit rewards Catalogue prices are initial defaults. Once seeded, live prices come from the `custom_coop_shop_prices` SQLite table so in-game edits survive crashes and diff --git a/gamemode/cl_progression.lua b/gamemode/cl_progression.lua index 14f700a..5c2b428 100644 --- a/gamemode/cl_progression.lua +++ b/gamemode/cl_progression.lua @@ -5,10 +5,13 @@ 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 = {}, @@ -107,8 +110,15 @@ hook.Add("PlayerButtonDown", "CustomCoopProgressionF3Fallback", function(ply, bu 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() @@ -117,6 +127,7 @@ hook.Add("Think", "CustomCoopProgressionShopEscape", function() end) end escapeWasDown = escapeDown + f3WasDown = f3Down end) local function SelectItem(itemType, itemID) @@ -146,14 +157,17 @@ local function AddItemCard(layout, itemType, item) local isWeapon = itemType == "weapon" local isMusic = itemType == "music" local isBehaviour = itemType == "behaviour" + local isTrail = itemType == "trail" local isConsumable = isBehaviour and item.consumable == true 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 card = layout:Add("DPanel") @@ -194,6 +208,7 @@ local function AddItemCard(layout, itemType, item) else local previewText = isMusic and "SERVER MUSIC" or isBehaviour and "PLAYER ABILITY" + or isTrail and "PLAYER TRAIL" or isWeapon and "MAP LOADOUT" or "NO PREVIEW" local noPreview = AddText(card, previewText, "CustomCoopProgressionHeading", colors.muted) @@ -324,7 +339,8 @@ local function CreateAdminPage(sheet) { itemType = "model", items = Progression.Models }, { itemType = "weapon", items = Progression.Weapons }, { itemType = "music", items = Progression.Music }, - { itemType = "behaviour", items = Progression.Behaviours } + { itemType = "behaviour", items = Progression.Behaviours }, + { itemType = "trail", items = Progression.Trails } }) do for _, item in ipairs(catalogue.items) do local row = list:AddLine(catalogue.itemType, item.id, item.name, CurrentPrice(catalogue.itemType, item)) @@ -359,6 +375,14 @@ local function OpenShop() shopFrame:SetTitle("") shopFrame:ShowCloseButton(true) shopFrame:MakePopup() + 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 @@ -370,20 +394,36 @@ local function OpenShop() local summary = AddText( shopFrame, string.format( - "Lifetime Score: %d Credits: %d Rank: %s", + "Lifetime Score: %d Credits: %d Rank: %s Played: %s", shopState.score, shopState.credits, - Progression.GetRank(shopState.score) + 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, - "Kills add to both totals. Purchases and deaths spend Credits only; Lifetime Score and rank never decrease.", + 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 ) @@ -398,6 +438,7 @@ local function OpenShop() 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) if shopState.isAdmin then CreateAdminPage(sheet) end end @@ -414,10 +455,13 @@ 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 = {} @@ -433,6 +477,8 @@ net.Receive("CustomCoopProgressionOpenShop", function() shopState.ownedMusic[itemID] = true elseif itemType == "behaviour" then shopState.ownedBehaviours[itemID] = true + elseif itemType == "trail" then + shopState.ownedTrails[itemID] = true end end @@ -564,12 +610,13 @@ hook.Add("HUDPaint", "CustomCoopProgressionHUD", function() 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 = 66 + (hoverpackEnabled and 24 or 0) + (godmodeSeconds > 0 and 24 or 0) + 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) @@ -578,8 +625,10 @@ hook.Add("HUDPaint", "CustomCoopProgressionHUD", function() 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 + 64 + 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 .. "%") @@ -601,24 +650,37 @@ hook.Add("HUDPaint", "CustomCoopProgressionHUD", function() end) local function AddProgressionToOfficialRow(ply, row) - if row.CustomCoopProgressionAdded then return end + if row.CustomCoopProgressionVersion == 2 then return end + row.CustomCoopProgressionVersion = 2 row.CustomCoopProgressionAdded = true - 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") + 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 - 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") + 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) @@ -631,6 +693,9 @@ local function AddProgressionToOfficialRow(ply, row) 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()) @@ -640,10 +705,11 @@ end local function ExtendOfficialScoreboard() if not IsValid(g_Scoreboard) then return end - if not g_Scoreboard.CustomCoopProgressionAdded then + 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, 980) + 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) diff --git a/gamemode/progression_shared.lua b/gamemode/progression_shared.lua index e49b047..bef37bb 100644 --- a/gamemode/progression_shared.lua +++ b/gamemode/progression_shared.lua @@ -7,8 +7,12 @@ local Progression = CustomCoopProgression Progression.KillReward = 1 Progression.DeathCreditCost = 5 +Progression.PlaytimeUpdateInterval = 10 +Progression.PlaytimeRewardInterval = 600 +Progression.PlaytimeCreditsPerInterval = 5 Progression.DefaultModelID = "kleiner" Progression.DefaultWeaponID = "none" +Progression.DefaultTrailID = "none" Progression.MaximumShopPrice = 100000000 Progression.MusicCooldownPadding = 10 Progression.AdminShopTestingBypass = true @@ -178,72 +182,143 @@ Progression.Behaviours = { price = 500, description = "Hold Space after jumping to hover and rise slowly. Toggle this behaviour on or off." }, + { + id = "godmode_30", + name = "God Mode — 30 Seconds", + price = 400, + duration = 30, + consumable = true, + godmode = true, + description = "Activate 30 seconds of complete damage immunity. Each use is purchased separately." + }, { id = "godmode_90", name = "God Mode — 90 Seconds", price = 1000, duration = 90, consumable = true, + godmode = true, description = "Activate 90 seconds of complete damage immunity. Each use is purchased separately." } } --- Only these combat NPC classes award points. Keeping an allow-list avoids --- rewards for friendly citizens, scripted targets and manually invented entity --- classes. Add a class here if a future coop map uses another real monster. -Progression.MonsterClasses = { - npc_advisor = true, - npc_antlion = true, - npc_antlionguard = true, - npc_antlion_worker = true, - npc_barnacle = true, - npc_clawscanner = true, - npc_combine_s = true, - npc_combinedropship = true, - npc_combinegunship = true, - npc_cscanner = true, - npc_fastzombie = true, - npc_fastzombie_torso = true, - npc_headcrab = true, - npc_headcrab_black = true, - npc_headcrab_fast = true, - npc_headcrab_poison = true, - npc_helicopter = true, - npc_hunter = true, - npc_manhack = true, - npc_metropolice = true, - npc_poisonzombie = true, - npc_rollermine = true, - npc_sniper = true, - npc_stalker = true, - npc_strider = true, - npc_turret_ceiling = true, - npc_turret_floor = true, - npc_turret_ground = true, - npc_vortigaunt = true, - npc_zombie = true, - npc_zombie_torso = true, - npc_zombine = true, +Progression.Trails = { + { + id = "none", + name = "No Trail", + price = 0, + material = "", + description = "Turn off your currently equipped player trail." + }, + { + id = "lol", + name = "LOLOLOL Trail", + price = 75, + material = "trails/lol", + color = Color(255, 255, 255), + description = "Leave a stream of Garry's Mod's classic LOL material behind you." + }, + { + id = "love", + name = "Love Trail", + price = 100, + material = "trails/love", + color = Color(255, 90, 180), + description = "A bright pink trail made from the built-in love material." + }, + { + id = "rainbow", + name = "Rainbow Plasma", + price = 150, + material = "trails/plasma", + color = Color(255, 255, 255), + rainbow = true, + description = "A plasma trail that continuously cycles through rainbow colours." + }, + { + id = "electric", + name = "Electric Blue", + price = 125, + material = "trails/electric", + color = Color(70, 150, 255), + additive = true, + description = "A glowing blue electrical trail." + }, + { + id = "laser", + name = "Green Laser", + price = 125, + material = "trails/laser", + color = Color(80, 255, 100), + additive = true, + description = "A sharp green laser trail." + }, + { + id = "smoke", + name = "Smoke Trail", + price = 50, + material = "trails/smoke", + color = Color(190, 190, 190), + description = "A subtle grey smoke trail." + } +} - monster_alien_controller = true, - monster_alien_grunt = true, - monster_alien_slave = true, - monster_apache = true, - monster_barnacle = true, - monster_bigmomma = true, - monster_bullchicken = true, - monster_gargantua = true, - monster_headcrab = true, - monster_houndeye = true, - monster_human_assassin = true, - monster_human_grunt = true, - monster_ichthyosaur = true, - monster_leech = true, - monster_nihilanth = true, - monster_osprey = true, - monster_snark = true, - monster_tentacle = true, - monster_zombie = true +-- Only these combat NPC classes award points. The numeric value is the Score +-- and Credit reward, so bosses and other dangerous enemies are worth more. +-- Keeping an allow-list avoids farming friendly or player-spawned NPCs. +Progression.MonsterClasses = { + npc_advisor = 25, + npc_antlion = 1, + npc_antlionguard = 12, + npc_antlion_worker = 2, + npc_barnacle = 2, + npc_clawscanner = 1, + npc_combine_s = 3, + npc_combinedropship = 15, + npc_combinegunship = 20, + npc_cscanner = 1, + npc_fastzombie = 3, + npc_fastzombie_torso = 1, + npc_headcrab = 1, + npc_headcrab_black = 2, + npc_headcrab_fast = 1, + npc_headcrab_poison = 2, + npc_helicopter = 20, + npc_hunter = 8, + npc_manhack = 1, + npc_metropolice = 2, + npc_poisonzombie = 6, + npc_rollermine = 2, + npc_sniper = 4, + npc_stalker = 2, + npc_strider = 25, + npc_turret_ceiling = 2, + npc_turret_floor = 2, + npc_turret_ground = 2, + npc_vortigaunt = 4, + npc_zombie = 2, + npc_zombie_torso = 1, + npc_zombine = 4, + + monster_alien_controller = 5, + monster_alien_grunt = 3, + monster_alien_slave = 2, + monster_apache = 20, + monster_barnacle = 2, + monster_bigmomma = 25, + monster_bullchicken = 3, + monster_gargantua = 25, + monster_headcrab = 1, + monster_houndeye = 2, + monster_human_assassin = 5, + monster_human_grunt = 3, + monster_ichthyosaur = 6, + monster_leech = 1, + monster_nihilanth = 40, + monster_osprey = 20, + monster_snark = 1, + monster_tentacle = 8, + monster_zombie = 2 } local function FindByID(items, id) @@ -268,6 +343,10 @@ function Progression.GetBehaviour(id) return FindByID(Progression.Behaviours, id) end +function Progression.GetTrail(id) + return FindByID(Progression.Trails, id) +end + function Progression.GetRank(score) local result = Progression.Ranks[1].name score = math.max(tonumber(score) or 0, 0) @@ -280,6 +359,24 @@ function Progression.GetRank(score) return result end +function Progression.GetMonsterReward(className) + local configured = Progression.MonsterClasses[className] + if configured == nil or configured == false then return end + if configured == true then configured = Progression.KillReward end + return math.max(math.floor(tonumber(configured) or Progression.KillReward), 1) +end + +function Progression.FormatPlaytime(totalSeconds) + local totalMinutes = math.max(math.floor((tonumber(totalSeconds) or 0) / 60), 0) + local days = math.floor(totalMinutes / 1440) + local hours = math.floor(totalMinutes % 1440 / 60) + local minutes = totalMinutes % 60 + + if days > 0 then return string.format("%dd %dh", days, hours) end + if hours > 0 then return string.format("%dh %02dm", hours, minutes) end + return string.format("%dm", minutes) +end + local function SetHoverpackThrusting(ply, thrusting) if SERVER and Progression.SetHoverpackThrusting then Progression.SetHoverpackThrusting(ply, thrusting) diff --git a/gamemode/sv_progression.lua b/gamemode/sv_progression.lua index 4a94da1..4de9d64 100644 --- a/gamemode/sv_progression.lua +++ b/gamemode/sv_progression.lua @@ -55,8 +55,10 @@ local function CreateTables() player_name TEXT NOT NULL DEFAULT '', lifetime_score INTEGER NOT NULL DEFAULT 0, credits INTEGER NOT NULL DEFAULT 0, + playtime_seconds INTEGER NOT NULL DEFAULT 0, equipped_model TEXT NOT NULL DEFAULT 'kleiner', - equipped_weapon TEXT NOT NULL DEFAULT 'none' + equipped_weapon TEXT NOT NULL DEFAULT 'none', + equipped_trail TEXT NOT NULL DEFAULT 'none' ) ]], "could not create player table") @@ -86,6 +88,26 @@ local function CreateTables() PRIMARY KEY (steamid64, item_id) ) ]], "could not create enabled behaviour table") + + local columns = Query("PRAGMA table_info(custom_coop_players)", "could not inspect player table") + local hasEquippedTrail = false + local hasPlaytime = false + for _, column in ipairs(columns or {}) do + if column.name == "equipped_trail" then hasEquippedTrail = true end + if column.name == "playtime_seconds" then hasPlaytime = true end + end + if not hasEquippedTrail then + Query( + "ALTER TABLE custom_coop_players ADD COLUMN equipped_trail TEXT NOT NULL DEFAULT 'none'", + "could not add equipped trail column" + ) + end + if not hasPlaytime then + Query( + "ALTER TABLE custom_coop_players ADD COLUMN playtime_seconds INTEGER NOT NULL DEFAULT 0", + "could not add playtime column" + ) + end end CreateTables() @@ -94,7 +116,8 @@ local catalogues = { { itemType = "model", items = Progression.Models }, { itemType = "weapon", items = Progression.Weapons }, { itemType = "music", items = Progression.Music }, - { itemType = "behaviour", items = Progression.Behaviours } + { itemType = "behaviour", items = Progression.Behaviours }, + { itemType = "trail", items = Progression.Trails } } for _, item in ipairs(Progression.Music) do @@ -122,6 +145,7 @@ local function GetCatalogueItem(itemType, itemID) if itemType == "weapon" then return Progression.GetWeapon(itemID) end if itemType == "music" then return Progression.GetMusic(itemID) end if itemType == "behaviour" then return Progression.GetBehaviour(itemID) end + if itemType == "trail" then return Progression.GetTrail(itemID) end end local function GetShopPrice(itemType, item) @@ -177,10 +201,15 @@ local function EmptyState() return { score = 0, credits = 0, + playtimeSeconds = 0, + playtimeRemainder = 0, + playtimeUpdatedAt = CurTime(), equippedModel = Progression.DefaultModelID, equippedWeapon = Progression.DefaultWeaponID, + equippedTrail = Progression.DefaultTrailID, ownedModels = { [Progression.DefaultModelID] = true }, ownedWeapons = { [Progression.DefaultWeaponID] = true }, + ownedTrails = { [Progression.DefaultTrailID] = true }, ownedMusic = {}, ownedBehaviours = {}, enabledBehaviours = {}, @@ -195,7 +224,9 @@ local function SyncPlayer(ply, state) ply:SetNW2Int("CustomCoopScore", state.score) ply:SetNW2Int("CustomCoopCredits", state.credits) + ply:SetNW2Int("CustomCoopPlaytime", math.min(math.floor(state.playtimeSeconds or 0), 2147483647)) ply:SetNW2String("CustomCoopRank", Progression.GetRank(state.score)) + ply:SetNW2String("CustomCoopTrail", state.equippedTrail) ply:SetNW2Bool("CustomCoopHoverpackEnabled", state.enabledBehaviours.hoverpack == true) if state.enabledBehaviours.hoverpack and ply.EquipSuit and (not ply.IsSuitEquipped or not ply:IsSuitEquipped()) then @@ -203,6 +234,47 @@ local function SyncPlayer(ply, state) end end +local function AccruePlaytime(ply, state) + if not state then return 0 end + + local now = CurTime() + local previousUpdate = state.playtimeUpdatedAt or now + state.playtimeUpdatedAt = now + state.playtimeRemainder = (state.playtimeRemainder or 0) + math.max(now - previousUpdate, 0) + + local wholeSeconds = math.floor(state.playtimeRemainder) + if wholeSeconds <= 0 then return 0 end + + state.playtimeRemainder = state.playtimeRemainder - wholeSeconds + local oldPlaytime = math.max(math.floor(state.playtimeSeconds or 0), 0) + state.playtimeSeconds = oldPlaytime + wholeSeconds + state.profileDirty = true + + local rewardInterval = math.max(math.floor(Progression.PlaytimeRewardInterval or 0), 0) + local creditsPerInterval = math.max(math.floor(Progression.PlaytimeCreditsPerInterval or 0), 0) + local reward = 0 + if rewardInterval > 0 and creditsPerInterval > 0 then + local oldMilestones = math.floor(oldPlaytime / rewardInterval) + local newMilestones = math.floor(state.playtimeSeconds / rewardInterval) + reward = math.max(newMilestones - oldMilestones, 0) * creditsPerInterval + state.credits = state.credits + reward + end + + if IsValid(ply) then + state.playerName = ply:Nick() + SyncPlayer(ply, state) + if reward > 0 then + ply:ChatPrint(string.format( + "[CO-OP] Playtime reward: +%d Credits for %s played.", + reward, + Progression.FormatPlaytime(state.playtimeSeconds) + )) + end + end + + return reward +end + local function LoadPlayer(ply) if not IsValid(ply) then return end @@ -215,13 +287,14 @@ local function LoadPlayer(ply) if disconnectedState then disconnectedStates[steamID64] = nil disconnectedState.playerName = ply:Nick() + disconnectedState.playtimeUpdatedAt = CurTime() playerStates[ply] = disconnectedState SyncPlayer(ply, disconnectedState) return disconnectedState end local row = Query( - "SELECT lifetime_score, credits, equipped_model, equipped_weapon " .. + "SELECT lifetime_score, credits, playtime_seconds, equipped_model, equipped_weapon, equipped_trail " .. "FROM custom_coop_players WHERE steamid64 = " .. SQLString(steamID64) .. " LIMIT 1", "could not load player" ) @@ -233,8 +306,10 @@ local function LoadPlayer(ply) if istable(row) and row[1] then state.score = math.max(tonumber(row[1].lifetime_score) or 0, 0) state.credits = math.max(tonumber(row[1].credits) or 0, 0) + state.playtimeSeconds = math.max(tonumber(row[1].playtime_seconds) or 0, 0) state.equippedModel = row[1].equipped_model or Progression.DefaultModelID state.equippedWeapon = row[1].equipped_weapon or Progression.DefaultWeaponID + state.equippedTrail = row[1].equipped_trail or Progression.DefaultTrailID end local ownedRows = Query( @@ -252,6 +327,8 @@ local function LoadPlayer(ply) state.ownedMusic[owned.item_id] = true elseif owned.item_type == "behaviour" and Progression.GetBehaviour(owned.item_id) then state.ownedBehaviours[owned.item_id] = true + elseif owned.item_type == "trail" and Progression.GetTrail(owned.item_id) then + state.ownedTrails[owned.item_id] = true end end end @@ -276,6 +353,10 @@ local function LoadPlayer(ply) state.equippedWeapon = Progression.DefaultWeaponID state.profileDirty = true end + if not Progression.GetTrail(state.equippedTrail) or not state.ownedTrails[state.equippedTrail] then + state.equippedTrail = Progression.DefaultTrailID + state.profileDirty = true + end playerStates[ply] = state SyncPlayer(ply, state) @@ -308,14 +389,16 @@ local function SavePlayerState(ply, state) local result = Query(string.format( "INSERT OR REPLACE INTO custom_coop_players " .. - "(steamid64, player_name, lifetime_score, credits, equipped_model, equipped_weapon) " .. - "VALUES (%s, %s, %d, %d, %s, %s)", + "(steamid64, player_name, lifetime_score, credits, playtime_seconds, equipped_model, equipped_weapon, equipped_trail) " .. + "VALUES (%s, %s, %d, %d, %d, %s, %s, %s)", SQLString(steamID64), SQLString(state.playerName), math.max(math.floor(state.score or 0), 0), math.max(math.floor(state.credits or 0), 0), + math.max(math.floor(state.playtimeSeconds or 0), 0), SQLString(state.equippedModel), - SQLString(state.equippedWeapon) + SQLString(state.equippedWeapon), + SQLString(state.equippedTrail) ), "could not save player profile") if result ~= false then @@ -442,6 +525,17 @@ cvars.AddChangeCallback("gc_progression_save_interval", function() StartProgressionSaveTimer() end, "CustomCoopProgressionSaveInterval") +timer.Create( + "CustomCoopProgressionAccruePlaytime", + math.max(tonumber(Progression.PlaytimeUpdateInterval) or 10, 1), + 0, + function() + for _, ply in ipairs(player.GetHumans()) do + if not ply:IsBot() then AccruePlaytime(ply, GetState(ply)) end + end + end +) + local function ApplyModel(ply) local state = GetState(ply) local item = state and Progression.GetModel(state.equippedModel) @@ -477,6 +571,51 @@ local function ApplyWeapon(ply, previousWeaponID) ply.CustomCoopGrantedWeapon = item.weaponClass end +local function RemovePlayerTrail(ply) + if IsValid(ply.CustomCoopTrailEntity) then ply.CustomCoopTrailEntity:Remove() end + ply.CustomCoopTrailEntity = nil +end + +local function ApplyTrail(ply) + if not IsValid(ply) then return end + RemovePlayerTrail(ply) + + local state = GetState(ply) + local item = state and Progression.GetTrail(state.equippedTrail) + if not item or item.material == "" or not ply:Alive() then return end + + local startWidth = math.Clamp(tonumber(item.startWidth) or 24, 1, 128) + local endWidth = math.Clamp(tonumber(item.endWidth) or 2, 0, 128) + local lifetime = math.Clamp(tonumber(item.lifetime) or 2.5, 0.1, 10) + local textureResolution = 1 / math.max((startWidth + endWidth) * 0.5, 0.0001) + local color = item.color or color_white + + ply.CustomCoopTrailEntity = util.SpriteTrail( + ply, + 0, + color, + item.additive == true, + startWidth, + endWidth, + lifetime, + textureResolution, + item.material .. ".vmt" + ) +end + +hook.Add("Think", "CustomCoopProgressionRainbowTrails", function() + for _, ply in ipairs(player.GetAll()) do + if IsValid(ply.CustomCoopTrailEntity) then + local state = playerStates[ply] + local item = state and Progression.GetTrail(state.equippedTrail) + if item and item.rainbow then + local color = HSVToColor((CurTime() * 90 + ply:EntIndex() * 23) % 360, 1, 1) + ply.CustomCoopTrailEntity:SetColor(color) + end + end + end +end) + function Progression.SetHoverpackThrusting(ply, thrusting) if not IsValid(ply) then return end @@ -544,6 +683,7 @@ end) local function SendShop(ply) local state = GetState(ply) if not state then return end + AccruePlaytime(ply, state) local owned = {} for id in pairs(state.ownedModels) do @@ -558,12 +698,17 @@ local function SendShop(ply) for id in pairs(state.ownedBehaviours) do owned[#owned + 1] = { itemType = "behaviour", id = id } end + for id in pairs(state.ownedTrails) do + owned[#owned + 1] = { itemType = "trail", id = id } + end net.Start("CustomCoopProgressionOpenShop") net.WriteUInt(math.min(state.score, 4294967295), 32) net.WriteUInt(math.min(state.credits, 4294967295), 32) + net.WriteUInt(math.min(math.floor(state.playtimeSeconds or 0), 4294967295), 32) net.WriteString(state.equippedModel) net.WriteString(state.equippedWeapon) + net.WriteString(state.equippedTrail) net.WriteUInt(math.min(#owned, 255), 8) for index = 1, math.min(#owned, 255) do net.WriteString(owned[index].itemType) @@ -658,7 +803,7 @@ local function PlayMusic(ply, item, successMessage) end local function ActivateConsumableBehaviour(ply, item) - if item.id ~= "godmode_90" then return false end + if not item.godmode then return false end local now = CurTime() local activeUntil = ply.CustomCoopGodmodeUntil or 0 @@ -741,6 +886,7 @@ local function PurchaseOrEquip(ply, itemType, itemID) local ownedItems = itemType == "model" and state.ownedModels or itemType == "weapon" and state.ownedWeapons or itemType == "music" and state.ownedMusic + or itemType == "trail" and state.ownedTrails or state.ownedBehaviours local price = GetShopPrice(itemType, item) local alreadyOwned = ownedItems[itemID] == true @@ -810,6 +956,19 @@ local function PurchaseOrEquip(ply, itemType, itemID) return end + if itemType == "trail" then + state.equippedTrail = itemID + state.profileDirty = true + SyncPlayer(ply, state) + ApplyTrail(ply) + local action = alreadyOwned and "Equipped " + or adminCreditBypass and "Admin test-unlocked and equipped " + or "Purchased and equipped " + SendNotice(ply, true, action .. item.name .. ".") + SendShop(ply) + return + end + if itemType == "model" then state.equippedModel = itemID ApplyModel(ply) @@ -846,11 +1005,11 @@ local function ResolvePlayerAttacker(attacker, inflictor) end end -local function AwardMonsterKill(ply) +local function AwardMonsterKill(ply, reward) local state = GetState(ply) if not state then return end - local reward = math.max(math.floor(Progression.KillReward), 1) + reward = math.max(math.floor(tonumber(reward) or Progression.KillReward), 1) state.score = state.score + reward state.credits = state.credits + reward state.playerName = ply:Nick() @@ -885,8 +1044,13 @@ hook.Add("PlayerInitialSpawn", "CustomCoopProgressionLoad", function(ply) timer.Simple(4, function() if not IsValid(ply) then return end - ply:ChatPrint("[CO-OP] Kill monsters to earn Score and Credits. Your lifetime Score determines your rank.") - ply:ChatPrint("[CO-OP] Press F3 or type !shop to buy models, weapons, music, and toggleable behaviours.") + ply:ChatPrint("[CO-OP] Harder monsters award more Score and Credits. Lifetime Score determines your rank.") + ply:ChatPrint(string.format( + "[CO-OP] Every %s played awards %d bonus Credits.", + Progression.FormatPlaytime(Progression.PlaytimeRewardInterval), + Progression.PlaytimeCreditsPerInterval + )) + ply:ChatPrint("[CO-OP] Press F3 or type !shop to buy models, weapons, music, behaviours, and player trails.") ply:ChatPrint("[CO-OP] Enable the Hoverpack in Behaviours, then hold Space after jumping to hover.") ply:ChatPrint("[CO-OP] Bought music plays for everyone. Type !musicmute if you do not want to hear it.") ply:ChatPrint("[CO-OP] Dying costs up to " .. Progression.DeathCreditCost .. " Credits, but never reduces Score or rank.") @@ -896,7 +1060,9 @@ end) hook.Add("PlayerDisconnected", "CustomCoopProgressionForget", function(ply) Progression.SetHoverpackThrusting(ply, false) + RemovePlayerTrail(ply) local state = playerStates[ply] + if state then AccruePlaytime(nil, state) end if state and (state.profileDirty or state.behavioursDirty or next(state.pendingOwnedItems) ~= nil) then disconnectedStates[state.steamID64] = state end @@ -908,6 +1074,9 @@ hook.Add("PlayerDisconnected", "CustomCoopProgressionForget", function(ply) end) hook.Add("ShutDown", "CustomCoopProgressionSaveOnShutdown", function() + for ply, state in pairs(playerStates) do + AccruePlaytime(ply, state) + end FlushDirtyProgression() end) @@ -923,7 +1092,10 @@ hook.Add("PlayerSpawn", "CustomCoopProgressionApplyLoadout", function(ply) ply.CustomCoopGrantedWeapon = nil timer.Simple(0.5, function() - if IsValid(ply) then ApplyWeapon(ply) end + if IsValid(ply) then + ApplyWeapon(ply) + ApplyTrail(ply) + end end) -- mapperstuff.lua restores its carried model two seconds after some map @@ -939,7 +1111,8 @@ end) hook.Add("OnNPCKilled", "CustomCoopProgressionMonsterKilled", function(npc, attacker, inflictor) if not IsValid(npc) or npc.CustomCoopNoScore or npc.CustomCoopScoreAwarded then return end - if not Progression.MonsterClasses[npc:GetClass()] then return end + local reward = Progression.GetMonsterReward(npc:GetClass()) + if not reward then return end local creator = npc:GetCreator() if IsValid(creator) and creator:IsPlayer() then return end @@ -948,7 +1121,7 @@ hook.Add("OnNPCKilled", "CustomCoopProgressionMonsterKilled", function(npc, atta if not IsValid(ply) or ply:IsBot() then return end npc.CustomCoopScoreAwarded = true - AwardMonsterKill(ply) + AwardMonsterKill(ply, reward) end) hook.Add("EntityTakeDamage", "CustomCoopProgressionTimedGodmode", function(target, damageInfo) @@ -962,9 +1135,18 @@ end) hook.Add("PlayerDeath", "CustomCoopProgressionDeathPenalty", function(victim) if not IsValid(victim) or victim:IsBot() then return end Progression.SetHoverpackThrusting(victim, false) + RemovePlayerTrail(victim) ChargeForDeath(victim) end) +hook.Add("PostCleanupMap", "CustomCoopProgressionRestoreTrails", function() + timer.Simple(0.5, function() + for _, ply in ipairs(player.GetAll()) do + if IsValid(ply) then ApplyTrail(ply) end + end + end) +end) + hook.Add("PlayerSay", "CustomCoopProgressionChatCommands", function(ply, text) local command = string.Trim(string.lower(text or "")) if command == "!shop" or command == "/shop" then @@ -975,9 +1157,13 @@ hook.Add("PlayerSay", "CustomCoopProgressionChatCommands", function(ply, text) if command == "!points" or command == "/points" then local state = GetState(ply) if state then + AccruePlaytime(ply, state) ply:ChatPrint(string.format( - "[CO-OP] Score: %d | Credits: %d | Rank: %s", - state.score, state.credits, Progression.GetRank(state.score) + "[CO-OP] Score: %d | Credits: %d | Rank: %s | Played: %s", + state.score, + state.credits, + Progression.GetRank(state.score), + Progression.FormatPlaytime(state.playtimeSeconds) )) end return "" @@ -1030,7 +1216,7 @@ concommand.Add("coop_setprice", function(ply, _, args) local itemID = string.lower(args[2] or "") local price = tonumber(args[3]) if not GetCatalogueItem(itemType, itemID) or not price then - local usage = "Usage: coop_setprice " + local usage = "Usage: coop_setprice " if IsValid(ply) then ply:ChatPrint("[CO-OP] " .. usage) else print("[CO-OP] " .. usage) end return end