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; + } +}