diff --git a/Content.Client/Actions/ActionsSystem.cs b/Content.Client/Actions/ActionsSystem.cs index de67ffa87c..f76205063b 100644 --- a/Content.Client/Actions/ActionsSystem.cs +++ b/Content.Client/Actions/ActionsSystem.cs @@ -149,7 +149,7 @@ namespace Content.Client.Actions /// /// Execute convenience functionality for actions (pop-ups, sound, speech) /// - protected override bool PerformBasicActions(EntityUid user, ActionType action) + protected override bool PerformBasicActions(EntityUid user, ActionType action, bool predicted) { var performedAction = action.Sound != null || !string.IsNullOrWhiteSpace(action.UserPopup) @@ -233,7 +233,7 @@ namespace Content.Client.Actions if (instantAction.Event != null) instantAction.Event.Performer = user; - PerformAction(PlayerActions, instantAction, instantAction.Event, GameTiming.CurTime); + PerformAction(user, PlayerActions, instantAction, instantAction.Event, GameTiming.CurTime); } else { diff --git a/Content.Client/UserInterface/Systems/Actions/ActionUIController.cs b/Content.Client/UserInterface/Systems/Actions/ActionUIController.cs index ac6e0510d2..ad8172695f 100644 --- a/Content.Client/UserInterface/Systems/Actions/ActionUIController.cs +++ b/Content.Client/UserInterface/Systems/Actions/ActionUIController.cs @@ -1,4 +1,4 @@ -using System.Linq; +using System.Linq; using System.Runtime.InteropServices; using Content.Client.Actions; using Content.Client.Construction; @@ -224,7 +224,7 @@ public sealed class ActionUIController : UIController, IOnStateChanged +/// This component enables an entity to perform actions when used to interact with the world, without actually +/// granting that action to the entity that is using the item. +/// +/// +/// If the entity is used in hand (), it will perform a random available instant +/// action. If the entity is used to interact with another entity (), it will +/// attempt to perform a random entity target action. Finally, if the entity is used to click somewhere in the world +/// and no other interaction takes place (), then it will try to perform a random +/// available entity or world target action. This component does not bypass standard interaction checks. +/// +/// This component mainly exists as a lazy way to add utility entities that can do things like cast "spells". +/// +[RegisterComponent] +public sealed class ActionOnInteractComponent : Component +{ + [DataField("activateActions")] + public List? ActivateActions; + + [DataField("entityActions")] + public List? EntityActions; + + [DataField("worldActions")] + public List? WorldActions; +} diff --git a/Content.Server/Actions/ActionOnInteractSystem.cs b/Content.Server/Actions/ActionOnInteractSystem.cs new file mode 100644 index 0000000000..baa01e9123 --- /dev/null +++ b/Content.Server/Actions/ActionOnInteractSystem.cs @@ -0,0 +1,131 @@ +using Content.Shared.Actions; +using Content.Shared.Actions.ActionTypes; +using Content.Shared.Interaction; +using Robust.Shared.Random; +using Robust.Shared.Timing; + +namespace Content.Server.Actions; + +/// +/// This System handled interactions for the . +/// +public sealed class ActionOnInteractSystem : EntitySystem +{ + [Dependency] private readonly IRobustRandom _random = default!; + [Dependency] private readonly IGameTiming _timing = default!; + [Dependency] private readonly SharedActionsSystem _actions = default!; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnActivate); + SubscribeLocalEvent(OnAfterInteract); + } + + private void OnActivate(EntityUid uid, ActionOnInteractComponent component, ActivateInWorldEvent args) + { + if (args.Handled || component.ActivateActions == null) + return; + + var options = new List(); + foreach (var action in component.ActivateActions) + { + if (ValidAction(action)) + options.Add(action); + } + + if (options.Count == 0) + return; + + var act = _random.Pick(options); + if (act.Event != null) + act.Event.Performer = args.User; + + act.Provider = uid; + _actions.PerformAction(args.User, null, act, act.Event, _timing.CurTime, false); + args.Handled = true; + } + + private void OnAfterInteract(EntityUid uid, ActionOnInteractComponent component, AfterInteractEvent args) + { + if (args.Handled) + return; + + // First, try entity target actions + if (args.Target != null && component.EntityActions != null) + { + var entOptions = new List(); + foreach (var action in component.EntityActions) + { + if (!ValidAction(action, args.CanReach)) + continue; + + if (!_actions.ValidateEntityTarget(args.User, args.Target.Value, action)) + continue; + + entOptions.Add(action); + } + + if (entOptions.Count > 0) + { + var entAct = _random.Pick(entOptions); + if (entAct.Event != null) + { + entAct.Event.Performer = args.User; + entAct.Event.Target = args.Target.Value; + } + + entAct.Provider = uid; + _actions.PerformAction(args.User, null, entAct, entAct.Event, _timing.CurTime, false); + args.Handled = true; + return; + } + } + + // else: try world target actions + if (component.WorldActions == null) + return; + + var options = new List(); + foreach (var action in component.WorldActions) + { + if (!ValidAction(action, args.CanReach)) + continue; + + if (!_actions.ValidateWorldTarget(args.User, args.ClickLocation, action)) + continue; + + options.Add(action); + } + + if (options.Count == 0) + return; + + var act = _random.Pick(options); + if (act.Event != null) + { + act.Event.Performer = args.User; + act.Event.Target = args.ClickLocation; + } + + act.Provider = uid; + _actions.PerformAction(args.User, null, act, act.Event, _timing.CurTime, false); + args.Handled = true; + } + + private bool ValidAction(ActionType act, bool canReach = true) + { + if (!act.Enabled) + return false; + + if (act.Charges.HasValue && act.Charges <= 0) + return false; + + var curTime = _timing.CurTime; + if (act.Cooldown.HasValue && act.Cooldown.Value.End > curTime) + return false; + + return canReach || act is TargetedAction { CheckCanAccess: false }; + } +} diff --git a/Content.Server/Actions/ActionsSystem.cs b/Content.Server/Actions/ActionsSystem.cs index 8d042be79a..3ccf02c206 100644 --- a/Content.Server/Actions/ActionsSystem.cs +++ b/Content.Server/Actions/ActionsSystem.cs @@ -18,9 +18,9 @@ namespace Content.Server.Actions base.Initialize(); } - protected override bool PerformBasicActions(EntityUid user, ActionType action) + protected override bool PerformBasicActions(EntityUid user, ActionType action, bool predicted) { - var result = base.PerformBasicActions(user, action); + var result = base.PerformBasicActions(user, action, predicted); if (!string.IsNullOrWhiteSpace(action.Speech)) { diff --git a/Content.Server/Magic/Events/ChangeComponentsSpellEvent.cs b/Content.Server/Magic/Events/ChangeComponentsSpellEvent.cs new file mode 100644 index 0000000000..c9961605b9 --- /dev/null +++ b/Content.Server/Magic/Events/ChangeComponentsSpellEvent.cs @@ -0,0 +1,21 @@ +using Content.Shared.Actions; +using Robust.Shared.Prototypes; + +namespace Content.Server.Magic.Events; + +/// +/// Spell that uses the magic of ECS to add & remove components. Components are first removed, then added. +/// +public sealed class ChangeComponentsSpellEvent : EntityTargetActionEvent +{ + // TODO allow it to set component data-fields? + // for now a Hackish way to do that is to remove & add, but that doesn't allow you to selectively set specific data fields. + + [DataField("toAdd")] + [AlwaysPushInheritance] + public EntityPrototype.ComponentRegistry ToAdd = new(); + + [DataField("toRemove")] + [AlwaysPushInheritance] + public HashSet ToRemove = new(); +} diff --git a/Content.Server/Magic/MagicSystem.cs b/Content.Server/Magic/MagicSystem.cs index 76e5336673..9628c2b6f6 100644 --- a/Content.Server/Magic/MagicSystem.cs +++ b/Content.Server/Magic/MagicSystem.cs @@ -18,11 +18,13 @@ using Content.Shared.Spawners.Components; using Content.Shared.Storage; using Robust.Server.GameObjects; using Robust.Shared.Audio; +using Robust.Shared.GameObjects; using Robust.Shared.Map; using Robust.Shared.Physics.Components; using Robust.Shared.Player; using Robust.Shared.Prototypes; using Robust.Shared.Random; +using Robust.Shared.Serialization.Manager; namespace Content.Server.Magic; @@ -31,6 +33,8 @@ namespace Content.Server.Magic; /// public sealed class MagicSystem : EntitySystem { + [Dependency] private readonly ISerializationManager _seriMan = default!; + [Dependency] private readonly IComponentFactory _compFact = default!; [Dependency] private readonly IMapManager _mapManager = default!; [Dependency] private readonly IPrototypeManager _prototypeManager = default!; [Dependency] private readonly IRobustRandom _random = default!; @@ -58,6 +62,7 @@ public sealed class MagicSystem : EntitySystem SubscribeLocalEvent(OnSmiteSpell); SubscribeLocalEvent(OnWorldSpawn); SubscribeLocalEvent(OnProjectileSpell); + SubscribeLocalEvent(OnChangeComponentsSpell); } private void OnInit(EntityUid uid, SpellbookComponent component, ComponentInit args) @@ -173,6 +178,27 @@ public sealed class MagicSystem : EntitySystem } } + private void OnChangeComponentsSpell(ChangeComponentsSpellEvent ev) + { + foreach (var toRemove in ev.ToRemove) + { + if (_compFact.TryGetRegistration(toRemove, out var registration)) + RemComp(ev.Target, registration.Type); + } + + foreach (var (name, data) in ev.ToAdd) + { + if (HasComp(ev.Target, data.Component.GetType())) + continue; + + var component = (Component) _compFact.GetComponent(name); + component.Owner = ev.Target; + var temp = (object) component; + _seriMan.CopyTo(data.Component, ref temp); + EntityManager.AddComponent(ev.Target, (Component) temp!); + } + } + private List GetSpawnPositions(TransformComponent casterXform, MagicSpawnData data) { switch (data) diff --git a/Content.Shared/Actions/ActionTypes/ActionType.cs b/Content.Shared/Actions/ActionTypes/ActionType.cs index f8f334d400..5681be17aa 100644 --- a/Content.Shared/Actions/ActionTypes/ActionType.cs +++ b/Content.Shared/Actions/ActionTypes/ActionType.cs @@ -34,7 +34,7 @@ public abstract class ActionType : IEquatable, IComparable, ICloneab /// /// Name to show in UI. /// - [DataField("name", required: true)] + [DataField("name")] public string DisplayName = string.Empty; /// diff --git a/Content.Shared/Actions/SharedActionsSystem.cs b/Content.Shared/Actions/SharedActionsSystem.cs index 0d0dbffda4..6bc8ef513a 100644 --- a/Content.Shared/Actions/SharedActionsSystem.cs +++ b/Content.Shared/Actions/SharedActionsSystem.cs @@ -202,7 +202,7 @@ public abstract class SharedActionsSystem : EntitySystem performEvent.Performer = user; // All checks passed. Perform the action! - PerformAction(component, act, performEvent, curTime); + PerformAction(user, component, act, performEvent, curTime); } public bool ValidateEntityTarget(EntityUid user, EntityUid target, EntityTargetAction action) @@ -265,7 +265,7 @@ public abstract class SharedActionsSystem : EntitySystem return _interactionSystem.InRangeUnobstructed(user, coords, range: action.Range); } - public void PerformAction(ActionsComponent component, ActionType action, BaseActionEvent? actionEvent, TimeSpan curTime) + public void PerformAction(EntityUid performer, ActionsComponent? component, ActionType action, BaseActionEvent? actionEvent, TimeSpan curTime, bool predicted = true) { var handled = false; @@ -277,7 +277,7 @@ public abstract class SharedActionsSystem : EntitySystem actionEvent.Handled = false; if (action.Provider == null) - RaiseLocalEvent(component.Owner, (object) actionEvent, broadcast: true); + RaiseLocalEvent(performer, (object) actionEvent, broadcast: true); else RaiseLocalEvent(action.Provider.Value, (object) actionEvent, broadcast: true); @@ -285,7 +285,7 @@ public abstract class SharedActionsSystem : EntitySystem } // Execute convenience functionality (pop-ups, sound, speech) - handled |= PerformBasicActions(component.Owner, action); + handled |= PerformBasicActions(performer, action, predicted); if (!handled) return; // no interaction occurred. @@ -309,19 +309,19 @@ public abstract class SharedActionsSystem : EntitySystem action.Cooldown = (curTime, curTime + action.UseDelay.Value); } - if (dirty) + if (dirty && component != null) Dirty(component); } /// /// Execute convenience functionality for actions (pop-ups, sound, speech) /// - protected virtual bool PerformBasicActions(EntityUid performer, ActionType action) + protected virtual bool PerformBasicActions(EntityUid performer, ActionType action, bool predicted) { if (action.Sound == null && string.IsNullOrWhiteSpace(action.Popup)) return false; - var filter = Filter.PvsExcept(performer); + var filter = predicted ? Filter.PvsExcept(performer) : Filter.Pvs(performer); _audio.Play(action.Sound, filter, performer, true, action.AudioParams); diff --git a/Resources/Prototypes/Entities/Objects/Decoration/present.yml b/Resources/Prototypes/Entities/Objects/Decoration/present.yml index fb186f7b07..0740bb0a4d 100644 --- a/Resources/Prototypes/Entities/Objects/Decoration/present.yml +++ b/Resources/Prototypes/Entities/Objects/Decoration/present.yml @@ -352,6 +352,8 @@ - id: WeaponPulseCarbine prob: .001 orGroup: GiftPool + - id: RGBStaff + orGroup: GiftPool sound: path: /Audio/Effects/unwrap.ogg diff --git a/Resources/Prototypes/Magic/staves.yml b/Resources/Prototypes/Magic/staves.yml new file mode 100644 index 0000000000..833c1215f0 --- /dev/null +++ b/Resources/Prototypes/Magic/staves.yml @@ -0,0 +1,37 @@ +# non-projectile / "gun" staves + +# wand that gives lights an RGB effect. +- type: entity + id: RGBStaff + parent: BaseItem + name: RGB Staff + description: Helps fix the underabundance of RGB gear on the station. + components: + - type: Sprite + sprite: Objects/Weapons/Guns/Basic/staves.rsi + layers: + - state: nothing + - state: nothing-unshaded + shader: unshaded + - type: ActionOnInteract + entityActions: + - whitelist: { components: [ PointLight ] } + charges: 25 + sound: /Audio/Magic/blink.ogg + event: !type:ChangeComponentsSpellEvent + toAdd: + - type: RgbLightController + - type: Item + inhandVisuals: + left: + - state: staff-inhand-left + - state: staff-inhand-left-unshaded + shader: unshaded + right: + - state: staff-inhand-right + - state: staff-inhand-right-unshaded + shader: unshaded + - type: RgbLightController + - type: PointLight + enabled: true + radius: 2 \ No newline at end of file diff --git a/Resources/Textures/Objects/Weapons/Guns/Basic/staves.rsi/meta.json b/Resources/Textures/Objects/Weapons/Guns/Basic/staves.rsi/meta.json index 336829591b..a8510801f8 100644 --- a/Resources/Textures/Objects/Weapons/Guns/Basic/staves.rsi/meta.json +++ b/Resources/Textures/Objects/Weapons/Guns/Basic/staves.rsi/meta.json @@ -1,7 +1,7 @@ { "version": 1, "license": "CC-BY-SA-3.0", - "copyright": "/tg/station at https://github.com/tgstation/tgstation/commit/270acce4f551253d8ac75de19236b8b4be598f7f. edited by mirrorcult to allow layering", + "copyright": "/tg/station at https://github.com/tgstation/tgstation/commit/270acce4f551253d8ac75de19236b8b4be598f7f. edited by mirrorcult to allow layering. Generic unshaded layers added by electro.", "size": { "x": 32, "y": 32 @@ -517,6 +517,17 @@ 0.5 ] ] + }, + { + "name": "staff-inhand-right-unshaded", + "directions": 4 + }, + { + "name": "staff-inhand-left-unshaded", + "directions": 4 + }, + { + "name": "nothing-unshaded" } ] } \ No newline at end of file diff --git a/Resources/Textures/Objects/Weapons/Guns/Basic/staves.rsi/nothing-unshaded.png b/Resources/Textures/Objects/Weapons/Guns/Basic/staves.rsi/nothing-unshaded.png new file mode 100644 index 0000000000..f1bacf6d57 Binary files /dev/null and b/Resources/Textures/Objects/Weapons/Guns/Basic/staves.rsi/nothing-unshaded.png differ diff --git a/Resources/Textures/Objects/Weapons/Guns/Basic/staves.rsi/staff-inhand-left-unshaded.png b/Resources/Textures/Objects/Weapons/Guns/Basic/staves.rsi/staff-inhand-left-unshaded.png new file mode 100644 index 0000000000..2c4b01902e Binary files /dev/null and b/Resources/Textures/Objects/Weapons/Guns/Basic/staves.rsi/staff-inhand-left-unshaded.png differ diff --git a/Resources/Textures/Objects/Weapons/Guns/Basic/staves.rsi/staff-inhand-right-unshaded.png b/Resources/Textures/Objects/Weapons/Guns/Basic/staves.rsi/staff-inhand-right-unshaded.png new file mode 100644 index 0000000000..71b57625db Binary files /dev/null and b/Resources/Textures/Objects/Weapons/Guns/Basic/staves.rsi/staff-inhand-right-unshaded.png differ