diff --git a/Mods/Core/Languages/en/ui.json b/Mods/Core/Languages/en/ui.json
index bb0403b..916cab9 100644
--- a/Mods/Core/Languages/en/ui.json
+++ b/Mods/Core/Languages/en/ui.json
@@ -7,6 +7,9 @@
"season.autumn": "autumn",
"season.winter": "winter",
"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.growing": "Growth: {0:0}% to next stage",
"inspect.mature": "Fully grown",
@@ -57,6 +60,7 @@
"settings.vsync": "Vertical sync",
"settings.resolution": "Resolution",
"settings.volume": "Volume",
+ "settings.uiscale": "UI scale",
"settings.apply": "Apply",
"settings.back": "Back",
"pause.title": "Paused",
diff --git a/Mods/Core/Languages/ru/ui.json b/Mods/Core/Languages/ru/ui.json
index 05e1408..d41562d 100644
--- a/Mods/Core/Languages/ru/ui.json
+++ b/Mods/Core/Languages/ru/ui.json
@@ -7,6 +7,9 @@
"season.autumn": "осень",
"season.winter": "зима",
"hud.controls": "WASD — камера, колесо — зум, ЛКМ — выбрать, ` — консоль, F1 — инспектор",
+ "inspect.tab.overview": "Обзор",
+ "inspect.tab.genes": "Гены",
+ "inspect.tab.products": "Продукты",
"inspect.stage": "Стадия: {0} ({1}/{2})",
"inspect.growing": "Рост: {0:0}% до следующей стадии",
"inspect.mature": "Полностью выросло",
@@ -57,6 +60,7 @@
"settings.vsync": "Вертикальная синхронизация",
"settings.resolution": "Разрешение",
"settings.volume": "Громкость",
+ "settings.uiscale": "Масштаб UI",
"settings.apply": "Применить",
"settings.back": "Назад",
"pause.title": "Пауза",
diff --git a/src/LittleSim/App/GameSettings.cs b/src/LittleSim/App/GameSettings.cs
index 50b3a46..27a0e88 100644
--- a/src/LittleSim/App/GameSettings.cs
+++ b/src/LittleSim/App/GameSettings.cs
@@ -28,6 +28,9 @@ public sealed class GameSettings
/// Общая громкость 0..1.
public float Volume { get; set; } = 1f;
+
+ /// Масштаб интерфейса (1 — обычный); применяется к Myra-десктопу всех сцен.
+ public float UiScale { get; set; } = 1f;
}
/// Чтение/запись и применение их к движку.
diff --git a/src/LittleSim/Scenes/BootScene.cs b/src/LittleSim/Scenes/BootScene.cs
index 92f8d28..dc52f14 100644
--- a/src/LittleSim/Scenes/BootScene.cs
+++ b/src/LittleSim/Scenes/BootScene.cs
@@ -1,91 +1,91 @@
-using System;
-using System.Threading.Tasks;
-using LittleSim.App;
-using LittleSim.Content;
-using LittleSim.UI;
-using Microsoft.Xna.Framework;
-using MrGameEng.Audio;
-using MrGameEng.Core;
-using MrGameEng.Host;
-using MrGameEng.UI;
-using Myra.Graphics2D.UI;
-
-namespace LittleSim.Scenes;
-
-///
-/// Загрузочный экран. Поднимает аудио и управление скоростью, грузит настройки и применяет
-/// окно/громкость, затем грузит контент Core-мода в фоне (чистый CPU/диск — без GPU) с
-/// анимированной надписью. По готовности регистрирует сервисы и уходит в главное меню.
-///
-public sealed class BootScene : Scene
-{
- private readonly string[] _steps = { "Загрузка", "Loading" };
- private readonly string? _connectTo;
- private Task? _load;
- private GameSettings _settings = new();
- private Label _label = null!;
-
- /// Обычный запуск — в главное меню.
- public BootScene()
- : this(null) { }
-
- /// С (ws://host:port) грузится сразу в сетевую сцену.
- public BootScene(string? connectTo) => _connectTo = connectTo;
-
- protected override void OnLoad()
- {
- Context.UseAudio();
- Context.UseGameSpeed(1f, 3f, 6f);
-
- _settings = GameSettingsStore.Load();
- var host = (GameHost)Context.Services.Get();
- host.Graphics.IsFullScreen = _settings.Fullscreen;
- host.Graphics.SynchronizeWithVerticalRetrace = _settings.VSync;
- host.Graphics.PreferredBackBufferWidth = _settings.Width;
- host.Graphics.PreferredBackBufferHeight = _settings.Height;
- host.Graphics.ApplyChanges();
- Context.Services.Get().MasterVolume = _settings.Volume;
-
- var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
- _label = new Label
- {
- TextColor = Ui.Accent,
- HorizontalAlignment = HorizontalAlignment.Center,
- VerticalAlignment = VerticalAlignment.Center,
- };
- desktop.Root = Ui.Screen(_label);
-
- // Сборка атласов и дефов — без GPU, поэтому безопасно вне главного потока.
- _load = Task.Run(() => GameContent.Load());
- UpdateSystems.Add(new CallbackSystem(Tick));
- }
-
- private void Tick()
- {
- var word = _settings.Language == "en" ? _steps[1] : _steps[0];
- var dots = new string('.', (int)(Context.Clock.UnscaledTotalTime * 2) % 4);
- _label.Text = word + dots;
-
- if (_load is null || !_load.IsCompleted || Context.Scenes.IsTransitioning)
- {
- return;
- }
-
- if (_load.IsFaulted)
- {
- _label.Text = _load.Exception?.GetBaseException().Message ?? "load failed";
- Log.Error($"Content load failed: {_label.Text}");
- return;
- }
-
- var content = _load.Result;
- _load = null;
- Context.Services.Add(content);
- Context.Services.Add(new ModAtlases(content.AtlasCacheDirectory));
- content.Languages.SetLanguage(_settings.Language);
- Scene next = _connectTo is null
- ? new MainMenuScene()
- : new MultiplayerScene(new Uri(_connectTo));
- Context.Scenes.Switch(next, Transitions.Fade(0.6f));
- }
-}
+using System;
+using System.Threading.Tasks;
+using LittleSim.App;
+using LittleSim.Content;
+using LittleSim.UI;
+using Microsoft.Xna.Framework;
+using MrGameEng.Audio;
+using MrGameEng.Core;
+using MrGameEng.Host;
+using MrGameEng.UI;
+using Myra.Graphics2D.UI;
+
+namespace LittleSim.Scenes;
+
+///
+/// Загрузочный экран. Поднимает аудио и управление скоростью, грузит настройки и применяет
+/// окно/громкость, затем грузит контент Core-мода в фоне (чистый CPU/диск — без GPU) с
+/// анимированной надписью. По готовности регистрирует сервисы и уходит в главное меню.
+///
+public sealed class BootScene : Scene
+{
+ private readonly string[] _steps = { "Загрузка", "Loading" };
+ private readonly string? _connectTo;
+ private Task? _load;
+ private GameSettings _settings = new();
+ private Label _label = null!;
+
+ /// Обычный запуск — в главное меню.
+ public BootScene()
+ : this(null) { }
+
+ /// С (ws://host:port) грузится сразу в сетевую сцену.
+ public BootScene(string? connectTo) => _connectTo = connectTo;
+
+ protected override void OnLoad()
+ {
+ Context.UseAudio();
+ Context.UseGameSpeed(1f, 3f, 6f);
+
+ _settings = GameSettingsStore.Load();
+ var host = (GameHost)Context.Services.Get();
+ host.Graphics.IsFullScreen = _settings.Fullscreen;
+ host.Graphics.SynchronizeWithVerticalRetrace = _settings.VSync;
+ host.Graphics.PreferredBackBufferWidth = _settings.Width;
+ host.Graphics.PreferredBackBufferHeight = _settings.Height;
+ host.Graphics.ApplyChanges();
+ Context.Services.Get().MasterVolume = _settings.Volume;
+
+ var desktop = this.UseScaledUI(); // ставит MyraEnvironment.Game до создания виджетов
+ _label = new Label
+ {
+ TextColor = Ui.Accent,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ desktop.Root = Ui.Screen(_label);
+
+ // Сборка атласов и дефов — без GPU, поэтому безопасно вне главного потока.
+ _load = Task.Run(() => GameContent.Load());
+ UpdateSystems.Add(new CallbackSystem(Tick));
+ }
+
+ private void Tick()
+ {
+ var word = _settings.Language == "en" ? _steps[1] : _steps[0];
+ var dots = new string('.', (int)(Context.Clock.UnscaledTotalTime * 2) % 4);
+ _label.Text = word + dots;
+
+ if (_load is null || !_load.IsCompleted || Context.Scenes.IsTransitioning)
+ {
+ return;
+ }
+
+ if (_load.IsFaulted)
+ {
+ _label.Text = _load.Exception?.GetBaseException().Message ?? "load failed";
+ Log.Error($"Content load failed: {_label.Text}");
+ return;
+ }
+
+ var content = _load.Result;
+ _load = null;
+ Context.Services.Add(content);
+ Context.Services.Add(new ModAtlases(content.AtlasCacheDirectory));
+ content.Languages.SetLanguage(_settings.Language);
+ Scene next = _connectTo is null
+ ? new MainMenuScene()
+ : new MultiplayerScene(new Uri(_connectTo));
+ Context.Scenes.Switch(next, Transitions.Fade(0.6f));
+ }
+}
diff --git a/src/LittleSim/Scenes/CreditsScene.cs b/src/LittleSim/Scenes/CreditsScene.cs
index b4e63e5..254e6ec 100644
--- a/src/LittleSim/Scenes/CreditsScene.cs
+++ b/src/LittleSim/Scenes/CreditsScene.cs
@@ -15,7 +15,7 @@ public sealed class CreditsScene : Scene
{
var lang = Context.Services.Get().Languages;
var input = this.UseInput();
- var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
+ var desktop = this.UseScaledUI(); // ставит MyraEnvironment.Game до создания виджетов
var column = Ui.Column(12);
column.Widgets.Add(Ui.Title(lang.Get("credits.title")));
diff --git a/src/LittleSim/Scenes/GameLayers.cs b/src/LittleSim/Scenes/GameLayers.cs
index 01604fe..bc9fc05 100644
--- a/src/LittleSim/Scenes/GameLayers.cs
+++ b/src/LittleSim/Scenes/GameLayers.cs
@@ -13,6 +13,9 @@ public static class GameLayers
/// Y-sort слой существ и объектов мира: кто ниже на экране — тот ближе.
public static LayerId Beings { get; private set; }
+ /// Слой-оверлей поверх мира: рамка выделения (рисуется после существ, без Y-сорта).
+ public static LayerId Selection { get; private set; }
+
/// Регистрирует слои при первом вызове.
public static void EnsureRegistered(Renderer2D renderer)
{
@@ -22,6 +25,7 @@ public static class GameLayers
}
Beings = renderer.Layers.Register("Beings", LayerSpace.World, LayerSortMode.YSort);
+ Selection = renderer.Layers.Register("Selection", LayerSpace.World, LayerSortMode.Depth);
_registered = true;
}
}
diff --git a/src/LittleSim/Scenes/LoadGameScene.cs b/src/LittleSim/Scenes/LoadGameScene.cs
index be85763..586a466 100644
--- a/src/LittleSim/Scenes/LoadGameScene.cs
+++ b/src/LittleSim/Scenes/LoadGameScene.cs
@@ -24,7 +24,7 @@ public sealed class LoadGameScene : Scene
{
_content = Context.Services.Get();
var input = this.UseInput();
- _desktop = this.UseUI();
+ _desktop = this.UseScaledUI();
Rebuild();
UpdateSystems.Add(
diff --git a/src/LittleSim/Scenes/MainMenuScene.cs b/src/LittleSim/Scenes/MainMenuScene.cs
index 64dbc57..73b1e9d 100644
--- a/src/LittleSim/Scenes/MainMenuScene.cs
+++ b/src/LittleSim/Scenes/MainMenuScene.cs
@@ -17,7 +17,7 @@ public sealed class MainMenuScene : Scene
{
var content = Context.Services.Get();
var lang = content.Languages;
- var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
+ var desktop = this.UseScaledUI(); // ставит MyraEnvironment.Game до создания виджетов
var column = Ui.Column(12);
column.Widgets.Add(Ui.Title(lang.Get("menu.title")));
diff --git a/src/LittleSim/Scenes/MultiplayerScene.cs b/src/LittleSim/Scenes/MultiplayerScene.cs
index 1188d22..87caf7f 100644
--- a/src/LittleSim/Scenes/MultiplayerScene.cs
+++ b/src/LittleSim/Scenes/MultiplayerScene.cs
@@ -69,7 +69,7 @@ public sealed class MultiplayerScene : Scene
entity.AddComponent(new NetLerp());
};
- var desktop = this.UseUI();
+ var desktop = this.UseScaledUI();
_hud = new Label { Left = 10, Top = 8 };
desktop.Root = Ui.Screen(_hud);
diff --git a/src/LittleSim/Scenes/NewWorldScene.cs b/src/LittleSim/Scenes/NewWorldScene.cs
index a379f2a..abcfa4a 100644
--- a/src/LittleSim/Scenes/NewWorldScene.cs
+++ b/src/LittleSim/Scenes/NewWorldScene.cs
@@ -33,7 +33,7 @@ public sealed class NewWorldScene : Scene
var content = Context.Services.Get();
var lang = content.Languages;
var input = this.UseInput();
- var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
+ var desktop = this.UseScaledUI(); // ставит MyraEnvironment.Game до создания виджетов
var presets = content.Defs.All().OrderBy(p => p.Order).ToList();
_preset = presets[0];
diff --git a/src/LittleSim/Scenes/PauseMenu.cs b/src/LittleSim/Scenes/PauseMenu.cs
index 6071706..462e799 100644
--- a/src/LittleSim/Scenes/PauseMenu.cs
+++ b/src/LittleSim/Scenes/PauseMenu.cs
@@ -24,6 +24,7 @@ internal sealed class PauseMenu
private readonly GameSpeed _speed;
private readonly GameHost _host;
private readonly AudioManager _audio;
+ private readonly Desktop _desktop;
private readonly Func _onSave;
private readonly Action _onMainMenu;
private readonly Action _onQuit;
@@ -36,6 +37,7 @@ internal sealed class PauseMenu
EngineContext context,
GameContent content,
GameSpeed speed,
+ Desktop desktop,
Func onSave,
Action onMainMenu,
Action onQuit
@@ -44,6 +46,7 @@ internal sealed class PauseMenu
_context = context;
_content = content;
_speed = speed;
+ _desktop = desktop;
_host = (GameHost)context.Services.Get();
_audio = context.Services.Get();
_onSave = onSave;
@@ -144,6 +147,7 @@ internal sealed class PauseMenu
{
GameSettingsStore.Save(_settings);
GameSettingsStore.Apply(_settings, _host.Graphics, _content.Languages, _audio);
+ UiScaling.Apply(_context, _desktop, _settings.UiScale); // масштаб UI — сразу
ShowSettings(); // язык мог смениться
},
onBack: () =>
diff --git a/src/LittleSim/Scenes/SettingsScene.cs b/src/LittleSim/Scenes/SettingsScene.cs
index ce1e79f..f4986df 100644
--- a/src/LittleSim/Scenes/SettingsScene.cs
+++ b/src/LittleSim/Scenes/SettingsScene.cs
@@ -32,7 +32,7 @@ public sealed class SettingsScene : Scene
_host = (GameHost)Context.Services.Get();
var input = this.UseInput();
- _desktop = this.UseUI();
+ _desktop = this.UseScaledUI();
Rebuild();
UpdateSystems.Add(
@@ -53,6 +53,7 @@ public sealed class SettingsScene : Scene
{
GameSettingsStore.Save(_settings);
GameSettingsStore.Apply(_settings, _host.Graphics, _content.Languages, _audio);
+ UiScaling.Apply(Context, _desktop, _settings.UiScale); // масштаб UI — сразу
Rebuild(); // язык мог смениться — перестраиваем подписи
}
diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs
index 844c508..f5a0dd3 100644
--- a/src/LittleSim/Scenes/WorldScene.cs
+++ b/src/LittleSim/Scenes/WorldScene.cs
@@ -123,26 +123,26 @@ public sealed class WorldScene : Scene
var camera = Store.CreateEntity(new Camera(_bounds.Center, zoom: 1f, bounds: _bounds));
// HUD, полоса скорости и меню-пауза в одной корневой панели.
- var desktop = this.UseUI();
+ var desktop = this.UseScaledUI();
var hudLabel = new Label { Left = 10, Top = 8 };
var speedBar = BuildSpeedBar(content);
_pause = new PauseMenu(
Context,
content,
_speed,
+ desktop,
onSave: SaveWorld,
onMainMenu: () => Switch(new MainMenuScene()),
onQuit: () => Context.Services.Get().Exit()
);
_selection = new Selection();
- _inspect = new InspectPanel(Store, _plants, content, climate, _selection);
- desktop.Root = Ui.Screen(
- hudLabel,
- _inspect.Highlight,
- _inspect.Panel,
- speedBar,
- _pause.Root
+ var brackets = new SelectionBrackets(
+ Store,
+ atlases.GetRegion(device, "ui/overlays/selectionbracket"),
+ GameLayers.Selection
);
+ _inspect = new InspectPanel(Store, _plants, content, climate, _selection, brackets);
+ desktop.Root = Ui.Screen(hudLabel, _inspect.Panel, speedBar, _pause.Root);
this.UseInspector(renderer);
var console = this.UseDevConsole();
@@ -199,10 +199,10 @@ public sealed class WorldScene : Scene
renderer,
_selection,
() => _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(
new HudSystem(
Context,
diff --git a/src/LittleSim/Sim/Selection.cs b/src/LittleSim/Sim/Selection.cs
index 7b9153f..90d1689 100644
--- a/src/LittleSim/Sim/Selection.cs
+++ b/src/LittleSim/Sim/Selection.cs
@@ -40,14 +40,14 @@ public sealed class SelectionSystem(
Renderer2D renderer,
Selection selection,
Func blocked,
- Func overUi
+ Func overUi
) : BaseSystem
{
protected override void OnUpdateGroup()
{
- if (blocked())
+ if (blocked() || overUi())
{
- return;
+ return; // меню-пауза/консоль или клик пришёлся на виджет UI — это не пикинг мира
}
if (input.IsMousePressed(MouseButton.Right))
@@ -62,10 +62,6 @@ public sealed class SelectionSystem(
}
var mouse = input.MousePosition;
- if (overUi(mouse))
- {
- return; // клик пришёлся на панель — это не пикинг мира
- }
var world = renderer.ScreenToWorld(new Vector2(mouse.X, mouse.Y));
if (TryPick(world, out var id))
@@ -78,12 +74,17 @@ public sealed class SelectionSystem(
}
}
- // Ближайшая сущность, чья ограничивающая окружность спрайта накрывает точку мира.
+ // Ближайшее растение, чья ограничивающая окружность спрайта накрывает точку мира. Запрос
+ // ограничен PlantGrowth — так из пикинга выпадают спрайты рамки выделения и прочий не-контент.
private bool TryPick(Vector2 world, out int id)
{
id = -1;
var bestDistance = float.MaxValue;
- foreach (var (transforms, sprites, entities) in store.Query().Chunks)
+ foreach (
+ var (transforms, sprites, _, entities) in store
+ .Query()
+ .Chunks
+ )
{
var t = transforms.Span;
var s = sprites.Span;
diff --git a/src/LittleSim/Sim/SelectionBrackets.cs b/src/LittleSim/Sim/SelectionBrackets.cs
new file mode 100644
index 0000000..c51a4cc
--- /dev/null
+++ b/src/LittleSim/Sim/SelectionBrackets.cs
@@ -0,0 +1,75 @@
+using Friflo.Engine.ECS;
+using Microsoft.Xna.Framework;
+using MrGameEng.Graphics;
+
+namespace LittleSim.Sim;
+
+///
+/// Рамка выделения в стиле RimWorld: четыре угловые скобки вокруг выбранного объекта. Реализована
+/// как четыре спрайт-сущности на слое-оверлее () — их рисует
+/// штатный спрайт-рендер, а одна и та же текстура уголка (ui/overlays/selectionbracket)
+/// зеркалится во все четыре угла флагами (батчер при флипе меняет только UV,
+/// квадрат остаётся на месте). Скобки не имеют PlantGrowth, поэтому не попадают под пикинг и
+/// прочие запросы растений. двигает их к объекту, прячет.
+///
+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;
+
+ /// Создаёт четыре скрытые скобки на слое из текстуры .
+ 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],
+ }
+ );
+ }
+ }
+
+ /// Ставит рамку вокруг точки с полуразмером .
+ 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();
+ ref var sprite = ref _brackets[i].GetComponent();
+ transform.Position = center;
+ transform.Scale = scale;
+ sprite.Region = _region;
+ }
+ }
+
+ /// Прячет рамку (сбрасывает регион — рендер пропускает спрайты без текстуры).
+ public void Hide()
+ {
+ for (var i = 0; i < _brackets.Length; i++)
+ {
+ _brackets[i].GetComponent().Region = null;
+ }
+ }
+}
diff --git a/src/LittleSim/UI/InspectPanel.cs b/src/LittleSim/UI/InspectPanel.cs
index 03228ef..be4673b 100644
--- a/src/LittleSim/UI/InspectPanel.cs
+++ b/src/LittleSim/UI/InspectPanel.cs
@@ -1,3 +1,4 @@
+using System.Collections.Generic;
using System.Text;
using Friflo.Engine.ECS;
using LittleSim.Content;
@@ -14,31 +15,41 @@ using Myra.Graphics2D.UI;
namespace LittleSim.UI;
///
-/// Панель осмотра выбранной сущности (как «инфо-карточка» RimWorld): рамка-подсветка вокруг объекта
-/// в мире и боковая панель с именем, процессом роста, состоянием, температурным диапазоном, полным
-/// набором генов/признаков и продуктами растения. Источник выделения — ;
-/// панель только читает компоненты и обновляется каждый кадр (), оставаясь
-/// живой даже на паузе. Строки идут через (ключи inspect.*).
+/// Панель осмотра выбранной сущности (инфо-карточка в стиле RimWorld): угловая рамка вокруг объекта
+/// () и боковая панель в левом нижнем углу с вкладками — «Обзор»,
+/// «Гены», «Продукты». Источник выделения — ; панель только читает компоненты
+/// и обновляется каждый кадр (), оставаясь живой даже на паузе. Все строки идут
+/// через (ключи inspect.*).
///
internal sealed class InspectPanel
{
+ private enum Tab
+ {
+ Overview,
+ Genes,
+ Products,
+ }
+
private readonly EntityStore _store;
private readonly PlantSet _plants;
private readonly GameContent _content;
private readonly Climate _climate;
private readonly Selection _selection;
+ private readonly SelectionBrackets _brackets;
- private readonly Panel _highlight;
private readonly VerticalStackPanel _panel;
private readonly Label _title;
private readonly Label _body;
+ private readonly List<(Tab Tab, TextButton Button)> _tabs = [];
+ private Tab _tab = Tab.Overview;
public InspectPanel(
EntityStore store,
PlantSet plants,
GameContent content,
Climate climate,
- Selection selection
+ Selection selection,
+ SelectionBrackets brackets
)
{
_store = store;
@@ -46,52 +57,60 @@ internal sealed class InspectPanel
_content = content;
_climate = climate;
_selection = selection;
+ _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 };
+ AddTab(tabBar, Tab.Overview, "inspect.tab.overview");
+ AddTab(tabBar, Tab.Genes, "inspect.tab.genes");
+ AddTab(tabBar, Tab.Products, "inspect.tab.products");
+
_panel = new VerticalStackPanel
{
Spacing = 6,
- Padding = new Thickness(10),
- Width = 290,
- HorizontalAlignment = HorizontalAlignment.Right,
- VerticalAlignment = VerticalAlignment.Top,
- Margin = new Thickness(0, 10, 10, 0),
- Background = new SolidBrush(new Color(8, 10, 14, 235)),
- Visible = false,
- };
- _panel.Widgets.Add(_title);
- _panel.Widgets.Add(new HorizontalSeparator());
- _panel.Widgets.Add(_body);
-
- _highlight = new Panel
- {
- Border = new SolidBrush(Ui.Accent),
+ Padding = new Thickness(14),
+ Width = 380,
+ HorizontalAlignment = HorizontalAlignment.Left,
+ VerticalAlignment = VerticalAlignment.Bottom,
+ Margin = new Thickness(12, 0, 0, 14),
+ Background = new SolidBrush(new Color(10, 13, 18, 240)),
+ Border = new SolidBrush(new Color(96, 134, 168)),
BorderThickness = new Thickness(2),
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,
+ }
+ );
}
- /// Рамка-подсветка в мире — добавляется в корень экрана позади панели.
- public Panel Highlight => _highlight;
-
/// Боковая инфо-панель — добавляется в корень экрана.
public VerticalStackPanel Panel => _panel;
- /// Над панелью ли точка экрана (тогда клик не считается пикингом мира).
- public bool IsOverPanel(Point screen) => _panel.Visible && _panel.Bounds.Contains(screen);
-
///
/// Подтягивает выделение каждый кадр: проверяет, жива ли сущность (иначе снимает выбор), двигает
- /// рамку по экрану и пересобирает текст. Не-растения панель не показывает (пока осматриваем только их).
+ /// рамку к объекту и пересобирает текст активной вкладки. Не-растения панель не показывает.
///
- public void Refresh(Renderer2D renderer)
+ public void Refresh()
{
if (
!_selection.HasSelection
|| !_store.TryGetEntityById(_selection.EntityId, out var entity)
|| entity.IsNull
+ || !entity.HasComponent()
+ || !entity.HasComponent()
+ || !entity.HasComponent()
+ || !entity.HasComponent()
)
{
if (_selection.HasSelection)
@@ -99,55 +118,33 @@ internal sealed class InspectPanel
_selection.Clear(); // сущность исчезла (растение погибло)
}
- Hide();
+ _panel.Visible = false;
+ _brackets.Hide();
return;
}
- if (
- !entity.HasComponent()
- || !entity.HasComponent()
- || !entity.HasComponent()
- || !entity.HasComponent()
- )
+ var transform = entity.GetComponent();
+ var sprite = entity.GetComponent();
+ if (sprite.Region is { } region)
{
- Hide();
- return;
+ var (center, radius) = CullingMath.SpriteBoundingCircle(
+ in transform,
+ region,
+ sprite.Origin
+ );
+ _brackets.Show(center, radius);
}
- UpdateHighlight(renderer, entity);
UpdateText(entity);
_panel.Visible = true;
}
- private void Hide()
+ private void AddTab(HorizontalStackPanel bar, Tab tab, string labelKey)
{
- _panel.Visible = false;
- _highlight.Visible = false;
- }
-
- private void UpdateHighlight(Renderer2D renderer, Entity entity)
- {
- var transform = entity.GetComponent();
- var sprite = entity.GetComponent();
- 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);
+ var button = new TextButton { Text = _content.Languages.Get(labelKey) };
+ button.Click += (_, _) => _tab = tab;
+ bar.Widgets.Add(button);
+ _tabs.Add((tab, button));
}
private void UpdateText(Entity entity)
@@ -160,16 +157,44 @@ internal sealed class InspectPanel
var traits = org.Traits;
_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 mature = grow.Stage >= lastStage;
- var text = new StringBuilder();
- // Стадия и процесс роста.
text.AppendLine(
languages.Format(
"inspect.stage",
- StageLabel(def, grow.Stage),
+ StageLabel(species.Def, grow.Stage),
grow.Stage + 1,
species.Stages.Length
)
@@ -188,22 +213,16 @@ internal sealed class InspectPanel
text.AppendLine(languages.Format("inspect.age", grow.AgeDays, traits.Lifespan));
- // Состояние по температуре (рост/покой), при наличии — накопленный стресс.
+ // Состояние по температуре (рост/покой) и накопленный стресс.
var (tMin, tLow, tHigh, tMax) = traits.TemperatureBand();
var temperature = _climate.Temperature;
var suit = Suitability.Trapezoid(temperature, tMin, tLow, tHigh, tMax);
- string state;
- if (suit <= 0f)
- {
- state = languages.Get(
- temperature <= tMin ? "inspect.state.dormantcold" : "inspect.state.dormanthot"
- );
- }
- else
- {
- state = languages.Get(mature ? "inspect.state.mature" : "inspect.state.growing");
- }
-
+ var state =
+ suit <= 0f
+ ? languages.Get(
+ temperature <= tMin ? "inspect.state.dormantcold" : "inspect.state.dormanthot"
+ )
+ : languages.Get(mature ? "inspect.state.mature" : "inspect.state.growing");
text.AppendLine(languages.Format("inspect.state", state));
if (entity.TryGetComponent(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();
- text.AppendLine(languages.Get("inspect.genes"));
+ private void BuildGenes(StringBuilder text, 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)
@@ -237,9 +257,16 @@ internal sealed class InspectPanel
{
text.AppendLine(languages.Get("inspect.gene.variant"));
}
+ }
- // Продукты: сбор и плоды.
- text.AppendLine();
+ private void BuildProducts(
+ StringBuilder text,
+ Entity entity,
+ PlantDef def,
+ in PlantPhenotype traits
+ )
+ {
+ var languages = _content.Languages;
if (def.HarvestProduct is { } harvest)
{
text.AppendLine(
@@ -267,9 +294,6 @@ internal sealed class InspectPanel
{
text.AppendLine(languages.Get("inspect.barren"));
}
-
- text.Append(languages.Get("inspect.hint"));
- _body.Text = text.ToString();
}
private string StageLabel(PlantDef def, int stage) =>
diff --git a/src/LittleSim/UI/SettingsPanel.cs b/src/LittleSim/UI/SettingsPanel.cs
index 2206374..a1b1984 100644
--- a/src/LittleSim/UI/SettingsPanel.cs
+++ b/src/LittleSim/UI/SettingsPanel.cs
@@ -82,6 +82,19 @@ internal static class SettingsPanel
)
);
+ // Масштаб интерфейса — сегменты пресетов.
+ var scales = new List { 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));
diff --git a/src/LittleSim/UI/UiScaling.cs b/src/LittleSim/UI/UiScaling.cs
new file mode 100644
index 0000000..887b1e3
--- /dev/null
+++ b/src/LittleSim/UI/UiScaling.cs
@@ -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;
+
+///
+/// Масштаб интерфейса для Myra-десктопа. Scale увеличивает всю отрисовку, а BoundsFetcher
+/// отдаёт логический размер «экран ÷ масштаб», поэтому раскладка перетекает (а не обрезается):
+/// при масштабе 1.25 виджеты крупнее, но углы/центры остаются прижаты к краям экрана. Ввод Myra
+/// трансформируется тем же преобразованием, так что попадания/IsMouseOverGUI остаются верными.
+///
+internal static class UiScaling
+{
+ /// Применяет масштаб (клампится в разумные пределы) к десктопу.
+ 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)
+ );
+ };
+ }
+}
+
+/// Расширение сцены: создаёт Myra-десктоп и сразу применяет масштаб UI из настроек.
+internal static class ScaledUiExtensions
+{
+ /// Как UseUI, но десктоп уже масштабирован по .
+ public static Desktop UseScaledUI(this Scene scene)
+ {
+ var desktop = scene.UseUI();
+ UiScaling.Apply(scene.Context, desktop, GameSettingsStore.Load().UiScale);
+ return desktop;
+ }
+}