Compare commits
3
Commits
6f38d949ab
...
c60ae072e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c60ae072e9 | ||
|
|
f46ae15dc6 | ||
|
|
6b29a40b7f |
@@ -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": "переподключение…"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
Submodule engine updated: d360093be1...08381703f7
+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,4 +1,4 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
@page "/index.html"
|
@page "/index.html"
|
||||||
@inject IJSRuntime JsRuntime
|
@inject IJSRuntime JsRuntime
|
||||||
@using nkast.Wasm.Canvas
|
@using nkast.Wasm.Canvas
|
||||||
@@ -18,3 +18,12 @@
|
|||||||
">
|
">
|
||||||
<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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Адрес сервера: ?server=ws://host:port в URL страницы; по умолчанию —
|
// Перерисовываем оверлей только при смене статуса (или изредка — чтобы освежить счётчик),
|
||||||
// хост самой страницы на порту LittleSim.Server.
|
// а не каждый кадр rAF.
|
||||||
private Uri ResolveServerUri()
|
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++;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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
|
}
|
||||||
|
|
||||||
|
// Обрыв установленного соединения: чистим и уходим в переподключение.
|
||||||
|
if (_connection is { IsOpen: false })
|
||||||
{
|
{
|
||||||
_connection = finished.Result;
|
_connection = null;
|
||||||
Log.Info($"Connected to {_server}");
|
_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
|
}
|
||||||
|
|
||||||
|
// Обрыв уже установленного соединения: чистим и уходим в переподключение.
|
||||||
|
if (_connection is { IsOpen: false })
|
||||||
{
|
{
|
||||||
_connection = finished.Result;
|
_connection = null;
|
||||||
_statusKey = "net.connected";
|
_replication.Clear();
|
||||||
Log.Info($"Connected to {_server}");
|
ScheduleReconnect(now);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_connection is not null)
|
if (_connection is not null)
|
||||||
{
|
{
|
||||||
_replication.Pump(_connection);
|
_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(
|
_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,
|
||||||
@@ -309,9 +330,17 @@ public sealed class WorldScene : Scene
|
|||||||
private void Hotkeys(InputManager input)
|
private void Hotkeys(InputManager input)
|
||||||
{
|
{
|
||||||
if (input.IsKeyPressed(Keys.Escape))
|
if (input.IsKeyPressed(Keys.Escape))
|
||||||
|
{
|
||||||
|
// Esc сначала снимает выделение (если есть), и только потом открывает меню-паузу.
|
||||||
|
if (_selection.HasSelection)
|
||||||
|
{
|
||||||
|
_selection.Clear();
|
||||||
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
_pause.Toggle();
|
_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