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)