convert this repo to a full garrysmod addon, defining a gamemode and a npc friendly scanner

This commit is contained in:
2026-09-13 18:57:32 +02:00
parent 568eb1520f
commit 8aae75a472
150 changed files with 1054 additions and 116 deletions
+30 -2
View File
@@ -1,4 +1,10 @@
# feedc0de Coop Campaign
# feedc0de Coop Addon
This repository is a complete Garry's Mod addon. It contains the
`feedc0de_coop` gamemode under `gamemodes/` and reusable Lua entities under
`lua/entities/`. Installing the repository as
`garrysmod/addons/feedc0de_coop` enables both the campaign gamemode and the
standalone friendly scanner.
## Persistent progression and rewards shop
@@ -114,7 +120,8 @@ The equivalent server/admin console command is:
For example: `coop_setprice music song_id 500`. The maximum accepted price is
100,000,000 Credits. Existing owners retain their unlocks when a price changes.
Configuration is in `gamemode/progression_shared.lua`:
Configuration is in
`gamemodes/feedc0de_coop/gamemode/progression_shared.lua`:
- `Progression.Ranks`: rank names and Lifetime Score thresholds
- `Progression.Models`: player-model catalogue and prices
@@ -144,6 +151,27 @@ back it up separately.
Player-spawned NPCs and unknown/friendly NPC classes do not award points.
Each map also gets one yellow friendly scanner near the preferred player spawn.
It is an independently reusable scripted entity listed in the Q-menu under
`feedc0de Coop`, so admins can spawn it in Sandbox and other gamemodes too. It
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
scanner at the same spawn anchor after five seconds. The defaults can be
adjusted with
`gc_friendlyscanner_enabled`, `gc_friendlyscanner_healradius`,
`gc_friendlyscanner_searchradius`, `gc_friendlyscanner_healrate`, and
`gc_friendlyscanner_respawntime`.
The gamemode establishes its map anchor only after the first player's position
has remained free of teleport-sized jumps for several samples. A downward
brush trace acts as the scanner's floor-distance sensor: every patrol target,
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.
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.
-2
View File
@@ -1,2 +0,0 @@
include( "shared.lua" )
include( "cl_progression.lua" )

Before

Width:  |  Height:  |  Size: 527 KiB

After

Width:  |  Height:  |  Size: 527 KiB

Before

Width:  |  Height:  |  Size: 468 KiB

After

Width:  |  Height:  |  Size: 468 KiB

Before

Width:  |  Height:  |  Size: 360 KiB

After

Width:  |  Height:  |  Size: 360 KiB

Before

Width:  |  Height:  |  Size: 394 KiB

After

Width:  |  Height:  |  Size: 394 KiB

Before

Width:  |  Height:  |  Size: 603 KiB

After

Width:  |  Height:  |  Size: 603 KiB

Before

Width:  |  Height:  |  Size: 341 KiB

After

Width:  |  Height:  |  Size: 341 KiB

Before

Width:  |  Height:  |  Size: 485 KiB

After

Width:  |  Height:  |  Size: 485 KiB

Before

Width:  |  Height:  |  Size: 521 KiB

After

Width:  |  Height:  |  Size: 521 KiB

Before

Width:  |  Height:  |  Size: 376 KiB

After

Width:  |  Height:  |  Size: 376 KiB

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Before

Width:  |  Height:  |  Size: 582 KiB

After

Width:  |  Height:  |  Size: 582 KiB

@@ -0,0 +1,6 @@
include( "shared.lua" )
include( "cl_progression.lua" )
-- Clear render hooks left by the scanner's former gamemode-local implementation.
hook.Remove("Think", "CustomCoopFriendlyScannerRenderInterpolation")
hook.Remove("PostDrawTranslucentRenderables", "CustomCoopFriendlyScannerEffects")
@@ -0,0 +1,282 @@
if not SERVER then return end
local scannerClass = "feedc0de_friendly_scanner"
local managedTag = "CustomCoopGamemodeManaged"
local reanchorTimer = "Feedc0deCoopFriendlyScannerInitialAnchor"
local respawnTimer = "Feedc0deCoopFriendlyScannerRespawn"
local managedScanner
local anchorFinalized = false
local hoverHeight = 97
local preferredSpawnClasses = {
info_player_deathmatch = 1,
info_player_coop = 1,
info_coop_spawn = 1,
gmod_player_start = 1,
info_player_rebel = 2,
info_player_start = 3,
info_player_combine = 4
}
for _, className in ipairs(scripted_ents.GetMember("gmod_player_start", "SpawnPointClasses") or {}) do
preferredSpawnClasses[className] = preferredSpawnClasses[className] or 2
end
local function FindManagedScanner()
if IsValid(managedScanner) then return managedScanner end
for _, candidate in ipairs(ents.FindByClass(scannerClass)) do
if candidate:GetNW2Bool(managedTag, false) then
managedScanner = candidate
return candidate
end
end
end
local function FindClearPosition(base, ignoredEntity)
local ignored = {}
if IsValid(managedScanner) then ignored[#ignored + 1] = managedScanner end
if IsValid(ignoredEntity) then ignored[#ignored + 1] = ignoredEntity end
local offsets = {
Vector(36, 0, 0),
Vector(-36, 0, 0),
Vector(0, 36, 0),
Vector(0, -36, 0),
Vector(56, 0, 0),
Vector(-56, 0, 0),
Vector(0, 56, 0),
Vector(0, -56, 0),
Vector(0, 0, 0),
Vector(88, 88, 0),
Vector(-88, -88, 0),
Vector(88, -88, 0),
Vector(-88, 88, 0)
}
for _, offset in ipairs(offsets) do
local horizontal = base + offset
local floorTrace = util.TraceLine({
start = Vector(horizontal.x, horizontal.y, base.z + 32),
endpos = Vector(horizontal.x, horizontal.y, base.z - 80),
mask = MASK_SOLID_BRUSHONLY
})
if floorTrace.Hit and not floorTrace.StartSolid and floorTrace.HitNormal.z > 0.45 then
local floorZ = floorTrace.HitPos.z
local ceilingTrace = util.TraceLine({
start = Vector(horizontal.x, horizontal.y, floorZ + 20),
endpos = Vector(horizontal.x, horizontal.y, floorZ + hoverHeight + 64),
mask = MASK_SOLID_BRUSHONLY
})
local height = hoverHeight
if ceilingTrace.Hit then height = math.min(height, ceilingTrace.HitPos.z - floorZ - 24) end
if height >= 30 then
local position = Vector(horizontal.x, horizontal.y, floorZ + height)
local clearance = util.TraceHull({
start = position,
endpos = position,
mins = Vector(-34, -34, -22),
maxs = Vector(34, 34, 22),
filter = ignored,
mask = MASK_SOLID
})
if not clearance.StartSolid and util.IsInWorld(position) then return position end
end
end
end
return nil
end
local function FindMapSpawnPosition()
local bestPriority
local candidates = {}
for _, candidate in ipairs(ents.GetAll()) do
local priority = preferredSpawnClasses[candidate:GetClass()]
if priority and candidate:IsInWorld() then
if candidate:HasSpawnFlags(1) then priority = 0 end
if not bestPriority or priority < bestPriority then
bestPriority = priority
candidates = { candidate }
elseif priority == bestPriority then
candidates[#candidates + 1] = candidate
end
end
end
local base = #candidates > 0 and candidates[math.random(#candidates)]:GetPos() or vector_origin
return FindClearPosition(base)
end
local function SpawnManagedScanner(position)
local enabled = GetConVar("gc_friendlyscanner_enabled")
if enabled and not enabled:GetBool() then return end
local existing = FindManagedScanner()
if IsValid(existing) then return existing end
local scanner = ents.Create(scannerClass)
if not IsValid(scanner) then
ErrorNoHalt("[feedc0de_coop] The friendly scanner addon entity is unavailable\n")
return
end
position = position or FindMapSpawnPosition()
if not isvector(position) then
ErrorNoHalt("[feedc0de_coop] No in-world clearance for the friendly scanner near a player spawn\n")
scanner:Remove()
return
end
scanner:SetPos(position)
scanner:SetAngles(Angle(0, math.random(0, 359), 0))
scanner:Spawn()
scanner:Activate()
scanner:SetPatrolAnchor(position, true)
scanner:SetNW2Bool(managedTag, true)
managedScanner = scanner
return scanner
end
local function PlayerSpawnPosition(ply)
if IsValid(ply) then
local spawnpoint = ply:GetVar("LastSpawnpoint")
if IsValid(spawnpoint) then
local position = FindClearPosition(spawnpoint:GetPos())
if isvector(position) then return position end
end
-- Teleport-based coop maps can keep their nominal Source spawn in
-- empty void. Retry against the player's actual post-teleport origin.
local position = FindClearPosition(ply:GetPos(), ply)
if isvector(position) then return position end
end
return FindMapSpawnPosition()
end
local function BeginPlayerSpawnTracking(ply)
if anchorFinalized or timer.Exists(reanchorTimer) or not IsValid(ply) then return end
local previousPosition
local stableSamples = 0
timer.Create(reanchorTimer, 0.15, 30, function()
if anchorFinalized then
timer.Remove(reanchorTimer)
return
end
if not IsValid(ply) or not ply:Alive() then
previousPosition = nil
stableSamples = 0
return
end
local currentPosition = ply:GetPos()
local candidate = FindClearPosition(currentPosition, ply)
local ordinaryMovement = isvector(previousPosition)
and currentPosition:DistToSqr(previousPosition) <= 80 * 80
if ordinaryMovement and isvector(candidate) then
stableSamples = stableSamples + 1
else
-- Teleports and invalid/void positions restart stabilization.
stableSamples = 0
end
previousPosition = currentPosition
if stableSamples < 4 then return end
local scanner = FindManagedScanner() or SpawnManagedScanner(candidate)
if not IsValid(scanner) then return end
scanner:SetPatrolAnchor(candidate, true)
anchorFinalized = true
timer.Remove(reanchorTimer)
end)
end
local function ResetManagedScanner()
timer.Remove(reanchorTimer)
timer.Remove(respawnTimer)
managedScanner = nil
anchorFinalized = false
end
hook.Add("InitPostEntity", "Feedc0deCoopFriendlyScannerSpawn", ResetManagedScanner)
hook.Add("PostCleanupMap", "Feedc0deCoopFriendlyScannerCleanup", ResetManagedScanner)
hook.Add("PlayerSpawn", "Feedc0deCoopFriendlyScannerInitialPlayerAnchor", function(ply)
BeginPlayerSpawnTracking(ply)
end)
hook.Add("Feedc0deFriendlyScannerDestroyed", "Feedc0deCoopFriendlyScannerRespawn", function(scanner, anchor, managed)
if not managed then return end
if managedScanner == scanner then managedScanner = nil end
timer.Remove(respawnTimer)
local delay = GetConVar("gc_friendlyscanner_respawntime")
timer.Create(respawnTimer, math.max(delay and delay:GetFloat() or 5, 1), 1, function()
SpawnManagedScanner(anchor)
end)
end)
concommand.Remove("coop_friendlyscanner_here")
concommand.Add("coop_friendlyscanner_here", function(ply)
if IsValid(ply) and not ply:IsAdmin() then return end
local anchorPlayer = IsValid(ply) and ply or player.GetHumans()[1]
if not IsValid(anchorPlayer) then return end
local scanner = FindManagedScanner() or SpawnManagedScanner()
if not IsValid(scanner) then return end
local position = FindClearPosition(anchorPlayer:GetPos(), anchorPlayer)
if not isvector(position) then return end
scanner:SetPatrolAnchor(position, true)
anchorFinalized = true
end)
concommand.Remove("coop_friendlyscanner_reset_spawn")
concommand.Add("coop_friendlyscanner_reset_spawn", function(ply)
if IsValid(ply) and not ply:IsAdmin() then return end
local referencePlayer = IsValid(ply) and ply or player.GetHumans()[1]
local scanner = FindManagedScanner() or SpawnManagedScanner()
if not IsValid(scanner) then return end
local position = PlayerSpawnPosition(referencePlayer)
if not isvector(position) then return end
scanner:SetPatrolAnchor(position, true)
anchorFinalized = true
end)
concommand.Remove("coop_friendlyscanner_arm_next_spawn")
concommand.Add("coop_friendlyscanner_arm_next_spawn", function(ply)
if IsValid(ply) and not ply:IsAdmin() then return end
timer.Remove(reanchorTimer)
timer.Remove(respawnTimer)
for _, scanner in ipairs(ents.FindByClass(scannerClass)) do
if scanner:GetNW2Bool(managedTag, false) then scanner:Remove() end
end
managedScanner = nil
anchorFinalized = false
end)
concommand.Remove("coop_friendlyscanner_reload")
concommand.Add("coop_friendlyscanner_reload", function(ply)
if IsValid(ply) and not ply:IsAdmin() then return end
local scanner = FindManagedScanner()
if not IsValid(scanner) then return end
local anchor = scanner:GetSpawnAnchor()
scanner:SetNW2Bool(managedTag, false)
scanner:Remove()
managedScanner = nil
timer.Simple(0.1, function()
SpawnManagedScanner(anchor)
end)
end)
-- Remove hooks from the pre-addon implementation during live migration.
hook.Remove("Think", "CustomCoopFriendlyScannerThink")
hook.Remove("PlayerInitialSpawn", "CustomCoopFriendlyScannerPlayerRelationship")
hook.Remove("PlayerSpawn", "CustomCoopFriendlyScannerFollowActualSpawn")
hook.Remove("PhysgunPickup", "CustomCoopFriendlyScannerPhysgunPickup")
hook.Remove("PhysgunDrop", "CustomCoopFriendlyScannerPhysgunDrop")
hook.Remove("EntityTakeDamage", "CustomCoopFriendlyScannerPhysgunProtection")
hook.Remove("OnNPCKilled", "CustomCoopFriendlyScannerKilled")
timer.Remove("CustomCoopFriendlyScannerRespawn")
timer.Simple(0, function()
for _, candidate in ipairs(ents.GetAll()) do
if candidate:GetNW2Bool("CustomCoopFriendlyScanner", false) and candidate:GetClass() ~= scannerClass then
candidate:Remove()
end
end
local existing = FindManagedScanner()
if IsValid(existing) and CurTime() > 10 then
-- Hot-refreshing this file must not make the next ordinary player
-- respawn reposition an already-established scanner.
anchorFinalized = true
end
end)
@@ -19,5 +19,6 @@ include( "noheadcrabs.lua" )
include( "shared.lua" )
include( "maprotation.lua" )
include( "sv_progression.lua" )
include( "friendly_scanner_spawner.lua" )
resource.AddFile( "sound/weapons/universal/pap.wav" )
@@ -1,13 +1,13 @@
GM.Name = "feedc0de Coop Campaign"
GM.Author = "0xFEEDC0DE64"
GM.Email = "daniel@brunner.ninja"
GM.Website = "brunner.ninja"
GM.Author = "0xFEEDC0DE64"
GM.Email = "daniel@brunner.ninja"
GM.Website = "brunner.ninja"
DeriveGamemode("sandbox")
include( "levels.lua" )
include( "progression_shared.lua" )
if (SERVER) then
function ChangeRandomLevel()
local randomLevel = GetRandomLevel()
@@ -40,49 +40,49 @@ if (SERVER) then
timer.Simple(0, NextRandomLevel)
end)
end
-- timer.Simple(
-- 1,
-- function()
-- function HideThings(name)
-- if (name == "CHudDamageIndicator" or name == "CHudHintDisplay") then
-- return false
-- end
-- end
-- hook.Add("HUDShouldDraw", "HideThings", HideThings)
-- end
-- )
-- timer.Simple(
-- 1,
-- function()
-- function HideThings(name)
-- if (name == "CHudDamageIndicator" or name == "CHudHintDisplay") then
-- return false
-- end
-- end
-- hook.Add("HUDShouldDraw", "HideThings", HideThings)
-- end
-- )
function GM:IsSpawnpointSuitable(ply, spawnpointent, bMakeSuitable)
local Pos = spawnpointent:GetPos()
-- Note that we're searching the default hull size here for a player in the way of our spawning.
-- This seems pretty rough, seeing as our player's hull could be different.. but it should do the job
-- ( HL2DM kills everything within a 128 unit radius )
local Ents = ents.FindInBox(Pos + Vector(-16, -16, 0), Pos + Vector(16, 16, 72))
if (ply:Team() == TEAM_SPECTATOR or ply:Team() == TEAM_UNASSIGNED) then
return true
end
local Blockers = 0
for k, v in pairs(Ents) do
if (IsValid(v) and v:GetClass() == "player" and v:Alive()) then
Blockers = Blockers + 1
if (bMakeSuitable) then
v:SetVelocity(v:GetPos() + Vector(100, 100, 0))
end
end
end
if (bMakeSuitable) then
return true
end
if (Blockers > 0) then
return false
end
local Pos = spawnpointent:GetPos()
-- Note that we're searching the default hull size here for a player in the way of our spawning.
-- This seems pretty rough, seeing as our player's hull could be different.. but it should do the job
-- ( HL2DM kills everything within a 128 unit radius )
local Ents = ents.FindInBox(Pos + Vector(-16, -16, 0), Pos + Vector(16, 16, 72))
if (ply:Team() == TEAM_SPECTATOR or ply:Team() == TEAM_UNASSIGNED) then
return true
end
local Blockers = 0
for k, v in pairs(Ents) do
if (IsValid(v) and v:GetClass() == "player" and v:Alive()) then
Blockers = Blockers + 1
if (bMakeSuitable) then
v:SetVelocity(v:GetPos() + Vector(100, 100, 0))
end
end
end
if (bMakeSuitable) then
return true
end
if (Blockers > 0) then
return false
end
return true
end
@@ -152,7 +152,20 @@ if SERVER then
end
if #candidates == 0 then
return DefaultPlayerSelectSpawn(self, ply, transition)
if isfunction(DefaultPlayerSelectSpawn) then
return DefaultPlayerSelectSpawn(self, ply, transition)
end
local inherited = self.BaseClass and self.BaseClass.PlayerSelectSpawn
if isfunction(inherited) then
return inherited(self, ply, transition)
end
local remembered = ply:GetNWEntity("CustomCoopLastSpawn")
if IsValid(remembered) then return remembered end
ErrorNoHalt("[feedc0de_coop] No valid player spawn or inherited spawn selector is available\n")
return
end
local lastSpawnpoint = ply:GetVar("LastSpawnpoint")
@@ -252,67 +265,67 @@ function GM:PlayerLoadout(ply)
ply:GiveAmmo(6, "Buckshot", true)
ply:GiveAmmo(6, "357", true)
end
hook.Add( "SpawnMenuOpen", "DisallowSpawnMenu", function ()
return LocalPlayer():IsAdmin()
end)
hook.Add( "ContextMenuOpen", "DisallowSpawnMenu", function ()
return LocalPlayer():IsAdmin()
end)
function GM:PlayerNoClip(ply, desiredState)
if desiredState then
return ply:IsAdmin()
else
return true
end
end
function GM:PlayerDeathSound()
return true
end
function GM:DrawDeathNotice(x, y)
return false
end
function GM:PlayerSpawnVehicle(ply, model, name, table)
return ply:IsAdmin()
end
function GM:PlayerSpawnSWEP(ply, weapon, info)
return ply:IsAdmin()
end
function GM:PlayerSpawnSENT(ply, class)
return ply:IsAdmin()
end
function GM:PlayerSpawnRagdoll(ply, model)
return ply:IsAdmin()
end
function GM:PlayerSpawnProp(ply, model)
return ply:IsAdmin()
end
function GM:PlayerSpawnObject(ply, model, skin)
return ply:IsAdmin()
end
function GM:PlayerSpawnNPC(ply, npc_type, weapon)
return ply:IsAdmin()
end
function GM:PlayerSpawnEffect(ply, model)
return ply:IsAdmin()
end
function GM:PlayerGiveSWEP(ply, weapon, swep)
return ply:IsAdmin()
end
--function GM:HUDAmmoPickedUp( item, amount )
-- return false;
--end
hook.Add( "SpawnMenuOpen", "DisallowSpawnMenu", function ()
return LocalPlayer():IsAdmin()
end)
hook.Add( "ContextMenuOpen", "DisallowSpawnMenu", function ()
return LocalPlayer():IsAdmin()
end)
function GM:PlayerNoClip(ply, desiredState)
if desiredState then
return ply:IsAdmin()
else
return true
end
end
function GM:PlayerDeathSound()
return true
end
function GM:DrawDeathNotice(x, y)
return false
end
function GM:PlayerSpawnVehicle(ply, model, name, table)
return ply:IsAdmin()
end
function GM:PlayerSpawnSWEP(ply, weapon, info)
return ply:IsAdmin()
end
function GM:PlayerSpawnSENT(ply, class)
return ply:IsAdmin()
end
function GM:PlayerSpawnRagdoll(ply, model)
return ply:IsAdmin()
end
function GM:PlayerSpawnProp(ply, model)
return ply:IsAdmin()
end
function GM:PlayerSpawnObject(ply, model, skin)
return ply:IsAdmin()
end
function GM:PlayerSpawnNPC(ply, npc_type, weapon)
return ply:IsAdmin()
end
function GM:PlayerSpawnEffect(ply, model)
return ply:IsAdmin()
end
function GM:PlayerGiveSWEP(ply, weapon, swep)
return ply:IsAdmin()
end
--function GM:HUDAmmoPickedUp( item, amount )
-- return false;
--end

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 117 KiB

@@ -0,0 +1,6 @@
list.Set("NPC", "feedc0de_friendly_scanner", {
Name = "Friendly Healing Scanner",
Class = "feedc0de_friendly_scanner",
Category = "feedc0de Coop",
AdminOnly = true
})
@@ -0,0 +1,69 @@
include("shared.lua")
local healingBeam = Material("effects/laser1")
local healingGlow = Material("sprites/light_glow02_add")
local beamColor = Color(255, 218, 65, 235)
local beamCoreColor = Color(255, 252, 205, 255)
local function LightPosition(scanner)
local attachmentID = scanner:LookupAttachment("light")
local attachment = attachmentID and attachmentID > 0 and scanner:GetAttachment(attachmentID)
return attachment and attachment.Pos or (scanner:WorldSpaceCenter() + scanner:GetForward() * 16)
end
local function DrawHealingArc(startPosition, endPosition, scannerIndex)
local direction = endPosition - startPosition
local normalized = direction:GetNormalized()
local perpendicular = normalized:Cross(Vector(0, 0, 1))
if perpendicular:LengthSqr() < 0.01 then perpendicular = Vector(1, 0, 0) end
perpendicular:Normalize()
local vertical = normalized:Cross(perpendicular):GetNormalized()
local time = CurTime()
local segments = 12
render.SetMaterial(healingBeam)
render.StartBeam(segments + 1)
for segment = 0, segments do
local fraction = segment / segments
local position = LerpVector(fraction, startPosition, endPosition)
if segment > 0 and segment < segments then
local taper = math.sin(fraction * math.pi)
position = position
+ perpendicular * math.sin(time * 13 + segment * 2.31 + scannerIndex) * 6 * taper
+ vertical * math.cos(time * 17 + segment * 1.73 + scannerIndex) * 4 * taper
end
render.AddBeam(position, 4.5, fraction, beamColor)
end
render.EndBeam()
render.DrawBeam(startPosition, endPosition, 1.25, 0, 1, beamCoreColor)
end
function ENT:Draw()
self:DrawModel()
end
function ENT:DrawTranslucent()
if self:GetNW2Bool("CustomCoopScannerWreck", false) then return end
local lightPosition = LightPosition(self)
render.SetMaterial(healingGlow)
render.DrawSprite(lightPosition, 24, 24, beamColor)
local light = DynamicLight(self:EntIndex())
if light then
light.pos = lightPosition
light.r = 255
light.g = 205
light.b = 45
light.brightness = 2
light.Decay = 500
light.Size = 105
light.DieTime = CurTime() + 0.2
end
local patient = self:GetHealingTarget()
if IsValid(patient) and patient:IsPlayer() then
DrawHealingArc(lightPosition, patient:WorldSpaceCenter(), self:EntIndex())
render.DrawSprite(patient:WorldSpaceCenter(), 15, 15, beamCoreColor)
end
end
@@ -0,0 +1,508 @@
AddCSLuaFile("cl_init.lua")
AddCSLuaFile("shared.lua")
include("shared.lua")
local enabled = CreateConVar("gc_friendlyscanner_enabled", "1", FCVAR_ARCHIVE, "Enable friendly healing scanners")
local healRadius = CreateConVar("gc_friendlyscanner_healradius", "360", FCVAR_ARCHIVE, "Friendly scanner healing radius")
local patientSearchRadius = CreateConVar("gc_friendlyscanner_searchradius", "700", FCVAR_ARCHIVE, "Radius in which the friendly scanner looks for hurt players")
local healRate = CreateConVar("gc_friendlyscanner_healrate", "15", FCVAR_ARCHIVE, "Friendly scanner health restored per second")
local respawnTime = CreateConVar("gc_friendlyscanner_respawntime", "5", FCVAR_ARCHIVE, "Friendly scanner respawn delay")
local hoverHeight = 97
local patrolRadius = 250
local flightHullMins = Vector(-34, -34, -22)
local flightHullMaxs = Vector(34, 34, 22)
local worldUp = Vector(0, 0, 1)
local farewellMessages = {
"I will get you for that, %s!",
"That was by accident, right, %s?",
"I was the yellow scanner, %s. The yellow one!",
"Remember: medics have feelings too, %s.",
"My repair bill is going straight to %s.",
"Five seconds, %s. Then we are having a talk.",
"I hope your next medkit rolls away from you, %s.",
"Diagnostic result: %s is hazardous to helpful machinery."
}
local function ClampVectorLength(value, maximum)
local length = value:Length()
if length <= maximum or length <= 0 then return value end
return value * (maximum / length)
end
local function IsScanner(ent)
return IsValid(ent) and ent:GetClass() == "feedc0de_friendly_scanner"
end
function ENT:SpawnFunction(ply, trace, className)
if not trace.Hit then return end
local scanner = ents.Create(className)
if not IsValid(scanner) then return end
local position = trace.HitPos + trace.HitNormal * hoverHeight
scanner:SetPos(position)
scanner:SetAngles(Angle(0, IsValid(ply) and ply:EyeAngles().y or 0, 0))
scanner:Spawn()
scanner:Activate()
scanner:SetSpawnAnchor(position)
if IsValid(ply) then scanner:SetCreator(ply) end
return scanner
end
function ENT:Initialize()
self:SetModel("models/combine_scanner.mdl")
self:PhysicsInit(SOLID_VPHYSICS)
self:SetMoveType(MOVETYPE_VPHYSICS)
self:SetSolid(SOLID_VPHYSICS)
self:SetCollisionGroup(COLLISION_GROUP_NONE)
self:SetUseType(SIMPLE_USE)
self:SetRenderMode(RENDERMODE_TRANSCOLOR)
self:SetColor(Color(255, 210, 55))
self:SetMaxHealth(100)
self:SetHealth(100)
self:SetHealingTarget(NULL)
if self:GetSpawnAnchor() == vector_origin then self:SetSpawnAnchor(self:GetPos()) end
local physics = self:GetPhysicsObject()
if not IsValid(physics) then
ErrorNoHalt("[feedc0de_scanner] Scanner model has no VPhysics object\n")
self:Remove()
return
end
physics:EnableGravity(false)
physics:EnableDrag(true)
physics:SetMass(12)
physics:SetDamping(0.5, 1.8)
physics:Wake()
self.NextControllerThink = CurTime()
self.LastControllerThink = CurTime()
self.NextDestinationAt = 0
self.HealRemainder = 0
self.NextIdleSound = CurTime() + math.Rand(3, 7)
self:SetNW2Bool("CustomCoopFriendlyScanner", true)
self.FlightSound = CreateSound(self, "NPC_CScanner.FlyLoop")
if self.FlightSound then self.FlightSound:PlayEx(0.55, 100) end
-- NPC-list spawning places scripted entities directly on the floor and
-- does not necessarily call SpawnFunction. Establish the desired anchor
-- from a downward floor sensor after the physics object exists.
timer.Simple(0, function()
if not IsValid(self) or self.Destroyed then return end
local hoverPoint = self:FindFloorHoverPoint(self:GetPos())
if isvector(hoverPoint) then self:SetPatrolAnchor(hoverPoint, false) end
end)
end
function ENT:SetPatrolAnchor(position, moveThere)
if not isvector(position) then return end
self:SetSpawnAnchor(position)
self.PatrolDestination = nil
self.FocusPlayer = nil
self.NextDestinationAt = 0
local physics = self:GetPhysicsObject()
if IsValid(physics) then
physics:SetVelocity(vector_origin)
physics:AddAngleVelocity(-physics:GetAngleVelocity())
end
if moveThere then self:SetPos(position) end
end
function ENT:FindFloorHoverPoint(position)
local anchor = self:GetSpawnAnchor()
local probeZ = math.max(position.z, self:GetPos().z)
local floorTrace = util.TraceLine({
start = Vector(position.x, position.y, probeZ),
endpos = Vector(position.x, position.y, probeZ - 512),
filter = self,
mask = MASK_SOLID_BRUSHONLY
})
if not floorTrace.Hit or floorTrace.StartSolid or floorTrace.HitNormal.z <= 0.45 then
return Vector(position.x, position.y, anchor.z)
end
local floorZ = floorTrace.HitPos.z
local ceilingTrace = util.TraceLine({
start = Vector(position.x, position.y, floorZ + 20),
endpos = Vector(position.x, position.y, floorZ + hoverHeight + 64),
filter = self,
mask = MASK_SOLID_BRUSHONLY
})
local targetZ = floorZ + hoverHeight
if ceilingTrace.Hit then targetZ = math.min(targetZ, ceilingTrace.HitPos.z - 24) end
targetZ = math.max(targetZ, floorZ + 30)
return Vector(position.x, position.y, targetZ)
end
function ENT:PlayersNearAnchor(maximumDistance)
local result = {}
local maximumDistanceSquared = maximumDistance * maximumDistance
for _, ply in ipairs(player.GetHumans()) do
if ply:Alive() and ply:GetPos():DistToSqr(self:GetSpawnAnchor()) <= maximumDistanceSquared then
result[#result + 1] = ply
end
end
return result
end
function ENT:FindHealingPatient()
local maximumDistance = math.max(patientSearchRadius:GetFloat(), healRadius:GetFloat(), 0)
local maximumDistanceSquared = maximumDistance * maximumDistance
local closest
local closestDistance
for _, ply in ipairs(player.GetHumans()) do
local distance = self:GetPos():DistToSqr(ply:GetPos())
local maximumHealth = math.max(ply:GetMaxHealth(), 100)
if ply:Alive()
and ply:Health() > 0
and ply:Health() < maximumHealth
and distance <= maximumDistanceSquared
and (not closestDistance or distance < closestDistance)
then
-- Acquisition deliberately ignores line of sight. The scanner
-- must first approach patients around spawn-room corners; the
-- actual healing beam still requires a clear trace.
closest = ply
closestDistance = distance
end
end
return closest
end
function ENT:CanHealPatient(patient)
if not IsValid(patient) or not patient:IsPlayer() or not patient:Alive() then return false end
local maximumDistance = math.max(healRadius:GetFloat(), 0)
if self:GetPos():DistToSqr(patient:GetPos()) > maximumDistance * maximumDistance then return false end
local trace = util.TraceLine({
start = self:WorldSpaceCenter(),
endpos = patient:WorldSpaceCenter(),
filter = { self, patient },
mask = MASK_SOLID_BRUSHONLY
})
return not trace.Hit
end
function ENT:ChooseDestination(preferredPlayer)
local nearbyPlayers = self:PlayersNearAnchor(380)
local focus = IsValid(preferredPlayer) and preferredPlayer
or (#nearbyPlayers > 0 and nearbyPlayers[math.random(#nearbyPlayers)] or nil)
self.FocusPlayer = focus
for attempt = 1, 24 do
local destination
if IsValid(focus) and (IsValid(preferredPlayer) or math.Rand(0, 1) < 0.65) then
local angle = math.Rand(0, math.pi * 2)
local offset = Vector(math.cos(angle), math.sin(angle), 0) * math.Rand(55, 90)
destination = self:FindFloorHoverPoint(focus:GetPos() + offset)
else
local angle = math.Rand(0, math.pi * 2)
local offset = Vector(math.cos(angle), math.sin(angle), 0) * math.Rand(35, patrolRadius)
destination = self:FindFloorHoverPoint(self:GetSpawnAnchor() + offset)
end
local fromAnchor = destination - self:GetSpawnAnchor()
local horizontal = Vector(fromAnchor.x, fromAnchor.y, 0)
if horizontal:Length() > patrolRadius then
horizontal = horizontal:GetNormalized() * patrolRadius
destination.x = self:GetSpawnAnchor().x + horizontal.x
destination.y = self:GetSpawnAnchor().y + horizontal.y
end
local clear = util.TraceHull({
start = destination,
endpos = destination,
mins = flightHullMins,
maxs = flightHullMaxs,
filter = self,
mask = MASK_SOLID
})
local route = util.TraceHull({
start = self:GetPos(),
endpos = destination,
mins = flightHullMins,
maxs = flightHullMaxs,
filter = self,
mask = MASK_SOLID_BRUSHONLY
})
local sufficientlyFar = self:GetPos():DistToSqr(destination) >= 70 * 70
if not clear.StartSolid
and not route.Hit
and util.IsInWorld(destination)
and (IsValid(preferredPlayer) or sufficientlyFar or attempt > 16)
then
self.PatrolDestination = destination
local path = destination - self:GetPos()
local horizontalPath = Vector(path.x, path.y, 0)
if horizontalPath:LengthSqr() > 1 then
horizontalPath:Normalize()
self.SwayAxis = Vector(-horizontalPath.y, horizontalPath.x, 0)
end
self.HoldPosition = nil
self.NextDestinationAt = CurTime() + math.Rand(5, 9)
return
end
end
self.PatrolDestination = nil
self.HoldPosition = self:GetPos()
self.NextDestinationAt = CurTime() + 1
end
function ENT:ApplyFlightController(targetPosition, lookDirection, elapsed, feedForwardVelocity, lookingAtPlayer)
local physics = self:GetPhysicsObject()
if not IsValid(physics) or self.PhysgunHeld then return end
feedForwardVelocity = feedForwardVelocity or vector_origin
local positionError = targetPosition - self:GetPos()
local horizontalError = Vector(positionError.x, positionError.y, 0)
local horizontalFeedForward = Vector(feedForwardVelocity.x, feedForwardVelocity.y, 0)
local desiredHorizontal = ClampVectorLength(horizontalError * 0.55 + horizontalFeedForward, 36)
local desiredVelocity = Vector(
desiredHorizontal.x,
desiredHorizontal.y,
math.Clamp(positionError.z * 0.5 + feedForwardVelocity.z, -12, 12)
)
local velocityError = desiredVelocity - physics:GetVelocity()
-- Strong enough to recover promptly after a barrel or explosion launches
-- it, while remaining an ordinary force-driven VPhysics body that can be
-- displaced and spun by the collision itself.
-- ApplyForceCenter changes VPhysics velocity during the current physics
-- step. Treating these as continuous-force gains above 1 makes velocity
-- overshoot and reverse every update. Correct only a small, elapsed-time
-- scaled fraction of the error per step for critically damped motion.
local horizontalGain = math.min(elapsed * 3.5, 0.2)
local verticalGain = math.min(elapsed * 4, 0.24)
local force = Vector(
velocityError.x * physics:GetMass() * horizontalGain,
velocityError.y * physics:GetMass() * horizontalGain,
velocityError.z * physics:GetMass() * verticalGain
)
force = ClampVectorLength(force, physics:GetMass() * 40)
physics:ApplyForceCenter(force)
if lookDirection:LengthSqr() > 1 then
local lookAngles = lookDirection:Angle()
local wavePitch = lookingAtPlayer and 0 or math.Clamp(-feedForwardVelocity.z * 0.8, -8, 8)
local desiredPitch = math.Clamp(math.NormalizeAngle(lookAngles.p) + wavePitch, -15, 40)
local desiredAngles = Angle(desiredPitch, lookAngles.y, 0)
local yawError = math.AngleDifference(desiredAngles.y, self:GetAngles().y)
local angularVelocity = physics:GetAngleVelocity()
local desiredYawVelocity = math.Clamp(yawError * 1.6, -75, 75)
local currentUp = self:GetUp()
local desiredUp = desiredAngles:Up()
local alignment = math.Clamp(currentUp:Dot(desiredUp), -1, 1)
local tiltAngle = math.deg(math.acos(alignment))
local rightingAxis = currentUp:Cross(desiredUp)
if rightingAxis:LengthSqr() < 0.001 and alignment < 0 then
rightingAxis = self:GetRight()
elseif rightingAxis:LengthSqr() > 0 then
rightingAxis:Normalize()
end
-- Normal steering stays soft, but a collision that rolls the scanner
-- far over gets a decisive recovery. Suppress most yaw authority
-- while badly tilted so turning cannot compete with self-righting.
local severeTilt = tiltAngle > 25
local yawAuthority = math.Clamp((alignment - 0.15) / 0.7, 0, 1)
local rightingSpeed = math.min(tiltAngle * (severeTilt and 5 or 3), 220)
local desiredWorldAngularVelocity = rightingAxis * rightingSpeed
+ worldUp * desiredYawVelocity * yawAuthority
local desiredAngularVelocity = physics:WorldToLocalVector(desiredWorldAngularVelocity)
local correctionGain = math.min(elapsed * (severeTilt and 9 or 4.5), severeTilt and 0.55 or 0.25)
local correctionLimit = severeTilt and 30 or 12
local correction = (desiredAngularVelocity - angularVelocity) * correctionGain
physics:AddAngleVelocity(ClampVectorLength(correction, correctionLimit))
end
end
function ENT:Think()
if self.Destroyed then return false end
local now = CurTime()
if (self.NextControllerThink or 0) > now then
self:NextThink(self.NextControllerThink)
return true
end
local elapsed = math.Clamp(now - (self.LastControllerThink or now), 0.01, 0.1)
self.LastControllerThink = now
self.NextControllerThink = now + 0.05
self:NextThink(self.NextControllerThink)
if not enabled:GetBool() then
self:Remove()
return true
end
local patient = self:FindHealingPatient()
local canHeal = self:CanHealPatient(patient)
self:SetHealingTarget(canHeal and patient or NULL)
if IsValid(patient) and self.FocusPlayer ~= patient then
self:ChooseDestination(patient)
elseif not self.PatrolDestination and (self.NextDestinationAt or 0) <= now then
self:ChooseDestination(IsValid(patient) and patient or nil)
end
local wavePhase = now + self:EntIndex()
local wave = math.sin(wavePhase) * 10
local waveVelocity = math.cos(wavePhase) * 10
local sway = math.cos(wavePhase) * 6
local swayVelocity = -math.sin(wavePhase) * 6
local swayAxis = self.SwayAxis or Vector(self:GetRight().x, self:GetRight().y, 0):GetNormalized()
local targetPosition = self.PatrolDestination or self.HoldPosition or self:GetSpawnAnchor()
targetPosition = Vector(targetPosition.x, targetPosition.y, targetPosition.z + wave) + swayAxis * sway
local feedForwardVelocity = swayAxis * swayVelocity + Vector(0, 0, waveVelocity)
local travel = targetPosition - self:GetPos()
if travel:LengthSqr() < 12 * 12 and self.PatrolDestination then
self.HoldPosition = self.PatrolDestination
self.PatrolDestination = nil
self.NextDestinationAt = now + math.Rand(1.5, 3)
elseif travel:LengthSqr() > 1 then
local obstacle = util.TraceHull({
start = self:GetPos(),
endpos = self:GetPos() + travel:GetNormalized() * math.min(travel:Length(), 42),
mins = flightHullMins,
maxs = flightHullMaxs,
filter = self,
mask = MASK_SOLID
})
if obstacle.Hit then
self.PatrolDestination = nil
self.HoldPosition = self:GetPos()
self.NextDestinationAt = now + 0.8
targetPosition = self.HoldPosition
feedForwardVelocity = vector_origin
travel = targetPosition - self:GetPos()
end
end
local horizontalTravel = Vector(travel.x, travel.y, 0)
local lookDirection
local lookingAtPlayer = false
if IsValid(patient) then
lookDirection = patient:EyePos() - self:WorldSpaceCenter()
lookingAtPlayer = true
elseif IsValid(self.FocusPlayer) then
lookDirection = self.FocusPlayer:EyePos() - self:WorldSpaceCenter()
lookingAtPlayer = true
elseif horizontalTravel:LengthSqr() > 12 * 12 then
lookDirection = travel
else
lookDirection = Vector(self:GetForward().x, self:GetForward().y, 0)
end
self:ApplyFlightController(targetPosition, lookDirection, elapsed, feedForwardVelocity, lookingAtPlayer)
self:SetPoseParameter("dynamo_wheel", math.NormalizeAngle(now * 180))
self:SetPoseParameter("tail_control", 0)
self:SetPoseParameter("alert_control", IsValid(patient) and 1 or 0.25)
self:SetPoseParameter("flex_vert", 0)
self:SetPoseParameter("flex_horz", 0)
if (self.NextIdleSound or 0) <= now then
self.NextIdleSound = now + math.Rand(6, 12)
self:EmitSound("NPC_CScanner.Idle", 65, math.random(96, 106), 0.42)
end
if canHeal then
self.HealRemainder = (self.HealRemainder or 0) + math.max(healRate:GetFloat(), 0) * elapsed
local health = math.floor(self.HealRemainder)
if health > 0 then
self.HealRemainder = self.HealRemainder - health
patient:SetHealth(math.min(patient:Health() + health, math.max(patient:GetMaxHealth(), 100)))
end
if (self.NextHealSound or 0) <= now then
self.NextHealSound = now + 1.2
self:EmitSound("items/smallmedkit1.wav", 58, math.random(105, 118), 0.28)
end
else
self.HealRemainder = 0
end
return true
end
function ENT:OnTakeDamage(damageInfo)
local physics = self:GetPhysicsObject()
if self.Destroyed then
if IsValid(physics) and damageInfo:GetDamageForce():LengthSqr() > 0 then
physics:ApplyForceOffset(damageInfo:GetDamageForce(), damageInfo:GetDamagePosition())
physics:Wake()
end
return
end
if self.PhysgunHeld
or (self.PhysgunProtectedUntil or 0) > CurTime()
or damageInfo:IsDamageType(DMG_CRUSH)
then
return
end
if IsValid(physics) and damageInfo:GetDamageForce():LengthSqr() > 0 then
physics:ApplyForceOffset(damageInfo:GetDamageForce(), damageInfo:GetDamagePosition())
end
self:SetHealth(self:Health() - math.max(damageInfo:GetDamage(), 0))
if self:Health() > 0 or self.Destroyed then return end
self.Destroyed = true
local killer = IsValid(damageInfo:GetAttacker()) and damageInfo:GetAttacker() or damageInfo:GetInflictor()
if IsValid(killer) and not killer:IsPlayer() and IsValid(killer:GetOwner()) then killer = killer:GetOwner() end
if IsValid(killer) and killer:IsPlayer() then
PrintMessage(HUD_PRINTTALK, "[Friendly Scanner] " .. string.format(
farewellMessages[math.random(#farewellMessages)], killer:Nick()
))
else
PrintMessage(HUD_PRINTTALK, "[Friendly Scanner] Tell my charging station I loved it.")
end
local anchor = self:GetSpawnAnchor()
local managed = self:GetNW2Bool("CustomCoopGamemodeManaged", false)
local effect = EffectData()
effect:SetOrigin(self:WorldSpaceCenter())
util.Effect("cball_explode", effect, true, true)
self:EmitSound("npc/scanner/scanner_explode_crash2.wav", 72, 105, 0.8)
-- Leave the destroyed scanner behind as an ordinary physical wreck. The
-- controller no longer fights gravity or impact forces, so explosions,
-- props and players can throw it around naturally.
self:SetHealingTarget(NULL)
self:SetNW2Bool("CustomCoopFriendlyScanner", false)
self:SetNW2Bool("CustomCoopGamemodeManaged", false)
self:SetNW2Bool("CustomCoopScannerWreck", true)
self:SetColor(Color(90, 82, 68))
if IsValid(physics) then
physics:EnableGravity(true)
physics:EnableDrag(true)
physics:SetDamping(0.05, 0.08)
physics:SetMass(18)
physics:Wake()
local damageForce = damageInfo:GetDamageForce()
if damageForce:LengthSqr() > 0 then
physics:ApplyForceOffset(damageForce, damageInfo:GetDamagePosition())
end
end
SafeRemoveEntityDelayed(self, 25)
hook.Run("Feedc0deFriendlyScannerDestroyed", self, anchor, managed)
end
function ENT:OnRemove()
if self.FlightSound then
self.FlightSound:Stop()
self.FlightSound = nil
end
end
hook.Add("PhysgunPickup", "Feedc0deFriendlyScannerPhysgunPickup", function(_, ent)
if not IsScanner(ent) then return end
ent.PhysgunHeld = true
ent.PhysgunProtectedUntil = CurTime() + 1
ent.PatrolDestination = nil
return true
end)
hook.Add("PhysgunDrop", "Feedc0deFriendlyScannerPhysgunDrop", function(_, ent)
if not IsScanner(ent) then return end
ent.PhysgunHeld = false
ent.PhysgunProtectedUntil = CurTime() + 2
local hoverPoint = ent:FindFloorHoverPoint(ent:GetPos())
ent:SetPatrolAnchor(isvector(hoverPoint) and hoverPoint or ent:GetPos(), false)
end)
@@ -0,0 +1,13 @@
ENT.Type = "anim"
ENT.Base = "base_anim"
ENT.PrintName = "Friendly Healing Scanner"
ENT.Author = "0xFEEDC0DE64"
ENT.Category = "feedc0de Coop"
ENT.Spawnable = true
ENT.AdminOnly = true
ENT.RenderGroup = RENDERGROUP_BOTH
function ENT:SetupDataTables()
self:NetworkVar("Vector", 0, "SpawnAnchor")
self:NetworkVar("Entity", 0, "HealingTarget")
end
+14
View File
@@ -0,0 +1,14 @@
-- Hot-load bridge for an SRCDS process that started before this addon existed.
-- A normal server start discovers and registers the addon without this file.
if not SERVER then return end
local previousENT = ENT
ENT = {}
include("entities/feedc0de_friendly_scanner/shared.lua")
include("entities/feedc0de_friendly_scanner/init.lua")
scripted_ents.Register(ENT, "feedc0de_friendly_scanner")
ENT = previousENT
AddCSLuaFile("autorun/feedc0de_friendly_scanner.lua")
include("autorun/feedc0de_friendly_scanner.lua")
print("[feedc0de_scanner] scripted entity and Q-menu entry registered")

Some files were not shown because too many files have changed in this diff Show More