diff --git a/Mods/Core/Defs/plants.json b/Mods/Core/Defs/plants.json index e520e4b..0791e14 100644 --- a/Mods/Core/Defs/plants.json +++ b/Mods/Core/Defs/plants.json @@ -21,8 +21,8 @@ { "defName": "GrassA", "label": "plant.grass", "texture": "things/plant/grass/grassa", "sizeCells": 1.2, "harvestProduct": "ProductGrass", - "genome": { "optimalLight": 0.85, "lightTolerance": 0.35, "optimalTemperature": 22, "temperatureTolerance": 8, - "coldHardiness": 8, "heatHardiness": 6, + "genome": { "optimalLight": 0.85, "lightTolerance": 0.35, "optimalTemperature": 19, "temperatureTolerance": 8, + "coldHardiness": 9, "heatHardiness": 6, "optimalFertility": 1.0, "fertilityTolerance": 1.0, "vigor": 1.3, "lifespan": 22, "dispersalRange": 3, "reproduceInterval": 3, "selfPollination": 0.8, "mutationRate": 0.06, "variantChance": 0.2, "spread": 0.1, diff --git a/Mods/Core/Defs/terrain.json b/Mods/Core/Defs/terrain.json index a53f681..d86cb5b 100644 --- a/Mods/Core/Defs/terrain.json +++ b/Mods/Core/Defs/terrain.json @@ -8,13 +8,14 @@ { "defName": "Grass", "label": "terrain.grass", "maxHeight": 0.68, "color": [90, 160, 70], "isLand": true, "surface": "terrain/surfaces/mossy", "fertility": 1.0, "scatter": [ - { "chance": 0.35, "options": ["GrassA"] }, + { "chance": 0.6, "options": ["GrassA"] }, { "chance": 0.07, "options": ["BushA", "BushB", "BushC"] } ] }, { "defName": "Forest", "label": "terrain.forest", "maxHeight": 0.85, "color": [44, 110, 50], "isLand": true, "surface": "terrain/surfaces/soil", "fertility": 1.6, "scatter": [ - { "chance": 0.35, "options": ["TreeOakA", "TreeOakB", "TreeBirchA", "TreeGrayPineA"] } + { "chance": 0.35, "options": ["TreeOakA", "TreeOakB", "TreeBirchA", "TreeGrayPineA"] }, + { "chance": 0.3, "options": ["GrassA"] } ] }, { "defName": "Mountain", "label": "terrain.mountain", "maxHeight": 1.01, "color": [136, 132, 128], "surface": "terrain/surfaces/roughhewnrock", "fertility": 0.2, "blocksLight": true } diff --git a/Mods/Core/Languages/en/ui.json b/Mods/Core/Languages/en/ui.json index e57fb66..bb0403b 100644 --- a/Mods/Core/Languages/en/ui.json +++ b/Mods/Core/Languages/en/ui.json @@ -6,7 +6,29 @@ "season.summer": "summer", "season.autumn": "autumn", "season.winter": "winter", - "hud.controls": "WASD — camera, wheel — zoom, ` — console, F1 — inspector", + "hud.controls": "WASD — camera, wheel — zoom, LMB — select, ` — console, F1 — inspector", + "inspect.stage": "Stage: {0} ({1}/{2})", + "inspect.growing": "Growth: {0:0}% to next stage", + "inspect.mature": "Fully grown", + "inspect.age": "Age: {0:0.0} d · lives to {1:0}", + "inspect.state": "State: {0}", + "inspect.state.growing": "growing", + "inspect.state.mature": "mature", + "inspect.state.dormantcold": "dormant — too cold", + "inspect.state.dormanthot": "dormant — too hot", + "inspect.stress": "Stress: {0:0.0} d to death", + "inspect.temp": "Temperature: grows {0:0}…{1:0}°C, optimal {2:0}…{3:0}°C", + "inspect.genes": "— Genes —", + "inspect.gene.vigor": "Vigor {0:0.00} · lifespan {1:0} d", + "inspect.gene.env": "Opt. light {0:0.00} · opt. soil {1:0.00}", + "inspect.gene.repro": "Dispersal {0:0.0} · interval {1:0.0} d", + "inspect.gene.repro2": "Self-pollin. {0:0.00} · mutation {1:0.00}", + "inspect.gene.hardy": "Cold/heat {0:0}/{1:0} · leaf hue {2:0.00}", + "inspect.gene.variant": "Morph: recessive variant", + "inspect.harvest": "Harvest: {0} ×{1:0.#}", + "inspect.fruit": "Fruit: {0} ×{1:0.#} ({2}) · ripe {3:0.#}", + "inspect.barren": "Bears no fruit", + "inspect.hint": "LMB — select · RMB/Esc — clear", "hud.paused": "PAUSED", "menu.title": "LittleSim", "menu.subtitle": "a god-game: minimal graphics, deep simulation", @@ -84,5 +106,6 @@ "net.connecting": "connecting…", "net.connected": "connected", "net.failed": "connection failed", - "net.lost": "connection lost" + "net.lost": "connection lost", + "net.reconnecting": "reconnecting…" } diff --git a/Mods/Core/Languages/ru/ui.json b/Mods/Core/Languages/ru/ui.json index b7982f4..05e1408 100644 --- a/Mods/Core/Languages/ru/ui.json +++ b/Mods/Core/Languages/ru/ui.json @@ -6,7 +6,29 @@ "season.summer": "лето", "season.autumn": "осень", "season.winter": "зима", - "hud.controls": "WASD — камера, колесо — зум, ` — консоль, F1 — инспектор", + "hud.controls": "WASD — камера, колесо — зум, ЛКМ — выбрать, ` — консоль, F1 — инспектор", + "inspect.stage": "Стадия: {0} ({1}/{2})", + "inspect.growing": "Рост: {0:0}% до следующей стадии", + "inspect.mature": "Полностью выросло", + "inspect.age": "Возраст: {0:0.0} дн · живёт до {1:0}", + "inspect.state": "Состояние: {0}", + "inspect.state.growing": "растёт", + "inspect.state.mature": "созрело", + "inspect.state.dormantcold": "покой — слишком холодно", + "inspect.state.dormanthot": "покой — слишком жарко", + "inspect.stress": "Стресс: {0:0.0} дн до гибели", + "inspect.temp": "Температура: рост {0:0}…{1:0}°C, оптимум {2:0}…{3:0}°C", + "inspect.genes": "— Гены —", + "inspect.gene.vigor": "Бодрость {0:0.00} · жизнь {1:0} дн", + "inspect.gene.env": "Опт. свет {0:0.00} · опт. почва {1:0.00}", + "inspect.gene.repro": "Расселение {0:0.0} кл · период {1:0.0} дн", + "inspect.gene.repro2": "Самоопыление {0:0.00} · мутации {1:0.00}", + "inspect.gene.hardy": "Морозо/жаро {0:0}/{1:0} · оттенок {2:0.00}", + "inspect.gene.variant": "Морфа: рецессивный вариант", + "inspect.harvest": "Сбор: {0} ×{1:0.#}", + "inspect.fruit": "Плоды: {0} ×{1:0.#} ({2}) · зрелых {3:0.#}", + "inspect.barren": "Не плодоносит", + "inspect.hint": "ЛКМ — выбрать · ПКМ/Esc — снять", "hud.paused": "ПАУЗА", "menu.title": "LittleSim", "menu.subtitle": "бог-игра: минимум графики, максимум симуляции", @@ -84,5 +106,6 @@ "net.connecting": "подключение…", "net.connected": "подключено", "net.failed": "не удалось подключиться", - "net.lost": "соединение потеряно" + "net.lost": "соединение потеряно", + "net.reconnecting": "переподключение…" } diff --git a/scripts/run.ps1 b/scripts/run.ps1 index 19984da..404d248 100644 --- a/scripts/run.ps1 +++ b/scripts/run.ps1 @@ -9,7 +9,9 @@ $Project = Join-Path $Root "src\LittleSim\LittleSim.csproj" Push-Location $Root try { - dotnet build LittleSim.sln -c $Configuration + # Только игровой проект (тянет за собой нужные движковые библиотеки) — не вся солюшн с + # тестами, сервером и WASM-клиентом (его emscripten-сборка занимает минуты). + dotnet build $Project -c $Configuration if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/scripts/run.sh b/scripts/run.sh index 51b8afd..930af78 100644 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -18,5 +18,7 @@ done cd "$ROOT" -dotnet build LittleSim.sln -c "$CONFIGURATION" +# Только игровой проект (тянет за собой нужные движковые библиотеки) — не вся солюшн с +# тестами, сервером и WASM-клиентом (его emscripten-сборка занимает минуты). +dotnet build src/LittleSim/LittleSim.csproj -c "$CONFIGURATION" dotnet run --project src/LittleSim/LittleSim.csproj -c "$CONFIGURATION" --no-build "$@" diff --git a/src/LittleSim.Web/LittleSimWebGame.cs b/src/LittleSim.Web/LittleSimWebGame.cs index 6892636..9f420ff 100644 --- a/src/LittleSim.Web/LittleSimWebGame.cs +++ b/src/LittleSim.Web/LittleSimWebGame.cs @@ -16,24 +16,33 @@ public class LittleSimWebGame : Game /// Контекст ядра движка, общий со сценами и системами. public EngineContext Context { get; } = new EngineContext(); - private readonly Uri _server; private GraphicsDeviceManager _graphics; private SpriteBatch _spriteBatch = null!; private Texture2D _pixel = null!; + private WorldViewScene _scene = null!; - /// Игра, подключающаяся к серверу . - public LittleSimWebGame(Uri server) + /// Игра-наблюдатель: мира не создаёт, ждёт из UI. + public LittleSimWebGame() { - _server = server; _graphics = new GraphicsDeviceManager(this); Content.RootDirectory = "Content"; } + /// Состояние соединения со сценой-наблюдателем — читает оверлей подключения. + public ConnectionStatus Status => _scene?.Status ?? ConnectionStatus.Idle; + + /// Число реплицированных жителей — для статуса в оверлее. + public int EntityCount => _scene?.EntityCount ?? 0; + + /// Подключиться к серверу (вызывается из Blazor-оверлея). + public void Connect(Uri server) => _scene?.Connect(server); + /// protected override void Initialize() { base.Initialize(); - Context.Scenes.Switch(new WorldViewScene(_server, () => _spriteBatch, () => _pixel)); + _scene = new WorldViewScene(() => _spriteBatch, () => _pixel); + Context.Scenes.Switch(_scene); } /// diff --git a/src/LittleSim.Web/Pages/Index.razor b/src/LittleSim.Web/Pages/Index.razor index 262c9da..adaaa04 100644 --- a/src/LittleSim.Web/Pages/Index.razor +++ b/src/LittleSim.Web/Pages/Index.razor @@ -1,20 +1,29 @@ -@page "/" -@page "/index.html" -@inject IJSRuntime JsRuntime -@using nkast.Wasm.Canvas - -LittleSim - -
- -
+@page "/" +@page "/index.html" +@inject IJSRuntime JsRuntime +@using nkast.Wasm.Canvas + +LittleSim + +
+ +
+ +
+ LittleSim + + + @StatusText +
diff --git a/src/LittleSim.Web/Pages/Index.razor.cs b/src/LittleSim.Web/Pages/Index.razor.cs index 072d728..a78a404 100644 --- a/src/LittleSim.Web/Pages/Index.razor.cs +++ b/src/LittleSim.Web/Pages/Index.razor.cs @@ -1,7 +1,7 @@ using System; using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; using Microsoft.JSInterop; -using Microsoft.Xna.Framework; namespace LittleSim.Web.Pages { @@ -10,7 +10,12 @@ namespace LittleSim.Web.Pages [Inject] private NavigationManager Navigation { get; set; } = null!; - private Game? _game; + private LittleSimWebGame? _game; + private string _serverAddress = ""; + private Uri? _pendingServer; // запрошен Connect до создания игры — подключим в первом тике + private ConnectionStatus _lastStatus = ConnectionStatus.Idle; + private int _lastCount = -1; + private int _frame; protected override void OnAfterRender(bool firstRender) { @@ -18,25 +23,92 @@ namespace LittleSim.Web.Pages if (firstRender) { + _serverAddress = DefaultServerAddress(); + StateHasChanged(); JsRuntime.InvokeAsync("initRenderJS", DotNetObjectReference.Create(this)); } } + // Гонится из requestAnimationFrame (index.html). Игру создаём лениво в первом тике — + // мира она не создаёт, лишь ждёт адрес сервера из формы. [JSInvokable] public void TickDotNet() { if (_game == null) { - _game = new LittleSimWebGame(ResolveServerUri()); + _game = new LittleSimWebGame(); _game.Run(); } _game.Tick(); + + if (_pendingServer is not null) + { + _game.Connect(_pendingServer); + _pendingServer = null; + } + + // Перерисовываем оверлей только при смене статуса (или изредка — чтобы освежить счётчик), + // а не каждый кадр rAF. + var status = _game.Status; + var count = _game.EntityCount; + if ( + status != _lastStatus + || (status == ConnectionStatus.Connected && count != _lastCount && _frame % 15 == 0) + ) + { + _lastStatus = status; + _lastCount = count; + InvokeAsync(StateHasChanged); + } + + _frame++; } - // Адрес сервера: ?server=ws://host:port в URL страницы; по умолчанию — - // хост самой страницы на порту LittleSim.Server. - private Uri ResolveServerUri() + private void OnAddressKey(KeyboardEventArgs e) + { + if (e.Key == "Enter") + { + Connect(); + } + } + + private void Connect() + { + if (!Uri.TryCreate(_serverAddress?.Trim(), UriKind.Absolute, out var uri)) + { + return; // адрес не похож на ws://… — игнорируем + } + + if (_game is not null) + { + _game.Connect(uri); + } + else + { + _pendingServer = uri; + } + } + + private string StatusText => + _lastStatus switch + { + ConnectionStatus.Connecting => "подключение…", + ConnectionStatus.Connected => $"подключено · {_lastCount} жит.", + ConnectionStatus.Reconnecting => "переподключение…", + _ => "не подключено", + }; + + private string StatusClass => + _lastStatus switch + { + ConnectionStatus.Connected => "ok", + ConnectionStatus.Connecting or ConnectionStatus.Reconnecting => "warn", + _ => "idle", + }; + + // Адрес по умолчанию: ?server=ws://host:port из URL страницы, иначе хост страницы на порту сервера. + private string DefaultServerAddress() { var page = new Uri(Navigation.Uri); var query = page.Query.TrimStart('?'); @@ -45,7 +117,7 @@ namespace LittleSim.Web.Pages var separator = pair.IndexOf('='); if (separator > 0 && pair[..separator] == "server") { - return new Uri(Uri.UnescapeDataString(pair[(separator + 1)..])); + return Uri.UnescapeDataString(pair[(separator + 1)..]); } } @@ -54,7 +126,7 @@ namespace LittleSim.Web.Pages Scheme = "ws", Host = page.Host, Port = WebNetSchema.DefaultPort, - }.Uri; + }.Uri.ToString(); } } } diff --git a/src/LittleSim.Web/Pages/Index.razor.css b/src/LittleSim.Web/Pages/Index.razor.css new file mode 100644 index 0000000..df90da9 --- /dev/null +++ b/src/LittleSim.Web/Pages/Index.razor.css @@ -0,0 +1,72 @@ +.connect-bar { + position: fixed; + top: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + box-sizing: border-box; + background: rgba(12, 16, 24, 0.82); + border-bottom: 1px solid rgba(108, 198, 255, 0.25); + color: #d8e0ea; + font-family: 'Segoe UI', system-ui, sans-serif; + font-size: 14px; + z-index: 10; +} + +.connect-bar .title { + font-weight: 600; + color: #6cc6ff; + letter-spacing: 0.02em; +} + +.connect-bar .server { + flex: 0 1 320px; + min-width: 0; + padding: 5px 9px; + background: #0c1018; + border: 1px solid #2a3850; + border-radius: 4px; + color: #e6edf6; + font-family: ui-monospace, 'Cascadia Code', monospace; + font-size: 13px; +} + +.connect-bar .server:focus { + outline: none; + border-color: #6cc6ff; +} + +.connect-bar .connect { + padding: 5px 14px; + background: #1c2c44; + border: 1px solid #3a567f; + border-radius: 4px; + color: #d8e0ea; + font-size: 13px; + cursor: pointer; +} + +.connect-bar .connect:hover { + background: #25395a; + border-color: #6cc6ff; +} + +.connect-bar .status { + margin-left: auto; + font-variant-numeric: tabular-nums; +} + +.connect-bar .status.ok { + color: #7fdca0; +} + +.connect-bar .status.warn { + color: #f0c060; +} + +.connect-bar .status.idle { + color: #8a96a6; +} diff --git a/src/LittleSim.Web/WorldViewScene.cs b/src/LittleSim.Web/WorldViewScene.cs index 4d4756b..8db2755 100644 --- a/src/LittleSim.Web/WorldViewScene.cs +++ b/src/LittleSim.Web/WorldViewScene.cs @@ -9,31 +9,74 @@ using MrGameEng.Net; namespace LittleSim.Web; +/// Состояние соединения веб-клиента — его читает оверлей подключения (Blazor). +public enum ConnectionStatus +{ + /// Сервер ещё не выбран — ждём, пока пользователь нажмёт «Подключиться». + Idle, + + /// Идёт первая попытка подключения. + Connecting, + + /// Соединение установлено, снапшоты приходят. + Connected, + + /// Соединение оборвалось/не удалось — ждём следующей попытки (бэкофф). + Reconnecting, +} + /// -/// Браузерный клиент мира LittleSim: подключается к дедикейтед-серверу -/// (LittleSim.Server --listen), применяет дельта-снапшоты в свой EntityStore и рисует -/// жителей через KNI SpriteBatch (WebGL). Симуляция целиком на сервере — сюда приезжают -/// только компоненты схемы (позиция + потребности); позиции сглаживаются до частоты -/// кадра, усталость затемняет квадратик жителя. +/// Браузерный клиент мира LittleSim: по команде подключается к +/// дедикейтед-серверу (LittleSim.Server --listen), применяет дельта-снапшоты в свой +/// EntityStore и рисует жителей через KNI SpriteBatch (WebGL). Мира не создаёт — только +/// наблюдает: симуляция целиком на сервере, сюда приезжают лишь компоненты схемы (позиция + +/// потребности). Позиции сглаживаются до частоты кадра, усталость затемняет квадратик. При +/// обрыве переподключается сам с экспоненциальным бэкоффом, очищая устаревшие сущности. /// public sealed class WorldViewScene : Scene { - private readonly Uri _server; + // Бэкофф переподключения: задержка удваивается с каждой неудачей до потолка. + private const float MaxBackoffSeconds = 8f; + private readonly Func _spriteBatch; private readonly Func _pixel; private ReplicationClient _replication = null!; private Task? _connecting; private WebSocketClient? _connection; + private Uri? _server; // целевой сервер; null — пользователь ещё не подключался + private double _nextAttemptAt; + private int _attempt; - /// Сцена, подключающаяся к . - public WorldViewScene(Uri server, Func spriteBatch, Func pixel) + /// Текущее состояние соединения — для оверлея подключения. + public ConnectionStatus Status { get; private set; } = ConnectionStatus.Idle; + + /// Сколько реплицированных жителей сейчас в мире клиента. + public int EntityCount => _replication?.EntityCount ?? 0; + + /// Сцена-наблюдатель; сервер задаётся позже через . + public WorldViewScene(Func spriteBatch, Func pixel) { - _server = server; _spriteBatch = spriteBatch; _pixel = pixel; } + /// + /// Подключиться к (ws:// или wss://). Сбрасывает прежнее + /// соединение и счётчик попыток — подключение начнётся в ближайшем тике. Дальше клиент сам + /// переподключается к этому адресу при обрывах. + /// + public void Connect(Uri server) + { + _connection?.Close(); + _connection = null; + _replication?.Clear(); + _server = server; + _attempt = 0; + _nextAttemptAt = 0f; + Status = ConnectionStatus.Connecting; + } + /// Сглаживание сетевой позиции: цель из снапшота, визуал лерпится покадрово. private struct NetLerp : IComponent { @@ -51,35 +94,68 @@ public sealed class WorldViewScene : Scene UpdateSystems.Add(new NetSmoothingSystem()); DrawSystems.Add(new PawnDrawSystem(_spriteBatch, _pixel)); - Log.Info($"LittleSim.Web: connecting to {_server}…"); - _connecting = WebSocketClient.ConnectAsync(_server); + Log.Info("LittleSim.Web ready — awaiting connect"); } protected override void OnUnload() => _connection?.Close(); private void Pump() { + if (_server is null) + { + return; // адрес ещё не задан — ждём команды Connect из оверлея + } + + var now = Context.Clock.UnscaledTotalTime; + + // Завершилась попытка подключения: успех — берём соединение; провал — планируем повтор. if (_connecting is { IsCompleted: true } finished) { _connecting = null; - if (finished.IsFaulted) + if (finished.IsCompletedSuccessfully) + { + _connection = finished.Result; + _replication.Clear(); // сбрасываем устаревшие сущности перед свежим полным снапшотом + _attempt = 0; + Status = ConnectionStatus.Connected; + Log.Info($"Connected to {_server}"); + } + else { Log.Error( $"Connect to {_server} failed: " + finished.Exception?.GetBaseException().Message ); + ScheduleReconnect(now); } - else - { - _connection = finished.Result; - Log.Info($"Connected to {_server}"); - } + } + + // Обрыв установленного соединения: чистим и уходим в переподключение. + if (_connection is { IsOpen: false }) + { + _connection = null; + _replication.Clear(); + ScheduleReconnect(now); } if (_connection is not null) { _replication.Pump(_connection); } + else if (_connecting is null && now >= _nextAttemptAt) + { + Status = _attempt == 0 ? ConnectionStatus.Connecting : ConnectionStatus.Reconnecting; + _connecting = WebSocketClient.ConnectAsync(_server); + } + } + + // Экспоненциальный бэкофф: 1, 2, 4, 8, 8, … секунд между попытками. + private void ScheduleReconnect(double now) + { + var delay = MathF.Min(MaxBackoffSeconds, 1f * (1 << Math.Min(_attempt, 3))); + _attempt++; + _nextAttemptAt = now + delay; + Status = ConnectionStatus.Reconnecting; } /// Вызывает делегат каждый тик — мелкая логика сцены без отдельного класса. diff --git a/src/LittleSim/Scenes/MultiplayerScene.cs b/src/LittleSim/Scenes/MultiplayerScene.cs index accd9d2..1188d22 100644 --- a/src/LittleSim/Scenes/MultiplayerScene.cs +++ b/src/LittleSim/Scenes/MultiplayerScene.cs @@ -30,6 +30,9 @@ public sealed class MultiplayerScene : Scene { private static readonly RectF Bounds = new(0f, 0f, 1280f, 720f); + // Бэкофф переподключения: задержка удваивается с каждой неудачей до потолка. + private const float MaxBackoffSeconds = 8f; + private readonly Uri _server; private Task? _connecting; private WebSocketClient? _connection; @@ -38,6 +41,8 @@ public sealed class MultiplayerScene : Scene private GameContent _content = null!; private Label _hud = null!; private string _statusKey = "net.connecting"; + private double _nextAttemptAt; // время (unscaled) следующей попытки подключения + private int _attempt; // 0 — первая попытка; растёт при обрывах, задаёт бэкофф /// Сцена, подключающаяся к серверу (ws:// или wss://). public MultiplayerScene(Uri server) => _server = server; @@ -86,39 +91,53 @@ public sealed class MultiplayerScene : Scene } ); - _connecting = WebSocketClient.ConnectAsync(_server); + // Первую попытку запускает сам Pump (единый путь с переподключением): _nextAttemptAt = 0. } protected override void OnUnload() => _connection?.Close(); private void Pump() { + var now = Context.Clock.UnscaledTotalTime; + + // Завершилась попытка подключения: успех — берём соединение; провал — планируем повтор. if (_connecting is { IsCompleted: true } finished) { _connecting = null; - if (finished.IsFaulted) + if (finished.IsCompletedSuccessfully) + { + _connection = finished.Result; + _replication.Clear(); // сбрасываем устаревшие сущности перед свежим полным снапшотом + _attempt = 0; + _statusKey = "net.connected"; + Log.Info($"Connected to {_server}"); + } + else { - _statusKey = "net.failed"; Log.Error( $"Connect to {_server} failed: " + finished.Exception?.GetBaseException().Message ); + ScheduleReconnect(now); } - else - { - _connection = finished.Result; - _statusKey = "net.connected"; - Log.Info($"Connected to {_server}"); - } + } + + // Обрыв уже установленного соединения: чистим и уходим в переподключение. + if (_connection is { IsOpen: false }) + { + _connection = null; + _replication.Clear(); + ScheduleReconnect(now); } if (_connection is not null) { _replication.Pump(_connection); - if (!_connection.IsOpen) - { - _statusKey = "net.lost"; - } + } + else if (_connecting is null && now >= _nextAttemptAt) + { + _statusKey = _attempt == 0 ? "net.connecting" : "net.reconnecting"; + _connecting = WebSocketClient.ConnectAsync(_server); } _hud.Text = _content.Languages.Format( @@ -133,4 +152,13 @@ public sealed class MultiplayerScene : Scene Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f)); } } + + // Экспоненциальный бэкофф: 1, 2, 4, 8, 8, … секунд между попытками. + private void ScheduleReconnect(double now) + { + var delay = MathF.Min(MaxBackoffSeconds, 1f * (1 << Math.Min(_attempt, 3))); + _attempt++; + _nextAttemptAt = now + delay; + _statusKey = "net.reconnecting"; + } } diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs index 9e7413a..844c508 100644 --- a/src/LittleSim/Scenes/WorldScene.cs +++ b/src/LittleSim/Scenes/WorldScene.cs @@ -51,6 +51,8 @@ public sealed class WorldScene : Scene private GameSpeed _speed = null!; private PauseMenu _pause = null!; + private Selection _selection = null!; + private InspectPanel _inspect = null!; private readonly List _speedRefreshers = []; private PlantSet _plants = null!; @@ -132,7 +134,15 @@ public sealed class WorldScene : Scene onMainMenu: () => Switch(new MainMenuScene()), onQuit: () => Context.Services.Get().Exit() ); - desktop.Root = Ui.Screen(hudLabel, speedBar, _pause.Root); + _selection = new Selection(); + _inspect = new InspectPanel(Store, _plants, content, climate, _selection); + desktop.Root = Ui.Screen( + hudLabel, + _inspect.Highlight, + _inspect.Panel, + speedBar, + _pause.Root + ); this.UseInspector(renderer); var console = this.UseDevConsole(); @@ -182,6 +192,17 @@ public sealed class WorldScene : Scene UpdateSystems.Add( new GodCameraSystem(camera, input, renderer, console, () => _pause.IsOpen) ); + UpdateSystems.Add( + new SelectionSystem( + Store, + input, + renderer, + _selection, + () => _pause.IsOpen, + p => _inspect.IsOverPanel(p) || speedBar.Bounds.Contains(p) + ) + ); + UpdateSystems.Add(new CallbackSystem(() => _inspect.Refresh(renderer))); UpdateSystems.Add( new HudSystem( Context, @@ -310,7 +331,15 @@ public sealed class WorldScene : Scene { if (input.IsKeyPressed(Keys.Escape)) { - _pause.Toggle(); + // Esc сначала снимает выделение (если есть), и только потом открывает меню-паузу. + if (_selection.HasSelection) + { + _selection.Clear(); + } + else + { + _pause.Toggle(); + } } if (_pause.IsOpen) diff --git a/src/LittleSim/Sim/Selection.cs b/src/LittleSim/Sim/Selection.cs new file mode 100644 index 0000000..7b9153f --- /dev/null +++ b/src/LittleSim/Sim/Selection.cs @@ -0,0 +1,113 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using MrGameEng.Graphics; +using MrGameEng.Input; + +namespace LittleSim.Sim; + +/// +/// Текущее выделение игрока (как в RimWorld): id выбранной сущности или -1. Общий источник истины +/// для системы пикинга () и панели осмотра (InspectPanel): +/// первая пишет, вторая читает и рисует рамку/инфо. Хранит id, а не , чтобы +/// безопасно переживать удаление сущности (растение умерло) — валидность проверяется через +/// EntityStore.TryGetEntityById. +/// +public sealed class Selection +{ + /// Runtime-id выбранной сущности, или -1, если ничего не выбрано. + public int EntityId { get; private set; } = -1; + + /// Есть ли активное выделение. + public bool HasSelection => EntityId >= 0; + + /// Выбрать сущность по id. + public void Select(int entityId) => EntityId = entityId; + + /// Снять выделение. + public void Clear() => EntityId = -1; +} + +/// +/// Пикинг мышью: ЛКМ выбирает верхнюю сущность под курсором (ближайшую по ограничивающей окружности +/// спрайта), клик по пустому месте или ПКМ снимает выделение. Молчит, пока ввод захвачен оверлеем +/// (консоль/инспектор) или активен (меню-пауза), и игнорирует клики над +/// UI-панелями (). Выбор работает и на паузе — мир можно осматривать стоя. +/// +public sealed class SelectionSystem( + EntityStore store, + InputManager input, + Renderer2D renderer, + Selection selection, + Func blocked, + Func overUi +) : BaseSystem +{ + protected override void OnUpdateGroup() + { + if (blocked()) + { + return; + } + + if (input.IsMousePressed(MouseButton.Right)) + { + selection.Clear(); + return; + } + + if (!input.IsMousePressed(MouseButton.Left)) + { + return; + } + + var mouse = input.MousePosition; + if (overUi(mouse)) + { + return; // клик пришёлся на панель — это не пикинг мира + } + + var world = renderer.ScreenToWorld(new Vector2(mouse.X, mouse.Y)); + if (TryPick(world, out var id)) + { + selection.Select(id); + } + else + { + selection.Clear(); // клик по пустой земле снимает выделение + } + } + + // Ближайшая сущность, чья ограничивающая окружность спрайта накрывает точку мира. + private bool TryPick(Vector2 world, out int id) + { + id = -1; + var bestDistance = float.MaxValue; + foreach (var (transforms, sprites, entities) in store.Query().Chunks) + { + var t = transforms.Span; + var s = sprites.Span; + for (var i = 0; i < t.Length; i++) + { + if (s[i].Region is not { } region) + { + continue; + } + + var (center, radius) = CullingMath.SpriteBoundingCircle( + in t[i], + region, + s[i].Origin + ); + var distance = Vector2.DistanceSquared(center, world); + if (distance <= radius * radius && distance < bestDistance) + { + bestDistance = distance; + id = entities.EntityAt(i).Id; + } + } + } + + return id >= 0; + } +} diff --git a/src/LittleSim/Sim/SimSystems.cs b/src/LittleSim/Sim/SimSystems.cs index 061c7b6..30f5f89 100644 --- a/src/LittleSim/Sim/SimSystems.cs +++ b/src/LittleSim/Sim/SimSystems.cs @@ -155,11 +155,15 @@ public sealed class GodCameraSystem( if (newZoom != oldZoom) { // Точка мира под курсором до зума; после — сдвигаем камеру, чтобы она осталась там же. + // Якорь и базовая позиция берутся из одного кадра камеры: WorldCenter — это позиция + // ПОСЛЕ клампа к границам (то, что реально нарисовано), а не сырой camera.Position — + // иначе у краёв карты/при широком обзоре зум «уезжает» от курсора. var anchor = renderer.ScreenToWorld( new Vector2(input.MousePosition.X, input.MousePosition.Y) ); + var effective = renderer.Camera.WorldCenter; camera.Zoom = newZoom; - camera.Position = anchor - (anchor - camera.Position) * (oldZoom / newZoom); + camera.Position = anchor - (anchor - effective) * (oldZoom / newZoom); } } } diff --git a/src/LittleSim/UI/InspectPanel.cs b/src/LittleSim/UI/InspectPanel.cs new file mode 100644 index 0000000..03228ef --- /dev/null +++ b/src/LittleSim/UI/InspectPanel.cs @@ -0,0 +1,284 @@ +using System.Text; +using Friflo.Engine.ECS; +using LittleSim.Content; +using LittleSim.Sim; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Graphics; +using MrGameEng.Mods; +using Myra.Graphics2D; +using Myra.Graphics2D.Brushes; +using Myra.Graphics2D.UI; + +namespace LittleSim.UI; + +/// +/// Панель осмотра выбранной сущности (как «инфо-карточка» RimWorld): рамка-подсветка вокруг объекта +/// в мире и боковая панель с именем, процессом роста, состоянием, температурным диапазоном, полным +/// набором генов/признаков и продуктами растения. Источник выделения — ; +/// панель только читает компоненты и обновляется каждый кадр (), оставаясь +/// живой даже на паузе. Строки идут через (ключи inspect.*). +/// +internal sealed class InspectPanel +{ + private readonly EntityStore _store; + private readonly PlantSet _plants; + private readonly GameContent _content; + private readonly Climate _climate; + private readonly Selection _selection; + + private readonly Panel _highlight; + private readonly VerticalStackPanel _panel; + private readonly Label _title; + private readonly Label _body; + + public InspectPanel( + EntityStore store, + PlantSet plants, + GameContent content, + Climate climate, + Selection selection + ) + { + _store = store; + _plants = plants; + _content = content; + _climate = climate; + _selection = selection; + + _title = new Label { TextColor = Ui.Accent, Wrap = true }; + _body = new Label { TextColor = new Color(210, 216, 226), Wrap = true }; + + _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), + BorderThickness = new Thickness(2), + Visible = false, + }; + } + + /// Рамка-подсветка в мире — добавляется в корень экрана позади панели. + public Panel Highlight => _highlight; + + /// Боковая инфо-панель — добавляется в корень экрана. + public VerticalStackPanel Panel => _panel; + + /// Над панелью ли точка экрана (тогда клик не считается пикингом мира). + public bool IsOverPanel(Point screen) => _panel.Visible && _panel.Bounds.Contains(screen); + + /// + /// Подтягивает выделение каждый кадр: проверяет, жива ли сущность (иначе снимает выбор), двигает + /// рамку по экрану и пересобирает текст. Не-растения панель не показывает (пока осматриваем только их). + /// + public void Refresh(Renderer2D renderer) + { + if ( + !_selection.HasSelection + || !_store.TryGetEntityById(_selection.EntityId, out var entity) + || entity.IsNull + ) + { + if (_selection.HasSelection) + { + _selection.Clear(); // сущность исчезла (растение погибло) + } + + Hide(); + return; + } + + if ( + !entity.HasComponent() + || !entity.HasComponent() + || !entity.HasComponent() + || !entity.HasComponent() + ) + { + Hide(); + return; + } + + UpdateHighlight(renderer, entity); + UpdateText(entity); + _panel.Visible = true; + } + + private void Hide() + { + _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); + } + + private void UpdateText(Entity entity) + { + var languages = _content.Languages; + ref readonly var grow = ref entity.GetComponent(); + ref readonly var org = ref entity.GetComponent(); + var species = _plants[grow.Species]; + var def = species.Def; + var traits = org.Traits; + + _title.Text = languages.Get(def.Label) + (traits.IsVariant ? " *" : ""); + + 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), + grow.Stage + 1, + species.Stages.Length + ) + ); + if (mature) + { + text.AppendLine(languages.Get("inspect.mature")); + } + else + { + var from = species.Stages[grow.Stage].EnterDay; + var to = species.Stages[grow.Stage + 1].EnterDay; + var pct = to > from ? (grow.AgeDays - from) / (to - from) * 100f : 0f; + text.AppendLine(languages.Format("inspect.growing", Math.Clamp(pct, 0f, 100f))); + } + + 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"); + } + + text.AppendLine(languages.Format("inspect.state", state)); + if (entity.TryGetComponent(out var stress) && stress.Days > 0.05f) + { + text.AppendLine(languages.Format("inspect.stress", stress.Days)); + } + + text.AppendLine(languages.Format("inspect.temp", tMin, tMax, tLow, tHigh)); + + // Гены / фенотип. + text.AppendLine(); + text.AppendLine(languages.Get("inspect.genes")); + text.AppendLine(languages.Format("inspect.gene.vigor", traits.Vigor, traits.Lifespan)); + text.AppendLine( + languages.Format("inspect.gene.env", traits.OptimalLight, traits.OptimalFertility) + ); + text.AppendLine( + languages.Format("inspect.gene.repro", traits.DispersalRange, traits.ReproduceInterval) + ); + text.AppendLine( + languages.Format("inspect.gene.repro2", traits.SelfPollination, traits.MutationRate) + ); + text.AppendLine( + languages.Format( + "inspect.gene.hardy", + traits.ColdHardiness, + traits.HeatHardiness, + traits.LeafHue + ) + ); + if (traits.IsVariant) + { + text.AppendLine(languages.Get("inspect.gene.variant")); + } + + // Продукты: сбор и плоды. + text.AppendLine(); + if (def.HarvestProduct is { } harvest) + { + text.AppendLine( + languages.Format("inspect.harvest", ProductLabel(harvest), traits.HarvestAmount) + ); + } + + if (def.FruitProduct is { } fruit && traits.FruitYield >= 1f) + { + var ripe = entity.TryGetComponent(out var fruiting) ? fruiting.RipeFruit : 0f; + var season = languages.Get( + "season." + ((Season)traits.FruitSeason).ToString().ToLowerInvariant() + ); + text.AppendLine( + languages.Format( + "inspect.fruit", + ProductLabel(fruit), + traits.FruitYield, + season, + ripe + ) + ); + } + else + { + text.AppendLine(languages.Get("inspect.barren")); + } + + text.Append(languages.Get("inspect.hint")); + _body.Text = text.ToString(); + } + + private string StageLabel(PlantDef def, int stage) => + stage < def.Stages.Count && def.Stages[stage].Label is { } key + ? _content.Languages.Get(key) + : "—"; + + private string ProductLabel(string productDefName) => + _content.Defs.TryGet(productDefName, out var product) + ? _content.Languages.Get(product.Label) + : productDefName; +}