From: deltanedas <39013340+deltanedas@users.noreply.github.com> Date: Mon, 17 Apr 2023 07:33:27 +0000 (+0000) Subject: [Antag] add space ninja as midround antag (#14069) X-Git-Url: https://git.smokeofanarchy.ru/gitweb.cgi?a=commitdiff_plain;h=c1cda0dbf8b0f1e4bb69cd8cf54ef19a19f979bf;p=space-station-14.git [Antag] add space ninja as midround antag (#14069) * start of space ninja midround antag * suit has powercell, can be upgraded only (not replaced with equal or worse battery) * add doorjacking to ninja gloves, power cell, doorjack objective (broken), tweaks * :skull: * add basic suit power display that uses stamina rsi * add draining apc/sub/smes - no wires yet * add research downloading * ninja starts implanted, move some stuff to yaml * add Automated field to OnUseTimerTrigger * implement spider charge and objective * fix client crash when taking suit off, some refactor * add survive condition and tweak locale * add comms console icon for objective * add calling in a threat - currently revenant and dragon * combine all glove abilities * locale * spark sounds when draining, refactoring * toggle is actually toggle now * prevent crash if disabling stealth with outline * add antag ctrl for ninja, hopefully show greentext * fix greentext and some other things * disabling gloves if taken off or suit taken off * basic energy katana, change ninja loadout * recallable katana, refactoring * start of dash - not done yet * katana dashing ability * merge upstream + compiling, make AutomatedTimer its own component * docs and stuff * partial refactor of glove abilities, still need to move handling * make dooremaggedevent by ref * move bunch of stuff to shared - broken * clean ninja antag verb * doc * mark rule config fields as required * fix client crash * wip systems refactor * big refactor of systems * fuck * make TryDoElectrocution callable from shared * finish refactoring? * no guns * start with internals on * clean up glove abilities, add range check * create soap, in place of ninja throwing stars * add emp suit ability * able to eat chefs stolen food in space * stuff, tell client when un/cloaked but there is bug with gloves * fix prediction breaking gloves on client * ninja soap despawns after a minute * ninja spawns outside the station now, with gps + station coords to navigate * add cooldown to stun ability * cant use glove abilities in combat mode * require empty hand to use glove abilities * use ghost role spawner * Update Content.Server/Ninja/Systems/NinjaSuitSystem.cs Co-authored-by: keronshb <54602815+keronshb@users.noreply.github.com> * some review changes * show powercell charge on examine * new is needed * address some reviews * ninja starts with jetpack, i hope * partial feedback * uhh * pro * remove pirate from threats list * use doafter refactor * pro i gave skeleton jetpack * some stuff * use auto gen state * mr handy * use EntityQueryEnumerator * cleanup * spider charge target anti-troll * mmmmmm --------- Co-authored-by: deltanedas Co-authored-by: deltanedas Co-authored-by: deltanedas <@deltanedas:kde.org> Co-authored-by: keronshb <54602815+keronshb@users.noreply.github.com> --- diff --git a/Content.Client/Ninja/Systems/NinjaGlovesSystem.cs b/Content.Client/Ninja/Systems/NinjaGlovesSystem.cs new file mode 100644 index 0000000000..7758c3d7e2 --- /dev/null +++ b/Content.Client/Ninja/Systems/NinjaGlovesSystem.cs @@ -0,0 +1,10 @@ +using Content.Shared.Ninja.Systems; + +namespace Content.Client.Ninja.Systems; + +/// +/// Does nothing special, only exists to provide a client implementation. +/// +public sealed class NinjaGlovesSystem : SharedNinjaGlovesSystem +{ +} diff --git a/Content.Client/Ninja/Systems/NinjaSuitSystem.cs b/Content.Client/Ninja/Systems/NinjaSuitSystem.cs new file mode 100644 index 0000000000..eabcb21ab4 --- /dev/null +++ b/Content.Client/Ninja/Systems/NinjaSuitSystem.cs @@ -0,0 +1,10 @@ +using Content.Shared.Ninja.Systems; + +namespace Content.Client.Ninja.Systems; + +/// +/// Does nothing special, only exists to provide a client implementation. +/// +public sealed class NinjaSuitSystem : SharedNinjaSuitSystem +{ +} diff --git a/Content.Client/Ninja/Systems/NinjaSystem.cs b/Content.Client/Ninja/Systems/NinjaSystem.cs new file mode 100644 index 0000000000..bf9df3745b --- /dev/null +++ b/Content.Client/Ninja/Systems/NinjaSystem.cs @@ -0,0 +1,12 @@ +using Content.Shared.Ninja.Systems; + +namespace Content.Client.Ninja.Systems; + +/// +/// Currently does nothing special clientside. +/// All functionality is in shared and server. +/// Only exists to prevent crashing. +/// +public sealed class NinjaSystem : SharedNinjaSystem +{ +} diff --git a/Content.Client/Stealth/StealthSystem.cs b/Content.Client/Stealth/StealthSystem.cs index 6ceff19576..c09f3fe2e4 100644 --- a/Content.Client/Stealth/StealthSystem.cs +++ b/Content.Client/Stealth/StealthSystem.cs @@ -44,7 +44,7 @@ public sealed class StealthSystem : SharedStealthSystem if (!enabled) { if (component.HadOutline) - AddComp(uid); + EnsureComp(uid); return; } diff --git a/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs b/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs index b134d15fb3..1023b1c515 100644 --- a/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs +++ b/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs @@ -1,5 +1,6 @@ using Content.Server.GameTicking.Rules; using Content.Server.Mind.Components; +using Content.Server.Ninja.Systems; using Content.Server.Zombies; using Content.Shared.Administration; using Content.Shared.Database; @@ -14,6 +15,7 @@ public sealed partial class AdminVerbSystem { [Dependency] private readonly ZombifyOnDeathSystem _zombify = default!; [Dependency] private readonly TraitorRuleSystem _traitorRule = default!; + [Dependency] private readonly NinjaSystem _ninja = default!; [Dependency] private readonly NukeopsRuleSystem _nukeopsRule = default!; [Dependency] private readonly PiratesRuleSystem _piratesRule = default!; @@ -102,5 +104,21 @@ public sealed partial class AdminVerbSystem }; args.Verbs.Add(pirate); + Verb spaceNinja = new() + { + Text = "Make space ninja", + Category = VerbCategory.Antag, + Icon = new SpriteSpecifier.Rsi(new ResourcePath("/Textures/Objects/Weapons/Melee/energykatana.rsi"), "icon"), + Act = () => + { + if (targetMindComp.Mind == null || targetMindComp.Mind.Session == null) + return; + + _ninja.MakeNinja(targetMindComp.Mind); + }, + Impact = LogImpact.High, + Message = Loc.GetString("admin-verb-make-space-ninja"), + }; + args.Verbs.Add(spaceNinja); } } diff --git a/Content.Server/Doors/Systems/DoorSystem.cs b/Content.Server/Doors/Systems/DoorSystem.cs index d6ecd5d65b..24f1914f66 100644 --- a/Content.Server/Doors/Systems/DoorSystem.cs +++ b/Content.Server/Doors/Systems/DoorSystem.cs @@ -266,6 +266,8 @@ public sealed class DoorSystem : SharedDoorSystem { SetState(uid, DoorState.Emagging, door); PlaySound(uid, door.SparkSound, AudioParams.Default.WithVolume(8), args.UserUid, false); + var emagged = new DoorEmaggedEvent(args.UserUid); + RaiseLocalEvent(uid, ref emagged); args.Handled = true; } } @@ -300,3 +302,12 @@ public sealed class DoorSystem : SharedDoorSystem } } +public sealed class PryFinishedEvent : EntityEventArgs { } +public sealed class PryCancelledEvent : EntityEventArgs { } + +/// +/// Event raised when a door is emagged, either with an emag or a Space Ninja's doorjack ability. +/// Used to track doors for ninja's objective. +/// +[ByRefEvent] +public readonly record struct DoorEmaggedEvent(EntityUid UserUid); diff --git a/Content.Server/Electrocution/ElectrocutionSystem.cs b/Content.Server/Electrocution/ElectrocutionSystem.cs index 811232a86a..33f38c648b 100644 --- a/Content.Server/Electrocution/ElectrocutionSystem.cs +++ b/Content.Server/Electrocution/ElectrocutionSystem.cs @@ -262,16 +262,8 @@ namespace Content.Server.Electrocution } } - /// Entity being electrocuted. - /// Source entity of the electrocution. - /// How much shock damage the entity takes. - /// How long the entity will be stunned. - /// Should time be refreshed (instead of accumilated) if the entity is already electrocuted? - /// How insulated the entity is from the shock. 0 means completely insulated, and 1 means no insulation. - /// Status effects to apply to the entity. - /// Should the electrocution bypass the Insulated component? - /// Whether the entity was stunned by the shock. - public bool TryDoElectrocution( + /// + public override bool TryDoElectrocution( EntityUid uid, EntityUid? sourceUid, int shockDamage, TimeSpan time, bool refresh, float siemensCoefficient = 1f, StatusEffectsComponent? statusEffects = null, bool ignoreInsulation = false) { diff --git a/Content.Server/Explosion/Components/AutomatedTimerComponent.cs b/Content.Server/Explosion/Components/AutomatedTimerComponent.cs new file mode 100644 index 0000000000..aac1beebb5 --- /dev/null +++ b/Content.Server/Explosion/Components/AutomatedTimerComponent.cs @@ -0,0 +1,9 @@ +namespace Content.Server.Explosion.Components; + +/// +/// Disallows starting the timer by hand, must be stuck or triggered by a system. +/// +[RegisterComponent] +public sealed class AutomatedTimerComponent : Component +{ +} diff --git a/Content.Server/Explosion/EntitySystems/TriggerSystem.OnUse.cs b/Content.Server/Explosion/EntitySystems/TriggerSystem.OnUse.cs index 826ed29e12..79c5455f3d 100644 --- a/Content.Server/Explosion/EntitySystems/TriggerSystem.OnUse.cs +++ b/Content.Server/Explosion/EntitySystems/TriggerSystem.OnUse.cs @@ -141,7 +141,7 @@ public sealed partial class TriggerSystem private void OnTimerUse(EntityUid uid, OnUseTimerTriggerComponent component, UseInHandEvent args) { - if (args.Handled) + if (args.Handled || HasComp(uid)) return; HandleTimerTrigger( diff --git a/Content.Server/GameTicking/Rules/Configurations/NinjaRuleConfiguration.cs b/Content.Server/GameTicking/Rules/Configurations/NinjaRuleConfiguration.cs new file mode 100644 index 0000000000..5cba8542f2 --- /dev/null +++ b/Content.Server/GameTicking/Rules/Configurations/NinjaRuleConfiguration.cs @@ -0,0 +1,66 @@ +using Content.Server.Objectives; +using Robust.Shared.Audio; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.List; + +namespace Content.Server.GameTicking.Rules.Configurations; + +/// +/// Configuration for the Space Ninja antag. +/// +public sealed class NinjaRuleConfiguration : StationEventRuleConfiguration +{ + /// + /// List of objective prototype ids to add + /// + [DataField("objectives", required: true, customTypeSerializer: typeof(PrototypeIdListSerializer))] + public readonly List Objectives = new(); + + // TODO: move to job and use job??? + /// + /// List of implants to inject on spawn + /// + [DataField("implants", required: true, customTypeSerializer: typeof(PrototypeIdListSerializer))] + public readonly List Implants = new(); + + /// + /// List of threats that can be called in + /// + [DataField("threats", required: true)] + public readonly List Threats = new(); + + /// + /// Sound played when making the player a ninja via antag control or ghost role + /// + [DataField("greetingSound", customTypeSerializer: typeof(SoundSpecifierTypeSerializer))] + public SoundSpecifier? GreetingSound = new SoundPathSpecifier("/Audio/Misc/ninja_greeting.ogg"); + + /// + /// Distance that the ninja spawns from the station's half AABB radius + /// + [DataField("spawnDistance")] + public float SpawnDistance = 20f; +} + +/// +/// A threat that can be called in to the station by a ninja hacking a communications console. +/// Generally some kind of mid-round antag, though you could make it call in scrubber backflow if you wanted to. +/// You wouldn't do that, right? +/// +[DataDefinition] +public sealed class Threat +{ + /// + /// Locale id for the announcement to be made from CentCom. + /// + [DataField("announcement")] + public readonly string Announcement = default!; + + /// + /// The game rule for the threat to be added, it should be able to work when added mid-round otherwise this will do nothing. + /// + [DataField("rule", customTypeSerializer: typeof(PrototypeIdSerializer))] + public readonly string Rule = default!; +} diff --git a/Content.Server/Ninja/Components/NinjaStationGridComponent.cs b/Content.Server/Ninja/Components/NinjaStationGridComponent.cs new file mode 100644 index 0000000000..7fe4004dd1 --- /dev/null +++ b/Content.Server/Ninja/Components/NinjaStationGridComponent.cs @@ -0,0 +1,13 @@ +namespace Content.Server.Ninja.Components; + +/// +/// Used by space ninja to indicate what station grid to head towards. +/// +[RegisterComponent] +public sealed class NinjaStationGridComponent : Component +{ + /// + /// The grid uid being targeted. + /// + public EntityUid Grid; +} diff --git a/Content.Server/Ninja/Systems/NinjaGlovesSystem.cs b/Content.Server/Ninja/Systems/NinjaGlovesSystem.cs new file mode 100644 index 0000000000..0a7fe722ec --- /dev/null +++ b/Content.Server/Ninja/Systems/NinjaGlovesSystem.cs @@ -0,0 +1,36 @@ +using Content.Server.Communications; +using Content.Server.DoAfter; +using Content.Server.Power.Components; +using Content.Shared.DoAfter; +using Content.Shared.Interaction.Events; +using Content.Shared.Ninja.Components; +using Content.Shared.Ninja.Systems; + +namespace Content.Server.Ninja.Systems; + +public sealed class NinjaGlovesSystem : SharedNinjaGlovesSystem +{ + protected override void OnDrain(EntityUid uid, NinjaDrainComponent comp, InteractionAttemptEvent args) + { + if (!GloveCheck(uid, args, out var gloves, out var user, out var target) + || !HasComp(target)) + return; + + // nicer for spam-clicking to not open apc ui, and when draining starts, so cancel the ui action + args.Cancel(); + + var doAfterArgs = new DoAfterArgs(user, comp.DrainTime, new DrainDoAfterEvent(), target: target, used: uid, eventTarget: uid) + { + BreakOnUserMove = true, + MovementThreshold = 0.5f, + CancelDuplicate = false + }; + + _doAfter.TryStartDoAfter(doAfterArgs); + } + + protected override bool IsCommsConsole(EntityUid uid) + { + return HasComp(uid); + } +} diff --git a/Content.Server/Ninja/Systems/NinjaSuitSystem.cs b/Content.Server/Ninja/Systems/NinjaSuitSystem.cs new file mode 100644 index 0000000000..bca1e8b6ea --- /dev/null +++ b/Content.Server/Ninja/Systems/NinjaSuitSystem.cs @@ -0,0 +1,148 @@ +using Content.Server.Emp; +using Content.Server.Popups; +using Content.Server.Power.Components; +using Content.Server.PowerCell; +using Content.Shared.Actions; +using Content.Shared.Examine; +using Content.Shared.Hands.EntitySystems; +using Content.Shared.Ninja.Components; +using Content.Shared.Ninja.Systems; +using Content.Shared.Popups; +using Robust.Shared.Containers; + +namespace Content.Server.Ninja.Systems; + +public sealed class NinjaSuitSystem : SharedNinjaSuitSystem +{ + [Dependency] private readonly EmpSystem _emp = default!; + [Dependency] private readonly SharedHandsSystem _hands = default!; + [Dependency] private readonly new NinjaSystem _ninja = default!; + [Dependency] private readonly PopupSystem _popups = default!; + [Dependency] private readonly PowerCellSystem _powerCell = default!; + [Dependency] private readonly SharedTransformSystem _transform = default!; + + public override void Initialize() + { + base.Initialize(); + + // TODO: maybe have suit activation stuff + SubscribeLocalEvent(OnSuitInsertAttempt); + SubscribeLocalEvent(OnExamined); + SubscribeLocalEvent(OnTogglePhaseCloak); + SubscribeLocalEvent(OnCreateSoap); + SubscribeLocalEvent(OnRecallKatana); + SubscribeLocalEvent(OnEmp); + } + + protected override void NinjaEquippedSuit(EntityUid uid, NinjaSuitComponent comp, EntityUid user, NinjaComponent ninja) + { + base.NinjaEquippedSuit(uid, comp, user, ninja); + + _ninja.SetSuitPowerAlert(user); + } + + // TODO: if/when battery is in shared, put this there too + private void OnSuitInsertAttempt(EntityUid uid, NinjaSuitComponent comp, ContainerIsInsertingAttemptEvent args) + { + // no power cell for some reason??? allow it + if (!_powerCell.TryGetBatteryFromSlot(uid, out var battery)) + return; + + // can only upgrade power cell, not swap to recharge instantly otherwise ninja could just swap batteries with flashlights in maints for easy power + if (!TryComp(args.EntityUid, out var inserting) || inserting.MaxCharge <= battery.MaxCharge) + { + args.Cancel(); + } + } + + private void OnExamined(EntityUid uid, NinjaSuitComponent comp, ExaminedEvent args) + { + // TODO: make this also return the uid of the battery + if (_powerCell.TryGetBatteryFromSlot(uid, out var battery)) + RaiseLocalEvent(battery.Owner, args); + } + + protected override void UserUnequippedSuit(EntityUid uid, NinjaSuitComponent comp, EntityUid user) + { + base.UserUnequippedSuit(uid, comp, user); + + // remove power indicator + _ninja.SetSuitPowerAlert(user); + } + + private void OnTogglePhaseCloak(EntityUid uid, NinjaSuitComponent comp, TogglePhaseCloakEvent args) + { + args.Handled = true; + var user = args.Performer; + // need 1 second of charge to turn on stealth + var chargeNeeded = SuitWattage(comp); + if (!comp.Cloaked && (!_ninja.GetNinjaBattery(user, out var battery) || battery.CurrentCharge < chargeNeeded || _useDelay.ActiveDelay(uid))) + { + _popups.PopupEntity(Loc.GetString("ninja-no-power"), user, user); + return; + } + + comp.Cloaked = !comp.Cloaked; + SetCloaked(args.Performer, comp.Cloaked); + RaiseNetworkEvent(new SetCloakedMessage() + { + User = user, + Cloaked = comp.Cloaked + }); + } + + private void OnCreateSoap(EntityUid uid, NinjaSuitComponent comp, CreateSoapEvent args) + { + args.Handled = true; + var user = args.Performer; + if (!_ninja.TryUseCharge(user, comp.SoapCharge) || _useDelay.ActiveDelay(uid)) + { + _popups.PopupEntity(Loc.GetString("ninja-no-power"), user, user); + return; + } + + // try to put soap in hand, otherwise it goes on the ground + var soap = Spawn(comp.SoapPrototype, Transform(user).Coordinates); + _hands.TryPickupAnyHand(user, soap); + } + + private void OnRecallKatana(EntityUid uid, NinjaSuitComponent comp, RecallKatanaEvent args) + { + args.Handled = true; + var user = args.Performer; + if (!TryComp(user, out var ninja) || ninja.Katana == null) + return; + + // 1% charge per tile + var katana = ninja.Katana.Value; + var coords = _transform.GetWorldPosition(katana); + var distance = (_transform.GetWorldPosition(user) - coords).Length; + var chargeNeeded = (float) distance * 3.6f; + if (!_ninja.TryUseCharge(user, chargeNeeded) || _useDelay.ActiveDelay(uid)) + { + _popups.PopupEntity(Loc.GetString("ninja-no-power"), user, user); + return; + } + + // TODO: teleporting into belt slot + var message = _hands.TryPickupAnyHand(user, katana) + ? "ninja-katana-recalled" + : "ninja-hands-full"; + _popups.PopupEntity(Loc.GetString(message), user, user); + } + + private void OnEmp(EntityUid uid, NinjaSuitComponent comp, NinjaEmpEvent args) + { + args.Handled = true; + var user = args.Performer; + if (!_ninja.TryUseCharge(user, comp.EmpCharge) || _useDelay.ActiveDelay(uid)) + { + _popups.PopupEntity(Loc.GetString("ninja-no-power"), user, user); + return; + } + + // I don't think this affects the suit battery, but if it ever does in the future add a blacklist for it + var coords = Transform(user).MapPosition; + _emp.EmpPulse(coords, comp.EmpRange, comp.EmpConsumption); + } +} diff --git a/Content.Server/Ninja/Systems/NinjaSystem.cs b/Content.Server/Ninja/Systems/NinjaSystem.cs new file mode 100644 index 0000000000..318d1115bd --- /dev/null +++ b/Content.Server/Ninja/Systems/NinjaSystem.cs @@ -0,0 +1,314 @@ +using Content.Server.Administration.Commands; +using Content.Server.Body.Systems; +using Content.Server.Chat.Managers; +using Content.Server.Chat.Systems; +using Content.Server.Doors.Systems; +using Content.Server.GameTicking; +using Content.Server.GameTicking.Rules; +using Content.Server.GameTicking.Rules.Configurations; +using Content.Server.Ghost.Roles.Events; +using Content.Server.Mind.Components; +using Content.Server.Ninja.Components; +using Content.Server.Objectives; +using Content.Server.Popups; +using Content.Server.Power.Components; +using Content.Server.PowerCell; +using Content.Server.Traitor; +using Content.Server.Warps; +using Content.Shared.Alert; +using Content.Shared.Doors.Components; +using Content.Shared.Implants; +using Content.Shared.Implants.Components; +using Content.Shared.Ninja.Components; +using Content.Shared.Ninja.Systems; +using Content.Shared.Roles; +using Content.Shared.Popups; +using Content.Shared.PowerCell.Components; +using Content.Shared.Rounding; +using Robust.Shared.Audio; +using Robust.Shared.GameObjects; +using Robust.Shared.Physics.Components; +using Robust.Shared.Player; +using Robust.Shared.Prototypes; +using Robust.Shared.Random; +using System.Diagnostics.CodeAnalysis; + +namespace Content.Server.Ninja.Systems; + +public sealed class NinjaSystem : SharedNinjaSystem +{ + [Dependency] private readonly AlertsSystem _alerts = default!; + [Dependency] private readonly SharedAudioSystem _audio = default!; + [Dependency] private readonly ChatSystem _chat = default!; + [Dependency] private readonly IChatManager _chatMan = default!; + [Dependency] private readonly GameTicker _gameTicker = default!; + [Dependency] private readonly SharedSubdermalImplantSystem _implants = default!; + [Dependency] private readonly InternalsSystem _internals = default!; + [Dependency] private readonly IPrototypeManager _proto = default!; + [Dependency] private readonly PopupSystem _popups = default!; + [Dependency] private readonly PowerCellSystem _powerCell = default!; + [Dependency] private readonly IRobustRandom _random = default!; + [Dependency] private readonly TraitorRuleSystem _traitorRule = default!; + [Dependency] private readonly SharedTransformSystem _transform = default!; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnNinjaStartup); + SubscribeLocalEvent(OnNinjaSpawned); + SubscribeLocalEvent(OnNinjaMindAdded); + + SubscribeLocalEvent(OnDoorEmagged); + } + + public override void Update(float frameTime) + { + var query = EntityQueryEnumerator(); + while (query.MoveNext(out var uid, out var ninja)) + { + UpdateNinja(uid, ninja, frameTime); + } + } + + /// + /// Turns the player into a space ninja + /// + public void MakeNinja(Mind.Mind mind) + { + if (mind.OwnedEntity == null) + return; + + // prevent double ninja'ing + var user = mind.OwnedEntity.Value; + if (HasComp(user)) + return; + + AddComp(user); + SetOutfitCommand.SetOutfit(user, "SpaceNinjaGear", EntityManager); + GreetNinja(mind); + } + + /// + /// Returns the space ninja spawn gamerule's config + /// + public NinjaRuleConfiguration RuleConfig() + { + return (NinjaRuleConfiguration) _proto.Index("SpaceNinjaSpawn").Configuration; + } + + /// + /// Update the alert for the ninja's suit power indicator. + /// + public void SetSuitPowerAlert(EntityUid uid, NinjaComponent? comp = null) + { + if (!Resolve(uid, ref comp, false) || comp.Deleted || comp.Suit == null) + { + _alerts.ClearAlert(uid, AlertType.SuitPower); + return; + } + + if (GetNinjaBattery(uid, out var battery)) + { + var severity = ContentHelpers.RoundToLevels(MathF.Max(0f, battery.CurrentCharge), battery.MaxCharge, 7); + _alerts.ShowAlert(uid, AlertType.SuitPower, (short) severity); + } + else + { + _alerts.ClearAlert(uid, AlertType.SuitPower); + } + } + + /// + /// Set the station grid on an entity, either ninja spawner or the ninja itself. + /// Used to tell a ghost that takes ninja role where the station is. + /// + public void SetNinjaStationGrid(EntityUid uid, EntityUid grid) + { + var station = EnsureComp(uid); + station.Grid = grid; + } + + /// + /// Get the battery component in a ninja's suit, if it's worn. + /// + public bool GetNinjaBattery(EntityUid user, [NotNullWhen(true)] out BatteryComponent? battery) + { + if (TryComp(user, out var ninja) + && ninja.Suit != null + && _powerCell.TryGetBatteryFromSlot(ninja.Suit.Value, out battery)) + { + return true; + } + + battery = null; + return false; + } + + public override bool TryUseCharge(EntityUid user, float charge) + { + return GetNinjaBattery(user, out var battery) && battery.TryUseCharge(charge); + } + + public override void CallInThreat(NinjaComponent comp) + { + base.CallInThreat(comp); + + var config = RuleConfig(); + if (config.Threats.Count == 0) + return; + + var threat = _random.Pick(config.Threats); + if (_proto.TryIndex(threat.Rule, out var rule)) + { + _gameTicker.AddGameRule(rule); + _chat.DispatchGlobalAnnouncement(Loc.GetString(threat.Announcement), playSound: false, colorOverride: Color.Red); + } + else + { + Logger.Error($"Threat gamerule does not exist: {threat.Rule}"); + } + } + + public override void TryDrainPower(EntityUid user, NinjaDrainComponent drain, EntityUid target) + { + if (!GetNinjaBattery(user, out var suitBattery)) + // took suit off or something, ignore draining + return; + + if (!TryComp(target, out var battery) || !TryComp(target, out var pnb)) + return; + + if (suitBattery.IsFullyCharged) + { + _popups.PopupEntity(Loc.GetString("ninja-drain-full"), user, user, PopupType.Medium); + return; + } + + if (MathHelper.CloseToPercent(battery.CurrentCharge, 0)) + { + _popups.PopupEntity(Loc.GetString("ninja-drain-empty", ("battery", target)), user, user, PopupType.Medium); + return; + } + + var available = battery.CurrentCharge; + var required = suitBattery.MaxCharge - suitBattery.CurrentCharge; + // higher tier storages can charge more + var maxDrained = pnb.MaxSupply * drain.DrainTime; + var input = Math.Min(Math.Min(available, required / drain.DrainEfficiency), maxDrained); + if (battery.TryUseCharge(input)) + { + var output = input * drain.DrainEfficiency; + suitBattery.CurrentCharge += output; + _popups.PopupEntity(Loc.GetString("ninja-drain-success", ("battery", target)), user, user); + // TODO: spark effects + _audio.PlayPvs(drain.SparkSound, target); + } + } + + private void OnNinjaStartup(EntityUid uid, NinjaComponent comp, ComponentStartup args) + { + var config = RuleConfig(); + + // start with internals on, only when spawned by event. antag control ninja won't do this due to component add order. + _internals.ToggleInternals(uid, uid, true); + + // inject starting implants + var coords = Transform(uid).Coordinates; + foreach (var id in config.Implants) + { + var implant = Spawn(id, coords); + + if (!TryComp(implant, out var implantComp)) + return; + + _implants.ForceImplant(uid, implant, implantComp); + } + + // choose spider charge detonation point + // currently based on warp points, something better could be done (but would likely require mapping work) + var warps = new List(); + var query = EntityQueryEnumerator(); + while (query.MoveNext(out var warpUid, out var warp)) + { + // won't be asked to detonate the nuke disk or singularity + if (warp.Location != null && !HasComp(warpUid)) + warps.Add(warpUid); + } + + if (warps.Count > 0) + comp.SpiderChargeTarget = _random.Pick(warps); + } + + private void OnNinjaSpawned(EntityUid uid, NinjaComponent comp, GhostRoleSpawnerUsedEvent args) + { + // inherit spawner's station grid + if (TryComp(args.Spawner, out var station)) + SetNinjaStationGrid(uid, station.Grid); + } + + private void OnNinjaMindAdded(EntityUid uid, NinjaComponent comp, MindAddedMessage args) + { + if (TryComp(uid, out var mind) && mind.Mind != null) + GreetNinja(mind.Mind); + } + + private void GreetNinja(Mind.Mind mind) + { + if (!mind.TryGetSession(out var session)) + return; + + var config = RuleConfig(); + var role = new TraitorRole(mind, _proto.Index("SpaceNinja")); + mind.AddRole(role); + _traitorRule.Traitors.Add(role); + foreach (var objective in config.Objectives) + { + AddObjective(mind, objective); + } + + _audio.PlayGlobal(config.GreetingSound, Filter.Empty().AddPlayer(session), false, AudioParams.Default); + _chatMan.DispatchServerMessage(session, Loc.GetString("ninja-role-greeting")); + + if (TryComp(mind.OwnedEntity, out var station)) + { + var gridPos = _transform.GetWorldPosition(station.Grid); + var ninjaPos = _transform.GetWorldPosition(mind.OwnedEntity.Value); + var vector = gridPos - ninjaPos; + var direction = vector.GetDir(); + var position = $"({(int) gridPos.X}, {(int) gridPos.Y})"; + var msg = Loc.GetString("ninja-role-greeting-direction", ("direction", direction), ("position", position)); + _chatMan.DispatchServerMessage(session, msg); + } + } + + private void OnDoorEmagged(EntityUid uid, DoorComponent door, ref DoorEmaggedEvent args) + { + // make sure it's a ninja doorjacking it + if (TryComp(args.UserUid, out var ninja)) + ninja.DoorsJacked++; + } + + private void UpdateNinja(EntityUid uid, NinjaComponent ninja, float frameTime) + { + if (ninja.Suit == null || !TryComp(ninja.Suit, out var suit)) + return; + + float wattage = _suit.SuitWattage(suit); + + SetSuitPowerAlert(uid, ninja); + if (!TryUseCharge(uid, wattage * frameTime)) + { + // ran out of power, reveal ninja + _suit.RevealNinja(ninja.Suit.Value, suit, uid); + } + } + + private void AddObjective(Mind.Mind mind, string name) + { + if (_proto.TryIndex(name, out var objective)) + mind.TryAddObjective(objective); + else + Logger.Error($"Ninja has unknown objective prototype: {name}"); + } +} diff --git a/Content.Server/Ninja/Systems/SpiderChargeSystem.cs b/Content.Server/Ninja/Systems/SpiderChargeSystem.cs new file mode 100644 index 0000000000..94d3fb7911 --- /dev/null +++ b/Content.Server/Ninja/Systems/SpiderChargeSystem.cs @@ -0,0 +1,64 @@ +using Content.Server.Explosion.EntitySystems; +using Content.Server.Sticky.Events; +using Content.Server.Popups; +using Content.Shared.Interaction; +using Content.Shared.Ninja.Components; +using Robust.Shared.GameObjects; + +namespace Content.Server.Ninja.Systems; + +public sealed class SpiderChargeSystem : EntitySystem +{ + [Dependency] private readonly NinjaSystem _ninja = default!; + [Dependency] private readonly PopupSystem _popups = default!; + [Dependency] private readonly SharedTransformSystem _transform = default!; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(BeforePlant); + SubscribeLocalEvent(OnStuck); + SubscribeLocalEvent(OnExplode); + } + + private void BeforePlant(EntityUid uid, SpiderChargeComponent comp, BeforeRangedInteractEvent args) + { + var user = args.User; + + if (!TryComp(user, out var ninja)) + { + _popups.PopupEntity(Loc.GetString("spider-charge-not-ninja"), user, user); + args.Handled = true; + return; + } + + // allow planting anywhere if there is no target, which should never happen + if (ninja.SpiderChargeTarget != null) + { + // assumes warp point still exists + var target = Transform(ninja.SpiderChargeTarget.Value).MapPosition; + var coords = args.ClickLocation.ToMap(EntityManager, _transform); + if (!coords.InRange(target, comp.Range)) + { + _popups.PopupEntity(Loc.GetString("spider-charge-too-far"), user, user); + args.Handled = true; + return; + } + } + } + + private void OnStuck(EntityUid uid, SpiderChargeComponent comp, EntityStuckEvent args) + { + comp.Planter = args.User; + } + + private void OnExplode(EntityUid uid, SpiderChargeComponent comp, TriggerEvent args) + { + if (comp.Planter == null || !TryComp(comp.Planter, out var ninja)) + return; + + // assumes the target was destroyed, that the charge wasn't moved somehow + _ninja.DetonateSpiderCharge(ninja); + } +} diff --git a/Content.Server/Objectives/Conditions/DoorjackCondition.cs b/Content.Server/Objectives/Conditions/DoorjackCondition.cs new file mode 100644 index 0000000000..335b18f198 --- /dev/null +++ b/Content.Server/Objectives/Conditions/DoorjackCondition.cs @@ -0,0 +1,64 @@ +using Content.Server.Objectives.Interfaces; +using Content.Shared.Ninja.Components; +using Robust.Shared.Random; +using Robust.Shared.Utility; + +namespace Content.Server.Objectives.Conditions; + +[DataDefinition] +public sealed class DoorjackCondition : IObjectiveCondition +{ + private Mind.Mind? _mind; + private int _target; + + public IObjectiveCondition GetAssigned(Mind.Mind mind) + { + // TODO: clamp to number of doors on station incase its somehow a shittle or something + return new DoorjackCondition { + _mind = mind, + _target = IoCManager.Resolve().Next(15, 40) + }; + } + + public string Title => Loc.GetString("objective-condition-doorjack-title", ("count", _target)); + + public string Description => Loc.GetString("objective-condition-doorjack-description", ("count", _target)); + + public SpriteSpecifier Icon => new SpriteSpecifier.Rsi(new ResourcePath("Objects/Tools/emag.rsi"), "icon"); + + public float Progress + { + get + { + var entMan = IoCManager.Resolve(); + if (_mind?.OwnedEntity == null + || !entMan.TryGetComponent(_mind.OwnedEntity, out var ninja)) + return 0f; + + // prevent divide-by-zero + if (_target == 0) + return 1f; + + return (float) ninja.DoorsJacked / (float) _target; + } + } + + public float Difficulty => 1.5f; + + public bool Equals(IObjectiveCondition? other) + { + return other is DoorjackCondition cond && Equals(_mind, cond._mind) && _target == cond._target; + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + return obj is DoorjackCondition cond && cond.Equals(this); + } + + public override int GetHashCode() + { + return HashCode.Combine(_mind?.GetHashCode() ?? 0, _target); + } +} diff --git a/Content.Server/Objectives/Conditions/DownloadCondition.cs b/Content.Server/Objectives/Conditions/DownloadCondition.cs new file mode 100644 index 0000000000..18948b1955 --- /dev/null +++ b/Content.Server/Objectives/Conditions/DownloadCondition.cs @@ -0,0 +1,64 @@ +using Content.Server.Objectives.Interfaces; +using Content.Shared.Ninja.Components; +using Robust.Shared.Random; +using Robust.Shared.Utility; + +namespace Content.Server.Objectives.Conditions; + +[DataDefinition] +public sealed class DownloadCondition : IObjectiveCondition +{ + private Mind.Mind? _mind; + private int _target; + + public IObjectiveCondition GetAssigned(Mind.Mind mind) + { + // TODO: clamp to number of research nodes in tree so easily maintainable + return new DownloadCondition { + _mind = mind, + _target = IoCManager.Resolve().Next(5, 10) + }; + } + + public string Title => Loc.GetString("objective-condition-download-title", ("count", _target)); + + public string Description => Loc.GetString("objective-condition-download-description"); + + public SpriteSpecifier Icon => new SpriteSpecifier.Rsi(new ResourcePath("Structures/Machines/server.rsi"), "server"); + + public float Progress + { + get + { + // prevent divide-by-zero + if (_target == 0) + return 1f; + + var entMan = IoCManager.Resolve(); + if (_mind?.OwnedEntity == null + || !entMan.TryGetComponent(_mind.OwnedEntity, out var ninja)) + return 0f; + + return (float) ninja.DownloadedNodes.Count / (float) _target; + } + } + + public float Difficulty => 2.5f; + + public bool Equals(IObjectiveCondition? other) + { + return other is DownloadCondition cond && Equals(_mind, cond._mind) && _target == cond._target; + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + return obj is DownloadCondition cond && cond.Equals(this); + } + + public override int GetHashCode() + { + return HashCode.Combine(_mind?.GetHashCode() ?? 0, _target); + } +} diff --git a/Content.Server/Objectives/Conditions/SpiderChargeCondition.cs b/Content.Server/Objectives/Conditions/SpiderChargeCondition.cs new file mode 100644 index 0000000000..c8ea4897df --- /dev/null +++ b/Content.Server/Objectives/Conditions/SpiderChargeCondition.cs @@ -0,0 +1,73 @@ +using Content.Server.Objectives.Interfaces; +using Content.Server.Warps; +using Content.Shared.Ninja.Components; +using Robust.Shared.Random; +using Robust.Shared.Utility; + +namespace Content.Server.Objectives.Conditions; + +[DataDefinition] +public sealed class SpiderChargeCondition : IObjectiveCondition +{ + private Mind.Mind? _mind; + + public IObjectiveCondition GetAssigned(Mind.Mind mind) + { + return new SpiderChargeCondition { + _mind = mind + }; + } + + public string Title + { + get + { + var entMan = IoCManager.Resolve(); + if (_mind?.OwnedEntity == null + || !entMan.TryGetComponent(_mind.OwnedEntity, out var ninja) + || ninja.SpiderChargeTarget == null + || !entMan.TryGetComponent(ninja.SpiderChargeTarget, out var warp) + || warp.Location == null) + // if you are funny and microbomb then press c, you get this + return Loc.GetString("objective-condition-spider-charge-no-target"); + + return Loc.GetString("objective-condition-spider-charge-title", ("location", warp.Location)); + } + } + + public string Description => Loc.GetString("objective-condition-spider-charge-description"); + + public SpriteSpecifier Icon => new SpriteSpecifier.Rsi(new ResourcePath("Objects/Weapons/Bombs/spidercharge.rsi"), "icon"); + + public float Progress + { + get + { + var entMan = IoCManager.Resolve(); + if (_mind?.OwnedEntity == null + || !entMan.TryGetComponent(_mind.OwnedEntity, out var ninja)) + return 0f; + + return ninja.SpiderChargeDetonated ? 1f : 0f; + } + } + + public float Difficulty => 2.5f; + + public bool Equals(IObjectiveCondition? other) + { + return other is SpiderChargeCondition cond && Equals(_mind, cond._mind); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + return obj is SpiderChargeCondition cond && cond.Equals(this); + } + + public override int GetHashCode() + { + return _mind?.GetHashCode() ?? 0; + } +} diff --git a/Content.Server/Objectives/Conditions/SurviveCondition.cs b/Content.Server/Objectives/Conditions/SurviveCondition.cs new file mode 100644 index 0000000000..b4bfe4426d --- /dev/null +++ b/Content.Server/Objectives/Conditions/SurviveCondition.cs @@ -0,0 +1,46 @@ +using Content.Server.Objectives.Interfaces; +using JetBrains.Annotations; +using Robust.Shared.Utility; + +namespace Content.Server.Objectives.Conditions +{ + [UsedImplicitly] + [DataDefinition] + public sealed class SurviveCondition : IObjectiveCondition + { + private Mind.Mind? _mind; + + public IObjectiveCondition GetAssigned(Mind.Mind mind) + { + return new SurviveCondition {_mind = mind}; + } + + public string Title => Loc.GetString("objective-condition-survive-title"); + + public string Description => Loc.GetString("objective-condition-survive-description"); + + public SpriteSpecifier Icon => new SpriteSpecifier.Rsi(new ResourcePath("Clothing/Head/Helmets/spaceninja.rsi"), "icon"); + + public float Difficulty => 0.5f; + + public float Progress => (_mind?.CharacterDeadIC ?? true) ? 0f : 1f; + + public bool Equals(IObjectiveCondition? other) + { + return other is SurviveCondition condition && Equals(_mind, condition._mind); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != GetType()) return false; + return Equals((SurviveCondition) obj); + } + + public override int GetHashCode() + { + return (_mind != null ? _mind.GetHashCode() : 0); + } + } +} diff --git a/Content.Server/Objectives/Conditions/TerrorCondition.cs b/Content.Server/Objectives/Conditions/TerrorCondition.cs new file mode 100644 index 0000000000..10f76b6a6e --- /dev/null +++ b/Content.Server/Objectives/Conditions/TerrorCondition.cs @@ -0,0 +1,54 @@ +using Content.Server.Objectives.Interfaces; +using Content.Shared.Ninja.Components; +using Robust.Shared.Utility; + +namespace Content.Server.Objectives.Conditions; + +[DataDefinition] +public sealed class TerrorCondition : IObjectiveCondition +{ + private Mind.Mind? _mind; + + public IObjectiveCondition GetAssigned(Mind.Mind mind) + { + return new TerrorCondition {_mind = mind}; + } + + public string Title => Loc.GetString("objective-condition-terror-title"); + + public string Description => Loc.GetString("objective-condition-terror-description"); + + public SpriteSpecifier Icon => new SpriteSpecifier.Rsi(new ResourcePath("Structures/Machines/computers.rsi"), "comm_icon"); + + public float Progress + { + get + { + var entMan = IoCManager.Resolve(); + if (_mind?.OwnedEntity == null + || !entMan.TryGetComponent(_mind.OwnedEntity, out var ninja)) + return 0f; + + return ninja.CalledInThreat ? 1f : 0f; + } + } + + public float Difficulty => 2.75f; + + public bool Equals(IObjectiveCondition? other) + { + return other is TerrorCondition cond && Equals(_mind, cond._mind); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + return obj is TerrorCondition cond && cond.Equals(this); + } + + public override int GetHashCode() + { + return _mind?.GetHashCode() ?? 0; + } +} diff --git a/Content.Server/StationEvents/Events/SpaceNinjaSpawn.cs b/Content.Server/StationEvents/Events/SpaceNinjaSpawn.cs new file mode 100644 index 0000000000..7deab89c2d --- /dev/null +++ b/Content.Server/StationEvents/Events/SpaceNinjaSpawn.cs @@ -0,0 +1,81 @@ +using Content.Server.GameTicking; +using Content.Server.GameTicking.Rules; +using Content.Server.GameTicking.Rules.Configurations; +using Content.Server.Ninja.Systems; +using Content.Server.StationEvents.Components; +using Content.Server.Station.Components; +using Robust.Server.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Map.Components; +using Robust.Shared.Prototypes; +using Robust.Shared.Random; +using System.Linq; + +namespace Content.Server.StationEvents.Events; + +/// +/// Event for spawning a Space Ninja mid-game. +/// +public sealed class SpaceNinjaSpawn : StationEventSystem +{ + [Dependency] private readonly NinjaSystem _ninja = default!; + [Dependency] private readonly IPrototypeManager _proto = default!; + [Dependency] private readonly IRobustRandom _random = default!; + [Dependency] private readonly GameTicker _ticker = default!; + [Dependency] private readonly SharedTransformSystem _transform = default!; + + public override string Prototype => "SpaceNinjaSpawn"; + + public override void Started() + { + base.Started(); + + if (StationSystem.Stations.Count == 0) + { + Sawmill.Error("No stations exist, cannot spawn space ninja!"); + return; + } + + var station = _random.Pick(StationSystem.Stations); + if (!TryComp(station, out var stationData)) + { + Sawmill.Error("Chosen station isn't a station, cannot spawn space ninja!"); + return; + } + + // find a station grid + var gridUid = StationSystem.GetLargestGrid(stationData); + if (gridUid == null || !TryComp(gridUid, out var grid)) + { + Sawmill.Error("Chosen station has no grids, cannot spawn space ninja!"); + return; + } + + // figure out its AABB size and use that as a guide to how far ninja should be + var config = (NinjaRuleConfiguration) Configuration; + var size = grid.LocalAABB.Size.Length / 2; + var distance = size + config.SpawnDistance; + var angle = _random.NextAngle(); + // position relative to station center + var location = angle.ToVec() * distance; + + // create the spawner, the ninja will appear when a ghost has picked the role + var xform = Transform(gridUid.Value); + var position = _transform.GetWorldPosition(xform) + location; + var coords = new MapCoordinates(position, xform.MapID); + Sawmill.Info($"Creating ninja spawnpoint at {coords}"); + var spawner = Spawn("SpawnPointGhostSpaceNinja", coords); + + // tell the player where the station is when they pick the role + _ninja.SetNinjaStationGrid(spawner, gridUid.Value); + + // start traitor rule incase it isn't, for the sweet greentext + var rule = _proto.Index("Traitor"); + _ticker.StartGameRule(rule); + } + + public override void Added() + { + Sawmill.Info("Added space ninja spawn rule"); + } +} diff --git a/Content.Shared/Alert/AlertType.cs b/Content.Shared/Alert/AlertType.cs index 4d80a8acfd..47f3f0f241 100644 --- a/Content.Shared/Alert/AlertType.cs +++ b/Content.Shared/Alert/AlertType.cs @@ -43,7 +43,8 @@ namespace Content.Shared.Alert Debug3, Debug4, Debug5, - Debug6 + Debug6, + SuitPower } } diff --git a/Content.Shared/Electrocution/SharedElectrocutionSystem.cs b/Content.Shared/Electrocution/SharedElectrocutionSystem.cs index d7848c073b..67a395bb81 100644 --- a/Content.Shared/Electrocution/SharedElectrocutionSystem.cs +++ b/Content.Shared/Electrocution/SharedElectrocutionSystem.cs @@ -1,4 +1,5 @@ using Content.Shared.Inventory; +using Content.Shared.StatusEffect; using Robust.Shared.GameStates; namespace Content.Shared.Electrocution @@ -25,6 +26,23 @@ namespace Content.Shared.Electrocution Dirty(insulated); } + /// Entity being electrocuted. + /// Source entity of the electrocution. + /// How much shock damage the entity takes. + /// How long the entity will be stunned. + /// Should time be refreshed (instead of accumilated) if the entity is already electrocuted? + /// How insulated the entity is from the shock. 0 means completely insulated, and 1 means no insulation. + /// Status effects to apply to the entity. + /// Should the electrocution bypass the Insulated component? + /// Whether the entity was stunned by the shock. + public virtual bool TryDoElectrocution( + EntityUid uid, EntityUid? sourceUid, int shockDamage, TimeSpan time, bool refresh, float siemensCoefficient = 1f, + StatusEffectsComponent? statusEffects = null, bool ignoreInsulation = false) + { + // only done serverside + return false; + } + private void OnInsulatedElectrocutionAttempt(EntityUid uid, InsulatedComponent insulated, ElectrocutionAttemptEvent args) { args.SiemensCoefficient *= insulated.SiemensCoefficient; diff --git a/Content.Shared/Interaction/SharedInteractionSystem.cs b/Content.Shared/Interaction/SharedInteractionSystem.cs index 7d8de660a2..2caaa23a2b 100644 --- a/Content.Shared/Interaction/SharedInteractionSystem.cs +++ b/Content.Shared/Interaction/SharedInteractionSystem.cs @@ -449,7 +449,6 @@ namespace Content.Shared.Interaction // Have to be on same map regardless. if (other.MapId != origin.MapId) return false; - var dir = other.Position - origin.Position; var length = dir.Length; diff --git a/Content.Shared/Ninja/Components/EnergyKatanaComponent.cs b/Content.Shared/Ninja/Components/EnergyKatanaComponent.cs new file mode 100644 index 0000000000..77ba537741 --- /dev/null +++ b/Content.Shared/Ninja/Components/EnergyKatanaComponent.cs @@ -0,0 +1,66 @@ +using Content.Shared.Actions; +using Content.Shared.Actions.ActionTypes; +using Content.Shared.Ninja.Systems; +using Robust.Shared.Audio; +using Robust.Shared.GameStates; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype; +using Robust.Shared.Serialization; + +namespace Content.Shared.Ninja.Components; + +/// +/// Component for a Space Ninja's katana, controls its dash sound. +/// Requires a ninja with a suit for abilities to work. +/// +// basically emag but without immune tag, TODO: make the charge thing its own component and have emag use it too +[Access(typeof(EnergyKatanaSystem))] +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] +public sealed partial class EnergyKatanaComponent : Component +{ + public EntityUid? Ninja = null; + + /// + /// Sound played when using dash action. + /// + [DataField("blinkSound")] + public SoundSpecifier BlinkSound = new SoundPathSpecifier("/Audio/Magic/blink.ogg"); + + /// + /// Volume control for katana dash action. + /// + [DataField("blinkVolume")] + public float BlinkVolume = 5f; + + /// + /// The maximum number of dash charges the katana can have + /// + [DataField("maxCharges"), ViewVariables(VVAccess.ReadWrite), AutoNetworkedField] + public int MaxCharges = 3; + + /// + /// The current number of dash charges on the katana + /// + [DataField("charges"), ViewVariables(VVAccess.ReadWrite), AutoNetworkedField] + public int Charges = 3; + + /// + /// Whether or not the katana automatically recharges over time. + /// + [DataField("autoRecharge"), ViewVariables(VVAccess.ReadWrite), AutoNetworkedField] + public bool AutoRecharge = true; + + /// + /// The time it takes to regain a single dash charge + /// + [DataField("rechargeDuration"), ViewVariables(VVAccess.ReadWrite), AutoNetworkedField] + public TimeSpan RechargeDuration = TimeSpan.FromSeconds(20); + + /// + /// The time when the next dash charge will be added + /// + [DataField("nextChargeTime", customTypeSerializer: typeof(TimeOffsetSerializer)), ViewVariables(VVAccess.ReadWrite), AutoNetworkedField] + public TimeSpan NextChargeTime = TimeSpan.MaxValue; +} + +public sealed class KatanaDashEvent : WorldTargetActionEvent { } diff --git a/Content.Shared/Ninja/Components/NinjaComponent.cs b/Content.Shared/Ninja/Components/NinjaComponent.cs new file mode 100644 index 0000000000..851be9a5a9 --- /dev/null +++ b/Content.Shared/Ninja/Components/NinjaComponent.cs @@ -0,0 +1,69 @@ +using Content.Shared.Ninja.Systems; +using Robust.Shared.GameStates; +using Robust.Shared.Serialization; + +namespace Content.Shared.Ninja.Components; + +/// +/// Component placed on a mob to make it a space ninja, able to use suit and glove powers. +/// Contains ids of all ninja equipment. +/// +// TODO: Contains objective related stuff, might want to move it out somehow +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] +[Access(typeof(SharedNinjaSystem))] +public sealed partial class NinjaComponent : Component +{ + /// + /// Grid entity of the station the ninja was spawned around. Set if spawned naturally by the event. + /// + public EntityUid? StationGrid; + + /// + /// Currently worn suit + /// + [ViewVariables] + public EntityUid? Suit = null; + + /// + /// Currently worn gloves + /// + [ViewVariables] + public EntityUid? Gloves = null; + + /// + /// Bound katana, set once picked up and never removed + /// + [ViewVariables] + public EntityUid? Katana = null; + + /// + /// Number of doors that have been doorjacked, used for objective + /// + [ViewVariables, AutoNetworkedField] + public int DoorsJacked = 0; + + /// + /// Research nodes that have been downloaded, used for objective + /// + // TODO: client doesn't need to know what nodes are downloaded, just how many + [ViewVariables, AutoNetworkedField] + public HashSet DownloadedNodes = new(); + + /// + /// Warp point that the spider charge has to target + /// + [ViewVariables, AutoNetworkedField] + public EntityUid? SpiderChargeTarget = null; + + /// + /// Whether the spider charge has been detonated on the target, used for objective + /// + [ViewVariables, AutoNetworkedField] + public bool SpiderChargeDetonated; + + /// + /// Whether the comms console has been hacked, used for objective + /// + [ViewVariables, AutoNetworkedField] + public bool CalledInThreat; +} diff --git a/Content.Shared/Ninja/Components/NinjaGlovesComponent.cs b/Content.Shared/Ninja/Components/NinjaGlovesComponent.cs new file mode 100644 index 0000000000..58b760a086 --- /dev/null +++ b/Content.Shared/Ninja/Components/NinjaGlovesComponent.cs @@ -0,0 +1,151 @@ +using Content.Shared.Actions; +using Content.Shared.Actions.ActionTypes; +using Content.Shared.DoAfter; +using Content.Shared.Ninja.Systems; +using Content.Shared.Tag; +using Content.Shared.Toggleable; +using Robust.Shared.Audio; +using Robust.Shared.GameStates; +using Robust.Shared.Serialization; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype; +using Robust.Shared.Utility; +using System.Threading; + +namespace Content.Shared.Ninja.Components; + +/// +/// Component for toggling glove powers. +/// Powers being enabled is controlled by GlovesEnabledComponent +/// +[Access(typeof(SharedNinjaGlovesSystem))] +[RegisterComponent, NetworkedComponent] +public sealed class NinjaGlovesComponent : Component +{ + /// + /// Entity of the ninja using these gloves, usually means enabled + /// + [ViewVariables] + public EntityUid? User; + + /// + /// The action for toggling ninja gloves abilities + /// + [DataField("toggleAction")] + public InstantAction ToggleAction = new() + { + DisplayName = "action-name-toggle-ninja-gloves", + Description = "action-desc-toggle-ninja-gloves", + Priority = -13, + Event = new ToggleActionEvent() + }; +} + +/// +/// Component for emagging doors on click, when gloves are enabled. +/// Only works on entities with DoorComponent. +/// +[RegisterComponent] +public sealed class NinjaDoorjackComponent : Component +{ + /// + /// The tag that marks an entity as immune to doorjacking + /// + [DataField("emagImmuneTag", customTypeSerializer: typeof(PrototypeIdSerializer))] + public string EmagImmuneTag = "EmagImmune"; +} + +/// +/// Component for stunning mobs on click, when gloves are enabled. +/// Knocks them down for a bit and deals shock damage. +/// +[RegisterComponent] +public sealed class NinjaStunComponent : Component +{ + /// + /// Joules required in the suit to stun someone. Defaults to 10 uses on a small battery. + /// + [DataField("stunCharge")] + public float StunCharge = 36.0f; + + /// + /// Shock damage dealt when stunning someone + /// + [DataField("stunDamage")] + public int StunDamage = 5; + + /// + /// Time that someone is stunned for, stacks if done multiple times. + /// + [DataField("stunTime")] + public TimeSpan StunTime = TimeSpan.FromSeconds(3); +} + +/// +/// Component for draining power from APCs/substations/SMESes, when gloves are enabled. +/// +[RegisterComponent] +public sealed class NinjaDrainComponent : Component +{ + /// + /// Conversion rate between joules in a device and joules added to suit. + /// Should be very low since powercells store nothing compared to even an APC. + /// + [DataField("drainEfficiency")] + public float DrainEfficiency = 0.001f; + + /// + /// Time that the do after takes to drain charge from a battery, in seconds + /// + [DataField("drainTime")] + public float DrainTime = 1f; + + [DataField("sparkSound")] + public SoundSpecifier SparkSound = new SoundCollectionSpecifier("sparks"); +} + +/// +/// Component for downloading research nodes from a R&D server, when gloves are enabled. +/// Requirement for greentext. +/// +[RegisterComponent] +public sealed class NinjaDownloadComponent : Component +{ + /// + /// Time taken to download research from a server + /// + [DataField("downloadTime")] + public float DownloadTime = 20f; +} + + +/// +/// Component for hacking a communications console to call in a threat. +/// Called threat is rolled from the ninja gamerule config. +/// +[RegisterComponent] +public sealed class NinjaTerrorComponent : Component +{ + /// + /// Time taken to hack the console + /// + [DataField("terrorTime")] + public float TerrorTime = 20f; +} + +/// +/// DoAfter event for drain ability. +/// +[Serializable, NetSerializable] +public sealed class DrainDoAfterEvent : SimpleDoAfterEvent { } + +/// +/// DoAfter event for research download ability. +/// +[Serializable, NetSerializable] +public sealed class DownloadDoAfterEvent : SimpleDoAfterEvent { } + +/// +/// DoAfter event for comms console terror ability. +/// +[Serializable, NetSerializable] +public sealed class TerrorDoAfterEvent : SimpleDoAfterEvent { } diff --git a/Content.Shared/Ninja/Components/NinjaSuitComponent.cs b/Content.Shared/Ninja/Components/NinjaSuitComponent.cs new file mode 100644 index 0000000000..575940da11 --- /dev/null +++ b/Content.Shared/Ninja/Components/NinjaSuitComponent.cs @@ -0,0 +1,148 @@ +using Content.Shared.Actions; +using Content.Shared.Actions.ActionTypes; +using Content.Shared.Ninja.Systems; +using Robust.Shared.GameStates; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype; +using Robust.Shared.Utility; + +namespace Content.Shared.Ninja.Components; + +// TODO: ResourcePath -> ResPath when thing gets merged + +/// +/// Component for ninja suit abilities and power consumption. +/// As an implementation detail, dashing with katana is a suit action which isn't ideal. +/// +[Access(typeof(SharedNinjaSuitSystem))] +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] +public sealed partial class NinjaSuitComponent : Component +{ + [ViewVariables, AutoNetworkedField] + public bool Cloaked = false; + + /// + /// The action for toggling suit phase cloak ability + /// + [DataField("togglePhaseCloakAction")] + public InstantAction TogglePhaseCloakAction = new() + { + UseDelay = TimeSpan.FromSeconds(5), // have to plan un/cloaking ahead of time + DisplayName = "action-name-toggle-phase-cloak", + Description = "action-desc-toggle-phase-cloak", + Priority = -9, + Event = new TogglePhaseCloakEvent() + }; + + /// + /// Battery charge used passively, in watts. Will last 1000 seconds on a small-capacity power cell. + /// + [DataField("passiveWattage")] + public float PassiveWattage = 0.36f; + + /// + /// Battery charge used while cloaked, stacks with passive. Will last 200 seconds while cloaked on a small-capacity power cell. + /// + [DataField("cloakWattage")] + public float CloakWattage = 1.44f; + + /// + /// The action for creating throwing soap, in place of ninja throwing stars since embedding doesn't exist. + /// + [DataField("createSoapAction")] + public InstantAction CreateSoapAction = new() + { + UseDelay = TimeSpan.FromSeconds(10), + Icon = new SpriteSpecifier.Rsi(new ResourcePath("Objects/Specific/Janitorial/soap.rsi"), "soap"), + ItemIconStyle = ItemActionIconStyle.NoItem, + DisplayName = "action-name-create-soap", + Description = "action-desc-create-soap", + Priority = -10, + Event = new CreateSoapEvent() + }; + + /// + /// Battery charge used to create a throwing soap. Can do it 25 times on a small-capacity power cell. + /// + [DataField("soapCharge")] + public float SoapCharge = 14.4f; + + /// + /// Soap item to create with the action + /// + [DataField("soapPrototype", customTypeSerializer: typeof(PrototypeIdSerializer))] + public string SoapPrototype = "SoapNinja"; + + /// + /// The action for recalling a bound energy katana + /// + [DataField("recallkatanaAction")] + public InstantAction RecallKatanaAction = new() + { + UseDelay = TimeSpan.FromSeconds(1), + Icon = new SpriteSpecifier.Rsi(new ResourcePath("Objects/Weapons/Melee/energykatana.rsi"), "icon"), + ItemIconStyle = ItemActionIconStyle.NoItem, + DisplayName = "action-name-recall-katana", + Description = "action-desc-recall-katana", + Priority = -11, + Event = new RecallKatanaEvent() + }; + + /// + /// The action for dashing somewhere using katana + /// + [DataField("katanaDashAction")] + public WorldTargetAction KatanaDashAction = new() + { + Icon = new SpriteSpecifier.Rsi(new ResourcePath("Objects/Magic/magicactions.rsi"), "blink"), + ItemIconStyle = ItemActionIconStyle.NoItem, + DisplayName = "action-name-katana-dash", + Description = "action-desc-katana-dash", + Priority = -12, + Event = new KatanaDashEvent(), + // doing checks manually + CheckCanAccess = false, + Range = 0f + }; + + /// + /// The action for creating an EMP burst + /// + [DataField("empAction")] + public InstantAction EmpAction = new() + { + Icon = new SpriteSpecifier.Rsi(new ResourcePath("Objects/Weapons/Grenades/empgrenade.rsi"), "icon"), + ItemIconStyle = ItemActionIconStyle.BigAction, + DisplayName = "action-name-em-burst", + Description = "action-desc-em-burst", + Priority = -13, + Event = new NinjaEmpEvent() + }; + + /// + /// Battery charge used to create an EMP burst. Can do it 2 times on a small-capacity power cell. + /// + [DataField("empCharge")] + public float EmpCharge = 180f; + + /// + /// Range of the EMP in tiles. + /// + [DataField("empRange")] + public float EmpRange = 6f; + + /// + /// Power consumed from batteries by the EMP + /// + [DataField("empConsumption")] + public float EmpConsumption = 100000f; +} + +public sealed class TogglePhaseCloakEvent : InstantActionEvent { } + +public sealed class CreateSoapEvent : InstantActionEvent { } + +public sealed class RecallKatanaEvent : InstantActionEvent { } + +public sealed class NinjaEmpEvent : InstantActionEvent { } diff --git a/Content.Shared/Ninja/Components/SpiderChargeComponent.cs b/Content.Shared/Ninja/Components/SpiderChargeComponent.cs new file mode 100644 index 0000000000..221716c599 --- /dev/null +++ b/Content.Shared/Ninja/Components/SpiderChargeComponent.cs @@ -0,0 +1,17 @@ +namespace Content.Shared.Ninja.Components; + +/// +/// Component for the Space Ninja's unique Spider Charge. +/// Only this component detonating can trigger the ninja's objective. +/// +[RegisterComponent] +public sealed class SpiderChargeComponent : Component +{ + /// Range for planting within the target area + [DataField("range")] + public float Range = 10f; + + /// The ninja that planted this charge + [ViewVariables] + public EntityUid? Planter = null; +} diff --git a/Content.Shared/Ninja/Systems/EnergyKatanaSystem.cs b/Content.Shared/Ninja/Systems/EnergyKatanaSystem.cs new file mode 100644 index 0000000000..b7adb5415a --- /dev/null +++ b/Content.Shared/Ninja/Systems/EnergyKatanaSystem.cs @@ -0,0 +1,147 @@ +using Content.Shared.Examine; +using Content.Shared.Hands.EntitySystems; +using Content.Shared.Interaction; +using Content.Shared.Inventory.Events; +using Content.Shared.Ninja.Components; +using Content.Shared.Physics; +using Content.Shared.Popups; +using Robust.Shared.Audio; +using Robust.Shared.GameObjects; +using Robust.Shared.Timing; + +namespace Content.Shared.Ninja.Systems; + +/// +/// System for katana dashing, recharging and what not. +/// +// TODO: move all recharging stuff into its own system and use for emag too +public sealed class EnergyKatanaSystem : EntitySystem +{ + [Dependency] private readonly SharedAudioSystem _audio = default!; + [Dependency] private readonly SharedHandsSystem _hands = default!; + [Dependency] private readonly SharedInteractionSystem _interaction = default!; + [Dependency] private readonly SharedNinjaSystem _ninja = default!; + [Dependency] private readonly SharedPopupSystem _popups = default!; + [Dependency] private readonly IGameTiming _timing = default!; + [Dependency] private readonly SharedTransformSystem _transform = default!; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnEquipped); + SubscribeLocalEvent(OnExamine); + SubscribeLocalEvent(OnDash); + SubscribeLocalEvent(OnUnpaused); + } + + private void OnEquipped(EntityUid uid, EnergyKatanaComponent comp, GotEquippedEvent args) + { + // check if already bound + if (comp.Ninja != null) + return; + + // check if ninja already has a katana bound + var user = args.Equipee; + if (!TryComp(user, out var ninja) || ninja.Katana != null) + return; + + // bind it + comp.Ninja = user; + _ninja.BindKatana(ninja, uid); + } + + private void OnUnpaused(EntityUid uid, EnergyKatanaComponent component, ref EntityUnpausedEvent args) + { + component.NextChargeTime += args.PausedTime; + } + + private void OnExamine(EntityUid uid, EnergyKatanaComponent component, ExaminedEvent args) + { + args.PushMarkup(Loc.GetString("emag-charges-remaining", ("charges", component.Charges))); + if (component.Charges == component.MaxCharges) + { + args.PushMarkup(Loc.GetString("emag-max-charges")); + return; + } + var timeRemaining = Math.Round((component.NextChargeTime - _timing.CurTime).TotalSeconds); + args.PushMarkup(Loc.GetString("emag-recharging", ("seconds", timeRemaining))); + } + + // TODO: remove and use LimitedCharges+AutoRecharge + public override void Update(float frameTime) + { + base.Update(frameTime); + + foreach (var comp in EntityQuery()) + { + if (!comp.AutoRecharge) + continue; + + if (comp.Charges == comp.MaxCharges) + continue; + + if (_timing.CurTime < comp.NextChargeTime) + continue; + + ChangeCharge(comp.Owner, 1, true, comp); + } + } + + public void OnDash(EntityUid suit, NinjaSuitComponent comp, KatanaDashEvent args) + { + var user = args.Performer; + args.Handled = true; + if (!TryComp(user, out var ninja) || ninja.Katana == null) + return; + + var uid = ninja.Katana.Value; + if (!TryComp(uid, out var katana) || !_hands.IsHolding(user, uid, out var _)) + { + _popups.PopupEntity(Loc.GetString("ninja-katana-not-held"), user, user); + return; + } + + if (katana.Charges <= 0) + { + _popups.PopupEntity(Loc.GetString("emag-no-charges"), user, user); + return; + } + + // TODO: check that target is not dense + var origin = Transform(user).MapPosition; + var target = args.Target.ToMap(EntityManager, _transform); + // prevent collision with the user duh + if (!_interaction.InRangeUnobstructed(origin, target, 0f, CollisionGroup.Opaque, uid => uid == user)) + { + // can only dash if the destination is visible on screen + _popups.PopupEntity(Loc.GetString("ninja-katana-cant-see"), user, user); + return; + } + + _transform.SetCoordinates(user, args.Target); + _transform.AttachToGridOrMap(user); + _audio.PlayPvs(katana.BlinkSound, user, AudioParams.Default.WithVolume(katana.BlinkVolume)); + // TODO: show the funny green man thing + ChangeCharge(uid, -1, false, katana); + } + + /// + /// Changes the charge on an energy katana. + /// + public bool ChangeCharge(EntityUid uid, int change, bool resetTimer, EnergyKatanaComponent? katana = null) + { + if (!Resolve(uid, ref katana)) + return false; + + if (katana.Charges + change < 0 || katana.Charges + change > katana.MaxCharges) + return false; + + if (resetTimer || katana.Charges == katana.MaxCharges) + katana.NextChargeTime = _timing.CurTime + katana.RechargeDuration; + + katana.Charges += change; + Dirty(katana); + return true; + } +} diff --git a/Content.Shared/Ninja/Systems/NinjaGlovesSystem.cs b/Content.Shared/Ninja/Systems/NinjaGlovesSystem.cs new file mode 100644 index 0000000000..bccb3d9521 --- /dev/null +++ b/Content.Shared/Ninja/Systems/NinjaGlovesSystem.cs @@ -0,0 +1,314 @@ +using Content.Shared.Actions; +using Content.Shared.Administration.Logs; +using Content.Shared.CombatMode; +using Content.Shared.Damage.Components; +using Content.Shared.Database; +using Content.Shared.Doors.Components; +using Content.Shared.DoAfter; +using Content.Shared.Electrocution; +using Content.Shared.Emag.Systems; +using Content.Shared.Examine; +using Content.Shared.Hands.Components; +using Content.Shared.IdentityManagement; +using Content.Shared.Interaction; +using Content.Shared.Interaction.Components; +using Content.Shared.Interaction.Events; +using Content.Shared.Inventory.Events; +using Content.Shared.Ninja.Components; +using Content.Shared.Popups; +using Content.Shared.Research.Components; +using Content.Shared.Tag; +using Content.Shared.Timing; +using Content.Shared.Toggleable; +using Robust.Shared.Network; +using Robust.Shared.Timing; +using System.Diagnostics.CodeAnalysis; + +namespace Content.Shared.Ninja.Systems; + +public abstract class SharedNinjaGlovesSystem : EntitySystem +{ + [Dependency] private readonly ISharedAdminLogManager _adminLogger = default!; + [Dependency] private readonly SharedCombatModeSystem _combatMode = default!; + [Dependency] protected readonly SharedDoAfterSystem _doAfter = default!; + [Dependency] private readonly SharedElectrocutionSystem _electrocution = default!; + [Dependency] private readonly EmagSystem _emag = default!; + [Dependency] private readonly SharedInteractionSystem _interaction = default!; + [Dependency] private readonly INetManager _net = default!; + [Dependency] private readonly SharedNinjaSystem _ninja = default!; + [Dependency] private readonly SharedPopupSystem _popups = default!; + [Dependency] private readonly TagSystem _tags = default!; + [Dependency] private readonly IGameTiming _timing = default!; + [Dependency] private readonly UseDelaySystem _useDelay = default!; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnGetItemActions); + SubscribeLocalEvent(OnExamined); + SubscribeLocalEvent(OnToggleAction); + SubscribeLocalEvent(OnUnequipped); + + SubscribeLocalEvent(OnDoorjack); + + SubscribeLocalEvent(OnStun); + + SubscribeLocalEvent(OnDrain); + SubscribeLocalEvent(OnDrainDoAfter); + + SubscribeLocalEvent(OnDownload); + SubscribeLocalEvent(OnDownloadDoAfter); + + SubscribeLocalEvent(OnTerror); + SubscribeLocalEvent(OnTerrorDoAfter); + } + + /// + /// Disable glove abilities and show the popup if they were enabled previously. + /// + public void DisableGloves(NinjaGlovesComponent comp, EntityUid user) + { + if (comp.User != null) + { + comp.User = null; + _popups.PopupEntity(Loc.GetString("ninja-gloves-off"), user, user); + } + } + + private void OnGetItemActions(EntityUid uid, NinjaGlovesComponent comp, GetItemActionsEvent args) + { + args.Actions.Add(comp.ToggleAction); + } + + private void OnToggleAction(EntityUid uid, NinjaGlovesComponent comp, ToggleActionEvent args) + { + // client prediction desyncs it hard + if (args.Handled || !_timing.IsFirstTimePredicted) + return; + + args.Handled = true; + + var user = args.Performer; + // need to wear suit to enable gloves + if (!TryComp(user, out var ninja) + || ninja.Suit == null + || !HasComp(ninja.Suit.Value)) + { + ClientPopup(Loc.GetString("ninja-gloves-not-wearing-suit"), user); + return; + } + + var enabling = comp.User == null; + var message = Loc.GetString(enabling ? "ninja-gloves-on" : "ninja-gloves-off"); + ClientPopup(message, user); + + if (enabling) + { + comp.User = user; + _ninja.AssignGloves(ninja, uid); + // set up interaction relay for handling glove abilities, comp.User is used to see the actual user of the events + _interaction.SetRelay(user, uid, EnsureComp(user)); + } + else + { + comp.User = null; + _ninja.AssignGloves(ninja, null); + RemComp(user); + } + } + + private void OnExamined(EntityUid uid, NinjaGlovesComponent comp, ExaminedEvent args) + { + if (!args.IsInDetailsRange) + return; + + args.PushText(Loc.GetString(comp.User != null ? "ninja-gloves-examine-on" : "ninja-gloves-examine-off")); + } + + private void OnUnequipped(EntityUid uid, NinjaGlovesComponent comp, GotUnequippedEvent args) + { + comp.User = null; + if (TryComp(args.Equipee, out var ninja)) + _ninja.AssignGloves(ninja, null); + } + + /// + /// Helper for glove ability handlers, checks gloves, range, combat mode and stuff. + /// + protected bool GloveCheck(EntityUid uid, InteractionAttemptEvent args, [NotNullWhen(true)] out NinjaGlovesComponent? gloves, + out EntityUid user, out EntityUid target) + { + if (args.Target != null && TryComp(uid, out gloves) + && gloves.User != null + && !_combatMode.IsInCombatMode(gloves.User) + && _timing.IsFirstTimePredicted + && TryComp(gloves.User, out var hands) + && hands.ActiveHandEntity == null) + { + user = gloves.User.Value; + target = args.Target.Value; + + if (_interaction.InRangeUnobstructed(user, target)) + return true; + } + + gloves = null; + user = target = EntityUid.Invalid; + return false; + } + + private void OnDoorjack(EntityUid uid, NinjaDoorjackComponent comp, InteractionAttemptEvent args) + { + if (!GloveCheck(uid, args, out var gloves, out var user, out var target)) + return; + + // only allowed to emag non-immune doors + if (!HasComp(target) || _tags.HasTag(target, comp.EmagImmuneTag)) + return; + + var handled = _emag.DoEmagEffect(user, target); + if (!handled) + return; + + ClientPopup(Loc.GetString("ninja-doorjack-success", ("target", Identity.Entity(target, EntityManager))), user, PopupType.Medium); + _adminLogger.Add(LogType.Emag, LogImpact.High, $"{ToPrettyString(user):player} doorjacked {ToPrettyString(target):target}"); + } + + private void OnStun(EntityUid uid, NinjaStunComponent comp, InteractionAttemptEvent args) + { + if (!GloveCheck(uid, args, out var gloves, out var user, out var target)) + return; + + // short cooldown to prevent instant stunlocking + if (_useDelay.ActiveDelay(uid)) + return; + + // battery can't be predicted since it's serverside + if (user == target || _net.IsClient || !HasComp(target)) + return; + + // take charge from battery + if (!_ninja.TryUseCharge(user, comp.StunCharge)) + { + _popups.PopupEntity(Loc.GetString("ninja-no-power"), user, user); + return; + } + + // not holding hands with target so insuls don't matter + _electrocution.TryDoElectrocution(target, uid, comp.StunDamage, comp.StunTime, false, ignoreInsulation: true); + _useDelay.BeginDelay(uid); + } + + // can't predict PNBC existing so only done on server. + protected virtual void OnDrain(EntityUid uid, NinjaDrainComponent comp, InteractionAttemptEvent args) { } + + private void OnDrainDoAfter(EntityUid uid, NinjaDrainComponent comp, DrainDoAfterEvent args) + { + if (args.Cancelled || args.Handled || args.Target == null) + return; + + _ninja.TryDrainPower(args.User, comp, args.Target.Value); + } + + private void OnDownload(EntityUid uid, NinjaDownloadComponent comp, InteractionAttemptEvent args) + { + if (!GloveCheck(uid, args, out var gloves, out var user, out var target)) + return; + + // can only hack the server, not a random console + if (!TryComp(target, out var database) || HasComp(target)) + return; + + // fail fast if theres no tech right now + if (database.TechnologyIds.Count == 0) + { + ClientPopup(Loc.GetString("ninja-download-fail"), user); + return; + } + + var doAfterArgs = new DoAfterArgs(user, comp.DownloadTime, new DownloadDoAfterEvent(), target: target, used: uid, eventTarget: uid) + { + BreakOnDamage = true, + BreakOnUserMove = true, + MovementThreshold = 0.5f, + CancelDuplicate = false + }; + + _doAfter.TryStartDoAfter(doAfterArgs); + args.Cancel(); + } + + private void OnDownloadDoAfter(EntityUid uid, NinjaDownloadComponent comp, DownloadDoAfterEvent args) + { + if (args.Cancelled || args.Handled) + return; + + var user = args.User; + var target = args.Target; + + if (!TryComp(user, out var ninja) + || !TryComp(target, out var database)) + return; + + var gained = _ninja.Download(ninja, database.TechnologyIds); + var str = gained == 0 + ? Loc.GetString("ninja-download-fail") + : Loc.GetString("ninja-download-success", ("count", gained), ("server", target)); + + _popups.PopupEntity(str, user, user, PopupType.Medium); + } + + private void OnTerror(EntityUid uid, NinjaTerrorComponent comp, InteractionAttemptEvent args) + { + if (!GloveCheck(uid, args, out var gloves, out var user, out var target) + || !TryComp(user, out var ninja)) + return; + + if (!IsCommsConsole(target)) + return; + + // can only do it once + if (ninja.CalledInThreat) + { + _popups.PopupEntity(Loc.GetString("ninja-terror-already-called"), user, user); + return; + } + + var doAfterArgs = new DoAfterArgs(user, comp.TerrorTime, new TerrorDoAfterEvent(), target: target, used: uid, eventTarget: uid) + { + BreakOnDamage = true, + BreakOnUserMove = true, + MovementThreshold = 0.5f, + CancelDuplicate = false + }; + + _doAfter.TryStartDoAfter(doAfterArgs); + // FIXME: doesnt work, don't show the console popup + args.Cancel(); + } + + //for some reason shared comms console component isn't a component, so this has to be done server-side + protected virtual bool IsCommsConsole(EntityUid uid) + { + return false; + } + + private void OnTerrorDoAfter(EntityUid uid, NinjaTerrorComponent comp, TerrorDoAfterEvent args) + { + if (args.Cancelled || args.Handled) + return; + + var user = args.User; + if (!TryComp(user, out var ninja) || ninja.CalledInThreat) + return; + + _ninja.CallInThreat(ninja); + } + + private void ClientPopup(string msg, EntityUid user, PopupType type = PopupType.Small) + { + if (_net.IsClient) + _popups.PopupEntity(msg, user, user, type); + } +} diff --git a/Content.Shared/Ninja/Systems/NinjaSuitSystem.cs b/Content.Shared/Ninja/Systems/NinjaSuitSystem.cs new file mode 100644 index 0000000000..3090218865 --- /dev/null +++ b/Content.Shared/Ninja/Systems/NinjaSuitSystem.cs @@ -0,0 +1,151 @@ +using Content.Shared.Actions; +using Content.Shared.Inventory.Events; +using Content.Shared.Ninja.Components; +using Content.Shared.Stealth; +using Content.Shared.Stealth.Components; +using Content.Shared.Timing; +using Robust.Shared.Network; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization; + +namespace Content.Shared.Ninja.Systems; + +public abstract class SharedNinjaSuitSystem : EntitySystem +{ + [Dependency] private readonly SharedNinjaGlovesSystem _gloves = default!; + [Dependency] protected readonly SharedNinjaSystem _ninja = default!; + [Dependency] private readonly SharedStealthSystem _stealth = default!; + [Dependency] protected readonly UseDelaySystem _useDelay = default!; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnEquipped); + SubscribeLocalEvent(OnGetItemActions); + SubscribeLocalEvent(OnUnequipped); + + SubscribeNetworkEvent(OnSetCloakedMessage); + } + + private void OnEquipped(EntityUid uid, NinjaSuitComponent comp, GotEquippedEvent args) + { + var user = args.Equipee; + if (!TryComp(user, out var ninja)) + return; + + NinjaEquippedSuit(uid, comp, user, ninja); + } + + private void OnGetItemActions(EntityUid uid, NinjaSuitComponent comp, GetItemActionsEvent args) + { + args.Actions.Add(comp.TogglePhaseCloakAction); + args.Actions.Add(comp.RecallKatanaAction); + // TODO: ninja stars instead of soap, when embedding is a thing + // The cooldown should also be reduced from 10 to 1 or so + args.Actions.Add(comp.CreateSoapAction); + args.Actions.Add(comp.KatanaDashAction); + args.Actions.Add(comp.EmpAction); + } + + private void OnUnequipped(EntityUid uid, NinjaSuitComponent comp, GotUnequippedEvent args) + { + UserUnequippedSuit(uid, comp, args.Equipee); + } + + /// + /// Called when a suit is equipped by a space ninja. + /// In the future it might be changed to an explicit activation toggle/verb like gloves are. + /// + protected virtual void NinjaEquippedSuit(EntityUid uid, NinjaSuitComponent comp, EntityUid user, NinjaComponent ninja) + { + // mark the user as wearing this suit, used when being attacked among other things + _ninja.AssignSuit(ninja, uid); + + // initialize phase cloak + EnsureComp(user); + SetCloaked(user, comp.Cloaked); + } + + /// + /// Force uncloak the user, disables suit abilities if the bool is set. + /// + public void RevealNinja(EntityUid uid, NinjaSuitComponent comp, EntityUid user, bool disableAbilities = false) + { + if (comp.Cloaked) + { + comp.Cloaked = false; + SetCloaked(user, false); + // TODO: add the box open thing its funny + + if (disableAbilities) + _useDelay.BeginDelay(uid); + } + } + + /// + /// Returns the power used by a suit + /// + public float SuitWattage(NinjaSuitComponent suit) + { + float wattage = suit.PassiveWattage; + if (suit.Cloaked) + wattage += suit.CloakWattage; + return wattage; + } + + /// + /// Sets the stealth effect for a ninja cloaking. + /// Does not update suit Cloaked field, has to be done yourself. + /// + protected void SetCloaked(EntityUid user, bool cloaked) + { + if (!TryComp(user, out var stealth) || stealth.Deleted) + return; + + // slightly visible, but doesn't change when moving so it's ok + var visibility = cloaked ? stealth.MinVisibility + 0.25f : stealth.MaxVisibility; + _stealth.SetVisibility(user, visibility, stealth); + _stealth.SetEnabled(user, cloaked, stealth); + } + + /// + /// Called when a suit is unequipped, not necessarily by a space ninja. + /// In the future it might be changed to also have explicit deactivation via toggle. + /// + protected virtual void UserUnequippedSuit(EntityUid uid, NinjaSuitComponent comp, EntityUid user) + { + // mark the user as not wearing a suit + if (TryComp(user, out var ninja)) + { + _ninja.AssignSuit(ninja, null); + // disable glove abilities + if (ninja.Gloves != null && TryComp(ninja.Gloves.Value, out var gloves)) + _gloves.DisableGloves(gloves, user); + } + + // force uncloak the user + comp.Cloaked = false; + SetCloaked(user, false); + RemComp(user); + } + + private void OnSetCloakedMessage(SetCloakedMessage msg) + { + if (TryComp(msg.User, out var ninja) && TryComp(ninja.Suit, out var suit)) + { + suit.Cloaked = msg.Cloaked; + SetCloaked(msg.User, msg.Cloaked); + } + } +} + +/// +/// Calls SetCloaked on the client from the server, along with updating the suit Cloaked bool. +/// +[Serializable, NetSerializable] +public sealed class SetCloakedMessage : EntityEventArgs +{ + public EntityUid User; + public bool Cloaked; +} diff --git a/Content.Shared/Ninja/Systems/NinjaSystem.cs b/Content.Shared/Ninja/Systems/NinjaSystem.cs new file mode 100644 index 0000000000..d1b51ad77c --- /dev/null +++ b/Content.Shared/Ninja/Systems/NinjaSystem.cs @@ -0,0 +1,101 @@ +using Content.Shared.Ninja.Components; +using Content.Shared.Weapons.Melee.Events; +using Robust.Shared.GameStates; +using Robust.Shared.Network; + +namespace Content.Shared.Ninja.Systems; + +public abstract class SharedNinjaSystem : EntitySystem +{ + [Dependency] protected readonly SharedNinjaSuitSystem _suit = default!; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnNinjaAttacked); + } + + /// + /// Sets the station grid entity that the ninja was spawned near. + /// + public void SetStationGrid(NinjaComponent comp, EntityUid? grid) + { + comp.StationGrid = grid; + } + + /// + /// Set the ninja's worn suit entity + /// + public void AssignSuit(NinjaComponent comp, EntityUid? suit) + { + comp.Suit = suit; + } + + /// + /// Set the ninja's worn gloves entity + /// + public void AssignGloves(NinjaComponent comp, EntityUid? gloves) + { + comp.Gloves = gloves; + } + + /// + /// Bind a katana entity to a ninja, letting it be recalled and dash. + /// + public void BindKatana(NinjaComponent comp, EntityUid? katana) + { + comp.Katana = katana; + } + + // TODO: remove when objective stuff moved into objectives somehow + public void DetonateSpiderCharge(NinjaComponent comp) + { + comp.SpiderChargeDetonated = true; + } + + /// + /// Marks the objective as complete. + /// On server, makes announcement and adds rule of random threat. + /// + public virtual void CallInThreat(NinjaComponent comp) + { + comp.CalledInThreat = true; + } + + /// + /// Drain power from a target battery into the ninja's suit battery. + /// Serverside only. + /// + public virtual void TryDrainPower(EntityUid user, NinjaDrainComponent drain, EntityUid target) + { + } + + /// + /// Download the given set of nodes, returning how many new nodes were downloaded.' + /// + public int Download(NinjaComponent ninja, List ids) + { + var oldCount = ninja.DownloadedNodes.Count; + ninja.DownloadedNodes.UnionWith(ids); + var newCount = ninja.DownloadedNodes.Count; + return newCount - oldCount; + } + + /// + /// Gets the user's battery and tries to use some charge from it, returning true if successful. + /// Serverside only. + /// + public virtual bool TryUseCharge(EntityUid user, float charge) + { + return false; + } + + private void OnNinjaAttacked(EntityUid uid, NinjaComponent comp, AttackedEvent args) + { + if (comp.Suit != null && TryComp(comp.Suit, out var suit) && suit.Cloaked) + { + _suit.RevealNinja(comp.Suit.Value, suit, uid, true); + } + } +} diff --git a/Resources/Audio/Misc/attributions.yml b/Resources/Audio/Misc/attributions.yml index 1ab305412c..0d65aab7be 100644 --- a/Resources/Audio/Misc/attributions.yml +++ b/Resources/Audio/Misc/attributions.yml @@ -2,3 +2,8 @@ license: "CC-BY-NC-SA-3.0" copyright: "Taken from TG station." source: "https://github.com/tgstation/tgstation/commit/97945e7d08d1457ffc27e46526a48c0453cc95e4" + +- files: ["ninja_greeting.ogg"] + license: "CC-BY-NC-SA-3.0" + copyright: "Taken from TG station." + source: "https://github.com/tgstation/tgstation/commit/b02b93ce2ab891164511a973493cdf951b4120f7" diff --git a/Resources/Audio/Misc/ninja_greeting.ogg b/Resources/Audio/Misc/ninja_greeting.ogg new file mode 100644 index 0000000000..e8f17bdea6 Binary files /dev/null and b/Resources/Audio/Misc/ninja_greeting.ogg differ diff --git a/Resources/Locale/en-US/administration/antag.ftl b/Resources/Locale/en-US/administration/antag.ftl index 529e960fb3..a906eecc56 100644 --- a/Resources/Locale/en-US/administration/antag.ftl +++ b/Resources/Locale/en-US/administration/antag.ftl @@ -2,4 +2,5 @@ verb-categories-antag = Antag ctrl admin-verb-make-traitor = Make the target into a traitor. admin-verb-make-zombie = Zombifies the target immediately. admin-verb-make-nuclear-operative = Make target a into lone Nuclear Operative. -admin-verb-make-pirate = Make the target into a pirate. Note this doesn't configure the game rule. +admin-verb-make-pirate = Make the target into a pirate. Note that this doesn't configure the game rule. +admin-verb-make-space-ninja = Make the target into a space ninja. Note that you must enable the Traitor game rule for the end round summary, as space ninja uses this. diff --git a/Resources/Locale/en-US/alerts/alerts.ftl b/Resources/Locale/en-US/alerts/alerts.ftl index 4b5980696d..de78b8341c 100644 --- a/Resources/Locale/en-US/alerts/alerts.ftl +++ b/Resources/Locale/en-US/alerts/alerts.ftl @@ -83,3 +83,6 @@ alerts-pulled-desc = You're being pulled. Move to break free. alerts-pulling-name = Pulling alerts-pulling-desc = You're pulling something. Click the alert to stop. + +alerts-suit-power-name = Suit Power +alerts-suit-power-desc = How much power your space ninja suit has. diff --git a/Resources/Locale/en-US/game-ticking/game-presets/preset-traitor.ftl b/Resources/Locale/en-US/game-ticking/game-presets/preset-traitor.ftl index 44c4a3cc32..35d29eda74 100644 --- a/Resources/Locale/en-US/game-ticking/game-presets/preset-traitor.ftl +++ b/Resources/Locale/en-US/game-ticking/game-presets/preset-traitor.ftl @@ -19,6 +19,7 @@ traitor-user-was-a-traitor-with-objectives-named = [color=White]{$name}[/color] traitor-was-a-traitor-with-objectives-named = [color=White]{$name}[/color] was a traitor who had the following objectives: preset-traitor-objective-issuer-syndicate = [color=#87cefa]The Syndicate[/color] +preset-traitor-objective-issuer-spiderclan = [color=#33cc00]Spider Clan[/color] # Shown at the end of a round of Traitor traitor-objective-condition-success = {$condition} | [color={$markupColor}]Success![/color] diff --git a/Resources/Locale/en-US/ninja/gloves.ftl b/Resources/Locale/en-US/ninja/gloves.ftl new file mode 100644 index 0000000000..af3b207a0d --- /dev/null +++ b/Resources/Locale/en-US/ninja/gloves.ftl @@ -0,0 +1,7 @@ +ninja-gloves-on = The gloves surge with power! +ninja-gloves-off = The gloves power down... +ninja-gloves-not-wearing-suit = You aren't wearing a ninja suit +ninja-gloves-examine-on = All abilities are enabled. +ninja-gloves-examine-off = Boring old gloves... + +ninja-doorjack-success = The gloves zap something in {THE($target)}. diff --git a/Resources/Locale/en-US/ninja/katana.ftl b/Resources/Locale/en-US/ninja/katana.ftl new file mode 100644 index 0000000000..30fba96c48 --- /dev/null +++ b/Resources/Locale/en-US/ninja/katana.ftl @@ -0,0 +1,4 @@ +ninja-katana-recalled = Your Energy Katana teleports into your hand! +ninja-katana-not-held = You aren't holding your katana! +ninja-katana-cant-see = You can't see that! +ninja-hands-full = Your hands are full! diff --git a/Resources/Locale/en-US/ninja/ninja-actions.ftl b/Resources/Locale/en-US/ninja/ninja-actions.ftl new file mode 100644 index 0000000000..04e63fc6d1 --- /dev/null +++ b/Resources/Locale/en-US/ninja/ninja-actions.ftl @@ -0,0 +1,27 @@ +action-name-toggle-ninja-gloves = Toggle ninja gloves +action-desc-toggle-ninja-gloves = Toggles all glove actions on left click. Includes your doorjack, draining power, stunning enemies, downloading research and calling in a threat. + +action-name-toggle-phase-cloak = Phase cloak +action-desc-toggle-phase-cloak = Toggles your suit's phase cloak. Beware that if you are hit, all abilities are disabled for 5 seconds, including your cloak! +ninja-no-power = Not enough charge in suit battery! + +action-name-create-soap = Create soap +action-desc-create-soap = Channels suit power into creating a bar of ninja soap. The future is now, old man! + +action-name-recall-katana = Recall katana +action-desc-recall-katana = Teleports the Energy Katana linked to this suit to its wearer, cost based on distance. + +action-name-katana-dash = Katana dash +action-desc-katana-dash = Teleport to anywhere you can see, if your Energy Katana is in your hand. + +action-name-em-burst = EM Burst +action-desc-em-burst = Disable any nearby technology with an electro-magnetic pulse. + +ninja-full-power = Suit battery is already full +ninja-drain-empty = {CAPITALIZE(THE($battery))} does not have enough power to drain +ninja-drain-success = You drain power from {THE($battery)}! + +ninja-download-fail = No new research nodes were copied... +ninja-download-success = Copied {$count} new nodes from {THE($server)}. + +ninja-terror-already-called = You already called in a threat! diff --git a/Resources/Locale/en-US/ninja/role.ftl b/Resources/Locale/en-US/ninja/role.ftl new file mode 100644 index 0000000000..45dd7e7b93 --- /dev/null +++ b/Resources/Locale/en-US/ninja/role.ftl @@ -0,0 +1,5 @@ +ninja-role-greeting = + I am an elite mercenary of the mighty Spider Clan! + Surprise is my weapon. Shadows are my armor. Without them, I am nothing. + +ninja-role-greeting-direction = The station is located to your {$direction} at {$position}. diff --git a/Resources/Locale/en-US/ninja/spider-charge.ftl b/Resources/Locale/en-US/ninja/spider-charge.ftl new file mode 100644 index 0000000000..78a7b8688d --- /dev/null +++ b/Resources/Locale/en-US/ninja/spider-charge.ftl @@ -0,0 +1,2 @@ +spider-charge-not-ninja = While it appears normal, you can't seem to detonate the charge. +spider-charge-too-far = This isn't the location you're supposed to use this! diff --git a/Resources/Locale/en-US/ninja/terror.ftl b/Resources/Locale/en-US/ninja/terror.ftl new file mode 100644 index 0000000000..4bc80c0bd6 --- /dev/null +++ b/Resources/Locale/en-US/ninja/terror.ftl @@ -0,0 +1,2 @@ +terror-dragon = Attention crew, it appears that someone on your station has made an unexpected communication with a strange fish in nearby space. +terror-revenant = Attention crew, it appears that someone on your station has made an unexpected communication with an otherworldly energy in nearby space. diff --git a/Resources/Locale/en-US/objectives/conditions/doorjack-condition.ftl b/Resources/Locale/en-US/objectives/conditions/doorjack-condition.ftl new file mode 100644 index 0000000000..cc8c2fc002 --- /dev/null +++ b/Resources/Locale/en-US/objectives/conditions/doorjack-condition.ftl @@ -0,0 +1,2 @@ +objective-condition-doorjack-title = Doorjack {$count} doors on the station. +objective-condition-doorjack-description = Use your gloves to doorjack {$count} airlocks on the station. diff --git a/Resources/Locale/en-US/objectives/conditions/download-condition.ftl b/Resources/Locale/en-US/objectives/conditions/download-condition.ftl new file mode 100644 index 0000000000..cecd7c07c6 --- /dev/null +++ b/Resources/Locale/en-US/objectives/conditions/download-condition.ftl @@ -0,0 +1,2 @@ +objective-condition-download-title = Download {$count} research nodes. +objective-condition-download-description = Use your gloves on a research server to download its data. diff --git a/Resources/Locale/en-US/objectives/conditions/spider-charge-condition.ftl b/Resources/Locale/en-US/objectives/conditions/spider-charge-condition.ftl new file mode 100644 index 0000000000..f24abb670e --- /dev/null +++ b/Resources/Locale/en-US/objectives/conditions/spider-charge-condition.ftl @@ -0,0 +1,3 @@ +objective-condition-spider-charge-title = Detonate the spider charge in {$location} +objective-condition-spider-charge-no-target = Detonate the spider charge... somewhere? +objective-condition-spider-charge-description = Detonate your starter bomb in a specific location. Note that the bomb will not work anywhere else! diff --git a/Resources/Locale/en-US/objectives/conditions/survive-condition.ftl b/Resources/Locale/en-US/objectives/conditions/survive-condition.ftl new file mode 100644 index 0000000000..5c9115a79f --- /dev/null +++ b/Resources/Locale/en-US/objectives/conditions/survive-condition.ftl @@ -0,0 +1,2 @@ +objective-condition-survive-title = Survive +objective-condition-survive-description = You wouldn't be a very good ninja if you died, now would you? diff --git a/Resources/Locale/en-US/objectives/conditions/terror-condition.ftl b/Resources/Locale/en-US/objectives/conditions/terror-condition.ftl new file mode 100644 index 0000000000..104f5782dd --- /dev/null +++ b/Resources/Locale/en-US/objectives/conditions/terror-condition.ftl @@ -0,0 +1,2 @@ +objective-condition-terror-title = Call in a threat +objective-condition-terror-description = Use your gloves on a communication console in order to bring another threat to the station. diff --git a/Resources/Locale/en-US/prototypes/roles/antags.ftl b/Resources/Locale/en-US/prototypes/roles/antags.ftl index fb3e14d677..390b635884 100644 --- a/Resources/Locale/en-US/prototypes/roles/antags.ftl +++ b/Resources/Locale/en-US/prototypes/roles/antags.ftl @@ -18,3 +18,6 @@ roles-antag-nuclear-operative-commander-objective = Lead your team to the destru roles-antag-nuclear-operative-name = Nuclear operative roles-antag-nuclear-operative-objective = Find the nuke disk and blow up the station. + +roles-antag-space-ninja-name = Space Ninja +roles-antag-space-ninja-objective = Energy sword everything, nom on electrical wires. diff --git a/Resources/Prototypes/Alerts/alerts.yml b/Resources/Prototypes/Alerts/alerts.yml index 090256de7a..402fbc3e03 100644 --- a/Resources/Prototypes/Alerts/alerts.yml +++ b/Resources/Prototypes/Alerts/alerts.yml @@ -6,6 +6,7 @@ order: - category: Health - category: Stamina + - alertType: SuitPower - category: Internals - alertType: Fire - alertType: Handcuffed diff --git a/Resources/Prototypes/Alerts/ninja.yml b/Resources/Prototypes/Alerts/ninja.yml new file mode 100644 index 0000000000..7a0c4a7d2f --- /dev/null +++ b/Resources/Prototypes/Alerts/ninja.yml @@ -0,0 +1,21 @@ +- type: alert + id: SuitPower + icons: + - sprite: /Textures/Interface/Alerts/stamina.rsi + state: stamina0 + - sprite: /Textures/Interface/Alerts/stamina.rsi + state: stamina1 + - sprite: /Textures/Interface/Alerts/stamina.rsi + state: stamina2 + - sprite: /Textures/Interface/Alerts/stamina.rsi + state: stamina3 + - sprite: /Textures/Interface/Alerts/stamina.rsi + state: stamina4 + - sprite: /Textures/Interface/Alerts/stamina.rsi + state: stamina5 + - sprite: /Textures/Interface/Alerts/stamina.rsi + state: stamina6 + name: alerts-suit-power-name + description: alerts-suit-power-desc + minSeverity: 0 + maxSeverity: 6 diff --git a/Resources/Prototypes/Catalog/Fills/Backpacks/StarterGear/satchel.yml b/Resources/Prototypes/Catalog/Fills/Backpacks/StarterGear/satchel.yml index cb9f4e9d92..93454dc206 100644 --- a/Resources/Prototypes/Catalog/Fills/Backpacks/StarterGear/satchel.yml +++ b/Resources/Prototypes/Catalog/Fills/Backpacks/StarterGear/satchel.yml @@ -9,6 +9,20 @@ - type: entity noSpawn: true + parent: ClothingBackpackSatchel + id: ClothingBackpackSatchelTools + components: + - type: StorageFill + contents: + - id: BoxSurvival + - id: Crowbar + - id: Wrench + - id: Screwdriver + - id: Wirecutter + - id: Welder + - id: Multitool + +- type: entity parent: ClothingBackpackSatchelClown id: ClothingBackpackSatchelClownFilled components: diff --git a/Resources/Prototypes/Entities/Clothing/Hands/gloves.yml b/Resources/Prototypes/Entities/Clothing/Hands/gloves.yml index 9edab0d4d1..80dcd1ae01 100644 --- a/Resources/Prototypes/Entities/Clothing/Hands/gloves.yml +++ b/Resources/Prototypes/Entities/Clothing/Hands/gloves.yml @@ -203,6 +203,17 @@ - type: Thieving stripTimeReduction: 1 stealthy: true + - type: NinjaGloves + - type: NinjaDoorjack + - type: NinjaDrain + - type: NinjaStun + - type: NinjaDownload + - type: NinjaTerror + # not actually electrified, just used to make stun ability work + - type: Electrified + # delay for stunning to prevent instant stunlocking + - type: UseDelay + delay: 1 - type: entity parent: ClothingHandsBase diff --git a/Resources/Prototypes/Entities/Clothing/Head/helmets.yml b/Resources/Prototypes/Entities/Clothing/Head/helmets.yml index 92a42e8636..19612e133c 100644 --- a/Resources/Prototypes/Entities/Clothing/Head/helmets.yml +++ b/Resources/Prototypes/Entities/Clothing/Head/helmets.yml @@ -137,7 +137,7 @@ - HidesHair - type: entity - parent: ClothingHeadBase + parent: ClothingHeadEVAHelmetBase id: ClothingHeadHelmetSpaceNinja name: space ninja helmet description: What may appear to be a simple black garment is in fact a highly sophisticated nano-weave helmet. Standard issue ninja gear. @@ -146,7 +146,6 @@ sprite: Clothing/Head/Helmets/spaceninja.rsi - type: Clothing sprite: Clothing/Head/Helmets/spaceninja.rsi - - type: IngestionBlocker - type: Tag tags: - HidesHair diff --git a/Resources/Prototypes/Entities/Clothing/OuterClothing/suits.yml b/Resources/Prototypes/Entities/Clothing/OuterClothing/suits.yml index 8254b3e91d..22e7f69970 100644 --- a/Resources/Prototypes/Entities/Clothing/OuterClothing/suits.yml +++ b/Resources/Prototypes/Entities/Clothing/OuterClothing/suits.yml @@ -92,6 +92,13 @@ sprite: Clothing/OuterClothing/Suits/spaceninja.rsi - type: Clothing sprite: Clothing/OuterClothing/Suits/spaceninja.rsi + - type: PressureProtection + highPressureMultiplier: 0.6 + lowPressureMultiplier: 1000 + - type: DiseaseProtection + protection: 0.05 + - type: TemperatureProtection + coefficient: 0.01 - type: Armor modifiers: coefficients: @@ -99,6 +106,22 @@ Slash: 0.6 Piercing: 0.6 Heat: 0.6 + - type: NinjaSuit + - type: PowerCellSlot + cellSlotId: cell_slot + # throwing in a recharger would bypass glove charging mechanic + fitsInCharger: false + - type: ContainerContainer + containers: + cell_slot: !type:ContainerSlot + - type: ItemSlots + slots: + cell_slot: + name: power-cell-slot-component-slot-name-default + startingItem: PowerCellSmall + # delay for when attacked while cloaked + - type: UseDelay + delay: 5 - type: entity parent: ClothingOuterBase diff --git a/Resources/Prototypes/Entities/Clothing/Shoes/specific.yml b/Resources/Prototypes/Entities/Clothing/Shoes/specific.yml index e690d890d6..25a81900b6 100644 --- a/Resources/Prototypes/Entities/Clothing/Shoes/specific.yml +++ b/Resources/Prototypes/Entities/Clothing/Shoes/specific.yml @@ -77,6 +77,10 @@ - type: Clothing sprite: Clothing/Shoes/Specific/spaceninja.rsi - type: NoSlip + - type: ClothingSpeedModifier + # ninja are masters of sneaking around relatively quickly, won't break cloak + walkModifier: 1.1 + sprintModifier: 1.3 - type: entity parent: ClothingShoesBaseButcherable diff --git a/Resources/Prototypes/Entities/Markers/Spawners/ghost_roles.yml b/Resources/Prototypes/Entities/Markers/Spawners/ghost_roles.yml index 5ef59790ac..558f7fb6f4 100644 --- a/Resources/Prototypes/Entities/Markers/Spawners/ghost_roles.yml +++ b/Resources/Prototypes/Entities/Markers/Spawners/ghost_roles.yml @@ -93,3 +93,21 @@ - state: green - sprite: Structures/Wallmounts/signs.rsi state: radiation + +- type: entity + id: SpawnPointGhostSpaceNinja + name: ghost role spawn point + suffix: space ninja + parent: MarkerBase + components: + - type: GhostRoleMobSpawner + prototype: MobHumanSpaceNinja + name: Space Ninja + description: Use stealth and deception to sabotage the station. + rules: You are an elite mercenary of the Spider Clan. You aren't required to follow your objectives, yet your NINJA HONOR demands you try. + - type: Sprite + sprite: Markers/jobs.rsi + layers: + - state: green + - sprite: Objects/Weapons/Melee/energykatana.rsi + state: icon diff --git a/Resources/Prototypes/Entities/Mobs/Player/human.yml b/Resources/Prototypes/Entities/Mobs/Player/human.yml index c373aee051..ca73570c50 100644 --- a/Resources/Prototypes/Entities/Mobs/Player/human.yml +++ b/Resources/Prototypes/Entities/Mobs/Player/human.yml @@ -77,3 +77,26 @@ - type: Faction factions: - Syndicate + +# Space Ninja +- type: entity + noSpawn: true + name: Space Ninja + parent: MobHuman + id: MobHumanSpaceNinja + components: + - type: Loadout + prototype: SpaceNinjaGear + prototypes: [SpaceNinjaGear] + - type: Faction + factions: + - Syndicate + - type: Ninja + - type: RandomMetadata + nameSegments: + - names_ninja_title + - names_ninja + - type: Tag + tags: + # fight with honor! + - GunsDisabled diff --git a/Resources/Prototypes/Entities/Objects/Specific/Janitorial/soap.yml b/Resources/Prototypes/Entities/Objects/Specific/Janitorial/soap.yml index 58f484a1a0..a8c3d51c40 100644 --- a/Resources/Prototypes/Entities/Objects/Specific/Janitorial/soap.yml +++ b/Resources/Prototypes/Entities/Objects/Specific/Janitorial/soap.yml @@ -102,3 +102,18 @@ - type: StepTrigger - type: Item heldPrefix: omega + +- type: entity + name: ninja soap + id: SoapNinja + parent: Soap + description: The most important soap in the entire universe, as without it we would all cease to exist. Smells of honor. + components: + - type: Item + heldPrefix: ninja + # despawn to prevent ninja killing server + - type: TimedDespawn + lifetime: 60 + # no holding ninja hostage and forcing him to make infinite money for cargo + - type: StaticPrice + price: 0 diff --git a/Resources/Prototypes/Entities/Objects/Weapons/Bombs/spider.yml b/Resources/Prototypes/Entities/Objects/Weapons/Bombs/spider.yml new file mode 100644 index 0000000000..28d7a62047 --- /dev/null +++ b/Resources/Prototypes/Entities/Objects/Weapons/Bombs/spider.yml @@ -0,0 +1,47 @@ +- type: entity + name: spider charge + description: A modified C-4 charge supplied to you by the Spider Clan. Its explosive power has been juiced up, but only works in one specific area. + # not actually modified C-4! oh the horror! + parent: BaseItem + id: SpiderCharge + components: + - type: Sprite + sprite: Objects/Weapons/Bombs/spidercharge.rsi + state: icon + - type: Item + sprite: Objects/Weapons/Bombs/spidercharge.rsi + size: 10 + - type: SpiderCharge + - type: OnUseTimerTrigger + delay: 10 + delayOptions: [5, 10, 30, 60] + initialBeepDelay: 0 + beepSound: /Audio/Machines/Nuke/general_beep.ogg + startOnStick: true + - type: AutomatedTimer + - type: Sticky + stickDelay: 5 + stickPopupStart: comp-sticky-start-stick-bomb + stickPopupSuccess: comp-sticky-success-stick-bomb + # can only stick it in target area, no reason to unstick + canUnstick: false + blacklist: # can't stick it to movable things, even if they are in the target area + components: + - Anchorable + - Item + - Body + - type: Explosive # Powerful explosion in a medium radius. Will break underplating. + explosionType: DemolitionCharge + totalIntensity: 60 + intensitySlope: 10 + maxIntensity: 60 + canCreateVacuum: true + - type: ExplodeOnTrigger + - type: StickyVisualizer + - type: Appearance + visuals: + - type: GenericEnumVisualizer + key: enum.Trigger.TriggerVisuals.VisualState + states: + enum.Trigger.TriggerVisualState.Primed: primed + enum.Trigger.TriggerVisualState.Unprimed: complete diff --git a/Resources/Prototypes/Entities/Objects/Weapons/Melee/sword.yml b/Resources/Prototypes/Entities/Objects/Weapons/Melee/sword.yml index c2e4adaf10..633034f6b8 100644 --- a/Resources/Prototypes/Entities/Objects/Weapons/Melee/sword.yml +++ b/Resources/Prototypes/Entities/Objects/Weapons/Melee/sword.yml @@ -43,6 +43,29 @@ sprite: Objects/Weapons/Melee/katana.rsi - type: DisarmMalus +- type: entity + name: energy katana + parent: Katana + id: EnergyKatana + description: A katana infused with strong energy. + components: + - type: Sprite + sprite: Objects/Weapons/Melee/energykatana.rsi + state: icon + - type: MeleeWeapon + damage: + types: + Slash: 30 + - type: Item + size: 15 + sprite: Objects/Weapons/Melee/energykatana.rsi + - type: EnergyKatana + - type: Clothing + sprite: Objects/Weapons/Melee/energykatana.rsi + slots: + - Back + - Belt + - type: entity name: machete parent: BaseItem diff --git a/Resources/Prototypes/GameRules/events.yml b/Resources/Prototypes/GameRules/events.yml index f1736e8a3b..0b6ef2fee6 100644 --- a/Resources/Prototypes/GameRules/events.yml +++ b/Resources/Prototypes/GameRules/events.yml @@ -69,6 +69,28 @@ earliestStart: 15 minimumPlayers: 15 +- type: gameRule + id: SpaceNinjaSpawn + config: + !type:NinjaRuleConfiguration + id: SpaceNinjaSpawn + weight: 10 + endAfter: 1 + earliestStart: 60 + minimumPlayers: 15 + objectives: + - DownloadObjective + - DoorjackObjective + - SpiderChargeObjective + - TerrorObjective + - SurviveObjective + implants: [ MicroBombImplant ] + threats: + - announcement: terror-dragon + rule: Dragon + - announcement: terror-revenant + rule: RevenantSpawn + - type: gameRule id: RevenantSpawn config: diff --git a/Resources/Prototypes/Objectives/ninjaObjectives.yml b/Resources/Prototypes/Objectives/ninjaObjectives.yml new file mode 100644 index 0000000000..d2e1fa4068 --- /dev/null +++ b/Resources/Prototypes/Objectives/ninjaObjectives.yml @@ -0,0 +1,39 @@ +- type: objective + id: DownloadObjective + issuer: spiderclan + requirements: + - !type:TraitorRequirement {} + conditions: + - !type:DownloadCondition {} + +- type: objective + id: DoorjackObjective + issuer: spiderclan + requirements: + - !type:TraitorRequirement {} + conditions: + - !type:DoorjackCondition {} + +- type: objective + id: SpiderChargeObjective + issuer: spiderclan + requirements: + - !type:TraitorRequirement {} + conditions: + - !type:SpiderChargeCondition {} + +- type: objective + id: TerrorObjective + issuer: spiderclan + requirements: + - !type:TraitorRequirement {} + conditions: + - !type:TerrorCondition {} + +- type: objective + id: SurviveObjective + issuer: spiderclan + requirements: + - !type:TraitorRequirement {} + conditions: + - !type:SurviveCondition {} diff --git a/Resources/Prototypes/Roles/Antags/ninja.yml b/Resources/Prototypes/Roles/Antags/ninja.yml new file mode 100644 index 0000000000..cde1235256 --- /dev/null +++ b/Resources/Prototypes/Roles/Antags/ninja.yml @@ -0,0 +1,9 @@ +- type: antag + id: SpaceNinja + name: roles-antag-space-ninja-name + antagonist: true + setPreference: false + objective: roles-antag-space-ninja-objective +# special: +# - !type:AddImplantSpecial +# implants: [ MicroBombImplant ] diff --git a/Resources/Prototypes/Roles/Jobs/Fun/misc_startinggear.yml b/Resources/Prototypes/Roles/Jobs/Fun/misc_startinggear.yml index 71e001d640..b9055b0958 100644 --- a/Resources/Prototypes/Roles/Jobs/Fun/misc_startinggear.yml +++ b/Resources/Prototypes/Roles/Jobs/Fun/misc_startinggear.yml @@ -40,12 +40,23 @@ id: SpaceNinjaGear equipment: jumpsuit: ClothingUniformJumpsuitColorBlack - back: ClothingBackpackFilled + # belt holds katana so satchel has the tools for sabotaging things + back: ClothingBackpackSatchelTools + mask: ClothingMaskGasSyndicate head: ClothingHeadHelmetSpaceNinja + # TODO: space ninja mask + eyes: ClothingEyesGlassesMeson gloves: ClothingHandsGlovesSpaceNinja outerClothing: ClothingOuterSuitSpaceninja shoes: ClothingShoesSpaceNinja - id: PassengerPDA + id: AgentIDCard + ears: ClothingHeadsetGrey + pocket1: SpiderCharge + pocket2: HandheldGPSBasic + belt: EnergyKatana + suitstorage: YellowOxygenTankFilled + inhand: + left hand: JetpackBlackFilled innerclothingskirt: ClothingUniformJumpskirtColorBlack satchel: ClothingBackpackSatchelFilled duffelbag: ClothingBackpackDuffelFilled diff --git a/Resources/Textures/Objects/Weapons/Bombs/spidercharge.rsi/icon.png b/Resources/Textures/Objects/Weapons/Bombs/spidercharge.rsi/icon.png new file mode 100644 index 0000000000..19eeac4947 Binary files /dev/null and b/Resources/Textures/Objects/Weapons/Bombs/spidercharge.rsi/icon.png differ diff --git a/Resources/Textures/Objects/Weapons/Bombs/spidercharge.rsi/inhand-left.png b/Resources/Textures/Objects/Weapons/Bombs/spidercharge.rsi/inhand-left.png new file mode 100644 index 0000000000..0b7ddbf8ff Binary files /dev/null and b/Resources/Textures/Objects/Weapons/Bombs/spidercharge.rsi/inhand-left.png differ diff --git a/Resources/Textures/Objects/Weapons/Bombs/spidercharge.rsi/inhand-right.png b/Resources/Textures/Objects/Weapons/Bombs/spidercharge.rsi/inhand-right.png new file mode 100644 index 0000000000..9147eb7598 Binary files /dev/null and b/Resources/Textures/Objects/Weapons/Bombs/spidercharge.rsi/inhand-right.png differ diff --git a/Resources/Textures/Objects/Weapons/Bombs/spidercharge.rsi/meta.json b/Resources/Textures/Objects/Weapons/Bombs/spidercharge.rsi/meta.json new file mode 100644 index 0000000000..16c2ce2aff --- /dev/null +++ b/Resources/Textures/Objects/Weapons/Bombs/spidercharge.rsi/meta.json @@ -0,0 +1,31 @@ +{ + "version": 1, + "license": "CC-BY-SA-3.0", + "copyright": "Taken from tgstation at https://github.com/tgstation/tgstation/commit/db2efd4f0df2b630a8bb9851f53f4922b669a5b3", + "size": { + "x": 32, + "y": 32 + }, + "states": [ + { + "name": "icon" + }, + { + "name": "primed", + "delays": [ + [ + 0.1, + 0.1 + ] + ] + }, + { + "name": "inhand-left", + "directions": 4 + }, + { + "name": "inhand-right", + "directions": 4 + } + ] +} diff --git a/Resources/Textures/Objects/Weapons/Bombs/spidercharge.rsi/primed.png b/Resources/Textures/Objects/Weapons/Bombs/spidercharge.rsi/primed.png new file mode 100644 index 0000000000..a3a5df1863 Binary files /dev/null and b/Resources/Textures/Objects/Weapons/Bombs/spidercharge.rsi/primed.png differ diff --git a/Resources/Textures/Objects/Weapons/Melee/energykatana.rsi/equipped-BELT.png b/Resources/Textures/Objects/Weapons/Melee/energykatana.rsi/equipped-BELT.png new file mode 100644 index 0000000000..a6c113829a Binary files /dev/null and b/Resources/Textures/Objects/Weapons/Melee/energykatana.rsi/equipped-BELT.png differ diff --git a/Resources/Textures/Objects/Weapons/Melee/energykatana.rsi/icon.png b/Resources/Textures/Objects/Weapons/Melee/energykatana.rsi/icon.png new file mode 100644 index 0000000000..e185890fbf Binary files /dev/null and b/Resources/Textures/Objects/Weapons/Melee/energykatana.rsi/icon.png differ diff --git a/Resources/Textures/Objects/Weapons/Melee/energykatana.rsi/inhand-left.png b/Resources/Textures/Objects/Weapons/Melee/energykatana.rsi/inhand-left.png new file mode 100644 index 0000000000..5f35b502da Binary files /dev/null and b/Resources/Textures/Objects/Weapons/Melee/energykatana.rsi/inhand-left.png differ diff --git a/Resources/Textures/Objects/Weapons/Melee/energykatana.rsi/inhand-right.png b/Resources/Textures/Objects/Weapons/Melee/energykatana.rsi/inhand-right.png new file mode 100644 index 0000000000..5926122d27 Binary files /dev/null and b/Resources/Textures/Objects/Weapons/Melee/energykatana.rsi/inhand-right.png differ diff --git a/Resources/Textures/Objects/Weapons/Melee/energykatana.rsi/meta.json b/Resources/Textures/Objects/Weapons/Melee/energykatana.rsi/meta.json new file mode 100644 index 0000000000..1dfa76c4e3 --- /dev/null +++ b/Resources/Textures/Objects/Weapons/Melee/energykatana.rsi/meta.json @@ -0,0 +1,26 @@ +{ + "version": 1, + "license": "CC-BY-SA-3.0", + "copyright": "Taken from tgstation at https://github.com/tgstation/tgstation/commit/a9451f4d22f233d328b63490c2bcf64a640e42ff", + "size": { + "x": 32, + "y": 32 + }, + "states": [ + { + "name": "icon" + }, + { + "name": "equipped-BELT", + "directions": 4 + }, + { + "name": "inhand-left", + "directions": 4 + }, + { + "name": "inhand-right", + "directions": 4 + } + ] +} diff --git a/Resources/Textures/Structures/Machines/computers.rsi/comm_icon.png b/Resources/Textures/Structures/Machines/computers.rsi/comm_icon.png new file mode 100644 index 0000000000..ce1a3cb333 Binary files /dev/null and b/Resources/Textures/Structures/Machines/computers.rsi/comm_icon.png differ diff --git a/Resources/Textures/Structures/Machines/computers.rsi/meta.json b/Resources/Textures/Structures/Machines/computers.rsi/meta.json index 2fb8dcee28..8f5f5ac62d 100644 --- a/Resources/Textures/Structures/Machines/computers.rsi/meta.json +++ b/Resources/Textures/Structures/Machines/computers.rsi/meta.json @@ -275,6 +275,9 @@ ] ] }, + { + "name": "comm_icon" + }, { "name": "comm_logs", "directions": 4,