diff --git a/Content.Client/Entry/EntryPoint.cs b/Content.Client/Entry/EntryPoint.cs index 3a8a22e11f..89c6fabb9d 100644 --- a/Content.Client/Entry/EntryPoint.cs +++ b/Content.Client/Entry/EntryPoint.cs @@ -125,6 +125,7 @@ public override void Init() _prototypeManager.RegisterIgnore("nukeopsRole"); _prototypeManager.RegisterIgnore("stationGoal"); // Corvax-StationGoal _prototypeManager.RegisterIgnore("ghostRoleRaffleDecider"); + _prototypeManager.RegisterIgnore("spawnGroupProto"); // Exodus-Lavaland _componentFactory.GenerateNetIds(); _adminManager.Initialize(); diff --git a/Content.Server/Explosion/EntitySystems/TriggerSystem.cs b/Content.Server/Explosion/EntitySystems/TriggerSystem.cs index 92e065bf4c..3a9dca5808 100644 --- a/Content.Server/Explosion/EntitySystems/TriggerSystem.cs +++ b/Content.Server/Explosion/EntitySystems/TriggerSystem.cs @@ -113,6 +113,10 @@ private void OnSoundTrigger(EntityUid uid, SoundOnTriggerComponent component, Tr { var xform = Transform(uid); _audio.PlayPvs(component.Sound, xform.Coordinates); // play the sound at its last known coordinates + + // Exodus-Lavaland-start + RemCompDeferred(uid); + // Exodus-Lavaland-end } else // if the component doesn't get removed when triggered { diff --git a/Content.Server/NPC/Components/NPCAbilityCombatComponent.cs b/Content.Server/NPC/Components/NPCAbilityCombatComponent.cs new file mode 100644 index 0000000000..c6ba8d281e --- /dev/null +++ b/Content.Server/NPC/Components/NPCAbilityCombatComponent.cs @@ -0,0 +1,56 @@ +// Exodus-Lavaland +namespace Content.Server.NPC.Components; + +/// +/// Added to NPCs whenever they're in ability combat so they can be handled by the dedicated system. +/// +[RegisterComponent] +public sealed partial class NPCAbilityCombatComponent : Component +{ + [ViewVariables] + public EntityUid Target; + + [ViewVariables] + public AbilityCombatStatus Status = AbilityCombatStatus.Normal; + + [ViewVariables] + public TimeSpan NextAction = new(); + + [ViewVariables] + public int ActionsPerUpd = 1; + + [ViewVariables] + public int UsedActionsLastUpd = 0; + + [ViewVariables] + public float ActionsTimeReload = 1.0f; + +} + +public enum AbilityCombatStatus : byte +{ + /// + /// The target isn't in LOS anymore. + /// + NotInSight, + + /// + /// Due to some generic reason we are unable to attack the target. + /// + Unspecified, + + /// + /// Set if we can't reach the target for whatever reason. + /// + TargetUnreachable, + + /// + /// If the target is outside of our melee range. + /// + TargetOutOfRange, + + /// + /// No dramas. + /// + Normal, +} diff --git a/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Combat/JukeOperator.cs b/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Combat/JukeOperator.cs index 02a3b08510..214aeaa3ba 100644 --- a/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Combat/JukeOperator.cs +++ b/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Combat/JukeOperator.cs @@ -9,6 +9,11 @@ public sealed partial class JukeOperator : HTNOperator, IHtnConditionalShutdown [DataField("jukeType")] public JukeType JukeType = JukeType.AdjacentTile; + // Exodus-Lavaland-AdvancedAI-Start + [DataField("jukeDuration")] + public float JukeDuration = 0.5f; + // Exodus-Lavaland-AdvancedAI-End + [DataField("shutdownState")] public HTNPlanState ShutdownState { get; private set; } = HTNPlanState.PlanFinished; @@ -17,6 +22,7 @@ public override void Startup(NPCBlackboard blackboard) base.Startup(blackboard); var juke = _entManager.EnsureComponent(blackboard.GetValue(NPCBlackboard.Owner)); juke.JukeType = JukeType; + juke.JukeDuration = JukeDuration; // Exodus-Lavaland-AdvancedAI } public override HTNOperatorStatus Update(NPCBlackboard blackboard, float frameTime) diff --git a/Content.Server/NPC/Systems/NPCCombatSystem.cs b/Content.Server/NPC/Systems/NPCCombatSystem.cs index 7b012300da..3df9e0cf00 100644 --- a/Content.Server/NPC/Systems/NPCCombatSystem.cs +++ b/Content.Server/NPC/Systems/NPCCombatSystem.cs @@ -42,5 +42,6 @@ public override void Update(float frameTime) base.Update(frameTime); UpdateMelee(frameTime); UpdateRanged(frameTime); + UpdateAbility(frameTime); // Exodus-Lavaland } } diff --git a/Content.Server/NPC/Systems/NPCCombatSystrem.Ability.cs b/Content.Server/NPC/Systems/NPCCombatSystrem.Ability.cs new file mode 100644 index 0000000000..f1d12353ac --- /dev/null +++ b/Content.Server/NPC/Systems/NPCCombatSystrem.Ability.cs @@ -0,0 +1,239 @@ +// Exodus-AdvancedAI +using System.Numerics; +using Content.Server.NPC.Components; +using Content.Shared.NPC; +using Robust.Shared.Map; +using Robust.Shared.Physics.Components; +using Robust.Shared.Random; +using Content.Shared.ActionBlocker; +using Content.Shared.Actions.Events; +using Content.Shared.Administration.Logs; +using Content.Shared.Database; + +using Content.Shared.Interaction; +using Content.Shared.Actions; +using Content.Server.Actions; +using Content.Shared.Directions; + +namespace Content.Server.NPC.Systems; + +public sealed partial class NPCCombatSystem +{ + [Dependency] private readonly ActionsSystem _actions = default!; + [Dependency] private readonly ActionBlockerSystem _actionBlockerSystem = default!; + [Dependency] private readonly ISharedAdminLogManager _adminLogger = default!; + [Dependency] private readonly RotateToFaceSystem _rotateToFaceSystem = default!; + + private const float TargetAbilityLostRange = 28f; + + private void InitializeAbility() + { + SubscribeLocalEvent(OnAbilityShutdown); + } + + private void OnAbilityShutdown(EntityUid uid, NPCAbilityCombatComponent component, ComponentShutdown args) + { + _steering.Unregister(uid); + } + + private void UpdateAbility(float frameTime) + { + var xformQuery = GetEntityQuery(); + var physicsQuery = GetEntityQuery(); + var curTime = _timing.CurTime; + var query = EntityQueryEnumerator(); + + while (query.MoveNext(out var uid, out var comp, out _)) + { + CastAction(uid, comp, curTime, physicsQuery, xformQuery); + } + } + + private void CastAction(EntityUid uid, NPCAbilityCombatComponent combatComp, TimeSpan curTime, EntityQuery physicsQuery, EntityQuery xformQuery) + { + combatComp.Status = AbilityCombatStatus.Normal; + + if (!xformQuery.TryGetComponent(uid, out var xform) || + !xformQuery.TryGetComponent(combatComp.Target, out var targetXform)) + { + combatComp.Status = AbilityCombatStatus.TargetUnreachable; + return; + } + + if (!xform.Coordinates.TryDistance(EntityManager, targetXform.Coordinates, out var distance)) + { + combatComp.Status = AbilityCombatStatus.TargetUnreachable; + return; + } + + if (distance > TargetMeleeLostRange) + { + combatComp.Status = AbilityCombatStatus.TargetUnreachable; + return; + } + + if (TryComp(uid, out var steering) && + steering.Status == SteeringStatus.NoPath) + { + combatComp.Status = AbilityCombatStatus.TargetUnreachable; + return; + } + + _steering.Register(uid, new EntityCoordinates(combatComp.Target, Vector2.Zero), steering); + + if (combatComp.NextAction > curTime) + return; + + // Get Actions + if (!TryComp(uid, out ActionsComponent? actionComp)) + return; + + List actions = []; + actions.AddRange(actionComp.Actions); + + while (actions.Count > 0) + { + if (combatComp.UsedActionsLastUpd >= combatComp.ActionsPerUpd) + break; + + var act = _random.PickAndTake(actions); + + var attemptEv = new ActionAttemptEvent(uid); + RaiseLocalEvent(act, ref attemptEv); + if (attemptEv.Cancelled) + return; + + if (TryUseAction(uid, act, distance, combatComp, curTime)) + combatComp.UsedActionsLastUpd++; + } + + if (combatComp.UsedActionsLastUpd >= combatComp.ActionsPerUpd) + { + combatComp.UsedActionsLastUpd = 0; + combatComp.NextAction = curTime + TimeSpan.FromSeconds(combatComp.ActionsTimeReload); + } + } + + private bool TryUseAction(EntityUid uid, + EntityUid actionUid, + float distance, + NPCAbilityCombatComponent combatComp, + TimeSpan curTime) + { + if (!TryComp(uid, out ActionsComponent? actionComp)) + return false; + + if (!TryComp(actionUid, out MetaDataComponent? actionMeta)) + return false; + + if (!_actions.TryGetActionData(actionUid, out var action)) + return false; + + if (!action.Enabled) + return false; + + // check for action use prevention + var attemptEv = new ActionAttemptEvent(uid); + RaiseLocalEvent(actionUid, ref attemptEv); + if (attemptEv.Cancelled) + return false; + + if (action.Cooldown.HasValue && action.Cooldown.Value.End > curTime) + return false; + + if (action is { Charges: < 1, RenewCharges: true }) + _actions.ResetCharges(actionUid); + + BaseActionEvent? performEvent = null; + + if (action.CheckConsciousness && !_actionBlockerSystem.CanConsciouslyPerformAction(uid)) + return false; + + if (!combatComp.Target.IsValid()) + { + Log.Error($"Attempted to perform an entity-targeted action without a target! Action: {actionMeta.EntityName}"); + return false; + } + + if (action.MinAIUseRange >= distance || + action.MaxAIUseRange <= distance) + { + Log.Error("Out"); + return false; + } + + // Validate request by checking action blockers and the like: + switch (action) + { + case EntityTargetActionComponent entityAction: + var targetWorldPos = _transform.GetWorldPosition(combatComp.Target); + + if (entityAction.Range <= distance) + return false; + + _rotateToFaceSystem.TryFaceCoordinates(uid, targetWorldPos); + + if (!_actions.ValidateEntityTarget(uid, combatComp.Target, (actionUid, entityAction))) + return false; + + _adminLogger.Add(LogType.Action, + $"{ToPrettyString(uid):user} is performing the {actionMeta.EntityName:action} action (provided by {ToPrettyString(action.Container ?? uid):provider}) targeted at {ToPrettyString(combatComp.Target):target}."); + + if (entityAction.Event != null) + { + entityAction.Event.Target = combatComp.Target; + Dirty(actionUid, entityAction); + performEvent = entityAction.Event; + } + break; + case WorldTargetActionComponent worldAction: + var entityCoordinatesTarget = Transform(combatComp.Target).Coordinates; + + if (worldAction.Range <= distance) + { + var mapTargetPos = entityCoordinatesTarget.ToMapPos(EntityManager, _transform); + var mapUserPos = Transform(uid).Coordinates.ToMapPos(EntityManager, _transform); + + var direction = mapTargetPos - mapUserPos; + var coefficient = worldAction.Range / distance; + var delta = new Vector2(direction.X * coefficient, direction.Y * coefficient); + entityCoordinatesTarget = new EntityCoordinates(entityCoordinatesTarget.EntityId, mapUserPos + delta); + } + + _rotateToFaceSystem.TryFaceCoordinates(uid, entityCoordinatesTarget.ToMapPos(EntityManager, _transform)); + + if (!_actions.ValidateWorldTarget(uid, entityCoordinatesTarget, (actionUid, worldAction))) + return false; + + _adminLogger.Add(LogType.Action, + $"{ToPrettyString(uid):user} is performing the {actionMeta.EntityName:action} action (provided by {ToPrettyString(action.Container ?? uid):provider}) targeted at {entityCoordinatesTarget:target}."); + + if (worldAction.Event != null) + { + worldAction.Event.Target = entityCoordinatesTarget; + Dirty(actionUid, worldAction); + performEvent = worldAction.Event; + } + + break; + case InstantActionComponent instantAction: + if (action.CheckCanInteract && !_actionBlockerSystem.CanInteract(uid, null)) + return false; + + _adminLogger.Add(LogType.Action, + $"{ToPrettyString(uid):user} is performing the {actionMeta.EntityName:action} action provided by {ToPrettyString(action.Container ?? uid):provider}."); + + performEvent = instantAction.Event; + break; + } + + if (performEvent != null) + performEvent.Performer = uid; + + // All checks passed. Perform the action! + _actions.PerformAction(uid, actionComp, actionUid, action, performEvent, curTime); + + return true; + } + +} diff --git a/Content.Shared/Actions/BaseActionComponent.cs b/Content.Shared/Actions/BaseActionComponent.cs index 9156f747f5..768524d7c2 100644 --- a/Content.Shared/Actions/BaseActionComponent.cs +++ b/Content.Shared/Actions/BaseActionComponent.cs @@ -171,6 +171,11 @@ public EntityUid? EntityIcon /// If not null, this sound will be played when performing this action. /// [DataField("sound")] public SoundSpecifier? Sound; + + // Exodus-Lavaland-Start + [DataField("maxUseRange")] public float MaxAIUseRange = float.PositiveInfinity; + [DataField("minUseRange")] public float MinAIUseRange = 0; + // Exodus-Lavaland-End } [Serializable, NetSerializable] diff --git a/Content.Shared/Magic/Events/ProjectileSpellEvent.cs b/Content.Shared/Magic/Events/ProjectileSpellEvent.cs index 336ea03346..9407713d01 100644 --- a/Content.Shared/Magic/Events/ProjectileSpellEvent.cs +++ b/Content.Shared/Magic/Events/ProjectileSpellEvent.cs @@ -12,5 +12,5 @@ public sealed partial class ProjectileSpellEvent : WorldTargetActionEvent, ISpea public EntProtoId Prototype; [DataField] - public string? Speech { get; private set; } + public string? Speech { get; set; } // Exodus-Lavaland } diff --git a/Resources/Audio/Exodus/Lavaland/Bosses/Hierophant/attributions.yml b/Resources/Audio/Exodus/Lavaland/Bosses/Hierophant/attributions.yml new file mode 100644 index 0000000000..2ce474fe7b --- /dev/null +++ b/Resources/Audio/Exodus/Lavaland/Bosses/Hierophant/attributions.yml @@ -0,0 +1,4 @@ +- files: ["spawn.ogg", "bum.ogg"] + license: "CC-BY-SA-3.0" + copyright: "Taken from Zvukipro.com" + source: "https://zvukipro.com/electronic/368-zvuki-lazera.html" diff --git a/Resources/Audio/Exodus/Lavaland/Bosses/Hierophant/bum.ogg b/Resources/Audio/Exodus/Lavaland/Bosses/Hierophant/bum.ogg new file mode 100644 index 0000000000..e53e0475e0 Binary files /dev/null and b/Resources/Audio/Exodus/Lavaland/Bosses/Hierophant/bum.ogg differ diff --git a/Resources/Audio/Exodus/Lavaland/Bosses/Hierophant/spawn.ogg b/Resources/Audio/Exodus/Lavaland/Bosses/Hierophant/spawn.ogg new file mode 100644 index 0000000000..b58ce014ef Binary files /dev/null and b/Resources/Audio/Exodus/Lavaland/Bosses/Hierophant/spawn.ogg differ