Modular multitasking and command handling PB script for Space Engineers, offering a fully flexible and auto-updating hierarchical menu system.
Table of Contents
A short demonstration of the menu handling capabilities, which includes: traversing tree structures, automatic word-wrapping, prefix and suffix bullet characters based on item type, auto-scrolling, displaying navigation breadcrumbs, etc. This demo doesn’t cover other existing capabilities, e.g. displaying automatically updating text.
The script has been updated to utilize the game’s relatively new low-level graphics API, instead of the initial text-based implementation.
(Yes, it has an integrated log with customizable logging level. Every developer’s dream. 😅)
GridOS is a modular multitasking and command handling ingame script for Space Engineers. This script provides a framework for creating separate code modules to run on a single Programmable Block.
Additionally, it provides access to a highly flexible hierarchical menu system that implements intelligent automatic screen updates (when the underlying data changes), and supports showing different parts of the same menu hierarchy on different displays (additional displays can be added or removed dynamically at runtime).
From time to time expect some breaking changes to the consumer-facing interfaces. I don’t think anyone besides me is using this script currently, so there doesn’t seem to be any reason to be careful yet. I’m taking long breaks from the project, but I’ll strive to produce a release version sometimes in the future.
The script has been updated to a graphical interface from the initial text-based one, utilizing Space Engineer’s new low-level sprite API. This makes it somewhat more presentable and increases its customizability. I basically implemented a simple GUI system, where the screen consists of controls, and each control describes its appearance via familiar properties like colors, margin, padding and text size, supporting multiple units, e.g. screen percentage, pixel, and em (relative to the base font size).
This is already quite flexible in some ways, but with this implementation controls are still essentially just text boxes, and I realized that a significantly wider range of GUI-capabilities could be well utilized. So the next step is to redesign the rendering logic to make each control draw itself. This will allow controls to contain shapes, or other nested controls, thus unlocking the ability to implement even layout elements, like stackpanels in WPF. After this feature is added, I can start implementing various utility controls, e.g. sliders and pop-ups.
Subsequently I plan to implement the ability to add, remove and customize LCD panels directly in the menu system, including selecting which controls should be visible on which LCDs. This would allow, for example, to display a certain inventory content on a certain LCD, or any combination of supported controls, without the entire menu (since the menu itself is also just a control).
One problem is that this project is already too complex for an in-game script (even if it runs well), and I constantly struggle with the script length limitation, especially considering that the custom code written by users will require space as well. Possibly I’ll have to make this into a mod.
Functional overview of GridOS:
(Note that this solution interprets concepts rather pragmatically, and some industry standard practices, like using abstractions and dependency injection everywhere, don’t really add value in this limited/constrained runtime environment.)
GridOS facade: The main GridOS class (the one you’re instantiating) is a simple facade or front controller that handles all features under the hood via a single entry point (Main()
), with the help of multiple dispatcher units, namely:
Display stack: The display subsystem of GridOS follows a loosely layered GUI design that uses the notion of controllers
, views
, controls
, models
, etc. A Display Orchestrator governs the list of Display Controllers, all of which represent a separate display stack that drives menu display on a specific ingame TextSurface
.
StringBuilders
, the temporary entities are all structs
, and multiple pipeline methods stream IEnumerables
instead of relying on collections (but collections are also cached everywhere of course).Composite menu system: The model part of the display system is mostly based on the Composite pattern, which means that it’s a node-based hierarchical structure where nodes can contain additional nodes, creating a tree structure.
GridOS basically uses the concept of ‘modules’. You have to create a module (or multiple) and put your menu items, commands and update method inside. Then you can register an instance of your module via GridOS’s RegisterModule()
method.
Each module is a separate class that implements the IModule
interface. Implementing this interface is what makes you able to register the module in the GridOS
instance. But IModule
alone doesn’t do anything; you need to indicate which features you want to use, by implementing any/all of the following interfaces:
IUpdateSubscriber
: With this interface you can subscribe to recurring automatic execution, which simply requires setting an UpdateFrequency
and declaring an Update()
method. You can modify this frequency any time, and the system will adjust. The system dynamically changes the main frequency of the programmable block too, so it runs only when it is actually requested by at least one module.
ICommandPublisher
: With this interface you can publish a list of commands. The methods linked to the commands will be executed when the programmable block receives the commands as an argument.
IDisplayElementPublisher
: With this interface you can specify a display element to be shown in GridOS’s display system. The display element can be a simple DisplayElement
, for displaying non-interactive textual information; DisplayCommand
, for displaying executable commands; and DisplayGroup
, for creating a node that contains other nodes. This system is extremely flexible; you can create a fully custom hierarchy, and even change it during runtime, since most changes are designed to propage to the screen automatically. The DisplayGroup
node type lets you subscribe to multiple events, e.g. BeforeOpen
, so you can be notified when the group is about to be opened (for e.g. refreshing the information elements or command labels).
IUpdateSubscriber
, etc. interfaces, the framework will expose a service object through which modules can dynamically subscribe to, use, and unsubscribe from framework services during runtime.Update1
, Update10
, Update100
, and None
).Update100
tier, all of them will execute in the same Programmable Block invocation (in the same tick). I’m planning to introduce load-balancing, which will offset each module’s running cycle. The tradeoff will be a higher base frequency of the Programmable Block itself. This feature will probably be switchable.(Skip this section if you have Visual Studio and MDK already set up, and it’s obvious to you how to add a shared project to your solution.)
Environment
If you wish to utilize this framework in your scripting projects, you should be using:
The MDK plugin is the only comfortable way currently available for merging multiple project files into a single Programmable Block script.
Adding the framework as a shared project
This project is created as a shared project that you can (after cloning or downloading it) add to your existing solutions, by selecting File > Open > Project/Solution > Add to solution. Afterwards you need to add a reference to it by right-clicking the References node in your own project (in Solution Explorer), then selecting Add Reference > Shared Projects, and checking the checkbox in front of GridOS.
Deploying the finished script
After you’ve written your script against the framework, and wish to transfer it to SE, right-click the solution node in Solution Explorer, and select Deploy All MDK Scripts. This command merges all files, including both the files of the framework and your own project files, into a single script file placed into SE’s script folder, ready for selecting it in-game from the list of scripts.
The class below, after instantiating it, and registering it in the GridOS
instance, will have its Update()
cycle called according to its UpdateFrequency
setting. The specified CommandItem
will be executable from argument, and the specified DisplayElements
will appear on the system’s display.
public class ExampleModule : IModule, ICommandPublisher, IUpdateSubscriber, IDisplayElementPublisher
{
public string ModuleDisplayName { get; } = "Example Module";
public ObservableUpdateFrequency Frequency { get; } = new ObservableUpdateFrequency(UpdateFrequency.Update100);
public List<CommandItem> Commands => _commands;
private List<CommandItem> _commands = new List<CommandItem>();
public IDisplayElement DisplayElement => _displayElement;
private DisplayGroup _displayElement = new DisplayGroup("Menu Group");
private DisplayCommand _myDisplayCommand;
// Inject your dependencies through the constructor
public ExampleModule()
{
_commands.Add(new CommandItem(
CommandName: "SomeCommand",
Execute: ExecuteSomeCommand
));
_displayElement.AddChild(new DisplayElement("This can be any information"));
// Save reference if you want to modify it later
_myDisplayCommand = new DisplayCommand("Do something", DoSomething);
_displayElement.AddChild(_myDisplayCommand);
}
public void Update(UpdateType updateType)
{
// Do something at each update cycle, call other methods, etc.
// Modify UpdateFrequency any time if needed
Frequency.Set(UpdateFrequency.Update10);
}
private void ExecuteSomeCommand(CommandItem sender, string param)
{
// Do something when command is called via argument
}
private void DoSomething()
{
// Do something when display command is selected
_myDisplayCommand.Label = "This will update on the display";
_displayElement.AddChild(new DisplayElement("This is some new information, dynamically added."));
}
}
The following example shows the current, simplified instantiation of the framework, along with the registration of a single module. The framework uses multiple components as dependencies, but the instantiation of these happens internally, to facilitate ease of use.
private GridOS gridOS;
public Program()
{
IMyTextPanel gridOSDisplay = GridTerminalSystem.GetBlockWithName("GridOSDisplay") as IMyTextPanel;
gridOS = new GridOS(this);
// For using display capabilities (optional).
// Multi-display supported: Multiple textpanels can be registered.
// Each display has their own view state of the same hierarchical menu system.
// Each display creates its own unique navigation commands in the internal command registry.
// Currently the commands for the first registered display are: Display1Up, Display1Down, and Display1Select.
// Additonal displays added use an incremented version of these commands (e.g. Display2Up, etc.).
gridOS.RegisterTextPanel(gridOSDisplay);
ExampleModule exampleModule = new ExampleModule();
gridOS.RegisterModule(exampleModule);
}
public void Main(string argument, UpdateType UpdateType)
{
// Simply transfer control to the system, passing all parameters
gridOS.Main(argument, UpdateType);
}