Add pawn utility-AI brain; format code with CSharpier; enforce formatter

Wire the engine's new MrGameEng.AI module into a PawnBrain for world pawns,
plus surrounding content/scene tweaks. Apply CSharpier across the game and
document the convention in CLAUDE.md. Add .vscode/settings.json so the editor
formats on save with the CSharpier extension.
This commit is contained in:
Leonid Pershin
2026-06-12 07:20:03 +03:00
parent 8bc30e008e
commit 58de27248e
14 changed files with 553 additions and 153 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"editor.defaultFormatter": "csharpier.csharpier-vscode",
"editor.formatOnPaste": true,
"editor.formatOnSave": true
}
+4
View File
@@ -63,3 +63,7 @@ Never commit a pointer to an unpushed engine commit.
- Content as data: numbers, textures and balance live in `Mods/Core/Defs`, user-facing
strings in `Mods/Core/Languages` — code only defines systems and def classes.
- Every new mechanic gets a dev-console command for testing (`regen`, `timescale`, …).
- Formatting: all C# code is formatted with **CSharpier** (`editor.defaultFormatter`
is `csharpier.csharpier-vscode`, format-on-save is on). Match CSharpier's output —
run `csharpier format .` (or let format-on-save handle it) before committing; never
hand-format against it.
+30
View File
@@ -51,6 +51,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Mods", "engine\sr
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Mods.Tests", "engine\tests\MrGameEng.Mods.Tests\MrGameEng.Mods.Tests.csproj", "{07986629-FE7C-42BE-868B-0FCA1915D2A2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.AI", "engine\src\MrGameEng.AI\MrGameEng.AI.csproj", "{EBD0B6A4-B8F3-45D6-88EF-D5D17DEDEFA5}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.AI.Tests", "engine\tests\MrGameEng.AI.Tests\MrGameEng.AI.Tests.csproj", "{6731DC3C-0807-4791-AF8E-C56F7F7D1F85}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -301,6 +305,30 @@ Global
{07986629-FE7C-42BE-868B-0FCA1915D2A2}.Release|x64.Build.0 = Release|Any CPU
{07986629-FE7C-42BE-868B-0FCA1915D2A2}.Release|x86.ActiveCfg = Release|Any CPU
{07986629-FE7C-42BE-868B-0FCA1915D2A2}.Release|x86.Build.0 = Release|Any CPU
{EBD0B6A4-B8F3-45D6-88EF-D5D17DEDEFA5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EBD0B6A4-B8F3-45D6-88EF-D5D17DEDEFA5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EBD0B6A4-B8F3-45D6-88EF-D5D17DEDEFA5}.Debug|x64.ActiveCfg = Debug|Any CPU
{EBD0B6A4-B8F3-45D6-88EF-D5D17DEDEFA5}.Debug|x64.Build.0 = Debug|Any CPU
{EBD0B6A4-B8F3-45D6-88EF-D5D17DEDEFA5}.Debug|x86.ActiveCfg = Debug|Any CPU
{EBD0B6A4-B8F3-45D6-88EF-D5D17DEDEFA5}.Debug|x86.Build.0 = Debug|Any CPU
{EBD0B6A4-B8F3-45D6-88EF-D5D17DEDEFA5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EBD0B6A4-B8F3-45D6-88EF-D5D17DEDEFA5}.Release|Any CPU.Build.0 = Release|Any CPU
{EBD0B6A4-B8F3-45D6-88EF-D5D17DEDEFA5}.Release|x64.ActiveCfg = Release|Any CPU
{EBD0B6A4-B8F3-45D6-88EF-D5D17DEDEFA5}.Release|x64.Build.0 = Release|Any CPU
{EBD0B6A4-B8F3-45D6-88EF-D5D17DEDEFA5}.Release|x86.ActiveCfg = Release|Any CPU
{EBD0B6A4-B8F3-45D6-88EF-D5D17DEDEFA5}.Release|x86.Build.0 = Release|Any CPU
{6731DC3C-0807-4791-AF8E-C56F7F7D1F85}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6731DC3C-0807-4791-AF8E-C56F7F7D1F85}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6731DC3C-0807-4791-AF8E-C56F7F7D1F85}.Debug|x64.ActiveCfg = Debug|Any CPU
{6731DC3C-0807-4791-AF8E-C56F7F7D1F85}.Debug|x64.Build.0 = Debug|Any CPU
{6731DC3C-0807-4791-AF8E-C56F7F7D1F85}.Debug|x86.ActiveCfg = Debug|Any CPU
{6731DC3C-0807-4791-AF8E-C56F7F7D1F85}.Debug|x86.Build.0 = Debug|Any CPU
{6731DC3C-0807-4791-AF8E-C56F7F7D1F85}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6731DC3C-0807-4791-AF8E-C56F7F7D1F85}.Release|Any CPU.Build.0 = Release|Any CPU
{6731DC3C-0807-4791-AF8E-C56F7F7D1F85}.Release|x64.ActiveCfg = Release|Any CPU
{6731DC3C-0807-4791-AF8E-C56F7F7D1F85}.Release|x64.Build.0 = Release|Any CPU
{6731DC3C-0807-4791-AF8E-C56F7F7D1F85}.Release|x86.ActiveCfg = Release|Any CPU
{6731DC3C-0807-4791-AF8E-C56F7F7D1F85}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -328,5 +356,7 @@ Global
{B9161776-32FE-415E-9401-DC5BB87FC225} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5}
{F91AA8C8-FD6B-4B92-BB3A-844DCA932D82} = {F4A69A46-AF86-AB4E-7D23-4CCCB7B9A519}
{07986629-FE7C-42BE-868B-0FCA1915D2A2} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5}
{EBD0B6A4-B8F3-45D6-88EF-D5D17DEDEFA5} = {F4A69A46-AF86-AB4E-7D23-4CCCB7B9A519}
{6731DC3C-0807-4791-AF8E-C56F7F7D1F85} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5}
EndGlobalSection
EndGlobal
+2 -2
View File
@@ -9,8 +9,8 @@
- [x] Жители-заглушки (блуждание), камера бога, HUD, консоль (`regen`)
## Этап 1 — Жители как агенты
- [ ] Потребности: голод, усталость (тикают со временем)
- [ ] Utility-выбор действия: искать еду / отдыхать / бродить
- [~] Потребности: голод, усталость (тикают со временем) — усталость/энергия готова (`PawnNeeds`), голод впереди
- [~] Utility-выбор действия: искать еду / отдыхать / бродить — каркас на движке (`MrGameEng.AI`), выбор «отдыхать/бродить» работает; «искать еду» впереди
- [ ] Еда на траве/в лесу: растёт, собирается, истощается
- [ ] Смерть от голода; визуальное состояние жителя (цвет/прозрачность)
- [ ] Консоль: `spawn <n>`, `feed`, `starve` для тестов
+28 -8
View File
@@ -16,7 +16,8 @@ public sealed class GameContent
DefDatabase defs,
LanguageManager languages,
TerrainSet terrains,
string atlasCacheDirectory)
string atlasCacheDirectory
)
{
Mods = mods;
Defs = defs;
@@ -46,9 +47,11 @@ public sealed class GameContent
/// </summary>
public static GameContent Load()
{
var modsRoot = ModLoader.FindModsRoot(AppContext.BaseDirectory)
var modsRoot =
ModLoader.FindModsRoot(AppContext.BaseDirectory)
?? throw new DirectoryNotFoundException(
"Папка Mods не найдена ни рядом с игрой, ни выше по дереву каталогов.");
"Папка Mods не найдена ни рядом с игрой, ни выше по дереву каталогов."
);
var mods = ModLoader.Load(modsRoot);
var defs = new DefDatabase();
@@ -62,14 +65,31 @@ public sealed class GameContent
// Текстуры поздних модов переопределяют ранние по относительному пути;
// сборка инкрементальная — без изменений старт мгновенный.
var atlasCacheDirectory = Path.Combine(Path.GetDirectoryName(modsRoot)!, "Cache", "Atlases");
var atlasCacheDirectory = Path.Combine(
Path.GetDirectoryName(modsRoot)!,
"Cache",
"Atlases"
);
var textures = ModContentTree.Build(mods, "Textures", ".png", ".jpg", ".jpeg", ".bmp");
var result = AtlasBuilder.Build(
new AtlasBuildOptions { OutputDirectory = atlasCacheDirectory, GroupDepth = ModAtlases.GroupDepth },
textures.Files.Select(f => (f.FullPath, f.RelativePath)));
new AtlasBuildOptions
{
OutputDirectory = atlasCacheDirectory,
GroupDepth = ModAtlases.GroupDepth,
},
textures.Files.Select(f => (f.FullPath, f.RelativePath))
);
var built = result.Groups.Count(g => !g.Skipped);
Log.Info($"Atlases: {built} built, {result.Groups.Count - built} up to date ({result.Groups.Count} total)");
Log.Info(
$"Atlases: {built} built, {result.Groups.Count - built} up to date ({result.Groups.Count} total)"
);
return new GameContent(mods, defs, languages, new TerrainSet(defs.All<TerrainDef>()), atlasCacheDirectory);
return new GameContent(
mods,
defs,
languages,
new TerrainSet(defs.All<TerrainDef>()),
atlasCacheDirectory
);
}
}
+3 -1
View File
@@ -14,7 +14,9 @@ public sealed class ModAtlases : IDisposable
public const int GroupDepth = 2;
private readonly string _directory;
private readonly Dictionary<string, TextureAtlas> _loaded = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, TextureAtlas> _loaded = new(
StringComparer.OrdinalIgnoreCase
);
/// <summary>Создаёт реестр над кэш-директорией атласов.</summary>
public ModAtlases(string directory) => _directory = directory;
+3 -1
View File
@@ -10,7 +10,9 @@ public sealed class TerrainSet
{
if (defs.Count == 0)
{
throw new InvalidDataException("Не загружен ни один TerrainDef — отсутствует Core-мод?");
throw new InvalidDataException(
"Не загружен ни один TerrainDef — отсутствует Core-мод?"
);
}
_byHeight = defs.OrderBy(d => d.MaxHeight).ToArray();
+6 -4
View File
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0</TargetFramework>
@@ -14,17 +13,20 @@
<ProjectReference Include="..\..\engine\src\MrGameEng.Atlases\MrGameEng.Atlases.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Tilemaps\MrGameEng.Tilemaps.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Pathfinding\MrGameEng.Pathfinding.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.AI\MrGameEng.AI.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Collisions\MrGameEng.Collisions.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.UI\MrGameEng.UI.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.DevConsole\MrGameEng.DevConsole.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Mods\MrGameEng.Mods.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Assets.Generator\MrGameEng.Assets.Generator.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
<ProjectReference
Include="..\..\engine\src\MrGameEng.Assets.Generator\MrGameEng.Assets.Generator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false"
/>
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="Assets\**\*.*" />
<None Include="Assets\**\*.*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+2 -1
View File
@@ -14,7 +14,8 @@ using var host = new GameHost(
Height = 720,
ClearColor = new Color(12, 16, 24),
},
new WorldScene(seed: 1337));
new WorldScene(seed: 1337)
);
host.Context.Services.Add(content);
host.Context.Services.Add(new ModAtlases(content.AtlasCacheDirectory));
+116 -71
View File
@@ -17,9 +17,17 @@ internal static class ScatterSpawner
/// когда заданы слои коллизий.
/// </summary>
public static void Spawn(
Scene scene, GameContent content, ModAtlases atlases, GraphicsDevice device,
TerrainDef terrain, Point cell, int cellSize, Random random,
uint obstacleLayer = 0, uint collidesWith = 0)
Scene scene,
GameContent content,
ModAtlases atlases,
GraphicsDevice device,
TerrainDef terrain,
Point cell,
int cellSize,
Random random,
uint obstacleLayer = 0,
uint collidesWith = 0
)
{
foreach (var entry in terrain.Scatter)
{
@@ -36,9 +44,12 @@ internal static class ScatterSpawner
new Transform2D(
new Vector2(
(cell.X + 0.3f + random.NextSingle() * 0.4f) * cellSize,
(cell.Y + 0.3f + random.NextSingle() * 0.4f) * cellSize),
scale: new Vector2(cellSize * plant.SizeCells / region.Width)),
sprite);
(cell.Y + 0.3f + random.NextSingle() * 0.4f) * cellSize
),
scale: new Vector2(cellSize * plant.SizeCells / region.Width)
),
sprite
);
if (obstacleLayer != 0 && plant.TrunkRadiusCells > 0f)
{
@@ -57,88 +68,122 @@ internal static class ContentCommands
/// <summary>Регистрирует (или перерегистрирует) команды контента на общей консоли.</summary>
public static void Register(DevConsole console, GameContent content, ModAtlases atlases)
{
console.Register("mods", "mods — list active mods in load order", (c, _) =>
{
foreach (var mod in content.Mods)
console.Register(
"mods",
"mods — list active mods in load order",
(c, _) =>
{
c.WriteLine($" {mod.Id} {mod.Info.Version} — {mod.Info.Name}");
foreach (var mod in content.Mods)
{
c.WriteLine($" {mod.Id} {mod.Info.Version} — {mod.Info.Name}");
}
c.WriteLine($"{content.Mods.Count} mod(s)");
}
);
c.WriteLine($"{content.Mods.Count} mod(s)");
});
console.Register("lang", "lang [code] — show or switch the UI language", (c, args) =>
{
if (args.Length == 0)
console.Register(
"lang",
"lang [code] — show or switch the UI language",
(c, args) =>
{
if (args.Length == 0)
{
c.WriteLine(
$"language: {content.Languages.CurrentLanguage} "
+ $"(available: {string.Join(", ", content.Languages.AvailableLanguages)})"
);
return;
}
c.WriteLine(
$"language: {content.Languages.CurrentLanguage} " +
$"(available: {string.Join(", ", content.Languages.AvailableLanguages)})");
return;
content.Languages.SetLanguage(args[0])
? $"language set to {content.Languages.CurrentLanguage}"
: $"unknown language '{args[0]}'; available: {string.Join(", ", content.Languages.AvailableLanguages)}"
);
}
);
c.WriteLine(content.Languages.SetLanguage(args[0])
? $"language set to {content.Languages.CurrentLanguage}"
: $"unknown language '{args[0]}'; available: {string.Join(", ", content.Languages.AvailableLanguages)}");
});
console.Register("defs", "defs [type] — list def types or defs of one type", (c, args) =>
{
if (args.Length == 0)
console.Register(
"defs",
"defs [type] — list def types or defs of one type",
(c, args) =>
{
foreach (var typeKey in content.Defs.TypeKeys)
if (args.Length == 0)
{
c.WriteLine($" {typeKey}: {content.Defs.NamesOf(typeKey).Count} def(s)");
foreach (var typeKey in content.Defs.TypeKeys)
{
c.WriteLine($" {typeKey}: {content.Defs.NamesOf(typeKey).Count} def(s)");
}
return;
}
return;
}
var names = content.Defs.NamesOf(args[0]);
if (names.Count == 0)
{
c.WriteLine($"no defs of type '{args[0]}'; types: {string.Join(", ", content.Defs.TypeKeys)}");
return;
}
foreach (var name in names)
{
c.WriteLine($" {name}");
}
c.WriteLine($"{names.Count} def(s)");
});
console.Register("atlas", "atlas [name] [filter] — inspect loaded texture atlases", (c, args) =>
{
if (args.Length == 0)
{
foreach (var (name, atlas) in atlases.Loaded.OrderBy(a => a.Key, StringComparer.Ordinal))
var names = content.Defs.NamesOf(args[0]);
if (names.Count == 0)
{
c.WriteLine($" {name}: {atlas.Pages.Count} page(s), {atlas.Regions.Count} region(s)");
c.WriteLine(
$"no defs of type '{args[0]}'; types: {string.Join(", ", content.Defs.TypeKeys)}"
);
return;
}
c.WriteLine($"{atlases.Loaded.Count} atlas(es) loaded");
return;
}
foreach (var name in names)
{
c.WriteLine($" {name}");
}
if (!atlases.Loaded.TryGetValue(args[0], out var found))
c.WriteLine($"{names.Count} def(s)");
}
);
console.Register(
"atlas",
"atlas [name] [filter] — inspect loaded texture atlases",
(c, args) =>
{
c.WriteLine($"atlas '{args[0]}' is not loaded; loaded: {string.Join(", ", atlases.Loaded.Keys)}");
return;
}
if (args.Length == 0)
{
foreach (
var (name, atlas) in atlases.Loaded.OrderBy(
a => a.Key,
StringComparer.Ordinal
)
)
{
c.WriteLine(
$" {name}: {atlas.Pages.Count} page(s), {atlas.Regions.Count} region(s)"
);
}
var filter = args.Length > 1 ? args[1] : null;
var keys = found.Regions.Keys
.Where(k => filter is null || k.Contains(filter, StringComparison.OrdinalIgnoreCase))
.Order(StringComparer.Ordinal)
.ToList();
foreach (var key in keys.Take(25))
{
c.WriteLine($" {key}");
}
c.WriteLine($"{atlases.Loaded.Count} atlas(es) loaded");
return;
}
c.WriteLine($"{keys.Count} region(s){(keys.Count > 25 ? ", showing first 25" : "")}");
});
if (!atlases.Loaded.TryGetValue(args[0], out var found))
{
c.WriteLine(
$"atlas '{args[0]}' is not loaded; loaded: {string.Join(", ", atlases.Loaded.Keys)}"
);
return;
}
var filter = args.Length > 1 ? args[1] : null;
var keys = found
.Regions.Keys.Where(k =>
filter is null || k.Contains(filter, StringComparison.OrdinalIgnoreCase)
)
.Order(StringComparer.Ordinal)
.ToList();
foreach (var key in keys.Take(25))
{
c.WriteLine($" {key}");
}
c.WriteLine(
$"{keys.Count} region(s){(keys.Count > 25 ? ", showing first 25" : "")}"
);
}
);
}
}
+58 -26
View File
@@ -26,7 +26,12 @@ public sealed class TerrainScene : Scene
private const int CellSize = 24;
private const int Population = 14;
private static readonly RectF TerrainBounds = new(0f, 0f, TerrainWidth * CellSize, TerrainHeight * CellSize);
private static readonly RectF TerrainBounds = new(
0f,
0f,
TerrainWidth * CellSize,
TerrainHeight * CellSize
);
private const uint AnimalLayer = 0b01;
private const uint ObstacleLayer = 0b10;
@@ -42,7 +47,9 @@ public sealed class TerrainScene : Scene
var atlases = Context.Services.Get<ModAtlases>();
var device = Context.GraphicsDevice;
var input = this.UseInput();
var renderer = this.UseRenderer2D(new Renderer2DOptions { VirtualResolution = new Point(1280, 720) });
var renderer = this.UseRenderer2D(
new Renderer2DOptions { VirtualResolution = new Point(1280, 720) }
);
GameLayers.EnsureRegistered(renderer);
this.UseTilemaps();
@@ -76,8 +83,17 @@ public sealed class TerrainScene : Scene
}
ScatterSpawner.Spawn(
this, content, atlases, device, terrain, cell, CellSize, random,
obstacleLayer: ObstacleLayer, collidesWith: AnimalLayer);
this,
content,
atlases,
device,
terrain,
cell,
CellSize,
random,
obstacleLayer: ObstacleLayer,
collidesWith: AnimalLayer
);
}
}
@@ -98,13 +114,17 @@ public sealed class TerrainScene : Scene
Store.CreateEntity(
new Transform2D(
new Vector2((cell.X + 0.5f) * CellSize, (cell.Y + 0.5f) * CellSize),
scale: new Vector2(CellSize * animal.SizeCells / body.Width)),
scale: new Vector2(CellSize * animal.SizeCells / body.Width)
),
sprite,
new Wander(),
collider);
collider
);
}
var camera = Store.CreateEntity(new Camera(TerrainBounds.Center, zoom: 1.6f, bounds: TerrainBounds));
var camera = Store.CreateEntity(
new Camera(TerrainBounds.Center, zoom: 1.6f, bounds: TerrainBounds)
);
var desktop = this.UseUI();
var hudLabel = new Label { Left = 10, Top = 8 };
@@ -112,34 +132,46 @@ public sealed class TerrainScene : Scene
var console = this.UseDevConsole();
ContentCommands.Register(console, content, atlases);
console.Register("regen", "regen [seed] — regenerate the terrain", (c, args) =>
{
if (Context.Scenes.IsTransitioning)
console.Register(
"regen",
"regen [seed] — regenerate the terrain",
(c, args) =>
{
return;
}
if (Context.Scenes.IsTransitioning)
{
return;
}
var seed = args.Length > 0 ? int.Parse(args[0]) : Random.Shared.Next();
c.WriteLine($"regenerating terrain, seed {seed}");
Context.Scenes.Switch(new TerrainScene(seed), Transition.Fade(0.6f));
});
console.Register("world", "world [seed] — switch to the world scene", (c, args) =>
{
if (Context.Scenes.IsTransitioning)
var seed = args.Length > 0 ? int.Parse(args[0]) : Random.Shared.Next();
c.WriteLine($"regenerating terrain, seed {seed}");
Context.Scenes.Switch(new TerrainScene(seed), Transition.Fade(0.6f));
}
);
console.Register(
"world",
"world [seed] — switch to the world scene",
(c, args) =>
{
return;
}
if (Context.Scenes.IsTransitioning)
{
return;
}
var seed = args.Length > 0 ? int.Parse(args[0]) : _seed;
Context.Scenes.Switch(new WorldScene(seed), Transition.Fade(0.6f));
});
var seed = args.Length > 0 ? int.Parse(args[0]) : _seed;
Context.Scenes.Switch(new WorldScene(seed), Transition.Fade(0.6f));
}
);
UpdateSystems.Add(new WanderSystem(_seed, TerrainBounds));
var collisions = this.UseCollisions(cellSize: CellSize * 2f); // после движения
UpdateSystems.Add(new SeparationSystem(collisions));
UpdateSystems.Add(new GodCameraSystem(camera, input, console));
UpdateSystems.Add(new HudSystem(Context, content.Languages, hudLabel, "hud.terrain", _seed, Population));
UpdateSystems.Add(
new HudSystem(Context, content.Languages, hudLabel, "hud.terrain", _seed, Population)
);
Log.Info($"Terrain generated: seed {_seed}, {TerrainWidth}x{TerrainHeight} cells, {Population} animals");
Log.Info(
$"Terrain generated: seed {_seed}, {TerrainWidth}x{TerrainHeight} cells, {Population} animals"
);
}
}
+100 -27
View File
@@ -24,7 +24,12 @@ public sealed class WorldScene : Scene
public const int CellSize = 16;
private const int Population = 80;
private static readonly RectF WorldBounds = new(0f, 0f, WorldWidth * CellSize, WorldHeight * CellSize);
private static readonly RectF WorldBounds = new(
0f,
0f,
WorldWidth * CellSize,
WorldHeight * CellSize
);
private readonly int _seed;
@@ -37,7 +42,9 @@ public sealed class WorldScene : Scene
var atlases = Context.Services.Get<ModAtlases>();
var device = Context.GraphicsDevice;
var input = this.UseInput();
var renderer = this.UseRenderer2D(new Renderer2DOptions { VirtualResolution = new Point(1280, 720) });
var renderer = this.UseRenderer2D(
new Renderer2DOptions { VirtualResolution = new Point(1280, 720) }
);
GameLayers.EnsureRegistered(renderer);
var white = new Texture2DRegion(assets.Load(GameAssets.Textures.White));
@@ -54,7 +61,8 @@ public sealed class WorldScene : Scene
var terrain = content.Terrains.Classify(heights[x, y]);
Store.CreateEntity(
Transform2D.At(new Vector2(x * CellSize, y * CellSize)),
new Sprite(white) { Color = terrain.Tint });
new Sprite(white) { Color = terrain.Tint }
);
var cell = new Point(x, y);
if (terrain.IsLand)
@@ -62,7 +70,16 @@ public sealed class WorldScene : Scene
landCells.Add(cell);
}
ScatterSpawner.Spawn(this, content, atlases, device, terrain, cell, CellSize, random);
ScatterSpawner.Spawn(
this,
content,
atlases,
device,
terrain,
cell,
CellSize,
random
);
}
}
@@ -78,12 +95,19 @@ public sealed class WorldScene : Scene
Store.CreateEntity(
new Transform2D(
new Vector2((cell.X + 0.5f) * CellSize, (cell.Y + 0.5f) * CellSize),
scale: new Vector2(CellSize * being.SizeCells / body.Width)),
scale: new Vector2(CellSize * being.SizeCells / body.Width)
),
sprite,
new Wander());
new Wander(),
// Начальная энергия и фаза решения разбросаны сидом — жители не отдыхают синхронно.
new PawnNeeds { Energy = 0.5f + random.NextSingle() * 0.5f },
new PawnBrain { Action = PawnAction.Wander, DecideIn = random.NextSingle() * 0.75f }
);
}
var camera = Store.CreateEntity(new Camera(WorldBounds.Center, zoom: 1f, bounds: WorldBounds));
var camera = Store.CreateEntity(
new Camera(WorldBounds.Center, zoom: 1f, bounds: WorldBounds)
);
// HUD и консоль.
var desktop = this.UseUI();
@@ -92,34 +116,83 @@ public sealed class WorldScene : Scene
var console = this.UseDevConsole();
ContentCommands.Register(console, content, atlases);
console.Register("regen", "regen [seed] — regenerate the world", (c, args) =>
{
if (Context.Scenes.IsTransitioning)
console.Register(
"regen",
"regen [seed] — regenerate the world",
(c, args) =>
{
return;
if (Context.Scenes.IsTransitioning)
{
return;
}
var seed = args.Length > 0 ? int.Parse(args[0]) : Random.Shared.Next();
c.WriteLine($"regenerating world, seed {seed}");
Context.Scenes.Switch(new WorldScene(seed), Transition.Fade(0.6f));
}
);
var seed = args.Length > 0 ? int.Parse(args[0]) : Random.Shared.Next();
c.WriteLine($"regenerating world, seed {seed}");
Context.Scenes.Switch(new WorldScene(seed), Transition.Fade(0.6f));
});
console.Register("terrain", "terrain [seed] — switch to the tile terrain scene", (c, args) =>
{
if (Context.Scenes.IsTransitioning)
console.Register(
"terrain",
"terrain [seed] — switch to the tile terrain scene",
(c, args) =>
{
return;
if (Context.Scenes.IsTransitioning)
{
return;
}
var seed = args.Length > 0 ? int.Parse(args[0]) : _seed;
c.WriteLine($"switching to terrain scene, seed {seed}");
Context.Scenes.Switch(new TerrainScene(seed), Transition.Fade(0.6f));
}
);
var seed = args.Length > 0 ? int.Parse(args[0]) : _seed;
c.WriteLine($"switching to terrain scene, seed {seed}");
Context.Scenes.Switch(new TerrainScene(seed), Transition.Fade(0.6f));
});
var decisions = new PawnDecisionSystem();
UpdateSystems.Add(decisions);
UpdateSystems.Add(new WanderSystem(_seed, WorldBounds));
UpdateSystems.Add(new PawnNeedsSystem());
UpdateSystems.Add(new PawnAppearanceSystem());
UpdateSystems.Add(new GodCameraSystem(camera, input, console));
UpdateSystems.Add(new HudSystem(Context, content.Languages, hudLabel, "hud.world", _seed, Population));
UpdateSystems.Add(
new HudSystem(Context, content.Languages, hudLabel, "hud.world", _seed, Population)
);
Log.Info($"World generated: seed {_seed}, {WorldWidth}x{WorldHeight} cells, population {Population}");
console.Register(
"ai",
"ai [energy] — utility scores at the given energy (0..1), plus the rest/wander split",
(c, args) =>
{
var energy =
args.Length > 0 && float.TryParse(args[0], out var e)
? Math.Clamp(e, 0f, 1f)
: 0.3f;
c.WriteLine($"utility scores at energy {energy:0.00}:");
foreach (var (name, score) in decisions.Inspect(energy))
{
c.WriteLine($" {name, -8} {score:0.000}");
}
var resting = 0;
var total = 0;
Store
.Query<PawnBrain>()
.ForEachEntity(
(ref PawnBrain brain, Entity _) =>
{
total++;
if (brain.Action == PawnAction.Rest)
{
resting++;
}
}
);
c.WriteLine($"population: {resting} resting / {total - resting} wandering");
}
);
Log.Info(
$"World generated: seed {_seed}, {WorldWidth}x{WorldHeight} cells, population {Population}"
);
}
}
+161
View File
@@ -0,0 +1,161 @@
using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems;
using Microsoft.Xna.Framework;
using MrGameEng.AI;
using MrGameEng.Graphics;
namespace LittleSim.Sim;
/// <summary>Что житель делает прямо сейчас — результат utility-выбора.</summary>
public enum PawnAction
{
/// <summary>Бродит по миру, тратя энергию.</summary>
Wander,
/// <summary>Стоит и восстанавливает энергию.</summary>
Rest,
}
/// <summary>
/// Потребности жителя. Пока одна — энергия (0 — без сил, 1 — полон сил); со временем
/// расходуется при блуждании и восстанавливается на отдыхе. Голод/жажда добавятся так же.
/// </summary>
public struct PawnNeeds : IComponent
{
/// <summary>Запас сил в диапазоне [0, 1].</summary>
public float Energy;
}
/// <summary>
/// «Мозг» жителя: выбранное действие и таймер до следующего пересмотра решения.
/// Решение принимает <see cref="PawnDecisionSystem"/> через движковый <see cref="UtilityAi{TContext}"/>.
/// </summary>
public struct PawnBrain : IComponent
{
/// <summary>Текущее действие.</summary>
public PawnAction Action;
/// <summary>Секунды до следующего пересмотра решения — не пересчитываем выбор каждый кадр.</summary>
public float DecideIn;
}
/// <summary>Снимок состояния жителя, который читают соображения utility-выбора.</summary>
public readonly record struct PawnContext(float Energy);
/// <summary>
/// Utility-выбор действия жителя на движковом <see cref="UtilityAi{TContext}"/>. Reasoner строится
/// один раз; каждый житель пересматривает решение раз в <see cref="ReevaluateInterval"/> секунд, а
/// не каждый кадр (энергия меняется медленно — частить незачем, и решение не дёргается на границе).
/// Выбор детерминирован: при равенстве очков побеждает первое действие.
/// </summary>
public sealed class PawnDecisionSystem : QuerySystem<PawnNeeds, PawnBrain>
{
private const float ReevaluateInterval = 0.75f;
private readonly UtilityAi<PawnContext> _brain = new(
// Отдых тянет к себе только когда сил мало: (1 − energy)^3 быстро спадает к нулю выше ~0.3.
new UtilityAction<PawnContext>(
"rest",
new Consideration<PawnContext>(
"tired",
c => c.Energy,
curve: ResponseCurve.Polynomial(exponent: 3f, slope: -1f, xShift: 1f)
)
),
// Блуждание — поведение по умолчанию: тем привлекательнее, чем больше сил.
new UtilityAction<PawnContext>(
"wander",
new Consideration<PawnContext>("rested", c => c.Energy)
)
);
protected override void OnUpdate()
{
var delta = Tick.deltaTime;
foreach (var (needs, brains, _) in Query.Chunks)
{
var n = needs.Span;
var b = brains.Span;
for (var i = 0; i < n.Length; i++)
{
ref var brain = ref b[i];
brain.DecideIn -= delta;
if (brain.DecideIn > 0f)
{
continue;
}
brain.DecideIn = ReevaluateInterval;
var choice = _brain.Select(new PawnContext(n[i].Energy));
brain.Action = choice?.Name == "rest" ? PawnAction.Rest : PawnAction.Wander;
}
}
}
/// <summary>Очки последнего вычисленного выбора, по порядку действий — для дебаг-команды.</summary>
public (string Name, float Score)[] Inspect(float energy)
{
_brain.Select(new PawnContext(energy));
var scores = _brain.LastScores;
var result = new (string, float)[_brain.Actions.Count];
for (var i = 0; i < result.Length; i++)
{
result[i] = (_brain.Actions[i].Name, scores[i]);
}
return result;
}
}
/// <summary>
/// Презентация состояния ИИ: уставший житель темнеет (альфа падает с энергией). Система читает
/// симуляцию и пишет только в <see cref="Sprite"/> — никаких draw-вызовов, граница sim/presentation цела.
/// </summary>
public sealed class PawnAppearanceSystem : QuerySystem<PawnNeeds, Sprite>
{
private const float MinBrightness = 0.45f;
protected override void OnUpdate()
{
foreach (var (needs, sprites, _) in Query.Chunks)
{
var n = needs.Span;
var s = sprites.Span;
for (var i = 0; i < n.Length; i++)
{
var brightness =
MinBrightness + (1f - MinBrightness) * Math.Clamp(n[i].Energy, 0f, 1f);
s[i].Color = Color.White * brightness;
}
}
}
}
/// <summary>
/// Расход и восстановление энергии по текущему действию: блуждающий устаёт, отдыхающий
/// восстанавливается. Скорости подобраны так, что отдых короткий, а блуждание долгое.
/// </summary>
public sealed class PawnNeedsSystem : QuerySystem<PawnNeeds, PawnBrain>
{
private const float DrainPerSecond = 0.04f;
private const float RegenPerSecond = 0.2f;
protected override void OnUpdate()
{
var delta = Tick.deltaTime;
foreach (var (needs, brains, _) in Query.Chunks)
{
var n = needs.Span;
var b = brains.Span;
for (var i = 0; i < n.Length; i++)
{
ref var energy = ref n[i].Energy;
energy +=
b[i].Action == PawnAction.Rest
? RegenPerSecond * delta
: -DrainPerSecond * delta;
energy = Math.Clamp(energy, 0f, 1f);
}
}
}
}
+35 -12
View File
@@ -16,9 +16,11 @@ public struct Wander : IComponent
/// <summary>
/// Блуждание жителей. Один Random на систему, сид от мира — при одинаковом сиде
/// и порядке обновления симуляция воспроизводима.
/// и порядке обновления симуляция воспроизводима. Отдыхающие жители (см.
/// <see cref="PawnDecisionSystem"/>) стоят на месте — таймер смены направления тоже замирает.
/// </summary>
public sealed class WanderSystem(int seed, RectF worldBounds) : QuerySystem<Transform2D, Wander>
public sealed class WanderSystem(int seed, RectF worldBounds)
: QuerySystem<Transform2D, Wander, PawnBrain>
{
private const float Speed = 22f;
@@ -27,12 +29,18 @@ public sealed class WanderSystem(int seed, RectF worldBounds) : QuerySystem<Tran
protected override void OnUpdate()
{
var delta = Tick.deltaTime;
foreach (var (transforms, wanders, _) in Query.Chunks)
foreach (var (transforms, wanders, brains, _) in Query.Chunks)
{
var t = transforms.Span;
var w = wanders.Span;
var b = brains.Span;
for (var i = 0; i < t.Length; i++)
{
if (b[i].Action == PawnAction.Rest)
{
continue;
}
ref var wander = ref w[i];
wander.ChangeIn -= delta;
if (wander.ChangeIn <= 0f)
@@ -65,8 +73,9 @@ public sealed class SeparationSystem(MrGameEng.Collisions.CollisionWorld world)
{
ref var ta = ref pair.A.GetComponent<Transform2D>();
ref var tb = ref pair.B.GetComponent<Transform2D>();
var sum = pair.A.GetComponent<MrGameEng.Collisions.Collider>().Radius +
pair.B.GetComponent<MrGameEng.Collisions.Collider>().Radius;
var sum =
pair.A.GetComponent<MrGameEng.Collisions.Collider>().Radius
+ pair.B.GetComponent<MrGameEng.Collisions.Collider>().Radius;
var delta = tb.Position - ta.Position;
var distance = delta.Length();
@@ -103,7 +112,10 @@ public sealed class SeparationSystem(MrGameEng.Collisions.CollisionWorld world)
/// <summary>Камера бога: WASD/стрелки — панорамирование, колесо — зум. Молчит, пока открыта консоль.</summary>
public sealed class GodCameraSystem(
Entity cameraEntity, InputManager input, MrGameEng.DevConsole.DevConsole console) : BaseSystem
Entity cameraEntity,
InputManager input,
MrGameEng.DevConsole.DevConsole console
) : BaseSystem
{
protected override void OnUpdateGroup()
{
@@ -116,7 +128,8 @@ public sealed class GodCameraSystem(
var pan = new Vector2(
Axis(input, Keys.A, Keys.Left, Keys.D, Keys.Right),
Axis(input, Keys.W, Keys.Up, Keys.S, Keys.Down));
Axis(input, Keys.W, Keys.Up, Keys.S, Keys.Down)
);
if (pan != Vector2.Zero)
{
pan.Normalize();
@@ -126,9 +139,15 @@ public sealed class GodCameraSystem(
camera.Zoom = Math.Clamp(camera.Zoom * (1f + input.WheelDelta * 0.001f), 0.4f, 8f);
}
private static float Axis(InputManager input, Keys negativeA, Keys negativeB, Keys positiveA, Keys positiveB) =>
(input.IsKeyDown(positiveA) || input.IsKeyDown(positiveB) ? 1f : 0f) -
(input.IsKeyDown(negativeA) || input.IsKeyDown(negativeB) ? 1f : 0f);
private static float Axis(
InputManager input,
Keys negativeA,
Keys negativeB,
Keys positiveA,
Keys positiveB
) =>
(input.IsKeyDown(positiveA) || input.IsKeyDown(positiveB) ? 1f : 0f)
- (input.IsKeyDown(negativeA) || input.IsKeyDown(negativeB) ? 1f : 0f);
}
/// <summary>
@@ -141,7 +160,8 @@ public sealed class HudSystem(
Myra.Graphics2D.UI.Label label,
string titleKey,
int seed,
int population) : BaseSystem
int population
) : BaseSystem
{
private float _accumulated;
private int _frames;
@@ -158,6 +178,9 @@ public sealed class HudSystem(
var fps = (int)MathF.Round(_frames / _accumulated);
_accumulated = 0f;
_frames = 0;
label.Text = languages.Format(titleKey, seed, population, fps) + "\n" + languages.Get("hud.controls");
label.Text =
languages.Format(titleKey, seed, population, fps)
+ "\n"
+ languages.Get("hud.controls");
}
}