diff --git a/Content.Client/Flight/Components/FlightVisualsComponent.cs b/Content.Client/Flight/Components/FlightVisualsComponent.cs
new file mode 100644
index 00000000000..3f378f60ef2
--- /dev/null
+++ b/Content.Client/Flight/Components/FlightVisualsComponent.cs
@@ -0,0 +1,40 @@
+using Robust.Client.Graphics;
+using Robust.Shared.GameStates;
+
+namespace Content.Client.Flight.Components;
+
+[RegisterComponent]
+public sealed partial class FlightVisualsComponent : Component
+{
+ ///
+ /// How long does the animation last
+ ///
+ [DataField]
+ public float Speed;
+
+ ///
+ /// How far it goes in any direction.
+ ///
+ [DataField]
+ public float Multiplier;
+
+ ///
+ /// How much the limbs (if there are any) rotate.
+ ///
+ [DataField]
+ public float Offset;
+
+ ///
+ /// Are we animating layers or the entire sprite?
+ ///
+ public bool AnimateLayer = false;
+ public int? TargetLayer;
+
+ [DataField]
+ public string AnimationKey = "default";
+
+ [ViewVariables(VVAccess.ReadWrite)]
+ public ShaderInstance Shader = default!;
+
+
+}
\ No newline at end of file
diff --git a/Content.Client/Flight/FlightSystem.cs b/Content.Client/Flight/FlightSystem.cs
new file mode 100644
index 00000000000..bd1a6767bd9
--- /dev/null
+++ b/Content.Client/Flight/FlightSystem.cs
@@ -0,0 +1,67 @@
+using Robust.Client.GameObjects;
+using Content.Shared.Flight;
+using Content.Shared.Flight.Events;
+using Content.Client.Flight.Components;
+
+namespace Content.Client.Flight;
+public sealed class FlightSystem : SharedFlightSystem
+{
+ [Dependency] private readonly IEntityManager _entityManager = default!;
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeNetworkEvent(OnFlight);
+
+ }
+
+ private void OnFlight(FlightEvent args)
+ {
+ var uid = GetEntity(args.Uid);
+ if (!_entityManager.TryGetComponent(uid, out SpriteComponent? sprite)
+ || !args.IsAnimated
+ || !_entityManager.TryGetComponent(uid, out FlightComponent? flight))
+ return;
+
+
+ int? targetLayer = null;
+ if (flight.IsLayerAnimated && flight.Layer is not null)
+ {
+ targetLayer = GetAnimatedLayer(uid, flight.Layer, sprite);
+ if (targetLayer == null)
+ return;
+ }
+
+ if (args.IsFlying && args.IsAnimated && flight.AnimationKey != "default")
+ {
+ var comp = new FlightVisualsComponent
+ {
+ AnimateLayer = flight.IsLayerAnimated,
+ AnimationKey = flight.AnimationKey,
+ Multiplier = flight.ShaderMultiplier,
+ Offset = flight.ShaderOffset,
+ Speed = flight.ShaderSpeed,
+ TargetLayer = targetLayer,
+ };
+ AddComp(uid, comp);
+ }
+ if (!args.IsFlying)
+ RemComp(uid);
+ }
+
+ public int? GetAnimatedLayer(EntityUid uid, string targetLayer, SpriteComponent? sprite = null)
+ {
+ if (!Resolve(uid, ref sprite))
+ return null;
+
+ int index = 0;
+ foreach (var layer in sprite.AllLayers)
+ {
+ // This feels like absolute shitcode, isn't there a better way to check for it?
+ if (layer.Rsi?.Path.ToString() == targetLayer)
+ return index;
+ index++;
+ }
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/Content.Client/Flight/FlyingVisualizerSystem.cs b/Content.Client/Flight/FlyingVisualizerSystem.cs
new file mode 100644
index 00000000000..6dde6cf5638
--- /dev/null
+++ b/Content.Client/Flight/FlyingVisualizerSystem.cs
@@ -0,0 +1,64 @@
+using Content.Client.Flight.Components;
+using Robust.Client.GameObjects;
+using Robust.Client.Graphics;
+using Robust.Shared.Prototypes;
+
+namespace Content.Client.Flight;
+
+///
+/// Handles offsetting an entity while flying
+///
+public sealed class FlyingVisualizerSystem : EntitySystem
+{
+ [Dependency] private readonly IPrototypeManager _protoMan = default!;
+ [Dependency] private readonly SpriteSystem _spriteSystem = default!;
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent(OnStartup);
+ SubscribeLocalEvent(OnShutdown);
+ SubscribeLocalEvent(OnBeforeShaderPost);
+ }
+
+ private void OnStartup(EntityUid uid, FlightVisualsComponent comp, ComponentStartup args)
+ {
+ comp.Shader = _protoMan.Index(comp.AnimationKey).InstanceUnique();
+ AddShader(uid, comp.Shader, comp.AnimateLayer, comp.TargetLayer);
+ SetValues(comp, comp.Speed, comp.Offset, comp.Multiplier);
+ }
+
+ private void OnShutdown(EntityUid uid, FlightVisualsComponent comp, ComponentShutdown args)
+ {
+ AddShader(uid, null, comp.AnimateLayer, comp.TargetLayer);
+ }
+
+ private void AddShader(Entity entity, ShaderInstance? shader, bool animateLayer, int? layer)
+ {
+ if (!Resolve(entity, ref entity.Comp, false))
+ return;
+
+ if (!animateLayer)
+ entity.Comp.PostShader = shader;
+
+ if (animateLayer && layer is not null)
+ entity.Comp.LayerSetShader(layer.Value, shader);
+
+ entity.Comp.GetScreenTexture = shader is not null;
+ entity.Comp.RaiseShaderEvent = shader is not null;
+ }
+
+ ///
+ /// This function can be used to modify the shader's values while its running.
+ ///
+ private void OnBeforeShaderPost(EntityUid uid, FlightVisualsComponent comp, ref BeforePostShaderRenderEvent args)
+ {
+ SetValues(comp, comp.Speed, comp.Offset, comp.Multiplier);
+ }
+
+ private void SetValues(FlightVisualsComponent comp, float speed, float offset, float multiplier)
+ {
+ comp.Shader.SetParameter("Speed", speed);
+ comp.Shader.SetParameter("Offset", offset);
+ comp.Shader.SetParameter("Multiplier", multiplier);
+ }
+}
\ No newline at end of file
diff --git a/Content.Server/Flight/FlightSystem.cs b/Content.Server/Flight/FlightSystem.cs
new file mode 100644
index 00000000000..e056fc24ec0
--- /dev/null
+++ b/Content.Server/Flight/FlightSystem.cs
@@ -0,0 +1,158 @@
+
+using Content.Shared.Cuffs.Components;
+using Content.Shared.Damage.Components;
+using Content.Shared.DoAfter;
+using Content.Shared.Flight;
+using Content.Shared.Flight.Events;
+using Content.Shared.Mobs;
+using Content.Shared.Popups;
+using Content.Shared.Stunnable;
+using Content.Shared.Zombies;
+using Robust.Shared.Audio.Systems;
+
+namespace Content.Server.Flight;
+public sealed class FlightSystem : SharedFlightSystem
+{
+ [Dependency] private readonly SharedAudioSystem _audio = default!;
+ [Dependency] private readonly SharedDoAfterSystem _doAfter = default!;
+ [Dependency] private readonly SharedPopupSystem _popupSystem = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnToggleFlight);
+ SubscribeLocalEvent(OnFlightDoAfter);
+ SubscribeLocalEvent(OnMobStateChangedEvent);
+ SubscribeLocalEvent(OnZombified);
+ SubscribeLocalEvent(OnKnockedDown);
+ SubscribeLocalEvent(OnStunned);
+ SubscribeLocalEvent(OnSleep);
+ }
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var component))
+ {
+ if (!component.On)
+ continue;
+
+ component.TimeUntilFlap -= frameTime;
+
+ if (component.TimeUntilFlap > 0f)
+ continue;
+
+ _audio.PlayPvs(component.FlapSound, uid);
+ component.TimeUntilFlap = component.FlapInterval;
+
+ }
+ }
+
+ #region Core Functions
+ private void OnToggleFlight(EntityUid uid, FlightComponent component, ToggleFlightEvent args)
+ {
+ // If the user isnt flying, we check for conditionals and initiate a doafter.
+ if (!component.On)
+ {
+ if (!CanFly(uid, component))
+ return;
+
+ var doAfterArgs = new DoAfterArgs(EntityManager,
+ uid, component.ActivationDelay,
+ new FlightDoAfterEvent(), uid, target: uid)
+ {
+ BlockDuplicate = true,
+ BreakOnTargetMove = true,
+ BreakOnUserMove = true,
+ BreakOnDamage = true,
+ NeedHand = true
+ };
+
+ if (!_doAfter.TryStartDoAfter(doAfterArgs))
+ return;
+ }
+ else
+ ToggleActive(uid, false, component);
+ }
+
+ private void OnFlightDoAfter(EntityUid uid, FlightComponent component, FlightDoAfterEvent args)
+ {
+ if (args.Handled || args.Cancelled)
+ return;
+
+ ToggleActive(uid, true, component);
+ args.Handled = true;
+ }
+
+ #endregion
+
+ #region Conditionals
+
+ private bool CanFly(EntityUid uid, FlightComponent component)
+ {
+ if (TryComp(uid, out var cuffableComp) && !cuffableComp.CanStillInteract)
+ {
+ _popupSystem.PopupEntity(Loc.GetString("no-flight-while-restrained"), uid, uid, PopupType.Medium);
+ return false;
+ }
+
+ if (HasComp(uid))
+ {
+ _popupSystem.PopupEntity(Loc.GetString("no-flight-while-zombified"), uid, uid, PopupType.Medium);
+ return false;
+ }
+ return true;
+ }
+
+ private void OnMobStateChangedEvent(EntityUid uid, FlightComponent component, MobStateChangedEvent args)
+ {
+ if (!component.On
+ || args.NewMobState is MobState.Critical or MobState.Dead)
+ return;
+
+ ToggleActive(args.Target, false, component);
+ }
+
+ private void OnZombified(EntityUid uid, FlightComponent component, ref EntityZombifiedEvent args)
+ {
+ if (!component.On)
+ return;
+
+ ToggleActive(args.Target, false, component);
+ if (!TryComp(uid, out var stamina))
+ return;
+ Dirty(uid, stamina);
+ }
+
+ private void OnKnockedDown(EntityUid uid, FlightComponent component, ref KnockedDownEvent args)
+ {
+ if (!component.On)
+ return;
+
+ ToggleActive(uid, false, component);
+ }
+
+ private void OnStunned(EntityUid uid, FlightComponent component, ref StunnedEvent args)
+ {
+ if (!component.On)
+ return;
+
+ ToggleActive(uid, false, component);
+ }
+
+ private void OnSleep(EntityUid uid, FlightComponent component, ref SleepStateChangedEvent args)
+ {
+ if (!component.On
+ || !args.FellAsleep)
+ return;
+
+ ToggleActive(uid, false, component);
+ if (!TryComp(uid, out var stamina))
+ return;
+
+ Dirty(uid, stamina);
+ }
+ #endregion
+}
\ No newline at end of file
diff --git a/Content.Shared/Cuffs/SharedCuffableSystem.cs b/Content.Shared/Cuffs/SharedCuffableSystem.cs
index ebbafef7f0e..9777b239884 100644
--- a/Content.Shared/Cuffs/SharedCuffableSystem.cs
+++ b/Content.Shared/Cuffs/SharedCuffableSystem.cs
@@ -7,6 +7,7 @@
using Content.Shared.Contests;
using Content.Shared.Cuffs.Components;
using Content.Shared.Database;
+using Content.Shared.Flight;
using Content.Shared.DoAfter;
using Content.Shared.Hands;
using Content.Shared.Hands.Components;
@@ -479,6 +480,13 @@ public bool TryCuffing(EntityUid user, EntityUid target, EntityUid handcuff, Han
return true;
}
+ if (TryComp(target, out var flight) && flight.On)
+ {
+ _popup.PopupClient(Loc.GetString("handcuff-component-target-flying-error",
+ ("targetName", Identity.Name(target, EntityManager, user))), user, user);
+ return true;
+ }
+
var cuffTime = handcuffComponent.CuffTime;
if (HasComp(target))
@@ -731,4 +739,4 @@ private sealed partial class AddCuffDoAfterEvent : SimpleDoAfterEvent
{
}
}
-}
+}
\ No newline at end of file
diff --git a/Content.Shared/Damage/Components/StaminaComponent.cs b/Content.Shared/Damage/Components/StaminaComponent.cs
index 65c025c3adf..b78fe978090 100644
--- a/Content.Shared/Damage/Components/StaminaComponent.cs
+++ b/Content.Shared/Damage/Components/StaminaComponent.cs
@@ -39,6 +39,13 @@ public sealed partial class StaminaComponent : Component
[ViewVariables(VVAccess.ReadWrite), DataField, AutoNetworkedField]
public float CritThreshold = 100f;
+ ///
+ /// A dictionary of active stamina drains, with the key being the source of the drain,
+ /// DrainRate how much it changes per tick, and ModifiesSpeed if it should slow down the user.
+ ///
+ [DataField, AutoNetworkedField]
+ public Dictionary ActiveDrains = new();
+
///
/// How long will this mob be stunned for?
///
@@ -63,4 +70,4 @@ public sealed partial class StaminaComponent : Component
///
[DataField, AutoNetworkedField]
public float SlowdownMultiplier = 0.75f;
-}
+}
\ No newline at end of file
diff --git a/Content.Shared/Damage/Systems/StaminaSystem.cs b/Content.Shared/Damage/Systems/StaminaSystem.cs
index f8a0f7c62ba..e4840a6630b 100644
--- a/Content.Shared/Damage/Systems/StaminaSystem.cs
+++ b/Content.Shared/Damage/Systems/StaminaSystem.cs
@@ -258,7 +258,7 @@ public bool TryTakeStamina(EntityUid uid, float value, StaminaComponent? compone
}
public void TakeStaminaDamage(EntityUid uid, float value, StaminaComponent? component = null,
- EntityUid? source = null, EntityUid? with = null, bool visual = true, SoundSpecifier? sound = null)
+ EntityUid? source = null, EntityUid? with = null, bool visual = true, SoundSpecifier? sound = null, bool? allowsSlowdown = true)
{
if (!Resolve(uid, ref component, false)
|| value == 0)
@@ -284,8 +284,8 @@ public void TakeStaminaDamage(EntityUid uid, float value, StaminaComponent? comp
if (component.NextUpdate < nextUpdate)
component.NextUpdate = nextUpdate;
}
-
- _movementSpeed.RefreshMovementSpeedModifiers(uid);
+ if (allowsSlowdown == true)
+ _movementSpeed.RefreshMovementSpeedModifiers(uid);
SetStaminaAlert(uid, component);
if (!component.Critical)
@@ -328,27 +328,51 @@ public void TakeStaminaDamage(EntityUid uid, float value, StaminaComponent? comp
}
}
+ public void ToggleStaminaDrain(EntityUid target, float drainRate, bool enabled, bool modifiesSpeed, EntityUid? source = null)
+ {
+ if (!TryComp(target, out var stamina))
+ return;
+
+ // If theres no source, we assume its the target that caused the drain.
+ var actualSource = source ?? target;
+
+ if (enabled)
+ {
+ stamina.ActiveDrains[actualSource] = (drainRate, modifiesSpeed);
+ EnsureComp(target);
+ }
+ else
+ stamina.ActiveDrains.Remove(actualSource);
+
+ Dirty(target, stamina);
+ }
+
public override void Update(float frameTime)
{
base.Update(frameTime);
-
if (!_timing.IsFirstTimePredicted)
return;
var stamQuery = GetEntityQuery();
var query = EntityQueryEnumerator();
var curTime = _timing.CurTime;
-
while (query.MoveNext(out var uid, out _))
{
// Just in case we have active but not stamina we'll check and account for it.
if (!stamQuery.TryGetComponent(uid, out var comp) ||
- comp.StaminaDamage <= 0f && !comp.Critical)
+ comp.StaminaDamage <= 0f && !comp.Critical && comp.ActiveDrains.Count == 0)
{
RemComp(uid);
continue;
}
-
+ if (comp.ActiveDrains.Count > 0)
+ foreach (var (source, (drainRate, modifiesSpeed)) in comp.ActiveDrains)
+ TakeStaminaDamage(uid,
+ drainRate * frameTime,
+ comp,
+ source: source,
+ visual: false,
+ allowsSlowdown: modifiesSpeed);
// Shouldn't need to consider paused time as we're only iterating non-paused stamina components.
var nextUpdate = comp.NextUpdate;
@@ -363,8 +387,11 @@ public override void Update(float frameTime)
}
comp.NextUpdate += TimeSpan.FromSeconds(1f);
- TakeStaminaDamage(uid, -comp.Decay, comp);
- Dirty(comp);
+ // If theres no active drains, recover stamina.
+ if (comp.ActiveDrains.Count == 0)
+ TakeStaminaDamage(uid, -comp.Decay, comp);
+
+ Dirty(uid, comp);
}
}
@@ -380,7 +407,6 @@ private void EnterStamCrit(EntityUid uid, StaminaComponent? component = null)
component.StaminaDamage = component.CritThreshold;
_stunSystem.TryParalyze(uid, component.StunTime, true);
-
// Give them buffer before being able to be re-stunned
component.NextUpdate = _timing.CurTime + component.StunTime + StamCritBufferTime;
EnsureComp(uid);
@@ -407,4 +433,4 @@ private void ExitStamCrit(EntityUid uid, StaminaComponent? component = null)
/// Raised before stamina damage is dealt to allow other systems to cancel it.
///
[ByRefEvent]
-public record struct BeforeStaminaDamageEvent(float Value, bool Cancelled = false);
+public record struct BeforeStaminaDamageEvent(float Value, bool Cancelled = false);
\ No newline at end of file
diff --git a/Content.Shared/Flight/Events.cs b/Content.Shared/Flight/Events.cs
new file mode 100644
index 00000000000..6666971b539
--- /dev/null
+++ b/Content.Shared/Flight/Events.cs
@@ -0,0 +1,24 @@
+using Robust.Shared.Serialization;
+using Content.Shared.DoAfter;
+
+namespace Content.Shared.Flight.Events;
+
+[Serializable, NetSerializable]
+public sealed partial class DashDoAfterEvent : SimpleDoAfterEvent { }
+
+[Serializable, NetSerializable]
+public sealed partial class FlightDoAfterEvent : SimpleDoAfterEvent { }
+
+[Serializable, NetSerializable]
+public sealed class FlightEvent : EntityEventArgs
+{
+ public NetEntity Uid { get; }
+ public bool IsFlying { get; }
+ public bool IsAnimated { get; }
+ public FlightEvent(NetEntity uid, bool isFlying, bool isAnimated)
+ {
+ Uid = uid;
+ IsFlying = isFlying;
+ IsAnimated = isAnimated;
+ }
+}
diff --git a/Content.Shared/Flight/FlightComponent.cs b/Content.Shared/Flight/FlightComponent.cs
new file mode 100644
index 00000000000..d250744544d
--- /dev/null
+++ b/Content.Shared/Flight/FlightComponent.cs
@@ -0,0 +1,101 @@
+using Robust.Shared.Audio;
+using Robust.Shared.GameStates;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
+
+namespace Content.Shared.Flight;
+
+///
+/// Adds an action that allows the user to become temporarily
+/// weightless at the cost of stamina and hand usage.
+///
+[RegisterComponent, NetworkedComponent(), AutoGenerateComponentState]
+public sealed partial class FlightComponent : Component
+{
+ [DataField(customTypeSerializer: typeof(PrototypeIdSerializer))]
+ public string? ToggleAction = "ActionToggleFlight";
+
+ [DataField, AutoNetworkedField]
+ public EntityUid? ToggleActionEntity;
+
+ ///
+ /// Is the user flying right now?
+ ///
+ [DataField, AutoNetworkedField]
+ public bool On;
+
+ ///
+ /// Stamina drain per second when flying
+ ///
+ [DataField, AutoNetworkedField]
+ public float StaminaDrainRate = 6.0f;
+
+ ///
+ /// DoAfter delay until the user becomes weightless.
+ ///
+ [DataField, AutoNetworkedField]
+ public float ActivationDelay = 1.0f;
+
+ ///
+ /// Speed modifier while in flight
+ ///
+ [DataField, AutoNetworkedField]
+ public float SpeedModifier = 2.0f;
+
+ ///
+ /// Path to a sound specifier or collection for the noises made during flight
+ ///
+ [DataField]
+ public SoundSpecifier FlapSound = new SoundCollectionSpecifier("WingFlaps");
+
+ ///
+ /// Is the flight animated?
+ ///
+ [DataField]
+ public bool IsAnimated = true;
+
+ ///
+ /// Does the animation animate a layer?.
+ ///
+ [DataField]
+ public bool IsLayerAnimated;
+
+ ///
+ /// Which RSI layer path does this animate?
+ ///
+ [DataField]
+ public string? Layer;
+
+ ///
+ /// Whats the speed of the shader?
+ ///
+ [DataField]
+ public float ShaderSpeed = 6.0f;
+
+ ///
+ /// How much are the values in the shader's calculations multiplied by?
+ ///
+ [DataField]
+ public float ShaderMultiplier = 0.01f;
+
+ ///
+ /// What is the offset on the shader?
+ ///
+ [DataField]
+ public float ShaderOffset = 0.25f;
+
+ ///
+ /// What animation does the flight use?
+ ///
+
+ [DataField]
+ public string AnimationKey = "default";
+
+ ///
+ /// Time between sounds being played
+ ///
+ [DataField]
+ public float FlapInterval = 1.0f;
+
+ public float TimeUntilFlap;
+}
diff --git a/Content.Shared/Flight/SharedFlightSystem.cs b/Content.Shared/Flight/SharedFlightSystem.cs
new file mode 100644
index 00000000000..281c6d70f08
--- /dev/null
+++ b/Content.Shared/Flight/SharedFlightSystem.cs
@@ -0,0 +1,104 @@
+using Content.Shared.Actions;
+using Content.Shared.Movement.Systems;
+using Content.Shared.Damage.Systems;
+using Content.Shared.Hands.Components;
+using Content.Shared.Hands.EntitySystems;
+using Content.Shared.Interaction.Components;
+using Content.Shared.Inventory.VirtualItem;
+using Content.Shared.Flight.Events;
+
+namespace Content.Shared.Flight;
+public abstract class SharedFlightSystem : EntitySystem
+{
+ [Dependency] private readonly SharedActionsSystem _actionsSystem = default!;
+ [Dependency] private readonly SharedVirtualItemSystem _virtualItem = default!;
+ [Dependency] private readonly StaminaSystem _staminaSystem = default!;
+ [Dependency] private readonly SharedHandsSystem _hands = default!;
+ [Dependency] private readonly MovementSpeedModifierSystem _movementSpeed = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnStartup);
+ SubscribeLocalEvent(OnShutdown);
+ SubscribeLocalEvent(OnRefreshMoveSpeed);
+ }
+
+ #region Core Functions
+ private void OnStartup(EntityUid uid, FlightComponent component, ComponentStartup args)
+ {
+ _actionsSystem.AddAction(uid, ref component.ToggleActionEntity, component.ToggleAction);
+ }
+
+ private void OnShutdown(EntityUid uid, FlightComponent component, ComponentShutdown args)
+ {
+ _actionsSystem.RemoveAction(uid, component.ToggleActionEntity);
+ }
+
+ public void ToggleActive(EntityUid uid, bool active, FlightComponent component)
+ {
+ component.On = active;
+ component.TimeUntilFlap = 0f;
+ _actionsSystem.SetToggled(component.ToggleActionEntity, component.On);
+ RaiseNetworkEvent(new FlightEvent(GetNetEntity(uid), component.On, component.IsAnimated));
+ _staminaSystem.ToggleStaminaDrain(uid, component.StaminaDrainRate, active, false);
+ _movementSpeed.RefreshMovementSpeedModifiers(uid);
+ UpdateHands(uid, active);
+ Dirty(uid, component);
+ }
+
+ private void UpdateHands(EntityUid uid, bool flying)
+ {
+ if (!TryComp(uid, out var handsComponent))
+ return;
+
+ if (flying)
+ BlockHands(uid, handsComponent);
+ else
+ FreeHands(uid);
+ }
+
+ private void BlockHands(EntityUid uid, HandsComponent handsComponent)
+ {
+ var freeHands = 0;
+ foreach (var hand in _hands.EnumerateHands(uid, handsComponent))
+ {
+ if (hand.HeldEntity == null)
+ {
+ freeHands++;
+ continue;
+ }
+
+ // Is this entity removable? (they might have handcuffs on)
+ if (HasComp(hand.HeldEntity) && hand.HeldEntity != uid)
+ continue;
+
+ _hands.DoDrop(uid, hand, true, handsComponent);
+ freeHands++;
+ if (freeHands == 2)
+ break;
+ }
+ if (_virtualItem.TrySpawnVirtualItemInHand(uid, uid, out var virtItem1))
+ EnsureComp(virtItem1.Value);
+
+ if (_virtualItem.TrySpawnVirtualItemInHand(uid, uid, out var virtItem2))
+ EnsureComp(virtItem2.Value);
+ }
+
+ private void FreeHands(EntityUid uid)
+ {
+ _virtualItem.DeleteInHandsMatching(uid, uid);
+ }
+
+ private void OnRefreshMoveSpeed(EntityUid uid, FlightComponent component, RefreshMovementSpeedModifiersEvent args)
+ {
+ if (!component.On)
+ return;
+
+ args.ModifySpeed(component.SpeedModifier, component.SpeedModifier);
+ }
+
+ #endregion
+}
+public sealed partial class ToggleFlightEvent : InstantActionEvent { }
diff --git a/Content.Shared/Gravity/SharedFloatingVisualizerSystem.cs b/Content.Shared/Gravity/SharedFloatingVisualizerSystem.cs
index 57136116caa..8fe9e00e7eb 100644
--- a/Content.Shared/Gravity/SharedFloatingVisualizerSystem.cs
+++ b/Content.Shared/Gravity/SharedFloatingVisualizerSystem.cs
@@ -1,5 +1,6 @@
using System.Numerics;
using Robust.Shared.Map;
+using Content.Shared.Flight.Events;
namespace Content.Shared.Gravity;
@@ -17,6 +18,7 @@ public override void Initialize()
SubscribeLocalEvent(OnComponentStartup);
SubscribeLocalEvent(OnGravityChanged);
SubscribeLocalEvent(OnEntParentChanged);
+ SubscribeNetworkEvent(OnFlight);
}
///
@@ -62,10 +64,24 @@ private void OnGravityChanged(ref GravityChangedEvent args)
}
}
+ private void OnFlight(FlightEvent args)
+ {
+ var uid = GetEntity(args.Uid);
+ if (!TryComp(uid, out var floating))
+ return;
+ floating.CanFloat = args.IsFlying;
+
+ if (!args.IsFlying
+ || !args.IsAnimated)
+ return;
+
+ FloatAnimation(uid, floating.Offset, floating.AnimationKey, floating.AnimationTime);
+ }
+
private void OnEntParentChanged(EntityUid uid, FloatingVisualsComponent component, ref EntParentChangedMessage args)
{
var transform = args.Transform;
if (CanFloat(uid, component, transform))
FloatAnimation(uid, component.Offset, component.AnimationKey, component.AnimationTime);
}
-}
+}
\ No newline at end of file
diff --git a/Content.Shared/Gravity/SharedGravitySystem.cs b/Content.Shared/Gravity/SharedGravitySystem.cs
index 100d2ee74fb..55187bf14ac 100644
--- a/Content.Shared/Gravity/SharedGravitySystem.cs
+++ b/Content.Shared/Gravity/SharedGravitySystem.cs
@@ -8,6 +8,7 @@
using Robust.Shared.Physics.Components;
using Robust.Shared.Serialization;
using Robust.Shared.Timing;
+using Content.Shared.Flight;
namespace Content.Shared.Gravity
{
@@ -24,6 +25,9 @@ public bool IsWeightless(EntityUid uid, PhysicsComponent? body = null, Transform
if ((body?.BodyType & (BodyType.Static | BodyType.Kinematic)) != 0)
return false;
+ if (TryComp(uid, out var flying) && flying.On)
+ return true;
+
if (TryComp(uid, out var ignoreGravityComponent))
return ignoreGravityComponent.Weightless;
@@ -142,4 +146,4 @@ public GravityComponentState(bool enabled)
}
}
}
-}
+}
\ No newline at end of file
diff --git a/Resources/Audio/Effects/Flight/wingflap1.ogg b/Resources/Audio/Effects/Flight/wingflap1.ogg
new file mode 100644
index 00000000000..724ac3ecd20
Binary files /dev/null and b/Resources/Audio/Effects/Flight/wingflap1.ogg differ
diff --git a/Resources/Audio/Effects/Flight/wingflap2.ogg b/Resources/Audio/Effects/Flight/wingflap2.ogg
new file mode 100644
index 00000000000..b0264a13170
Binary files /dev/null and b/Resources/Audio/Effects/Flight/wingflap2.ogg differ
diff --git a/Resources/Audio/Effects/Flight/wingflap3.ogg b/Resources/Audio/Effects/Flight/wingflap3.ogg
new file mode 100644
index 00000000000..aeb0e21acf6
Binary files /dev/null and b/Resources/Audio/Effects/Flight/wingflap3.ogg differ
diff --git a/Resources/Locale/en-US/cuffs/components/handcuff-component.ftl b/Resources/Locale/en-US/cuffs/components/handcuff-component.ftl
index 16447f42515..1f8a895164c 100644
--- a/Resources/Locale/en-US/cuffs/components/handcuff-component.ftl
+++ b/Resources/Locale/en-US/cuffs/components/handcuff-component.ftl
@@ -3,6 +3,7 @@ handcuff-component-cuffs-broken-error = The restraints are broken!
handcuff-component-target-has-no-hands-error = {$targetName} has no hands!
handcuff-component-target-has-no-free-hands-error = {$targetName} has no free hands!
handcuff-component-too-far-away-error = You are too far away to use the restraints!
+handcuff-component-target-flying-error = You cannot reach {$targetName}'s hands!
handcuff-component-start-cuffing-observer = {$user} starts restraining {$target}!
handcuff-component-start-cuffing-target-message = You start restraining {$targetName}.
handcuff-component-start-cuffing-by-other-message = {$otherName} starts restraining you!
diff --git a/Resources/Locale/en-US/flight/flight_system.ftl b/Resources/Locale/en-US/flight/flight_system.ftl
new file mode 100644
index 00000000000..12693cc8467
--- /dev/null
+++ b/Resources/Locale/en-US/flight/flight_system.ftl
@@ -0,0 +1,2 @@
+no-flight-while-restrained = You can't fly right now.
+no-flight-while-zombified = You can't use your wings right now.
\ No newline at end of file
diff --git a/Resources/Prototypes/Entities/Mobs/Species/harpy.yml b/Resources/Prototypes/Entities/Mobs/Species/harpy.yml
index 788b21eafb0..05ac3de8bb3 100644
--- a/Resources/Prototypes/Entities/Mobs/Species/harpy.yml
+++ b/Resources/Prototypes/Entities/Mobs/Species/harpy.yml
@@ -5,6 +5,10 @@
id: MobHarpyBase
abstract: true
components:
+ - type: Flight
+ isLayerAnimated: true
+ layer: "/Textures/Mobs/Customization/Harpy/harpy_wings.rsi"
+ animationKey: "Flap"
- type: Singer
proto: HarpySinger
- type: Sprite
@@ -197,3 +201,15 @@
icon: DeltaV/Interface/Actions/harpy_syrinx.png
itemIconStyle: BigAction
event: !type:VoiceMaskSetNameEvent
+
+- type: entity
+ id: ActionToggleFlight
+ name: Fly
+ description: Make use of your wings to fly. Beat the flightless bird allegations.
+ noSpawn: true
+ components:
+ - type: InstantAction
+ checkCanInteract: false
+ icon: { sprite: Interface/Actions/flight.rsi, state: flight_off }
+ iconOn: { sprite : Interface/Actions/flight.rsi, state: flight_on }
+ event: !type:ToggleFlightEvent
\ No newline at end of file
diff --git a/Resources/Prototypes/Shaders/shaders.yml b/Resources/Prototypes/Shaders/shaders.yml
index b495490201c..3f0cb5ae1fb 100644
--- a/Resources/Prototypes/Shaders/shaders.yml
+++ b/Resources/Prototypes/Shaders/shaders.yml
@@ -104,3 +104,10 @@
id: SaturationScale
kind: source
path: "/Textures/Shaders/saturationscale.swsl"
+
+ # Flight shaders
+
+- type: shader
+ id: Flap
+ kind: source
+ path: "/Textures/Shaders/flap.swsl"
\ No newline at end of file
diff --git a/Resources/Prototypes/SoundCollections/flight.yml b/Resources/Prototypes/SoundCollections/flight.yml
new file mode 100644
index 00000000000..cca2cfb014a
--- /dev/null
+++ b/Resources/Prototypes/SoundCollections/flight.yml
@@ -0,0 +1,6 @@
+- type: soundCollection
+ id: WingFlaps
+ files:
+ - /Audio/Effects/Flight/wingflap1.ogg
+ - /Audio/Effects/Flight/wingflap2.ogg
+ - /Audio/Effects/Flight/wingflap3.ogg
diff --git a/Resources/Textures/Interface/Actions/flight.rsi/flight_off.png b/Resources/Textures/Interface/Actions/flight.rsi/flight_off.png
new file mode 100644
index 00000000000..852dd300e91
Binary files /dev/null and b/Resources/Textures/Interface/Actions/flight.rsi/flight_off.png differ
diff --git a/Resources/Textures/Interface/Actions/flight.rsi/flight_on.png b/Resources/Textures/Interface/Actions/flight.rsi/flight_on.png
new file mode 100644
index 00000000000..c47b923a681
Binary files /dev/null and b/Resources/Textures/Interface/Actions/flight.rsi/flight_on.png differ
diff --git a/Resources/Textures/Interface/Actions/flight.rsi/meta.json b/Resources/Textures/Interface/Actions/flight.rsi/meta.json
new file mode 100644
index 00000000000..b4a013190db
--- /dev/null
+++ b/Resources/Textures/Interface/Actions/flight.rsi/meta.json
@@ -0,0 +1,17 @@
+{
+ "copyright" : "Made by dootythefrooty (273243513800622090)",
+ "license" : "CC-BY-SA-3.0",
+ "version": 1,
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "states": [
+ {
+ "name": "flight_off"
+ },
+ {
+ "name": "flight_on"
+ }
+ ]
+ }
\ No newline at end of file
diff --git a/Resources/Textures/Shaders/flap.swsl b/Resources/Textures/Shaders/flap.swsl
new file mode 100644
index 00000000000..3082e19b496
--- /dev/null
+++ b/Resources/Textures/Shaders/flap.swsl
@@ -0,0 +1,35 @@
+preset raw;
+
+varying highp vec4 VtxModulate;
+varying highp vec2 Pos;
+
+uniform highp float Speed;
+uniform highp float Multiplier;
+uniform highp float Offset;
+
+void fragment() {
+ highp vec4 texColor = zTexture(UV);
+ lowp vec3 lightSample = texture2D(lightMap, Pos).rgb;
+ COLOR = texColor * VtxModulate * vec4(lightSample, 1.0);
+}
+
+void vertex() {
+ vec2 pos = aPos;
+
+ // Apply MVP transformation first
+ vec2 transformedPos = apply_mvp(pos);
+
+ // Calculate vertical movement in screen space
+ float verticalOffset = (sin(TIME * Speed) + Offset) * Multiplier;
+
+ // Apply vertical movement after MVP transformation
+ transformedPos.y += verticalOffset;
+
+ // Assign the final position
+ VERTEX = transformedPos;
+
+ // Keep the original UV coordinates
+ UV = mix(modifyUV.xy, modifyUV.zw, tCoord);
+ Pos = (VERTEX + 1.0) / 2.0;
+ VtxModulate = zFromSrgb(modulate);
+}
\ No newline at end of file