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
+444
View File
@@ -0,0 +1,444 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UIElements;
namespace Ironspore
{
// Owns a settings draft and its UI. It does not write files or mutate gameplay state.
public sealed class SettingsScreen
{
readonly SettingsService settings;
readonly Func<bool> backPressed;
readonly List<VisualElement> focusOrder = new List<VisualElement>();
readonly Dictionary<Button, Action> buttonActions = new Dictionary<Button, Action>();
VisualElement parent, screen;
ScrollView scroll;
Label countdown;
Action closed;
GameSettingsData draft;
BindingSlot capture;
string messageKey;
object[] messageArgs = Array.Empty<object>();
int tab, lastKeyFrame = -1;
bool open;
static string L(string key, params object[] args) => GameLocalization.Text(key, args);
public SettingsScreen(SettingsService settings, Func<bool> backPressed)
{
this.settings = settings;
this.backPressed = backPressed;
}
public void Open(VisualElement parent, Action closed)
{
this.parent = parent;
this.closed = closed;
draft = settings.Current;
messageKey = null;
open = true;
Draw();
}
public void Close()
{
if (!open)
return;
open = false;
capture = null;
settings.DiscardDraft();
screen?.RemoveFromHierarchy();
screen = null;
scroll = null;
closed = null;
focusOrder.Clear();
buttonActions.Clear();
}
public void RefreshLocale()
{
if (!open) return;
string focusedName = (screen.focusController.focusedElement as VisualElement)?.name;
Draw();
if (!string.IsNullOrEmpty(focusedName)) FocusAfterDispatch(screen.Q(focusedName));
}
public void CancelInput() => settings.Bindings.Cancel();
void Draw()
{
Vector2 offset = scroll?.scrollOffset ?? Vector2.zero;
screen?.RemoveFromHierarchy();
focusOrder.Clear();
buttonActions.Clear();
screen = Element(parent, "settings-screen", "settings-screen");
screen.RegisterCallback<KeyDownEvent>(evt =>
{
if (HandleKey(evt.keyCode, evt.shiftKey))
evt.StopImmediatePropagation();
}, TrickleDown.TrickleDown);
screen.RegisterCallback<PointerDownEvent>(CapturePointer, TrickleDown.TrickleDown);
var panel = Element(screen, "settings-panel");
var header = Element(panel, "settings-header");
var title = Element(header, "settings-title-group");
Text(title, L("settings.title"), "heading");
Text(title, L("settings.help"), "settings-help");
var locales = GameLocalization.Catalog.locales;
var names = new List<string>();
foreach (var locale in locales)
names.Add(locale.nativeName);
int languageIndex = Array.FindIndex(locales, x => x.code == GameLocalization.CurrentCode);
var language = Dropdown(header, "settings.language", names, languageIndex, index => GameLocalization.SelectLocale(locales[index].code));
language.name = "settings-language";
language.AddToClassList("settings-language");
var body = Element(panel, "settings-body");
var navigation = Element(body, "settings-navigation");
string[] tabs = { "settings.audio", "settings.display", "settings.controls" };
for (int i = 0; i < tabs.Length; i++)
{
int choice = i;
var button = AddButton(navigation, L(tabs[i]), () => { tab = choice; scroll = null; Draw(); }, "settings-tab");
button.name = "settings-tab-" + i;
if (tab == i)
button.AddToClassList("selected");
}
scroll = new ScrollView(ScrollViewMode.Vertical) { horizontalScrollerVisibility = ScrollerVisibility.Hidden };
scroll.AddToClassList("settings-scroll");
body.Add(scroll);
if (tab == 0)
Audio(scroll);
else if (tab == 1)
Display(scroll);
else
Controls(scroll);
var renderedScroll = scroll;
renderedScroll.schedule.Execute(() => { if (renderedScroll.panel != null) renderedScroll.scrollOffset = offset; });
var status = Text(panel, string.IsNullOrEmpty(messageKey) ? "" : L(messageKey, messageArgs), "settings-status");
status.name = "settings-status";
var footer = Element(panel, "settings-footer");
var reset = AddButton(footer, L("settings.reset"), Reset, "button");
reset.name = "settings-reset";
var footerActions = Element(footer, "settings-footer-actions");
AddButton(footerActions, L("settings.back"), Back, "button").name = "settings-back";
AddButton(footerActions, L("settings.apply"), Apply, "primary").name = "settings-apply";
if (settings.ConfirmingDisplay)
{
header.SetEnabled(false);
body.SetEnabled(false);
footer.SetEnabled(false);
var dialog = Dialog("settings.display.confirm");
countdown = Text(dialog, L("settings.display.countdown", settings.ConfirmationSeconds), "body");
FocusAfterDispatch(AddButton(dialog, L("settings.display.keep"), ConfirmDisplay, "primary"));
AddButton(dialog, L("settings.display.revert"), RevertDisplay, "button");
}
else if (capture != null)
{
header.SetEnabled(false);
body.SetEnabled(false);
footer.SetEnabled(false);
var dialog = Dialog("settings.bind.waiting");
Text(dialog, L("settings.bind.prompt", L(capture.LabelKey)), "body");
var cancel = AddButton(dialog, L("settings.bind.cancel"), settings.Bindings.Cancel, "button");
cancel.name = "settings-capture-cancel";
FocusAfterDispatch(cancel);
}
else
FocusAfterDispatch(navigation.Q<Button>("settings-tab-" + tab));
}
static void FocusAfterDispatch(VisualElement element)
{
// Popup and button dispatch may restore focus to the old, detached control afterwards.
if (element != null)
element.schedule.Execute(() => { if (element.panel != null && element.enabledInHierarchy) element.Focus(); });
}
void Audio(VisualElement content)
{
Text(content, L("settings.audio"), "section");
Slider(content, "settings.volume", draft.masterVolume, 0, 1, .01f,
value => draft.masterVolume = value, value => L("settings.percent", Mathf.RoundToInt(value * 100)));
Text(content, L("settings.volumeHelp"), "hint");
}
void Display(VisualElement content)
{
Text(content, L("settings.display"), "section");
string[] tiers = QualitySettings.names;
var qualityNames = new List<string>();
foreach (string tier in tiers)
{
switch (tier)
{
case "Very Low": qualityNames.Add(L("settings.quality.veryLow")); break;
case "Low": qualityNames.Add(L("settings.quality.low")); break;
case "Medium": qualityNames.Add(L("settings.quality.medium")); break;
case "High": qualityNames.Add(L("settings.quality.high")); break;
case "Very High": qualityNames.Add(L("settings.quality.veryHigh")); break;
case "Ultra": qualityNames.Add(L("settings.quality.ultra")); break;
default: qualityNames.Add(tier); break;
}
}
Dropdown(content, "settings.quality", qualityNames, draft.qualityLevel, index => draft.qualityLevel = index).name = "settings-quality";
var resolutions = settings.Resolutions();
var choices = new List<string>();
int selected = 0;
for (int i = 0; i < resolutions.Count; i++)
{
var size = resolutions[i];
choices.Add(size.x + " × " + size.y);
if (size.x == draft.resolutionWidth && size.y == draft.resolutionHeight)
selected = i;
}
Dropdown(content, "settings.resolution", choices, selected, index =>
{
draft.resolutionWidth = resolutions[index].x;
draft.resolutionHeight = resolutions[index].y;
}).name = "settings-resolution";
Dropdown(content, "settings.windowMode", new List<string> { L("settings.windowed"), L("settings.fullscreen") }, draft.fullscreen ? 1 : 0,
index => draft.fullscreen = index == 1).name = "settings-window-mode";
Text(content, L("settings.displayHelp"), "hint");
}
void Controls(VisualElement content)
{
Text(content, L("settings.mouse"), "section");
Slider(content, "settings.sensitivity", draft.mouseSensitivity, .1f, 5, .05f,
value => draft.mouseSensitivity = value, value => L("settings.multiplier", value.ToString("0.00", GameLocalization.Culture)));
Slider(content, "settings.aimSensitivity", draft.aimSensitivity, .1f, 2, .05f,
value => draft.aimSensitivity = value, value => L("settings.multiplier", value.ToString("0.00", GameLocalization.Culture)));
Text(content, L("settings.bindings"), "section");
Text(content, L("settings.bind.help"), "hint");
foreach (var slot in settings.Bindings.Slots)
{
var row = Element(content, "settings-binding-row");
Text(row, L(slot.LabelKey), "settings-binding-label");
var button = AddButton(row, settings.Bindings.Display(slot), () => BeginRebind(slot), "settings-binding");
button.name = "binding-" + slot.Action.name.ToLowerInvariant() + "-" + slot.Index;
button.tooltip = L("settings.bind.prompt", L(slot.LabelKey));
}
}
void BeginRebind(BindingSlot slot)
{
capture = slot;
settings.Bindings.Begin(slot, (result, conflict) =>
{
if (!open)
return;
capture = null;
draft.bindingOverrides = settings.Bindings.Save();
string key = result == BindingChange.Success ? "settings.bind.changed"
: result == BindingChange.Cancelled ? "settings.bind.cancelled"
: result == BindingChange.Conflict ? "settings.bind.conflict"
: result == BindingChange.Reserved ? "settings.bind.reserved" : "settings.bind.unsupported";
Message(key, result == BindingChange.Conflict ? L(conflict.LabelKey) : L(slot.LabelKey));
Draw();
});
Draw();
}
void Apply()
{
var result = settings.Apply(draft);
if (result == SettingsApplyResult.Saved)
{
draft = settings.Current;
Message("settings.saved");
}
else if (result == SettingsApplyResult.Failed)
Message("settings.saveFailed");
Draw();
}
void ConfirmDisplay()
{
bool saved = settings.ConfirmDisplay();
draft = settings.Current;
Message(saved ? "settings.saved" : "settings.saveFailed");
Draw();
}
void RevertDisplay()
{
settings.CancelDisplay();
var saved = settings.Current;
draft.resolutionWidth = saved.resolutionWidth;
draft.resolutionHeight = saved.resolutionHeight;
draft.fullscreen = saved.fullscreen;
Message("settings.display.reverted");
Draw();
}
void Reset()
{
draft = settings.Defaults;
settings.Bindings.Reset();
Message("settings.resetHint");
Draw();
}
void Back()
{
if (settings.Bindings.IsRebinding)
{
settings.Bindings.Cancel();
return;
}
if (settings.ConfirmingDisplay)
{
RevertDisplay();
return;
}
var callback = closed;
Close();
callback?.Invoke();
}
public void Tick()
{
if (!open)
return;
if (settings.Tick())
RevertDisplay();
if (settings.ConfirmingDisplay && countdown != null)
countdown.text = L("settings.display.countdown", settings.ConfirmationSeconds);
if (settings.Bindings.SuppressMenuInput)
return;
var keyboard = Keyboard.current;
if (keyboard != null)
{
if (keyboard.escapeKey.wasPressedThisFrame) HandleKey(KeyCode.Escape, false);
else if (keyboard.tabKey.wasPressedThisFrame) HandleKey(KeyCode.Tab, keyboard.shiftKey.isPressed);
else if (keyboard.enterKey.wasPressedThisFrame || keyboard.numpadEnterKey.wasPressedThisFrame) HandleKey(KeyCode.Return, false);
else if (keyboard.spaceKey.wasPressedThisFrame) HandleKey(KeyCode.Space, false);
else if (keyboard.leftArrowKey.wasPressedThisFrame) HandleKey(KeyCode.LeftArrow, false);
else if (keyboard.rightArrowKey.wasPressedThisFrame) HandleKey(KeyCode.RightArrow, false);
else if (keyboard.upArrowKey.wasPressedThisFrame) HandleKey(KeyCode.UpArrow, false);
else if (keyboard.downArrowKey.wasPressedThisFrame) HandleKey(KeyCode.DownArrow, false);
}
if (backPressed() && lastKeyFrame != Time.frameCount)
HandleKey(KeyCode.Escape, false);
}
bool HandleKey(KeyCode key, bool shift)
{
if (settings.Bindings.IsRebinding)
{
if (key == KeyCode.Escape) settings.Bindings.Cancel();
else if (key != KeyCode.None) settings.Bindings.CapturePath(SettingsInputCapture.KeyboardPath(key));
return true;
}
if (settings.Bindings.SuppressMenuInput)
return true;
if (lastKeyFrame == Time.frameCount)
return true;
var focused = screen.focusController.focusedElement as VisualElement;
int index = focusOrder.FindIndex(x => x == focused || (focused != null && x.Contains(focused)));
if (key == KeyCode.Escape)
Back();
else if (key == KeyCode.Tab)
{
int direction = shift ? -1 : 1;
for (int i = 0; i < focusOrder.Count; i++)
{
index = (index + direction + focusOrder.Count) % focusOrder.Count;
if (focusOrder[index].enabledInHierarchy)
{
focusOrder[index].Focus();
if (scroll.Contains(focusOrder[index])) scroll.ScrollTo(focusOrder[index]);
break;
}
}
}
else if ((key == KeyCode.Return || key == KeyCode.KeypadEnter || key == KeyCode.Space) && focused is Button button && buttonActions.TryGetValue(button, out var action))
action();
else if (index >= 0 && (key == KeyCode.LeftArrow || key == KeyCode.RightArrow || key == KeyCode.UpArrow || key == KeyCode.DownArrow))
{
int direction = key == KeyCode.LeftArrow || key == KeyCode.DownArrow ? -1 : 1;
if (focusOrder[index] is Slider slider)
slider.value += direction * (float)slider.userData;
else if (focusOrder[index] is DropdownField dropdown)
dropdown.index = (dropdown.index + direction + dropdown.choices.Count) % dropdown.choices.Count;
else
return false;
}
else
return false;
lastKeyFrame = Time.frameCount;
return true;
}
void CapturePointer(PointerDownEvent evt)
{
if (!settings.Bindings.IsRebinding)
return;
var cancel = screen.Q<Button>("settings-capture-cancel");
var target = evt.target as VisualElement;
if (cancel != null && target != null && (target == cancel || cancel.Contains(target)))
settings.Bindings.Cancel();
else
settings.Bindings.CapturePath(SettingsInputCapture.MousePath(evt.button));
evt.StopImmediatePropagation();
}
void Message(string key, params object[] args) { messageKey = key; messageArgs = args; }
VisualElement Dialog(string titleKey)
{
var shade = Element(screen, "settings-dialog-shade");
var dialog = Element(shade, "settings-dialog");
Text(dialog, L(titleKey), "heading");
return dialog;
}
static VisualElement Element(VisualElement parent, string css, string name = null)
{
var element = new VisualElement { name = name };
element.AddToClassList(css);
parent.Add(element);
return element;
}
static Label Text(VisualElement parent, string value, string css)
{
var label = new Label(value);
label.AddToClassList(css);
parent.Add(label);
return label;
}
Button AddButton(VisualElement parent, string label, Action action, string css)
{
var button = new Button(action) { text = label };
button.AddToClassList(css);
parent.Add(button);
focusOrder.Add(button);
buttonActions.Add(button, action);
return button;
}
DropdownField Dropdown(VisualElement parent, string key, List<string> choices, int index, Action<int> changed)
{
var dropdown = new DropdownField(L(key), choices, Mathf.Clamp(index, 0, choices.Count - 1));
dropdown.AddToClassList("settings-dropdown");
dropdown.RegisterValueChangedCallback(evt => changed(dropdown.index));
parent.Add(dropdown);
focusOrder.Add(dropdown);
return dropdown;
}
void Slider(VisualElement parent, string key, float value, float min, float max, float step, Action<float> changed, Func<float, string> format)
{
var row = Element(parent, "settings-slider-row");
var heading = Element(row, "settings-slider-heading");
Text(heading, L(key), "settings-label");
var readout = Text(heading, format(value), "settings-value");
var slider = new Slider(min, max) { value = value, name = key, userData = step };
slider.AddToClassList("settings-slider");
slider.RegisterValueChangedCallback(evt => { changed(evt.newValue); readout.text = format(evt.newValue); });
row.Add(slider);
focusOrder.Add(slider);
}
}
}