diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c295009 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "editor.defaultFormatter": "csharpier.csharpier-vscode", + "editor.formatOnPaste": true, + "editor.formatOnSave": true +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index d3db751..d3dffcb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/LittleSim.sln b/LittleSim.sln index 69ee0aa..a3c3d1f 100644 --- a/LittleSim.sln +++ b/LittleSim.sln @@ -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 diff --git a/docs/roadmap.md b/docs/roadmap.md index 2f33d27..cc039ba 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -9,8 +9,8 @@ - [x] Жители-заглушки (блуждание), камера бога, HUD, консоль (`regen`) ## Этап 1 — Жители как агенты -- [ ] Потребности: голод, усталость (тикают со временем) -- [ ] Utility-выбор действия: искать еду / отдыхать / бродить +- [~] Потребности: голод, усталость (тикают со временем) — усталость/энергия готова (`PawnNeeds`), голод впереди +- [~] Utility-выбор действия: искать еду / отдыхать / бродить — каркас на движке (`MrGameEng.AI`), выбор «отдыхать/бродить» работает; «искать еду» впереди - [ ] Еда на траве/в лесу: растёт, собирается, истощается - [ ] Смерть от голода; визуальное состояние жителя (цвет/прозрачность) - [ ] Консоль: `spawn `, `feed`, `starve` для тестов diff --git a/src/LittleSim/Content/GameContent.cs b/src/LittleSim/Content/GameContent.cs index 407f723..fcde94d 100644 --- a/src/LittleSim/Content/GameContent.cs +++ b/src/LittleSim/Content/GameContent.cs @@ -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 /// 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()), atlasCacheDirectory); + return new GameContent( + mods, + defs, + languages, + new TerrainSet(defs.All()), + atlasCacheDirectory + ); } } diff --git a/src/LittleSim/Content/ModAtlases.cs b/src/LittleSim/Content/ModAtlases.cs index ccf912b..e0d7946 100644 --- a/src/LittleSim/Content/ModAtlases.cs +++ b/src/LittleSim/Content/ModAtlases.cs @@ -14,7 +14,9 @@ public sealed class ModAtlases : IDisposable public const int GroupDepth = 2; private readonly string _directory; - private readonly Dictionary _loaded = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _loaded = new( + StringComparer.OrdinalIgnoreCase + ); /// Создаёт реестр над кэш-директорией атласов. public ModAtlases(string directory) => _directory = directory; diff --git a/src/LittleSim/Content/TerrainSet.cs b/src/LittleSim/Content/TerrainSet.cs index fa07535..040ca52 100644 --- a/src/LittleSim/Content/TerrainSet.cs +++ b/src/LittleSim/Content/TerrainSet.cs @@ -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(); diff --git a/src/LittleSim/LittleSim.csproj b/src/LittleSim/LittleSim.csproj index 0c910ec..6852443 100644 --- a/src/LittleSim/LittleSim.csproj +++ b/src/LittleSim/LittleSim.csproj @@ -1,5 +1,4 @@ - WinExe net8.0 @@ -14,17 +13,20 @@ + - + - diff --git a/src/LittleSim/Program.cs b/src/LittleSim/Program.cs index 311c12c..c5714f6 100644 --- a/src/LittleSim/Program.cs +++ b/src/LittleSim/Program.cs @@ -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)); diff --git a/src/LittleSim/Scenes/ContentHelpers.cs b/src/LittleSim/Scenes/ContentHelpers.cs index 0d38413..74da80b 100644 --- a/src/LittleSim/Scenes/ContentHelpers.cs +++ b/src/LittleSim/Scenes/ContentHelpers.cs @@ -17,9 +17,17 @@ internal static class ScatterSpawner /// когда заданы слои коллизий. /// 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 /// Регистрирует (или перерегистрирует) команды контента на общей консоли. 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" : "")}" + ); + } + ); } } diff --git a/src/LittleSim/Scenes/TerrainScene.cs b/src/LittleSim/Scenes/TerrainScene.cs index efab833..20c4c18 100644 --- a/src/LittleSim/Scenes/TerrainScene.cs +++ b/src/LittleSim/Scenes/TerrainScene.cs @@ -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(); 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" + ); } } diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs index 523f2eb..8248f9e 100644 --- a/src/LittleSim/Scenes/WorldScene.cs +++ b/src/LittleSim/Scenes/WorldScene.cs @@ -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(); 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() + .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}" + ); } } diff --git a/src/LittleSim/Sim/PawnBrain.cs b/src/LittleSim/Sim/PawnBrain.cs new file mode 100644 index 0000000..95ec032 --- /dev/null +++ b/src/LittleSim/Sim/PawnBrain.cs @@ -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; + +/// Что житель делает прямо сейчас — результат utility-выбора. +public enum PawnAction +{ + /// Бродит по миру, тратя энергию. + Wander, + + /// Стоит и восстанавливает энергию. + Rest, +} + +/// +/// Потребности жителя. Пока одна — энергия (0 — без сил, 1 — полон сил); со временем +/// расходуется при блуждании и восстанавливается на отдыхе. Голод/жажда добавятся так же. +/// +public struct PawnNeeds : IComponent +{ + /// Запас сил в диапазоне [0, 1]. + public float Energy; +} + +/// +/// «Мозг» жителя: выбранное действие и таймер до следующего пересмотра решения. +/// Решение принимает через движковый . +/// +public struct PawnBrain : IComponent +{ + /// Текущее действие. + public PawnAction Action; + + /// Секунды до следующего пересмотра решения — не пересчитываем выбор каждый кадр. + public float DecideIn; +} + +/// Снимок состояния жителя, который читают соображения utility-выбора. +public readonly record struct PawnContext(float Energy); + +/// +/// Utility-выбор действия жителя на движковом . Reasoner строится +/// один раз; каждый житель пересматривает решение раз в секунд, а +/// не каждый кадр (энергия меняется медленно — частить незачем, и решение не дёргается на границе). +/// Выбор детерминирован: при равенстве очков побеждает первое действие. +/// +public sealed class PawnDecisionSystem : QuerySystem +{ + private const float ReevaluateInterval = 0.75f; + + private readonly UtilityAi _brain = new( + // Отдых тянет к себе только когда сил мало: (1 − energy)^3 быстро спадает к нулю выше ~0.3. + new UtilityAction( + "rest", + new Consideration( + "tired", + c => c.Energy, + curve: ResponseCurve.Polynomial(exponent: 3f, slope: -1f, xShift: 1f) + ) + ), + // Блуждание — поведение по умолчанию: тем привлекательнее, чем больше сил. + new UtilityAction( + "wander", + new Consideration("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; + } + } + } + + /// Очки последнего вычисленного выбора, по порядку действий — для дебаг-команды. + 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; + } +} + +/// +/// Презентация состояния ИИ: уставший житель темнеет (альфа падает с энергией). Система читает +/// симуляцию и пишет только в — никаких draw-вызовов, граница sim/presentation цела. +/// +public sealed class PawnAppearanceSystem : QuerySystem +{ + 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; + } + } + } +} + +/// +/// Расход и восстановление энергии по текущему действию: блуждающий устаёт, отдыхающий +/// восстанавливается. Скорости подобраны так, что отдых короткий, а блуждание долгое. +/// +public sealed class PawnNeedsSystem : QuerySystem +{ + 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); + } + } + } +} diff --git a/src/LittleSim/Sim/SimSystems.cs b/src/LittleSim/Sim/SimSystems.cs index df25dfb..ab37d9b 100644 --- a/src/LittleSim/Sim/SimSystems.cs +++ b/src/LittleSim/Sim/SimSystems.cs @@ -16,9 +16,11 @@ public struct Wander : IComponent /// /// Блуждание жителей. Один Random на систему, сид от мира — при одинаковом сиде -/// и порядке обновления симуляция воспроизводима. +/// и порядке обновления симуляция воспроизводима. Отдыхающие жители (см. +/// ) стоят на месте — таймер смены направления тоже замирает. /// -public sealed class WanderSystem(int seed, RectF worldBounds) : QuerySystem +public sealed class WanderSystem(int seed, RectF worldBounds) + : QuerySystem { private const float Speed = 22f; @@ -27,12 +29,18 @@ public sealed class WanderSystem(int seed, RectF worldBounds) : QuerySystem(); ref var tb = ref pair.B.GetComponent(); - var sum = pair.A.GetComponent().Radius + - pair.B.GetComponent().Radius; + var sum = + pair.A.GetComponent().Radius + + pair.B.GetComponent().Radius; var delta = tb.Position - ta.Position; var distance = delta.Length(); @@ -103,7 +112,10 @@ public sealed class SeparationSystem(MrGameEng.Collisions.CollisionWorld world) /// Камера бога: WASD/стрелки — панорамирование, колесо — зум. Молчит, пока открыта консоль. 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); } /// @@ -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"); } }