using Content.Shared.DoAfter; using Content.Shared.Emp; using Content.Shared.Examine; using Content.Shared.Interaction; using Content.Shared.Interaction.Events; using Content.Shared.Verbs; using Content.Shared.Weapons.Ranged.Components; using Content.Shared.Weapons.Ranged.Events; using Robust.Shared.Containers; using Robust.Shared.Map; using Robust.Shared.Serialization; namespace Content.Shared.Weapons.Ranged.Systems; public abstract partial class SharedGunSystem { [Dependency] private readonly SharedDoAfterSystem _doAfter = default!; [Dependency] private readonly SharedInteractionSystem _interaction = default!; [MustCallBase] protected virtual void InitializeBallistic() { SubscribeLocalEvent(OnBallisticInit); SubscribeLocalEvent(OnBallisticMapInit); SubscribeLocalEvent(OnBallisticTakeAmmo); SubscribeLocalEvent(OnBallisticAmmoCount); SubscribeLocalEvent(OnBallisticExamine); SubscribeLocalEvent>(OnBallisticVerb); SubscribeLocalEvent(OnBallisticInteractUsing); SubscribeLocalEvent(OnBallisticAfterInteract); SubscribeLocalEvent(OnBallisticAmmoFillDoAfter); SubscribeLocalEvent(OnBallisticUse); SubscribeLocalEvent(OnBallisticRefillerMapInit); SubscribeLocalEvent(OnRefillerEmpPulsed); } private void OnBallisticRefillerMapInit(Entity entity, ref MapInitEvent _) { entity.Comp.NextAutoRefill = Timing.CurTime + entity.Comp.AutoRefillRate; } private void OnBallisticUse(Entity ent, ref UseInHandEvent args) { if (args.Handled) return; ManualCycle(ent, TransformSystem.GetMapCoordinates(ent), args.User); args.Handled = true; } private void OnBallisticInteractUsing(Entity ent, ref InteractUsingEvent args) { if (args.Handled) return; if (TryBallisticInsert(ent, args.Used, args.User)) args.Handled = true; } private void OnBallisticAfterInteract(EntityUid uid, BallisticAmmoProviderComponent component, AfterInteractEvent args) { if (args.Handled || !component.MayTransfer || !Timing.IsFirstTimePredicted || args.Target == null || args.Used == args.Target || Deleted(args.Target) || !TryComp(args.Target, out var targetComponent) || targetComponent.Whitelist == null) { return; } args.Handled = true; // Continuous loading _doAfter.TryStartDoAfter(new DoAfterArgs(EntityManager, args.User, component.FillDelay, new AmmoFillDoAfterEvent(), used: uid, target: args.Target, eventTarget: uid) { BreakOnMove = false, // DeltaV - reload while moving BreakOnDamage = false, NeedHand = true, }); } private void OnBallisticAmmoFillDoAfter(EntityUid uid, BallisticAmmoProviderComponent component, AmmoFillDoAfterEvent args) { if (args.Handled || args.Cancelled) return; if (Deleted(args.Target) || !TryComp(args.Target, out var target) || target.Whitelist == null) return; if (target.Entities.Count + target.UnspawnedCount == target.Capacity) { Popup( Loc.GetString("gun-ballistic-transfer-target-full", ("entity", args.Target)), args.Target, args.User); return; } if (component.Entities.Count + component.UnspawnedCount == 0) { Popup( Loc.GetString("gun-ballistic-transfer-empty", ("entity", uid)), uid, args.User); return; } void SimulateInsertAmmo(EntityUid ammo, EntityUid ammoProvider, EntityCoordinates coordinates) { // We call SharedInteractionSystem to raise contact events. Checks are already done by this point. _interaction.InteractUsing(args.User, ammo, ammoProvider, coordinates, checkCanInteract: false, checkCanUse: false); } List<(EntityUid? Entity, IShootable Shootable)> ammo = new(); var evTakeAmmo = new TakeAmmoEvent(1, ammo, Transform(uid).Coordinates, args.User); RaiseLocalEvent(uid, evTakeAmmo); foreach (var (ent, _) in ammo) { if (ent == null) continue; if (_whitelistSystem.IsWhitelistFail(target.Whitelist, ent.Value)) { Popup( Loc.GetString("gun-ballistic-transfer-invalid", ("ammoEntity", ent.Value), ("targetEntity", args.Target.Value)), uid, args.User); SimulateInsertAmmo(ent.Value, uid, Transform(uid).Coordinates); } else { // play sound to be cool Audio.PlayPredicted(component.SoundInsert, uid, args.User); SimulateInsertAmmo(ent.Value, args.Target.Value, Transform(args.Target.Value).Coordinates); } if (IsClientSide(ent.Value)) Del(ent.Value); } // repeat if there is more space in the target and more ammo to fill var moreSpace = target.Entities.Count + target.UnspawnedCount < target.Capacity; var moreAmmo = component.Entities.Count + component.UnspawnedCount > 0; args.Repeat = moreSpace && moreAmmo; } private void OnBallisticVerb(EntityUid uid, BallisticAmmoProviderComponent component, GetVerbsEvent args) { if (!args.CanAccess || !args.CanInteract || args.Hands == null || !component.Cycleable) return; if (component.Cycleable) { args.Verbs.Add(new Verb() { Text = Loc.GetString("gun-ballistic-cycle"), Disabled = GetBallisticShots(component) == 0, Act = () => ManualCycle((uid, component), TransformSystem.GetMapCoordinates(uid), args.User), }); } } private void OnBallisticExamine(Entity ent, ref ExaminedEvent args) { if (!args.IsInDetailsRange) return; args.PushMarkup(Loc.GetString("gun-magazine-examine", ("color", AmmoExamineColor), ("count", GetBallisticShots(ent.Comp)))); } private void ManualCycle(Entity ent, MapCoordinates coordinates, EntityUid? user = null, GunComponent? gunComp = null) { if (!ent.Comp.Cycleable) return; // Reset shotting for cycling if (Resolve(ent, ref gunComp, false) && gunComp is { FireRateModified: > 0f } && !Paused(ent)) { gunComp.NextFire = Timing.CurTime + TimeSpan.FromSeconds(1 / gunComp.FireRateModified); DirtyField(ent, gunComp, nameof(GunComponent.NextFire)); } Audio.PlayPredicted(ent.Comp.SoundRack, ent, user); var shots = GetBallisticShots(ent.Comp); Cycle(ent, coordinates); var text = Loc.GetString(shots == 0 ? "gun-ballistic-cycled-empty" : "gun-ballistic-cycled"); Popup(text, ent, user); UpdateBallisticAppearance(ent); UpdateAmmoCount(ent); } protected abstract void Cycle(Entity ent, MapCoordinates coordinates); private void OnBallisticInit(Entity ent, ref ComponentInit args) { ent.Comp.Container = Containers.EnsureContainer(ent, "ballistic-ammo"); // TODO: This is called twice though we need to support loading appearance data (and we need to call it on MapInit // to ensure it's correct). UpdateBallisticAppearance(ent); } private void OnBallisticMapInit(Entity ent, ref MapInitEvent args) { // TODO this should be part of the prototype, not set on map init. // Alternatively, just track spawned count, instead of unspawned count. if (ent.Comp.Proto != null) { ent.Comp.UnspawnedCount = Math.Max(0, ent.Comp.Capacity - ent.Comp.Container.ContainedEntities.Count); UpdateBallisticAppearance(ent); DirtyField(ent.AsNullable(), nameof(BallisticAmmoProviderComponent.UnspawnedCount)); } } protected int GetBallisticShots(BallisticAmmoProviderComponent component) { return component.Entities.Count + component.UnspawnedCount; } private void OnBallisticTakeAmmo(Entity ent, ref TakeAmmoEvent args) { for (var i = 0; i < args.Shots; i++) { EntityUid? ammoEntity = null; if (ent.Comp.Entities.Count > 0) { var existingEnt = ent.Comp.Entities[^1]; ent.Comp.Entities.RemoveAt(ent.Comp.Entities.Count - 1); DirtyField(ent.AsNullable(), nameof(BallisticAmmoProviderComponent.Entities)); Containers.Remove(existingEnt, ent.Comp.Container); ammoEntity = existingEnt; } else if (ent.Comp.UnspawnedCount > 0) { ent.Comp.UnspawnedCount--; DirtyField(ent.AsNullable(), nameof(BallisticAmmoProviderComponent.UnspawnedCount)); ammoEntity = Spawn(ent.Comp.Proto, args.Coordinates); } if (ammoEntity is not { } ammoEnt) continue; args.Ammo.Add((ammoEnt, EnsureShootable(ammoEnt))); if (TryComp(ent, out var refiller)) { PauseSelfRefill((ent, refiller)); } } UpdateBallisticAppearance(ent); } private void OnBallisticAmmoCount(Entity ent, ref GetAmmoCountEvent args) { args.Count = GetBallisticShots(ent.Comp); args.Capacity = ent.Comp.Capacity; } /// /// Causes to pause its refilling for either at least /// (if not null) or the entity's . If the /// entity's next refill would occur after the pause duration, this function has no effect. /// public void PauseSelfRefill( Entity 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)); } } /// /// Returns true if the given 's ballistic ammunition is full, false otherwise. /// public bool IsFull(Entity entity) { return GetBallisticShots(entity.Comp) >= entity.Comp.Capacity; } /// /// Returns whether or not can be inserted into , based on /// available space and whitelists. /// public bool CanInsertBallistic(Entity entity, EntityUid inserted) { return !_whitelistSystem.IsWhitelistFailOrNull(entity.Comp.Whitelist, inserted) && !IsFull(entity); } /// /// Attempts to insert into as ammunition. Returns true on /// success, false otherwise. /// public bool TryBallisticInsert( Entity 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); UpdateAmmoCount(entity); DirtyField(entity.AsNullable(), nameof(BallisticAmmoProviderComponent.Entities)); return true; } public void UpdateBallisticAppearance(Entity ent) { if (!Timing.IsFirstTimePredicted || !TryComp(ent, out var appearance)) return; Appearance.SetData(ent, AmmoVisuals.AmmoCount, GetBallisticShots(ent.Comp), appearance); Appearance.SetData(ent, AmmoVisuals.AmmoMax, ent.Comp.Capacity, appearance); } public void SetBallisticUnspawned(Entity entity, int count) { if (entity.Comp.UnspawnedCount == count) return; entity.Comp.UnspawnedCount = count; UpdateBallisticAppearance(entity); UpdateAmmoCount(entity.Owner); Dirty(entity); } private void OnRefillerEmpPulsed(Entity entity, ref EmpPulseEvent args) { if (!entity.Comp.AffectedByEmp) return; PauseSelfRefill(entity, args.Duration); } private void UpdateBallistic(float frameTime) { var query = EntityQueryEnumerator(); while (query.MoveNext(out var uid, out var refiller, out var ammo)) { BallisticSelfRefillerUpdate((uid, ammo, refiller)); } } private void BallisticSelfRefillerUpdate( Entity 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)}?"); } } } } /// /// DoAfter event for filling one ballistic ammo provider from another. /// [Serializable, NetSerializable] public sealed partial class AmmoFillDoAfterEvent : SimpleDoAfterEvent { }