diff --git a/Content.Client/GameObjects/Components/Power/ApcNetComponents/PowerReceiverUsers/PoweredLightVisualizer.cs b/Content.Client/GameObjects/Components/Power/ApcNetComponents/PowerReceiverUsers/PoweredLightVisualizer.cs new file mode 100644 index 0000000000..2c06aaf864 --- /dev/null +++ b/Content.Client/GameObjects/Components/Power/ApcNetComponents/PowerReceiverUsers/PoweredLightVisualizer.cs @@ -0,0 +1,153 @@ +#nullable enable +using System; +using Content.Shared.GameObjects.Components.Power.ApcNetComponents.PowerReceiverUsers; +using JetBrains.Annotations; +using Robust.Client.Animations; +using Robust.Client.GameObjects; +using Robust.Shared.Animations; +using Robust.Shared.GameObjects; +using Robust.Shared.IoC; +using Robust.Shared.Maths; +using Robust.Shared.Random; +using Robust.Shared.Serialization; +using YamlDotNet.RepresentationModel; + +namespace Content.Client.GameObjects.Components.Power.ApcNetComponents.PowerReceiverUsers +{ + [UsedImplicitly] + public class PoweredLightVisualizer : AppearanceVisualizer + { + private float _minBlinkingTime; + private float _maxBlinkingTime; + private string? _blinkingSound; + + private bool _wasBlinking; + + private Action? _blinkingCallback; + + public override void LoadData(YamlMappingNode node) + { + base.LoadData(node); + + var serializer = YamlObjectSerializer.NewReader(node); + serializer.DataField(ref _minBlinkingTime, "minBlinkingTime", 0.5f); + serializer.DataField(ref _maxBlinkingTime, "maxBlinkingTime", 2.0f); + serializer.DataField(ref _blinkingSound, "blinkingSound", null); + } + + public override void OnChangeData(AppearanceComponent component) + { + base.OnChangeData(component); + + if (!component.Owner.TryGetComponent(out ISpriteComponent? sprite)) return; + if (!component.Owner.TryGetComponent(out PointLightComponent? light)) return; + if (!component.TryGetData(PoweredLightVisuals.BulbState, out PoweredLightState state)) return; + + switch (state) + { + case PoweredLightState.Empty: + sprite.LayerSetState(PoweredLightLayers.Base, "empty"); + ToggleBlinkingAnimation(component, false); + light.Enabled = false; + break; + case PoweredLightState.Off: + sprite.LayerSetState(PoweredLightLayers.Base, "off"); + ToggleBlinkingAnimation(component, false); + light.Enabled = false; + break; + case PoweredLightState.On: + if (component.TryGetData(PoweredLightVisuals.BulbColor, out Color color)) + light.Color = color; + if (component.TryGetData(PoweredLightVisuals.Blinking, out bool isBlinking)) + ToggleBlinkingAnimation(component, isBlinking); + if (!isBlinking) + { + sprite.LayerSetState(PoweredLightLayers.Base, "on"); + light.Enabled = true; + } + break; + case PoweredLightState.Broken: + sprite.LayerSetState(PoweredLightLayers.Base, "broken"); + ToggleBlinkingAnimation(component, false); + light.Enabled = false; + break; + case PoweredLightState.Burned: + sprite.LayerSetState(PoweredLightLayers.Base, "burn"); + ToggleBlinkingAnimation(component, false); + light.Enabled = false; + break; + } + } + + + private void ToggleBlinkingAnimation(AppearanceComponent component, bool isBlinking) + { + if (isBlinking == _wasBlinking) + return; + _wasBlinking = isBlinking; + + component.Owner.EnsureComponent(out AnimationPlayerComponent animationPlayer); + + if (isBlinking) + { + _blinkingCallback = (animName) => animationPlayer.Play(BlinkingAnimation(), "blinking"); + animationPlayer.AnimationCompleted += _blinkingCallback; + animationPlayer.Play(BlinkingAnimation(), "blinking"); + } + else if (animationPlayer.HasRunningAnimation("blinking")) + { + if (_blinkingCallback != null) + animationPlayer.AnimationCompleted -= _blinkingCallback; + animationPlayer.Stop("blinking"); + } + } + + private Animation BlinkingAnimation() + { + var random = IoCManager.Resolve(); + var randomTime = random.NextFloat() * + (_maxBlinkingTime - _minBlinkingTime) + _minBlinkingTime; + + var blinkingAnim = new Animation() + { + Length = TimeSpan.FromSeconds(randomTime), + AnimationTracks = + { + new AnimationTrackComponentProperty + { + ComponentType = typeof(PointLightComponent), + InterpolationMode = AnimationInterpolationMode.Nearest, + Property = nameof(PointLightComponent.Enabled), + KeyFrames = + { + new AnimationTrackProperty.KeyFrame(false, 0), + new AnimationTrackProperty.KeyFrame(true, 1) + } + }, + new AnimationTrackSpriteFlick() + { + LayerKey = PoweredLightLayers.Base, + KeyFrames = + { + new AnimationTrackSpriteFlick.KeyFrame("off", 0), + new AnimationTrackSpriteFlick.KeyFrame("on", 0.5f) + } + } + } + }; + + if (_blinkingSound != null) + { + blinkingAnim.AnimationTracks.Add(new AnimationTrackPlaySound() + { + KeyFrames = + { + new AnimationTrackPlaySound.KeyFrame(_blinkingSound, 0.5f) + } + }); + } + + return blinkingAnim; + } + } +} diff --git a/Content.Client/Input/ContentContexts.cs b/Content.Client/Input/ContentContexts.cs index b12c0a83d5..c49b79dcec 100644 --- a/Content.Client/Input/ContentContexts.cs +++ b/Content.Client/Input/ContentContexts.cs @@ -46,26 +46,28 @@ namespace Content.Client.Input human.AddFunction(ContentKeyFunctions.Arcade1); human.AddFunction(ContentKeyFunctions.Arcade2); human.AddFunction(ContentKeyFunctions.Arcade3); - human.AddFunction(ContentKeyFunctions.OpenActionsMenu); - human.AddFunction(ContentKeyFunctions.Hotbar0); - human.AddFunction(ContentKeyFunctions.Hotbar1); - human.AddFunction(ContentKeyFunctions.Hotbar2); - human.AddFunction(ContentKeyFunctions.Hotbar3); - human.AddFunction(ContentKeyFunctions.Hotbar4); - human.AddFunction(ContentKeyFunctions.Hotbar5); - human.AddFunction(ContentKeyFunctions.Hotbar6); - human.AddFunction(ContentKeyFunctions.Hotbar7); - human.AddFunction(ContentKeyFunctions.Hotbar8); - human.AddFunction(ContentKeyFunctions.Hotbar9); - human.AddFunction(ContentKeyFunctions.Loadout1); - human.AddFunction(ContentKeyFunctions.Loadout2); - human.AddFunction(ContentKeyFunctions.Loadout3); - human.AddFunction(ContentKeyFunctions.Loadout4); - human.AddFunction(ContentKeyFunctions.Loadout5); - human.AddFunction(ContentKeyFunctions.Loadout6); - human.AddFunction(ContentKeyFunctions.Loadout7); - human.AddFunction(ContentKeyFunctions.Loadout8); - human.AddFunction(ContentKeyFunctions.Loadout9); + + // actions should be common (for ghosts, mobs, etc) + common.AddFunction(ContentKeyFunctions.OpenActionsMenu); + common.AddFunction(ContentKeyFunctions.Hotbar0); + common.AddFunction(ContentKeyFunctions.Hotbar1); + common.AddFunction(ContentKeyFunctions.Hotbar2); + common.AddFunction(ContentKeyFunctions.Hotbar3); + common.AddFunction(ContentKeyFunctions.Hotbar4); + common.AddFunction(ContentKeyFunctions.Hotbar5); + common.AddFunction(ContentKeyFunctions.Hotbar6); + common.AddFunction(ContentKeyFunctions.Hotbar7); + common.AddFunction(ContentKeyFunctions.Hotbar8); + common.AddFunction(ContentKeyFunctions.Hotbar9); + common.AddFunction(ContentKeyFunctions.Loadout1); + common.AddFunction(ContentKeyFunctions.Loadout2); + common.AddFunction(ContentKeyFunctions.Loadout3); + common.AddFunction(ContentKeyFunctions.Loadout4); + common.AddFunction(ContentKeyFunctions.Loadout5); + common.AddFunction(ContentKeyFunctions.Loadout6); + common.AddFunction(ContentKeyFunctions.Loadout7); + common.AddFunction(ContentKeyFunctions.Loadout8); + common.AddFunction(ContentKeyFunctions.Loadout9); var ghost = contexts.New("ghost", "common"); ghost.AddFunction(EngineKeyFunctions.MoveUp); diff --git a/Content.Server/Actions/GhostBoo.cs b/Content.Server/Actions/GhostBoo.cs new file mode 100644 index 0000000000..e8ab01becc --- /dev/null +++ b/Content.Server/Actions/GhostBoo.cs @@ -0,0 +1,54 @@ +#nullable enable +using System.Linq; +using Content.Server.GameObjects.Components.Observer; +using Content.Shared.Actions; +using Content.Shared.GameObjects.Components.Mobs; +using Content.Shared.Utility; +using JetBrains.Annotations; +using Robust.Shared.Serialization; + +namespace Content.Server.Actions +{ + /// + /// Blink lights and scare livings + /// + [UsedImplicitly] + public class GhostBoo : IInstantAction + { + private float _radius; + private float _cooldown; + private int _maxTargets; + + void IExposeData.ExposeData(ObjectSerializer serializer) + { + serializer.DataField(ref _radius, "radius", 3); + serializer.DataField(ref _cooldown, "cooldown", 120); + serializer.DataField(ref _maxTargets, "maxTargets", 3); + } + + public void DoInstantAction(InstantActionEventArgs args) + { + if (!args.Performer.TryGetComponent(out var actions)) return; + + // find all IGhostBooAffected nearby and do boo on them + var entityMan = args.Performer.EntityManager; + var ents = entityMan.GetEntitiesInRange(args.Performer, _radius, false); + + var booCounter = 0; + foreach (var ent in ents) + { + var boos = ent.GetAllComponents().ToList(); + foreach (var boo in boos) + { + if (boo.AffectedByGhostBoo(args)) + booCounter++; + } + + if (booCounter >= _maxTargets) + break; + } + + actions.Cooldown(args.ActionType, Cooldowns.SecondsFromNow(_cooldown)); + } + } +} diff --git a/Content.Server/GameObjects/Components/Observer/IGhostBooAffected.cs b/Content.Server/GameObjects/Components/Observer/IGhostBooAffected.cs new file mode 100644 index 0000000000..26bc68b6ba --- /dev/null +++ b/Content.Server/GameObjects/Components/Observer/IGhostBooAffected.cs @@ -0,0 +1,19 @@ +#nullable enable +using Content.Shared.Actions; + +namespace Content.Server.GameObjects.Components.Observer +{ + /// + /// Allow ghost to interact with object by boo action + /// + public interface IGhostBooAffected + { + /// + /// Invokes when ghost used boo action near entity. + /// Use it to blink lights or make something spooky. + /// + /// Boo action details + /// Returns true if object was affected + bool AffectedByGhostBoo(InstantActionEventArgs args); + } +} diff --git a/Content.Server/GameObjects/Components/Power/ApcNetComponents/PowerReceiverUsers/PoweredLightComponent.cs b/Content.Server/GameObjects/Components/Power/ApcNetComponents/PowerReceiverUsers/PoweredLightComponent.cs index 5faa922a0d..b6613ea344 100644 --- a/Content.Server/GameObjects/Components/Power/ApcNetComponents/PowerReceiverUsers/PoweredLightComponent.cs +++ b/Content.Server/GameObjects/Components/Power/ApcNetComponents/PowerReceiverUsers/PoweredLightComponent.cs @@ -6,8 +6,11 @@ using Content.Server.GameObjects.Components.Items.Storage; using Content.Server.GameObjects.Components.MachineLinking; using Content.Server.GameObjects.Components.MachineLinking.Signals; using Content.Server.GameObjects.Components.Mobs; +using Content.Server.GameObjects.Components.Observer; +using Content.Shared.Actions; using Content.Shared.Damage; using Content.Shared.GameObjects.Components.Damage; +using Content.Shared.GameObjects.Components.Power.ApcNetComponents.PowerReceiverUsers; using Content.Shared.Interfaces; using Content.Shared.Interfaces.GameObjects.Components; using Robust.Server.GameObjects; @@ -15,6 +18,7 @@ using Robust.Shared.Audio; using Robust.Shared.GameObjects; using Robust.Shared.IoC; using Robust.Shared.Localization; +using Robust.Shared.Log; using Robust.Shared.Serialization; using Robust.Shared.Timing; using Robust.Shared.ViewVariables; @@ -25,17 +29,27 @@ namespace Content.Server.GameObjects.Components.Power.ApcNetComponents.PowerRece /// Component that represents a wall light. It has a light bulb that can be replaced when broken. /// [RegisterComponent] - public class PoweredLightComponent : Component, IInteractHand, IInteractUsing, IMapInit, ISignalReceiver, ISignalReceiver + public class PoweredLightComponent : Component, IInteractHand, IInteractUsing, IMapInit, ISignalReceiver, ISignalReceiver, IGhostBooAffected { [Dependency] private readonly IGameTiming _gameTiming = default!; public override string Name => "PoweredLight"; private static readonly TimeSpan _thunkDelay = TimeSpan.FromSeconds(2); + // time to blink light when ghost made boo nearby + private static readonly TimeSpan ghostBlinkingTime = TimeSpan.FromSeconds(10); + private static readonly TimeSpan ghostBlinkingCooldown = TimeSpan.FromSeconds(60); + + [ComponentDependency] + private readonly AppearanceComponent? _appearance; + private TimeSpan _lastThunk; + private TimeSpan? _lastGhostBlink; private bool _hasLampOnSpawn; [ViewVariables] private bool _on; + [ViewVariables] private bool _isBlinking; + [ViewVariables] private bool _ignoreGhostsBoo; private LightBulbType BulbType = LightBulbType.Tube; [ViewVariables] private ContainerSlot _lightBulbContainer = default!; @@ -145,6 +159,7 @@ namespace Content.Server.GameObjects.Components.Power.ApcNetComponents.PowerRece serializer.DataField(ref BulbType, "bulb", LightBulbType.Tube); serializer.DataField(ref _on, "on", true); serializer.DataField(ref _hasLampOnSpawn, "hasLampOnSpawn", true); + serializer.DataField(ref _ignoreGhostsBoo, "ignoreGhostsBoo", false); } /// @@ -163,13 +178,11 @@ namespace Content.Server.GameObjects.Components.Power.ApcNetComponents.PowerRece public void UpdateLight() { var powerReceiver = Owner.GetComponent(); - var sprite = Owner.GetComponent(); - var light = Owner.GetComponent(); + if (LightBulb == null) // No light bulb. { powerReceiver.Load = 0; - sprite.LayerSetState(0, "empty"); - light.Enabled = false; + _appearance?.SetData(PoweredLightVisuals.BulbState, PoweredLightState.Empty); return; } @@ -179,9 +192,8 @@ namespace Content.Server.GameObjects.Components.Power.ApcNetComponents.PowerRece if (powerReceiver.Powered && _on) { powerReceiver.Load = LightBulb.PowerUse; - sprite.LayerSetState(0, "on"); - light.Enabled = true; - light.Color = LightBulb.Color; + _appearance?.SetData(PoweredLightVisuals.BulbState, PoweredLightState.On); + _appearance?.SetData(PoweredLightVisuals.BulbColor, LightBulb.Color); var time = _gameTiming.CurTime; if (time > _lastThunk + _thunkDelay) { @@ -191,17 +203,14 @@ namespace Content.Server.GameObjects.Components.Power.ApcNetComponents.PowerRece } else { - sprite.LayerSetState(0, "off"); - light.Enabled = false; + _appearance?.SetData(PoweredLightVisuals.BulbState, PoweredLightState.Off); } break; case LightBulbState.Broken: - sprite.LayerSetState(0, "broken"); - light.Enabled = false; + _appearance?.SetData(PoweredLightVisuals.BulbState, PoweredLightState.Broken); break; case LightBulbState.Burned: - sprite.LayerSetState(0, "burned"); - light.Enabled = false; + _appearance?.SetData(PoweredLightVisuals.BulbState, PoweredLightState.Burned); break; } } @@ -268,5 +277,36 @@ namespace Content.Server.GameObjects.Components.Power.ApcNetComponents.PowerRece _on = !_on; UpdateLight(); } + + public void ToggleBlinkingLight(bool isNowBlinking) + { + if (_isBlinking == isNowBlinking) + return; + + _isBlinking = isNowBlinking; + _appearance?.SetData(PoweredLightVisuals.Blinking, _isBlinking); + } + + public bool AffectedByGhostBoo(InstantActionEventArgs args) + { + if (_ignoreGhostsBoo) + return false; + + // check cooldown first to prevent abuse + var time = _gameTiming.CurTime; + if (_lastGhostBlink != null) + { + if (time <= _lastGhostBlink + ghostBlinkingCooldown) + return false; + } + _lastGhostBlink = time; + + ToggleBlinkingLight(true); + Owner.SpawnTimer(ghostBlinkingTime, () => { + ToggleBlinkingLight(false); + }); + + return true; + } } } diff --git a/Content.Shared/Actions/ActionType.cs b/Content.Shared/Actions/ActionType.cs index 80690d8117..db95f165ee 100644 --- a/Content.Shared/Actions/ActionType.cs +++ b/Content.Shared/Actions/ActionType.cs @@ -8,6 +8,7 @@ Error, HumanScream, Disarm, + GhostBoo, DebugInstant, DebugToggle, DebugTargetPoint, diff --git a/Content.Shared/GameObjects/Components/Power/ApcNetComponents/PowerReceiverUsers/SharedPoweredLightVisuals.cs b/Content.Shared/GameObjects/Components/Power/ApcNetComponents/PowerReceiverUsers/SharedPoweredLightVisuals.cs new file mode 100644 index 0000000000..6fdd3a5b3d --- /dev/null +++ b/Content.Shared/GameObjects/Components/Power/ApcNetComponents/PowerReceiverUsers/SharedPoweredLightVisuals.cs @@ -0,0 +1,29 @@ +#nullable enable +using Robust.Shared.Serialization; +using System; + +namespace Content.Shared.GameObjects.Components.Power.ApcNetComponents.PowerReceiverUsers +{ + [Serializable, NetSerializable] + public enum PoweredLightVisuals : byte + { + BulbState, + BulbColor, + Blinking + } + + [Serializable, NetSerializable] + public enum PoweredLightState : byte + { + Empty, + On, + Off, + Broken, + Burned + } + + public enum PoweredLightLayers : byte + { + Base + } +} diff --git a/Resources/Prototypes/Actions/actions.yml b/Resources/Prototypes/Actions/actions.yml index d588369a6b..badf781f8f 100644 --- a/Resources/Prototypes/Actions/actions.yml +++ b/Resources/Prototypes/Actions/actions.yml @@ -33,6 +33,19 @@ repeat: true behavior: !type:DisarmAction { } +- type: action + actionType: GhostBoo + icon: Interface/Actions/scream.png + name: "Boo" + description: "Scare your crew members because of boredom!" + filters: + - ghost + behaviorType: Instant + behavior: !type:GhostBoo + radius: 3 + cooldown: 120 + maxTargets: 3 + - type: action actionType: DebugInstant icon: Interface/Alerts/Human/human1.png diff --git a/Resources/Prototypes/Entities/Constructible/Walls/emergency_light.yml b/Resources/Prototypes/Entities/Constructible/Walls/emergency_light.yml index a97c19eea8..cbcb1a0e6f 100644 --- a/Resources/Prototypes/Entities/Constructible/Walls/emergency_light.yml +++ b/Resources/Prototypes/Entities/Constructible/Walls/emergency_light.yml @@ -18,7 +18,8 @@ - type: EmergencyLight - type: Sprite sprite: Constructible/Lighting/emergency_light.rsi - state: emergency_light_off + layers: + - state: emergency_light_off placement: snap: - Wallmount diff --git a/Resources/Prototypes/Entities/Constructible/Walls/lighting.yml b/Resources/Prototypes/Entities/Constructible/Walls/lighting.yml index 3f9a666352..2e1c49aa26 100644 --- a/Resources/Prototypes/Entities/Constructible/Walls/lighting.yml +++ b/Resources/Prototypes/Entities/Constructible/Walls/lighting.yml @@ -16,6 +16,9 @@ - type: LoopingSound - type: Sprite sprite: Constructible/Lighting/light_tube.rsi + layers: + - state: on + map: ["enum.PoweredLightLayers.Base"] state: on - type: PointLight radius: 8 @@ -57,6 +60,10 @@ - type: PoweredLight bulb: Tube - type: PowerReceiver + - type: Appearance + visuals: + - type: PoweredLightVisualizer + blinkingSound: "/Audio/Machines/light_tube_on.ogg" - type: entity id: PoweredlightEmpty @@ -122,6 +129,9 @@ - type: PoweredLight bulb: Bulb - type: PowerReceiver + - type: Appearance + visuals: + - type: PoweredLightVisualizer - type: entity id: PoweredSmallLightEmpty diff --git a/Resources/Prototypes/Entities/Mobs/Player/observer.yml b/Resources/Prototypes/Entities/Mobs/Player/observer.yml index ddb6d7dec9..c8ff82560a 100644 --- a/Resources/Prototypes/Entities/Mobs/Player/observer.yml +++ b/Resources/Prototypes/Entities/Mobs/Player/observer.yml @@ -41,3 +41,6 @@ baseSprintSpeed: 14 baseWalkSpeed: 7 - type: MovementIgnoreGravity + - type: Actions + innateActions: + - GhostBoo