diff --git a/Robust.Client/Animus/Actions/AnimusActionAnimationBase.cs b/Robust.Client/Animus/Actions/AnimusActionAnimationBase.cs new file mode 100644 index 00000000000..6d73b413f56 --- /dev/null +++ b/Robust.Client/Animus/Actions/AnimusActionAnimationBase.cs @@ -0,0 +1,82 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Numerics; +using JetBrains.Annotations; +using Robust.Client.Animations; +using Robust.Client.GameObjects; +using Robust.Shared.Animations; +using Robust.Shared.GameObjects; +using Robust.Shared.Maths; +using Robust.Shared.Serialization.Manager.Attributes; + +namespace Robust.Client.Animus.Actions; + +[ImplicitDataDefinitionForInheritors] +[PublicAPI] +public abstract partial class AnimusActionAnimationBase +{ + /// + /// Simple stopping animation for cases where only rotation and offset were affected. + /// + protected static readonly Animation StopAnimation = new() + { + Length = TimeSpan.FromSeconds(0), + AnimationTracks = + { + new AnimationTrackComponentProperty() + { + ComponentType = typeof(SpriteComponent), + Property = nameof(SpriteComponent.Rotation), + InterpolationMode = AnimationInterpolationMode.Linear, + KeyFrames = + { + new AnimationTrackProperty.KeyFrame(Angle.FromDegrees(0), 0), + }, + }, + new AnimationTrackComponentProperty() + { + ComponentType = typeof(SpriteComponent), + Property = nameof(SpriteComponent.Offset), + InterpolationMode = AnimationInterpolationMode.Linear, + KeyFrames = + { + new AnimationTrackProperty.KeyFrame(new Vector2(), 0), + }, + }, + }, + }; + + public virtual void Initialize(EntityManager entityManager) + { + + } + + protected abstract Animation? GetNextAnimation(AppearanceSystem appearanceSystem, EntityUid entity, bool restarting); + + protected abstract Animation? GetStopAnimation(AppearanceSystem appearanceSystem, EntityUid entity); + + internal bool TryNextAnimation(AppearanceSystem appearanceSystem, EntityUid entity, [NotNullWhen(true)] out Animation? anim, bool restarting) + { + anim = GetNextAnimation(appearanceSystem, entity, restarting); + return anim != null; + } + + internal bool TryStopAnimation(AppearanceSystem appearanceSystem, EntityUid entity, [NotNullWhen(true)] out Animation? anim) + { + anim = GetStopAnimation(appearanceSystem, entity); + return anim != null; + } +} + +public sealed partial class AnimusActionAnimationNull : AnimusActionAnimationBase +{ + protected override Animation? GetNextAnimation(AppearanceSystem appearanceSystem, EntityUid entity, bool restarting) + { + return null; + } + + protected override Animation? GetStopAnimation(AppearanceSystem appearanceSystem, EntityUid entity) + { + return null; + } +} diff --git a/Robust.Client/Animus/Actions/AnimusActionSpriteChangeBase.cs b/Robust.Client/Animus/Actions/AnimusActionSpriteChangeBase.cs new file mode 100644 index 00000000000..36be38fa123 --- /dev/null +++ b/Robust.Client/Animus/Actions/AnimusActionSpriteChangeBase.cs @@ -0,0 +1,33 @@ +using JetBrains.Annotations; +using Robust.Client.GameObjects; +using Robust.Shared.GameObjects; +using Robust.Shared.Serialization.Manager.Attributes; + +namespace Robust.Client.Animus.Actions; + +[ImplicitDataDefinitionForInheritors] +[PublicAPI] +public abstract partial class AnimusActionSpriteChangeBase +{ + public abstract void Initialize(Entity entity, EntityManager entityManager); + public abstract void ExecuteSpriteChange(Entity entity); + public abstract void ResetSpriteChange(Entity entity); +} + +public sealed partial class AnimusActionSpriteChangeNull : AnimusActionSpriteChangeBase +{ + public override void Initialize(Entity entity, EntityManager entityManager) + { + + } + + public override void ExecuteSpriteChange(Entity entity) + { + + } + + public override void ResetSpriteChange(Entity entity) + { + + } +} diff --git a/Robust.Client/Animus/AnimusComponent.cs b/Robust.Client/Animus/AnimusComponent.cs new file mode 100644 index 00000000000..2db725d69e3 --- /dev/null +++ b/Robust.Client/Animus/AnimusComponent.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using Robust.Client.Animus.States; +using Robust.Client.Animus.Timers; +using Robust.Shared.GameObjects; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization.Manager.Attributes; + +namespace Robust.Client.Animus; + +[RegisterComponent] +public sealed partial class AnimusComponent : Component +{ + [DataField] + internal List> StateMachines; + + internal readonly List ActiveStateMachines = []; +} + +/// +/// Prototypes are global instances but conditions and other objects may require per-instance data. +/// +internal sealed class AnimusInstance +{ + internal ProtoId Prototype = default; + internal AnimusStateBase[] States = []; + internal AnimusTimerBase? Timer = null; + internal AnimusStateBase ActiveState = new AnimusStateNull(); + internal AnimusStateBase DefaultState = new AnimusStateNull(); + internal TimeSpan ActiveStateExitTime = TimeSpan.Zero; +} diff --git a/Robust.Client/Animus/AnimusPrototype.cs b/Robust.Client/Animus/AnimusPrototype.cs new file mode 100644 index 00000000000..85eb375371f --- /dev/null +++ b/Robust.Client/Animus/AnimusPrototype.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using Robust.Client.Animus.States; +using Robust.Client.Animus.Timers; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization.Manager.Attributes; +using Robust.Shared.ViewVariables; + +namespace Robust.Client.Animus; + +[Prototype] +internal sealed partial class AnimusPrototype : IPrototype +{ + [ViewVariables] + [IdDataField] + public string ID { get; private set; } = default!; + + /// + /// A collection of possible states for this animus. + /// + [DataField] + internal List States = []; + + /// + /// Optional timer for executing an update. + /// Setting this disables periodic condition checks. + /// + [DataField] + internal AnimusTimerBase? Timer; + + /// + /// The default state to enter when no other state matches their conditions. + /// + [DataField] + internal AnimusStateBase DefaultState = new AnimusStateNull(); + +} diff --git a/Robust.Client/Animus/AnimusSystem.cs b/Robust.Client/Animus/AnimusSystem.cs new file mode 100644 index 00000000000..9f6494d4632 --- /dev/null +++ b/Robust.Client/Animus/AnimusSystem.cs @@ -0,0 +1,224 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Robust.Client.Animus.States; +using Robust.Client.GameObjects; +using Robust.Client.Timing; +using Robust.Shared.GameObjects; +using Robust.Shared.IoC; +using Robust.Shared.Log; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization.Manager; + +namespace Robust.Client.Animus; + +public sealed partial class AnimusSystem : EntitySystem +{ + [Dependency] private ILogManager _logger = default!; + [Dependency] private IClientGameTiming _timing = default!; + [Dependency] private IPrototypeManager _prototypeManager = default!; + [Dependency] private ISerializationManager _serializationManager = default!; + + private ISawmill _sawmill = default!; + + private readonly Dictionary> _actingComponentProperties = new(); + + // TODO: Subscribe to OnAnimationStarted event to register running legacy animations. + private void OnAnimationCompleted(Entity entity, ref AnimationCompletedEvent args) + { + // TODO: Deregister legacy animations. + + if (!TryComp(entity, out var comp)) + return; + + AnimusInstance? animusInstance = null; + foreach (var machine in comp.ActiveStateMachines) + { + if (machine.ActiveState is AnimusStateAnimation animState && animState.RunningAnimationKey == args.Key) + { + animusInstance = machine; + } + } + + if (animusInstance == null) + return; + + if (animusInstance.ActiveState.OneShot) + { + SwitchState((entity, comp), animusInstance.DefaultState); + } + else if (EvaluateConditions((entity, comp), animusInstance.ActiveState)) + { + animusInstance.ActiveState.Update(entity, args.Finished); + } + } + + private void OnAnimationStateMachineComponentInit(Entity entity, ref ComponentInit args) + { + foreach (var animusInstance in entity.Comp.StateMachines) + { + InitializeStateMachine(entity, animusInstance); + } + } + + public override void Initialize() + { + base.Initialize(); + + _sawmill = _logger.GetSawmill("asm"); + + SubscribeLocalEvent(OnAnimationStateMachineComponentInit); + SubscribeLocalEvent(OnAnimationCompleted); + } + + public override void Update(float frameTime) + { + base.Update(frameTime); + + if (!_timing.IsFirstTimePredicted) + return; + + var query = EntityQueryEnumerator(); + while (query.MoveNext(out var entity, out var comp)) + { + foreach (var animusInstance in comp.ActiveStateMachines) + { + UpdateStateMachine((entity, comp), animusInstance); + } + } + } + + internal void RegisterEntityAnimationProperty(EntityUid uid, Type type, string prop) + { + if (!_actingComponentProperties.ContainsKey(uid)) + _actingComponentProperties.Add(uid, []); + if (_actingComponentProperties[uid] + .Any(x => x.Item1 == type && x.Item2 == prop)) + { + _sawmill.Error($"An animation using the {prop} property on {type.Name} has already been registered for entity {uid}"); + return; + } + _actingComponentProperties[uid].Add((type, prop)); + } + + internal void DeregisterEntityAnimationProperty(EntityUid uid, Type type, string prop) + { + if(!_actingComponentProperties.ContainsKey(uid)) + _actingComponentProperties.Add(uid, []); + var tuple = _actingComponentProperties[uid].Single(x => x.Item1 == type && x.Item2 == prop); + _actingComponentProperties[uid].Remove(tuple); + } + + /// + /// Initialize state machine from prototype and add to list of active instances. + /// + private void InitializeStateMachine(Entity entity, ProtoId protoId) + { + List states = []; + + var proto = _prototypeManager.Index(protoId); + + var animusInstance = new AnimusInstance() + { + Prototype = proto, + Timer = _serializationManager.CreateCopy(proto.Timer, null, false), + DefaultState = _serializationManager.CreateCopy(proto.DefaultState, null, false, false), + }; + + animusInstance.ActiveState.Initialize(entity, EntityManager, animusInstance); + animusInstance.DefaultState.Initialize(entity, EntityManager, animusInstance); + + foreach (var state in proto.States) + { + // TODO: Add more info to error message. + if (state.Conditions.Length == 0) + { + _sawmill.Error("Every AnimusState must have at least one condition. (Except DefaultState)"); + continue; + } + + states.Add(CopyInitializeState(entity, state, animusInstance)); + } + + animusInstance.States = states.ToArray(); + animusInstance.DefaultState = CopyInitializeState(entity, proto.DefaultState, animusInstance); + + entity.Comp.ActiveStateMachines.Add(animusInstance); + } + + private AnimusStateBase CopyInitializeState(Entity entity, AnimusStateBase state, AnimusInstance animusInstance) + { + var stateCopy = _serializationManager.CreateCopy(state, null, false, false); + stateCopy.Initialize(entity.Owner, EntityManager, animusInstance); + + foreach (var cond in stateCopy.Conditions) + { + cond.Initialize(EntityManager); + } + + return stateCopy; + } + + private void UpdateStateMachine(Entity entity, AnimusInstance animusInstance) + { + ExitActiveStateIfExitTimerReached(entity, animusInstance); + CheckStateMachineConditionsAndUpdateState(entity, animusInstance); + } + + private void ExitActiveStateIfExitTimerReached(Entity entity, AnimusInstance animusInstance) + { + if (animusInstance.ActiveStateExitTime == TimeSpan.Zero) + return; + + if (animusInstance.ActiveStateExitTime > _timing.CurTime) + return; + + SwitchState(entity, animusInstance.DefaultState); + animusInstance.ActiveStateExitTime = TimeSpan.Zero; + } + + private void CheckStateMachineConditionsAndUpdateState(Entity entity, AnimusInstance animusInstance) + { + var currentState = animusInstance.ActiveState; + var nextState = animusInstance.DefaultState; + + // Return if currentState has conditions that are still fulfilled. + if (currentState is { Conditions.Length: > 0, OneShot: false } && + EvaluateConditions(entity, currentState) && + animusInstance.ActiveStateExitTime == TimeSpan.Zero) + return; + + foreach (var state in animusInstance.States) + { + if (!EvaluateConditions(entity, state)) + continue; + + nextState = state; + break; + } + + SwitchState(entity, nextState); + if (nextState.ExitPeriod != TimeSpan.Zero) + animusInstance.ActiveStateExitTime = _timing.CurTime + nextState.ExitPeriod; + } + + private bool EvaluateConditions(Entity entity, AnimusStateBase state) + { + foreach (var cond in state.Conditions) + { + if (!cond.EvaluateInternal(entity, _timing.CurTime, _timing.IsFirstTimePredicted)) + return false; + } + return true; + } + + private static void SwitchState(Entity entity, AnimusStateBase newState) + { + if (newState.Instance.ActiveState == newState) + return; + + newState.Instance.ActiveState.Exit(entity.Owner); + newState.Enter(entity.Owner); + newState.Instance.ActiveState = newState; + } +} diff --git a/Robust.Client/Animus/Conditions/AnimusConditionBase.cs b/Robust.Client/Animus/Conditions/AnimusConditionBase.cs new file mode 100644 index 00000000000..e373d9a0a28 --- /dev/null +++ b/Robust.Client/Animus/Conditions/AnimusConditionBase.cs @@ -0,0 +1,34 @@ +using System; +using JetBrains.Annotations; +using Robust.Shared.GameObjects; +using Robust.Shared.Serialization.Manager.Attributes; + +namespace Robust.Client.Animus.Conditions; + +[ImplicitDataDefinitionForInheritors] +[PublicAPI] +public abstract partial class AnimusConditionBase +{ + private bool _lastResult; + private TimeSpan _lastUpdate; + protected virtual TimeSpan UpdateInterval { get; } = TimeSpan.FromSeconds(0.1); + + /// + /// IoCManager.InjectDependencies doesn't work, override this method to initialize dependencies manually. + /// + /// + public virtual void Initialize(EntityManager entityManager) + { + + } + + protected abstract bool Evaluate(EntityUid ent); + + internal bool EvaluateInternal(Entity ent, TimeSpan currentTime, bool isFirstTimePredicted) + { + if (!isFirstTimePredicted || currentTime - _lastUpdate < UpdateInterval) + return _lastResult; + _lastResult = Evaluate(ent); + return _lastResult; + } +} diff --git a/Robust.Client/Animus/States/AnimusStateAnimation.cs b/Robust.Client/Animus/States/AnimusStateAnimation.cs new file mode 100644 index 00000000000..4bc3425ab37 --- /dev/null +++ b/Robust.Client/Animus/States/AnimusStateAnimation.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using Robust.Client.Animations; +using Robust.Client.Animus.Actions; +using Robust.Client.GameObjects; +using Robust.Shared.GameObjects; +using Robust.Shared.IoC; +using Robust.Shared.Log; +using Robust.Shared.Serialization.Manager.Attributes; + +namespace Robust.Client.Animus.States; + +internal sealed partial class AnimusStateAnimation : AnimusStateBase +{ + private static readonly AnimusActionAnimationBase NullAction = new AnimusActionAnimationNull(); + + private AnimationPlayerSystem _animationPlayerSystem; + private AnimusSystem _animationStateMachineSystem; + private AppearanceSystem _appearanceSystem; + + private readonly List<(Type, string)> _animationCompProps = []; + + [DataField] + internal AnimusActionAnimationBase Action = NullAction; + + internal string RunningAnimationKey => Action.GetType().Name + "_RUNNING"; + internal string StopAnimationKey => Action.GetType().Name + "_STOP"; + + /// + /// IoCManager.InjectDependencies won't work, use this override to inject your dependencies manually. + /// + internal override void Initialize(EntityUid ent, EntityManager entityManager, AnimusInstance animusInstance) + { + base.Initialize(ent, entityManager, animusInstance); + _animationPlayerSystem = entityManager.System(); + _animationStateMachineSystem = entityManager.System(); + _appearanceSystem = entityManager.System(); + Action.Initialize(entityManager); + } + + internal override void Enter(EntityUid ent) + { + if (_animationPlayerSystem.HasRunningAnimation(ent, RunningAnimationKey) || + !Action.TryNextAnimation(_appearanceSystem, ent, out var animation, false)) + return; + + UpdateAnimationCompProps(ent, animation); + _animationPlayerSystem.Play(ent, animation, RunningAnimationKey); + } + + internal override void Update(EntityUid ent, bool finished) + { + if (_animationPlayerSystem.HasRunningAnimation(ent, RunningAnimationKey) || + !Action.TryNextAnimation(_appearanceSystem, ent, out var animation, finished)) + return; + + UpdateAnimationCompProps(ent, animation); + _animationPlayerSystem.Play(ent, animation, RunningAnimationKey); + } + + internal override void Exit(EntityUid ent) + { + // Stop the running animation of this state. + if (_animationPlayerSystem.HasRunningAnimation(ent, RunningAnimationKey)) + _animationPlayerSystem.Stop(ent, RunningAnimationKey); + + // TODO: The stopping animation can still clash with registered component properties but to fix this + // a state queue would be required first. + // For now, we'll test and see if any glitches would even occur due to this. + ClearAnimationCompProps(ent); + + // Fetch stopping animation if it isn't running yet. + if (_animationPlayerSystem.HasRunningAnimation(ent, StopAnimationKey) || + !Action.TryStopAnimation(_appearanceSystem, ent, out var animation)) + return; + + // Play the stopping animation. + _animationPlayerSystem.Play(ent, animation, StopAnimationKey); + } + + private void ClearAnimationCompProps(EntityUid ent) + { + foreach (var compProp in _animationCompProps) + { + _animationStateMachineSystem.DeregisterEntityAnimationProperty(ent, + compProp.Item1, + compProp.Item2); + } + _animationCompProps.Clear(); + } + + private void UpdateAnimationCompProps(EntityUid ent, Animation anim) + { + ClearAnimationCompProps(ent); + foreach (var track in anim.AnimationTracks) + { + if (track is not AnimationTrackComponentProperty { ComponentType: not null, Property: not null } propTrack) + continue; + + _animationCompProps.Add((propTrack.ComponentType, propTrack.Property)); + _animationStateMachineSystem.RegisterEntityAnimationProperty(ent, + propTrack.ComponentType, + propTrack.Property); + } + } +} diff --git a/Robust.Client/Animus/States/AnimusStateBase.cs b/Robust.Client/Animus/States/AnimusStateBase.cs new file mode 100644 index 00000000000..6427876b1d2 --- /dev/null +++ b/Robust.Client/Animus/States/AnimusStateBase.cs @@ -0,0 +1,56 @@ +using System; +using Robust.Client.Animus.Conditions; +using Robust.Shared.GameObjects; +using Robust.Shared.Serialization.Manager.Attributes; + +namespace Robust.Client.Animus.States; + +[ImplicitDataDefinitionForInheritors] +internal abstract partial class AnimusStateBase +{ + /// + /// A collection of conditions that must be true for this state to activate. + /// + [DataField] + internal AnimusConditionBase[] Conditions = []; + + /// + /// If set to true, the state only executes its action one time instead of restarting it once the animation ends. + /// + [DataField] + internal bool OneShot; + + /// + /// If set to non-zero, state exits automatically after the defined timespan. + /// + [DataField] + internal TimeSpan ExitPeriod = TimeSpan.Zero; + + internal AnimusInstance Instance; + + [MustCallBase] + internal virtual void Initialize(EntityUid ent, EntityManager entityManager, AnimusInstance animusInstance) + { + Instance = animusInstance; + } + + internal virtual void Enter(EntityUid ent) + { + + } + + internal virtual void Update(EntityUid ent, bool finished) + { + + } + + internal virtual void Exit(EntityUid ent) + { + + } +} + +internal sealed partial class AnimusStateNull : AnimusStateBase +{ + +} diff --git a/Robust.Client/Animus/States/AnimusStateSpriteChange.cs b/Robust.Client/Animus/States/AnimusStateSpriteChange.cs new file mode 100644 index 00000000000..0b9258608ef --- /dev/null +++ b/Robust.Client/Animus/States/AnimusStateSpriteChange.cs @@ -0,0 +1,40 @@ +using JetBrains.Annotations; +using Robust.Client.Animus.Actions; +using Robust.Client.GameObjects; +using Robust.Shared.GameObjects; +using Robust.Shared.Serialization.Manager.Attributes; + +namespace Robust.Client.Animus.States; + +[PublicAPI] +internal sealed partial class AnimusStateSpriteChange : AnimusStateBase +{ + [DataField] + internal AnimusActionSpriteChangeBase Action = NullAction; + + private static readonly AnimusActionSpriteChangeBase NullAction = new AnimusActionSpriteChangeNull(); + private EntityManager _entityManager; + + internal override void Initialize(EntityUid ent, EntityManager entityManager, AnimusInstance animusInstance) + { + base.Initialize(ent, entityManager, animusInstance); + _entityManager = entityManager; + if (!entityManager.TryGetComponent(ent, out var spriteComponent)) + return; + Action.Initialize((ent, spriteComponent), entityManager); + } + + internal override void Enter(EntityUid ent) + { + if (!_entityManager.TryGetComponent(ent, out var spriteComponent)) + return; + Action.ExecuteSpriteChange((ent, spriteComponent)); + } + + internal override void Exit(EntityUid ent) + { + if (!_entityManager.TryGetComponent(ent, out var spriteComponent)) + return; + Action.ResetSpriteChange((ent, spriteComponent)); + } +} diff --git a/Robust.Client/Animus/Timers/AnimusTimerBase.cs b/Robust.Client/Animus/Timers/AnimusTimerBase.cs new file mode 100644 index 00000000000..4d91a38582e --- /dev/null +++ b/Robust.Client/Animus/Timers/AnimusTimerBase.cs @@ -0,0 +1,13 @@ +using System; +using JetBrains.Annotations; +using Robust.Shared.Random; +using Robust.Shared.Serialization.Manager.Attributes; + +namespace Robust.Client.Animus.Timers; + +[ImplicitDataDefinitionForInheritors] +[PublicAPI] +public abstract partial class AnimusTimerBase +{ + public abstract TimeSpan GetNextPeriod(IRobustRandom random); +} diff --git a/Robust.Server/GameObjects/ServerComponentFactory.cs b/Robust.Server/GameObjects/ServerComponentFactory.cs index 9e718fbb079..0257df45f50 100644 --- a/Robust.Server/GameObjects/ServerComponentFactory.cs +++ b/Robust.Server/GameObjects/ServerComponentFactory.cs @@ -14,5 +14,6 @@ public ServerComponentFactory(IDynamicTypeFactoryInternal typeFactory, IReflecti RegisterIgnore("AnimationPlayer"); RegisterIgnore("GenericVisualizer"); RegisterIgnore("Sprite"); // Fucking finally + RegisterIgnore("Animus"); } } diff --git a/Robust.Server/Prototypes/ServerPrototypeManager.cs b/Robust.Server/Prototypes/ServerPrototypeManager.cs index 02e44b91122..a0a3201b570 100644 --- a/Robust.Server/Prototypes/ServerPrototypeManager.cs +++ b/Robust.Server/Prototypes/ServerPrototypeManager.cs @@ -25,6 +25,7 @@ public ServerPrototypeManager() RegisterIgnore("shader"); RegisterIgnore("uiTheme"); RegisterIgnore("font"); + RegisterIgnore("animus"); } public override void Initialize()