diff --git a/Content.Client/Turrets/DeployableTurretSystem.cs b/Content.Client/Turrets/DeployableTurretSystem.cs new file mode 100644 index 0000000000..a83997403e --- /dev/null +++ b/Content.Client/Turrets/DeployableTurretSystem.cs @@ -0,0 +1,121 @@ +using Content.Client.Power; +using Content.Shared.Turrets; +using Robust.Client.Animations; +using Robust.Client.GameObjects; + +namespace Content.Client.Turrets; + +public sealed partial class DeployableTurretSystem : SharedDeployableTurretSystem +{ + [Dependency] private readonly AppearanceSystem _appearance = default!; + [Dependency] private readonly AnimationPlayerSystem _animation = default!; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnComponentInit); + SubscribeLocalEvent(OnAnimationCompleted); + SubscribeLocalEvent(OnAppearanceChange); + } + + private void OnComponentInit(Entity ent, ref ComponentInit args) + { + ent.Comp.DeploymentAnimation = new Animation + { + Length = TimeSpan.FromSeconds(ent.Comp.DeploymentLength), + AnimationTracks = { + new AnimationTrackSpriteFlick() { + LayerKey = DeployableTurretVisuals.Turret, + KeyFrames = {new AnimationTrackSpriteFlick.KeyFrame(ent.Comp.DeployingState, 0f)} + }, + } + }; + + ent.Comp.RetractionAnimation = new Animation + { + Length = TimeSpan.FromSeconds(ent.Comp.RetractionLength), + AnimationTracks = { + new AnimationTrackSpriteFlick() { + LayerKey = DeployableTurretVisuals.Turret, + KeyFrames = {new AnimationTrackSpriteFlick.KeyFrame(ent.Comp.RetractingState, 0f)} + }, + } + }; + } + + private void OnAnimationCompleted(Entity ent, ref AnimationCompletedEvent args) + { + if (args.Key != DeployableTurretComponent.AnimationKey) + return; + + if (!TryComp(ent, out var sprite)) + return; + + if (!_appearance.TryGetData(ent, DeployableTurretVisuals.Turret, out var state)) + state = ent.Comp.VisualState; + + // Convert to terminal state + var targetState = state & DeployableTurretState.Deployed; + + UpdateVisuals(ent, targetState, sprite, args.AnimationPlayer); + } + + private void OnAppearanceChange(Entity ent, ref AppearanceChangeEvent args) + { + if (args.Sprite == null) + return; + + if (!TryComp(ent, out var animPlayer)) + return; + + if (!_appearance.TryGetData(ent, DeployableTurretVisuals.Turret, out var state, args.Component)) + state = DeployableTurretState.Retracted; + + UpdateVisuals(ent, state, args.Sprite, animPlayer); + } + + private void UpdateVisuals(Entity ent, DeployableTurretState state, SpriteComponent sprite, AnimationPlayerComponent? animPlayer = null) + { + if (!Resolve(ent, ref animPlayer)) + return; + + if (_animation.HasRunningAnimation(ent, animPlayer, DeployableTurretComponent.AnimationKey)) + return; + + if (state == ent.Comp.VisualState) + return; + + var targetState = state & DeployableTurretState.Deployed; + var destinationState = ent.Comp.VisualState & DeployableTurretState.Deployed; + + if (targetState != destinationState) + targetState = targetState | DeployableTurretState.Retracting; + + ent.Comp.VisualState = state; + + // Toggle layer visibility + sprite.LayerSetVisible(DeployableTurretVisuals.Weapon, (targetState & DeployableTurretState.Deployed) > 0); + sprite.LayerSetVisible(PowerDeviceVisualLayers.Powered, HasAmmo(ent) && targetState == DeployableTurretState.Retracted); + + // Change the visual state + switch (targetState) + { + case DeployableTurretState.Deploying: + _animation.Play((ent, animPlayer), (Animation)ent.Comp.DeploymentAnimation, DeployableTurretComponent.AnimationKey); + break; + + case DeployableTurretState.Retracting: + _animation.Play((ent, animPlayer), (Animation)ent.Comp.RetractionAnimation, DeployableTurretComponent.AnimationKey); + break; + + case DeployableTurretState.Deployed: + sprite.LayerSetState(DeployableTurretVisuals.Turret, ent.Comp.DeployedState); + break; + + case DeployableTurretState.Retracted: + sprite.LayerSetState(DeployableTurretVisuals.Turret, ent.Comp.RetractedState); + break; + } + } +} diff --git a/Content.Server/Destructible/DestructibleComponent.cs b/Content.Server/Destructible/DestructibleComponent.cs index 5c593fb083..d154811c78 100644 --- a/Content.Server/Destructible/DestructibleComponent.cs +++ b/Content.Server/Destructible/DestructibleComponent.cs @@ -9,8 +9,17 @@ namespace Content.Server.Destructible [RegisterComponent] public sealed partial class DestructibleComponent : Component { - [DataField("thresholds")] + /// + /// A list of damage thresholds for the entity; + /// includes their triggers and resultant behaviors + /// + [DataField] public List Thresholds = new(); + /// + /// Specifies whether the entity has passed a damage threshold that causes it to break + /// + [DataField] + public bool IsBroken = false; } } diff --git a/Content.Server/Destructible/DestructibleSystem.cs b/Content.Server/Destructible/DestructibleSystem.cs index 48b38e9d01..ca7f975e60 100644 --- a/Content.Server/Destructible/DestructibleSystem.cs +++ b/Content.Server/Destructible/DestructibleSystem.cs @@ -57,6 +57,8 @@ namespace Content.Server.Destructible /// public void Execute(EntityUid uid, DestructibleComponent component, DamageChangedEvent args) { + component.IsBroken = false; + foreach (var threshold in component.Thresholds) { if (threshold.Reached(args.Damageable, this)) @@ -96,6 +98,12 @@ namespace Content.Server.Destructible threshold.Execute(uid, this, EntityManager, args.Origin); } + if (threshold.OldTriggered) + { + component.IsBroken |= threshold.Behaviors.Any(b => b is DoActsBehavior doActsBehavior && + (doActsBehavior.HasAct(ThresholdActs.Breakage) || doActsBehavior.HasAct(ThresholdActs.Destruction))); + } + // if destruction behavior (or some other deletion effect) occurred, don't run other triggers. if (EntityManager.IsQueuedForDeletion(uid) || Deleted(uid)) return; diff --git a/Content.Server/NPC/Queries/Considerations/TargetIsStunnedCon.cs b/Content.Server/NPC/Queries/Considerations/TargetIsStunnedCon.cs new file mode 100644 index 0000000000..6188ae96d4 --- /dev/null +++ b/Content.Server/NPC/Queries/Considerations/TargetIsStunnedCon.cs @@ -0,0 +1,10 @@ +namespace Content.Server.NPC.Queries.Considerations; + +/// +/// Returns 1f if the target has the +/// +public sealed partial class TargetIsStunnedCon : UtilityConsideration +{ + +} + diff --git a/Content.Server/NPC/Systems/NPCUtilitySystem.cs b/Content.Server/NPC/Systems/NPCUtilitySystem.cs index b5d3ac3cbd..eff4f2772b 100644 --- a/Content.Server/NPC/Systems/NPCUtilitySystem.cs +++ b/Content.Server/NPC/Systems/NPCUtilitySystem.cs @@ -19,6 +19,7 @@ using Content.Shared.Mobs.Systems; using Content.Shared.NPC.Systems; using Content.Shared.Nutrition.Components; using Content.Shared.Nutrition.EntitySystems; +using Content.Shared.Stunnable; using Content.Shared.Tools.Systems; using Content.Shared.Turrets; using Content.Shared.Weapons.Melee; @@ -360,6 +361,10 @@ public sealed class NPCUtilitySystem : EntitySystem return 1f; return 0f; } + case TargetIsStunnedCon: + { + return HasComp(targetUid) ? 1f : 0f; + } case TurretTargetingCon: { if (!TryComp(owner, out var turretTargetSettings) || diff --git a/Content.Server/Turrets/DeployableTurretSystem.cs b/Content.Server/Turrets/DeployableTurretSystem.cs new file mode 100644 index 0000000000..359d91fd1d --- /dev/null +++ b/Content.Server/Turrets/DeployableTurretSystem.cs @@ -0,0 +1,175 @@ +using Content.Server.Destructible; +using Content.Server.DeviceNetwork; +using Content.Server.DeviceNetwork.Components; +using Content.Server.DeviceNetwork.Systems; +using Content.Server.NPC.HTN; +using Content.Server.NPC.HTN.PrimitiveTasks.Operators.Combat.Ranged; +using Content.Server.Power.Components; +using Content.Server.Repairable; +using Content.Shared.Destructible; +using Content.Shared.DeviceNetwork; +using Content.Shared.Power; +using Content.Shared.Turrets; +using Content.Shared.Weapons.Ranged.Events; +using Robust.Shared.Audio; +using Robust.Shared.Audio.Systems; +using Robust.Shared.Timing; + +namespace Content.Server.Turrets; + +public sealed partial class DeployableTurretSystem : SharedDeployableTurretSystem +{ + [Dependency] private readonly HTNSystem _htn = default!; + [Dependency] private readonly SharedAppearanceSystem _appearance = default!; + [Dependency] private readonly SharedAudioSystem _audio = default!; + [Dependency] private readonly DeviceNetworkSystem _deviceNetwork = default!; + [Dependency] private readonly IGameTiming _timing = default!; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnAmmoShot); + SubscribeLocalEvent(OnChargeChanged); + SubscribeLocalEvent(OnPowerChanged); + SubscribeLocalEvent(OnBroken); + SubscribeLocalEvent(OnRepaired); + SubscribeLocalEvent(OnBeforeBroadcast); + } + + private void OnAmmoShot(Entity ent, ref AmmoShotEvent args) + { + UpdateAmmoStatus(ent); + } + + private void OnChargeChanged(Entity ent, ref ChargeChangedEvent args) + { + UpdateAmmoStatus(ent); + } + + private void OnPowerChanged(Entity ent, ref PowerChangedEvent args) + { + UpdateAmmoStatus(ent); + } + + private void OnBroken(Entity ent, ref BreakageEventArgs args) + { + if (TryComp(ent, out var appearance)) + _appearance.SetData(ent, DeployableTurretVisuals.Broken, true, appearance); + + SetState(ent, false); + } + + private void OnRepaired(Entity ent, ref RepairedEvent args) + { + if (TryComp(ent, out var appearance)) + _appearance.SetData(ent, DeployableTurretVisuals.Broken, false, appearance); + } + + private void OnBeforeBroadcast(Entity ent, ref BeforeBroadcastAttemptEvent args) + { + if (!TryComp(ent, out var deviceNetwork)) + return; + + var recipientDeviceNetworks = new HashSet(); + + // Only broadcast to connected devices + foreach (var recipient in deviceNetwork.DeviceLists) + { + if (!TryComp(recipient, out var recipientDeviceNetwork)) + continue; + + recipientDeviceNetworks.Add(recipientDeviceNetwork); + } + + if (recipientDeviceNetworks.Count > 0) + args.ModifiedRecipients = recipientDeviceNetworks; + } + + private void SendStateUpdateToDeviceNetwork(Entity ent) + { + if (!TryComp(ent, out var device)) + return; + + var payload = new NetworkPayload + { + [DeviceNetworkConstants.Command] = DeviceNetworkConstants.CmdUpdatedState, + [DeviceNetworkConstants.CmdUpdatedState] = GetTurretState(ent) + }; + + _deviceNetwork.QueuePacket(ent, null, payload, device: device); + } + + protected override void SetState(Entity ent, bool enabled, EntityUid? user = null) + { + if (ent.Comp.Enabled == enabled) + return; + + base.SetState(ent, enabled, user); + DirtyField(ent, ent.Comp, nameof(DeployableTurretComponent.Enabled)); + + // Determine how much time is remaining in the current animation and the one next in queue + var animTimeRemaining = MathF.Max((float)(ent.Comp.AnimationCompletionTime - _timing.CurTime).TotalSeconds, 0f); + var animTimeNext = ent.Comp.Enabled ? ent.Comp.DeploymentLength : ent.Comp.RetractionLength; + + // End/restart any tasks the NPC was doing + // Delay the resumption of any tasks based on the total animation length (plus a buffer) + var planCooldown = animTimeRemaining + animTimeNext + 0.5f; + + if (TryComp(ent, out var htn)) + _htn.SetHTNEnabled((ent, htn), ent.Comp.Enabled, planCooldown); + + // Play audio + _audio.PlayPvs(ent.Comp.Enabled ? ent.Comp.DeploymentSound : ent.Comp.RetractionSound, ent, new AudioParams { Volume = -10f }); + } + + private void UpdateAmmoStatus(Entity ent) + { + if (!HasAmmo(ent)) + SetState(ent, false); + } + + private DeployableTurretState GetTurretState(Entity ent, DestructibleComponent? destructable = null, HTNComponent? htn = null) + { + Resolve(ent, ref destructable, ref htn); + + if (destructable?.IsBroken == true) + return DeployableTurretState.Broken; + + if (htn == null || !HasAmmo(ent)) + return DeployableTurretState.Disabled; + + if (htn.Plan?.CurrentTask.Operator is GunOperator) + return DeployableTurretState.Firing; + + if (ent.Comp.AnimationCompletionTime > _timing.CurTime) + return ent.Comp.Enabled ? DeployableTurretState.Deploying : DeployableTurretState.Retracting; + + return ent.Comp.Enabled ? DeployableTurretState.Deployed : DeployableTurretState.Retracted; + } + + public override void Update(float frameTime) + { + base.Update(frameTime); + + var query = EntityQueryEnumerator(); + while (query.MoveNext(out var uid, out var deployableTurret, out var destructible, out var htn)) + { + // Check if the turret state has changed since the last update, + // and if it has, inform the device network + var ent = new Entity(uid, deployableTurret); + var newState = GetTurretState(ent, destructible, htn); + + if (newState != deployableTurret.CurrentState) + { + deployableTurret.CurrentState = newState; + DirtyField(uid, deployableTurret, nameof(DeployableTurretComponent.CurrentState)); + + SendStateUpdateToDeviceNetwork(ent); + + if (TryComp(ent, out var appearance)) + _appearance.SetData(ent, DeployableTurretVisuals.Turret, newState, appearance); + } + } + } +} diff --git a/Content.Shared/Damage/Systems/DamageableSystem.cs b/Content.Shared/Damage/Systems/DamageableSystem.cs index 8557e5623f..fb55a6184e 100644 --- a/Content.Shared/Damage/Systems/DamageableSystem.cs +++ b/Content.Shared/Damage/Systems/DamageableSystem.cs @@ -296,7 +296,7 @@ namespace Content.Shared.Damage DamageChanged(uid, component, new DamageSpecifier()); } - public void SetDamageModifierSetId(EntityUid uid, string damageModifierSetId, DamageableComponent? comp = null) + public void SetDamageModifierSetId(EntityUid uid, string? damageModifierSetId, DamageableComponent? comp = null) { if (!_damageableQuery.Resolve(uid, ref comp)) return; diff --git a/Content.Shared/Power/Components/ApcPowerReceiverBatteryChangedEvent.cs b/Content.Shared/Power/Components/ApcPowerReceiverBatteryComponent.cs similarity index 100% rename from Content.Shared/Power/Components/ApcPowerReceiverBatteryChangedEvent.cs rename to Content.Shared/Power/Components/ApcPowerReceiverBatteryComponent.cs diff --git a/Content.Shared/Turrets/DeployableTurretComponent.cs b/Content.Shared/Turrets/DeployableTurretComponent.cs new file mode 100644 index 0000000000..a23b4ec86c --- /dev/null +++ b/Content.Shared/Turrets/DeployableTurretComponent.cs @@ -0,0 +1,161 @@ +using Content.Shared.Damage.Prototypes; +using Robust.Shared.Audio; +using Robust.Shared.GameStates; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization; + +namespace Content.Shared.Turrets; + +/// +/// Attached to turrets that can be toggled between an inactive and active state +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState(fieldDeltas: true), AutoGenerateComponentPause] +[Access(typeof(SharedDeployableTurretSystem))] +public sealed partial class DeployableTurretComponent : Component +{ + /// + /// Whether the turret is toggled 'on' or 'off' + /// + [DataField, AutoNetworkedField] + public bool Enabled = false; + + /// + /// The current state of the turret. Used to inform the device network. + /// + [DataField, AutoNetworkedField] + public DeployableTurretState CurrentState = DeployableTurretState.Retracted; + + /// + /// The visual state of the turret. Used on the client-side. + /// + [DataField] + public DeployableTurretState VisualState = DeployableTurretState.Retracted; + + /// + /// The physics fixture that will have its collisions disabled when the turret is retracted. + /// + [DataField] + public string? DeployedFixture = "turret"; + + /// + /// When retracted, the following damage modifier set will be applied to the turret. + /// + [DataField] + public ProtoId? RetractedDamageModifierSetId; + + /// + /// When deployed, the following damage modifier set will be applied to the turret. + /// + [DataField] + public ProtoId? DeployedDamageModifierSetId; + + #region: Sound data + + /// + /// Sound to play when denied access to the turret. + /// + [DataField] + public SoundSpecifier AccessDeniedSound = new SoundPathSpecifier("/Audio/Machines/custom_deny.ogg"); + + /// + /// Sound to play when the turret deploys. + /// + [DataField] + public SoundSpecifier DeploymentSound = new SoundPathSpecifier("/Audio/Machines/blastdoor.ogg"); + + /// + /// Sound to play when the turret retracts. + /// + [DataField] + public SoundSpecifier RetractionSound = new SoundPathSpecifier("/Audio/Machines/blastdoor.ogg"); + + #endregion + + #region: Animation data + + /// + /// The length of the deployment animation (in seconds) + /// + [DataField] + public float DeploymentLength = 1.19f; + + /// + /// The length of the retraction animation (in seconds) + /// + [DataField] + public float RetractionLength = 1.19f; + + /// + /// The time that the current animation should complete (in seconds) + /// + [DataField, AutoPausedField] + public TimeSpan AnimationCompletionTime = TimeSpan.Zero; + + /// + /// The animation used when turret activates + /// + [ViewVariables(VVAccess.ReadWrite)] + public object DeploymentAnimation = default!; + + /// + /// The animation used when turret deactivates + /// + [ViewVariables(VVAccess.ReadWrite)] + public object RetractionAnimation = default!; + + /// + /// The key used to index the animation played when turning the turret on/off. + /// + [ViewVariables(VVAccess.ReadOnly)] + public const string AnimationKey = "deployable_turret_animation"; + + #endregion + + #region: Visual state data + + /// + /// The visual state to use when the turret is deployed. + /// + [DataField] + public string DeployedState = "cover_open"; + + /// + /// The visual state to use when the turret is not deployed. + /// + [DataField] + public string RetractedState = "cover_closed"; + + /// + /// Used to build the deployment animation when the component is initialized. + /// + [DataField] + public string DeployingState = "cover_opening"; + + /// + /// Used to build the retraction animation when the component is initialized. + /// + [DataField] + public string RetractingState = "cover_closing"; + + #endregion +} + +[Serializable, NetSerializable] +public enum DeployableTurretVisuals : byte +{ + Turret, + Weapon, + Broken, +} + +[Serializable, NetSerializable] +public enum DeployableTurretState : byte +{ + Retracted = 0, + Deployed = (1 << 0), + Retracting = (1 << 1), + Deploying = (1 << 1) | Deployed, + Firing = (1 << 2) | Deployed, + Disabled = (1 << 3), + Broken = (1 << 4), +} diff --git a/Content.Shared/Turrets/SharedDeployableTurretSystem.cs b/Content.Shared/Turrets/SharedDeployableTurretSystem.cs new file mode 100644 index 0000000000..8209a49efd --- /dev/null +++ b/Content.Shared/Turrets/SharedDeployableTurretSystem.cs @@ -0,0 +1,167 @@ +using Content.Shared.Access.Components; +using Content.Shared.Access.Systems; +using Content.Shared.Damage; +using Content.Shared.Database; +using Content.Shared.Interaction; +using Content.Shared.Popups; +using Content.Shared.Timing; +using Content.Shared.Verbs; +using Content.Shared.Weapons.Ranged.Events; +using Content.Shared.Wires; +using Robust.Shared.Audio.Systems; +using Robust.Shared.Physics; +using Robust.Shared.Physics.Systems; +using Robust.Shared.Timing; +using Robust.Shared.Utility; + +namespace Content.Shared.Turrets; + +public abstract partial class SharedDeployableTurretSystem : EntitySystem +{ + [Dependency] private readonly SharedPopupSystem _popup = default!; + [Dependency] private readonly SharedAudioSystem _audio = default!; + [Dependency] private readonly UseDelaySystem _useDelay = default!; + [Dependency] private readonly AccessReaderSystem _accessReader = default!; + [Dependency] private readonly DamageableSystem _damageable = default!; + [Dependency] private readonly SharedPhysicsSystem _physics = default!; + [Dependency] private readonly SharedWiresSystem _wires = default!; + [Dependency] private readonly IGameTiming _timing = default!; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnActivate); + SubscribeLocalEvent(OnAttemptChangeWirePanelWire); + SubscribeLocalEvent>(OnGetVerb); + } + + private void OnGetVerb(Entity ent, ref GetVerbsEvent args) + { + if (!args.CanAccess || !args.CanInteract || !args.CanComplexInteract) + return; + + if (!_accessReader.IsAllowed(args.User, ent)) + return; + + var user = args.User; + + var verb = new Verb + { + Priority = 1, + Text = ent.Comp.Enabled ? Loc.GetString("deployable-turret-component-deactivate") : Loc.GetString("deployable-turret-component-activate"), + Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/VerbIcons/Spare/poweronoff.svg.192dpi.png")), + Disabled = !HasAmmo(ent), + Impact = LogImpact.Low, + Act = () => { TryToggleState(ent, user); } + }; + + args.Verbs.Add(verb); + } + + private void OnActivate(Entity ent, ref ActivateInWorldEvent args) + { + if (TryComp(ent, out UseDelayComponent? useDelay) && !_useDelay.TryResetDelay((ent, useDelay), true)) + return; + + if (!_accessReader.IsAllowed(args.User, ent)) + { + _popup.PopupClient(Loc.GetString("deployable-turret-component-access-denied"), ent, args.User); + _audio.PlayPredicted(ent.Comp.AccessDeniedSound, ent, args.User); + + return; + } + + TryToggleState(ent, args.User); + } + + private void OnAttemptChangeWirePanelWire(Entity ent, ref AttemptChangePanelEvent args) + { + if (!ent.Comp.Enabled || args.Cancelled) + return; + + _popup.PopupClient(Loc.GetString("deployable-turret-component-cannot-access-wires"), ent, args.User); + + args.Cancelled = true; + } + + public bool TryToggleState(Entity ent, EntityUid? user = null) + { + return TrySetState(ent, !ent.Comp.Enabled, user); + } + + public bool TrySetState(Entity ent, bool enabled, EntityUid? user = null) + { + if (enabled && ent.Comp.CurrentState == DeployableTurretState.Broken) + { + if (user != null) + _popup.PopupClient(Loc.GetString("deployable-turret-component-is-broken"), ent, user.Value); + + return false; + } + + if (enabled && !HasAmmo(ent)) + { + if (user != null) + _popup.PopupClient(Loc.GetString("deployable-turret-component-no-ammo"), ent, user.Value); + + return false; + } + + SetState(ent, enabled, user); + + return true; + } + + protected virtual void SetState(Entity ent, bool enabled, EntityUid? user = null) + { + if (ent.Comp.Enabled == enabled) + return; + + // Hide the wires panel UI on activation + if (enabled && TryComp(ent, out var wires) && wires.Open) + { + _wires.TogglePanel(ent, wires, false); + _audio.PlayPredicted(wires.ScrewdriverCloseSound, ent, user); + } + + // Determine how much time is remaining in the current animation and the one next in queue + // We track this so that when a turret is toggled on/off, we can wait for all queued animations + // to end before the turret's HTN is reactivated + var animTimeRemaining = MathF.Max((float)(ent.Comp.AnimationCompletionTime - _timing.CurTime).TotalSeconds, 0f); + var animTimeNext = enabled ? ent.Comp.DeploymentLength : ent.Comp.RetractionLength; + + ent.Comp.AnimationCompletionTime = _timing.CurTime + TimeSpan.FromSeconds(animTimeNext + animTimeRemaining); + + // Change the turret's damage modifiers + if (TryComp(ent, out var damageable)) + { + var damageSetID = enabled ? ent.Comp.DeployedDamageModifierSetId : ent.Comp.RetractedDamageModifierSetId; + _damageable.SetDamageModifierSetId(ent, damageSetID, damageable); + } + + // Change the turret's fixtures + if (ent.Comp.DeployedFixture != null && + TryComp(ent, out FixturesComponent? fixtures) && + fixtures.Fixtures.TryGetValue(ent.Comp.DeployedFixture, out var fixture)) + { + _physics.SetHard(ent, fixture, enabled); + } + + // Play pop up message + var msg = enabled ? "deployable-turret-component-activating" : "deployable-turret-component-deactivating"; + _popup.PopupClient(Loc.GetString(msg), ent, user); + + // Update enabled state + ent.Comp.Enabled = enabled; + DirtyField(ent, ent.Comp, "Enabled"); + } + + public bool HasAmmo(Entity ent) + { + var ammoCountEv = new GetAmmoCountEvent(); + RaiseLocalEvent(ent, ref ammoCountEv); + + return ammoCountEv.Count > 0; + } +} diff --git a/Content.Shared/Weapons/Ranged/Components/BatteryWeaponFireModesComponent.cs b/Content.Shared/Weapons/Ranged/Components/BatteryWeaponFireModesComponent.cs index b0ca1f215c..77b9f53b7b 100644 --- a/Content.Shared/Weapons/Ranged/Components/BatteryWeaponFireModesComponent.cs +++ b/Content.Shared/Weapons/Ranged/Components/BatteryWeaponFireModesComponent.cs @@ -43,3 +43,9 @@ public sealed partial class BatteryWeaponFireMode [DataField] public float FireCost = 100; } + +[Serializable, NetSerializable] +public enum BatteryWeaponFireModeVisuals : byte +{ + State +} diff --git a/Content.Shared/Weapons/Ranged/Systems/BatteryWeaponFireModesSystem.cs b/Content.Shared/Weapons/Ranged/Systems/BatteryWeaponFireModesSystem.cs index bae5b95a19..0c90ae1637 100644 --- a/Content.Shared/Weapons/Ranged/Systems/BatteryWeaponFireModesSystem.cs +++ b/Content.Shared/Weapons/Ranged/Systems/BatteryWeaponFireModesSystem.cs @@ -1,7 +1,8 @@ -using System.Linq; +using Content.Shared.Access.Components; +using Content.Shared.Access.Systems; using Content.Shared.Database; using Content.Shared.Examine; -using Content.Shared.Interaction; +using Content.Shared.Interaction.Events; using Content.Shared.Popups; using Content.Shared.Verbs; using Content.Shared.Weapons.Ranged.Components; @@ -14,12 +15,14 @@ public sealed class BatteryWeaponFireModesSystem : EntitySystem { [Dependency] private readonly IPrototypeManager _prototypeManager = default!; [Dependency] private readonly SharedPopupSystem _popupSystem = default!; + [Dependency] private readonly AccessReaderSystem _accessReaderSystem = default!; + [Dependency] private readonly SharedAppearanceSystem _appearanceSystem = default!; public override void Initialize() { base.Initialize(); - SubscribeLocalEvent(OnInteractHandEvent); + SubscribeLocalEvent(OnUseInHandEvent); SubscribeLocalEvent>(OnGetVerb); SubscribeLocalEvent(OnExamined); } @@ -44,12 +47,15 @@ public sealed class BatteryWeaponFireModesSystem : EntitySystem private void OnGetVerb(EntityUid uid, BatteryWeaponFireModesComponent component, GetVerbsEvent args) { - if (!args.CanAccess || !args.CanInteract || args.Hands == null) + if (!args.CanAccess || !args.CanInteract || !args.CanComplexInteract) return; if (component.FireModes.Count < 2) return; + if (!_accessReaderSystem.IsAllowed(args.User, uid)) + return; + for (var i = 0; i < component.FireModes.Count; i++) { var fireMode = component.FireModes[i]; @@ -62,11 +68,11 @@ public sealed class BatteryWeaponFireModesSystem : EntitySystem Category = VerbCategory.SelectType, Text = entProto.Name, Disabled = i == component.CurrentFireMode, - Impact = LogImpact.Low, + Impact = LogImpact.Medium, DoContactInteraction = true, Act = () => { - SetFireMode(uid, component, index, args.User); + TrySetFireMode(uid, component, index, args.User); } }; @@ -74,24 +80,31 @@ public sealed class BatteryWeaponFireModesSystem : EntitySystem } } - private void OnInteractHandEvent(EntityUid uid, BatteryWeaponFireModesComponent component, ActivateInWorldEvent args) + private void OnUseInHandEvent(EntityUid uid, BatteryWeaponFireModesComponent component, UseInHandEvent args) { - if (!args.Complex) - return; - - if (component.FireModes.Count < 2) - return; - - CycleFireMode(uid, component, args.User); + TryCycleFireMode(uid, component, args.User); } - private void CycleFireMode(EntityUid uid, BatteryWeaponFireModesComponent component, EntityUid user) + public void TryCycleFireMode(EntityUid uid, BatteryWeaponFireModesComponent component, EntityUid? user = null) { if (component.FireModes.Count < 2) return; var index = (component.CurrentFireMode + 1) % component.FireModes.Count; + TrySetFireMode(uid, component, index, user); + } + + public bool TrySetFireMode(EntityUid uid, BatteryWeaponFireModesComponent component, int index, EntityUid? user = null) + { + if (index < 0 || index >= component.FireModes.Count) + return false; + + if (user != null && !_accessReaderSystem.IsAllowed(user.Value, uid)) + return false; + SetFireMode(uid, component, index, user); + + return true; } private void SetFireMode(EntityUid uid, BatteryWeaponFireModesComponent component, int index, EntityUid? user = null) @@ -100,26 +113,30 @@ public sealed class BatteryWeaponFireModesSystem : EntitySystem component.CurrentFireMode = index; Dirty(uid, component); + if (_prototypeManager.TryIndex(fireMode.Prototype, out var prototype)) + { + if (TryComp(uid, out var appearance)) + _appearanceSystem.SetData(uid, BatteryWeaponFireModeVisuals.State, prototype.ID, appearance); + + if (user != null) + _popupSystem.PopupClient(Loc.GetString("gun-set-fire-mode", ("mode", prototype.Name)), uid, user.Value); + } + if (TryComp(uid, out ProjectileBatteryAmmoProviderComponent? projectileBatteryAmmoProviderComponent)) { - if (!_prototypeManager.TryIndex(fireMode.Prototype, out var prototype)) - return; - // TODO: Have this get the info directly from the batteryComponent when power is moved to shared. var OldFireCost = projectileBatteryAmmoProviderComponent.FireCost; projectileBatteryAmmoProviderComponent.Prototype = fireMode.Prototype; projectileBatteryAmmoProviderComponent.FireCost = fireMode.FireCost; + float FireCostDiff = (float)fireMode.FireCost / (float)OldFireCost; - projectileBatteryAmmoProviderComponent.Shots = (int)Math.Round(projectileBatteryAmmoProviderComponent.Shots/FireCostDiff); - projectileBatteryAmmoProviderComponent.Capacity = (int)Math.Round(projectileBatteryAmmoProviderComponent.Capacity/FireCostDiff); + projectileBatteryAmmoProviderComponent.Shots = (int)Math.Round(projectileBatteryAmmoProviderComponent.Shots / FireCostDiff); + projectileBatteryAmmoProviderComponent.Capacity = (int)Math.Round(projectileBatteryAmmoProviderComponent.Capacity / FireCostDiff); + Dirty(uid, projectileBatteryAmmoProviderComponent); + var updateClientAmmoEvent = new UpdateClientAmmoEvent(); RaiseLocalEvent(uid, ref updateClientAmmoEvent); - - if (user != null) - { - _popupSystem.PopupClient(Loc.GetString("gun-set-fire-mode", ("mode", prototype.Name)), uid, user.Value); - } } } } diff --git a/Resources/Locale/en-US/construction/steps/arbitrary-insert-construction-graph-step.ftl b/Resources/Locale/en-US/construction/steps/arbitrary-insert-construction-graph-step.ftl index 430888ed36..b2c86dd6a6 100644 --- a/Resources/Locale/en-US/construction/steps/arbitrary-insert-construction-graph-step.ftl +++ b/Resources/Locale/en-US/construction/steps/arbitrary-insert-construction-graph-step.ftl @@ -8,3 +8,5 @@ construction-insert-info-examine-name-instrument-string = string intrument construction-insert-info-examine-name-instrument-woodwind = woodwind instrument construction-insert-info-examine-name-knife = knife construction-insert-info-examine-name-utensil = utensil +construction-insert-info-examine-name-laser-cannon = high power laser weapon +construction-insert-info-examine-name-power-cell = power cell diff --git a/Resources/Locale/en-US/devices/device-network.ftl b/Resources/Locale/en-US/devices/device-network.ftl index dd473866dc..c19903c313 100644 --- a/Resources/Locale/en-US/devices/device-network.ftl +++ b/Resources/Locale/en-US/devices/device-network.ftl @@ -9,6 +9,8 @@ device-frequency-prototype-name-fax = Fax device-frequency-prototype-name-basic-device = Basic Devices device-frequency-prototype-name-cyborg-control = Cyborg Control device-frequency-prototype-name-robotics-console = Robotics Console +device-frequency-prototype-name-turret = Sentry Turret +device-frequency-prototype-name-turret-control = Sentry Turret Control ## camera frequencies device-frequency-prototype-name-surveillance-camera-test = Subnet Test @@ -32,6 +34,7 @@ device-address-prefix-heater = HTR- device-address-prefix-freezer = FZR- device-address-prefix-volume-pump = VPP- device-address-prefix-smes = SMS- +device-address-prefix-turret = TRT- # PDAs and terminals device-address-prefix-console = CLS- diff --git a/Resources/Locale/en-US/weapons/ranged/turrets.ftl b/Resources/Locale/en-US/weapons/ranged/turrets.ftl new file mode 100644 index 0000000000..213599d926 --- /dev/null +++ b/Resources/Locale/en-US/weapons/ranged/turrets.ftl @@ -0,0 +1,12 @@ +# Deployable turret component +deployable-turret-component-activating = Deploying... +deployable-turret-component-deactivating = Deactivating... +deployable-turret-component-activate = Activate +deployable-turret-component-deactivate = Deactivate +deployable-turret-component-access-denied = Access denied +deployable-turret-component-no-ammo = Weapon systems depleted +deployable-turret-component-is-broken = The turret is heavily damaged and must be repaired +deployable-turret-component-cannot-access-wires = You can't reach the maintenance panel while the turret is active + +# Turret notification for station AI +station-ai-turret-is-attacking-warning = {CAPITALIZE($source)} has engaged a hostile target. \ No newline at end of file diff --git a/Resources/Locale/en-US/wires/wire-names.ftl b/Resources/Locale/en-US/wires/wire-names.ftl index 08e5af4000..1c35bdeb8c 100644 --- a/Resources/Locale/en-US/wires/wire-names.ftl +++ b/Resources/Locale/en-US/wires/wire-names.ftl @@ -43,6 +43,8 @@ wires-board-name-jukebox = Jukebox wires-board-name-computer = Computer wires-board-name-holopad = Holopad wires-board-name-barsign = Bar Sign +wires-board-name-weapon-energy-turret = Sentry turret +wires-board-name-turret-controls = Sentry turret control panel # names that get displayed in the wire hacking hud & admin logs. diff --git a/Resources/Prototypes/Device/devicenet_frequencies.yml b/Resources/Prototypes/Device/devicenet_frequencies.yml index ecdbb3bb4c..64b8c8e687 100644 --- a/Resources/Prototypes/Device/devicenet_frequencies.yml +++ b/Resources/Prototypes/Device/devicenet_frequencies.yml @@ -87,6 +87,30 @@ name: device-frequency-prototype-name-cyborg-control frequency: 1292 +# Turret controllers send data to their turrets on this frequency +- type: deviceFrequency + id: TurretControl + name: device-frequency-prototype-name-turret-control + frequency: 2151 + +# Turrets send data to their controllers on this frequency +- type: deviceFrequency + id: Turret + name: device-frequency-prototype-name-turret + frequency: 2152 + +# AI turret controllers send data to their turrets on this frequency +- type: deviceFrequency + id: TurretControlAI + name: device-frequency-prototype-name-turret-control + frequency: 2153 + +# AI turrets send data to their controllers on this frequency +- type: deviceFrequency + id: TurretAI + name: device-frequency-prototype-name-turret + frequency: 2154 + # This frequency will likely have a LARGE number of listening entities. Please don't broadcast on this frequency. - type: deviceFrequency id: SmartLight #used by powered lights. diff --git a/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/Machine/turrets.yml b/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/Machine/turrets.yml new file mode 100644 index 0000000000..5bbf2bb596 --- /dev/null +++ b/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/Machine/turrets.yml @@ -0,0 +1,36 @@ +- type: entity + id: WeaponEnergyTurretStationMachineCircuitboard + parent: BaseMachineCircuitboard + name: sentry turret machine board + description: A machine printed circuit board for a sentry turret. + components: + - type: Sprite + sprite: Objects/Misc/module.rsi + state: security + - type: MachineBoard + prototype: WeaponEnergyTurretStation + tagRequirements: + TurretCompatibleWeapon: + amount: 1 + defaultPrototype: WeaponLaserCannon + examineName: construction-insert-info-examine-name-laser-cannon + ProximitySensor: + amount: 1 + defaultPrototype: ProximitySensor + componentRequirements: + PowerCell: + amount: 1 + defaultPrototype: PowerCellMedium + examineName: construction-insert-info-examine-name-power-cell + +- type: entity + id: WeaponEnergyTurretAIMachineCircuitboard + parent: WeaponEnergyTurretStationMachineCircuitboard + name: AI sentry turret machine board + description: A machine printed circuit board for an AI sentry turret. + components: + - type: Sprite + sprite: Objects/Misc/module.rsi + state: command + - type: MachineBoard + prototype: WeaponEnergyTurretAI \ No newline at end of file diff --git a/Resources/Prototypes/Entities/Objects/Weapons/Guns/Battery/battery_guns.yml b/Resources/Prototypes/Entities/Objects/Weapons/Guns/Battery/battery_guns.yml index 73d06a200c..7b1c77b8d0 100644 --- a/Resources/Prototypes/Entities/Objects/Weapons/Guns/Battery/battery_guns.yml +++ b/Resources/Prototypes/Entities/Objects/Weapons/Guns/Battery/battery_guns.yml @@ -371,6 +371,9 @@ - type: HitscanBatteryAmmoProvider proto: RedHeavyLaser fireCost: 100 + - type: Tag + tags: + - TurretCompatibleWeapon - type: entity name: portable particle decelerator diff --git a/Resources/Prototypes/Entities/Objects/Weapons/Guns/Turrets/turrets_base.yml b/Resources/Prototypes/Entities/Objects/Weapons/Guns/Turrets/turrets_base.yml index aaa45a2136..ed157365da 100644 --- a/Resources/Prototypes/Entities/Objects/Weapons/Guns/Turrets/turrets_base.yml +++ b/Resources/Prototypes/Entities/Objects/Weapons/Guns/Turrets/turrets_base.yml @@ -127,9 +127,12 @@ maxCharge: 2000 startingCharge: 0 - type: ApcPowerReceiverBattery - idlePowerUse: 5 + idleLoad: 5 batteryRechargeRate: 200 batteryRechargeEfficiency: 1.225 - type: ApcPowerReceiver powerLoad: 5 - - type: ExtensionCableReceiver \ No newline at end of file + - type: ExtensionCableReceiver + - type: HTN + rootTask: + task: EnergyTurretCompound \ No newline at end of file diff --git a/Resources/Prototypes/Entities/Objects/Weapons/Guns/Turrets/turrets_energy.yml b/Resources/Prototypes/Entities/Objects/Weapons/Guns/Turrets/turrets_energy.yml new file mode 100644 index 0000000000..2a37ba09c5 --- /dev/null +++ b/Resources/Prototypes/Entities/Objects/Weapons/Guns/Turrets/turrets_energy.yml @@ -0,0 +1,182 @@ +- type: entity + parent: [BaseWeaponEnergyTurret, ConstructibleMachine] + id: WeaponEnergyTurretStation + name: sentry turret + description: A high-tech autonomous weapons system designed to keep unauthorized personnel out of sensitive areas. + components: + + # Physics + - type: Fixtures + fixtures: + body: + shape: + !type:PhysShapeCircle + radius: 0.45 + density: 60 + mask: + - Impassable + turret: + shape: + !type:PhysShapeCircle + radius: 0.45 + density: 60 + mask: + - MachineMask + layer: + - MachineLayer + hard: false + + # Sprites and appearance + - type: Sprite + sprite: Objects/Weapons/Guns/Turrets/sentry_turret.rsi + drawdepth: FloorObjects + granularLayersRendering: true + layers: + - state: support + renderingStrategy: NoRotation + - state: base_shadow + map: [ "shadow" ] + - state: base + map: [ "base" ] + - state: stun + map: [ "enum.DeployableTurretVisuals.Weapon" ] + shader: "unshaded" + visible: false + - state: cover_closed + map: [ "enum.DeployableTurretVisuals.Turret" ] + renderingStrategy: NoRotation + - state: cover_light_on + map: [ "enum.PowerDeviceVisualLayers.Powered" ] + shader: "unshaded" + renderingStrategy: NoRotation + visible: false + - state: panel + map: [ "enum.WiresVisualLayers.MaintenancePanel" ] + renderingStrategy: NoRotation + visible: false + - type: AnimationPlayer + - type: Appearance + - type: GenericVisualizer + visuals: + enum.BatteryWeaponFireModeVisuals.State: + enum.DeployableTurretVisuals.Weapon: + BulletEnergyTurretDisabler: { state: stun } + BulletEnergyTurretLaser: { state: lethal } + enum.DeployableTurretVisuals.Broken: + base: + True: { state: destroyed } + False: { state: base } + enum.WiresVisuals.MaintenancePanelState: + enum.WiresVisualLayers.MaintenancePanel: + True: { visible: false } + False: { visible: true } + + # HTN + - type: HTN + enabled: false + + # Faction / control + - type: StationAiWhitelist + - type: NpcFactionMember + factions: + - AllHostile + - type: AccessReader + access: [["Security"]] + + # Weapon systems + - type: ProjectileBatteryAmmoProvider + proto: BulletEnergyTurretDisabler + fireCost: 100 + - type: BatteryWeaponFireModes + fireModes: + - proto: BulletEnergyTurretDisabler + fireCost: 100 + - proto: BulletEnergyTurretLaser + fireCost: 100 + - type: TurretTargetSettings + exemptAccessLevels: + - Security + - Borg + - BasicSilicon + + # Defenses / destruction + - type: DeployableTurret + retractedDamageModifierSetId: Metallic + deployedDamageModifierSetId: FlimsyMetallic + - type: Damageable + damageModifierSet: Metallic + - type: Repairable + doAfterDelay: 10 + allowSelfRepair: false + - type: Destructible + thresholds: + - trigger: + !type:DamageTrigger + damage: 300 + behaviors: + - !type:PlaySoundBehavior + sound: + collection: MetalBreak + - !type:DoActsBehavior + acts: [ "Breakage" ] + - trigger: + !type:DamageTrigger + damage: 600 + behaviors: + - !type:PlaySoundBehavior + sound: + collection: MetalBreak + - !type:ChangeConstructionNodeBehavior + node: machineFrame + - !type:DoActsBehavior + acts: ["Destruction"] + + # Device network + - type: DeviceNetwork + deviceNetId: Wired + receiveFrequencyId: TurretControl + transmitFrequencyId: Turret + sendBroadcastAttemptEvent: true + prefix: device-address-prefix-turret + examinableAddress: true + - type: DeviceNetworkRequiresPower + - type: WiredNetworkConnection + + # Wires + - type: UserInterface + interfaces: + enum.WiresUiKey.Key: + type: WiresBoundUserInterface + - type: WiresPanel + - type: WiresVisuals + - type: Wires + boardName: wires-board-name-weapon-energy-turret + layoutId: WeaponEnergyTurret + - type: Lock + locked: true + unlockOnClick: false + - type: LockedWiresPanel + + # General properties + - type: Machine + board: WeaponEnergyTurretStationMachineCircuitboard + - type: UseDelay + delay: 1.2 + +- type: entity + parent: WeaponEnergyTurretStation + id: WeaponEnergyTurretAI + name: AI sentry turret + description: A high-tech autonomous weapons system under the direct control of a local artifical intelligence. + components: + - type: AccessReader + access: [["StationAi"]] + - type: TurretTargetSettings + exemptAccessLevels: + - Borg + - BasicSilicon + - type: Machine + board: WeaponEnergyTurretAIMachineCircuitboard + - type: DeviceNetwork + receiveFrequencyId: TurretControlAI + transmitFrequencyId: TurretAI diff --git a/Resources/Prototypes/NPCs/utility_queries.yml b/Resources/Prototypes/NPCs/utility_queries.yml index 03764e2b1f..69ae4a337d 100644 --- a/Resources/Prototypes/NPCs/utility_queries.yml +++ b/Resources/Prototypes/NPCs/utility_queries.yml @@ -190,6 +190,8 @@ curve: !type:BoolCurve - !type:TargetIsCritCon curve: !type:InverseBoolCurve + - !type:TargetIsStunnedCon + curve: !type:InverseBoolCurve - !type:TurretTargetingCon curve: !type:BoolCurve - !type:TargetDistanceCon diff --git a/Resources/Prototypes/Wires/layouts.yml b/Resources/Prototypes/Wires/layouts.yml index 32c1488683..d94355361f 100644 --- a/Resources/Prototypes/Wires/layouts.yml +++ b/Resources/Prototypes/Wires/layouts.yml @@ -204,4 +204,25 @@ wires: - !type:PowerWireAction - !type:AiInteractWireAction - - !type:AccessWireAction \ No newline at end of file + - !type:AccessWireAction + +- type: wireLayout + id: WeaponEnergyTurret + dummyWires: 4 + wires: + - !type:PowerWireAction + - !type:PowerWireAction + pulseTimeout: 15 + - !type:AiInteractWireAction + - !type:AccessWireAction + +- type: wireLayout + id: TurretControls + dummyWires: 2 + wires: + - !type:PowerWireAction + - !type:PowerWireAction + pulseTimeout: 15 + - !type:AiInteractWireAction + - !type:AccessWireAction + \ No newline at end of file diff --git a/Resources/Prototypes/tags.yml b/Resources/Prototypes/tags.yml index 68b69fc3b4..6577b7eb89 100644 --- a/Resources/Prototypes/tags.yml +++ b/Resources/Prototypes/tags.yml @@ -1317,6 +1317,12 @@ - type: Tag id: Truncheon + +- type: Tag + id: TurretCompatibleWeapon # Used in the construction of sentry turrets + +- type: Tag + id: TurretControlElectronics # Used in the construction of sentry turret control panels - type: Tag id: Unimplantable