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>