Adds BallisticAmmoSelfRefillerComponent (#38537)

* Adds BallisticAmmoSelfRefillerComponent

And uses it to replace battery-based refilling of the Syndicate L6 and Viper modules.
- Add `BallisticAmmoSelfRefillerComponent`
- Handle `EmpPulseEvent` to pause refilling behavior for EMP's duration
- Change `Content.Server.Weapons.Ranged.Systems.Update` override in `GunSystem.AutoFire.cs` to `UpdateAutoFire`
- Add `Content.Server.Weapons.Ranged.Systems.Update` to `GunSystem.cs` so that it can call `UpdateAutoFire` and `UpdateBallistic`
- Add public methods to GunSystem for use by refilling implementation
  - PauseSelfRefill
  - IsFullBallistic (same as #299)
  - CanInsertBallistic (same as #299)
  - TryBallisticInsert (same as #299)

* _timing -> Timing

* unspawned count stuff

* imagine building the code before pushing

* - apply to c20r ROW
- make predicted/shared

* revert server system import only changes

* oop

* o great and wise Slarti

* Scar comments

* field deltas + correct serializer

* review

---------

Co-authored-by: ScarKy0 <scarky0@onet.eu>
This commit is contained in:
Centronias 2025-12-17 14:52:32 -08:00 committed by BarryNorfolk
parent bb4f91d408
commit d50706dd5e
6 changed files with 247 additions and 45 deletions

View File

@ -0,0 +1,61 @@
using Content.Shared.Power.Components;
using Content.Shared.Weapons.Ranged.Systems;
using Robust.Shared.GameStates;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
namespace Content.Shared.Weapons.Ranged.Components;
/// <summary>
/// This component, analogous to <see cref="BatterySelfRechargerComponent"/>, will attempt insert ballistic ammunition
/// into its owner's <see cref="BallisticAmmoProviderComponent"/>.
/// </summary>
[RegisterComponent, NetworkedComponent, AutoGenerateComponentState(fieldDeltas: true), AutoGenerateComponentPause,
Access(typeof(SharedGunSystem))]
public sealed partial class BallisticAmmoSelfRefillerComponent : Component
{
/// <summary>
/// True if the refilling behavior is active, false otherwise.
/// </summary>
[DataField, AutoNetworkedField]
public bool AutoRefill = true;
/// <summary>
/// How often a new piece of ammunition is inserted into the owner's <see cref="BallisticAmmoProviderComponent"/>.
/// </summary>
[DataField, AutoNetworkedField]
public TimeSpan AutoRefillRate = TimeSpan.FromSeconds(1);
/// <summary>
/// If true, causes the refilling behavior to be delayed by at least <see cref="AutoRefillPauseDuration"/> after
/// the owner is fired.
/// </summary>
[DataField, AutoNetworkedField]
public bool FiringPausesAutoRefill = false;
/// <summary>
/// How long to pause for if <see cref="FiringPausesAutoRefill"/> is true.
/// </summary>
[DataField, AutoNetworkedField]
public TimeSpan AutoRefillPauseDuration = TimeSpan.Zero;
/// <summary>
/// What entity to spawn and attempt to insert into the owner. If null, uses
/// <see cref="BallisticAmmoProviderComponent.Proto"/>. If that's also null, this component does nothing but log
/// errors.
/// </summary>
[DataField, AutoNetworkedField]
public EntProtoId? AmmoProto;
/// <summary>
/// If true, EMPs will pause this component's behavior.
/// </summary>
[DataField, AutoNetworkedField]
public bool AffectedByEmp = false;
/// <summary>
/// When the next auto refill should occur. This is just implementation state.
/// </summary>
[DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoNetworkedField, AutoPausedField]
public TimeSpan NextAutoRefill = TimeSpan.Zero;
}

View File

@ -1,4 +1,5 @@
using Content.Shared.DoAfter;
using Content.Shared.Emp;
using Content.Shared.Examine;
using Content.Shared.Interaction;
using Content.Shared.Interaction.Events;
@ -16,7 +17,7 @@ public abstract partial class SharedGunSystem
[Dependency] private readonly SharedDoAfterSystem _doAfter = default!;
[Dependency] private readonly SharedInteractionSystem _interaction = default!;
[MustCallBase]
protected virtual void InitializeBallistic()
{
SubscribeLocalEvent<BallisticAmmoProviderComponent, ComponentInit>(OnBallisticInit);
@ -30,6 +31,14 @@ public abstract partial class SharedGunSystem
SubscribeLocalEvent<BallisticAmmoProviderComponent, AfterInteractEvent>(OnBallisticAfterInteract);
SubscribeLocalEvent<BallisticAmmoProviderComponent, AmmoFillDoAfterEvent>(OnBallisticAmmoFillDoAfter);
SubscribeLocalEvent<BallisticAmmoProviderComponent, UseInHandEvent>(OnBallisticUse);
SubscribeLocalEvent<BallisticAmmoSelfRefillerComponent, MapInitEvent>(OnBallisticRefillerMapInit);
SubscribeLocalEvent<BallisticAmmoSelfRefillerComponent, EmpPulseEvent>(OnRefillerEmpPulsed);
}
private void OnBallisticRefillerMapInit(Entity<BallisticAmmoSelfRefillerComponent> entity, ref MapInitEvent args)
{
entity.Comp.NextAutoRefill = Timing.CurTime + entity.Comp.AutoRefillRate;
}
private void OnBallisticUse(EntityUid uid, BallisticAmmoProviderComponent component, UseInHandEvent args)
@ -46,20 +55,8 @@ public abstract partial class SharedGunSystem
if (args.Handled)
return;
if (_whitelistSystem.IsWhitelistFailOrNull(component.Whitelist, args.Used))
return;
if (GetBallisticShots(component) >= component.Capacity)
return;
component.Entities.Add(args.Used);
Containers.Insert(args.Used, component.Container);
// Not predicted so
Audio.PlayPredicted(component.SoundInsert, uid, args.User);
args.Handled = true;
UpdateBallisticAppearance(uid, component);
UpdateAmmoCount(args.Target);
DirtyField(uid, component, nameof(BallisticAmmoProviderComponent.Entities));
if (TryBallisticInsert((uid, component), args.Used, args.User))
args.Handled = true;
}
private void OnBallisticAfterInteract(EntityUid uid, BallisticAmmoProviderComponent component, AfterInteractEvent args)
@ -242,23 +239,29 @@ public abstract partial class SharedGunSystem
{
for (var i = 0; i < args.Shots; i++)
{
EntityUid entity;
EntityUid? ammoEntity = null;
if (component.Entities.Count > 0)
{
entity = component.Entities[^1];
args.Ammo.Add((entity, EnsureShootable(entity)));
var existingEnt = component.Entities[^1];
component.Entities.RemoveAt(component.Entities.Count - 1);
DirtyField(uid, component, nameof(BallisticAmmoProviderComponent.Entities));
Containers.Remove(entity, component.Container);
Containers.Remove(existingEnt, component.Container);
ammoEntity = existingEnt;
}
else if (component.UnspawnedCount > 0)
{
component.UnspawnedCount--;
DirtyField(uid, component, nameof(BallisticAmmoProviderComponent.UnspawnedCount));
entity = Spawn(component.Proto, args.Coordinates);
args.Ammo.Add((entity, EnsureShootable(entity)));
ammoEntity = Spawn(component.Proto, args.Coordinates);
}
if (ammoEntity is { } ent)
{
args.Ammo.Add((ent, EnsureShootable(ent)));
if (TryComp<BallisticAmmoSelfRefillerComponent>(uid, out var refiller))
{
PauseSelfRefill((uid, refiller));
}
}
}
@ -271,6 +274,73 @@ public abstract partial class SharedGunSystem
args.Capacity = component.Capacity;
}
/// <summary>
/// Causes <paramref name="entity"/> to pause its refilling for either at least <paramref name="overridePauseDuration"/>
/// (if not null) or the entity's <see cref="BallisticAmmoSelfRefillerComponent.AutoRefillPauseDuration"/>. If the
/// entity's next refill would occur after the pause duration, this function has no effect.
/// </summary>
public void PauseSelfRefill(
Entity<BallisticAmmoSelfRefillerComponent> entity,
TimeSpan? overridePauseDuration = null
)
{
if (overridePauseDuration == null && !entity.Comp.FiringPausesAutoRefill)
return;
var nextRefillByPause = Timing.CurTime + (overridePauseDuration ?? entity.Comp.AutoRefillPauseDuration);
if (nextRefillByPause > entity.Comp.NextAutoRefill)
{
entity.Comp.NextAutoRefill = nextRefillByPause;
DirtyField(entity.AsNullable(), nameof(BallisticAmmoSelfRefillerComponent.NextAutoRefill));
}
}
/// <summary>
/// Returns true if the given <paramref name="entity"/>'s ballistic ammunition is full, false otherwise.
/// </summary>
public bool IsFull(Entity<BallisticAmmoProviderComponent> entity)
{
return GetBallisticShots(entity.Comp) >= entity.Comp.Capacity;
}
/// <summary>
/// Returns whether or not <paramref name="inserted"/> can be inserted into <paramref name="entity"/>, based on
/// available space and whitelists.
/// </summary>
public bool CanInsertBallistic(Entity<BallisticAmmoProviderComponent> entity, EntityUid inserted)
{
return !_whitelistSystem.IsWhitelistFailOrNull(entity.Comp.Whitelist, inserted) &&
!IsFull(entity);
}
/// <summary>
/// Attempts to insert <paramref name="inserted"/> into <paramref name="entity"/> as ammunition. Returns true on
/// success, false otherwise.
/// </summary>
public bool TryBallisticInsert(
Entity<BallisticAmmoProviderComponent> entity,
EntityUid inserted,
EntityUid? user,
bool suppressInsertionSound = false
)
{
if (!CanInsertBallistic(entity, inserted))
return false;
entity.Comp.Entities.Add(inserted);
Containers.Insert(inserted, entity.Comp.Container);
if (!suppressInsertionSound)
{
Audio.PlayPredicted(entity.Comp.SoundInsert, entity, user);
}
UpdateBallisticAppearance(entity, entity.Comp);
UpdateAmmoCount(entity);
DirtyField(entity.AsNullable(), nameof(BallisticAmmoProviderComponent.Entities));
return true;
}
public void UpdateBallisticAppearance(EntityUid uid, BallisticAmmoProviderComponent component)
{
if (!Timing.IsFirstTimePredicted || !TryComp<AppearanceComponent>(uid, out var appearance))
@ -290,6 +360,70 @@ public abstract partial class SharedGunSystem
UpdateAmmoCount(entity.Owner);
Dirty(entity);
}
private void OnRefillerEmpPulsed(Entity<BallisticAmmoSelfRefillerComponent> entity, ref EmpPulseEvent args)
{
if (!entity.Comp.AffectedByEmp)
return;
PauseSelfRefill(entity, args.Duration);
}
private void UpdateBallistic(float frameTime)
{
var query = EntityQueryEnumerator<BallisticAmmoSelfRefillerComponent, BallisticAmmoProviderComponent>();
while (query.MoveNext(out var uid, out var refiller, out var ammo))
{
BallisticSelfRefillerUpdate((uid, ammo, refiller));
}
}
private void BallisticSelfRefillerUpdate(
Entity<BallisticAmmoProviderComponent, BallisticAmmoSelfRefillerComponent> entity
)
{
var ammo = entity.Comp1;
var refiller = entity.Comp2;
if (Timing.CurTime < refiller.NextAutoRefill)
return;
refiller.NextAutoRefill += refiller.AutoRefillRate;
DirtyField(entity, refiller, nameof(BallisticAmmoSelfRefillerComponent.NextAutoRefill));
if (!refiller.AutoRefill || IsFull(entity))
return;
if (refiller.AmmoProto is not { } refillerAmmoProto)
{
// No ammo proto on the refiller, so just increment the unspawned count on the provider
// if it has an ammo proto.
if (ammo.Proto is null)
{
Log.Error(
$"Neither of {entity}'s {nameof(BallisticAmmoSelfRefillerComponent)}'s or {nameof(BallisticAmmoProviderComponent)}'s ammunition protos is specified. This is a configuration error as it means {nameof(BallisticAmmoSelfRefillerComponent)} cannot do anything.");
return;
}
SetBallisticUnspawned(entity, ammo.UnspawnedCount + 1);
}
else if (ammo.Proto == refillerAmmoProto)
{
// The ammo proto on the refiller and the provider match. Add an unspawned ammo.
SetBallisticUnspawned(entity, ammo.UnspawnedCount + 1);
}
else
{
// Can't use unspawned ammo, so spawn an entity and try to insert it.
var ammoEntity = PredictedSpawnAttachedTo(refiller.AmmoProto, Transform(entity).Coordinates);
var insertSucceeded = TryBallisticInsert(entity, ammoEntity, null, suppressInsertionSound: true);
if (!insertSucceeded)
{
PredictedQueueDel(ammoEntity);
Log.Error(
$"Failed to insert ammo {ammoEntity} into non-full {entity}. This is a configuration error. Is the {nameof(BallisticAmmoSelfRefillerComponent)}'s {nameof(BallisticAmmoSelfRefillerComponent.AmmoProto)} incorrect for the {nameof(BallisticAmmoProviderComponent)}'s {nameof(BallisticAmmoProviderComponent.Whitelist)}?");
}
}
}
}
/// <summary>

View File

@ -682,6 +682,7 @@ public abstract partial class SharedGunSystem : EntitySystem
public override void Update(float frameTime)
{
UpdateBattery(frameTime);
UpdateBallistic(frameTime);
}
}

View File

@ -116,12 +116,14 @@
- type: ContainerContainer
containers:
ballistic-ammo: !type:Container
- type: BatteryAmmoProvider
- type: BallisticAmmoProvider
whitelist:
tags:
- CartridgeLightRifle
capacity: 100
proto: CartridgeLightRifle
fireCost: 100
- type: Battery
maxCharge: 10000
startingCharge: 10000
- type: BatterySelfRecharger
autoRechargeRate: 25
cycleable: false # No synthesizing ammo for your syndicate masters.
- type: BallisticAmmoSelfRefiller
autoRefillRate: 4s
affectedByEmp: true
- type: AmmoCounter

View File

@ -130,14 +130,16 @@
- type: ContainerContainer
containers:
ballistic-ammo: !type:Container
- type: BatteryAmmoProvider
proto: BulletPistol
fireCost: 100
- type: Battery
maxCharge: 1000
startingCharge: 1000
- type: BatterySelfRecharger
autoRechargeRate: 25
- type: BallisticAmmoProvider
whitelist:
tags:
- CartridgePistol
capacity: 10
proto: CartridgePistol
cycleable: false # No synthesizing ammo for your syndicate masters.
- type: BallisticAmmoSelfRefiller
autoRefillRate: 4s
affectedByEmp: true
- type: AmmoCounter
- type: entity

View File

@ -152,14 +152,16 @@
- type: ContainerContainer
containers:
ballistic-ammo: !type:Container
- type: BatteryAmmoProvider
- type: BallisticAmmoProvider
whitelist:
tags:
- CartridgePistol
capacity: 30
proto: CartridgePistol
fireCost: 100
- type: Battery
maxCharge: 3000
startingCharge: 3000
- type: BatterySelfRecharger
autoRechargeRate: 25
cycleable: false # No synthesizing ammo for your syndicate masters.
- type: BallisticAmmoSelfRefiller
autoRefillRate: 4s
affectedByEmp: true
- type: AmmoCounter
- type: entity