Guidebook Revival (#13320)
* Fix some bugs in stations and do a little cleanup. * Begin backporting the guidebook. * wow that's a lot of work. * More work, gives the monkey some more interactions. * disco monkye. * monky * jobs entry. * more writing. * disco * im being harassed * fix spacing. * i hate writing. * Update Resources/Prototypes/Entities/Mobs/NPCs/animals.yml Co-authored-by: Leon Friedrich <60421075+ElectroJr@users.noreply.github.com> * builds again * a * pilfer changes from AL * fix and remove unused code * pilfer actual guide changes from AL * localization * more error logs & safety checks * replace controls button with command * add test * todos * pidgin parsing * remove old parser * Move files and change tree sorting * add localization and public methods. * Add help component/verb * rename ITag to IDocumentTag * Fix yml and tweak tooltips * autoclose tooltip * Split container * Fancier-tree * Hover color * txt to xml * oops * Curse you hidden merge conflicts * Rename parsing manager * Stricter arg parsing tag args must now be of the form key="value" * Change default args * Moar tests * nullable enable * Even fancier tree * extremely fancy trees * better indent icons * stricter xml and subheadings * tweak embed margin * Fix parsing bugs * quick fixes. * spain. * ogh * hn bmvdsyc Co-authored-by: moonheart08 <moonheart08@users.noreply.github.com>
This commit is contained in:
23
Content.Client/Guidebook/Components/GuideHelpComponent.cs
Normal file
23
Content.Client/Guidebook/Components/GuideHelpComponent.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.List;
|
||||
|
||||
namespace Content.Client.Guidebook;
|
||||
|
||||
/// <summary>
|
||||
/// This component stores a reference to a guidebook that contains information relevant to this entity.
|
||||
/// </summary>
|
||||
[RegisterComponent]
|
||||
public sealed class GuideHelpComponent : Component
|
||||
{
|
||||
/// <summary>
|
||||
/// What guides to include show when opening the guidebook. The first entry will be used to select the currently
|
||||
/// selected guidebook.
|
||||
/// </summary>
|
||||
[DataField("guides", customTypeSerializer: typeof(PrototypeIdListSerializer<GuideEntryPrototype>), required: true)]
|
||||
public List<string> Guides = new();
|
||||
|
||||
/// <summary>
|
||||
/// Whether or not to automatically include the children of the given guides.
|
||||
/// </summary>
|
||||
[DataField("includeChildren")]
|
||||
public bool IncludeChildren = true;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Content.Client.Guidebook;
|
||||
|
||||
/// <summary>
|
||||
/// This is used for the guidebook monkey.
|
||||
/// </summary>
|
||||
[RegisterComponent]
|
||||
public sealed class GuidebookControlsTestComponent : Component
|
||||
{
|
||||
|
||||
}
|
||||
6
Content.Client/Guidebook/Controls/GuideEntityEmbed.xaml
Normal file
6
Content.Client/Guidebook/Controls/GuideEntityEmbed.xaml
Normal file
@@ -0,0 +1,6 @@
|
||||
<BoxContainer xmlns="https://spacestation14.io"
|
||||
Orientation="Vertical"
|
||||
Margin="5 5 5 5">
|
||||
<SpriteView Name="View"/>
|
||||
<Label Name="Caption" HorizontalAlignment="Center"/>
|
||||
</BoxContainer>
|
||||
172
Content.Client/Guidebook/Controls/GuideEntityEmbed.xaml.cs
Normal file
172
Content.Client/Guidebook/Controls/GuideEntityEmbed.xaml.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Control for embedding an entity into a guidebook/document. This is effectively a sprite-view that supports
|
||||
/// examination, interactions, and captions.
|
||||
/// </summary>
|
||||
[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<TagSystem>();
|
||||
_examineSystem = _systemManager.GetEntitySystem<ExamineSystem>();
|
||||
_guidebookSystem = _systemManager.GetEntitySystem<GuidebookSystem>();
|
||||
MouseFilter = MouseFilterMode.Stop;
|
||||
}
|
||||
|
||||
public GuideEntityEmbed(string proto, bool caption, bool interactive) : this()
|
||||
{
|
||||
Interactive = interactive;
|
||||
|
||||
var ent = _entityManager.SpawnEntity(proto, MapCoordinates.Nullspace);
|
||||
Sprite = _entityManager.GetComponent<SpriteComponent>(ent);
|
||||
|
||||
if (caption)
|
||||
Caption.Text = _entityManager.GetComponent<MetaDataComponent>(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<VerbMenuUIController>().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<ContextMenuUIController>().Close();
|
||||
args.Handle();
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.Function == ContentKeyFunctions.AltActivateItemInWorld)
|
||||
{
|
||||
_guidebookSystem.FakeClientAltActivateInWorld(entity.Value);
|
||||
_ui.GetUIController<ContextMenuUIController>().Close();
|
||||
args.Handle();
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.Function == ContentKeyFunctions.AltActivateItemInWorld)
|
||||
{
|
||||
_guidebookSystem.FakeClientUse(entity.Value);
|
||||
_ui.GetUIController<ContextMenuUIController>().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<string, string> 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<SpriteComponent>(ent);
|
||||
|
||||
if (!args.TryGetValue("Caption", out var caption))
|
||||
caption = _entityManager.GetComponent<MetaDataComponent>(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;
|
||||
}
|
||||
}
|
||||
26
Content.Client/Guidebook/Controls/GuidebookWindow.xaml
Normal file
26
Content.Client/Guidebook/Controls/GuidebookWindow.xaml
Normal file
@@ -0,0 +1,26 @@
|
||||
<controls:FancyWindow xmlns:ui="clr-namespace:Content.Client.UserInterface"
|
||||
xmlns="https://spacestation14.io"
|
||||
xmlns:cc="clr-namespace:Content.Client.Administration.UI.CustomControls"
|
||||
xmlns:fancyTree="clr-namespace:Content.Client.UserInterface.Controls.FancyTree"
|
||||
xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls"
|
||||
SetSize="750 700"
|
||||
MinSize="100 200"
|
||||
Resizable="True"
|
||||
Title="{Loc 'guidebook-window-title'}">
|
||||
<SplitContainer Orientation="Horizontal" HorizontalExpand="True" Name="Split">
|
||||
<!-- Guide select -->
|
||||
<BoxContainer Orientation="Horizontal" Name="TreeBox">
|
||||
<fancyTree:FancyTree Name="Tree" VerticalExpand="True" HorizontalExpand="True"/>
|
||||
<cc:VSeparator StyleClasses="LowDivider" Margin="0 -2"/>
|
||||
</BoxContainer>
|
||||
<ScrollContainer Name="Scroll" HScrollEnabled="False" HorizontalExpand="True" VerticalExpand="True">
|
||||
<Control>
|
||||
<BoxContainer Orientation="Vertical" Name="EntryContainer" Margin="5 5 5 5" Visible="False"/>
|
||||
<BoxContainer Orientation="Vertical" Name="Placeholder" Margin="5 5 5 5">
|
||||
<Label HorizontalAlignment="Center" VerticalAlignment="Center" Text="{Loc 'guidebook-placeholder-text'}"/>
|
||||
<Label HorizontalAlignment="Center" VerticalAlignment="Center" Text="{Loc 'guidebook-placeholder-text-2'}"/>
|
||||
</BoxContainer>
|
||||
</Control>
|
||||
</ScrollContainer>
|
||||
</SplitContainer>
|
||||
</controls:FancyWindow>
|
||||
142
Content.Client/Guidebook/Controls/GuidebookWindow.xaml.cs
Normal file
142
Content.Client/Guidebook/Controls/GuidebookWindow.xaml.cs
Normal file
@@ -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<string, GuideEntry> _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<string, GuideEntry> entries,
|
||||
List<string>? 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<GuideEntry> GetSortedRootEntries(List<string>? rootEntries)
|
||||
{
|
||||
if (rootEntries == null)
|
||||
{
|
||||
HashSet<string> 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<string>? roots = null, string? forcedRoot = null)
|
||||
{
|
||||
Tree.Clear();
|
||||
|
||||
HashSet<string> 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<string> 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;
|
||||
}
|
||||
}
|
||||
84
Content.Client/Guidebook/DocumentParsingManager.cs
Normal file
84
Content.Client/Guidebook/DocumentParsingManager.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// This manager should be used to convert documents (shitty rich-text / pseudo-xaml) into UI Controls
|
||||
/// </summary>
|
||||
public sealed partial class DocumentParsingManager
|
||||
{
|
||||
[Dependency] private readonly IReflectionManager _reflectionManager = default!;
|
||||
[Dependency] private readonly ISandboxHelper _sandboxHelper = default!;
|
||||
|
||||
private readonly Dictionary<string, Parser<char, Control>> _tagControlParsers = new();
|
||||
private Parser<char, Control> _tagParser = default!;
|
||||
private Parser<char, Control> _controlParser = default!;
|
||||
public Parser<char, IEnumerable<Control>> 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<IDocumentTag>())
|
||||
{
|
||||
_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<char, Control> 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<char, IEnumerable<Control>> TagContentParser(string tag) =>
|
||||
OneOf(
|
||||
Try(ImmediateTagEnd).ThenReturn(Enumerable.Empty<Control>()),
|
||||
TagEnd.Then(_controlParser.Until(TryTagTerminator(tag)).Labelled($"{tag} children"))
|
||||
);
|
||||
}
|
||||
142
Content.Client/Guidebook/DocumentParsingManager.static.cs
Normal file
142
Content.Client/Guidebook/DocumentParsingManager.static.cs
Normal file
@@ -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<char>;
|
||||
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<char, char> 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<char, Unit> SkipNewline = Whitespace.SkipUntil(Char('\n'));
|
||||
|
||||
private static readonly Parser<char, char> TrySingleNewlineToSpace = Try(SkipNewline).Then(SkipWhitespaces).ThenReturn(' ');
|
||||
|
||||
private static readonly Parser<char, char> 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<char, char> QuotedTextChar = OneOf(TryEscapedChar, Any);
|
||||
|
||||
// Quoted text
|
||||
private static readonly Parser<char, string> QuotedText = Char('"').Then(QuotedTextChar.Until(Try(Char('"'))).Select(string.Concat)).Labelled("quoted text");
|
||||
#endregion
|
||||
|
||||
#region rich text-end markers
|
||||
private static readonly Parser<char, Unit> TryStartList = Try(SkipNewline.Then(SkipWhitespaces).Then(Char('-'))).Then(SkipWhitespaces);
|
||||
private static readonly Parser<char, Unit> TryStartTag = Try(Char('<')).Then(SkipWhitespaces);
|
||||
private static readonly Parser<char, Unit> TryStartParagraph = Try(SkipNewline.Then(SkipNewline)).Then(SkipWhitespaces);
|
||||
private static readonly Parser<char, Unit> TryLookTextEnd = Lookahead(OneOf(TryStartTag, TryStartList, TryStartParagraph, Try(Whitespace.SkipUntil(End))));
|
||||
#endregion
|
||||
|
||||
// parses text characters until it hits a text-end
|
||||
private static readonly Parser<char, string> TextParser = TextChar.AtLeastOnceUntil(TryLookTextEnd).Select(string.Concat);
|
||||
|
||||
private static readonly Parser<char, Control> 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<Control>()).Labelled("richtext");
|
||||
#endregion
|
||||
|
||||
#region Headers
|
||||
private static readonly Parser<char, Control> HeaderControlParser = Try(Char('#')).Then(SkipWhitespaces.Then(Map(text => new Label()
|
||||
{
|
||||
Text = text,
|
||||
StyleClasses = { "LabelHeadingBigger" }
|
||||
}, AnyCharExcept('\n').AtLeastOnceString()).Cast<Control>())).Labelled("header");
|
||||
|
||||
private static readonly Parser<char, Control> SubHeaderControlParser = Try(String("##")).Then(SkipWhitespaces.Then(Map(text => new Label()
|
||||
{
|
||||
Text = text,
|
||||
StyleClasses = { "LabelHeading" }
|
||||
}, AnyCharExcept('\n').AtLeastOnceString()).Cast<Control>())).Labelled("subheader");
|
||||
|
||||
private static readonly Parser<char, Control> 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<char, Control> ListControlParser = Try(Char('-')).Then(SkipWhitespaces).Then(Map(
|
||||
control => new BoxContainer()
|
||||
{
|
||||
Children = { new Label() { Text = ListBullet, VerticalAlignment = VAlignment.Top, }, control },
|
||||
Orientation = LayoutOrientation.Horizontal,
|
||||
}, TextControlParser).Cast<Control>()).Labelled("list");
|
||||
|
||||
#region Tag Parsing
|
||||
// closing brackets for tags
|
||||
private static readonly Parser<char, Unit> TagEnd = Char('>').Then(SkipWhitespaces);
|
||||
private static readonly Parser<char, Unit> ImmediateTagEnd = String("/>").Then(SkipWhitespaces);
|
||||
|
||||
private static readonly Parser<char, Unit> TryLookTagEnd = Lookahead(OneOf(Try(TagEnd), Try(ImmediateTagEnd)));
|
||||
|
||||
//parse tag argument key. any normal text character up until we hit a "="
|
||||
private static readonly Parser<char, string> 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<char, (string, string)> TagArgParser = Map((key, value) => (key, value), TagArgKey, QuotedText).Before(SkipWhitespaces);
|
||||
|
||||
// parser for all tag arguments
|
||||
private static readonly Parser<char, IEnumerable<(string, string)>> TagArgsParser = TagArgParser.Until(TryLookTagEnd);
|
||||
|
||||
// parser for an opening tag.
|
||||
private static readonly Parser<char, string> TryOpeningTag =
|
||||
Try(Char('<'))
|
||||
.Then(SkipWhitespaces)
|
||||
.Then(TextChar.Until(OneOf(Whitespace.SkipAtLeastOnce(), TryLookTagEnd)))
|
||||
.Select(string.Concat).Labelled($"opening tag");
|
||||
|
||||
private static Parser<char, Dictionary<string, string>> ParseTagArgs(string tag)
|
||||
{
|
||||
return TagArgsParser.Labelled($"{tag} arguments")
|
||||
.Select(x => x.ToDictionary(y => y.Item1, y => y.Item2))
|
||||
.Before(SkipWhitespaces);
|
||||
}
|
||||
|
||||
private static Parser<char, Unit> TryTagTerminator(string tag)
|
||||
{
|
||||
return Try(String("</"))
|
||||
.Then(SkipWhitespaces)
|
||||
.Then(String(tag))
|
||||
.Then(SkipWhitespaces)
|
||||
.Then(TagEnd)
|
||||
.Labelled($"closing {tag} tag");
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
43
Content.Client/Guidebook/GuideEntry.cs
Normal file
43
Content.Client/Guidebook/GuideEntry.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using Robust.Shared.Prototypes;
|
||||
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.List;
|
||||
using Robust.Shared.Utility;
|
||||
|
||||
namespace Content.Client.Guidebook;
|
||||
|
||||
[Virtual]
|
||||
public class GuideEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// The file containing the contents of this guide.
|
||||
/// </summary>
|
||||
[DataField("text", required: true)] public ResourcePath Text = default!;
|
||||
|
||||
/// <summary>
|
||||
/// The unique id for this guide.
|
||||
/// </summary>
|
||||
[IdDataField]
|
||||
public string Id = default!;
|
||||
|
||||
/// <summary>
|
||||
/// The name of this guide. This gets localized.
|
||||
/// </summary>
|
||||
[DataField("name", required: true)] public string Name = default!;
|
||||
|
||||
/// <summary>
|
||||
/// The "children" of this guide for when guides are shown in a tree / table of contents.
|
||||
/// </summary>
|
||||
[DataField("children", customTypeSerializer:typeof(PrototypeIdListSerializer<GuideEntryPrototype>))]
|
||||
public List<string> Children = new();
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="Children"/>.
|
||||
/// </summary>
|
||||
[DataField("priority")] public int Priority = 0;
|
||||
}
|
||||
|
||||
[Prototype("guideEntry")]
|
||||
public sealed class GuideEntryPrototype : GuideEntry, IPrototype
|
||||
{
|
||||
public string ID => Id;
|
||||
}
|
||||
244
Content.Client/Guidebook/GuidebookSystem.cs
Normal file
244
Content.Client/Guidebook/GuidebookSystem.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// This system handles the help-verb and interactions with various client-side entities that are embedded into guidebooks.
|
||||
/// </summary>
|
||||
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";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Initialize()
|
||||
{
|
||||
CommandBinds.Builder
|
||||
.Bind(ContentKeyFunctions.OpenGuidebook,
|
||||
new PointerInputCmdHandler(HandleOpenGuidebook))
|
||||
.Register<GuidebookSystem>();
|
||||
_guideWindow = new GuidebookWindow();
|
||||
|
||||
SubscribeLocalEvent<GuideHelpComponent, GetVerbsEvent<ExamineVerb>>(OnGetVerbs);
|
||||
SubscribeLocalEvent<GuidebookControlsTestComponent, InteractHandEvent>(OnGuidebookControlsTestInteractHand);
|
||||
SubscribeLocalEvent<GuidebookControlsTestComponent, ActivateInWorldEvent>(OnGuidebookControlsTestActivateInWorld);
|
||||
SubscribeLocalEvent<GuidebookControlsTestComponent, GetVerbsEvent<AlternativeVerb>>(
|
||||
OnGuidebookControlsTestGetAlternateVerbs);
|
||||
}
|
||||
|
||||
private void OnGetVerbs(EntityUid uid, GuideHelpComponent component, GetVerbsEvent<ExamineVerb> 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<AlternativeVerb> 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<PointLightComponent>(uid); // RGB demands this.
|
||||
light.Enabled = false;
|
||||
var rgb = EnsureComp<RgbLightControllerComponent>(uid);
|
||||
|
||||
var sprite = EnsureComp<SpriteComponent>(uid);
|
||||
var layers = new List<int>();
|
||||
|
||||
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<SpeechComponent>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the guidebook.
|
||||
/// </summary>
|
||||
/// <param name="guides">What guides should be shown. If not specified, this will instead raise a <see
|
||||
/// cref="GetGuidesEvent"/> and automatically include all guide prototypes.</param>
|
||||
/// <param name="rootEntries">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.</param>
|
||||
/// <param name="forceRoot">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.</param>
|
||||
/// <param name="includeChildren">Whether or not to automatically include child entries. If false, this will ONLY
|
||||
/// show the specified entries</param>
|
||||
/// <param name="selected">The guide whose contents should be displayed when the guidebook is opened</param>
|
||||
public bool OpenGuidebook(
|
||||
Dictionary<string, GuideEntry>? guides = null,
|
||||
List<string>? rootEntries = null,
|
||||
string? forceRoot = null,
|
||||
bool includeChildren = true,
|
||||
string? selected = null)
|
||||
{
|
||||
_guideWindow.OpenCenteredRight();
|
||||
|
||||
if (guides == null)
|
||||
{
|
||||
var ev = new GetGuidesEvent()
|
||||
{
|
||||
Guides = _prototypeManager.EnumeratePrototypes<GuideEntryPrototype>().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<string> guideList,
|
||||
List<string>? rootEntries = null,
|
||||
string? forceRoot = null,
|
||||
bool includeChildren = true,
|
||||
string? selected = null)
|
||||
{
|
||||
Dictionary<string, GuideEntry>? guides = new();
|
||||
foreach (var guideId in guideList)
|
||||
{
|
||||
if (!_prototypeManager.TryIndex<GuideEntryPrototype>(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<string, GuideEntry> guides)
|
||||
{
|
||||
foreach (var childId in guide.Children)
|
||||
{
|
||||
if (guides.ContainsKey(childId))
|
||||
continue;
|
||||
|
||||
if (!_prototypeManager.TryIndex<GuideEntryPrototype>(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<string, GuideEntry> Guides { get; init; } = new();
|
||||
}
|
||||
29
Content.Client/Guidebook/Richtext/Box.cs
Normal file
29
Content.Client/Guidebook/Richtext/Box.cs
Normal file
@@ -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<string, string> args, [NotNullWhen(true)] out Control? control)
|
||||
{
|
||||
HorizontalExpand = true;
|
||||
control = this;
|
||||
|
||||
if (args.TryGetValue("Orientation", out var orientation))
|
||||
Orientation = Enum.Parse<LayoutOrientation>(orientation);
|
||||
else
|
||||
Orientation = LayoutOrientation.Horizontal;
|
||||
|
||||
if (args.TryGetValue("HorizontalAlignment", out var halign))
|
||||
HorizontalAlignment = Enum.Parse<HAlignment>(halign);
|
||||
else
|
||||
HorizontalAlignment = HAlignment.Center;
|
||||
|
||||
if (args.TryGetValue("VerticalAlignment", out var valign))
|
||||
VerticalAlignment = Enum.Parse<VAlignment>(valign);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
29
Content.Client/Guidebook/Richtext/Document.cs
Normal file
29
Content.Client/Guidebook/Richtext/Document.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// A document, containing arbitrary text and UI elements.
|
||||
/// </summary>
|
||||
public sealed class Document : BoxContainer, IDocumentTag
|
||||
{
|
||||
public Document()
|
||||
{
|
||||
Orientation = LayoutOrientation.Vertical;
|
||||
}
|
||||
|
||||
public bool TryParseTag(Dictionary<string, string> args, [NotNullWhen(true)] out Control? control)
|
||||
{
|
||||
DebugTools.Assert(args.Count == 0);
|
||||
control = this;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public interface IDocumentTag
|
||||
{
|
||||
public bool TryParseTag(Dictionary<string, string> args, [NotNullWhen(true)] out Control? control);
|
||||
}
|
||||
Reference in New Issue
Block a user