diff --git a/README.md b/README.md index f983a9b..a6a4a26 100644 --- a/README.md +++ b/README.md @@ -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 ` +`coop_setprice ` For example: `coop_setprice music song_id 500`. The maximum accepted price is 100,000,000 Credits. Existing owners retain their unlocks when a price changes. @@ -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 diff --git a/gamemode/cl_progression.lua b/gamemode/cl_progression.lua index 5c2b428..74dbedd 100644 --- a/gamemode/cl_progression.lua +++ b/gamemode/cl_progression.lua @@ -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 diff --git a/gamemode/progression_shared.lua b/gamemode/progression_shared.lua index bef37bb..76a61fc 100644 --- a/gamemode/progression_shared.lua +++ b/gamemode/progression_shared.lua @@ -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) diff --git a/gamemode/sv_progression.lua b/gamemode/sv_progression.lua index 4de9d64..5e1aa56 100644 --- a/gamemode/sv_progression.lua +++ b/gamemode/sv_progression.lua @@ -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 " + local usage = "Usage: coop_setprice " if IsValid(ply) then ply:ChatPrint("[CO-OP] " .. usage) else print("[CO-OP] " .. usage) end return end