From 7d2035e4c337816547767a6dd14d74196f0611ee Mon Sep 17 00:00:00 2001 From: 0xFEEDC0DE64 Date: Sat, 19 Sep 2026 14:28:43 +0200 Subject: [PATCH] Bot respawning logic and shop improvements --- README.md | 66 +- .../feedc0de_coop/gamemode/allplayersdied.lua | 13 +- .../feedc0de_coop/gamemode/cl_progression.lua | 174 +++- .../feedc0de_coop/gamemode/coop_bots.lua | 876 ++++++++++++++++++ .../gamemode/friendly_scanner_spawner.lua | 1 + gamemodes/feedc0de_coop/gamemode/init.lua | 4 +- .../gamemode/map_compatibility.lua | 22 + .../gamemode/progression_shared.lua | 196 +++- .../feedc0de_coop/gamemode/spawninit.lua | 6 +- .../feedc0de_coop/gamemode/sv_progression.lua | 363 +++++++- .../feedc0de_friendly_scanner/cl_init.lua | 22 + 11 files changed, 1684 insertions(+), 59 deletions(-) create mode 100644 gamemodes/feedc0de_coop/gamemode/coop_bots.lua create mode 100644 gamemodes/feedc0de_coop/gamemode/map_compatibility.lua diff --git a/README.md b/README.md index b772fa9..30d3da8 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,9 @@ standalone friendly scanner. Players earn Lifetime Score and spendable Credits for eligible monster kills; harder enemies award more. Lifetime Score determines rank and never decreases. +Each awarded kill creates its own HUD line with the enemy name and Score gained; +rapid and simultaneous kills stack independently, remain for one second, and +then fade out. Every ten connected minutes also awards five Credits. Shop purchases deduct Credits only. Changes remain in memory during active play and are written to Garry's Mod's SQLite database at `garrysmod/sv.db` only when the server is @@ -27,8 +30,27 @@ Player controls: - Chat `!musicmute` or client console `coop_music_mute`: toggle purchased server music locally - Client console `coop_music_volume 0.0` through `coop_music_volume 1.0`: set music volume - With Hoverpack enabled, jump normally and continue holding `Space` to hover +- With Grappling Hook enabled, hold `Use + Space` while aiming at solid world + geometry; release either key to detach +- `!health`, `!suit`, `!ammo`, `!allguns`, `!boost`, `!cloak`, and `!godmode`: + buy the matching consumable without opening F3 +- `!grapple`, `!bhop`, `!speed`, `!fisheye`, and `!beep`: buy or toggle the + matching persistent Behaviour +- `!cp`, `!go`, and `!ucp`: save, return to, or clear a per-map checkpoint - Hold `TAB`: compare connected players' Lifetime Scores, ranks, and total playtime +Managed cooperative bots also show a score, rank, and increasing playtime in the +TAB scoreboard. Their score uses the same class-weighted monster rewards as a +human player's score, but both values are session-only and kept exclusively in +memory. Bots are never inserted into SQLite and therefore never appear in the +loading-page high scores or the F3 Players list. + +Bots use Steam-style handles, a randomly selected player model and trail from +the shop catalogue, and a randomized in-memory score/playtime offset when they +join. They may very rarely play Trolololo through the normal server music path: +the check is once per minute at a 1% chance and it has a shared 30-minute +minimum interval. + The shop contains persistent Half-Life 2 player models and permanent bonus weapon unlocks. Only one bonus weapon is equipped at a time. It also contains a Music category. Buying a song unlocks it permanently; selecting Play broadcasts @@ -58,6 +80,19 @@ and recharges faster while walking; active sprinting does not receive the extra hoverpack recharge. Its built-in looping thruster sound follows the player and is audible to nearby players. +The Grappling Hook is a separate permanent Behaviour. It traces the solid point +under the crosshair, steadily shortens an invisible tether, and applies a smooth +speed-capped pull so the player can swing. Clients render the tether as a softly +sagging electrical arc. It can be enabled together with Hoverpack: plain Space +hovers, Use + Space grapples, and holding both actions lets their forces coexist. +Both consume the same AUX reserve, with their drain added while used together. + +Other Behaviours provide automatic bunny hopping, a 25% movement boost, +wide-angle vision, a pulsing teammate beacon, direct health/suit/ammunition +refills, a standard Half-Life 2 arsenal, a three-minute emergency bundle, and a +one-minute visual cloak. Consumables are charged per activation; the remaining +abilities are bought once and can then be toggled. + God Mode is a non-permanent Behaviour purchase. Each activation costs 1,000 Credits by default and immediately grants 90 seconds of server-enforced damage immunity. It creates no ownership record. Attempting to activate it while it is @@ -75,9 +110,10 @@ 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. +Explosive Barrel, 125 for a five-second 250-damage TNT charge, 150 for the red +damage-triggered Nuke Pack TNT, and 225 for a Friendly Healing Scanner. 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, @@ -158,7 +194,9 @@ uses normal VPhysics, patrols around its anchor, notices injured players farther away, flies toward them, and heals them in range with a visible yellow electrical beam. It cannot award Score or Credits. When destroyed it addresses its killer with a random farewell and falls as a gravity-enabled physical wreck -for 25 seconds. The gamemode—not the reusable entity—creates a new living +for 25 seconds. Its `Scanner.Tail` bone has a subtle client-rendered wiggle; +this is deliberately isolated from the upright VPhysics flight controller. The +gamemode—not the reusable entity—creates a new living scanner at the same spawn anchor after five seconds. The defaults can be adjusted with `gc_friendlyscanner_enabled`, `gc_friendlyscanner_healradius`, @@ -172,6 +210,26 @@ initial Q-menu spawn, and physgun release resolves to the local floor plus the hover offset and is clamped below the ceiling. This lets the scanner glide up and down stairs and return to flying height after being placed low or high. +## Cooperative bots + +The gamemode maintains two naturally named player bots by default. They +record the route taken by living humans and follow its newest reachable +breadcrumbs around corridor corners on maps without a navmesh. They operate +doors and buttons in their path, jump/strafe around short obstructions, and use +a delayed safe-position catch-up only after route following has failed. They +shoot hostile NPCs in sight and respawn after three seconds. + +Bots evaluate every weapon they carry, switch away from empty weapons, reload +from reserve ammunition, and seek visible unowned map weapons, ammo, health and +armour while out of combat. They ignore player-created weapons and construction +tools, so they do not steal equipment placed for another player. + +Steam and the scoreboard still report them as bots; their display names do not +carry a redundant `[BOT]` prefix. They are excluded from progression, SQLite player +statistics, human difficulty scaling, scanner spawn anchoring, and campaign +failure decisions. Set `gc_bot_count` from zero through eight, then run +`coop_bots_refresh` to reconcile the population immediately. + Purchase, equip, behaviour, and price responses update the existing F3 controls in place. They do not recreate, close, move, or scroll the window, so there is no visible frame flash and the selected tab and scroll position stay untouched. diff --git a/gamemodes/feedc0de_coop/gamemode/allplayersdied.lua b/gamemodes/feedc0de_coop/gamemode/allplayersdied.lua index 80290ee..5a8b026 100644 --- a/gamemodes/feedc0de_coop/gamemode/allplayersdied.lua +++ b/gamemodes/feedc0de_coop/gamemode/allplayersdied.lua @@ -22,7 +22,7 @@ hook.Add( "Initialize", "MapLoadFunctionDeadChecking", MapLoadFunctionInitDead ) function MissionFailed() if(restarting == 0) then restarting = 1 - for k, v in pairs(player.GetAll()) do + for k, v in pairs(player.GetHumans()) do if(v:IsValid()) then v:ScreenFade(SCREENFADE.OUT, color_black,6,2) v:Freeze(true) @@ -44,7 +44,7 @@ function MissionFailed() end stopspawning = false; timer.Simple( 7.1, function() - for m, l in pairs(player.GetAll()) do + for m, l in pairs(player.GetHumans()) do if(l:IsValid()) then l:UnSpectate() l:Freeze(false) @@ -62,7 +62,7 @@ end function CheckingAlivePlayers() aliveplayers = 0 deadplayers = 0 - for k, v in pairs(player.GetAll()) do + for k, v in pairs(player.GetHumans()) do if (v:IsValid() && v:Alive()) then aliveplayers = aliveplayers + 1 end @@ -73,7 +73,7 @@ function CheckingAlivePlayers() if(aliveplayers == 0 && deadplayers != 0 && restarting == 0) then if((GetConVarNumber( "gc_realismmode" ) == 1) || forcerealismenable) then restarting = 1 - for k, v in pairs(player.GetAll()) do + for k, v in pairs(player.GetHumans()) do v:ScreenFade(SCREENFADE.OUT, color_black,6,2) v:Freeze(true) v:ConCommand("play " .. failmusic) @@ -133,8 +133,9 @@ if((GetConVarNumber( "gc_allowcheckpoints" ) == 1) && (disablecheckpoints == fal end end ) -hook.Add( "DoPlayerDeath", "FirstDeathVector", function( victim ) - if(anydeaths == false) then +hook.Add( "DoPlayerDeath", "FirstDeathVector", function( victim ) + if(victim:IsBot()) then return end + if(anydeaths == false) then firstdeath = victim:GetPos() anydeaths = true end diff --git a/gamemodes/feedc0de_coop/gamemode/cl_progression.lua b/gamemodes/feedc0de_coop/gamemode/cl_progression.lua index d923dbd..317b7b8 100644 --- a/gamemodes/feedc0de_coop/gamemode/cl_progression.lua +++ b/gamemodes/feedc0de_coop/gamemode/cl_progression.lua @@ -87,6 +87,72 @@ local colors = { text = Color(240, 244, 248), muted = Color(170, 181, 191) } +local grapplingBeam = Material("effects/laser1") + +hook.Add("CalcView", "CustomCoopProgressionWideAngleVision", function(ply, origin, angles, fov) + if not IsValid(ply) or not ply:GetNW2Bool("CustomCoopFisheyeEnabled", false) then return end + return { + origin = origin, + angles = angles, + fov = math.max(fov, Progression.FisheyeFOV or 112), + drawviewer = false + } +end) + +hook.Add("PreDrawHalos", "CustomCoopProgressionBeacons", function() + local beaconPlayers = {} + for _, ply in ipairs(player.GetAll()) do + if IsValid(ply) and ply:Alive() and ply:GetNW2Bool("CustomCoopBeaconEnabled", false) then + beaconPlayers[#beaconPlayers + 1] = ply + end + end + if #beaconPlayers == 0 then return end + + local pulse = 0.65 + math.sin(RealTime() * 4) * 0.35 + halo.Add(beaconPlayers, Color(70 + 90 * pulse, 170 + 70 * pulse, 255), 3, 3, 2, true, true) +end) + +local function GrappleStartPosition(ply) + local bone = ply:LookupBone("ValveBiped.Bip01_R_Hand") + if bone then + local position = ply:GetBonePosition(bone) + if position then return position end + end + return ply:EyePos() + ply:GetAimVector() * 10 +end + +hook.Add("PostDrawTranslucentRenderables", "CustomCoopProgressionGrapplingArcs", function() + render.SetMaterial(grapplingBeam) + for _, ply in ipairs(player.GetAll()) do + if IsValid(ply) and ply:Alive() and ply:GetNW2Bool("CustomCoopGrappling", false) then + local startPosition = GrappleStartPosition(ply) + local endPosition = ply:GetNW2Vector("CustomCoopGrapplePoint", startPosition) + local delta = endPosition - startPosition + local distance = delta:Length() + if distance > 4 then + local forward = delta / distance + local side = forward:Cross(Vector(0, 0, 1)) + if side:LengthSqr() < 0.01 then side = Vector(1, 0, 0) end + side:Normalize() + local vertical = forward:Cross(side):GetNormalized() + local segments = math.Clamp(math.ceil(distance / 140), 8, 24) + render.StartBeam(segments + 1) + for segment = 0, segments do + local fraction = segment / segments + local taper = math.sin(fraction * math.pi) + local arc = math.sin(fraction * math.pi) * math.min(distance * 0.035, 42) + local jitter = math.sin(RealTime() * 11 + segment * 1.73 + ply:EntIndex()) * 2.5 * taper + local position = LerpVector(fraction, startPosition, endPosition) + - Vector(0, 0, arc) + + side * jitter + + vertical * math.cos(RealTime() * 9 + segment * 2.11) * 1.5 * taper + render.AddBeam(position, 3.2, fraction * 2, Color(100, 205, 255, 235)) + end + render.EndBeam() + end + end + end +end) local function RequestShop() net.Start("CustomCoopProgressionRequestShop") @@ -989,9 +1055,67 @@ hook.Add("Think", "CustomCoopProgressionMoveMusicSource", function() activeMusicChannel:SetPos(activeMusicSource:WorldSpaceCenter()) end) -local previousScore -local gainedScore = 0 -local gainExpires = 0 +local killRewardFeed = {} +local killRewardHoldTime = 1 +local killRewardFadeTime = 0.65 + +local function FriendlyEnemyName(className) + local translated = language.GetPhrase(className) + if translated and translated ~= "" and translated ~= className then return translated end + + local name = string.gsub(className or "enemy", "^npc_", "") + name = string.gsub(name, "^monster_", "") + name = string.gsub(name, "_", " ") + return string.gsub(" " .. name, "%W%l", string.upper):sub(2) +end + +net.Receive("CustomCoopProgressionKillReward", function() + killRewardFeed[#killRewardFeed + 1] = { + enemy = FriendlyEnemyName(net.ReadString()), + reward = net.ReadUInt(16), + appearedAt = RealTime() + } + + -- This is only a safety valve for maliciously spawned NPC storms. Normal + -- multikills retain one independently fading line for every enemy. + while #killRewardFeed > 64 do table.remove(killRewardFeed, 1) end +end) + +local function PaintKillRewardFeed(hudX, hudY, hudHeight) + local now = RealTime() + local lifetime = killRewardHoldTime + killRewardFadeTime + for index = #killRewardFeed, 1, -1 do + if now - killRewardFeed[index].appearedAt >= lifetime then + table.remove(killRewardFeed, index) + end + end + + local firstY = hudY + hudHeight + 10 + local rowHeight = 22 + local rowsPerColumn = math.max(math.floor((ScrH() - firstY - 12) / rowHeight), 1) + for index, entry in ipairs(killRewardFeed) do + local age = now - entry.appearedAt + local alpha = age <= killRewardHoldTime and 255 + or math.Clamp(255 * (1 - (age - killRewardHoldTime) / killRewardFadeTime), 0, 255) + local column = math.floor((index - 1) / rowsPerColumn) + local row = (index - 1) % rowsPerColumn + local x = hudX + 278 - column * 240 + local y = firstY + row * rowHeight + local textColor = Color(colors.success.r, colors.success.g, colors.success.b, alpha) + local outlineColor = Color(0, 0, 0, alpha) + draw.SimpleTextOutlined( + string.format("+%d Score %s", entry.reward, entry.enemy), + "CustomCoopProgressionBody", + x, + y, + textColor, + TEXT_ALIGN_RIGHT, + TEXT_ALIGN_TOP, + 1, + outlineColor + ) + end +end hook.Add("HUDPaint", "CustomCoopProgressionHUD", function() local ply = LocalPlayer() @@ -1000,21 +1124,23 @@ hook.Add("HUDPaint", "CustomCoopProgressionHUD", function() local score = ply:GetNW2Int("CustomCoopScore", -1) if score < 0 then return end - if previousScore and score > previousScore then - gainedScore = score - previousScore - gainExpires = CurTime() + 2 - surface.PlaySound("buttons/button14.wav") - end - previousScore = score - local credits = ply:GetNW2Int("CustomCoopCredits", 0) local playtime = ply:GetNW2Int("CustomCoopPlaytime", 0) local rank = ply:GetNW2String("CustomCoopRank", Progression.GetRank(score)) local hoverpackEnabled = ply:GetNW2Bool("CustomCoopHoverpackEnabled", false) local hoverpackThrusting = ply:GetNW2Bool("CustomCoopHoverpackThrusting", false) + local grapplingEnabled = ply:GetNW2Bool("CustomCoopGrapplingHookEnabled", false) + local grappling = ply:GetNW2Bool("CustomCoopGrappling", false) local godmodeSeconds = math.max(math.ceil(ply:GetNW2Float("CustomCoopGodmodeUntil", 0) - CurTime()), 0) + local cloakSeconds = math.max(math.ceil(ply:GetNW2Float("CustomCoopCloakUntil", 0) - CurTime()), 0) + local boostSeconds = math.max(math.ceil(ply:GetNW2Float("CustomCoopTemporarySpeedUntil", 0) - CurTime()), 0) local width = 288 - local height = 88 + (hoverpackEnabled and 24 or 0) + (godmodeSeconds > 0 and 24 or 0) + local height = 88 + + (hoverpackEnabled and 24 or 0) + + (grapplingEnabled and 24 or 0) + + (godmodeSeconds > 0 and 24 or 0) + + (cloakSeconds > 0 and 24 or 0) + + (boostSeconds > 0 and 24 or 0) local x, y = ScrW() - width - 18, 18 draw.RoundedBox(8, x, y, width, height, colors.background) @@ -1037,14 +1163,32 @@ hook.Add("HUDPaint", "CustomCoopProgressionHUD", function() statusY = statusY + 24 end + if grapplingEnabled then + local status = grappling and "ATTACHED — release Use or Space" + or "READY — hold Use + Space" + draw.SimpleText("Grapple: " .. status, "CustomCoopProgressionSmall", x + 12, statusY, + grappling and colors.success or colors.muted) + statusY = statusY + 24 + end + if godmodeSeconds > 0 then draw.SimpleText("God Mode: " .. godmodeSeconds .. "s remaining", "CustomCoopProgressionSmall", x + 12, statusY, colors.success) + statusY = statusY + 24 end - if CurTime() < gainExpires then - draw.SimpleText("+" .. gainedScore, "CustomCoopProgressionHeading", x - 10, y + height / 2, colors.success, TEXT_ALIGN_RIGHT, TEXT_ALIGN_CENTER) + if cloakSeconds > 0 then + draw.SimpleText("Cloak: " .. cloakSeconds .. "s remaining", "CustomCoopProgressionSmall", + x + 12, statusY, colors.success) + statusY = statusY + 24 end + + if boostSeconds > 0 then + draw.SimpleText("Movement Boost: " .. boostSeconds .. "s remaining", "CustomCoopProgressionSmall", + x + 12, statusY, colors.success) + end + + PaintKillRewardFeed(x, y, height) end) local function AddProgressionToOfficialRow(ply, row) @@ -1059,7 +1203,7 @@ local function AddProgressionToOfficialRow(ply, row) row.CustomCoopScore:SetFont("ScoreboardDefault") row.CustomCoopScore:SetTextColor(Color(75, 115, 155)) row.CustomCoopScore:SetContentAlignment(5) - row.CustomCoopScore:SetTooltip("Lifetime Score") + row.CustomCoopScore:SetTooltip(ply:IsBot() and "Bot session score" or "Lifetime Score") end if not IsValid(row.CustomCoopRank) then @@ -1078,7 +1222,7 @@ local function AddProgressionToOfficialRow(ply, row) row.CustomCoopPlaytime:SetFont("ScoreboardDefault") row.CustomCoopPlaytime:SetTextColor(Color(75, 115, 155)) row.CustomCoopPlaytime:SetContentAlignment(5) - row.CustomCoopPlaytime:SetTooltip("Total Playtime") + row.CustomCoopPlaytime:SetTooltip(ply:IsBot() and "Bot session playtime" or "Total Playtime") if IsValid(row.Name) then row.Name:SetFont("ScoreboardDefault") end row:SetTall(42) diff --git a/gamemodes/feedc0de_coop/gamemode/coop_bots.lua b/gamemodes/feedc0de_coop/gamemode/coop_bots.lua new file mode 100644 index 0000000..1e9146c --- /dev/null +++ b/gamemodes/feedc0de_coop/gamemode/coop_bots.lua @@ -0,0 +1,876 @@ +if not SERVER then return end + +local botCount = CreateConVar( + "gc_bot_count", + "2", + FCVAR_ARCHIVE, + "Number of clearly identified cooperative player bots", + 0, + 8 +) + +local legacyBotPrefix = "[BOT] " +-- These are deliberately plausible Steam-style handles rather than labels +-- announcing that they are server bots. +local botNames = { + "toasty_boi", "xX_barnaclebait_Xx", "cr0wbarEnjoyer", "mike.exe", + "shyCombine", "PIZZA_4_BREAKFAST", "gladosFan_2007", "[EU] 1tapLarry" +} +local managedBotNames = {} +for _, name in ipairs(botNames) do managedBotNames[name] = true end +local retiredBotNames = { + ["Alyx's Intern"] = true, ["Crowbar Carl"] = true, + ["Dr. Kleiner Jr."] = true, ["Lamar's Chauffeur"] = true, + ["Definitely Human"] = true, ["Scanner Wrangler"] = true, + ["Barney's Backup"] = true, ["Headcrab Snack"] = true +} +local ignoredEnemyClasses = { + npc_bullseye = true, + npc_combine_camera = true, + npc_enemyfinder = true +} +CustomCoopBotRuntime = CustomCoopBotRuntime or {} +CustomCoopBotRuntime.botState = CustomCoopBotRuntime.botState + or setmetatable({}, { __mode = "k" }) +CustomCoopBotRuntime.humanTrails = CustomCoopBotRuntime.humanTrails + or setmetatable({}, { __mode = "k" }) +CustomCoopBotRuntime.fakeProfiles = CustomCoopBotRuntime.fakeProfiles or {} +CustomCoopBotRuntime.nextNoveltyMusicAt = CustomCoopBotRuntime.nextNoveltyMusicAt or 0 +local botState = CustomCoopBotRuntime.botState +local humanTrails = CustomCoopBotRuntime.humanTrails +local fakeProfiles = CustomCoopBotRuntime.fakeProfiles +local breadcrumbSpacingSquared = 24 * 24 +local maximumBreadcrumbs = 512 + +local function IsManagedBot(ply) + if not IsValid(ply) or not ply:IsPlayer() or not ply:IsBot() then return false end + return ply.CustomCoopManagedBot == true + or managedBotNames[ply:Nick()] == true + or retiredBotNames[ply:Nick()] == true + or string.StartWith(ply:Nick(), legacyBotPrefix) +end + +local function ManagedBots() + local result = {} + for _, ply in ipairs(player.GetBots()) do + if IsManagedBot(ply) then result[#result + 1] = ply end + end + return result +end + +local function BotProfile(bot) + if not IsManagedBot(bot) then return end + local key = bot:Nick() + local profile = fakeProfiles[key] + if not profile then + profile = { + -- Existing history makes a bot look like it joined mid-campaign. + score = math.random(15, 2500), + playtime = math.random(20 * 60, 90 * 60 * 60), + modelID = nil, + trailID = nil, + lastUpdated = CurTime() + } + local progression = CustomCoopProgression + if progression then + if istable(progression.Models) and #progression.Models > 0 then + profile.modelID = progression.Models[math.random(#progression.Models)].id + end + if istable(progression.Trails) and #progression.Trails > 0 then + local trails = {} + for _, trail in ipairs(progression.Trails) do + if trail.material and trail.material ~= "" then trails[#trails + 1] = trail end + end + if #trails > 0 then profile.trailID = trails[math.random(#trails)].id end + end + end + fakeProfiles[key] = profile + end + return profile +end + +local function SyncBotProfile(bot, profile) + if not IsValid(bot) or not profile then return end + local score = math.max(math.floor(profile.score or 0), 0) + local playtime = math.max(math.floor(profile.playtime or 0), 0) + local progression = CustomCoopProgression + local rank = progression and progression.GetRank + and progression.GetRank(score) + or "Newbie" + + bot:SetNW2Int("CustomCoopScore", score) + bot:SetNW2Int("CustomCoopCredits", 0) + bot:SetNW2Int("CustomCoopPlaytime", playtime) + bot:SetNW2String("CustomCoopRank", rank) + bot:SetNW2String("CustomCoopTrail", profile.trailID or "none") +end + +local function ApplyBotCosmetics(bot, profile) + if not IsValid(bot) or not profile then return end + local progression = CustomCoopProgression + local model = progression and progression.GetModel and progression.GetModel(profile.modelID) + if model and model.model and model.model ~= "" then bot:SetModel(model.model) end + + if IsValid(bot.CustomCoopTrailEntity) then bot.CustomCoopTrailEntity:Remove() end + bot.CustomCoopTrailEntity = nil + local trail = progression and progression.GetTrail and progression.GetTrail(profile.trailID) + if not trail or trail.material == "" then return end + + local startWidth = math.Clamp(tonumber(trail.startWidth) or 24, 1, 128) + local endWidth = math.Clamp(tonumber(trail.endWidth) or 2, 0, 128) + local lifetime = math.Clamp(tonumber(trail.lifetime) or 2.5, 0.1, 10) + bot.CustomCoopTrailEntity = util.SpriteTrail( + bot, 0, trail.color or color_white, trail.additive == true, + startWidth, endWidth, lifetime, + 1 / math.max((startWidth + endWidth) * 0.5, 0.0001), trail.material .. ".vmt" + ) +end + +hook.Add("Think", "Feedc0deCoopBotRainbowTrails", function() + for _, bot in ipairs(ManagedBots()) do + local profile = BotProfile(bot) + local trail = profile and CustomCoopProgression + and CustomCoopProgression.GetTrail and CustomCoopProgression.GetTrail(profile.trailID) + if trail and trail.rainbow and IsValid(bot.CustomCoopTrailEntity) then + bot.CustomCoopTrailEntity:SetColor( + HSVToColor((CurTime() * 90 + bot:EntIndex() * 23) % 360, 1, 1) + ) + end + end +end) + +local function UpdateBotProfiles() + local now = CurTime() + for _, bot in ipairs(ManagedBots()) do + local profile = BotProfile(bot) + if profile then + local previous = profile.lastUpdated or now + profile.playtime = math.max(profile.playtime or 0, 0) + math.max(now - previous, 0) + profile.lastUpdated = now + SyncBotProfile(bot, profile) + end + end +end + +local function ResolveManagedBotAttacker(attacker, inflictor) + if IsManagedBot(attacker) then return attacker end + + if IsValid(attacker) then + local owner = attacker:GetOwner() + if IsManagedBot(owner) then return owner end + + if attacker:IsVehicle() then + local driver = attacker:GetDriver() + if IsManagedBot(driver) then return driver end + end + end + + if IsValid(inflictor) then + local owner = inflictor:GetOwner() + if IsManagedBot(owner) then return owner end + end +end + +hook.Add("OnNPCKilled", "Feedc0deCoopBotFakeScore", function(npc, attacker, inflictor) + if not IsValid(npc) or npc.CustomCoopNoScore or npc.CustomCoopBotScoreAwarded then return end + + local progression = CustomCoopProgression + local reward = progression and progression.GetMonsterReward + and progression.GetMonsterReward(npc:GetClass()) + if not reward then return end + + local creator = npc:GetCreator() + if IsValid(creator) and creator:IsPlayer() then return end + + local bot = ResolveManagedBotAttacker(attacker, inflictor) + if not IsManagedBot(bot) then return end + + npc.CustomCoopBotScoreAwarded = true + local profile = BotProfile(bot) + if not profile then return end + profile.score = math.max(profile.score or 0, 0) + math.max(math.floor(reward), 1) + SyncBotProfile(bot, profile) +end) + +local function MaintainBots() + if game.SinglePlayer() then return end + + local bots = ManagedBots() + + -- Bots cannot be renamed through the player API. Replace legacy and + -- retired-name instances once; current bots retain their state. + for index = #bots, 1, -1 do + if string.StartWith(bots[index]:Nick(), legacyBotPrefix) + or retiredBotNames[bots[index]:Nick()] then + bots[index]:Kick("Refreshing co-op bot profile") + table.remove(bots, index) + end + end + + local maximum = math.max(game.MaxPlayers() - #player.GetHumans() - 1, 0) + local wanted = math.min(math.Clamp(botCount:GetInt(), 0, #botNames), maximum) + + while #bots > wanted do + local bot = table.remove(bots) + if IsValid(bot) then bot:Kick("Coop bot count reduced") end + end + + local usedNames = {} + for _, ply in ipairs(player.GetAll()) do usedNames[ply:Nick()] = true end + for index = #bots + 1, wanted do + local name = botNames[index] + if usedNames[name] then name = name .. " " .. index end + local bot = player.CreateNextBot(name) + if IsValid(bot) then + bot.CustomCoopManagedBot = true + local profile = BotProfile(bot) + if profile then profile.lastUpdated = CurTime() end + SyncBotProfile(bot, profile) + ApplyBotCosmetics(bot, profile) + usedNames[name] = true + end + end +end + +local function ClosestHuman(bot) + local closest + local closestDistance + for _, ply in ipairs(player.GetHumans()) do + if ply:Alive() then + local distance = bot:GetPos():DistToSqr(ply:GetPos()) + if not closestDistance or distance < closestDistance then + closest = ply + closestDistance = distance + end + end + end + return closest, closestDistance +end + +local function ResetHumanTrail(ply) + if not IsValid(ply) or ply:IsBot() then return end + humanTrails[ply] = { + points = { + { position = Vector(ply:GetPos()), recordedAt = CurTime() } + }, + lastPosition = Vector(ply:GetPos()) + } +end + +local function RecordHumanTrails() + local now = CurTime() + for _, ply in ipairs(player.GetHumans()) do + if ply:Alive() then + local trail = humanTrails[ply] + if not trail then + ResetHumanTrail(ply) + trail = humanTrails[ply] + end + + local position = ply:GetPos() + if position:DistToSqr(trail.lastPosition) >= breadcrumbSpacingSquared then + trail.points[#trail.points + 1] = { + position = Vector(position), + recordedAt = now + } + trail.lastPosition = Vector(position) + while #trail.points > maximumBreadcrumbs do table.remove(trail.points, 1) end + end + end + end +end + +timer.Create("Feedc0deCoopBotHumanBreadcrumbs", 0.1, 0, RecordHumanTrails) + +hook.Add("PlayerSpawn", "Feedc0deCoopBotResetHumanTrail", function(ply) + if ply:IsBot() then return end + timer.Simple(0.1, function() + if IsValid(ply) and ply:Alive() then ResetHumanTrail(ply) end + end) +end) + +hook.Add("PlayerDisconnected", "Feedc0deCoopBotForgetHumanTrail", function(ply) + humanTrails[ply] = nil + if IsManagedBot(ply) then + local profile = fakeProfiles[ply:Nick()] + if profile then profile.lastUpdated = nil end + end +end) + +local function RoutePointVisible(bot, human, position) + local players = player.GetAll() + local trace = util.TraceHull({ + start = bot:GetPos() + Vector(0, 0, 42), + endpos = position + Vector(0, 0, 42), + mins = Vector(-8, -8, -12), + maxs = Vector(8, 8, 12), + filter = players, + mask = MASK_PLAYERSOLID + }) + return not trace.Hit +end + +local function FollowPosition(bot, human) + if RoutePointVisible(bot, human, human:GetPos()) then + return human:WorldSpaceCenter(), false + end + + local trail = humanTrails[human] + local points = trail and trail.points + if not points or #points == 0 then return human:WorldSpaceCenter(), false end + + -- Start with the newest breadcrumb that is directly reachable from the + -- bot's current corridor, then aim one short segment beyond it. The extra + -- segment makes the bot actually round a corner instead of stopping just + -- before it. Large discontinuities are teleports, so those are never + -- skipped: the bot must touch the entry point itself. + for index = #points, 1, -1 do + local point = points[index] + if CurTime() - point.recordedAt <= 300 + and RoutePointVisible(bot, human, point.position) then + local target = point.position + local following = points[index + 1] + if following and target:DistToSqr(following.position) <= 144 * 144 then + target = following.position + end + return target + Vector(0, 0, 40), true + end + end + + return human:WorldSpaceCenter(), false +end + +local function VisibleAimPosition(bot, target) + if not IsValid(target) then return false end + local position = target:BodyTarget(bot:GetShootPos()) + local trace = util.TraceLine({ + start = bot:GetShootPos(), + endpos = position, + filter = bot, + mask = MASK_SHOT + }) + return trace.Entity == target, position +end + +local function ClosestEnemy(bot) + local closest + local closestDistance + local relationshipTarget = ClosestHuman(bot) or bot + for _, candidate in ipairs(ents.FindInSphere(bot:GetPos(), 1400)) do + if candidate:IsNPC() + and candidate:Health() > 0 + and not ignoredEnemyClasses[candidate:GetClass()] + and candidate:Disposition(relationshipTarget) == D_HT + and VisibleAimPosition(bot, candidate) + then + local distance = bot:GetPos():DistToSqr(candidate:GetPos()) + if not closestDistance or distance < closestDistance then + closest = candidate + closestDistance = distance + end + end + end + return closest, closestDistance +end + +local function MovementBlocked(bot, direction) + local trace = util.TraceHull({ + start = bot:GetPos() + Vector(0, 0, 8), + endpos = bot:GetPos() + direction * 56 + Vector(0, 0, 8), + mins = Vector(-16, -16, 0), + maxs = Vector(16, 16, 64), + filter = bot, + mask = MASK_PLAYERSOLID + }) + return trace.Hit +end + +local function DoorOrButtonAhead(bot, direction) + local trace = util.TraceLine({ + start = bot:GetShootPos(), + endpos = bot:GetShootPos() + direction * 80, + filter = player.GetAll(), + mask = MASK_PLAYERSOLID + }) + if not IsValid(trace.Entity) then return false end + local className = trace.Entity:GetClass() + return className == "prop_door_rotating" + or className == "func_door" + or className == "func_door_rotating" + or className == "func_button" + or className == "func_rot_button" +end + +local function ForceObstacleHop(bot, state, direction, now) + state.duckUntil = now + 0.45 + if not bot:OnGround() or (state.nextForcedHop or 0) > now then return end + + state.nextForcedHop = now + 0.65 + -- StartCommand's jump bit is enough for normal ledges. This small, + -- rate-limited impulse handles workshop props whose collision hulls eat + -- the button-driven jump before the fake client leaves the ground. + bot:SetVelocity(direction * 90 + Vector(0, 0, math.max(bot:GetJumpPower(), 210))) +end + +local weaponPriority = { + weapon_crowbar = 15, + weapon_stunstick = 12, + weapon_pistol = 25, + weapon_357 = 58, + weapon_smg1 = 45, + weapon_ar2 = 72, + weapon_shotgun = 68, + weapon_crossbow = 62, + weapon_frag = 52, + weapon_rpg = 85, + coop_glock = 28, + coop_p228 = 30, + coop_usp = 32, + coop_fiveseven = 34, + coop_mac10 = 44, + coop_mp5 = 48, + coop_famas = 66, + coop_ak47 = 70, + coop_m4 = 69, + coop_deagle = 60, + coop_pumpshotgun = 67, + coop_autoshotgun = 76, + coop_para = 80 +} + +local meleeWeapons = { + weapon_crowbar = true, + weapon_stunstick = true, + npc_knife = true, + weapon_lightsaber = true +} +local ignoredWeaponPickups = { + gmod_camera = true, + gmod_tool = true, + weapon_physgun = true +} + +local pickupClasses = { + item_healthkit = "health", + item_healthvial = "health", + item_battery = "armour", + item_ammo_crate = "ammo", + item_ammo_pistol = "ammo", + item_ammo_pistol_large = "ammo", + item_ammo_smg1 = "ammo", + item_ammo_smg1_large = "ammo", + item_ammo_smg1_grenade = "ammo", + item_ammo_ar2 = "ammo", + item_ammo_ar2_large = "ammo", + item_ammo_ar2_altfire = "ammo", + item_box_buckshot = "ammo", + item_ammo_357 = "ammo", + item_ammo_357_large = "ammo", + item_ammo_crossbow = "ammo", + item_rpg_round = "ammo" +} + +local function WeaponCanFire(bot, weapon) + if not IsValid(weapon) then return false end + if meleeWeapons[weapon:GetClass()] then return true end + if weapon:Clip1() > 0 then return true end + local ammoType = weapon:GetPrimaryAmmoType() + if ammoType and ammoType >= 0 then return bot:GetAmmoCount(ammoType) > 0 end + return weapon:Clip1() == -1 +end + +local function WeaponScore(weapon) + if not IsValid(weapon) then return -1 end + return weaponPriority[weapon:GetClass()] or 35 +end + +local function SelectBestWeapon(bot, state, now) + local active = bot:GetActiveWeapon() + if (state.nextWeaponChoice or 0) > now and WeaponCanFire(bot, active) then return active end + state.nextWeaponChoice = now + 0.4 + + local best = WeaponCanFire(bot, active) and active or nil + local bestScore = WeaponScore(best) + for _, weapon in ipairs(bot:GetWeapons()) do + local score = WeaponScore(weapon) + if WeaponCanFire(bot, weapon) and score > bestScore then + best = weapon + bestScore = score + end + end + if IsValid(best) and best ~= active then bot:SelectWeapon(best:GetClass()) end + return best +end + +local function PickupWanted(bot, candidate, activeWeapon) + if candidate:IsWeapon() then + if IsValid(candidate:GetOwner()) then return false end + local creator = candidate:GetCreator() + if IsValid(creator) and creator:IsPlayer() then return false end + return not ignoredWeaponPickups[candidate:GetClass()] + and not bot:HasWeapon(candidate:GetClass()) + end + + local pickupType = pickupClasses[candidate:GetClass()] + if pickupType == "health" then return bot:Health() < bot:GetMaxHealth() end + if pickupType == "armour" then return bot:Armor() < 75 end + if pickupType == "ammo" then + if not WeaponCanFire(bot, activeWeapon) then return true end + if not IsValid(activeWeapon) then return true end + local ammoType = activeWeapon:GetPrimaryAmmoType() + return ammoType and ammoType >= 0 and bot:GetAmmoCount(ammoType) < 20 + end + return false +end + +local function ClosestUsefulPickup(bot, activeWeapon, state, now) + local closest + local closestDistance + for _, candidate in ipairs(ents.FindInSphere(bot:GetPos(), 800)) do + if IsValid(candidate) + and not (candidate == state.ignoredPickup and (state.ignorePickupUntil or 0) > now) + and PickupWanted(bot, candidate, activeWeapon) + and RoutePointVisible(bot, bot, candidate:GetPos()) then + local distance = bot:GetPos():DistToSqr(candidate:GetPos()) + if not closestDistance or distance < closestDistance then + closest = candidate + closestDistance = distance + end + end + end + return closest, closestDistance +end + +local function CatchUpToHuman(bot) + local human = ClosestHuman(bot) + if not IsValid(human) or not human:OnGround() then return false end + + local offsets = { + Vector(48, 0, 0), + Vector(-48, 0, 0), + Vector(0, 48, 0), + Vector(0, -48, 0), + Vector(64, 64, 0), + Vector(-64, -64, 0), + Vector(64, -64, 0), + Vector(-64, 64, 0) + } + for _, offset in ipairs(offsets) do + local probe = human:GetPos() + offset + local floor = util.TraceLine({ + start = probe + Vector(0, 0, 32), + endpos = probe - Vector(0, 0, 96), + filter = { bot, human }, + mask = MASK_PLAYERSOLID + }) + if floor.Hit and not floor.StartSolid and floor.HitNormal.z > 0.45 then + local position = floor.HitPos + Vector(0, 0, 1) + local clearance = util.TraceHull({ + start = position, + endpos = position, + mins = Vector(-16, -16, 0), + maxs = Vector(16, 16, 72), + filter = { bot, human }, + mask = MASK_PLAYERSOLID + }) + if not clearance.StartSolid and util.IsInWorld(position) then + bot:SetPos(position) + return true + end + end + end + return false +end + +hook.Add("StartCommand", "Feedc0deCoopBotControl", function(bot, command) + if not IsManagedBot(bot) or not bot:Alive() then return end + + command:ClearButtons() + command:ClearMovement() + + local state = botState[bot] or {} + botState[bot] = state + local now = CurTime() + local activeWeapon = SelectBestWeapon(bot, state, now) + if (state.nextEnemySearch or 0) <= now then + state.enemy, state.enemyDistance = ClosestEnemy(bot) + state.nextEnemySearch = now + 0.35 + end + + local target = IsValid(state.enemy) and state.enemy or nil + local distanceSquared = state.enemyDistance + local targetPosition + local attacking = false + local followingHuman = false + local seekingPickup = false + if target and target:Health() > 0 then + attacking, targetPosition = VisibleAimPosition(bot, target) + if attacking then + distanceSquared = bot:GetPos():DistToSqr(target:GetPos()) + else + state.enemy = nil + target = nil + end + end + if attacking and not WeaponCanFire(bot, activeWeapon) then + local pickup + pickup, distanceSquared = ClosestUsefulPickup(bot, activeWeapon, state, now) + if IsValid(pickup) then + target = pickup + targetPosition = pickup:WorldSpaceCenter() + attacking = false + seekingPickup = true + state.hasBreadcrumbRoute = false + end + end + if not target then + local human, humanDistanceSquared = ClosestHuman(bot) + local pickup + -- Collect gear only while staying near the player. A tempting weapon + -- must never replace the human as a long-distance navigation target. + if not humanDistanceSquared or humanDistanceSquared <= 450 * 450 then + pickup, distanceSquared = ClosestUsefulPickup(bot, activeWeapon, state, now) + end + if IsValid(pickup) then + target = pickup + targetPosition = pickup:WorldSpaceCenter() + seekingPickup = true + state.hasBreadcrumbRoute = false + else + target, distanceSquared = human, humanDistanceSquared + if IsValid(target) then + targetPosition, state.hasBreadcrumbRoute = FollowPosition(bot, target) + distanceSquared = bot:GetPos():DistToSqr(targetPosition) + followingHuman = true + end + end + end + if not targetPosition then return end + + local direction = targetPosition - bot:GetShootPos() + local aim = direction:Angle() + aim.p = math.Clamp(math.NormalizeAngle(aim.p), -85, 85) + aim.r = 0 + command:SetViewAngles(aim) + bot:SetEyeAngles(aim) + + local buttons = 0 + if (state.duckUntil or 0) > now then buttons = bit.bor(buttons, IN_DUCK) end + local wantsMovement = false + local needsReload = IsValid(activeWeapon) + and activeWeapon:Clip1() == 0 + and activeWeapon:GetPrimaryAmmoType() >= 0 + and bot:GetAmmoCount(activeWeapon:GetPrimaryAmmoType()) > 0 + if needsReload then + buttons = bit.bor(buttons, IN_RELOAD) + elseif attacking and WeaponCanFire(bot, activeWeapon) then + buttons = bit.bor(buttons, IN_ATTACK) + end + local horizontal = Vector(direction.x, direction.y, 0) + if horizontal:LengthSqr() > 1 then horizontal:Normalize() end + + if followingHuman then + local verticalDifference = target:GetPos().z - bot:GetPos().z + local humanDistanceSquared = bot:GetPos():DistToSqr(target:GetPos()) + local badlySeparated = humanDistanceSquared > 650 * 650 or math.abs(verticalDifference) > 220 + if badlySeparated then + state.separatedSince = state.separatedSince or now + local catchUpDelay = state.hasBreadcrumbRoute and 12 or 4 + if now - state.separatedSince >= catchUpDelay + and (state.nextCatchUpAttempt or 0) <= now then + state.nextCatchUpAttempt = now + 1 + if CatchUpToHuman(bot) then + state.separatedSince = nil + return + end + end + else + state.separatedSince = nil + end + + -- Direct steering otherwise tends to hesitate at ledges because it + -- has no navmesh route saying that the human deliberately dropped. + if verticalDifference < -72 then + command:SetForwardMove(300) + buttons = bit.bor(buttons, IN_JUMP) + wantsMovement = true + end + else + state.separatedSince = nil + end + + local movementDistance = attacking and 420 + or seekingPickup and 34 + or followingHuman and state.hasBreadcrumbRoute and 48 + or 190 + if distanceSquared and distanceSquared > movementDistance * movementDistance then + command:SetForwardMove(260) + wantsMovement = true + if MovementBlocked(bot, horizontal) then + if DoorOrButtonAhead(bot, horizontal) then + buttons = bit.bor(buttons, IN_USE) + command:SetForwardMove(140) + else + buttons = bit.bor(buttons, IN_JUMP, IN_DUCK) + ForceObstacleHop(bot, state, horizontal, now) + command:SetSideMove(state.strafeRight == false and -220 or 220) + end + if (state.nextStrafeChange or 0) <= now then + state.strafeRight = not state.strafeRight + state.nextStrafeChange = now + math.Rand(0.8, 1.8) + end + end + elseif attacking and distanceSquared and distanceSquared < 150 * 150 then + command:SetForwardMove(-120) + end + + if seekingPickup and IsValid(target) and distanceSquared and distanceSquared <= 100 * 100 + and target:GetClass() == "item_ammo_crate" then + buttons = bit.bor(buttons, IN_USE) + elseif not attacking and IsValid(activeWeapon) + and activeWeapon:Clip1() >= 0 + and activeWeapon:Clip1() < math.max(activeWeapon:GetMaxClip1() * 0.35, 1) + and activeWeapon:GetPrimaryAmmoType() >= 0 + and bot:GetAmmoCount(activeWeapon:GetPrimaryAmmoType()) > 0 then + buttons = bit.bor(buttons, IN_RELOAD) + end + + if wantsMovement then + local position = bot:GetPos() + if not state.progressPosition or position:DistToSqr(state.progressPosition) >= 20 * 20 then + state.progressPosition = position + state.lastProgressAt = now + elseif now - (state.lastProgressAt or now) >= 0.8 then + buttons = bit.bor(buttons, IN_JUMP, IN_DUCK) + ForceObstacleHop(bot, state, horizontal, now) + command:SetForwardMove(-80) + command:SetSideMove(state.strafeRight == false and -280 or 280) + if (state.nextStrafeChange or 0) <= now then + state.strafeRight = not state.strafeRight + state.nextStrafeChange = now + math.Rand(0.6, 1.2) + end + + if DoorOrButtonAhead(bot, horizontal) then buttons = bit.bor(buttons, IN_USE) end + + if seekingPickup and now - (state.lastProgressAt or now) >= 2.5 then + state.ignoredPickup = target + state.ignorePickupUntil = now + 20 + state.progressPosition = bot:GetPos() + state.lastProgressAt = now + return + end + + if followingHuman and now - (state.lastProgressAt or now) >= 8 then + if CatchUpToHuman(bot) then + state.progressPosition = bot:GetPos() + state.lastProgressAt = now + return + end + end + end + else + state.progressPosition = bot:GetPos() + state.lastProgressAt = now + end + + command:SetButtons(buttons) +end) + +hook.Add("PlayerSpawn", "Feedc0deCoopBotLoadout", function(bot) + if not IsManagedBot(bot) then return end + local profile = BotProfile(bot) + SyncBotProfile(bot, profile) + timer.Simple(0.5, function() + if not IsValid(bot) or not bot:Alive() then return end + ApplyBotCosmetics(bot, profile) + CatchUpToHuman(bot) + bot:SetJumpPower(math.max(bot:GetJumpPower(), 210)) + if not bot:HasWeapon("weapon_smg1") then bot:Give("weapon_smg1") end + bot:GiveAmmo(120, "SMG1", true) + bot:SelectWeapon("weapon_smg1") + end) +end) + +-- A 1% roll each minute, with a shared 30 minute floor, makes Trolololo an +-- occasional surprise instead of a recurring soundtrack. +timer.Create("Feedc0deCoopBotNoveltyMusic", 60, 0, function() + local runtime = CustomCoopBotRuntime + if CurTime() < runtime.nextNoveltyMusicAt or math.random(100) ~= 1 then return end + local progression = CustomCoopProgression + local music = progression and progression.GetMusic and progression.GetMusic("trololo") + local playMusic = progression and progression.PlayMusic + local bots = ManagedBots() + if not music or not playMusic or #bots == 0 then return end + local bot = bots[math.random(#bots)] + if IsValid(bot) and bot:Alive() then + if playMusic(bot, music, "") then + runtime.nextNoveltyMusicAt = CurTime() + 30 * 60 + end + end +end) + +hook.Add("PlayerDeath", "Feedc0deCoopBotRespawn", function(bot) + if not IsManagedBot(bot) then return end + timer.Simple(3, function() + if not IsValid(bot) or bot:Alive() then return end + bot:Spawn() + end) +end) + +local function RecoverStalledBots() + local now = CurTime() + for _, bot in ipairs(ManagedBots()) do + if bot:Alive() then + local human, distanceSquared = ClosestHuman(bot) + local state = botState[bot] or {} + botState[bot] = state + local position = bot:GetPos() + + if not IsValid(human) or not distanceSquared or distanceSquared <= 300 * 300 then + state.recoveryPosition = position + state.recoveryProgressAt = now + state.recoveryFarSince = nil + else + if not state.recoveryPosition + or position:DistToSqr(state.recoveryPosition) >= 24 * 24 then + state.recoveryPosition = position + state.recoveryProgressAt = now + end + + if distanceSquared > 700 * 700 then + state.recoveryFarSince = state.recoveryFarSince or now + else + state.recoveryFarSince = nil + end + + local stalled = now - (state.recoveryProgressAt or now) >= 6 + local veryFar = state.recoveryFarSince and now - state.recoveryFarSince >= 4 + if (stalled or veryFar) and CatchUpToHuman(bot) then + state.recoveryPosition = bot:GetPos() + state.recoveryProgressAt = now + state.recoveryFarSince = nil + state.separatedSince = nil + state.progressPosition = bot:GetPos() + state.lastProgressAt = now + end + end + end + end +end + +hook.Add("Initialize", "Feedc0deCoopBotsInitialize", function() + timer.Simple(2, MaintainBots) +end) + +timer.Create("Feedc0deCoopBotsMaintain", 10, 0, MaintainBots) +timer.Create("Feedc0deCoopBotsRecoverStalls", 1, 0, RecoverStalledBots) +timer.Create("Feedc0deCoopBotFakeProfiles", 1, 0, UpdateBotProfiles) +cvars.AddChangeCallback("gc_bot_count", function() + timer.Simple(0, MaintainBots) +end, "Feedc0deCoopBotsCount") + +concommand.Remove("coop_bots_refresh") +concommand.Add("coop_bots_refresh", function(ply) + if IsValid(ply) and not ply:IsAdmin() then return end + MaintainBots() +end) diff --git a/gamemodes/feedc0de_coop/gamemode/friendly_scanner_spawner.lua b/gamemodes/feedc0de_coop/gamemode/friendly_scanner_spawner.lua index ffbb8a3..b79e542 100644 --- a/gamemodes/feedc0de_coop/gamemode/friendly_scanner_spawner.lua +++ b/gamemodes/feedc0de_coop/gamemode/friendly_scanner_spawner.lua @@ -193,6 +193,7 @@ end hook.Add("InitPostEntity", "Feedc0deCoopFriendlyScannerSpawn", ResetManagedScanner) hook.Add("PostCleanupMap", "Feedc0deCoopFriendlyScannerCleanup", ResetManagedScanner) hook.Add("PlayerSpawn", "Feedc0deCoopFriendlyScannerInitialPlayerAnchor", function(ply) + if ply:IsBot() then return end BeginPlayerSpawnTracking(ply) end) diff --git a/gamemodes/feedc0de_coop/gamemode/init.lua b/gamemodes/feedc0de_coop/gamemode/init.lua index 25454a1..21bef19 100644 --- a/gamemodes/feedc0de_coop/gamemode/init.lua +++ b/gamemodes/feedc0de_coop/gamemode/init.lua @@ -3,7 +3,8 @@ AddCSLuaFile( "shared.lua" ) AddCSLuaFile( "levels.lua" ) AddCSLuaFile( "progression_shared.lua" ) AddCSLuaFile( "cl_progression.lua" ) -include( "spawninit.lua" ) +include( "map_compatibility.lua" ) +include( "spawninit.lua" ) include( "weaponsystem.lua" ) include( "chargersystem.lua" ) include( "allplayersdied.lua" ) @@ -19,6 +20,7 @@ include( "noheadcrabs.lua" ) include( "shared.lua" ) include( "maprotation.lua" ) include( "sv_progression.lua" ) +include( "coop_bots.lua" ) include( "friendly_scanner_spawner.lua" ) resource.AddFile( "sound/weapons/universal/pap.wav" ) diff --git a/gamemodes/feedc0de_coop/gamemode/map_compatibility.lua b/gamemodes/feedc0de_coop/gamemode/map_compatibility.lua new file mode 100644 index 0000000..688e483 --- /dev/null +++ b/gamemodes/feedc0de_coop/gamemode/map_compatibility.lua @@ -0,0 +1,22 @@ +-- coop_abducted_4 is a legacy map made for the old "coopgamemode" addon. +-- Its logic_auto runs this command during OnMapSpawn: +-- kickall "Use the CO-OP Gamemode!" +-- feedc0de_coop is the compatible replacement, so suppress only that exact +-- map-enforced kick. All other server and administrator kick commands retain +-- their normal behavior. +if SERVER then + CustomCoopOriginalRunConsoleCommand = + CustomCoopOriginalRunConsoleCommand or RunConsoleCommand + + RunConsoleCommand = function(command, ...) + if command == "kickall" and game.GetMap() == "coop_abducted_4" then + local reason = select(1, ...) + if reason == "Use the CO-OP Gamemode!" then + print("[feedc0de_coop] Ignoring coop_abducted_4 legacy gamemode kick") + return + end + end + + return CustomCoopOriginalRunConsoleCommand(command, ...) + end +end diff --git a/gamemodes/feedc0de_coop/gamemode/progression_shared.lua b/gamemodes/feedc0de_coop/gamemode/progression_shared.lua index ae80741..b4ceae9 100644 --- a/gamemodes/feedc0de_coop/gamemode/progression_shared.lua +++ b/gamemodes/feedc0de_coop/gamemode/progression_shared.lua @@ -25,6 +25,14 @@ Progression.HoverpackWalkingRechargePerSecond = 22 Progression.HoverpackSound = "PhysicsCannister.ThrusterLoop" Progression.HoverpackSoundVolume = 0.55 Progression.HoverpackSoundPitch = 115 +Progression.GrappleMaximumDistance = 3500 +Progression.GrappleMinimumLength = 72 +Progression.GrappleReelSpeed = 620 +Progression.GrappleAcceleration = 1900 +Progression.GrappleMaximumPullSpeed = 1050 +Progression.GrappleDrainPerSecond = 10 +Progression.SpeedBoostMultiplier = 1.25 +Progression.FisheyeFOV = 112 Progression.ShopSpawnDistance = 500 Progression.PurchasedEntityLimit = 12 Progression.PurchasedHelperLimit = 6 @@ -222,7 +230,7 @@ Progression.Weapons = { id = "popcorn_spammable", name = "Popcorn Spammable", weaponClass = "weapon_popcorn_spam", - price = 750, + price = 50, description = "Cover the battlefield in an unreasonable amount of popcorn." }, { @@ -262,6 +270,87 @@ Progression.Behaviours = { price = 500, description = "Hold Space after jumping to hover and rise slowly. Toggle this behaviour on or off." }, + { + id = "grappling_hook", + name = "Grappling Hook", + price = 600, + description = "Hold Use + Space while looking at a surface to attach, reel in and swing. Works together with Hoverpack." + }, + { + id = "auto_bhop", + name = "Automatic Bunny Hop", + price = 250, + description = "Hold Space while moving to jump again automatically whenever you touch the ground." + }, + { + id = "speed_boost", + name = "Movement Boost", + price = 350, + description = "Permanently unlock a toggleable 25% walking and running speed boost." + }, + { + id = "fisheye", + name = "Wide-Angle Vision", + price = 25, + description = "Toggle a wide 112-degree field of view without changing your weapon or sensitivity settings." + }, + { + id = "beacon", + name = "Pulsating Beacon", + price = 75, + description = "Toggle a bright, softly pulsating outline that helps teammates find you." + }, + { + id = "health_refill", + name = "Restore Health", + price = 20, + consumable = true, + action = "health", + description = "Immediately restore your health to its current maximum. Purchased separately each time." + }, + { + id = "suit_refill", + name = "Overcharged Suit", + price = 35, + amount = 200, + consumable = true, + action = "suit", + description = "Immediately charge your suit armour to 200. Purchased separately each time." + }, + { + id = "ammo_refill", + name = "Full Ammunition", + price = 40, + consumable = true, + action = "ammo", + description = "Refill ammunition for every weapon you currently carry. Purchased separately each time." + }, + { + id = "allguns", + name = "Half-Life 2 Arsenal", + price = 100, + consumable = true, + action = "allguns", + description = "Receive the standard Half-Life 2 weapons and full ammunition for this life." + }, + { + id = "boost_bundle", + name = "Emergency Boost Bundle", + price = 180, + duration = 180, + consumable = true, + action = "boost", + description = "All HL2 weapons, full ammo, 200 armour and a three-minute movement boost." + }, + { + id = "cloak_60", + name = "Cloak — 60 Seconds", + price = 150, + duration = 60, + consumable = true, + action = "cloak", + description = "Become nearly transparent for one minute. NPC awareness is unchanged." + }, { id = "godmode_30", name = "God Mode — 30 Seconds", @@ -414,6 +503,17 @@ Progression.Entities = { price = 150, spawnOffset = 16, description = "The red Nuke Pack TNT. Damage it to detonate it; nearby charges can chain-react." + }, + { + id = "friendly_healing_scanner", + name = "Friendly Healing Scanner", + class = "feedc0de_friendly_scanner", + model = "models/combine_scanner.mdl", + price = 225, + spawnOffset = 40, + spawnRadius = 32, + spawnHeight = 64, + description = "Deploy a friendly scanner that follows the team and heals injured survivors." } } @@ -608,6 +708,12 @@ local function SetHoverpackThrusting(ply, thrusting) end end +local function SetGrappling(ply, active, position) + if SERVER and Progression.SetGrappling then + Progression.SetGrappling(ply, active, position) + end +end + hook.Add("SetupMove", "CustomCoopProgressionHoverpack", function(ply, moveData) local jumpHeld = moveData:KeyDown(IN_JUMP) local suitPower = ply.GetSuitPower and ply:GetSuitPower() or 100 @@ -645,7 +751,93 @@ hook.Add("SetupMove", "CustomCoopProgressionHoverpack", function(ply, moveData) end local velocity = moveData:GetVelocity() - velocity.z = Progression.HoverpackRiseSpeed + velocity.z = math.max(velocity.z, Progression.HoverpackRiseSpeed) moveData:SetVelocity(velocity) SetHoverpackThrusting(ply, true) end) + +hook.Add("SetupMove", "CustomCoopProgressionGrapplingHook", function(ply, moveData) + local activeKeys = moveData:KeyDown(IN_USE) and moveData:KeyDown(IN_JUMP) + local canGrapple = ply:GetNW2Bool("CustomCoopGrapplingHookEnabled", false) + and ply:Alive() + and ply:GetMoveType() == MOVETYPE_WALK + and ply:WaterLevel() < 3 + and not ply:InVehicle() + and (not ply.GetSuitPower or ply:GetSuitPower() > 0.01) + + if not activeKeys or not canGrapple then + ply.CustomCoopGrapplePoint = nil + ply.CustomCoopGrappleLength = nil + SetGrappling(ply, false) + return + end + + if not ply.CustomCoopGrapplePoint then + local trace = util.TraceLine({ + start = ply:GetShootPos(), + endpos = ply:GetShootPos() + moveData:GetMoveAngles():Forward() + * math.max(Progression.GrappleMaximumDistance or 3500, 128), + filter = ply, + mask = MASK_SOLID + }) + if not trace.Hit or trace.HitSky + or (SERVER and not util.IsInWorld(trace.HitPos)) then + SetGrappling(ply, false) + return + end + + ply.CustomCoopGrapplePoint = trace.HitPos + ply.CustomCoopGrappleLength = ply:WorldSpaceCenter():Distance(trace.HitPos) + SetGrappling(ply, true, trace.HitPos) + end + + local point = ply.CustomCoopGrapplePoint + local offset = point - ply:WorldSpaceCenter() + local distance = offset:Length() + if distance <= 0.001 then return end + + local elapsed = engine.TickInterval() + local minimumLength = math.max(Progression.GrappleMinimumLength or 72, 32) + ply.CustomCoopGrappleLength = math.max( + (ply.CustomCoopGrappleLength or distance) + - math.max(Progression.GrappleReelSpeed or 620, 0) * elapsed, + minimumLength + ) + + local stretch = math.max(distance - ply.CustomCoopGrappleLength, 0) + local direction = offset / distance + local velocity = moveData:GetVelocity() + local currentPullSpeed = velocity:Dot(direction) + local wantedPullSpeed = math.min( + math.max(Progression.GrappleReelSpeed or 620, 0) + stretch * 5, + math.max(Progression.GrappleMaximumPullSpeed or 1050, 100) + ) + if currentPullSpeed < wantedPullSpeed then + local acceleration = math.max(Progression.GrappleAcceleration or 1900, 100) * elapsed + velocity = velocity + direction * math.min(wantedPullSpeed - currentPullSpeed, acceleration) + moveData:SetVelocity(velocity) + end + + if SERVER then ply:SetGroundEntity(NULL) end +end) + +hook.Add("SetupMove", "CustomCoopProgressionMovementBoosts", function(ply, moveData) + if not ply:Alive() or ply:GetMoveType() ~= MOVETYPE_WALK then return end + + -- Preserve Use + Space for the grappling hook. Normal held-Space jumping + -- still gets the usual auto-bhop release while airborne. + if ply:GetNW2Bool("CustomCoopAutoBhopEnabled", false) + and moveData:KeyDown(IN_JUMP) + and not moveData:KeyDown(IN_USE) + and not ply:OnGround() then + moveData:SetButtons(bit.band(moveData:GetButtons(), bit.bnot(IN_JUMP))) + end + + local boosted = ply:GetNW2Bool("CustomCoopSpeedBoostEnabled", false) + or ply:GetNW2Float("CustomCoopTemporarySpeedUntil", 0) > CurTime() + if boosted then + local multiplier = math.Clamp(Progression.SpeedBoostMultiplier or 1.25, 1, 2) + moveData:SetMaxSpeed(moveData:GetMaxSpeed() * multiplier) + moveData:SetMaxClientSpeed(moveData:GetMaxClientSpeed() * multiplier) + end +end) diff --git a/gamemodes/feedc0de_coop/gamemode/spawninit.lua b/gamemodes/feedc0de_coop/gamemode/spawninit.lua index dbf08f3..20fffca 100644 --- a/gamemodes/feedc0de_coop/gamemode/spawninit.lua +++ b/gamemodes/feedc0de_coop/gamemode/spawninit.lua @@ -18,8 +18,10 @@ hook.Add( "PlayerSpawn", "SpawningInitialize", function(player) player:SetMaxSpeed(215) player:SetJumpPower(160) end - player:ConCommand([[mat_colorcorrection 1]]) - player:ConCommand([[cl_showhints 0]]) + if(!player:IsBot()) then + player:ConCommand([[mat_colorcorrection 1]]) + player:ConCommand([[cl_showhints 0]]) + end end end ) player:SetMoveType(MOVETYPE_WALK) diff --git a/gamemodes/feedc0de_coop/gamemode/sv_progression.lua b/gamemodes/feedc0de_coop/gamemode/sv_progression.lua index 8f45c8c..326096e 100644 --- a/gamemodes/feedc0de_coop/gamemode/sv_progression.lua +++ b/gamemodes/feedc0de_coop/gamemode/sv_progression.lua @@ -18,6 +18,8 @@ runtime.purchasedSpawns = runtime.purchasedSpawns or setmetatable({}, { __mode = runtime.shopPrices = runtime.shopPrices or {} runtime.dirtyShopPrices = runtime.dirtyShopPrices or {} runtime.availableMusic = runtime.availableMusic or {} +runtime.checkpoints = runtime.checkpoints or setmetatable({}, { __mode = "k" }) +runtime.checkpointCooldowns = runtime.checkpointCooldowns or setmetatable({}, { __mode = "k" }) local playerStates = runtime.playerStates local disconnectedStates = runtime.disconnectedStates @@ -27,6 +29,8 @@ local purchasedSpawns = runtime.purchasedSpawns local shopPrices = runtime.shopPrices local dirtyShopPrices = runtime.dirtyShopPrices local availableMusic = runtime.availableMusic +local checkpoints = runtime.checkpoints +local checkpointCooldowns = runtime.checkpointCooldowns local musicBusyUntil = 0 local totalsSaveInterval = CreateConVar( "gc_progression_save_interval", @@ -47,6 +51,7 @@ util.AddNetworkString("CustomCoopProgressionPlayMusic") util.AddNetworkString("CustomCoopProgressionToggleMusicMute") util.AddNetworkString("CustomCoopProgressionRequestPlayers") util.AddNetworkString("CustomCoopProgressionPlayers") +util.AddNetworkString("CustomCoopProgressionKillReward") local function Query(query, context) local startedAt = SysTime() @@ -232,6 +237,24 @@ if not runtime.shopPricesLoaded then runtime.shopPricesLoaded = true end +-- Existing servers keep shop prices in SQLite. Apply this one-off balance +-- change on both a normal start and a live Lua refresh, without touching +-- administrator-adjusted prices that differ from the old catalogue value. +if not runtime.popcornSpammablePriceMigration then + local oldPopcornPrice = 750 + local newPopcornPrice = 50 + local migration = Query(string.format( + "UPDATE custom_coop_shop_prices SET price = %d WHERE item_type = 'weapon' " .. + "AND item_id = 'popcorn_spammable' AND price = %d", + newPopcornPrice, + oldPopcornPrice + ), "could not migrate Popcorn Spammable price") + if migration ~= false and shopPrices[PriceKey("weapon", "popcorn_spammable")] == oldPopcornPrice then + shopPrices[PriceKey("weapon", "popcorn_spammable")] = newPopcornPrice + end + runtime.popcornSpammablePriceMigration = true +end + local function WriteShopPrices() local entries = {} for _, catalogue in ipairs(catalogues) do @@ -284,7 +307,13 @@ local function SyncPlayer(ply, state) ply:SetNW2String("CustomCoopRank", Progression.GetRank(state.score)) ply:SetNW2String("CustomCoopTrail", state.equippedTrail) ply:SetNW2Bool("CustomCoopHoverpackEnabled", state.enabledBehaviours.hoverpack == true) - if state.enabledBehaviours.hoverpack and ply.EquipSuit + ply:SetNW2Bool("CustomCoopGrapplingHookEnabled", state.enabledBehaviours.grappling_hook == true) + ply:SetNW2Bool("CustomCoopAutoBhopEnabled", state.enabledBehaviours.auto_bhop == true) + ply:SetNW2Bool("CustomCoopSpeedBoostEnabled", state.enabledBehaviours.speed_boost == true) + ply:SetNW2Bool("CustomCoopFisheyeEnabled", state.enabledBehaviours.fisheye == true) + ply:SetNW2Bool("CustomCoopBeaconEnabled", state.enabledBehaviours.beacon == true) + local needsSuit = state.enabledBehaviours.hoverpack or state.enabledBehaviours.grappling_hook + if needsSuit and ply.EquipSuit and (not ply.IsSuitEquipped or not ply:IsSuitEquipped()) then ply:EquipSuit() end @@ -332,7 +361,7 @@ local function AccruePlaytime(ply, state) end local function LoadPlayer(ply) - if not IsValid(ply) then return end + if not IsValid(ply) or ply:IsBot() then return end local steamID64 = ply:SteamID64() if not steamID64 or steamID64 == "" then return end @@ -698,6 +727,23 @@ function Progression.SetHoverpackThrusting(ply, thrusting) end end +function Progression.SetGrappling(ply, active, position) + if not IsValid(ply) then return end + + active = active == true + and ply:GetNW2Bool("CustomCoopGrapplingHookEnabled", false) + and ply:Alive() + and (not ply.GetSuitPower or ply:GetSuitPower() > 0.01) + + if active and isvector(position) then + ply:SetNW2Vector("CustomCoopGrapplePoint", position) + end + if ply:GetNW2Bool("CustomCoopGrappling", false) ~= active then + ply:SetNW2Bool("CustomCoopGrappling", active) + end + if active then ply.CustomCoopGrappleLastUsed = CurTime() end +end + hook.Add("Think", "CustomCoopProgressionHoverpackPower", function() local now = CurTime() @@ -706,16 +752,24 @@ hook.Add("Think", "CustomCoopProgressionHoverpackPower", function() local elapsed = math.Clamp(now - previousUpdate, 0, 0.25) ply.CustomCoopHoverpackPowerUpdated = now - if elapsed > 0 and ply:GetNW2Bool("CustomCoopHoverpackEnabled", false) + local mobilityEnabled = ply:GetNW2Bool("CustomCoopHoverpackEnabled", false) + or ply:GetNW2Bool("CustomCoopGrapplingHookEnabled", false) + if elapsed > 0 and mobilityEnabled and ply.GetSuitPower and ply.SetSuitPower then local power = math.Clamp(ply:GetSuitPower(), 0, 100) local thrusting = ply:GetNW2Bool("CustomCoopHoverpackThrusting", false) + local grappling = ply:GetNW2Bool("CustomCoopGrappling", false) local newPower = power - if thrusting then + if thrusting or grappling then ply.CustomCoopHoverpackLastThrust = now - newPower = math.max(power - Progression.HoverpackDrainPerSecond * elapsed, 0) - if newPower <= 0 then Progression.SetHoverpackThrusting(ply, false) end + local drain = (thrusting and Progression.HoverpackDrainPerSecond or 0) + + (grappling and Progression.GrappleDrainPerSecond or 0) + newPower = math.max(power - drain * elapsed, 0) + if newPower <= 0 then + Progression.SetHoverpackThrusting(ply, false) + Progression.SetGrappling(ply, false) + end else local lastThrust = ply.CustomCoopHoverpackLastThrust or 0 local sprinting = ply:KeyDown(IN_SPEED) and ply:GetVelocity():Length2D() > ply:GetWalkSpeed() @@ -1069,8 +1123,8 @@ local function FindShopSpawnTransform(ply, item, isHelper) 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 radius = math.max(tonumber(item.spawnRadius) or (isHelper and 18 or 12), 1) + local height = math.max(tonumber(item.spawnHeight) or (isHelper and 72 or 28), 1) local blocked = util.TraceHull({ start = position, endpos = position + Vector(0, 0, 1), @@ -1272,12 +1326,12 @@ local function PlayMusic(ply, item, successMessage) "Music is busy. Try again in %d seconds.", math.ceil(musicBusyUntil - now) )) - return + return false end if not availableMusic[item.id] then SendNotice(ply, false, "That music file is unavailable on the server.") - return + return false end local duration = math.max(tonumber(item.duration) or 0, 1) @@ -1292,20 +1346,89 @@ local function PlayMusic(ply, item, successMessage) PrintMessage(HUD_PRINTTALK, string.format("[CO-OP] %s is playing %s.", ply:Nick(), item.name)) local playbackTarget = item.spatial and " from your character." or " for everyone." SendNotice(ply, true, successMessage or ("Playing " .. item.name .. playbackTarget)) + return true +end + +-- Server-side helpers use this instead of duplicating the music resource, +-- cooldown, and broadcast rules. +Progression.PlayMusic = PlayMusic + +local baseHalfLifeWeapons = { + "weapon_crowbar", + "weapon_stunstick", + "weapon_pistol", + "weapon_357", + "weapon_smg1", + "weapon_ar2", + "weapon_shotgun", + "weapon_crossbow", + "weapon_frag", + "weapon_rpg", + "weapon_physcannon" +} + +local function RefillAmmunition(ply) + for _, weapon in ipairs(ply:GetWeapons()) do + for _, ammoType in ipairs({ weapon:GetPrimaryAmmoType(), weapon:GetSecondaryAmmoType() }) do + if ammoType and ammoType >= 0 then + local maximum = game.GetAmmoMax(ammoType) + if not maximum or maximum <= 0 then maximum = 300 end + ply:SetAmmo(math.min(maximum, 9999), ammoType) + end + end + end +end + +local function GiveBaseHalfLifeWeapons(ply) + for _, weaponClass in ipairs(baseHalfLifeWeapons) do + if weapons.GetStored(weaponClass) then ply:Give(weaponClass) end + end + RefillAmmunition(ply) +end + +local function ClearCloak(ply) + if not IsValid(ply) then return end + if ply.CustomCoopCloakPreviousColor then + ply:SetColor(ply.CustomCoopCloakPreviousColor) + ply:SetRenderMode(ply.CustomCoopCloakPreviousRenderMode or RENDERMODE_NORMAL) + end + ply:DrawShadow(true) + ply.CustomCoopCloakPreviousColor = nil + ply.CustomCoopCloakPreviousRenderMode = nil + ply.CustomCoopCloakUntil = nil + ply:SetNW2Float("CustomCoopCloakUntil", 0) end local function ActivateConsumableBehaviour(ply, item) - if not item.godmode then return false end + local action = item.action + if not item.godmode and not action then return false end + if not IsValid(ply) or not ply:Alive() then + SendNotice(ply, false, "You must be alive to activate that purchase.") + return true + end local now = CurTime() - local activeUntil = ply.CustomCoopGodmodeUntil or 0 - if activeUntil > now then + if item.godmode and (ply.CustomCoopGodmodeUntil or 0) > now then SendNotice(ply, false, string.format( "God Mode is already active for another %d seconds. No credits were spent.", - math.ceil(activeUntil - now) + math.ceil(ply.CustomCoopGodmodeUntil - now) )) SendShop(ply) return true + elseif action == "cloak" and (ply.CustomCoopCloakUntil or 0) > now then + SendNotice(ply, false, "Cloak is already active. No credits were spent.") + SendShop(ply) + return true + elseif action == "boost" and ply:GetNW2Float("CustomCoopTemporarySpeedUntil", 0) > now then + SendNotice(ply, false, "The Emergency Boost is already active. No credits were spent.") + SendShop(ply) + return true + elseif action == "health" and ply:Health() >= ply:GetMaxHealth() then + SendNotice(ply, false, "Your health is already full. No credits were spent.") + return true + elseif action == "suit" and ply:Armor() >= math.max(math.floor(item.amount or 200), 0) then + SendNotice(ply, false, "Your suit is already fully overcharged. No credits were spent.") + return true end local state = GetState(ply) @@ -1331,24 +1454,74 @@ local function ActivateConsumableBehaviour(ply, item) state.profileDirty = true end - local duration = math.max(math.floor(tonumber(item.duration) or 90), 1) + local duration = math.max(math.floor(tonumber(item.duration) or 0), 0) local expiresAt = now + duration - ply.CustomCoopGodmodeUntil = expiresAt - ply:SetNW2Float("CustomCoopGodmodeUntil", expiresAt) + local activatedMessage + local expiredMessage + + if item.godmode then + ply.CustomCoopGodmodeUntil = expiresAt + ply:SetNW2Float("CustomCoopGodmodeUntil", expiresAt) + activatedMessage = string.format("Activated God Mode for %d seconds.", duration) + expiredMessage = "God Mode expired." + elseif action == "health" then + ply:SetHealth(ply:GetMaxHealth()) + activatedMessage = "Health restored." + elseif action == "suit" then + if ply.EquipSuit and (not ply.IsSuitEquipped or not ply:IsSuitEquipped()) then ply:EquipSuit() end + ply:SetArmor(math.max(math.floor(item.amount or 200), 0)) + activatedMessage = "Suit armour overcharged." + elseif action == "ammo" then + RefillAmmunition(ply) + activatedMessage = "Ammunition refilled." + elseif action == "allguns" then + GiveBaseHalfLifeWeapons(ply) + activatedMessage = "Half-Life 2 arsenal supplied for this life." + elseif action == "boost" then + GiveBaseHalfLifeWeapons(ply) + if ply.EquipSuit and (not ply.IsSuitEquipped or not ply:IsSuitEquipped()) then ply:EquipSuit() end + ply:SetArmor(math.max(ply:Armor(), 200)) + ply:SetNW2Float("CustomCoopTemporarySpeedUntil", expiresAt) + activatedMessage = string.format("Emergency Boost active for %d seconds.", duration) + expiredMessage = "Emergency movement boost expired." + elseif action == "cloak" then + ply.CustomCoopCloakPreviousColor = ply:GetColor() + ply.CustomCoopCloakPreviousRenderMode = ply:GetRenderMode() + ply.CustomCoopCloakUntil = expiresAt + ply:SetNW2Float("CustomCoopCloakUntil", expiresAt) + ply:SetRenderMode(RENDERMODE_TRANSCOLOR) + local current = ply:GetColor() + ply:SetColor(Color(current.r, current.g, current.b, 35)) + ply:DrawShadow(false) + activatedMessage = string.format("Cloaked for %d seconds.", duration) + expiredMessage = "Cloak expired." + end + SyncPlayer(ply, state) local message = adminCreditBypass - and string.format("Admin test-activated God Mode for %d seconds.", duration) - or string.format("Activated God Mode for %d seconds.", duration) + and ("Admin test: " .. activatedMessage) + or activatedMessage SendNotice(ply, true, message) SendShop(ply) - timer.Simple(duration, function() - if not IsValid(ply) or ply.CustomCoopGodmodeUntil ~= expiresAt then return end - ply.CustomCoopGodmodeUntil = nil - ply:SetNW2Float("CustomCoopGodmodeUntil", 0) - SendNotice(ply, false, "God Mode expired.") - end) + if expiredMessage then + timer.Simple(duration, function() + if not IsValid(ply) then return end + if item.godmode then + if ply.CustomCoopGodmodeUntil ~= expiresAt then return end + ply.CustomCoopGodmodeUntil = nil + ply:SetNW2Float("CustomCoopGodmodeUntil", 0) + elseif action == "boost" then + if ply:GetNW2Float("CustomCoopTemporarySpeedUntil", 0) ~= expiresAt then return end + ply:SetNW2Float("CustomCoopTemporarySpeedUntil", 0) + elseif action == "cloak" then + if ply.CustomCoopCloakUntil ~= expiresAt then return end + ClearCloak(ply) + end + SendNotice(ply, false, expiredMessage) + end) + end return true end @@ -1401,7 +1574,13 @@ local function PurchaseOrEquip(ply, itemType, itemID) state.enabledBehaviours[itemID] = enabled or nil state.behavioursDirty = true SyncPlayer(ply, state) - if not enabled then Progression.SetHoverpackThrusting(ply, false) end + if not enabled and item.id == "hoverpack" then + Progression.SetHoverpackThrusting(ply, false) + elseif not enabled and item.id == "grappling_hook" then + Progression.SetGrappling(ply, false) + ply.CustomCoopGrapplePoint = nil + ply.CustomCoopGrappleLength = nil + end SendNotice(ply, true, item.name .. (enabled and " enabled." or " disabled.")) SendShop(ply) return @@ -1502,7 +1681,7 @@ local function ResolvePlayerAttacker(attacker, inflictor) end end -local function AwardMonsterKill(ply, reward) +local function AwardMonsterKill(ply, reward, npcClass) local state = GetState(ply) if not state then return end @@ -1512,6 +1691,14 @@ local function AwardMonsterKill(ply, reward) state.playerName = ply:Nick() state.profileDirty = true SyncPlayer(ply, state) + + -- Send an event for every kill instead of deriving feedback from the NW2 + -- total. Several NPCs can die in one server tick, in which case the total + -- may arrive at the client as one combined change. + net.Start("CustomCoopProgressionKillReward") + net.WriteString(tostring(npcClass or "enemy")) + net.WriteUInt(math.min(reward, 65535), 16) + net.Send(ply) end local function ChargeForDeath(ply) @@ -1537,6 +1724,7 @@ local function ChargeForDeath(ply) end hook.Add("PlayerInitialSpawn", "CustomCoopProgressionLoad", function(ply) + if ply:IsBot() then return end LoadPlayer(ply) timer.Simple(4, function() @@ -1558,6 +1746,8 @@ end) hook.Add("PlayerDisconnected", "CustomCoopProgressionForget", function(ply) Progression.SetHoverpackThrusting(ply, false) + Progression.SetGrappling(ply, false) + ClearCloak(ply) RemovePlayerTrail(ply) local spawned = purchasedSpawns[ply] if spawned then @@ -1595,7 +1785,14 @@ hook.Add("PlayerSetModel", "CustomCoopProgressionApplyModel", function(ply) end) hook.Add("PlayerSpawn", "CustomCoopProgressionApplyLoadout", function(ply) + -- Managed bots use in-memory profiles in coop_bots.lua; never create a + -- human progression state or overwrite their randomly selected cosmetics. + if ply:IsBot() then return end Progression.SetHoverpackThrusting(ply, false) + Progression.SetGrappling(ply, false) + ClearCloak(ply) + ply.CustomCoopGrapplePoint = nil + ply.CustomCoopGrappleLength = nil ply.CustomCoopGrantedWeapon = nil timer.Simple(0.5, function() @@ -1628,7 +1825,7 @@ hook.Add("OnNPCKilled", "CustomCoopProgressionMonsterKilled", function(npc, atta if not IsValid(ply) or ply:IsBot() then return end npc.CustomCoopScoreAwarded = true - AwardMonsterKill(ply, reward) + AwardMonsterKill(ply, reward, npc:GetClass()) end) hook.Add("EntityTakeDamage", "CustomCoopProgressionTimedGodmode", function(target, damageInfo) @@ -1642,6 +1839,10 @@ end) hook.Add("PlayerDeath", "CustomCoopProgressionDeathPenalty", function(victim) if not IsValid(victim) or victim:IsBot() then return end Progression.SetHoverpackThrusting(victim, false) + Progression.SetGrappling(victim, false) + ClearCloak(victim) + victim.CustomCoopGrapplePoint = nil + victim.CustomCoopGrappleLength = nil RemovePlayerTrail(victim) ChargeForDeath(victim) end) @@ -1654,13 +1855,117 @@ hook.Add("PostCleanupMap", "CustomCoopProgressionRestoreTrails", function() end) end) +local shopChatItems = { + ["!health"] = "health_refill", + ["/health"] = "health_refill", + ["!suit"] = "suit_refill", + ["/suit"] = "suit_refill", + ["!ammo"] = "ammo_refill", + ["/ammo"] = "ammo_refill", + ["!allguns"] = "allguns", + ["/allguns"] = "allguns", + ["!boost"] = "boost_bundle", + ["/boost"] = "boost_bundle", + ["!cloak"] = "cloak_60", + ["/cloak"] = "cloak_60", + ["!godmode"] = "godmode_30", + ["/godmode"] = "godmode_30", + ["!grapple"] = "grappling_hook", + ["/grapple"] = "grappling_hook", + ["!bhop"] = "auto_bhop", + ["/bhop"] = "auto_bhop", + ["!speed"] = "speed_boost", + ["/speed"] = "speed_boost", + ["!fisheye"] = "fisheye", + ["/fisheye"] = "fisheye", + ["!beep"] = "beacon", + ["/beep"] = "beacon" +} + +local function SaveCheckpoint(ply) + if not ply:Alive() or not ply:OnGround() then + SendNotice(ply, false, "Stand safely on the ground before saving a checkpoint.") + return + end + checkpoints[ply] = { + map = game.GetMap(), + position = Vector(ply:GetPos()), + eyeAngles = Angle(ply:EyeAngles()) + } + SendNotice(ply, true, "Checkpoint saved.") +end + +local function GoToCheckpoint(ply) + local checkpoint = checkpoints[ply] + if not checkpoint or checkpoint.map ~= game.GetMap() then + SendNotice(ply, false, "You have no checkpoint on this map.") + return + end + if not ply:Alive() then + SendNotice(ply, false, "You must be alive to return to a checkpoint.") + return + end + if (checkpointCooldowns[ply] or 0) > CurTime() then + SendNotice(ply, false, "Checkpoint travel is cooling down.") + return + end + + local blocked = util.TraceHull({ + start = checkpoint.position, + endpos = checkpoint.position, + mins = ply:OBBMins(), + maxs = ply:OBBMaxs(), + filter = ply, + mask = MASK_PLAYERSOLID + }) + if blocked.StartSolid then + SendNotice(ply, false, "The saved checkpoint is currently obstructed.") + return + end + + checkpointCooldowns[ply] = CurTime() + 5 + ply:SetPos(checkpoint.position) + ply:SetEyeAngles(checkpoint.eyeAngles) + ply:SetLocalVelocity(vector_origin) + SendNotice(ply, true, "Returned to checkpoint.") +end + hook.Add("PlayerSay", "CustomCoopProgressionChatCommands", function(ply, text) local command = string.Trim(string.lower(text or "")) - if command == "!shop" or command == "/shop" then + if command == "!shop" or command == "/shop" + or command == "!buy" or command == "/buy" + or command == "!store" or command == "/store" then SendShop(ply) return "" end + local behaviourID = shopChatItems[command] + if behaviourID then + PurchaseOrEquip(ply, "behaviour", behaviourID) + return "" + end + + if command == "!cp" or command == "/cp" then + SaveCheckpoint(ply) + return "" + end + + if command == "!go" or command == "/go" then + GoToCheckpoint(ply) + return "" + end + + if command == "!ucp" or command == "/ucp" then + checkpoints[ply] = nil + SendNotice(ply, true, "Checkpoint removed.") + return "" + end + + if command == "currentmap" or command == "!currentmap" or command == "/currentmap" then + ply:ChatPrint("[CO-OP] Current map: " .. game.GetMap()) + return "" + end + if command == "!points" or command == "/points" then local state = GetState(ply) if state then diff --git a/lua/entities/feedc0de_friendly_scanner/cl_init.lua b/lua/entities/feedc0de_friendly_scanner/cl_init.lua index b5babb4..256510a 100644 --- a/lua/entities/feedc0de_friendly_scanner/cl_init.lua +++ b/lua/entities/feedc0de_friendly_scanner/cl_init.lua @@ -42,6 +42,28 @@ function ENT:Draw() self:DrawModel() end +function ENT:Think() + self.CustomCoopTailBone = self.CustomCoopTailBone or self:LookupBone("Scanner.Tail") + local tailBone = self.CustomCoopTailBone + if tailBone then + if self:GetNW2Bool("CustomCoopScannerWreck", false) then + self:ManipulateBoneAngles(tailBone, angle_zero) + else + -- Client-only bone motion: this gives the tail some life without + -- feeding any rotation back into the VPhysics flight controller. + local speed = math.Clamp(self:GetVelocity():Length() / 80, 0, 1) + local phase = RealTime() + self:EntIndex() * 0.37 + local yaw = math.sin(phase * 2.1) * (6 + speed * 4) + local pitch = math.sin(phase * 1.35 + 0.8) * 3.5 + local roll = math.sin(phase * 1.7 + 2.2) * 2 + self:ManipulateBoneAngles(tailBone, Angle(pitch, yaw, roll)) + end + end + + self:SetNextClientThink(CurTime()) + return true +end + function ENT:DrawTranslucent() if self:GetNW2Bool("CustomCoopScannerWreck", false) then return end