diff --git a/Content.Server/DeltaV/StationEvents/Components/GlimmerMobRuleComponent.cs b/Content.Server/DeltaV/StationEvents/Components/GlimmerMobRuleComponent.cs deleted file mode 100644 index bde0422a1ae..00000000000 --- a/Content.Server/DeltaV/StationEvents/Components/GlimmerMobRuleComponent.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Content.Server.StationEvents.Events; -using Robust.Shared.Prototypes; - -namespace Content.Server.StationEvents.Components; - -[RegisterComponent, Access(typeof(GlimmerMobRule))] -public sealed partial class GlimmerMobRuleComponent : Component -{ - [DataField(required: true)] - public EntProtoId MobPrototype = string.Empty; -} diff --git a/Content.Server/DeltaV/StationEvents/Events/GlimmerMobSpawnRule.cs b/Content.Server/DeltaV/StationEvents/Events/GlimmerMobSpawnRule.cs deleted file mode 100644 index c9bf659fd94..00000000000 --- a/Content.Server/DeltaV/StationEvents/Events/GlimmerMobSpawnRule.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System.Linq; -using Content.Server.GameTicking.Components; -using Robust.Shared.Random; -using Content.Server.GameTicking; -using Content.Server.NPC.Components; -using Content.Server.Psionics.Glimmer; -using Content.Server.StationEvents.Components; -using Content.Shared.Psionics.Glimmer; -using Content.Shared.Abilities.Psionics; - -namespace Content.Server.StationEvents.Events; - -public sealed class GlimmerMobRule : StationEventSystem -{ - [Dependency] private readonly IRobustRandom _robustRandom = default!; - [Dependency] private readonly GlimmerSystem _glimmerSystem = default!; - [Dependency] private readonly GameTicker _gameTicker = default!; - - - protected override void Started(EntityUid uid, GlimmerMobRuleComponent component, GameRuleComponent gameRule, GameRuleStartedEvent args) - { - base.Started(uid, component, gameRule, args); - - var station = _gameTicker.GetSpawnableStations(); - if (station is null) - return; - - var glimmerSources = new List<(GlimmerSourceComponent, TransformComponent)>(); - foreach (var source in EntityQuery().ToList()) - { - if (!station.Contains(source.Item2.Owner)) - continue; - - glimmerSources.Add(source); - } - - - var normalSpawnLocations = new List<(VentCritterSpawnLocationComponent, TransformComponent)>(); - foreach (var source in EntityQuery().ToList()) - { - if (!station.Contains(source.Item2.Owner)) - continue; - - normalSpawnLocations.Add(source); - } - - var hiddenSpawnLocations = new List<(MidRoundAntagSpawnLocationComponent, TransformComponent)>(); - foreach (var source in EntityQuery().ToList()) - { - if (!station.Contains(source.Item2.Owner)) - continue; - - hiddenSpawnLocations.Add(source); - } - - var baseCount = Math.Max(1, EntityQuery().Count() / 10); - int multiplier = Math.Max(1, (int) _glimmerSystem.GetGlimmerTier() - 2); - - var total = baseCount * multiplier; - - int i = 0; - while (i < total) - { - if (glimmerSources.Count != 0 && _robustRandom.Prob(0.4f)) - { - Spawn(component.MobPrototype, _robustRandom.Pick(glimmerSources).Item2.Coordinates); - i++; - continue; - } - - if (normalSpawnLocations.Count != 0) - { - Spawn(component.MobPrototype, _robustRandom.Pick(normalSpawnLocations).Item2.Coordinates); - i++; - continue; - } - - if (hiddenSpawnLocations.Count != 0) - { - Spawn(component.MobPrototype, _robustRandom.Pick(hiddenSpawnLocations).Item2.Coordinates); - i++; - continue; - } - - return; - } - } -} diff --git a/Content.Server/LifeDrainer/LifeDrainerComponent.cs b/Content.Server/LifeDrainer/LifeDrainerComponent.cs new file mode 100644 index 00000000000..c5d09266c77 --- /dev/null +++ b/Content.Server/LifeDrainer/LifeDrainerComponent.cs @@ -0,0 +1,60 @@ +using Content.Shared.Damage; +using Content.Shared.DoAfter; +using Content.Shared.Whitelist; +using Robust.Shared.Audio; + +namespace Content.Server.LifeDrainer; + +/// +/// Adds a verb to drain life from a crit mob that matches a whitelist. +/// Successfully draining a mob rejuvenates you completely. +/// +[RegisterComponent, Access(typeof(LifeDrainerSystem))] +public sealed partial class LifeDrainerComponent : Component +{ + /// + /// Damage to give to the target when draining is complete + /// + [DataField(required: true)] + public DamageSpecifier Damage = new(); + + /// + /// Mobs have to match this whitelist to be drained. + /// + [DataField] + public EntityWhitelist? Whitelist; + + /// + /// The time that it takes to drain an entity. + /// + [DataField] + public TimeSpan Delay = TimeSpan.FromSeconds(8.35f); + + /// + /// Sound played while draining a mob. + /// + [DataField] + public SoundSpecifier DrainSound = new SoundPathSpecifier("/Audio/DeltaV/Effects/clang2.ogg"); + + /// + /// Sound played after draining is complete. + /// + [DataField] + public SoundSpecifier FinishSound = new SoundPathSpecifier("/Audio/Effects/guardian_inject.ogg"); + + [DataField] + public EntityUid? DrainStream; + + /// + /// A current drain doafter in progress. + /// + [DataField] + public DoAfterId? DoAfter; + + /// + /// What mob is being targeted for draining. + /// When draining stops the AI will try to drain this target again until successful. + /// + [DataField] + public EntityUid? Target; +} diff --git a/Content.Server/LifeDrainer/LifeDrainerSystem.cs b/Content.Server/LifeDrainer/LifeDrainerSystem.cs new file mode 100644 index 00000000000..900438ff710 --- /dev/null +++ b/Content.Server/LifeDrainer/LifeDrainerSystem.cs @@ -0,0 +1,143 @@ +using Content.Server.Carrying; +using Content.Server.NPC.Systems; +using Content.Shared.ActionBlocker; +using Content.Shared.Damage; +using Content.Shared.DoAfter; +using Content.Shared.Interaction; +using Content.Shared.Mobs.Systems; +using Content.Shared.Movement.Pulling.Components; +using Content.Shared.Popups; +using Content.Shared.Rejuvenate; +using Content.Shared.Verbs; +using Content.Shared.Whitelist; +using Robust.Shared.Audio.Systems; +using Robust.Shared.Player; +using Robust.Shared.Utility; + +namespace Content.Server.LifeDrainer; + +public sealed class LifeDrainerSystem : EntitySystem +{ + [Dependency] private readonly ActionBlockerSystem _actionBlocker = default!; + [Dependency] private readonly DamageableSystem _damageable = default!; + [Dependency] private readonly EntityWhitelistSystem _whitelist = default!; + [Dependency] private readonly MobStateSystem _mob = default!; + [Dependency] private readonly NpcFactionSystem _faction = default!; + [Dependency] private readonly SharedAudioSystem _audio = default!; + [Dependency] private readonly SharedDoAfterSystem _doAfter = default!; + [Dependency] private readonly SharedInteractionSystem _interaction = default!; + [Dependency] private readonly SharedPopupSystem _popup = default!; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent>(OnGetVerbs); + SubscribeLocalEvent(OnDrain); + } + + private void OnGetVerbs(Entity ent, ref GetVerbsEvent args) + { + var target = args.Target; + if (!args.CanAccess || !args.CanInteract || !CanDrain(ent, target)) + return; + + args.Verbs.Add(new InnateVerb() + { + Act = () => + { + TryDrain(ent, target); + }, + Text = Loc.GetString("verb-life-drain"), + Icon = new SpriteSpecifier.Texture(new ("/Textures/Nyanotrasen/Icons/verbiconfangs.png")), + Priority = 2 + }); + } + + private void OnDrain(Entity ent, ref LifeDrainDoAfterEvent args) + { + var (uid, comp) = ent; + CancelDrain(comp); + if (args.Handled || args.Args.Target is not {} target) + return; + + // attack whoever interrupted the draining + if (args.Cancelled) + { + // someone pulled the psionic away + if (TryComp(target, out var pullable) && pullable.Puller is {} puller) + _faction.AggroEntity(uid, puller); + + // someone pulled me away + if (TryComp(ent, out pullable) && pullable.Puller is {} selfPuller) + _faction.AggroEntity(uid, selfPuller); + + // someone carried the psionic away + if (TryComp(target, out var carried)) + _faction.AggroEntity(uid, carried.Carrier); + + return; + } + + _popup.PopupEntity(Loc.GetString("life-drain-second-end", ("drainer", uid)), target, target, PopupType.LargeCaution); + _popup.PopupEntity(Loc.GetString("life-drain-third-end", ("drainer", uid), ("target", target)), target, Filter.PvsExcept(target), true, PopupType.LargeCaution); + + var rejuv = new RejuvenateEvent(); + RaiseLocalEvent(uid, rejuv); + + _audio.PlayPvs(comp.FinishSound, uid); + + _damageable.TryChangeDamage(target, comp.Damage, true, origin: uid); + } + + public bool CanDrain(Entity ent, EntityUid target) + { + var (uid, comp) = ent; + return !IsDraining(comp) + && uid != target + && (comp.Whitelist is null || _whitelist.IsValid(comp.Whitelist, target)) + && _mob.IsCritical(target); + } + + public bool IsDraining(LifeDrainerComponent comp) + { + return _doAfter.GetStatus(comp.DoAfter) == DoAfterStatus.Running; + } + + public bool TryDrain(Entity ent, EntityUid target) + { + var (uid, comp) = ent; + if (!CanDrain(ent, target) || !_actionBlocker.CanInteract(uid, target) || !_interaction.InRangeUnobstructed(ent, target, popup: true)) + return false; + + _popup.PopupEntity(Loc.GetString("life-drain-second-start", ("drainer", uid)), target, target, PopupType.LargeCaution); + _popup.PopupEntity(Loc.GetString("life-drain-third-start", ("drainer", uid), ("target", target)), target, Filter.PvsExcept(target), true, PopupType.LargeCaution); + + if (_audio.PlayPvs(comp.DrainSound, target) is {} stream) + comp.DrainStream = stream.Item1; + + var ev = new LifeDrainDoAfterEvent(); + var args = new DoAfterArgs(EntityManager, uid, comp.Delay, ev, target: target, eventTarget: uid) + { + BreakOnTargetMove = true, + BreakOnUserMove = true, + MovementThreshold = 2f, + NeedHand = false + }; + + if (!_doAfter.TryStartDoAfter(args, out var id)) + return false; + + comp.DoAfter = id; + comp.Target = target; + return true; + } + + public void CancelDrain(LifeDrainerComponent comp) + { + comp.DrainStream = _audio.Stop(comp.DrainStream); + _doAfter.Cancel(comp.DoAfter); + comp.DoAfter = null; + comp.Target = null; + } +} diff --git a/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Interactions/DrainOperator.cs b/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Interactions/DrainOperator.cs new file mode 100644 index 00000000000..ecb6e16c9d8 --- /dev/null +++ b/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Interactions/DrainOperator.cs @@ -0,0 +1,50 @@ +using Content.Server.LifeDrainer; + +namespace Content.Server.NPC.HTN.PrimitiveTasks.Operators.Interactions; + +public sealed partial class DrainOperator : HTNOperator +{ + [Dependency] private readonly IEntityManager _entMan = default!; + + private LifeDrainerSystem _drainer = default!; + private EntityQuery _drainerQuery; + + [DataField(required: true)] + public string DrainKey = string.Empty; + + public override void Initialize(IEntitySystemManager sysManager) + { + base.Initialize(sysManager); + + _drainer = sysManager.GetEntitySystem(); + _drainerQuery = _entMan.GetEntityQuery(); + } + + public override HTNOperatorStatus Update(NPCBlackboard blackboard, float frameTime) + { + var owner = blackboard.GetValue(NPCBlackboard.Owner); + var target = blackboard.GetValue(DrainKey); + + if (_entMan.Deleted(target)) + return HTNOperatorStatus.Failed; + + if (!_drainerQuery.TryComp(owner, out var wisp)) + return HTNOperatorStatus.Failed; + + // still draining hold your horses + if (_drainer.IsDraining(wisp)) + return HTNOperatorStatus.Continuing; + + // not draining and no target set, start to drain + if (wisp.Target == null) + { + return _drainer.TryDrain((owner, wisp), target) + ? HTNOperatorStatus.Continuing + : HTNOperatorStatus.Failed; + } + + // stopped draining, clean up and find another one after + _drainer.CancelDrain(wisp); + return HTNOperatorStatus.Finished; + } +} diff --git a/Content.Server/NPC/HTN/PrimitiveTasks/Operators/PickDrainTargetOperator.cs b/Content.Server/NPC/HTN/PrimitiveTasks/Operators/PickDrainTargetOperator.cs new file mode 100644 index 00000000000..52c12777aa5 --- /dev/null +++ b/Content.Server/NPC/HTN/PrimitiveTasks/Operators/PickDrainTargetOperator.cs @@ -0,0 +1,74 @@ +using System.Threading; +using System.Threading.Tasks; +using Content.Server.LifeDrainer; +using Content.Server.NPC.Pathfinding; +using Content.Server.NPC.Systems; + +namespace Content.Server.NPC.HTN.PrimitiveTasks.Operators; + +public sealed partial class PickDrainTargetOperator : HTNOperator +{ + [Dependency] private readonly IEntityManager _entMan = default!; + + private LifeDrainerSystem _drainer = default!; + private NpcFactionSystem _faction = default!; + private PathfindingSystem _pathfinding = default!; + + private EntityQuery _drainerQuery; + private EntityQuery _xformQuery; + + [DataField(required: true)] public string + RangeKey = string.Empty, + TargetKey = string.Empty, + DrainKey = string.Empty; + + /// + /// Where the pathfinding result will be stored (if applicable). This gets removed after execution. + /// + [DataField] + public string PathfindKey = NPCBlackboard.PathfindKey; + + public override void Initialize(IEntitySystemManager sysMan) + { + base.Initialize(sysMan); + + _drainer = sysMan.GetEntitySystem(); + _faction = sysMan.GetEntitySystem(); + _pathfinding = sysMan.GetEntitySystem(); + + _drainerQuery = _entMan.GetEntityQuery(); + _xformQuery = _entMan.GetEntityQuery(); + } + + public override async Task<(bool Valid, Dictionary? Effects)> Plan(NPCBlackboard blackboard, CancellationToken cancelToken) { + var owner = blackboard.GetValue(NPCBlackboard.Owner); + if (!_drainerQuery.TryComp(owner, out var drainer)) + return (false, null); + + var ent = (owner, drainer); + if (!blackboard.TryGetValue(RangeKey, out var range, _entMan)) + return (false, null); + + // find crit psionics nearby + foreach (var target in _faction.GetNearbyHostiles(owner, range)) + { + if (!_drainer.CanDrain(ent, target) || !_xformQuery.TryComp(target, out var xform)) + continue; + + // pathfind to the first crit psionic in range to start draining + var targetCoords = xform.Coordinates; + var path = await _pathfinding.GetPath(owner, target, range, cancelToken); + if (path.Result != PathResult.Path) + continue; + + return (true, new Dictionary() + { + { TargetKey, targetCoords }, + { DrainKey, target }, + { PathfindKey, path } + }); + } + + return (false, null); + } +} diff --git a/Content.Server/Nyanotrasen/Psionics/NPC/PsionicNpcCombatSystem.cs b/Content.Server/Nyanotrasen/Psionics/NPC/PsionicNpcCombatSystem.cs new file mode 100644 index 00000000000..9caef36a752 --- /dev/null +++ b/Content.Server/Nyanotrasen/Psionics/NPC/PsionicNpcCombatSystem.cs @@ -0,0 +1,51 @@ +using Content.Shared.Abilities.Psionics; +using Content.Shared.Actions; +using Content.Server.NPC.Events; +using Content.Server.NPC.Components; +using Content.Server.Abilities.Psionics; +using Content.Shared.Psionics; +using Robust.Shared.Prototypes; +using Robust.Shared.Timing; +using Robust.Shared.Utility; + +namespace Content.Server.Psionics.NPC; + +// TODO this is nyanotrasen shitcode. It works, but it needs to be refactored to be more generic. +public sealed class PsionicNpcCombatSystem : EntitySystem +{ + [Dependency] private readonly IGameTiming _timing = default!; + [Dependency] private readonly IPrototypeManager _protoMan = default!; + [Dependency] private readonly SharedActionsSystem _actions = default!; + + private static readonly ProtoId NoosphericZapProto = "NoosphericZapPower"; + private PsionicPowerPrototype NoosphericZap = default!; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(ZapCombat); + + NoosphericZap = _protoMan.Index(NoosphericZapProto); + DebugTools.Assert(NoosphericZap.Actions.Count == 1, "I can't account for this, so it's your problem now"); + } + + private void ZapCombat(Entity ent, ref NPCSteeringEvent args) + { + PsionicComponent? psionics = null; + if (!Resolve(ent, ref psionics, logMissing: true) + || !psionics.Actions.TryGetValue(NoosphericZap.Actions[0], out var action) + || action is null) + return; + + var actionTarget = Comp(action.Value); + if (actionTarget.Cooldown is {} cooldown && cooldown.End > _timing.CurTime + || !TryComp(ent, out var combat) + || !_actions.ValidateEntityTarget(ent, combat.Target, (action.Value, actionTarget)) + || actionTarget.Event is not {} ev) + return; + + ev.Target = combat.Target; + _actions.PerformAction(ent, null, action.Value, actionTarget, ev, _timing.CurTime, predicted: false); + } +} diff --git a/Content.Server/StationEvents/Components/GlimmerMobRuleComponent.cs b/Content.Server/StationEvents/Components/GlimmerMobRuleComponent.cs new file mode 100644 index 00000000000..982cb35ae8e --- /dev/null +++ b/Content.Server/StationEvents/Components/GlimmerMobRuleComponent.cs @@ -0,0 +1,51 @@ +using Content.Server.StationEvents.Events; +using Content.Shared.Psionics.Glimmer; +using Robust.Shared.Prototypes; + +namespace Content.Server.StationEvents.Components; + +/// +/// Tries to spawn a random number of mobs scaling with psionic people. +/// Rolls glimmer sources then vents then midround spawns in that order. +/// +[RegisterComponent, Access(typeof(GlimmerMobRule))] +public sealed partial class GlimmerMobRuleComponent : Component +{ + /// + /// The mob to spawn. + /// + [DataField(required: true)] + public EntProtoId MobPrototype = string.Empty; + + /// + /// Every this number of psionics spawns 1 mob. + /// + [DataField] + public int MobsPerPsionic = 10; + + /// + /// If the current glimmer tier is above this, mob count gets multiplied by the difference. + /// So by default 500-900 glimmer will double it and 900+ will triple it. + /// + [DataField] + public GlimmerTier GlimmerTier = GlimmerTier.Moderate; + + /// + /// Probability of rolling a glimmer source location. + /// + [DataField] + public float GlimmerProb = 0.4f; + + /// + /// Probability of rolling a vent location. + /// + [DataField] + public float NormalProb = 1f; + + /// + /// Probability of rolling a midround antag location. + /// Should always be 1 to guarantee the right number of spawned mobs. + /// + [DataField] + public float HiddenProb = 1f; +} diff --git a/Content.Server/StationEvents/Components/GlimmerWispRuleComponent.cs b/Content.Server/StationEvents/Components/GlimmerWispRuleComponent.cs deleted file mode 100644 index 60477e6a6a7..00000000000 --- a/Content.Server/StationEvents/Components/GlimmerWispRuleComponent.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Content.Server.StationEvents.Events; - -namespace Content.Server.StationEvents.Components; - -[RegisterComponent, Access(typeof(GlimmerWispRule))] -public sealed partial class GlimmerWispRuleComponent : Component -{ -} diff --git a/Content.Server/StationEvents/Events/GlimmerMobSpawnRule.cs b/Content.Server/StationEvents/Events/GlimmerMobSpawnRule.cs new file mode 100644 index 00000000000..702147842c6 --- /dev/null +++ b/Content.Server/StationEvents/Events/GlimmerMobSpawnRule.cs @@ -0,0 +1,77 @@ +using System.Linq; +using Content.Server.GameTicking.Components; +using Robust.Shared.Random; +using Content.Server.GameTicking; +using Content.Server.NPC.Components; +using Content.Server.Psionics.Glimmer; +using Content.Server.Station.Systems; +using Content.Server.StationEvents.Components; +using Content.Shared.Psionics.Glimmer; +using Content.Shared.Abilities.Psionics; +using Robust.Shared.Map; + +namespace Content.Server.StationEvents.Events; + +public sealed class GlimmerMobRule : StationEventSystem +{ + [Dependency] private readonly GameTicker _gameTicker = default!; + [Dependency] private readonly GlimmerSystem _glimmer = default!; + [Dependency] private readonly StationSystem _stations = default!; + + protected override void Started(EntityUid uid, GlimmerMobRuleComponent comp, GameRuleComponent gameRule, GameRuleStartedEvent args) + { + base.Started(uid, comp, gameRule, args); + + var stations = _gameTicker.GetSpawnableStations(); + if (stations.Count <= 0) + return; + + List + glimmerSources = GetCoords(stations), + normalSpawns = GetCoords(stations), + hiddenSpawns = GetCoords(stations); + + var psionics = EntityQuery().Count(); + var baseCount = Math.Max(1, psionics / comp.MobsPerPsionic); + var multiplier = Math.Max(1, (int) _glimmer.GetGlimmerTier() - (int) comp.GlimmerTier); + var total = baseCount * multiplier; + + Log.Info($"Spawning {total} of {comp.MobPrototype} from {ToPrettyString(uid):rule}"); + + for (var i = 0; i < total; i++) + { + // if we cant get a spawn just give up + if (!TrySpawn(comp, glimmerSources, comp.GlimmerProb) && + !TrySpawn(comp, normalSpawns, comp.NormalProb) && + !TrySpawn(comp, hiddenSpawns, comp.HiddenProb)) + return; + } + } + + private List GetCoords(List allowedStations) where T : IComponent + { + var coords = new List(); + var query = EntityQueryEnumerator(); + + while (query.MoveNext(out var uid, out _, out var xform)) + { + var station = _stations.GetOwningStation(uid, xform); + if (station is null || !allowedStations.Contains(station.Value)) + continue; + + coords.Add(xform.Coordinates); + } + + return coords; + } + + private bool TrySpawn(GlimmerMobRuleComponent comp, List spawns, float prob) + { + if (spawns.Count == 0 || !RobustRandom.Prob(prob)) + return false; + + var coords = RobustRandom.Pick(spawns); + Spawn(comp.MobPrototype, coords); + return true; + } +} diff --git a/Content.Server/StationEvents/Events/GlimmerWispSpawnRule.cs b/Content.Server/StationEvents/Events/GlimmerWispSpawnRule.cs deleted file mode 100644 index c2cb4eca6d4..00000000000 --- a/Content.Server/StationEvents/Events/GlimmerWispSpawnRule.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Linq; -using Content.Server.GameTicking.Components; -using Robust.Shared.Random; -using Content.Server.GameTicking.Rules.Components; -using Content.Server.NPC.Components; -using Content.Server.Psionics.Glimmer; -using Content.Server.StationEvents.Components; -using Content.Shared.Psionics.Glimmer; -using Content.Shared.Abilities.Psionics; - -namespace Content.Server.StationEvents.Events; - -internal sealed class GlimmerWispRule : StationEventSystem -{ - [Dependency] private readonly IRobustRandom _robustRandom = default!; - [Dependency] private readonly GlimmerSystem _glimmerSystem = default!; - - private static readonly string WispPrototype = "MobGlimmerWisp"; - - protected override void Started(EntityUid uid, GlimmerWispRuleComponent component, GameRuleComponent gameRule, GameRuleStartedEvent args) - { - base.Started(uid, component, gameRule, args); - - var glimmerSources = EntityManager.EntityQuery().ToList(); - var normalSpawnLocations = EntityManager.EntityQuery().ToList(); - var hiddenSpawnLocations = EntityManager.EntityQuery().ToList(); - - var baseCount = Math.Max(1, EntityManager.EntityQuery().Count() / 10); - int multiplier = Math.Max(1, (int) _glimmerSystem.GetGlimmerTier() - 2); - - var total = baseCount * multiplier; - - int i = 0; - while (i < total) - { - if (glimmerSources.Count != 0 && _robustRandom.Prob(0.4f)) - { - EntityManager.SpawnEntity(WispPrototype, _robustRandom.Pick(glimmerSources).Item2.Coordinates); - i++; - continue; - } - - if (normalSpawnLocations.Count != 0) - { - EntityManager.SpawnEntity(WispPrototype, _robustRandom.Pick(normalSpawnLocations).Item2.Coordinates); - i++; - continue; - } - - if (hiddenSpawnLocations.Count != 0) - { - EntityManager.SpawnEntity(WispPrototype, _robustRandom.Pick(hiddenSpawnLocations).Item2.Coordinates); - i++; - continue; - } - return; - } - } -} diff --git a/Content.Shared/DeltaV/GlimmerWisp/Events.cs b/Content.Shared/DeltaV/GlimmerWisp/Events.cs new file mode 100644 index 00000000000..10d985193b1 --- /dev/null +++ b/Content.Shared/DeltaV/GlimmerWisp/Events.cs @@ -0,0 +1,5 @@ +using Content.Shared.DoAfter; +using Robust.Shared.Serialization; + +[NetSerializable, Serializable] +public sealed partial class LifeDrainDoAfterEvent : SimpleDoAfterEvent; diff --git a/Resources/Audio/Ambience/attributions.yml b/Resources/Audio/Ambience/attributions.yml index 01d1f1c92ed..931d27f22eb 100644 --- a/Resources/Audio/Ambience/attributions.yml +++ b/Resources/Audio/Ambience/attributions.yml @@ -121,3 +121,9 @@ license: "CC0-1.0" copyright: "Created by dimbark1, edited and converted to mono by TheShuEd" source: "https://freesound.org/people/dimbark1/sounds/316797/" + +- files: ["wisp_ambience.ogg"] + license: "CC-BY-4.0" + copyright: "Created by AlienXXX, touched up by Rane" + source: "https://freesound.org/people/AlienXXX/sounds/123647/" + diff --git a/Resources/Audio/Ambience/wisp_ambience.ogg b/Resources/Audio/Ambience/wisp_ambience.ogg new file mode 100644 index 00000000000..83f823ec818 Binary files /dev/null and b/Resources/Audio/Ambience/wisp_ambience.ogg differ diff --git a/Resources/Audio/DeltaV/Effects/attributions.yml b/Resources/Audio/DeltaV/Effects/attributions.yml new file mode 100644 index 00000000000..6a4a98a7ee2 --- /dev/null +++ b/Resources/Audio/DeltaV/Effects/attributions.yml @@ -0,0 +1,4 @@ +- files: ["clang2.ogg"] + license: "CC-BY-NC-3.0" + copyright: "Freesound user BristolStories" + source: "https://freesound.org/people/BristolStories/sounds/65915/" diff --git a/Resources/Audio/DeltaV/Effects/clang2.ogg b/Resources/Audio/DeltaV/Effects/clang2.ogg new file mode 100644 index 00000000000..74f0909a9ee Binary files /dev/null and b/Resources/Audio/DeltaV/Effects/clang2.ogg differ diff --git a/Resources/Audio/Effects/attributions.yml b/Resources/Audio/Effects/attributions.yml index 2554b84bd30..7199a008eb0 100644 --- a/Resources/Audio/Effects/attributions.yml +++ b/Resources/Audio/Effects/attributions.yml @@ -232,3 +232,22 @@ license: "CC-BY-SA-3.0" source: https://github.com/YuriyKiss/space-station-14/commit/971a135a9c83aed46e967aac9302ab5b35562b5f +- files: ["magic_missile_1.ogg"] + license: "CC-BY-SA-4.0" + copyright: "From battle for wesnoth" + source: "https://github.com/wesnoth/wesnoth" + +- files: ["magic_missile_2.ogg"] + license: "CC-BY-SA-4.0" + copyright: "From battle for wesnoth" + source: "https://github.com/wesnoth/wesnoth" + +- files: ["magic_missile_3.ogg"] + license: "CC-BY-SA-4.0" + copyright: "From battle for wesnoth" + source: "https://github.com/wesnoth/wesnoth" + +- files: ["wail.ogg"] + license: "CC-BY-SA-4.0" + copyright: "From battle for wesnoth" + source: "https://github.com/wesnoth/wesnoth" diff --git a/Resources/Audio/Effects/magic_missile_1.ogg b/Resources/Audio/Effects/magic_missile_1.ogg new file mode 100644 index 00000000000..331a3efc54e Binary files /dev/null and b/Resources/Audio/Effects/magic_missile_1.ogg differ diff --git a/Resources/Audio/Effects/magic_missile_2.ogg b/Resources/Audio/Effects/magic_missile_2.ogg new file mode 100644 index 00000000000..8aac11d665e Binary files /dev/null and b/Resources/Audio/Effects/magic_missile_2.ogg differ diff --git a/Resources/Audio/Effects/magic_missile_3.ogg b/Resources/Audio/Effects/magic_missile_3.ogg new file mode 100644 index 00000000000..b7f4e941117 Binary files /dev/null and b/Resources/Audio/Effects/magic_missile_3.ogg differ diff --git a/Resources/Audio/Effects/wail.ogg b/Resources/Audio/Effects/wail.ogg new file mode 100644 index 00000000000..b40ec5ab252 Binary files /dev/null and b/Resources/Audio/Effects/wail.ogg differ diff --git a/Resources/Locale/en-US/abilities/lifedrainer.ftl b/Resources/Locale/en-US/abilities/lifedrainer.ftl new file mode 100644 index 00000000000..4072129ab0f --- /dev/null +++ b/Resources/Locale/en-US/abilities/lifedrainer.ftl @@ -0,0 +1,5 @@ +verb-life-drain = Life drain +life-drain-second-start = {CAPITALIZE(THE($drainer))} starts draining your life force! +life-drain-third-start = {CAPITALIZE(THE($drainer))} starts draining {THE($target)}'s life force! +life-drain-second-end = Your being is annihilated. +life-drain-third-end = {CAPITALIZE(THE($drainer))} drains {THE($target)}'s life force! diff --git a/Resources/Locale/en-US/guidebook/guides.ftl b/Resources/Locale/en-US/guidebook/guides.ftl index e807bcad9dc..84f3d9957f2 100644 --- a/Resources/Locale/en-US/guidebook/guides.ftl +++ b/Resources/Locale/en-US/guidebook/guides.ftl @@ -72,4 +72,5 @@ guide-entry-writing = Writing guide-entry-glossary = Glossary guide-entry-altars-golemancy = Altars and Golemancy +guide-entry-glimmer-creatures = Glimmer Creatures guide-entry-reverse-engineering = Reverse Engineering diff --git a/Resources/Prototypes/DeltaV/Entities/Mobs/NPCs/glimmer_creatures.yml b/Resources/Prototypes/DeltaV/Entities/Mobs/NPCs/glimmer_creatures.yml deleted file mode 100644 index 0a39ef6de88..00000000000 --- a/Resources/Prototypes/DeltaV/Entities/Mobs/NPCs/glimmer_creatures.yml +++ /dev/null @@ -1,31 +0,0 @@ -- type: entity - name: glimmer mite - parent: MobCockroach - id: MobGlimmerMite - description: A strange pest from a world beyond the noosphere. - components: - - type: Sprite - sprite: DeltaV/Mobs/Ghosts/glimmermite.rsi - layers: - - map: ["enum.DamageStateVisualLayers.Base"] - state: mite - - type: DamageStateVisuals - states: - Alive: - Base: mite - Dead: - Base: mite_dead - baseDecayRate: 0.25 - - type: SolutionContainerManager - solutions: - food: - reagents: - - ReagentId: Ectoplasm - Quantity: 15 - - type: Psionic - - type: GlimmerSource - - type: AmbientSound - range: 6 - volume: -3 - sound: /Audio/DeltaV/Glimmer_Creatures/mite.ogg - - type: AmbientOnPowered diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/glimmer_creatures.yml b/Resources/Prototypes/Entities/Mobs/NPCs/glimmer_creatures.yml new file mode 100644 index 00000000000..33e4ecee260 --- /dev/null +++ b/Resources/Prototypes/Entities/Mobs/NPCs/glimmer_creatures.yml @@ -0,0 +1,163 @@ +- type: entity + name: glimmer mite + parent: MobCockroach + id: MobGlimmerMite + description: A strange pest from a world beyond the noosphere. + components: + - type: Sprite + sprite: DeltaV/Mobs/Ghosts/glimmermite.rsi + layers: + - map: ["enum.DamageStateVisualLayers.Base"] + state: mite + - type: DamageStateVisuals + states: + Alive: + Base: mite + Dead: + Base: mite_dead + baseDecayRate: 0.25 + - type: SolutionContainerManager + solutions: + food: + reagents: + - ReagentId: Ectoplasm + Quantity: 15 + - type: Psionic + - type: GlimmerSource + - type: AmbientSound + range: 6 + volume: -3 + sound: /Audio/DeltaV/Glimmer_Creatures/mite.ogg + - type: AmbientOnPowered + +- type: entity + parent: + - BaseMob + - MobCombat + - MobDamageable + id: MobGlimmerWisp + name: glimmer wisp + description: A strange orb that moves with intent. + components: + # appearance + - type: Sprite + drawDepth: Ghosts + sprite: Mobs/Demons/glimmer_wisp.rsi + layers: + - state: willowisp + shader: unshaded + - type: PointLight + color: "#419ba3" + - type: Stealth + lastVisibility: 0.66 + - type: AmbientSound + volume: -8 + range: 5 + sound: + path: /Audio/Ambience/wisp_ambience.ogg + # physical + - type: Fixtures + fixtures: + fix1: + shape: + !type:PhysShapeCircle + radius: 0.35 + density: 13 + mask: + - Opaque + layer: + - MobLayer + - type: MovementSpeedModifier + baseSprintSpeed: 8 + baseWalkSpeed: 5 + - type: MovementIgnoreGravity + - type: Speech + # powers + - type: Psionic + removable: false + - type: InnatePsionicPowers + powersToAdd: + - NoosphericZapPower + - type: LifeDrainer + damage: + types: + Asphyxiation: 200 + whitelist: + components: + - Psionic + # damage + - type: Reactive + groups: + Acidic: [Touch] # Holy water + - type: MobState + allowedStates: + - Alive + - Dead + - type: MobThresholds + thresholds: + 0: Alive + 300: Dead + - type: Destructible + thresholds: + - trigger: + !type:DamageTrigger + damage: 300 + behaviors: + - !type:PlaySoundBehavior + sound: + path: /Audio/Effects/wail.ogg + - !type:SpawnEntitiesBehavior + spawn: + Ectoplasm: + min: 2 + max: 3 + - !type:DoActsBehavior + acts: [ "Destruction" ] + - type: Damageable + damageContainer: Spirit + damageModifierSet: CorporealSpirit + - type: DamageOnDispel + damage: + types: + Heat: 100 + - type: SlowOnDamage + speedModifierThresholds: + 150: 0.8 + 200: 0.6 + 250: 0.3 + - type: StatusEffects + allowed: + - Stun + - KnockedDown #KnockedDown is inseperable from stun because... IT JUST IS OK? + - SlowedDown + - Pacified + # combat + - type: Gun + fireRate: 0.7 + soundGunshot: + collection: MagicMissile + showExamineText: false + selectedMode: SemiAuto + availableModes: + - SemiAuto + - type: HitscanBatteryAmmoProvider + proto: WispLash + fireCost: 1 + # TODO: implement upstream or make it use a proper thing, maybe copy dragon + #examinable: false + - type: Battery + maxCharge: 1000 + startingCharge: 1000 + - type: BatterySelfRecharger + autoRecharge: true + autoRechargeRate: 100 + # AI + - type: HTN + rootTask: + task: GlimmerWispCompound + - type: NpcFactionMember + factions: + - GlimmerMonster + - type: NPCRetaliation + attackMemoryLength: 10 + - type: NPCRangedCombat diff --git a/Resources/Prototypes/Entities/Objects/Weapons/Guns/Projectiles/hitscan.yml b/Resources/Prototypes/Entities/Objects/Weapons/Guns/Projectiles/hitscan.yml index 3f37d308db5..94cac9bcec3 100644 --- a/Resources/Prototypes/Entities/Objects/Weapons/Guns/Projectiles/hitscan.yml +++ b/Resources/Prototypes/Entities/Objects/Weapons/Guns/Projectiles/hitscan.yml @@ -138,3 +138,20 @@ impactFlash: sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi state: impact_beam_heavy2 + +# Glimmer wisp laser +- type: hitscan + id: WispLash + damage: + types: + Cold: 8 + Shock: 8 + muzzleFlash: + sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi + state: muzzle_omni + travelFlash: + sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi + state: beam_omni + impactFlash: + sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi + state: impact_omni diff --git a/Resources/Prototypes/Guidebook/science.yml b/Resources/Prototypes/Guidebook/science.yml index bceaaa6b0c9..56363a6a92a 100644 --- a/Resources/Prototypes/Guidebook/science.yml +++ b/Resources/Prototypes/Guidebook/science.yml @@ -10,6 +10,7 @@ - MachineUpgrading - AltarsGolemancy - ReverseEngineering + - GlimmerCreatures - type: guideEntry id: Technologies @@ -68,3 +69,8 @@ id: Cyborgs name: guide-entry-cyborgs text: "/ServerInfo/Guidebook/Science/Cyborgs.xml" + +- type: guideEntry + id: GlimmerCreatures + name: guide-entry-glimmer-creatures + text: /ServerInfo/Guidebook/DeltaV/Epistemics/GlimmerCreatures.xml diff --git a/Resources/Prototypes/NPCs/wisp.yml b/Resources/Prototypes/NPCs/wisp.yml new file mode 100644 index 00000000000..3793a47cb2d --- /dev/null +++ b/Resources/Prototypes/NPCs/wisp.yml @@ -0,0 +1,28 @@ +- type: htnCompound + id: GlimmerWispCompound + branches: + - tasks: + - !type:HTNCompoundTask + task: RangedCombatCompound + - tasks: + - !type:HTNCompoundTask + task: DrainPsionicCompound + - tasks: + - !type:HTNCompoundTask + task: IdleCompound + +- type: htnCompound + id: DrainPsionicCompound + branches: + - tasks: + - !type:HTNPrimitiveTask + operator: !type:PickDrainTargetOperator + targetKey: TargetCoordinates + drainKey: DrainTarget + rangeKey: IdleRange + - !type:HTNPrimitiveTask + operator: !type:MoveToOperator + pathfindInPlanning: false + - !type:HTNPrimitiveTask + operator: !type:DrainOperator + drainKey: DrainTarget diff --git a/Resources/Prototypes/Nyanotrasen/GameRules/events.yml b/Resources/Prototypes/Nyanotrasen/GameRules/events.yml index e73ac51d5c3..de9ea15a699 100644 --- a/Resources/Prototypes/Nyanotrasen/GameRules/events.yml +++ b/Resources/Prototypes/Nyanotrasen/GameRules/events.yml @@ -112,49 +112,52 @@ - type: MassMindSwapRule isTemporary: true # Permanent mindswap is hell. -#- type: entity -# id: GlimmerWispSpawn -# parent: BaseGlimmerEvent -# noSpawn: true -# components: -# - type: GlimmerEvent -# minimumGlimmer: 300 -# maximumGlimmer: 1000 -# report: glimmer-event-report-signatures -# - type: GlimmerWispRule +- type: entity + abstract: true + parent: BaseGlimmerEvent + id: BaseGlimmerSignaturesEvent + noSpawn: true + components: + - type: GlimmerEvent + minimumGlimmer: 300 + maximumGlimmer: 1000 + report: glimmer-event-report-signatures + +- type: entity + id: GlimmerWispSpawn + parent: BaseGlimmerSignaturesEvent + noSpawn: true + components: + - type: GlimmerMobRule + mobPrototype: MobGlimmerWisp - type: entity + parent: BaseGlimmerSignaturesEvent id: FreeProber - parent: BaseGlimmerEvent noSpawn: true components: - - type: GlimmerEvent - minimumGlimmer: 300 - maximumGlimmer: 1000 - report: glimmer-event-report-signatures - - type: FreeProberRule + - type: FreeProberRule ## converted upstream events - type: entity + parent: BaseGlimmerSignaturesEvent id: GlimmerRandomSentience - parent: BaseGlimmerEvent noSpawn: true components: - - type: StationEvent - weight: 7 - duration: 1 - earliestStart: 15 - reoccurrenceDelay: 15 - minimumPlayers: 10 - - type: GlimmerEvent - minimumGlimmer: 250 - maximumGlimmer: 900 - report: glimmer-event-report-signatures - - type: GlimmerRandomSentienceRule + - type: StationEvent + weight: 7 + duration: 1 + earliestStart: 15 + reoccurrenceDelay: 15 + minimumPlayers: 10 + - type: GlimmerEvent + minimumGlimmer: 350 + maximumGlimmer: 1000 + - type: GlimmerRandomSentienceRule - type: entity + parent: BaseGlimmerSignaturesEvent id: GlimmerRevenantSpawn - parent: BaseGlimmerEvent noSpawn: true components: - type: GlimmerEvent @@ -162,17 +165,16 @@ maximumGlimmer: 900 glimmerBurnLower: 50 glimmerBurnUpper: 100 # Gives epi a chance to recover - report: glimmer-event-report-signatures - type: GlimmerRevenantRule - type: entity + parent: BaseGlimmerSignaturesEvent id: GlimmerMiteSpawn - parent: BaseGlimmerEvent noSpawn: true components: - - type: GlimmerEvent - minimumGlimmer: 250 - maximumGlimmer: 900 - report: glimmer-event-report-signatures - - type: GlimmerMobRule - mobPrototype: MobGlimmerMite + - type: GlimmerEvent + minimumGlimmer: 250 + maximumGlimmer: 900 + - type: GlimmerMobRule + mobPrototype: MobGlimmerMite + glimmerTier: Low # get more mites earlier on diff --git a/Resources/Prototypes/SoundCollections/glimmer_wisp.yml b/Resources/Prototypes/SoundCollections/glimmer_wisp.yml new file mode 100644 index 00000000000..4fc09903978 --- /dev/null +++ b/Resources/Prototypes/SoundCollections/glimmer_wisp.yml @@ -0,0 +1,6 @@ +- type: soundCollection + id: MagicMissile + files: + - /Audio/Effects/magic_missile_1.ogg + - /Audio/Effects/magic_missile_2.ogg + - /Audio/Effects/magic_missile_3.ogg diff --git a/Resources/ServerInfo/Guidebook/DeltaV/Epistemics/GlimmerCreatures.xml b/Resources/ServerInfo/Guidebook/DeltaV/Epistemics/GlimmerCreatures.xml new file mode 100644 index 00000000000..3d2f07a2fe3 --- /dev/null +++ b/Resources/ServerInfo/Guidebook/DeltaV/Epistemics/GlimmerCreatures.xml @@ -0,0 +1,87 @@ + +# Glimmer Creatures +As glimmer rises higher and higher, there can be occasional disturbances in the [color=#a4885c]noösphere[/color]. +When new psionic signatures are detected Central Command will make an announcement to the station. +Sometimes these signatures can be the appearance of glimmer creatures! + +Glimmer creatures can appear in numbers as low as 1, or multiple depending on how high glimmer is and how many psionics there are. + +These are the entities currently known to NanoTrasen. +Research is ongoing, more are yet to be discovered. + +# Glimmer Mite + + + +The glimmer mite is a small insect-like being that naturally provokes the noösphere. + +Minimum glimmer rating: Low + +## Assessment +As long as it is physically intact it will slowly raise glimmer. + +They show no signs of aggression and are physically harmless. + +## Procedure +Kill them as soon as they're spotted, they affect the noösphere and the brooding sound drives crew insane! + +## Analysis +You can get liquid [color=#a4885c]Ectoplasm[/color] from blending its body, so they can be a decent source of [color=#a4885c]Normality Crystals[/color]. + +In an emergency the body can be beaten to a pulp which will make it cease raising glimmer, at the cost of not yielding ectoplasm. + +Overall, a mild help to stopping glimmer, if you can find them. + +# Glimmer Wisp + + + +These wisps are physical manifestations of glimmer that are ghostly in appearance. + +Minimum glimmer rating: High + +## Assessment + +They don't directly affect the noösphere themselves, the main threat is that they [color=#a4885c]hunt down[/color] any psionics! + +Once a psionic is critically injured, they will begin to drain the life out of the body to [color=#a4885c]heal all injuries[/color]. + +## Procedure +Most physical attacks pass through them unnoticed, but holy weapons like the Chaplain's [color=#a4885c]Bible[/color] will weaken it. + +Once attacked they will retaliate for some time, so be prepared for a fight! + +They can also be dispelled by any gifted psionic. + +## Analysis +If you manage to kill a wisp, it will leave behind a large amount of ectoplasm which can easily make a drain or two. + +Extremely useful to a trained Epistemics team, but will require coordination to take it down. + +# Revenant + + + +A tortured soul taking revenge on those who wronged them, brought to this plane by the noösphere. + +Minimum glimmer rating: Dangerous + +## Assessment + +They have no effect on the noösphere but are a serious threat to the station, feeding on the souls of dead crew. + +Unlike wisps, revenants cannot be attacked in any way by default. + +Once a revenant uses their abilities, they can be attacked by any means for a short time. + +## Procedure +Holy damage from a bible or anti-psionic knife is especially effective at taking down wisps. + +Assistance from the Security department is usually recommended. + +Spare no time in putting down a revenant. + +## Analysis +Mostly annoying to the crew, limited exploitation due to low amount of ectoplasm when killed. + + diff --git a/Resources/Textures/Mobs/Demons/glimmer_wisp.rsi/meta.json b/Resources/Textures/Mobs/Demons/glimmer_wisp.rsi/meta.json new file mode 100644 index 00000000000..4f2cce97e1c --- /dev/null +++ b/Resources/Textures/Mobs/Demons/glimmer_wisp.rsi/meta.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "license": "CC-BY-SA-4.0", + "copyright": "Created by @Vordenburg (github) for Nyanotrasen", + "size": { + "x": 32, + "y": 32 + }, + "states": [ + { + "name": "willowisp" + } + ] +} diff --git a/Resources/Textures/Mobs/Demons/glimmer_wisp.rsi/willowisp.png b/Resources/Textures/Mobs/Demons/glimmer_wisp.rsi/willowisp.png new file mode 100644 index 00000000000..6ee16245edb Binary files /dev/null and b/Resources/Textures/Mobs/Demons/glimmer_wisp.rsi/willowisp.png differ