convert this repo to a full garrysmod addon, defining a gamemode and a npc friendly scanner
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||