Compare commits

...
6 Commits
Author SHA1 Message Date
Leonid PershinandClaude Opus 4.8 92d0302138 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>
2026-06-15 01:22:30 +03:00
Leonid PershinandClaude Opus 4.8 36b6d576a1 UI: themed dark menu — cards, accent rule, hover/press buttons, backdrop
The menus were default gray Myra buttons on the raw clear color. Give the shared
Ui helpers a cohesive dark theme so every menu screen benefits at once:
- Button: dark fill, accent border, hover (OverBackground) and press (PressedBackground)
  highlights, padded, light text — real feedback instead of flat gray boxes.
- Card: bordered, translucent-dark, padded container; AccentRule: thin accent divider
  under the title.
- MenuScreen: full-screen dark backdrop behind menu content (separate from Screen,
  which stays background-free so the in-game HUD over the world is unaffected).

Main menu, New World, Credits and Load are now framed cards with a title + accent
rule; Settings/Boot/Loading get the backdrop. WorldScene/MultiplayerScene keep the
background-free Screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 01:07:30 +03:00
Leonid PershinandClaude Opus 4.8 02dcd01c0f UI: redesign inspect panel — stat bars, sections, scroll, clearer tabs
Replace the single per-tab text blob with structured widgets:
- StatBar: RimWorld-style colored bars for needs, skills, capacities, blood/pain,
  mood. Good→bad gradient (red/amber/green); drives & skills use a neutral blue;
  pain fills by amount but colors inverted (more pain = red).
- Section headers (accent) group Health (vitals/capacities/conditions) and Mood;
  section keys lose the "— … —" dashes (the header styling separates them now).
- Body lives in a fixed-height ScrollViewer so long tabs (Health) scroll and the
  panel keeps a constant size instead of jumping between tabs.
- Active tab gets a background highlight, not just accent text; tab buttons padded.
- Narrative data (overview/genes/products) stays as styled lines; plant growth-to-
  next-stage shown as a bar.

New loc: inspect.health.vitals, inspect.vital.blood/pain (ru/en).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 00:53:08 +03:00
Leonid PershinandClaude Opus 4.8 56f2c22932 UI: visible pause button + tighter, dimmer selection bracket
- speed.pause was "⏸" (U+23F8), a glyph the bitmap font lacks, so the pause button
  rendered blank/invisible (x1/x3/x6 are ASCII and showed). Use the word Pause/Пауза.
- Selection bracket was radius x2.2 of the sprite''s bounding CIRCLE (half-diagonal),
  so it sat much larger than the entity, in a bright opaque blue. Shrink to x1.4 and
  mute the tint so it hugs the target without shouting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 00:40:06 +03:00
Leonid PershinandClaude Opus 4.8 9f6b8a0554 Stop tracking runtime settings.json (gitignore it)
settings.json is written by the game at runtime in the working dir and was
accidentally added; untrack it and gitignore /settings.json and /Saves/ (the local
file is kept so the game still reads it). .vscode/settings.json is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 00:24:11 +03:00
Leonid PershinandClaude Opus 4.8 ccb7c960f9 Disable tree light occlusion (fixes strange isolated shadows on open ground)
A mature tree marked a single lightmap cell as an occluder (shaded to 35% + a
directional sun-shadow tail up to 7 cells). With mountains gone, trees were the
only occluders, so on open soil these showed up as strange dark blobs detached
from the (larger, offset) tree sprite. Gated tree occlusion behind TreesCastShade
(off) so the forest is evenly lit; the occluder infra stays for light-blocking
terrain and re-enabling once tree canopies have a real multi-cell shade footprint.
Trade-off: understory shade no longer dims growth under canopy (minor). Engine
lighting constants are untouched (would need the submodule workflow). Build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 00:22:56 +03:00
15 changed files with 515 additions and 246 deletions
+3
View File
@@ -417,4 +417,7 @@ FodyWeavers.xsd
# LittleSim
Cache/
# Рантайм-файлы (пишутся игрой в рабочем каталоге) — не версионируем
/settings.json
/Saves/
+10 -5
View File
@@ -27,7 +27,7 @@
"inspect.state.dormanthot": "dormant — too hot",
"inspect.stress": "Stress: {0:0.0} d to death",
"inspect.temp": "Temperature: grows {0:0}…{1:0}°C, optimal {2:0}…{3:0}°C",
"inspect.genes": "Genes",
"inspect.genes": "Genes",
"inspect.gene.vigor": "Vigor {0:0.00} · lifespan {1:0} d",
"inspect.gene.env": "Opt. light {0:0.00} · opt. soil {1:0.00}",
"inspect.gene.repro": "Dispersal {0:0.0} · interval {1:0.0} d",
@@ -48,14 +48,17 @@
"skill.hunting": "hunting",
"skill.evasion": "evasion",
"inspect.health.blood": "Blood: {0:0}% · pain: {1:0}%",
"inspect.health.caps": "— Capacities —",
"inspect.health.vitals": "Vitals",
"inspect.vital.blood": "blood",
"inspect.vital.pain": "pain",
"inspect.health.caps": "Capacities",
"inspect.health.cap": "{0}: {1:0}%",
"inspect.health.hediffs": "Conditions",
"inspect.health.hediffs": "Conditions",
"inspect.health.hediffline": "{0}: severity {1:0}%",
"inspect.health.none": "Healthy, no wounds or illness",
"inspect.mood.value": "Mood: {0:0}%",
"inspect.mood.none": "No mood (primitive brain)",
"inspect.mood.thoughts": "Thoughts",
"inspect.mood.thoughts": "Thoughts",
"inspect.mood.thoughtline": "{0}: {1}",
"inspect.mood.calm": "Calm, nothing on its mind",
"animalstage.baby": "baby",
@@ -117,6 +120,8 @@
"settings.volume": "Volume",
"settings.uiscale": "UI scale",
"settings.devmode": "Developer mode",
"settings.on": "On",
"settings.off": "Off",
"dev.title": "Dev spawner",
"dev.hint": "Pick an entry · F9 to hide",
"dev.armed": "In hand: {0} · LMB to spawn, RMB to clear",
@@ -135,7 +140,7 @@
"pause.mainmenu": "Main menu",
"pause.quit": "Quit",
"pause.saved": "Saved: {0}",
"speed.pause": "",
"speed.pause": "Pause",
"credits.title": "Credits",
"credits.author": "mrleo1nid",
"credits.role": "Concept, code, mrgameeng engine",
+10 -5
View File
@@ -27,7 +27,7 @@
"inspect.state.dormanthot": "покой — слишком жарко",
"inspect.stress": "Стресс: {0:0.0} дн до гибели",
"inspect.temp": "Температура: рост {0:0}…{1:0}°C, оптимум {2:0}…{3:0}°C",
"inspect.genes": "Гены",
"inspect.genes": "Гены",
"inspect.gene.vigor": "Бодрость {0:0.00} · жизнь {1:0} дн",
"inspect.gene.env": "Опт. свет {0:0.00} · опт. почва {1:0.00}",
"inspect.gene.repro": "Расселение {0:0.0} кл · период {1:0.0} дн",
@@ -48,14 +48,17 @@
"skill.hunting": "охота",
"skill.evasion": "уклонение",
"inspect.health.blood": "Кровь: {0:0}% · боль: {1:0}%",
"inspect.health.caps": "— Способности —",
"inspect.health.vitals": "Жизненные показатели",
"inspect.vital.blood": "кровь",
"inspect.vital.pain": "боль",
"inspect.health.caps": "Способности",
"inspect.health.cap": "{0}: {1:0}%",
"inspect.health.hediffs": "Состояния",
"inspect.health.hediffs": "Состояния",
"inspect.health.hediffline": "{0}: тяжесть {1:0}%",
"inspect.health.none": "Здоров, ран и болезней нет",
"inspect.mood.value": "Настроение: {0:0}%",
"inspect.mood.none": "Настроения нет (примитивный мозг)",
"inspect.mood.thoughts": "Мысли",
"inspect.mood.thoughts": "Мысли",
"inspect.mood.thoughtline": "{0}: {1}",
"inspect.mood.calm": "Спокоен, особых мыслей нет",
"animalstage.baby": "детёныш",
@@ -117,6 +120,8 @@
"settings.volume": "Громкость",
"settings.uiscale": "Масштаб UI",
"settings.devmode": "Режим разработчика",
"settings.on": "Вкл",
"settings.off": "Откл",
"dev.title": "Дев-спавнер",
"dev.hint": "Выбери пункт · F9 — скрыть",
"dev.armed": "В руке: {0} · ЛКМ — спавн, ПКМ — снять",
@@ -135,7 +140,7 @@
"pause.mainmenu": "Главное меню",
"pause.quit": "Выход",
"pause.saved": "Сохранено: {0}",
"speed.pause": "",
"speed.pause": "Пауза",
"credits.title": "Авторы",
"credits.author": "mrleo1nid",
"credits.role": "Идея, код, движок mrgameeng",
+1 -1
View File
@@ -53,7 +53,7 @@ public sealed class BootScene : Scene
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
desktop.Root = Ui.Screen(_label);
desktop.Root = Ui.MenuScreen(_label);
// Сборка атласов и дефов — без GPU, поэтому безопасно вне главного потока.
_load = Task.Run(() => GameContent.Load());
+4 -4
View File
@@ -17,15 +17,15 @@ public sealed class CreditsScene : Scene
var input = this.UseInput();
var desktop = this.UseScaledUI(); // ставит MyraEnvironment.Game до создания виджетов
var column = Ui.Column(12);
var column = Ui.Card(10);
column.Widgets.Add(Ui.Title(lang.Get("credits.title")));
column.Widgets.Add(new Myra.Graphics2D.UI.Label { Height = 16 });
column.Widgets.Add(Ui.AccentRule());
column.Widgets.Add(Ui.Subtitle(lang.Get("credits.author")));
column.Widgets.Add(Ui.Subtitle(lang.Get("credits.role")));
column.Widgets.Add(new Myra.Graphics2D.UI.Label { Height = 16 });
column.Widgets.Add(new Myra.Graphics2D.UI.Label { Height = 12 });
column.Widgets.Add(Ui.Button(lang.Get("credits.back"), Back));
desktop.Root = Ui.Screen(column);
desktop.Root = Ui.MenuScreen(column);
UpdateSystems.Add(
new CallbackSystem(() =>
{
+3 -3
View File
@@ -41,9 +41,9 @@ public sealed class LoadGameScene : Scene
private void Rebuild()
{
var lang = _content.Languages;
var column = Ui.Column(8);
var column = Ui.Card(8);
column.Widgets.Add(Ui.Title(lang.Get("load.title")));
column.Widgets.Add(new Label { Height = 8 });
column.Widgets.Add(Ui.AccentRule());
var saves = _store.List();
if (saves.Count == 0)
@@ -98,7 +98,7 @@ public sealed class LoadGameScene : Scene
column.Widgets.Add(new Label { Height = 8 });
column.Widgets.Add(Ui.Button(lang.Get("load.back"), Back));
_desktop.Root = Ui.Screen(column);
_desktop.Root = Ui.MenuScreen(column);
}
private void Back()
+10 -10
View File
@@ -19,19 +19,19 @@ public sealed class MainMenuScene : Scene
var lang = content.Languages;
var desktop = this.UseScaledUI(); // ставит MyraEnvironment.Game до создания виджетов
var column = Ui.Column(12);
column.Widgets.Add(Ui.Title(lang.Get("menu.title")));
column.Widgets.Add(Ui.Subtitle(lang.Get("menu.subtitle")));
column.Widgets.Add(new Myra.Graphics2D.UI.Label { Height = 16 });
column.Widgets.Add(Ui.Button(lang.Get("menu.newworld"), () => Go(new NewWorldScene())));
column.Widgets.Add(Ui.Button(lang.Get("menu.load"), () => Go(new LoadGameScene())));
column.Widgets.Add(Ui.Button(lang.Get("menu.settings"), () => Go(new SettingsScene())));
column.Widgets.Add(Ui.Button(lang.Get("menu.credits"), () => Go(new CreditsScene())));
column.Widgets.Add(
var card = Ui.Card(8);
card.Widgets.Add(Ui.Title(lang.Get("menu.title")));
card.Widgets.Add(Ui.Subtitle(lang.Get("menu.subtitle")));
card.Widgets.Add(Ui.AccentRule());
card.Widgets.Add(Ui.Button(lang.Get("menu.newworld"), () => Go(new NewWorldScene())));
card.Widgets.Add(Ui.Button(lang.Get("menu.load"), () => Go(new LoadGameScene())));
card.Widgets.Add(Ui.Button(lang.Get("menu.settings"), () => Go(new SettingsScene())));
card.Widgets.Add(Ui.Button(lang.Get("menu.credits"), () => Go(new CreditsScene())));
card.Widgets.Add(
Ui.Button(lang.Get("menu.quit"), () => Context.Services.Get<Game>().Exit())
);
desktop.Root = Ui.Screen(column);
desktop.Root = Ui.MenuScreen(card);
}
private void Go(Scene scene)
+3 -3
View File
@@ -38,9 +38,9 @@ public sealed class NewWorldScene : Scene
var presets = content.Defs.All<WorldPresetDef>().OrderBy(p => p.Order).ToList();
_preset = presets[0];
var column = Ui.Column(10);
var column = Ui.Card(10);
column.Widgets.Add(Ui.Title(lang.Get("newworld.title")));
column.Widgets.Add(new Label { Height = 8 });
column.Widgets.Add(Ui.AccentRule());
_name = new TextBox { Text = lang.Get("newworld.defaultname"), Width = 280 };
column.Widgets.Add(LabeledRow(lang.Get("newworld.name"), _name));
@@ -106,7 +106,7 @@ public sealed class NewWorldScene : Scene
buttons.Widgets.Add(Ui.Button(lang.Get("newworld.back"), Back, width: 150));
column.Widgets.Add(buttons);
desktop.Root = Ui.Screen(column);
desktop.Root = Ui.MenuScreen(column);
Refresh();
UpdateSystems.Add(
+3 -1
View File
@@ -47,7 +47,9 @@ public sealed class SettingsScene : Scene
}
private void Rebuild() =>
_desktop.Root = Ui.Screen(SettingsPanel.Build(_settings, _content.Languages, Apply, Back));
_desktop.Root = Ui.MenuScreen(
SettingsPanel.Build(_settings, _content.Languages, Apply, Back)
);
private void Apply()
{
+1 -1
View File
@@ -39,7 +39,7 @@ public sealed class WorldLoadingScene : Scene
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
desktop.Root = Ui.Screen(_label);
desktop.Root = Ui.MenuScreen(_label);
UpdateSystems.Add(new CallbackSystem(Tick));
}
+11 -1
View File
@@ -994,10 +994,20 @@ public sealed class WorldScene : Scene
return shore.ToArray();
}
// Текущая сетка окклюдеров для лайтмапа: светонепроницаемый террейн (сейчас нет) + зрелые деревья.
// Затеняют ли зрелые деревья землю. Сейчас выключено: одиночное дерево = 1 клетка-окклюдер, а спрайт
// кроны крупнее и смещён, плюс направленная тень тянется «хвостом» — на открытой почве это читается как
// странные оторванные пятна. Вернуть, когда у кроны будет настоящий многоклеточный след затенения.
private static readonly bool TreesCastShade = false;
// Сетка окклюдеров для лайтмапа: светонепроницаемый террейн (сейчас такого нет) + опц. зрелые деревья.
private bool[] BuildOccluders()
{
Array.Copy(_cellOccluderBase, _cellOccluder, _cellOccluder.Length);
if (!TreesCastShade)
{
return _cellOccluder; // ровное освещение — без странных теней от отдельных деревьев
}
Store
.Query<PlantGrowth, Transform2D>()
.ForEachEntity(
+5 -2
View File
@@ -23,7 +23,8 @@ public sealed class SelectionBrackets
SpriteFlip.X | SpriteFlip.Y,
];
private static readonly Color Tint = new(150, 210, 255);
// Приглушённый голубой (раньше был ярче) — рамка обозначает выбор, но не «кричит».
private static readonly Color Tint = new(110, 150, 185);
private readonly Entity[] _brackets = new Entity[4];
private readonly Texture2DRegion _region;
@@ -53,7 +54,9 @@ public sealed class SelectionBrackets
public void Show(Vector2 center, float radius)
{
// Квад чуть больше объекта — скобки садятся снаружи (как в RimWorld); арт-уголок ~⅓ ширины.
var scale = new Vector2(radius * 2.2f / _region.Width);
// radius — полудиагональ спрайта (описывающая окружность), поэтому множитель скромный, иначе
// рамка выходит заметно крупнее самой особи.
var scale = new Vector2(radius * 1.4f / _region.Width);
for (var i = 0; i < _brackets.Length; i++)
{
ref var transform = ref _brackets[i].GetComponent<Transform2D>();
+221 -143
View File
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Text;
using Friflo.Engine.ECS;
using LittleSim.Content;
using LittleSim.Sim;
@@ -18,10 +17,13 @@ namespace LittleSim.UI;
/// <summary>
/// Панель осмотра выбранной сущности (инфо-карточка в стиле RimWorld): угловая рамка вокруг объекта
/// (<see cref="SelectionBrackets"/>) и боковая панель в левом нижнем углу с вкладками. Набор вкладок
/// зависит от типа: растение — «Обзор/Гены/Продукты», животное — «Обзор/Нужды/Здоровье/Настроение».
/// Источник выделения — <see cref="Selection"/>; панель только читает компоненты и обновляется каждый
/// кадр (<see cref="Refresh"/>), оставаясь живой даже на паузе. Все строки идут через
/// <see cref="LanguageManager"/> (ключи <c>inspect.*</c>).
/// зависит от типа: растение — «Обзор/Гены/Продукты», животное — «Обзор/Нужды/Навыки/Здоровье/Настроение».
/// Источник выделения — <see cref="Selection"/>; панель только читает компоненты и пересобирает тело
/// активной вкладки каждый кадр (<see cref="Refresh"/>), оставаясь живой даже на паузе. Числовые
/// показатели (нужды, навыки, способности, кровь/боль, настроение) рисуются цветными полосами
/// (<see cref="StatBar"/>), описательные данные — строками с заголовками-секциями. Тело прокручивается
/// (<see cref="ScrollViewer"/> фиксированной высоты), поэтому панель не «прыгает» при смене вкладок.
/// Все строки идут через <see cref="LanguageManager"/> (ключи <c>inspect.*</c>).
/// </summary>
internal sealed class InspectPanel
{
@@ -46,6 +48,14 @@ internal sealed class InspectPanel
Tab.Mood,
];
// Палитра тела панели и полос.
private static readonly Color Body = new(210, 216, 226);
private static readonly Color Warn = new(220, 150, 90);
private static readonly Color Neutral = new(86, 132, 178); // нейтральная величина (навык, влечение)
private static readonly IBrush ActiveTab = new SolidBrush(new Color(40, 54, 72));
private const int BarWidth = 300;
private readonly EntityStore _store;
private readonly PlantSet _plants;
private readonly AnimalSet _animals;
@@ -58,7 +68,7 @@ internal sealed class InspectPanel
private readonly VerticalStackPanel _panel;
private readonly Label _title;
private readonly Label _body;
private readonly VerticalStackPanel _bodyPanel;
private readonly List<(Tab Tab, TextButton Button)> _tabs = [];
private Tab _tab = Tab.Overview;
@@ -85,9 +95,8 @@ internal sealed class InspectPanel
_brackets = brackets;
_title = new Label { TextColor = Ui.Accent, Wrap = true };
_body = new Label { TextColor = new Color(210, 216, 226), Wrap = true };
var tabBar = new HorizontalStackPanel { Spacing = 4 };
var tabBar = new HorizontalStackPanel { Spacing = 2 };
AddTab(tabBar, Tab.Overview, "inspect.tab.overview");
AddTab(tabBar, Tab.Genes, "inspect.tab.genes");
AddTab(tabBar, Tab.Products, "inspect.tab.products");
@@ -96,11 +105,21 @@ internal sealed class InspectPanel
AddTab(tabBar, Tab.Health, "inspect.tab.health");
AddTab(tabBar, Tab.Mood, "inspect.tab.mood");
// Тело вкладки пересобирается каждый кадр; живёт в скролле фиксированной высоты, чтобы панель
// держала постоянный размер и не «прыгала» при переключении вкладок и появлении/исчезании строк.
_bodyPanel = new VerticalStackPanel { Spacing = 3 };
var scroll = new ScrollViewer
{
Content = _bodyPanel,
Height = 280,
ShowHorizontalScrollBar = false,
};
_panel = new VerticalStackPanel
{
Spacing = 6,
Padding = new Thickness(14),
Width = 380,
Width = 360,
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Bottom,
Margin = new Thickness(12, 0, 0, 14),
@@ -112,7 +131,8 @@ internal sealed class InspectPanel
_panel.Widgets.Add(_title);
_panel.Widgets.Add(tabBar);
_panel.Widgets.Add(new HorizontalSeparator());
_panel.Widgets.Add(_body);
_panel.Widgets.Add(scroll);
_panel.Widgets.Add(new HorizontalSeparator());
_panel.Widgets.Add(
new Label
{
@@ -128,7 +148,7 @@ internal sealed class InspectPanel
/// <summary>
/// Подтягивает выделение каждый кадр: проверяет, жива ли сущность (иначе снимает выбор), двигает
/// рамку к объекту и пересобирает текст активной вкладки. Осматриваются растения и животные.
/// рамку к объекту и пересобирает тело активной вкладки. Осматриваются растения и животные.
/// </summary>
public void Refresh()
{
@@ -164,6 +184,7 @@ internal sealed class InspectPanel
_brackets.Show(center, radius);
}
_bodyPanel.Widgets.Clear();
if (isPlant)
{
UpdatePlant(entity);
@@ -189,14 +210,18 @@ internal sealed class InspectPanel
private void AddTab(HorizontalStackPanel bar, Tab tab, string labelKey)
{
var button = new TextButton { Text = _content.Languages.Get(labelKey) };
var button = new TextButton
{
Text = _content.Languages.Get(labelKey),
Padding = new Thickness(6, 3),
};
button.Click += (_, _) => _tab = tab;
bar.Widgets.Add(button);
_tabs.Add((tab, button));
}
// Показывает только вкладки текущего типа, подсвечивает активную; если активная не из набора —
// откатывает на первую (Обзор) при смене типа выбранного объекта.
// Показывает только вкладки текущего типа, подсвечивает активную (цвет + фон); если активная не из
// набора — откатывает на первую (Обзор) при смене типа выбранного объекта.
private void ShowTabs(Tab[] valid)
{
if (Array.IndexOf(valid, _tab) < 0)
@@ -206,11 +231,57 @@ internal sealed class InspectPanel
foreach (var (tab, button) in _tabs)
{
button.Visible = Array.IndexOf(valid, tab) >= 0;
button.TextColor = tab == _tab ? Ui.Accent : Ui.Muted;
var visible = Array.IndexOf(valid, tab) >= 0;
button.Visible = visible;
var active = tab == _tab;
button.TextColor = active ? Ui.Accent : Ui.Muted;
button.Background = active ? ActiveTab : null;
}
}
// — Виджеты-кирпичики тела вкладки —
private void Header(string key) =>
_bodyPanel.Widgets.Add(
new Label
{
Text = _content.Languages.Get(key),
TextColor = Ui.Accent,
Margin = new Thickness(0, 6, 0, 1),
}
);
private void Line(string text, Color? color = null) =>
_bodyPanel.Widgets.Add(
new Label
{
Text = text,
TextColor = color ?? Body,
Wrap = true,
}
);
// Полоса показателя: ширина заливки = доля, цвет задаётся вызывающим (см. GoodBad/Neutral).
private void Bar(string name, float fraction, Color color, string? value = null) =>
_bodyPanel.Widgets.Add(
new StatBar(BarWidth, name, fraction, value ?? Pct(fraction), color)
);
private static string Pct(float fraction) =>
((int)MathF.Round(Math.Clamp(fraction, 0f, 1f) * 100f)).ToString() + "%";
// Цвет «хорошо↔плохо»: 0 — красный, 0.5 — янтарный, 1 — зелёный (приглушённые, чтобы белый текст читался).
private static Color GoodBad(float fraction)
{
fraction = Math.Clamp(fraction, 0f, 1f);
var bad = new Color(176, 72, 66);
var mid = new Color(176, 146, 58);
var good = new Color(86, 158, 96);
return fraction < 0.5f
? Color.Lerp(bad, mid, fraction * 2f)
: Color.Lerp(mid, good, (fraction - 0.5f) * 2f);
}
private void UpdatePlant(Entity entity)
{
var languages = _content.Languages;
@@ -223,21 +294,18 @@ internal sealed class InspectPanel
_title.Text = languages.Get(def.Label) + (traits.IsVariant ? " *" : "");
ShowTabs(PlantTabs);
var text = new StringBuilder();
switch (_tab)
{
case Tab.Overview:
BuildOverview(text, entity, grow, traits, species);
BuildOverview(entity, grow, traits, species);
break;
case Tab.Genes:
BuildGenes(text, traits);
BuildGenes(traits);
break;
case Tab.Products:
BuildProducts(text, entity, def, traits);
BuildProducts(entity, def, traits);
break;
}
_body.Text = text.ToString().TrimEnd();
}
private void UpdateAnimal(Entity entity)
@@ -249,31 +317,27 @@ internal sealed class InspectPanel
_title.Text = languages.Get(sp.Def.Label);
ShowTabs(AnimalTabs);
var text = new StringBuilder();
switch (_tab)
{
case Tab.Overview:
BuildAnimalOverview(text, entity, org, sp);
BuildAnimalOverview(entity, org, sp);
break;
case Tab.Needs:
BuildAnimalNeeds(text, entity);
BuildAnimalNeeds(entity);
break;
case Tab.Skills:
BuildAnimalSkills(text, entity);
BuildAnimalSkills(entity);
break;
case Tab.Health:
BuildAnimalHealth(text, entity);
BuildAnimalHealth(entity);
break;
case Tab.Mood:
BuildAnimalMood(text, entity);
BuildAnimalMood(entity);
break;
}
_body.Text = text.ToString().TrimEnd();
}
private void BuildOverview(
StringBuilder text,
Entity entity,
in PlantGrowth grow,
in PlantPhenotype traits,
@@ -284,7 +348,7 @@ internal sealed class InspectPanel
var lastStage = species.Stages.Length - 1;
var mature = grow.Stage >= lastStage;
text.AppendLine(
Line(
languages.Format(
"inspect.stage",
StageLabel(species.Def, grow.Stage),
@@ -294,17 +358,17 @@ internal sealed class InspectPanel
);
if (mature)
{
text.AppendLine(languages.Get("inspect.mature"));
Line(languages.Get("inspect.mature"));
}
else
{
var from = species.Stages[grow.Stage].EnterDay;
var to = species.Stages[grow.Stage + 1].EnterDay;
var pct = to > from ? (grow.AgeDays - from) / (to - from) * 100f : 0f;
text.AppendLine(languages.Format("inspect.growing", Math.Clamp(pct, 0f, 100f)));
var pct = to > from ? (grow.AgeDays - from) / (to - from) : 0f;
Bar(languages.Get("inspect.tab.overview"), Math.Clamp(pct, 0f, 1f), Neutral);
}
text.AppendLine(languages.Format("inspect.age", grow.AgeDays, traits.Lifespan));
Line(languages.Format("inspect.age", grow.AgeDays, traits.Lifespan));
// Вес растения (∝ кубу размера текущей стадии относительно зрелой).
var plantMass = Mass.OfPlant(
@@ -312,7 +376,7 @@ internal sealed class InspectPanel
species.Stages[grow.Stage].SizeCells,
species.Stages[^1].SizeCells
);
text.AppendLine(languages.Format("inspect.plantmass", plantMass));
Line(languages.Format("inspect.plantmass", plantMass));
// Состояние по температуре (рост/покой) и накопленный стресс.
var (tMin, tLow, tHigh, tMax) = traits.TemperatureBand();
@@ -324,29 +388,25 @@ internal sealed class InspectPanel
temperature <= tMin ? "inspect.state.dormantcold" : "inspect.state.dormanthot"
)
: languages.Get(mature ? "inspect.state.mature" : "inspect.state.growing");
text.AppendLine(languages.Format("inspect.state", state));
Line(languages.Format("inspect.state", state));
if (entity.TryGetComponent<TemperatureStress>(out var stress) && stress.Days > 0.05f)
{
text.AppendLine(languages.Format("inspect.stress", stress.Days));
Line(languages.Format("inspect.stress", stress.Days), Warn);
}
text.AppendLine(languages.Format("inspect.temp", tMin, tMax, tLow, tHigh));
Line(languages.Format("inspect.temp", tMin, tMax, tLow, tHigh));
}
private void BuildGenes(StringBuilder text, in PlantPhenotype traits)
private void BuildGenes(in PlantPhenotype traits)
{
var languages = _content.Languages;
text.AppendLine(languages.Format("inspect.gene.vigor", traits.Vigor, traits.Lifespan));
text.AppendLine(
languages.Format("inspect.gene.env", traits.OptimalLight, traits.OptimalFertility)
);
text.AppendLine(
Line(languages.Format("inspect.gene.vigor", traits.Vigor, traits.Lifespan));
Line(languages.Format("inspect.gene.env", traits.OptimalLight, traits.OptimalFertility));
Line(
languages.Format("inspect.gene.repro", traits.DispersalRange, traits.ReproduceInterval)
);
text.AppendLine(
languages.Format("inspect.gene.repro2", traits.SelfPollination, traits.MutationRate)
);
text.AppendLine(
Line(languages.Format("inspect.gene.repro2", traits.SelfPollination, traits.MutationRate));
Line(
languages.Format(
"inspect.gene.hardy",
traits.ColdHardiness,
@@ -356,30 +416,21 @@ internal sealed class InspectPanel
);
if (traits.Toxicity > 0.01f || traits.Thorns > 0.01f)
{
text.AppendLine(
languages.Format("inspect.gene.defense", traits.Toxicity, traits.Thorns)
);
Line(languages.Format("inspect.gene.defense", traits.Toxicity, traits.Thorns), Warn);
}
if (traits.IsVariant)
{
text.AppendLine(languages.Get("inspect.gene.variant"));
Line(languages.Get("inspect.gene.variant"));
}
}
private void BuildProducts(
StringBuilder text,
Entity entity,
PlantDef def,
in PlantPhenotype traits
)
private void BuildProducts(Entity entity, PlantDef def, in PlantPhenotype traits)
{
var languages = _content.Languages;
if (def.HarvestProduct is { } harvest)
{
text.AppendLine(
languages.Format("inspect.harvest", ProductLabel(harvest), traits.HarvestAmount)
);
Line(languages.Format("inspect.harvest", ProductLabel(harvest), traits.HarvestAmount));
}
if (def.FruitProduct is { } fruit && traits.FruitYield >= 1f)
@@ -388,7 +439,7 @@ internal sealed class InspectPanel
var season = languages.Get(
"season." + ((Season)traits.FruitSeason).ToString().ToLowerInvariant()
);
text.AppendLine(
Line(
languages.Format(
"inspect.fruit",
ProductLabel(fruit),
@@ -400,29 +451,22 @@ internal sealed class InspectPanel
}
else
{
text.AppendLine(languages.Get("inspect.barren"));
Line(languages.Get("inspect.barren"));
}
}
// — Животное: Обзор — стадия, возраст, пол/поколение, беременность.
private void BuildAnimalOverview(
StringBuilder text,
Entity entity,
in AnimalOrganism org,
AnimalSet.Species sp
)
// — Животное: Обзор — стадия, возраст, пол/поколение, вес, беременность.
private void BuildAnimalOverview(Entity entity, in AnimalOrganism org, AnimalSet.Species sp)
{
var languages = _content.Languages;
var grow = entity.GetComponent<AnimalGrowth>();
var stageName = languages.Get(
"animalstage." + sp.Stages[grow.Stage].Name.ToLowerInvariant()
);
text.AppendLine(
languages.Format("inspect.stage", stageName, grow.Stage + 1, sp.Stages.Length)
);
text.AppendLine(languages.Format("inspect.age", grow.AgeDays, org.Traits.Lifespan));
Line(languages.Format("inspect.stage", stageName, grow.Stage + 1, sp.Stages.Length));
Line(languages.Format("inspect.age", grow.AgeDays, org.Traits.Lifespan));
var sex = languages.Get(org.IsMale ? "inspect.sex.male" : "inspect.sex.female");
text.AppendLine(languages.Format("inspect.animal.sex", sex, org.Generation));
Line(languages.Format("inspect.animal.sex", sex, org.Generation));
// Вес тела и грузоподъёмность (∝ массе × способность нести; четвероногие — через Moving).
var stageScale = sp.Stages[grow.Stage].Scale;
@@ -431,16 +475,16 @@ internal sealed class InspectPanel
entity.TryGetComponent<Health>(out var health) && health.State is not null
? Mass.CarryCapacity(mass, health.State.Capacity(AnimalCapacities.Moving))
: Mass.CarryCapacity(mass, 1f);
text.AppendLine(languages.Format("inspect.mass", mass, carryCap));
Line(languages.Format("inspect.mass", mass, carryCap));
if (entity.TryGetComponent<Pregnant>(out var preg))
{
text.AppendLine(languages.Format("inspect.animal.pregnant", preg.DueInDays));
Line(languages.Format("inspect.animal.pregnant", preg.DueInDays), Ui.Accent);
}
}
// — Животное: Нужды — значение каждой нужды (голод/жажда/отдых/влечение) в процентах.
private void BuildAnimalNeeds(StringBuilder text, Entity entity)
// — Животное: Нужды — полоса на каждую нужду; убывающие красят «хорошо↔плохо», влечения — нейтрально.
private void BuildAnimalNeeds(Entity entity)
{
var languages = _content.Languages;
var values = entity.GetComponent<AnimalNeeds>().Values;
@@ -451,23 +495,19 @@ internal sealed class InspectPanel
for (var k = 0; k < _needs.Count && k < values.Length; k++)
{
text.AppendLine(
languages.Format(
"inspect.needline",
languages.Get(_needs[k].Label),
values[k] * 100f
)
);
var v = values[k];
var color = _needs.IsDrive(k) ? Neutral : GoodBad(v);
Bar(languages.Get(_needs[k].Label), v, color);
}
}
// — Животное: Навыки — уровень каждого навыка (%) и страсть (★/★★), растут от практики.
private void BuildAnimalSkills(StringBuilder text, Entity entity)
// — Животное: Навыки — полоса уровня (нейтральный цвет) + страсть (★/★★) в подписи.
private void BuildAnimalSkills(Entity entity)
{
var languages = _content.Languages;
if (!entity.TryGetComponent<Skills>(out var sk) || sk.Levels is null)
{
text.AppendLine(languages.Get("inspect.health.none"));
Line(languages.Get("inspect.health.none"));
return;
}
@@ -477,45 +517,41 @@ internal sealed class InspectPanel
sk.Passions is not null && i < sk.Passions.Length ? sk.Passions[i] : (byte)0;
var stars = passion switch
{
2 => "★★",
1 => "★",
2 => " ★★",
1 => " ★",
_ => "",
};
text.AppendLine(
languages.Format(
"inspect.skillline",
languages.Get(_skills[i].Label),
sk.Levels[i] * 100f,
stars
)
);
Bar(languages.Get(_skills[i].Label) + stars, sk.Levels[i], Neutral);
}
}
// — Животное: Здоровье — кровь/боль, ключевые способности, активные хедифы (раны/болезни/гон).
private void BuildAnimalHealth(StringBuilder text, Entity entity)
// — Животное: Здоровье — полосы жизненных показателей, способностей и активных состояний.
private void BuildAnimalHealth(Entity entity)
{
var languages = _content.Languages;
var state = entity.GetComponent<Health>().State;
if (state is null)
{
text.AppendLine(languages.Get("inspect.health.none"));
Line(languages.Get("inspect.health.none"));
return;
}
text.AppendLine(
languages.Format("inspect.health.blood", state.BloodLevel * 100f, state.Pain * 100f)
);
Header("inspect.health.vitals");
Bar(languages.Get("inspect.vital.blood"), state.BloodLevel, GoodBad(state.BloodLevel));
// Боль: длина = уровень боли, цвет инвертирован (много боли — красный).
Bar(languages.Get("inspect.vital.pain"), state.Pain, GoodBad(1f - state.Pain));
if (state.Capacities.Count > 0)
{
text.AppendLine(languages.Get("inspect.health.caps"));
Header("inspect.health.caps");
// Сначала известные способности в порядке отображения, затем прочие (модовые) — как есть.
var shown = new HashSet<string>(StringComparer.Ordinal);
foreach (var capId in AnimalCapacities.DisplayOrder)
{
if (state.Capacities.ContainsKey(capId))
{
AppendCapacity(text, state, capId, CapacityLabel(capId));
var v = state.Capacity(capId);
Bar(languages.Get(CapacityLabel(capId)), v, GoodBad(v));
shown.Add(capId);
}
}
@@ -524,82 +560,65 @@ internal sealed class InspectPanel
{
if (shown.Add(capId))
{
AppendCapacity(text, state, capId, CapacityLabel(capId));
var v = state.Capacity(capId);
Bar(languages.Get(CapacityLabel(capId)), v, GoodBad(v));
}
}
}
Header("inspect.health.hediffs");
if (state.Hediffs.Count > 0)
{
text.AppendLine(languages.Get("inspect.health.hediffs"));
foreach (var hediff in state.Hediffs)
{
text.AppendLine(
languages.Format(
"inspect.health.hediffline",
languages.Get(hediff.Def.Label),
hediff.Severity * 100f
)
Bar(
languages.Get(hediff.Def.Label),
hediff.Severity,
GoodBad(1f - hediff.Severity)
);
}
}
else
{
text.AppendLine(languages.Get("inspect.health.none"));
Line(languages.Get("inspect.health.none"));
}
}
// Ключ локализации метки способности: cap.<id в нижнем регистре> (cap.consciousness, cap.bloodpumping…).
private static string CapacityLabel(string capId) => "cap." + capId.ToLowerInvariant();
private void AppendCapacity(
StringBuilder text,
HealthState state,
string capId,
string labelKey
)
{
var languages = _content.Languages;
text.AppendLine(
languages.Format(
"inspect.health.cap",
languages.Get(labelKey),
state.Capacity(capId) * 100f
)
);
}
// — Животное: Настроение — значение и активные событийные мысли с их сдвигом (тир 4+).
private void BuildAnimalMood(StringBuilder text, Entity entity)
// — Животное: Настроение — полоса значения и активные событийные мысли с их сдвигом (тир 4+).
private void BuildAnimalMood(Entity entity)
{
var languages = _content.Languages;
var mood = entity.GetComponent<Mood>();
if (!mood.Active)
{
text.AppendLine(languages.Get("inspect.mood.none"));
Line(languages.Get("inspect.mood.none"));
return;
}
text.AppendLine(languages.Format("inspect.mood.value", mood.Value * 100f));
Bar(languages.Get("inspect.tab.mood"), mood.Value, GoodBad(mood.Value));
if (mood.Thoughts is { Count: > 0 })
{
text.AppendLine(languages.Get("inspect.mood.thoughts"));
Header("inspect.mood.thoughts");
foreach (var thought in mood.Thoughts)
{
var pct = thought.Def.MoodOffset * 100f;
var offset = (pct >= 0f ? "+" : "") + pct.ToString("0") + "%";
text.AppendLine(
Line(
languages.Format(
"inspect.mood.thoughtline",
languages.Get(thought.Def.Label),
offset
)
),
pct >= 0f ? Body : Warn
);
}
}
else
{
text.AppendLine(languages.Get("inspect.mood.calm"));
Line(languages.Get("inspect.mood.calm"));
}
}
@@ -613,3 +632,62 @@ internal sealed class InspectPanel
? _content.Languages.Get(product.Label)
: productDefName;
}
/// <summary>
/// Полоса-индикатор показателя в стиле RimWorld: тёмная дорожка фиксированной ширины, цветная заливка
/// длиной <c>fraction</c>, поверх — подпись слева и значение справа. Самодостаточный виджет (ничего не
/// обновляет — пересоздаётся при каждой пересборке тела панели), цвет заливки задаёт вызывающий.
/// </summary>
internal sealed class StatBar : Panel
{
private static readonly IBrush Track = new SolidBrush(new Color(28, 34, 44));
private static readonly IBrush TrackBorder = new SolidBrush(new Color(64, 78, 98));
private const int BarHeight = 22;
public StatBar(int width, string name, float fraction, string value, Color fill)
{
Width = width;
Height = BarHeight;
Background = Track;
Border = TrackBorder;
BorderThickness = new Thickness(1);
var fillWidth = (int)MathF.Round((width - 2) * Math.Clamp(fraction, 0f, 1f));
if (fillWidth > 0)
{
Widgets.Add(
new Panel
{
Background = new SolidBrush(fill),
Width = fillWidth,
Height = BarHeight - 2,
Left = 1,
Top = 1,
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Top,
}
);
}
Widgets.Add(
new Label
{
Text = name,
TextColor = Color.White,
Left = 8,
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Center,
}
);
Widgets.Add(
new Label
{
Text = value,
TextColor = Color.White,
Margin = new Thickness(0, 0, 8, 0),
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center,
}
);
}
}
+104 -4
View File
@@ -1,13 +1,18 @@
using System;
using Friflo.Engine.ECS.Systems;
using Microsoft.Xna.Framework;
using Myra.Graphics2D;
using Myra.Graphics2D.Brushes;
using Myra.Graphics2D.UI;
namespace LittleSim.UI;
/// <summary>
/// Общие фабрики виджетов Myra для экранов меню — единый стиль и отсутствие дублирования
/// разметки между сценами. Все подписи приходят готовыми строками (локализованными вызывающим).
/// разметки между сценами. Тёмная тема с холодным акцентом: затемнённый фон-подложка на весь
/// экран (<see cref="Screen"/>), карточки с рамкой (<see cref="Card"/>), кнопки с подсветкой
/// при наведении и нажатии (<see cref="Button"/>). Все подписи приходят готовыми строками
/// (локализованными вызывающим).
/// </summary>
internal static class Ui
{
@@ -17,7 +22,20 @@ internal static class Ui
/// <summary>Приглушённый цвет вторичного текста.</summary>
public static readonly Color Muted = new(150, 160, 175);
private const int ButtonWidth = 280;
/// <summary>Основной цвет текста на кнопках/карточках.</summary>
public static readonly Color Text = new(214, 222, 232);
// Палитра тёмной темы (фон → карточка → кнопка и её состояния).
private static readonly Color Backdrop = new(12, 15, 21);
private static readonly Color CardBg = new(22, 28, 38, 235);
private static readonly Color CardBorder = new(70, 100, 134);
private static readonly Color ButtonBg = new(30, 39, 52);
private static readonly Color ButtonOver = new(46, 64, 88);
private static readonly Color ButtonPress = new(62, 88, 120);
private static readonly Color ButtonBorder = new(64, 88, 118);
private static readonly Color ChoiceActiveBg = new(48, 84, 120);
private const int ButtonWidth = 300;
/// <summary>Крупный заголовок по центру.</summary>
public static Label Title(string text) =>
@@ -37,7 +55,18 @@ internal static class Ui
HorizontalAlignment = HorizontalAlignment.Center,
};
/// <summary>Кнопка фиксированной ширины с обработчиком клика.</summary>
/// <summary>Тонкая акцентная черта-разделитель по центру (под заголовком).</summary>
public static Panel AccentRule(int width = 130) =>
new()
{
Width = width,
Height = 2,
HorizontalAlignment = HorizontalAlignment.Center,
Margin = new Thickness(0, 4, 0, 10),
Background = new SolidBrush(Accent),
};
/// <summary>Кнопка фиксированной ширины: тёмная заливка, акцентная рамка, подсветка hover/press.</summary>
public static TextButton Button(string text, Action onClick, int width = ButtonWidth)
{
var button = new TextButton
@@ -45,11 +74,44 @@ internal static class Ui
Text = text,
Width = width,
HorizontalAlignment = HorizontalAlignment.Center,
Padding = new Thickness(12, 9),
TextColor = Text,
Background = new SolidBrush(ButtonBg),
OverBackground = new SolidBrush(ButtonOver),
PressedBackground = new SolidBrush(ButtonPress),
Border = new SolidBrush(ButtonBorder),
BorderThickness = new Thickness(1),
};
button.Click += (_, _) => onClick();
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>
public static VerticalStackPanel Column(int spacing = 10) =>
new()
@@ -59,11 +121,25 @@ internal static class Ui
VerticalAlignment = VerticalAlignment.Center,
};
/// <summary>Карточка по центру: вертикальная колонка с тёмным фоном, акцентной рамкой и отступами.</summary>
public static VerticalStackPanel Card(int spacing = 8) =>
new()
{
Spacing = spacing,
Padding = new Thickness(30, 26),
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Background = new SolidBrush(CardBg),
Border = new SolidBrush(CardBorder),
BorderThickness = new Thickness(1),
};
/// <summary>Горизонтальный ряд.</summary>
public static HorizontalStackPanel Row(int spacing = 8) =>
new() { Spacing = spacing, HorizontalAlignment = HorizontalAlignment.Center };
/// <summary>Оборачивает содержимое в панель на весь экран (абсолютное позиционирование детей).</summary>
/// <summary>Оборачивает содержимое в панель на весь экран (абсолютное позиционирование детей).
/// Без фона — годится и для HUD поверх игрового мира.</summary>
public static Panel Screen(params Widget[] children)
{
var panel = new Panel();
@@ -74,6 +150,30 @@ internal static class Ui
return panel;
}
/// <summary>
/// Как <see cref="Screen"/>, но с тёмной подложкой на весь экран под содержимым — для экранов меню,
/// чтобы фон был единообразно тёмным независимо от цвета очистки кадра. НЕ использовать для HUD над
/// игровым миром (подложка непрозрачна и скрыла бы мир).
/// </summary>
public static Panel MenuScreen(params Widget[] children)
{
var panel = new Panel();
panel.Widgets.Add(
new Panel
{
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch,
Background = new SolidBrush(Backdrop),
}
);
foreach (var child in children)
{
panel.Widgets.Add(child);
}
return panel;
}
}
/// <summary>
+125 -62
View File
@@ -7,13 +7,17 @@ using Myra.Graphics2D.UI;
namespace LittleSim.UI;
/// <summary>
/// Конструктор панели настроек (язык, полный экран, VSync, разрешение, громкость).
/// Возвращает виджет Myra, работающий с переданной копией <see cref="GameSettings"/>; общий
/// для сцены настроек и оверлея паузы. Вместо неоднозначных комбо/чекбоксов — кнопки-сегменты
/// и тумблеры (состояние показывает цвет текста), что переносимо между версиями Myra.
/// Конструктор панели настроек (язык, полный экран, VSync, разрешение, масштаб UI, громкость,
/// режим разработчика). Возвращает виджет Myra, работающий с переданной копией <see cref="GameSettings"/>;
/// общий для сцены настроек и оверлея паузы. Каждая настройка — ряд «подпись + контрол» с единым
/// выравниванием: сегменты-кнопки (активная подсвечена акцентом) и тумблеры Вкл/Откл вместо
/// неоднозначных комбо/чекбоксов, что переносимо между версиями Myra. Любой клик мгновенно
/// перерисовывает подсветку (все рефрешеры), а не только после «Применить».
/// </summary>
internal static class SettingsPanel
{
private const int LabelWidth = 200;
private static readonly (int W, int H)[] Resolutions =
{
(1280, 720),
@@ -30,47 +34,58 @@ internal static class SettingsPanel
)
{
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")));
column.Widgets.Add(new Label { Height = 8 });
var card = Ui.Card(8);
card.Widgets.Add(Ui.Title(lang.Get("settings.title")));
card.Widgets.Add(Ui.AccentRule());
// Язык — по одному сегменту на доступный язык.
column.Widgets.Add(
card.Widgets.Add(
Segment(
lang.Get("settings.language"),
lang.AvailableLanguages,
code => code.ToUpperInvariant(),
() => settings.Language,
code => settings.Language = code,
refreshers
refreshers,
RefreshAll
)
);
// Полный экран и VSync — тумблеры (цвет = состояние).
column.Widgets.Add(
// Полный экран и VSync — тумблеры Вкл/Откл.
card.Widgets.Add(
Toggle(
lang,
lang.Get("settings.fullscreen"),
() => settings.Fullscreen,
v => settings.Fullscreen = v,
refreshers
refreshers,
RefreshAll
)
);
column.Widgets.Add(
card.Widgets.Add(
Toggle(
lang,
lang.Get("settings.vsync"),
() => settings.VSync,
v => settings.VSync = v,
refreshers
refreshers,
RefreshAll
)
);
// Разрешение — сегменты пресетов.
var resolutions = new List<(int, int)>(Resolutions);
column.Widgets.Add(
card.Widgets.Add(
Segment(
lang.Get("settings.resolution"),
resolutions,
new List<(int, int)>(Resolutions),
r => $"{r.Item1}×{r.Item2}",
() => (settings.Width, settings.Height),
r =>
@@ -78,48 +93,69 @@ internal static class SettingsPanel
settings.Width = r.Item1;
settings.Height = r.Item2;
},
refreshers
refreshers,
RefreshAll
)
);
// Масштаб интерфейса — сегменты пресетов.
var scales = new List<float> { 0.75f, 1f, 1.25f, 1.5f };
column.Widgets.Add(
card.Widgets.Add(
Segment(
lang.Get("settings.uiscale"),
scales,
new List<float> { 0.75f, 1f, 1.25f, 1.5f },
s => $"{(int)MathF.Round(s * 100)}%",
() => settings.UiScale,
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(
lang,
lang.Get("settings.devmode"),
() => settings.DeveloperMode,
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);
buttons.Widgets.Add(Ui.Button(lang.Get("settings.apply"), onApply, 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();
}
return column;
Spacing = 12,
HorizontalAlignment = HorizontalAlignment.Left,
};
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>(
@@ -128,67 +164,94 @@ internal static class SettingsPanel
Func<T, string> text,
Func<T> get,
Action<T> set,
List<Action> refreshers
List<Action> refreshers,
Action onChange
)
{
var row = Ui.Row(6);
row.Widgets.Add(new Label { Text = label, TextColor = Ui.Muted });
var group = new HorizontalStackPanel
{
Spacing = 4,
VerticalAlignment = VerticalAlignment.Center,
};
foreach (var option in options)
{
var value = option;
var button = new TextButton { Text = text(value) };
button.Click += (_, _) => set(value);
row.Widgets.Add(button);
var button = Ui.ChoiceButton(text(value));
button.Click += (_, _) =>
{
set(value);
onChange();
};
group.Widgets.Add(button);
refreshers.Add(() =>
button.TextColor = EqualityComparer<T>.Default.Equals(get(), value)
? Ui.Accent
: Ui.Muted
Ui.SetChoiceActive(button, EqualityComparer<T>.Default.Equals(get(), value))
);
}
return row;
return Field(label, group);
}
private static Widget Toggle(
LanguageManager lang,
string label,
Func<bool> get,
Action<bool> set,
List<Action> refreshers
List<Action> refreshers,
Action onChange
)
{
var button = new TextButton();
button.Click += (_, _) => set(!get());
var button = Ui.ChoiceButton("");
button.Width = 90;
button.Click += (_, _) =>
{
set(!get());
onChange();
};
refreshers.Add(() =>
{
button.Text = (get() ? "● " : "○ ") + label;
button.TextColor = get() ? Ui.Accent : Ui.Muted;
button.Text = lang.Get(get() ? "settings.on" : "settings.off");
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 caption = new Label { TextColor = Ui.Muted };
refreshers.Add(() => caption.Text = $"{label}: {(int)MathF.Round(settings.Volume * 100)}%");
var value = new Label
{
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)
{
settings.Volume = Math.Clamp(settings.Volume + delta, 0f, 1f);
foreach (var refresh in refreshers)
{
refresh();
}
onChange();
}
var minus = new TextButton { Text = "" };
var minus = Ui.ChoiceButton("");
minus.Width = 42;
minus.Click += (_, _) => Adjust(-0.1f);
var plus = new TextButton { Text = "+" };
var plus = Ui.ChoiceButton("+");
plus.Width = 42;
plus.Click += (_, _) => Adjust(+0.1f);
row.Widgets.Add(caption);
row.Widgets.Add(minus);
row.Widgets.Add(plus);
return row;
var group = new HorizontalStackPanel
{
Spacing = 4,
VerticalAlignment = VerticalAlignment.Center,
};
group.Widgets.Add(minus);
group.Widgets.Add(value);
group.Widgets.Add(plus);
return Field(label, group);
}
}