/* * Copyright 2022 Peter Han * Permission is hereby granted, free of charge, to any person obtaining a copy of this software * and associated documentation files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, copy, modify, merge, publish, * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all copies or * substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ using System; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; namespace PeterHan.PLib.UI { /// /// A dialog root for UI components. /// public sealed class PDialog : IUIComponent { /// /// The margin around dialog buttons. /// public static readonly RectOffset BUTTON_MARGIN = new RectOffset(13, 13, 13, 13); /// /// The margin inside the dialog close button. /// private static readonly RectOffset CLOSE_ICON_MARGIN = new RectOffset(4, 4, 4, 4); /// /// The size of the dialog close button's icon. /// private static readonly Vector2 CLOSE_ICON_SIZE = new Vector2f(16.0f, 16.0f); /// /// The dialog key returned if the user closes the dialog with [ESC] or the X. /// public const string DIALOG_KEY_CLOSE = "close"; /// /// Returns a suitable parent object for a dialog. /// /// A game object that can be used as a dialog parent depending on the game /// stage, or null if none is available. public static GameObject GetParentObject() { GameObject parent = null; var fi = FrontEndManager.Instance; if (fi != null) parent = fi.gameObject; else { // Grr unity var gi = GameScreenManager.Instance; if (gi != null) parent = gi.ssOverlayCanvas; else PUIUtils.LogUIWarning("No dialog parent found!"); } return parent; } /// /// Rounds the size up to the nearest even integer. /// /// The current size. /// The maximum allowed size. /// The rounded size. private static float RoundUpSize(float size, float maxSize) { int upOne = Mathf.CeilToInt(size); if (upOne % 2 == 1) upOne++; if (upOne > maxSize && maxSize > 0.0f) upOne -= 2; return upOne; } /// /// The dialog body panel. To add custom components to the dialog, use AddChild on /// this panel. Its direction, margin, and spacing can also be customized. /// public PPanel Body { get; } /// /// The background color of the dialog itself (including button panel). /// public Color DialogBackColor { get; set; } /// /// The dialog's maximum size. If the dialog preferred size is bigger than this size, /// the dialog will be decreased in size to fit. If either axis is zero, the dialog /// gets its preferred size in that axis, at least the value in Size. /// public Vector2 MaxSize { get; set; } public string Name { get; } /// /// The dialog's parent. /// public GameObject Parent { get; set; } /// /// If a dialog with an odd width/height is displayed, all offsets will end up on a /// half pixel offset, which may cause unusual display artifacts as Banker's Rounding /// will round values that are supposed to be 1.0 units apart into integer values 2 /// units apart. If set, this flag will cause Build to round the dialog's size up to /// the nearest even integer. If the dialog is already at its maximum size and is still /// an odd integer in size, it is rounded down one instead. /// public bool RoundToNearestEven { get; set; } /// /// The dialog's minimum size. If the dialog preferred size is bigger than this size, /// the dialog will be increased in size to fit. If either axis is zero, the dialog /// gets its preferred size in that axis, up until the value in MaxSize. /// public Vector2 Size { get; set; } /// /// The dialog sort order which determines which other dialogs this one is on top of. /// public float SortKey { get; set; } /// /// The dialog's title. /// public string Title { get; set; } /// /// The allowable button choices for the dialog. /// private readonly ICollection buttons; /// /// The events to invoke when the dialog is closed. /// public PUIDelegates.OnDialogClosed DialogClosed { get; set; } public event PUIDelegates.OnRealize OnRealize; public PDialog(string name) { Body = new PPanel("Body") { Alignment = TextAnchor.UpperCenter, FlexSize = Vector2.one, Margin = new RectOffset(6, 6, 6, 6) }; DialogBackColor = PUITuning.Colors.ButtonBlueStyle.inactiveColor; buttons = new List(4); MaxSize = Vector2.zero; Name = name ?? "Dialog"; // First try the front end manager (menu), then the in-game (game) Parent = GetParentObject(); RoundToNearestEven = false; Size = Vector2.zero; SortKey = 0.0f; Title = "Dialog"; } /// /// Adds a button to the dialog. The button will use a blue background with white text /// in the default UI font, except for the last button which will be pink. /// /// The key to report if this button is selected. /// The button text. /// The tooltip to display on the button (optional) /// This dialog for call chaining. public PDialog AddButton(string key, string text, string tooltip = null) { buttons.Add(new DialogButton(key, text, tooltip, null, null)); return this; } /// /// Adds a button to the dialog. /// /// The key to report if this button is selected. /// The button text. /// The tooltip to display on the button (optional) /// The background color to use for the button. If null or /// omitted, the last button will be pink and all others will be blue. /// The foreground color to use for the button. If null or /// omitted, white text with the default game UI font will be used. /// This dialog for call chaining. public PDialog AddButton(string key, string text, string tooltip = null, ColorStyleSetting backColor = null, TextStyleSetting foreColor = null) { buttons.Add(new DialogButton(key, text, tooltip, backColor, foreColor)); return this; } /// /// Adds a handler when this dialog is realized. /// /// The handler to invoke on realization. /// This dialog for call chaining. public PDialog AddOnRealize(PUIDelegates.OnRealize onRealize) { OnRealize += onRealize; return this; } public GameObject Build() { if (Parent == null) throw new InvalidOperationException("Parent for dialog may not be null"); var dialog = PUIElements.CreateUI(Parent, Name); var dComponent = dialog.AddComponent(); // Background dialog.AddComponent(); dialog.AddComponent(); var bg = dialog.AddComponent(); bg.color = DialogBackColor; bg.sprite = PUITuning.Images.BoxBorder; bg.type = Image.Type.Sliced; // Add each component var layout = dialog.AddComponent(); layout.AddRow(new GridRowSpec()); layout.AddRow(new GridRowSpec(flex: 1.0f)); layout.AddRow(new GridRowSpec()); layout.AddColumn(new GridColumnSpec(flex: 1.0f)); layout.AddColumn(new GridColumnSpec()); LayoutTitle(layout, dComponent.DoButton); layout.AddComponent(Body.Build(), new GridComponentSpec(1, 0) { ColumnSpan = 2, Margin = new RectOffset(10, 10, 10, 10) }); CreateUserButtons(layout, dComponent.DoButton); // Configure body position SetDialogSize(dialog); // Dialog is realized dComponent.dialog = this; dComponent.sortKey = SortKey; OnRealize?.Invoke(dialog); return dialog; } /// /// Creates the user buttons. /// /// The location to add the buttons. /// The handler to call when any button is pressed. private void CreateUserButtons(PGridLayoutGroup layout, PUIDelegates.OnButtonPressed onPressed) { var buttonPanel = new PPanel("Buttons") { Alignment = TextAnchor.LowerCenter, Spacing = 7, Direction = PanelDirection. Horizontal, Margin = new RectOffset(5, 5, 0, 10) }; int i = 0; // Add each user button foreach (var button in buttons) { string key = button.key; var bgColor = button.backColor; var fgColor = button.textColor ?? PUITuning.Fonts.UILightStyle; var db = new PButton(key) { Text = button.text, ToolTip = button.tooltip, Margin = BUTTON_MARGIN, OnClick = onPressed, Color = bgColor, TextStyle = fgColor }; // Last button is special and gets a pink color if (bgColor == null) { if (++i >= buttons.Count) db.SetKleiPinkStyle(); else db.SetKleiBlueStyle(); } buttonPanel.AddChild(db); } layout.AddComponent(buttonPanel.Build(), new GridComponentSpec(2, 0) { ColumnSpan = 2 }); } /// /// Lays out the dialog title bar and close button. /// /// The layout manager for the dialog. /// The action to invoke when close is pressed. private void LayoutTitle(PGridLayoutGroup layout, PUIDelegates.OnButtonPressed onClose) { // Title text, expand to width var title = new PLabel("Title") { Margin = new RectOffset(3, 4, 0, 0), Text = Title, FlexSize = Vector2.one }.SetKleiPinkColor().Build(); layout.AddComponent(title, new GridComponentSpec(0, 0) { Margin = new RectOffset(0, -2, 0, 0) }); // Black border on the title bar overlaps the edge, but 0 margins so should be OK // Fixes the 2px bug handily! var titleBG = title.AddOrGet(); titleBG.sprite = PUITuning.Images.BoxBorder; titleBG.type = Image.Type.Sliced; // Close button layout.AddComponent(new PButton(DIALOG_KEY_CLOSE) { Sprite = PUITuning.Images.Close, Margin = CLOSE_ICON_MARGIN, OnClick = onClose, SpriteSize = CLOSE_ICON_SIZE, ToolTip = STRINGS.UI.TOOLTIPS.CLOSETOOLTIP }.SetKleiBlueStyle().Build(), new GridComponentSpec(0, 1)); } /// /// Sets the final size of the dialog using its current position. /// /// The realized dialog with all components populated. private void SetDialogSize(GameObject dialog) { // Calculate the final dialog size var dialogRT = dialog.rectTransform(); LayoutRebuilder.ForceRebuildLayoutImmediate(dialogRT); float bodyWidth = Math.Max(Size.x, LayoutUtility.GetPreferredWidth(dialogRT)), bodyHeight = Math.Max(Size.y, LayoutUtility.GetPreferredHeight(dialogRT)), maxX = MaxSize.x, maxY = MaxSize.y; // Maximum size constraint if (maxX > 0.0f) bodyWidth = Math.Min(bodyWidth, maxX); if (maxY > 0.0f) bodyHeight = Math.Min(bodyHeight, maxY); if (RoundToNearestEven) { // Round up the size to even integers, even if currently fractional bodyWidth = RoundUpSize(bodyWidth, maxX); bodyHeight = RoundUpSize(bodyHeight, maxY); } dialogRT.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, bodyWidth); dialogRT.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, bodyHeight); } /// /// Builds and shows this dialog. /// public void Show() { if (Build().TryGetComponent(out KScreen screen)) UIDetours.ACTIVATE_KSCREEN.Invoke(screen); } public override string ToString() { return string.Format("PDialog[Name={0},Title={1}]", Name, Title); } /// /// Stores information about a dialog button in this dialog. /// private sealed class DialogButton { /// /// The color to use when displaying the button. If null, the default color will /// be used. /// public readonly ColorStyleSetting backColor; /// /// The button key used to indicate that it was selected. /// public readonly string key; /// /// The text to display for the button. /// public readonly string text; /// /// The color to use when displaying the button text. If null, the default color /// will be used. /// public readonly TextStyleSetting textColor; /// /// The tooltip for this button. /// public readonly string tooltip; internal DialogButton(string key, string text, string tooltip, ColorStyleSetting backColor, TextStyleSetting foreColor) { this.backColor = backColor; this.key = key; this.text = text; this.tooltip = tooltip; textColor = foreColor; } public override string ToString() { return string.Format("DialogButton[key={0:D},text={1:D}]", key, text); } } /// /// The Klei component which backs the dialog. /// private sealed class PDialogComp : KScreen { /// /// The events to invoke when the dialog is closed. /// internal PDialog dialog; /// /// The key selected by the user. /// internal string key; /// /// The sort order of this dialog. /// internal float sortKey; internal PDialogComp() { key = DIALOG_KEY_CLOSE; sortKey = 0.0f; } /// /// A delegate which closes the dialog on prompt. /// /// The button source. internal void DoButton(GameObject source) { key = source.name; UIDetours.DEACTIVATE_KSCREEN.Invoke(this); } public override float GetSortKey() { return sortKey; } protected override void OnDeactivate() { if (dialog != null) // Klei destroys the dialog GameObject for us dialog.DialogClosed?.Invoke(key); base.OnDeactivate(); dialog = null; } public override void OnKeyDown(KButtonEvent e) { if (e.TryConsume(Action.Escape)) UIDetours.DEACTIVATE_KSCREEN.Invoke(this); else base.OnKeyDown(e); } } } }