From b9bb7006be3fda2b9b33053d845167bd8b27fffd Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sat, 13 Jun 2026 06:24:53 +0300 Subject: [PATCH] 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. --- Mods/Core/Languages/en/ui.json | 4 + Mods/Core/Languages/ru/ui.json | 4 + src/LittleSim/App/GameSettings.cs | 3 + src/LittleSim/Scenes/BootScene.cs | 182 ++++++++++---------- src/LittleSim/Scenes/CreditsScene.cs | 2 +- src/LittleSim/Scenes/GameLayers.cs | 4 + src/LittleSim/Scenes/LoadGameScene.cs | 2 +- src/LittleSim/Scenes/MainMenuScene.cs | 2 +- src/LittleSim/Scenes/MultiplayerScene.cs | 2 +- src/LittleSim/Scenes/NewWorldScene.cs | 2 +- src/LittleSim/Scenes/PauseMenu.cs | 4 + src/LittleSim/Scenes/SettingsScene.cs | 3 +- src/LittleSim/Scenes/WorldScene.cs | 20 +-- src/LittleSim/Sim/Selection.cs | 19 ++- src/LittleSim/Sim/SelectionBrackets.cs | 75 ++++++++ src/LittleSim/UI/InspectPanel.cs | 208 +++++++++++++---------- src/LittleSim/UI/SettingsPanel.cs | 13 ++ src/LittleSim/UI/UiScaling.cs | 47 +++++ 18 files changed, 388 insertions(+), 208 deletions(-) create mode 100644 src/LittleSim/Sim/SelectionBrackets.cs create mode 100644 src/LittleSim/UI/UiScaling.cs 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; + } +}