diff --git a/Content.Client/Popups/PopupSystem.cs b/Content.Client/Popups/PopupSystem.cs index 14e456177d..573c45617a 100644 --- a/Content.Client/Popups/PopupSystem.cs +++ b/Content.Client/Popups/PopupSystem.cs @@ -10,6 +10,7 @@ using Robust.Shared.Map; using Robust.Shared.Player; using Robust.Shared.Players; using Robust.Shared.Prototypes; +using Robust.Shared.Timing; using Robust.Shared.Utility; namespace Content.Client.Popups @@ -22,6 +23,7 @@ namespace Content.Client.Popups [Dependency] private readonly IPlayerManager _playerManager = default!; [Dependency] private readonly IPrototypeManager _prototype = default!; [Dependency] private readonly IResourceCache _resource = default!; + [Dependency] private readonly IGameTiming _timing = default!; [Dependency] private readonly IUserInterfaceManager _uiManager = default!; public IReadOnlyList WorldLabels => _aliveWorldLabels; @@ -123,6 +125,12 @@ namespace Content.Client.Popups PopupEntity(message, uid, type); } + public override void PopupClient(string message, EntityUid uid, EntityUid recipient, PopupType type = PopupType.Small) + { + if (_timing.IsFirstTimePredicted) + PopupEntity(message, uid, recipient); + } + public override void PopupEntity(string message, EntityUid uid, PopupType type = PopupType.Small) { if (!EntityManager.EntityExists(uid)) diff --git a/Content.Server/Popups/PopupSystem.cs b/Content.Server/Popups/PopupSystem.cs index ef5cd7ca88..9c4b0ae082 100644 --- a/Content.Server/Popups/PopupSystem.cs +++ b/Content.Server/Popups/PopupSystem.cs @@ -64,6 +64,11 @@ namespace Content.Server.Popups RaiseNetworkEvent(new PopupEntityEvent(message, type, uid), actor.PlayerSession); } + public override void PopupClient(string message, EntityUid uid, EntityUid recipient, PopupType type = PopupType.Small) + { + // do nothing duh its for client only + } + public override void PopupEntity(string message, EntityUid uid, ICommonSession recipient, PopupType type = PopupType.Small) { diff --git a/Content.Server/RCD/Components/RCDAmmoComponent.cs b/Content.Server/RCD/Components/RCDAmmoComponent.cs deleted file mode 100644 index 29a0d0717f..0000000000 --- a/Content.Server/RCD/Components/RCDAmmoComponent.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Content.Server.RCD.Components -{ - [RegisterComponent] - public sealed class RCDAmmoComponent : Component - { - //How much ammo we refill - [ViewVariables(VVAccess.ReadWrite)] [DataField("refillAmmo")] public int RefillAmmo = 5; - } -} diff --git a/Content.Server/RCD/Components/RCDComponent.cs b/Content.Server/RCD/Components/RCDComponent.cs deleted file mode 100644 index 5375ae71b7..0000000000 --- a/Content.Server/RCD/Components/RCDComponent.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Threading; -using Robust.Shared.Audio; - -namespace Content.Server.RCD.Components -{ - public enum RcdMode : byte - { - Floors, - Walls, - Airlock, - Deconstruct - } - - [RegisterComponent] - public sealed class RCDComponent : Component - { - private const int DefaultAmmoCount = 5; - - [ViewVariables(VVAccess.ReadOnly)] - [DataField("maxAmmo")] public int MaxAmmo = DefaultAmmoCount; - - [ViewVariables(VVAccess.ReadWrite)] [DataField("delay")] - public float Delay = 2f; - - [DataField("swapModeSound")] - public SoundSpecifier SwapModeSound = new SoundPathSpecifier("/Audio/Items/genhit.ogg"); - - [DataField("successSound")] - public SoundSpecifier SuccessSound = new SoundPathSpecifier("/Audio/Items/deconstruct.ogg"); - - /// - /// What mode are we on? Can be floors, walls, deconstruct. - /// - [DataField("mode")] - public RcdMode Mode = RcdMode.Floors; - - /// - /// How much "ammo" we have left. You can refill this with RCD ammo. - /// - [ViewVariables(VVAccess.ReadWrite)] [DataField("ammo")] - public int CurrentAmmo = DefaultAmmoCount; - } -} diff --git a/Content.Server/RCD/Systems/RCDAmmoSystem.cs b/Content.Server/RCD/Systems/RCDAmmoSystem.cs deleted file mode 100644 index b6d45e5945..0000000000 --- a/Content.Server/RCD/Systems/RCDAmmoSystem.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Content.Server.RCD.Components; -using Content.Shared.Examine; -using Content.Shared.Interaction; -using Content.Shared.Popups; - -namespace Content.Server.RCD.Systems -{ - public sealed class RCDAmmoSystem : EntitySystem - { - public override void Initialize() - { - base.Initialize(); - SubscribeLocalEvent(OnExamine); - SubscribeLocalEvent(OnAfterInteract); - } - - private void OnExamine(EntityUid uid, RCDAmmoComponent component, ExaminedEvent args) - { - var examineMessage = Loc.GetString("rcd-ammo-component-on-examine-text", ("ammo", component.RefillAmmo)); - args.PushText(examineMessage); - } - - private void OnAfterInteract(EntityUid uid, RCDAmmoComponent component, AfterInteractEvent args) - { - if (args.Handled || !args.CanReach) - return; - - if (args.Target is not {Valid: true} target || - !EntityManager.TryGetComponent(target, out RCDComponent? rcdComponent)) - return; - - if (rcdComponent.MaxAmmo - rcdComponent.CurrentAmmo < component.RefillAmmo) - { - rcdComponent.Owner.PopupMessage(args.User, Loc.GetString("rcd-ammo-component-after-interact-full-text")); - args.Handled = true; - return; - } - - rcdComponent.CurrentAmmo = Math.Min(rcdComponent.MaxAmmo, rcdComponent.CurrentAmmo + component.RefillAmmo); - rcdComponent.Owner.PopupMessage(args.User, Loc.GetString("rcd-ammo-component-after-interact-refilled-text")); - EntityManager.QueueDeleteEntity(uid); - - args.Handled = true; - } - } -} diff --git a/Content.Server/RCD/Systems/RCDSystem.cs b/Content.Server/RCD/Systems/RCDSystem.cs deleted file mode 100644 index 9a2fb2fa9d..0000000000 --- a/Content.Server/RCD/Systems/RCDSystem.cs +++ /dev/null @@ -1,246 +0,0 @@ -using Content.Server.Administration.Logs; -using Content.Server.Popups; -using Content.Server.RCD.Components; -using Content.Shared.Database; -using Content.Shared.DoAfter; -using Content.Shared.Examine; -using Content.Shared.Interaction; -using Content.Shared.Interaction.Events; -using Content.Shared.Maps; -using Content.Shared.Tag; -using Robust.Shared.Audio; -using Robust.Shared.Map; -using Robust.Shared.Map.Components; -using Robust.Shared.Player; - -namespace Content.Server.RCD.Systems -{ - public sealed class RCDSystem : EntitySystem - { - [Dependency] private readonly ITileDefinitionManager _tileDefinitionManager = default!; - [Dependency] private readonly IMapManager _mapManager = default!; - - [Dependency] private readonly IAdminLogManager _adminLogger = default!; - [Dependency] private readonly SharedDoAfterSystem _doAfterSystem = default!; - [Dependency] private readonly SharedInteractionSystem _interactionSystem = default!; - [Dependency] private readonly PopupSystem _popup = default!; - [Dependency] private readonly TagSystem _tagSystem = default!; - - private readonly int RCDModeCount = Enum.GetValues(typeof(RcdMode)).Length; - - public override void Initialize() - { - base.Initialize(); - SubscribeLocalEvent(OnExamine); - SubscribeLocalEvent(OnUseInHand); - SubscribeLocalEvent(OnAfterInteract); - } - - private void OnExamine(EntityUid uid, RCDComponent component, ExaminedEvent args) - { - var msg = Loc.GetString("rcd-component-examine-detail-count", - ("mode", component.Mode), ("ammoCount", component.CurrentAmmo)); - args.PushMarkup(msg); - } - - private void OnUseInHand(EntityUid uid, RCDComponent component, UseInHandEvent args) - { - if (args.Handled) - return; - - NextMode(uid, component, args.User); - args.Handled = true; - } - - private async void OnAfterInteract(EntityUid uid, RCDComponent rcd, AfterInteractEvent args) - { - if (args.Handled || !args.CanReach) - return; - - if (!args.ClickLocation.IsValid(EntityManager)) return; - - var clickLocationMod = args.ClickLocation; - // Initial validity check - if (!clickLocationMod.IsValid(EntityManager)) - return; - // Try to fix it (i.e. if clicking on space) - // Note: Ideally there'd be a better way, but there isn't right now. - var gridIdOpt = clickLocationMod.GetGridUid(EntityManager); - if (!(gridIdOpt is EntityUid gridId) || !gridId.IsValid()) - { - clickLocationMod = clickLocationMod.AlignWithClosestGridTile(); - gridIdOpt = clickLocationMod.GetGridUid(EntityManager); - // Check if fixing it failed / get final grid ID - if (!(gridIdOpt is EntityUid gridId2) || !gridId2.IsValid()) - return; - gridId = gridId2; - } - - var mapGrid = _mapManager.GetGrid(gridId); - var tile = mapGrid.GetTileRef(clickLocationMod); - var snapPos = mapGrid.TileIndicesFor(clickLocationMod); - - //No changing mode mid-RCD - var startingMode = rcd.Mode; - args.Handled = true; - var user = args.User; - - //Using an RCD isn't instantaneous - var doAfterEventArgs = new DoAfterArgs(user, rcd.Delay, new AwaitedDoAfterEvent(), null, target: args.Target) - { - BreakOnDamage = true, - NeedHand = true, - BreakOnHandChange = true, - BreakOnUserMove = true, - BreakOnTargetMove = true, - AttemptFrequency = AttemptFrequency.EveryTick, - ExtraCheck = () => IsRCDStillValid(rcd, args, mapGrid, tile, startingMode) //All of the sanity checks are here - }; - - var result = await _doAfterSystem.WaitDoAfter(doAfterEventArgs); - - if (result == DoAfterStatus.Cancelled) - return; - - switch (rcd.Mode) - { - //Floor mode just needs the tile to be a space tile (subFloor) - case RcdMode.Floors: - mapGrid.SetTile(snapPos, new Tile(_tileDefinitionManager["FloorSteel"].TileId)); - _adminLogger.Add(LogType.RCD, LogImpact.High, $"{ToPrettyString(args.User):user} used RCD to set grid: {tile.GridUid} {snapPos} to FloorSteel"); - break; - //We don't want to place a space tile on something that's already a space tile. Let's do the inverse of the last check. - case RcdMode.Deconstruct: - if (!tile.IsBlockedTurf(true)) //Delete the turf - { - mapGrid.SetTile(snapPos, Tile.Empty); - _adminLogger.Add(LogType.RCD, LogImpact.High, $"{ToPrettyString(args.User):user} used RCD to set grid: {tile.GridUid} tile: {snapPos} to space"); - } - else //Delete what the user targeted - { - if (args.Target is {Valid: true} target) - { - _adminLogger.Add(LogType.RCD, LogImpact.High, $"{ToPrettyString(args.User):user} used RCD to delete {ToPrettyString(target):target}"); - QueueDel(target); - } - } - break; - //Walls are a special behaviour, and require us to build a new object with a transform rather than setting a grid tile, - // thus we early return to avoid the tile set code. - case RcdMode.Walls: - var ent = EntityManager.SpawnEntity("WallSolid", mapGrid.GridTileToLocal(snapPos)); - Transform(ent).LocalRotation = Angle.Zero; // Walls always need to point south. - _adminLogger.Add(LogType.RCD, LogImpact.High, $"{ToPrettyString(args.User):user} used RCD to spawn {ToPrettyString(ent)} at {snapPos} on grid {mapGrid.Owner}"); - break; - case RcdMode.Airlock: - var airlock = EntityManager.SpawnEntity("Airlock", mapGrid.GridTileToLocal(snapPos)); - Transform(airlock).LocalRotation = Transform(rcd.Owner).LocalRotation; //Now apply icon smoothing. - _adminLogger.Add(LogType.RCD, LogImpact.High, $"{ToPrettyString(args.User):user} used RCD to spawn {ToPrettyString(airlock)} at {snapPos} on grid {mapGrid.Owner}"); - break; - default: - args.Handled = true; - return; //I don't know why this would happen, but sure I guess. Get out of here invalid state! - } - - SoundSystem.Play(rcd.SuccessSound.GetSound(), Filter.Pvs(uid, entityManager: EntityManager), rcd.Owner); - rcd.CurrentAmmo--; - args.Handled = true; - } - - private bool IsRCDStillValid(RCDComponent rcd, AfterInteractEvent eventArgs, MapGridComponent mapGrid, TileRef tile, RcdMode startingMode) - { - //Less expensive checks first. Failing those ones, we need to check that the tile isn't obstructed. - if (rcd.CurrentAmmo <= 0) - { - _popup.PopupEntity(Loc.GetString("rcd-component-no-ammo-message"), rcd.Owner, eventArgs.User); - return false; - } - - if (rcd.Mode != startingMode) - { - return false; - } - - var unobstructed = eventArgs.Target == null - ? _interactionSystem.InRangeUnobstructed(eventArgs.User, mapGrid.GridTileToWorld(tile.GridIndices), popup: true) - : _interactionSystem.InRangeUnobstructed(eventArgs.User, eventArgs.Target.Value, popup: true); - - if (!unobstructed) - return false; - - switch (rcd.Mode) - { - //Floor mode just needs the tile to be a space tile (subFloor) - case RcdMode.Floors: - if (!tile.Tile.IsEmpty) - { - _popup.PopupEntity(Loc.GetString("rcd-component-cannot-build-floor-tile-not-empty-message"), rcd.Owner, eventArgs.User); - return false; - } - - return true; - //We don't want to place a space tile on something that's already a space tile. Let's do the inverse of the last check. - case RcdMode.Deconstruct: - if (tile.Tile.IsEmpty) - { - return false; - } - - //They tried to decon a turf but the turf is blocked - if (eventArgs.Target == null && tile.IsBlockedTurf(true)) - { - _popup.PopupEntity(Loc.GetString("rcd-component-tile-obstructed-message"), rcd.Owner, eventArgs.User); - return false; - } - //They tried to decon a non-turf but it's not in the whitelist - if (eventArgs.Target != null && !_tagSystem.HasTag(eventArgs.Target.Value, "RCDDeconstructWhitelist")) - { - _popup.PopupEntity(Loc.GetString("rcd-component-deconstruct-target-not-on-whitelist-message"), rcd.Owner, eventArgs.User); - return false; - } - - return true; - //Walls are a special behaviour, and require us to build a new object with a transform rather than setting a grid tile, thus we early return to avoid the tile set code. - case RcdMode.Walls: - if (tile.Tile.IsEmpty) - { - _popup.PopupEntity(Loc.GetString("rcd-component-cannot-build-wall-tile-not-empty-message"), rcd.Owner, eventArgs.User); - return false; - } - - if (tile.IsBlockedTurf(true)) - { - _popup.PopupEntity(Loc.GetString("rcd-component-tile-obstructed-message"), rcd.Owner, eventArgs.User); - return false; - } - return true; - case RcdMode.Airlock: - if (tile.Tile.IsEmpty) - { - _popup.PopupEntity(Loc.GetString("rcd-component-cannot-build-airlock-tile-not-empty-message"), rcd.Owner, eventArgs.User); - return false; - } - if (tile.IsBlockedTurf(true)) - { - _popup.PopupEntity(Loc.GetString("rcd-component-tile-obstructed-message"), rcd.Owner, eventArgs.User); - return false; - } - return true; - default: - return false; //I don't know why this would happen, but sure I guess. Get out of here invalid state! - } - } - - private void NextMode(EntityUid uid, RCDComponent rcd, EntityUid user) - { - SoundSystem.Play(rcd.SwapModeSound.GetSound(), Filter.Pvs(uid, entityManager: EntityManager), uid); - - var mode = (int) rcd.Mode; - mode = ++mode % RCDModeCount; - rcd.Mode = (RcdMode) mode; - - var msg = Loc.GetString("rcd-component-change-mode", ("mode", rcd.Mode.ToString())); - _popup.PopupEntity(msg, rcd.Owner, user); - } - } -} diff --git a/Content.Shared/Popups/SharedPopupSystem.cs b/Content.Shared/Popups/SharedPopupSystem.cs index 8ff3e7467c..4f3619dbd3 100644 --- a/Content.Shared/Popups/SharedPopupSystem.cs +++ b/Content.Shared/Popups/SharedPopupSystem.cs @@ -82,6 +82,12 @@ namespace Content.Shared.Popups /// if the filtering has to be more specific than simply PVS range based. /// public abstract void PopupEntity(string message, EntityUid uid, Filter filter, bool recordReplay, PopupType type = PopupType.Small); + + /// + /// Variant of that only runs on the client, outside of prediction. + /// Useful for shared code that is always ran by both sides to avoid duplicate popups. + /// + public abstract void PopupClient(string message, EntityUid uid, EntityUid recipient, PopupType type = PopupType.Small); } /// diff --git a/Content.Shared/RCD/Components/RCDAmmoComponent.cs b/Content.Shared/RCD/Components/RCDAmmoComponent.cs new file mode 100644 index 0000000000..7b1fc001d4 --- /dev/null +++ b/Content.Shared/RCD/Components/RCDAmmoComponent.cs @@ -0,0 +1,18 @@ +using Content.Shared.RCD.Systems; +using Robust.Shared.GameStates; + +namespace Content.Shared.RCD.Components; + +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] +[Access(typeof(RCDAmmoSystem))] +public sealed partial class RCDAmmoComponent : Component +{ + /// + /// How many charges are contained in this ammo cartridge. + /// Can be partially transferred into an RCD, until it is empty then it gets deleted. + /// + [DataField("charges"), ViewVariables(VVAccess.ReadWrite), AutoNetworkedField] + public int Charges = 5; +} + +// TODO: state??? check if it desyncs diff --git a/Content.Shared/RCD/Components/RCDComponent.cs b/Content.Shared/RCD/Components/RCDComponent.cs new file mode 100644 index 0000000000..8e1032884a --- /dev/null +++ b/Content.Shared/RCD/Components/RCDComponent.cs @@ -0,0 +1,51 @@ +using Content.Shared.Maps; +using Content.Shared.RCD.Systems; +using Robust.Shared.Audio; +using Robust.Shared.GameStates; +using Robust.Shared.Serialization; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype; + +namespace Content.Shared.RCD.Components; + +public enum RcdMode : byte +{ + Floors, + Walls, + Airlock, + Deconstruct +} + +/// +/// Main component for the RCD +/// Optionally uses LimitedChargesComponent. +/// Charges can be refilled with RCD ammo +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] +[Access(typeof(RCDSystem))] +public sealed partial class RCDComponent : Component +{ + /// + /// Time taken to do an action like placing a wall + /// + [DataField("delay"), ViewVariables(VVAccess.ReadWrite), AutoNetworkedField] + public float Delay = 2f; + + [DataField("swapModeSound")] + public SoundSpecifier SwapModeSound = new SoundPathSpecifier("/Audio/Items/genhit.ogg"); + + [DataField("successSound")] + public SoundSpecifier SuccessSound = new SoundPathSpecifier("/Audio/Items/deconstruct.ogg"); + + /// + /// What mode are we on? Can be floors, walls, airlock, deconstruct. + /// + [DataField("mode"), AutoNetworkedField] + public RcdMode Mode = RcdMode.Floors; + + /// + /// ID of the floor to create when using the floor mode. + /// + [DataField("floor", customTypeSerializer: typeof(PrototypeIdSerializer))] + [ViewVariables(VVAccess.ReadWrite), AutoNetworkedField] + public string Floor = "FloorSteel"; +} diff --git a/Content.Shared/RCD/Systems/RCDAmmoSystem.cs b/Content.Shared/RCD/Systems/RCDAmmoSystem.cs new file mode 100644 index 0000000000..9481d299aa --- /dev/null +++ b/Content.Shared/RCD/Systems/RCDAmmoSystem.cs @@ -0,0 +1,62 @@ +using Content.Shared.Charges.Components; +using Content.Shared.Charges.Systems; +using Content.Shared.Examine; +using Content.Shared.Interaction; +using Content.Shared.Popups; +using Content.Shared.RCD.Components; +using Robust.Shared.Timing; + +namespace Content.Shared.RCD.Systems; + +public sealed class RCDAmmoSystem : EntitySystem +{ + [Dependency] private readonly SharedChargesSystem _charges = default!; + [Dependency] private readonly SharedPopupSystem _popup = default!; + [Dependency] private readonly IGameTiming _timing = default!; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnExamine); + SubscribeLocalEvent(OnAfterInteract); + } + + private void OnExamine(EntityUid uid, RCDAmmoComponent comp, ExaminedEvent args) + { + if (!args.IsInDetailsRange) + return; + + var examineMessage = Loc.GetString("rcd-ammo-component-on-examine", ("charges", comp.Charges)); + args.PushText(examineMessage); + } + + private void OnAfterInteract(EntityUid uid, RCDAmmoComponent comp, AfterInteractEvent args) + { + if (args.Handled || !args.CanReach || !_timing.IsFirstTimePredicted) + return; + + if (args.Target is not {Valid: true} target || + !HasComp(target) || + !TryComp(target, out var charges)) + return; + + var user = args.User; + args.Handled = true; + var count = Math.Min(charges.MaxCharges - charges.Charges, comp.Charges); + if (count <= 0) + { + _popup.PopupClient(Loc.GetString("rcd-ammo-component-after-interact-full"), target, user); + return; + } + + _popup.PopupClient(Loc.GetString("rcd-ammo-component-after-interact-refilled"), target, user); + _charges.AddCharges(target, count, charges); + comp.Charges -= count; + Dirty(comp); + + // prevent having useless ammo with 0 charges + if (comp.Charges <= 0) + QueueDel(uid); + } +} diff --git a/Content.Shared/RCD/Systems/RCDSystem.cs b/Content.Shared/RCD/Systems/RCDSystem.cs new file mode 100644 index 0000000000..9bab9c6a4d --- /dev/null +++ b/Content.Shared/RCD/Systems/RCDSystem.cs @@ -0,0 +1,324 @@ +using Content.Shared.Administration.Logs; +using Content.Shared.Charges.Components; +using Content.Shared.Charges.Systems; +using Content.Shared.Database; +using Content.Shared.DoAfter; +using Content.Shared.Examine; +using Content.Shared.Interaction; +using Content.Shared.Interaction.Events; +using Content.Shared.Maps; +using Content.Shared.Physics; +using Content.Shared.Popups; +using Content.Shared.RCD.Components; +using Content.Shared.Tag; +using Robust.Shared.Audio; +using Robust.Shared.Map; +using Robust.Shared.Map.Components; +using Robust.Shared.Network; +using Robust.Shared.Serialization; +using Robust.Shared.Timing; + +namespace Content.Shared.RCD.Systems; + +public sealed class RCDSystem : EntitySystem +{ + [Dependency] private readonly ISharedAdminLogManager _adminLogger = default!; + [Dependency] private readonly SharedAudioSystem _audio = default!; + [Dependency] private readonly SharedChargesSystem _charges = default!; + [Dependency] private readonly SharedDoAfterSystem _doAfter = default!; + [Dependency] private readonly SharedInteractionSystem _interaction = default!; + [Dependency] private readonly IMapManager _mapMan = default!; + [Dependency] private readonly INetManager _net = default!; + [Dependency] private readonly SharedPopupSystem _popup = default!; + [Dependency] private readonly TagSystem _tag = default!; + [Dependency] private readonly ITileDefinitionManager _tileDefMan = default!; + [Dependency] private readonly IGameTiming _timing = default!; + [Dependency] private readonly TurfSystem _turf = default!; + + private readonly int RcdModeCount = Enum.GetValues(typeof(RcdMode)).Length; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnExamine); + SubscribeLocalEvent(OnUseInHand); + SubscribeLocalEvent(OnAfterInteract); + SubscribeLocalEvent(OnDoAfter); + SubscribeLocalEvent>(OnDoAfterAttempt); + } + + private void OnExamine(EntityUid uid, RCDComponent comp, ExaminedEvent args) + { + if (!args.IsInDetailsRange) + return; + + var msg = Loc.GetString("rcd-component-examine-detail", ("mode", comp.Mode)); + args.PushMarkup(msg); + } + + private void OnUseInHand(EntityUid uid, RCDComponent comp, UseInHandEvent args) + { + if (args.Handled) + return; + + NextMode(uid, comp, args.User); + args.Handled = true; + } + + private void OnAfterInteract(EntityUid uid, RCDComponent comp, AfterInteractEvent args) + { + if (args.Handled || !args.CanReach) + return; + + var user = args.User; + + TryComp(uid, out var charges); + if (_charges.IsEmpty(uid, charges)) + { + _popup.PopupClient(Loc.GetString("rcd-component-no-ammo-message"), uid, user); + return; + } + + var location = args.ClickLocation; + // Initial validity check + if (!location.IsValid(EntityManager)) + return; + + var gridId = location.GetGridUid(EntityManager); + if (!HasComp(gridId)) + { + location = location.AlignWithClosestGridTile(); + gridId = location.GetGridUid(EntityManager); + // Check if fixing it failed / get final grid ID + if (!HasComp(gridId)) + return; + } + + var doAfterArgs = new DoAfterArgs(user, comp.Delay, new RCDDoAfterEvent(location, comp.Mode), uid, target: args.Target, used: uid) + { + BreakOnDamage = true, + NeedHand = true, + BreakOnHandChange = true, + BreakOnUserMove = true, + BreakOnTargetMove = args.Target != null, + AttemptFrequency = AttemptFrequency.EveryTick + }; + + args.Handled = true; + _doAfter.TryStartDoAfter(doAfterArgs); + } + + private void OnDoAfterAttempt(EntityUid uid, RCDComponent comp, DoAfterAttemptEvent args) + { + // sus client crash why + if (args.Event?.DoAfter?.Args == null) + return; + + var location = args.Event.Location; + + var gridId = location.GetGridUid(EntityManager); + if (!HasComp(gridId)) + { + location = location.AlignWithClosestGridTile(); + gridId = location.GetGridUid(EntityManager); + // Check if fixing it failed / get final grid ID + if (!HasComp(gridId)) + return; + } + + var mapGrid = _mapMan.GetGrid(gridId.Value); + var tile = mapGrid.GetTileRef(location); + + if (!IsRCDStillValid(uid, comp, args.Event.User, args.Event.Target, mapGrid, tile, args.Event.StartingMode)) + args.Cancel(); + } + + private void OnDoAfter(EntityUid uid, RCDComponent comp, RCDDoAfterEvent args) + { + if (args.Handled || args.Cancelled || !_timing.IsFirstTimePredicted) + return; + + var user = args.User; + var location = args.Location; + + var gridId = location.GetGridUid(EntityManager); + if (!HasComp(gridId)) + { + location = location.AlignWithClosestGridTile(); + gridId = location.GetGridUid(EntityManager); + // Check if fixing it failed / get final grid ID + if (!HasComp(gridId)) + return; + } + + var mapGrid = _mapMan.GetGrid(gridId.Value); + var tile = mapGrid.GetTileRef(location); + var snapPos = mapGrid.TileIndicesFor(location); + + switch (comp.Mode) + { + //Floor mode just needs the tile to be a space tile (subFloor) + case RcdMode.Floors: + + mapGrid.SetTile(snapPos, new Tile(_tileDefMan[comp.Floor].TileId)); + _adminLogger.Add(LogType.RCD, LogImpact.High, $"{ToPrettyString(args.User):user} used RCD to set grid: {tile.GridUid} {snapPos} to {comp.Floor}"); + break; + //We don't want to place a space tile on something that's already a space tile. Let's do the inverse of the last check. + case RcdMode.Deconstruct: + if (!IsTileBlocked(tile)) // Delete the turf + { + mapGrid.SetTile(snapPos, Tile.Empty); + _adminLogger.Add(LogType.RCD, LogImpact.High, $"{ToPrettyString(args.User):user} used RCD to set grid: {tile.GridUid} tile: {snapPos} to space"); + } + else // Delete the targeted thing + { + var target = args.Target!.Value; + _adminLogger.Add(LogType.RCD, LogImpact.High, $"{ToPrettyString(args.User):user} used RCD to delete {ToPrettyString(target):target}"); + QueueDel(target); + } + break; + //Walls are a special behaviour, and require us to build a new object with a transform rather than setting a grid tile, + // thus we early return to avoid the tile set code. + case RcdMode.Walls: + // only spawn on the server + if (_net.IsServer) + { + var ent = Spawn("WallSolid", mapGrid.GridTileToLocal(snapPos)); + Transform(ent).LocalRotation = Angle.Zero; // Walls always need to point south. + _adminLogger.Add(LogType.RCD, LogImpact.High, $"{ToPrettyString(args.User):user} used RCD to spawn {ToPrettyString(ent)} at {snapPos} on grid {tile.GridUid}"); + } + break; + case RcdMode.Airlock: + // only spawn on the server + if (_net.IsServer) + { + var airlock = Spawn("Airlock", mapGrid.GridTileToLocal(snapPos)); + Transform(airlock).LocalRotation = Transform(uid).LocalRotation; //Now apply icon smoothing. + _adminLogger.Add(LogType.RCD, LogImpact.High, $"{ToPrettyString(args.User):user} used RCD to spawn {ToPrettyString(airlock)} at {snapPos} on grid {tile.GridUid}"); + } + break; + default: + args.Handled = true; + return; //I don't know why this would happen, but sure I guess. Get out of here invalid state! + } + + _audio.PlayPredicted(comp.SuccessSound, uid, user); + _charges.UseCharge(uid); + args.Handled = true; + } + + private bool IsRCDStillValid(EntityUid uid, RCDComponent comp, EntityUid user, EntityUid? target, MapGridComponent mapGrid, TileRef tile, RcdMode startingMode) + { + //Less expensive checks first. Failing those ones, we need to check that the tile isn't obstructed. + if (comp.Mode != startingMode) + return false; + + var unobstructed = target == null + ? _interaction.InRangeUnobstructed(user, mapGrid.GridTileToWorld(tile.GridIndices), popup: true) + : _interaction.InRangeUnobstructed(user, target.Value, popup: true); + + if (!unobstructed) + return false; + + switch (comp.Mode) + { + //Floor mode just needs the tile to be a space tile (subFloor) + case RcdMode.Floors: + if (!tile.Tile.IsEmpty) + { + _popup.PopupClient(Loc.GetString("rcd-component-cannot-build-floor-tile-not-empty-message"), uid, user); + return false; + } + + return true; + //We don't want to place a space tile on something that's already a space tile. Let's do the inverse of the last check. + case RcdMode.Deconstruct: + if (tile.Tile.IsEmpty) + return false; + + //They tried to decon a turf but the turf is blocked + if (target == null && IsTileBlocked(tile)) + { + _popup.PopupClient(Loc.GetString("rcd-component-tile-obstructed-message"), uid, user); + return false; + } + //They tried to decon a non-turf but it's not in the whitelist + if (target != null && !_tag.HasTag(target.Value, "RCDDeconstructWhitelist")) + { + _popup.PopupClient(Loc.GetString("rcd-component-deconstruct-target-not-on-whitelist-message"), uid, user); + return false; + } + + return true; + //Walls are a special behaviour, and require us to build a new object with a transform rather than setting a grid tile, thus we early return to avoid the tile set code. + case RcdMode.Walls: + if (tile.Tile.IsEmpty) + { + _popup.PopupClient(Loc.GetString("rcd-component-cannot-build-wall-tile-not-empty-message"), uid, user); + return false; + } + + if (IsTileBlocked(tile)) + { + _popup.PopupClient(Loc.GetString("rcd-component-tile-obstructed-message"), uid, user); + return false; + } + return true; + case RcdMode.Airlock: + if (tile.Tile.IsEmpty) + { + _popup.PopupClient(Loc.GetString("rcd-component-cannot-build-airlock-tile-not-empty-message"), uid, user); + return false; + } + if (IsTileBlocked(tile)) + { + _popup.PopupClient(Loc.GetString("rcd-component-tile-obstructed-message"), uid, user); + return false; + } + return true; + default: + return false; //I don't know why this would happen, but sure I guess. Get out of here invalid state! + } + } + + private void NextMode(EntityUid uid, RCDComponent comp, EntityUid user) + { + _audio.PlayPredicted(comp.SwapModeSound, uid, user); + + var mode = (int) comp.Mode; + mode = ++mode % RcdModeCount; + comp.Mode = (RcdMode) mode; + Dirty(comp); + + var msg = Loc.GetString("rcd-component-change-mode", ("mode", comp.Mode.ToString())); + _popup.PopupClient(msg, uid, user); + } + + private bool IsTileBlocked(TileRef tile) + { + return _turf.IsTileBlocked(tile, CollisionGroup.MobMask); + } +} + +[Serializable, NetSerializable] +public sealed class RCDDoAfterEvent : DoAfterEvent +{ + [DataField("location", required: true)] + public readonly EntityCoordinates Location = default!; + + [DataField("startingMode", required: true)] + public readonly RcdMode StartingMode = default!; + + private RCDDoAfterEvent() + { + } + + public RCDDoAfterEvent(EntityCoordinates location, RcdMode startingMode) + { + Location = location; + StartingMode = startingMode; + } + + public override DoAfterEvent Clone() => this; +} diff --git a/Resources/Locale/en-US/rcd/components/rcd-ammo-component.ftl b/Resources/Locale/en-US/rcd/components/rcd-ammo-component.ftl index 705f8fdc5c..e65a9b3147 100644 --- a/Resources/Locale/en-US/rcd/components/rcd-ammo-component.ftl +++ b/Resources/Locale/en-US/rcd/components/rcd-ammo-component.ftl @@ -1,3 +1,3 @@ -rcd-ammo-component-on-examine-text = It holds {$ammo} charges. -rcd-ammo-component-after-interact-full-text = The RCD is full! -rcd-ammo-component-after-interact-refilled-text = You refill the RCD. \ No newline at end of file +rcd-ammo-component-on-examine = It holds {$charges} charges. +rcd-ammo-component-after-interact-full = The RCD is full! +rcd-ammo-component-after-interact-refilled = You refill the RCD. diff --git a/Resources/Locale/en-US/rcd/components/rcd-component.ftl b/Resources/Locale/en-US/rcd/components/rcd-component.ftl index a1671fa7f9..00ca3fa262 100644 --- a/Resources/Locale/en-US/rcd/components/rcd-component.ftl +++ b/Resources/Locale/en-US/rcd/components/rcd-component.ftl @@ -2,11 +2,7 @@ ### UI # Shown when an RCD is examined in details range -rcd-component-examine-detail-count = It's currently on {$mode} mode, and holds {$ammoCount -> - *[zero] no charges. - [one] one charge. - [other] {$ammoCount} charges. -} +rcd-component-examine-detail = It's currently on {$mode} mode. ### Interaction Messages @@ -18,4 +14,4 @@ rcd-component-tile-obstructed-message = That tile is obstructed! rcd-component-deconstruct-target-not-on-whitelist-message = You can't deconstruct that! rcd-component-cannot-build-floor-tile-not-empty-message = You can only build a floor on space! rcd-component-cannot-build-wall-tile-not-empty-message = You cannot build a wall on space! -rcd-component-cannot-build-airlock-tile-not-empty-message = Cannot build an airlock on space! \ No newline at end of file +rcd-component-cannot-build-airlock-tile-not-empty-message = Cannot build an airlock on space! diff --git a/Resources/Prototypes/Entities/Objects/Tools/tools.yml b/Resources/Prototypes/Entities/Objects/Tools/tools.yml index 9708b7f538..34cb1cc368 100644 --- a/Resources/Prototypes/Entities/Objects/Tools/tools.yml +++ b/Resources/Prototypes/Entities/Objects/Tools/tools.yml @@ -272,6 +272,9 @@ description: An advanced construction device which can place/remove walls, floors, and airlocks quickly. components: - type: RCD + - type: LimitedCharges + maxCharges: 5 + charges: 5 - type: UseDelay delay: 1.0 - type: Sprite @@ -296,8 +299,19 @@ parent: RCD suffix: Empty components: - - type: RCD - ammo: 0 + - type: LimitedCharges + maxCharges: 5 + charges: 0 + +- type: entity + id: RCDExperimental + parent: RCD + suffix: Admeme + name: experimental rcd + description: A bluespace-enhanced RCD that regenerates charges passively. + components: + - type: AutoRecharge + rechargeDuration: 5 - type: entity name: RCD Ammo