feat(ui): add persistent bilingual settings and combat HUD
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ironspore
|
||||
{
|
||||
[Serializable]
|
||||
public sealed class GameSettingsData
|
||||
{
|
||||
public int version = 1;
|
||||
public float masterVolume = 1;
|
||||
public int qualityLevel = 2;
|
||||
public int resolutionWidth = 1280;
|
||||
public int resolutionHeight = 720;
|
||||
public bool fullscreen;
|
||||
public float mouseSensitivity = 1;
|
||||
public float aimSensitivity = 1;
|
||||
public string bindingOverrides = "";
|
||||
|
||||
public GameSettingsData Copy() => (GameSettingsData)MemberwiseClone();
|
||||
|
||||
public GameSettingsData Normalize(int qualityCount)
|
||||
{
|
||||
var copy = Copy();
|
||||
copy.masterVolume = FiniteClamp(masterVolume, 0, 1, 1);
|
||||
copy.mouseSensitivity = FiniteClamp(mouseSensitivity, .1f, 5, 1);
|
||||
copy.aimSensitivity = FiniteClamp(aimSensitivity, .1f, 2, 1);
|
||||
copy.qualityLevel = Mathf.Clamp(qualityLevel, 0, Mathf.Max(0, qualityCount - 1));
|
||||
if (resolutionWidth < 640 || resolutionWidth > 16384 || resolutionHeight < 360 || resolutionHeight > 16384)
|
||||
{
|
||||
copy.resolutionWidth = 1280;
|
||||
copy.resolutionHeight = 720;
|
||||
}
|
||||
copy.bindingOverrides = bindingOverrides ?? "";
|
||||
return copy;
|
||||
}
|
||||
|
||||
public bool SameDisplay(GameSettingsData other) => resolutionWidth == other.resolutionWidth
|
||||
&& resolutionHeight == other.resolutionHeight && fullscreen == other.fullscreen;
|
||||
|
||||
static float FiniteClamp(float value, float min, float max, float fallback) =>
|
||||
float.IsNaN(value) || float.IsInfinity(value) ? fallback : Mathf.Clamp(value, min, max);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7a1196ee2b7e1734abda51de9ef9c0c4
|
||||
@@ -0,0 +1,225 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
|
||||
namespace Ironspore
|
||||
{
|
||||
public sealed class BindingSlot
|
||||
{
|
||||
public readonly InputAction Action;
|
||||
public readonly int Index;
|
||||
public readonly string LabelKey;
|
||||
public BindingSlot(InputAction action, int index)
|
||||
{
|
||||
Action = action;
|
||||
Index = index;
|
||||
LabelKey = "settings.action." + action.name.ToLowerInvariant()
|
||||
+ (action.bindings[index].isPartOfComposite ? "." + action.bindings[index].name.ToLowerInvariant() : "");
|
||||
}
|
||||
}
|
||||
|
||||
public enum BindingChange { Success, Cancelled, Conflict, Reserved, Unsupported }
|
||||
|
||||
public sealed class InputBindingService : IDisposable
|
||||
{
|
||||
readonly InputActionAsset input;
|
||||
readonly List<BindingSlot> slots = new List<BindingSlot>();
|
||||
readonly List<InputAction> enabledActions = new List<InputAction>();
|
||||
InputActionRebindingExtensions.RebindingOperation operation;
|
||||
BindingSlot activeSlot;
|
||||
Action<BindingChange, BindingSlot> onCompleted;
|
||||
public IReadOnlyList<BindingSlot> Slots => slots;
|
||||
public bool IsRebinding => operation != null;
|
||||
public int LastRebindFrame { get; private set; } = -1;
|
||||
// Input callbacks can run before Unity advances frameCount for Update.
|
||||
public bool SuppressMenuInput => IsRebinding || (LastRebindFrame >= 0 && Time.frameCount <= LastRebindFrame + 1);
|
||||
|
||||
public InputBindingService(InputActionAsset input)
|
||||
{
|
||||
this.input = input;
|
||||
foreach (var action in input.FindActionMap("Pilot", true).actions)
|
||||
for (int i = 0; i < action.bindings.Count; i++)
|
||||
if (action.name != "Look" && !action.bindings[i].isComposite)
|
||||
slots.Add(new BindingSlot(action, i));
|
||||
}
|
||||
|
||||
public string Save() => input.SaveBindingOverridesAsJson();
|
||||
|
||||
public bool TryLoad(string json)
|
||||
{
|
||||
Cancel();
|
||||
string previous = Save();
|
||||
try
|
||||
{
|
||||
input.RemoveAllBindingOverrides();
|
||||
if (!string.IsNullOrEmpty(json))
|
||||
input.LoadBindingOverridesFromJson(json);
|
||||
// Older profiles may already use R. Preserve their binding and move Reload.
|
||||
var reload = slots.Find(slot => slot.Action.name == "Reload");
|
||||
if (reload != null && !reload.Action.bindings[reload.Index].hasOverrides &&
|
||||
Check(reload, reload.Action.bindings[reload.Index].path, out _) == BindingChange.Conflict)
|
||||
{
|
||||
foreach (char letter in "tyuiohjklbnmpxczaqwsdefgvr")
|
||||
if (Rebind(reload, "<Keyboard>/" + letter, out _) == BindingChange.Success)
|
||||
break;
|
||||
}
|
||||
foreach (var map in input.actionMaps)
|
||||
foreach (var action in map.actions)
|
||||
foreach (var binding in action.bindings)
|
||||
if (binding.hasOverrides && (action.name == "Look" || binding.isComposite
|
||||
|| binding.overrideInteractions != null || binding.overrideProcessors != null))
|
||||
throw new ArgumentException("Only button binding paths can be overridden.");
|
||||
foreach (var slot in slots)
|
||||
if (slot.Action.bindings[slot.Index].hasOverrides && Check(slot, slot.Action.bindings[slot.Index].effectivePath, out _) != BindingChange.Success)
|
||||
throw new ArgumentException("Invalid or conflicting binding override.");
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
input.RemoveAllBindingOverrides();
|
||||
input.LoadBindingOverridesFromJson(previous);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Reset() { Cancel(); input.RemoveAllBindingOverrides(); }
|
||||
|
||||
public BindingChange Check(BindingSlot target, string path, out BindingSlot conflict)
|
||||
{
|
||||
conflict = null;
|
||||
if (string.Equals(path, "<Keyboard>/escape", StringComparison.OrdinalIgnoreCase) && target.Action.name != "Pause")
|
||||
return BindingChange.Reserved;
|
||||
if (!Supported(path))
|
||||
return BindingChange.Unsupported;
|
||||
foreach (var slot in slots)
|
||||
if ((slot.Action != target.Action || slot.Index != target.Index)
|
||||
&& string.Equals(slot.Action.bindings[slot.Index].effectivePath, path, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
conflict = slot;
|
||||
return BindingChange.Conflict;
|
||||
}
|
||||
return BindingChange.Success;
|
||||
}
|
||||
|
||||
public BindingChange Rebind(BindingSlot slot, string path, out BindingSlot conflict)
|
||||
{
|
||||
var result = Check(slot, path, out conflict);
|
||||
if (result == BindingChange.Success)
|
||||
slot.Action.ApplyBindingOverride(slot.Index, path);
|
||||
return result;
|
||||
}
|
||||
|
||||
public void Begin(BindingSlot slot, Action<BindingChange, BindingSlot> completed)
|
||||
{
|
||||
Cancel();
|
||||
activeSlot = slot;
|
||||
onCompleted = completed;
|
||||
enabledActions.Clear();
|
||||
foreach (var map in input.actionMaps)
|
||||
foreach (var action in map.actions)
|
||||
if (action.enabled)
|
||||
enabledActions.Add(action);
|
||||
input.Disable();
|
||||
BindingChange result = BindingChange.Cancelled;
|
||||
BindingSlot conflict = null;
|
||||
operation = slot.Action.PerformInteractiveRebinding(slot.Index)
|
||||
.WithExpectedControlType("Button")
|
||||
.WithControlsHavingToMatchPath("<Keyboard>")
|
||||
.WithControlsHavingToMatchPath("<Mouse>")
|
||||
.WithControlsExcluding("<Mouse>/position")
|
||||
.WithControlsExcluding("<Mouse>/delta")
|
||||
.WithControlsExcluding("<Mouse>/scroll")
|
||||
// Let the UI adapter distinguish a cancel-button click from a left-button binding.
|
||||
.WithControlsExcluding("<Mouse>/leftButton")
|
||||
.WithControlsExcluding("<Keyboard>/anyKey")
|
||||
.WithControlsExcluding("<Keyboard>/shift")
|
||||
.WithControlsExcluding("<Keyboard>/ctrl")
|
||||
.WithControlsExcluding("<Keyboard>/alt")
|
||||
.WithCancelingThrough("<Keyboard>/escape")
|
||||
.OnMatchWaitForAnother(0)
|
||||
.OnApplyBinding((_, path) => result = Rebind(slot, path, out conflict))
|
||||
.OnCancel(_ => Finish(BindingChange.Cancelled, null))
|
||||
.OnComplete(_ => Finish(result, conflict));
|
||||
operation.Start();
|
||||
}
|
||||
|
||||
// UI Toolkit also delivers keyboard/pointer events when a platform does not forward
|
||||
// synthetic or accessibility input as raw device events. Both routes use the same validator.
|
||||
public bool CapturePath(string path)
|
||||
{
|
||||
if (!IsRebinding)
|
||||
return false;
|
||||
var completed = onCompleted;
|
||||
onCompleted = null;
|
||||
var result = Rebind(activeSlot, path, out var conflict);
|
||||
Cancel();
|
||||
completed?.Invoke(result, conflict);
|
||||
return true;
|
||||
}
|
||||
|
||||
void Finish(BindingChange result, BindingSlot conflict)
|
||||
{
|
||||
var finished = operation;
|
||||
var completed = onCompleted;
|
||||
operation = null;
|
||||
onCompleted = null;
|
||||
activeSlot = null;
|
||||
finished?.Dispose();
|
||||
foreach (var action in enabledActions)
|
||||
action.Enable();
|
||||
enabledActions.Clear();
|
||||
LastRebindFrame = Time.frameCount;
|
||||
completed?.Invoke(result, conflict);
|
||||
}
|
||||
|
||||
public void Cancel() => operation?.Cancel();
|
||||
public void Dispose() => Cancel();
|
||||
|
||||
static bool Supported(string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
return false;
|
||||
if (path.StartsWith("<Keyboard>/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string name = path.Substring(11);
|
||||
foreach (var keyControl in InputSystem.LoadLayout("Keyboard").controls)
|
||||
if (keyControl.layout.ToString() == "Key" && string.Equals(keyControl.name.ToString(), name, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
if (!path.StartsWith("<Mouse>/", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
string control = path.Substring(8).ToLowerInvariant();
|
||||
return control == "leftbutton" || control == "rightbutton" || control == "middlebutton"
|
||||
|| control == "forwardbutton" || control == "backbutton";
|
||||
}
|
||||
|
||||
public string Display(BindingSlot slot)
|
||||
{
|
||||
string path = slot.Action.bindings[slot.Index].effectivePath;
|
||||
string special = path?.ToLowerInvariant();
|
||||
switch (special)
|
||||
{
|
||||
case "<mouse>/leftbutton": return GameLocalization.Text("settings.key.mouseLeft");
|
||||
case "<mouse>/rightbutton": return GameLocalization.Text("settings.key.mouseRight");
|
||||
case "<mouse>/middlebutton": return GameLocalization.Text("settings.key.mouseMiddle");
|
||||
case "<mouse>/forwardbutton": return GameLocalization.Text("settings.key.mouseForward");
|
||||
case "<mouse>/backbutton": return GameLocalization.Text("settings.key.mouseBack");
|
||||
case "<keyboard>/space": return GameLocalization.Text("settings.key.space");
|
||||
case "<keyboard>/leftshift": return GameLocalization.Text("settings.key.leftShift");
|
||||
case "<keyboard>/rightshift": return GameLocalization.Text("settings.key.rightShift");
|
||||
}
|
||||
return slot.Action.GetBindingDisplayString(slot.Index);
|
||||
}
|
||||
|
||||
public string Display(string actionName)
|
||||
{
|
||||
var names = new List<string>();
|
||||
foreach (var slot in slots)
|
||||
if (slot.Action.name == actionName)
|
||||
names.Add(Display(slot));
|
||||
return string.Join(" / ", names);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5338ce6eea517ea409e49e4303af1a00
|
||||
@@ -0,0 +1,152 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
|
||||
namespace Ironspore
|
||||
{
|
||||
public enum SettingsApplyResult { Saved, ConfirmDisplay, Failed }
|
||||
|
||||
// Runtime settings orchestration; UI owns its draft, while the store owns durable snapshots.
|
||||
public sealed class SettingsService : IDisposable
|
||||
{
|
||||
readonly SettingsStore store;
|
||||
readonly GameSettingsData defaults;
|
||||
readonly float initialVolume;
|
||||
readonly int initialQuality;
|
||||
readonly Func<float> clock;
|
||||
GameSettingsData current, preview;
|
||||
float displayDeadline;
|
||||
public InputBindingService Bindings { get; }
|
||||
public GameSettingsData Current => current.Copy();
|
||||
public GameSettingsData Defaults => defaults.Copy();
|
||||
public bool ConfirmingDisplay => preview != null;
|
||||
public int ConfirmationSeconds => Mathf.Max(0, Mathf.CeilToInt(displayDeadline - clock()));
|
||||
public string Error => store.Error;
|
||||
public float LookSensitivity => current.mouseSensitivity;
|
||||
public float AimSensitivity => current.aimSensitivity;
|
||||
|
||||
public SettingsService(SettingsStore store, InputActionAsset input, GameSettingsData defaults, Func<float> clock = null)
|
||||
{
|
||||
this.store = store;
|
||||
this.defaults = defaults.Copy();
|
||||
this.clock = clock ?? (() => Time.realtimeSinceStartup);
|
||||
initialVolume = AudioListener.volume;
|
||||
initialQuality = QualitySettings.GetQualityLevel();
|
||||
Bindings = new InputBindingService(input);
|
||||
current = store.Data.Normalize(QualitySettings.names.Length);
|
||||
if (!Bindings.TryLoad(current.bindingOverrides))
|
||||
Debug.LogWarning("Invalid input overrides were ignored; using default controls.");
|
||||
current.bindingOverrides = Bindings.Save();
|
||||
ApplyRuntime(current, true);
|
||||
}
|
||||
|
||||
public static GameSettingsData RuntimeDefaults() => new GameSettingsData
|
||||
{
|
||||
qualityLevel = Mathf.Min(2, QualitySettings.names.Length - 1),
|
||||
resolutionWidth = Application.isEditor ? 1280 : Mathf.Max(640, Screen.width),
|
||||
resolutionHeight = Application.isEditor ? 720 : Mathf.Max(360, Screen.height),
|
||||
fullscreen = !Application.isEditor && Screen.fullScreen
|
||||
};
|
||||
|
||||
public List<Vector2Int> Resolutions()
|
||||
{
|
||||
var sizes = new List<Vector2Int>();
|
||||
foreach (var resolution in Screen.resolutions)
|
||||
AddSize(sizes, resolution.width, resolution.height);
|
||||
AddSize(sizes, 1280, 720);
|
||||
AddSize(sizes, 1600, 900);
|
||||
AddSize(sizes, 1920, 1080);
|
||||
AddSize(sizes, current.resolutionWidth, current.resolutionHeight);
|
||||
sizes.Sort((a, b) => a.x == b.x ? a.y.CompareTo(b.y) : a.x.CompareTo(b.x));
|
||||
return sizes;
|
||||
}
|
||||
|
||||
static void AddSize(List<Vector2Int> sizes, int width, int height)
|
||||
{
|
||||
var size = new Vector2Int(width, height);
|
||||
if (width >= 640 && height >= 360 && !sizes.Contains(size))
|
||||
sizes.Add(size);
|
||||
}
|
||||
|
||||
public SettingsApplyResult Apply(GameSettingsData draft)
|
||||
{
|
||||
CancelDisplay();
|
||||
var candidate = draft.Normalize(QualitySettings.names.Length);
|
||||
candidate.bindingOverrides = Bindings.Save();
|
||||
if (!candidate.SameDisplay(current))
|
||||
{
|
||||
preview = candidate;
|
||||
displayDeadline = clock() + 15;
|
||||
ApplyRuntime(preview, true);
|
||||
return SettingsApplyResult.ConfirmDisplay;
|
||||
}
|
||||
return Save(candidate) ? SettingsApplyResult.Saved : SettingsApplyResult.Failed;
|
||||
}
|
||||
|
||||
public bool ConfirmDisplay()
|
||||
{
|
||||
if (preview == null)
|
||||
return false;
|
||||
var candidate = preview;
|
||||
preview = null;
|
||||
if (Save(candidate))
|
||||
return true;
|
||||
ApplyRuntime(current, true);
|
||||
Bindings.TryLoad(current.bindingOverrides);
|
||||
return false;
|
||||
}
|
||||
|
||||
bool Save(GameSettingsData candidate)
|
||||
{
|
||||
if (!store.Save(candidate))
|
||||
return false;
|
||||
current = candidate.Copy();
|
||||
ApplyRuntime(current, false);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Tick()
|
||||
{
|
||||
if (!ConfirmingDisplay || clock() < displayDeadline)
|
||||
return false;
|
||||
CancelDisplay();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void CancelDisplay()
|
||||
{
|
||||
if (preview == null)
|
||||
return;
|
||||
preview = null;
|
||||
ApplyRuntime(current, true);
|
||||
}
|
||||
|
||||
public void DiscardDraft()
|
||||
{
|
||||
Bindings.Cancel();
|
||||
CancelDisplay();
|
||||
Bindings.TryLoad(current.bindingOverrides);
|
||||
}
|
||||
|
||||
void ApplyRuntime(GameSettingsData data, bool display)
|
||||
{
|
||||
AudioListener.volume = data.masterVolume;
|
||||
if (QualitySettings.GetQualityLevel() != data.qualityLevel)
|
||||
QualitySettings.SetQualityLevel(data.qualityLevel, true);
|
||||
if (display && !Application.isEditor)
|
||||
Screen.SetResolution(data.resolutionWidth, data.resolutionHeight,
|
||||
data.fullscreen ? FullScreenMode.ExclusiveFullScreen : FullScreenMode.Windowed);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DiscardDraft();
|
||||
Bindings.Dispose();
|
||||
#if UNITY_EDITOR
|
||||
AudioListener.volume = initialVolume;
|
||||
QualitySettings.SetQualityLevel(initialQuality, true);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2ee4518453bf9384faa13484f6751b4e
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ironspore
|
||||
{
|
||||
// Settings have their own version and file; progression and recovery receipts stay in ProfileStore.
|
||||
public sealed class SettingsStore
|
||||
{
|
||||
readonly string path;
|
||||
readonly GameSettingsData defaults;
|
||||
public GameSettingsData Data { get; private set; }
|
||||
public string Error { get; private set; }
|
||||
|
||||
public SettingsStore(string path, GameSettingsData defaults = null)
|
||||
{
|
||||
this.path = path;
|
||||
this.defaults = (defaults ?? new GameSettingsData()).Copy();
|
||||
Data = Load(path) ?? Load(path + ".bak") ?? this.defaults.Copy();
|
||||
}
|
||||
|
||||
GameSettingsData Load(string file)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(file))
|
||||
return null;
|
||||
var data = defaults.Copy();
|
||||
JsonUtility.FromJsonOverwrite(File.ReadAllText(file), data);
|
||||
return data.version == 1 ? data : null;
|
||||
}
|
||||
catch (Exception) { return null; }
|
||||
}
|
||||
|
||||
public bool Save(GameSettingsData candidate)
|
||||
{
|
||||
if (candidate == null || candidate.version != 1)
|
||||
{
|
||||
Error = "Unsupported settings version.";
|
||||
return false;
|
||||
}
|
||||
try
|
||||
{
|
||||
var snapshot = candidate.Copy();
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path)));
|
||||
string temp = path + ".tmp";
|
||||
using (var stream = new FileStream(temp, FileMode.Create, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(JsonUtility.ToJson(snapshot, true));
|
||||
stream.Write(bytes, 0, bytes.Length);
|
||||
stream.Flush(true);
|
||||
}
|
||||
if (File.Exists(path))
|
||||
File.Replace(temp, path, path + ".bak");
|
||||
else
|
||||
File.Move(temp, path);
|
||||
Data = snapshot;
|
||||
Error = null;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) { Error = ex.Message; return false; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 95681a73f0c68c6488b2e98c655d67f3
|
||||
Reference in New Issue
Block a user