Initial commit

This commit is contained in:
2022-12-30 00:58:33 +01:00
commit 3f1075e67f
176 changed files with 25715 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
/*
* 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 PeterHan.PLib.UI;
using System;
using UnityEngine;
namespace PeterHan.PLib.Options {
/// <summary>
/// An options entry that displays a button. Not intended to be serializable to the
/// options file, instead declare a read-only property that returns a handler method as
/// an Action in the settings class, e.g:
///
/// [Option("Click Here!", "Button tool tip")]
/// public System.Action&lt;object&gt; MyButton => Handler;
///
/// public void Handler() {
/// // ...
/// }
/// </summary>
public class ButtonOptionsEntry : OptionsEntry {
public override object Value {
get {
return value;
}
set {
if (value is Action<object> newValue)
this.value = newValue;
}
}
/// <summary>
/// The action to invoke when the button is pushed.
/// </summary>
private Action<object> value;
public ButtonOptionsEntry(string field, IOptionSpec spec) : base(field, spec) { }
public override void CreateUIEntry(PGridPanel parent, ref int row) {
parent.AddChild(new PButton(Field) {
Text = LookInStrings(Title), ToolTip = LookInStrings(Tooltip),
OnClick = OnButtonClicked
}.SetKleiPinkStyle(), new GridComponentSpec(row, 0) {
Margin = CONTROL_MARGIN, Alignment = TextAnchor.MiddleCenter, ColumnSpan = 2
});
}
private void OnButtonClicked(GameObject _) {
value?.Invoke(null);
}
public override GameObject GetUIComponent() {
// Will not be invoked
return new GameObject("Empty");
}
}
}

View File

@@ -0,0 +1,102 @@
/*
* 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 PeterHan.PLib.UI;
using UnityEngine;
using UnityEngine.Events;
namespace PeterHan.PLib.Options {
/// <summary>
/// Handles events for expanding and contracting options categories.
/// </summary>
internal sealed class CategoryExpandHandler {
/// <summary>
/// The realized panel containing the options.
/// </summary>
private GameObject contents;
/// <summary>
/// The initial state of the button.
/// </summary>
private readonly bool initialState;
/// <summary>
/// The realized toggle button.
/// </summary>
private GameObject toggle;
/// <summary>
/// Creates a new options category.
/// </summary>
/// <param name="initialState">true to start expanded, or false to start collapsed.</param>
public CategoryExpandHandler(bool initialState = true) {
this.initialState = initialState;
}
/// <summary>
/// Fired when the options category is expanded or contracted.
/// </summary>
/// <param name="on">true if the button is on, or false if it is off.</param>
public void OnExpandContract(GameObject _, bool on) {
var scale = on ? Vector3.one : Vector3.zero;
if (contents != null) {
var rt = contents.rectTransform();
rt.localScale = scale;
if (rt != null)
UnityEngine.UI.LayoutRebuilder.MarkLayoutForRebuild(rt);
}
}
/// <summary>
/// Fired when the header is clicked.
/// </summary>
private void OnHeaderClicked() {
if (toggle != null) {
bool state = PToggle.GetToggleState(toggle);
PToggle.SetToggleState(toggle, !state);
}
}
/// <summary>
/// Fired when the category label is realized.
/// </summary>
/// <param name="header">The realized header label of the category.</param>
public void OnRealizeHeader(GameObject header) {
var button = header.AddComponent<UnityEngine.UI.Button>();
button.onClick.AddListener(new UnityAction(OnHeaderClicked));
button.interactable = true;
}
/// <summary>
/// Fired when the body is realized.
/// </summary>
/// <param name="panel">The realized body of the category.</param>
public void OnRealizePanel(GameObject panel) {
contents = panel;
OnExpandContract(null, initialState);
}
/// <summary>
/// Fired when the toggle button is realized.
/// </summary>
/// <param name="toggle">The realized expand/contract button.</param>
public void OnRealizeToggle(GameObject toggle) {
this.toggle = toggle;
}
}
}

View File

@@ -0,0 +1,71 @@
/*
* 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 PeterHan.PLib.UI;
using UnityEngine;
namespace PeterHan.PLib.Options {
/// <summary>
/// An options entry which represents bool and displays a check box.
/// </summary>
public class CheckboxOptionsEntry : OptionsEntry {
public override object Value {
get {
return check;
}
set {
if (value is bool newCheck) {
check = newCheck;
Update();
}
}
}
/// <summary>
/// true if it is checked, or false otherwise
/// </summary>
private bool check;
/// <summary>
/// The realized item checkbox.
/// </summary>
private GameObject checkbox;
public CheckboxOptionsEntry(string field, IOptionSpec spec) : base(field, spec) {
check = false;
checkbox = null;
}
public override GameObject GetUIComponent() {
checkbox = new PCheckBox() {
OnChecked = (source, state) => {
// Swap the check: checked and partial -> unchecked
check = state == PCheckBox.STATE_UNCHECKED;
Update();
}, ToolTip = LookInStrings(Tooltip)
}.SetKleiBlueStyle().Build();
Update();
return checkbox;
}
private void Update() {
PCheckBox.SetCheckState(checkbox, check ? PCheckBox.STATE_CHECKED : PCheckBox.
STATE_UNCHECKED);
}
}
}

View File

@@ -0,0 +1,38 @@
/*
* 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 UnityEngine;
namespace PeterHan.PLib.Options {
/// <summary>
/// An options entry which represents Color32 and displays a color picker with sliders.
/// </summary>
internal class Color32OptionsEntry : ColorBaseOptionsEntry {
public override object Value {
get => (Color32)value;
set {
if (value is Color32 newValue) {
this.value = newValue;
UpdateAll();
}
}
}
public Color32OptionsEntry(string field, IOptionSpec spec) : base(field, spec) { }
}
}

View File

@@ -0,0 +1,328 @@
/*
* 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 PeterHan.PLib.Core;
using PeterHan.PLib.UI;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace PeterHan.PLib.Options {
/// <summary>
/// The abstract base of options entries that display a color picker with sliders.
/// </summary>
internal abstract class ColorBaseOptionsEntry : OptionsEntry {
/// <summary>
/// The margin between the color sliders and the rest of the dialog.
/// </summary>
protected static readonly RectOffset ENTRY_MARGIN = new RectOffset(10, 10, 2, 5);
/// <summary>
/// The margin around each slider.
/// </summary>
protected static readonly RectOffset SLIDER_MARGIN = new RectOffset(10, 0, 2, 2);
/// <summary>
/// The size of the sample swatch.
/// </summary>
protected const float SWATCH_SIZE = 32.0f;
/// <summary>
/// The hue displayed gradient.
/// </summary>
protected ColorGradient hueGradient;
/// <summary>
/// The hue slider.
/// </summary>
protected KSlider hueSlider;
/// <summary>
/// The realized text field for BLUE.
/// </summary>
protected TMP_InputField blue;
/// <summary>
/// The realized text field for GREEN.
/// </summary>
protected TMP_InputField green;
/// <summary>
/// The realized text field for RED.
/// </summary>
protected TMP_InputField red;
/// <summary>
/// The saturation displayed gradient.
/// </summary>
protected ColorGradient satGradient;
/// <summary>
/// The saturation slider.
/// </summary>
protected KSlider satSlider;
/// <summary>
/// The color sample swatch.
/// </summary>
protected Image swatch;
/// <summary>
/// The value displayed gradient.
/// </summary>
protected ColorGradient valGradient;
/// <summary>
/// The value slider.
/// </summary>
protected KSlider valSlider;
/// <summary>
/// The value as a Color.
/// </summary>
protected Color value;
protected ColorBaseOptionsEntry(string field, IOptionSpec spec) : base(field, spec) {
value = Color.white;
blue = null;
green = null;
red = null;
hueGradient = null; hueSlider = null;
satGradient = null; satSlider = null;
valGradient = null; valSlider = null;
swatch = null;
}
public override void CreateUIEntry(PGridPanel parent, ref int row) {
base.CreateUIEntry(parent, ref row);
// Add 3 rows for the H, S, and V
parent.AddRow(new GridRowSpec());
var h = new PSliderSingle("Hue") {
ToolTip = PLibStrings.TOOLTIP_HUE, MinValue = 0.0f, MaxValue = 1.0f,
CustomTrack = true, FlexSize = Vector2.right, OnValueChanged = OnHueChanged
}.AddOnRealize(OnHueRealized);
var s = new PSliderSingle("Saturation") {
ToolTip = PLibStrings.TOOLTIP_SATURATION, MinValue = 0.0f, MaxValue = 1.0f,
CustomTrack = true, FlexSize = Vector2.right, OnValueChanged = OnSatChanged
}.AddOnRealize(OnSatRealized);
var v = new PSliderSingle("Value") {
ToolTip = PLibStrings.TOOLTIP_VALUE, MinValue = 0.0f, MaxValue = 1.0f,
CustomTrack = true, FlexSize = Vector2.right, OnValueChanged = OnValChanged
}.AddOnRealize(OnValRealized);
var sw = new PLabel("Swatch") {
ToolTip = LookInStrings(Tooltip), DynamicSize = false,
Sprite = PUITuning.Images.BoxBorder, SpriteMode = Image.Type.Sliced,
SpriteSize = new Vector2(SWATCH_SIZE, SWATCH_SIZE)
}.AddOnRealize(OnSwatchRealized);
var panel = new PRelativePanel("ColorPicker") {
FlexSize = Vector2.right, DynamicSize = false
}.AddChild(h).AddChild(s).AddChild(v).AddChild(sw).SetRightEdge(h, fraction: 1.0f).
SetRightEdge(s, fraction: 1.0f).SetRightEdge(v, fraction: 1.0f).
SetLeftEdge(sw, fraction: 0.0f).SetMargin(h, SLIDER_MARGIN).
SetMargin(s, SLIDER_MARGIN).SetMargin(v, SLIDER_MARGIN).AnchorYAxis(sw).
SetLeftEdge(h, toRight: sw).SetLeftEdge(s, toRight: sw).
SetLeftEdge(v, toRight: sw).SetTopEdge(h, fraction: 1.0f).
SetBottomEdge(v, fraction: 0.0f).SetTopEdge(s, below: h).
SetTopEdge(v, below: s);
parent.AddChild(panel, new GridComponentSpec(++row, 0) {
ColumnSpan = 2, Margin = ENTRY_MARGIN
});
}
public override GameObject GetUIComponent() {
Color32 rgb = value;
var go = new PPanel("RGB") {
DynamicSize = false, Alignment = TextAnchor.MiddleRight, Spacing = 5,
Direction = PanelDirection.Horizontal
}.AddChild(new PLabel("Red") {
TextStyle = PUITuning.Fonts.TextLightStyle, Text = PLibStrings.LABEL_R
}).AddChild(new PTextField("RedValue") {
OnTextChanged = OnRGBChanged, ToolTip = PLibStrings.TOOLTIP_RED,
Text = rgb.r.ToString(), MinWidth = 32, MaxLength = 3,
Type = PTextField.FieldType.Integer
}.AddOnRealize(OnRedRealized)).AddChild(new PLabel("Green") {
TextStyle = PUITuning.Fonts.TextLightStyle, Text = PLibStrings.LABEL_G
}).AddChild(new PTextField("GreenValue") {
OnTextChanged = OnRGBChanged, ToolTip = PLibStrings.TOOLTIP_GREEN,
Text = rgb.g.ToString(), MinWidth = 32, MaxLength = 3,
Type = PTextField.FieldType.Integer
}.AddOnRealize(OnGreenRealized)).AddChild(new PLabel("Blue") {
TextStyle = PUITuning.Fonts.TextLightStyle, Text = PLibStrings.LABEL_B
}).AddChild(new PTextField("BlueValue") {
OnTextChanged = OnRGBChanged, ToolTip = PLibStrings.TOOLTIP_BLUE,
Text = rgb.b.ToString(), MinWidth = 32, MaxLength = 3,
Type = PTextField.FieldType.Integer
}.AddOnRealize(OnBlueRealized)).Build();
UpdateAll();
return go;
}
private void OnBlueRealized(GameObject realized) {
blue = realized.GetComponentInChildren<TMP_InputField>();
}
private void OnGreenRealized(GameObject realized) {
green = realized.GetComponentInChildren<TMP_InputField>();
}
private void OnHueChanged(GameObject _, float newHue) {
if (hueGradient != null && hueSlider != null) {
float oldAlpha = value.a;
hueGradient.Position = hueSlider.value;
value = hueGradient.SelectedColor;
value.a = oldAlpha;
UpdateRGB();
UpdateSat(false);
UpdateVal(false);
}
}
private void OnHueRealized(GameObject realized) {
hueGradient = realized.AddOrGet<ColorGradient>();
realized.TryGetComponent(out hueSlider);
}
private void OnRedRealized(GameObject realized) {
red = realized.GetComponentInChildren<TMP_InputField>();
}
/// <summary>
/// Called when the red, green, or blue field's text is changed.
/// </summary>
/// <param name="text">The new color value.</param>
protected void OnRGBChanged(GameObject _, string text) {
Color32 rgb = value;
if (byte.TryParse(red.text, out byte r))
rgb.r = r;
if (byte.TryParse(green.text, out byte g))
rgb.g = g;
if (byte.TryParse(blue.text, out byte b))
rgb.b = b;
value = rgb;
UpdateAll();
}
private void OnSatChanged(GameObject _, float newSat) {
if (satGradient != null && satSlider != null) {
float oldAlpha = value.a;
satGradient.Position = satSlider.value;
value = satGradient.SelectedColor;
value.a = oldAlpha;
UpdateRGB();
UpdateHue(false);
UpdateVal(false);
}
}
private void OnSatRealized(GameObject realized) {
satGradient = realized.AddOrGet<ColorGradient>();
realized.TryGetComponent(out satSlider);
}
private void OnSwatchRealized(GameObject realized) {
swatch = realized.GetComponentInChildren<Image>();
}
private void OnValChanged(GameObject _, float newValue) {
if (valGradient != null && valSlider != null) {
float oldAlpha = value.a;
valGradient.Position = valSlider.value;
value = valGradient.SelectedColor;
value.a = oldAlpha;
UpdateRGB();
UpdateHue(false);
UpdateSat(false);
}
}
private void OnValRealized(GameObject realized) {
valGradient = realized.AddOrGet<ColorGradient>();
realized.TryGetComponent(out valSlider);
}
/// <summary>
/// If the color is changed externally, updates all sliders.
/// </summary>
protected void UpdateAll() {
UpdateRGB();
UpdateHue(true);
UpdateSat(true);
UpdateVal(true);
}
/// <summary>
/// Updates the position of the hue slider with the currently selected color.
/// </summary>
/// <param name="moveSlider">true to move the slider handle if necessary, or false to
/// leave it where it is.</param>
protected void UpdateHue(bool moveSlider) {
if (hueGradient != null && hueSlider != null) {
Color.RGBToHSV(value, out _, out float s, out float v);
hueGradient.SetRange(0.0f, 1.0f, s, s, v, v);
hueGradient.SelectedColor = value;
if (moveSlider)
hueSlider.value = hueGradient.Position;
}
}
/// <summary>
/// Updates the displayed value.
/// </summary>
protected void UpdateRGB() {
Color32 rgb = value;
if (red != null)
red.text = rgb.r.ToString();
if (green != null)
green.text = rgb.g.ToString();
if (blue != null)
blue.text = rgb.b.ToString();
if (swatch != null)
swatch.color = value;
}
/// <summary>
/// Updates the position of the saturation slider with the currently selected color.
/// </summary>
/// <param name="moveSlider">true to move the slider handle if necessary, or false to
/// leave it where it is.</param>
protected void UpdateSat(bool moveSlider) {
if (satGradient != null && satSlider != null) {
Color.RGBToHSV(value, out float h, out _, out float v);
satGradient.SetRange(h, h, 0.0f, 1.0f, v, v);
satGradient.SelectedColor = value;
if (moveSlider)
satSlider.value = satGradient.Position;
}
}
/// <summary>
/// Updates the position of the value slider with the currently selected color.
/// </summary>
/// <param name="moveSlider">true to move the slider handle if necessary, or false to
/// leave it where it is.</param>
protected void UpdateVal(bool moveSlider) {
if (valGradient != null && valSlider != null) {
Color.RGBToHSV(value, out float h, out float s, out _);
valGradient.SetRange(h, h, s, s, 0.0f, 1.0f);
valGradient.SelectedColor = value;
if (moveSlider)
valSlider.value = valGradient.Position;
}
}
}
}

View File

@@ -0,0 +1,227 @@
/*
* 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 UnityEngine;
using UnityEngine.UI;
namespace PeterHan.PLib.Options {
/// <summary>
/// A background image which displays a gradient between two different colors using HSV
/// interpolation.
/// </summary>
internal sealed class ColorGradient : Image {
/// <summary>
/// The position to use on the track.
/// </summary>
public float Position {
get => position;
set {
position = Mathf.Clamp01(value);
SetPosition();
}
}
/// <summary>
/// The currently selected color.
/// </summary>
public Color SelectedColor {
get => current;
set {
current = value;
EstimatePosition();
}
}
/// <summary>
/// The currently selected color.
/// </summary>
private Color current;
/// <summary>
/// Whether the image texture needs to be regenerated.
/// </summary>
private bool dirty;
/// <summary>
/// Gradient unfortunately always uses RGB. Run curves manually using HSV.
/// </summary>
private Vector2 hue;
/// <summary>
/// The position to use on the track.
/// </summary>
private float position;
/// <summary>
/// The texture used when drawing the background.
/// </summary>
private Texture2D preview;
private Vector2 sat;
private Vector2 val;
public ColorGradient() {
current = Color.black;
position = 0.0f;
preview = null;
dirty = true;
hue = Vector2.right;
sat = Vector2.right;
val = Vector2.right;
}
/// <summary>
/// Estimates a position based on the selected color.
/// </summary>
internal void EstimatePosition() {
Color.RGBToHSV(current, out float h, out float s, out float v);
float newPos = 0.0f, divider = 0.0f;
if (!Mathf.Approximately(hue.x, hue.y)) {
newPos += Mathf.Clamp01(Mathf.InverseLerp(hue.x, hue.y, h));
divider += 1.0f;
}
if (!Mathf.Approximately(sat.x, sat.y)) {
newPos += Mathf.Clamp01(Mathf.InverseLerp(sat.x, sat.y, s));
divider += 1.0f;
}
if (!Mathf.Approximately(val.x, val.y)) {
newPos += Mathf.Clamp01(Mathf.InverseLerp(val.x, val.y, v));
divider += 1.0f;
}
if (divider > 0.0f) {
position = newPos / divider;
SetPosition();
}
}
/// <summary>
/// Cleans up the preview when the component is disposed.
/// </summary>
protected override void OnDestroy() {
if (preview != null) {
Destroy(preview);
preview = null;
}
if (sprite != null) {
Destroy(sprite);
sprite = null;
}
base.OnDestroy();
}
/// <summary>
/// Called when the image needs to be resized.
/// </summary>
protected override void OnRectTransformDimensionsChange() {
base.OnRectTransformDimensionsChange();
dirty = true;
}
/// <summary>
/// Updates the currently selected color with the interpolated value based on the
/// current position.
/// </summary>
internal void SetPosition() {
float h = Mathf.Clamp01(Mathf.Lerp(hue.x, hue.y, position));
float s = Mathf.Clamp01(Mathf.Lerp(sat.x, sat.y, position));
float v = Mathf.Clamp01(Mathf.Lerp(val.x, val.y, position));
current = Color.HSVToRGB(h, s, v);
}
/// <summary>
/// Sets the range of colors to be displayed.
/// </summary>
/// <param name="hMin">The minimum hue.</param>
/// <param name="hMax">The maximum hue.</param>
/// <param name="sMin">The minimum saturation.</param>
/// <param name="sMax">The maximum saturation.</param>
/// <param name="vMin">The minimum value.</param>
/// <param name="vMax">The maximum value.</param>
public void SetRange(float hMin, float hMax, float sMin, float sMax, float vMin,
float vMax) {
// Ensure correct order
if (hMin > hMax) {
hue.x = hMax; hue.y = hMin;
} else {
hue.x = hMin; hue.y = hMax;
}
if (sMin > sMax) {
sat.x = sMax; sat.y = sMin;
} else {
sat.x = sMin; sat.y = sMax;
}
if (vMin > vMax) {
val.x = vMax; val.y = vMin;
} else {
val.x = vMin; val.y = vMax;
}
EstimatePosition();
dirty = true;
}
/// <summary>
/// Called by Unity when the component is created.
/// </summary>
protected override void Start() {
base.Start();
dirty = true;
}
/// <summary>
/// Regenerates the color gradient image if necessary.
/// </summary>
internal void Update() {
if (dirty || preview == null || sprite == null) {
var rt = rectTransform.rect;
int w = Mathf.RoundToInt(rt.width), h = Mathf.RoundToInt(rt.height);
if (w > 0 && h > 0) {
var newData = new Color[w * h];
float hMin = hue.x, hMax = hue.y, sMin = sat.x, sMax = sat.y, vMin = val.x,
vMax = val.y;
var oldSprite = sprite;
bool rebuild = preview == null || oldSprite == null || preview.width !=
w || preview.height != h;
if (rebuild) {
if (preview != null)
Destroy(preview);
if (oldSprite != null)
Destroy(oldSprite);
preview = new Texture2D(w, h);
}
// Generate one row of colors
for (int i = 0; i < w; i++) {
float t = (float)i / w;
newData[i] = Color.HSVToRGB(Mathf.Lerp(hMin, hMax, t), Mathf.Lerp(sMin,
sMax, t), Mathf.Lerp(vMin, vMax, t));
}
// Native copy row to all others
for (int i = 1; i < h; i++)
Array.Copy(newData, 0, newData, i * w, w);
preview.SetPixels(newData);
preview.Apply();
if (rebuild)
sprite = Sprite.Create(preview, new Rect(0.0f, 0.0f, w, h),
new Vector2(0.5f, 0.5f));
}
dirty = false;
}
}
}
}

View File

@@ -0,0 +1,38 @@
/*
* 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 UnityEngine;
namespace PeterHan.PLib.Options {
/// <summary>
/// An options entry which represents Color and displays a color picker with sliders.
/// </summary>
internal class ColorOptionsEntry : ColorBaseOptionsEntry {
public override object Value {
get => value;
set {
if (value is Color newValue) {
this.value = newValue;
UpdateAll();
}
}
}
public ColorOptionsEntry(string field, IOptionSpec spec) : base(field, spec) { }
}
}

View File

@@ -0,0 +1,161 @@
/*
* 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 PeterHan.PLib.Core;
using PeterHan.PLib.UI;
using System;
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
namespace PeterHan.PLib.Options {
/// <summary>
/// An options entry that encapsulates other options. The category annotation on those
/// objects will be ignored, and the category of the Option attribute on the property
/// that declared those options (to avoid infinite loops) will be used instead.
///
/// <b>This object is not in the scene graph.</b> Any events in OnRealize will never be
/// invoked, and it is never "built".
/// </summary>
internal class CompositeOptionsEntry : OptionsEntry {
/// <summary>
/// Creates an options entry wrapper for the specified property, iterating its internal
/// fields to create sub-options if needed (recursively).
/// </summary>
/// <param name="info">The property to wrap.</param>
/// <param name="spec">The option title and tool tip.</param>
/// <param name="depth">The current depth of iteration to avoid infinite loops.</param>
/// <returns>An options wrapper, or null if no inner properties are themselves options.</returns>
internal static CompositeOptionsEntry Create(IOptionSpec spec, PropertyInfo info,
int depth) {
var type = info.PropertyType;
var composite = new CompositeOptionsEntry(info.Name, spec, type);
// Skip static properties if they exist
foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.
Instance)) {
var entry = TryCreateEntry(prop, depth + 1);
if (entry != null)
composite.AddField(prop, entry);
}
return composite.ChildCount > 0 ? composite : null;
}
/// <summary>
/// Reports the number of options contained inside this one.
/// </summary>
public int ChildCount {
get {
return subOptions.Count;
}
}
public override object Value {
get {
return value;
}
set {
if (value != null && targetType.IsAssignableFrom(value.GetType()))
this.value = value;
}
}
/// <summary>
/// The options encapsulated in this object.
/// </summary>
protected readonly IDictionary<PropertyInfo, IOptionsEntry> subOptions;
/// <summary>
/// The type of the encapsulated object.
/// </summary>
protected readonly Type targetType;
/// <summary>
/// The object thus wrapped.
/// </summary>
protected object value;
public CompositeOptionsEntry(string field, IOptionSpec spec, Type fieldType) :
base(field, spec) {
subOptions = new Dictionary<PropertyInfo, IOptionsEntry>(16);
targetType = fieldType ?? throw new ArgumentNullException(nameof(fieldType));
value = OptionsDialog.CreateOptions(fieldType);
}
/// <summary>
/// Adds an options entry object that operates on Option fields of the encapsulated
/// object.
/// </summary>
/// <param name="info">The property that is wrapped.</param>
/// <param name="entry">The entry to add.</param>
public void AddField(PropertyInfo info, IOptionsEntry entry) {
if (entry == null)
throw new ArgumentNullException(nameof(entry));
if (info == null)
throw new ArgumentNullException(nameof(info));
subOptions.Add(info, entry);
}
public override void CreateUIEntry(PGridPanel parent, ref int row) {
int i = row;
bool first = true;
parent.AddOnRealize(WhenRealized);
// Render each sub-entry - order is always Add Spec, Create Entry, Increment Row
foreach (var pair in subOptions) {
if (!first) {
i++;
parent.AddRow(new GridRowSpec());
}
pair.Value.CreateUIEntry(parent, ref i);
first = false;
}
row = i;
}
public override GameObject GetUIComponent() {
// Will not be invoked
return new GameObject("Empty");
}
public override void ReadFrom(object settings) {
base.ReadFrom(settings);
foreach (var pair in subOptions)
pair.Value.ReadFrom(value);
}
public override string ToString() {
return "{1}[field={0},title={2},children=[{3}]]".F(Field, GetType().Name, Title,
subOptions.Join());
}
/// <summary>
/// Updates the child objects for the first time when the panel is realized.
/// </summary>
private void WhenRealized(GameObject _) {
foreach (var pair in subOptions)
pair.Value.ReadFrom(value);
}
public override void WriteTo(object settings) {
foreach (var pair in subOptions)
// Cannot detour as the types are not known at compile time, and delegates
// bake in the target object which changes upon each update
pair.Value.WriteTo(value);
base.WriteTo(settings);
}
}
}

View File

@@ -0,0 +1,59 @@
/*
* 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;
namespace PeterHan.PLib.Options {
/// <summary>
/// An attribute placed on an options class only (will not function on a member property)
/// which denotes the config file name to use for that mod, and allows save/load options
/// to be set.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
public sealed class ConfigFileAttribute : Attribute {
/// <summary>
/// The configuration file name. If null, the default file name will be used.
/// </summary>
public string ConfigFileName { get; }
/// <summary>
/// Whether the output should be indented nicely. Defaults to false for smaller
/// config files.
/// </summary>
public bool IndentOutput { get; }
/// <summary>
/// If true, the config file will be moved from the mod folder to a folder in the
/// config directory shared across mods. This change preserves the mod configuration
/// across updates, but may not be cleared when the mod is uninstalled. Use with
/// caution.
/// </summary>
public bool UseSharedConfigLocation { get; }
public ConfigFileAttribute(string FileName = POptions.CONFIG_FILE_NAME,
bool IndentOutput = false, bool SharedConfigLocation = false) {
ConfigFileName = FileName;
this.IndentOutput = IndentOutput;
UseSharedConfigLocation = SharedConfigLocation;
}
public override string ToString() {
return ConfigFileName;
}
}
}

View File

@@ -0,0 +1,54 @@
/*
* 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 PeterHan.PLib.Core;
using System;
namespace PeterHan.PLib {
/// <summary>
/// An attribute placed on an option property for a class used as mod options in order to
/// make PLib use a custom options handler. The type used for the handler must inherit
/// from IOptionsEntry.
/// </summary>
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public sealed class DynamicOptionAttribute : Attribute {
/// <summary>
/// The option category.
/// </summary>
public string Category { get; }
/// <summary>
/// The option handler.
/// </summary>
public Type Handler { get; }
/// <summary>
/// Denotes a mod option field.
/// </summary>
/// <param name="type">The type that will handle this dynamic option.</param>
/// <param name="category">The category to use, or null for the default category.</param>
public DynamicOptionAttribute(Type type, string category = null) {
Category = category;
Handler = type ?? throw new ArgumentNullException(nameof(type));
}
public override string ToString() {
return "DynamicOption[handler={0},category={1}]".F(Handler.FullName, Category);
}
}
}

View File

@@ -0,0 +1,119 @@
/*
* 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 PeterHan.PLib.UI;
using TMPro;
using UnityEngine;
namespace PeterHan.PLib.Options {
/// <summary>
/// An options entry which represents float and displays a text field and slider.
/// </summary>
public class FloatOptionsEntry : SlidingBaseOptionsEntry {
/// <summary>
/// The format to use if none is provided.
/// </summary>
public const string DEFAULT_FORMAT = "F2";
public override object Value {
get {
return value;
}
set {
if (value is float newValue) {
this.value = newValue;
Update();
}
}
}
/// <summary>
/// The realized text field.
/// </summary>
private GameObject textField;
/// <summary>
/// The value in the text field.
/// </summary>
private float value;
public FloatOptionsEntry(string field, IOptionSpec spec, LimitAttribute limit = null) :
base(field, spec, limit) {
textField = null;
value = 0.0f;
}
protected override PSliderSingle GetSlider() {
return new PSliderSingle() {
OnValueChanged = OnSliderChanged, ToolTip = LookInStrings(Tooltip),
MinValue = (float)limits.Minimum, MaxValue = (float)limits.Maximum,
InitialValue = value
};
}
public override GameObject GetUIComponent() {
textField = new PTextField() {
OnTextChanged = OnTextChanged, ToolTip = LookInStrings(Tooltip),
Text = value.ToString(Format ?? DEFAULT_FORMAT), MinWidth = 64, MaxLength = 16,
Type = PTextField.FieldType.Float
}.Build();
Update();
return textField;
}
/// <summary>
/// Called when the slider's value is changed.
/// </summary>
/// <param name="newValue">The new slider value.</param>
private void OnSliderChanged(GameObject _, float newValue) {
// Record the value
if (limits != null)
value = limits.ClampToRange(newValue);
else
value = newValue;
Update();
}
/// <summary>
/// Called when the input field's text is changed.
/// </summary>
/// <param name="text">The new text.</param>
private void OnTextChanged(GameObject _, string text) {
if (float.TryParse(text, out float newValue)) {
if (Format != null && Format.ToUpperInvariant().IndexOf('P') >= 0)
newValue *= 0.01f;
if (limits != null)
newValue = limits.ClampToRange(newValue);
// Record the valid value
value = newValue;
}
Update();
}
/// <summary>
/// Updates the displayed value.
/// </summary>
protected override void Update() {
var field = textField?.GetComponentInChildren<TMP_InputField>();
if (field != null)
field.text = value.ToString(Format ?? DEFAULT_FORMAT);
if (slider != null)
PSliderSingle.SetCurrentValue(slider, value);
}
}
}

View File

@@ -0,0 +1,53 @@
/*
* 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.
*/
namespace PeterHan.PLib.Options {
/// <summary>
/// The common parent of all classes that can specify the user visible attributes of an
/// option.
/// </summary>
public interface IOptionSpec {
/// <summary>
/// The option category. Ignored and replaced with the parent option's category if
/// this option is part of a custom grouped type.
/// </summary>
string Category { get; }
/// <summary>
/// The format string to use when displaying this option value. Only applicable for
/// some types of options.
///
/// <b>Warning</b>: Attribute may have issues on nested classes that are used as custom
/// grouped options. To mitigate, try declaring the custom class in a non-nested
/// context (i.e. not declared inside another class).
/// </summary>
string Format { get; }
/// <summary>
/// The option title. Ignored for fields which are displayed as custom grouped types
/// types of other options.
/// </summary>
string Title { get; }
/// <summary>
/// The option description tooltip. Ignored for fields which are displayed as custom
/// grouped types of other options.
/// </summary>
string Tooltip { get; }
}
}

View File

@@ -0,0 +1,54 @@
/*
* 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.Collections.Generic;
namespace PeterHan.PLib.Options {
/// <summary>
/// An optional interface which can be implemented to give mods the ability to dynamically
/// add new options at runtime, or to get a notification when options are updated to the
/// options file.
///
/// This interface is <b>optional</b>. There is no need to implement it to use PLib
/// Options. But if one method is implemented, the other must also be. If not used,
/// OnOptionsChanged should be empty, and CreateOptions should return an empty collection.
/// </summary>
public interface IOptions {
/// <summary>
/// Called to create additional options. After the options in this class have been
/// read from the data file, but before the dialog is shown, this method will be
/// invoked. Each return value must be of a type that implements IOptionsEntry.
///
/// The options will be sorted and categorized normally as if they were present at the
/// end of the property list in a regular options class.
///
/// This method can be an enumerator using code like
/// yield return new MyOptionsHandler();
/// </summary>
/// <returns>The custom options to implement.</returns>
IEnumerable<IOptionsEntry> CreateOptions();
/// <summary>
/// Called when options are written to the file. The current object will have the same
/// values as the data that was just written to the file. This call happens after the
/// options have been stored to disk, but before any restart required dialog is shown
/// (if [RestartRequired] is also on this class).
/// </summary>
void OnOptionsChanged();
}
}

View File

@@ -0,0 +1,46 @@
/*
* 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 PeterHan.PLib.UI;
namespace PeterHan.PLib.Options {
/// <summary>
/// All options handlers, including user dynamic option handlers, implement this type.
/// </summary>
public interface IOptionsEntry : IOptionSpec {
/// <summary>
/// Creates UI components that will present this option.
/// </summary>
/// <param name="parent">The parent panel where the components should be added.</param>
/// <param name="row">The row index where the component should be placed. If multiple
/// rows of components are added, increment this value for each additional row.</param>
void CreateUIEntry(PGridPanel parent, ref int row);
/// <summary>
/// Reads the option value into the UI from the provided settings object.
/// </summary>
/// <param name="settings">The settings object.</param>
void ReadFrom(object settings);
/// <summary>
/// Writes the option value from the UI into the provided settings object.
/// </summary>
/// <param name="settings">The settings object.</param>
void WriteTo(object settings);
}
}

View File

@@ -0,0 +1,112 @@
/*
* 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 PeterHan.PLib.UI;
using TMPro;
using UnityEngine;
namespace PeterHan.PLib.Options {
/// <summary>
/// An options entry which represents int and displays a text field and slider.
/// </summary>
public class IntOptionsEntry : SlidingBaseOptionsEntry {
public override object Value {
get {
return value;
}
set {
if (value is int newValue) {
this.value = newValue;
Update();
}
}
}
/// <summary>
/// The realized text field.
/// </summary>
private GameObject textField;
/// <summary>
/// The value in the text field.
/// </summary>
private int value;
public IntOptionsEntry(string field, IOptionSpec spec, LimitAttribute limit = null) :
base(field, spec, limit) {
textField = null;
value = 0;
}
protected override PSliderSingle GetSlider() {
return new PSliderSingle() {
OnValueChanged = OnSliderChanged, ToolTip = LookInStrings(Tooltip),
MinValue = (float)limits.Minimum, MaxValue = (float)limits.Maximum,
InitialValue = value, IntegersOnly = true
};
}
public override GameObject GetUIComponent() {
textField = new PTextField() {
OnTextChanged = OnTextChanged, ToolTip = LookInStrings(Tooltip),
Text = value.ToString(Format ?? "D"), MinWidth = 64, MaxLength = 10,
Type = PTextField.FieldType.Integer
}.Build();
Update();
return textField;
}
/// <summary>
/// Called when the slider's value is changed.
/// </summary>
/// <param name="newValue">The new slider value.</param>
private void OnSliderChanged(GameObject _, float newValue) {
int newIntValue = Mathf.RoundToInt(newValue);
if (limits != null)
newIntValue = limits.ClampToRange(newIntValue);
// Record the value
value = newIntValue;
Update();
}
/// <summary>
/// Called when the input field's text is changed.
/// </summary>
/// <param name="text">The new text.</param>
private void OnTextChanged(GameObject _, string text) {
if (int.TryParse(text, out int newValue)) {
if (limits != null)
newValue = limits.ClampToRange(newValue);
// Record the valid value
value = newValue;
}
Update();
}
/// <summary>
/// Updates the displayed value.
/// </summary>
protected override void Update() {
var field = textField?.GetComponentInChildren<TMP_InputField>();
if (field != null)
field.text = value.ToString(Format ?? "D");
if (slider != null)
PSliderSingle.SetCurrentValue(slider, value);
}
}
}

View File

@@ -0,0 +1,75 @@
/*
* 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 PeterHan.PLib.Core;
using System;
namespace PeterHan.PLib.Options {
/// <summary>
/// An attribute placed on an option field for a property used as mod options to define
/// minimum and maximum acceptable values.
/// </summary>
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public sealed class LimitAttribute : Attribute {
/// <summary>
/// The maximum value (inclusive).
/// </summary>
public double Maximum { get; }
/// <summary>
/// The minimum value (inclusive).
/// </summary>
public double Minimum { get; }
public LimitAttribute(double min, double max) {
Minimum = min.IsNaNOrInfinity() ? 0.0 : min;
Maximum = (max.IsNaNOrInfinity() || max < min) ? min : max;
}
/// <summary>
/// Clamps the specified value to the range of this Limits object.
/// </summary>
/// <param name="value">The value to coerce.</param>
/// <returns>The nearest value included by these limits to the specified value.</returns>
public float ClampToRange(float value) {
return value.InRange((float)Minimum, (float)Maximum);
}
/// <summary>
/// Clamps the specified value to the range of this Limits object.
/// </summary>
/// <param name="value">The value to coerce.</param>
/// <returns>The nearest value included by these limits to the specified value.</returns>
public int ClampToRange(int value) {
return value.InRange((int)Minimum, (int)Maximum);
}
/// <summary>
/// Reports whether a value is in the range included in these limits.
/// </summary>
/// <param name="value">The value to check.</param>
/// <returns>true if it is included in the limits, or false otherwise.</returns>
public bool InRange(double value) {
return value >= Minimum && value <= Maximum;
}
public override string ToString() {
return "{0:F2} to {1:F2}".F(Minimum, Maximum);
}
}
}

View File

@@ -0,0 +1,120 @@
/*
* 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 PeterHan.PLib.UI;
using System;
using TMPro;
using UnityEngine;
namespace PeterHan.PLib.Options {
/// <summary>
/// An options entry which represents float and displays a text field and slider.
/// This entry uses a logarithmic scale.
/// </summary>
public class LogFloatOptionsEntry : SlidingBaseOptionsEntry {
/// <summary>
/// The format to use if none is provided.
/// </summary>
public const string DEFAULT_FORMAT = "F2";
public override object Value {
get {
return value;
}
set {
if (value is float newValue) {
this.value = limits.ClampToRange(newValue);
Update();
}
}
}
/// <summary>
/// The realized text field.
/// </summary>
private GameObject textField;
/// <summary>
/// The value in the text field.
/// </summary>
private float value;
public LogFloatOptionsEntry(string field, IOptionSpec spec, LimitAttribute limit) :
base(field, spec, limit) {
if (limit == null)
throw new ArgumentNullException(nameof(limit));
if (limit.Minimum <= 0.0f || limit.Maximum <= 0.0f)
throw new ArgumentOutOfRangeException(nameof(limit),
"Logarithmic values must be positive");
textField = null;
value = limit.ClampToRange(1.0f);
}
protected override PSliderSingle GetSlider() {
return new PSliderSingle() {
OnValueChanged = OnSliderChanged, ToolTip = LookInStrings(Tooltip),
MinValue = Mathf.Log((float)limits.Minimum), MaxValue = Mathf.Log(
(float)limits.Maximum), InitialValue = Mathf.Log(value)
};
}
public override GameObject GetUIComponent() {
textField = new PTextField() {
OnTextChanged = OnTextChanged, ToolTip = LookInStrings(Tooltip),
Text = value.ToString(Format ?? DEFAULT_FORMAT), MinWidth = 64, MaxLength = 16,
Type = PTextField.FieldType.Float
}.Build();
Update();
return textField;
}
/// <summary>
/// Called when the slider's value is changed.
/// </summary>
/// <param name="newValue">The new slider value.</param>
private void OnSliderChanged(GameObject _, float newValue) {
value = limits.ClampToRange(Mathf.Exp(newValue));
Update();
}
/// <summary>
/// Called when the input field's text is changed.
/// </summary>
/// <param name="text">The new text.</param>
private void OnTextChanged(GameObject _, string text) {
if (float.TryParse(text, out float newValue)) {
if (Format != null && Format.ToUpperInvariant().IndexOf('P') >= 0)
newValue *= 0.01f;
value = limits.ClampToRange(newValue);
}
Update();
}
/// <summary>
/// Updates the displayed value.
/// </summary>
protected override void Update() {
var field = textField?.GetComponentInChildren<TMP_InputField>();
if (field != null)
field.text = value.ToString(Format ?? DEFAULT_FORMAT);
if (slider != null)
PSliderSingle.SetCurrentValue(slider, Mathf.Log(Mathf.Max(float.Epsilon,
value)));
}
}
}

View File

@@ -0,0 +1,93 @@
/*
* 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 PeterHan.PLib.Core;
using System;
namespace PeterHan.PLib.Options {
/// <summary>
/// Stores the information displayed about a mod in its options dialog.
/// </summary>
internal sealed class ModDialogInfo {
/// <summary>
/// Gets the text shown for a mod's version.
/// </summary>
/// <param name="optionsType">The type used for the mod settings.</param>
/// <returns>The mod version description.</returns>
private static string GetModVersionText(Type optionsType) {
var asm = optionsType.Assembly;
string version = asm.GetFileVersion();
// Use FileVersion if available, else assembly version
if (string.IsNullOrEmpty(version))
version = string.Format(PLibStrings.MOD_ASSEMBLY_VERSION, asm.GetName().
Version);
else
version = string.Format(PLibStrings.MOD_VERSION, version);
return version;
}
/// <summary>
/// The path to the image displayed (on the file system) for this mod.
/// </summary>
public string Image { get; }
/// <summary>
/// The mod title. The title is taken directly from the mod version information.
/// </summary>
public string Title { get; }
/// <summary>
/// The URL which will be displayed. If none was provided, the Steam workshop page URL
/// will be reported for Steam mods, and an empty string for local/dev mods.
/// </summary>
public string URL { get; }
/// <summary>
/// The mod version.
/// </summary>
public string Version { get; }
internal ModDialogInfo(Type type, string url, string image) {
var mod = POptions.GetModFromType(type);
string title, version;
Image = image ?? "";
if (mod != null) {
string modInfoVersion = mod.packagedModInfo?.version;
title = mod.title;
if (string.IsNullOrEmpty(url) && mod.label.distribution_platform == KMod.Label.
DistributionPlatform.Steam)
url = "https://steamcommunity.com/sharedfiles/filedetails/?id=" + mod.
label.id;
if (!string.IsNullOrEmpty(modInfoVersion))
version = string.Format(PLibStrings.MOD_VERSION, modInfoVersion);
else
version = GetModVersionText(type);
} else {
title = type.Assembly.GetNameSafe();
version = GetModVersionText(type);
}
Title = title ?? "";
URL = url ?? "";
Version = version;
}
public override string ToString() {
return base.ToString();
}
}
}

View File

@@ -0,0 +1,57 @@
/*
* 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;
namespace PeterHan.PLib.Options {
/// <summary>
/// Allows mod authors to specify attributes for their mods to be shown in the Options
/// dialog.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
public sealed class ModInfoAttribute : Attribute {
/// <summary>
/// If true, forces all categories in the options screen to begin collapsed (except
/// the default category).
/// </summary>
public bool ForceCollapseCategories { get; }
/// <summary>
/// The name of the image file (in the mod's root directory) to display in the options
/// dialog. If null or empty (or it cannot be loaded), no image is displayed.
/// </summary>
public string Image { get; }
/// <summary>
/// The URL to use for the mod. If null or empty, the Steam workshop link will be used
/// if possible, or otherwise the button will not be shown.
/// </summary>
public string URL { get; }
public ModInfoAttribute(string url, string image = null, bool collapse = false)
{
ForceCollapseCategories = collapse;
Image = image;
URL = url;
}
public override string ToString() {
return string.Format("ModInfoAttribute[URL={1},Image={2}]", URL, Image);
}
}
}

View File

@@ -0,0 +1,132 @@
/*
* 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 PeterHan.PLib.UI;
using TMPro;
using UnityEngine;
namespace PeterHan.PLib.Options {
/// <summary>
/// An options entry which represents float? and displays a text field and slider.
/// </summary>
public class NullableFloatOptionsEntry : SlidingBaseOptionsEntry {
/// <summary>
/// The text that is rendered for the current value of the entry.
/// </summary>
protected virtual string FieldText {
get {
return value?.ToString(Format ?? FloatOptionsEntry.DEFAULT_FORMAT) ?? string.
Empty;
}
}
public override object Value {
get {
return value;
}
set {
if (value == null) {
this.value = null;
Update();
} else if (value is float newValue) {
this.value = newValue;
Update();
}
}
}
/// <summary>
/// The realized text field.
/// </summary>
private GameObject textField;
/// <summary>
/// The value in the text field.
/// </summary>
private float? value;
public NullableFloatOptionsEntry(string field, IOptionSpec spec,
LimitAttribute limit = null) : base(field, spec, limit) {
textField = null;
value = null;
}
protected override PSliderSingle GetSlider() {
float minLimit = (float)limits.Minimum, maxLimit = (float)limits.Maximum;
return new PSliderSingle() {
OnValueChanged = OnSliderChanged, ToolTip = LookInStrings(Tooltip),
MinValue = minLimit, MaxValue = maxLimit, InitialValue = 0.5f * (minLimit +
maxLimit)
};
}
public override GameObject GetUIComponent() {
textField = new PTextField() {
OnTextChanged = OnTextChanged, ToolTip = LookInStrings(Tooltip),
Text = FieldText, MinWidth = 64, MaxLength = 16,
Type = PTextField.FieldType.Float
}.Build();
Update();
return textField;
}
/// <summary>
/// Called when the slider's value is changed.
/// </summary>
/// <param name="newValue">The new slider value.</param>
private void OnSliderChanged(GameObject _, float newValue) {
// Record the value
if (limits != null)
value = limits.ClampToRange(newValue);
else
value = newValue;
Update();
}
/// <summary>
/// Called when the input field's text is changed.
/// </summary>
/// <param name="text">The new text.</param>
private void OnTextChanged(GameObject _, string text) {
if (string.IsNullOrWhiteSpace(text))
// Limits are assumed to allow null, because why not use non-nullable
// otherwise?
value = null;
else if (float.TryParse(text, out float newValue)) {
if (Format != null && Format.ToUpperInvariant().IndexOf('P') >= 0)
newValue *= 0.01f;
if (limits != null)
newValue = limits.ClampToRange(newValue);
// Record the valid value
value = newValue;
}
Update();
}
/// <summary>
/// Updates the displayed value.
/// </summary>
protected override void Update() {
var field = textField?.GetComponentInChildren<TMP_InputField>();
if (field != null)
field.text = FieldText;
if (slider != null && value != null)
PSliderSingle.SetCurrentValue(slider, (float)value);
}
}
}

View File

@@ -0,0 +1,130 @@
/*
* 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 PeterHan.PLib.UI;
using System.Reflection;
using TMPro;
using UnityEngine;
namespace PeterHan.PLib.Options {
/// <summary>
/// An options entry which represents int? and displays a text field and slider.
/// </summary>
public class NullableIntOptionsEntry : SlidingBaseOptionsEntry {
/// <summary>
/// The text that is rendered for the current value of the entry.
/// </summary>
protected virtual string FieldText {
get {
return value?.ToString(Format ?? "D") ?? string.Empty;
}
}
public override object Value {
get {
return value;
}
set {
if (value == null) {
this.value = null;
Update();
} else if (value is int newValue) {
this.value = newValue;
Update();
}
}
}
/// <summary>
/// The realized text field.
/// </summary>
private GameObject textField;
/// <summary>
/// The value in the text field.
/// </summary>
private int? value;
public NullableIntOptionsEntry(string field, IOptionSpec spec,
LimitAttribute limit = null) : base(field, spec, limit) {
textField = null;
value = null;
}
protected override PSliderSingle GetSlider() {
float minLimit = (float)limits.Minimum, maxLimit = (float)limits.Maximum;
return new PSliderSingle() {
OnValueChanged = OnSliderChanged, ToolTip = LookInStrings(Tooltip),
MinValue = minLimit, MaxValue = maxLimit, InitialValue = minLimit,
IntegersOnly = true
};
}
public override GameObject GetUIComponent() {
textField = new PTextField() {
OnTextChanged = OnTextChanged, ToolTip = LookInStrings(Tooltip),
Text = FieldText, MinWidth = 64, MaxLength = 10,
Type = PTextField.FieldType.Integer
}.Build();
Update();
return textField;
}
/// <summary>
/// Called when the slider's value is changed.
/// </summary>
/// <param name="newValue">The new slider value.</param>
private void OnSliderChanged(GameObject _, float newValue) {
int newIntValue = Mathf.RoundToInt(newValue);
if (limits != null)
newIntValue = limits.ClampToRange(newIntValue);
// Record the value
value = newIntValue;
Update();
}
/// <summary>
/// Called when the input field's text is changed.
/// </summary>
/// <param name="text">The new text.</param>
private void OnTextChanged(GameObject _, string text) {
if (string.IsNullOrWhiteSpace(text))
// Limits are assumed to allow null, because why not use non-nullable
// otherwise?
value = null;
else if (int.TryParse(text, out int newValue)) {
if (limits != null)
newValue = limits.ClampToRange(newValue);
// Record the valid value
value = newValue;
}
Update();
}
/// <summary>
/// Updates the displayed value.
/// </summary>
protected override void Update() {
var field = textField?.GetComponentInChildren<TMP_InputField>();
if (field != null)
field.text = FieldText;
if (slider != null && value != null)
PSliderSingle.SetCurrentValue(slider, (int)value);
}
}
}

View File

@@ -0,0 +1,80 @@
/*
* 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;
namespace PeterHan.PLib.Options {
/// <summary>
/// An attribute placed on an option property or enum value for a class used as mod options
/// in order to denote the display title and other options.
///
/// Options attributes will be recursively searched if a custom type is used for a property
/// with this attribute. If fields in that type have Option attributes, they will be
/// displayed under the category of their parent option (ignoring their own category
/// declaration).
/// </summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false,
Inherited = true)]
public sealed class OptionAttribute : Attribute, IOptionSpec {
public string Category { get; }
public string Format { get; set; }
public string Title { get; }
public string Tooltip { get; }
/// <summary>
/// Denotes a mod option field. Can also be used on members of an Enum type to give
/// them a friendly display name.
///
/// This overload will take the option strings from STRINGS, using the namespace of the
/// declaring type and the name of the property. A type declared in the MyName.
/// MyNamespace namespace with a property named TestProperty will get the title
/// STRINGS.MYNAME.MYNAMESPACE.OPTIONS.TESTPROPERTY.NAME, the tooltip
/// STRINGS.MYNAME.MYNAMESPACE.OPTIONS.TESTPROPERTY.TOOLTIP, and the category
/// STRINGS.MYNAME.MYNAMESPACE.OPTIONS.TESTPROPERTY.CATEGORY.
/// </summary>
public OptionAttribute() {
Format = null;
Title = null;
Tooltip = null;
Category = null;
}
/// <summary>
/// Denotes a mod option field. Can also be used on members of an Enum type to give
/// them a friendly display name.
/// </summary>
/// <param name="title">The field title to display.</param>
/// <param name="tooltip">The tool tip for the field.</param>
/// <param name="category">The category to use, or null for the default category.</param>
public OptionAttribute(string title, string tooltip = null, string category = null) {
if (string.IsNullOrEmpty(title))
throw new ArgumentNullException(nameof(title));
Category = category;
Format = null;
Title = title;
Tooltip = tooltip;
}
public override string ToString() {
return Title;
}
}
}

View File

@@ -0,0 +1,473 @@
/*
* 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 PeterHan.PLib.Core;
using PeterHan.PLib.UI;
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using UnityEngine;
using OptionsList = System.Collections.Generic.ICollection<PeterHan.PLib.Options.IOptionsEntry>;
namespace PeterHan.PLib.Options {
/// <summary>
/// A dialog for handling mod options events.
/// </summary>
internal sealed class OptionsDialog {
/// <summary>
/// The color of option category titles.
/// </summary>
private static readonly Color CATEGORY_TITLE_COLOR = new Color32(143, 150, 175, 255);
/// <summary>
/// The text style applied to option category titles.
/// </summary>
private static readonly TextStyleSetting CATEGORY_TITLE_STYLE;
/// <summary>
/// The margins inside the colored boxes in each config section.
/// </summary>
private static readonly int CATEGORY_MARGIN = 8;
/// <summary>
/// The size of the mod preview image displayed.
/// </summary>
private static readonly Vector2 MOD_IMAGE_SIZE = new Vector2(192.0f, 192.0f);
/// <summary>
/// The margins between the dialog edge and the colored boxes in each config section.
/// </summary>
private static readonly int OUTER_MARGIN = 10;
/// <summary>
/// The default size of the Mod Settings dialog.
/// </summary>
private static readonly Vector2 SETTINGS_DIALOG_SIZE = new Vector2(320.0f, 200.0f);
/// <summary>
/// The maximum size of the Mod Settings dialog before it gets scroll bars.
/// </summary>
private static readonly Vector2 SETTINGS_DIALOG_MAX_SIZE = new Vector2(800.0f, 600.0f);
/// <summary>
/// The size of the toggle button on each (non-default) config section.
/// </summary>
private static readonly Vector2 TOGGLE_SIZE = new Vector2(12.0f, 12.0f);
static OptionsDialog() {
CATEGORY_TITLE_STYLE = PUITuning.Fonts.UILightStyle.DeriveStyle(newColor:
CATEGORY_TITLE_COLOR, style: TMPro.FontStyles.Bold);
}
/// <summary>
/// Creates an options object using the default constructor if possible.
/// </summary>
/// <param name="type">The type of the object to create.</param>
internal static object CreateOptions(Type type) {
object result = null;
try {
var cons = type.GetConstructor(Type.EmptyTypes);
if (cons != null)
result = cons.Invoke(null);
} catch (TargetInvocationException e) {
// Other mod's error
PUtil.LogExcWarn(e.GetBaseException());
} catch (AmbiguousMatchException e) {
// Other mod's error
PUtil.LogException(e);
} catch (MemberAccessException e) {
// Other mod's error
PUtil.LogException(e);
}
return result;
}
/// <summary>
/// Saves the mod enabled settings and restarts the game.
/// </summary>
private static void SaveAndRestart() {
#if OPTIONS_ONLY
POptionsPatches.SaveMods();
#else
PGameUtils.SaveMods();
#endif
App.instance.Restart();
}
/// <summary>
/// If true, all categories begin collapsed.
/// </summary>
private readonly bool collapseCategories;
/// <summary>
/// The config file attribute for the options type, if present.
/// </summary>
private readonly ConfigFileAttribute configAttr;
/// <summary>
/// The currently active dialog.
/// </summary>
private KScreen dialog;
/// <summary>
/// The sprite to display for this mod.
/// </summary>
private Sprite modImage;
/// <summary>
/// Collects information from the ModInfoAttribute and KMod.Mod objects for display.
/// </summary>
private readonly ModDialogInfo displayInfo;
/// <summary>
/// The event to invoke when the dialog is closed.
/// </summary>
public Action<object> OnClose { get; set; }
/// <summary>
/// The option entries in the dialog.
/// </summary>
private readonly IDictionary<string, OptionsList> optionCategories;
/// <summary>
/// The options read from the config. It might contain hidden options so preserve its
/// contents here.
/// </summary>
private object options;
/// <summary>
/// The type used to determine which options are visible.
/// </summary>
private readonly Type optionsType;
internal OptionsDialog(Type optionsType) {
OnClose = null;
dialog = null;
modImage = null;
this.optionsType = optionsType ?? throw new ArgumentNullException(nameof(
optionsType));
optionCategories = OptionsEntry.BuildOptions(optionsType);
options = null;
// Determine config location
var infoAttr = optionsType.GetCustomAttribute<ModInfoAttribute>();
collapseCategories = infoAttr != null && infoAttr.ForceCollapseCategories;
configAttr = optionsType.GetCustomAttribute<ConfigFileAttribute>();
displayInfo = new ModDialogInfo(optionsType, infoAttr?.URL, infoAttr?.Image);
}
/// <summary>
/// Adds a category header to the dialog.
/// </summary>
/// <param name="container">The parent of the header.</param>
/// <param name="category">The header title.</param>
/// <param name="contents">The panel containing the options in this category.</param>
private void AddCategoryHeader(PGridPanel container, string category,
PGridPanel contents) {
contents.AddColumn(new GridColumnSpec(flex: 1.0f)).AddColumn(new GridColumnSpec());
if (!string.IsNullOrEmpty(category)) {
bool state = !collapseCategories;
var handler = new CategoryExpandHandler(state);
container.AddColumn(new GridColumnSpec()).AddColumn(new GridColumnSpec(
flex: 1.0f)).AddRow(new GridRowSpec()).AddRow(new GridRowSpec(flex: 1.0f));
// Toggle is upper left, header is upper right
container.AddChild(new PLabel("CategoryHeader") {
Text = OptionsEntry.LookInStrings(category), TextStyle =
CATEGORY_TITLE_STYLE, TextAlignment = TextAnchor.LowerCenter
}.AddOnRealize(handler.OnRealizeHeader), new GridComponentSpec(0, 1) {
Margin = new RectOffset(OUTER_MARGIN, OUTER_MARGIN, 0, 0)
}).AddChild(new PToggle("CategoryToggle") {
Color = PUITuning.Colors.ComponentDarkStyle, InitialState = state,
ToolTip = PLibStrings.TOOLTIP_TOGGLE, Size = TOGGLE_SIZE,
OnStateChanged = handler.OnExpandContract
}.AddOnRealize(handler.OnRealizeToggle), new GridComponentSpec(0, 0));
contents.OnRealize += handler.OnRealizePanel;
container.AddChild(contents, new GridComponentSpec(1, 0) { ColumnSpan = 2 });
} else
// Default of unconstrained fills the whole panel
container.AddColumn(new GridColumnSpec(flex: 1.0f)).AddRow(new GridRowSpec(
flex: 1.0f)).AddChild(contents, new GridComponentSpec(0, 0));
}
/// <summary>
/// Fills in the mod info screen, assuming that infoAttr is non-null.
/// </summary>
/// <param name="optionsDialog">The dialog to populate.</param>
private void AddModInfoScreen(PDialog optionsDialog) {
string image = displayInfo.Image;
var body = optionsDialog.Body;
// Try to load the mod image sprite if possible
if (modImage == null && !string.IsNullOrEmpty(image)) {
string rootDir = PUtil.GetModPath(optionsType.Assembly);
modImage = PUIUtils.LoadSpriteFile(rootDir == null ? image : Path.Combine(
rootDir, image));
}
var websiteButton = new PButton("ModSite") {
Text = PLibStrings.MOD_HOMEPAGE, ToolTip = PLibStrings.TOOLTIP_HOMEPAGE,
OnClick = VisitModHomepage, Margin = PDialog.BUTTON_MARGIN
}.SetKleiBlueStyle();
var versionLabel = new PLabel("ModVersion") {
Text = displayInfo.Version, ToolTip = PLibStrings.TOOLTIP_VERSION,
TextStyle = PUITuning.Fonts.UILightStyle, Margin = new RectOffset(0, 0,
OUTER_MARGIN, 0)
};
// Find mod URL
string modURL = displayInfo.URL;
if (modImage != null) {
// 2 rows and 1 column
if (optionCategories.Count > 0)
body.Direction = PanelDirection.Horizontal;
var infoPanel = new PPanel("ModInfo") {
FlexSize = Vector2.up, Direction = PanelDirection.Vertical,
Alignment = TextAnchor.UpperCenter
}.AddChild(new PLabel("ModImage") {
SpriteSize = MOD_IMAGE_SIZE, TextAlignment = TextAnchor.UpperLeft,
Margin = new RectOffset(0, OUTER_MARGIN, 0, OUTER_MARGIN),
Sprite = modImage
});
if (!string.IsNullOrEmpty(modURL))
infoPanel.AddChild(websiteButton);
body.AddChild(infoPanel.AddChild(versionLabel));
} else {
if (!string.IsNullOrEmpty(modURL))
body.AddChild(websiteButton);
body.AddChild(versionLabel);
}
}
/// <summary>
/// Checks the mod config class for the [RestartRequired] attribute, and brings up a
/// restart dialog if necessary.
/// </summary>
private void CheckForRestart() {
if (options != null && options.GetType().GetCustomAttribute(typeof(
RestartRequiredAttribute)) != null)
// Prompt user to restart
PUIElements.ShowConfirmDialog(null, PLibStrings.RESTART_REQUIRED,
SaveAndRestart, null, PLibStrings.RESTART_OK, PLibStrings.RESTART_CANCEL);
}
/// <summary>
/// Closes the current dialog.
/// </summary>
private void CloseDialog() {
if (dialog != null) {
dialog.Deactivate();
// dialog's game object is destroyed by Deactivate()
dialog = null;
}
if (modImage != null) {
UnityEngine.Object.Destroy(modImage);
modImage = null;
}
}
/// <summary>
/// Fills in the actual mod option fields.
/// </summary>
/// <param name="optionsDialog">The dialog to populate.</param>
private void FillModOptions(PDialog optionsDialog) {
IEnumerable<IOptionsEntry> dynamicOptions;
var body = optionsDialog.Body;
var margin = new RectOffset(CATEGORY_MARGIN, CATEGORY_MARGIN, CATEGORY_MARGIN,
CATEGORY_MARGIN);
// For each option, add its UI component to panel
body.Margin = new RectOffset();
var scrollBody = new PPanel("ScrollContent") {
Spacing = OUTER_MARGIN, Direction = PanelDirection.Vertical, Alignment =
TextAnchor.UpperCenter, FlexSize = Vector2.right
};
var allOptions = optionCategories;
// Add options from the user's class
if (options is IOptions dynOptions && (dynamicOptions = dynOptions.
CreateOptions()) != null) {
allOptions = new Dictionary<string, OptionsList>(optionCategories);
foreach (var dynamicOption in dynamicOptions)
OptionsEntry.AddToCategory(allOptions, dynamicOption);
}
// Display all categories
foreach (var catEntries in allOptions) {
string category = catEntries.Key;
var optionsList = catEntries.Value;
if (optionsList.Count > 0) {
string name = string.IsNullOrEmpty(category) ? "Default" : category;
int i = 0;
// Not optimal for layout performance, but the panel is needed to have a
// different background color for each category "box"
var container = new PGridPanel("Category_" + name) {
Margin = margin, BackColor = PUITuning.Colors.DialogDarkBackground,
FlexSize = Vector2.right
};
// Needs to be a separate panel so that it can be collapsed
var contents = new PGridPanel("Entries") { FlexSize = Vector2.right };
AddCategoryHeader(container, catEntries.Key, contents);
foreach (var entry in optionsList) {
contents.AddRow(new GridRowSpec());
entry.CreateUIEntry(contents, ref i);
i++;
}
scrollBody.AddChild(container);
}
}
// Manual config and reset button
scrollBody.AddChild(new PPanel("ConfigButtons") {
Spacing = 10, Direction = PanelDirection.Horizontal, Alignment =
TextAnchor.MiddleCenter, FlexSize = Vector2.right
}.AddChild(new PButton("ManualConfig") {
Text = PLibStrings.BUTTON_MANUAL, ToolTip = PLibStrings.TOOLTIP_MANUAL,
OnClick = OnManualConfig, TextAlignment = TextAnchor.MiddleCenter, Margin =
PDialog.BUTTON_MARGIN
}.SetKleiBlueStyle()).AddChild(new PButton("ResetConfig") {
Text = PLibStrings.BUTTON_RESET, ToolTip = PLibStrings.TOOLTIP_RESET,
OnClick = OnResetConfig, TextAlignment = TextAnchor.MiddleCenter, Margin =
PDialog.BUTTON_MARGIN
}.SetKleiBlueStyle()));
body.AddChild(new PScrollPane() {
ScrollHorizontal = false, ScrollVertical = allOptions.Count > 0,
Child = scrollBody, FlexSize = Vector2.right, TrackSize = 8,
AlwaysShowHorizontal = false, AlwaysShowVertical = false
});
}
/// <summary>
/// Invoked when the manual config button is pressed.
/// </summary>
private void OnManualConfig(GameObject _) {
string uri = null, path = POptions.GetConfigFilePath(optionsType);
try {
uri = new Uri(Path.GetDirectoryName(path) ?? path).AbsoluteUri;
} catch (UriFormatException e) {
PUtil.LogWarning("Unable to convert parent of " + path + " to a URI:");
PUtil.LogExcWarn(e);
}
if (!string.IsNullOrEmpty(uri)) {
// Open the config folder, opening the file itself might start an unknown
// editor which could execute the json somehow...
WriteOptions();
CloseDialog();
PUtil.LogDebug("Opening config folder: " + uri);
Application.OpenURL(uri);
CheckForRestart();
}
}
/// <summary>
/// Invoked when the dialog is closed.
/// </summary>
/// <param name="action">The action key taken.</param>
private void OnOptionsSelected(string action) {
switch (action) {
case "ok":
// Save changes to mod options
WriteOptions();
CheckForRestart();
break;
case PDialog.DIALOG_KEY_CLOSE:
OnClose?.Invoke(options);
break;
}
}
/// <summary>
/// Invoked when the reset to default button is pressed.
/// </summary>
private void OnResetConfig(GameObject _) {
options = CreateOptions(optionsType);
UpdateOptions();
}
/// <summary>
/// Triggered when the Mod Options button is clicked.
/// </summary>
public void ShowDialog() {
string title;
if (string.IsNullOrEmpty(displayInfo.Title))
title = PLibStrings.BUTTON_OPTIONS;
else
title = string.Format(PLibStrings.DIALOG_TITLE, OptionsEntry.LookInStrings(
displayInfo.Title));
// Close current dialog if open
CloseDialog();
// Ensure that it is on top of other screens (which may be +100 modal)
var pDialog = new PDialog("ModOptions") {
Title = title, Size = SETTINGS_DIALOG_SIZE, SortKey = 150.0f,
DialogBackColor = PUITuning.Colors.OptionsBackground,
DialogClosed = OnOptionsSelected, MaxSize = SETTINGS_DIALOG_MAX_SIZE,
RoundToNearestEven = true
}.AddButton("ok", STRINGS.UI.CONFIRMDIALOG.OK, PLibStrings.TOOLTIP_OK,
PUITuning.Colors.ButtonPinkStyle).AddButton(PDialog.DIALOG_KEY_CLOSE,
STRINGS.UI.CONFIRMDIALOG.CANCEL, PLibStrings.TOOLTIP_CANCEL,
PUITuning.Colors.ButtonBlueStyle);
options = POptions.ReadSettings(POptions.GetConfigFilePath(optionsType),
optionsType) ?? CreateOptions(optionsType);
AddModInfoScreen(pDialog);
FillModOptions(pDialog);
// Manually build the dialog so the options can be updated after realization
var obj = pDialog.Build();
UpdateOptions();
if (obj.TryGetComponent(out dialog))
dialog.Activate();
}
/// <summary>
/// Calls the user OnOptionsChanged handler if present.
/// </summary>
/// <param name="newOptions">The updated options object.</param>
private void TriggerUpdateOptions(object newOptions) {
// Call the user handler
if (newOptions is IOptions onChanged)
onChanged.OnOptionsChanged();
OnClose?.Invoke(newOptions);
}
/// <summary>
/// Updates the dialog with the latest options from the file.
/// </summary>
private void UpdateOptions() {
// Read into local options
if (options != null)
foreach (var catEntries in optionCategories)
foreach (var option in catEntries.Value)
option.ReadFrom(options);
}
/// <summary>
/// If configured, opens the mod's home page in the default browser.
/// </summary>
private void VisitModHomepage(GameObject _) {
if (!string.IsNullOrWhiteSpace(displayInfo.URL))
Application.OpenURL(displayInfo.URL);
}
/// <summary>
/// Writes the mod options to its config file.
/// </summary>
private void WriteOptions() {
if (options != null) {
// Update from local options
foreach (var catEntries in optionCategories)
foreach (var option in catEntries.Value)
option.WriteTo(options);
POptions.WriteSettings(options, POptions.GetConfigFilePath(optionsType),
configAttr?.IndentOutput ?? false);
TriggerUpdateOptions(options);
}
}
}
}

View File

@@ -0,0 +1,383 @@
/*
* 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 PeterHan.PLib.UI;
using System;
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
using OptionsList = System.Collections.Generic.ICollection<PeterHan.PLib.Options.IOptionsEntry>;
using AllOptions = System.Collections.Generic.IDictionary<string, System.Collections.Generic.
ICollection<PeterHan.PLib.Options.IOptionsEntry>>;
using PeterHan.PLib.Core;
namespace PeterHan.PLib.Options {
/// <summary>
/// An abstract parent class containing methods shared by all built-in options handlers.
/// </summary>
public abstract class OptionsEntry : IOptionsEntry, IComparable<OptionsEntry>,
IUIComponent {
private const BindingFlags INSTANCE_PUBLIC = BindingFlags.Public | BindingFlags.
Instance;
/// <summary>
/// The margins around the control used in each entry.
/// </summary>
protected static readonly RectOffset CONTROL_MARGIN = new RectOffset(0, 0, 2, 2);
/// <summary>
/// The margins around the label for each entry.
/// </summary>
protected static readonly RectOffset LABEL_MARGIN = new RectOffset(0, 5, 2, 2);
/// <summary>
/// Adds an options entry to the category list, creating a new category if necessary.
/// </summary>
/// <param name="entries">The existing categories.</param>
/// <param name="entry">The option entry to add.</param>
internal static void AddToCategory(AllOptions entries, IOptionsEntry entry) {
string category = entry.Category ?? "";
if (!entries.TryGetValue(category, out OptionsList inCat)) {
inCat = new List<IOptionsEntry>(16);
entries.Add(category, inCat);
}
inCat.Add(entry);
}
/// <summary>
/// Builds the options entries from the type.
/// </summary>
/// <param name="forType">The type of the options class.</param>
/// <returns>A list of all public properties annotated for options dialogs.</returns>
internal static AllOptions BuildOptions(Type forType) {
var entries = new SortedList<string, OptionsList>(8);
OptionsHandlers.InitPredefinedOptions();
foreach (var prop in forType.GetProperties(INSTANCE_PUBLIC)) {
// Must have the annotation
var entry = TryCreateEntry(prop, 0);
if (entry != null)
AddToCategory(entries, entry);
}
return entries;
}
/// <summary>
/// Creates a default UI entry. This entry will have the title and tool tip in the
/// first column, and the provided UI component in the second column. Only one row is
/// added by this method.
/// </summary>
/// <param name="entry">The options entry to be presented.</param>
/// <param name="parent">The parent where the components will be added.</param>
/// <param name="row">The row index where the components will be added.</param>
/// <param name="presenter">The presenter that can display this option's value.</param>
public static void CreateDefaultUIEntry(IOptionsEntry entry, PGridPanel parent,
int row, IUIComponent presenter) {
parent.AddChild(new PLabel("Label") {
Text = LookInStrings(entry.Title), ToolTip = LookInStrings(entry.Tooltip),
TextStyle = PUITuning.Fonts.TextLightStyle
}, new GridComponentSpec(row, 0) {
Margin = LABEL_MARGIN, Alignment = TextAnchor.MiddleLeft
});
parent.AddChild(presenter, new GridComponentSpec(row, 1) {
Alignment = TextAnchor.MiddleRight, Margin = CONTROL_MARGIN
});
}
/// <summary>
/// Creates a dynamic options entry.
/// </summary>
/// <param name="prop">The property to be created.</param>
/// <param name="handler">The type which can handle the property.</param>
/// <returns>The created entry, or null if no entry could be created.</returns>
private static IOptionsEntry CreateDynamicOption(PropertyInfo prop, Type handler) {
IOptionsEntry result = null;
var constructors = handler.GetConstructors(BindingFlags.Public | BindingFlags.
Instance);
string name = prop.Name;
int n = constructors.Length;
for (int i = 0; i < n && result == null; i++)
try {
if (ExecuteConstructor(prop, constructors[i]) is IOptionsEntry entry)
result = entry;
} catch (TargetInvocationException e) {
PUtil.LogError("Unable to create option handler for property " +
name + ":");
PUtil.LogException(e.GetBaseException());
} catch (MemberAccessException) {
// Should never happen, filtered to public and instance
} catch (AmbiguousMatchException) {
} catch (TypeLoadException e) {
PUtil.LogError("Unable to instantiate option handler for property " +
name + ":");
PUtil.LogException(e.GetBaseException());
}
if (result == null)
PUtil.LogWarning("Unable to create option handler for property " +
name + ", it must have a public constructor");
return result;
}
/// <summary>
/// Runs a dynamic option constructor.
/// </summary>
/// <param name="prop">The property to be created.</param>
/// <param name="cons">The constructor to run.</param>
/// <returns>The constructed dynamic option.</returns>
private static object ExecuteConstructor(PropertyInfo prop, ConstructorInfo cons) {
object result;
var parameters = cons.GetParameters();
int p = parameters.Length;
if (p == 0)
// Public default
result = cons.Invoke(null);
else {
var values = new object[p];
for (int j = 0; j < p; j++) {
var targetType = parameters[j].ParameterType;
// Custom attribute
if (typeof(Attribute).IsAssignableFrom(targetType))
values[j] = prop.GetCustomAttribute(targetType);
else if (targetType == typeof(IOptionSpec))
values[j] = prop.GetCustomAttribute<OptionAttribute>();
else if (targetType == typeof(string))
values[j] = prop.Name;
else
PUtil.LogWarning("DynamicOption cannot handle constructor parameter of type " +
targetType.FullName);
}
result = cons.Invoke(values);
}
return result;
}
/// <summary>
/// Substitutes default strings for an options entry with an empty title.
/// </summary>
/// <param name="spec">The option attribute supplied (Format is still accepted!)</param>
/// <param name="member">The item declaring the attribute.</param>
/// <returns>A substitute attribute with default values from STRINGS.</returns>
internal static IOptionSpec HandleDefaults(IOptionSpec spec, MemberInfo member) {
// Replace with entries takem from the strings
string prefix = "STRINGS.{0}.OPTIONS.{1}.".F(member.DeclaringType?.
Namespace?.ToUpperInvariant(), member.Name.ToUpperInvariant());
string category = "";
if (Strings.TryGet(prefix + "CATEGORY", out var entry))
category = entry.String;
return new OptionAttribute(prefix + "NAME", prefix + "TOOLTIP", category) {
Format = spec.Format
};
}
/// <summary>
/// First looks to see if the string exists in the string database; if it does, returns
/// the localized value, otherwise returns the string unmodified.
///
/// This method is somewhat slow. Cache the result if possible.
/// </summary>
/// <param name="keyOrValue">The string key to check.</param>
/// <returns>The string value with that key, or the key if there is no such localized
/// string value.</returns>
public static string LookInStrings(string keyOrValue) {
string result = keyOrValue;
if (!string.IsNullOrEmpty(keyOrValue) && Strings.TryGet(keyOrValue, out var
entry))
result = entry.String;
return result;
}
/// <summary>
/// Shared code to create an options entry if an [Option] attribute is found on a
/// property.
/// </summary>
/// <param name="prop">The property to inspect.</param>
/// <param name="depth">The current depth of iteration to avoid infinite loops.</param>
/// <returns>The OptionsEntry created, or null if none was.</returns>
internal static IOptionsEntry TryCreateEntry(PropertyInfo prop, int depth) {
IOptionsEntry result = null;
// Must have the annotation, cannot be indexed
var indexes = prop.GetIndexParameters();
if (indexes.Length < 1) {
var attributes = ListPool<Attribute, OptionsEntry>.Allocate();
bool dlcMatch = true;
attributes.AddRange(prop.GetCustomAttributes());
int n = attributes.Count;
for (int i = 0; i < n; i++)
// Do not create an entry if the DLC does not match
if (attributes[i] is RequireDLCAttribute requireDLC && DlcManager.
IsContentActive(requireDLC.DlcID) != requireDLC.Required) {
dlcMatch = false;
break;
}
if (dlcMatch)
for (int i = 0; i < n; i++) {
result = TryCreateEntry(attributes[i], prop, depth);
if (result != null)
break;
}
attributes.Recycle();
}
return result;
}
/// <summary>
/// Creates an options entry if an attribute is a valid IOptionSpec or
/// DynamicOptionAttribute.
/// </summary>
/// <param name="attribute">The attribute to parse.</param>
/// <param name="prop">The property to inspect.</param>
/// <param name="depth">The current depth of iteration to avoid infinite loops.</param>
/// <returns>The OptionsEntry created from the attribute, or null if none was.</returns>
private static IOptionsEntry TryCreateEntry(Attribute attribute, PropertyInfo prop,
int depth) {
IOptionsEntry result = null;
if (prop == null)
throw new ArgumentNullException(nameof(prop));
if (attribute is IOptionSpec spec) {
if (string.IsNullOrEmpty(spec.Title))
spec = HandleDefaults(spec, prop);
// Attempt to find a class that will represent it
var type = prop.PropertyType;
result = OptionsHandlers.FindOptionClass(spec, prop);
// See if it has entries that can themselves be added, ignore
// value types and avoid infinite recursion
if (result == null && !type.IsValueType && depth < 16 && type !=
prop.DeclaringType)
result = CompositeOptionsEntry.Create(spec, prop, depth);
} else if (attribute is DynamicOptionAttribute doa &&
typeof(IOptionsEntry).IsAssignableFrom(doa.Handler))
result = CreateDynamicOption(prop, doa.Handler);
return result;
}
/// <summary>
/// The category for this entry.
/// </summary>
public string Category { get; }
/// <summary>
/// The option field name.
/// </summary>
public string Field { get; }
/// <summary>
/// The format string to use when rendering this option, or null if none was supplied.
/// </summary>
public string Format { get; }
public virtual string Name => nameof(OptionsEntry);
public event PUIDelegates.OnRealize OnRealize;
/// <summary>
/// The option title on screen.
/// </summary>
public string Title { get; protected set; }
/// <summary>
/// The tool tip to display.
/// </summary>
public string Tooltip { get; protected set; }
/// <summary>
/// The current value selected by the user.
/// </summary>
public abstract object Value { get; set; }
protected OptionsEntry(string field, IOptionSpec attr) {
if (attr == null)
throw new ArgumentNullException(nameof(attr));
Field = field;
Format = attr.Format;
Title = attr.Title ?? throw new ArgumentException("attr.Title is null");
Tooltip = attr.Tooltip;
Category = attr.Category;
}
public GameObject Build() {
var comp = GetUIComponent();
OnRealize?.Invoke(comp);
return comp;
}
public int CompareTo(OptionsEntry other) {
if (other == null)
throw new ArgumentNullException(nameof(other));
return string.Compare(Category, other.Category, StringComparison.
CurrentCultureIgnoreCase);
}
/// <summary>
/// Adds the line item entry for this options entry.
/// </summary>
/// <param name="parent">The location to add this entry.</param>
/// <param name="row">The layout row index to use. If updated, the row index will
/// continue to count up from the new value.</param>
public virtual void CreateUIEntry(PGridPanel parent, ref int row) {
CreateDefaultUIEntry(this, parent, row, this);
}
/// <summary>
/// Retrieves the UI component which can alter this setting. It should be sized
/// properly to display any of the valid settings. The actual value will be set after
/// the component is realized.
/// </summary>
/// <returns>The UI component to display.</returns>
public abstract GameObject GetUIComponent();
public virtual void ReadFrom(object settings) {
if (Field != null && settings != null)
try {
var prop = settings.GetType().GetProperty(Field, INSTANCE_PUBLIC);
if (prop != null && prop.CanRead)
Value = prop.GetValue(settings, null);
} catch (TargetInvocationException e) {
// Other mod's error
PUtil.LogException(e);
} catch (AmbiguousMatchException e) {
// Other mod's error
PUtil.LogException(e);
} catch (InvalidCastException e) {
// Our error!
PUtil.LogException(e);
}
}
public override string ToString() {
return "{1}[field={0},title={2}]".F(Field, GetType().Name, Title);
}
public virtual void WriteTo(object settings) {
if (Field != null && settings != null)
try {
var prop = settings.GetType().GetProperty(Field, INSTANCE_PUBLIC);
if (prop != null && prop.CanWrite)
prop.SetValue(settings, Value, null);
} catch (TargetInvocationException e) {
// Other mod's error
PUtil.LogException(e);
} catch (AmbiguousMatchException e) {
// Other mod's error
PUtil.LogException(e);
} catch (InvalidCastException e) {
// Our error!
PUtil.LogException(e);
}
}
}
}

View File

@@ -0,0 +1,151 @@
/*
* 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 System.Reflection;
using PeterHan.PLib.Core;
using PeterHan.PLib.Detours;
using UnityEngine;
namespace PeterHan.PLib.Options {
/// <summary>
/// Registers types to options entry classes that can handle them.
/// </summary>
public static class OptionsHandlers {
private delegate IOptionsEntry CreateOption(string field, IOptionSpec spec);
private delegate IOptionsEntry CreateOptionType(string field, IOptionSpec spec,
Type fieldType);
private delegate IOptionsEntry CreateOptionLimit(string field, IOptionSpec spec,
LimitAttribute limit);
/// <summary>
/// Maps types to the constructor delegate that can create an options entry for them.
/// </summary>
private static readonly IDictionary<Type, Delegate> OPTIONS_HANDLERS =
new Dictionary<Type, Delegate>(64);
/// <summary>
/// Adds a custom type to handle all options entries of a specific type. The change
/// will only affect this mod's options.
/// </summary>
/// <param name="optionType">The property type to be handled.</param>
/// <param name="handlerType">The type which will handle all option attributes of
/// this type. It must subclass from IOptionsEntry and have a constructor of the
/// signature HandlerType(string, IOptionSpec).</param>
public static void AddOptionClass(Type optionType, Type handlerType) {
if (optionType != null && handlerType != null && !OPTIONS_HANDLERS.
ContainsKey(optionType) && typeof(IOptionsEntry).IsAssignableFrom(
handlerType)) {
var constructors = handlerType.GetConstructors();
int n = constructors.Length;
for (int i = 0; i < n; i++) {
var del = CreateDelegate(handlerType, constructors[i]);
if (del != null) {
OPTIONS_HANDLERS[optionType] = del;
break;
}
}
}
}
/// <summary>
/// If a candidate options entry constructor is valid, creates a delegate which can
/// call the constructor.
/// </summary>
/// <param name="constructor">The constructor to wrap.</param>
/// <param name="handlerType">The type which will handle all option attributes of this type.</param>
/// <returns>If the constructor can be used to create an options entry, a delegate
/// which calls the constructor using one of the delegate types declared in this class;
/// otherwise, null.</returns>
private static Delegate CreateDelegate(Type handlerType, ConstructorInfo constructor) {
var param = constructor.GetParameters();
int n = param.Length;
Delegate result = null;
// Must begin with string, IOptionsSpec
if (n > 1 && param[0].ParameterType.IsAssignableFrom(typeof(string)) &&
param[1].ParameterType.IsAssignableFrom(typeof(IOptionSpec))) {
switch (n) {
case 2:
result = constructor.Detour<CreateOption>();
break;
case 3:
var extraType = param[2].ParameterType;
if (extraType.IsAssignableFrom(typeof(LimitAttribute)))
result = constructor.Detour<CreateOptionLimit>();
else if (extraType.IsAssignableFrom(typeof(Type)))
result = constructor.Detour<CreateOptionType>();
break;
default:
PUtil.LogWarning("Constructor on options handler type " + handlerType +
" cannot be constructed by OptionsHandlers");
break;
}
}
return result;
}
/// <summary>
/// Creates an options entry wrapper for the specified property.
/// </summary>
/// <param name="info">The property to wrap.</param>
/// <param name="spec">The option title and tool tip.</param>
/// <returns>An options wrapper, or null if none can handle this type.</returns>
public static IOptionsEntry FindOptionClass(IOptionSpec spec, PropertyInfo info) {
IOptionsEntry entry = null;
if (spec != null && info != null) {
var type = info.PropertyType;
string field = info.Name;
if (type.IsEnum)
// Enumeration type
entry = new SelectOneOptionsEntry(field, spec, type);
else if (OPTIONS_HANDLERS.TryGetValue(type, out var handler)) {
if (handler is CreateOption createOption)
entry = createOption.Invoke(field, spec);
else if (handler is CreateOptionLimit createOptionLimit)
entry = createOptionLimit.Invoke(field, spec, info.
GetCustomAttribute<LimitAttribute>());
else if (handler is CreateOptionType createOptionType)
entry = createOptionType.Invoke(field, spec, type);
}
}
return entry;
}
/// <summary>
/// Adds the predefined options classes.
/// </summary>
internal static void InitPredefinedOptions() {
if (OPTIONS_HANDLERS.Count < 1) {
AddOptionClass(typeof(bool), typeof(CheckboxOptionsEntry));
AddOptionClass(typeof(int), typeof(IntOptionsEntry));
AddOptionClass(typeof(int?), typeof(NullableIntOptionsEntry));
AddOptionClass(typeof(float), typeof(FloatOptionsEntry));
AddOptionClass(typeof(float?), typeof(NullableFloatOptionsEntry));
AddOptionClass(typeof(Color32), typeof(Color32OptionsEntry));
AddOptionClass(typeof(Color), typeof(ColorOptionsEntry));
AddOptionClass(typeof(int), typeof(IntOptionsEntry));
AddOptionClass(typeof(Action<object>), typeof(ButtonOptionsEntry));
AddOptionClass(typeof(int), typeof(IntOptionsEntry));
AddOptionClass(typeof(LocText), typeof(TextBlockOptionsEntry));
}
}
}
}

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Title>PLib Options</Title>
<AssemblyTitle>PLib.Options</AssemblyTitle>
<Version>4.11.0.0</Version>
<UsesPLib>false</UsesPLib>
<RootNamespace>PeterHan.PLib.Options</RootNamespace>
<AssemblyVersion>4.11.0.0</AssemblyVersion>
<DistributeMod>false</DistributeMod>
<PLibCore>true</PLibCore>
<Platforms>Vanilla;Mergedown</Platforms>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DocumentationFile>bin\$(Platform)\Release\PLibOptions.xml</DocumentationFile>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../PLibCore/PLibCore.csproj" />
<ProjectReference Include="../PLibUI/PLibUI.csproj" />
</ItemGroup>
</Project>

405
mod/PLibOptions/POptions.cs Normal file
View File

@@ -0,0 +1,405 @@
/*
* 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 HarmonyLib;
using Newtonsoft.Json;
using PeterHan.PLib.Core;
using PeterHan.PLib.UI;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Reflection;
using UnityEngine;
namespace PeterHan.PLib.Options {
/// <summary>
/// Adds an "Options" screen to a mod in the Mods menu.
/// </summary>
public sealed class POptions : PForwardedComponent {
/// <summary>
/// The configuration file name used by default for classes that do not specify
/// otherwise. This file name is case sensitive.
/// </summary>
public const string CONFIG_FILE_NAME = "config.json";
/// <summary>
/// The maximum nested class depth which will be serialized in mod options to avoid
/// infinite loops.
/// </summary>
public const int MAX_SERIALIZATION_DEPTH = 8;
/// <summary>
/// The margins around the Options button.
/// </summary>
internal static readonly RectOffset OPTION_BUTTON_MARGIN = new RectOffset(11, 11, 5, 5);
/// <summary>
/// The shared mod configuration folder, which works between archived versions and
/// local/dev/Steam.
/// </summary>
public const string SHARED_CONFIG_FOLDER = "config";
/// <summary>
/// The version of this component. Uses the running PLib version.
/// </summary>
internal static readonly Version VERSION = new Version(PVersion.VERSION);
/// <summary>
/// The instantiated copy of this class.
/// </summary>
internal static POptions Instance { get; private set; }
/// <summary>
/// Applied to ModsScreen if mod options are registered, after BuildDisplay runs.
/// </summary>
private static void BuildDisplay_Postfix(GameObject ___entryPrefab,
System.Collections.IEnumerable ___displayedMods) {
if (Instance != null) {
int index = 0;
// Harmony does not check the type at all on accessing private fields with ___
foreach (var displayedMod in ___displayedMods)
Instance.AddModOptions(displayedMod, index++, ___entryPrefab);
}
}
/// <summary>
/// Retrieves the configuration file path used by PLib Options for a specified type.
/// </summary>
/// <param name="optionsType">The options type stored in the config file.</param>
/// <returns>The path to the configuration file that will be used by PLib for that
/// mod's config.</returns>
public static string GetConfigFilePath(Type optionsType) {
return GetConfigPath(optionsType.GetCustomAttribute<ConfigFileAttribute>(),
optionsType.Assembly);
}
/// <summary>
/// Attempts to find the mod which owns the specified type.
/// </summary>
/// <param name="optionsType">The type to look up.</param>
/// <returns>The Mod that owns it, or null if no owning mod could be found, such as for
/// types in System or Assembly-CSharp.</returns>
internal static KMod.Mod GetModFromType(Type optionsType) {
if (optionsType == null)
throw new ArgumentNullException(nameof(optionsType));
var sd = PRegistry.Instance.GetSharedData(typeof(POptions).FullName);
// Look up mod in the shared data
if (!(sd is IDictionary<Assembly, KMod.Mod> lookup) || !lookup.TryGetValue(
optionsType.Assembly, out KMod.Mod result))
result = null;
return result;
}
/// <summary>
/// Retrieves the configuration file path used by PLib Options for a specified type.
/// </summary>
/// <param name="attr">The config file attribute for that type.</param>
/// <param name="modAssembly">The assembly to use for determining the path.</param>
/// <returns>The path to the configuration file that will be used by PLib for that
/// mod's config.</returns>
private static string GetConfigPath(ConfigFileAttribute attr, Assembly modAssembly) {
string path, name = modAssembly.GetNameSafe();
path = (name != null && (attr?.UseSharedConfigLocation == true)) ?
Path.Combine(KMod.Manager.GetDirectory(), SHARED_CONFIG_FOLDER, name) :
PUtil.GetModPath(modAssembly);
return Path.Combine(path, attr?.ConfigFileName ?? CONFIG_FILE_NAME);
}
/// <summary>
/// Reads a mod's settings from its configuration file. The assembly defining T is used
/// to resolve the proper settings folder.
/// </summary>
/// <typeparam name="T">The type of the settings object.</typeparam>
/// <returns>The settings read, or null if they could not be read (e.g. newly installed).</returns>
public static T ReadSettings<T>() where T : class {
var type = typeof(T);
return ReadSettings(GetConfigPath(type.GetCustomAttribute<ConfigFileAttribute>(),
type.Assembly), type) as T;
}
/// <summary>
/// Reads a mod's settings from its configuration file.
/// </summary>
/// <param name="path">The path to the settings file.</param>
/// <param name="optionsType">The options type.</param>
/// <returns>The settings read, or null if they could not be read (e.g. newly installed)</returns>
internal static object ReadSettings(string path, Type optionsType) {
object options = null;
try {
using (var jr = new JsonTextReader(File.OpenText(path))) {
var serializer = new JsonSerializer { MaxDepth = MAX_SERIALIZATION_DEPTH };
// Deserialize from stream avoids reading file text into memory
options = serializer.Deserialize(jr, optionsType);
}
} catch (FileNotFoundException) {
#if DEBUG
PUtil.LogDebug("{0} was not found; using default settings".F(Path.GetFileName(
path)));
#endif
} catch (UnauthorizedAccessException e) {
// Options will be set to defaults
PUtil.LogExcWarn(e);
} catch (IOException e) {
// Again set defaults
PUtil.LogExcWarn(e);
} catch (JsonException e) {
// Again set defaults
PUtil.LogExcWarn(e);
}
return options;
}
/// <summary>
/// Shows a mod options dialog now, as if Options was used inside the Mods menu.
/// </summary>
/// <param name="optionsType">The type of the options to show. The mod to configure,
/// configuration directory, and so forth will be retrieved from the provided type.
/// This type must be the same type configured in RegisterOptions for the mod.</param>
/// <param name="onClose">The method to call when the dialog is closed.</param>
public static void ShowDialog(Type optionsType, Action<object> onClose = null) {
var args = new OpenDialogArgs(optionsType, onClose);
var allOptions = PRegistry.Instance.GetAllComponents(typeof(POptions).FullName);
if (allOptions != null)
foreach (var mod in allOptions)
mod?.Process(0, args);
}
/// <summary>
/// Writes a mod's settings to its configuration file. The assembly defining T is used
/// to resolve the proper settings folder.
/// </summary>
/// <typeparam name="T">The type of the settings object.</typeparam>
/// <param name="settings">The settings to write.</param>
public static void WriteSettings<T>(T settings) where T : class {
var attr = typeof(T).GetCustomAttribute<ConfigFileAttribute>();
WriteSettings(settings, GetConfigPath(attr, typeof(T).Assembly), attr?.
IndentOutput ?? false);
}
/// <summary>
/// Writes a mod's settings to its configuration file.
/// </summary>
/// <param name="settings">The settings to write.</param>
/// <param name="path">The path to the settings file.</param>
/// <param name="indent">true to indent the output, or false to leave it in one line.</param>
internal static void WriteSettings(object settings, string path, bool indent = false) {
if (settings != null)
try {
// SharedConfigLocation
Directory.CreateDirectory(Path.GetDirectoryName(path));
using (var jw = new JsonTextWriter(File.CreateText(path))) {
var serializer = new JsonSerializer {
MaxDepth = MAX_SERIALIZATION_DEPTH
};
serializer.Formatting = indent ? Formatting.Indented : Formatting.None;
// Serialize from stream avoids creating file text in memory
serializer.Serialize(jw, settings);
}
} catch (UnauthorizedAccessException e) {
// Options cannot be set
PUtil.LogExcWarn(e);
} catch (IOException e) {
// Options cannot be set
PUtil.LogExcWarn(e);
} catch (JsonException e) {
// Options cannot be set
PUtil.LogExcWarn(e);
}
}
/// <summary>
/// Maps mod static IDs to their options.
/// </summary>
private readonly IDictionary<string, Type> modOptions;
/// <summary>
/// Maps mod assemblies to handlers that can fire their options. Only populated in
/// the instantiated copy of POptions.
/// </summary>
private readonly IDictionary<string, ModOptionsHandler> registered;
public override Version Version => VERSION;
public POptions() {
modOptions = new Dictionary<string, Type>(8);
registered = new Dictionary<string, ModOptionsHandler>(32);
InstanceData = modOptions;
}
/// <summary>
/// Adds the Options button to the Mods screen.
/// </summary>
/// <param name="modEntry">The mod entry where the button should be added.</param>
/// <param name="fallbackIndex">The index to use if it cannot be determined from the entry.</param>
/// <param name="parent">The parent where the entries were added, used only if the
/// fallback index is required.</param>
private void AddModOptions(object modEntry, int fallbackIndex, GameObject parent) {
var mods = Global.Instance.modManager?.mods;
if (!PPatchTools.TryGetFieldValue(modEntry, "mod_index", out int index))
index = fallbackIndex;
if (!PPatchTools.TryGetFieldValue(modEntry, "rect_transform", out Transform
transform))
transform = parent.transform.GetChild(index);
if (mods != null && index >= 0 && index < mods.Count && transform != null) {
var modSpec = mods[index];
string label = modSpec.staticID;
if (modSpec.IsEnabledForActiveDlc() && registered.TryGetValue(label,
out ModOptionsHandler handler)) {
#if DEBUG
PUtil.LogDebug("Adding options for mod: {0}".F(modSpec.staticID));
#endif
// Create delegate to open settings dialog
new PButton("ModSettingsButton") {
FlexSize = Vector2.up, OnClick = handler.ShowDialog,
ToolTip = PLibStrings.DIALOG_TITLE.text.F(modSpec.title), Text =
CultureInfo.CurrentCulture.TextInfo.ToTitleCase(PLibStrings.
BUTTON_OPTIONS.text.ToLower()), Margin = OPTION_BUTTON_MARGIN
// Move before the subscription and enable button
}.SetKleiPinkStyle().AddTo(transform.gameObject, 4);
}
}
}
/// <summary>
/// Initializes and stores the options table for quicker lookups later.
/// </summary>
public override void Initialize(Harmony plibInstance) {
Instance = this;
registered.Clear();
SetSharedData(PUtil.CreateAssemblyToModTable());
foreach (var optionsProvider in PRegistry.Instance.GetAllComponents(ID)) {
var options = optionsProvider.GetInstanceData<IDictionary<string, Type>>();
if (options != null)
// Map the static ID to the mod's option type and fire the correct handler
foreach (var pair in options) {
string label = pair.Key;
if (registered.ContainsKey(label))
PUtil.LogWarning("Mod {0} already has options registered - only one option type per mod".
F(label ?? "?"));
else
registered.Add(label, new ModOptionsHandler(optionsProvider,
pair.Value));
}
}
plibInstance.Patch(typeof(ModsScreen), "BuildDisplay", postfix: PatchMethod(nameof(
BuildDisplay_Postfix)));
}
public override void Process(uint operation, object args) {
// POptions is no longer forwarded, show the dialog from the assembly that has the
// options type - ignore calls that are not for our mod
if (operation == 0 && PPatchTools.TryGetPropertyValue(args, nameof(OpenDialogArgs.
OptionsType), out Type forType)) {
foreach (var pair in modOptions)
// Linear search is not phenomenal, but there is usually only one options
// type per instance
if (pair.Value == forType) {
var dialog = new OptionsDialog(forType);
if (PPatchTools.TryGetPropertyValue(args, nameof(OpenDialogArgs.
OnClose), out Action<object> handler))
dialog.OnClose = handler;
dialog.ShowDialog();
break;
}
}
}
/// <summary>
/// Registers a class as a mod options class. The type is registered for the mod
/// instance specified, which is easily available in OnLoad.
/// </summary>
/// <param name="mod">The mod for which the type will be registered.</param>
/// <param name="optionsType">The class which will represent the options for this mod.</param>
public void RegisterOptions(KMod.UserMod2 mod, Type optionsType) {
var kmod = mod?.mod;
if (optionsType == null)
throw new ArgumentNullException(nameof(optionsType));
if (kmod == null)
throw new ArgumentNullException(nameof(mod));
RegisterForForwarding();
string id = kmod.staticID;
if (modOptions.TryGetValue(id, out Type curType))
PUtil.LogWarning("Mod {0} already has options type {1}".F(id, curType.
FullName));
else {
modOptions.Add(id, optionsType);
PUtil.LogDebug("Registered mod options class {0} for {1}".F(optionsType.
FullName, id));
}
}
/// <summary>
/// Opens the mod options dialog for a specific mod assembly.
/// </summary>
private sealed class ModOptionsHandler {
/// <summary>
/// The type whose options will be shown.
/// </summary>
private readonly Type forType;
/// <summary>
/// The options instance that will handle the dialog.
/// </summary>
private readonly PForwardedComponent options;
internal ModOptionsHandler(PForwardedComponent options, Type forType) {
this.forType = forType ?? throw new ArgumentNullException(nameof(forType));
this.options = options ?? throw new ArgumentNullException(nameof(options));
}
/// <summary>
/// Shows the options dialog.
/// </summary>
internal void ShowDialog(GameObject _) {
options.Process(0, new OpenDialogArgs(forType, null));
}
public override string ToString() {
return "ModOptionsHandler[Type={0}]".F(forType);
}
}
/// <summary>
/// The arguments to be passed with message SHOW_DIALOG_MOD.
/// </summary>
private sealed class OpenDialogArgs {
/// <summary>
/// The handler (if not null) to be called when the dialog is closed.
/// </summary>
public Action<object> OnClose { get; }
/// <summary>
/// The mod options type to show.
/// </summary>
public Type OptionsType { get; }
public OpenDialogArgs(Type optionsType, Action<object> onClose) {
OnClose = onClose;
OptionsType = optionsType ?? throw new ArgumentNullException(
nameof(optionsType));
}
public override string ToString() {
return "OpenDialogArgs[Type={0}]".F(OptionsType);
}
}
}
}

View File

@@ -0,0 +1,68 @@
/*
* 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 PeterHan.PLib.Core;
using System;
namespace PeterHan.PLib.Options {
/// <summary>
/// An attribute placed on an option property for a class used as mod options in order to
/// show or hide it for particular DLCs. If the option is hidden, the value currently
/// in the options file is preserved unchanged when reading or writing.
/// </summary>
[AttributeUsage(AttributeTargets.Property, AllowMultiple = true, Inherited = true)]
public sealed class RequireDLCAttribute : Attribute {
/// <summary>
/// The DLC ID to check.
/// </summary>
public string DlcID { get; }
/// <summary>
/// If true, the DLC is required, and the option is hidden if the DLC is inactive.
/// If false, the DLC is forbidden, and the option is hidden if the DLC is active.
/// </summary>
public bool Required { get; }
/// <summary>
/// Annotates an option field as requiring the specified DLC. The [Option] attribute
/// must also be present to be displayed at all.
/// </summary>
/// <param name="dlcID">The DLC ID to require. Must be one of:
/// DlcManager.EXPANSION1_ID, DlcManager.VANILLA_ID</param>
public RequireDLCAttribute(string dlcID) {
DlcID = dlcID;
Required = true;
}
/// <summary>
/// Annotates an option field as requiring or forbidding the specified DLC. The
/// [Option] attribute must also be present to be displayed at all.
/// </summary>
/// <param name="dlcID">The DLC ID to require or forbid. Must be one of:
/// DlcManager.EXPANSION1_ID, DlcManager.VANILLA_ID</param>
/// <param name="required">true to require the DLC, or false to forbid it.</param>
public RequireDLCAttribute(string dlcID, bool required) {
DlcID = dlcID ?? "";
Required = required;
}
public override string ToString() {
return "RequireDLC[DLC={0},require={1}]".F(DlcID, Required);
}
}
}

View File

@@ -0,0 +1,30 @@
/*
* 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;
namespace PeterHan.PLib.Options {
/// <summary>
/// An empty marker attribute. If applied to an options class, PLib will notify the user
/// that the game must be restarted to apply the options. This attribute will not work if
/// it is applied to an individual option, only if applied to the class as a whole.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
public sealed class RestartRequiredAttribute : Attribute {
}
}

View File

@@ -0,0 +1,179 @@
/*
* 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 System.Reflection;
using PeterHan.PLib.UI;
using UnityEngine;
namespace PeterHan.PLib.Options {
/// <summary>
/// An options entry which represents Enum and displays a spinner with text options.
/// </summary>
public class SelectOneOptionsEntry : OptionsEntry {
/// <summary>
/// Obtains the title and tool tip for an enumeration value.
/// </summary>
/// <param name="enumValue">The value in the enumeration.</param>
/// <param name="fieldType">The type of the Enum field.</param>
/// <returns>The matching Option</returns>
private static EnumOption GetAttribute(object enumValue, Type fieldType) {
if (enumValue == null)
throw new ArgumentNullException(nameof(enumValue));
string valueName = enumValue.ToString(), title = valueName, tooltip = "";
foreach (var enumField in fieldType.GetMember(valueName, BindingFlags.Public |
BindingFlags.Static))
if (enumField.DeclaringType == fieldType) {
// Search for OptionsAttribute
foreach (var attrib in enumField.GetCustomAttributes(false))
if (attrib is IOptionSpec spec) {
if (string.IsNullOrEmpty(spec.Title))
spec = HandleDefaults(spec, enumField);
title = LookInStrings(spec.Title);
tooltip = LookInStrings(spec.Tooltip);
break;
}
break;
}
return new EnumOption(title, tooltip, enumValue);
}
public override object Value {
get {
return chosen?.Value;
}
set {
// Find a matching value, if possible
string valueStr = value?.ToString() ?? "";
foreach (var option in options)
if (valueStr == option.Value.ToString()) {
chosen = option;
Update();
break;
}
}
}
/// <summary>
/// The chosen item in the array.
/// </summary>
protected EnumOption chosen;
/// <summary>
/// The realized item label.
/// </summary>
private GameObject comboBox;
/// <summary>
/// The available options to cycle through.
/// </summary>
protected readonly IList<EnumOption> options;
public SelectOneOptionsEntry(string field, IOptionSpec spec, Type fieldType) :
base(field, spec) {
var eval = Enum.GetValues(fieldType);
if (eval == null)
throw new ArgumentException("No values, or invalid values, for enum");
int n = eval.Length;
if (n == 0)
throw new ArgumentException("Enum has no declared members");
chosen = null;
comboBox = null;
options = new List<EnumOption>(n);
for (int i = 0; i < n; i++)
options.Add(GetAttribute(eval.GetValue(i), fieldType));
}
public override GameObject GetUIComponent() {
// Find largest option to size the label appropriately, using em width this time!
EnumOption firstOption = null;
int maxLen = 0;
foreach (var option in options) {
int len = option.Title?.Trim()?.Length ?? 0;
// Kerning each string is slow, so estimate based on em width instead
if (firstOption == null && len > 0)
firstOption = option;
if (len > maxLen)
maxLen = len;
}
comboBox = new PComboBox<EnumOption>("Select") {
BackColor = PUITuning.Colors.ButtonPinkStyle, InitialItem = firstOption,
Content = options, EntryColor = PUITuning.Colors.ButtonBlueStyle,
TextStyle = PUITuning.Fonts.TextLightStyle, OnOptionSelected = UpdateValue,
}.SetMinWidthInCharacters(maxLen).Build();
Update();
return comboBox;
}
/// <summary>
/// Updates the displayed text to match the current item.
/// </summary>
private void Update() {
if (comboBox != null && chosen != null)
PComboBox<EnumOption>.SetSelectedItem(comboBox, chosen, false);
}
/// <summary>
/// Triggered when the value chosen from the combo box has been changed.
/// </summary>
/// <param name="selected">The value selected by the user.</param>
private void UpdateValue(GameObject _, EnumOption selected) {
if (selected != null)
chosen = selected;
}
/// <summary>
/// Represents a selectable option.
/// </summary>
protected sealed class EnumOption : ITooltipListableOption {
/// <summary>
/// The option title.
/// </summary>
public string Title { get; }
/// <summary>
/// The option tool tip.
/// </summary>
public string ToolTip { get; }
/// <summary>
/// The value to assign if this option is chosen.
/// </summary>
public object Value { get; }
public EnumOption(string title, string toolTip, object value) {
Title = title ?? throw new ArgumentNullException(nameof(title));
ToolTip = toolTip;
Value = value;
}
public string GetProperName() {
return Title;
}
public string GetToolTipText() {
return ToolTip;
}
public override string ToString() {
return string.Format("Option[Title={0},Value={1}]", Title, Value);
}
}
}
}

View File

@@ -0,0 +1,52 @@
/*
* 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.
*/
namespace PeterHan.PLib.Options {
/// <summary>
/// A class which can be used by mods to maintain a singleton of their options. This
/// class should be the superclass of the mod options class, and &lt;T&gt; should be
/// the type of the options class to store.
///
/// This class only initializes the mod options once by default. If the settings can
/// be updated without restarting the game, update the Instance manually using
/// IOptions.OnOptionsChanged. If the game has to be restarted anyways, add
/// [RestartRequired].
/// </summary>
/// <typeparam name="T">The mod options class to wrap.</typeparam>
public abstract class SingletonOptions<T> where T : class, new() {
/// <summary>
/// The only instance of the singleton options.
/// </summary>
protected static T instance;
/// <summary>
/// Retrieves the program options, or lazily initializes them if not yet loaded.
/// </summary>
public static T Instance {
get {
if (instance == null)
instance = POptions.ReadSettings<T>() ?? new T();
return instance;
}
protected set {
if (value != null)
instance = value;
}
}
}
}

View File

@@ -0,0 +1,105 @@
/*
* 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 PeterHan.PLib.UI;
using UnityEngine;
namespace PeterHan.PLib.Options {
/// <summary>
/// An options entry which displays a slider below it.
/// </summary>
public abstract class SlidingBaseOptionsEntry : OptionsEntry {
/// <summary>
/// The margin between the slider extra row and the rest of the dialog.
/// </summary>
internal static readonly RectOffset ENTRY_MARGIN = new RectOffset(15, 0, 0, 5);
/// <summary>
/// The margin between the slider and its labels.
/// </summary>
internal static readonly RectOffset SLIDER_MARGIN = new RectOffset(10, 10, 0, 0);
/// <summary>
/// The limits allowed for the entry.
/// </summary>
protected readonly LimitAttribute limits;
/// <summary>
/// The realized slider.
/// </summary>
protected GameObject slider;
protected SlidingBaseOptionsEntry(string field, IOptionSpec spec,
LimitAttribute limit = null) : base(field, spec) {
limits = limit;
slider = null;
}
public override void CreateUIEntry(PGridPanel parent, ref int row) {
double minLimit, maxLimit;
base.CreateUIEntry(parent, ref row);
if (limits != null && (minLimit = limits.Minimum) > float.MinValue && (maxLimit =
limits.Maximum) < float.MaxValue && maxLimit > minLimit) {
// NaN will be false on either comparison
var slider = GetSlider().AddOnRealize(OnRealizeSlider);
// Min and max labels
var minLabel = new PLabel("MinValue") {
TextStyle = PUITuning.Fonts.TextLightStyle, Text = minLimit.
ToString(Format ?? "G3"), TextAlignment = TextAnchor.MiddleRight
};
var maxLabel = new PLabel("MaxValue") {
TextStyle = PUITuning.Fonts.TextLightStyle, Text = maxLimit.
ToString(Format ?? "G3"), TextAlignment = TextAnchor.MiddleLeft
};
// Lay out left to right
var panel = new PRelativePanel("Slider Grid") {
FlexSize = Vector2.right, DynamicSize = false
}.AddChild(slider).AddChild(minLabel).AddChild(maxLabel).AnchorYAxis(slider).
AnchorYAxis(minLabel, 0.5f).AnchorYAxis(maxLabel, 0.5f).SetLeftEdge(
minLabel, fraction: 0.0f).SetRightEdge(maxLabel, fraction: 1.0f).
SetLeftEdge(slider, toRight: minLabel).SetRightEdge(slider, toLeft:
maxLabel).SetMargin(slider, SLIDER_MARGIN);
// Add another row for the slider
parent.AddRow(new GridRowSpec());
parent.AddChild(panel, new GridComponentSpec(++row, 0) {
ColumnSpan = 2, Margin = ENTRY_MARGIN
});
}
}
/// <summary>
/// Gets the initialized PLib slider to be used for value display.
/// </summary>
/// <returns>The slider to be used.</returns>
protected abstract PSliderSingle GetSlider();
/// <summary>
/// Called when the slider is realized.
/// </summary>
/// <param name="realized">The actual slider.</param>
protected void OnRealizeSlider(GameObject realized) {
slider = realized;
Update();
}
/// <summary>
/// Updates the displayed value.
/// </summary>
protected abstract void Update();
}
}

View File

@@ -0,0 +1,94 @@
/*
* 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 PeterHan.PLib.UI;
using System;
using TMPro;
using UnityEngine;
namespace PeterHan.PLib.Options {
/// <summary>
/// An options entry which represents a string and displays a text field.
/// </summary>
public class StringOptionsEntry : OptionsEntry {
public override object Value {
get {
return value;
}
set {
this.value = (value == null) ? "" : value.ToString();
Update();
}
}
/// <summary>
/// The maximum entry length.
/// </summary>
private readonly int maxLength;
/// <summary>
/// The realized text field.
/// </summary>
private GameObject textField;
/// <summary>
/// The value in the text field.
/// </summary>
private string value;
public StringOptionsEntry(string field, IOptionSpec spec,
LimitAttribute limit = null) : base(field, spec) {
// Use the maximum limit value for the max length, if present
if (limit != null)
maxLength = Math.Max(2, (int)Math.Round(limit.Maximum));
else
maxLength = 256;
textField = null;
value = "";
}
public override GameObject GetUIComponent() {
textField = new PTextField() {
OnTextChanged = OnTextChanged, ToolTip = LookInStrings(Tooltip),
Text = value.ToString(), MinWidth = 128, Type = PTextField.FieldType.Text,
MaxLength = maxLength
}.Build();
Update();
return textField;
}
/// <summary>
/// Called when the input field's text is changed.
/// </summary>
/// <param name="text">The new text.</param>
private void OnTextChanged(GameObject _, string text) {
value = text;
Update();
}
/// <summary>
/// Updates the displayed value.
/// </summary>
private void Update() {
TMP_InputField field;
if (textField != null && (field = textField.
GetComponentInChildren<TMP_InputField>()) != null)
field.text = value;
}
}
}

View File

@@ -0,0 +1,76 @@
/*
* 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 PeterHan.PLib.UI;
using UnityEngine;
namespace PeterHan.PLib.Options {
/// <summary>
/// An options entry that displays static text. Not intended to be serializable to the
/// options file, instead declare a read-only property that returns null with a type of
/// LocText, e.g:
///
/// [Option("Your text goes here", "Tool tip for the text")]
/// public LocText MyLabel => null;
///
/// Unity font formatting can be used in the text. The name of a strings table entry can
/// also be used to allow localization.
/// </summary>
public class TextBlockOptionsEntry : OptionsEntry {
/// <summary>
/// A font style that looks like TextLightStyle but allows word wrapping.
/// </summary>
private static readonly TextStyleSetting WRAP_TEXT_STYLE;
static TextBlockOptionsEntry() {
WRAP_TEXT_STYLE = PUITuning.Fonts.TextLightStyle.DeriveStyle();
WRAP_TEXT_STYLE.enableWordWrapping = true;
}
public override object Value {
get {
return ignore;
}
set {
if (value is LocText newValue)
ignore = newValue;
}
}
/// <summary>
/// This value is not used, it only exists to satisfy the contract.
/// </summary>
private LocText ignore;
public TextBlockOptionsEntry(string field, IOptionSpec spec) : base(field, spec) { }
public override void CreateUIEntry(PGridPanel parent, ref int row) {
parent.AddChild(new PLabel(Field) {
Text = LookInStrings(Title), ToolTip = LookInStrings(Tooltip),
TextStyle = WRAP_TEXT_STYLE
}, new GridComponentSpec(row, 0) {
Margin = CONTROL_MARGIN, Alignment = TextAnchor.MiddleCenter, ColumnSpan = 2
});
}
public override GameObject GetUIComponent() {
// Will not be invoked
return new GameObject("Empty");
}
}
}