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