diff --git a/Content.Client/Tips/ClippyUI.xaml b/Content.Client/Tips/ClippyUI.xaml new file mode 100644 index 0000000000..496c47e11a --- /dev/null +++ b/Content.Client/Tips/ClippyUI.xaml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/Content.Client/Tips/ClippyUI.xaml.cs b/Content.Client/Tips/ClippyUI.xaml.cs new file mode 100644 index 0000000000..e92b51eebe --- /dev/null +++ b/Content.Client/Tips/ClippyUI.xaml.cs @@ -0,0 +1,53 @@ +using Content.Client.Paper; +using Robust.Client.AutoGenerated; +using Robust.Client.Graphics; +using Robust.Client.ResourceManagement; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.XAML; + +namespace Content.Client.Tips; + +[GenerateTypedNameReferences] +public sealed partial class ClippyUI : UIWidget +{ + public ClippyState State = ClippyState.Hidden; + + public ClippyUI() + { + RobustXamlLoader.Load(this); + } + + public void InitLabel(PaperVisualsComponent? visuals, IResourceCache resCache) + { + if (visuals == null) + return; + + Label.ModulateSelfOverride = visuals.FontAccentColor; + + if (visuals.BackgroundImagePath == null) + return; + + LabelPanel.ModulateSelfOverride = visuals.BackgroundModulate; + var backgroundImage = resCache.GetResource(visuals.BackgroundImagePath); + var backgroundImageMode = visuals.BackgroundImageTile ? StyleBoxTexture.StretchMode.Tile : StyleBoxTexture.StretchMode.Stretch; + var backgroundPatchMargin = visuals.BackgroundPatchMargin; + LabelPanel.PanelOverride = new StyleBoxTexture + { + Texture = backgroundImage, + TextureScale = visuals.BackgroundScale, + Mode = backgroundImageMode, + PatchMarginLeft = backgroundPatchMargin.Left, + PatchMarginBottom = backgroundPatchMargin.Bottom, + PatchMarginRight = backgroundPatchMargin.Right, + PatchMarginTop = backgroundPatchMargin.Top + }; + } + + public enum ClippyState : byte + { + Hidden, + Revealing, + Speaking, + Hiding, + } +} diff --git a/Content.Client/Tips/ClippyUIController.cs b/Content.Client/Tips/ClippyUIController.cs new file mode 100644 index 0000000000..2072d9b966 --- /dev/null +++ b/Content.Client/Tips/ClippyUIController.cs @@ -0,0 +1,244 @@ +using Content.Client.Gameplay; +using Content.Client.Message; +using Content.Client.Paper; +using Content.Shared.CCVar; +using Content.Shared.Movement.Components; +using Content.Shared.Tips; +using Robust.Client.GameObjects; +using Robust.Client.ResourceManagement; +using Robust.Client.State; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controllers; +using Robust.Client.UserInterface.Controls; +using Robust.Shared.Configuration; +using Robust.Shared.Console; +using Robust.Shared.Map; +using Robust.Shared.Prototypes; +using Robust.Shared.Timing; +using static Content.Client.Tips.ClippyUI; + +namespace Content.Client.Tips; + +public sealed class ClippyUIController : UIController +{ + [Dependency] private readonly IStateManager _state = default!; + [Dependency] private readonly IConsoleHost _conHost = default!; + [Dependency] private readonly IPrototypeManager _protoMan = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; + [Dependency] private readonly IResourceCache _resCache = default!; + + [UISystemDependency] private readonly AudioSystem? _audio = default; + + public const float Padding = 50; + public static Angle WaddleRotation = Angle.FromDegrees(10); + + private EntityUid _entity; + private float _secondsUntilNextState; + private int _previousStep = 0; + private ClippyEvent? _currentMessage; + private readonly Queue _queuedMessages = new(); + + public override void Initialize() + { + base.Initialize(); + + _conHost.RegisterCommand("local_clippy", ClippyCommand); + UIManager.OnScreenChanged += OnScreenChanged; + } + + private void ClippyCommand(IConsoleShell shell, string argStr, string[] args) + { + if (args.Length == 0) + { + shell.WriteLine("usage: clippy [entity prototype] [speak time] [animate time] [waddle]"); + return; + } + + var ev = new ClippyEvent(args[0]); + + string proto; + if (args.Length > 1) + { + ev.Proto = args[1]; + + if (!_protoMan.HasIndex(ev.Proto)) + { + shell.WriteError($"Unknown prototype: {ev.Proto}"); + return; + } + } + + + if (args.Length > 2) + ev.SpeakTime = float.Parse(args[2]); + + if (args.Length > 3) + ev.SlideTime = float.Parse(args[3]); + + if (args.Length > 4) + ev.WaddleInterval = float.Parse(args[4]); + + AddMessage(ev); + } + + public void AddMessage(ClippyEvent ev) + { + _queuedMessages.Enqueue(ev); + } + + public override void FrameUpdate(FrameEventArgs args) + { + base.FrameUpdate(args); + + var screen = UIManager.ActiveScreen; + if (screen == null) + { + _queuedMessages.Clear(); + return; + } + + var clippy = screen.GetOrAddWidget(); + _secondsUntilNextState -= args.DeltaSeconds; + + if (_secondsUntilNextState <= 0) + NextState(clippy); + else + { + var pos = UpdatePosition(clippy, screen.Size, args); ; + LayoutContainer.SetPosition(clippy, pos); + } + } + + private Vector2 UpdatePosition(ClippyUI clippy, Vector2 screenSize, FrameEventArgs args) + { + if (_currentMessage == null) + return default; + + var slideTime = _currentMessage.SlideTime; + + var offset = clippy.State switch + { + ClippyState.Hidden => 0, + ClippyState.Revealing => Math.Clamp(1 - _secondsUntilNextState / slideTime, 0, 1), + ClippyState.Hiding => Math.Clamp(_secondsUntilNextState / slideTime, 0, 1), + _ => 1, + }; + + var waddle = _currentMessage.WaddleInterval; + + if (_currentMessage == null + || waddle <= 0 + || clippy.State == ClippyState.Hidden + || clippy.State == ClippyState.Speaking + || !EntityManager.TryGetComponent(_entity, out SpriteComponent? sprite)) + { + return (screenSize.X - offset * (clippy.DesiredSize.X + Padding), (screenSize.Y - clippy.DesiredSize.Y) / 2); + } + + var numSteps = (int) Math.Ceiling(slideTime / waddle); + var curStep = (int) Math.Floor(numSteps * offset); + var stepSize = (clippy.DesiredSize.X + Padding) / numSteps; + + if (curStep != _previousStep) + { + _previousStep = curStep; + sprite.Rotation = sprite.Rotation > 0 + ? -WaddleRotation + : WaddleRotation; + + if (EntityManager.TryGetComponent(_entity, out FootstepModifierComponent? step)) + { + var audioParams = step.Sound.Params + .AddVolume(-7f) + .WithVariation(0.1f); + _audio?.PlayGlobal(step.Sound, EntityUid.Invalid, audioParams); + } + } + + return (screenSize.X - stepSize * curStep, (screenSize.Y - clippy.DesiredSize.Y) / 2); + } + + private void NextState(ClippyUI clippy) + { + SpriteComponent? sprite; + switch (clippy.State) + { + case ClippyState.Hidden: + if (!_queuedMessages.TryDequeue(out var next)) + return; + + _entity = EntityManager.SpawnEntity(next.Proto ?? _cfg.GetCVar(CCVars.ClippyEntity), MapCoordinates.Nullspace); + if (!EntityManager.TryGetComponent(_entity, out sprite)) + return; + clippy.InitLabel(EntityManager.GetComponentOrNull(_entity), _resCache); + + var scale = sprite.Scale; + sprite.Scale = Vector2.One; + clippy.Entity.Sprite = sprite; + clippy.Entity.Scale = scale; + + _currentMessage = next; + _secondsUntilNextState = next.SlideTime; + clippy.State = ClippyState.Revealing; + _previousStep = 0; + sprite.LayerSetAnimationTime("revealing", 0); + sprite.LayerSetVisible("revealing", true); + sprite.LayerSetVisible("speaking", false); + sprite.LayerSetVisible("hiding", false); + sprite.Rotation = 0; + clippy.Label.SetMarkup(_currentMessage.Msg); + clippy.LabelPanel.Visible = false; + clippy.Visible = true; + sprite.Visible = true; + break; + + case ClippyState.Revealing: + clippy.State = ClippyState.Speaking; + if (!EntityManager.TryGetComponent(_entity, out sprite)) + return; + sprite.Rotation = 0; + _previousStep = 0; + sprite.LayerSetAnimationTime("speaking", 0); + sprite.LayerSetVisible("revealing", false); + sprite.LayerSetVisible("speaking", true); + sprite.LayerSetVisible("hiding", false); + clippy.LabelPanel.Visible = true; + clippy.InvalidateArrange(); + clippy.InvalidateMeasure(); + if (_currentMessage != null) + _secondsUntilNextState = _currentMessage.SpeakTime; + + break; + + case ClippyState.Speaking: + clippy.State = ClippyState.Hiding; + if (!EntityManager.TryGetComponent(_entity, out sprite)) + return; + sprite.LayerSetAnimationTime("hiding", 0); + sprite.LayerSetVisible("revealing", false); + sprite.LayerSetVisible("speaking", false); + sprite.LayerSetVisible("hiding", true); + clippy.LabelPanel.Visible = false; + if (_currentMessage != null) + _secondsUntilNextState = _currentMessage.SlideTime; + break; + + default: // finished hiding + + EntityManager.DeleteEntity(_entity); + _entity = default; + clippy.Visible = false; + _currentMessage = null; + _secondsUntilNextState = 0; + clippy.State = ClippyState.Hidden; + break; + } + } + + private void OnScreenChanged((UIScreen? Old, UIScreen? New) ev) + { + ev.Old?.RemoveWidget(); + _currentMessage = null; + EntityManager.DeleteEntity(_entity); + } +} diff --git a/Content.Client/Tips/TipsSystem.cs b/Content.Client/Tips/TipsSystem.cs new file mode 100644 index 0000000000..af9847a4ce --- /dev/null +++ b/Content.Client/Tips/TipsSystem.cs @@ -0,0 +1,20 @@ +using Content.Shared.Tips; +using Robust.Client.UserInterface; + +namespace Content.Client.Tips; + +public sealed class TipsSystem : EntitySystem +{ + [Dependency] private readonly IUserInterfaceManager _uiMan = default!; + public override void Initialize() + { + base.Initialize(); + + SubscribeNetworkEvent(OnClippyEv); + } + + private void OnClippyEv(ClippyEvent ev) + { + _uiMan.GetUIController().AddMessage(ev); + } +} diff --git a/Content.Server/Tips/TipsSystem.cs b/Content.Server/Tips/TipsSystem.cs index 3ca7a0414b..c88e27d97a 100644 --- a/Content.Server/Tips/TipsSystem.cs +++ b/Content.Server/Tips/TipsSystem.cs @@ -3,7 +3,11 @@ using Content.Server.GameTicking; using Content.Shared.CCVar; using Content.Shared.Chat; using Content.Shared.Dataset; +using Content.Shared.Tips; +using Robust.Server.GameObjects; +using Robust.Server.Player; using Robust.Shared.Configuration; +using Robust.Shared.Console; using Robust.Shared.Player; using Robust.Shared.Prototypes; using Robust.Shared.Random; @@ -23,6 +27,7 @@ public sealed class TipsSystem : EntitySystem [Dependency] private readonly IGameTiming _timing = default!; [Dependency] private readonly IRobustRandom _random = default!; [Dependency] private readonly GameTicker _ticker = default!; + [Dependency] private readonly IConsoleHost _conHost = default!; private bool _tipsEnabled; private float _tipTimeOutOfRound; @@ -43,8 +48,65 @@ public sealed class TipsSystem : EntitySystem _cfg.OnValueChanged(CCVars.TipsDataset, SetDataset, true); RecalculateNextTipTime(); + _conHost.RegisterCommand("clippy", SendClippy); + _conHost.RegisterCommand("tip", SendTip); } + private void SendTip(IConsoleShell shell, string argstr, string[] args) + { + AnnounceRandomTip(); + RecalculateNextTipTime(); + } + + private void SendClippy(IConsoleShell shell, string argstr, string[] args) + { + if (args.Length < 2) + { + shell.WriteLine( + "usage: clippy [entity prototype] [speak time] [slide time] [waddle]"); + return; + } + + ActorComponent? actor = null; + if (args[0] != "broadcast" && args[0] != "all") + { + if (!EntityUid.TryParse(args[0], out var uid) + || !TryComp(uid, out actor)) + { + shell.WriteError($"Could not find player {args[0]}"); + return; + } + } + + var ev = new ClippyEvent(args[1]); + + string proto; + if (args.Length > 2) + { + ev.Proto = args[2]; + if (!_prototype.HasIndex(args[2])) + { + shell.WriteError($"Unknown prototype: {args[2]}"); + return; + } + } + + if (args.Length > 3) + ev.SpeakTime = float.Parse(args[3]); + + if (args.Length > 4) + ev.SlideTime = float.Parse(args[4]); + + if (args.Length > 5) + ev.WaddleInterval = float.Parse(args[5]); + + if (actor != null) + RaiseNetworkEvent(ev, actor.PlayerSession); + else + RaiseNetworkEvent(ev); + } + + public override void Update(float frameTime) { base.Update(frameTime); @@ -88,10 +150,11 @@ public sealed class TipsSystem : EntitySystem return; var tip = _random.Pick(tips.Values); - var msg = Loc.GetString("tips-system-chat-message-wrap", ("tip", tip)); + var msg = $"Tippy the Clown says:\n\n{tip}"; - _chat.ChatMessageToManyFiltered(Filter.Broadcast(), ChatChannel.OOC, tip, msg, - EntityUid.Invalid, false, false, Color.MediumPurple); + var ev = new ClippyEvent(msg); + ev.SpeakTime = 1 + tip.Length * 0.05f; + RaiseNetworkEvent(ev); } private void RecalculateNextTipTime() diff --git a/Content.Shared/CCVar/CCVars.cs b/Content.Shared/CCVar/CCVars.cs index 8280d8d294..12fd34666a 100644 --- a/Content.Shared/CCVar/CCVars.cs +++ b/Content.Shared/CCVar/CCVars.cs @@ -1549,5 +1549,9 @@ namespace Content.Shared.CCVar /// public static readonly CVarDef ConfigPresetDebug = CVarDef.Create("config.preset_debug", true, CVar.SERVERONLY); + + // april fools + public static readonly CVarDef ClippyEntity = + CVarDef.Create("clippy.entity", "Tippy", CVar.SERVER | CVar.REPLICATED); } } diff --git a/Content.Shared/Tips/ClippyEvent.cs b/Content.Shared/Tips/ClippyEvent.cs new file mode 100644 index 0000000000..b214fbf157 --- /dev/null +++ b/Content.Shared/Tips/ClippyEvent.cs @@ -0,0 +1,19 @@ + +using Robust.Shared.Serialization; + +namespace Content.Shared.Tips; + +[Serializable, NetSerializable] +public sealed class ClippyEvent : EntityEventArgs +{ + public ClippyEvent(string msg) + { + Msg = msg; + } + + public string Msg; + public string? Proto; + public float SpeakTime = 5; + public float SlideTime = 3; + public float WaddleInterval = 0.5f; +} diff --git a/Resources/Prototypes/Entities/Debugging/clippy.yml b/Resources/Prototypes/Entities/Debugging/clippy.yml new file mode 100644 index 0000000000..76282ce098 --- /dev/null +++ b/Resources/Prototypes/Entities/Debugging/clippy.yml @@ -0,0 +1,29 @@ +- type: entity + id: Tippy + components: + - type: Sprite + netsync: false + noRot: false + scale: 4,4 + layers: + - sprite: Tips/tippy.rsi + state: left + map: [ "revealing" ] + - sprite: Tips/tippy.rsi + state: right + map: [ "hiding" ] + - sprite: Tips/tippy.rsi + state: down + visible: false + map: [ "speaking" ] + # footstep sounds wile waddling onto the screen. + - type: FootstepModifier + footstepSoundCollection: + collection: FootstepClown + # visuals for the speech bubble. + # only supports background image. + - type: PaperVisuals + backgroundImagePath: "/Textures/Interface/Paper/paper_background_default.svg.96dpi.png" + backgroundPatchMargin: 16.0, 16.0, 16.0, 16.0 + backgroundModulate: "#ffffcc" + fontAccentColor: "#000000" \ No newline at end of file diff --git a/Resources/Textures/Tips/tippy.rsi/down.png b/Resources/Textures/Tips/tippy.rsi/down.png new file mode 100644 index 0000000000..bdfcf315b6 Binary files /dev/null and b/Resources/Textures/Tips/tippy.rsi/down.png differ diff --git a/Resources/Textures/Tips/tippy.rsi/left.png b/Resources/Textures/Tips/tippy.rsi/left.png new file mode 100644 index 0000000000..f2293c6111 Binary files /dev/null and b/Resources/Textures/Tips/tippy.rsi/left.png differ diff --git a/Resources/Textures/Tips/tippy.rsi/meta.json b/Resources/Textures/Tips/tippy.rsi/meta.json new file mode 100644 index 0000000000..68942d731c --- /dev/null +++ b/Resources/Textures/Tips/tippy.rsi/meta.json @@ -0,0 +1,20 @@ +{ + "version": 1, + "license": "CC-BY-SA-3.0", + "copyright": "", + "size": { + "x": 32, + "y": 32 + }, + "states": [ + { + "name": "down" + }, + { + "name": "left" + }, + { + "name": "right" + } + ] +} diff --git a/Resources/Textures/Tips/tippy.rsi/right.png b/Resources/Textures/Tips/tippy.rsi/right.png new file mode 100644 index 0000000000..900262932d Binary files /dev/null and b/Resources/Textures/Tips/tippy.rsi/right.png differ diff --git a/Resources/engineCommandPerms.yml b/Resources/engineCommandPerms.yml index 05793d11e9..88e49ddac7 100644 --- a/Resources/engineCommandPerms.yml +++ b/Resources/engineCommandPerms.yml @@ -124,3 +124,8 @@ - Flags: QUERY Commands: - forall + +- Flags: FUN + Commands: + - clippy + - tip