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,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);
}
}
}