using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Text.RegularExpressions; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using MLEM.Formatting; using MLEM.Formatting.Codes; using MLEM.Input; using MLEM.Misc; using MLEM.Textures; using MLEM.Ui.Elements; using MLEM.Ui.Style; namespace MLEM.Ui { /// /// A ui system is the central location for the updating and rendering of all ui s. /// Each element added to the root of the ui system is assigned a that has additional data like a transformation matrix. /// For more information on how ui systems work, check out https://mlem.ellpeck.de/articles/ui.html. /// public class UiSystem : GameComponent { /// /// The viewport that this ui system is rendering inside of. /// This is automatically updated during /// public Rectangle Viewport; /// /// Set this field to true to cause the ui system and all of its elements to automatically scale up or down with greater and lower resolution, respectively. /// If this field is true, is used as the size that uses default /// public bool AutoScaleWithScreen; /// /// If is true, this is used as the screen size that uses the default /// public Point AutoScaleReferenceSize; /// /// The global rendering scale of this ui system and all of its child elements. /// If is true, this scale will be different based on the window size. /// public float GlobalScale { get { if (!this.AutoScaleWithScreen) return this.globalScale; return Math.Min(this.Viewport.Width / (float) this.AutoScaleReferenceSize.X, this.Viewport.Height / (float) this.AutoScaleReferenceSize.Y) * this.globalScale; } set { this.globalScale = value; foreach (var root in this.rootElements) root.Element.ForceUpdateArea(); } } /// /// The style options that this ui system and all of its elements use. /// To set the default, untextured style, use . /// public UiStyle Style { get => this.style; set { this.style = value; foreach (var root in this.rootElements) { root.Element.AndChildren(e => e.System = this); root.Element.ForceUpdateArea(); } } } /// /// The transparency (alpha value) that this ui system and all of its elements draw at. /// public float DrawAlpha = 1; /// /// The blend state that this ui system and all of its elements draw with /// public BlendState BlendState; /// /// The sampler state that this ui system and all of its elements draw with. /// The default is , as that is the one that works best with pixel graphics. /// public SamplerState SamplerState = SamplerState.PointClamp; /// /// The depth stencil state that this ui system and all of its elements draw with. /// The default is , which is also the default for . /// public DepthStencilState DepthStencilState = DepthStencilState.None; /// /// The effect that this ui system and all of its elements draw with. /// The default is null, which means that no custom effect will be used. /// public Effect Effect; /// /// The that this ui system's elements format their text with. /// To add new formatting codes to the ui system, add them to this formatter. /// public TextFormatter TextFormatter; /// /// The that this ui system is controlled by. /// The ui controls are also the place to change bindings for controller and keyboard input. /// public UiControls Controls; /// /// The update and rendering statistics to be used for runtime debugging and profiling. /// The metrics are reset accordingly every frame: is called at the start of , and is called at the start of , or at the start of if was not called. /// public UiMetrics Metrics; /// /// Event that is invoked after an is drawn, but before its children are drawn. /// public event Element.DrawCallback OnElementDrawn; /// /// Event that is invoked after the for each root element is drawn, but before its children are drawn. /// public event Element.DrawCallback OnSelectedElementDrawn; /// /// Event that is invoked when an is updated /// public event Element.TimeCallback OnElementUpdated; /// /// Event that is invoked when an is pressed with the primary action key /// public event Element.GenericCallback OnElementPressed; /// /// Event that is invoked when an is pressed with the secondary action key /// public event Element.GenericCallback OnElementSecondaryPressed; /// /// Event that is invoked when an is newly selected using automatic navigation, or after it has been pressed with the mouse. /// public event Element.GenericCallback OnElementSelected; /// /// Event that is invoked when an is deselected during the selection of a new element. /// public event Element.GenericCallback OnElementDeselected; /// /// Event that is invoked when the mouse enters an /// public event Element.GenericCallback OnElementMouseEnter; /// /// Event that is invoked when the mouse exits an /// public event Element.GenericCallback OnElementMouseExit; /// /// Event that is invoked when an starts being touched /// public event Element.GenericCallback OnElementTouchEnter; /// /// Event that is invoked when an stops being touched /// public event Element.GenericCallback OnElementTouchExit; /// /// Event that is invoked when an 's display area changes /// public event Element.GenericCallback OnElementAreaUpdated; /// /// Event that is invoked when the that the mouse is currently over changes /// public event Element.GenericCallback OnMousedElementChanged; /// /// Event that is invoked when the that is being touched changes /// public event Element.GenericCallback OnTouchedElementChanged; /// /// Event that is invoked when the selected changes, either through automatic navigation, or by pressing on an element with the mouse /// public event Element.GenericCallback OnSelectedElementChanged; /// /// Event that is invoked when a new is added to this ui system /// public event RootCallback OnRootAdded; /// /// Event that is invoked when a is removed from this ui system /// public event RootCallback OnRootRemoved; internal readonly Stopwatch Stopwatch = new Stopwatch(); private readonly List rootElements = new List(); private float globalScale = 1; private bool drewEarly; private UiStyle style; /// /// Creates a new ui system with the given settings. /// /// The game /// The style settings that this ui should have. Use for the default, untextured style. /// The input handler that this ui's should use. If none is supplied, a new input handler is created for this ui. /// If this value is set to true, the ui system's will be set automatically based on the 's size. Defaults to true. public UiSystem(Game game, UiStyle style, InputHandler inputHandler = null, bool automaticViewport = true) : base(game) { this.Controls = new UiControls(this, inputHandler); this.style = style; this.OnElementDrawn += (e, time, batch, alpha) => e.OnDrawn?.Invoke(e, time, batch, alpha); this.OnElementUpdated += (e, time) => e.OnUpdated?.Invoke(e, time); this.OnElementPressed += e => e.OnPressed?.Invoke(e); this.OnElementSecondaryPressed += e => e.OnSecondaryPressed?.Invoke(e); this.OnElementSelected += e => e.OnSelected?.Invoke(e); this.OnElementDeselected += e => e.OnDeselected?.Invoke(e); this.OnElementMouseEnter += e => e.OnMouseEnter?.Invoke(e); this.OnElementMouseExit += e => e.OnMouseExit?.Invoke(e); this.OnElementTouchEnter += e => e.OnTouchEnter?.Invoke(e); this.OnElementTouchExit += e => e.OnTouchExit?.Invoke(e); this.OnElementAreaUpdated += e => e.OnAreaUpdated?.Invoke(e); this.OnMousedElementChanged += e => this.ApplyToAll(t => t.OnMousedElementChanged?.Invoke(t, e)); this.OnTouchedElementChanged += e => this.ApplyToAll(t => t.OnTouchedElementChanged?.Invoke(t, e)); this.OnSelectedElementChanged += e => this.ApplyToAll(t => t.OnSelectedElementChanged?.Invoke(t, e)); this.OnSelectedElementDrawn += (element, time, batch, alpha) => { if (this.Controls.IsAutoNavMode && element.SelectionIndicator.HasValue()) batch.Draw(element.SelectionIndicator, element.DisplayArea, Color.White * alpha, element.Scale / 2); }; this.OnElementPressed += e => { if (e.OnPressed != null) e.ActionSound.Value?.Play(); }; this.OnElementSecondaryPressed += e => { if (e.OnSecondaryPressed != null) e.SecondActionSound.Value?.Play(); }; MlemPlatform.Current?.AddTextInputListener(game.Window, (sender, key, character) => this.ApplyToAll(e => e.OnTextInput?.Invoke(e, key, character))); if (automaticViewport) { this.Viewport = new Rectangle(Point.Zero, game.Window.ClientBounds.Size); this.AutoScaleReferenceSize = this.Viewport.Size; game.Window.ClientSizeChanged += (sender, args) => { this.Viewport = new Rectangle(Point.Zero, game.Window.ClientBounds.Size); foreach (var root in this.rootElements) root.Element.ForceUpdateArea(); }; } this.TextFormatter = new TextFormatter(); this.TextFormatter.Codes.Add(new Regex("]+))?>"), (f, m, r) => new LinkCode(m, r, 1 / 16F, 0.85F, t => this.Controls.MousedElement is Paragraph.Link l1 && l1.Token == t || this.Controls.TouchedElement is Paragraph.Link l2 && l2.Token == t)); this.TextFormatter.Codes.Add(new Regex("]+)>"), (_, m, r) => new FontCode(m, r, f => this.Style.AdditionalFonts != null && this.Style.AdditionalFonts.TryGetValue(m.Groups[1].Value, out var c) ? c : f)); } /// /// Update this ui system, querying the necessary events and updating each element's data. /// /// The game's time public override void Update(GameTime time) { this.Metrics.ResetUpdates(); this.Stopwatch.Restart(); this.Controls.Update(); for (var i = this.rootElements.Count - 1; i >= 0; i--) this.rootElements[i].Element.Update(time); this.Stopwatch.Stop(); this.Metrics.UpdateTime += this.Stopwatch.Elapsed; } /// /// Draws any and other elements that draw onto rather than directly onto the screen. /// For drawing in this manner to work correctly, this method has to be called before your is cleared, and before everything else in your game is drawn. /// /// The game's time /// The sprite batch to use for drawing public void DrawEarly(GameTime time, SpriteBatch batch) { this.Metrics.ResetDraws(); this.Stopwatch.Restart(); foreach (var root in this.rootElements) { if (!root.Element.IsHidden) root.Element.DrawEarly(time, batch, this.DrawAlpha * root.Element.DrawAlpha, this.BlendState, this.SamplerState, this.DepthStencilState, this.Effect, root.Transform); } this.Stopwatch.Stop(); this.Metrics.DrawTime += this.Stopwatch.Elapsed; this.drewEarly = true; } /// /// Draws any s onto the screen. /// Note that, when using s with a scroll bar, needs to be called as well. /// /// The game's time /// The sprite batch to use for drawing public void Draw(GameTime time, SpriteBatch batch) { if (!this.drewEarly) this.Metrics.ResetDraws(); this.Stopwatch.Restart(); foreach (var root in this.rootElements) { if (root.Element.IsHidden) continue; batch.Begin(SpriteSortMode.Deferred, this.BlendState, this.SamplerState, this.DepthStencilState, null, this.Effect, root.Transform); var alpha = this.DrawAlpha * root.Element.DrawAlpha; root.Element.DrawTransformed(time, batch, alpha, this.BlendState, this.SamplerState, this.DepthStencilState, this.Effect, root.Transform); batch.End(); } this.Stopwatch.Stop(); this.Metrics.DrawTime += this.Stopwatch.Elapsed; this.drewEarly = false; } /// /// Adds a new root element to this ui system and returns the newly created . /// Note that, when adding new elements that should be part of the same ui (like buttons on a panel), should be used. /// /// The name of the new root element /// The root element to add /// The newly created root element, or null if an element with the specified name already exists. public RootElement Add(string name, Element element) { if (this.IndexOf(name) >= 0) return null; var root = new RootElement(name, element, this); this.rootElements.Add(root); root.Element.AndChildren(e => { e.Root = root; e.System = this; root.InvokeOnElementAdded(e); e.SetAreaDirty(); }); this.OnRootAdded?.Invoke(root); root.InvokeOnAddedToUi(this); this.SortRoots(); return root; } /// /// Removes the with the specified name, or does nothing if there is no such element. /// /// The name of the root element to remove public void Remove(string name) { var root = this.Get(name); if (root == null) return; this.rootElements.Remove(root); this.Controls.SelectElement(root, null); root.Element.AndChildren(e => { e.Root = null; e.System = null; root.InvokeOnElementRemoved(e); e.SetAreaDirty(); }); this.OnRootRemoved?.Invoke(root); root.InvokeOnRemovedFromUi(this); } /// /// Finds the with the given name. /// /// The root element's name /// The root element with the given name, or null if no such element exists public RootElement Get(string name) { var index = this.IndexOf(name); return index < 0 ? null : this.rootElements[index]; } private int IndexOf(string name) { return this.rootElements.FindIndex(element => element.Name == name); } internal void SortRoots() { // Normal list sorting isn't stable, but ordering is var sorted = this.rootElements.OrderBy(root => root.Priority).ToArray(); this.rootElements.Clear(); this.rootElements.AddRange(sorted); } /// /// Returns an enumerable of all of the instances that this ui system holds. /// /// All of this ui system's root elements public IEnumerable GetRootElements() { for (var i = this.rootElements.Count - 1; i >= 0; i--) yield return this.rootElements[i]; } /// /// Applies the given action to all instances in this ui system recursively. /// Note that, when this method is invoked, all root elements and all of their children receive the . /// /// The action to execute on each element public void ApplyToAll(Action action) { foreach (var root in this.rootElements) root.Element.AndChildren(action); } internal void InvokeOnElementDrawn(Element element, GameTime time, SpriteBatch batch, float alpha) { this.OnElementDrawn?.Invoke(element, time, batch, alpha); } internal void InvokeOnSelectedElementDrawn(Element element, GameTime time, SpriteBatch batch, float alpha) { this.OnSelectedElementDrawn?.Invoke(element, time, batch, alpha); } internal void InvokeOnElementUpdated(Element element, GameTime time) { this.OnElementUpdated?.Invoke(element, time); } internal void InvokeOnElementAreaUpdated(Element element) { this.OnElementAreaUpdated?.Invoke(element); } internal void InvokeOnElementPressed(Element element) { this.OnElementPressed?.Invoke(element); } internal void InvokeOnElementSecondaryPressed(Element element) { this.OnElementSecondaryPressed?.Invoke(element); } internal void InvokeOnElementSelected(Element element) { this.OnElementSelected?.Invoke(element); } internal void InvokeOnElementDeselected(Element element) { this.OnElementDeselected?.Invoke(element); } internal void InvokeOnSelectedElementChanged(Element element) { this.OnSelectedElementChanged?.Invoke(element); } internal void InvokeOnElementMouseExit(Element element) { this.OnElementMouseExit?.Invoke(element); } internal void InvokeOnElementMouseEnter(Element element) { this.OnElementMouseEnter?.Invoke(element); } internal void InvokeOnMousedElementChanged(Element element) { this.OnMousedElementChanged?.Invoke(element); } internal void InvokeOnElementTouchExit(Element element) { this.OnElementTouchExit?.Invoke(element); } internal void InvokeOnElementTouchEnter(Element element) { this.OnElementTouchEnter?.Invoke(element); } internal void InvokeOnTouchedElementChanged(Element element) { this.OnTouchedElementChanged?.Invoke(element); } /// /// A delegate used for callbacks that involve a /// /// The root element public delegate void RootCallback(RootElement root); } /// /// A root element is a wrapper around an that contains additional data. /// Root elements are only used for the element in each element tree that doesn't have a /// To create a new root element, use /// public class RootElement : GenericDataHolder { /// /// The name of this root element /// public readonly string Name; /// /// The element that this root element represents. /// This is the only element in its family tree that does not have a . /// public readonly Element Element; /// /// The that this root element is a part of. /// public readonly UiSystem System; private float scale = 1; /// /// The scale of this root element. /// Note that, to change the scale of every root element, you can use /// public float Scale { get => this.scale; set { if (this.scale == value) return; this.scale = value; this.Element.ForceUpdateArea(); } } private int priority; /// /// The priority of this root element. /// A higher priority means the element will be updated first, as well as rendered on top. /// public int Priority { get => this.priority; set { this.priority = value; this.System.SortRoots(); } } /// /// The actual scale of this root element. /// This is a combination of this root element's as well as the ui system's . /// public float ActualScale => this.System.GlobalScale * this.Scale; /// /// The transformation that this root element (and all of its children) has. /// This transform is applied both to input, as well as to rendering. /// public Matrix Transform = Matrix.Identity; /// /// An inversion of /// public Matrix InvTransform => Matrix.Invert(this.Transform); /// /// The child element of this root element that is currently selected. /// If there is no selected element in this root, this value will be null. /// public Element SelectedElement => this.System.Controls.GetSelectedElement(this); /// /// Determines whether this root element contains any children that . /// This value is automatically calculated. /// public bool CanSelectContent { get; private set; } /// /// Event that is invoked when a is added to this root element or any of its children. /// public event Element.GenericCallback OnElementAdded; /// /// Event that is invoked when a is removed rom this root element of any of its children. /// public event Element.GenericCallback OnElementRemoved; /// /// Event that is invoked when this gets added to a in /// public event Action OnAddedToUi; /// /// Event that is invoked when this gets removed from a in /// public event Action OnRemovedFromUi; internal RootElement(string name, Element element, UiSystem system) { this.Name = name; this.Element = element; this.System = system; this.OnElementAdded += e => { if (e.CanBeSelected) this.CanSelectContent = true; }; this.OnElementRemoved += e => { if (e.CanBeSelected) { // check if removing this element removed all other selectable elements foreach (var c in this.Element.GetChildren(regardGrandchildren: true)) { if (c.CanBeSelected) return; } this.CanSelectContent = false; } }; } /// /// Selects the given element that is a child of this root element. /// Optionally, automatic navigation can be forced on, causing the to be drawn around the element. /// /// The element to select, or null to deselect the selected element. /// Whether automatic navigation should be forced on public void SelectElement(Element element, bool? autoNav = null) { this.System.Controls.SelectElement(this, element, autoNav); } /// /// Scales this root element's matrix based on the given scale and origin. /// /// The scale to use /// The origin to use for scaling, or null to use this root's element's center point public void ScaleOrigin(float scale, Vector2? origin = null) { this.Transform = Matrix.CreateScale(scale, scale, 1) * Matrix.CreateTranslation(new Vector3((1 - scale) * (origin ?? this.Element.DisplayArea.Center), 0)); } internal void InvokeOnElementAdded(Element element) { this.OnElementAdded?.Invoke(element); } internal void InvokeOnElementRemoved(Element element) { this.OnElementRemoved?.Invoke(element); } internal void InvokeOnAddedToUi(UiSystem system) { this.OnAddedToUi?.Invoke(system); } internal void InvokeOnRemovedFromUi(UiSystem system) { this.OnRemovedFromUi?.Invoke(system); } } }