Bot respawning logic and shop improvements

This commit is contained in:
2026-09-19 14:28:43 +02:00
parent 8aae75a472
commit 7d2035e4c3
11 changed files with 1684 additions and 59 deletions
+62 -4
View File
@@ -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.
@@ -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
@@ -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)
@@ -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)
@@ -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)
+3 -1
View File
@@ -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" )
@@ -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
@@ -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)
@@ -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)
@@ -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
@@ -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