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:
@@ -21,8 +21,8 @@
|
|||||||
|
|
||||||
{ "defName": "GrassA", "label": "plant.grass", "texture": "things/plant/grass/grassa", "sizeCells": 1.2,
|
{ "defName": "GrassA", "label": "plant.grass", "texture": "things/plant/grass/grassa", "sizeCells": 1.2,
|
||||||
"harvestProduct": "ProductGrass",
|
"harvestProduct": "ProductGrass",
|
||||||
"genome": { "optimalLight": 0.85, "lightTolerance": 0.35, "optimalTemperature": 22, "temperatureTolerance": 8,
|
"genome": { "optimalLight": 0.85, "lightTolerance": 0.35, "optimalTemperature": 19, "temperatureTolerance": 8,
|
||||||
"coldHardiness": 8, "heatHardiness": 6,
|
"coldHardiness": 9, "heatHardiness": 6,
|
||||||
"optimalFertility": 1.0, "fertilityTolerance": 1.0, "vigor": 1.3,
|
"optimalFertility": 1.0, "fertilityTolerance": 1.0, "vigor": 1.3,
|
||||||
"lifespan": 22, "dispersalRange": 3, "reproduceInterval": 3, "selfPollination": 0.8,
|
"lifespan": 22, "dispersalRange": 3, "reproduceInterval": 3, "selfPollination": 0.8,
|
||||||
"mutationRate": 0.06, "variantChance": 0.2, "spread": 0.1,
|
"mutationRate": 0.06, "variantChance": 0.2, "spread": 0.1,
|
||||||
|
|||||||
@@ -8,13 +8,14 @@
|
|||||||
{ "defName": "Grass", "label": "terrain.grass", "maxHeight": 0.68, "color": [90, 160, 70],
|
{ "defName": "Grass", "label": "terrain.grass", "maxHeight": 0.68, "color": [90, 160, 70],
|
||||||
"isLand": true, "surface": "terrain/surfaces/mossy", "fertility": 1.0,
|
"isLand": true, "surface": "terrain/surfaces/mossy", "fertility": 1.0,
|
||||||
"scatter": [
|
"scatter": [
|
||||||
{ "chance": 0.35, "options": ["GrassA"] },
|
{ "chance": 0.6, "options": ["GrassA"] },
|
||||||
{ "chance": 0.07, "options": ["BushA", "BushB", "BushC"] }
|
{ "chance": 0.07, "options": ["BushA", "BushB", "BushC"] }
|
||||||
] },
|
] },
|
||||||
{ "defName": "Forest", "label": "terrain.forest", "maxHeight": 0.85, "color": [44, 110, 50],
|
{ "defName": "Forest", "label": "terrain.forest", "maxHeight": 0.85, "color": [44, 110, 50],
|
||||||
"isLand": true, "surface": "terrain/surfaces/soil", "fertility": 1.6,
|
"isLand": true, "surface": "terrain/surfaces/soil", "fertility": 1.6,
|
||||||
"scatter": [
|
"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],
|
{ "defName": "Mountain", "label": "terrain.mountain", "maxHeight": 1.01, "color": [136, 132, 128],
|
||||||
"surface": "terrain/surfaces/roughhewnrock", "fertility": 0.2, "blocksLight": true }
|
"surface": "terrain/surfaces/roughhewnrock", "fertility": 0.2, "blocksLight": true }
|
||||||
|
|||||||
@@ -6,7 +6,29 @@
|
|||||||
"season.summer": "summer",
|
"season.summer": "summer",
|
||||||
"season.autumn": "autumn",
|
"season.autumn": "autumn",
|
||||||
"season.winter": "winter",
|
"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",
|
"hud.paused": "PAUSED",
|
||||||
"menu.title": "LittleSim",
|
"menu.title": "LittleSim",
|
||||||
"menu.subtitle": "a god-game: minimal graphics, deep simulation",
|
"menu.subtitle": "a god-game: minimal graphics, deep simulation",
|
||||||
@@ -84,5 +106,6 @@
|
|||||||
"net.connecting": "connecting…",
|
"net.connecting": "connecting…",
|
||||||
"net.connected": "connected",
|
"net.connected": "connected",
|
||||||
"net.failed": "connection failed",
|
"net.failed": "connection failed",
|
||||||
"net.lost": "connection lost"
|
"net.lost": "connection lost",
|
||||||
|
"net.reconnecting": "reconnecting…"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,29 @@
|
|||||||
"season.summer": "лето",
|
"season.summer": "лето",
|
||||||
"season.autumn": "осень",
|
"season.autumn": "осень",
|
||||||
"season.winter": "зима",
|
"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": "ПАУЗА",
|
"hud.paused": "ПАУЗА",
|
||||||
"menu.title": "LittleSim",
|
"menu.title": "LittleSim",
|
||||||
"menu.subtitle": "бог-игра: минимум графики, максимум симуляции",
|
"menu.subtitle": "бог-игра: минимум графики, максимум симуляции",
|
||||||
@@ -84,5 +106,6 @@
|
|||||||
"net.connecting": "подключение…",
|
"net.connecting": "подключение…",
|
||||||
"net.connected": "подключено",
|
"net.connected": "подключено",
|
||||||
"net.failed": "не удалось подключиться",
|
"net.failed": "не удалось подключиться",
|
||||||
"net.lost": "соединение потеряно"
|
"net.lost": "соединение потеряно",
|
||||||
|
"net.reconnecting": "переподключение…"
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -9,7 +9,9 @@ $Project = Join-Path $Root "src\LittleSim\LittleSim.csproj"
|
|||||||
|
|
||||||
Push-Location $Root
|
Push-Location $Root
|
||||||
try {
|
try {
|
||||||
dotnet build LittleSim.sln -c $Configuration
|
# Только игровой проект (тянет за собой нужные движковые библиотеки) — не вся солюшн с
|
||||||
|
# тестами, сервером и WASM-клиентом (его emscripten-сборка занимает минуты).
|
||||||
|
dotnet build $Project -c $Configuration
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
exit $LASTEXITCODE
|
exit $LASTEXITCODE
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -18,5 +18,7 @@ done
|
|||||||
|
|
||||||
cd "$ROOT"
|
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 "$@"
|
dotnet run --project src/LittleSim/LittleSim.csproj -c "$CONFIGURATION" --no-build "$@"
|
||||||
|
|||||||
@@ -16,24 +16,33 @@ public class LittleSimWebGame : Game
|
|||||||
/// <summary>Контекст ядра движка, общий со сценами и системами.</summary>
|
/// <summary>Контекст ядра движка, общий со сценами и системами.</summary>
|
||||||
public EngineContext Context { get; } = new EngineContext();
|
public EngineContext Context { get; } = new EngineContext();
|
||||||
|
|
||||||
private readonly Uri _server;
|
|
||||||
private GraphicsDeviceManager _graphics;
|
private GraphicsDeviceManager _graphics;
|
||||||
private SpriteBatch _spriteBatch = null!;
|
private SpriteBatch _spriteBatch = null!;
|
||||||
private Texture2D _pixel = null!;
|
private Texture2D _pixel = null!;
|
||||||
|
private WorldViewScene _scene = null!;
|
||||||
|
|
||||||
/// <summary>Игра, подключающаяся к серверу <paramref name="server"/>.</summary>
|
/// <summary>Игра-наблюдатель: мира не создаёт, ждёт <see cref="Connect"/> из UI.</summary>
|
||||||
public LittleSimWebGame(Uri server)
|
public LittleSimWebGame()
|
||||||
{
|
{
|
||||||
_server = server;
|
|
||||||
_graphics = new GraphicsDeviceManager(this);
|
_graphics = new GraphicsDeviceManager(this);
|
||||||
Content.RootDirectory = "Content";
|
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 />
|
/// <inheritdoc />
|
||||||
protected override void Initialize()
|
protected override void Initialize()
|
||||||
{
|
{
|
||||||
base.Initialize();
|
base.Initialize();
|
||||||
Context.Scenes.Switch(new WorldViewScene(_server, () => _spriteBatch, () => _pixel));
|
_scene = new WorldViewScene(() => _spriteBatch, () => _pixel);
|
||||||
|
Context.Scenes.Switch(_scene);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
@page "/index.html"
|
@page "/index.html"
|
||||||
@inject IJSRuntime JsRuntime
|
@inject IJSRuntime JsRuntime
|
||||||
@using nkast.Wasm.Canvas
|
@using nkast.Wasm.Canvas
|
||||||
|
|
||||||
<PageTitle>LittleSim</PageTitle>
|
<PageTitle>LittleSim</PageTitle>
|
||||||
|
|
||||||
<div id="canvasHolder" style="
|
<div id="canvasHolder" style="
|
||||||
background: #000;
|
background: #000;
|
||||||
margin:0%;
|
margin:0%;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0px;
|
top: 0px;
|
||||||
right: 0px;
|
right: 0px;
|
||||||
bottom: 0px;
|
bottom: 0px;
|
||||||
left: 0px;
|
left: 0px;
|
||||||
width:100vw;
|
width:100vw;
|
||||||
height:100vh;
|
height:100vh;
|
||||||
">
|
">
|
||||||
<canvas id="theCanvas" style="touch-action:none;"></canvas>
|
<canvas id="theCanvas" style="touch-action:none;"></canvas>
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.AspNetCore.Components.Web;
|
||||||
using Microsoft.JSInterop;
|
using Microsoft.JSInterop;
|
||||||
using Microsoft.Xna.Framework;
|
|
||||||
|
|
||||||
namespace LittleSim.Web.Pages
|
namespace LittleSim.Web.Pages
|
||||||
{
|
{
|
||||||
@@ -10,7 +10,12 @@ namespace LittleSim.Web.Pages
|
|||||||
[Inject]
|
[Inject]
|
||||||
private NavigationManager Navigation { get; set; } = null!;
|
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)
|
protected override void OnAfterRender(bool firstRender)
|
||||||
{
|
{
|
||||||
@@ -18,25 +23,92 @@ namespace LittleSim.Web.Pages
|
|||||||
|
|
||||||
if (firstRender)
|
if (firstRender)
|
||||||
{
|
{
|
||||||
|
_serverAddress = DefaultServerAddress();
|
||||||
|
StateHasChanged();
|
||||||
JsRuntime.InvokeAsync<object>("initRenderJS", DotNetObjectReference.Create(this));
|
JsRuntime.InvokeAsync<object>("initRenderJS", DotNetObjectReference.Create(this));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Гонится из requestAnimationFrame (index.html). Игру создаём лениво в первом тике —
|
||||||
|
// мира она не создаёт, лишь ждёт адрес сервера из формы.
|
||||||
[JSInvokable]
|
[JSInvokable]
|
||||||
public void TickDotNet()
|
public void TickDotNet()
|
||||||
{
|
{
|
||||||
if (_game == null)
|
if (_game == null)
|
||||||
{
|
{
|
||||||
_game = new LittleSimWebGame(ResolveServerUri());
|
_game = new LittleSimWebGame();
|
||||||
_game.Run();
|
_game.Run();
|
||||||
}
|
}
|
||||||
|
|
||||||
_game.Tick();
|
_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 страницы; по умолчанию —
|
private void OnAddressKey(KeyboardEventArgs e)
|
||||||
// хост самой страницы на порту LittleSim.Server.
|
{
|
||||||
private Uri ResolveServerUri()
|
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 page = new Uri(Navigation.Uri);
|
||||||
var query = page.Query.TrimStart('?');
|
var query = page.Query.TrimStart('?');
|
||||||
@@ -45,7 +117,7 @@ namespace LittleSim.Web.Pages
|
|||||||
var separator = pair.IndexOf('=');
|
var separator = pair.IndexOf('=');
|
||||||
if (separator > 0 && pair[..separator] == "server")
|
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",
|
Scheme = "ws",
|
||||||
Host = page.Host,
|
Host = page.Host,
|
||||||
Port = WebNetSchema.DefaultPort,
|
Port = WebNetSchema.DefaultPort,
|
||||||
}.Uri;
|
}.Uri.ToString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -9,31 +9,74 @@ using MrGameEng.Net;
|
|||||||
|
|
||||||
namespace LittleSim.Web;
|
namespace LittleSim.Web;
|
||||||
|
|
||||||
|
/// <summary>Состояние соединения веб-клиента — его читает оверлей подключения (Blazor).</summary>
|
||||||
|
public enum ConnectionStatus
|
||||||
|
{
|
||||||
|
/// <summary>Сервер ещё не выбран — ждём, пока пользователь нажмёт «Подключиться».</summary>
|
||||||
|
Idle,
|
||||||
|
|
||||||
|
/// <summary>Идёт первая попытка подключения.</summary>
|
||||||
|
Connecting,
|
||||||
|
|
||||||
|
/// <summary>Соединение установлено, снапшоты приходят.</summary>
|
||||||
|
Connected,
|
||||||
|
|
||||||
|
/// <summary>Соединение оборвалось/не удалось — ждём следующей попытки (бэкофф).</summary>
|
||||||
|
Reconnecting,
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Браузерный клиент мира LittleSim: подключается к дедикейтед-серверу
|
/// Браузерный клиент мира LittleSim: по команде <see cref="Connect"/> подключается к
|
||||||
/// (LittleSim.Server --listen), применяет дельта-снапшоты в свой EntityStore и рисует
|
/// дедикейтед-серверу (LittleSim.Server --listen), применяет дельта-снапшоты в свой
|
||||||
/// жителей через KNI SpriteBatch (WebGL). Симуляция целиком на сервере — сюда приезжают
|
/// EntityStore и рисует жителей через KNI SpriteBatch (WebGL). Мира не создаёт — только
|
||||||
/// только компоненты схемы (позиция + потребности); позиции сглаживаются до частоты
|
/// наблюдает: симуляция целиком на сервере, сюда приезжают лишь компоненты схемы (позиция +
|
||||||
/// кадра, усталость затемняет квадратик жителя.
|
/// потребности). Позиции сглаживаются до частоты кадра, усталость затемняет квадратик. При
|
||||||
|
/// обрыве переподключается сам с экспоненциальным бэкоффом, очищая устаревшие сущности.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class WorldViewScene : Scene
|
public sealed class WorldViewScene : Scene
|
||||||
{
|
{
|
||||||
private readonly Uri _server;
|
// Бэкофф переподключения: задержка удваивается с каждой неудачей до потолка.
|
||||||
|
private const float MaxBackoffSeconds = 8f;
|
||||||
|
|
||||||
private readonly Func<SpriteBatch> _spriteBatch;
|
private readonly Func<SpriteBatch> _spriteBatch;
|
||||||
private readonly Func<Texture2D> _pixel;
|
private readonly Func<Texture2D> _pixel;
|
||||||
|
|
||||||
private ReplicationClient _replication = null!;
|
private ReplicationClient _replication = null!;
|
||||||
private Task<WebSocketClient>? _connecting;
|
private Task<WebSocketClient>? _connecting;
|
||||||
private WebSocketClient? _connection;
|
private WebSocketClient? _connection;
|
||||||
|
private Uri? _server; // целевой сервер; null — пользователь ещё не подключался
|
||||||
|
private double _nextAttemptAt;
|
||||||
|
private int _attempt;
|
||||||
|
|
||||||
/// <summary>Сцена, подключающаяся к <paramref name="server"/>.</summary>
|
/// <summary>Текущее состояние соединения — для оверлея подключения.</summary>
|
||||||
public WorldViewScene(Uri server, Func<SpriteBatch> spriteBatch, Func<Texture2D> pixel)
|
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;
|
_spriteBatch = spriteBatch;
|
||||||
_pixel = pixel;
|
_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>
|
/// <summary>Сглаживание сетевой позиции: цель из снапшота, визуал лерпится покадрово.</summary>
|
||||||
private struct NetLerp : IComponent
|
private struct NetLerp : IComponent
|
||||||
{
|
{
|
||||||
@@ -51,35 +94,68 @@ public sealed class WorldViewScene : Scene
|
|||||||
UpdateSystems.Add(new NetSmoothingSystem());
|
UpdateSystems.Add(new NetSmoothingSystem());
|
||||||
DrawSystems.Add(new PawnDrawSystem(_spriteBatch, _pixel));
|
DrawSystems.Add(new PawnDrawSystem(_spriteBatch, _pixel));
|
||||||
|
|
||||||
Log.Info($"LittleSim.Web: connecting to {_server}…");
|
Log.Info("LittleSim.Web ready — awaiting connect");
|
||||||
_connecting = WebSocketClient.ConnectAsync(_server);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnUnload() => _connection?.Close();
|
protected override void OnUnload() => _connection?.Close();
|
||||||
|
|
||||||
private void Pump()
|
private void Pump()
|
||||||
{
|
{
|
||||||
|
if (_server is null)
|
||||||
|
{
|
||||||
|
return; // адрес ещё не задан — ждём команды Connect из оверлея
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = Context.Clock.UnscaledTotalTime;
|
||||||
|
|
||||||
|
// Завершилась попытка подключения: успех — берём соединение; провал — планируем повтор.
|
||||||
if (_connecting is { IsCompleted: true } finished)
|
if (_connecting is { IsCompleted: true } finished)
|
||||||
{
|
{
|
||||||
_connecting = null;
|
_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(
|
Log.Error(
|
||||||
$"Connect to {_server} failed: "
|
$"Connect to {_server} failed: "
|
||||||
+ finished.Exception?.GetBaseException().Message
|
+ 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)
|
if (_connection is not null)
|
||||||
{
|
{
|
||||||
_replication.Pump(_connection);
|
_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>
|
/// <summary>Вызывает делегат каждый тик — мелкая логика сцены без отдельного класса.</summary>
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ public sealed class MultiplayerScene : Scene
|
|||||||
{
|
{
|
||||||
private static readonly RectF Bounds = new(0f, 0f, 1280f, 720f);
|
private static readonly RectF Bounds = new(0f, 0f, 1280f, 720f);
|
||||||
|
|
||||||
|
// Бэкофф переподключения: задержка удваивается с каждой неудачей до потолка.
|
||||||
|
private const float MaxBackoffSeconds = 8f;
|
||||||
|
|
||||||
private readonly Uri _server;
|
private readonly Uri _server;
|
||||||
private Task<WebSocketClient>? _connecting;
|
private Task<WebSocketClient>? _connecting;
|
||||||
private WebSocketClient? _connection;
|
private WebSocketClient? _connection;
|
||||||
@@ -38,6 +41,8 @@ public sealed class MultiplayerScene : Scene
|
|||||||
private GameContent _content = null!;
|
private GameContent _content = null!;
|
||||||
private Label _hud = null!;
|
private Label _hud = null!;
|
||||||
private string _statusKey = "net.connecting";
|
private string _statusKey = "net.connecting";
|
||||||
|
private double _nextAttemptAt; // время (unscaled) следующей попытки подключения
|
||||||
|
private int _attempt; // 0 — первая попытка; растёт при обрывах, задаёт бэкофф
|
||||||
|
|
||||||
/// <summary>Сцена, подключающаяся к серверу <paramref name="server"/> (ws:// или wss://).</summary>
|
/// <summary>Сцена, подключающаяся к серверу <paramref name="server"/> (ws:// или wss://).</summary>
|
||||||
public MultiplayerScene(Uri server) => _server = server;
|
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();
|
protected override void OnUnload() => _connection?.Close();
|
||||||
|
|
||||||
private void Pump()
|
private void Pump()
|
||||||
{
|
{
|
||||||
|
var now = Context.Clock.UnscaledTotalTime;
|
||||||
|
|
||||||
|
// Завершилась попытка подключения: успех — берём соединение; провал — планируем повтор.
|
||||||
if (_connecting is { IsCompleted: true } finished)
|
if (_connecting is { IsCompleted: true } finished)
|
||||||
{
|
{
|
||||||
_connecting = null;
|
_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(
|
Log.Error(
|
||||||
$"Connect to {_server} failed: "
|
$"Connect to {_server} failed: "
|
||||||
+ finished.Exception?.GetBaseException().Message
|
+ finished.Exception?.GetBaseException().Message
|
||||||
);
|
);
|
||||||
|
ScheduleReconnect(now);
|
||||||
}
|
}
|
||||||
else
|
}
|
||||||
{
|
|
||||||
_connection = finished.Result;
|
// Обрыв уже установленного соединения: чистим и уходим в переподключение.
|
||||||
_statusKey = "net.connected";
|
if (_connection is { IsOpen: false })
|
||||||
Log.Info($"Connected to {_server}");
|
{
|
||||||
}
|
_connection = null;
|
||||||
|
_replication.Clear();
|
||||||
|
ScheduleReconnect(now);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_connection is not null)
|
if (_connection is not null)
|
||||||
{
|
{
|
||||||
_replication.Pump(_connection);
|
_replication.Pump(_connection);
|
||||||
if (!_connection.IsOpen)
|
}
|
||||||
{
|
else if (_connecting is null && now >= _nextAttemptAt)
|
||||||
_statusKey = "net.lost";
|
{
|
||||||
}
|
_statusKey = _attempt == 0 ? "net.connecting" : "net.reconnecting";
|
||||||
|
_connecting = WebSocketClient.ConnectAsync(_server);
|
||||||
}
|
}
|
||||||
|
|
||||||
_hud.Text = _content.Languages.Format(
|
_hud.Text = _content.Languages.Format(
|
||||||
@@ -133,4 +152,13 @@ public sealed class MultiplayerScene : Scene
|
|||||||
Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
|
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";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ public sealed class WorldScene : Scene
|
|||||||
|
|
||||||
private GameSpeed _speed = null!;
|
private GameSpeed _speed = null!;
|
||||||
private PauseMenu _pause = null!;
|
private PauseMenu _pause = null!;
|
||||||
|
private Selection _selection = null!;
|
||||||
|
private InspectPanel _inspect = null!;
|
||||||
private readonly List<Action> _speedRefreshers = [];
|
private readonly List<Action> _speedRefreshers = [];
|
||||||
|
|
||||||
private PlantSet _plants = null!;
|
private PlantSet _plants = null!;
|
||||||
@@ -132,7 +134,15 @@ public sealed class WorldScene : Scene
|
|||||||
onMainMenu: () => Switch(new MainMenuScene()),
|
onMainMenu: () => Switch(new MainMenuScene()),
|
||||||
onQuit: () => Context.Services.Get<Game>().Exit()
|
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);
|
this.UseInspector(renderer);
|
||||||
var console = this.UseDevConsole();
|
var console = this.UseDevConsole();
|
||||||
@@ -182,6 +192,17 @@ public sealed class WorldScene : Scene
|
|||||||
UpdateSystems.Add(
|
UpdateSystems.Add(
|
||||||
new GodCameraSystem(camera, input, renderer, console, () => _pause.IsOpen)
|
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(
|
UpdateSystems.Add(
|
||||||
new HudSystem(
|
new HudSystem(
|
||||||
Context,
|
Context,
|
||||||
@@ -310,7 +331,15 @@ public sealed class WorldScene : Scene
|
|||||||
{
|
{
|
||||||
if (input.IsKeyPressed(Keys.Escape))
|
if (input.IsKeyPressed(Keys.Escape))
|
||||||
{
|
{
|
||||||
_pause.Toggle();
|
// Esc сначала снимает выделение (если есть), и только потом открывает меню-паузу.
|
||||||
|
if (_selection.HasSelection)
|
||||||
|
{
|
||||||
|
_selection.Clear();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_pause.Toggle();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_pause.IsOpen)
|
if (_pause.IsOpen)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -155,11 +155,15 @@ public sealed class GodCameraSystem(
|
|||||||
if (newZoom != oldZoom)
|
if (newZoom != oldZoom)
|
||||||
{
|
{
|
||||||
// Точка мира под курсором до зума; после — сдвигаем камеру, чтобы она осталась там же.
|
// Точка мира под курсором до зума; после — сдвигаем камеру, чтобы она осталась там же.
|
||||||
|
// Якорь и базовая позиция берутся из одного кадра камеры: WorldCenter — это позиция
|
||||||
|
// ПОСЛЕ клампа к границам (то, что реально нарисовано), а не сырой camera.Position —
|
||||||
|
// иначе у краёв карты/при широком обзоре зум «уезжает» от курсора.
|
||||||
var anchor = renderer.ScreenToWorld(
|
var anchor = renderer.ScreenToWorld(
|
||||||
new Vector2(input.MousePosition.X, input.MousePosition.Y)
|
new Vector2(input.MousePosition.X, input.MousePosition.Y)
|
||||||
);
|
);
|
||||||
|
var effective = renderer.Camera.WorldCenter;
|
||||||
camera.Zoom = newZoom;
|
camera.Zoom = newZoom;
|
||||||
camera.Position = anchor - (anchor - camera.Position) * (oldZoom / newZoom);
|
camera.Position = anchor - (anchor - effective) * (oldZoom / newZoom);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user