Add Trails to shop

This commit is contained in:
2026-09-07 08:03:24 +02:00
parent 3f349ccadb
commit 160517f9b6
4 changed files with 465 additions and 103 deletions
+21 -8
View File
@@ -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 <model|weapon|music|behaviour> <item_id> <credits>`
`coop_setprice <model|weapon|music|behaviour|trail> <item_id> <credits>`
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
+89 -23
View File
@@ -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)
+152 -55
View File
@@ -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)
+203 -17
View File
@@ -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 <model|weapon|music|behaviour> <item_id> <credits>"
local usage = "Usage: coop_setprice <model|weapon|music|behaviour|trail> <item_id> <credits>"
if IsValid(ply) then ply:ChatPrint("[CO-OP] " .. usage) else print("[CO-OP] " .. usage) end
return
end