Enhance UI localization and settings functionality. Added new localization keys for inspection tabs and UI scale settings in both English and Russian. Updated GameSettings to include a UiScale property. Refactored scenes to utilize UseScaledUI for consistent UI scaling across various game scenes. Improved InspectPanel to support tabbed navigation for overview, genes, and products. Adjusted SettingsPanel to allow users to modify UI scale dynamically.

This commit is contained in:
Leonid Pershin
2026-06-13 06:24:53 +03:00
parent c60ae072e9
commit b9bb7006be
18 changed files with 388 additions and 208 deletions
+4
View File
@@ -7,6 +7,9 @@
"season.autumn": "autumn", "season.autumn": "autumn",
"season.winter": "winter", "season.winter": "winter",
"hud.controls": "WASD — camera, wheel — zoom, LMB — select, ` — console, F1 — inspector", "hud.controls": "WASD — camera, wheel — zoom, LMB — select, ` — console, F1 — inspector",
"inspect.tab.overview": "Overview",
"inspect.tab.genes": "Genes",
"inspect.tab.products": "Products",
"inspect.stage": "Stage: {0} ({1}/{2})", "inspect.stage": "Stage: {0} ({1}/{2})",
"inspect.growing": "Growth: {0:0}% to next stage", "inspect.growing": "Growth: {0:0}% to next stage",
"inspect.mature": "Fully grown", "inspect.mature": "Fully grown",
@@ -57,6 +60,7 @@
"settings.vsync": "Vertical sync", "settings.vsync": "Vertical sync",
"settings.resolution": "Resolution", "settings.resolution": "Resolution",
"settings.volume": "Volume", "settings.volume": "Volume",
"settings.uiscale": "UI scale",
"settings.apply": "Apply", "settings.apply": "Apply",
"settings.back": "Back", "settings.back": "Back",
"pause.title": "Paused", "pause.title": "Paused",
+4
View File
@@ -7,6 +7,9 @@
"season.autumn": "осень", "season.autumn": "осень",
"season.winter": "зима", "season.winter": "зима",
"hud.controls": "WASD — камера, колесо — зум, ЛКМ — выбрать, ` — консоль, F1 — инспектор", "hud.controls": "WASD — камера, колесо — зум, ЛКМ — выбрать, ` — консоль, F1 — инспектор",
"inspect.tab.overview": "Обзор",
"inspect.tab.genes": "Гены",
"inspect.tab.products": "Продукты",
"inspect.stage": "Стадия: {0} ({1}/{2})", "inspect.stage": "Стадия: {0} ({1}/{2})",
"inspect.growing": "Рост: {0:0}% до следующей стадии", "inspect.growing": "Рост: {0:0}% до следующей стадии",
"inspect.mature": "Полностью выросло", "inspect.mature": "Полностью выросло",
@@ -57,6 +60,7 @@
"settings.vsync": "Вертикальная синхронизация", "settings.vsync": "Вертикальная синхронизация",
"settings.resolution": "Разрешение", "settings.resolution": "Разрешение",
"settings.volume": "Громкость", "settings.volume": "Громкость",
"settings.uiscale": "Масштаб UI",
"settings.apply": "Применить", "settings.apply": "Применить",
"settings.back": "Назад", "settings.back": "Назад",
"pause.title": "Пауза", "pause.title": "Пауза",
+3
View File
@@ -28,6 +28,9 @@ public sealed class GameSettings
/// <summary>Общая громкость 0..1.</summary> /// <summary>Общая громкость 0..1.</summary>
public float Volume { get; set; } = 1f; public float Volume { get; set; } = 1f;
/// <summary>Масштаб интерфейса (1 — обычный); применяется к Myra-десктопу всех сцен.</summary>
public float UiScale { get; set; } = 1f;
} }
/// <summary>Чтение/запись <see cref="GameSettings"/> и применение их к движку.</summary> /// <summary>Чтение/запись <see cref="GameSettings"/> и применение их к движку.</summary>
+1 -1
View File
@@ -46,7 +46,7 @@ public sealed class BootScene : Scene
host.Graphics.ApplyChanges(); host.Graphics.ApplyChanges();
Context.Services.Get<AudioManager>().MasterVolume = _settings.Volume; Context.Services.Get<AudioManager>().MasterVolume = _settings.Volume;
var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов var desktop = this.UseScaledUI(); // ставит MyraEnvironment.Game до создания виджетов
_label = new Label _label = new Label
{ {
TextColor = Ui.Accent, TextColor = Ui.Accent,
+1 -1
View File
@@ -15,7 +15,7 @@ public sealed class CreditsScene : Scene
{ {
var lang = Context.Services.Get<GameContent>().Languages; var lang = Context.Services.Get<GameContent>().Languages;
var input = this.UseInput(); var input = this.UseInput();
var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов var desktop = this.UseScaledUI(); // ставит MyraEnvironment.Game до создания виджетов
var column = Ui.Column(12); var column = Ui.Column(12);
column.Widgets.Add(Ui.Title(lang.Get("credits.title"))); column.Widgets.Add(Ui.Title(lang.Get("credits.title")));
+4
View File
@@ -13,6 +13,9 @@ public static class GameLayers
/// <summary>Y-sort слой существ и объектов мира: кто ниже на экране — тот ближе.</summary> /// <summary>Y-sort слой существ и объектов мира: кто ниже на экране — тот ближе.</summary>
public static LayerId Beings { get; private set; } public static LayerId Beings { get; private set; }
/// <summary>Слой-оверлей поверх мира: рамка выделения (рисуется после существ, без Y-сорта).</summary>
public static LayerId Selection { get; private set; }
/// <summary>Регистрирует слои при первом вызове.</summary> /// <summary>Регистрирует слои при первом вызове.</summary>
public static void EnsureRegistered(Renderer2D renderer) public static void EnsureRegistered(Renderer2D renderer)
{ {
@@ -22,6 +25,7 @@ public static class GameLayers
} }
Beings = renderer.Layers.Register("Beings", LayerSpace.World, LayerSortMode.YSort); Beings = renderer.Layers.Register("Beings", LayerSpace.World, LayerSortMode.YSort);
Selection = renderer.Layers.Register("Selection", LayerSpace.World, LayerSortMode.Depth);
_registered = true; _registered = true;
} }
} }
+1 -1
View File
@@ -24,7 +24,7 @@ public sealed class LoadGameScene : Scene
{ {
_content = Context.Services.Get<GameContent>(); _content = Context.Services.Get<GameContent>();
var input = this.UseInput(); var input = this.UseInput();
_desktop = this.UseUI(); _desktop = this.UseScaledUI();
Rebuild(); Rebuild();
UpdateSystems.Add( UpdateSystems.Add(
+1 -1
View File
@@ -17,7 +17,7 @@ public sealed class MainMenuScene : Scene
{ {
var content = Context.Services.Get<GameContent>(); var content = Context.Services.Get<GameContent>();
var lang = content.Languages; var lang = content.Languages;
var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов var desktop = this.UseScaledUI(); // ставит MyraEnvironment.Game до создания виджетов
var column = Ui.Column(12); var column = Ui.Column(12);
column.Widgets.Add(Ui.Title(lang.Get("menu.title"))); column.Widgets.Add(Ui.Title(lang.Get("menu.title")));
+1 -1
View File
@@ -69,7 +69,7 @@ public sealed class MultiplayerScene : Scene
entity.AddComponent(new NetLerp()); entity.AddComponent(new NetLerp());
}; };
var desktop = this.UseUI(); var desktop = this.UseScaledUI();
_hud = new Label { Left = 10, Top = 8 }; _hud = new Label { Left = 10, Top = 8 };
desktop.Root = Ui.Screen(_hud); desktop.Root = Ui.Screen(_hud);
+1 -1
View File
@@ -33,7 +33,7 @@ public sealed class NewWorldScene : Scene
var content = Context.Services.Get<GameContent>(); var content = Context.Services.Get<GameContent>();
var lang = content.Languages; var lang = content.Languages;
var input = this.UseInput(); var input = this.UseInput();
var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов var desktop = this.UseScaledUI(); // ставит MyraEnvironment.Game до создания виджетов
var presets = content.Defs.All<WorldPresetDef>().OrderBy(p => p.Order).ToList(); var presets = content.Defs.All<WorldPresetDef>().OrderBy(p => p.Order).ToList();
_preset = presets[0]; _preset = presets[0];
+4
View File
@@ -24,6 +24,7 @@ internal sealed class PauseMenu
private readonly GameSpeed _speed; private readonly GameSpeed _speed;
private readonly GameHost _host; private readonly GameHost _host;
private readonly AudioManager _audio; private readonly AudioManager _audio;
private readonly Desktop _desktop;
private readonly Func<string> _onSave; private readonly Func<string> _onSave;
private readonly Action _onMainMenu; private readonly Action _onMainMenu;
private readonly Action _onQuit; private readonly Action _onQuit;
@@ -36,6 +37,7 @@ internal sealed class PauseMenu
EngineContext context, EngineContext context,
GameContent content, GameContent content,
GameSpeed speed, GameSpeed speed,
Desktop desktop,
Func<string> onSave, Func<string> onSave,
Action onMainMenu, Action onMainMenu,
Action onQuit Action onQuit
@@ -44,6 +46,7 @@ internal sealed class PauseMenu
_context = context; _context = context;
_content = content; _content = content;
_speed = speed; _speed = speed;
_desktop = desktop;
_host = (GameHost)context.Services.Get<Game>(); _host = (GameHost)context.Services.Get<Game>();
_audio = context.Services.Get<AudioManager>(); _audio = context.Services.Get<AudioManager>();
_onSave = onSave; _onSave = onSave;
@@ -144,6 +147,7 @@ internal sealed class PauseMenu
{ {
GameSettingsStore.Save(_settings); GameSettingsStore.Save(_settings);
GameSettingsStore.Apply(_settings, _host.Graphics, _content.Languages, _audio); GameSettingsStore.Apply(_settings, _host.Graphics, _content.Languages, _audio);
UiScaling.Apply(_context, _desktop, _settings.UiScale); // масштаб UI — сразу
ShowSettings(); // язык мог смениться ShowSettings(); // язык мог смениться
}, },
onBack: () => onBack: () =>
+2 -1
View File
@@ -32,7 +32,7 @@ public sealed class SettingsScene : Scene
_host = (GameHost)Context.Services.Get<Game>(); _host = (GameHost)Context.Services.Get<Game>();
var input = this.UseInput(); var input = this.UseInput();
_desktop = this.UseUI(); _desktop = this.UseScaledUI();
Rebuild(); Rebuild();
UpdateSystems.Add( UpdateSystems.Add(
@@ -53,6 +53,7 @@ public sealed class SettingsScene : Scene
{ {
GameSettingsStore.Save(_settings); GameSettingsStore.Save(_settings);
GameSettingsStore.Apply(_settings, _host.Graphics, _content.Languages, _audio); GameSettingsStore.Apply(_settings, _host.Graphics, _content.Languages, _audio);
UiScaling.Apply(Context, _desktop, _settings.UiScale); // масштаб UI — сразу
Rebuild(); // язык мог смениться — перестраиваем подписи Rebuild(); // язык мог смениться — перестраиваем подписи
} }
+10 -10
View File
@@ -123,26 +123,26 @@ public sealed class WorldScene : Scene
var camera = Store.CreateEntity(new Camera(_bounds.Center, zoom: 1f, bounds: _bounds)); var camera = Store.CreateEntity(new Camera(_bounds.Center, zoom: 1f, bounds: _bounds));
// HUD, полоса скорости и меню-пауза в одной корневой панели. // HUD, полоса скорости и меню-пауза в одной корневой панели.
var desktop = this.UseUI(); var desktop = this.UseScaledUI();
var hudLabel = new Label { Left = 10, Top = 8 }; var hudLabel = new Label { Left = 10, Top = 8 };
var speedBar = BuildSpeedBar(content); var speedBar = BuildSpeedBar(content);
_pause = new PauseMenu( _pause = new PauseMenu(
Context, Context,
content, content,
_speed, _speed,
desktop,
onSave: SaveWorld, onSave: SaveWorld,
onMainMenu: () => Switch(new MainMenuScene()), onMainMenu: () => Switch(new MainMenuScene()),
onQuit: () => Context.Services.Get<Game>().Exit() onQuit: () => Context.Services.Get<Game>().Exit()
); );
_selection = new Selection(); _selection = new Selection();
_inspect = new InspectPanel(Store, _plants, content, climate, _selection); var brackets = new SelectionBrackets(
desktop.Root = Ui.Screen( Store,
hudLabel, atlases.GetRegion(device, "ui/overlays/selectionbracket"),
_inspect.Highlight, GameLayers.Selection
_inspect.Panel,
speedBar,
_pause.Root
); );
_inspect = new InspectPanel(Store, _plants, content, climate, _selection, brackets);
desktop.Root = Ui.Screen(hudLabel, _inspect.Panel, speedBar, _pause.Root);
this.UseInspector(renderer); this.UseInspector(renderer);
var console = this.UseDevConsole(); var console = this.UseDevConsole();
@@ -199,10 +199,10 @@ public sealed class WorldScene : Scene
renderer, renderer,
_selection, _selection,
() => _pause.IsOpen, () => _pause.IsOpen,
p => _inspect.IsOverPanel(p) || speedBar.Bounds.Contains(p) () => desktop.IsMouseOverGUI
) )
); );
UpdateSystems.Add(new CallbackSystem(() => _inspect.Refresh(renderer))); UpdateSystems.Add(new CallbackSystem(() => _inspect.Refresh()));
UpdateSystems.Add( UpdateSystems.Add(
new HudSystem( new HudSystem(
Context, Context,
+10 -9
View File
@@ -40,14 +40,14 @@ public sealed class SelectionSystem(
Renderer2D renderer, Renderer2D renderer,
Selection selection, Selection selection,
Func<bool> blocked, Func<bool> blocked,
Func<Point, bool> overUi Func<bool> overUi
) : BaseSystem ) : BaseSystem
{ {
protected override void OnUpdateGroup() protected override void OnUpdateGroup()
{ {
if (blocked()) if (blocked() || overUi())
{ {
return; return; // меню-пауза/консоль или клик пришёлся на виджет UI — это не пикинг мира
} }
if (input.IsMousePressed(MouseButton.Right)) if (input.IsMousePressed(MouseButton.Right))
@@ -62,10 +62,6 @@ public sealed class SelectionSystem(
} }
var mouse = input.MousePosition; var mouse = input.MousePosition;
if (overUi(mouse))
{
return; // клик пришёлся на панель — это не пикинг мира
}
var world = renderer.ScreenToWorld(new Vector2(mouse.X, mouse.Y)); var world = renderer.ScreenToWorld(new Vector2(mouse.X, mouse.Y));
if (TryPick(world, out var id)) if (TryPick(world, out var id))
@@ -78,12 +74,17 @@ public sealed class SelectionSystem(
} }
} }
// Ближайшая сущность, чья ограничивающая окружность спрайта накрывает точку мира. // Ближайшее растение, чья ограничивающая окружность спрайта накрывает точку мира. Запрос
// ограничен PlantGrowth — так из пикинга выпадают спрайты рамки выделения и прочий не-контент.
private bool TryPick(Vector2 world, out int id) private bool TryPick(Vector2 world, out int id)
{ {
id = -1; id = -1;
var bestDistance = float.MaxValue; var bestDistance = float.MaxValue;
foreach (var (transforms, sprites, entities) in store.Query<Transform2D, Sprite>().Chunks) foreach (
var (transforms, sprites, _, entities) in store
.Query<Transform2D, Sprite, PlantGrowth>()
.Chunks
)
{ {
var t = transforms.Span; var t = transforms.Span;
var s = sprites.Span; var s = sprites.Span;
+75
View File
@@ -0,0 +1,75 @@
using Friflo.Engine.ECS;
using Microsoft.Xna.Framework;
using MrGameEng.Graphics;
namespace LittleSim.Sim;
/// <summary>
/// Рамка выделения в стиле RimWorld: четыре угловые скобки вокруг выбранного объекта. Реализована
/// как четыре спрайт-сущности на слое-оверлее (<see cref="Scenes.GameLayers.Selection"/>) — их рисует
/// штатный спрайт-рендер, а одна и та же текстура уголка (<c>ui/overlays/selectionbracket</c>)
/// зеркалится во все четыре угла флагами <see cref="SpriteFlip"/> (батчер при флипе меняет только UV,
/// квадрат остаётся на месте). Скобки не имеют <c>PlantGrowth</c>, поэтому не попадают под пикинг и
/// прочие запросы растений. <see cref="Show"/> двигает их к объекту, <see cref="Hide"/> прячет.
/// </summary>
public sealed class SelectionBrackets
{
// По уголку на каждый угол: исходный арт лежит в левом-нижнем, флип разносит его по остальным.
private static readonly SpriteFlip[] Corners =
[
SpriteFlip.None,
SpriteFlip.X,
SpriteFlip.Y,
SpriteFlip.X | SpriteFlip.Y,
];
private static readonly Color Tint = new(150, 210, 255);
private readonly Entity[] _brackets = new Entity[4];
private readonly Texture2DRegion _region;
/// <summary>Создаёт четыре скрытые скобки на слое <paramref name="layer"/> из текстуры <paramref name="region"/>.</summary>
public SelectionBrackets(EntityStore store, Texture2DRegion region, LayerId layer)
{
_region = region;
var origin = new Vector2(region.Width / 2f, region.Height / 2f);
for (var i = 0; i < _brackets.Length; i++)
{
_brackets[i] = store.CreateEntity(
Transform2D.At(Vector2.Zero),
new Sprite
{
Region = null, // скрыта, пока ничего не выбрано
Color = Tint,
Origin = origin, // центр текстуры → квад центрируется на позиции
Layer = layer,
Flip = Corners[i],
}
);
}
}
/// <summary>Ставит рамку вокруг точки <paramref name="center"/> с полуразмером <paramref name="radius"/>.</summary>
public void Show(Vector2 center, float radius)
{
// Квад чуть больше объекта — скобки садятся снаружи (как в RimWorld); арт-уголок ~⅓ ширины.
var scale = new Vector2(radius * 2.2f / _region.Width);
for (var i = 0; i < _brackets.Length; i++)
{
ref var transform = ref _brackets[i].GetComponent<Transform2D>();
ref var sprite = ref _brackets[i].GetComponent<Sprite>();
transform.Position = center;
transform.Scale = scale;
sprite.Region = _region;
}
}
/// <summary>Прячет рамку (сбрасывает регион — рендер пропускает спрайты без текстуры).</summary>
public void Hide()
{
for (var i = 0; i < _brackets.Length; i++)
{
_brackets[i].GetComponent<Sprite>().Region = null;
}
}
}
+116 -92
View File
@@ -1,3 +1,4 @@
using System.Collections.Generic;
using System.Text; using System.Text;
using Friflo.Engine.ECS; using Friflo.Engine.ECS;
using LittleSim.Content; using LittleSim.Content;
@@ -14,31 +15,41 @@ using Myra.Graphics2D.UI;
namespace LittleSim.UI; namespace LittleSim.UI;
/// <summary> /// <summary>
/// Панель осмотра выбранной сущности (как «инфо-карточка» RimWorld): рамка-подсветка вокруг объекта /// Панель осмотра выбранной сущности (инфо-карточка в стиле RimWorld): угловая рамка вокруг объекта
/// в мире и боковая панель с именем, процессом роста, состоянием, температурным диапазоном, полным /// (<see cref="SelectionBrackets"/>) и боковая панель в левом нижнем углу с вкладками — «Обзор»,
/// набором генов/признаков и продуктами растения. Источник выделения — <see cref="Selection"/>; /// «Гены», «Продукты». Источник выделения — <see cref="Selection"/>; панель только читает компоненты
/// панель только читает компоненты и обновляется каждый кадр (<see cref="Refresh"/>), оставаясь /// и обновляется каждый кадр (<see cref="Refresh"/>), оставаясь живой даже на паузе. Все строки идут
/// живой даже на паузе. Строки идут через <see cref="LanguageManager"/> (ключи <c>inspect.*</c>). /// через <see cref="LanguageManager"/> (ключи <c>inspect.*</c>).
/// </summary> /// </summary>
internal sealed class InspectPanel internal sealed class InspectPanel
{ {
private enum Tab
{
Overview,
Genes,
Products,
}
private readonly EntityStore _store; private readonly EntityStore _store;
private readonly PlantSet _plants; private readonly PlantSet _plants;
private readonly GameContent _content; private readonly GameContent _content;
private readonly Climate _climate; private readonly Climate _climate;
private readonly Selection _selection; private readonly Selection _selection;
private readonly SelectionBrackets _brackets;
private readonly Panel _highlight;
private readonly VerticalStackPanel _panel; private readonly VerticalStackPanel _panel;
private readonly Label _title; private readonly Label _title;
private readonly Label _body; private readonly Label _body;
private readonly List<(Tab Tab, TextButton Button)> _tabs = [];
private Tab _tab = Tab.Overview;
public InspectPanel( public InspectPanel(
EntityStore store, EntityStore store,
PlantSet plants, PlantSet plants,
GameContent content, GameContent content,
Climate climate, Climate climate,
Selection selection Selection selection,
SelectionBrackets brackets
) )
{ {
_store = store; _store = store;
@@ -46,52 +57,60 @@ internal sealed class InspectPanel
_content = content; _content = content;
_climate = climate; _climate = climate;
_selection = selection; _selection = selection;
_brackets = brackets;
_title = new Label { TextColor = Ui.Accent, Wrap = true }; _title = new Label { TextColor = Ui.Accent, Wrap = true };
_body = new Label { TextColor = new Color(210, 216, 226), Wrap = true }; _body = new Label { TextColor = new Color(210, 216, 226), Wrap = true };
var tabBar = new HorizontalStackPanel { Spacing = 4 };
AddTab(tabBar, Tab.Overview, "inspect.tab.overview");
AddTab(tabBar, Tab.Genes, "inspect.tab.genes");
AddTab(tabBar, Tab.Products, "inspect.tab.products");
_panel = new VerticalStackPanel _panel = new VerticalStackPanel
{ {
Spacing = 6, Spacing = 6,
Padding = new Thickness(10), Padding = new Thickness(14),
Width = 290, Width = 380,
HorizontalAlignment = HorizontalAlignment.Right, HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Top, VerticalAlignment = VerticalAlignment.Bottom,
Margin = new Thickness(0, 10, 10, 0), Margin = new Thickness(12, 0, 0, 14),
Background = new SolidBrush(new Color(8, 10, 14, 235)), Background = new SolidBrush(new Color(10, 13, 18, 240)),
Visible = false, Border = new SolidBrush(new Color(96, 134, 168)),
};
_panel.Widgets.Add(_title);
_panel.Widgets.Add(new HorizontalSeparator());
_panel.Widgets.Add(_body);
_highlight = new Panel
{
Border = new SolidBrush(Ui.Accent),
BorderThickness = new Thickness(2), BorderThickness = new Thickness(2),
Visible = false, Visible = false,
}; };
_panel.Widgets.Add(_title);
_panel.Widgets.Add(tabBar);
_panel.Widgets.Add(new HorizontalSeparator());
_panel.Widgets.Add(_body);
_panel.Widgets.Add(
new Label
{
Text = _content.Languages.Get("inspect.hint"),
TextColor = Ui.Muted,
Wrap = true,
}
);
} }
/// <summary>Рамка-подсветка в мире — добавляется в корень экрана позади панели.</summary>
public Panel Highlight => _highlight;
/// <summary>Боковая инфо-панель — добавляется в корень экрана.</summary> /// <summary>Боковая инфо-панель — добавляется в корень экрана.</summary>
public VerticalStackPanel Panel => _panel; public VerticalStackPanel Panel => _panel;
/// <summary>Над панелью ли точка экрана (тогда клик не считается пикингом мира).</summary>
public bool IsOverPanel(Point screen) => _panel.Visible && _panel.Bounds.Contains(screen);
/// <summary> /// <summary>
/// Подтягивает выделение каждый кадр: проверяет, жива ли сущность (иначе снимает выбор), двигает /// Подтягивает выделение каждый кадр: проверяет, жива ли сущность (иначе снимает выбор), двигает
/// рамку по экрану и пересобирает текст. Не-растения панель не показывает (пока осматриваем только их). /// рамку к объекту и пересобирает текст активной вкладки. Не-растения панель не показывает.
/// </summary> /// </summary>
public void Refresh(Renderer2D renderer) public void Refresh()
{ {
if ( if (
!_selection.HasSelection !_selection.HasSelection
|| !_store.TryGetEntityById(_selection.EntityId, out var entity) || !_store.TryGetEntityById(_selection.EntityId, out var entity)
|| entity.IsNull || entity.IsNull
|| !entity.HasComponent<PlantGrowth>()
|| !entity.HasComponent<PlantOrganism>()
|| !entity.HasComponent<Transform2D>()
|| !entity.HasComponent<Sprite>()
) )
{ {
if (_selection.HasSelection) if (_selection.HasSelection)
@@ -99,55 +118,33 @@ internal sealed class InspectPanel
_selection.Clear(); // сущность исчезла (растение погибло) _selection.Clear(); // сущность исчезла (растение погибло)
} }
Hide(); _panel.Visible = false;
_brackets.Hide();
return; return;
} }
if ( var transform = entity.GetComponent<Transform2D>();
!entity.HasComponent<PlantGrowth>() var sprite = entity.GetComponent<Sprite>();
|| !entity.HasComponent<PlantOrganism>() if (sprite.Region is { } region)
|| !entity.HasComponent<Transform2D>()
|| !entity.HasComponent<Sprite>()
)
{ {
Hide(); var (center, radius) = CullingMath.SpriteBoundingCircle(
return; in transform,
region,
sprite.Origin
);
_brackets.Show(center, radius);
} }
UpdateHighlight(renderer, entity);
UpdateText(entity); UpdateText(entity);
_panel.Visible = true; _panel.Visible = true;
} }
private void Hide() private void AddTab(HorizontalStackPanel bar, Tab tab, string labelKey)
{ {
_panel.Visible = false; var button = new TextButton { Text = _content.Languages.Get(labelKey) };
_highlight.Visible = false; button.Click += (_, _) => _tab = tab;
} bar.Widgets.Add(button);
_tabs.Add((tab, button));
private void UpdateHighlight(Renderer2D renderer, Entity entity)
{
var transform = entity.GetComponent<Transform2D>();
var sprite = entity.GetComponent<Sprite>();
if (sprite.Region is not { } region)
{
_highlight.Visible = false;
return;
}
var (center, radius) = CullingMath.SpriteBoundingCircle(
in transform,
region,
sprite.Origin
);
var screenCenter = renderer.WorldToScreen(center);
var edge = renderer.WorldToScreen(center + new Vector2(radius, 0f));
var screenRadius = MathF.Max(4f, MathF.Abs(edge.X - screenCenter.X));
_highlight.Visible = true;
_highlight.Left = (int)(screenCenter.X - screenRadius);
_highlight.Top = (int)(screenCenter.Y - screenRadius);
_highlight.Width = (int)(screenRadius * 2f);
_highlight.Height = (int)(screenRadius * 2f);
} }
private void UpdateText(Entity entity) private void UpdateText(Entity entity)
@@ -160,16 +157,44 @@ internal sealed class InspectPanel
var traits = org.Traits; var traits = org.Traits;
_title.Text = languages.Get(def.Label) + (traits.IsVariant ? " *" : ""); _title.Text = languages.Get(def.Label) + (traits.IsVariant ? " *" : "");
foreach (var (tab, button) in _tabs)
{
button.TextColor = tab == _tab ? Ui.Accent : Ui.Muted;
}
var text = new StringBuilder();
switch (_tab)
{
case Tab.Overview:
BuildOverview(text, entity, grow, traits, species);
break;
case Tab.Genes:
BuildGenes(text, traits);
break;
case Tab.Products:
BuildProducts(text, entity, def, traits);
break;
}
_body.Text = text.ToString().TrimEnd();
}
private void BuildOverview(
StringBuilder text,
Entity entity,
in PlantGrowth grow,
in PlantPhenotype traits,
PlantSet.Species species
)
{
var languages = _content.Languages;
var lastStage = species.Stages.Length - 1; var lastStage = species.Stages.Length - 1;
var mature = grow.Stage >= lastStage; var mature = grow.Stage >= lastStage;
var text = new StringBuilder();
// Стадия и процесс роста.
text.AppendLine( text.AppendLine(
languages.Format( languages.Format(
"inspect.stage", "inspect.stage",
StageLabel(def, grow.Stage), StageLabel(species.Def, grow.Stage),
grow.Stage + 1, grow.Stage + 1,
species.Stages.Length species.Stages.Length
) )
@@ -188,22 +213,16 @@ internal sealed class InspectPanel
text.AppendLine(languages.Format("inspect.age", grow.AgeDays, traits.Lifespan)); text.AppendLine(languages.Format("inspect.age", grow.AgeDays, traits.Lifespan));
// Состояние по температуре (рост/покой), при наличии — накопленный стресс. // Состояние по температуре (рост/покой) и накопленный стресс.
var (tMin, tLow, tHigh, tMax) = traits.TemperatureBand(); var (tMin, tLow, tHigh, tMax) = traits.TemperatureBand();
var temperature = _climate.Temperature; var temperature = _climate.Temperature;
var suit = Suitability.Trapezoid(temperature, tMin, tLow, tHigh, tMax); var suit = Suitability.Trapezoid(temperature, tMin, tLow, tHigh, tMax);
string state; var state =
if (suit <= 0f) suit <= 0f
{ ? languages.Get(
state = languages.Get( temperature <= tMin ? "inspect.state.dormantcold" : "inspect.state.dormanthot"
temperature <= tMin ? "inspect.state.dormantcold" : "inspect.state.dormanthot" )
); : languages.Get(mature ? "inspect.state.mature" : "inspect.state.growing");
}
else
{
state = languages.Get(mature ? "inspect.state.mature" : "inspect.state.growing");
}
text.AppendLine(languages.Format("inspect.state", state)); text.AppendLine(languages.Format("inspect.state", state));
if (entity.TryGetComponent<TemperatureStress>(out var stress) && stress.Days > 0.05f) if (entity.TryGetComponent<TemperatureStress>(out var stress) && stress.Days > 0.05f)
{ {
@@ -211,10 +230,11 @@ internal sealed class InspectPanel
} }
text.AppendLine(languages.Format("inspect.temp", tMin, tMax, tLow, tHigh)); text.AppendLine(languages.Format("inspect.temp", tMin, tMax, tLow, tHigh));
}
// Гены / фенотип. private void BuildGenes(StringBuilder text, in PlantPhenotype traits)
text.AppendLine(); {
text.AppendLine(languages.Get("inspect.genes")); var languages = _content.Languages;
text.AppendLine(languages.Format("inspect.gene.vigor", traits.Vigor, traits.Lifespan)); text.AppendLine(languages.Format("inspect.gene.vigor", traits.Vigor, traits.Lifespan));
text.AppendLine( text.AppendLine(
languages.Format("inspect.gene.env", traits.OptimalLight, traits.OptimalFertility) languages.Format("inspect.gene.env", traits.OptimalLight, traits.OptimalFertility)
@@ -237,9 +257,16 @@ internal sealed class InspectPanel
{ {
text.AppendLine(languages.Get("inspect.gene.variant")); text.AppendLine(languages.Get("inspect.gene.variant"));
} }
}
// Продукты: сбор и плоды. private void BuildProducts(
text.AppendLine(); StringBuilder text,
Entity entity,
PlantDef def,
in PlantPhenotype traits
)
{
var languages = _content.Languages;
if (def.HarvestProduct is { } harvest) if (def.HarvestProduct is { } harvest)
{ {
text.AppendLine( text.AppendLine(
@@ -267,9 +294,6 @@ internal sealed class InspectPanel
{ {
text.AppendLine(languages.Get("inspect.barren")); text.AppendLine(languages.Get("inspect.barren"));
} }
text.Append(languages.Get("inspect.hint"));
_body.Text = text.ToString();
} }
private string StageLabel(PlantDef def, int stage) => private string StageLabel(PlantDef def, int stage) =>
+13
View File
@@ -82,6 +82,19 @@ internal static class SettingsPanel
) )
); );
// Масштаб интерфейса — сегменты пресетов.
var scales = new List<float> { 0.75f, 1f, 1.25f, 1.5f };
column.Widgets.Add(
Segment(
lang.Get("settings.uiscale"),
scales,
s => $"{(int)MathF.Round(s * 100)}%",
() => settings.UiScale,
s => settings.UiScale = s,
refreshers
)
);
// Громкость — кнопки −/+ с подписью процента. // Громкость — кнопки −/+ с подписью процента.
column.Widgets.Add(Volume(lang.Get("settings.volume"), settings, refreshers)); column.Widgets.Add(Volume(lang.Get("settings.volume"), settings, refreshers));
+47
View File
@@ -0,0 +1,47 @@
using LittleSim.App;
using Microsoft.Xna.Framework;
using MrGameEng.Core;
using MrGameEng.Graphics;
using MrGameEng.UI;
using Myra.Graphics2D.UI;
namespace LittleSim.UI;
/// <summary>
/// Масштаб интерфейса для Myra-десктопа. Scale увеличивает всю отрисовку, а <c>BoundsFetcher</c>
/// отдаёт логический размер «экран ÷ масштаб», поэтому раскладка перетекает (а не обрезается):
/// при масштабе 1.25 виджеты крупнее, но углы/центры остаются прижаты к краям экрана. Ввод Myra
/// трансформируется тем же преобразованием, так что попадания/<c>IsMouseOverGUI</c> остаются верными.
/// </summary>
internal static class UiScaling
{
/// <summary>Применяет масштаб <paramref name="scale"/> (клампится в разумные пределы) к десктопу.</summary>
public static void Apply(EngineContext context, Desktop desktop, float scale)
{
scale = System.Math.Clamp(scale, 0.75f, 2f);
desktop.TransformOrigin = Vector2.Zero; // масштабируем от левого-верхнего угла — края на месте
desktop.Scale = new Vector2(scale);
desktop.BoundsFetcher = () =>
{
var viewport = context.GetGraphicsDevice().Viewport;
return new Rectangle(
0,
0,
(int)MathF.Round(viewport.Width / scale),
(int)MathF.Round(viewport.Height / scale)
);
};
}
}
/// <summary>Расширение сцены: создаёт Myra-десктоп и сразу применяет масштаб UI из настроек.</summary>
internal static class ScaledUiExtensions
{
/// <summary>Как <c>UseUI</c>, но десктоп уже масштабирован по <see cref="GameSettings.UiScale"/>.</summary>
public static Desktop UseScaledUI(this Scene scene)
{
var desktop = scene.UseUI();
UiScaling.Apply(scene.Context, desktop, GameSettingsStore.Load().UiScale);
return desktop;
}
}