diff --git a/Content.Client/Administration/UI/CustomControls/CommandButton.cs b/Content.Client/Administration/UI/CustomControls/CommandButton.cs index 2b61a0fe02..3084767563 100644 --- a/Content.Client/Administration/UI/CustomControls/CommandButton.cs +++ b/Content.Client/Administration/UI/CustomControls/CommandButton.cs @@ -1,11 +1,13 @@ -using Robust.Client.Console; +using System.Diagnostics.CodeAnalysis; +using Content.Client.Guidebook.Richtext; +using Robust.Client.Console; +using Robust.Client.UserInterface; using Robust.Client.UserInterface.Controls; -using Robust.Shared.IoC; namespace Content.Client.Administration.UI.CustomControls { [Virtual] - public class CommandButton : Button + public class CommandButton : Button, IDocumentTag { public string? Command { get; set; } @@ -34,5 +36,20 @@ namespace Content.Client.Administration.UI.CustomControls if (!string.IsNullOrEmpty(Command)) IoCManager.Resolve().ExecuteCommand(Command); } + + public bool TryParseTag(Dictionary args, [NotNullWhen(true)] out Control? control) + { + if (args.Count != 2 || !args.TryGetValue("Text", out var text) || !args.TryGetValue("Command", out var command)) + { + Logger.Error($"Invalid arguments passed to {nameof(CommandButton)}"); + control = null; + return false; + } + + Command = command; + Text = Loc.GetString(text); + control = this; + return true; + } } } diff --git a/Content.Client/Entry/EntryPoint.cs b/Content.Client/Entry/EntryPoint.cs index 9c7a04b7ea..514cfa20a1 100644 --- a/Content.Client/Entry/EntryPoint.cs +++ b/Content.Client/Entry/EntryPoint.cs @@ -4,6 +4,7 @@ using Content.Client.Chat.Managers; using Content.Client.Eui; using Content.Client.Flash; using Content.Client.GhostKick; +using Content.Client.Guidebook; using Content.Client.Info; using Content.Client.Input; using Content.Client.IoC; @@ -60,6 +61,7 @@ namespace Content.Client.Entry [Dependency] private readonly IVoteManager _voteManager = default!; [Dependency] private readonly IGamePrototypeLoadManager _gamePrototypeLoadManager = default!; [Dependency] private readonly NetworkResourceManager _networkResources = default!; + [Dependency] private readonly DocumentParsingManager _documentParsingManager = default!; [Dependency] private readonly GhostKickManager _ghostKick = default!; [Dependency] private readonly ExtendedDisconnectInformationManager _extendedDisconnectInformation = default!; [Dependency] private readonly PlayTimeTrackingManager _playTimeTracking = default!; @@ -156,6 +158,7 @@ namespace Content.Client.Entry _gamePrototypeLoadManager.Initialize(); _networkResources.Initialize(); _userInterfaceManager.SetDefaultTheme("SS14DefaultTheme"); + _documentParsingManager.Initialize(); _baseClient.RunLevelChanged += (_, args) => { diff --git a/Content.Client/Examine/ExamineButton.cs b/Content.Client/Examine/ExamineButton.cs index f96c285aca..ed91121665 100644 --- a/Content.Client/Examine/ExamineButton.cs +++ b/Content.Client/Examine/ExamineButton.cs @@ -40,7 +40,8 @@ public sealed class ExamineButton : ContainerButton Disabled = true; } - ToolTip = verb.Message; + ToolTip = verb.Message ?? verb.Text; + TooltipDelay = 0.3f; // if you're hovering over these icons, you probably want to know what they do. Icon = new TextureRect { diff --git a/Content.Client/Examine/ExamineSystem.cs b/Content.Client/Examine/ExamineSystem.cs index b86f2d3368..2a8dbd9ff9 100644 --- a/Content.Client/Examine/ExamineSystem.cs +++ b/Content.Client/Examine/ExamineSystem.cs @@ -306,6 +306,8 @@ namespace Content.Client.Examine if (obj.Button is ExamineButton button) { _verbSystem.ExecuteVerb(_examinedEntity, button.Verb); + if (button.Verb.CloseMenu ?? button.Verb.CloseMenuDefault) + CloseTooltip(); } } diff --git a/Content.Client/Guidebook/Components/GuideHelpComponent.cs b/Content.Client/Guidebook/Components/GuideHelpComponent.cs new file mode 100644 index 0000000000..a0124c5a7b --- /dev/null +++ b/Content.Client/Guidebook/Components/GuideHelpComponent.cs @@ -0,0 +1,23 @@ +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.List; + +namespace Content.Client.Guidebook; + +/// +/// This component stores a reference to a guidebook that contains information relevant to this entity. +/// +[RegisterComponent] +public sealed class GuideHelpComponent : Component +{ + /// + /// What guides to include show when opening the guidebook. The first entry will be used to select the currently + /// selected guidebook. + /// + [DataField("guides", customTypeSerializer: typeof(PrototypeIdListSerializer), required: true)] + public List Guides = new(); + + /// + /// Whether or not to automatically include the children of the given guides. + /// + [DataField("includeChildren")] + public bool IncludeChildren = true; +} diff --git a/Content.Client/Guidebook/Components/GuidebookControlsTestComponent.cs b/Content.Client/Guidebook/Components/GuidebookControlsTestComponent.cs new file mode 100644 index 0000000000..a2935011cd --- /dev/null +++ b/Content.Client/Guidebook/Components/GuidebookControlsTestComponent.cs @@ -0,0 +1,10 @@ +namespace Content.Client.Guidebook; + +/// +/// This is used for the guidebook monkey. +/// +[RegisterComponent] +public sealed class GuidebookControlsTestComponent : Component +{ + +} diff --git a/Content.Client/Guidebook/Controls/GuideEntityEmbed.xaml b/Content.Client/Guidebook/Controls/GuideEntityEmbed.xaml new file mode 100644 index 0000000000..c384a4f676 --- /dev/null +++ b/Content.Client/Guidebook/Controls/GuideEntityEmbed.xaml @@ -0,0 +1,6 @@ + + + diff --git a/Content.Client/Guidebook/Controls/GuideEntityEmbed.xaml.cs b/Content.Client/Guidebook/Controls/GuideEntityEmbed.xaml.cs new file mode 100644 index 0000000000..d00e3c2dac --- /dev/null +++ b/Content.Client/Guidebook/Controls/GuideEntityEmbed.xaml.cs @@ -0,0 +1,172 @@ +using System.Diagnostics.CodeAnalysis; +using Content.Client.ContextMenu.UI; +using Content.Client.Examine; +using Content.Client.Guidebook.Richtext; +using Content.Client.Verbs; +using Content.Client.Verbs.UI; +using Content.Shared.Input; +using Content.Shared.Tag; +using Robust.Client.AutoGenerated; +using Robust.Client.GameObjects; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.XAML; +using Robust.Shared.Input; +using Robust.Shared.Map; + +namespace Content.Client.Guidebook.Controls; + +/// +/// Control for embedding an entity into a guidebook/document. This is effectively a sprite-view that supports +/// examination, interactions, and captions. +/// +[GenerateTypedNameReferences] +public sealed partial class GuideEntityEmbed : BoxContainer, IDocumentTag +{ + [Dependency] private readonly IEntityManager _entityManager = default!; + [Dependency] private readonly IEntitySystemManager _systemManager = default!; + [Dependency] private readonly IUserInterfaceManager _ui = default!; + + private readonly TagSystem _tagSystem; + private readonly ExamineSystem _examineSystem; + private readonly GuidebookSystem _guidebookSystem; + + public bool Interactive; + + public SpriteComponent? Sprite + { + get => View.Sprite; + set => View.Sprite = value; + } + + public Vector2 Scale + { + get => View.Scale; + set => View.Scale = value; + } + + public GuideEntityEmbed() + { + RobustXamlLoader.Load(this); + IoCManager.InjectDependencies(this); + _tagSystem = _systemManager.GetEntitySystem(); + _examineSystem = _systemManager.GetEntitySystem(); + _guidebookSystem = _systemManager.GetEntitySystem(); + MouseFilter = MouseFilterMode.Stop; + } + + public GuideEntityEmbed(string proto, bool caption, bool interactive) : this() + { + Interactive = interactive; + + var ent = _entityManager.SpawnEntity(proto, MapCoordinates.Nullspace); + Sprite = _entityManager.GetComponent(ent); + + if (caption) + Caption.Text = _entityManager.GetComponent(ent).EntityName; + } + + protected override void KeyBindDown(GUIBoundKeyEventArgs args) + { + base.KeyBindDown(args); + // get an entity associated with this element + var entity = Sprite?.Owner; + + // Deleted() automatically checks for null & existence. + if (_entityManager.Deleted(entity)) + return; + + // do examination? + if (args.Function == ContentKeyFunctions.ExamineEntity) + { + _examineSystem.DoExamine(entity.Value); + args.Handle(); + return; + } + + if (!Interactive) + return; + + // open verb menu? + if (args.Function == EngineKeyFunctions.UseSecondary) + { + _ui.GetUIController().OpenVerbMenu(entity.Value); + args.Handle(); + return; + } + + // from here out we're faking interactions! sue me. --moony + + if (args.Function == ContentKeyFunctions.ActivateItemInWorld) + { + _guidebookSystem.FakeClientActivateInWorld(entity.Value); + _ui.GetUIController().Close(); + args.Handle(); + return; + } + + if (args.Function == ContentKeyFunctions.AltActivateItemInWorld) + { + _guidebookSystem.FakeClientAltActivateInWorld(entity.Value); + _ui.GetUIController().Close(); + args.Handle(); + return; + } + + if (args.Function == ContentKeyFunctions.AltActivateItemInWorld) + { + _guidebookSystem.FakeClientUse(entity.Value); + _ui.GetUIController().Close(); + args.Handle(); + } + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (Sprite is not null) + _entityManager.DeleteEntity(Sprite.Owner); + } + + public bool TryParseTag(Dictionary args, [NotNullWhen(true)] out Control? control) + { + if (!args.TryGetValue("Entity", out var proto)) + { + Logger.Error("Entity embed tag is missing entity prototype argument"); + control = null; + return false; + } + + var ent = _entityManager.SpawnEntity(proto, MapCoordinates.Nullspace); + + _tagSystem.AddTag(ent, GuidebookSystem.GuideEmbedTag); + Sprite = _entityManager.GetComponent(ent); + + if (!args.TryGetValue("Caption", out var caption)) + caption = _entityManager.GetComponent(ent).EntityName; + + if (!string.IsNullOrEmpty(caption)) + Caption.Text = caption; + // else: + // caption text already defaults to null + + if (args.TryGetValue("Scale", out var scaleStr)) + { + var scale = float.Parse(scaleStr); + Scale = new Vector2(scale, scale); + } + else + { + Scale = (2, 2); + } + + if (args.TryGetValue("Interactive", out var interactive)) + Interactive = bool.Parse(interactive); + + Margin = new Thickness(4, 8); + + control = this; + return true; + } +} diff --git a/Content.Client/Guidebook/Controls/GuidebookWindow.xaml b/Content.Client/Guidebook/Controls/GuidebookWindow.xaml new file mode 100644 index 0000000000..e23b083674 --- /dev/null +++ b/Content.Client/Guidebook/Controls/GuidebookWindow.xaml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + diff --git a/Content.Client/Guidebook/Controls/GuidebookWindow.xaml.cs b/Content.Client/Guidebook/Controls/GuidebookWindow.xaml.cs new file mode 100644 index 0000000000..32a7e2b070 --- /dev/null +++ b/Content.Client/Guidebook/Controls/GuidebookWindow.xaml.cs @@ -0,0 +1,142 @@ +using System.Linq; +using Content.Client.UserInterface.Controls; +using Content.Client.UserInterface.Controls.FancyTree; +using Robust.Client.AutoGenerated; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.XAML; +using Robust.Shared.ContentPack; + +namespace Content.Client.Guidebook.Controls; + +[GenerateTypedNameReferences] +public sealed partial class GuidebookWindow : FancyWindow +{ + [Dependency] private readonly IResourceManager _resourceManager = default!; + [Dependency] private readonly DocumentParsingManager _parsingMan = default!; + + private Dictionary _entries = new(); + + public GuidebookWindow() + { + RobustXamlLoader.Load(this); + IoCManager.InjectDependencies(this); + + Tree.OnSelectedItemChanged += OnSelectionChanged; + } + + private void OnSelectionChanged(TreeItem? item) + { + if (item != null && item.Metadata is GuideEntry entry) + ShowGuide(entry); + else + ClearSelectedGuide(); + } + + public void ClearSelectedGuide() + { + Placeholder.Visible = true; + EntryContainer.Visible = false; + EntryContainer.RemoveAllChildren(); + } + + private void ShowGuide(GuideEntry entry) + { + Scroll.SetScrollValue(default); + Placeholder.Visible = false; + EntryContainer.Visible = true; + EntryContainer.RemoveAllChildren(); + using var file = _resourceManager.ContentFileReadText(entry.Text); + + if (!_parsingMan.TryAddMarkup(EntryContainer, file.ReadToEnd())) + { + EntryContainer.AddChild(new Label() { Text = "ERROR: Failed to parse document." }); + Logger.Error($"Failed to parse contents of guide document {entry.Id}."); + } + } + + public void UpdateGuides( + Dictionary entries, + List? rootEntries = null, + string? forceRoot = null, + string? selected = null) + { + _entries = entries; + RepopulateTree(rootEntries, forceRoot); + ClearSelectedGuide(); + + Split.State = SplitContainer.SplitState.Auto; + if (entries.Count == 1) + { + TreeBox.Visible = false; + Split.ResizeMode = SplitContainer.SplitResizeMode.NotResizable; + selected = entries.Keys.First(); + } + else + { + TreeBox.Visible = true; + Split.ResizeMode = SplitContainer.SplitResizeMode.RespectChildrenMinSize; + } + + if (selected != null) + { + var item = Tree.Items.FirstOrDefault(x => x.Metadata is GuideEntry entry && entry.Id == selected); + Tree.SetSelectedIndex(item?.Index); + } + } + + private IEnumerable GetSortedRootEntries(List? rootEntries) + { + if (rootEntries == null) + { + HashSet entries = new(_entries.Keys); + foreach (var entry in _entries.Values) + { + entries.ExceptWith(entry.Children); + } + rootEntries = entries.ToList(); + } + + return rootEntries + .Select(x => _entries[x]) + .OrderBy(x => x.Priority) + .ThenBy(x => Loc.GetString(x.Name)); + } + + private void RepopulateTree(List? roots = null, string? forcedRoot = null) + { + Tree.Clear(); + + HashSet addedEntries = new(); + + TreeItem? parent = forcedRoot == null ? null : AddEntry(forcedRoot, null, addedEntries); + foreach (var entry in GetSortedRootEntries(roots)) + { + AddEntry(entry.Id, parent, addedEntries); + } + Tree.SetAllExpanded(true); + } + + private TreeItem? AddEntry(string id, TreeItem? parent, HashSet addedEntries) + { + if (!_entries.TryGetValue(id, out var entry)) + return null; + + if (!addedEntries.Add(id)) + { + Logger.Error($"Adding duplicate guide entry: {id}"); + return null; + } + + var item = Tree.AddItem(parent); + item.Metadata = entry; + var name = Loc.GetString(entry.Name); + item.Label.Text = name; + + foreach (var child in entry.Children) + { + AddEntry(child, item, addedEntries); + } + + return item; + } +} diff --git a/Content.Client/Guidebook/DocumentParsingManager.cs b/Content.Client/Guidebook/DocumentParsingManager.cs new file mode 100644 index 0000000000..b81866a626 --- /dev/null +++ b/Content.Client/Guidebook/DocumentParsingManager.cs @@ -0,0 +1,84 @@ +using System.Linq; +using Content.Client.Guidebook.Richtext; +using Pidgin; +using Robust.Client.UserInterface; +using Robust.Shared.Reflection; +using Robust.Shared.Sandboxing; +using static Pidgin.Parser; + +namespace Content.Client.Guidebook; + +/// +/// This manager should be used to convert documents (shitty rich-text / pseudo-xaml) into UI Controls +/// +public sealed partial class DocumentParsingManager +{ + [Dependency] private readonly IReflectionManager _reflectionManager = default!; + [Dependency] private readonly ISandboxHelper _sandboxHelper = default!; + + private readonly Dictionary> _tagControlParsers = new(); + private Parser _tagParser = default!; + private Parser _controlParser = default!; + public Parser> ControlParser = default!; + + public void Initialize() + { + _tagParser = TryOpeningTag + .Assert(_tagControlParsers.ContainsKey, tag => $"unknown tag: {tag}") + .Bind(tag => _tagControlParsers[tag]); + + _controlParser = OneOf(_tagParser, TryHeaderControl, ListControlParser, TextControlParser).Before(SkipWhitespaces); + + foreach (var typ in _reflectionManager.GetAllChildren()) + { + _tagControlParsers.Add(typ.Name, CreateTagControlParser(typ.Name, typ, _sandboxHelper)); + } + + ControlParser = SkipWhitespaces.Then(_controlParser.Many()); + } + + public bool TryAddMarkup(Control control, string text, bool log = true) + { + try + { + foreach (var child in ControlParser.ParseOrThrow(text)) + { + control.AddChild(child); + } + } + catch (Exception e) + { + if (log) + Logger.Error($"Encountered error while generating markup controls: {e}"); + return false; + } + + return true; + } + + private Parser CreateTagControlParser(string tagId, Type tagType, ISandboxHelper sandbox) => Map( + (args, controls) => + { + var tag = (IDocumentTag) sandbox.CreateInstance(tagType); + if (!tag.TryParseTag(args, out var control)) + { + Logger.Error($"Failed to parse {tagId} args"); + return new Control(); + } + + foreach (var child in controls) + { + control.AddChild(child); + } + return control; + }, + ParseTagArgs(tagId), + TagContentParser(tagId)).Labelled($"{tagId} control"); + + // Parse a bunch of controls until we encounter a matching closing tag. + private Parser> TagContentParser(string tag) => + OneOf( + Try(ImmediateTagEnd).ThenReturn(Enumerable.Empty()), + TagEnd.Then(_controlParser.Until(TryTagTerminator(tag)).Labelled($"{tag} children")) + ); +} diff --git a/Content.Client/Guidebook/DocumentParsingManager.static.cs b/Content.Client/Guidebook/DocumentParsingManager.static.cs new file mode 100644 index 0000000000..ab38fcb154 --- /dev/null +++ b/Content.Client/Guidebook/DocumentParsingManager.static.cs @@ -0,0 +1,142 @@ +using System.Linq; +using Pidgin; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Shared.Utility; +using static Pidgin.Parser; +using static Pidgin.Parser; +using static Robust.Client.UserInterface.Control; +using static Robust.Client.UserInterface.Controls.BoxContainer; + +namespace Content.Client.Guidebook; + +public sealed partial class DocumentParsingManager +{ + private const string ListBullet = " › "; + + #region Text Parsing + #region Basic Text Parsing + // Try look for an escaped character. If found, skip the escaping slash and return the character. + private static readonly Parser TryEscapedChar = Try(Char('\\').Then(OneOf( + Try(Char('<')), + Try(Char('>')), + Try(Char('\\')), + Try(Char('-')), + Try(Char('=')), + Try(Char('"')), + Try(Char(' ')), + Try(Char('n')).ThenReturn('\n'), + Try(Char('t')).ThenReturn('\t') + ))); + + private static readonly Parser SkipNewline = Whitespace.SkipUntil(Char('\n')); + + private static readonly Parser TrySingleNewlineToSpace = Try(SkipNewline).Then(SkipWhitespaces).ThenReturn(' '); + + private static readonly Parser TextChar = OneOf( + TryEscapedChar, // consume any backslashed being used to escape text + TrySingleNewlineToSpace, // turn single newlines into spaces + Any // just return the character. + ); + + // like TextChar, but not skipping whitespace around newlines + private static readonly Parser QuotedTextChar = OneOf(TryEscapedChar, Any); + + // Quoted text + private static readonly Parser QuotedText = Char('"').Then(QuotedTextChar.Until(Try(Char('"'))).Select(string.Concat)).Labelled("quoted text"); + #endregion + + #region rich text-end markers + private static readonly Parser TryStartList = Try(SkipNewline.Then(SkipWhitespaces).Then(Char('-'))).Then(SkipWhitespaces); + private static readonly Parser TryStartTag = Try(Char('<')).Then(SkipWhitespaces); + private static readonly Parser TryStartParagraph = Try(SkipNewline.Then(SkipNewline)).Then(SkipWhitespaces); + private static readonly Parser TryLookTextEnd = Lookahead(OneOf(TryStartTag, TryStartList, TryStartParagraph, Try(Whitespace.SkipUntil(End)))); + #endregion + + // parses text characters until it hits a text-end + private static readonly Parser TextParser = TextChar.AtLeastOnceUntil(TryLookTextEnd).Select(string.Concat); + + private static readonly Parser TextControlParser = Try(Map(text => + { + var rt = new RichTextLabel() + { + HorizontalExpand = true, + Margin = new Thickness(0, 0, 0, 15.0f), + }; + + var msg = new FormattedMessage(); + // THANK YOU RICHTEXT VERY COOL + // (text doesn't default to white). + msg.PushColor(Color.White); + msg.AddMarkup(text); + msg.Pop(); + rt.SetMessage(msg); + return rt; + }, TextParser).Cast()).Labelled("richtext"); + #endregion + + #region Headers + private static readonly Parser HeaderControlParser = Try(Char('#')).Then(SkipWhitespaces.Then(Map(text => new Label() + { + Text = text, + StyleClasses = { "LabelHeadingBigger" } + }, AnyCharExcept('\n').AtLeastOnceString()).Cast())).Labelled("header"); + + private static readonly Parser SubHeaderControlParser = Try(String("##")).Then(SkipWhitespaces.Then(Map(text => new Label() + { + Text = text, + StyleClasses = { "LabelHeading" } + }, AnyCharExcept('\n').AtLeastOnceString()).Cast())).Labelled("subheader"); + + private static readonly Parser TryHeaderControl = OneOf(SubHeaderControlParser, HeaderControlParser); + #endregion + + // Parser that consumes a - and then just parses normal rich text with some prefix text (a bullet point). + private static readonly Parser ListControlParser = Try(Char('-')).Then(SkipWhitespaces).Then(Map( + control => new BoxContainer() + { + Children = { new Label() { Text = ListBullet, VerticalAlignment = VAlignment.Top, }, control }, + Orientation = LayoutOrientation.Horizontal, + }, TextControlParser).Cast()).Labelled("list"); + + #region Tag Parsing + // closing brackets for tags + private static readonly Parser TagEnd = Char('>').Then(SkipWhitespaces); + private static readonly Parser ImmediateTagEnd = String("/>").Then(SkipWhitespaces); + + private static readonly Parser TryLookTagEnd = Lookahead(OneOf(Try(TagEnd), Try(ImmediateTagEnd))); + + //parse tag argument key. any normal text character up until we hit a "=" + private static readonly Parser TagArgKey = LetterOrDigit.Until(Char('=')).Select(string.Concat).Labelled("tag argument key"); + + // parser for a singular tag argument. Note that each TryQuoteOrChar will consume a whole quoted block before the Until() looks for whitespace + private static readonly Parser TagArgParser = Map((key, value) => (key, value), TagArgKey, QuotedText).Before(SkipWhitespaces); + + // parser for all tag arguments + private static readonly Parser> TagArgsParser = TagArgParser.Until(TryLookTagEnd); + + // parser for an opening tag. + private static readonly Parser TryOpeningTag = + Try(Char('<')) + .Then(SkipWhitespaces) + .Then(TextChar.Until(OneOf(Whitespace.SkipAtLeastOnce(), TryLookTagEnd))) + .Select(string.Concat).Labelled($"opening tag"); + + private static Parser> ParseTagArgs(string tag) + { + return TagArgsParser.Labelled($"{tag} arguments") + .Select(x => x.ToDictionary(y => y.Item1, y => y.Item2)) + .Before(SkipWhitespaces); + } + + private static Parser TryTagTerminator(string tag) + { + return Try(String(" + /// The file containing the contents of this guide. + /// + [DataField("text", required: true)] public ResourcePath Text = default!; + + /// + /// The unique id for this guide. + /// + [IdDataField] + public string Id = default!; + + /// + /// The name of this guide. This gets localized. + /// + [DataField("name", required: true)] public string Name = default!; + + /// + /// The "children" of this guide for when guides are shown in a tree / table of contents. + /// + [DataField("children", customTypeSerializer:typeof(PrototypeIdListSerializer))] + public List Children = new(); + + /// + /// Priority for sorting top-level guides when shown in a tree / table of contents. + /// If the guide is the child of some other guide, the order simply determined by the order of children in . + /// + [DataField("priority")] public int Priority = 0; +} + +[Prototype("guideEntry")] +public sealed class GuideEntryPrototype : GuideEntry, IPrototype +{ + public string ID => Id; +} diff --git a/Content.Client/Guidebook/GuidebookSystem.cs b/Content.Client/Guidebook/GuidebookSystem.cs new file mode 100644 index 0000000000..bb936215a2 --- /dev/null +++ b/Content.Client/Guidebook/GuidebookSystem.cs @@ -0,0 +1,244 @@ +using System.Linq; +using Content.Client.Guidebook.Controls; +using Content.Client.Light; +using Content.Client.Verbs; +using Content.Shared.Input; +using Content.Shared.Interaction; +using Content.Shared.Light.Component; +using Content.Shared.Speech; +using Content.Shared.Tag; +using Content.Shared.Verbs; +using Robust.Client.GameObjects; +using Robust.Client.Player; +using Robust.Shared.Input; +using Robust.Shared.Input.Binding; +using Robust.Shared.Player; +using Robust.Shared.Prototypes; + +namespace Content.Client.Guidebook; + +/// +/// This system handles the help-verb and interactions with various client-side entities that are embedded into guidebooks. +/// +public sealed class GuidebookSystem : EntitySystem +{ + [Dependency] private readonly IPlayerManager _playerManager = default!; + [Dependency] private readonly IPrototypeManager _prototypeManager = default!; + [Dependency] private readonly SharedAudioSystem _audioSystem = default!; + [Dependency] private readonly VerbSystem _verbSystem = default!; + [Dependency] private readonly RgbLightControllerSystem _rgbLightControllerSystem = default!; + [Dependency] private readonly TagSystem _tags = default!; + private GuidebookWindow _guideWindow = default!; + + public const string GuideEmbedTag = "GuideEmbeded"; + + /// + public override void Initialize() + { + CommandBinds.Builder + .Bind(ContentKeyFunctions.OpenGuidebook, + new PointerInputCmdHandler(HandleOpenGuidebook)) + .Register(); + _guideWindow = new GuidebookWindow(); + + SubscribeLocalEvent>(OnGetVerbs); + SubscribeLocalEvent(OnGuidebookControlsTestInteractHand); + SubscribeLocalEvent(OnGuidebookControlsTestActivateInWorld); + SubscribeLocalEvent>( + OnGuidebookControlsTestGetAlternateVerbs); + } + + private void OnGetVerbs(EntityUid uid, GuideHelpComponent component, GetVerbsEvent args) + { + if (component.Guides.Count == 0 || _tags.HasTag(uid, GuideEmbedTag)) + return; + + args.Verbs.Add(new() + { + Text = Loc.GetString("guide-help-verb"), + IconTexture = "/Textures/Interface/VerbIcons/information.svg.192dpi.png", + Act = () => OpenGuidebook(component.Guides, includeChildren: component.IncludeChildren, selected: component.Guides[0]), + ClientExclusive = true, + CloseMenu = true + }); + } + + private void OnGuidebookControlsTestGetAlternateVerbs(EntityUid uid, GuidebookControlsTestComponent component, GetVerbsEvent args) + { + args.Verbs.Add(new AlternativeVerb() + { + Act = () => + { + if (Transform(uid).LocalRotation != Angle.Zero) + Transform(uid).LocalRotation -= Angle.FromDegrees(90); + }, + Text = Loc.GetString("guidebook-monkey-unspin"), + Priority = -9999, + }); + + args.Verbs.Add(new AlternativeVerb() + { + Act = () => + { + var light = EnsureComp(uid); // RGB demands this. + light.Enabled = false; + var rgb = EnsureComp(uid); + + var sprite = EnsureComp(uid); + var layers = new List(); + + for (var i = 0; i < sprite.AllLayers.Count(); i++) + { + layers.Add(i); + } + + _rgbLightControllerSystem.SetLayers(uid, layers, rgb); + }, + Text = Loc.GetString("guidebook-monkey-disco"), + Priority = -9998, + }); + } + + private void OnGuidebookControlsTestActivateInWorld(EntityUid uid, GuidebookControlsTestComponent component, ActivateInWorldEvent args) + { + Transform(uid).LocalRotation += Angle.FromDegrees(90); + } + + private void OnGuidebookControlsTestInteractHand(EntityUid uid, GuidebookControlsTestComponent component, InteractHandEvent args) + { + if (!TryComp(uid, out var speech) || speech.SpeechSounds is null) + return; + + _audioSystem.PlayGlobal(speech.SpeechSounds, Filter.Local(), false, speech.AudioParams); + } + + + public void FakeClientActivateInWorld(EntityUid activated) + { + var user = _playerManager.LocalPlayer!.ControlledEntity; + if (user is null) + return; + var activateMsg = new ActivateInWorldEvent(user.Value, activated); + RaiseLocalEvent(activated, activateMsg, true); + } + + public void FakeClientAltActivateInWorld(EntityUid activated) + { + var user = _playerManager.LocalPlayer!.ControlledEntity; + if (user is null) + return; + // Get list of alt-interact verbs + var verbs = _verbSystem.GetLocalVerbs(activated, user.Value, typeof(AlternativeVerb)); + + if (!verbs.Any()) + return; + + _verbSystem.ExecuteVerb(verbs.First(), user.Value, activated); + } + + public void FakeClientUse(EntityUid activated) + { + var user = _playerManager.LocalPlayer!.ControlledEntity ?? EntityUid.Invalid; + var activateMsg = new InteractHandEvent(user, activated); + RaiseLocalEvent(activated, activateMsg, true); + } + + private bool HandleOpenGuidebook(in PointerInputCmdHandler.PointerInputCmdArgs args) + { + if (args.State != BoundKeyState.Down) + return false; + + OpenGuidebook(); + return true; + } + + /// + /// Opens the guidebook. + /// + /// What guides should be shown. If not specified, this will instead raise a and automatically include all guide prototypes. + /// A list of guides that should form the base of the table of contents. If not specified, + /// this will automatically simply be a list of all guides that have no parent. + /// This forces a singular guide to contain all other guides. This guide will + /// contain its own children, in addition to what would normally be the root guides if this were not + /// specified. + /// Whether or not to automatically include child entries. If false, this will ONLY + /// show the specified entries + /// The guide whose contents should be displayed when the guidebook is opened + public bool OpenGuidebook( + Dictionary? guides = null, + List? rootEntries = null, + string? forceRoot = null, + bool includeChildren = true, + string? selected = null) + { + _guideWindow.OpenCenteredRight(); + + if (guides == null) + { + var ev = new GetGuidesEvent() + { + Guides = _prototypeManager.EnumeratePrototypes().ToDictionary(x => x.ID, x => (GuideEntry) x) + }; + RaiseLocalEvent(ev); + guides = ev.Guides; + } + else if (includeChildren) + { + var oldGuides = guides; + guides = new(oldGuides); + foreach (var guide in oldGuides.Values) + { + RecursivelyAddChildren(guide, guides); + } + } + + _guideWindow.UpdateGuides(guides, rootEntries, forceRoot, selected); + + return true; + } + + public bool OpenGuidebook( + List guideList, + List? rootEntries = null, + string? forceRoot = null, + bool includeChildren = true, + string? selected = null) + { + Dictionary? guides = new(); + foreach (var guideId in guideList) + { + if (!_prototypeManager.TryIndex(guideId, out var guide)) + { + Logger.Error($"Encountered unknown guide prototype: {guideId}"); + continue; + } + guides.Add(guideId, guide); + } + + return OpenGuidebook(guides, rootEntries, forceRoot, includeChildren, selected); + } + + private void RecursivelyAddChildren(GuideEntry guide, Dictionary guides) + { + foreach (var childId in guide.Children) + { + if (guides.ContainsKey(childId)) + continue; + + if (!_prototypeManager.TryIndex(childId, out var child)) + { + Logger.Error($"Encountered unknown guide prototype: {childId} as a child of {guide.Id}. If the child is not a prototype, it must be directly provided."); + continue; + } + + guides.Add(childId, child); + RecursivelyAddChildren(child, guides); + } + } +} + +public sealed class GetGuidesEvent : EntityEventArgs +{ + public Dictionary Guides { get; init; } = new(); +} diff --git a/Content.Client/Guidebook/Richtext/Box.cs b/Content.Client/Guidebook/Richtext/Box.cs new file mode 100644 index 0000000000..ecf6cb21f7 --- /dev/null +++ b/Content.Client/Guidebook/Richtext/Box.cs @@ -0,0 +1,29 @@ +using System.Diagnostics.CodeAnalysis; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; + +namespace Content.Client.Guidebook.Richtext; + +public sealed class Box : BoxContainer, IDocumentTag +{ + public bool TryParseTag(Dictionary args, [NotNullWhen(true)] out Control? control) + { + HorizontalExpand = true; + control = this; + + if (args.TryGetValue("Orientation", out var orientation)) + Orientation = Enum.Parse(orientation); + else + Orientation = LayoutOrientation.Horizontal; + + if (args.TryGetValue("HorizontalAlignment", out var halign)) + HorizontalAlignment = Enum.Parse(halign); + else + HorizontalAlignment = HAlignment.Center; + + if (args.TryGetValue("VerticalAlignment", out var valign)) + VerticalAlignment = Enum.Parse(valign); + + return true; + } +} diff --git a/Content.Client/Guidebook/Richtext/Document.cs b/Content.Client/Guidebook/Richtext/Document.cs new file mode 100644 index 0000000000..3faf3f12a1 --- /dev/null +++ b/Content.Client/Guidebook/Richtext/Document.cs @@ -0,0 +1,29 @@ +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Shared.Utility; +using System.Diagnostics.CodeAnalysis; + +namespace Content.Client.Guidebook.Richtext; + +/// +/// A document, containing arbitrary text and UI elements. +/// +public sealed class Document : BoxContainer, IDocumentTag +{ + public Document() + { + Orientation = LayoutOrientation.Vertical; + } + + public bool TryParseTag(Dictionary args, [NotNullWhen(true)] out Control? control) + { + DebugTools.Assert(args.Count == 0); + control = this; + return true; + } +} + +public interface IDocumentTag +{ + public bool TryParseTag(Dictionary args, [NotNullWhen(true)] out Control? control); +} diff --git a/Content.Client/Input/ContentContexts.cs b/Content.Client/Input/ContentContexts.cs index 420d6ea5e4..feac437fe6 100644 --- a/Content.Client/Input/ContentContexts.cs +++ b/Content.Client/Input/ContentContexts.cs @@ -96,6 +96,7 @@ namespace Content.Client.Input common.AddFunction(ContentKeyFunctions.OpenTileSpawnWindow); common.AddFunction(ContentKeyFunctions.OpenDecalSpawnWindow); common.AddFunction(ContentKeyFunctions.OpenAdminMenu); + common.AddFunction(ContentKeyFunctions.OpenGuidebook); } } } diff --git a/Content.Client/IoC/ClientContentIoC.cs b/Content.Client/IoC/ClientContentIoC.cs index 811432f897..7eb709c494 100644 --- a/Content.Client/IoC/ClientContentIoC.cs +++ b/Content.Client/IoC/ClientContentIoC.cs @@ -1,4 +1,4 @@ -using Content.Client.Administration.Managers; +using Content.Client.Administration.Managers; using Content.Client.Changelog; using Content.Client.Chat.Managers; using Content.Client.Clickable; @@ -17,6 +17,7 @@ using Content.Client.Voting; using Content.Shared.Administration; using Content.Shared.Administration.Logs; using Content.Shared.Module; +using Content.Client.Guidebook; namespace Content.Client.IoC { @@ -42,6 +43,7 @@ namespace Content.Client.IoC IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); + IoCManager.Register(); } } } diff --git a/Content.Client/Options/UI/EscapeMenu.xaml b/Content.Client/Options/UI/EscapeMenu.xaml index cb7edb921a..71da231f29 100644 --- a/Content.Client/Options/UI/EscapeMenu.xaml +++ b/Content.Client/Options/UI/EscapeMenu.xaml @@ -10,6 +10,7 @@