feat(ui): add persistent bilingual settings and combat HUD

This commit is contained in:
2026-11-12 18:01:54 +08:00
parent 0857e291d5
commit 97fbf187e3
54 changed files with 3046 additions and 52 deletions
@@ -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