Add more entities and helping NPCs to buy
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user