Multiplayer: headless dedicated server, MrGameEng.Net, desktop and browser clients #1
@@ -31,6 +31,9 @@ git submodule update --init # after fresh clone
|
||||
dotnet build LittleSim.sln
|
||||
dotnet run --project src/LittleSim -c Release # measure perf in Release only
|
||||
dotnet run --project src/LittleSim.Server -- --days 10 --tps 60 # headless fast-forward
|
||||
dotnet run --project src/LittleSim.Server -- --listen # multiplayer world (WebSocket)
|
||||
dotnet run --project src/LittleSim -- --connect # client → ws://localhost:9050
|
||||
dotnet run --project src/LittleSim.Server -- --probe # CLI check of a running server
|
||||
dotnet test LittleSim.sln # runs the engine test suites
|
||||
```
|
||||
|
||||
|
||||
@@ -47,6 +47,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Host.Tests", "eng
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LittleSim.Server", "src\LittleSim.Server\LittleSim.Server.csproj", "{BEFB468B-6784-468E-9B60-EB44AB078D15}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Net", "engine\src\MrGameEng.Net\MrGameEng.Net.csproj", "{61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Net.Tests", "engine\tests\MrGameEng.Net.Tests\MrGameEng.Net.Tests.csproj", "{03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -273,6 +277,30 @@ Global
|
||||
{BEFB468B-6784-468E-9B60-EB44AB078D15}.Release|x64.Build.0 = Release|Any CPU
|
||||
{BEFB468B-6784-468E-9B60-EB44AB078D15}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{BEFB468B-6784-468E-9B60-EB44AB078D15}.Release|x86.Build.0 = Release|Any CPU
|
||||
{61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Release|x64.Build.0 = Release|Any CPU
|
||||
{61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Release|x86.Build.0 = Release|Any CPU
|
||||
{03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Release|x64.Build.0 = Release|Any CPU
|
||||
{03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -298,5 +326,7 @@ Global
|
||||
{D4222C26-40D8-4465-9B26-CD6BD0722EC0} = {F4A69A46-AF86-AB4E-7D23-4CCCB7B9A519}
|
||||
{41F0249B-106E-4D56-B022-73D2C2D74807} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5}
|
||||
{BEFB468B-6784-468E-9B60-EB44AB078D15} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{61C84FE7-1E0A-4CF2-A63F-39B23F936A7E} = {F4A69A46-AF86-AB4E-7D23-4CCCB7B9A519}
|
||||
{03EC6C39-E1DE-4C66-835F-4B05B5C40DB0} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"season.winter": "winter",
|
||||
"hud.controls": "WASD — camera, wheel — zoom, ` — console, F1 — inspector",
|
||||
"hud.paused": "PAUSED",
|
||||
|
||||
"menu.title": "LittleSim",
|
||||
"menu.subtitle": "a god-game: minimal graphics, deep simulation",
|
||||
"menu.newworld": "New World",
|
||||
@@ -16,7 +15,6 @@
|
||||
"menu.settings": "Settings",
|
||||
"menu.credits": "Credits",
|
||||
"menu.quit": "Quit",
|
||||
|
||||
"newworld.title": "New World",
|
||||
"newworld.name": "Name",
|
||||
"newworld.defaultname": "New World",
|
||||
@@ -26,13 +24,11 @@
|
||||
"newworld.smoothing": "Terrain smoothing",
|
||||
"newworld.create": "Create",
|
||||
"newworld.back": "Back",
|
||||
|
||||
"load.title": "Load",
|
||||
"load.empty": "No saves yet",
|
||||
"load.load": "Load",
|
||||
"load.delete": "Delete",
|
||||
"load.back": "Back",
|
||||
|
||||
"settings.title": "Settings",
|
||||
"settings.language": "Language",
|
||||
"settings.fullscreen": "Fullscreen",
|
||||
@@ -41,7 +37,6 @@
|
||||
"settings.volume": "Volume",
|
||||
"settings.apply": "Apply",
|
||||
"settings.back": "Back",
|
||||
|
||||
"pause.title": "Paused",
|
||||
"pause.resume": "Resume",
|
||||
"pause.settings": "Settings",
|
||||
@@ -49,25 +44,20 @@
|
||||
"pause.mainmenu": "Main menu",
|
||||
"pause.quit": "Quit",
|
||||
"pause.saved": "Saved: {0}",
|
||||
|
||||
"speed.pause": "⏸",
|
||||
|
||||
"credits.title": "Credits",
|
||||
"credits.author": "mrleo1nid",
|
||||
"credits.role": "Concept, code, mrgameeng engine",
|
||||
"credits.back": "Back",
|
||||
|
||||
"preset.small": "Small",
|
||||
"preset.medium": "Medium",
|
||||
"preset.large": "Large",
|
||||
|
||||
"terrain.deepwater": "deep water",
|
||||
"terrain.water": "water",
|
||||
"terrain.sand": "sand",
|
||||
"terrain.grass": "grass",
|
||||
"terrain.forest": "forest",
|
||||
"terrain.mountain": "mountains",
|
||||
|
||||
"plant.oak": "oak",
|
||||
"plant.birch": "birch",
|
||||
"plant.pine": "pine",
|
||||
@@ -77,7 +67,6 @@
|
||||
"plant.stage.sprout": "sprout",
|
||||
"plant.stage.sapling": "sapling",
|
||||
"plant.stage.mature": "mature",
|
||||
|
||||
"pawn.being": "being",
|
||||
"pawn.bear": "bear",
|
||||
"pawn.deer": "deer",
|
||||
@@ -86,5 +75,10 @@
|
||||
"pawn.boar": "boar",
|
||||
"pawn.wolf": "wolf",
|
||||
"pawn.muffalo": "muffalo",
|
||||
"pawn.squirrel": "squirrel"
|
||||
"pawn.squirrel": "squirrel",
|
||||
"net.hud": "Multiplayer: {0} | beings: {1} | Esc — back to menu",
|
||||
"net.connecting": "connecting…",
|
||||
"net.connected": "connected",
|
||||
"net.failed": "connection failed",
|
||||
"net.lost": "connection lost"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"season.winter": "зима",
|
||||
"hud.controls": "WASD — камера, колесо — зум, ` — консоль, F1 — инспектор",
|
||||
"hud.paused": "ПАУЗА",
|
||||
|
||||
"menu.title": "LittleSim",
|
||||
"menu.subtitle": "бог-игра: минимум графики, максимум симуляции",
|
||||
"menu.newworld": "Новый мир",
|
||||
@@ -16,7 +15,6 @@
|
||||
"menu.settings": "Настройки",
|
||||
"menu.credits": "Авторы",
|
||||
"menu.quit": "Выход",
|
||||
|
||||
"newworld.title": "Новый мир",
|
||||
"newworld.name": "Название",
|
||||
"newworld.defaultname": "Новый мир",
|
||||
@@ -26,13 +24,11 @@
|
||||
"newworld.smoothing": "Сглаживание рельефа",
|
||||
"newworld.create": "Создать",
|
||||
"newworld.back": "Назад",
|
||||
|
||||
"load.title": "Загрузка",
|
||||
"load.empty": "Сохранений пока нет",
|
||||
"load.load": "Загрузить",
|
||||
"load.delete": "Удалить",
|
||||
"load.back": "Назад",
|
||||
|
||||
"settings.title": "Настройки",
|
||||
"settings.language": "Язык",
|
||||
"settings.fullscreen": "Полный экран",
|
||||
@@ -41,7 +37,6 @@
|
||||
"settings.volume": "Громкость",
|
||||
"settings.apply": "Применить",
|
||||
"settings.back": "Назад",
|
||||
|
||||
"pause.title": "Пауза",
|
||||
"pause.resume": "Продолжить",
|
||||
"pause.settings": "Настройки",
|
||||
@@ -49,25 +44,20 @@
|
||||
"pause.mainmenu": "Главное меню",
|
||||
"pause.quit": "Выход",
|
||||
"pause.saved": "Сохранено: {0}",
|
||||
|
||||
"speed.pause": "⏸",
|
||||
|
||||
"credits.title": "Авторы",
|
||||
"credits.author": "mrleo1nid",
|
||||
"credits.role": "Идея, код, движок mrgameeng",
|
||||
"credits.back": "Назад",
|
||||
|
||||
"preset.small": "Маленький",
|
||||
"preset.medium": "Средний",
|
||||
"preset.large": "Большой",
|
||||
|
||||
"terrain.deepwater": "глубокая вода",
|
||||
"terrain.water": "вода",
|
||||
"terrain.sand": "песок",
|
||||
"terrain.grass": "трава",
|
||||
"terrain.forest": "лес",
|
||||
"terrain.mountain": "горы",
|
||||
|
||||
"plant.oak": "дуб",
|
||||
"plant.birch": "берёза",
|
||||
"plant.pine": "сосна",
|
||||
@@ -77,7 +67,6 @@
|
||||
"plant.stage.sprout": "всходы",
|
||||
"plant.stage.sapling": "саженец",
|
||||
"plant.stage.mature": "взрослое",
|
||||
|
||||
"pawn.being": "житель",
|
||||
"pawn.bear": "медведь",
|
||||
"pawn.deer": "олень",
|
||||
@@ -86,5 +75,10 @@
|
||||
"pawn.boar": "кабан",
|
||||
"pawn.wolf": "волк",
|
||||
"pawn.muffalo": "муффало",
|
||||
"pawn.squirrel": "белка"
|
||||
"pawn.squirrel": "белка",
|
||||
"net.hud": "Мультиплеер: {0} | жителей: {1} | Esc — в меню",
|
||||
"net.connecting": "подключение…",
|
||||
"net.connected": "подключено",
|
||||
"net.failed": "не удалось подключиться",
|
||||
"net.lost": "соединение потеряно"
|
||||
}
|
||||
|
||||
+1
-1
Submodule engine updated: 2ac074004a...3438ed77f6
@@ -1,4 +1 @@
|
||||
<Project>
|
||||
|
||||
</Project>
|
||||
|
||||
<Project></Project>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
||||
|
||||
<PropertyGroup>
|
||||
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
@@ -8,19 +7,17 @@
|
||||
<RootNamespace>KniWebSpike</RootNamespace>
|
||||
<AssemblyName>KniWebSpike</AssemblyName>
|
||||
<DefineConstants>$(DefineConstants);BLAZORGL</DefineConstants>
|
||||
<KniPlatform>BlazorGL</KniPlatform>
|
||||
<KniPlatform>BlazorGL</KniPlatform>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
<PropertyGroup>
|
||||
<BlazorEnableTimeZoneSupport>false</BlazorEnableTimeZoneSupport>
|
||||
<!--<InvariantGlobalization>true</InvariantGlobalization>-->
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' "></PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' "></PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="Pages\Index.razor.cs" />
|
||||
@@ -51,11 +48,14 @@
|
||||
|
||||
<ItemGroup Condition=" '$(TargetFramework)' == 'net8.0' ">
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.17" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.17" PrivateAssets="all" />
|
||||
<PackageReference
|
||||
Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer"
|
||||
Version="8.0.17"
|
||||
PrivateAssets="all"
|
||||
/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<KniContentReference Include="Content\KniWebSpikeContent.mgcb" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -31,6 +31,5 @@ namespace KniWebSpike.Pages
|
||||
// run gameloop
|
||||
_game.Tick();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace KniWebSpike
|
||||
builder.RootComponents.Add<HeadOutlet>("head::after");
|
||||
builder.Services.AddScoped(sp => new HttpClient()
|
||||
{
|
||||
BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
|
||||
BaseAddress = new Uri(builder.HostEnvironment.BaseAddress),
|
||||
});
|
||||
await builder.Build().RunAsync();
|
||||
}
|
||||
|
||||
@@ -39,7 +39,12 @@ namespace KniWebSpike
|
||||
private readonly float _width;
|
||||
private readonly float _height;
|
||||
|
||||
public SpikeScene(Func<SpriteBatch> spriteBatch, Func<Texture2D> pixel, float width, float height)
|
||||
public SpikeScene(
|
||||
Func<SpriteBatch> spriteBatch,
|
||||
Func<Texture2D> pixel,
|
||||
float width,
|
||||
float height
|
||||
)
|
||||
{
|
||||
_spriteBatch = spriteBatch;
|
||||
_pixel = pixel;
|
||||
@@ -60,11 +65,7 @@ namespace KniWebSpike
|
||||
X = (float)random.NextDouble() * _width,
|
||||
Y = (float)random.NextDouble() * _height,
|
||||
},
|
||||
new DotVelocity
|
||||
{
|
||||
X = MathF.Cos(angle) * speed,
|
||||
Y = MathF.Sin(angle) * speed,
|
||||
},
|
||||
new DotVelocity { X = MathF.Cos(angle) * speed, Y = MathF.Sin(angle) * speed },
|
||||
new DotTint
|
||||
{
|
||||
R = (byte)random.Next(64, 256),
|
||||
|
||||
@@ -1,27 +1,107 @@
|
||||
using Friflo.Engine.ECS;
|
||||
using Friflo.Engine.ECS.Systems;
|
||||
using LittleSim.Content;
|
||||
using LittleSim.Net;
|
||||
using LittleSim.Scenes;
|
||||
using LittleSim.Sim;
|
||||
using Microsoft.Xna.Framework;
|
||||
using MrGameEng.Core;
|
||||
using MrGameEng.Graphics;
|
||||
using MrGameEng.Net;
|
||||
|
||||
namespace LittleSim.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Серверная сцена-зародыш: календарь и климат мира идут на фиксированном тике
|
||||
/// <see cref="HeadlessHost"/>, без окна, GPU и текстур. Демонстрирует headless-режим
|
||||
/// движка; дальше сюда переедет полная симуляция мира (растения, жители) и сетевой слой.
|
||||
/// Серверный мир: жители бродят, устают и отдыхают (те же системы, что в WorldScene) на
|
||||
/// фиксированном тике <see cref="HeadlessHost"/> — без окна, GPU и спрайтов. С
|
||||
/// <see cref="WebSocketServer"/> мир ещё и реплицируется подключённым клиентам по схеме
|
||||
/// <see cref="NetSchema"/> (10 снапшотов в секунду, дельты). Демонстрация MrGameEng.Net.
|
||||
/// </summary>
|
||||
internal sealed class HeadlessWorldScene : Scene
|
||||
{
|
||||
private readonly GameContent _content;
|
||||
/// <summary>Жителей в серверном мире.</summary>
|
||||
public const int PawnCount = 24;
|
||||
|
||||
public HeadlessWorldScene(GameContent content) => _content = content;
|
||||
/// <summary>Сид мира — рельефа пока нет, но блуждание детерминировано им.</summary>
|
||||
public const int Seed = 424242;
|
||||
|
||||
private static readonly RectF Bounds = new(0f, 0f, 1280f, 720f);
|
||||
|
||||
private readonly GameContent _content;
|
||||
private readonly WebSocketServer? _server;
|
||||
|
||||
/// <summary>Мир без сети (fast-forward) или, с <paramref name="server"/>, онлайн-мир.</summary>
|
||||
public HeadlessWorldScene(GameContent content, WebSocketServer? server = null)
|
||||
{
|
||||
_content = content;
|
||||
_server = server;
|
||||
}
|
||||
|
||||
protected override void OnLoad()
|
||||
{
|
||||
Context.Services.Add(_content);
|
||||
var calendar = Context.UseCalendar(WorldScene.SecondsPerDay);
|
||||
var climate = Context.UseClimate(ClimateSettings.Default);
|
||||
|
||||
var replication = _server is null ? null : new ReplicationServer(NetSchema.Create(), Store);
|
||||
var random = new Random(Seed);
|
||||
for (var i = 0; i < PawnCount; i++)
|
||||
{
|
||||
var position = new Vector2(
|
||||
random.NextSingle() * Bounds.Width,
|
||||
random.NextSingle() * Bounds.Height
|
||||
);
|
||||
var transform = new Transform2D(position, scale: new Vector2(12f));
|
||||
var needs = new PawnNeeds { Energy = 0.4f + random.NextSingle() * 0.6f };
|
||||
if (replication is null)
|
||||
{
|
||||
Store.CreateEntity(transform, new Wander(), new PawnBrain(), needs);
|
||||
}
|
||||
else
|
||||
{
|
||||
Store.CreateEntity(
|
||||
transform,
|
||||
new Wander(),
|
||||
new PawnBrain(),
|
||||
needs,
|
||||
new NetId { Value = replication.NextNetId() }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
UpdateSystems.Add(new PawnDecisionSystem());
|
||||
UpdateSystems.Add(new PawnNeedsSystem());
|
||||
UpdateSystems.Add(new WanderSystem(Seed, Bounds));
|
||||
UpdateSystems.Add(new DayReportSystem(calendar, climate));
|
||||
if (_server is not null && replication is not null)
|
||||
{
|
||||
UpdateSystems.Add(new NetworkSystem(_server, replication));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Принимает новые соединения и шлёт дельта-снапшоты с сетевой частотой.</summary>
|
||||
private sealed class NetworkSystem(WebSocketServer server, ReplicationServer replication)
|
||||
: BaseSystem
|
||||
{
|
||||
// 60 тиков симуляции / 6 = 10 снапшотов в секунду.
|
||||
private const int SendEveryTicks = 6;
|
||||
|
||||
private int _ticks;
|
||||
|
||||
protected override void OnUpdateGroup()
|
||||
{
|
||||
while (server.TryAcceptConnection(out var connection))
|
||||
{
|
||||
Log.Info(
|
||||
$"Клиент #{connection.Id} подключился ({server.Connections.Count} онлайн)"
|
||||
);
|
||||
}
|
||||
|
||||
if (++_ticks % SendEveryTicks == 0)
|
||||
{
|
||||
replication.Send(server.Connections);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Пишет строку состояния мира в лог на рассвете каждого игрового дня.</summary>
|
||||
|
||||
+122
-15
@@ -1,19 +1,31 @@
|
||||
using System.Diagnostics;
|
||||
using Friflo.Engine.ECS;
|
||||
using LittleSim.Content;
|
||||
using LittleSim.Net;
|
||||
using LittleSim.Scenes;
|
||||
using LittleSim.Server;
|
||||
using MrGameEng.Core;
|
||||
using MrGameEng.Graphics;
|
||||
using MrGameEng.Net;
|
||||
|
||||
// Дедикейтед-сервер LittleSim (прототип): мир без окна и GPU на HeadlessHost движка.
|
||||
// Пока умеет загрузить моды/дефы (без атласов) и прогнать календарь и климат на
|
||||
// фиксированном тике. Использование:
|
||||
// dotnet run --project src/LittleSim.Server [-- --days N] [--tps N]
|
||||
// Дедикейтед-сервер LittleSim: мир без окна и GPU на HeadlessHost движка.
|
||||
// dotnet run --project src/LittleSim.Server — fast-forward N дней
|
||||
// dotnet run --project src/LittleSim.Server -- --days 30 — то же, явное число дней
|
||||
// dotnet run --project src/LittleSim.Server -- --listen [--port N] — онлайн-мир (WebSocket)
|
||||
// dotnet run --project src/LittleSim.Server -- --probe [--port N] — проверка: подключиться
|
||||
// к серверу и показать реплицированных жителей
|
||||
|
||||
var days = ReadOption("--days", 10);
|
||||
var ticksPerSecond = ReadOption("--tps", 60);
|
||||
var port = ReadOption("--port", NetSchema.DefaultPort);
|
||||
|
||||
Log.MessageLogged += (level, message) => Console.WriteLine($"[{level}] {message}");
|
||||
|
||||
if (Array.IndexOf(args, "--probe") >= 0)
|
||||
{
|
||||
return await Probe(port);
|
||||
}
|
||||
|
||||
var content = GameContent.Load(buildAtlases: false);
|
||||
Log.Info(
|
||||
$"Моды: {string.Join(", ", content.Mods.Select(m => m.ToString()))} | "
|
||||
@@ -22,18 +34,113 @@ Log.Info(
|
||||
+ $"{content.Defs.All<PawnDef>().Count} жителей"
|
||||
);
|
||||
|
||||
using var host = new HeadlessHost(
|
||||
new HeadlessHostOptions { TicksPerSecond = ticksPerSecond, Realtime = false },
|
||||
new HeadlessWorldScene(content)
|
||||
);
|
||||
if (Array.IndexOf(args, "--listen") >= 0)
|
||||
{
|
||||
using var socketServer = new WebSocketServer(port);
|
||||
socketServer.Start();
|
||||
using var host = new HeadlessHost(
|
||||
new HeadlessHostOptions { TicksPerSecond = ticksPerSecond, Realtime = true },
|
||||
new HeadlessWorldScene(content, socketServer)
|
||||
);
|
||||
using var cancel = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, eventArgs) =>
|
||||
{
|
||||
eventArgs.Cancel = true;
|
||||
cancel.Cancel();
|
||||
};
|
||||
Log.Info(
|
||||
$"Мир онлайн: ws://localhost:{port}, {HeadlessWorldScene.PawnCount} жителей, "
|
||||
+ $"{ticksPerSecond} тиков/с. Клиент: dotnet run --project src/LittleSim -- --connect. "
|
||||
+ "Ctrl+C — остановка."
|
||||
);
|
||||
host.Run(cancel.Token);
|
||||
Log.Info($"Сервер остановлен на тике {host.TickCount}.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var ticks = (long)((double)days * WorldScene.SecondsPerDay * ticksPerSecond);
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
host.RunTicks(ticks);
|
||||
Log.Info(
|
||||
$"{days} игровых дней за {stopwatch.Elapsed.TotalSeconds:F1} с реального времени "
|
||||
+ $"({ticks} тиков, {ticks / Math.Max(stopwatch.Elapsed.TotalSeconds, 0.001):F0} тиков/с)"
|
||||
);
|
||||
using (
|
||||
var host = new HeadlessHost(
|
||||
new HeadlessHostOptions { TicksPerSecond = ticksPerSecond, Realtime = false },
|
||||
new HeadlessWorldScene(content)
|
||||
)
|
||||
)
|
||||
{
|
||||
var ticks = (long)((double)days * WorldScene.SecondsPerDay * ticksPerSecond);
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
host.RunTicks(ticks);
|
||||
Log.Info(
|
||||
$"{days} игровых дней за {stopwatch.Elapsed.TotalSeconds:F1} с реального времени "
|
||||
+ $"({ticks} тиков, {ticks / Math.Max(stopwatch.Elapsed.TotalSeconds, 0.001):F0} тиков/с)"
|
||||
);
|
||||
}
|
||||
|
||||
return 0;
|
||||
|
||||
// Подключается к работающему серверу, секунду слушает снапшоты и показывает жителей —
|
||||
// консольная проверка репликации без игрового клиента.
|
||||
async Task<int> Probe(int probePort)
|
||||
{
|
||||
var store = new EntityStore();
|
||||
var replication = new ReplicationClient(NetSchema.Create(), store);
|
||||
var uri = new Uri($"ws://localhost:{probePort}/");
|
||||
Log.Info($"Подключение к {uri}…");
|
||||
WebSocketClient connection;
|
||||
try
|
||||
{
|
||||
connection = await WebSocketClient.ConnectAsync(
|
||||
uri,
|
||||
new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token
|
||||
);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Log.Error($"Не подключилось: {exception.GetBaseException().Message}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
using (connection)
|
||||
{
|
||||
await Task.Delay(1000);
|
||||
replication.Pump(connection);
|
||||
var first = SamplePositions(store);
|
||||
Log.Info($"Снапшот получен: {replication.EntityCount} жителей");
|
||||
|
||||
await Task.Delay(2000);
|
||||
replication.Pump(connection);
|
||||
var second = SamplePositions(store);
|
||||
for (var i = 0; i < first.Count; i++)
|
||||
{
|
||||
Log.Info(
|
||||
$" житель {first[i].NetId}: ({first[i].X:F0}, {first[i].Y:F0}) → "
|
||||
+ $"({second[i].X:F0}, {second[i].Y:F0})"
|
||||
);
|
||||
}
|
||||
|
||||
var moved = first.Where((p, i) => p.X != second[i].X || p.Y != second[i].Y).Count();
|
||||
Log.Info($"Двигались {moved} из {first.Count} показанных — мир жив.");
|
||||
return replication.EntityCount > 0 && moved > 0 ? 0 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
static List<(int NetId, float X, float Y)> SamplePositions(EntityStore store)
|
||||
{
|
||||
var result = new List<(int, float, float)>();
|
||||
foreach (var entity in store.Entities)
|
||||
{
|
||||
if (result.Count == 5)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (entity.HasComponent<NetId>() && entity.HasComponent<Transform2D>())
|
||||
{
|
||||
var position = entity.GetComponent<Transform2D>().Position;
|
||||
result.Add((entity.GetComponent<NetId>().Value, position.X, position.Y));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
int ReadOption(string name, int fallback)
|
||||
{
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<ProjectReference Include="..\..\engine\src\MrGameEng.Audio\MrGameEng.Audio.csproj" />
|
||||
<ProjectReference Include="..\..\engine\src\MrGameEng.Content\MrGameEng.Content.csproj" />
|
||||
<ProjectReference Include="..\..\engine\src\MrGameEng.Simulation\MrGameEng.Simulation.csproj" />
|
||||
<ProjectReference Include="..\..\engine\src\MrGameEng.Net\MrGameEng.Net.csproj" />
|
||||
<ProjectReference Include="..\..\engine\src\MrGameEng.UI\MrGameEng.UI.csproj" />
|
||||
<ProjectReference
|
||||
Include="..\..\engine\src\MrGameEng.Assets.Generator\MrGameEng.Assets.Generator.csproj"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using LittleSim.Sim;
|
||||
using MrGameEng.Graphics;
|
||||
using MrGameEng.Net;
|
||||
|
||||
namespace LittleSim.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Сетевой контракт LittleSim: какие компоненты реплицируются с сервера на клиентов.
|
||||
/// Сервер (LittleSim.Server) и клиент (<see cref="Scenes.MultiplayerScene"/>) обязаны
|
||||
/// строить схему одинаково — порядок регистрации определяет wire-id компонентов.
|
||||
/// </summary>
|
||||
public static class NetSchema
|
||||
{
|
||||
/// <summary>Порт сервера по умолчанию.</summary>
|
||||
public const int DefaultPort = 9050;
|
||||
|
||||
/// <summary>Схема репликации: позиция/масштаб жителя и его потребности.</summary>
|
||||
public static ReplicationSchema Create() =>
|
||||
new ReplicationSchema().Register<Transform2D>().Register<PawnNeeds>();
|
||||
}
|
||||
@@ -1,7 +1,20 @@
|
||||
using LittleSim.Net;
|
||||
using LittleSim.Scenes;
|
||||
using Microsoft.Xna.Framework;
|
||||
using MrGameEng.Host;
|
||||
|
||||
// --connect [ws://host:port] — после загрузки контента сразу в сетевую сцену
|
||||
// (см. MultiplayerScene); без аргументов — обычный запуск в главное меню.
|
||||
string? connect = null;
|
||||
var connectIndex = Array.IndexOf(args, "--connect");
|
||||
if (connectIndex >= 0)
|
||||
{
|
||||
connect =
|
||||
connectIndex + 1 < args.Length && !args[connectIndex + 1].StartsWith("--")
|
||||
? args[connectIndex + 1]
|
||||
: $"ws://localhost:{NetSchema.DefaultPort}/";
|
||||
}
|
||||
|
||||
// Контент Core-мода грузится уже в окне — на загрузочном экране (BootScene), в фоне.
|
||||
using var host = new GameHost(
|
||||
new GameHostOptions
|
||||
@@ -11,7 +24,7 @@ using var host = new GameHost(
|
||||
Height = 720,
|
||||
ClearColor = new Color(12, 16, 24),
|
||||
},
|
||||
new BootScene()
|
||||
new BootScene(connect)
|
||||
);
|
||||
|
||||
host.Run();
|
||||
|
||||
@@ -19,10 +19,18 @@ namespace LittleSim.Scenes;
|
||||
public sealed class BootScene : Scene
|
||||
{
|
||||
private readonly string[] _steps = { "Загрузка", "Loading" };
|
||||
private readonly string? _connectTo;
|
||||
private Task<GameContent>? _load;
|
||||
private GameSettings _settings = new();
|
||||
private Label _label = null!;
|
||||
|
||||
/// <summary>Обычный запуск — в главное меню.</summary>
|
||||
public BootScene()
|
||||
: this(null) { }
|
||||
|
||||
/// <summary>С <paramref name="connectTo"/> (ws://host:port) грузится сразу в сетевую сцену.</summary>
|
||||
public BootScene(string? connectTo) => _connectTo = connectTo;
|
||||
|
||||
protected override void OnLoad()
|
||||
{
|
||||
Context.UseAudio();
|
||||
@@ -74,6 +82,9 @@ public sealed class BootScene : Scene
|
||||
Context.Services.Add(content);
|
||||
Context.Services.Add(new ModAtlases(content.AtlasCacheDirectory));
|
||||
content.Languages.SetLanguage(_settings.Language);
|
||||
Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.6f));
|
||||
Scene next = _connectTo is null
|
||||
? new MainMenuScene()
|
||||
: new MultiplayerScene(new Uri(_connectTo));
|
||||
Context.Scenes.Switch(next, Transitions.Fade(0.6f));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Friflo.Engine.ECS;
|
||||
using LittleSim.Content;
|
||||
using LittleSim.Net;
|
||||
using LittleSim.Sim;
|
||||
using LittleSim.UI;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using MrGameEng.Assets;
|
||||
using MrGameEng.Core;
|
||||
using MrGameEng.DevConsole;
|
||||
using MrGameEng.Graphics;
|
||||
using MrGameEng.Host;
|
||||
using MrGameEng.Input;
|
||||
using MrGameEng.Net;
|
||||
using MrGameEng.UI;
|
||||
using Myra.Graphics2D.UI;
|
||||
|
||||
namespace LittleSim.Scenes;
|
||||
|
||||
/// <summary>
|
||||
/// Сетевой клиент: подключается к дедикейтед-серверу (LittleSim.Server --listen) и рендерит
|
||||
/// реплицированных жителей. Симуляция целиком на сервере; сюда приезжают только компоненты
|
||||
/// из <see cref="NetSchema"/>, а спрайты вешаются локально при спавне — граница
|
||||
/// sim/presentation проходит теперь через сеть. Esc — назад в меню, команда консоли `net` —
|
||||
/// состояние соединения.
|
||||
/// </summary>
|
||||
public sealed class MultiplayerScene : Scene
|
||||
{
|
||||
private static readonly RectF Bounds = new(0f, 0f, 1280f, 720f);
|
||||
|
||||
private readonly Uri _server;
|
||||
private Task<WebSocketClient>? _connecting;
|
||||
private WebSocketClient? _connection;
|
||||
private ReplicationClient _replication = null!;
|
||||
private InputManager _input = null!;
|
||||
private GameContent _content = null!;
|
||||
private Label _hud = null!;
|
||||
private string _statusKey = "net.connecting";
|
||||
|
||||
/// <summary>Сцена, подключающаяся к серверу <paramref name="server"/> (ws:// или wss://).</summary>
|
||||
public MultiplayerScene(Uri server) => _server = server;
|
||||
|
||||
protected override void OnLoad()
|
||||
{
|
||||
_content = Context.Services.Get<GameContent>();
|
||||
var assets = Context.Services.GetOrDefault<AssetManager>() ?? Context.UseAssets();
|
||||
_input = this.UseInput();
|
||||
var renderer = this.UseRenderer2D(
|
||||
new Renderer2DOptions { VirtualResolution = new Point(1280, 720) }
|
||||
);
|
||||
GameLayers.EnsureRegistered(renderer);
|
||||
var white = new Texture2DRegion(assets.Load(GameAssets.Textures.White));
|
||||
Store.CreateEntity(new Camera(Bounds.Center, zoom: 1f, bounds: Bounds));
|
||||
|
||||
// Реплика пишет в ECS сцены; презентацию (спрайт) вешаем сами при спавне.
|
||||
_replication = new ReplicationClient(NetSchema.Create(), Store);
|
||||
_replication.EntitySpawned += entity =>
|
||||
{
|
||||
var sprite = new Sprite(white, GameLayers.Beings);
|
||||
sprite.CenterOrigin();
|
||||
entity.AddComponent(sprite);
|
||||
};
|
||||
|
||||
var desktop = this.UseUI();
|
||||
_hud = new Label { Left = 10, Top = 8 };
|
||||
desktop.Root = Ui.Screen(_hud);
|
||||
|
||||
UpdateSystems.Add(new CallbackSystem(Pump));
|
||||
// Усталость жителей видна и по сети: PawnNeeds реплицируется, спрайт темнеет локально.
|
||||
UpdateSystems.Add(new PawnAppearanceSystem());
|
||||
|
||||
var console = this.UseDevConsole();
|
||||
console.Register(
|
||||
"net",
|
||||
"net — connection status and replicated entity count",
|
||||
(c, _) =>
|
||||
{
|
||||
c.WriteLine($"server: {_server}");
|
||||
c.WriteLine($"state: {(_connection?.IsOpen == true ? "connected" : "offline")}");
|
||||
c.WriteLine($"entities: {_replication.EntityCount}");
|
||||
}
|
||||
);
|
||||
|
||||
_connecting = WebSocketClient.ConnectAsync(_server);
|
||||
}
|
||||
|
||||
protected override void OnUnload() => _connection?.Close();
|
||||
|
||||
private void Pump()
|
||||
{
|
||||
if (_connecting is { IsCompleted: true } finished)
|
||||
{
|
||||
_connecting = null;
|
||||
if (finished.IsFaulted)
|
||||
{
|
||||
_statusKey = "net.failed";
|
||||
Log.Error(
|
||||
$"Connect to {_server} failed: "
|
||||
+ finished.Exception?.GetBaseException().Message
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
_connection = finished.Result;
|
||||
_statusKey = "net.connected";
|
||||
Log.Info($"Connected to {_server}");
|
||||
}
|
||||
}
|
||||
|
||||
if (_connection is not null)
|
||||
{
|
||||
_replication.Pump(_connection);
|
||||
if (!_connection.IsOpen)
|
||||
{
|
||||
_statusKey = "net.lost";
|
||||
}
|
||||
}
|
||||
|
||||
_hud.Text = _content.Languages.Format(
|
||||
"net.hud",
|
||||
_content.Languages.Get(_statusKey),
|
||||
_replication.EntityCount
|
||||
);
|
||||
|
||||
if (_input.IsKeyPressed(Keys.Escape))
|
||||
{
|
||||
_connection?.Close();
|
||||
Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user