Implement shop and score system
This commit is contained in:
@@ -1,2 +1,31 @@
|
||||
# garrysmod-coop
|
||||
|
||||
## 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`.
|
||||
|
||||
Dying deducts up to five Credits, stopping at zero. It never deducts Lifetime
|
||||
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
|
||||
- Hold `TAB`: compare all connected players' Lifetime Scores and ranks
|
||||
|
||||
The shop contains persistent Half-Life 2 player models and permanent bonus
|
||||
weapon unlocks. Only one bonus weapon is equipped at a time. The server checks
|
||||
all kills, balances, prices, ownership, and equipment; clients cannot award
|
||||
points or choose a price.
|
||||
|
||||
Configuration is in `gamemode/progression_shared.lua`:
|
||||
|
||||
- `Progression.Ranks`: rank names and Lifetime Score thresholds
|
||||
- `Progression.Models`: player-model catalogue and prices
|
||||
- `Progression.Weapons`: bonus-weapon catalogue and prices
|
||||
- `Progression.MonsterClasses`: NPC classes that award points
|
||||
|
||||
Player-spawned NPCs and unknown/friendly NPC classes do not award points.
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
include( "shared.lua" )
|
||||
include( "shared.lua" )
|
||||
include( "cl_progression.lua" )
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
if not CLIENT then return end
|
||||
|
||||
local Progression = CustomCoopProgression
|
||||
local shopFrame
|
||||
local scoreboardFrame
|
||||
local shopState = {
|
||||
score = 0,
|
||||
credits = 0,
|
||||
equippedModel = Progression.DefaultModelID,
|
||||
equippedWeapon = Progression.DefaultWeaponID,
|
||||
ownedModels = {},
|
||||
ownedWeapons = {}
|
||||
}
|
||||
|
||||
surface.CreateFont("CustomCoopProgressionTitle", {
|
||||
font = "Roboto",
|
||||
size = 28,
|
||||
weight = 700
|
||||
})
|
||||
|
||||
surface.CreateFont("CustomCoopProgressionHeading", {
|
||||
font = "Roboto",
|
||||
size = 20,
|
||||
weight = 700
|
||||
})
|
||||
|
||||
surface.CreateFont("CustomCoopProgressionBody", {
|
||||
font = "Roboto",
|
||||
size = 17,
|
||||
weight = 500
|
||||
})
|
||||
|
||||
surface.CreateFont("CustomCoopProgressionSmall", {
|
||||
font = "Roboto",
|
||||
size = 15,
|
||||
weight = 400
|
||||
})
|
||||
|
||||
local colors = {
|
||||
background = Color(20, 24, 29, 245),
|
||||
panel = Color(31, 37, 44, 250),
|
||||
panelHover = Color(39, 47, 56, 250),
|
||||
accent = Color(75, 176, 255),
|
||||
success = Color(92, 205, 122),
|
||||
warning = Color(244, 190, 74),
|
||||
text = Color(240, 244, 248),
|
||||
muted = Color(170, 181, 191)
|
||||
}
|
||||
|
||||
local function RequestShop()
|
||||
net.Start("CustomCoopProgressionRequestShop")
|
||||
net.SendToServer()
|
||||
end
|
||||
|
||||
concommand.Add("coop_shop", RequestShop)
|
||||
|
||||
hook.Add("ShowSpare1", "CustomCoopProgressionF3", function()
|
||||
RequestShop()
|
||||
return true
|
||||
end)
|
||||
|
||||
-- Some client configurations do not route F3 through ShowSpare1. This fallback
|
||||
-- keeps the advertised key reliable while the server-side rate limit absorbs a
|
||||
-- possible duplicate request.
|
||||
local nextF3Request = 0
|
||||
hook.Add("PlayerButtonDown", "CustomCoopProgressionF3Fallback", function(ply, button)
|
||||
if ply ~= LocalPlayer() or button ~= KEY_F3 or RealTime() < nextF3Request then return end
|
||||
nextF3Request = RealTime() + 0.5
|
||||
RequestShop()
|
||||
end)
|
||||
|
||||
local function SelectItem(itemType, itemID)
|
||||
net.Start("CustomCoopProgressionSelectItem")
|
||||
net.WriteString(itemType)
|
||||
net.WriteString(itemID)
|
||||
net.SendToServer()
|
||||
end
|
||||
|
||||
local function 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 owned = isModel and shopState.ownedModels[item.id] or shopState.ownedWeapons[item.id]
|
||||
local equipped = isModel and shopState.equippedModel == item.id or shopState.equippedWeapon == item.id
|
||||
|
||||
local card = layout:Add("DPanel")
|
||||
card:SetSize(252, 292)
|
||||
card.Paint = function(self, width, height)
|
||||
local color = self:IsHovered() and colors.panelHover or colors.panel
|
||||
draw.RoundedBox(8, 0, 0, width, height, color)
|
||||
if equipped then
|
||||
surface.SetDrawColor(colors.success)
|
||||
surface.DrawOutlinedRect(0, 0, width, height, 2)
|
||||
end
|
||||
end
|
||||
|
||||
local previewModel = item.model
|
||||
if not isModel and item.weaponClass ~= "" then
|
||||
local storedWeapon = weapons.GetStored(item.weaponClass)
|
||||
previewModel = storedWeapon and storedWeapon.WorldModel
|
||||
end
|
||||
|
||||
if previewModel and previewModel ~= "" then
|
||||
local preview = vgui.Create("DModelPanel", card)
|
||||
preview:SetPos(8, 8)
|
||||
preview:SetSize(236, 166)
|
||||
preview:SetModel(previewModel)
|
||||
preview:SetFOV(isModel and 30 or 38)
|
||||
|
||||
if isModel then
|
||||
preview:SetCamPos(Vector(68, 0, 64))
|
||||
preview:SetLookAt(Vector(0, 0, 64))
|
||||
else
|
||||
preview:SetCamPos(Vector(45, 45, 28))
|
||||
preview:SetLookAt(Vector(0, 0, 0))
|
||||
end
|
||||
|
||||
preview.LayoutEntity = function(_, entity)
|
||||
if IsValid(entity) then entity:SetAngles(Angle(0, RealTime() * 18 % 360, 0)) end
|
||||
end
|
||||
else
|
||||
local noPreview = AddText(card, itemType == "weapon" and "MAP LOADOUT" or "NO PREVIEW", "CustomCoopProgressionHeading", colors.muted)
|
||||
noPreview:SetContentAlignment(5)
|
||||
noPreview:SetPos(8, 8)
|
||||
noPreview:SetSize(236, 166)
|
||||
end
|
||||
|
||||
local title = AddText(card, item.name, "CustomCoopProgressionHeading", colors.text)
|
||||
title:SetPos(12, 177)
|
||||
title:SetSize(228, 25)
|
||||
|
||||
local description = AddText(card, item.description, "CustomCoopProgressionSmall", colors.muted)
|
||||
description:SetPos(12, 202)
|
||||
description:SetSize(228, 38)
|
||||
|
||||
local priceText = item.price == 0 and "FREE" or (item.price .. " credits")
|
||||
local price = AddText(card, priceText, "CustomCoopProgressionBody", item.price <= shopState.credits and colors.success or colors.warning)
|
||||
price:SetPos(12, 239)
|
||||
price:SetSize(100, 22)
|
||||
|
||||
local button = vgui.Create("DButton", card)
|
||||
button:SetPos(112, 244)
|
||||
button:SetSize(128, 36)
|
||||
button:SetFont("CustomCoopProgressionBody")
|
||||
button:SetText(equipped and "Equipped" or (owned or item.price == 0) and "Equip" or "Buy")
|
||||
button:SetEnabled(not equipped)
|
||||
button.DoClick = function()
|
||||
button:SetEnabled(false)
|
||||
button:SetText("Please wait...")
|
||||
SelectItem(itemType, item.id)
|
||||
end
|
||||
end
|
||||
|
||||
local function CreateCataloguePage(sheet, title, itemType, items)
|
||||
local page = vgui.Create("DPanel", sheet)
|
||||
page:DockPadding(10, 10, 10, 10)
|
||||
page.Paint = function(_, width, height)
|
||||
draw.RoundedBox(6, 0, 0, width, height, Color(24, 29, 35))
|
||||
end
|
||||
|
||||
local scroll = vgui.Create("DScrollPanel", page)
|
||||
scroll:Dock(FILL)
|
||||
|
||||
local layout = vgui.Create("DIconLayout", scroll)
|
||||
layout:Dock(TOP)
|
||||
layout:SetSpaceX(12)
|
||||
layout:SetSpaceY(12)
|
||||
layout:SetTall(math.ceil(#items / 3) * 304)
|
||||
|
||||
for _, item in ipairs(items) do
|
||||
AddItemCard(layout, itemType, item)
|
||||
end
|
||||
|
||||
sheet:AddSheet(title, page)
|
||||
end
|
||||
|
||||
local function OpenShop()
|
||||
if IsValid(shopFrame) then shopFrame:Remove() end
|
||||
|
||||
shopFrame = vgui.Create("DFrame")
|
||||
shopFrame:SetSize(math.min(ScrW() - 40, 850), math.min(ScrH() - 40, 650))
|
||||
shopFrame:Center()
|
||||
shopFrame:SetTitle("")
|
||||
shopFrame:ShowCloseButton(true)
|
||||
shopFrame:MakePopup()
|
||||
shopFrame.Paint = function(_, width, height)
|
||||
draw.RoundedBox(10, 0, 0, width, height, colors.background)
|
||||
draw.SimpleText("CO-OP REWARDS SHOP", "CustomCoopProgressionTitle", 18, 16, colors.text)
|
||||
end
|
||||
|
||||
local summary = AddText(
|
||||
shopFrame,
|
||||
string.format(
|
||||
"Lifetime Score: %d Credits: %d Rank: %s",
|
||||
shopState.score,
|
||||
shopState.credits,
|
||||
Progression.GetRank(shopState.score)
|
||||
),
|
||||
"CustomCoopProgressionBody",
|
||||
colors.accent
|
||||
)
|
||||
summary:SetPos(19, 52)
|
||||
summary:SetSize(shopFrame:GetWide() - 38, 25)
|
||||
|
||||
local help = AddText(
|
||||
shopFrame,
|
||||
"Kills add to both totals. Purchases and deaths spend Credits only; Lifetime Score and rank never decrease.",
|
||||
"CustomCoopProgressionSmall",
|
||||
colors.muted
|
||||
)
|
||||
help:SetPos(19, 77)
|
||||
help:SetSize(shopFrame:GetWide() - 38, 20)
|
||||
|
||||
local sheet = vgui.Create("DPropertySheet", shopFrame)
|
||||
sheet:SetPos(14, 104)
|
||||
sheet:SetSize(shopFrame:GetWide() - 28, shopFrame:GetTall() - 118)
|
||||
|
||||
CreateCataloguePage(sheet, "Player Models", "model", Progression.Models)
|
||||
CreateCataloguePage(sheet, "Bonus Weapons", "weapon", Progression.Weapons)
|
||||
end
|
||||
|
||||
net.Receive("CustomCoopProgressionOpenShop", function()
|
||||
shopState.score = net.ReadUInt(32)
|
||||
shopState.credits = net.ReadUInt(32)
|
||||
shopState.equippedModel = net.ReadString()
|
||||
shopState.equippedWeapon = net.ReadString()
|
||||
shopState.ownedModels = { [Progression.DefaultModelID] = true }
|
||||
shopState.ownedWeapons = { [Progression.DefaultWeaponID] = true }
|
||||
|
||||
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
|
||||
end
|
||||
end
|
||||
|
||||
OpenShop()
|
||||
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 previousScore
|
||||
local gainedScore = 0
|
||||
local gainExpires = 0
|
||||
|
||||
hook.Add("HUDPaint", "CustomCoopProgressionHUD", function()
|
||||
local ply = LocalPlayer()
|
||||
if not IsValid(ply) then return end
|
||||
|
||||
local score = ply:GetNW2Int("CustomCoopScore", -1)
|
||||
if score < 0 then return end
|
||||
|
||||
if previousScore and score > previousScore then
|
||||
gainedScore = score - previousScore
|
||||
gainExpires = CurTime() + 2
|
||||
surface.PlaySound("buttons/button14.wav")
|
||||
end
|
||||
previousScore = score
|
||||
|
||||
local credits = ply:GetNW2Int("CustomCoopCredits", 0)
|
||||
local rank = ply:GetNW2String("CustomCoopRank", Progression.GetRank(score))
|
||||
local width, height = 288, 66
|
||||
local x, y = ScrW() - width - 18, 18
|
||||
|
||||
draw.RoundedBox(8, x, y, width, height, colors.background)
|
||||
draw.SimpleText(rank, "CustomCoopProgressionHeading", x + 12, y + 9, colors.accent)
|
||||
draw.SimpleText("Score " .. score, "CustomCoopProgressionBody", x + 12, y + 37, colors.text)
|
||||
draw.SimpleText("Credits " .. credits, "CustomCoopProgressionBody", x + 151, y + 37, colors.text)
|
||||
|
||||
if CurTime() < gainExpires then
|
||||
draw.SimpleText("+" .. gainedScore, "CustomCoopProgressionHeading", x - 10, y + height / 2, colors.success, TEXT_ALIGN_RIGHT, TEXT_ALIGN_CENTER)
|
||||
end
|
||||
end)
|
||||
|
||||
local function RefreshScoreboard(list)
|
||||
if not IsValid(list) then return end
|
||||
list:Clear()
|
||||
|
||||
local players = player.GetAll()
|
||||
table.sort(players, function(left, right)
|
||||
return left:GetNW2Int("CustomCoopScore", 0) > right:GetNW2Int("CustomCoopScore", 0)
|
||||
end)
|
||||
|
||||
for _, ply in ipairs(players) do
|
||||
list:AddLine(
|
||||
ply:Nick(),
|
||||
ply:GetNW2String("CustomCoopRank", "Newbie"),
|
||||
ply:GetNW2Int("CustomCoopScore", 0),
|
||||
ply:Ping()
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
hook.Add("ScoreboardShow", "CustomCoopProgressionScoreboardShow", function()
|
||||
if IsValid(scoreboardFrame) then return true end
|
||||
|
||||
scoreboardFrame = vgui.Create("DFrame")
|
||||
scoreboardFrame:SetSize(math.min(ScrW() - 60, 760), math.min(ScrH() - 80, 600))
|
||||
scoreboardFrame:Center()
|
||||
scoreboardFrame:SetTitle("CO-OP LIFETIME SCORES")
|
||||
scoreboardFrame:ShowCloseButton(false)
|
||||
scoreboardFrame:SetDraggable(false)
|
||||
scoreboardFrame:SetKeyboardInputEnabled(false)
|
||||
scoreboardFrame:SetMouseInputEnabled(false)
|
||||
scoreboardFrame.Paint = function(_, width, height)
|
||||
draw.RoundedBox(8, 0, 0, width, height, colors.background)
|
||||
end
|
||||
|
||||
local list = vgui.Create("DListView", scoreboardFrame)
|
||||
list:Dock(FILL)
|
||||
list:DockMargin(10, 8, 10, 10)
|
||||
list:AddColumn("Player")
|
||||
list:AddColumn("Rank")
|
||||
list:AddColumn("Lifetime Score")
|
||||
list:AddColumn("Ping")
|
||||
RefreshScoreboard(list)
|
||||
|
||||
scoreboardFrame.NextRefresh = RealTime() + 0.5
|
||||
scoreboardFrame.Think = function(self)
|
||||
if RealTime() < self.NextRefresh then return end
|
||||
self.NextRefresh = RealTime() + 0.5
|
||||
RefreshScoreboard(list)
|
||||
end
|
||||
|
||||
return true
|
||||
end)
|
||||
|
||||
hook.Add("ScoreboardHide", "CustomCoopProgressionScoreboardHide", function()
|
||||
if IsValid(scoreboardFrame) then scoreboardFrame:Remove() end
|
||||
scoreboardFrame = nil
|
||||
return true
|
||||
end)
|
||||
+6
-3
@@ -1,6 +1,8 @@
|
||||
AddCSLuaFile( "cl_init.lua" )
|
||||
AddCSLuaFile( "shared.lua" )
|
||||
AddCSLuaFile( "levels.lua" )
|
||||
AddCSLuaFile( "cl_init.lua" )
|
||||
AddCSLuaFile( "shared.lua" )
|
||||
AddCSLuaFile( "levels.lua" )
|
||||
AddCSLuaFile( "progression_shared.lua" )
|
||||
AddCSLuaFile( "cl_progression.lua" )
|
||||
include( "spawninit.lua" )
|
||||
include( "weaponsystem.lua" )
|
||||
include( "chargersystem.lua" )
|
||||
@@ -16,5 +18,6 @@ include( "mapperstuff.lua" )
|
||||
include( "noheadcrabs.lua" )
|
||||
include( "shared.lua" )
|
||||
include( "maprotation.lua" )
|
||||
include( "sv_progression.lua" )
|
||||
|
||||
resource.AddFile( "sound/weapons/universal/pap.wav" )
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
-- Shared, server-authoritative progression configuration.
|
||||
-- Prices and rewards are only acted upon by sv_progression.lua. Clients receive
|
||||
-- this file so the shop can render the same catalogue without duplicating it.
|
||||
|
||||
CustomCoopProgression = CustomCoopProgression or {}
|
||||
local Progression = CustomCoopProgression
|
||||
|
||||
Progression.KillReward = 1
|
||||
Progression.DeathCreditCost = 5
|
||||
Progression.DefaultModelID = "kleiner"
|
||||
Progression.DefaultWeaponID = "none"
|
||||
|
||||
Progression.Ranks = {
|
||||
{ score = 0, name = "Newbie" },
|
||||
{ score = 10, name = "Survivor" },
|
||||
{ score = 50, name = "Hunter" },
|
||||
{ score = 150, name = "Slayer" },
|
||||
{ score = 500, name = "Veteran" },
|
||||
{ score = 1500, name = "Exterminator" },
|
||||
{ score = 5000, name = "Legend" }
|
||||
}
|
||||
|
||||
Progression.Models = {
|
||||
{
|
||||
id = "kleiner",
|
||||
name = "Dr. Kleiner",
|
||||
model = "models/player/kleiner.mdl",
|
||||
price = 0,
|
||||
description = "The free default model."
|
||||
},
|
||||
{
|
||||
id = "male_citizen",
|
||||
name = "Male Citizen",
|
||||
model = "models/player/group01/male_07.mdl",
|
||||
price = 5,
|
||||
description = "A survivor from City 17."
|
||||
},
|
||||
{
|
||||
id = "female_citizen",
|
||||
name = "Female Citizen",
|
||||
model = "models/player/group01/female_04.mdl",
|
||||
price = 5,
|
||||
description = "A survivor from City 17."
|
||||
},
|
||||
{
|
||||
id = "barney",
|
||||
name = "Barney Calhoun",
|
||||
model = "models/player/barney.mdl",
|
||||
price = 10,
|
||||
description = "Civil Protection's least loyal officer."
|
||||
},
|
||||
{
|
||||
id = "alyx",
|
||||
name = "Alyx Vance",
|
||||
model = "models/player/alyx.mdl",
|
||||
price = 15,
|
||||
description = "Resistance fighter and engineer."
|
||||
},
|
||||
{
|
||||
id = "monk",
|
||||
name = "Father Grigori",
|
||||
model = "models/player/monk.mdl",
|
||||
price = 25,
|
||||
description = "A familiar face from Ravenholm."
|
||||
},
|
||||
{
|
||||
id = "combine",
|
||||
name = "Combine Soldier",
|
||||
model = "models/player/combine_soldier.mdl",
|
||||
price = 50,
|
||||
description = "Standard Combine infantry."
|
||||
},
|
||||
{
|
||||
id = "combine_elite",
|
||||
name = "Combine Elite",
|
||||
model = "models/player/combine_super_soldier.mdl",
|
||||
price = 100,
|
||||
description = "An elite Overwatch soldier."
|
||||
}
|
||||
}
|
||||
|
||||
Progression.Weapons = {
|
||||
{
|
||||
id = "none",
|
||||
name = "No Bonus Weapon",
|
||||
weaponClass = "",
|
||||
price = 0,
|
||||
description = "Use only weapons supplied by the map."
|
||||
},
|
||||
{
|
||||
id = "glock",
|
||||
name = "Glock",
|
||||
weaponClass = "coop_glock",
|
||||
price = 15,
|
||||
description = "A reliable permanent sidearm unlock."
|
||||
},
|
||||
{
|
||||
id = "mp5",
|
||||
name = "MP5",
|
||||
weaponClass = "coop_mp5",
|
||||
price = 40,
|
||||
description = "A permanent compact SMG unlock."
|
||||
},
|
||||
{
|
||||
id = "pumpshotgun",
|
||||
name = "Pump Shotgun",
|
||||
weaponClass = "coop_pumpshotgun",
|
||||
price = 75,
|
||||
description = "A permanent close-range weapon unlock."
|
||||
},
|
||||
{
|
||||
id = "m4",
|
||||
name = "M4",
|
||||
weaponClass = "coop_m4",
|
||||
price = 125,
|
||||
description = "A permanent rifle unlock."
|
||||
},
|
||||
{
|
||||
id = "nyan_gun",
|
||||
name = "Nyan Gun",
|
||||
weaponClass = "weapon_nyangun",
|
||||
price = 1000,
|
||||
description = "Rainbows, music and extremely colourful destruction."
|
||||
},
|
||||
{
|
||||
id = "lightsaber",
|
||||
name = "Lightsaber",
|
||||
weaponClass = "weapon_lightsaber",
|
||||
price = 1000,
|
||||
description = "A permanent weapon from a galaxy far, far away."
|
||||
},
|
||||
{
|
||||
id = "orbital_friendship_cannon",
|
||||
name = "Orbital Friendship Cannon",
|
||||
weaponClass = "orbital_friendship_cannon",
|
||||
price = 5000,
|
||||
description = "Deliver friendship from orbit."
|
||||
},
|
||||
{
|
||||
id = "sexyness",
|
||||
name = "Sexyness (2-bit)",
|
||||
weaponClass = "pist_weagon",
|
||||
price = 100000,
|
||||
description = "The absurdly powerful Ultimate Admin Gun."
|
||||
}
|
||||
}
|
||||
|
||||
-- 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,
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
local function FindByID(items, id)
|
||||
for _, item in ipairs(items) do
|
||||
if item.id == id then return item end
|
||||
end
|
||||
end
|
||||
|
||||
function Progression.GetModel(id)
|
||||
return FindByID(Progression.Models, id)
|
||||
end
|
||||
|
||||
function Progression.GetWeapon(id)
|
||||
return FindByID(Progression.Weapons, id)
|
||||
end
|
||||
|
||||
function Progression.GetRank(score)
|
||||
local result = Progression.Ranks[1].name
|
||||
score = math.max(tonumber(score) or 0, 0)
|
||||
|
||||
for _, rank in ipairs(Progression.Ranks) do
|
||||
if score < rank.score then break end
|
||||
result = rank.name
|
||||
end
|
||||
|
||||
return result
|
||||
end
|
||||
+4
-3
@@ -3,9 +3,10 @@ GM.Author = "0xFEEDC0DE64"
|
||||
GM.Email = "daniel@brunner.ninja"
|
||||
GM.Website = "brunner.ninja"
|
||||
|
||||
DeriveGamemode("sandbox")
|
||||
|
||||
include( "levels.lua" )
|
||||
DeriveGamemode("sandbox")
|
||||
|
||||
include( "levels.lua" )
|
||||
include( "progression_shared.lua" )
|
||||
|
||||
if (SERVER) then
|
||||
function ChangeRandomLevel()
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
if not SERVER then return end
|
||||
|
||||
local Progression = CustomCoopProgression
|
||||
local playerStates = setmetatable({}, { __mode = "k" })
|
||||
local requestCooldowns = setmetatable({}, { __mode = "k" })
|
||||
|
||||
util.AddNetworkString("CustomCoopProgressionOpenShop")
|
||||
util.AddNetworkString("CustomCoopProgressionRequestShop")
|
||||
util.AddNetworkString("CustomCoopProgressionSelectItem")
|
||||
util.AddNetworkString("CustomCoopProgressionNotice")
|
||||
|
||||
local function Query(query, context)
|
||||
local result = sql.Query(query)
|
||||
if result == false then
|
||||
ErrorNoHalt("[custom_coop progression] " .. context .. ": " .. sql.LastError() .. "\n")
|
||||
return false
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
local function SQLString(value)
|
||||
return sql.SQLStr(tostring(value or ""))
|
||||
end
|
||||
|
||||
local function CreateTables()
|
||||
Query([[
|
||||
CREATE TABLE IF NOT EXISTS custom_coop_players (
|
||||
steamid64 TEXT PRIMARY KEY NOT NULL,
|
||||
player_name TEXT NOT NULL DEFAULT '',
|
||||
lifetime_score INTEGER NOT NULL DEFAULT 0,
|
||||
credits INTEGER NOT NULL DEFAULT 0,
|
||||
equipped_model TEXT NOT NULL DEFAULT 'kleiner',
|
||||
equipped_weapon TEXT NOT NULL DEFAULT 'none'
|
||||
)
|
||||
]], "could not create player table")
|
||||
|
||||
Query([[
|
||||
CREATE TABLE IF NOT EXISTS custom_coop_owned_items (
|
||||
steamid64 TEXT NOT NULL,
|
||||
item_type TEXT NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
purchased_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (steamid64, item_type, item_id)
|
||||
)
|
||||
]], "could not create ownership table")
|
||||
end
|
||||
|
||||
CreateTables()
|
||||
|
||||
local function EmptyState()
|
||||
return {
|
||||
score = 0,
|
||||
credits = 0,
|
||||
equippedModel = Progression.DefaultModelID,
|
||||
equippedWeapon = Progression.DefaultWeaponID,
|
||||
ownedModels = { [Progression.DefaultModelID] = true },
|
||||
ownedWeapons = { [Progression.DefaultWeaponID] = true }
|
||||
}
|
||||
end
|
||||
|
||||
local function SyncPlayer(ply, state)
|
||||
if not IsValid(ply) then return end
|
||||
|
||||
ply:SetNW2Int("CustomCoopScore", state.score)
|
||||
ply:SetNW2Int("CustomCoopCredits", state.credits)
|
||||
ply:SetNW2String("CustomCoopRank", Progression.GetRank(state.score))
|
||||
end
|
||||
|
||||
local function EnsurePlayerRow(ply)
|
||||
local steamID64 = ply:SteamID64()
|
||||
if not steamID64 or steamID64 == "" then return false end
|
||||
|
||||
return Query(string.format(
|
||||
"INSERT OR IGNORE INTO custom_coop_players " ..
|
||||
"(steamid64, player_name, equipped_model, equipped_weapon) VALUES (%s, %s, %s, %s)",
|
||||
SQLString(steamID64),
|
||||
SQLString(ply:Nick()),
|
||||
SQLString(Progression.DefaultModelID),
|
||||
SQLString(Progression.DefaultWeaponID)
|
||||
), "could not create player row") ~= false
|
||||
end
|
||||
|
||||
local function LoadPlayer(ply)
|
||||
if not IsValid(ply) or not EnsurePlayerRow(ply) then return end
|
||||
|
||||
local steamID64 = ply:SteamID64()
|
||||
local row = Query(
|
||||
"SELECT lifetime_score, credits, equipped_model, equipped_weapon " ..
|
||||
"FROM custom_coop_players WHERE steamid64 = " .. SQLString(steamID64) .. " LIMIT 1",
|
||||
"could not load player"
|
||||
)
|
||||
|
||||
local state = EmptyState()
|
||||
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.equippedModel = row[1].equipped_model or Progression.DefaultModelID
|
||||
state.equippedWeapon = row[1].equipped_weapon or Progression.DefaultWeaponID
|
||||
end
|
||||
|
||||
local ownedRows = Query(
|
||||
"SELECT item_type, item_id FROM custom_coop_owned_items WHERE steamid64 = " .. SQLString(steamID64),
|
||||
"could not load owned items"
|
||||
)
|
||||
|
||||
if istable(ownedRows) then
|
||||
for _, owned in ipairs(ownedRows) do
|
||||
if owned.item_type == "model" and Progression.GetModel(owned.item_id) then
|
||||
state.ownedModels[owned.item_id] = true
|
||||
elseif owned.item_type == "weapon" and Progression.GetWeapon(owned.item_id) then
|
||||
state.ownedWeapons[owned.item_id] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if not Progression.GetModel(state.equippedModel) or not state.ownedModels[state.equippedModel] then
|
||||
state.equippedModel = Progression.DefaultModelID
|
||||
end
|
||||
if not Progression.GetWeapon(state.equippedWeapon) or not state.ownedWeapons[state.equippedWeapon] then
|
||||
state.equippedWeapon = Progression.DefaultWeaponID
|
||||
end
|
||||
|
||||
playerStates[ply] = state
|
||||
SyncPlayer(ply, state)
|
||||
|
||||
Query(string.format(
|
||||
"UPDATE custom_coop_players SET player_name = %s, equipped_model = %s, equipped_weapon = %s " ..
|
||||
"WHERE steamid64 = %s",
|
||||
SQLString(ply:Nick()),
|
||||
SQLString(state.equippedModel),
|
||||
SQLString(state.equippedWeapon),
|
||||
SQLString(steamID64)
|
||||
), "could not refresh player row")
|
||||
|
||||
return state
|
||||
end
|
||||
|
||||
local function GetState(ply)
|
||||
return playerStates[ply] or LoadPlayer(ply)
|
||||
end
|
||||
|
||||
local function ApplyModel(ply)
|
||||
local state = GetState(ply)
|
||||
local item = state and Progression.GetModel(state.equippedModel)
|
||||
if not item then item = Progression.GetModel(Progression.DefaultModelID) end
|
||||
if not item then return end
|
||||
|
||||
util.PrecacheModel(item.model)
|
||||
ply:SetModel(item.model)
|
||||
end
|
||||
|
||||
local function ApplyWeapon(ply, previousWeaponID)
|
||||
local state = GetState(ply)
|
||||
if not state or not IsValid(ply) or not ply:Alive() then return end
|
||||
|
||||
if previousWeaponID then
|
||||
local previous = Progression.GetWeapon(previousWeaponID)
|
||||
if previous and previous.weaponClass ~= "" and ply.CustomCoopGrantedWeapon == previous.weaponClass then
|
||||
ply:StripWeapon(previous.weaponClass)
|
||||
end
|
||||
end
|
||||
|
||||
local item = Progression.GetWeapon(state.equippedWeapon)
|
||||
if not item or item.weaponClass == "" then
|
||||
ply.CustomCoopGrantedWeapon = nil
|
||||
return
|
||||
end
|
||||
|
||||
if not weapons.GetStored(item.weaponClass) then
|
||||
ErrorNoHalt("[custom_coop progression] Unknown shop weapon " .. item.weaponClass .. "\n")
|
||||
return
|
||||
end
|
||||
|
||||
ply:Give(item.weaponClass)
|
||||
ply.CustomCoopGrantedWeapon = item.weaponClass
|
||||
end
|
||||
|
||||
local function SendShop(ply)
|
||||
local state = GetState(ply)
|
||||
if not state then return end
|
||||
|
||||
local owned = {}
|
||||
for id in pairs(state.ownedModels) do
|
||||
owned[#owned + 1] = { itemType = "model", id = id }
|
||||
end
|
||||
for id in pairs(state.ownedWeapons) do
|
||||
owned[#owned + 1] = { itemType = "weapon", id = id }
|
||||
end
|
||||
|
||||
net.Start("CustomCoopProgressionOpenShop")
|
||||
net.WriteUInt(math.min(state.score, 4294967295), 32)
|
||||
net.WriteUInt(math.min(state.credits, 4294967295), 32)
|
||||
net.WriteString(state.equippedModel)
|
||||
net.WriteString(state.equippedWeapon)
|
||||
net.WriteUInt(math.min(#owned, 255), 8)
|
||||
for index = 1, math.min(#owned, 255) do
|
||||
net.WriteString(owned[index].itemType)
|
||||
net.WriteString(owned[index].id)
|
||||
end
|
||||
net.Send(ply)
|
||||
end
|
||||
|
||||
local function SendNotice(ply, success, message)
|
||||
net.Start("CustomCoopProgressionNotice")
|
||||
net.WriteBool(success)
|
||||
net.WriteString(message)
|
||||
net.Send(ply)
|
||||
end
|
||||
|
||||
local function RequestAllowed(ply)
|
||||
local now = CurTime()
|
||||
if (requestCooldowns[ply] or 0) > now then return false end
|
||||
requestCooldowns[ply] = now + 0.2
|
||||
return true
|
||||
end
|
||||
|
||||
local function PurchaseOrEquip(ply, itemType, itemID)
|
||||
if itemType ~= "model" and itemType ~= "weapon" then return end
|
||||
|
||||
local item = itemType == "model" and Progression.GetModel(itemID) or Progression.GetWeapon(itemID)
|
||||
if not item then return end
|
||||
|
||||
if itemType == "weapon" and item.weaponClass ~= "" and not weapons.GetStored(item.weaponClass) then
|
||||
SendNotice(ply, false, "That Workshop weapon is not currently available. No credits were spent.")
|
||||
return
|
||||
end
|
||||
|
||||
local state = GetState(ply)
|
||||
if not state then return end
|
||||
|
||||
local ownedItems = itemType == "model" and state.ownedModels or state.ownedWeapons
|
||||
local alreadyOwned = ownedItems[itemID] == true or item.price == 0
|
||||
local previousWeaponID = state.equippedWeapon
|
||||
|
||||
if not alreadyOwned then
|
||||
if state.credits < item.price then
|
||||
SendNotice(ply, false, "You need " .. (item.price - state.credits) .. " more credits.")
|
||||
return
|
||||
end
|
||||
|
||||
sql.Begin()
|
||||
local insertResult = Query(string.format(
|
||||
"INSERT INTO custom_coop_owned_items (steamid64, item_type, item_id, purchased_at) " ..
|
||||
"VALUES (%s, %s, %s, %d)",
|
||||
SQLString(ply:SteamID64()), SQLString(itemType), SQLString(itemID), os.time()
|
||||
), "could not store purchase")
|
||||
|
||||
local updateResult = insertResult ~= false and Query(string.format(
|
||||
"UPDATE custom_coop_players SET credits = credits - %d WHERE steamid64 = %s AND credits >= %d",
|
||||
item.price, SQLString(ply:SteamID64()), item.price
|
||||
), "could not debit purchase")
|
||||
|
||||
if insertResult == false or updateResult == false then
|
||||
sql.Query("ROLLBACK")
|
||||
SendNotice(ply, false, "The database rejected this purchase. No credits were spent.")
|
||||
return
|
||||
end
|
||||
sql.Commit()
|
||||
|
||||
state.credits = state.credits - item.price
|
||||
ownedItems[itemID] = true
|
||||
end
|
||||
|
||||
local column = itemType == "model" and "equipped_model" or "equipped_weapon"
|
||||
local equipResult = Query(string.format(
|
||||
"UPDATE custom_coop_players SET %s = %s WHERE steamid64 = %s",
|
||||
column, SQLString(itemID), SQLString(ply:SteamID64())
|
||||
), "could not equip item")
|
||||
|
||||
if equipResult == false then
|
||||
SendNotice(ply, false, "The item was purchased, but could not be equipped yet.")
|
||||
SyncPlayer(ply, state)
|
||||
SendShop(ply)
|
||||
return
|
||||
end
|
||||
|
||||
if itemType == "model" then
|
||||
state.equippedModel = itemID
|
||||
ApplyModel(ply)
|
||||
else
|
||||
state.equippedWeapon = itemID
|
||||
ApplyWeapon(ply, previousWeaponID)
|
||||
end
|
||||
|
||||
SyncPlayer(ply, state)
|
||||
SendNotice(ply, true, (alreadyOwned and "Equipped " or "Purchased and equipped ") .. item.name .. ".")
|
||||
SendShop(ply)
|
||||
end
|
||||
|
||||
local function ResolvePlayerAttacker(attacker, inflictor)
|
||||
if IsValid(attacker) and attacker:IsPlayer() then return attacker end
|
||||
|
||||
if IsValid(attacker) then
|
||||
local owner = attacker:GetOwner()
|
||||
if IsValid(owner) and owner:IsPlayer() then return owner end
|
||||
|
||||
if attacker:IsVehicle() then
|
||||
local driver = attacker:GetDriver()
|
||||
if IsValid(driver) and driver:IsPlayer() then return driver end
|
||||
end
|
||||
end
|
||||
|
||||
if IsValid(inflictor) then
|
||||
local owner = inflictor:GetOwner()
|
||||
if IsValid(owner) and owner:IsPlayer() then return owner end
|
||||
end
|
||||
end
|
||||
|
||||
local function AwardMonsterKill(ply)
|
||||
local state = GetState(ply)
|
||||
if not state then return end
|
||||
|
||||
local reward = math.max(math.floor(Progression.KillReward), 1)
|
||||
local result = Query(string.format(
|
||||
"UPDATE custom_coop_players SET lifetime_score = lifetime_score + %d, " ..
|
||||
"credits = credits + %d, player_name = %s WHERE steamid64 = %s",
|
||||
reward, reward, SQLString(ply:Nick()), SQLString(ply:SteamID64())
|
||||
), "could not save monster reward")
|
||||
|
||||
if result == false then return end
|
||||
|
||||
state.score = state.score + reward
|
||||
state.credits = state.credits + reward
|
||||
SyncPlayer(ply, state)
|
||||
end
|
||||
|
||||
local function ChargeForDeath(ply)
|
||||
local state = GetState(ply)
|
||||
if not state then return end
|
||||
|
||||
local configuredCost = math.max(math.floor(Progression.DeathCreditCost or 0), 0)
|
||||
local charged = math.min(state.credits, configuredCost)
|
||||
if charged <= 0 then
|
||||
SendNotice(ply, false, "You died, but had no credits to lose.")
|
||||
return
|
||||
end
|
||||
|
||||
local result = Query(string.format(
|
||||
"UPDATE custom_coop_players SET credits = MAX(credits - %d, 0) WHERE steamid64 = %s",
|
||||
configuredCost, SQLString(ply:SteamID64())
|
||||
), "could not save death penalty")
|
||||
|
||||
if result == false then return end
|
||||
|
||||
state.credits = math.max(state.credits - configuredCost, 0)
|
||||
SyncPlayer(ply, state)
|
||||
SendNotice(ply, false, string.format(
|
||||
"Death cost %d credit%s. Lifetime Score and rank were not affected.",
|
||||
charged,
|
||||
charged == 1 and "" or "s"
|
||||
))
|
||||
end
|
||||
|
||||
hook.Add("PlayerInitialSpawn", "CustomCoopProgressionLoad", function(ply)
|
||||
LoadPlayer(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 spend Credits on permanent models and bonus weapons.")
|
||||
ply:ChatPrint("[CO-OP] Dying costs up to " .. Progression.DeathCreditCost .. " Credits, but never reduces Score or rank.")
|
||||
ply:ChatPrint("[CO-OP] Type !points for your totals. Hold TAB to compare lifetime scores.")
|
||||
end)
|
||||
end)
|
||||
|
||||
hook.Add("PlayerDisconnected", "CustomCoopProgressionForget", function(ply)
|
||||
playerStates[ply] = nil
|
||||
requestCooldowns[ply] = nil
|
||||
end)
|
||||
|
||||
-- Returning true prevents Sandbox's unrestricted cl_playermodel selection from
|
||||
-- bypassing the shop. The equipped, server-owned model is the only source.
|
||||
hook.Add("PlayerSetModel", "CustomCoopProgressionApplyModel", function(ply)
|
||||
ApplyModel(ply)
|
||||
return true
|
||||
end)
|
||||
|
||||
hook.Add("PlayerSpawn", "CustomCoopProgressionApplyLoadout", function(ply)
|
||||
ply.CustomCoopGrantedWeapon = nil
|
||||
|
||||
timer.Simple(0.5, function()
|
||||
if IsValid(ply) then ApplyWeapon(ply) end
|
||||
end)
|
||||
|
||||
-- mapperstuff.lua restores its carried model two seconds after some map
|
||||
-- changes; reassert the selected shop model immediately afterward.
|
||||
timer.Simple(2.2, function()
|
||||
if IsValid(ply) then ApplyModel(ply) end
|
||||
end)
|
||||
end)
|
||||
|
||||
hook.Add("PlayerSpawnedNPC", "CustomCoopProgressionExcludeSpawnedNPC", function(_, npc)
|
||||
if IsValid(npc) then npc.CustomCoopNoScore = true end
|
||||
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 creator = npc:GetCreator()
|
||||
if IsValid(creator) and creator:IsPlayer() then return end
|
||||
|
||||
local ply = ResolvePlayerAttacker(attacker, inflictor)
|
||||
if not IsValid(ply) or ply:IsBot() then return end
|
||||
|
||||
npc.CustomCoopScoreAwarded = true
|
||||
AwardMonsterKill(ply)
|
||||
end)
|
||||
|
||||
hook.Add("PlayerDeath", "CustomCoopProgressionDeathPenalty", function(victim)
|
||||
if not IsValid(victim) or victim:IsBot() then return end
|
||||
ChargeForDeath(victim)
|
||||
end)
|
||||
|
||||
hook.Add("PlayerSay", "CustomCoopProgressionChatCommands", function(ply, text)
|
||||
local command = string.Trim(string.lower(text or ""))
|
||||
if command == "!shop" or command == "/shop" then
|
||||
SendShop(ply)
|
||||
return ""
|
||||
end
|
||||
|
||||
if command == "!points" or command == "/points" then
|
||||
local state = GetState(ply)
|
||||
if state then
|
||||
ply:ChatPrint(string.format(
|
||||
"[CO-OP] Score: %d | Credits: %d | Rank: %s",
|
||||
state.score, state.credits, Progression.GetRank(state.score)
|
||||
))
|
||||
end
|
||||
return ""
|
||||
end
|
||||
end)
|
||||
|
||||
net.Receive("CustomCoopProgressionRequestShop", function(_, ply)
|
||||
if not RequestAllowed(ply) then return end
|
||||
SendShop(ply)
|
||||
end)
|
||||
|
||||
net.Receive("CustomCoopProgressionSelectItem", function(_, ply)
|
||||
if not RequestAllowed(ply) then return end
|
||||
|
||||
local itemType = net.ReadString()
|
||||
local itemID = net.ReadString()
|
||||
if #itemType > 16 or #itemID > 64 then return end
|
||||
|
||||
PurchaseOrEquip(ply, itemType, itemID)
|
||||
end)
|
||||
Reference in New Issue
Block a user