Bot respawning logic and shop improvements
This commit is contained in:
@@ -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,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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user