diff --git a/EnhancedUI/Content/ControlPanel.html b/EnhancedUI/Content/ControlPanel.html index 798bc7a..51f9259 100644 --- a/EnhancedUI/Content/ControlPanel.html +++ b/EnhancedUI/Content/ControlPanel.html @@ -7,8 +7,6 @@ - - @@ -16,5 +14,8 @@
+ + + \ No newline at end of file diff --git a/EnhancedUI/Content/ControlPanel.js b/EnhancedUI/Content/ControlPanel.js index 44efdb9..d061e4c 100644 --- a/EnhancedUI/Content/ControlPanel.js +++ b/EnhancedUI/Content/ControlPanel.js @@ -1,164 +1,209 @@ -var blockStates = null; +let displayedVersion = 0; +let rendering = false; -// Invoked from C# +// Invoked from C# whenever a new game state version is available // noinspection JSUnusedGlobalSymbols -async function stateUpdated() { - const blockViews = $('#blocks'); - blockViews.empty(); - - blockStates = await state.GetBlockStates(); - for (const entityId in blockStates) { - renderBlock(blockViews, blockStates[entityId]); +async function OnGameStateChange(version) { + // Is the model accessible? + if (TerminalViewModel === undefined) + return; + + // Eliminate any any duplicate or redundant calls + if (rendering || version <= displayedVersion) + return; + + rendering = true; + try { + let blockIds = await TerminalViewModel.GetModifiedBlockIds(displayedVersion); + let blocks = $('#blocks'); + for (const i in blockIds) { + let blockId = blockIds[i]; + let blockState = await TerminalViewModel.GetBlockState(blockId); + renderBlock(blocks, blockState); + } + } finally { + displayedVersion = version; + rendering = false; } } function renderBlock(parent, blockState) { - let blockView = $('
'); - blockView.addClass('block'); - blockView.attr('id', 'block-' + blockState.EntityId); - renderBlockInner(blockView, blockState); - parent.append(blockView); + if (blockState == null) + return; + + let blockViewId = 'block-' + blockState.Id; + let oldBlockDiv = $('#' + blockViewId); + + let blockDiv = $('
'); + blockDiv.addClass('block'); + blockDiv.attr('id', blockViewId); + renderBlockInner(blockDiv, blockState); + + if (oldBlockDiv.length === 0) + parent.append(blockDiv); + else + oldBlockDiv.replaceWith(blockDiv); } function renderBlockInner(blockView, blockState) { - let id = $('
'); - id.addClass('entityId'); - id.text(blockState.EntityId); - blockView.append(id); - - let type = $('
'); - type.addClass('type'); - type.text(blockState.ClassName + ' | ' + blockState.TypeId + ' | ' + blockState.SubtypeName); - blockView.append(type); - - let name = $('
'); - name.addClass('name'); - name.text(blockState.Name); - blockView.append(name); - - let properties = $('
') - properties.addClass('properties'); - for (const propertyId in blockState.PropertyStates) { - renderBlockProperty(properties, blockState.PropertyStates[propertyId]) + let blockId = blockState.Id; + + let idDiv = $('
'); + idDiv.addClass('id'); + idDiv.text('Block #' + blockId); + blockView.append(idDiv); + + let typeDiv = $('
'); + typeDiv.addClass('type'); + typeDiv.text(blockState.ClassName + ' | ' + blockState.TypeId + ' | ' + blockState.SubtypeName); + blockView.append(typeDiv); + + let nameDiv = $('
'); + nameDiv.addClass('name'); + nameDiv.text(blockState.Name); + blockView.append(nameDiv); + + let propertiesDiv = $('
') + propertiesDiv.addClass('properties'); + for (const propertyId in blockState.Properties) { + renderBlockProperty(propertiesDiv, blockState.Id, blockState.Properties[propertyId]) } - blockView.append(properties) + blockView.append(propertiesDiv) blockView.append($('
')) } -function renderBlockProperty(parent, propertyState) { - let propertyView= $('
'); - propertyView.addClass('property'); +function renderBlockProperty(parent, blockId, propertyState) { + let propertyId = propertyState.Id; + let propertyValue = propertyState.Value; - let cb, label; + let propertyDiv= $('
'); + let propertyDivId = 'block-' + blockId + '-property-' + propertyId; + propertyDiv.attr('id', propertyDivId) + propertyDiv.addClass('property'); + + let propertyInputId = 'block-' + blockId + '-property-' + propertyId + '-input' + + let input, label; let value = $('
'); value.addClass('value'); switch(propertyState.TypeName) { case "Boolean": - cb = $('') - cb.attr('id', propertyState.Id); - cb.attr('type', 'checkbox'); - if (propertyState.Value) { - cb.attr('checked', 'checked'); + input = $('') + input.attr('id', propertyInputId); + input.attr('type', 'checkbox'); + if (propertyValue) { + input.attr('checked', 'checked'); } - value.append(cb); + value.append(input); label = $('
+ + + \ No newline at end of file diff --git a/EnhancedUI/Content/common.js b/EnhancedUI/Content/common.js index c8399b4..cbaf3c4 100644 --- a/EnhancedUI/Content/common.js +++ b/EnhancedUI/Content/common.js @@ -1,7 +1,3 @@ $(document).ready(async function () { - // For debugging only - // $("#window-size").text(`${window.innerWidth}x${window.innerHeight}`); - - await CefSharp.BindObjectAsync("state"); - state.NotifyBound(); + await CefSharp.BindObjectAsync("TerminalViewModel"); }); \ No newline at end of file diff --git a/EnhancedUI/EnhancedUI.csproj b/EnhancedUI/EnhancedUI.csproj index 2ac8d46..fbd9bab 100644 --- a/EnhancedUI/EnhancedUI.csproj +++ b/EnhancedUI/EnhancedUI.csproj @@ -124,6 +124,20 @@ + + + + + + + + + + + + + + diff --git a/EnhancedUI/Gui/Chromium.cs b/EnhancedUI/Gui/Chromium.cs index b40f0ca..81823b8 100644 --- a/EnhancedUI/Gui/Chromium.cs +++ b/EnhancedUI/Gui/Chromium.cs @@ -3,6 +3,7 @@ using System.Runtime.InteropServices; using CefSharp; using CefSharp.OffScreen; +using EnhancedUI.ViewModel; using VRage.Utils; using VRageMath; @@ -16,7 +17,7 @@ public class Chromium : IDisposable public readonly ChromiumWebBrowser Browser; - public Chromium(Vector2I size, object state) + public Chromium(Vector2I size) { videoData = new byte[size.X * size.Y * 4]; @@ -34,11 +35,11 @@ public Chromium(Vector2I size, object state) Browser.JavascriptObjectRepository.ResolveObject += (sender, e) => { var repo = e.ObjectRepository; - if (e.ObjectName == "state") + if (e.ObjectName == "TerminalViewModel") { // No CamelCase of Javascript Names repo.NameConverter = null; - repo.Register("state", state, isAsync: true, options: BindingOptions.DefaultBinder); + repo.Register("TerminalViewModel", TerminalViewModel.Instance, isAsync: true, options: BindingOptions.DefaultBinder); } }; } diff --git a/EnhancedUI/Gui/ChromiumGuiControl.cs b/EnhancedUI/Gui/ChromiumGuiControl.cs index 1d8b26c..84d28e8 100644 --- a/EnhancedUI/Gui/ChromiumGuiControl.cs +++ b/EnhancedUI/Gui/ChromiumGuiControl.cs @@ -1,5 +1,6 @@ using System; using CefSharp; +using EnhancedUI.ViewModel; using Sandbox.Graphics; using Sandbox.Graphics.GUI; using VRage.Utils; @@ -33,17 +34,13 @@ public partial class ChromiumGuiControl : MyGuiControlBase private readonly WebContent content; private readonly string name; - private readonly IPanelState state; - private static bool hooksInstalled; - public ChromiumGuiControl(WebContent content, string name, IPanelState state) + public ChromiumGuiControl(WebContent content, string name) { this.content = content; this.name = name; - this.state = state; - // FIXME: Do we need this? CanHaveFocus = true; MyLog.Default.Info($"{name} browser created"); @@ -53,10 +50,20 @@ public ChromiumGuiControl(WebContent content, string name, IPanelState state) InstallHooks(); hooksInstalled = true; } + + if (TerminalViewModel.Instance != null) + { + TerminalViewModel.Instance.OnGameStateChanged += OnGameStateChanged; + } } ~ChromiumGuiControl() { + if (TerminalViewModel.Instance != null) + { + TerminalViewModel.Instance.OnGameStateChanged -= OnGameStateChanged; + } + if (hooksInstalled) { UninstallHooks(); @@ -81,7 +88,7 @@ private void CreatePlayerIfNeeded() } var rect = GetVideoScreenRectangle(); - chromium = new Chromium(new Vector2I(rect.Width, rect.Height), state); + chromium = new Chromium(new Vector2I(rect.Width, rect.Height)); BrowserControls[name] = this; @@ -111,8 +118,6 @@ public override void OnRemoving() BrowserControls.Remove(name); - state.SetBrowser(null); - chromium.Ready -= OnChromiumReady; chromium.Browser.LoadingStateChanged -= OnBrowserLoadingStateChanged; @@ -144,11 +149,18 @@ private void OnChromiumReady() videoId = MyRenderProxy.PlayVideo(VideoPlayPatch.VideoNamePrefix + name, 0); } + private void OnGameStateChanged(long version) + { + if (!IsBrowserInitialized) + return; + + chromium?.Browser.ExecuteScriptAsync($"OnGameStateChange({version})"); + } + private void Navigate() { var url = content.FormatIndexUrl(name); MyLog.Default.Info($"{name} browser navigation: {url}"); - state.SetBrowser(chromium?.Browser); chromium?.Navigate(url); } @@ -207,5 +219,5 @@ private void DebugDraw() { MyGuiManager.DrawBorders(GetPositionAbsoluteTopLeft(), Size, Color.White, 1); } - } + } } \ No newline at end of file diff --git a/EnhancedUI/Gui/IPanelState.cs b/EnhancedUI/Gui/IPanelState.cs deleted file mode 100644 index 7d7519f..0000000 --- a/EnhancedUI/Gui/IPanelState.cs +++ /dev/null @@ -1,19 +0,0 @@ -using CefSharp.OffScreen; - -namespace EnhancedUI.Gui -{ - public interface IPanelState - { - // Access to the browser instance is required to invoke JavaScript - void SetBrowser(ChromiumWebBrowser? browser); - - // Return True if the page has been loaded - // ReSharper disable once UnusedMemberInSuper.Global - bool HasBound(); - - // Marks the page as loaded - // Invoked from JavaScript - // ReSharper disable once UnusedMember.Global - void NotifyBound(); - } -} \ No newline at end of file diff --git a/EnhancedUI/Gui/PanelState.cs b/EnhancedUI/Gui/PanelState.cs deleted file mode 100644 index c84e07a..0000000 --- a/EnhancedUI/Gui/PanelState.cs +++ /dev/null @@ -1,33 +0,0 @@ -using CefSharp; -using CefSharp.OffScreen; - -namespace EnhancedUI.Gui -{ - public class PanelState : IPanelState - { - // Stores a reference to the browser, required to invoke JavaScript code (send events from C# to JS) - protected ChromiumWebBrowser? Browser; - - // True value indicates that the state has been bound to a JS accessible global variable already - private bool bound; - - public void SetBrowser(ChromiumWebBrowser? browser) - { - Browser = browser; - bound = false; - } - - // Checks whether the state has been bound to a JS accessible global variable already - public bool HasBound() - { - return bound && Browser?.IsBrowserInitialized == true; - } - - // Invoked by JS code after the state is bound to a global variable - public virtual void NotifyBound() - { - bound = true; - Browser.ExecuteScriptAsync("stateUpdated();"); - } - } -} \ No newline at end of file diff --git a/EnhancedUI/Gui/Terminal/ControlPanel/BlockState.cs b/EnhancedUI/Gui/Terminal/ControlPanel/BlockState.cs deleted file mode 100644 index 9942608..0000000 --- a/EnhancedUI/Gui/Terminal/ControlPanel/BlockState.cs +++ /dev/null @@ -1,96 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using Sandbox.Game.Entities.Cube; -using Sandbox.ModAPI.Interfaces; -using SharpDX; -using VRageMath; -using VRageRender.Messages; - -namespace EnhancedUI.Gui.Terminal.ControlPanel -{ - public class BlockState - { - public readonly string ClassName; - public readonly string TypeId; - public readonly string SubtypeName; - - public readonly long EntityId; - public readonly string Name; - public readonly string DetailedInfo; - public readonly string CustomData; - - // ReSharper disable once CollectionNeverQueried.Global - public readonly Dictionary PropertyStates = new(); - - public override int GetHashCode() - { - return Name.GetHashCode(); - } - - public BlockState(MyTerminalBlock block) - { - ClassName = block.GetType().Name; - TypeId = block.BlockDefinition.Id.TypeId.ToString(); - SubtypeName = block.BlockDefinition.Id.SubtypeName; - - EntityId = block.EntityId; - Name = block.CustomName.ToString(); - if (Name == "") - { - Name = block.DisplayNameText ?? block.DisplayName; - } - - DetailedInfo = block.DetailedInfo.ToString(); - CustomData = block.CustomData; - - var properties = new List(); - block.GetProperties(properties, null); - foreach (var property in properties) - { - PropertyStates[property.Id] = new BlockPropertyState(block, property); - } - } - } - - public class BlockPropertyState - { - public readonly string Id; - public readonly string TypeName; - public object? Value; - - public BlockPropertyState(MyTerminalBlock block, ITerminalProperty property) - { - Id = property.Id; - TypeName = property.TypeName; - - switch (property.TypeName) - { - case "Boolean": - Value = property.AsBool().GetValue(block); - break; - - case "Single": - Value = property.AsFloat().GetValue(block); - break; - - case "Int64": - Value = property.As().GetValue(block); - break; - - case "StringBuilder": - Value = property.As().GetValue(block).ToString(); - break; - - case "Color": - Value = FormatColor(property.As().GetValue(block)); - break; - } - } - - private static string FormatColor(Color c) - { - return $"#{c.R:X02}{c.G:X02}{c.B:X02}"; - } - } -} \ No newline at end of file diff --git a/EnhancedUI/Gui/Terminal/ControlPanel/ControlPanelState.cs b/EnhancedUI/Gui/Terminal/ControlPanel/ControlPanelState.cs deleted file mode 100644 index 37bde14..0000000 --- a/EnhancedUI/Gui/Terminal/ControlPanel/ControlPanelState.cs +++ /dev/null @@ -1,153 +0,0 @@ -using System; -using System.Collections.Generic; -using CefSharp; -using Sandbox.Game.Entities.Cube; -using Sandbox.ModAPI.Interfaces; - -namespace EnhancedUI.Gui.Terminal.ControlPanel -{ - public class ControlPanelState : PanelState - { - public static ControlPanelState? Instance; - - // Link to SE's data model - private MyTerminalBlock? interactedBlock; - - // Blocks by EntityId - private readonly Dictionary terminalBlocks = new(); - - // Blocks states by EntityId - private readonly Dictionary blockStates = new(); - - // Invoked from JavaScript - // ReSharper disable once UnusedMember.Global - public Dictionary GetBlockStates() => blockStates; - - // Invoked from JavaScript - // ReSharper disable once UnusedMember.Global - public BlockState GetBlockState(long entityId) => blockStates[entityId]; - - public ControlPanelState() - { - Instance = this; - } - - public void Init(MyTerminalBlock block) - { - if (interactedBlock?.EntityId == block.EntityId) - { - return; - } - - Clear(); - - interactedBlock = block; - - LoadBlockStates(); - - foreach (var terminalBlock in terminalBlocks.Values) - { - terminalBlock.PropertiesChanged += OnPropertyChanged; - } - - Browser?.ExecuteScriptAsync("stateUpdated();"); - } - - public void Clear() - { - interactedBlock = null; - - foreach (var terminalBlock in terminalBlocks.Values) - { - terminalBlock.PropertiesChanged -= OnPropertyChanged; - } - - terminalBlocks.Clear(); - blockStates.Clear(); - } - - private void LoadBlockStates() - { - terminalBlocks.Clear(); - blockStates.Clear(); - - if (interactedBlock == null) - { - return; - } - - foreach (var terminalBlock in interactedBlock.CubeGrid.GridSystems.TerminalSystem.Blocks) - { - terminalBlocks[terminalBlock.EntityId] = terminalBlock; - blockStates[terminalBlock.EntityId] = new BlockState(terminalBlock); - } - } - - private void OnPropertyChanged(MyTerminalBlock terminalBlock) - { - // FIXME: Disabled due to bad performance. Collect changes and deliver periodically. - return; - - if (terminalBlock.Closed) - return; - - var entityId = terminalBlock.EntityId; - - blockStates[entityId] = new BlockState(terminalBlock); - - if (HasBound()) - { - Browser?.ExecuteScriptAsync("blockStateUpdated('" + entityId + "');"); - } - } - - // Methods to call from JS to change blocks - - public void ModifyBlock(long entityId, string name, string customData) - { - var block = terminalBlocks[entityId]; - if (block.Closed) - return; - - block.Name = name; - block.CustomData = customData; - blockStates[entityId] = new BlockState(block); - - if (HasBound()) - { - Browser?.ExecuteScriptAsync("blockStateUpdated('" + entityId + "');"); - } - } - - public void ModifyBlockAttribute(long entityId, string propertyId, object value) - { - var block = terminalBlocks[entityId]; - if (block.Closed) - return; - - var property = block.GetProperty(propertyId); - - switch (value) - { - case bool boolValue: - property.AsBool().SetValue(block, boolValue); - break; - - case long intValue: - property.As().SetValue(block, intValue); - break; - - case float floatValue: - property.AsFloat().SetValue(block, floatValue); - break; - } - - blockStates[entityId] = new BlockState(block); - - if (HasBound()) - { - Browser?.ExecuteScriptAsync("blockStateUpdated('" + entityId + "');"); - } - } - } -} \ No newline at end of file diff --git a/EnhancedUI/Gui/Terminal/ControlPanel/MyGuiScreenTerminal_Patch.cs b/EnhancedUI/Gui/Terminal/ControlPanel/MyGuiScreenTerminal_Patch.cs index 513e5f1..0079b60 100644 --- a/EnhancedUI/Gui/Terminal/ControlPanel/MyGuiScreenTerminal_Patch.cs +++ b/EnhancedUI/Gui/Terminal/ControlPanel/MyGuiScreenTerminal_Patch.cs @@ -15,7 +15,6 @@ internal static class MyGuiScreenTerminal_Patch { private const string Name = "ControlPanel"; private static readonly WebContent Content = new(); - // private static ChromiumGuiControl? currentControl; [HarmonyPatch("CreateControlPanelPageControls")] [HarmonyPrefix] @@ -31,8 +30,7 @@ private static bool CreateControlPanelPageControlsPrefix( page.TextEnum = MySpaceTexts.ControlPanel; page.TextScale = 0.7005405f; - var state = new ControlPanelState(); - var control = new ChromiumGuiControl(Content, Name, state) + var control = new ChromiumGuiControl(Content, Name) { Position = new Vector2(0f, 0.005f), Size = new Vector2(0.9f, 0.7f) diff --git a/EnhancedUI/Gui/Terminal/ControlPanel/MyTerminalControlPanel_Close_Patch.cs b/EnhancedUI/Gui/Terminal/ControlPanel/MyTerminalControlPanel_Close_Patch.cs index 602e7d9..2681f8f 100644 --- a/EnhancedUI/Gui/Terminal/ControlPanel/MyTerminalControlPanel_Close_Patch.cs +++ b/EnhancedUI/Gui/Terminal/ControlPanel/MyTerminalControlPanel_Close_Patch.cs @@ -1,5 +1,6 @@ using System; using System.Reflection; +using EnhancedUI.ViewModel; using HarmonyLib; namespace EnhancedUI.Gui.Terminal.ControlPanel diff --git a/EnhancedUI/Gui/Terminal/ControlPanel/MyTerminalControlPanel_Init_Patch.cs b/EnhancedUI/Gui/Terminal/ControlPanel/MyTerminalControlPanel_Init_Patch.cs index 6a40ff2..95c3981 100644 --- a/EnhancedUI/Gui/Terminal/ControlPanel/MyTerminalControlPanel_Init_Patch.cs +++ b/EnhancedUI/Gui/Terminal/ControlPanel/MyTerminalControlPanel_Init_Patch.cs @@ -1,5 +1,6 @@ using System; using System.Reflection; +using EnhancedUI.ViewModel; using HarmonyLib; using Sandbox.Game.Entities.Cube; using Sandbox.Game.Gui; @@ -23,7 +24,7 @@ private static bool Prefix() { if (MyGuiScreenTerminal.InteractedEntity is MyTerminalBlock block) { - ControlPanelState.Instance?.Init(block); + TerminalViewModel.Instance?.Connect(block); } return false; } diff --git a/EnhancedUI/Gui/Terminal/Inventory/InventoryState.cs b/EnhancedUI/Gui/Terminal/Inventory/InventoryState.cs deleted file mode 100644 index b0bba5e..0000000 --- a/EnhancedUI/Gui/Terminal/Inventory/InventoryState.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Sandbox.Game.Entities.Cube; - -namespace EnhancedUI.Gui.Terminal.Inventory -{ - public class InventoryState: PanelState - { - public static InventoryState? Instance; - - public InventoryState() - { - Instance = this; - } - - public void Init(MyTerminalBlock block) - { - // TODO - } - } -} \ No newline at end of file diff --git a/EnhancedUI/Gui/Terminal/Inventory/MyGuiScreenTerminal_Patch.cs b/EnhancedUI/Gui/Terminal/Inventory/MyGuiScreenTerminal_Patch.cs index a118d83..849418d 100644 --- a/EnhancedUI/Gui/Terminal/Inventory/MyGuiScreenTerminal_Patch.cs +++ b/EnhancedUI/Gui/Terminal/Inventory/MyGuiScreenTerminal_Patch.cs @@ -30,8 +30,7 @@ private static bool CreateInventoryPageControlsPrefix( page.TextEnum = MySpaceTexts.Inventory; page.TextScale = 0.7005405f; - var state = new InventoryState(); - var control = new ChromiumGuiControl(Content, Name, state) + var control = new ChromiumGuiControl(Content, Name) { Position = new Vector2(0f, 0.005f), Size = new Vector2(0.9f, 0.7f) diff --git a/EnhancedUI/Main.cs b/EnhancedUI/Main.cs index 8652fc3..80038f4 100644 --- a/EnhancedUI/Main.cs +++ b/EnhancedUI/Main.cs @@ -2,6 +2,7 @@ using System.Reflection; using CefSharp; using CefSharp.OffScreen; +using EnhancedUI.ViewModel; using HarmonyLib; using VRage.FileSystem; using VRage.Plugins; @@ -11,6 +12,9 @@ namespace EnhancedUI // ReSharper disable once UnusedType.Global public class Main : IPlugin { + // Single instance of the view model, reused for all browser instances + private readonly TerminalViewModel model = new(); + public void Dispose() { Cef.Shutdown(); @@ -34,6 +38,7 @@ public void Init(object gameInstance) public void Update() { + model.Update(); } } } \ No newline at end of file diff --git a/EnhancedUI/Utils/LinearAlgebraExtensions.cs b/EnhancedUI/Utils/LinearAlgebraExtensions.cs new file mode 100644 index 0000000..e89d853 --- /dev/null +++ b/EnhancedUI/Utils/LinearAlgebraExtensions.cs @@ -0,0 +1,12 @@ +using VRageMath; + +namespace EnhancedUI.Utils +{ + public static class LinearAlgebraExtensions + { + public static int[] ToArray(this Vector3I v) + { + return new[] { v.X, v.Y, v.Z }; + } + } +} \ No newline at end of file diff --git a/EnhancedUI/Utils/Tracker.cs b/EnhancedUI/Utils/Tracker.cs new file mode 100644 index 0000000..5cd2ecf --- /dev/null +++ b/EnhancedUI/Utils/Tracker.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Threading; + +namespace EnhancedUI.Utils +{ + // Tracker to allow concurrently collect items, + // then atomically processing the collected ones + public class Tracker + { + private readonly Mutex mutex = new(); + private readonly HashSet items = new(); + + public Context Process() + { + return new Context(this); + } + + public void Add(T item) + { + mutex.WaitOne(); + try + { + items.Add(item); + } + finally + { + mutex.ReleaseMutex(); + } + } + + public int Count + { + get + { + mutex.WaitOne(); + try + { + return items.Count; + } + finally + { + mutex.ReleaseMutex(); + } + } + } + + public class Context : IDisposable + { + private readonly Tracker tracker; + + public HashSet Items => tracker.items; + + public Context(Tracker tracker) + { + this.tracker = tracker; + tracker.mutex.WaitOne(); + } + + public void Dispose() + { + tracker.items.Clear(); + tracker.mutex.ReleaseMutex(); + } + } + } +} \ No newline at end of file diff --git a/EnhancedUI/ViewModel/BlockViewModel.cs b/EnhancedUI/ViewModel/BlockViewModel.cs new file mode 100644 index 0000000..de827af --- /dev/null +++ b/EnhancedUI/ViewModel/BlockViewModel.cs @@ -0,0 +1,236 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using EnhancedUI.Utils; +using Sandbox.Game.Entities.Cube; +using Sandbox.ModAPI.Interfaces; +using VRage.Utils; + +namespace EnhancedUI.ViewModel +{ + // Block view model passed to JavaScript + public class BlockViewModel : IDisposable + { + private static long nextId; + + // Owning block view model + private readonly TerminalViewModel terminalModel; + + // Terminal block + private readonly MyTerminalBlock block; + + // Identification + public long Id { get; } + public override int GetHashCode() => Id.GetHashCode(); + + // State version + // ReSharper disable once MemberCanBePrivate.Global + public long Version { get; private set; } + + // Terminal property view models for the block + // ReSharper disable once MemberCanBePrivate.Global + public readonly Dictionary Properties = new(); + + // True as long as the block is valid (not closed), + // set to false if a block is destroyed + // ReSharper disable once UnusedAutoPropertyAccessor.Global + // ReSharper disable once MemberCanBePrivate.Global + public bool IsValid { get; private set; } + + // True if the block is functional + // ReSharper disable once UnusedAutoPropertyAccessor.Global + // ReSharper disable once MemberCanBePrivate.Global + public bool IsFunctional { get; private set; } + + // Name of the block (used editable) + // ReSharper disable once MemberCanBePrivate.Global + public string Name { get; set; } + + // Custom data (user editable) + // ReSharper disable once MemberCanBePrivate.Global + public string CustomData { get; set; } + + // Detailed information may be provided by some of the blocks (read-only) + // ReSharper disable once UnusedAutoPropertyAccessor.Global + // ReSharper disable once MemberCanBePrivate.Global + public string DetailedInfo { get; private set; } + + // Block type + // ReSharper disable once UnusedMember.Global + // ReSharper disable once MemberCanBePrivate.Global + public string ClassName => block.GetType().Name; + // ReSharper disable once UnusedMember.Global + // ReSharper disable once MemberCanBePrivate.Global + public string TypeId => block.BlockDefinition.Id.TypeId.ToString(); + // ReSharper disable once UnusedMember.Global + // ReSharper disable once MemberCanBePrivate.Global + public string SubtypeName => block.BlockDefinition.Id.SubtypeName; + + // Geometry + // ReSharper disable once UnusedMember.Global + public int[] Position => block.Position.ToArray(); + // ReSharper disable once UnusedMember.Global + public int[] Size => block.BlockDefinition.Size.ToArray(); + + public override string ToString() => $"BlockViewModel #{Id} at {block.Position} is {SubtypeName} \"{Name}\""; + +#pragma warning disable 8618 + public BlockViewModel(TerminalViewModel terminalViewModel, MyTerminalBlock terminalBlock, long version) + { + Id = Interlocked.Increment(ref nextId); + + terminalModel = terminalViewModel; + block = terminalBlock; + Version = version; + + MyLog.Default.Info($"EnhancedUI: {this}"); + + UpdateFields(version); + CreatePropertyModels(); + + block.PropertiesChanged += OnPropertyChanged; + } +#pragma warning restore 8618 + + private bool UpdateFields(long version) + { + var changed = false; + + var isValid = !block.Closed && block.InScene && !block.IsPreview; + if (isValid != IsValid) + { + IsValid = isValid; + changed = true; + } + + var isFunctional = block.IsFunctional; + if (isFunctional != IsFunctional) + { + IsFunctional = isFunctional; + changed = true; + } + + var name = block.CustomName.ToString(); + if (name == "") + name = block.DisplayNameText ?? block.DisplayName; + if (name != Name) + { + Name = name; + changed = true; + } + + if (CustomData != block.CustomData) + { + CustomData = block.CustomData; + changed = true; + } + + var detailedInfo = block.DetailedInfo.ToString(); + if (detailedInfo != DetailedInfo) + { + DetailedInfo = detailedInfo; + changed = true; + } + + if (changed) + Version = version; + + return changed; + } + + private void CreatePropertyModels() + { + var terminalProperties = new List(); + block.GetProperties(terminalProperties); + foreach (var terminalProperty in terminalProperties) + { + Properties[terminalProperty.Id] = new PropertyViewModel(block, terminalProperty); + } + } + + public void Dispose() + { + block.PropertiesChanged -= OnPropertyChanged; + + Properties.Clear(); + } + + private void OnPropertyChanged(MyTerminalBlock obj) + { + terminalModel.NotifyGameModifiedBlock(Id); + } + + // Updates model from game state, returns true if anything has changed + public bool Update(long version) + { + var changed = UpdateFields(version); + return UpdateProperties(version) || changed; + } + + private bool UpdateProperties(long version) + { + var changed = false; + foreach (var property in Properties.Values) + changed = property.Update(block) || changed; + + if (changed) + Version = version; + + return changed; + } + + // Applies model changes to game state, returns true if anything has changed + public bool Apply(long version) + { + var changed = false; + + var defaultName = block.DisplayNameText ?? block.DisplayName; + if (Name != defaultName && Name != block.CustomName.ToString()) + { + block.CustomName.Clear(); + block.CustomName.Append(Name); + changed = true; + } + + if (CustomData != block.CustomData.ToString()) + { + block.CustomData = CustomData; + changed = true; + } + + foreach (var property in Properties.Values) + changed = property.Apply(block) || changed; + + if (changed) + Version = version; + + return changed; + } + + public void SetName(string name) + { + Name = name; + NotifyChange(); + } + + public void SetCustomData(string customData) + { + CustomData = customData; + NotifyChange(); + } + + public void SetProperty(string propertyId, object? value) + { + if (!Properties.TryGetValue(propertyId, out var property)) + return; + + property.Value = value; + NotifyChange(); + } + + private void NotifyChange() + { + terminalModel.NotifyUserModifiedBlock(Id); + } + } +} \ No newline at end of file diff --git a/EnhancedUI/ViewModel/ITerminalViewModel.cs b/EnhancedUI/ViewModel/ITerminalViewModel.cs new file mode 100644 index 0000000..a5c552a --- /dev/null +++ b/EnhancedUI/ViewModel/ITerminalViewModel.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; + +namespace EnhancedUI.ViewModel +{ + // JavaScript API of the ViewModel + public interface ITerminalViewModel + { + // TODO: Grid API + // TODO: Provide a way to query the ID of the interacted block. + + // Returns list of IDs of blocks the player have access to via the interacted block. + // Returns empty list if the player is not connected to a terminal port. + List GetBlockIds(); + + // Returns list of IDs of blocks have got any modifications since the given version + List GetModifiedBlockIds(long sinceVersion); + + // Retrieves the view model of the specific block. + // Returns null if the player is not connected to a terminal port. + BlockViewModel? GetBlockState(long blockId); + + // Modifies a block's Name, actual modification will happen on the next game update + void SetBlockName(long blockId, string name); + + // Modifies a block's CustomData, actual modification will happen on the next game update + void SetBlockCustomData(long blockId, string customData); + + // Modified a property value, actual modification will happen on the next game update + void SetBlockProperty(long blockId, string propertyId, object? value); + + // Returns the named groups of blocks the player have access to via the interacted block. + // Returns an empty dictionary if the player is not connected to a terminal port. + Dictionary> GetGroups(); + + // Returns the list of group names a specific block is member of + List GetBlockGroups(); + + // Enqueues adding a block to a named group + void AddBlockToGroup(long blockId, string groupName); + + // Enqueues removing a block from a named group + void RemoveBlockFromGroup(long blockId, string groupName); + } +} \ No newline at end of file diff --git a/EnhancedUI/ViewModel/PropertyViewModel.cs b/EnhancedUI/ViewModel/PropertyViewModel.cs new file mode 100644 index 0000000..a310237 --- /dev/null +++ b/EnhancedUI/ViewModel/PropertyViewModel.cs @@ -0,0 +1,124 @@ +using System.Text; +using Sandbox.Game.Entities.Cube; +using Sandbox.ModAPI.Interfaces; +using VRageMath; + +namespace EnhancedUI.ViewModel +{ + // Property view model passed to JavaScript + public class PropertyViewModel + { + private readonly ITerminalProperty property; + + // ReSharper disable once MemberCanBePrivate.Global + public object? Value { get; set; } + + // ReSharper disable once MemberCanBePrivate.Global + public string Id => property.Id; + + // ReSharper disable once MemberCanBePrivate.Global + // ReSharper disable once UnusedAutoPropertyAccessor.Global + public string TypeName => property.TypeName; + + public override int GetHashCode() => Id.GetHashCode(); + + public PropertyViewModel(MyTerminalBlock block, ITerminalProperty terminalProperty) + { + property = terminalProperty; + Update(block); + } + + // Updates value from in-game property + public bool Update(MyTerminalBlock block) + { + var value = Read(block, property); + if (value == Value) + return false; + + Value = value; + return true; + } + + // Applies the value to the in-game property + public bool Apply(MyTerminalBlock block) + { + return Write(block, property, Value); + } + + private static object? Read(MyTerminalBlock block, ITerminalProperty property) + { + switch (property.TypeName) + { + case "Boolean": + return property.AsBool().GetValue(block); + + case "Single": + return property.AsFloat().GetValue(block); + + case "Int64": + return property.As().GetValue(block); + + case "StringBuilder": + return property.As().GetValue(block).ToString(); + + case "Color": + return FormatColor(property.As().GetValue(block)); + } + + return null; + } + + private static bool Write(MyTerminalBlock block, ITerminalProperty property, object? value) + { + if (value == Read(block, property)) + return false; + + switch (property.TypeName) + { + case "Boolean": + property.AsBool().SetValue(block, value as bool? ?? property.AsBool().GetDefaultValue(block)); + return true; + + case "Single": + property.AsFloat().SetValue(block, value as float? ?? property.AsFloat().GetDefaultValue(block)); + return true; + + case "Int64": + property.As().SetValue(block, value as long? ?? property.As().GetDefaultValue(block)); + return true; + + case "StringBuilder": + property.As().SetValue(block, + value == null + ? property.As().GetDefaultValue(block) + : new StringBuilder(value as string ?? "")); + return true; + + case "Color": + property.As().SetValue(block, + value == null + ? property.As().GetDefaultValue(block) + : ParseColor(value as string ?? "")); + return true; + } + + return false; + } + + private static string FormatColor(Color c) + { + return $"#{c.R:X02}{c.G:X02}{c.B:X02}"; + } + + private static Color ParseColor(string c) + { + if (c.Length != 7 || !c.StartsWith("#")) + return Color.Black; + + var r = int.Parse(c.Substring(1, 2), System.Globalization.NumberStyles.HexNumber); + var g = int.Parse(c.Substring(3, 2), System.Globalization.NumberStyles.HexNumber); + var b = int.Parse(c.Substring(5, 2), System.Globalization.NumberStyles.HexNumber); + return new Color(r, g, b); + } + } +} \ No newline at end of file diff --git a/EnhancedUI/ViewModel/TerminalViewModel.cs b/EnhancedUI/ViewModel/TerminalViewModel.cs new file mode 100644 index 0000000..554b737 --- /dev/null +++ b/EnhancedUI/ViewModel/TerminalViewModel.cs @@ -0,0 +1,270 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using EnhancedUI.Utils; +using Sandbox.Game.Entities.Cube; +using VRage.Utils; + +namespace EnhancedUI.ViewModel +{ + public class TerminalViewModel : ITerminalViewModel, IDisposable + { + // Model is a singleton + public static TerminalViewModel? Instance; + + // Logical clock, model state versions for browser synchronization + private readonly object latestVersionLock = new(); + private long latestVersion; + + // Event triggered on new game state versions + public delegate void OnGameStateChangedHandler(long version); + + public event OnGameStateChangedHandler? OnGameStateChanged; + + // View model of reachable blocks by ID + private readonly Dictionary blocks = new(); + + // Set of IDs of blocks modified by the game + private readonly Tracker blocksModifiedByGame = new(); + + // Set of IDs of blocks modified by the user + private readonly Tracker blocksModifiedByUser = new(); + + // Terminal block the player interacts with + // Set to null if the player is not connected to any grids + private MyTerminalBlock? interactedBlock; + + // True if the player is connected to a terminal system + private bool IsConnected => interactedBlock?.IsFunctional == true; + + public TerminalViewModel() + { + if (Instance != null) + throw new Exception("This is a singleton"); + + Instance = this; + } + + public void Dispose() + { + Clear(); + Instance = null; + } + + // Called when the user connects to a terminal block + public void Connect(MyTerminalBlock block) + { + if (interactedBlock == block) + return; + + Clear(); + + interactedBlock = block; + + CreateBlockViewModels(); + + // TODO: Listen on grid modifications (add/remove blocks) and splits! + // TODO: Collect named groups! + } + + // Called to clear the current view model on closing the terminal + private void Clear() + { + if (interactedBlock == null) + return; + + lock (blocks) + { + foreach (var block in blocks.Values) + { + block.Dispose(); + } + + blocks.Clear(); + + interactedBlock = null; + } + } + + private void CreateBlockViewModels() + { + lock (blocks) + { + blocks.Clear(); + + if (interactedBlock == null || !IsConnected) + return; + + var version = GetNextVersion(); + foreach (var block in interactedBlock.CubeGrid.GridSystems.TerminalSystem.Blocks) + { + var blockViewModel = new BlockViewModel(this, block, version); + blocks[blockViewModel.Id] = blockViewModel; + } + } + } + + private long GetNextVersion() + { + lock (latestVersionLock) + { + return ++latestVersion; + } + } + + internal void NotifyGameModifiedBlock(long blockId) + { + if (blocks.ContainsKey(blockId)) + blocksModifiedByGame.Add(blockId); + } + + internal void NotifyUserModifiedBlock(long blockId) + { + if (blocks.ContainsKey(blockId)) + blocksModifiedByUser.Add(blockId); + } + + // Called on game updates + internal void Update() + { + if (!IsConnected) + { + Clear(); + return; + } + + bool changed; + lock (blocks) + { + var version = GetNextVersion(); + changed = UpdateGameModifiedBlocks(version); + changed = ApplyUserModifications(version) || changed; + } + + if (changed) + { + MyLog.Default.Debug($"EnhancedUI: OnGameStateChanged({latestVersion})"); + OnGameStateChanged?.Invoke(latestVersion); + } + } + + private bool ApplyUserModifications(long version) + { + var changed = false; + + using var context = blocksModifiedByUser.Process(); + foreach (var blockId in context.Items) + { + if (!blocks.TryGetValue(blockId, out var block)) + continue; + + changed = block.Apply(version) || changed; + } + + return changed; + } + + private bool UpdateGameModifiedBlocks(long version) + { + using var context = blocksModifiedByGame.Process(); + var changed = false; + foreach (var blockId in context.Items) + { + if (!blocks.TryGetValue(blockId, out var block)) + continue; + + changed = block.Update(version) || changed; + } + + return changed; + } + + #region JavaScript API + + public List GetBlockIds() + { + lock (blocks) + { + return blocks.Values.Select(b => b.Id).ToList(); + } + } + + public List GetModifiedBlockIds(long sinceVersion) + { + lock (blocks) + { + var blockIds =blocks.Values.Where(b => b.Version > sinceVersion).Select(b => b.Id).ToList(); + MyLog.Default.Debug($"EnhancedUI: GetModifiedBlockIds({sinceVersion}) => {blockIds.Count} blocks"); + return blockIds; + } + } + + public BlockViewModel? GetBlockState(long blockId) + { + lock (blocks) + { + var blockViewModel = blocks.GetValueOrDefault(blockId); + MyLog.Default.Debug( + blockViewModel == null + ? $"EnhancedUI: GetBlockState({blockId}) => NOT FOUND" + : $"EnhancedUI: GetBlockState({blockId}) => {blockViewModel}"); + return blockViewModel; + } + } + + public void SetBlockName(long blockId, string name) + { + lock (blocks) + { + if (!blocks.TryGetValue(blockId, out var block)) + return; + + block.SetName(name); + } + } + + public void SetBlockCustomData(long blockId, string customData) + { + lock (blocks) + { + if (!blocks.TryGetValue(blockId, out var block)) + return; + + block.SetCustomData(customData); + } + } + + public void SetBlockProperty(long blockId, string propertyId, object? value) + { + lock (blocks) + { + if (!blocks.TryGetValue(blockId, out var block)) + return; + + block.SetProperty(propertyId, value); + } + } + + public Dictionary> GetGroups() + { + throw new NotImplementedException(); + } + + public List GetBlockGroups() + { + throw new NotImplementedException(); + } + + public void AddBlockToGroup(long blockId, string groupName) + { + throw new NotImplementedException(); + } + + public void RemoveBlockFromGroup(long blockId, string groupName) + { + throw new NotImplementedException(); + } + + #endregion + } +} \ No newline at end of file