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