UI: rework settings panel — card, aligned rows, themed segmented controls
The settings screen was ragged: bare gray TextButtons (off-theme), each row center-aligned on its own so labels/controls never lined up, and a latent bug — clicking a segment or toggle only changed the value, the highlight didn''t refresh until Apply rebuilt the panel. - Wrap in a Card with title + accent rule, matching the other menus. - Every setting is a "label (fixed width) + control" row, left-aligned, so the controls form a clean column. - Segments and toggles use the themed ChoiceButton with an active state (Ui.SetChoiceActive: accent fill/border) instead of text-color-only. - Toggles read On/Off (new settings.on/off loc) instead of ●/○ glyphs. - Any click now runs all refreshers immediately, so highlights track the value live, not just after Apply. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
36b6d576a1
commit
92d0302138
@@ -120,6 +120,8 @@
|
|||||||
"settings.volume": "Volume",
|
"settings.volume": "Volume",
|
||||||
"settings.uiscale": "UI scale",
|
"settings.uiscale": "UI scale",
|
||||||
"settings.devmode": "Developer mode",
|
"settings.devmode": "Developer mode",
|
||||||
|
"settings.on": "On",
|
||||||
|
"settings.off": "Off",
|
||||||
"dev.title": "Dev spawner",
|
"dev.title": "Dev spawner",
|
||||||
"dev.hint": "Pick an entry · F9 to hide",
|
"dev.hint": "Pick an entry · F9 to hide",
|
||||||
"dev.armed": "In hand: {0} · LMB to spawn, RMB to clear",
|
"dev.armed": "In hand: {0} · LMB to spawn, RMB to clear",
|
||||||
|
|||||||
@@ -120,6 +120,8 @@
|
|||||||
"settings.volume": "Громкость",
|
"settings.volume": "Громкость",
|
||||||
"settings.uiscale": "Масштаб UI",
|
"settings.uiscale": "Масштаб UI",
|
||||||
"settings.devmode": "Режим разработчика",
|
"settings.devmode": "Режим разработчика",
|
||||||
|
"settings.on": "Вкл",
|
||||||
|
"settings.off": "Откл",
|
||||||
"dev.title": "Дев-спавнер",
|
"dev.title": "Дев-спавнер",
|
||||||
"dev.hint": "Выбери пункт · F9 — скрыть",
|
"dev.hint": "Выбери пункт · F9 — скрыть",
|
||||||
"dev.armed": "В руке: {0} · ЛКМ — спавн, ПКМ — снять",
|
"dev.armed": "В руке: {0} · ЛКМ — спавн, ПКМ — снять",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ internal static class Ui
|
|||||||
private static readonly Color ButtonOver = new(46, 64, 88);
|
private static readonly Color ButtonOver = new(46, 64, 88);
|
||||||
private static readonly Color ButtonPress = new(62, 88, 120);
|
private static readonly Color ButtonPress = new(62, 88, 120);
|
||||||
private static readonly Color ButtonBorder = new(64, 88, 118);
|
private static readonly Color ButtonBorder = new(64, 88, 118);
|
||||||
|
private static readonly Color ChoiceActiveBg = new(48, 84, 120);
|
||||||
|
|
||||||
private const int ButtonWidth = 300;
|
private const int ButtonWidth = 300;
|
||||||
|
|
||||||
@@ -85,6 +86,32 @@ internal static class Ui
|
|||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Небольшая кнопка-выбор (сегмент/тумблер): тёмная заливка, рамка, подсветка hover/press.
|
||||||
|
/// Активное состояние задаётся отдельно через <see cref="SetChoiceActive"/>.</summary>
|
||||||
|
public static TextButton ChoiceButton(string text)
|
||||||
|
{
|
||||||
|
var button = new TextButton
|
||||||
|
{
|
||||||
|
Text = text,
|
||||||
|
Padding = new Thickness(10, 6),
|
||||||
|
TextColor = Text,
|
||||||
|
Background = new SolidBrush(ButtonBg),
|
||||||
|
OverBackground = new SolidBrush(ButtonOver),
|
||||||
|
PressedBackground = new SolidBrush(ButtonPress),
|
||||||
|
Border = new SolidBrush(ButtonBorder),
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
};
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Подсвечивает кнопку-выбор как выбранную (акцентная заливка/рамка) или обычную.</summary>
|
||||||
|
public static void SetChoiceActive(TextButton button, bool active)
|
||||||
|
{
|
||||||
|
button.Background = new SolidBrush(active ? ChoiceActiveBg : ButtonBg);
|
||||||
|
button.Border = new SolidBrush(active ? Accent : ButtonBorder);
|
||||||
|
button.TextColor = active ? Color.White : Muted;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Вертикальная колонка по центру экрана.</summary>
|
/// <summary>Вертикальная колонка по центру экрана.</summary>
|
||||||
public static VerticalStackPanel Column(int spacing = 10) =>
|
public static VerticalStackPanel Column(int spacing = 10) =>
|
||||||
new()
|
new()
|
||||||
|
|||||||
@@ -7,13 +7,17 @@ using Myra.Graphics2D.UI;
|
|||||||
namespace LittleSim.UI;
|
namespace LittleSim.UI;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Конструктор панели настроек (язык, полный экран, VSync, разрешение, громкость).
|
/// Конструктор панели настроек (язык, полный экран, VSync, разрешение, масштаб UI, громкость,
|
||||||
/// Возвращает виджет Myra, работающий с переданной копией <see cref="GameSettings"/>; общий
|
/// режим разработчика). Возвращает виджет Myra, работающий с переданной копией <see cref="GameSettings"/>;
|
||||||
/// для сцены настроек и оверлея паузы. Вместо неоднозначных комбо/чекбоксов — кнопки-сегменты
|
/// общий для сцены настроек и оверлея паузы. Каждая настройка — ряд «подпись + контрол» с единым
|
||||||
/// и тумблеры (состояние показывает цвет текста), что переносимо между версиями Myra.
|
/// выравниванием: сегменты-кнопки (активная подсвечена акцентом) и тумблеры Вкл/Откл вместо
|
||||||
|
/// неоднозначных комбо/чекбоксов, что переносимо между версиями Myra. Любой клик мгновенно
|
||||||
|
/// перерисовывает подсветку (все рефрешеры), а не только после «Применить».
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class SettingsPanel
|
internal static class SettingsPanel
|
||||||
{
|
{
|
||||||
|
private const int LabelWidth = 200;
|
||||||
|
|
||||||
private static readonly (int W, int H)[] Resolutions =
|
private static readonly (int W, int H)[] Resolutions =
|
||||||
{
|
{
|
||||||
(1280, 720),
|
(1280, 720),
|
||||||
@@ -30,47 +34,58 @@ internal static class SettingsPanel
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
var refreshers = new List<Action>();
|
var refreshers = new List<Action>();
|
||||||
var column = Ui.Column(10);
|
void RefreshAll()
|
||||||
|
{
|
||||||
|
foreach (var refresh in refreshers)
|
||||||
|
{
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
column.Widgets.Add(Ui.Title(lang.Get("settings.title")));
|
var card = Ui.Card(8);
|
||||||
column.Widgets.Add(new Label { Height = 8 });
|
card.Widgets.Add(Ui.Title(lang.Get("settings.title")));
|
||||||
|
card.Widgets.Add(Ui.AccentRule());
|
||||||
|
|
||||||
// Язык — по одному сегменту на доступный язык.
|
// Язык — по одному сегменту на доступный язык.
|
||||||
column.Widgets.Add(
|
card.Widgets.Add(
|
||||||
Segment(
|
Segment(
|
||||||
lang.Get("settings.language"),
|
lang.Get("settings.language"),
|
||||||
lang.AvailableLanguages,
|
lang.AvailableLanguages,
|
||||||
code => code.ToUpperInvariant(),
|
code => code.ToUpperInvariant(),
|
||||||
() => settings.Language,
|
() => settings.Language,
|
||||||
code => settings.Language = code,
|
code => settings.Language = code,
|
||||||
refreshers
|
refreshers,
|
||||||
|
RefreshAll
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Полный экран и VSync — тумблеры (цвет = состояние).
|
// Полный экран и VSync — тумблеры Вкл/Откл.
|
||||||
column.Widgets.Add(
|
card.Widgets.Add(
|
||||||
Toggle(
|
Toggle(
|
||||||
|
lang,
|
||||||
lang.Get("settings.fullscreen"),
|
lang.Get("settings.fullscreen"),
|
||||||
() => settings.Fullscreen,
|
() => settings.Fullscreen,
|
||||||
v => settings.Fullscreen = v,
|
v => settings.Fullscreen = v,
|
||||||
refreshers
|
refreshers,
|
||||||
|
RefreshAll
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
column.Widgets.Add(
|
card.Widgets.Add(
|
||||||
Toggle(
|
Toggle(
|
||||||
|
lang,
|
||||||
lang.Get("settings.vsync"),
|
lang.Get("settings.vsync"),
|
||||||
() => settings.VSync,
|
() => settings.VSync,
|
||||||
v => settings.VSync = v,
|
v => settings.VSync = v,
|
||||||
refreshers
|
refreshers,
|
||||||
|
RefreshAll
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Разрешение — сегменты пресетов.
|
// Разрешение — сегменты пресетов.
|
||||||
var resolutions = new List<(int, int)>(Resolutions);
|
card.Widgets.Add(
|
||||||
column.Widgets.Add(
|
|
||||||
Segment(
|
Segment(
|
||||||
lang.Get("settings.resolution"),
|
lang.Get("settings.resolution"),
|
||||||
resolutions,
|
new List<(int, int)>(Resolutions),
|
||||||
r => $"{r.Item1}×{r.Item2}",
|
r => $"{r.Item1}×{r.Item2}",
|
||||||
() => (settings.Width, settings.Height),
|
() => (settings.Width, settings.Height),
|
||||||
r =>
|
r =>
|
||||||
@@ -78,48 +93,69 @@ internal static class SettingsPanel
|
|||||||
settings.Width = r.Item1;
|
settings.Width = r.Item1;
|
||||||
settings.Height = r.Item2;
|
settings.Height = r.Item2;
|
||||||
},
|
},
|
||||||
refreshers
|
refreshers,
|
||||||
|
RefreshAll
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Масштаб интерфейса — сегменты пресетов.
|
// Масштаб интерфейса — сегменты пресетов.
|
||||||
var scales = new List<float> { 0.75f, 1f, 1.25f, 1.5f };
|
card.Widgets.Add(
|
||||||
column.Widgets.Add(
|
|
||||||
Segment(
|
Segment(
|
||||||
lang.Get("settings.uiscale"),
|
lang.Get("settings.uiscale"),
|
||||||
scales,
|
new List<float> { 0.75f, 1f, 1.25f, 1.5f },
|
||||||
s => $"{(int)MathF.Round(s * 100)}%",
|
s => $"{(int)MathF.Round(s * 100)}%",
|
||||||
() => settings.UiScale,
|
() => settings.UiScale,
|
||||||
s => settings.UiScale = s,
|
s => settings.UiScale = s,
|
||||||
refreshers
|
refreshers,
|
||||||
|
RefreshAll
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Громкость — кнопки −/+ с подписью процента.
|
// Громкость — кнопки −/+ с подписью процента.
|
||||||
column.Widgets.Add(Volume(lang.Get("settings.volume"), settings, refreshers));
|
card.Widgets.Add(Volume(lang.Get("settings.volume"), settings, refreshers, RefreshAll));
|
||||||
|
|
||||||
// Режим разработчика — тумблер (дев-консоль и спавнер; по умолчанию включён).
|
// Режим разработчика — тумблер (дев-консоль и спавнер; по умолчанию включён).
|
||||||
column.Widgets.Add(
|
card.Widgets.Add(
|
||||||
Toggle(
|
Toggle(
|
||||||
|
lang,
|
||||||
lang.Get("settings.devmode"),
|
lang.Get("settings.devmode"),
|
||||||
() => settings.DeveloperMode,
|
() => settings.DeveloperMode,
|
||||||
v => settings.DeveloperMode = v,
|
v => settings.DeveloperMode = v,
|
||||||
refreshers
|
refreshers,
|
||||||
|
RefreshAll
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
column.Widgets.Add(new Label { Height = 8 });
|
card.Widgets.Add(new Label { Height = 8 });
|
||||||
var buttons = Ui.Row(10);
|
var buttons = Ui.Row(10);
|
||||||
buttons.Widgets.Add(Ui.Button(lang.Get("settings.apply"), onApply, width: 150));
|
buttons.Widgets.Add(Ui.Button(lang.Get("settings.apply"), onApply, width: 150));
|
||||||
buttons.Widgets.Add(Ui.Button(lang.Get("settings.back"), onBack, width: 150));
|
buttons.Widgets.Add(Ui.Button(lang.Get("settings.back"), onBack, width: 150));
|
||||||
column.Widgets.Add(buttons);
|
card.Widgets.Add(buttons);
|
||||||
|
|
||||||
foreach (var refresh in refreshers)
|
RefreshAll();
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ряд «подпись слева фиксированной ширины + контрол» — даёт ровные колонки между настройками.
|
||||||
|
private static HorizontalStackPanel Field(string label, Widget control)
|
||||||
|
{
|
||||||
|
var row = new HorizontalStackPanel
|
||||||
{
|
{
|
||||||
refresh();
|
Spacing = 12,
|
||||||
}
|
HorizontalAlignment = HorizontalAlignment.Left,
|
||||||
|
};
|
||||||
return column;
|
row.Widgets.Add(
|
||||||
|
new Label
|
||||||
|
{
|
||||||
|
Text = label,
|
||||||
|
Width = LabelWidth,
|
||||||
|
Wrap = true,
|
||||||
|
TextColor = Ui.Muted,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
row.Widgets.Add(control);
|
||||||
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Widget Segment<T>(
|
private static Widget Segment<T>(
|
||||||
@@ -128,67 +164,94 @@ internal static class SettingsPanel
|
|||||||
Func<T, string> text,
|
Func<T, string> text,
|
||||||
Func<T> get,
|
Func<T> get,
|
||||||
Action<T> set,
|
Action<T> set,
|
||||||
List<Action> refreshers
|
List<Action> refreshers,
|
||||||
|
Action onChange
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var row = Ui.Row(6);
|
var group = new HorizontalStackPanel
|
||||||
row.Widgets.Add(new Label { Text = label, TextColor = Ui.Muted });
|
{
|
||||||
|
Spacing = 4,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
};
|
||||||
foreach (var option in options)
|
foreach (var option in options)
|
||||||
{
|
{
|
||||||
var value = option;
|
var value = option;
|
||||||
var button = new TextButton { Text = text(value) };
|
var button = Ui.ChoiceButton(text(value));
|
||||||
button.Click += (_, _) => set(value);
|
button.Click += (_, _) =>
|
||||||
row.Widgets.Add(button);
|
{
|
||||||
|
set(value);
|
||||||
|
onChange();
|
||||||
|
};
|
||||||
|
group.Widgets.Add(button);
|
||||||
refreshers.Add(() =>
|
refreshers.Add(() =>
|
||||||
button.TextColor = EqualityComparer<T>.Default.Equals(get(), value)
|
Ui.SetChoiceActive(button, EqualityComparer<T>.Default.Equals(get(), value))
|
||||||
? Ui.Accent
|
|
||||||
: Ui.Muted
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return row;
|
return Field(label, group);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Widget Toggle(
|
private static Widget Toggle(
|
||||||
|
LanguageManager lang,
|
||||||
string label,
|
string label,
|
||||||
Func<bool> get,
|
Func<bool> get,
|
||||||
Action<bool> set,
|
Action<bool> set,
|
||||||
List<Action> refreshers
|
List<Action> refreshers,
|
||||||
|
Action onChange
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var button = new TextButton();
|
var button = Ui.ChoiceButton("");
|
||||||
button.Click += (_, _) => set(!get());
|
button.Width = 90;
|
||||||
|
button.Click += (_, _) =>
|
||||||
|
{
|
||||||
|
set(!get());
|
||||||
|
onChange();
|
||||||
|
};
|
||||||
refreshers.Add(() =>
|
refreshers.Add(() =>
|
||||||
{
|
{
|
||||||
button.Text = (get() ? "● " : "○ ") + label;
|
button.Text = lang.Get(get() ? "settings.on" : "settings.off");
|
||||||
button.TextColor = get() ? Ui.Accent : Ui.Muted;
|
Ui.SetChoiceActive(button, get());
|
||||||
});
|
});
|
||||||
return button;
|
return Field(label, button);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Widget Volume(string label, GameSettings settings, List<Action> refreshers)
|
private static Widget Volume(
|
||||||
|
string label,
|
||||||
|
GameSettings settings,
|
||||||
|
List<Action> refreshers,
|
||||||
|
Action onChange
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var row = Ui.Row(6);
|
var value = new Label
|
||||||
var caption = new Label { TextColor = Ui.Muted };
|
{
|
||||||
refreshers.Add(() => caption.Text = $"{label}: {(int)MathF.Round(settings.Volume * 100)}%");
|
TextColor = Ui.Text,
|
||||||
|
Width = 56,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
};
|
||||||
|
refreshers.Add(() => value.Text = $"{(int)MathF.Round(settings.Volume * 100)}%");
|
||||||
|
|
||||||
void Adjust(float delta)
|
void Adjust(float delta)
|
||||||
{
|
{
|
||||||
settings.Volume = Math.Clamp(settings.Volume + delta, 0f, 1f);
|
settings.Volume = Math.Clamp(settings.Volume + delta, 0f, 1f);
|
||||||
foreach (var refresh in refreshers)
|
onChange();
|
||||||
{
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var minus = new TextButton { Text = "−" };
|
var minus = Ui.ChoiceButton("−");
|
||||||
|
minus.Width = 42;
|
||||||
minus.Click += (_, _) => Adjust(-0.1f);
|
minus.Click += (_, _) => Adjust(-0.1f);
|
||||||
var plus = new TextButton { Text = "+" };
|
var plus = Ui.ChoiceButton("+");
|
||||||
|
plus.Width = 42;
|
||||||
plus.Click += (_, _) => Adjust(+0.1f);
|
plus.Click += (_, _) => Adjust(+0.1f);
|
||||||
|
|
||||||
row.Widgets.Add(caption);
|
var group = new HorizontalStackPanel
|
||||||
row.Widgets.Add(minus);
|
{
|
||||||
row.Widgets.Add(plus);
|
Spacing = 4,
|
||||||
return row;
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
};
|
||||||
|
group.Widgets.Add(minus);
|
||||||
|
group.Widgets.Add(value);
|
||||||
|
group.Widgets.Add(plus);
|
||||||
|
return Field(label, group);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user