Update engine subproject commit to f382fc98eadc4a489689e51e88748b616661a560-dirty; adjust plant genetics for optimal temperature and hardiness traits, enhance terrain scatter probabilities, and improve UI localization for connection status. Refactor multiplayer scene to implement exponential backoff for reconnections and streamline game initialization in web client.

This commit is contained in:
Leonid Pershin
2026-06-13 05:21:17 +03:00
parent 6b29a40b7f
commit f46ae15dc6
16 changed files with 823 additions and 76 deletions
+14 -5
View File
@@ -16,24 +16,33 @@ public class LittleSimWebGame : Game
/// <summary>Контекст ядра движка, общий со сценами и системами.</summary>
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!;
/// <summary>Игра, подключающаяся к серверу <paramref name="server"/>.</summary>
public LittleSimWebGame(Uri server)
/// <summary>Игра-наблюдатель: мира не создаёт, ждёт <see cref="Connect"/> из UI.</summary>
public LittleSimWebGame()
{
_server = server;
_graphics = new GraphicsDeviceManager(this);
Content.RootDirectory = "Content";
}
/// <summary>Состояние соединения со сценой-наблюдателем — читает оверлей подключения.</summary>
public ConnectionStatus Status => _scene?.Status ?? ConnectionStatus.Idle;
/// <summary>Число реплицированных жителей — для статуса в оверлее.</summary>
public int EntityCount => _scene?.EntityCount ?? 0;
/// <summary>Подключиться к серверу <paramref name="server"/> (вызывается из Blazor-оверлея).</summary>
public void Connect(Uri server) => _scene?.Connect(server);
/// <inheritdoc />
protected override void Initialize()
{
base.Initialize();
Context.Scenes.Switch(new WorldViewScene(_server, () => _spriteBatch, () => _pixel));
_scene = new WorldViewScene(() => _spriteBatch, () => _pixel);
Context.Scenes.Switch(_scene);
}
/// <inheritdoc />
+29 -20
View File
@@ -1,20 +1,29 @@
@page "/"
@page "/index.html"
@inject IJSRuntime JsRuntime
@using nkast.Wasm.Canvas
<PageTitle>LittleSim</PageTitle>
<div id="canvasHolder" style="
background: #000;
margin:0%;
position: fixed;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
width:100vw;
height:100vh;
">
<canvas id="theCanvas" style="touch-action:none;"></canvas>
</div>
@page "/"
@page "/index.html"
@inject IJSRuntime JsRuntime
@using nkast.Wasm.Canvas
<PageTitle>LittleSim</PageTitle>
<div id="canvasHolder" style="
background: #000;
margin:0%;
position: fixed;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
width:100vw;
height:100vh;
">
<canvas id="theCanvas" style="touch-action:none;"></canvas>
</div>
<div class="connect-bar">
<span class="title">LittleSim</span>
<input class="server" placeholder="ws://host:9050"
@bind="_serverAddress" @bind:event="oninput"
@onkeydown="OnAddressKey" />
<button class="connect" @onclick="Connect">Подключиться</button>
<span class="status @StatusClass">@StatusText</span>
</div>
+80 -8
View File
@@ -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<object>("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();
}
}
}
+72
View File
@@ -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;
}
+93 -17
View File
@@ -9,31 +9,74 @@ using MrGameEng.Net;
namespace LittleSim.Web;
/// <summary>Состояние соединения веб-клиента — его читает оверлей подключения (Blazor).</summary>
public enum ConnectionStatus
{
/// <summary>Сервер ещё не выбран — ждём, пока пользователь нажмёт «Подключиться».</summary>
Idle,
/// <summary>Идёт первая попытка подключения.</summary>
Connecting,
/// <summary>Соединение установлено, снапшоты приходят.</summary>
Connected,
/// <summary>Соединение оборвалось/не удалось — ждём следующей попытки (бэкофф).</summary>
Reconnecting,
}
/// <summary>
/// Браузерный клиент мира LittleSim: подключается к дедикейтед-серверу
/// (LittleSim.Server --listen), применяет дельта-снапшоты в свой EntityStore и рисует
/// жителей через KNI SpriteBatch (WebGL). Симуляция целиком на сервере — сюда приезжают
/// только компоненты схемы (позиция + потребности); позиции сглаживаются до частоты
/// кадра, усталость затемняет квадратик жителя.
/// Браузерный клиент мира LittleSim: по команде <see cref="Connect"/> подключается к
/// дедикейтед-серверу (LittleSim.Server --listen), применяет дельта-снапшоты в свой
/// EntityStore и рисует жителей через KNI SpriteBatch (WebGL). Мира не создаёт — только
/// наблюдает: симуляция целиком на сервере, сюда приезжают лишь компоненты схемы (позиция +
/// потребности). Позиции сглаживаются до частоты кадра, усталость затемняет квадратик. При
/// обрыве переподключается сам с экспоненциальным бэкоффом, очищая устаревшие сущности.
/// </summary>
public sealed class WorldViewScene : Scene
{
private readonly Uri _server;
// Бэкофф переподключения: задержка удваивается с каждой неудачей до потолка.
private const float MaxBackoffSeconds = 8f;
private readonly Func<SpriteBatch> _spriteBatch;
private readonly Func<Texture2D> _pixel;
private ReplicationClient _replication = null!;
private Task<WebSocketClient>? _connecting;
private WebSocketClient? _connection;
private Uri? _server; // целевой сервер; null — пользователь ещё не подключался
private double _nextAttemptAt;
private int _attempt;
/// <summary>Сцена, подключающаяся к <paramref name="server"/>.</summary>
public WorldViewScene(Uri server, Func<SpriteBatch> spriteBatch, Func<Texture2D> pixel)
/// <summary>Текущее состояние соединения — для оверлея подключения.</summary>
public ConnectionStatus Status { get; private set; } = ConnectionStatus.Idle;
/// <summary>Сколько реплицированных жителей сейчас в мире клиента.</summary>
public int EntityCount => _replication?.EntityCount ?? 0;
/// <summary>Сцена-наблюдатель; сервер задаётся позже через <see cref="Connect"/>.</summary>
public WorldViewScene(Func<SpriteBatch> spriteBatch, Func<Texture2D> pixel)
{
_server = server;
_spriteBatch = spriteBatch;
_pixel = pixel;
}
/// <summary>
/// Подключиться к <paramref name="server"/> (ws:// или wss://). Сбрасывает прежнее
/// соединение и счётчик попыток — подключение начнётся в ближайшем тике. Дальше клиент сам
/// переподключается к этому адресу при обрывах.
/// </summary>
public void Connect(Uri server)
{
_connection?.Close();
_connection = null;
_replication?.Clear();
_server = server;
_attempt = 0;
_nextAttemptAt = 0f;
Status = ConnectionStatus.Connecting;
}
/// <summary>Сглаживание сетевой позиции: цель из снапшота, визуал лерпится покадрово.</summary>
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;
}
/// <summary>Вызывает делегат каждый тик — мелкая логика сцены без отдельного класса.</summary>
+41 -13
View File
@@ -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<WebSocketClient>? _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 — первая попытка; растёт при обрывах, задаёт бэкофф
/// <summary>Сцена, подключающаяся к серверу <paramref name="server"/> (ws:// или wss://).</summary>
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";
}
}
+31 -2
View File
@@ -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<Action> _speedRefreshers = [];
private PlantSet _plants = null!;
@@ -132,7 +134,15 @@ public sealed class WorldScene : Scene
onMainMenu: () => Switch(new MainMenuScene()),
onQuit: () => Context.Services.Get<Game>().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)
+113
View File
@@ -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;
/// <summary>
/// Текущее выделение игрока (как в RimWorld): id выбранной сущности или -1. Общий источник истины
/// для системы пикинга (<see cref="SelectionSystem"/>) и панели осмотра (<c>InspectPanel</c>):
/// первая пишет, вторая читает и рисует рамку/инфо. Хранит id, а не <see cref="Entity"/>, чтобы
/// безопасно переживать удаление сущности (растение умерло) — валидность проверяется через
/// <c>EntityStore.TryGetEntityById</c>.
/// </summary>
public sealed class Selection
{
/// <summary>Runtime-id выбранной сущности, или -1, если ничего не выбрано.</summary>
public int EntityId { get; private set; } = -1;
/// <summary>Есть ли активное выделение.</summary>
public bool HasSelection => EntityId >= 0;
/// <summary>Выбрать сущность по id.</summary>
public void Select(int entityId) => EntityId = entityId;
/// <summary>Снять выделение.</summary>
public void Clear() => EntityId = -1;
}
/// <summary>
/// Пикинг мышью: ЛКМ выбирает верхнюю сущность под курсором (ближайшую по ограничивающей окружности
/// спрайта), клик по пустому месте или ПКМ снимает выделение. Молчит, пока ввод захвачен оверлеем
/// (консоль/инспектор) или активен <paramref name="blocked"/> (меню-пауза), и игнорирует клики над
/// UI-панелями (<paramref name="overUi"/>). Выбор работает и на паузе — мир можно осматривать стоя.
/// </summary>
public sealed class SelectionSystem(
EntityStore store,
InputManager input,
Renderer2D renderer,
Selection selection,
Func<bool> blocked,
Func<Point, bool> 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<Transform2D, Sprite>().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;
}
}
+5 -1
View File
@@ -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);
}
}
}
+284
View File
@@ -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;
/// <summary>
/// Панель осмотра выбранной сущности (как «инфо-карточка» RimWorld): рамка-подсветка вокруг объекта
/// в мире и боковая панель с именем, процессом роста, состоянием, температурным диапазоном, полным
/// набором генов/признаков и продуктами растения. Источник выделения — <see cref="Selection"/>;
/// панель только читает компоненты и обновляется каждый кадр (<see cref="Refresh"/>), оставаясь
/// живой даже на паузе. Строки идут через <see cref="LanguageManager"/> (ключи <c>inspect.*</c>).
/// </summary>
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,
};
}
/// <summary>Рамка-подсветка в мире — добавляется в корень экрана позади панели.</summary>
public Panel Highlight => _highlight;
/// <summary>Боковая инфо-панель — добавляется в корень экрана.</summary>
public VerticalStackPanel Panel => _panel;
/// <summary>Над панелью ли точка экрана (тогда клик не считается пикингом мира).</summary>
public bool IsOverPanel(Point screen) => _panel.Visible && _panel.Bounds.Contains(screen);
/// <summary>
/// Подтягивает выделение каждый кадр: проверяет, жива ли сущность (иначе снимает выбор), двигает
/// рамку по экрану и пересобирает текст. Не-растения панель не показывает (пока осматриваем только их).
/// </summary>
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<PlantGrowth>()
|| !entity.HasComponent<PlantOrganism>()
|| !entity.HasComponent<Transform2D>()
|| !entity.HasComponent<Sprite>()
)
{
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<Transform2D>();
var sprite = entity.GetComponent<Sprite>();
if (sprite.Region is not { } region)
{
_highlight.Visible = false;
return;
}
var (center, radius) = CullingMath.SpriteBoundingCircle(
in transform,
region,
sprite.Origin
);
var screenCenter = renderer.WorldToScreen(center);
var edge = renderer.WorldToScreen(center + new Vector2(radius, 0f));
var screenRadius = MathF.Max(4f, MathF.Abs(edge.X - screenCenter.X));
_highlight.Visible = true;
_highlight.Left = (int)(screenCenter.X - screenRadius);
_highlight.Top = (int)(screenCenter.Y - screenRadius);
_highlight.Width = (int)(screenRadius * 2f);
_highlight.Height = (int)(screenRadius * 2f);
}
private void UpdateText(Entity entity)
{
var languages = _content.Languages;
ref readonly var grow = ref entity.GetComponent<PlantGrowth>();
ref readonly var org = ref entity.GetComponent<PlantOrganism>();
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<TemperatureStress>(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<Fruiting>(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<ProductDef>(productDefName, out var product)
? _content.Languages.Get(product.Label)
: productDefName;
}