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:
Leon Friedrich
2023-01-16 21:42:22 +13:00
committed by GitHub
parent abcdd04f3c
commit 22d72f56b5
65 changed files with 1626 additions and 16 deletions

View File

@@ -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.Client.UserInterface.Controls;
using Robust.Shared.IoC;
namespace Content.Client.Administration.UI.CustomControls namespace Content.Client.Administration.UI.CustomControls
{ {
[Virtual] [Virtual]
public class CommandButton : Button public class CommandButton : Button, IDocumentTag
{ {
public string? Command { get; set; } public string? Command { get; set; }
@@ -34,5 +36,20 @@ namespace Content.Client.Administration.UI.CustomControls
if (!string.IsNullOrEmpty(Command)) if (!string.IsNullOrEmpty(Command))
IoCManager.Resolve<IClientConsoleHost>().ExecuteCommand(Command); IoCManager.Resolve<IClientConsoleHost>().ExecuteCommand(Command);
} }
public bool TryParseTag(Dictionary<string, string> 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;
}
} }
} }

View File

@@ -4,6 +4,7 @@ using Content.Client.Chat.Managers;
using Content.Client.Eui; using Content.Client.Eui;
using Content.Client.Flash; using Content.Client.Flash;
using Content.Client.GhostKick; using Content.Client.GhostKick;
using Content.Client.Guidebook;
using Content.Client.Info; using Content.Client.Info;
using Content.Client.Input; using Content.Client.Input;
using Content.Client.IoC; using Content.Client.IoC;
@@ -60,6 +61,7 @@ namespace Content.Client.Entry
[Dependency] private readonly IVoteManager _voteManager = default!; [Dependency] private readonly IVoteManager _voteManager = default!;
[Dependency] private readonly IGamePrototypeLoadManager _gamePrototypeLoadManager = default!; [Dependency] private readonly IGamePrototypeLoadManager _gamePrototypeLoadManager = default!;
[Dependency] private readonly NetworkResourceManager _networkResources = default!; [Dependency] private readonly NetworkResourceManager _networkResources = default!;
[Dependency] private readonly DocumentParsingManager _documentParsingManager = default!;
[Dependency] private readonly GhostKickManager _ghostKick = default!; [Dependency] private readonly GhostKickManager _ghostKick = default!;
[Dependency] private readonly ExtendedDisconnectInformationManager _extendedDisconnectInformation = default!; [Dependency] private readonly ExtendedDisconnectInformationManager _extendedDisconnectInformation = default!;
[Dependency] private readonly PlayTimeTrackingManager _playTimeTracking = default!; [Dependency] private readonly PlayTimeTrackingManager _playTimeTracking = default!;
@@ -156,6 +158,7 @@ namespace Content.Client.Entry
_gamePrototypeLoadManager.Initialize(); _gamePrototypeLoadManager.Initialize();
_networkResources.Initialize(); _networkResources.Initialize();
_userInterfaceManager.SetDefaultTheme("SS14DefaultTheme"); _userInterfaceManager.SetDefaultTheme("SS14DefaultTheme");
_documentParsingManager.Initialize();
_baseClient.RunLevelChanged += (_, args) => _baseClient.RunLevelChanged += (_, args) =>
{ {

View File

@@ -40,7 +40,8 @@ public sealed class ExamineButton : ContainerButton
Disabled = true; 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 Icon = new TextureRect
{ {

View File

@@ -306,6 +306,8 @@ namespace Content.Client.Examine
if (obj.Button is ExamineButton button) if (obj.Button is ExamineButton button)
{ {
_verbSystem.ExecuteVerb(_examinedEntity, button.Verb); _verbSystem.ExecuteVerb(_examinedEntity, button.Verb);
if (button.Verb.CloseMenu ?? button.Verb.CloseMenuDefault)
CloseTooltip();
} }
} }

View 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;
}

View File

@@ -0,0 +1,10 @@
namespace Content.Client.Guidebook;
/// <summary>
/// This is used for the guidebook monkey.
/// </summary>
[RegisterComponent]
public sealed class GuidebookControlsTestComponent : Component
{
}

View 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>

View 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;
}
}

View 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>

View 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;
}
}

View 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"))
);
}

View 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
}

View 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;
}

View 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();
}

View 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;
}
}

View 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);
}

View File

@@ -96,6 +96,7 @@ namespace Content.Client.Input
common.AddFunction(ContentKeyFunctions.OpenTileSpawnWindow); common.AddFunction(ContentKeyFunctions.OpenTileSpawnWindow);
common.AddFunction(ContentKeyFunctions.OpenDecalSpawnWindow); common.AddFunction(ContentKeyFunctions.OpenDecalSpawnWindow);
common.AddFunction(ContentKeyFunctions.OpenAdminMenu); common.AddFunction(ContentKeyFunctions.OpenAdminMenu);
common.AddFunction(ContentKeyFunctions.OpenGuidebook);
} }
} }
} }

View File

@@ -1,4 +1,4 @@
using Content.Client.Administration.Managers; using Content.Client.Administration.Managers;
using Content.Client.Changelog; using Content.Client.Changelog;
using Content.Client.Chat.Managers; using Content.Client.Chat.Managers;
using Content.Client.Clickable; using Content.Client.Clickable;
@@ -17,6 +17,7 @@ using Content.Client.Voting;
using Content.Shared.Administration; using Content.Shared.Administration;
using Content.Shared.Administration.Logs; using Content.Shared.Administration.Logs;
using Content.Shared.Module; using Content.Shared.Module;
using Content.Client.Guidebook;
namespace Content.Client.IoC namespace Content.Client.IoC
{ {
@@ -42,6 +43,7 @@ namespace Content.Client.IoC
IoCManager.Register<GhostKickManager>(); IoCManager.Register<GhostKickManager>();
IoCManager.Register<ExtendedDisconnectInformationManager>(); IoCManager.Register<ExtendedDisconnectInformationManager>();
IoCManager.Register<PlayTimeTrackingManager>(); IoCManager.Register<PlayTimeTrackingManager>();
IoCManager.Register<DocumentParsingManager>();
} }
} }
} }

View File

@@ -10,6 +10,7 @@
<ui:VoteCallMenuButton /> <ui:VoteCallMenuButton />
<Button Access="Public" Name="OptionsButton" Text="{Loc 'ui-escape-options'}" /> <Button Access="Public" Name="OptionsButton" Text="{Loc 'ui-escape-options'}" />
<Button Access="Public" Name="RulesButton" Text="{Loc 'ui-escape-rules'}" /> <Button Access="Public" Name="RulesButton" Text="{Loc 'ui-escape-rules'}" />
<Button Access="Public" Name="GuidebookButton" Text="{Loc 'ui-escape-guidebook'}" />
<Button Access="Public" Name="WikiButton" Text="{Loc 'ui-escape-wiki'}" /> <Button Access="Public" Name="WikiButton" Text="{Loc 'ui-escape-wiki'}" />
<Button Access="Public" Name="DisconnectButton" Text="{Loc 'ui-escape-disconnect'}" /> <Button Access="Public" Name="DisconnectButton" Text="{Loc 'ui-escape-disconnect'}" />
<Button Access="Public" Name="QuitButton" Text="{Loc 'ui-escape-quit'}" /> <Button Access="Public" Name="QuitButton" Text="{Loc 'ui-escape-quit'}" />

View File

@@ -2,7 +2,7 @@
xmlns:tabs="clr-namespace:Content.Client.Options.UI.Tabs" xmlns:tabs="clr-namespace:Content.Client.Options.UI.Tabs"
Title="{Loc 'ui-options-title'}" Title="{Loc 'ui-options-title'}"
MinSize="800 450"> MinSize="800 450">
<TabContainer Name="Tabs"> <TabContainer Name="Tabs" Access="Public">
<tabs:GraphicsTab /> <tabs:GraphicsTab />
<tabs:KeyRebindTab /> <tabs:KeyRebindTab />
<tabs:AudioTab /> <tabs:AudioTab />

View File

@@ -136,6 +136,7 @@ namespace Content.Client.Options.UI.Tabs
AddButton(ContentKeyFunctions.CycleChatChannelBackward); AddButton(ContentKeyFunctions.CycleChatChannelBackward);
AddButton(ContentKeyFunctions.OpenCharacterMenu); AddButton(ContentKeyFunctions.OpenCharacterMenu);
AddButton(ContentKeyFunctions.OpenCraftingMenu); AddButton(ContentKeyFunctions.OpenCraftingMenu);
AddButton(ContentKeyFunctions.OpenGuidebook);
AddButton(ContentKeyFunctions.OpenInventoryMenu); AddButton(ContentKeyFunctions.OpenInventoryMenu);
AddButton(ContentKeyFunctions.OpenAHelp); AddButton(ContentKeyFunctions.OpenAHelp);
AddButton(ContentKeyFunctions.OpenActionsMenu); AddButton(ContentKeyFunctions.OpenActionsMenu);

View File

@@ -66,6 +66,9 @@ public sealed class PointingSystem : SharedPointingSystem
private void AddPointingVerb(GetVerbsEvent<Verb> args) private void AddPointingVerb(GetVerbsEvent<Verb> args)
{ {
if (args.Target.IsClientSide())
return;
// Really this could probably be a properly predicted event, but that requires reworking pointing. For now // Really this could probably be a properly predicted event, but that requires reworking pointing. For now
// I'm just adding this verb exclusively to clients so that the verb-loading pop-in on the verb menu isn't // I'm just adding this verb exclusively to clients so that the verb-loading pop-in on the verb menu isn't
// as bad. Important for this verb seeing as its usually an option on just about any entity. // as bad. Important for this verb seeing as its usually an option on just about any entity.

View File

@@ -5,6 +5,7 @@ using Content.Client.PDA;
using Content.Client.Resources; using Content.Client.Resources;
using Content.Client.Targeting.UI; using Content.Client.Targeting.UI;
using Content.Client.UserInterface.Controls; using Content.Client.UserInterface.Controls;
using Content.Client.UserInterface.Controls.FancyTree;
using Content.Client.Verbs.UI; using Content.Client.Verbs.UI;
using Content.Shared.Verbs; using Content.Shared.Verbs;
using Robust.Client.Graphics; using Robust.Client.Graphics;

View File

@@ -1,5 +1,5 @@
<controls:FancyTree xmlns="https://spacestation14.io" <controls:FancyTree xmlns="https://spacestation14.io"
xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls"> xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls.FancyTree">
<ScrollContainer ReturnMeasure="True"> <ScrollContainer ReturnMeasure="True">
<BoxContainer Orientation="Vertical" Name="Body" Access="Public" Margin="2"/> <BoxContainer Orientation="Vertical" Name="Body" Access="Public" Margin="2"/>
</ScrollContainer> </ScrollContainer>

View File

@@ -8,10 +8,10 @@ using Robust.Client.UserInterface.XAML;
using Robust.Shared.Timing; using Robust.Shared.Timing;
using Robust.Shared.Utility; using Robust.Shared.Utility;
namespace Content.Client.UserInterface.Controls; namespace Content.Client.UserInterface.Controls.FancyTree;
/// <summary> /// <summary>
/// Functionally similar to <see cref="Tree"/>, but with collapsible sections, /// Functionally similar to <see cref="Tree"/>, but with collapsible sections,
/// </summary> /// </summary>
[GenerateTypedNameReferences] [GenerateTypedNameReferences]
public sealed partial class FancyTree : Control public sealed partial class FancyTree : Control

View File

@@ -1,5 +1,5 @@
<controls:TreeItem xmlns="https://spacestation14.io" <controls:TreeItem xmlns="https://spacestation14.io"
xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls"> xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls.FancyTree">
<BoxContainer Orientation="Vertical"> <BoxContainer Orientation="Vertical">
<ContainerButton Name="Button" Access="Public"> <ContainerButton Name="Button" Access="Public">
<BoxContainer Orientation="Horizontal"> <BoxContainer Orientation="Horizontal">

View File

@@ -3,7 +3,7 @@ using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls; using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.XAML; using Robust.Client.UserInterface.XAML;
namespace Content.Client.UserInterface.Controls; namespace Content.Client.UserInterface.Controls.FancyTree;
/// <summary> /// <summary>
/// Element of a <see cref="FancyTree"/> /// Element of a <see cref="FancyTree"/>

View File

@@ -2,7 +2,7 @@ using Robust.Client.Graphics;
using Robust.Client.UserInterface; using Robust.Client.UserInterface;
using Robust.Shared.Utility; using Robust.Shared.Utility;
namespace Content.Client.UserInterface.Controls; namespace Content.Client.UserInterface.Controls.FancyTree;
/// <summary> /// <summary>
/// This is a basic control that draws the lines connecting parents & children in a tree. /// This is a basic control that draws the lines connecting parents & children in a tree.

View File

@@ -1,4 +1,5 @@
using Content.Client.Gameplay; using Content.Client.Gameplay;
using Content.Client.Guidebook;
using Content.Client.Info; using Content.Client.Info;
using Content.Client.UserInterface.Controls; using Content.Client.UserInterface.Controls;
using Content.Client.UserInterface.Systems.Info; using Content.Client.UserInterface.Systems.Info;
@@ -25,6 +26,7 @@ public sealed class EscapeUIController : UIController, IOnStateEntered<GameplayS
[Dependency] private readonly ChangelogUIController _changelog = default!; [Dependency] private readonly ChangelogUIController _changelog = default!;
[Dependency] private readonly InfoUIController _info = default!; [Dependency] private readonly InfoUIController _info = default!;
[Dependency] private readonly OptionsUIController _options = default!; [Dependency] private readonly OptionsUIController _options = default!;
[UISystemDependency] private readonly GuidebookSystem? _guidebook = default!;
private Options.UI.EscapeMenu? _escapeWindow; private Options.UI.EscapeMenu? _escapeWindow;
@@ -98,6 +100,11 @@ public sealed class EscapeUIController : UIController, IOnStateEntered<GameplayS
_uri.OpenUri(_cfg.GetCVar(CCVars.InfoLinksWiki)); _uri.OpenUri(_cfg.GetCVar(CCVars.InfoLinksWiki));
}; };
_escapeWindow.GuidebookButton.OnPressed += _ =>
{
_guidebook?.OpenGuidebook();
};
// Hide wiki button if we don't have a link for it. // Hide wiki button if we don't have a link for it.
_escapeWindow.WikiButton.Visible = _cfg.GetCVar(CCVars.InfoLinksWiki) != ""; _escapeWindow.WikiButton.Visible = _cfg.GetCVar(CCVars.InfoLinksWiki) != "";

View File

@@ -1,13 +1,38 @@
using Content.Client.Options.UI; using Content.Client.Options.UI;
using JetBrains.Annotations; using JetBrains.Annotations;
using Robust.Client.State;
using Robust.Client.UserInterface.Controllers; using Robust.Client.UserInterface.Controllers;
using Robust.Shared.Console;
namespace Content.Client.UserInterface.Systems.EscapeMenu; namespace Content.Client.UserInterface.Systems.EscapeMenu;
[UsedImplicitly] [UsedImplicitly]
public sealed class OptionsUIController : UIController public sealed class OptionsUIController : UIController
{ {
[Dependency] private readonly IConsoleHost _con = default!;
public override void Initialize()
{
_con.RegisterCommand("options", Loc.GetString("cmd-options-desc"), Loc.GetString("cmd-options-help"), OptionsCommand);
}
private void OptionsCommand(IConsoleShell shell, string argStr, string[] args)
{
if (args.Length == 0)
{
ToggleWindow();
return;
}
OpenWindow();
if (!int.TryParse(args[0], out var tab))
{
shell.WriteError(Loc.GetString("cmd-parse-failure-int", ("arg", args[0])));
return;
}
_optionsWindow.Tabs.CurrentTab = tab;
}
private OptionsMenu _optionsWindow = default!; private OptionsMenu _optionsWindow = default!;
private void EnsureWindow() private void EnsureWindow()

View File

@@ -259,7 +259,8 @@ namespace Content.Client.Verbs.UI
private void ExecuteVerb(Verb verb) private void ExecuteVerb(Verb verb)
{ {
_verbSystem.ExecuteVerb(CurrentTarget, verb); _verbSystem.ExecuteVerb(CurrentTarget, verb);
if (verb.CloseMenu)
if (verb.CloseMenu ?? verb.CloseMenuDefault)
_context.Close(); _context.Close();
} }
} }

View File

@@ -0,0 +1,139 @@
#nullable enable
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using Content.Client.Guidebook;
using Content.Client.Guidebook.Richtext;
using NUnit.Framework;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
namespace Content.IntegrationTests.Tests.Guidebook;
/// <summary>
/// This test checks that an example document string properly gets parsed by the <see cref="DocumentParsingManager"/>.
/// </summary>
[TestFixture]
[TestOf(typeof(DocumentParsingManager))]
public sealed class DocumentParsingTest
{
public string TestDocument = @"multiple
lines
separated by
only single newlines
make a single rich text control
unless there is a double newline. Also
whitespace before newlines are ignored.
<TestControl/>
< TestControl />
<TestControl>
some text with a nested control
<TestControl/>
</TestControl>
<TestControl key1=""value1"" key2=""value2 with spaces"" key3=""value3 with a
newline""/>
<TestControl >
<TestControl k=""<\>\\>=\""=<-_?*3.0//"">
</TestControl>
</TestControl>";
[Test]
public async Task ParseTestDocument()
{
await using var pairTracker = await PoolManager.GetServerClient();
var client = pairTracker.Pair.Client;
await client.WaitIdleAsync();
var parser = client.ResolveDependency<DocumentParsingManager>();
Control ctrl = default!;
await client.WaitPost(() =>
{
ctrl = new Control();
Assert.That(parser.TryAddMarkup(ctrl, TestDocument));
});
Assert.That(ctrl.ChildCount, Is.EqualTo(7));
var richText1 = ctrl.GetChild(0) as RichTextLabel;
var richText2 = ctrl.GetChild(1) as RichTextLabel;
Assert.NotNull(richText1);
Assert.NotNull(richText2);
// uhh.. WTF. rich text has no means of getting the contents!?!?
// TODO assert text content is correct after fixing that bullshit.
//Assert.That(richText1?.Text, Is.EqualTo("multiple lines separated by only single newlines make a single rich text control"));
// Assert.That(richText2?.Text, Is.EqualTo("unless there is a double newline. Also whitespace before newlines are ignored."));
var test1 = ctrl.GetChild(2) as TestControl;
var test2 = ctrl.GetChild(3) as TestControl;
var test3 = ctrl.GetChild(4) as TestControl;
var test4 = ctrl.GetChild(5) as TestControl;
var test5 = ctrl.GetChild(6) as TestControl;
Assert.NotNull(test1);
Assert.NotNull(test2);
Assert.NotNull(test3);
Assert.NotNull(test4);
Assert.NotNull(test5);
Assert.That(test1?.ChildCount, Is.EqualTo(0));
Assert.That(test2?.ChildCount, Is.EqualTo(0));
Assert.That(test3?.ChildCount, Is.EqualTo(2));
Assert.That(test4?.ChildCount, Is.EqualTo(0));
Assert.That(test5?.ChildCount, Is.EqualTo(1));
var subText = test3.GetChild(0) as RichTextLabel;
var subTest = test3.GetChild(1) as TestControl;
Assert.NotNull(subText);
//Assert.That(subText?.Text, Is.EqualTo("some text with a nested control"));
Assert.NotNull(subTest);
Assert.That(subTest?.ChildCount, Is.EqualTo(0));
var subTest2 = test5.GetChild(0) as TestControl;
Assert.NotNull(subTest2);
Assert.That(subTest2?.ChildCount, Is.EqualTo(0));
Assert.That(test1?.Params?.Count, Is.EqualTo(0));
Assert.That(test2?.Params?.Count, Is.EqualTo(0));
Assert.That(test3?.Params?.Count, Is.EqualTo(0));
Assert.That(test4?.Params?.Count, Is.EqualTo(3));
Assert.That(test5?.Params?.Count, Is.EqualTo(0));
Assert.That(subTest2?.Params?.Count, Is.EqualTo(1));
string? val = null;
test4?.Params?.TryGetValue("key1", out val);
Assert.That(val, Is.EqualTo("value1"));
test4?.Params?.TryGetValue("key2", out val);
Assert.That(val, Is.EqualTo("value2 with spaces"));
test4?.Params?.TryGetValue("key3", out val);
Assert.That(val, Is.EqualTo(@"value3 with a
newline"));
subTest2?.Params?.TryGetValue("k", out val);
Assert.That(val, Is.EqualTo(@"<>\>=""=<-_?*3.0//"));
await pairTracker.CleanReturnAsync();
}
public sealed class TestControl : Control, IDocumentTag
{
public Dictionary<string, string> Params = default!;
public bool TryParseTag(Dictionary<string, string> param, [NotNullWhen(true)] out Control control)
{
Params = param;
control = this;
return true;
}
}
}

View File

@@ -0,0 +1,42 @@
using Content.Client.Guidebook;
using Content.Client.Guidebook.Richtext;
using NUnit.Framework;
using Robust.Shared.ContentPack;
using Robust.Shared.Prototypes;
using System.Linq;
using System.Threading.Tasks;
namespace Content.IntegrationTests.Tests.Guidebook;
[TestFixture]
[TestOf(typeof(GuidebookSystem))]
[TestOf(typeof(GuideEntryPrototype))]
[TestOf(typeof(DocumentParsingManager))]
public sealed class GuideEntryPrototypeTests
{
[Test]
public async Task ValidatePrototypeContents()
{
await using var pairTracker = await PoolManager.GetServerClient();
var client = pairTracker.Pair.Client;
await client.WaitIdleAsync();
var protoMan = client.ResolveDependency<IPrototypeManager>();
var resMan = client.ResolveDependency<IResourceManager>();
var parser = client.ResolveDependency<DocumentParsingManager>();
var prototypes = protoMan.EnumeratePrototypes<GuideEntryPrototype>().ToList();
await client.WaitAssertion(() =>
{
Assert.Multiple(() =>
{
foreach (var proto in prototypes)
{
var text = resMan.ContentFileReadText(proto.Text).ReadToEnd();
Assert.That(parser.TryAddMarkup(new Document(), text), $"Failed to parse guidebook: {proto.Id}");
}
});
});
await pairTracker.CleanReturnAsync();
}
}

View File

@@ -72,6 +72,7 @@ namespace Content.Server.Entry
} }
prototypes.RegisterIgnore("parallax"); prototypes.RegisterIgnore("parallax");
prototypes.RegisterIgnore("guideEntry");
ServerContentIoC.Register(); ServerContentIoC.Register();

View File

@@ -10,6 +10,8 @@ namespace Content.Server.Entry
"AnimationsTest", "AnimationsTest",
"ItemStatus", "ItemStatus",
"Marker", "Marker",
"GuidebookControlsTest",
"GuideHelp",
"Clickable", "Clickable",
"Icon", "Icon",
"ClientEntitySpawner", "ClientEntitySpawner",

View File

@@ -25,7 +25,7 @@ public sealed class FollowerSystem : EntitySystem
if (!HasComp<SharedGhostComponent>(ev.User)) if (!HasComp<SharedGhostComponent>(ev.User))
return; return;
if (ev.User == ev.Target) if (ev.User == ev.Target || ev.Target.IsClientSide())
return; return;
var verb = new AlternativeVerb var verb = new AlternativeVerb

View File

@@ -23,6 +23,7 @@ namespace Content.Shared.Input
public static readonly BoundKeyFunction CycleChatChannelBackward = "CycleChatChannelBackward"; public static readonly BoundKeyFunction CycleChatChannelBackward = "CycleChatChannelBackward";
public static readonly BoundKeyFunction OpenCharacterMenu = "OpenCharacterMenu"; public static readonly BoundKeyFunction OpenCharacterMenu = "OpenCharacterMenu";
public static readonly BoundKeyFunction OpenCraftingMenu = "OpenCraftingMenu"; public static readonly BoundKeyFunction OpenCraftingMenu = "OpenCraftingMenu";
public static readonly BoundKeyFunction OpenGuidebook = "OpenGuidebook";
public static readonly BoundKeyFunction OpenInventoryMenu = "OpenInventoryMenu"; public static readonly BoundKeyFunction OpenInventoryMenu = "OpenInventoryMenu";
public static readonly BoundKeyFunction SmartEquipBackpack = "SmartEquipBackpack"; public static readonly BoundKeyFunction SmartEquipBackpack = "SmartEquipBackpack";
public static readonly BoundKeyFunction SmartEquipBelt = "SmartEquipBelt"; public static readonly BoundKeyFunction SmartEquipBelt = "SmartEquipBelt";

View File

@@ -132,7 +132,9 @@ namespace Content.Shared.Verbs
/// Setting this to false may be useful for repeatable actions, like rotating an object or maybe knocking on /// Setting this to false may be useful for repeatable actions, like rotating an object or maybe knocking on
/// a window. /// a window.
/// </remarks> /// </remarks>
public bool CloseMenu = true; public bool? CloseMenu;
public virtual bool CloseMenuDefault => true;
/// <summary> /// <summary>
/// How important is this verb, for the purposes of admin logging? /// How important is this verb, for the purposes of admin logging?
@@ -339,6 +341,7 @@ namespace Content.Shared.Verbs
public sealed class ExamineVerb : Verb public sealed class ExamineVerb : Verb
{ {
public override int TypePriority => 0; public override int TypePriority => 0;
public override bool CloseMenuDefault => false; // for examine verbs, this will close the examine tooltip.
public bool ShowOnExamineTooltip = true; public bool ShowOnExamineTooltip = true;
} }

View File

@@ -3,6 +3,7 @@
ui-escape-title = Esc Menu ui-escape-title = Esc Menu
ui-escape-options = Options ui-escape-options = Options
ui-escape-rules = Rules ui-escape-rules = Rules
ui-escape-guidebook = Guidebook
ui-escape-wiki = Wiki ui-escape-wiki = Wiki
ui-escape-disconnect = Disconnect ui-escape-disconnect = Disconnect
ui-escape-quit = Quit ui-escape-quit = Quit

View File

@@ -210,3 +210,7 @@ ui-options-net-pvs-leave-tooltip = This limits the rate at which the client will
out-of-view entities. Lowering this can help reduce out-of-view entities. Lowering this can help reduce
stuttering when walking around, but could occasionally stuttering when walking around, but could occasionally
lead to mispredicts and other issues. lead to mispredicts and other issues.
## Toggle window console command
cmd-options-desc = Opens options menu, optionally with a specific tab selected.
cmd-options-help = Usage: options [tab]

View File

@@ -0,0 +1,7 @@
guidebook-window-title = Guidebook
guidebook-placeholder-text = Select an entry.
guidebook-placeholder-text-2 = If you're new, select the topmost entry to get started.
guidebook-monkey-unspin = Unspin Monkey
guidebook-monkey-disco = Disco Monkey

View File

@@ -0,0 +1,11 @@
guide-entry-engineering = Engineering
guide-entry-construction = Construction
guide-entry-atmospherics = Atmospherics
guide-entry-fires = Fires & Space
guide-entry-shuttle-craft = Shuttle-craft
guide-entry-power = Power
guide-entry-ame = Antimatter Engine (AME)
guide-entry-controls = Controls
guide-entry-jobs = Jobs
guide-entry-survival = Survival
guide-entry-ss14 = Space Station 14

View File

@@ -0,0 +1 @@
guide-help-verb = Help

View File

@@ -737,6 +737,15 @@
- type: Puller - type: Puller
- type: CanHostGuardian - type: CanHostGuardian
- type: entity
name: guidebook monkey
parent: MobMonkey
noSpawn: true
id: MobGuidebookMonkey
description: A hopefully helpful monkey whose only purpose in life is for you to click on. Does this count as having a monkey give you a tutorial?
components:
- type: GuidebookControlsTest
- type: entity - type: entity
name: mouse name: mouse
parent: SimpleMobBase parent: SimpleMobBase

View File

@@ -14,3 +14,5 @@
- type: AMEFuelContainer - type: AMEFuelContainer
- type: StaticPrice - type: StaticPrice
price: 500 price: 500
- type: GuideHelp
guides: [ AME, Power ]

View File

@@ -13,3 +13,5 @@
- type: AMEPart - type: AMEPart
- type: StaticPrice - type: StaticPrice
price: 500 price: 500
- type: GuideHelp
guides: [ AME, Power ]

View File

@@ -32,6 +32,7 @@
parent: CableStack parent: CableStack
name: HV cable coil name: HV cable coil
suffix: Full suffix: Full
description: HV cables for connecting engines to heavy duty machinery, SMESes, and substations.
components: components:
- type: Stack - type: Stack
stackType: CableHV stackType: CableHV
@@ -68,6 +69,7 @@
id: CableMVStack id: CableMVStack
name: MV cable coil name: MV cable coil
suffix: Full suffix: Full
description: MV cables for connecting substations to APCs, and also powering a select few things like emitters.
components: components:
- type: Stack - type: Stack
stackType: CableMV stackType: CableMV

View File

@@ -73,6 +73,8 @@
- type: ContainerContainer - type: ContainerContainer
containers: containers:
AMEController-fuelJarContainer: !type:ContainerSlot {} AMEController-fuelJarContainer: !type:ContainerSlot {}
- type: GuideHelp
guides: [ AME, Power ]
- type: entity - type: entity
noSpawn: true noSpawn: true
@@ -146,3 +148,5 @@
- type: Construction - type: Construction
graph: AMEShielding graph: AMEShielding
node: ameShielding node: ameShielding
- type: GuideHelp
guides: [ AME, Power ]

View File

@@ -0,0 +1,43 @@
- type: guideEntry
id: Engineering
name: guide-entry-engineering
text: "/Server Info/Guidebook/Engineering.xml"
children:
- Atmospherics
- Construction
- Power
- ShuttleCraft
- type: guideEntry
id: Construction
name: guide-entry-construction
text: "/Server Info/Guidebook/Construction.xml"
- type: guideEntry
id: Atmospherics
name: guide-entry-atmospherics
text: "/Server Info/Guidebook/Atmospherics.xml"
children:
- Fires
- type: guideEntry
id: Fires
name: guide-entry-fires
text: "/Server Info/Guidebook/Fires.xml"
- type: guideEntry
id: ShuttleCraft
name: guide-entry-shuttle-craft
text: "/Server Info/Guidebook/Shuttlecraft.xml"
- type: guideEntry
id: Power
name: guide-entry-power
text: "/Server Info/Guidebook/Power.xml"
children:
- AME
- type: guideEntry
id: AME
name: guide-entry-ame
text: "/Server Info/Guidebook/AME.xml"

View File

@@ -0,0 +1,4 @@
- type: guideEntry
id: Controls
name: guide-entry-controls
text: "/Server Info/Guidebook/Controls.xml"

View File

@@ -0,0 +1,11 @@
- type: guideEntry
id: Jobs
name: guide-entry-jobs
text: "/Server Info/Guidebook/Jobs.xml"
children:
- Engineering
- type: guideEntry
id: Survival
name: guide-entry-survival
text: "/Server Info/Guidebook/Survival.xml"

View File

@@ -0,0 +1,8 @@
- type: guideEntry
id: SS14
name: guide-entry-ss14
text: "/Server Info/Guidebook/Space Station 14.xml"
children:
- Controls
- Jobs
- Survival

View File

@@ -264,6 +264,9 @@
- type: Tag - type: Tag
id: Grenade id: Grenade
- type: Tag
id: GuideEmbeded
- type: Tag - type: Tag
id: GunsDisabled # Allow certain entities to not use guns without complicating the system with an event id: GunsDisabled # Allow certain entities to not use guns without complicating the system with an event

View File

@@ -0,0 +1,17 @@
<Document>
# Antimatter Engine (AME)
The AME is one of the simplest engines available. You put together the multi-tile structure, stick some fuel into it, and you're all set. This doesn't mean it isn't potentially dangerous with overheating though.
## Construction
<Box>Required parts:</Box>
<Box>
<GuideEntityEmbed Entity="AMEController"/>
<GuideEntityEmbed Entity="AMEPart"/>
<GuideEntityEmbed Entity="AMEJar"/>
</Box>
To assemble an AME, start by wrenching down the controller on the far end of a HV wire. On most stations, there's catwalks to assist with this. From there, start putting down a 3x3 or larger square of AME parts in preparation for construction, making sure to maximize the number of "center" pieces that are surrounded on all 8 sides.
Once this is done, you can use a multitool to convert each AME part into shielding, which should form a finished AME configuration. From there, insert a fuel jar, set the fuel rate to [color=#a4885c]twice the core count or less[/color], and turn on injection.
</Document>

View File

@@ -0,0 +1,34 @@
<Document>
# Atmospherics
Atmospherics setups are a necessity for your long-term comfort but are generally undocumented, resulting in them being a bit tricky to set up. The following attempts to cover the basics.
## Standard Mix
Breathing pure O2 or pure N2 is generally bad for the health of your crew, and it is recommended to instead aim for a mix of [color=#a4885c]78% N2 and 22% O2 at 101.24kPa.[/color] It's recommended that your gas mixer setup be set to output at least 1000kPa for faster re-pressurization of rooms.
<Box>
<GuideEntityEmbed Entity="OxygenCanister"/>
<GuideEntityEmbed Entity="NitrogenCanister"/>
<GuideEntityEmbed Entity="AirCanister"/>
</Box>
Variations on this mix may be necessary for the long-term comfort of atypical crew, for example crew who require a plasma gas mix to survive. For atypical crew, it is recommended to try and give them their own personal space, isolated by either airlock or disposals section. Keep in mind both methods are leaky and you will need scrubbers on both sides of the lock to clean up any leaked gasses.
<Box>
<GuideEntityEmbed Entity="PlasmaCanister"/>
<GuideEntityEmbed Entity="StorageCanister"/>
</Box>
## Vents and Scrubbers
Vents and scrubbers are core atmospherics devices that fill and cleanse rooms, respectively. They can be reconfigured by installing an air alarm, allowing you to change them from their default settings. By default, they are configured for filling rooms to standard pressure (101.24kPa) and to remove all non-O2/N2 gasses from a room.
<Box>
<GuideEntityEmbed Entity="GasVentPump"/>
<GuideEntityEmbed Entity="GasVentScrubber"/>
</Box>
During standard operation, if a vent detects that the outside environment is space, it will automatically cease operation until a minimum pressure is reached to avoid destruction of necessary gasses. This can be fixed by pressurizing the room up to that minimum pressure by refilling it with gas canister (potentially multiple, if the room is of significant size).
Should you encounter a situation where scrubbers aren't cleaning a room fast enough, employ portable scrubbers by dragging them to the affected location and wrenching them down. They work much faster than typical scrubbers and can clean up a room quite quickly. Large spills may require you to employ multiple.
<Box>
<GuideEntityEmbed Entity="PortableScrubber"/>
</Box>
## Reference Sheet
- Standard atmospheric mix is [color=#a4885c]78% N2 and 22% O2 at 101.24kPa.[/color]
- Gas obeys real math. You can use the equation PV = nRT (Pressure kPa * Volume L = Moles * R * Temperature K) to derive information you might need to know about a gas. R is approximately 8.31446
</Document>

View File

@@ -0,0 +1,10 @@
<Document>
# Construction
By pressing [color=#a4885c]G[/color], one can open the construction menu, which allows you to craft and build a variety of objects.
When placing objects that "snap" to the grid, you can hold [color=#a4885c]hold shift[/color] to place an entire line at a time, and [color=#a4885c]ctrl[/color] to place an entire square at a time.
When crafting objects with a lot of ingredients, keep in mind you don't have to hold everything at once, you can simply place the ingredients on the floor or on a table near you and they'll be used up during crafting like normal.
</Document>

View File

@@ -0,0 +1,46 @@
<Document>
# Controls
<Box Orientation="Vertical">You can change the keybinds at any time here:</Box>
<Box Orientation="Vertical"><CommandButton Text="ui-options-tab-controls" Command="options 1"/></Box>
## Basic controls
We shall politely assume you already know how WASD to walk works.
Beyond that, there's a handful primary interactions in SS14, ordered by importance:
- [color=#a4885c]Left click[/color] to pick up items and activate objects like buttons or computers, and [color=#a4885c]E[/color] to activate items. You can also press [color=#a4885c]alt[/color] while doing this to trigger alternate interactions for some objects.
- You can also quickly activate items you're holding by pressing [color=#a4885c]Z[/color] and [color=#a4885c]alt-Z[/color] respectively.
- [color=#a4885c]Right click[/color] to open the context menu. You can then either left click an entry just like you would in the world, or right click it again to open the verb menu, which gives you more complex ways to interact with an object.
- You can [color=#a4885c]shift-left click[/color] objects to examine them, and get their name and a detailed (though often humorous) description.
You can quickly try out these controls with the monkey below (note: clicking it only works in-game and not in the lobby), and at any point in this guidebook if you're shown an entity, [color=#a4885c]shift-left click to examine will always work[/color]:
<GuideEntityEmbed Entity="MobGuidebookMonkey" Interactive="True" Caption=""/>
## Inventory
In order to move items around in your inventory and between containers, you can click the item (or item name) to move it to your active hand, and then click the spot you want it to go (either a slot in your HUD, or the container).
The items in your hands are only active one at a time, in order to swap between what you're currently using, you can press [color=#a4885c]X[/color] on your keyboard to change active hands.
Opening containers in your inventory is easy as well, either hover over the item and activate it with [color=#a4885c]E[/color] or [color=#a4885c]Z[/color], or click on the bag icon in the bottom right corner of the slot it is in.
When you open a container a window will pop up showing the contents of the container and how much space it has available to hold things.
All items have an assigned size, some too large to fit into most or all containers.
To drop items, you can press [color=#a4885c]Q[/color], and to drop them more violently (also known as throwing), you can press [color=#a4885c]ctrl-Q[/color].
## Actions
To the right in your HUD there's a bar showing various actions you can take.
You can hover over each one to see a name and description for the action that tells you what it is and how it works.
You can click on an action to invoke it, or press a number key at any time to invoke the action without clicking on it.
In order to rearrange your actions, simply drag and drop them to other slots, right click to remove them, and press the gear icon at the top of the bar to open a menu to (re)add them.
You have 10 pages of actions total that you can switch between by pressing [color=#a4885c]shift-(number)[/color].
Additionally, you can press the lock icon to prevent the action bar from being modified.
## Movement
There's a good few things that can modify your movement, most notably slipping (which requires you to walk with [color=#a4885c]shift[/color] to avoid, usually.) and a lack of gravity.
Slipping simply stuns you for a bit, but no gravity can be deadly if you're off station and wander more than about 1.5m away from the nearest wall or solid structure, as you'll lose your grip and no longer be able to move without throwing something.
</Document>

View File

@@ -0,0 +1,21 @@
<Document>
# Engineering
Engineering is a combination of construction work, repair work, maintaining a death machine that happens to produce power, and making sure the station contains breathable air.
## Tools
<Box>
<GuideEntityEmbed Entity="Wrench"/>
<GuideEntityEmbed Entity="Crowbar"/>
<GuideEntityEmbed Entity="Screwdriver"/>
<GuideEntityEmbed Entity="Wirecutter"/>
</Box>
<Box>
<GuideEntityEmbed Entity="Welder"/>
<GuideEntityEmbed Entity="Multitool"/>
</Box>
Your core toolset is a small variety of tools. If you're an engineer, then you should have a belt on your waist containing one of each, if not you can likely find them in maintenance and tool storage within assorted toolboxes and vending machines.
Most tasks will have explainers for how to perform them on examination, for example if you're constructing a wall, it'll tell you the next step if you look at it a bit closer.
</Document>

View File

@@ -0,0 +1,15 @@
<Document>
# Fires & Space
Fires and spacings are an inevitability due to the highly flammable plasma gas and endless vacuum of space present in and around the station, so it's important to know how to manage them.
## Spacing
Space is arguably the easier of the two to handle.
While it does render an area uninhabitable, it can be trivially solved by simply sealing the hole that resulted in the vacuum and introducing a small amount of air.
By default, station vents automatically seal themselves upon exposure to space, in order to avoid wasting air, and will only resume cycling after pressure has been brought up high enough to reopen them.
## Fires
Fires can be delt with through a multitude of ways, but some of the most effective methods include:
- Spacing the enflamed area if possible. This will destroy all of the gasses in the room, which may be a problem if you're already straining life support.
- Dumping a Frezon canister into the enflamed area. This will ice over the flames and halt any ongoing reaction, provided you use enough Frezon. Additionally does not result in destruction of material, so you can simply scrub the rooms afterward.
</Document>

View File

@@ -0,0 +1,26 @@
<Document>
# Jobs
SS14 has a large number of jobs, divided into seven major departments:
## Service
This department includes the janitor, passengers, clown, mime, musician, chef, bartender, head of personnel, and other jobs who exist to serve the station.
It primarily functions as folks who entertain, clean, and serve the rest of the crew, with the exception of passengers who have no particular job beyond getting lost in maintenance.
## Cargo
Cargo consists of the cargo technicians, salvage technicians, and quartermaster. As a department, it responsible resource distribution and trade, capable of buying and selling things on the galactic market.
For the most part, this means they scrounge around the station and assorted wrecks to find materials to sell and redistribute, and purchase materials other departments request.
## Security
Security consists of the security cadets, lawyers, detective, security officers, warden, and head of security. As a department it is responsible for dealing with troublemakers and non-humanoid threats like giant rats and carp.
As a result of this, and the need to walk a fine line, security sometimes is overwhelmed by angry crew (don't kill the clown), and it is important for members of it to try and stay on their best behavior.
## Medical
Medical consists of the medical interns, chemists, medical doctors, and chief medical officer. As a department it is responsible for taking care of the crew, treat and/or clone the wounded and dead, treat disease, and prevent everyone from dying a horrible, bloody death in a closet somewhere.
Medical is one of the more well-equipped departments, with the ability to locate injured crew using the crew monitor, a nigh infinite supply of chems should chemistry be on-par, and the ability to rapidly diagnose and vaccinate contagious diseases.
</Document>

View File

@@ -0,0 +1,37 @@
<Document>
# Power
SS14 has a fairly in-depth power system through which all devices on the station receive electricity. It's divided into three main powernets; HV, LV, and MV.
<Box>
<GuideEntityEmbed Entity="CableHVStack"/>
<GuideEntityEmbed Entity="CableMVStack"/>
<GuideEntityEmbed Entity="CableApcStack"/>
</Box>
## Cabling
The three major cable types (HV, MV, and LV) can be used to form independent powernets. Examine them for a description of their uses.
<Box>
<GuideEntityEmbed Entity="CableHV"/>
<GuideEntityEmbed Entity="CableMV"/>
<GuideEntityEmbed Entity="CableApcExtension"/>
</Box>
## Power storage
Each power storage device presented functions as the transformer for its respective power level (HV, MV, and LV) and also provides a fairly sizable backup battery to help flatten out spikes and dips in power usage.
<Box>
<GuideEntityEmbed Entity="SMESBasic"/>
<GuideEntityEmbed Entity="SubstationBasic"/>
<GuideEntityEmbed Entity="APCBasic"/>
</Box>
## Ramping
Contrary to what one might expect from a video game electrical simulation, power is not instantly provided upon request. Generators and batteries take time to ramp up to match the draw imposed on them, which leads to brownouts when there are large changes in current draw all at once, for example when batteries run out.
## Installing power storage
Substations are the most self-explanatory. Simply install the machine on top of an MV and HV cable, it will draw power from the HV cable to provide to MV.
Installing APCs is similarly simple, except APCs are exclusively wallmounted machinery and cannot be installed on the floor. Make sure it has both MV and LV connections.
Installing a SMES requires you construct a cable terminal to use as the input. The SMES will draw power from the terminal and send power out from underneath. The terminal will ensure that the HV input and HV output do not connect. Avoid connecting a SMES to itself, this will result in a short circuit which can result in power flickering or outages depending on severity.
</Document>

View File

@@ -0,0 +1,40 @@
<Document>
# Shuttle-craft
Shuttle construction is simple and easy, albeit rather expensive and hard to pull off within an hour. It's a good activity if you have a significant amount of spare time on your hands and want a bit of a challenge.
## Getting started
<Box>Required parts:</Box>
<Box>
<GuideEntityEmbed Entity="Thruster"/>
<GuideEntityEmbed Entity="Gyroscope"/>
<GuideEntityEmbed Entity="ComputerShuttle"/>
<GuideEntityEmbed Entity="SubstationBasic"/>
<GuideEntityEmbed Entity="GeneratorPlasma"/>
</Box>
<Box>
<GuideEntityEmbed Entity="CableHVStack"/>
<GuideEntityEmbed Entity="CableMVStack"/>
<GuideEntityEmbed Entity="CableApcStack"/>
<GuideEntityEmbed Entity="APCBasic"/>
</Box>
<Box>Optional parts:</Box>
<Box>
<GuideEntityEmbed Entity="AirCanister"/>
<GuideEntityEmbed Entity="LightTube"/>
<GuideEntityEmbed Entity="AirlockGlassShuttle"/>
<GuideEntityEmbed Entity="SMESBasic"/>
</Box>
<Box>
<GuideEntityEmbed Entity="MobCorgiIan"/>
<GuideEntityEmbed Entity="NuclearBomb"/>
</Box>
Head out into space with steel sheets and metal rods in hand, and once you're three or more meters away from the station, click near or under you with the rods in hand. This will place some lattice, which can then be turned into plating with the steel sheets. Expand your lattice platform by clicking just off the edge with rods in hand.
From there, once you have the shape you want, bring out and install thrusters at the edges. They must be pointing outward into space to function and will not fire if there's a tile in the way of the nozzle. Install a gyroscope where convenient, and use your substation and generator to set up power. Construct a wall on top of an MV cable and then install an APC on that to power the devices onboard.
Finally, install the shuttle computer wherever is convenient and ensure all your thrusters and gyroscopes are receiving power. If they are, congratulations, you should have a functional shuttle! Making it livable and good looking is left as an exercise to the reader.
</Document>

View File

@@ -0,0 +1,14 @@
<Document>
# Space Station 14
Welcome to the pre-alpha of Space Station 14! We hope you enjoy the game, and this entry will serve to guide you on how to learn to play. There's quite a bit to read, but SS14 is in itself a very large and in-depth game, and hopefully these guides help you enjoy it to it's fullest.
## What this is
Space Station 14 is a free (forever) open source remake of the infamous Space Station 13, hoping to provide a improved experience for both newcomers and old players alike. Space Station 14 is designed as a fully moddable experience that you can modify to your liking with custom servers, adding entire swaths of new content for people to explore.
If you're just here to play, that's great! The [color=#a4885c]Where to start[/color] section of this entry will help you out, if you're looking to do a bit more with the game, you can join our discord and find our github page in the lobby.
## Where to start
It's recommended to start with the [color=#a4885c]Controls[/color] guide, and then read the [color=#a4885c]Character Creation[/color], [color=#a4885c]Roleplaying[/color], and [color=#a4885c]Jobs[/color] guides. Not all jobs or concepts will have guides yet, and it's strongly encouraged to help us write them if you're an experienced player!
</Document>

View File

@@ -0,0 +1,32 @@
<Document>
# Survival
It is generally wise to avoid situations that will cause you harm, because medical can only heal you so much when there's nuclear operatives on your doorstep.
## Identifying your situation
Your PDA contains both a vessel name and list of crew currently active in your vessel, a reminder for your name if needed, and your assigned job for the shift, allowing you to quickly assess the situation.
## Emergency Treatment
In the event of a serious emergency, there's a few things you can do to help ensure your long-term survival, including:
- If entering critical condition, use the emergency medipen from your emergency box, it'll make sure you don't end up unable to do anything. Emergency medipens can also be used to revive people currently in crit on the floor, and to prolong the amount of time you have to deal with poisons/etc.
<Box>
<GuideEntityEmbed Entity="EmergencyMedipen"/>
<GuideEntityEmbed Entity="SpaceMedipen"/>
</Box>
- Your emergency box contains a breath mask and oxygen tank, which can help you survive longer in a spacing situation.
<Box>
<GuideEntityEmbed Entity="ClothingMaskBreath"/>
<GuideEntityEmbed Entity="EmergencyOxygenTankFilled"/>
</Box>
- If actively bleeding out, or simply wishing to prepare, it's possible to slice up cloth items with a knife or other sharp object and use the resulting cloth to create gauze to stem bleeding with.
<Box>
<GuideEntityEmbed Entity="Gauze"/>
<GuideEntityEmbed Entity="MaterialCloth"/>
</Box>
- In lieu of an actual health analyzer, simply examining yourself and using the detailed examine is a good way to figure out what wounds you have.
- If going blind, carrots are another way to treat the issue (as they contain Oculine, the chemistry drug used to treat blindness) should they be available.
- Well-made meals (cooked food not from a vending machine) is generally much better for your overall health and can help heal smaller wounds more quickly.
- Simple bed rest cures the majority of diseases and also allows wounds to close up on their own. Medical beds are best for this, providing a sterile surface and support for all damaged body parts, but any bed works. Even sitting down helps, if only a little.
</Document>

View File

@@ -197,6 +197,9 @@ binds:
- function: OpenCraftingMenu - function: OpenCraftingMenu
type: State type: State
key: G key: G
- function: OpenGuidebook
type: State
key: NumpadNum0
- function: OpenAHelp - function: OpenAHelp
type: State type: State
key: F1 key: F1