--- /dev/null
+<tips:TippyUI xmlns="https://spacestation14.io"
+ xmlns:tips="clr-namespace:Content.Client.Tips"
+ MinSize="64 64"
+ Visible="False">
+ <PanelContainer Name="LabelPanel" Access="Public" Visible="False" MaxWidth="300" MaxHeight="200">
+ <ScrollContainer Name="ScrollingContents" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" HorizontalExpand="True" VerticalExpand="True" HScrollEnabled="False" ReturnMeasure="True">
+ <RichTextLabel Name="Label" Access="Public"/>
+ </ScrollContainer>
+ </PanelContainer>
+ <SpriteView Name="Entity" Access="Public" MinSize="128 128"/>
+</tips:TippyUI>
--- /dev/null
+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 TippyUI : UIWidget
+{
+ public TippyState State = TippyState.Hidden;
+ public bool ModifyLayers = true;
+
+ public TippyUI()
+ {
+ 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<TextureResource>(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 TippyState : byte
+ {
+ Hidden,
+ Revealing,
+ Speaking,
+ Hiding,
+ }
+}
--- /dev/null
+using Content.Client.Gameplay;
+using System.Numerics;
+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.Client.Audio;
+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.TippyUI;
+
+namespace Content.Client.Tips;
+
+public sealed class TippyUIController : 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!;
+ [UISystemDependency] private readonly EntityManager _entSys = default!;
+
+ public const float Padding = 50;
+ public static Angle WaddleRotation = Angle.FromDegrees(10);
+
+ private EntityUid _entity;
+ private float _secondsUntilNextState;
+ private int _previousStep = 0;
+ private TippyEvent? _currentMessage;
+ private readonly Queue<TippyEvent> _queuedMessages = new();
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ UIManager.OnScreenChanged += OnScreenChanged;
+ }
+
+ public void AddMessage(TippyEvent ev)
+ {
+ _queuedMessages.Enqueue(ev);
+ }
+
+ public override void FrameUpdate(FrameEventArgs args)
+ {
+ base.FrameUpdate(args);
+
+ var screen = UIManager.ActiveScreen;
+ if (screen == null)
+ {
+ _queuedMessages.Clear();
+ return;
+ }
+
+ var tippy = screen.GetOrAddWidget<TippyUI>();
+ _secondsUntilNextState -= args.DeltaSeconds;
+
+ if (_secondsUntilNextState <= 0)
+ NextState(tippy);
+ else
+ {
+ var pos = UpdatePosition(tippy, screen.Size, args); ;
+ LayoutContainer.SetPosition(tippy, pos);
+ }
+ }
+
+ private Vector2 UpdatePosition(TippyUI tippy, Vector2 screenSize, FrameEventArgs args)
+ {
+ if (_currentMessage == null)
+ return default;
+
+ var slideTime = _currentMessage.SlideTime;
+
+ var offset = tippy.State switch
+ {
+ TippyState.Hidden => 0,
+ TippyState.Revealing => Math.Clamp(1 - _secondsUntilNextState / slideTime, 0, 1),
+ TippyState.Hiding => Math.Clamp(_secondsUntilNextState / slideTime, 0, 1),
+ _ => 1,
+ };
+
+ var waddle = _currentMessage.WaddleInterval;
+
+ if (_currentMessage == null
+ || waddle <= 0
+ || tippy.State == TippyState.Hidden
+ || tippy.State == TippyState.Speaking
+ || !EntityManager.TryGetComponent(_entity, out SpriteComponent? sprite))
+ {
+ return new Vector2(screenSize.X - offset * (tippy.DesiredSize.X + Padding), (screenSize.Y - tippy.DesiredSize.Y) / 2);
+ }
+
+ var numSteps = (int) Math.Ceiling(slideTime / waddle);
+ var curStep = (int) Math.Floor(numSteps * offset);
+ var stepSize = (tippy.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.FootstepSoundCollection.Params
+ .AddVolume(-7f)
+ .WithVariation(0.1f);
+ _audio.PlayGlobal(step.FootstepSoundCollection, EntityUid.Invalid, audioParams);
+ }
+ }
+
+ return new Vector2(screenSize.X - stepSize * curStep, (screenSize.Y - tippy.DesiredSize.Y) / 2);
+ }
+
+ private void NextState(TippyUI tippy)
+ {
+ SpriteComponent? sprite;
+ switch (tippy.State)
+ {
+ case TippyState.Hidden:
+ if (!_queuedMessages.TryDequeue(out var next))
+ return;
+
+ if (next.Proto != null)
+ {
+ _entity = EntityManager.SpawnEntity(next.Proto, MapCoordinates.Nullspace);
+ tippy.ModifyLayers = false;
+ }
+ else
+ {
+ _entity = EntityManager.SpawnEntity(_cfg.GetCVar(CCVars.TippyEntity), MapCoordinates.Nullspace);
+ tippy.ModifyLayers = true;
+ }
+ if (!EntityManager.TryGetComponent(_entity, out sprite))
+ return;
+ if (!EntityManager.HasComponent<PaperVisualsComponent>(_entity))
+ {
+ var paper = EntityManager.AddComponent<PaperVisualsComponent>(_entity);
+ paper.BackgroundImagePath = "/Textures/Interface/Paper/paper_background_default.svg.96dpi.png";
+ paper.BackgroundPatchMargin = new(16f, 16f, 16f, 16f);
+ paper.BackgroundModulate = new(255, 255, 204);
+ paper.FontAccentColor = new(0, 0, 0);
+ }
+ tippy.InitLabel(EntityManager.GetComponentOrNull<PaperVisualsComponent>(_entity), _resCache);
+
+ var scale = sprite.Scale;
+ if (tippy.ModifyLayers)
+ {
+ sprite.Scale = Vector2.One;
+ }
+ else
+ {
+ sprite.Scale = new Vector2(3, 3);
+ }
+ tippy.Entity.SetEntity(_entity);
+ tippy.Entity.Scale = scale;
+
+ _currentMessage = next;
+ _secondsUntilNextState = next.SlideTime;
+ tippy.State = TippyState.Revealing;
+ _previousStep = 0;
+ if (tippy.ModifyLayers)
+ {
+ sprite.LayerSetAnimationTime("revealing", 0);
+ sprite.LayerSetVisible("revealing", true);
+ sprite.LayerSetVisible("speaking", false);
+ sprite.LayerSetVisible("hiding", false);
+ }
+ sprite.Rotation = 0;
+ tippy.Label.SetMarkup(_currentMessage.Msg);
+ tippy.Label.Visible = false;
+ tippy.LabelPanel.Visible = false;
+ tippy.Visible = true;
+ sprite.Visible = true;
+ break;
+
+ case TippyState.Revealing:
+ tippy.State = TippyState.Speaking;
+ if (!EntityManager.TryGetComponent(_entity, out sprite))
+ return;
+ sprite.Rotation = 0;
+ _previousStep = 0;
+ if (tippy.ModifyLayers)
+ {
+ sprite.LayerSetAnimationTime("speaking", 0);
+ sprite.LayerSetVisible("revealing", false);
+ sprite.LayerSetVisible("speaking", true);
+ sprite.LayerSetVisible("hiding", false);
+ }
+ tippy.Label.Visible = true;
+ tippy.LabelPanel.Visible = true;
+ tippy.InvalidateArrange();
+ tippy.InvalidateMeasure();
+ if (_currentMessage != null)
+ _secondsUntilNextState = _currentMessage.SpeakTime;
+
+ break;
+
+ case TippyState.Speaking:
+ tippy.State = TippyState.Hiding;
+ if (!EntityManager.TryGetComponent(_entity, out sprite))
+ return;
+ if (tippy.ModifyLayers)
+ {
+ sprite.LayerSetAnimationTime("hiding", 0);
+ sprite.LayerSetVisible("revealing", false);
+ sprite.LayerSetVisible("speaking", false);
+ sprite.LayerSetVisible("hiding", true);
+ }
+ tippy.LabelPanel.Visible = false;
+ if (_currentMessage != null)
+ _secondsUntilNextState = _currentMessage.SlideTime;
+ break;
+
+ default: // finished hiding
+
+ EntityManager.DeleteEntity(_entity);
+ _entity = default;
+ tippy.Visible = false;
+ _currentMessage = null;
+ _secondsUntilNextState = 0;
+ tippy.State = TippyState.Hidden;
+ break;
+ }
+ }
+
+ private void OnScreenChanged((UIScreen? Old, UIScreen? New) ev)
+ {
+ ev.Old?.RemoveWidget<TippyUI>();
+ _currentMessage = null;
+ EntityManager.DeleteEntity(_entity);
+ }
+}
--- /dev/null
+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<TippyEvent>(OnClippyEv);
+ }
+
+ private void OnClippyEv(TippyEvent ev)
+ {
+ _uiMan.GetUIController<TippyUIController>().AddMessage(ev);
+ }
+}
-using Content.Server.Chat.Managers;
+using Content.Server.Chat.Managers;
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;
[Dependency] private readonly IGameTiming _timing = default!;
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly GameTicker _ticker = default!;
+ [Dependency] private readonly IConsoleHost _conHost = default!;
+ [Dependency] private readonly IPlayerManager _playerManager = default!;
private bool _tipsEnabled;
private float _tipTimeOutOfRound;
private float _tipTimeInRound;
private string _tipsDataset = "";
+ private float _tipTippyChance;
[ViewVariables(VVAccess.ReadWrite)]
private TimeSpan _nextTipTime = TimeSpan.Zero;
Subs.CVar(_cfg, CCVars.TipFrequencyInRound, SetInRound, true);
Subs.CVar(_cfg, CCVars.TipsEnabled, SetEnabled, true);
Subs.CVar(_cfg, CCVars.TipsDataset, SetDataset, true);
+ Subs.CVar(_cfg, CCVars.TipsTippyChance, SetTippyChance, true);
RecalculateNextTipTime();
+ _conHost.RegisterCommand("tippy", Loc.GetString("cmd-tippy-desc"), Loc.GetString("cmd-tippy-help"), SendTippy, SendTippyHelper);
+ _conHost.RegisterCommand("tip", Loc.GetString("cmd-tip-desc"), "tip", SendTip);
}
+ private CompletionResult SendTippyHelper(IConsoleShell shell, string[] args)
+ {
+ return args.Length switch
+ {
+ 1 => CompletionResult.FromHintOptions(CompletionHelper.SessionNames(), Loc.GetString("cmd-tippy-auto-1")),
+ 2 => CompletionResult.FromHint(Loc.GetString("cmd-tippy-auto-2")),
+ 3 => CompletionResult.FromHintOptions(CompletionHelper.PrototypeIDs<EntityPrototype>(), Loc.GetString("cmd-tippy-auto-3")),
+ 4 => CompletionResult.FromHint(Loc.GetString("cmd-tippy-auto-4")),
+ 5 => CompletionResult.FromHint(Loc.GetString("cmd-tippy-auto-5")),
+ 6 => CompletionResult.FromHint(Loc.GetString("cmd-tippy-auto-6")),
+ _ => CompletionResult.Empty
+ };
+ }
+
+ private void SendTip(IConsoleShell shell, string argstr, string[] args)
+ {
+ AnnounceRandomTip();
+ RecalculateNextTipTime();
+ }
+
+ private void SendTippy(IConsoleShell shell, string argstr, string[] args)
+ {
+ if (args.Length < 2)
+ {
+ shell.WriteLine(Loc.GetString("cmd-tippy-help"));
+ return;
+ }
+
+ ActorComponent? actor = null;
+ if (args[0] != "all")
+ {
+ ICommonSession? session;
+ if (args.Length > 0)
+ {
+ // Get player entity
+ if (!_playerManager.TryGetSessionByUsername(args[0], out session))
+ {
+ shell.WriteLine(Loc.GetString("cmd-tippy-error-no-user"));
+ return;
+ }
+ }
+ else
+ {
+ session = shell.Player;
+ }
+
+ if (session?.AttachedEntity is not { } user)
+ {
+ shell.WriteLine(Loc.GetString("cmd-tippy-error-no-user"));
+ return;
+ }
+
+ if (!TryComp(user, out actor))
+ {
+ shell.WriteError(Loc.GetString("cmd-tippy-error-no-user"));
+ return;
+ }
+ }
+
+ var ev = new TippyEvent(args[1]);
+
+ string proto;
+ if (args.Length > 2)
+ {
+ ev.Proto = args[2];
+ if (!_prototype.HasIndex<EntityPrototype>(args[2]))
+ {
+ shell.WriteError(Loc.GetString("cmd-tippy-error-no-prototype", ("proto", 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);
_tipsDataset = value;
}
+ private void SetTippyChance(float value)
+ {
+ _tipTippyChance = value;
+ }
+
private void AnnounceRandomTip()
{
if (!_prototype.TryIndex<DatasetPrototype>(_tipsDataset, out var tips))
var tip = _random.Pick(tips.Values);
var msg = Loc.GetString("tips-system-chat-message-wrap", ("tip", tip));
- _chat.ChatMessageToManyFiltered(Filter.Broadcast(), ChatChannel.OOC, tip, msg,
+ if (_random.Prob(_tipTippyChance))
+ {
+ var ev = new TippyEvent(msg);
+ ev.SpeakTime = 1 + tip.Length * 0.05f;
+ RaiseNetworkEvent(ev);
+ } else
+ {
+ _chat.ChatMessageToManyFiltered(Filter.Broadcast(), ChatChannel.OOC, tip, msg,
EntityUid.Invalid, false, false, Color.MediumPurple);
+ }
}
private void RecalculateNextTipTime()
public static readonly CVarDef<string> LoginTipsDataset =
CVarDef.Create("tips.login_dataset", "Tips");
+ /// <summary>
+ /// The chance for Tippy to replace a normal tip message.
+ /// </summary>
+ public static readonly CVarDef<float> TipsTippyChance =
+ CVarDef.Create("tips.tippy_chance", 0.01f);
+
/*
* Console
*/
public static readonly CVarDef<bool> GatewayGeneratorEnabled =
CVarDef.Create("gateway.generator_enabled", true);
+ // Clippy!
+ public static readonly CVarDef<string> TippyEntity =
+ CVarDef.Create("tippy.entity", "Tippy", CVar.SERVER | CVar.REPLICATED);
+
/*
* DEBUG
*/
--- /dev/null
+
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Tips;
+
+[Serializable, NetSerializable]
+public sealed class TippyEvent : EntityEventArgs
+{
+ public TippyEvent(string msg)
+ {
+ Msg = msg;
+ }
+
+ public string Msg;
+ public string? Proto;
+ public float SpeakTime = 5;
+ public float SlideTime = 3;
+ public float WaddleInterval = 0.5f;
+}
--- /dev/null
+cmd-tippy-desc = Broadcast a message as Tippy the clown.
+cmd-tippy-help = tippy <user | all> <message> [entity prototype] [speak time] [slide time] [waddle interval]
+cmd-tippy-auto-1 = <user | all>
+cmd-tippy-auto-2 = message
+cmd-tippy-auto-3 = entity prototype
+cmd-tippy-auto-4 = speak time, in seconds
+cmd-tippy-auto-5 = slide time, in seconds
+cmd-tippy-auto-6 = waddle interval, in seconds
+cmd-tippy-error-no-user = User not found.
+cmd-tippy-error-no-prototype = Prototype not found: {$proto}
+
+cmd-tip-desc = Spawn a random game tip.
--- /dev/null
+- 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"
--- /dev/null
+{
+ "version": 1,
+ "license": "CC-BY-SA-3.0",
+ "copyright": "",
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "states": [
+ {
+ "name": "down"
+ },
+ {
+ "name": "left"
+ },
+ {
+ "name": "right"
+ }
+ ]
+}
- tp
- tpto
- respawn
+ - tippy
+ - tip
- Flags: SERVER
Commands: