From 79bd93fe54415188adabeca0259e7c0558486b53 Mon Sep 17 00:00:00 2001 From: 0xFEEDC0DE64 Date: Sun, 6 Sep 2026 18:05:34 +0200 Subject: [PATCH] Music fixes and admin price override --- README.md | 16 ++++++++-- gamemode/cl_progression.lua | 44 ++++++++++++++++++++++++++-- gamemode/init.lua | 1 - gamemode/progression_shared.lua | 6 +++- gamemode/sv_progression.lua | 52 ++++++++++++++++++++++++++------- 5 files changed, 100 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index f62bf52..decaeb4 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,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. +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 +reliable in-game positioning. + 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. @@ -31,6 +36,10 @@ cannot award points, submit a purchase price, or debit their own Credits. Administrators get an `Admin Prices` tab in the F3 shop. Select an item, enter a non-negative Credit price, and save it. The server permission-checks and persists the change in SQLite, then broadcasts the new catalogue to all connected clients. +For testing, an administrator who cannot afford an item is allowed to unlock it +without a Credit charge; the server reports the shortfall and Credits remain at +their current non-negative value. Set `Progression.AdminShopTestingBypass` to +`false` to disable this behavior. The equivalent server/admin console command is: `coop_setprice ` @@ -52,8 +61,9 @@ restarts. Music files are not bundled with this repository. Every server owner must supply the audio files referenced by their `Progression.Music` entries, place them -under `garrysmod/sound/`, and register their virtual paths with -`resource.AddFile` so connecting clients receive them. Keep locally supplied -audio outside the gamemode source tree and distribute or back it up separately. +under `garrysmod/sound/`, and reconnect clients so the server can send them. The +gamemode automatically registers every configured file with `resource.AddFile`. +Keep locally supplied audio outside the gamemode source tree and distribute or +back it up separately. Player-spawned NPCs and unknown/friendly NPC classes do not award points. diff --git a/gamemode/cl_progression.lua b/gamemode/cl_progression.lua index b3c048e..6e8e66a 100644 --- a/gamemode/cl_progression.lua +++ b/gamemode/cl_progression.lua @@ -15,6 +15,8 @@ local shopState = { isAdmin = false } local activeMusicChannel +local activeMusicSource +local activeMusicIsSpatial = false local previousMusicVolume = 0.7 local musicVolume = CreateClientConVar( "coop_music_volume", "0.7", true, false, @@ -419,16 +421,30 @@ net.Receive("CustomCoopProgressionToggleMusicMute", ToggleMusicMute) net.Receive("CustomCoopProgressionPlayMusic", function() local itemID = net.ReadString() + local sourcePlayer = net.ReadEntity() local buyerName = net.ReadString() local item = Progression.GetMusic(itemID) if not item then return end + local soundPath = "sound/" .. item.sound + if not file.Exists(soundPath, "GAME") then + notification.AddLegacy( + "Music file missing on this client. Reconnect once to download server music.", + NOTIFY_ERROR, + 8 + ) + return + end + if IsValid(activeMusicChannel) then activeMusicChannel:Stop() activeMusicChannel = nil end + activeMusicSource = nil + activeMusicIsSpatial = item.spatial == true - sound.PlayFile("sound/" .. item.sound, "noplay", function(channel, errorID, errorName) + local flags = activeMusicIsSpatial and "3d noplay" or "noplay" + sound.PlayFile(soundPath, flags, function(channel, errorID, errorName) if not IsValid(channel) then notification.AddLegacy( "Could not play " .. item.name .. ": " .. tostring(errorName or errorID), @@ -439,12 +455,34 @@ net.Receive("CustomCoopProgressionPlayMusic", function() end activeMusicChannel = channel - channel:SetVolume(math.Clamp(musicVolume:GetFloat(), 0, 1)) + activeMusicSource = sourcePlayer + local volume = math.Clamp(musicVolume:GetFloat(), 0, 1) + channel:SetVolume(volume) + if activeMusicIsSpatial and IsValid(activeMusicSource) then + local minimumDistance = math.max(tonumber(item.minimumDistance) or 120, 1) + local maximumDistance = math.max(tonumber(item.maximumDistance) or 2200, minimumDistance + 1) + channel:Set3DFadeDistance(minimumDistance, maximumDistance) + channel:SetPos(activeMusicSource:WorldSpaceCenter()) + end channel:Play() - notification.AddLegacy(buyerName .. " is playing " .. item.name .. ".", NOTIFY_GENERIC, 5) + if volume <= 0.001 then + notification.AddLegacy( + buyerName .. " is playing " .. item.name .. ", but your shop music is muted. Type !musicmute to hear it.", + NOTIFY_ERROR, + 7 + ) + else + notification.AddLegacy(buyerName .. " is playing " .. item.name .. ".", NOTIFY_GENERIC, 5) + end end) end) +hook.Add("Think", "CustomCoopProgressionMoveMusicSource", function() + if not activeMusicIsSpatial or not IsValid(activeMusicChannel) then return end + if not IsValid(activeMusicSource) then return end + activeMusicChannel:SetPos(activeMusicSource:WorldSpaceCenter()) +end) + local previousScore local gainedScore = 0 local gainExpires = 0 diff --git a/gamemode/init.lua b/gamemode/init.lua index b376048..2fbe1f5 100644 --- a/gamemode/init.lua +++ b/gamemode/init.lua @@ -21,4 +21,3 @@ include( "maprotation.lua" ) include( "sv_progression.lua" ) resource.AddFile( "sound/weapons/universal/pap.wav" ) -resource.AddFile( "sound/custom_coop_music/trololo.mp3" ) diff --git a/gamemode/progression_shared.lua b/gamemode/progression_shared.lua index 9fb35f3..54ee1bc 100644 --- a/gamemode/progression_shared.lua +++ b/gamemode/progression_shared.lua @@ -11,6 +11,7 @@ Progression.DefaultModelID = "kleiner" Progression.DefaultWeaponID = "none" Progression.MaximumShopPrice = 100000000 Progression.MusicCooldownPadding = 10 +Progression.AdminShopTestingBypass = true Progression.Ranks = { { score = 0, name = "Newbie" }, @@ -154,7 +155,10 @@ Progression.Music = { sound = "custom_coop_music/trololo.mp3", price = 250, duration = 162, - description = "Play the full song for everybody currently on the server. Unlocks permanently." + spatial = true, + minimumDistance = 120, + maximumDistance = 2200, + description = "Your character plays this song as moving, positional audio. Unlocks permanently." } } diff --git a/gamemode/sv_progression.lua b/gamemode/sv_progression.lua index 325981d..082d6b4 100644 --- a/gamemode/sv_progression.lua +++ b/gamemode/sv_progression.lua @@ -68,6 +68,15 @@ local catalogues = { { itemType = "music", items = Progression.Music } } +for _, item in ipairs(Progression.Music) do + local soundPath = "sound/" .. item.sound + if file.Exists(soundPath, "GAME") then + resource.AddFile(soundPath) + else + ErrorNoHalt("[custom_coop progression] Missing configured music file " .. soundPath .. "\n") + end +end + local function PriceKey(itemType, itemID) return itemType .. ":" .. itemID end @@ -337,7 +346,7 @@ local function SetShopPrice(actor, itemType, itemID, requestedPrice) return true end -local function PlayMusic(ply, item) +local function PlayMusic(ply, item, successMessage) local now = CurTime() if musicBusyUntil > now then SendNotice(ply, false, string.format( @@ -357,11 +366,13 @@ local function PlayMusic(ply, item) net.Start("CustomCoopProgressionPlayMusic") net.WriteString(item.id) + net.WriteEntity(ply) net.WriteString(ply:Nick()) net.Broadcast() PrintMessage(HUD_PRINTTALK, string.format("[CO-OP] %s is playing %s.", ply:Nick(), item.name)) - SendNotice(ply, true, "Playing " .. item.name .. " for everyone.") + local playbackTarget = item.spatial and " from your character." or " for everyone." + SendNotice(ply, true, successMessage or ("Playing " .. item.name .. playbackTarget)) end local function PurchaseOrEquip(ply, itemType, itemID) @@ -387,6 +398,7 @@ local function PurchaseOrEquip(ply, itemType, itemID) local price = GetShopPrice(itemType, item) local alreadyOwned = ownedItems[itemID] == true or price == 0 local previousWeaponID = state.equippedWeapon + local adminCreditBypass = false if itemType == "music" and alreadyOwned then PlayMusic(ply, item) @@ -396,8 +408,16 @@ local function PurchaseOrEquip(ply, itemType, itemID) if not alreadyOwned then if state.credits < price then - SendNotice(ply, false, "You need " .. (price - state.credits) .. " more credits.") - return + adminCreditBypass = Progression.AdminShopTestingBypass and ply:IsAdmin() + if not adminCreditBypass then + SendNotice(ply, false, "You need " .. (price - state.credits) .. " more credits.") + return + end + + SendNotice(ply, false, string.format( + "You need %d more credits. Admin testing override: no credits will be charged.", + price - state.credits + )) end sql.Begin() @@ -407,10 +427,13 @@ local function PurchaseOrEquip(ply, itemType, itemID) 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", - price, SQLString(ply:SteamID64()), price - ), "could not debit purchase") + local updateResult = true + if insertResult ~= false and not adminCreditBypass then + updateResult = Query(string.format( + "UPDATE custom_coop_players SET credits = credits - %d WHERE steamid64 = %s AND credits >= %d", + price, SQLString(ply:SteamID64()), price + ), "could not debit purchase") + end if insertResult == false or updateResult == false then sql.Query("ROLLBACK") @@ -419,13 +442,17 @@ local function PurchaseOrEquip(ply, itemType, itemID) end sql.Commit() - state.credits = state.credits - price + if not adminCreditBypass then state.credits = state.credits - price end ownedItems[itemID] = true end if itemType == "music" then SyncPlayer(ply, state) - SendNotice(ply, true, "Purchased " .. item.name .. ". Select it again to play it for everyone.") + local playbackTarget = item.spatial and " from your character." or " for everyone." + local message = adminCreditBypass + and ("Admin test-unlocked and playing " .. item.name .. playbackTarget) + or ("Purchased and playing " .. item.name .. playbackTarget) + PlayMusic(ply, item, message) SendShop(ply) return end @@ -452,7 +479,10 @@ local function PurchaseOrEquip(ply, itemType, itemID) end SyncPlayer(ply, state) - SendNotice(ply, true, (alreadyOwned and "Equipped " or "Purchased and equipped ") .. item.name .. ".") + local action = alreadyOwned and "Equipped " + or adminCreditBypass and "Admin test-unlocked and equipped " + or "Purchased and equipped " + SendNotice(ply, true, action .. item.name .. ".") SendShop(ply) end