Files

509 lines
21 KiB
Lua
Raw Permalink Normal View History

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)