Add more entities and helping NPCs to buy

This commit is contained in:
2026-09-07 08:37:04 +02:00
parent 160517f9b6
commit bcb6d6e357
4 changed files with 528 additions and 41 deletions
+24 -1
View File
@@ -27,6 +27,11 @@ Music category. Buying a song unlocks it permanently; selecting Play broadcasts
it to all connected players. One song can play at a time, with a short cooldown
afterward. Each player can mute or adjust the music locally.
The permanent weapon catalogue contains all thirteen spawnable weapons from the
gamemode's `CO-OP` Q-menu category: Glock, P228, USP, Five-Seven, MAC-10, MP5,
Pump Shotgun, FAMAS, AK-47, M4A1, Desert Eagle, Automatic Shotgun, and M249. The
separate Workshop novelty weapons remain available at their higher prices.
Music entries may set `spatial = true` plus `minimumDistance` and
`maximumDistance`. Spatial music follows the buyer's character while they move
and fades over distance. Supply spatial audio as 44.1 kHz mono MP3 or WAV for
@@ -57,6 +62,20 @@ 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 Entities category contains consumable deployables. Aim at a clear floor and
purchase one to spawn it there. Default prices are 10 Credits for a Bouncy Ball,
20 for a cyan Glowstick, 25 for a Health Kit, 35 for a Suit Battery, 60 for an
Explosive Barrel, 125 for a five-second 250-damage TNT charge, and 150 for the
red damage-triggered Nuke Pack TNT. The red charges can chain-react. These are
not permanent unlocks; every spawned item is a separate purchase.
The Helping NPCs category contains consumable Resistance reinforcements. Default
prices are 175 Credits for a Rebel, 275 for a Rebel Paramedic, 325 for Barney,
350 for Alyx, 400 for Father Grigori, and 500 for a friendly floor turret. Their
kills never award Score or Credits. Per-player active-spawn limits remove the
oldest purchases when exceeded, and remaining purchases are removed when their
buyer disconnects.
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.
@@ -70,7 +89,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|trail> <item_id> <credits>`
`coop_setprice <model|weapon|music|behaviour|trail|entity|helper> <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.
@@ -83,6 +102,10 @@ Configuration is in `gamemode/progression_shared.lua`:
- `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.Entities`: consumable entity catalogue, spawn settings, and prices
- `Progression.Helpers`: friendly NPC catalogue, equipment, variants, and prices
- `Progression.ShopSpawnDistance` and `Progression.Purchased*Limit`: safe spawning
distance and per-player cleanup caps
- `Progression.Hoverpack*`: hover delay, rise speed, AUX drain/recharge rates,
and thruster sound settings
- `Progression.Playtime*`: in-memory update rate, reward interval, and Credits
+15 -3
View File
@@ -158,6 +158,9 @@ local function AddItemCard(layout, itemType, item)
local isMusic = itemType == "music"
local isBehaviour = itemType == "behaviour"
local isTrail = itemType == "trail"
local isEntity = itemType == "entity"
local isHelper = itemType == "helper"
local isSpawnPurchase = isEntity or isHelper
local isConsumable = isBehaviour and item.consumable == true
local owned = isModel and shopState.ownedModels[item.id]
or isWeapon and shopState.ownedWeapons[item.id]
@@ -192,9 +195,10 @@ local function AddItemCard(layout, itemType, item)
preview:SetPos(8, 8)
preview:SetSize(236, 166)
preview:SetModel(previewModel)
preview:SetFOV(isModel and 30 or 38)
local humanoidPreview = isModel or (isHelper and item.id ~= "rebel_turret")
preview:SetFOV(humanoidPreview and 30 or 38)
if isModel then
if humanoidPreview then
preview:SetCamPos(Vector(68, 0, 64))
preview:SetLookAt(Vector(0, 0, 64))
else
@@ -209,6 +213,8 @@ local function AddItemCard(layout, itemType, item)
local previewText = isMusic and "SERVER MUSIC"
or isBehaviour and "PLAYER ABILITY"
or isTrail and "PLAYER TRAIL"
or isEntity and "DEPLOYABLE ENTITY"
or isHelper and "FRIENDLY NPC"
or isWeapon and "MAP LOADOUT"
or "NO PREVIEW"
local noPreview = AddText(card, previewText, "CustomCoopProgressionHeading", colors.muted)
@@ -237,6 +243,8 @@ local function AddItemCard(layout, itemType, item)
local actionText
if isMusic then
actionText = (owned or currentPrice == 0) and "Play" or "Buy"
elseif isSpawnPurchase then
actionText = "Buy & Spawn"
elseif isBehaviour then
actionText = isConsumable and "Activate"
or owned and (selected and "Disable" or "Enable")
@@ -340,7 +348,9 @@ local function CreateAdminPage(sheet)
{ itemType = "weapon", items = Progression.Weapons },
{ itemType = "music", items = Progression.Music },
{ itemType = "behaviour", items = Progression.Behaviours },
{ itemType = "trail", items = Progression.Trails }
{ itemType = "trail", items = Progression.Trails },
{ itemType = "entity", items = Progression.Entities },
{ itemType = "helper", items = Progression.Helpers }
}) do
for _, item in ipairs(catalogue.items) do
local row = list:AddLine(catalogue.itemType, item.id, item.name, CurrentPrice(catalogue.itemType, item))
@@ -439,6 +449,8 @@ local function OpenShop()
CreateCataloguePage(sheet, "Music", "music", Progression.Music)
CreateCataloguePage(sheet, "Behaviours", "behaviour", Progression.Behaviours)
CreateCataloguePage(sheet, "Player Trails", "trail", Progression.Trails)
CreateCataloguePage(sheet, "Entities", "entity", Progression.Entities)
CreateCataloguePage(sheet, "Helping NPCs", "helper", Progression.Helpers)
if shopState.isAdmin then CreateAdminPage(sheet) end
end
+211
View File
@@ -25,6 +25,9 @@ Progression.HoverpackWalkingRechargePerSecond = 22
Progression.HoverpackSound = "PhysicsCannister.ThrusterLoop"
Progression.HoverpackSoundVolume = 0.55
Progression.HoverpackSoundPitch = 115
Progression.ShopSpawnDistance = 500
Progression.PurchasedEntityLimit = 12
Progression.PurchasedHelperLimit = 6
Progression.Ranks = {
{ score = 0, name = "Newbie" },
@@ -110,6 +113,34 @@ Progression.Weapons = {
price = 15,
description = "A reliable permanent sidearm unlock."
},
{
id = "p228",
name = "P228",
weaponClass = "coop_p228",
price = 20,
description = "A permanent accurate 13-round sidearm unlock."
},
{
id = "usp",
name = "USP",
weaponClass = "coop_usp",
price = 25,
description = "A permanent tactical sidearm unlock with a detachable suppressor."
},
{
id = "fiveseven",
name = "Five-Seven",
weaponClass = "coop_fiveseven",
price = 30,
description = "A permanent fast-firing 20-round sidearm unlock."
},
{
id = "mac10",
name = "MAC-10",
weaponClass = "coop_mac10",
price = 35,
description = "A permanent compact automatic weapon unlock with a very high fire rate."
},
{
id = "mp5",
name = "MP5",
@@ -124,6 +155,20 @@ Progression.Weapons = {
price = 75,
description = "A permanent close-range weapon unlock."
},
{
id = "famas",
name = "FAMAS",
weaponClass = "coop_famas",
price = 100,
description = "A permanent accurate, controllable automatic rifle unlock."
},
{
id = "ak47",
name = "AK-47",
weaponClass = "coop_ak47",
price = 110,
description = "A permanent hard-hitting rifle unlock with substantial recoil."
},
{
id = "m4",
name = "M4",
@@ -131,6 +176,27 @@ Progression.Weapons = {
price = 125,
description = "A permanent rifle unlock."
},
{
id = "deagle",
name = "Desert Eagle",
weaponClass = "coop_deagle",
price = 150,
description = "A permanent seven-round hand cannon unlock with very high damage."
},
{
id = "autoshotgun",
name = "Automatic Shotgun",
weaponClass = "coop_autoshotgun",
price = 225,
description = "A permanent rapid-fire combat shotgun unlock."
},
{
id = "m249",
name = "M249",
weaponClass = "coop_para",
price = 300,
description = "A permanent light machine gun unlock with a 100-round magazine."
},
{
id = "nyan_gun",
name = "Nyan Gun",
@@ -263,6 +329,143 @@ Progression.Trails = {
}
}
-- These are consumable purchases: every successful purchase spawns one
-- server-controlled entity at the surface the player is aiming at.
Progression.Entities = {
{
id = "bouncy_ball",
name = "Bouncy Ball",
class = "sent_ball",
model = "models/Combine_Helicopter/helicopter_bomb01.mdl",
price = 10,
spawnOffset = 24,
ballSize = 24,
description = "A colourful edible ball that bounces and restores 5 health when used."
},
{
id = "glowstick",
name = "Cyan Glowstick",
class = "gmod_light",
model = "models/maxofs2d/light_tubular.mdl",
price = 20,
spawnOffset = 10,
color = Color(60, 220, 255),
brightness = 2,
lightSize = 320,
description = "A portable physics light for dark corners. It remains on until removed or cleaned up."
},
{
id = "healthkit",
name = "Health Kit",
class = "item_healthkit",
model = "models/items/healthkit.mdl",
price = 25,
spawnOffset = 10,
description = "Drop a Half-Life 2 health kit at the aimed location."
},
{
id = "suit_battery",
name = "Suit Battery",
class = "item_battery",
model = "models/items/battery.mdl",
price = 35,
spawnOffset = 10,
description = "Drop a suit battery for armour and AUX-equipped survivors."
},
{
id = "explosive_barrel",
name = "Explosive Barrel",
class = "prop_physics",
model = "models/props_c17/oildrum001_explosive.mdl",
price = 60,
spawnOffset = 48,
description = "A physics barrel that explodes when sufficiently damaged. Keep your distance."
},
{
id = "tnt",
name = "Timed TNT",
class = "gmod_dynamite",
model = "models/dynamite/dynamite.mdl",
price = 125,
spawnOffset = 8,
fuse = 5,
damage = 250,
description = "A 250-damage TNT charge that detonates five seconds after purchase."
},
{
id = "red_tnt",
name = "Red TNT — Shoot to Detonate",
class = "sent_tnt",
model = "models/dav0r/tnt/tnt.mdl",
price = 150,
spawnOffset = 16,
description = "The red Nuke Pack TNT. Damage it to detonate it; nearby charges can chain-react."
}
}
-- Helpers are also consumable purchases. They join the Resistance squad, but
-- their kills never award progression points to avoid an automatic score farm.
Progression.Helpers = {
{
id = "rebel",
name = "Rebel",
class = "npc_citizen",
model = "models/Humans/Group03/Male_07.mdl",
weapon = "weapon_smg1",
citizenType = 3,
price = 175,
description = "An armed Resistance fighter with an SMG."
},
{
id = "rebel_medic",
name = "Rebel Paramedic",
class = "npc_citizen",
model = "models/Humans/Group03m/Male_07.mdl",
weapon = "weapon_smg1",
citizenType = 3,
medic = true,
price = 275,
description = "An armed Resistance medic who can heal nearby players and allies."
},
{
id = "barney",
name = "Barney Calhoun",
class = "npc_barney",
model = "models/barney.mdl",
weapon = "weapon_smg1",
price = 325,
description = "A dependable Resistance companion armed with an SMG."
},
{
id = "alyx",
name = "Alyx Vance",
class = "npc_alyx",
model = "models/alyx.mdl",
weapon = "weapon_alyxgun",
price = 350,
description = "A fast and capable Resistance fighter with her signature weapon."
},
{
id = "father_grigori",
name = "Father Grigori",
class = "npc_monk",
model = "models/monk.mdl",
weapon = "weapon_annabelle",
price = 400,
description = "Ravenholm's fearless guardian, armed with Annabelle."
},
{
id = "rebel_turret",
name = "Rebel Floor Turret",
class = "npc_turret_floor",
model = "models/Combine_turrets/Floor_turret.mdl",
friendlyTurret = true,
price = 500,
spawnOffset = 4,
description = "A stationary floor turret configured to fight for the Resistance."
}
}
-- 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.
@@ -347,6 +550,14 @@ function Progression.GetTrail(id)
return FindByID(Progression.Trails, id)
end
function Progression.GetEntity(id)
return FindByID(Progression.Entities, id)
end
function Progression.GetHelper(id)
return FindByID(Progression.Helpers, id)
end
function Progression.GetRank(score)
local result = Progression.Ranks[1].name
score = math.max(tonumber(score) or 0, 0)
+278 -37
View File
@@ -1,12 +1,30 @@
if not SERVER then return end
local Progression = CustomCoopProgression
local playerStates = setmetatable({}, { __mode = "k" })
local disconnectedStates = {}
local requestCooldowns = setmetatable({}, { __mode = "k" })
local shopPrices = {}
local dirtyShopPrices = {}
local availableMusic = {}
-- This file is auto-refreshed while developing on a live server. Keep all
-- authoritative state outside this file's local scope so a refresh cannot
-- replace newer in-memory totals with an older SQLite snapshot.
local runtime = CustomCoopProgressionRuntime
if not istable(runtime) then
runtime = {}
CustomCoopProgressionRuntime = runtime
end
runtime.playerStates = runtime.playerStates or setmetatable({}, { __mode = "k" })
runtime.disconnectedStates = runtime.disconnectedStates or {}
runtime.requestCooldowns = runtime.requestCooldowns or setmetatable({}, { __mode = "k" })
runtime.purchasedSpawns = runtime.purchasedSpawns or setmetatable({}, { __mode = "k" })
runtime.shopPrices = runtime.shopPrices or {}
runtime.dirtyShopPrices = runtime.dirtyShopPrices or {}
runtime.availableMusic = runtime.availableMusic or {}
local playerStates = runtime.playerStates
local disconnectedStates = runtime.disconnectedStates
local requestCooldowns = runtime.requestCooldowns
local purchasedSpawns = runtime.purchasedSpawns
local shopPrices = runtime.shopPrices
local dirtyShopPrices = runtime.dirtyShopPrices
local availableMusic = runtime.availableMusic
local musicBusyUntil = 0
local totalsSaveInterval = CreateConVar(
"gc_progression_save_interval",
@@ -110,30 +128,41 @@ local function CreateTables()
end
end
CreateTables()
if not runtime.databaseInitialized then
CreateTables()
runtime.databaseInitialized = true
end
local catalogues = {
{ itemType = "model", items = Progression.Models },
{ itemType = "weapon", items = Progression.Weapons },
{ itemType = "music", items = Progression.Music },
{ itemType = "behaviour", items = Progression.Behaviours },
{ itemType = "trail", items = Progression.Trails }
{ itemType = "trail", items = Progression.Trails },
{ itemType = "entity", items = Progression.Entities },
{ itemType = "helper", items = Progression.Helpers }
}
for _, item in ipairs(Progression.Music) do
local soundPath = "sound/" .. item.sound
if file.Exists(soundPath, "GAME") then
availableMusic[item.id] = true
resource.AddFile(soundPath)
else
ErrorNoHalt("[custom_coop progression] Missing configured music file " .. soundPath .. "\n")
if not runtime.assetsPrepared then
for _, item in ipairs(Progression.Music) do
local soundPath = "sound/" .. item.sound
if file.Exists(soundPath, "GAME") then
availableMusic[item.id] = true
resource.AddFile(soundPath)
else
ErrorNoHalt("[custom_coop progression] Missing configured music file " .. soundPath .. "\n")
end
end
end
-- Model files can live on slow Workshop storage. Precache them while the
-- gamemode is loading rather than when a player clicks an item in the shop.
for _, item in ipairs(Progression.Models) do
util.PrecacheModel(item.model)
-- Model files can live on slow Workshop storage. Precache them once while
-- the gamemode is loading, not again after every live Lua refresh.
for _, items in ipairs({ Progression.Models, Progression.Entities, Progression.Helpers }) do
for _, item in ipairs(items) do
if item.model and item.model ~= "" then util.PrecacheModel(item.model) end
end
end
runtime.assetsPrepared = true
end
local function PriceKey(itemType, itemID)
@@ -146,6 +175,8 @@ local function GetCatalogueItem(itemType, itemID)
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
if itemType == "entity" then return Progression.GetEntity(itemID) end
if itemType == "helper" then return Progression.GetHelper(itemID) end
end
local function GetShopPrice(itemType, item)
@@ -153,28 +184,49 @@ local function GetShopPrice(itemType, item)
end
local function LoadShopPrices()
for _, catalogue in ipairs(catalogues) do
for _, item in ipairs(catalogue.items) do
Query(string.format(
"INSERT OR IGNORE INTO custom_coop_shop_prices (item_type, item_id, price) VALUES (%s, %s, %d)",
SQLString(catalogue.itemType), SQLString(item.id), math.max(math.floor(item.price or 0), 0)
), "could not seed shop price")
local rows = Query("SELECT item_type, item_id, price FROM custom_coop_shop_prices", "could not load shop prices")
local existing = {}
if istable(rows) then
for _, row in ipairs(rows) do
local item = GetCatalogueItem(row.item_type, row.item_id)
local price = math.floor(tonumber(row.price) or -1)
if item and price >= 0 and price <= Progression.MaximumShopPrice then
local key = PriceKey(row.item_type, row.item_id)
shopPrices[key] = price
existing[key] = true
end
end
end
local rows = Query("SELECT item_type, item_id, price FROM custom_coop_shop_prices", "could not load shop prices")
if not istable(rows) then return end
for _, row in ipairs(rows) do
local item = GetCatalogueItem(row.item_type, row.item_id)
local price = math.floor(tonumber(row.price) or -1)
if item and price >= 0 and price <= Progression.MaximumShopPrice then
shopPrices[PriceKey(row.item_type, row.item_id)] = price
-- SQLite is synchronous in Garry's Mod. Seed every newly configured item
-- in one statement so slow storage cannot stall once per catalogue entry.
local values = {}
for _, catalogue in ipairs(catalogues) do
for _, item in ipairs(catalogue.items) do
if not existing[PriceKey(catalogue.itemType, item.id)] then
values[#values + 1] = string.format(
"(%s, %s, %d)",
SQLString(catalogue.itemType),
SQLString(item.id),
math.max(math.floor(item.price or 0), 0)
)
end
end
end
if #values > 0 then
Query(
"INSERT OR IGNORE INTO custom_coop_shop_prices (item_type, item_id, price) VALUES " ..
table.concat(values, ","),
"could not seed shop prices"
)
end
end
LoadShopPrices()
if not runtime.shopPricesLoaded then
LoadShopPrices()
runtime.shopPricesLoaded = true
end
local function WriteShopPrices()
local entries = {}
@@ -734,6 +786,180 @@ local function SendNotice(ply, success, message)
net.Send(ply)
end
local function FindShopSpawnTransform(ply, item, isHelper)
if not IsValid(ply) or not ply:Alive() then return nil, "You must be alive to spawn this purchase." end
local startPosition = ply:EyePos()
local trace = util.TraceLine({
start = startPosition,
endpos = startPosition + ply:GetAimVector() * math.max(Progression.ShopSpawnDistance or 500, 100),
filter = ply,
mask = MASK_SOLID
})
if not trace.Hit or trace.HitSky or trace.HitNormal.z < 0.45 then
return nil, "Aim at a clear floor within range, then try again."
end
local position = trace.HitPos + trace.HitNormal * math.max(tonumber(item.spawnOffset) or 18, 2)
if not util.IsInWorld(position) then return nil, "That spawn position is outside the map." end
local radius = isHelper and 18 or 12
local height = isHelper and 72 or 28
local blocked = util.TraceHull({
start = position,
endpos = position + Vector(0, 0, 1),
mins = Vector(-radius, -radius, 0),
maxs = Vector(radius, radius, height),
filter = ply,
mask = MASK_PLAYERSOLID
})
if blocked.Hit then return nil, "There is not enough clear space at that position." end
return position, Angle(0, ply:EyeAngles().y + 180, 0)
end
local function TrackPurchasedSpawn(ply, itemType, ent)
local records = purchasedSpawns[ply]
if not records then
records = { entity = {}, helper = {} }
purchasedSpawns[ply] = records
end
local list = records[itemType]
for index = #list, 1, -1 do
if not IsValid(list[index]) then table.remove(list, index) end
end
local limit = itemType == "helper"
and math.max(math.floor(Progression.PurchasedHelperLimit or 6), 1)
or math.max(math.floor(Progression.PurchasedEntityLimit or 12), 1)
while #list >= limit do
local oldest = table.remove(list, 1)
if IsValid(oldest) then oldest:Remove() end
end
list[#list + 1] = ent
end
local function SpawnShopEntity(ply, item, position, angles)
local ent = ents.Create(item.class)
if not IsValid(ent) then return end
ent:SetPos(position)
ent:SetAngles(angles)
if item.model and item.model ~= "" then ent:SetModel(item.model) end
if item.ballSize and ent.SetBallSize then ent:SetBallSize(item.ballSize) end
ent:Spawn()
ent:Activate()
if item.id == "glowstick" then
if ent.SetColor then ent:SetColor(item.color or Color(60, 220, 255)) end
if ent.SetBrightness then ent:SetBrightness(tonumber(item.brightness) or 2) end
if ent.SetLightSize then ent:SetLightSize(tonumber(item.lightSize) or 320) end
if ent.SetToggle then ent:SetToggle(true) end
if ent.SetOn then ent:SetOn(true) end
elseif item.id == "tnt" then
if ent.SetDamage then ent:SetDamage(math.Clamp(tonumber(item.damage) or 250, 0, 1500)) end
if ent.SetShouldRemove then ent:SetShouldRemove(true) end
local fuse = math.Clamp(tonumber(item.fuse) or 5, 0.1, 60)
timer.Simple(fuse, function()
if IsValid(ent) and ent.Explode then ent:Explode(0, IsValid(ply) and ply or ent) end
end)
elseif item.id == "red_tnt" then
-- Nuke Pack 4 reads this legacy Lua variable when a charge is
-- wire-triggered; damage-triggered explosions replace it with the
-- actual attacker themselves.
ent:SetVar("Owner", ply)
end
local physics = ent:GetPhysicsObject()
if IsValid(physics) then physics:Wake() end
ent:SetCreator(ply)
ent.CustomCoopPurchasedSpawn = true
if ent.SetPlayer then ent:SetPlayer(ply) end
return ent
end
local function SpawnShopHelper(ply, item, position, angles)
local npc = ents.Create(item.class)
if not IsValid(npc) then return end
npc:SetPos(position)
npc:SetAngles(angles)
if item.model and item.model ~= "" then npc:SetModel(item.model) end
npc:SetKeyValue("SquadName", "custom_coop_resistance")
if item.citizenType then npc:SetKeyValue("citizentype", tostring(item.citizenType)) end
if item.weapon and item.weapon ~= "" then npc:SetKeyValue("additionalequipment", item.weapon) end
local spawnFlags = bit.bor(SF_NPC_FADE_CORPSE or 512, SF_NPC_ALWAYSTHINK or 1024)
if item.medic then
spawnFlags = bit.bor(spawnFlags, SF_NPC_DROP_HEALTHKIT or 8, SF_CITIZEN_MEDIC or 0)
end
if item.friendlyTurret then spawnFlags = SF_FLOOR_TURRET_CITIZEN or spawnFlags end
npc:SetKeyValue("spawnflags", tostring(spawnFlags))
npc:Spawn()
npc:Activate()
npc:DropToFloor()
npc:SetCreator(ply)
npc.CustomCoopNoScore = true
npc.CustomCoopPurchasedSpawn = true
if npc.SetCurrentWeaponProficiency then
npc:SetCurrentWeaponProficiency(WEAPON_PROFICIENCY_GOOD or 4)
end
for _, playerTarget in ipairs(player.GetAll()) do
npc:AddEntityRelationship(playerTarget, D_LI, 99)
end
return npc
end
local function ActivateSpawnPurchase(ply, itemType, item)
local isHelper = itemType == "helper"
local position, anglesOrMessage = FindShopSpawnTransform(ply, item, isHelper)
if not position then
SendNotice(ply, false, anglesOrMessage)
return
end
local state = GetState(ply)
if not state then return end
local price = GetShopPrice(itemType, item)
local adminCreditBypass = state.credits < price
and Progression.AdminShopTestingBypass
and ply:IsAdmin()
if state.credits < price and not adminCreditBypass then
SendNotice(ply, false, "You need " .. (price - state.credits) .. " more credits.")
return
end
local ent
if isHelper then
ent = SpawnShopHelper(ply, item, position, anglesOrMessage)
else
ent = SpawnShopEntity(ply, item, position, anglesOrMessage)
end
if not IsValid(ent) then
SendNotice(ply, false, "That purchase could not be spawned. No credits were spent.")
return
end
TrackPurchasedSpawn(ply, itemType, ent)
if adminCreditBypass then
SendNotice(ply, false, string.format(
"You need %d more credits. Admin testing override: no credits were charged.",
price - state.credits
))
else
state.credits = math.max(state.credits - price, 0)
state.profileDirty = true
end
SyncPlayer(ply, state)
SendNotice(ply, true, (adminCreditBypass and "Admin test-spawned " or "Purchased and spawned ") .. item.name .. ".")
SendShop(ply)
end
local function RequestAllowed(ply)
local now = CurTime()
if (requestCooldowns[ply] or 0) > now then return false end
@@ -865,6 +1091,11 @@ local function PurchaseOrEquip(ply, itemType, itemID)
local item = GetCatalogueItem(itemType, itemID)
if not item then return end
if itemType == "entity" or itemType == "helper" then
ActivateSpawnPurchase(ply, itemType, item)
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
@@ -1050,7 +1281,8 @@ hook.Add("PlayerInitialSpawn", "CustomCoopProgressionLoad", function(ply)
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] Press F3 or type !shop for models, weapons, music, abilities, trails, deployables, and friendly NPCs.")
ply:ChatPrint("[CO-OP] Deployables and friendly NPCs are consumables: aim at a clear floor before buying one.")
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.")
@@ -1061,6 +1293,15 @@ end)
hook.Add("PlayerDisconnected", "CustomCoopProgressionForget", function(ply)
Progression.SetHoverpackThrusting(ply, false)
RemovePlayerTrail(ply)
local spawned = purchasedSpawns[ply]
if spawned then
for _, list in pairs(spawned) do
for _, ent in ipairs(list) do
if IsValid(ent) then ent:Remove() end
end
end
purchasedSpawns[ply] = nil
end
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
@@ -1216,7 +1457,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|trail> <item_id> <credits>"
local usage = "Usage: coop_setprice <model|weapon|music|behaviour|trail|entity|helper> <item_id> <credits>"
if IsValid(ply) then ply:ChatPrint("[CO-OP] " .. usage) else print("[CO-OP] " .. usage) end
return
end