Make cameras react to the AI eye nearby (#43466)

* init

TODO:
Make this part of the surveillance camera system
Make this be bearable when unpowered(Probably commented out until we make AI lose vision without power)
Remove leftover shit
Probably clean up the camera RSI

* move to surv cameras, unpowered, remove leftovers

* silly

* review

* review

* I can see the light

* reverse fixtures

* review

* dependency query

* unused oops
This commit is contained in:
ScarKy0 2026-04-05 17:03:29 +02:00 • committed by GitHub
parent 9ad745045b
commit 4a88b375df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 207 additions and 161 deletions

View file

@ -30,7 +30,7 @@ public sealed class SurveillanceCameraMicrophoneSystem : EntitySystem
// This function ensures that chat popups appear on camera views that have connected microphones.
foreach (var (_, __, camera, xform) in EntityQuery<SurveillanceCameraMicrophoneComponent, ActiveListenerComponent, SurveillanceCameraComponent, TransformComponent>())
{
if (camera.ActiveViewers.Count == 0)
if (camera.ActivePvsViewers.Count == 0)
continue;
// get range to camera. This way wispers will still appear as obfuscated if they are too far from the camera's microphone
@ -41,7 +41,7 @@ public sealed class SurveillanceCameraMicrophoneSystem : EntitySystem
if (range < 0 || range > ev.VoiceRange)
continue;
foreach (var viewer in camera.ActiveViewers)
foreach (var viewer in camera.ActivePvsViewers)
{
// if the player has not already received the chat message, send it to them but don't log it to the chat
// window. This is simply so that it appears in camera.

View file

@ -0,0 +1,101 @@
using Content.Shared.Power.EntitySystems;
using Content.Shared.SurveillanceCamera;
using Content.Shared.SurveillanceCamera.Components;
using Robust.Shared.Physics.Events;
using Robust.Shared.Physics.Systems;
namespace Content.Server.SurveillanceCamera;
public partial class SurveillanceCameraSystem
{
[Dependency] private readonly SharedPhysicsSystem _physics = default!;
[Dependency] private readonly SharedPowerReceiverSystem _power = default!;
[Dependency] private readonly EntityQuery<CameraActiveOnCollideComponent> _cameraQuery = default!;
public void InitializeCollide()
{
SubscribeLocalEvent<CameraActiveOnCollideColliderComponent, PreventCollideEvent>(OnPreventCollide);
SubscribeLocalEvent<CameraActiveOnCollideColliderComponent, StartCollideEvent>(OnStart);
SubscribeLocalEvent<CameraActiveOnCollideColliderComponent, EndCollideEvent>(OnEnd);
SubscribeLocalEvent<CameraActiveOnCollideColliderComponent, ComponentShutdown>(OnCollideShutdown);
SubscribeLocalEvent<CameraActiveOnCollideComponent, SurveillanceCameraGetIsViewedExternallyEvent>(OnOverrideState);
}
private void OnCollideShutdown(Entity<CameraActiveOnCollideColliderComponent> ent, ref ComponentShutdown args)
{
// TODO: Check this on the event.
if (TerminatingOrDeleted(ent.Owner))
return;
// Regenerate contacts for everything we were colliding with.
var contacts = _physics.GetContacts(ent.Owner);
while (contacts.MoveNext(out var contact))
{
if (!contact.IsTouching)
continue;
var other = contact.OtherEnt(ent.Owner);
if (_cameraQuery.HasComp(other))
{
_physics.RegenerateContacts(other);
}
}
}
// You may be wondering what de fok this is doing here.
// At the moment there's no easy way to do collision whitelists based on components.
private void OnPreventCollide(Entity<CameraActiveOnCollideColliderComponent> ent, ref PreventCollideEvent args)
{
if (!_cameraQuery.HasComp(args.OtherEntity))
{
args.Cancelled = true;
}
}
private void OnEnd(Entity<CameraActiveOnCollideColliderComponent> ent, ref EndCollideEvent args)
{
if (args.OurFixtureId != ent.Comp.FixtureId)
return;
if (!_cameraQuery.TryComp(args.OtherEntity, out var cameraCollider))
return;
// TODO: Engine bug IsTouching box2d yay.
var contacts = _physics.GetTouchingContacts(args.OtherEntity) - 1;
if (contacts > 0)
return;
cameraCollider.Enabled = false;
Dirty(args.OtherEntity, cameraCollider);
UpdateVisuals(args.OtherEntity);
}
private void OnStart(Entity<CameraActiveOnCollideColliderComponent> ent, ref StartCollideEvent args)
{
if (args.OurFixtureId != ent.Comp.FixtureId)
return;
if (!_cameraQuery.TryComp(args.OtherEntity, out var cameraCollider))
return;
cameraCollider.Enabled = true;
Dirty(args.OtherEntity, cameraCollider);
UpdateVisuals(args.OtherEntity);
}
private void OnOverrideState(Entity<CameraActiveOnCollideComponent> ent, ref SurveillanceCameraGetIsViewedExternallyEvent args)
{
if (ent.Comp.RequiresPower && !_power.IsPowered(ent.Owner))
return;
if (!ent.Comp.Enabled)
return;
args.Viewed = true;
}
}

View file

@ -13,15 +13,15 @@ using Content.Shared.DeviceNetwork.Components;
namespace Content.Server.SurveillanceCamera;
public sealed class SurveillanceCameraSystem : SharedSurveillanceCameraSystem
public sealed partial class SurveillanceCameraSystem : SharedSurveillanceCameraSystem
{
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
[Dependency] private readonly ViewSubscriberSystem _viewSubscriberSystem = default!;
[Dependency] private readonly DeviceNetworkSystem _deviceNetworkSystem = default!;
[Dependency] private readonly UserInterfaceSystem _userInterface = default!;
[Dependency] private readonly SharedAppearanceSystem _appearance = default!;
[Dependency] private readonly IAdminLogManager _adminLogger = default!;
[Dependency] private readonly SurveillanceCameraMapSystem _cameraMapSystem = default!;
[Dependency] private readonly SharedAppearanceSystem _appearance = default!;
// Pings a surveillance camera subnet. All cameras will always respond
// with a data message if they are on the same subnet.
@ -61,6 +61,8 @@ public sealed class SurveillanceCameraSystem : SharedSurveillanceCameraSystem
SubscribeLocalEvent<SurveillanceCameraComponent, DeviceNetworkPacketEvent>(OnPacketReceived);
SubscribeLocalEvent<SurveillanceCameraComponent, SurveillanceCameraSetupSetName>(OnSetName);
SubscribeLocalEvent<SurveillanceCameraComponent, SurveillanceCameraSetupSetNetwork>(OnSetNetwork);
InitializeCollide();
}
private void OnPacketReceived(EntityUid uid, SurveillanceCameraComponent component, DeviceNetworkPacketEvent args)
@ -232,7 +234,7 @@ public sealed class SurveillanceCameraSystem : SharedSurveillanceCameraSystem
var ev = new SurveillanceCameraDeactivateEvent(camera);
RemoveActiveViewers(camera, new(component.ActiveViewers), null, component);
RemoveActiveViewers(camera, new(component.ActivePvsViewers), null, component);
component.Active = false;
// Send a targetted event to all monitors.
@ -249,6 +251,25 @@ public sealed class SurveillanceCameraSystem : SharedSurveillanceCameraSystem
UpdateVisuals(camera, component);
}
/// <summary>
/// Checks whether the camera is being viewed through by anyone at all.
/// </summary>
/// <param name="ent">The camera to check</param>
/// <returns>True if the camera is looked through, otherwise False.</returns>
public bool IsGettingViewed(Entity<SurveillanceCameraComponent?> ent)
{
if (!Resolve(ent, ref ent.Comp))
return false;
if (ent.Comp.ActivePvsViewers.Count > 0 || ent.Comp.ActiveMonitors.Count > 0)
return true;
var ev = new SurveillanceCameraGetIsViewedExternallyEvent();
RaiseLocalEvent(ent, ref ev);
return ev.Viewed;
}
public override void SetActive(EntityUid camera, bool setting, SurveillanceCameraComponent? component = null)
{
if (!Resolve(camera, ref component))
@ -284,7 +305,8 @@ public sealed class SurveillanceCameraSystem : SharedSurveillanceCameraSystem
}
_viewSubscriberSystem.AddViewSubscriber(camera, actor.PlayerSession);
component.ActiveViewers.Add(player);
component.ActivePvsViewers.Add(player);
if (monitor != null)
{
@ -346,7 +368,7 @@ public sealed class SurveillanceCameraSystem : SharedSurveillanceCameraSystem
if (Resolve(player, ref actor))
_viewSubscriberSystem.RemoveViewSubscriber(camera, actor.PlayerSession);
component.ActiveViewers.Remove(player);
component.ActivePvsViewers.Remove(player);
if (monitor != null)
{
@ -391,7 +413,7 @@ public sealed class SurveillanceCameraSystem : SharedSurveillanceCameraSystem
key = SurveillanceCameraVisuals.Active;
}
if (component.ActiveViewers.Count > 0 || component.ActiveMonitors.Count > 0)
if (IsGettingViewed((uid, component)))
{
key = SurveillanceCameraVisuals.InUse;
}

View file

@ -1,13 +0,0 @@
using Robust.Shared.GameStates;
namespace Content.Shared.Light.Components;
/// <summary>
/// Can activate <see cref="LightOnCollideComponent"/> when collided with.
/// </summary>
[RegisterComponent, NetworkedComponent]
public sealed partial class LightOnCollideColliderComponent : Component
{
[DataField]
public string FixtureId = "lightTrigger";
}

View file

@ -1,11 +0,0 @@
using Robust.Shared.GameStates;
namespace Content.Shared.Light.Components;
/// <summary>
/// Enables / disables pointlight whenever entities are contacting with it
/// </summary>
[RegisterComponent, NetworkedComponent]
public sealed partial class LightOnCollideComponent : Component
{
}

View file

@ -1,87 +0,0 @@
using Content.Shared.Light.Components;
using Robust.Shared.Physics.Events;
using Robust.Shared.Physics.Systems;
namespace Content.Shared.Light.EntitySystems;
public sealed class LightCollideSystem : EntitySystem
{
[Dependency] private readonly SharedPhysicsSystem _physics = default!;
[Dependency] private readonly SlimPoweredLightSystem _lights = default!;
private EntityQuery<LightOnCollideComponent> _lightQuery;
public override void Initialize()
{
base.Initialize();
_lightQuery = GetEntityQuery<LightOnCollideComponent>();
SubscribeLocalEvent<LightOnCollideColliderComponent, PreventCollideEvent>(OnPreventCollide);
SubscribeLocalEvent<LightOnCollideColliderComponent, StartCollideEvent>(OnStart);
SubscribeLocalEvent<LightOnCollideColliderComponent, EndCollideEvent>(OnEnd);
SubscribeLocalEvent<LightOnCollideColliderComponent, ComponentShutdown>(OnCollideShutdown);
}
private void OnCollideShutdown(Entity<LightOnCollideColliderComponent> ent, ref ComponentShutdown args)
{
// TODO: Check this on the event.
if (TerminatingOrDeleted(ent.Owner))
return;
// Regenerate contacts for everything we were colliding with.
var contacts = _physics.GetContacts(ent.Owner);
while (contacts.MoveNext(out var contact))
{
if (!contact.IsTouching)
continue;
var other = contact.OtherEnt(ent.Owner);
if (_lightQuery.HasComp(other))
{
_physics.RegenerateContacts(other);
}
}
}
// You may be wondering what de fok this is doing here.
// At the moment there's no easy way to do collision whitelists based on components.
private void OnPreventCollide(Entity<LightOnCollideColliderComponent> ent, ref PreventCollideEvent args)
{
if (!_lightQuery.HasComp(args.OtherEntity))
{
args.Cancelled = true;
}
}
private void OnEnd(Entity<LightOnCollideColliderComponent> ent, ref EndCollideEvent args)
{
if (args.OurFixtureId != ent.Comp.FixtureId)
return;
if (!_lightQuery.HasComp(args.OtherEntity))
return;
// TODO: Engine bug IsTouching box2d yay.
var contacts = _physics.GetTouchingContacts(args.OtherEntity) - 1;
if (contacts > 0)
return;
_lights.SetEnabled(args.OtherEntity, false);
}
private void OnStart(Entity<LightOnCollideColliderComponent> ent, ref StartCollideEvent args)
{
if (args.OurFixtureId != ent.Comp.FixtureId)
return;
if (!_lightQuery.HasComp(args.OtherEntity))
return;
_lights.SetEnabled(args.OtherEntity, true);
}
}

View file

@ -0,0 +1,16 @@
using Robust.Shared.GameStates;
namespace Content.Shared.SurveillanceCamera.Components;
/// <summary>
/// Can activate <see cref="CameraActiveOnCollideComponent"/> when collided with.
/// </summary>
[RegisterComponent, NetworkedComponent]
public sealed partial class CameraActiveOnCollideColliderComponent : Component
{
/// <summary>
/// The fixture id used for detecting the collision.
/// </summary>
[DataField]
public string FixtureId = "lightTrigger";
}

View file

@ -0,0 +1,22 @@
using Robust.Shared.GameStates;
namespace Content.Shared.SurveillanceCamera.Components;
/// <summary>
/// Marks an entity with <see cref="SurveillanceCameraComponent"/> whenever entities are contacting with it.
/// </summary>
[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
public sealed partial class CameraActiveOnCollideComponent : Component
{
/// <summary>
/// Whether this camera is currently being collided with.
/// </summary>
[DataField, AutoNetworkedField]
public bool Enabled;
/// <summary>
/// Whether the entity must be powered for this component to work.
/// </summary>
[DataField, AutoNetworkedField]
public bool RequiresPower = true;
}

View file

@ -8,18 +8,23 @@ namespace Content.Shared.SurveillanceCamera.Components;
[Access(typeof(SharedSurveillanceCameraSystem))]
public sealed partial class SurveillanceCameraComponent : Component
{
// List of active viewers. This is for bookkeeping purposes,
// so that when a camera shuts down, any entity viewing it
// will immediately have their subscription revoked.
/// <summary>
/// List of active viewers who have a PVS view subscription on this camera.
/// This is for bookkeeping purposes,
/// so that when a camera shuts down, any entity viewing it
/// will immediately have their subscription revoked.
/// </summary>
[ViewVariables]
public HashSet<EntityUid> ActiveViewers { get; } = new();
public HashSet<EntityUid> ActivePvsViewers { get; } = new();
// Monitors != Viewers, as viewers are entities that are tied
// to a player session that's viewing from this camera
//
// Monitors are grouped sets of viewers, and may be
// completely different monitor types (e.g., monitor console,
// AI, etc.)
/// <summary>
/// Monitors != Viewers, as viewers are entities that are tied
/// to a player session that's viewing from this camera
///
/// Monitors are grouped sets of viewers, and may be
/// completely different monitor types (e.g., monitor console,
/// AI, etc.)
/// </summary>
[ViewVariables]
public HashSet<EntityUid> ActiveMonitors { get; } = new();

View file

@ -5,7 +5,7 @@ using Robust.Shared.Serialization;
namespace Content.Shared.SurveillanceCamera;
public abstract class SharedSurveillanceCameraSystem : EntitySystem
public abstract partial class SharedSurveillanceCameraSystem : EntitySystem
{
public override void Initialize()
{
@ -68,3 +68,11 @@ public enum SurveillanceCameraVisuals : byte
Xray,
Emp
}
/// <summary>
/// Raised on a camera entity to find whether it is externally viewed by some entity.
/// This does not use the actual viewers or monitors camera has and is simply used to see whether the camera is "technically"
/// being looked through by somebody, such as the Station AI.
/// </summary>
[ByRefEvent]
public record struct SurveillanceCameraGetIsViewedExternallyEvent(bool Viewed = false);

View file

@ -14,34 +14,6 @@
- type: InstantAction
event: !type:JumpToCoreEvent
- type: entity
parent: BaseAction
id: ActionSurvCameraLights
name: Toggle camera lights
description: Enable surveillance camera lights near wherever you're viewing.
components:
- type: Action
priority: -5
itemIconStyle: BigAction
icon:
sprite: Interface/Actions/actions_ai.rsi
state: camera_light
- type: InstantAction
event: !type:RelayedActionComponentChangeEvent
components:
- type: LightOnCollideCollider
- type: FixturesChange
fixtures:
lightTrigger:
shape:
!type:PhysShapeCircle
radius: 0.35
density: 80
hard: false
layer:
- GhostImpassable
- type: entity
parent: BaseMentalAction
id: ActionAIViewLaws

View file

@ -34,7 +34,6 @@
- type: ActionGrant
actions:
- ActionJumpToCore
- ActionSurvCameraLights
- ActionAIViewLaws
- type: UserInterface
interfaces:
@ -487,6 +486,16 @@
description: The AI's viewer.
categories: [ HideSpawnMenu, DoNotMap ]
components:
- type: Fixtures
fixtures:
lightTrigger:
shape:
!type:PhysShapeCircle
radius: 8
density: 80
hard: false
layer:
- GhostImpassable
- type: NoFTL
- type: Tag
tags:
@ -506,6 +515,7 @@
- state: ai_camera
shader: unshaded
map: ["base"]
- type: CameraActiveOnCollideCollider
# The holographic representation of the AI that is projected from a holopad.
- type: entity

View file

@ -11,7 +11,7 @@
light:
shape:
!type:PhysShapeCircle
radius: 5
radius: 0.5
hard: false
mask:
- GhostImpassable
@ -24,7 +24,8 @@
- None
layer:
- TabletopMachineLayer
- type: LightOnCollide
- type: CameraActiveOnCollide
requiresPower: false # TODO: Change this when AI can no longer see via unpowered cameras
- type: PointLight
enabled: false
radius: 5