From ba8e590941c4408cdd2075c2504aed4f6e70420e Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Wed, 17 Jun 2026 08:13:52 +0300 Subject: [PATCH] Refactor game settings and remove unused files - Removed the GameSettingsStore class and its associated methods for loading and saving game settings, streamlining the GameSettings class. - Deleted obsolete files related to world saving, game definitions, network smoothing, and content helpers, which were no longer in use, reducing code complexity and improving maintainability. - Updated the WorldScene class to reflect changes in the GameSettings structure, ensuring compatibility with the new settings management approach. This update enhances the overall codebase by eliminating redundancy and focusing on essential components. --- src/LittleSim.Web/ConnectionStatus.cs | 17 + src/LittleSim.Web/LittleSim.Web.csproj | 1 + src/LittleSim.Web/WorldViewScene.cs | 16 - src/LittleSim/App/GameSettings.cs | 64 - src/LittleSim/App/GameSettingsStore.cs | 65 + src/LittleSim/App/Saves/AnimalSave.cs | 75 + src/LittleSim/App/Saves/CorpseSave.cs | 23 + src/LittleSim/App/Saves/EggSave.cs | 25 + src/LittleSim/App/Saves/HediffSave.cs | 14 + src/LittleSim/App/Saves/PlantSave.cs | 28 + src/LittleSim/App/Saves/SaveStore.cs | 92 + src/LittleSim/App/Saves/ThoughtSave.cs | 11 + src/LittleSim/App/Saves/WorldSave.cs | 64 + src/LittleSim/App/WorldSave.cs | 320 -- src/LittleSim/Content/Defs/AnimalDef.cs | 73 + src/LittleSim/Content/Defs/AnimalStageDef.cs | 28 + src/LittleSim/Content/Defs/BodyDef.cs | 13 + src/LittleSim/Content/Defs/BodyPartDef.cs | 27 + src/LittleSim/Content/Defs/DevToolDef.cs | 21 + src/LittleSim/Content/Defs/GenomeDef.cs | 78 + src/LittleSim/Content/Defs/HediffDef.cs | 39 + src/LittleSim/Content/Defs/NeedDef.cs | 36 + src/LittleSim/Content/Defs/PawnDef.cs | 22 + src/LittleSim/Content/Defs/PlantDef.cs | 31 + .../Content/Defs/PlantGrowthStage.cs | 21 + src/LittleSim/Content/Defs/ProductDef.cs | 17 + src/LittleSim/Content/Defs/ScatterEntry.cs | 11 + src/LittleSim/Content/Defs/SkillDef.cs | 24 + src/LittleSim/Content/Defs/TerrainDef.cs | 37 + src/LittleSim/Content/Defs/ThoughtDef.cs | 18 + src/LittleSim/Content/Defs/WorldPresetDef.cs | 23 + src/LittleSim/Content/GameDefs.cs | 483 --- src/LittleSim/Net/NetLerp.cs | 25 + ...{NetSmoothing.cs => NetSmoothingSystem.cs} | 19 - .../{ContentHelpers.cs => ContentCommands.cs} | 63 - src/LittleSim/Scenes/ScatterSpawner.cs | 74 + src/LittleSim/Scenes/WorldScene.Commands.cs | 669 ++++ src/LittleSim/Scenes/WorldScene.SaveLoad.cs | 463 +++ src/LittleSim/Scenes/WorldScene.Terrain.cs | 226 ++ src/LittleSim/Scenes/WorldScene.Ui.cs | 161 + src/LittleSim/Scenes/WorldScene.cs | 1401 +------ src/LittleSim/Sim/AnimalCapacities.cs | 37 + src/LittleSim/Sim/AnimalOrganism.cs | 759 ---- src/LittleSim/Sim/AnimalSystems.cs | 3208 ----------------- .../Sim/Animals/AnimalActionSystem.cs | 720 ++++ src/LittleSim/Sim/Animals/AnimalActions.cs | 43 + .../Sim/Animals/AnimalAppearanceSystem.cs | 49 + src/LittleSim/Sim/Animals/AnimalBobSystem.cs | 56 + src/LittleSim/Sim/Animals/AnimalBrain.cs | 48 + src/LittleSim/Sim/Animals/AnimalContext.cs | 59 + .../Sim/Animals/AnimalDecisionSystem.cs | 1110 ++++++ .../Sim/Animals/AnimalFacingSystem.cs | 71 + src/LittleSim/Sim/Animals/AnimalGrowth.cs | 18 + .../Sim/Animals/AnimalGrowthSystem.cs | 67 + .../Sim/Animals/AnimalHealthSystem.cs | 219 ++ .../Sim/Animals/AnimalMemorySystem.cs | 57 + src/LittleSim/Sim/Animals/AnimalMoodSystem.cs | 107 + .../Sim/Animals/AnimalMortalitySystem.cs | 67 + src/LittleSim/Sim/Animals/AnimalNeeds.cs | 24 + .../Sim/Animals/AnimalNeedsSystem.cs | 91 + src/LittleSim/Sim/Animals/AnimalOrganism.cs | 34 + src/LittleSim/Sim/Animals/AnimalPhenotype.cs | 134 + .../Sim/Animals/AnimalPregnancySystem.cs | 183 + src/LittleSim/Sim/Animals/AnimalRutSystem.cs | 44 + .../Sim/Animals/AnimalSkillSystem.cs | 49 + src/LittleSim/Sim/Animals/AnimalSkills.cs | 14 + src/LittleSim/Sim/Animals/AnimalThoughts.cs | 22 + src/LittleSim/Sim/Animals/Carrier.cs | 14 + src/LittleSim/Sim/Animals/Corpse.cs | 26 + src/LittleSim/Sim/Animals/CorpseSystem.cs | 88 + src/LittleSim/Sim/Animals/CreatureLog.cs | 18 + src/LittleSim/Sim/Animals/DamageFlash.cs | 14 + .../Sim/Animals/DamageFlashSystem.cs | 45 + src/LittleSim/Sim/Animals/Egg.cs | 24 + src/LittleSim/Sim/Animals/EggSystem.cs | 100 + src/LittleSim/Sim/Animals/GutSeed.cs | 21 + src/LittleSim/Sim/Animals/Health.cs | 10 + src/LittleSim/Sim/Animals/HealthState.cs | 268 ++ src/LittleSim/Sim/Animals/Hediff.cs | 16 + src/LittleSim/Sim/Animals/Memory.cs | 69 + src/LittleSim/Sim/Animals/MemoryEntry.cs | 13 + src/LittleSim/Sim/Animals/MemoryKind.cs | 10 + src/LittleSim/Sim/Animals/Mood.cs | 48 + src/LittleSim/Sim/Animals/PartInstance.cs | 19 + src/LittleSim/Sim/Animals/Pregnant.cs | 21 + .../Sim/Animals/SeedDispersalSystem.cs | 109 + src/LittleSim/Sim/Animals/Skills.cs | 17 + src/LittleSim/Sim/Animals/ThoughtInstance.cs | 11 + src/LittleSim/Sim/CapacityCalc.cs | 34 - src/LittleSim/Sim/EventCategory.cs | 23 + src/LittleSim/Sim/Fruiting.cs | 14 + src/LittleSim/Sim/GodCameraSystem.cs | 71 + src/LittleSim/Sim/HudSystem.cs | 63 + src/LittleSim/Sim/LogEntry.cs | 21 + src/LittleSim/Sim/PawnAction.cs | 17 + src/LittleSim/Sim/PawnAppearanceSystem.cs | 31 + src/LittleSim/Sim/PawnBrain.cs | 141 - src/LittleSim/Sim/PawnContext.cs | 10 + src/LittleSim/Sim/PawnDecisionSystem.cs | 72 + src/LittleSim/Sim/PawnId.cs | 14 + src/LittleSim/Sim/PawnNeeds.cs | 17 + src/LittleSim/Sim/PawnNeedsSystem.cs | 36 + src/LittleSim/Sim/PlantOrganism.cs | 123 - src/LittleSim/Sim/PlantPhenotype.cs | 102 + src/LittleSim/Sim/Selection.cs | 106 - src/LittleSim/Sim/SelectionSystem.cs | 114 + src/LittleSim/Sim/SeparationSystem.cs | 59 + src/LittleSim/Sim/SimSystems.cs | 235 -- src/LittleSim/Sim/TemperatureStress.cs | 15 + src/LittleSim/Sim/Wander.cs | 15 + src/LittleSim/Sim/WanderSystem.cs | 53 + src/LittleSim/Sim/WorldEvents.cs | 30 - src/LittleSim/UI/CallbackSystem.cs | 17 + src/LittleSim/UI/DevSpawner.cs | 42 - src/LittleSim/UI/DevSpawnerSystem.cs | 56 + src/LittleSim/UI/InspectPanel.cs | 59 - src/LittleSim/UI/Menu.cs | 9 - src/LittleSim/UI/ScaledUiExtensions.cs | 20 + src/LittleSim/UI/StatBar.cs | 74 + src/LittleSim/UI/UiScaling.cs | 12 - 120 files changed, 7751 insertions(+), 7123 deletions(-) create mode 100644 src/LittleSim.Web/ConnectionStatus.cs create mode 100644 src/LittleSim/App/GameSettingsStore.cs create mode 100644 src/LittleSim/App/Saves/AnimalSave.cs create mode 100644 src/LittleSim/App/Saves/CorpseSave.cs create mode 100644 src/LittleSim/App/Saves/EggSave.cs create mode 100644 src/LittleSim/App/Saves/HediffSave.cs create mode 100644 src/LittleSim/App/Saves/PlantSave.cs create mode 100644 src/LittleSim/App/Saves/SaveStore.cs create mode 100644 src/LittleSim/App/Saves/ThoughtSave.cs create mode 100644 src/LittleSim/App/Saves/WorldSave.cs delete mode 100644 src/LittleSim/App/WorldSave.cs create mode 100644 src/LittleSim/Content/Defs/AnimalDef.cs create mode 100644 src/LittleSim/Content/Defs/AnimalStageDef.cs create mode 100644 src/LittleSim/Content/Defs/BodyDef.cs create mode 100644 src/LittleSim/Content/Defs/BodyPartDef.cs create mode 100644 src/LittleSim/Content/Defs/DevToolDef.cs create mode 100644 src/LittleSim/Content/Defs/GenomeDef.cs create mode 100644 src/LittleSim/Content/Defs/HediffDef.cs create mode 100644 src/LittleSim/Content/Defs/NeedDef.cs create mode 100644 src/LittleSim/Content/Defs/PawnDef.cs create mode 100644 src/LittleSim/Content/Defs/PlantDef.cs create mode 100644 src/LittleSim/Content/Defs/PlantGrowthStage.cs create mode 100644 src/LittleSim/Content/Defs/ProductDef.cs create mode 100644 src/LittleSim/Content/Defs/ScatterEntry.cs create mode 100644 src/LittleSim/Content/Defs/SkillDef.cs create mode 100644 src/LittleSim/Content/Defs/TerrainDef.cs create mode 100644 src/LittleSim/Content/Defs/ThoughtDef.cs create mode 100644 src/LittleSim/Content/Defs/WorldPresetDef.cs delete mode 100644 src/LittleSim/Content/GameDefs.cs create mode 100644 src/LittleSim/Net/NetLerp.cs rename src/LittleSim/Net/{NetSmoothing.cs => NetSmoothingSystem.cs} (57%) rename src/LittleSim/Scenes/{ContentHelpers.cs => ContentCommands.cs} (58%) create mode 100644 src/LittleSim/Scenes/ScatterSpawner.cs create mode 100644 src/LittleSim/Scenes/WorldScene.Commands.cs create mode 100644 src/LittleSim/Scenes/WorldScene.SaveLoad.cs create mode 100644 src/LittleSim/Scenes/WorldScene.Terrain.cs create mode 100644 src/LittleSim/Scenes/WorldScene.Ui.cs create mode 100644 src/LittleSim/Sim/AnimalCapacities.cs delete mode 100644 src/LittleSim/Sim/AnimalOrganism.cs delete mode 100644 src/LittleSim/Sim/AnimalSystems.cs create mode 100644 src/LittleSim/Sim/Animals/AnimalActionSystem.cs create mode 100644 src/LittleSim/Sim/Animals/AnimalActions.cs create mode 100644 src/LittleSim/Sim/Animals/AnimalAppearanceSystem.cs create mode 100644 src/LittleSim/Sim/Animals/AnimalBobSystem.cs create mode 100644 src/LittleSim/Sim/Animals/AnimalBrain.cs create mode 100644 src/LittleSim/Sim/Animals/AnimalContext.cs create mode 100644 src/LittleSim/Sim/Animals/AnimalDecisionSystem.cs create mode 100644 src/LittleSim/Sim/Animals/AnimalFacingSystem.cs create mode 100644 src/LittleSim/Sim/Animals/AnimalGrowth.cs create mode 100644 src/LittleSim/Sim/Animals/AnimalGrowthSystem.cs create mode 100644 src/LittleSim/Sim/Animals/AnimalHealthSystem.cs create mode 100644 src/LittleSim/Sim/Animals/AnimalMemorySystem.cs create mode 100644 src/LittleSim/Sim/Animals/AnimalMoodSystem.cs create mode 100644 src/LittleSim/Sim/Animals/AnimalMortalitySystem.cs create mode 100644 src/LittleSim/Sim/Animals/AnimalNeeds.cs create mode 100644 src/LittleSim/Sim/Animals/AnimalNeedsSystem.cs create mode 100644 src/LittleSim/Sim/Animals/AnimalOrganism.cs create mode 100644 src/LittleSim/Sim/Animals/AnimalPhenotype.cs create mode 100644 src/LittleSim/Sim/Animals/AnimalPregnancySystem.cs create mode 100644 src/LittleSim/Sim/Animals/AnimalRutSystem.cs create mode 100644 src/LittleSim/Sim/Animals/AnimalSkillSystem.cs create mode 100644 src/LittleSim/Sim/Animals/AnimalSkills.cs create mode 100644 src/LittleSim/Sim/Animals/AnimalThoughts.cs create mode 100644 src/LittleSim/Sim/Animals/Carrier.cs create mode 100644 src/LittleSim/Sim/Animals/Corpse.cs create mode 100644 src/LittleSim/Sim/Animals/CorpseSystem.cs create mode 100644 src/LittleSim/Sim/Animals/CreatureLog.cs create mode 100644 src/LittleSim/Sim/Animals/DamageFlash.cs create mode 100644 src/LittleSim/Sim/Animals/DamageFlashSystem.cs create mode 100644 src/LittleSim/Sim/Animals/Egg.cs create mode 100644 src/LittleSim/Sim/Animals/EggSystem.cs create mode 100644 src/LittleSim/Sim/Animals/GutSeed.cs create mode 100644 src/LittleSim/Sim/Animals/Health.cs create mode 100644 src/LittleSim/Sim/Animals/HealthState.cs create mode 100644 src/LittleSim/Sim/Animals/Hediff.cs create mode 100644 src/LittleSim/Sim/Animals/Memory.cs create mode 100644 src/LittleSim/Sim/Animals/MemoryEntry.cs create mode 100644 src/LittleSim/Sim/Animals/MemoryKind.cs create mode 100644 src/LittleSim/Sim/Animals/Mood.cs create mode 100644 src/LittleSim/Sim/Animals/PartInstance.cs create mode 100644 src/LittleSim/Sim/Animals/Pregnant.cs create mode 100644 src/LittleSim/Sim/Animals/SeedDispersalSystem.cs create mode 100644 src/LittleSim/Sim/Animals/Skills.cs create mode 100644 src/LittleSim/Sim/Animals/ThoughtInstance.cs create mode 100644 src/LittleSim/Sim/EventCategory.cs create mode 100644 src/LittleSim/Sim/Fruiting.cs create mode 100644 src/LittleSim/Sim/GodCameraSystem.cs create mode 100644 src/LittleSim/Sim/HudSystem.cs create mode 100644 src/LittleSim/Sim/LogEntry.cs create mode 100644 src/LittleSim/Sim/PawnAction.cs create mode 100644 src/LittleSim/Sim/PawnAppearanceSystem.cs create mode 100644 src/LittleSim/Sim/PawnContext.cs create mode 100644 src/LittleSim/Sim/PawnDecisionSystem.cs create mode 100644 src/LittleSim/Sim/PawnId.cs create mode 100644 src/LittleSim/Sim/PawnNeeds.cs create mode 100644 src/LittleSim/Sim/PawnNeedsSystem.cs create mode 100644 src/LittleSim/Sim/PlantPhenotype.cs create mode 100644 src/LittleSim/Sim/SelectionSystem.cs create mode 100644 src/LittleSim/Sim/SeparationSystem.cs delete mode 100644 src/LittleSim/Sim/SimSystems.cs create mode 100644 src/LittleSim/Sim/TemperatureStress.cs create mode 100644 src/LittleSim/Sim/Wander.cs create mode 100644 src/LittleSim/Sim/WanderSystem.cs create mode 100644 src/LittleSim/UI/CallbackSystem.cs create mode 100644 src/LittleSim/UI/DevSpawnerSystem.cs create mode 100644 src/LittleSim/UI/ScaledUiExtensions.cs create mode 100644 src/LittleSim/UI/StatBar.cs diff --git a/src/LittleSim.Web/ConnectionStatus.cs b/src/LittleSim.Web/ConnectionStatus.cs new file mode 100644 index 0000000..0e31260 --- /dev/null +++ b/src/LittleSim.Web/ConnectionStatus.cs @@ -0,0 +1,17 @@ +namespace LittleSim.Web; + +/// Состояние соединения веб-клиента — его читает оверлей подключения (Blazor). +public enum ConnectionStatus +{ + /// Сервер ещё не выбран — ждём, пока пользователь нажмёт «Подключиться». + Idle, + + /// Идёт первая попытка подключения. + Connecting, + + /// Соединение установлено, снапшоты приходят. + Connected, + + /// Соединение оборвалось/не удалось — ждём следующей попытки (бэкофф). + Reconnecting, +} diff --git a/src/LittleSim.Web/LittleSim.Web.csproj b/src/LittleSim.Web/LittleSim.Web.csproj index cd01e60..38cc428 100644 --- a/src/LittleSim.Web/LittleSim.Web.csproj +++ b/src/LittleSim.Web/LittleSim.Web.csproj @@ -18,6 +18,7 @@ + diff --git a/src/LittleSim.Web/WorldViewScene.cs b/src/LittleSim.Web/WorldViewScene.cs index 8db2755..4f415b0 100644 --- a/src/LittleSim.Web/WorldViewScene.cs +++ b/src/LittleSim.Web/WorldViewScene.cs @@ -9,22 +9,6 @@ using MrGameEng.Net; namespace LittleSim.Web; -/// Состояние соединения веб-клиента — его читает оверлей подключения (Blazor). -public enum ConnectionStatus -{ - /// Сервер ещё не выбран — ждём, пока пользователь нажмёт «Подключиться». - Idle, - - /// Идёт первая попытка подключения. - Connecting, - - /// Соединение установлено, снапшоты приходят. - Connected, - - /// Соединение оборвалось/не удалось — ждём следующей попытки (бэкофф). - Reconnecting, -} - /// /// Браузерный клиент мира LittleSim: по команде подключается к /// дедикейтед-серверу (LittleSim.Server --listen), применяет дельта-снапшоты в свой diff --git a/src/LittleSim/App/GameSettings.cs b/src/LittleSim/App/GameSettings.cs index 0326c4d..60c1594 100644 --- a/src/LittleSim/App/GameSettings.cs +++ b/src/LittleSim/App/GameSettings.cs @@ -1,8 +1,3 @@ -using System.Text.Json; -using Microsoft.Xna.Framework; -using MrGameEng.Audio; -using MrGameEng.Mods; - namespace LittleSim.App; /// @@ -35,62 +30,3 @@ public sealed class GameSettings /// Режим разработчика: открывает дев-консоль (backtick) и дев-спавнер. По умолчанию включён. public bool DeveloperMode { get; set; } = true; } - -/// Чтение/запись и применение их к движку. -public static class GameSettingsStore -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - WriteIndented = true, - PropertyNameCaseInsensitive = true, - }; - - /// Загружает настройки из или возвращает значения по умолчанию. - public static GameSettings Load() - { - try - { - if (File.Exists(GamePaths.SettingsFile)) - { - return JsonSerializer.Deserialize( - File.ReadAllText(GamePaths.SettingsFile), - JsonOptions - ) ?? new GameSettings(); - } - } - catch (Exception exception) when (exception is IOException or JsonException) - { - // Битый файл настроек не должен ронять запуск — откатываемся к умолчаниям. - } - - return new GameSettings(); - } - - /// Сохраняет настройки в . - public static void Save(GameSettings settings) - { - Directory.CreateDirectory(GamePaths.Root); - File.WriteAllText(GamePaths.SettingsFile, JsonSerializer.Serialize(settings, JsonOptions)); - } - - /// - /// Применяет настройки к движку: язык (), полноэкранный - /// режим/VSync/размер окна () и громкость (). - /// - public static void Apply( - GameSettings settings, - GraphicsDeviceManager graphics, - LanguageManager languages, - AudioManager audio - ) - { - languages.SetLanguage(settings.Language); - audio.MasterVolume = settings.Volume; - - graphics.IsFullScreen = settings.Fullscreen; - graphics.SynchronizeWithVerticalRetrace = settings.VSync; - graphics.PreferredBackBufferWidth = settings.Width; - graphics.PreferredBackBufferHeight = settings.Height; - graphics.ApplyChanges(); - } -} diff --git a/src/LittleSim/App/GameSettingsStore.cs b/src/LittleSim/App/GameSettingsStore.cs new file mode 100644 index 0000000..96e9012 --- /dev/null +++ b/src/LittleSim/App/GameSettingsStore.cs @@ -0,0 +1,65 @@ +using System.Text.Json; +using Microsoft.Xna.Framework; +using MrGameEng.Audio; +using MrGameEng.Mods; + +namespace LittleSim.App; + +/// Чтение/запись и применение их к движку. +public static class GameSettingsStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + }; + + /// Загружает настройки из или возвращает значения по умолчанию. + public static GameSettings Load() + { + try + { + if (File.Exists(GamePaths.SettingsFile)) + { + return JsonSerializer.Deserialize( + File.ReadAllText(GamePaths.SettingsFile), + JsonOptions + ) ?? new GameSettings(); + } + } + catch (Exception exception) when (exception is IOException or JsonException) + { + // Битый файл настроек не должен ронять запуск — откатываемся к умолчаниям. + } + + return new GameSettings(); + } + + /// Сохраняет настройки в . + public static void Save(GameSettings settings) + { + Directory.CreateDirectory(GamePaths.Root); + File.WriteAllText(GamePaths.SettingsFile, JsonSerializer.Serialize(settings, JsonOptions)); + } + + /// + /// Применяет настройки к движку: язык (), полноэкранный + /// режим/VSync/размер окна () и громкость (). + /// + public static void Apply( + GameSettings settings, + GraphicsDeviceManager graphics, + LanguageManager languages, + AudioManager audio + ) + { + languages.SetLanguage(settings.Language); + audio.MasterVolume = settings.Volume; + + graphics.IsFullScreen = settings.Fullscreen; + graphics.SynchronizeWithVerticalRetrace = settings.VSync; + graphics.PreferredBackBufferWidth = settings.Width; + graphics.PreferredBackBufferHeight = settings.Height; + graphics.ApplyChanges(); + } +} diff --git a/src/LittleSim/App/Saves/AnimalSave.cs b/src/LittleSim/App/Saves/AnimalSave.cs new file mode 100644 index 0000000..808d17c --- /dev/null +++ b/src/LittleSim/App/Saves/AnimalSave.cs @@ -0,0 +1,75 @@ +using MrGameEng.Genetics; + +namespace LittleSim.App; + +/// +/// Сериализуемое состояние одного животного (фазы A1–A8): вид, позиция, геном (источник признаков и +/// пола), поколение, стадия/возраст, нужды, здоровье (кровь, HP частей, хедифы), настроение и +/// беременность. Спрайт пересобирается фабрикой из дефа. Чтобы популяция (эволюция/состояние) +/// переживала сейв/лоад, а не пересыпалась заново из сида. +/// +public sealed class AnimalSave +{ + /// Имя дефа вида — по нему берётся индекс/стадии/текстуры/тело. + public string Species { get; set; } = ""; + + /// Позиция X в мировых координатах. + public float X { get; set; } + + /// Позиция Y в мировых координатах. + public float Y { get; set; } + + /// Номер поколения (для popstats/дрейфа). + public int Generation { get; set; } + + /// Текущая стадия роста. + public int Stage { get; set; } + + /// Накопленный возраст в игровых днях. + public float AgeDays { get; set; } + + /// Геном особи: аллели по id генов (переменного состава; несёт и пол через GeneSex). + public Dictionary Genome { get; set; } = new(); + + /// Значения нужд по индексам NeedSet. + public float[] Needs { get; set; } = []; + + /// Накоплено дней истощения. + public float Starve { get; set; } + + /// Уровень крови [0,1]. + public float BloodLevel { get; set; } = 1f; + + /// HP частей тела по порядку анатомии вида (пусто — у вида нет тела). + public float[] PartHp { get; set; } = []; + + /// Активные хедифы (гон/болезни). + public List Hediffs { get; set; } = []; + + /// Текущее настроение [0,1]. + public float Mood { get; set; } + + /// Активно ли настроение (тир 4+). + public bool MoodActive { get; set; } + + /// Активные событийные мысли. + public List Thoughts { get; set; } = []; + + /// Беременна ли самка. + public bool Pregnant { get; set; } + + /// Геном отца (если беременна) — «слепок» на момент зачатия. + public Dictionary FatherGenome { get; set; } = new(); + + /// Остаток срока вынашивания (игровых дней). + public float DueInDays { get; set; } + + /// Размер помёта. + public int Litter { get; set; } + + /// Уровни навыков [0..1] по индексам SkillSet. + public float[] SkillLevels { get; set; } = []; + + /// Страсть к навыкам (0/1/2) по индексам SkillSet. + public int[] SkillPassions { get; set; } = []; +} diff --git a/src/LittleSim/App/Saves/CorpseSave.cs b/src/LittleSim/App/Saves/CorpseSave.cs new file mode 100644 index 0000000..e3b3142 --- /dev/null +++ b/src/LittleSim/App/Saves/CorpseSave.cs @@ -0,0 +1,23 @@ +namespace LittleSim.App; + +/// Сериализуемый труп (фаза A5): вид (для спрайта), позиция, таймер/стадия разложения, мясо. +public sealed class CorpseSave +{ + /// Имя дефа вида — по нему берётся спрайт трупа. + public string Species { get; set; } = ""; + + /// Позиция X в мировых координатах. + public float X { get; set; } + + /// Позиция Y в мировых координатах. + public float Y { get; set; } + + /// Накоплено игровых дней разложения. + public float RotDays { get; set; } + + /// Размер тела (для масштаба спрайта). + public float BodySize { get; set; } + + /// Доступное мясо. + public float Meat { get; set; } +} diff --git a/src/LittleSim/App/Saves/EggSave.cs b/src/LittleSim/App/Saves/EggSave.cs new file mode 100644 index 0000000..117201e --- /dev/null +++ b/src/LittleSim/App/Saves/EggSave.cs @@ -0,0 +1,25 @@ +using MrGameEng.Genetics; + +namespace LittleSim.App; + +/// Сериализуемое яйцо (тип рождения «яйцекладка»): вид, позиция, геном детёныша, поколение, инкубация. +public sealed class EggSave +{ + /// Имя дефа вида — по нему берётся индекс/спрайт/тело при вылуплении. + public string Species { get; set; } = ""; + + /// Позиция X в мировых координатах. + public float X { get; set; } + + /// Позиция Y в мировых координатах. + public float Y { get; set; } + + /// Поколение будущего детёныша. + public int Generation { get; set; } + + /// Остаток инкубации в игровых днях. + public float IncubateDays { get; set; } + + /// Геном будущего детёныша (уже скрещён на момент кладки). + public Dictionary Genome { get; set; } = new(); +} diff --git a/src/LittleSim/App/Saves/HediffSave.cs b/src/LittleSim/App/Saves/HediffSave.cs new file mode 100644 index 0000000..5da9a96 --- /dev/null +++ b/src/LittleSim/App/Saves/HediffSave.cs @@ -0,0 +1,14 @@ +namespace LittleSim.App; + +/// Сериализуемый хедиф особи (фаза A6): деф (по имени), тяжесть и набранный иммунитет. +public sealed class HediffSave +{ + /// Имя дефа хедифа (гон/болезнь). + public string DefName { get; set; } = ""; + + /// Текущая тяжесть. + public float Severity { get; set; } + + /// Набранный иммунитет [0,1]. + public float Immunity { get; set; } +} diff --git a/src/LittleSim/App/Saves/PlantSave.cs b/src/LittleSim/App/Saves/PlantSave.cs new file mode 100644 index 0000000..0a024e5 --- /dev/null +++ b/src/LittleSim/App/Saves/PlantSave.cs @@ -0,0 +1,28 @@ +using MrGameEng.Genetics; + +namespace LittleSim.App; + +/// Сериализуемое состояние одного растения: вид (деф), позиция, возраст, стадия, почва и геном. +public sealed class PlantSave +{ + /// Имя дефа вида — по нему при загрузке берётся индекс/стадии/текстуры. + public string Species { get; set; } = ""; + + /// Позиция X в мировых координатах. + public float X { get; set; } + + /// Позиция Y в мировых координатах. + public float Y { get; set; } + + /// Накопленный возраст в игровых днях. + public float AgeDays { get; set; } + + /// Текущая стадия роста. + public int Stage { get; set; } + + /// Плодородность клетки. + public float CellFertility { get; set; } + + /// Геном особи: аллели по id генов (переменного состава). + public Dictionary Genome { get; set; } = new(); +} diff --git a/src/LittleSim/App/Saves/SaveStore.cs b/src/LittleSim/App/Saves/SaveStore.cs new file mode 100644 index 0000000..e691bf4 --- /dev/null +++ b/src/LittleSim/App/Saves/SaveStore.cs @@ -0,0 +1,92 @@ +using System.Text.Json; + +namespace LittleSim.App; + +/// Список/чтение/запись/удаление файлов сохранений Saves/*.json. +public sealed class SaveStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + IncludeFields = true, // на случай публичных полей в сохраняемых sim-структурах + }; + + private readonly string _directory; + + /// Создаёт хранилище поверх каталога (по умолчанию ). + public SaveStore(string? directory = null) => + _directory = directory ?? GamePaths.SavesDirectory; + + /// Запись сохранения: один файл на имя мира (повторное сохранение перезаписывает). + public readonly record struct Entry(string FileName, WorldSave Save); + + /// Все сохранения, новые сверху. Битые файлы пропускаются. + public IReadOnlyList List() + { + if (!Directory.Exists(_directory)) + { + return []; + } + + var entries = new List(); + foreach (var file in Directory.EnumerateFiles(_directory, "*.json")) + { + try + { + var save = JsonSerializer.Deserialize( + File.ReadAllText(file), + JsonOptions + ); + // Сейв из будущей (несовместимой) версии прочитать корректно нельзя — пропускаем, + // чтобы не выдать пользователю молча битый мир. + if (save is not null && save.Version <= WorldSave.CurrentVersion) + { + entries.Add(new Entry(Path.GetFileName(file), save)); + } + } + catch (Exception exception) when (exception is IOException or JsonException) + { + // Битый файл сохранения не должен ломать список — пропускаем. + } + } + + entries.Sort((a, b) => b.Save.SavedUtc.CompareTo(a.Save.SavedUtc)); + return entries; + } + + /// Пишет сохранение в файл по имени мира и возвращает имя файла. + public string Write(WorldSave save) + { + Directory.CreateDirectory(_directory); + var fileName = Sanitize(save.Name) + ".json"; + File.WriteAllText( + Path.Combine(_directory, fileName), + JsonSerializer.Serialize(save, JsonOptions) + ); + return fileName; + } + + /// Удаляет файл сохранения по имени. + public void Delete(string fileName) + { + var path = Path.Combine(_directory, fileName); + if (File.Exists(path)) + { + File.Delete(path); + } + } + + // Набор недопустимых для имени файла символов — кэшируем один раз (раньше массив аллоцировался + // и линейно сканировался на КАЖДЫЙ символ имени). + private static readonly HashSet InvalidFileNameChars = new( + Path.GetInvalidFileNameChars() + ); + + private static string Sanitize(string name) + { + var trimmed = name.Trim(); + var safe = string.Concat(trimmed.Select(c => InvalidFileNameChars.Contains(c) ? '_' : c)); + return string.IsNullOrEmpty(safe) ? "world" : safe; + } +} diff --git a/src/LittleSim/App/Saves/ThoughtSave.cs b/src/LittleSim/App/Saves/ThoughtSave.cs new file mode 100644 index 0000000..15735d7 --- /dev/null +++ b/src/LittleSim/App/Saves/ThoughtSave.cs @@ -0,0 +1,11 @@ +namespace LittleSim.App; + +/// Сериализуемая событийная мысль (фаза A8): деф (по имени) и остаток времени до угасания. +public sealed class ThoughtSave +{ + /// Имя дефа мысли. + public string DefName { get; set; } = ""; + + /// Сколько игровых дней мысль ещё действует. + public float RemainingDays { get; set; } +} diff --git a/src/LittleSim/App/Saves/WorldSave.cs b/src/LittleSim/App/Saves/WorldSave.cs new file mode 100644 index 0000000..44353e9 --- /dev/null +++ b/src/LittleSim/App/Saves/WorldSave.cs @@ -0,0 +1,64 @@ +namespace LittleSim.App; + +/// +/// Полное состояние мира для сохранения/загрузки: конфиг мира (имя/размер/сид/сглаживание), +/// прошедшее время симуляции и снимок всех жителей. Рельеф не сохраняется — он +/// детерминированно регенерируется из сида при загрузке. +/// +public sealed class WorldSave +{ + /// Текущая версия схемы сейва (поднимать при несовместимых изменениях полей). + public const int CurrentVersion = 1; + + /// Версия схемы файла. Старые сейвы без поля совместимы (схема аддитивна) и читаются как + /// текущая версия; проверка нужна, чтобы отклонить файл из БУДУЩЕЙ (несовместимой) версии. + public int Version { get; set; } = CurrentVersion; + + /// Имя мира (и основа имени файла). + public string Name { get; set; } = ""; + + /// Ширина мира в клетках. + public int Width { get; set; } + + /// Высота мира в клетках. + public int Height { get; set; } + + /// Сид генерации. + public int Seed { get; set; } + + /// Число проходов сглаживания рельефа. + public int SmoothPasses { get; set; } = 4; + + /// Население на момент сохранения. + public int Population { get; set; } + + /// Момент сохранения (UTC). + public DateTime SavedUtc { get; set; } + + /// Прошедшее время симуляции в секундах. + public double ElapsedSeconds { get; set; } + + /// Снимок всех растений (геномы, позиции, возраст, стадия). + public List Plants { get; set; } = []; + + /// Снимок всех животных (геном, стадия/возраст, нужды, здоровье, настроение, беременность). + public List Animals { get; set; } = []; + + /// Снимок трупов (вид, позиция, таймер/стадия разложения, мясо). + public List Corpses { get; set; } = []; + + /// Снимок яиц (вид, позиция, геном детёныша, поколение, инкубация). + public List Eggs { get; set; } = []; + + /// Конфиг мира для пересоздания сцены. + public WorldConfig ToConfig() => + new() + { + Name = Name, + Width = Width, + Height = Height, + Seed = Seed, + SmoothPasses = SmoothPasses, + Population = Population, + }; +} diff --git a/src/LittleSim/App/WorldSave.cs b/src/LittleSim/App/WorldSave.cs deleted file mode 100644 index e5710ae..0000000 --- a/src/LittleSim/App/WorldSave.cs +++ /dev/null @@ -1,320 +0,0 @@ -using System.Text.Json; -using MrGameEng.Genetics; - -namespace LittleSim.App; - -/// Сериализуемое состояние одного растения: вид (деф), позиция, возраст, стадия, почва и геном. -public sealed class PlantSave -{ - /// Имя дефа вида — по нему при загрузке берётся индекс/стадии/текстуры. - public string Species { get; set; } = ""; - - /// Позиция X в мировых координатах. - public float X { get; set; } - - /// Позиция Y в мировых координатах. - public float Y { get; set; } - - /// Накопленный возраст в игровых днях. - public float AgeDays { get; set; } - - /// Текущая стадия роста. - public int Stage { get; set; } - - /// Плодородность клетки. - public float CellFertility { get; set; } - - /// Геном особи: аллели по id генов (переменного состава). - public Dictionary Genome { get; set; } = new(); -} - -/// Сериализуемый хедиф особи (фаза A6): деф (по имени), тяжесть и набранный иммунитет. -public sealed class HediffSave -{ - /// Имя дефа хедифа (гон/болезнь). - public string DefName { get; set; } = ""; - - /// Текущая тяжесть. - public float Severity { get; set; } - - /// Набранный иммунитет [0,1]. - public float Immunity { get; set; } -} - -/// Сериализуемая событийная мысль (фаза A8): деф (по имени) и остаток времени до угасания. -public sealed class ThoughtSave -{ - /// Имя дефа мысли. - public string DefName { get; set; } = ""; - - /// Сколько игровых дней мысль ещё действует. - public float RemainingDays { get; set; } -} - -/// -/// Сериализуемое состояние одного животного (фазы A1–A8): вид, позиция, геном (источник признаков и -/// пола), поколение, стадия/возраст, нужды, здоровье (кровь, HP частей, хедифы), настроение и -/// беременность. Спрайт пересобирается фабрикой из дефа. Чтобы популяция (эволюция/состояние) -/// переживала сейв/лоад, а не пересыпалась заново из сида. -/// -public sealed class AnimalSave -{ - /// Имя дефа вида — по нему берётся индекс/стадии/текстуры/тело. - public string Species { get; set; } = ""; - - /// Позиция X в мировых координатах. - public float X { get; set; } - - /// Позиция Y в мировых координатах. - public float Y { get; set; } - - /// Номер поколения (для popstats/дрейфа). - public int Generation { get; set; } - - /// Текущая стадия роста. - public int Stage { get; set; } - - /// Накопленный возраст в игровых днях. - public float AgeDays { get; set; } - - /// Геном особи: аллели по id генов (переменного состава; несёт и пол через GeneSex). - public Dictionary Genome { get; set; } = new(); - - /// Значения нужд по индексам NeedSet. - public float[] Needs { get; set; } = []; - - /// Накоплено дней истощения. - public float Starve { get; set; } - - /// Уровень крови [0,1]. - public float BloodLevel { get; set; } = 1f; - - /// HP частей тела по порядку анатомии вида (пусто — у вида нет тела). - public float[] PartHp { get; set; } = []; - - /// Активные хедифы (гон/болезни). - public List Hediffs { get; set; } = []; - - /// Текущее настроение [0,1]. - public float Mood { get; set; } - - /// Активно ли настроение (тир 4+). - public bool MoodActive { get; set; } - - /// Активные событийные мысли. - public List Thoughts { get; set; } = []; - - /// Беременна ли самка. - public bool Pregnant { get; set; } - - /// Геном отца (если беременна) — «слепок» на момент зачатия. - public Dictionary FatherGenome { get; set; } = new(); - - /// Остаток срока вынашивания (игровых дней). - public float DueInDays { get; set; } - - /// Размер помёта. - public int Litter { get; set; } - - /// Уровни навыков [0..1] по индексам SkillSet. - public float[] SkillLevels { get; set; } = []; - - /// Страсть к навыкам (0/1/2) по индексам SkillSet. - public int[] SkillPassions { get; set; } = []; -} - -/// Сериализуемый труп (фаза A5): вид (для спрайта), позиция, таймер/стадия разложения, мясо. -public sealed class CorpseSave -{ - /// Имя дефа вида — по нему берётся спрайт трупа. - public string Species { get; set; } = ""; - - /// Позиция X в мировых координатах. - public float X { get; set; } - - /// Позиция Y в мировых координатах. - public float Y { get; set; } - - /// Накоплено игровых дней разложения. - public float RotDays { get; set; } - - /// Размер тела (для масштаба спрайта). - public float BodySize { get; set; } - - /// Доступное мясо. - public float Meat { get; set; } -} - -/// Сериализуемое яйцо (тип рождения «яйцекладка»): вид, позиция, геном детёныша, поколение, инкубация. -public sealed class EggSave -{ - /// Имя дефа вида — по нему берётся индекс/спрайт/тело при вылуплении. - public string Species { get; set; } = ""; - - /// Позиция X в мировых координатах. - public float X { get; set; } - - /// Позиция Y в мировых координатах. - public float Y { get; set; } - - /// Поколение будущего детёныша. - public int Generation { get; set; } - - /// Остаток инкубации в игровых днях. - public float IncubateDays { get; set; } - - /// Геном будущего детёныша (уже скрещён на момент кладки). - public Dictionary Genome { get; set; } = new(); -} - -/// -/// Полное состояние мира для сохранения/загрузки: конфиг мира (имя/размер/сид/сглаживание), -/// прошедшее время симуляции и снимок всех жителей. Рельеф не сохраняется — он -/// детерминированно регенерируется из сида при загрузке. -/// -public sealed class WorldSave -{ - /// Текущая версия схемы сейва (поднимать при несовместимых изменениях полей). - public const int CurrentVersion = 1; - - /// Версия схемы файла. Старые сейвы без поля совместимы (схема аддитивна) и читаются как - /// текущая версия; проверка нужна, чтобы отклонить файл из БУДУЩЕЙ (несовместимой) версии. - public int Version { get; set; } = CurrentVersion; - - /// Имя мира (и основа имени файла). - public string Name { get; set; } = ""; - - /// Ширина мира в клетках. - public int Width { get; set; } - - /// Высота мира в клетках. - public int Height { get; set; } - - /// Сид генерации. - public int Seed { get; set; } - - /// Число проходов сглаживания рельефа. - public int SmoothPasses { get; set; } = 4; - - /// Население на момент сохранения. - public int Population { get; set; } - - /// Момент сохранения (UTC). - public DateTime SavedUtc { get; set; } - - /// Прошедшее время симуляции в секундах. - public double ElapsedSeconds { get; set; } - - /// Снимок всех растений (геномы, позиции, возраст, стадия). - public List Plants { get; set; } = []; - - /// Снимок всех животных (геном, стадия/возраст, нужды, здоровье, настроение, беременность). - public List Animals { get; set; } = []; - - /// Снимок трупов (вид, позиция, таймер/стадия разложения, мясо). - public List Corpses { get; set; } = []; - - /// Снимок яиц (вид, позиция, геном детёныша, поколение, инкубация). - public List Eggs { get; set; } = []; - - /// Конфиг мира для пересоздания сцены. - public WorldConfig ToConfig() => - new() - { - Name = Name, - Width = Width, - Height = Height, - Seed = Seed, - SmoothPasses = SmoothPasses, - Population = Population, - }; -} - -/// Список/чтение/запись/удаление файлов сохранений Saves/*.json. -public sealed class SaveStore -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - WriteIndented = true, - PropertyNameCaseInsensitive = true, - IncludeFields = true, // на случай публичных полей в сохраняемых sim-структурах - }; - - private readonly string _directory; - - /// Создаёт хранилище поверх каталога (по умолчанию ). - public SaveStore(string? directory = null) => - _directory = directory ?? GamePaths.SavesDirectory; - - /// Запись сохранения: один файл на имя мира (повторное сохранение перезаписывает). - public readonly record struct Entry(string FileName, WorldSave Save); - - /// Все сохранения, новые сверху. Битые файлы пропускаются. - public IReadOnlyList List() - { - if (!Directory.Exists(_directory)) - { - return []; - } - - var entries = new List(); - foreach (var file in Directory.EnumerateFiles(_directory, "*.json")) - { - try - { - var save = JsonSerializer.Deserialize( - File.ReadAllText(file), - JsonOptions - ); - // Сейв из будущей (несовместимой) версии прочитать корректно нельзя — пропускаем, - // чтобы не выдать пользователю молча битый мир. - if (save is not null && save.Version <= WorldSave.CurrentVersion) - { - entries.Add(new Entry(Path.GetFileName(file), save)); - } - } - catch (Exception exception) when (exception is IOException or JsonException) - { - // Битый файл сохранения не должен ломать список — пропускаем. - } - } - - entries.Sort((a, b) => b.Save.SavedUtc.CompareTo(a.Save.SavedUtc)); - return entries; - } - - /// Пишет сохранение в файл по имени мира и возвращает имя файла. - public string Write(WorldSave save) - { - Directory.CreateDirectory(_directory); - var fileName = Sanitize(save.Name) + ".json"; - File.WriteAllText( - Path.Combine(_directory, fileName), - JsonSerializer.Serialize(save, JsonOptions) - ); - return fileName; - } - - /// Удаляет файл сохранения по имени. - public void Delete(string fileName) - { - var path = Path.Combine(_directory, fileName); - if (File.Exists(path)) - { - File.Delete(path); - } - } - - // Набор недопустимых для имени файла символов — кэшируем один раз (раньше массив аллоцировался - // и линейно сканировался на КАЖДЫЙ символ имени). - private static readonly HashSet InvalidFileNameChars = new( - Path.GetInvalidFileNameChars() - ); - - private static string Sanitize(string name) - { - var trimmed = name.Trim(); - var safe = string.Concat(trimmed.Select(c => InvalidFileNameChars.Contains(c) ? '_' : c)); - return string.IsNullOrEmpty(safe) ? "world" : safe; - } -} diff --git a/src/LittleSim/Content/Defs/AnimalDef.cs b/src/LittleSim/Content/Defs/AnimalDef.cs new file mode 100644 index 0000000..6023b87 --- /dev/null +++ b/src/LittleSim/Content/Defs/AnimalDef.cs @@ -0,0 +1,73 @@ +namespace LittleSim.Content; + +/// +/// Вид-животное (Defs/Animals/): подтип (текстура/размер/вид) плюс геном +/// вида. Поведение, здоровье и стадии живут на компонентах сущности (фазы A2+), а не на типе дефа — +/// существующие 9 животных- остаются валидными. +/// +public sealed class AnimalDef : PawnDef +{ + /// Текстура взрослого самца (фаза A3); пусто — берётся (самка/общая). + public string MaleTexture { get; init; } = ""; + + /// Текстура детёныша/подростка (фаза A3); пусто — берётся . + public string BabyTexture { get; init; } = ""; + + /// Текстура трупа (фаза A5); пусто — берётся . + public string CorpseTexture { get; init; } = ""; + + /// Текстура яйца (тип рождения «яйцекладка»); по умолчанию — общий спрайт семени-точки. + public string EggTexture { get; init; } = "things/plant/seed_default"; + + /// Эталонная масса (кг) взрослой особи базового размера вида; масса особи ∝ (размер/эталон)³. + public float BaseMassKg { get; init; } = 40f; + + /// Имя — анатомия вида (части тела/органы); пусто — без частей тела. + public string Body { get; init; } = ""; + + /// + /// Геном вида как ДАННЫЕ: ключ — id гена (), значение — + /// центр аллелей особи. Состав открыт — модер добавляет/убирает ген одной строкой JSON, без правки + /// кода; разброс берётся из самого гена (GeneDef.Spread). Дискретные гены (напр. пол) сюда + /// не пишутся — назначаются генерацией. + /// + public Dictionary Genome { get; init; } = new(); + + /// + /// Диета как ДАННЫЕ: что вид ест ("plant" — травоядное; "meat" — хищник/падальщик; оба — всеядное). + /// Поиск корма гейтится этим списком — модер задаёт рацион без правки кода. + /// + public string[] Diet { get; init; } = []; + + /// + /// Стадии роста (данные); пусто — встроенный набор Baby→Juvenile→Adult→Senior по умолчанию. + /// + public AnimalStageDef[] Stages { get; init; } = []; + + /// Плотность стартового спавна: голов на 1000 клеток суши (0 — вид не спавнится сам). + public float SpawnPer1000Cells { get; init; } + + /// Базовая скорость передвижения (мировых единиц/сек при гене moveSpeed = 1). + public float BaseSpeed { get; init; } = 28f; + + /// Базовый радиус восприятия в клетках (× ген vision) — поиск корма/воды/партнёра. + public float VisionCells { get; init; } = 14f; + + /// Сила выедания: на сколько игровых дней роста убавляется растение за день кормёжки. + public float ForageBiteDays { get; init; } = 40f; + + /// Дальность атаки в клетках: на этом расстоянии хищник кусает добычу (контакт). + public float AttackRangeCells { get; init; } = 1.2f; + + /// Игровые секунды между ударами (кулдаун дискретной атаки, как в RimWorld). + public float AttackIntervalSec { get; init; } = 1.8f; + + /// Урон части тела добычи ЗА ОДИН УДАР (×силу: размер×навык). 0 — вид не атакует. + public float AttackDamage { get; init; } + + /// Прирост тяжести раны-кровотечения у добычи за один удар (×силу). + public float AttackBleed { get; init; } + + /// Острая кровопотеря добычи за один удар (доля объёма крови, ×силу) — основной путь к смерти. + public float AttackBloodLoss { get; init; } +} diff --git a/src/LittleSim/Content/Defs/AnimalStageDef.cs b/src/LittleSim/Content/Defs/AnimalStageDef.cs new file mode 100644 index 0000000..708845d --- /dev/null +++ b/src/LittleSim/Content/Defs/AnimalStageDef.cs @@ -0,0 +1,28 @@ +namespace LittleSim.Content; + +/// +/// Стадия роста животного как ДАННЫЕ (вложенный объект ): возраст входа = +/// × якорь ( — созревание или продолжительность жизни), +/// масштаб тела и набор текстур ("baby"/"adult"). Стадии перечисляются по возрастанию возраста входа. +/// Модер задаёт любой набор стадий (личинка/куколка/имаго и т.п.) без правки кода. +/// +public sealed class AnimalStageDef +{ + /// Имя стадии (для тултипов/отладки). + public string Name { get; init; } = ""; + + /// Множитель к якорю: возраст входа = EnterAt × (созревание | продолжительность жизни). + public float EnterAt { get; init; } + + /// Якорь возраста входа: "maturity" (созревание) или "lifespan" (продолжительность жизни). + public string RelativeTo { get; init; } = "maturity"; + + /// Масштаб размера тела на этой стадии (× ген размера). + public float Scale { get; init; } = 1f; + + /// Набор текстур: "baby" (детёныш) или "adult" (по полу — самец/самка). + public string Texture { get; init; } = "adult"; + + /// Взрослая стадия — способна размножаться. + public bool Adult { get; init; } +} diff --git a/src/LittleSim/Content/Defs/BodyDef.cs b/src/LittleSim/Content/Defs/BodyDef.cs new file mode 100644 index 0000000..e01708d --- /dev/null +++ b/src/LittleSim/Content/Defs/BodyDef.cs @@ -0,0 +1,13 @@ +using MrGameEng.Mods; + +namespace LittleSim.Content; + +/// +/// Анатомия вида (Defs/Bodies/): дерево частей тела/органов. Используется системой здоровья (фаза +/// A5): из неё строятся инстансы частей особи, по их HP/крови считаются способности (capacities). +/// +public sealed class BodyDef : Def +{ + /// Части тела/органы вида. + public BodyPartDef[] Parts { get; init; } = []; +} diff --git a/src/LittleSim/Content/Defs/BodyPartDef.cs b/src/LittleSim/Content/Defs/BodyPartDef.cs new file mode 100644 index 0000000..a4e10b6 --- /dev/null +++ b/src/LittleSim/Content/Defs/BodyPartDef.cs @@ -0,0 +1,27 @@ +namespace LittleSim.Content; + +/// +/// Часть тела/орган (вложенный объект ) как ДАННЫЕ: иерархия (родитель), +/// вес попадания, максимум HP (масштабируется размером тела), флаг жизненной важности и вклады в +/// способности (capacity id → доля). Модер задаёт любую анатомию (8 ног, два сердца) без правки кода. +/// +public sealed class BodyPartDef +{ + /// Имя части (уникально в теле); на него ссылается детей. + public string Name { get; init; } = ""; + + /// Имя родительской части ("" — корневая, напр. торс). + public string Parent { get; init; } = ""; + + /// Вес попадания (доля тела) — для будущего выбора задетой части. + public float Coverage { get; init; } = 0.1f; + + /// Базовый максимум HP (× размер тела при создании особи). + public float MaxHp { get; init; } = 10f; + + /// Жизненно важная: уничтожение → смерть. + public bool Vital { get; init; } + + /// Вклады в способности: capacity id (см. AnimalCapacities) → доля (сумма по телу ≈ 1). + public Dictionary Capacities { get; init; } = new(); +} diff --git a/src/LittleSim/Content/Defs/DevToolDef.cs b/src/LittleSim/Content/Defs/DevToolDef.cs new file mode 100644 index 0000000..859a5c7 --- /dev/null +++ b/src/LittleSim/Content/Defs/DevToolDef.cs @@ -0,0 +1,21 @@ +using MrGameEng.Mods; + +namespace LittleSim.Content; + +/// +/// Пункт дев-инструмента (Defs/DevTools/) — ЧТОБЫ МОДЫ ДОБАВЛЯЛИ кнопки в дев-спавнер без правки кода. +/// — id обработчика из реестра (spawn-animal/spawn-plant/infect/damage/kill/…), +/// — параметр (defName вида/хедифа). Виды животных/растений спавнер берёт из их +/// дефов сам (модовый контент появляется автоматически); DevToolDef добавляет ПРОЧИЕ действия. +/// +public sealed class DevToolDef : Def +{ + /// Категория для группировки в панели (например "tool"). + public string Category { get; init; } = "tool"; + + /// Id обработчика-инструмента из реестра кода (spawn-animal/spawn-plant/infect/damage/kill). + public string Kind { get; init; } = ""; + + /// Параметр инструмента: defName вида/хедифа и т.п. (по смыслу Kind). + public string Target { get; init; } = ""; +} diff --git a/src/LittleSim/Content/Defs/GenomeDef.cs b/src/LittleSim/Content/Defs/GenomeDef.cs new file mode 100644 index 0000000..00450a0 --- /dev/null +++ b/src/LittleSim/Content/Defs/GenomeDef.cs @@ -0,0 +1,78 @@ +namespace LittleSim.Content; + +/// +/// Базовый геном вида (вложенный объект ): оптимумы и толерантности по +/// факторам среды + базовая бодрость роста. Особь при спавне получает аллели рядом с этими базами +/// (разброс — ); фенотип = среднее аллелей. +/// +public sealed class GenomeDef +{ + /// Оптимальная освещённость (0..1) и её толерантность. + public float OptimalLight { get; init; } = 0.7f; + + /// Ширина толерантности по свету. + public float LightTolerance { get; init; } = 0.4f; + + /// Оптимальная температура (°C) и её толерантность. + public float OptimalTemperature { get; init; } = 18f; + + /// Ширина толерантности по температуре (°C) — половина плато полного роста вокруг оптимума. + public float TemperatureTolerance { get; init; } = 12f; + + /// Морозостойкость (°C): на сколько ниже плато рост спадает до нуля (жёсткая нижняя граница). + public float ColdHardiness { get; init; } = 8f; + + /// Жаростойкость (°C): на сколько выше плато рост спадает до нуля (жёсткая верхняя граница). + public float HeatHardiness { get; init; } = 10f; + + /// Оптимальная плодородность почвы и её толерантность. + public float OptimalFertility { get; init; } = 1f; + + /// Ширина толерантности по плодородности. + public float FertilityTolerance { get; init; } = 0.8f; + + /// Базовая бодрость роста (множитель скорости в идеальных условиях). + public float Vigor { get; init; } = 1f; + + /// Продолжительность жизни (игровых дней) до смерти от старости. + public float Lifespan { get; init; } = 80f; + + /// Дальность расселения семян (в клетках). + public float DispersalRange { get; init; } = 2f; + + /// Средний интервал между попытками дать семя (игровых дней). + public float ReproduceInterval { get; init; } = 8f; + + /// Способность к самоопылению (0..1): шанс дать семя без партнёра. + public float SelfPollination { get; init; } = 0.4f; + + /// Темп мутаций (0..1): вероятность сдвига аллеля у потомка. + public float MutationRate { get; init; } = 0.05f; + + /// Доля рецессивного аллеля морфы в стартовой популяции (0..1). + public float VariantChance { get; init; } = 0.15f; + + /// Доля разброса аллелей вокруг базы при генерации особи. + public float Spread { get; init; } = 0.08f; + + /// Плодовитость: сколько плодов несёт зрелое растение в сезон (0 — не плодоносит). + public float FruitYield { get; init; } + + /// Сезон плодоношения (0 — весна, 1 — лето, 2 — осень, 3 — зима). + public float FruitSeason { get; init; } = 1f; + + /// Сколько продукта даёт сбор растения (масштаб ). + public float HarvestAmount { get; init; } = 1f; + + /// Оттенок листвы (0..1) — сдвиг тинта спрайта; ген цвета. + public float LeafHue { get; init; } = 0.33f; + + /// Токсичность (0..1): отравляет поедателя (коэволюция); тормозит рост (цена защиты). + public float Toxicity { get; init; } + + /// Шипы (0..1): травмируют поедателя; тормозят рост (цена защиты). + public float Thorns { get; init; } + + /// Вкусность (0..1): обратная привлекательность для умных травоядных (избегание в C2). + public float Palatability { get; init; } = 1f; +} diff --git a/src/LittleSim/Content/Defs/HediffDef.cs b/src/LittleSim/Content/Defs/HediffDef.cs new file mode 100644 index 0000000..8b9e50d --- /dev/null +++ b/src/LittleSim/Content/Defs/HediffDef.cs @@ -0,0 +1,39 @@ +using MrGameEng.Mods; + +namespace LittleSim.Content; + +/// +/// Состояние/хедиф организма (Defs/Hediffs/): гон, болезни, позже раны/возрастные эффекты. Фаза A6: +/// болезнь прогрессирует (), параллельно растёт иммунитет +/// () — гонка: иммунитет→1 раньше = выздоровление, severity→ +/// раньше = смерть. Снижает способности () и даёт боль. +/// +public sealed class HediffDef : Def +{ + /// Начальная тяжесть при навешивании (гон — 1; болезнь — малая, напр. 0.05). + public float InitialSeverity { get; init; } = 1f; + + /// Прирост тяжести за игровой день (0 — статичный хедиф вроде гона). + public float SeverityPerDay { get; init; } + + /// Прирост иммунитета за день (0 — иммунитета нет, не выздоравливает сам). + public float ImmunityPerDay { get; init; } + + /// Тяжесть, при которой наступает смерть (0 — не летально). + public float LethalSeverity { get; init; } + + /// Боль при тяжести 1 (масштабируется тяжестью) — вход в сознание/настроение. + public float Pain { get; init; } + + /// Шанс подхватить фоном за день (0 — не фоновая болезнь). + public float AmbientPerDay { get; init; } + + /// + /// Кровопотеря за игровой день при тяжести 1 (масштабируется тяжестью): доля объёма крови, которую + /// рана теряет в сутки. 0 — хедиф не вызывает кровотечения. Кровь на нуле → смерть (см. система здоровья). + /// + public float BloodLossPerDay { get; init; } + + /// Модификаторы способностей: capacity id → множитель при тяжести 1 (лерп 1→factor по severity). + public Dictionary CapMods { get; init; } = new(); +} diff --git a/src/LittleSim/Content/Defs/NeedDef.cs b/src/LittleSim/Content/Defs/NeedDef.cs new file mode 100644 index 0000000..ebba91e --- /dev/null +++ b/src/LittleSim/Content/Defs/NeedDef.cs @@ -0,0 +1,36 @@ +using MrGameEng.Mods; + +namespace LittleSim.Content; + +/// +/// Нужда (Defs/Needs/) как ДАННЫЕ: имя, тип (deplete — убывает, низкое = срочно; drive — растёт под +/// условием, высокое = срочно), действие-исполнитель (по id, см. реестр поведений), темпы и порог +/// интеллекта. Модер добавляет нужду виду/всем одной записью; если она ссылается на существующее +/// действие — без правки кода. Хранилище нужд особи — динамическое (см. NeedSet/AnimalNeeds). +/// +public sealed class NeedDef : Def +{ + /// Тип: "deplete" (убывает, утоляется действием) или "drive" (растёт под условием). + public string Kind { get; init; } = "deplete"; + + /// Id действия-исполнителя, удовлетворяющего нужду (Eat/Drink/Sleep/Mate/…). + public string Action { get; init; } = ""; + + /// Темп убывания за игровой день (drive: темп спада, когда условие отсутствует). + public float DecayPerDay { get; init; } = 0.5f; + + /// drive: темп роста за день, пока активно условие . + public float RisePerDay { get; init; } = 1f; + + /// deplete: насколько за день восполняет нужду исполнение действия. + public float FeedPerDay { get; init; } = 4f; + + /// drive: id хедифа, под которым нужда растёт (напр. "Rut"); null — растёт всегда. + public string? RisesUnder { get; init; } + + /// deplete: на нуле копит счётчик голодной смерти (A3). + public bool Lethal { get; init; } + + /// Порог размера мозга, с которого нужда активна (гейт интеллектом — фаза A7). + public float MinBrain { get; init; } +} diff --git a/src/LittleSim/Content/Defs/PawnDef.cs b/src/LittleSim/Content/Defs/PawnDef.cs new file mode 100644 index 0000000..f479423 --- /dev/null +++ b/src/LittleSim/Content/Defs/PawnDef.cs @@ -0,0 +1,22 @@ +using MrGameEng.Mods; + +namespace LittleSim.Content; + +/// Существо (Defs/Pawns/): текстура, размер и вид (житель/животное). Не sealed — наследует. +public class PawnDef : Def +{ + /// Вид существа: жители ("being") живут в мировой сцене, животные ("animal") — в террейне. + public const string KindBeing = "being"; + + /// Вид животного. + public const string KindAnimal = "animal"; + + /// Ключ текстуры (путь региона в атласах). + public string Texture { get; init; } = ""; + + /// Размер спрайта в клетках. + public float SizeCells { get; init; } = 1f; + + /// Вид: или . + public string Kind { get; init; } = ""; +} diff --git a/src/LittleSim/Content/Defs/PlantDef.cs b/src/LittleSim/Content/Defs/PlantDef.cs new file mode 100644 index 0000000..0403610 --- /dev/null +++ b/src/LittleSim/Content/Defs/PlantDef.cs @@ -0,0 +1,31 @@ +using MrGameEng.Mods; + +namespace LittleSim.Content; + +/// Растение (Defs/Plants/): текстура, размер, опциональный ствол-препятствие, стадии роста, геном. +public sealed class PlantDef : Def +{ + /// Ключ текстуры (путь региона в атласах); база для стадий без своей текстуры. + public string Texture { get; init; } = ""; + + /// Базовый размер спрайта в клетках; база для стадий без своего размера. + public float SizeCells { get; init; } = 1f; + + /// Радиус коллайдера ствола в клетках; 0 — без коллайдера (трава, кусты). + public float TrunkRadiusCells { get; init; } + + /// Стадии роста по порядку; пусто — одна терминальная стадия из базовой текстуры. + public List Stages { get; init; } = []; + + /// Базовый геном вида: оптимумы/толерантности по среде и бодрость роста. + public GenomeDef Genome { get; init; } = new(); + + /// Имя , выдаваемого при сборе растения (дерево/трава); null — несборное. + public string? HarvestProduct { get; init; } + + /// Имя , выдаваемого плодами (ягоды/жёлудь); null — не плодоносит. + public string? FruitProduct { get; init; } + + /// Эталонная масса (кг) зрелого растения базового размера; масса особи ∝ (размер/эталон)³. + public float BaseMassKg { get; init; } = 20f; +} diff --git a/src/LittleSim/Content/Defs/PlantGrowthStage.cs b/src/LittleSim/Content/Defs/PlantGrowthStage.cs new file mode 100644 index 0000000..f394e7c --- /dev/null +++ b/src/LittleSim/Content/Defs/PlantGrowthStage.cs @@ -0,0 +1,21 @@ +namespace LittleSim.Content; + +/// +/// Одна стадия роста растения (вложенный объект ): свой спрайт и +/// размер, и сколько игровых дней растение проводит на этой стадии до перехода к следующей. +/// Растёт на месте — последняя стадия терминальная (её игнорируется). +/// +public sealed class PlantGrowthStage +{ + /// Ключ текстуры стадии; null — берётся базовая . + public string? Texture { get; init; } + + /// Размер спрайта в клетках на этой стадии; 0 — берётся базовый . + public float SizeCells { get; init; } + + /// Сколько игровых дней расти на этой стадии до перехода к следующей. + public float GrowDays { get; init; } + + /// Ключ локализации названия стадии (опционально, для будущих тултипов). + public string? Label { get; init; } +} diff --git a/src/LittleSim/Content/Defs/ProductDef.cs b/src/LittleSim/Content/Defs/ProductDef.cs new file mode 100644 index 0000000..fa84091 --- /dev/null +++ b/src/LittleSim/Content/Defs/ProductDef.cs @@ -0,0 +1,17 @@ +using MrGameEng.Mods; + +namespace LittleSim.Content; + +/// +/// Продукт (Defs/Products/): что растение даёт при сборе (древесина, трава) или плодоношении +/// (ягоды, жёлудь). Источник истины — деф; растения ссылаются на него по имени, а количество +/// определяют гены. — ключ локализации (product.*). +/// +public sealed class ProductDef : Def +{ + /// Категория продукта (например "material" или "food") — для будущей экономики/инвентаря. + public string Kind { get; init; } = ""; + + /// Масса одной единицы продукта (кг) — основа под перенос/инвентарь (грузоподъёмность). + public float MassKg { get; init; } = 1f; +} diff --git a/src/LittleSim/Content/Defs/ScatterEntry.cs b/src/LittleSim/Content/Defs/ScatterEntry.cs new file mode 100644 index 0000000..81785f8 --- /dev/null +++ b/src/LittleSim/Content/Defs/ScatterEntry.cs @@ -0,0 +1,11 @@ +namespace LittleSim.Content; + +/// Запись скаттера: с вероятностью на клетку ставится одно растение из . +public sealed class ScatterEntry +{ + /// Вероятность на клетку (0..1); записи бросаются независимо. + public float Chance { get; init; } + + /// Имена , из которых выбирается равновероятно. + public List Options { get; init; } = []; +} diff --git a/src/LittleSim/Content/Defs/SkillDef.cs b/src/LittleSim/Content/Defs/SkillDef.cs new file mode 100644 index 0000000..9736988 --- /dev/null +++ b/src/LittleSim/Content/Defs/SkillDef.cs @@ -0,0 +1,24 @@ +using MrGameEng.Mods; + +namespace LittleSim.Content; + +/// +/// Навык (Defs/Skills/) как ДАННЫЕ: уровень особи [0..1] растёт от практики (XP при действии, +/// ускоряется страстью) и медленно угасает без неё к полу . Эффект навыка — +/// код по его id (как у нужд/действий): добыча→выпас, охота→урон, уклонение→бегство. Модер добавляет +/// навык строкой JSON; новый эффект — код. — ключ локализации (skill.*). +/// +public sealed class SkillDef : Def +{ + /// Категория (для группировки в UI), напр. "survival". + public string Category { get; init; } = ""; + + /// Скорость обучения за игровой день непрерывной практики (при страсти ×1). + public float LearnRate { get; init; } = 0.5f; + + /// Угасание уровня за игровой день без практики. + public float DecayPerDay { get; init; } = 0.02f; + + /// Пол угасания (ниже не падает) — «не забывается совсем». + public float FloorLevel { get; init; } +} diff --git a/src/LittleSim/Content/Defs/TerrainDef.cs b/src/LittleSim/Content/Defs/TerrainDef.cs new file mode 100644 index 0000000..85f3d1b --- /dev/null +++ b/src/LittleSim/Content/Defs/TerrainDef.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Serialization; +using Microsoft.Xna.Framework; +using MrGameEng.Mods; + +namespace LittleSim.Content; + +/// Тип клетки рельефа (Defs/Terrain/): порог высоты, цвет, поверхность, скаттер растений. +public sealed class TerrainDef : Def +{ + /// Верхняя граница высоты (клетка относится к первому дефу, чей порог выше её высоты). + public float MaxHeight { get; init; } + + /// Цвет клетки [r, g, b] для сцен с тонированными квадратами и тайлов без поверхности. + [JsonPropertyName("color")] + public List Rgb { get; init; } = []; + + /// Суша: здесь появляются жители, животные и растения. + public bool IsLand { get; init; } + + /// Светонепроницаемый террейн — базовый окклюдер лайтмапа/теней (сейчас такого нет). + public bool BlocksLight { get; init; } + + /// Ключ текстуры поверхности для тайловой сцены; null — тонированный тайл. + public string? Surface { get; init; } + + /// Растительность, рассыпаемая по клеткам этого террейна. + public List Scatter { get; init; } = []; + + /// + /// Плодородность почвы — множитель скорости роста растений на этом террейне (1 — норма, + /// больше — быстрее). Богатая лесная почва растит быстрее песка/камня. + /// + public float Fertility { get; init; } = 1f; + + /// Цвет тонировки из . + public Color Tint => Rgb is [var r, var g, var b, ..] ? new(r, g, b) : Color.Magenta; +} diff --git a/src/LittleSim/Content/Defs/ThoughtDef.cs b/src/LittleSim/Content/Defs/ThoughtDef.cs new file mode 100644 index 0000000..571d4b5 --- /dev/null +++ b/src/LittleSim/Content/Defs/ThoughtDef.cs @@ -0,0 +1,18 @@ +using MrGameEng.Mods; + +namespace LittleSim.Content; + +/// +/// Мысль настроения (Defs/Thoughts/) как ДАННЫЕ (фаза A8, модель thoughts RimWorld): событие +/// (роды/спаривание/утоление жажды/…) вешает на особь временный сдвиг настроения, затухающий за +/// . Триггеры событий — код (реестр AnimalThoughts), а величины/метка — +/// данные: модер правит силу и длительность мысли одной записью JSON, без пересборки. +/// +public sealed class ThoughtDef : Def +{ + /// Сдвиг настроения [-1,1] (положительный — радость, отрицательный — горе). + public float MoodOffset { get; init; } + + /// Сколько игровых дней мысль живёт, прежде чем угаснуть. + public float DurationDays { get; init; } = 1f; +} diff --git a/src/LittleSim/Content/Defs/WorldPresetDef.cs b/src/LittleSim/Content/Defs/WorldPresetDef.cs new file mode 100644 index 0000000..a4e4970 --- /dev/null +++ b/src/LittleSim/Content/Defs/WorldPresetDef.cs @@ -0,0 +1,23 @@ +using MrGameEng.Mods; + +namespace LittleSim.Content; + +/// +/// Пресет размера мира (Defs/WorldPresets/): габариты сетки и население. Меню «Новый +/// мир» строит список размеров из этих дефов — новая механика приходит данными мода, а не +/// хардкодом. — ключ локализации (preset.*). +/// +public sealed class WorldPresetDef : Def +{ + /// Ширина мира в клетках. + public int Width { get; init; } + + /// Высота мира в клетках. + public int Height { get; init; } + + /// Число жителей при генерации. + public int Population { get; init; } + + /// Порядок в списке UI (по возрастанию). + public int Order { get; init; } +} diff --git a/src/LittleSim/Content/GameDefs.cs b/src/LittleSim/Content/GameDefs.cs deleted file mode 100644 index 648577f..0000000 --- a/src/LittleSim/Content/GameDefs.cs +++ /dev/null @@ -1,483 +0,0 @@ -using System.Text.Json.Serialization; -using Microsoft.Xna.Framework; -using MrGameEng.Mods; - -namespace LittleSim.Content; - -/// Запись скаттера: с вероятностью на клетку ставится одно растение из . -public sealed class ScatterEntry -{ - /// Вероятность на клетку (0..1); записи бросаются независимо. - public float Chance { get; init; } - - /// Имена , из которых выбирается равновероятно. - public List Options { get; init; } = []; -} - -/// Тип клетки рельефа (Defs/Terrain/): порог высоты, цвет, поверхность, скаттер растений. -public sealed class TerrainDef : Def -{ - /// Верхняя граница высоты (клетка относится к первому дефу, чей порог выше её высоты). - public float MaxHeight { get; init; } - - /// Цвет клетки [r, g, b] для сцен с тонированными квадратами и тайлов без поверхности. - [JsonPropertyName("color")] - public List Rgb { get; init; } = []; - - /// Суша: здесь появляются жители, животные и растения. - public bool IsLand { get; init; } - - /// Светонепроницаемый террейн — базовый окклюдер лайтмапа/теней (сейчас такого нет). - public bool BlocksLight { get; init; } - - /// Ключ текстуры поверхности для тайловой сцены; null — тонированный тайл. - public string? Surface { get; init; } - - /// Растительность, рассыпаемая по клеткам этого террейна. - public List Scatter { get; init; } = []; - - /// - /// Плодородность почвы — множитель скорости роста растений на этом террейне (1 — норма, - /// больше — быстрее). Богатая лесная почва растит быстрее песка/камня. - /// - public float Fertility { get; init; } = 1f; - - /// Цвет тонировки из . - public Color Tint => Rgb is [var r, var g, var b, ..] ? new(r, g, b) : Color.Magenta; -} - -/// -/// Одна стадия роста растения (вложенный объект ): свой спрайт и -/// размер, и сколько игровых дней растение проводит на этой стадии до перехода к следующей. -/// Растёт на месте — последняя стадия терминальная (её игнорируется). -/// -public sealed class PlantGrowthStage -{ - /// Ключ текстуры стадии; null — берётся базовая . - public string? Texture { get; init; } - - /// Размер спрайта в клетках на этой стадии; 0 — берётся базовый . - public float SizeCells { get; init; } - - /// Сколько игровых дней расти на этой стадии до перехода к следующей. - public float GrowDays { get; init; } - - /// Ключ локализации названия стадии (опционально, для будущих тултипов). - public string? Label { get; init; } -} - -/// -/// Продукт (Defs/Products/): что растение даёт при сборе (древесина, трава) или плодоношении -/// (ягоды, жёлудь). Источник истины — деф; растения ссылаются на него по имени, а количество -/// определяют гены. — ключ локализации (product.*). -/// -public sealed class ProductDef : Def -{ - /// Категория продукта (например "material" или "food") — для будущей экономики/инвентаря. - public string Kind { get; init; } = ""; - - /// Масса одной единицы продукта (кг) — основа под перенос/инвентарь (грузоподъёмность). - public float MassKg { get; init; } = 1f; -} - -/// -/// Базовый геном вида (вложенный объект ): оптимумы и толерантности по -/// факторам среды + базовая бодрость роста. Особь при спавне получает аллели рядом с этими базами -/// (разброс — ); фенотип = среднее аллелей. -/// -public sealed class GenomeDef -{ - /// Оптимальная освещённость (0..1) и её толерантность. - public float OptimalLight { get; init; } = 0.7f; - - /// Ширина толерантности по свету. - public float LightTolerance { get; init; } = 0.4f; - - /// Оптимальная температура (°C) и её толерантность. - public float OptimalTemperature { get; init; } = 18f; - - /// Ширина толерантности по температуре (°C) — половина плато полного роста вокруг оптимума. - public float TemperatureTolerance { get; init; } = 12f; - - /// Морозостойкость (°C): на сколько ниже плато рост спадает до нуля (жёсткая нижняя граница). - public float ColdHardiness { get; init; } = 8f; - - /// Жаростойкость (°C): на сколько выше плато рост спадает до нуля (жёсткая верхняя граница). - public float HeatHardiness { get; init; } = 10f; - - /// Оптимальная плодородность почвы и её толерантность. - public float OptimalFertility { get; init; } = 1f; - - /// Ширина толерантности по плодородности. - public float FertilityTolerance { get; init; } = 0.8f; - - /// Базовая бодрость роста (множитель скорости в идеальных условиях). - public float Vigor { get; init; } = 1f; - - /// Продолжительность жизни (игровых дней) до смерти от старости. - public float Lifespan { get; init; } = 80f; - - /// Дальность расселения семян (в клетках). - public float DispersalRange { get; init; } = 2f; - - /// Средний интервал между попытками дать семя (игровых дней). - public float ReproduceInterval { get; init; } = 8f; - - /// Способность к самоопылению (0..1): шанс дать семя без партнёра. - public float SelfPollination { get; init; } = 0.4f; - - /// Темп мутаций (0..1): вероятность сдвига аллеля у потомка. - public float MutationRate { get; init; } = 0.05f; - - /// Доля рецессивного аллеля морфы в стартовой популяции (0..1). - public float VariantChance { get; init; } = 0.15f; - - /// Доля разброса аллелей вокруг базы при генерации особи. - public float Spread { get; init; } = 0.08f; - - /// Плодовитость: сколько плодов несёт зрелое растение в сезон (0 — не плодоносит). - public float FruitYield { get; init; } - - /// Сезон плодоношения (0 — весна, 1 — лето, 2 — осень, 3 — зима). - public float FruitSeason { get; init; } = 1f; - - /// Сколько продукта даёт сбор растения (масштаб ). - public float HarvestAmount { get; init; } = 1f; - - /// Оттенок листвы (0..1) — сдвиг тинта спрайта; ген цвета. - public float LeafHue { get; init; } = 0.33f; - - /// Токсичность (0..1): отравляет поедателя (коэволюция); тормозит рост (цена защиты). - public float Toxicity { get; init; } - - /// Шипы (0..1): травмируют поедателя; тормозят рост (цена защиты). - public float Thorns { get; init; } - - /// Вкусность (0..1): обратная привлекательность для умных травоядных (избегание в C2). - public float Palatability { get; init; } = 1f; -} - -/// Растение (Defs/Plants/): текстура, размер, опциональный ствол-препятствие, стадии роста, геном. -public sealed class PlantDef : Def -{ - /// Ключ текстуры (путь региона в атласах); база для стадий без своей текстуры. - public string Texture { get; init; } = ""; - - /// Базовый размер спрайта в клетках; база для стадий без своего размера. - public float SizeCells { get; init; } = 1f; - - /// Радиус коллайдера ствола в клетках; 0 — без коллайдера (трава, кусты). - public float TrunkRadiusCells { get; init; } - - /// Стадии роста по порядку; пусто — одна терминальная стадия из базовой текстуры. - public List Stages { get; init; } = []; - - /// Базовый геном вида: оптимумы/толерантности по среде и бодрость роста. - public GenomeDef Genome { get; init; } = new(); - - /// Имя , выдаваемого при сборе растения (дерево/трава); null — несборное. - public string? HarvestProduct { get; init; } - - /// Имя , выдаваемого плодами (ягоды/жёлудь); null — не плодоносит. - public string? FruitProduct { get; init; } - - /// Эталонная масса (кг) зрелого растения базового размера; масса особи ∝ (размер/эталон)³. - public float BaseMassKg { get; init; } = 20f; -} - -/// -/// Пресет размера мира (Defs/WorldPresets/): габариты сетки и население. Меню «Новый -/// мир» строит список размеров из этих дефов — новая механика приходит данными мода, а не -/// хардкодом. — ключ локализации (preset.*). -/// -public sealed class WorldPresetDef : Def -{ - /// Ширина мира в клетках. - public int Width { get; init; } - - /// Высота мира в клетках. - public int Height { get; init; } - - /// Число жителей при генерации. - public int Population { get; init; } - - /// Порядок в списке UI (по возрастанию). - public int Order { get; init; } -} - -/// Существо (Defs/Pawns/): текстура, размер и вид (житель/животное). Не sealed — наследует. -public class PawnDef : Def -{ - /// Вид существа: жители ("being") живут в мировой сцене, животные ("animal") — в террейне. - public const string KindBeing = "being"; - - /// Вид животного. - public const string KindAnimal = "animal"; - - /// Ключ текстуры (путь региона в атласах). - public string Texture { get; init; } = ""; - - /// Размер спрайта в клетках. - public float SizeCells { get; init; } = 1f; - - /// Вид: или . - public string Kind { get; init; } = ""; -} - -/// -/// Стадия роста животного как ДАННЫЕ (вложенный объект ): возраст входа = -/// × якорь ( — созревание или продолжительность жизни), -/// масштаб тела и набор текстур ("baby"/"adult"). Стадии перечисляются по возрастанию возраста входа. -/// Модер задаёт любой набор стадий (личинка/куколка/имаго и т.п.) без правки кода. -/// -public sealed class AnimalStageDef -{ - /// Имя стадии (для тултипов/отладки). - public string Name { get; init; } = ""; - - /// Множитель к якорю: возраст входа = EnterAt × (созревание | продолжительность жизни). - public float EnterAt { get; init; } - - /// Якорь возраста входа: "maturity" (созревание) или "lifespan" (продолжительность жизни). - public string RelativeTo { get; init; } = "maturity"; - - /// Масштаб размера тела на этой стадии (× ген размера). - public float Scale { get; init; } = 1f; - - /// Набор текстур: "baby" (детёныш) или "adult" (по полу — самец/самка). - public string Texture { get; init; } = "adult"; - - /// Взрослая стадия — способна размножаться. - public bool Adult { get; init; } -} - -/// -/// Вид-животное (Defs/Animals/): подтип (текстура/размер/вид) плюс геном -/// вида. Поведение, здоровье и стадии живут на компонентах сущности (фазы A2+), а не на типе дефа — -/// существующие 9 животных- остаются валидными. -/// -public sealed class AnimalDef : PawnDef -{ - /// Текстура взрослого самца (фаза A3); пусто — берётся (самка/общая). - public string MaleTexture { get; init; } = ""; - - /// Текстура детёныша/подростка (фаза A3); пусто — берётся . - public string BabyTexture { get; init; } = ""; - - /// Текстура трупа (фаза A5); пусто — берётся . - public string CorpseTexture { get; init; } = ""; - - /// Текстура яйца (тип рождения «яйцекладка»); по умолчанию — общий спрайт семени-точки. - public string EggTexture { get; init; } = "things/plant/seed_default"; - - /// Эталонная масса (кг) взрослой особи базового размера вида; масса особи ∝ (размер/эталон)³. - public float BaseMassKg { get; init; } = 40f; - - /// Имя — анатомия вида (части тела/органы); пусто — без частей тела. - public string Body { get; init; } = ""; - - /// - /// Геном вида как ДАННЫЕ: ключ — id гена (), значение — - /// центр аллелей особи. Состав открыт — модер добавляет/убирает ген одной строкой JSON, без правки - /// кода; разброс берётся из самого гена (GeneDef.Spread). Дискретные гены (напр. пол) сюда - /// не пишутся — назначаются генерацией. - /// - public Dictionary Genome { get; init; } = new(); - - /// - /// Диета как ДАННЫЕ: что вид ест ("plant" — травоядное; "meat" — хищник/падальщик; оба — всеядное). - /// Поиск корма гейтится этим списком — модер задаёт рацион без правки кода. - /// - public string[] Diet { get; init; } = []; - - /// - /// Стадии роста (данные); пусто — встроенный набор Baby→Juvenile→Adult→Senior по умолчанию. - /// - public AnimalStageDef[] Stages { get; init; } = []; - - /// Плотность стартового спавна: голов на 1000 клеток суши (0 — вид не спавнится сам). - public float SpawnPer1000Cells { get; init; } - - /// Базовая скорость передвижения (мировых единиц/сек при гене moveSpeed = 1). - public float BaseSpeed { get; init; } = 28f; - - /// Базовый радиус восприятия в клетках (× ген vision) — поиск корма/воды/партнёра. - public float VisionCells { get; init; } = 14f; - - /// Сила выедания: на сколько игровых дней роста убавляется растение за день кормёжки. - public float ForageBiteDays { get; init; } = 40f; - - /// Дальность атаки в клетках: на этом расстоянии хищник кусает добычу (контакт). - public float AttackRangeCells { get; init; } = 1.2f; - - /// Игровые секунды между ударами (кулдаун дискретной атаки, как в RimWorld). - public float AttackIntervalSec { get; init; } = 1.8f; - - /// Урон части тела добычи ЗА ОДИН УДАР (×силу: размер×навык). 0 — вид не атакует. - public float AttackDamage { get; init; } - - /// Прирост тяжести раны-кровотечения у добычи за один удар (×силу). - public float AttackBleed { get; init; } - - /// Острая кровопотеря добычи за один удар (доля объёма крови, ×силу) — основной путь к смерти. - public float AttackBloodLoss { get; init; } -} - -/// -/// Нужда (Defs/Needs/) как ДАННЫЕ: имя, тип (deplete — убывает, низкое = срочно; drive — растёт под -/// условием, высокое = срочно), действие-исполнитель (по id, см. реестр поведений), темпы и порог -/// интеллекта. Модер добавляет нужду виду/всем одной записью; если она ссылается на существующее -/// действие — без правки кода. Хранилище нужд особи — динамическое (см. NeedSet/AnimalNeeds). -/// -public sealed class NeedDef : Def -{ - /// Тип: "deplete" (убывает, утоляется действием) или "drive" (растёт под условием). - public string Kind { get; init; } = "deplete"; - - /// Id действия-исполнителя, удовлетворяющего нужду (Eat/Drink/Sleep/Mate/…). - public string Action { get; init; } = ""; - - /// Темп убывания за игровой день (drive: темп спада, когда условие отсутствует). - public float DecayPerDay { get; init; } = 0.5f; - - /// drive: темп роста за день, пока активно условие . - public float RisePerDay { get; init; } = 1f; - - /// deplete: насколько за день восполняет нужду исполнение действия. - public float FeedPerDay { get; init; } = 4f; - - /// drive: id хедифа, под которым нужда растёт (напр. "Rut"); null — растёт всегда. - public string? RisesUnder { get; init; } - - /// deplete: на нуле копит счётчик голодной смерти (A3). - public bool Lethal { get; init; } - - /// Порог размера мозга, с которого нужда активна (гейт интеллектом — фаза A7). - public float MinBrain { get; init; } -} - -/// -/// Мысль настроения (Defs/Thoughts/) как ДАННЫЕ (фаза A8, модель thoughts RimWorld): событие -/// (роды/спаривание/утоление жажды/…) вешает на особь временный сдвиг настроения, затухающий за -/// . Триггеры событий — код (реестр AnimalThoughts), а величины/метка — -/// данные: модер правит силу и длительность мысли одной записью JSON, без пересборки. -/// -public sealed class ThoughtDef : Def -{ - /// Сдвиг настроения [-1,1] (положительный — радость, отрицательный — горе). - public float MoodOffset { get; init; } - - /// Сколько игровых дней мысль живёт, прежде чем угаснуть. - public float DurationDays { get; init; } = 1f; -} - -/// -/// Пункт дев-инструмента (Defs/DevTools/) — ЧТОБЫ МОДЫ ДОБАВЛЯЛИ кнопки в дев-спавнер без правки кода. -/// — id обработчика из реестра (spawn-animal/spawn-plant/infect/damage/kill/…), -/// — параметр (defName вида/хедифа). Виды животных/растений спавнер берёт из их -/// дефов сам (модовый контент появляется автоматически); DevToolDef добавляет ПРОЧИЕ действия. -/// -public sealed class DevToolDef : Def -{ - /// Категория для группировки в панели (например "tool"). - public string Category { get; init; } = "tool"; - - /// Id обработчика-инструмента из реестра кода (spawn-animal/spawn-plant/infect/damage/kill). - public string Kind { get; init; } = ""; - - /// Параметр инструмента: defName вида/хедифа и т.п. (по смыслу Kind). - public string Target { get; init; } = ""; -} - -/// -/// Навык (Defs/Skills/) как ДАННЫЕ: уровень особи [0..1] растёт от практики (XP при действии, -/// ускоряется страстью) и медленно угасает без неё к полу . Эффект навыка — -/// код по его id (как у нужд/действий): добыча→выпас, охота→урон, уклонение→бегство. Модер добавляет -/// навык строкой JSON; новый эффект — код. — ключ локализации (skill.*). -/// -public sealed class SkillDef : Def -{ - /// Категория (для группировки в UI), напр. "survival". - public string Category { get; init; } = ""; - - /// Скорость обучения за игровой день непрерывной практики (при страсти ×1). - public float LearnRate { get; init; } = 0.5f; - - /// Угасание уровня за игровой день без практики. - public float DecayPerDay { get; init; } = 0.02f; - - /// Пол угасания (ниже не падает) — «не забывается совсем». - public float FloorLevel { get; init; } -} - -/// -/// Часть тела/орган (вложенный объект ) как ДАННЫЕ: иерархия (родитель), -/// вес попадания, максимум HP (масштабируется размером тела), флаг жизненной важности и вклады в -/// способности (capacity id → доля). Модер задаёт любую анатомию (8 ног, два сердца) без правки кода. -/// -public sealed class BodyPartDef -{ - /// Имя части (уникально в теле); на него ссылается детей. - public string Name { get; init; } = ""; - - /// Имя родительской части ("" — корневая, напр. торс). - public string Parent { get; init; } = ""; - - /// Вес попадания (доля тела) — для будущего выбора задетой части. - public float Coverage { get; init; } = 0.1f; - - /// Базовый максимум HP (× размер тела при создании особи). - public float MaxHp { get; init; } = 10f; - - /// Жизненно важная: уничтожение → смерть. - public bool Vital { get; init; } - - /// Вклады в способности: capacity id (см. AnimalCapacities) → доля (сумма по телу ≈ 1). - public Dictionary Capacities { get; init; } = new(); -} - -/// -/// Анатомия вида (Defs/Bodies/): дерево частей тела/органов. Используется системой здоровья (фаза -/// A5): из неё строятся инстансы частей особи, по их HP/крови считаются способности (capacities). -/// -public sealed class BodyDef : Def -{ - /// Части тела/органы вида. - public BodyPartDef[] Parts { get; init; } = []; -} - -/// -/// Состояние/хедиф организма (Defs/Hediffs/): гон, болезни, позже раны/возрастные эффекты. Фаза A6: -/// болезнь прогрессирует (), параллельно растёт иммунитет -/// () — гонка: иммунитет→1 раньше = выздоровление, severity→ -/// раньше = смерть. Снижает способности () и даёт боль. -/// -public sealed class HediffDef : Def -{ - /// Начальная тяжесть при навешивании (гон — 1; болезнь — малая, напр. 0.05). - public float InitialSeverity { get; init; } = 1f; - - /// Прирост тяжести за игровой день (0 — статичный хедиф вроде гона). - public float SeverityPerDay { get; init; } - - /// Прирост иммунитета за день (0 — иммунитета нет, не выздоравливает сам). - public float ImmunityPerDay { get; init; } - - /// Тяжесть, при которой наступает смерть (0 — не летально). - public float LethalSeverity { get; init; } - - /// Боль при тяжести 1 (масштабируется тяжестью) — вход в сознание/настроение. - public float Pain { get; init; } - - /// Шанс подхватить фоном за день (0 — не фоновая болезнь). - public float AmbientPerDay { get; init; } - - /// - /// Кровопотеря за игровой день при тяжести 1 (масштабируется тяжестью): доля объёма крови, которую - /// рана теряет в сутки. 0 — хедиф не вызывает кровотечения. Кровь на нуле → смерть (см. система здоровья). - /// - public float BloodLossPerDay { get; init; } - - /// Модификаторы способностей: capacity id → множитель при тяжести 1 (лерп 1→factor по severity). - public Dictionary CapMods { get; init; } = new(); -} diff --git a/src/LittleSim/Net/NetLerp.cs b/src/LittleSim/Net/NetLerp.cs new file mode 100644 index 0000000..331ad50 --- /dev/null +++ b/src/LittleSim/Net/NetLerp.cs @@ -0,0 +1,25 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using MrGameEng.Graphics; + +namespace LittleSim.Net; + +/// +/// Сглаживание сетевой позиции между снапшотами. Сервер шлёт ~10 снапшотов в секунду, +/// а рендер идёт на частоте кадра — без сглаживания жители телепортируются рывками. +/// Снапшот пишет в ; система ловит это (позиция +/// разошлась с нарисованной), запоминает цель и каждый кадр экспоненциально подтягивает +/// видимую позицию к цели, записывая её обратно в трансформ для рендера. +/// +public struct NetLerp : IComponent +{ + /// Нарисованная (сглаженная) позиция прошлого кадра. + public Vector2 Visual; + + /// Последняя серверная позиция — цель сглаживания. + public Vector2 Target; + + /// Ложь до первого кадра: стартуем точно с серверной позиции, без подлёта. + public bool Initialized; +} diff --git a/src/LittleSim/Net/NetSmoothing.cs b/src/LittleSim/Net/NetSmoothingSystem.cs similarity index 57% rename from src/LittleSim/Net/NetSmoothing.cs rename to src/LittleSim/Net/NetSmoothingSystem.cs index 281eba1..dae7ab5 100644 --- a/src/LittleSim/Net/NetSmoothing.cs +++ b/src/LittleSim/Net/NetSmoothingSystem.cs @@ -5,25 +5,6 @@ using MrGameEng.Graphics; namespace LittleSim.Net; -/// -/// Сглаживание сетевой позиции между снапшотами. Сервер шлёт ~10 снапшотов в секунду, -/// а рендер идёт на частоте кадра — без сглаживания жители телепортируются рывками. -/// Снапшот пишет в ; система ловит это (позиция -/// разошлась с нарисованной), запоминает цель и каждый кадр экспоненциально подтягивает -/// видимую позицию к цели, записывая её обратно в трансформ для рендера. -/// -public struct NetLerp : IComponent -{ - /// Нарисованная (сглаженная) позиция прошлого кадра. - public Vector2 Visual; - - /// Последняя серверная позиция — цель сглаживания. - public Vector2 Target; - - /// Ложь до первого кадра: стартуем точно с серверной позиции, без подлёта. - public bool Initialized; -} - /// /// Двигает к и пишет результат в /// . Ставится после прокачки сети и до рендера. diff --git a/src/LittleSim/Scenes/ContentHelpers.cs b/src/LittleSim/Scenes/ContentCommands.cs similarity index 58% rename from src/LittleSim/Scenes/ContentHelpers.cs rename to src/LittleSim/Scenes/ContentCommands.cs index cb33af2..ad96cfb 100644 --- a/src/LittleSim/Scenes/ContentHelpers.cs +++ b/src/LittleSim/Scenes/ContentCommands.cs @@ -10,69 +10,6 @@ using MrGameEng.Lighting; namespace LittleSim.Scenes; -/// Спавн растительности по скаттеру дефов террейна. -internal static class ScatterSpawner -{ - // Ниже этой световой пригодности (при полном дневном свете клетки) растение не сеем: теневому виду - // (грибы, optimalLight≈0.15) в открытой клетке не место — без полога крон ему тут не выжить, поэтому - // он и не должен появляться, пока такой ниши нет. Солнечные виды (≥0.7 пригодности) проходят свободно. - private const float ShadeSpawnThreshold = 0.15f; - - /// - /// Бросает каждую запись скаттера клетки независимо; при успехе ставит одно растение со - /// случайным смещением и случайной (детерминированной сидом) зрелостью — мир после генерации - /// разновозрастный. Теневые виды не сеются в ярко освещённые клетки (см. ). - /// Стадия и спрайт берутся из , дальше растением управляет . - /// - public static void Spawn( - Scene scene, - GameContent content, - PlantSet plants, - TerrainDef terrain, - Point cell, - int cellSize, - Random random - ) - { - // Статичная освещённость клетки в полдень: затенена только светонепроницаемым террейном (полог - // крон сейчас не учитывается). По ней решаем, годится ли клетка теневому виду. - var exposure = terrain.BlocksLight ? LightmapBuilder.OccluderShade : 1f; - - foreach (var entry in terrain.Scatter) - { - if (entry.Options.Count == 0 || random.NextSingle() >= entry.Chance) - { - continue; - } - - var def = content.Defs.Get(entry.Options[random.Next(entry.Options.Count)]); - if ( - Suitability.Gaussian(exposure, def.Genome.OptimalLight, def.Genome.LightTolerance) - < ShadeSpawnThreshold - ) - { - continue; // теневому виду в ярко освещённой клетке не место - } - - var index = plants.IndexOf(def); - var px = (cell.X + 0.3f + random.NextSingle() * 0.4f) * cellSize; - var py = (cell.Y + 0.3f + random.NextSingle() * 0.4f) * cellSize; - var age = random.NextSingle() * plants[index].MaturityDays; - - PlantFactory.Create( - scene.Store, - plants, - index, - new Vector2(px, py), - age, - plants[index].Template.Generate(random), - terrain.Fertility, - cellSize - ); - } - } -} - /// Консольные команды контента: моды, язык, дефы, атласы (общие для сцен). internal static class ContentCommands { diff --git a/src/LittleSim/Scenes/ScatterSpawner.cs b/src/LittleSim/Scenes/ScatterSpawner.cs new file mode 100644 index 0000000..6ae0f96 --- /dev/null +++ b/src/LittleSim/Scenes/ScatterSpawner.cs @@ -0,0 +1,74 @@ +using Friflo.Engine.ECS; +using LittleSim.Content; +using LittleSim.Sim; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.DevConsole; +using MrGameEng.Graphics; +using MrGameEng.Lighting; + +namespace LittleSim.Scenes; + +/// Спавн растительности по скаттеру дефов террейна. +internal static class ScatterSpawner +{ + // Ниже этой световой пригодности (при полном дневном свете клетки) растение не сеем: теневому виду + // (грибы, optimalLight≈0.15) в открытой клетке не место — без полога крон ему тут не выжить, поэтому + // он и не должен появляться, пока такой ниши нет. Солнечные виды (≥0.7 пригодности) проходят свободно. + private const float ShadeSpawnThreshold = 0.15f; + + /// + /// Бросает каждую запись скаттера клетки независимо; при успехе ставит одно растение со + /// случайным смещением и случайной (детерминированной сидом) зрелостью — мир после генерации + /// разновозрастный. Теневые виды не сеются в ярко освещённые клетки (см. ). + /// Стадия и спрайт берутся из , дальше растением управляет . + /// + public static void Spawn( + Scene scene, + GameContent content, + PlantSet plants, + TerrainDef terrain, + Point cell, + int cellSize, + Random random + ) + { + // Статичная освещённость клетки в полдень: затенена только светонепроницаемым террейном (полог + // крон сейчас не учитывается). По ней решаем, годится ли клетка теневому виду. + var exposure = terrain.BlocksLight ? LightmapBuilder.OccluderShade : 1f; + + foreach (var entry in terrain.Scatter) + { + if (entry.Options.Count == 0 || random.NextSingle() >= entry.Chance) + { + continue; + } + + var def = content.Defs.Get(entry.Options[random.Next(entry.Options.Count)]); + if ( + Suitability.Gaussian(exposure, def.Genome.OptimalLight, def.Genome.LightTolerance) + < ShadeSpawnThreshold + ) + { + continue; // теневому виду в ярко освещённой клетке не место + } + + var index = plants.IndexOf(def); + var px = (cell.X + 0.3f + random.NextSingle() * 0.4f) * cellSize; + var py = (cell.Y + 0.3f + random.NextSingle() * 0.4f) * cellSize; + var age = random.NextSingle() * plants[index].MaturityDays; + + PlantFactory.Create( + scene.Store, + plants, + index, + new Vector2(px, py), + age, + plants[index].Template.Generate(random), + terrain.Fertility, + cellSize + ); + } + } +} diff --git a/src/LittleSim/Scenes/WorldScene.Commands.cs b/src/LittleSim/Scenes/WorldScene.Commands.cs new file mode 100644 index 0000000..664b6a3 --- /dev/null +++ b/src/LittleSim/Scenes/WorldScene.Commands.cs @@ -0,0 +1,669 @@ +using System; +using System.Collections.Generic; +using Friflo.Engine.ECS; +using LittleSim.App; +using LittleSim.Content; +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.Formulas; +using MrGameEng.Genetics; +using MrGameEng.Graphics; +using MrGameEng.Host; +using MrGameEng.Input; +using MrGameEng.Inspector; +using MrGameEng.Lighting; +using MrGameEng.Tilemaps; +using MrGameEng.UI; +using Myra.Graphics2D; +using Myra.Graphics2D.Brushes; +using Myra.Graphics2D.UI; + +namespace LittleSim.Scenes; + +// Часть WorldScene: регистрация и обработчики команд дев-консоли. +public sealed partial class WorldScene +{ + private void RegisterCommands(DevConsole console, GameContent content, ModAtlases atlases) + { + ContentCommands.Register(console, content, atlases); + console.Register( + "regen", + "regen [seed] — regenerate the world with a new seed", + (c, args) => + { + 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(_config with { Seed = seed }), + Transitions.Fade(0.6f) + ); + } + ); + console.Register( + "formula", + "formula — compile and evaluate an expression (gene-formula engine demo)", + (c, args) => + { + if (args.Length == 0) + { + c.WriteLine( + "usage: formula e.g. formula clamp(lerp(0, 10, 0.5), 0, 8)" + ); + return; + } + + var expression = string.Join(' ', args); + try + { + c.WriteLine($"{expression} = {Formula.Compile(expression).Evaluate()}"); + } + catch (FormulaException e) + { + c.WriteLine($"error: {e.Message}"); + } + } + ); + console.Register( + "gene", + "gene [seed] — generate a genome from Gene defs, show alleles/traits and a bred child", + (c, args) => RunGeneDemo(c, content, args) + ); + console.Register( + "plant", + "plant [seed] — sample a species genome and show its gene-driven traits/products", + (c, args) => RunPlantDemo(c, content, args) + ); + console.Register( + "animal", + "animal [seed] — sample an animal genome and show its gene-driven traits", + (c, args) => RunAnimalDemo(c, content, args) + ); + console.Register( + "diet", + "diet [species] — gene-derived diet (herbivory/carnivory/omnivory), role and hunting stats", + (c, args) => RunDiet(c, content, args) + ); + console.Register( + "popstats", + "popstats [species] — live population trait means and generation span (selection drift)", + (c, args) => RunPopStats(c, args) + ); + console.Register( + "body", + "body — dump a species body parts and a healthy individual's capacities", + (c, args) => RunBodyDemo(c, content, args) + ); + console.Register( + "infect", + "infect [hediff] — give every animal a disease (default Fever) to test the health system", + (c, args) => RunInfect(c, content, args) + ); + console.Register( + "intel", + "intel [consciousness] — effective intelligence (brain x consciousness) and which needs it gates", + (c, args) => RunIntel(c, content, args) + ); + console.Register( + "mood", + "mood [species] — live population mood (avg/min/max) and content/stressed counts", + (c, args) => RunMood(c, args) + ); + console.Register( + "skills", + "skills — mean skill levels across living animals (grow from practice, decay unused)", + (c, _) => RunSkills(c) + ); + console.Register( + "menu", + "menu — return to the main menu", + (_, _) => Switch(new MainMenuScene()) + ); + } + + // Демонстрация генной системы (фаза G2): из Gene-дефов генерируем геном, печатаем аллели, + // выраженные значения и признаки (вычисленные формулами), затем скрещиваем двух особей. + private static void RunGeneDemo(DevConsole console, GameContent content, string[] args) + { + var genes = content.Defs.All(); + if (genes.Count == 0) + { + console.WriteLine("no Gene defs loaded"); + return; + } + + var registry = genes.ToDictionary(g => g.DefName, g => g, StringComparer.Ordinal); + var seed = args.Length > 0 ? int.Parse(args[0]) : Random.Shared.Next(); + var random = new Random(seed); + + console.WriteLine($"genome from {genes.Count} genes, seed {seed}:"); + var parentA = Genome.Generate(genes, random); + foreach (var gene in genes) + { + var allele = parentA[gene.DefName]; + console.WriteLine( + $" {gene.DefName}: [{allele.A:0.##}, {allele.B:0.##}] -> {parentA.Express(gene):0.###}" + ); + } + + console.WriteLine("traits:"); + foreach (var (trait, value) in Phenotype.Compute(parentA, registry).OrderBy(t => t.Key)) + { + console.WriteLine($" {trait} = {value:0.###}"); + } + + var parentB = Genome.Generate(genes, random); + var child = Genome.Breed(parentA, parentB, registry, random); + var childTraits = Phenotype.Compute(child, registry); + console.WriteLine( + $"bred child: {child.Alleles.Count} genes, " + + $"vigor={childTraits.GetValueOrDefault("vigor"):0.###}, " + + $"lifespan={childTraits.GetValueOrDefault("lifespan"):0.#}, " + + $"variant={childTraits.GetValueOrDefault("variant"):0}" + ); + } + + // Демонстрация генного контента (фаза G4): по виду берём его набор генов, генерируем особь и + // печатаем признаки роста/жизни и продукты (плоды/добыча с количеством от генов). + private void RunPlantDemo(DevConsole console, GameContent content, string[] args) + { + var species = content.Defs.NamesOf("Plant"); + if (args.Length == 0) + { + console.WriteLine("usage: plant [seed] e.g. plant TreeOakA"); + console.WriteLine($"species: {string.Join(", ", species)}"); + return; + } + + if (!content.Defs.TryGet(args[0], out var def)) + { + console.WriteLine($"no plant '{args[0]}'; species: {string.Join(", ", species)}"); + return; + } + + var index = _plants.IndexOf(def); + var seed = args.Length > 1 ? int.Parse(args[1]) : Random.Shared.Next(); + var genome = _plants[index].Template.Generate(new Random(seed)); + var traits = PlantPhenotype.FromTraits(Phenotype.Compute(genome, _plants.GeneRegistry)); + + console.WriteLine( + $"{def.DefName} (seed {seed}){(traits.IsVariant ? " [variant morph]" : "")}:" + ); + console.WriteLine( + $" vigor {traits.Vigor:0.##}, lifespan {traits.Lifespan:0} d, " + + $"optimalLight {traits.OptimalLight:0.##}, leafHue {traits.LeafHue:0.##}" + ); + var (tMin, tLow, tHigh, tMax) = traits.TemperatureBand(); + console.WriteLine( + $" temp: grows {tMin:0.#}..{tMax:0.#}°C, optimal {tLow:0.#}..{tHigh:0.#}°C " + + $"(cold {traits.ColdHardiness:0.#}, heat {traits.HeatHardiness:0.#})" + ); + console.WriteLine( + $" defense: toxicity {traits.Toxicity:0.##}, thorns {traits.Thorns:0.##}, " + + $"palatability {traits.Palatability:0.##} (growth ×{traits.DefenseGrowthFactor():0.##})" + ); + console.WriteLine($" mass (mature): {def.BaseMassKg:0.##} kg"); + + if (def.HarvestProduct is { } harvest) + { + console.WriteLine( + $" harvest: {ProductLabel(content, harvest)} x{traits.HarvestAmount:0.#}" + ); + } + + if (def.FruitProduct is { } fruit && traits.FruitYield >= 1f) + { + var season = content.Languages.Get( + $"season.{((Season)traits.FruitSeason).ToString().ToLowerInvariant()}" + ); + console.WriteLine( + $" fruit: {ProductLabel(content, fruit)} x{traits.FruitYield:0.#} in {season}" + ); + } + else + { + console.WriteLine(" barren (no fruit)"); + } + } + + // Демонстрация животного (фаза A1): по виду берём его набор генов, генерируем особь и печатаем + // признаки, вычисленные формулами генов (тело/мозг/метаболизм/чувства/мех). + private void RunAnimalDemo(DevConsole console, GameContent content, string[] args) + { + var species = content.Defs.NamesOf("Animal"); + if (args.Length == 0) + { + console.WriteLine("usage: animal [seed] e.g. animal Deer"); + console.WriteLine($"species: {string.Join(", ", species)}"); + return; + } + + if (!content.Defs.TryGet(args[0], out var def)) + { + console.WriteLine($"no animal '{args[0]}'; species: {string.Join(", ", species)}"); + return; + } + + var index = _animals.IndexOf(def); + var seed = args.Length > 1 ? int.Parse(args[1]) : Random.Shared.Next(); + var genome = _animals.GenerateGenome(index, new Random(seed)); + var traits = AnimalPhenotype.FromTraits(Phenotype.Compute(genome, _animals.GeneRegistry)); + var sex = AnimalFactory.IsMaleGenome(genome) ? "male" : "female"; + + console.WriteLine($"{def.DefName} ({sex}, seed {seed}):"); + console.WriteLine( + $" body {traits.BodySize:0.##} cells, maturity {traits.MaturityAge:0} d, " + + $"lifespan {traits.Lifespan:0} d, brain {traits.BrainSize:0.##}, metabolism {traits.Metabolism:0.##}" + ); + console.WriteLine( + $" move {traits.MoveSpeed:0.##}, vision {traits.Vision:0.##}, blood {traits.BloodVolume:0.##}, " + + $"insulation {traits.Insulation:0.##}, furHue {traits.FurHue:0.##}" + ); + console.WriteLine( + $" diet: herb {traits.Herbivory:0.##} / carn {traits.Carnivory:0.##} / omni {traits.Omnivory:0.##}, " + + $"toxinTolerance {traits.ToxinTolerance:0.##}" + ); + console.WriteLine( + $" reproduction: {(traits.Oviparous ? "oviparous (lays eggs)" : "viviparous (live birth)")}, " + + $"gestation {traits.GestationDays:0} d, litter {traits.LitterSize:0.#}" + ); + console.WriteLine( + $" behaviour: insulation {traits.Insulation:0.##}, sociability {traits.Sociability:0.##}" + ); + var adultMass = Mass.OfAnimal(def, traits, 1f); + console.WriteLine( + $" mass: {adultMass:0.#} kg (adult), carries ~{Mass.CarryCapacity(adultMass, 1f):0.#} kg" + ); + } + + // Диета из генов (предатор-кластер): по каждому виду печатает herbivory/carnivory/omnivory, + // выведенный рацион (ест растения/мясо), роль и боевые параметры — проверка, что хищники заданы. + private void RunDiet(DevConsole console, GameContent content, string[] args) + { + var species = content.Defs.NamesOf("Animal"); + for (var s = 0; s < _animals.Count; s++) + { + var sp = _animals[s]; + if ( + args.Length > 0 + && !string.Equals(sp.Def.DefName, args[0], StringComparison.OrdinalIgnoreCase) + ) + { + continue; + } + + var genome = _animals.GenerateGenome(s, new Random(0x01E7 + s)); + var traits = AnimalPhenotype.FromTraits( + Phenotype.Compute(genome, _animals.GeneRegistry) + ); + var (eatsPlants, eatsMeat) = AnimalFactory.Diet(traits, sp.Def); + var role = + eatsMeat ? (eatsPlants ? "omnivore" : "carnivore") + : eatsPlants ? "herbivore" + : "none"; + console.WriteLine( + $"{sp.Def.DefName}: {role} — herb {traits.Herbivory:0.##} / carn {traits.Carnivory:0.##} / " + + $"omni {traits.Omnivory:0.##} -> plants={eatsPlants} meat={eatsMeat}, spawn {sp.Def.SpawnPer1000Cells:0.##}/1000" + ); + if (eatsMeat && sp.Def.AttackDamage > 0f) + { + console.WriteLine( + $" hunt: dmg {sp.Def.AttackDamage:0}/d, bleed {sp.Def.AttackBleed:0.#}/d, " + + $"bloodloss {sp.Def.AttackBloodLoss:0.#}/d, range {sp.Def.AttackRangeCells:0.#} cells" + ); + } + } + + if (args.Length == 0) + { + console.WriteLine($"species: {string.Join(", ", species)}"); + } + } + + // Наблюдаемость отбора (фаза A2): средние ключевых признаков живой популяции и размах поколений — + // видно дрейф генов под отбором. Без аргумента — все виды; с аргументом — один вид. + private void RunPopStats(DevConsole console, string[] args) + { + if (_animals.Count == 0) + { + console.WriteLine("no animal species"); + return; + } + + var count = new int[_animals.Count]; + var body = new double[_animals.Count]; + var brain = new double[_animals.Count]; + var life = new double[_animals.Count]; + var move = new double[_animals.Count]; + var tox = new double[_animals.Count]; + var maxGen = new int[_animals.Count]; + + Store + .Query() + .ForEachEntity( + (ref AnimalOrganism o, Entity _) => + { + var s = o.Species; + count[s]++; + body[s] += o.Traits.BodySize; + brain[s] += o.Traits.BrainSize; + life[s] += o.Traits.Lifespan; + move[s] += o.Traits.MoveSpeed; + tox[s] += o.Traits.ToxinTolerance; + if (o.Generation > maxGen[s]) + { + maxGen[s] = o.Generation; + } + } + ); + + var filter = args.Length > 0 ? args[0] : null; + var any = false; + for (var s = 0; s < _animals.Count; s++) + { + var name = _animals[s].Def.DefName; + if ( + count[s] == 0 + || ( + filter is not null + && !string.Equals(name, filter, StringComparison.OrdinalIgnoreCase) + ) + ) + { + continue; + } + + any = true; + var c = count[s]; + console.WriteLine( + $"{name}: n={c}, gen 0..{maxGen[s]}, body {body[s] / c:0.##}, " + + $"brain {brain[s] / c:0.##}, lifespan {life[s] / c:0} d, move {move[s] / c:0.##}, " + + $"toxinTol {tox[s] / c:0.##}" + ); + } + + if (!any) + { + console.WriteLine(filter is null ? "no animals alive" : $"no '{filter}' alive"); + } + + // Другая сторона гонки вооружений: средняя защита живых растений (коэволюция C2). Без фильтра. + if (filter is null) + { + var pn = 0; + double pTox = 0, + pThorn = 0, + pPalat = 0; + Store + .Query() + .ForEachEntity( + (ref PlantOrganism o, Entity _) => + { + pn++; + pTox += o.Traits.Toxicity; + pThorn += o.Traits.Thorns; + pPalat += o.Traits.Palatability; + } + ); + if (pn > 0) + { + console.WriteLine( + $"plants: n={pn}, toxicity {pTox / pn:0.###}, thorns {pThorn / pn:0.###}, " + + $"palatability {pPalat / pn:0.###}" + ); + } + } + } + + // Наблюдаемость здоровья (фаза A5): дерево частей тела вида и способности здоровой особи. + private void RunBodyDemo(DevConsole console, GameContent content, string[] args) + { + var species = content.Defs.NamesOf("Animal"); + if (args.Length == 0) + { + console.WriteLine("usage: body e.g. body Deer"); + console.WriteLine($"species: {string.Join(", ", species)}"); + return; + } + + if (!content.Defs.TryGet(args[0], out var def)) + { + console.WriteLine($"no animal '{args[0]}'; species: {string.Join(", ", species)}"); + return; + } + + if (string.IsNullOrEmpty(def.Body) || !content.Defs.TryGet(def.Body, out var body)) + { + console.WriteLine($"{def.DefName}: no body"); + return; + } + + console.WriteLine($"{def.DefName} body '{body.DefName}': {body.Parts.Length} parts"); + foreach (var part in body.Parts) + { + var caps = + part.Capacities.Count > 0 + ? " [" + + string.Join( + ", ", + part.Capacities.Select(kv => $"{kv.Key} {kv.Value:0.##}") + ) + + "]" + : ""; + console.WriteLine( + $" {part.Name}{(part.Vital ? " *vital" : "")} hp{part.MaxHp:0}{caps}" + ); + } + + var state = HealthState.Create(body, 1f); + console.WriteLine( + "capacities (healthy): " + + string.Join( + ", ", + state.Capacities.OrderBy(k => k.Key).Select(kv => $"{kv.Key} {kv.Value:0.##}") + ) + ); + } + + // Тест системы здоровья (фаза A6): навешивает болезнь всем живым животным. + private void RunInfect(DevConsole console, GameContent content, string[] args) + { + var name = args.Length > 0 ? args[0] : "Fever"; + if (!content.Defs.TryGet(name, out var def)) + { + console.WriteLine($"no hediff '{name}'"); + return; + } + + var count = 0; + Store + .Query() + .ForEachEntity( + (ref Health h, Entity _) => + { + h.State?.Add(def); + count++; + } + ); + console.WriteLine($"infected {count} animals with {def.DefName}"); + } + + // Наблюдаемость интеллекта (фаза A7): мозг особи (ген) × сознание = эффективный интеллект, который + // гейтит набор нужд/действий. Показывает активные/отключённые нужды при заданном уровне сознания + // (по умолчанию 1 — здоров; меньше — имитация повреждения мозга/болезни/боли). + private void RunIntel(DevConsole console, GameContent content, string[] args) + { + var species = content.Defs.NamesOf("Animal"); + if (args.Length == 0) + { + console.WriteLine( + "usage: intel [consciousness 0..1] [seed] e.g. intel Deer 0.6" + ); + console.WriteLine($"species: {string.Join(", ", species)}"); + return; + } + + if (!content.Defs.TryGet(args[0], out var def)) + { + console.WriteLine($"no animal '{args[0]}'; species: {string.Join(", ", species)}"); + return; + } + + var consciousness = + args.Length > 1 + ? Math.Clamp( + float.Parse(args[1], System.Globalization.CultureInfo.InvariantCulture), + 0f, + 1f + ) + : 1f; + var seed = args.Length > 2 ? int.Parse(args[2]) : Random.Shared.Next(); + var index = _animals.IndexOf(def); + var genome = _animals.GenerateGenome(index, new Random(seed)); + var traits = AnimalPhenotype.FromTraits(Phenotype.Compute(genome, _animals.GeneRegistry)); + var intelligence = traits.BrainSize * consciousness; + + console.WriteLine( + $"{def.DefName} (seed {seed}): brain {traits.BrainSize:0.##} x consciousness {consciousness:0.##} " + + $"= intelligence {intelligence:0.###}" + ); + var active = new List(); + var gated = new List(); + for (var k = 0; k < _needs.Count; k++) + { + var need = _needs[k]; + (need.MinBrain > intelligence ? gated : active).Add( + $"{need.DefName}({need.MinBrain:0.##})" + ); + } + + console.WriteLine($" active: {(active.Count > 0 ? string.Join(", ", active) : "—")}"); + console.WriteLine($" gated: {(gated.Count > 0 ? string.Join(", ", gated) : "—")}"); + } + + // Наблюдаемость настроения (фаза A8): среднее/мин/макс настроения живой популяции по виду и счётчики + // довольных (>0.7) / угнетённых (<0.35). Считает только особей с активным настроением (тир 4+). + private void RunMood(DevConsole console, string[] args) + { + if (_animals.Count == 0) + { + console.WriteLine("no animal species"); + return; + } + + var count = new int[_animals.Count]; + var sum = new double[_animals.Count]; + var min = new float[_animals.Count]; + var max = new float[_animals.Count]; + var content_ = new int[_animals.Count]; + var stressed = new int[_animals.Count]; + Array.Fill(min, 1f); + + Store + .Query() + .ForEachEntity( + (ref AnimalOrganism o, ref Mood m, Entity _) => + { + if (!m.Active) + { + return; // тир ниже зверя — настроения нет + } + + var s = o.Species; + count[s]++; + sum[s] += m.Value; + min[s] = MathF.Min(min[s], m.Value); + max[s] = MathF.Max(max[s], m.Value); + if (m.Value > 0.7f) + { + content_[s]++; + } + else if (m.Value < 0.35f) + { + stressed[s]++; + } + } + ); + + var filter = args.Length > 0 ? args[0] : null; + var any = false; + for (var s = 0; s < _animals.Count; s++) + { + var name = _animals[s].Def.DefName; + if ( + count[s] == 0 + || ( + filter is not null + && !string.Equals(name, filter, StringComparison.OrdinalIgnoreCase) + ) + ) + { + continue; + } + + any = true; + console.WriteLine( + $"{name}: n={count[s]}, mood {sum[s] / count[s]:0.##} (min {min[s]:0.##}, max {max[s]:0.##}), " + + $"content {content_[s]}, stressed {stressed[s]}" + ); + } + + if (!any) + { + console.WriteLine(filter is null ? "no animals with mood" : $"no '{filter}' with mood"); + } + } + + // Наблюдаемость навыков: средний уровень каждого навыка по живым животным (растут от практики). + private void RunSkills(DevConsole console) + { + if (_skills.Count == 0) + { + console.WriteLine("no skill defs loaded"); + return; + } + + var sum = new double[_skills.Count]; + var n = 0; + Store + .Query() + .ForEachEntity( + (ref Skills s, Entity _) => + { + if (s.Levels is null) + { + return; + } + + n++; + for (var i = 0; i < _skills.Count && i < s.Levels.Length; i++) + { + sum[i] += s.Levels[i]; + } + } + ); + + console.WriteLine($"skills over {n} animals (mean level):"); + for (var i = 0; i < _skills.Count; i++) + { + console.WriteLine( + $" {_skills[i].DefName}: {(n > 0 ? sum[i] / n : 0):0.###} " + + $"(learn {_skills[i].LearnRate:0.##}/d, decay {_skills[i].DecayPerDay:0.###}/d)" + ); + } + } + + private static string ProductLabel(GameContent content, string productDefName) => + content.Defs.TryGet(productDefName, out var product) + ? content.Languages.Get(product.Label) + : productDefName; +} diff --git a/src/LittleSim/Scenes/WorldScene.SaveLoad.cs b/src/LittleSim/Scenes/WorldScene.SaveLoad.cs new file mode 100644 index 0000000..b161c59 --- /dev/null +++ b/src/LittleSim/Scenes/WorldScene.SaveLoad.cs @@ -0,0 +1,463 @@ +using System; +using System.Collections.Generic; +using Friflo.Engine.ECS; +using LittleSim.App; +using LittleSim.Content; +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.Formulas; +using MrGameEng.Genetics; +using MrGameEng.Graphics; +using MrGameEng.Host; +using MrGameEng.Input; +using MrGameEng.Inspector; +using MrGameEng.Lighting; +using MrGameEng.Tilemaps; +using MrGameEng.UI; +using Myra.Graphics2D; +using Myra.Graphics2D.Brushes; +using Myra.Graphics2D.UI; + +namespace LittleSim.Scenes; + +// Часть WorldScene: сохранение/загрузка мира и стартовый спавн животных. +public sealed partial class WorldScene +{ + private string SaveWorld() + { + var save = new WorldSave + { + Name = _config.Name, + Width = _config.Width, + Height = _config.Height, + Seed = _config.Seed, + SmoothPasses = _config.SmoothPasses, + SavedUtc = DateTime.UtcNow, + ElapsedSeconds = Context.Clock.TotalTime, + }; + + // Снимок всей популяции растений: вид (деф), позиция, возраст, стадия, почва и геном. + Store + .Query() + .ForEachEntity( + ( + ref PlantGrowth grow, + ref PlantOrganism org, + ref Transform2D transform, + Entity _ + ) => + { + save.Plants.Add( + new PlantSave + { + Species = _plants[grow.Species].Def.DefName, + X = transform.Position.X, + Y = transform.Position.Y, + AgeDays = grow.AgeDays, + Stage = grow.Stage, + CellFertility = grow.CellFertility, + Genome = org.Genome.ToDictionary(), + } + ); + } + ); + + SaveAnimals(save); + + // Население — живой счётчик животных на момент сохранения (а не статичный пресет конфига). + save.Population = save.Animals.Count; + + new SaveStore().Write(save); + Log.Info( + $"World '{_config.Name}' saved ({save.Plants.Count} plants, " + + $"{save.Animals.Count} animals, {save.Corpses.Count} corpses, {save.Eggs.Count} eggs)" + ); + return _config.Name; + } + + // Снимок популяции животных и трупов: всё нужное, чтобы эволюция/состояние пережили сейв/лоад + // (а не пересыпались из сида). Health/Mood/Pregnant берём через entity — запрос ограничен 5 типами. + private void SaveAnimals(WorldSave save) + { + Store + .Query() + .ForEachEntity( + ( + ref AnimalOrganism org, + ref AnimalGrowth grow, + ref AnimalNeeds needs, + ref Transform2D transform, + Entity entity + ) => + { + var record = new AnimalSave + { + Species = _animals[org.Species].Def.DefName, + X = transform.Position.X, + Y = transform.Position.Y, + Generation = org.Generation, + Stage = grow.Stage, + AgeDays = grow.AgeDays, + Genome = org.Genome.ToDictionary(), + Needs = needs.Values, + Starve = needs.Starve, + }; + + if (entity.GetComponent().State is { } health) + { + record.BloodLevel = health.BloodLevel; + record.PartHp = health.Parts.Select(p => p.Hp).ToArray(); + foreach (var hediff in health.Hediffs) + { + record.Hediffs.Add( + new HediffSave + { + DefName = hediff.Def.DefName, + Severity = hediff.Severity, + Immunity = hediff.Immunity, + } + ); + } + } + + var mood = entity.GetComponent(); + record.Mood = mood.Value; + record.MoodActive = mood.Active; + if (mood.Thoughts is not null) + { + foreach (var thought in mood.Thoughts) + { + record.Thoughts.Add( + new ThoughtSave + { + DefName = thought.Def.DefName, + RemainingDays = thought.RemainingDays, + } + ); + } + } + + if (entity.TryGetComponent(out var preg)) + { + record.Pregnant = true; + record.FatherGenome = preg.FatherGenome.ToDictionary(); + record.DueInDays = preg.DueInDays; + record.Litter = preg.Litter; + } + + if (entity.TryGetComponent(out var sk)) + { + record.SkillLevels = sk.Levels ?? []; + record.SkillPassions = sk.Passions is null + ? [] + : Array.ConvertAll(sk.Passions, p => (int)p); + } + + save.Animals.Add(record); + } + ); + + Store + .Query() + .ForEachEntity( + (ref Corpse corpse, ref Transform2D transform, Entity _) => + save.Corpses.Add( + new CorpseSave + { + Species = _animals[corpse.Species].Def.DefName, + X = transform.Position.X, + Y = transform.Position.Y, + RotDays = corpse.RotDays, + BodySize = corpse.BodySize, + Meat = corpse.Meat, + } + ) + ); + + Store + .Query() + .ForEachEntity( + (ref Egg egg, ref Transform2D transform, Entity _) => + save.Eggs.Add( + new EggSave + { + Species = _animals[egg.Species].Def.DefName, + X = transform.Position.X, + Y = transform.Position.Y, + Generation = egg.Generation, + IncubateDays = egg.IncubateDays, + Genome = egg.Genome?.ToDictionary() ?? new(), + } + ) + ); + } + + private void RestorePlants(GameContent content) + { + var fallback = new Random(_config.Seed); // для старых/битых сейвов без генома + foreach (var plant in _save!.Plants) + { + if (!content.Defs.TryGet(plant.Species, out var def)) + { + continue; // вид пропал (мод убрали) — пропускаем растение + } + + var index = _plants.IndexOf(def); + var genome = plant.Genome is { Count: > 0 } + ? new Genome(plant.Genome) + : _plants[index].Template.Generate(fallback); + PlantFactory.Create( + Store, + _plants, + index, + new Vector2(plant.X, plant.Y), + plant.AgeDays, + genome, + plant.CellFertility, + CellSize + ); + } + } + + // Восстанавливает популяцию животных и трупы из сейва: фабрика собирает особь из генома (геном + // несёт и пол), затем динамическое состояние (нужды/стадия/здоровье/настроение/беременность) + // накладывается поверх. Старый/битый сейв без генома — особь регенерируется из шаблона вида. + private void RestoreAnimals(GameContent content) + { + var fallback = new Random(_config.Seed); + foreach (var saved in _save!.Animals) + { + if (!content.Defs.TryGet(saved.Species, out var def)) + { + continue; // вид пропал (мод убрали) — пропускаем особь + } + + var index = _animals.IndexOf(def); + var genome = saved.Genome is { Count: > 0 } + ? new Genome(saved.Genome) + : _animals.GenerateGenome(index, fallback); + var entity = AnimalFactory.Create( + Store, + _animals, + _needs, + _skills, + index, + new Vector2(saved.X, saved.Y), + saved.AgeDays, + genome, + saved.Generation, + CellSize + ); + RestoreAnimalState(entity, saved, content); + } + + foreach (var saved in _save.Corpses) + { + if (!content.Defs.TryGet(saved.Species, out var def)) + { + continue; + } + + var corpse = AnimalFactory.CreateCorpse( + Store, + _animals, + _animals.IndexOf(def), + new Vector2(saved.X, saved.Y), + saved.BodySize, + saved.Meat, + CellSize + ); + ref var data = ref corpse.GetComponent(); + data.RotDays = saved.RotDays; + data.Stage = -1; // форсируем пере-тинт стадии в CorpseSystem на следующем тике + } + + foreach (var saved in _save.Eggs) + { + if (!content.Defs.TryGet(saved.Species, out var def)) + { + continue; // вид пропал (мод убрали) — пропускаем яйцо + } + + var index = _animals.IndexOf(def); + var genome = saved.Genome is { Count: > 0 } + ? new Genome(saved.Genome) + : _animals.GenerateGenome(index, fallback); + AnimalFactory.CreateEgg( + Store, + _animals, + index, + new Vector2(saved.X, saved.Y), + genome, + saved.Generation, + saved.IncubateDays, + CellSize + ); + } + + Log.Info( + $"Restored {_save.Animals.Count} animals, {_save.Corpses.Count} corpses, {_save.Eggs.Count} eggs from save" + ); + } + + // Накладывает на свежесозданную особь сохранённое динамическое состояние. Ref-компоненты правим до + // AddComponent(Pregnant) (структурное изменение инвалидирует ссылки); Health — managed, безопасен. + private void RestoreAnimalState(Entity entity, AnimalSave saved, GameContent content) + { + ref var needs = ref entity.GetComponent(); + if (saved.Needs is { Length: > 0 } && saved.Needs.Length == _needs.Count) + { + needs.Values = saved.Needs; // состав совпал — иначе остаются свежие из фабрики + } + + needs.Starve = saved.Starve; + entity.GetComponent().Stage = saved.Stage; + + ref var mood = ref entity.GetComponent(); + mood.Value = saved.Mood; + mood.Active = saved.MoodActive; + mood.Thoughts.Clear(); + foreach (var thought in saved.Thoughts) + { + if (content.Defs.TryGet(thought.DefName, out var tdef)) + { + mood.Thoughts.Add( + new ThoughtInstance { Def = tdef, RemainingDays = thought.RemainingDays } + ); + } + } + + if (entity.GetComponent().State is { } health) + { + health.BloodLevel = saved.BloodLevel; + // HP частей применяем только при точном совпадении анатомии (как для нужд/навыков): иначе + // мод сменил состав/порядок частей тела, и значения легли бы не на те части — оставляем свежее. + if (saved.PartHp.Length == health.Parts.Length) + { + for (var i = 0; i < health.Parts.Length; i++) + { + health.Parts[i].Hp = saved.PartHp[i]; + } + } + else if (saved.PartHp.Length > 0) + { + Log.Warning( + $"Скелет вида '{saved.Species}' изменился ({saved.PartHp.Length}→{health.Parts.Length} частей) — HP частей не восстановлены." + ); + } + + foreach (var hediff in saved.Hediffs) + { + if (content.Defs.TryGet(hediff.DefName, out var hdef)) + { + health.AddHediff(hdef, hediff.Severity, hediff.Immunity); + } + } + + health.RecomputeCapacities(); + } + + if (saved.Pregnant && saved.FatherGenome is { Count: > 0 }) + { + entity.AddComponent( + new Pregnant + { + FatherGenome = new Genome(saved.FatherGenome), + DueInDays = saved.DueInDays, + Litter = saved.Litter, + } + ); + } + + // Навыки: накладываем сохранённые уровни/страсть, если состав совпал (иначе — свежие из фабрики). + if (entity.TryGetComponent(out var skills) && skills.Levels is not null) + { + if (saved.SkillLevels.Length == skills.Levels.Length) + { + saved.SkillLevels.CopyTo(skills.Levels, 0); + } + + if (skills.Passions is not null && saved.SkillPassions.Length == skills.Passions.Length) + { + for (var i = 0; i < skills.Passions.Length; i++) + { + skills.Passions[i] = (byte)Math.Clamp(saved.SkillPassions[i], 0, 255); + } + } + } + } + + // Детерминированный начальный спавн животных по данным: каждый вид с SpawnPer1000Cells > 0 + // рассыпается по клеткам суши с плотностью из дефа; геном и возраст особи — из шаблона вида. + private void SpawnAnimals() + { + var landCells = 0; + for (var i = 0; i < _cellLand.Length; i++) + { + if (_cellLand[i]) + { + landCells++; + } + } + + if (_animals.Count == 0 || landCells == 0) + { + return; + } + + // Свой офсет сида (0x5EED занят системой решений животных — нельзя связывать их RNG-потоки). + var random = new Random(_config.Seed + 0x5A1D); + var spawned = 0; + for (var s = 0; s < _animals.Count; s++) + { + var def = _animals[s].Def; + if (def.SpawnPer1000Cells <= 0f) + { + continue; // вид не спавнится сам (напр. появляется только в сейве/событием) + } + + var count = Math.Max(1, (int)MathF.Round(landCells / 1000f * def.SpawnPer1000Cells)); + var maxAge = + (def.Genome.TryGetValue("GeneLifespan", out var lifespan) ? lifespan : 160f) + * 0.85f; + var placed = 0; + var attempts = 0; + while (placed < count && attempts < count * 50) + { + attempts++; + var cell = random.Next(_cellLand.Length); + if (!_cellLand[cell]) + { + continue; + } + + var cx = cell % _config.Width; + var cy = cell / _config.Width; + var px = (cx + 0.2f + random.NextSingle() * 0.6f) * CellSize; + var py = (cy + 0.2f + random.NextSingle() * 0.6f) * CellSize; + AnimalFactory.Create( + Store, + _animals, + _needs, + _skills, + s, + new Vector2(px, py), + ageDays: random.NextSingle() * maxAge, + _animals.GenerateGenome(s, random), + generation: 0, + CellSize + ); + placed++; + } + + spawned += placed; + } + + Log.Info($"Spawned {spawned} animals ({_animals.Count} species)"); + } +} diff --git a/src/LittleSim/Scenes/WorldScene.Terrain.cs b/src/LittleSim/Scenes/WorldScene.Terrain.cs new file mode 100644 index 0000000..aa590d3 --- /dev/null +++ b/src/LittleSim/Scenes/WorldScene.Terrain.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Generic; +using Friflo.Engine.ECS; +using LittleSim.App; +using LittleSim.Content; +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.Formulas; +using MrGameEng.Genetics; +using MrGameEng.Graphics; +using MrGameEng.Host; +using MrGameEng.Input; +using MrGameEng.Inspector; +using MrGameEng.Lighting; +using MrGameEng.Tilemaps; +using MrGameEng.UI; +using Myra.Graphics2D; +using Myra.Graphics2D.Brushes; +using Myra.Graphics2D.UI; + +namespace LittleSim.Scenes; + +// Часть WorldScene: генерация рельефа, поле берега и окклюдеры освещения. +public sealed partial class WorldScene +{ + /// + /// Строит сущность- (тайлсет из дефов рельефа + сетка по карте высот) и + /// заполняет по-клеточные массивы плодородности/суши для жизненного цикла. Если + /// задан (новый мир) — рассыпает растительность по дефам; + /// при загрузке передаётся null (растения восстанавливаются из сейва отдельно). + /// + private void BuildTerrain( + GameContent content, + ModAtlases atlases, + Microsoft.Xna.Framework.Graphics.GraphicsDevice device, + AssetManager assets, + Random? scatterRandom + ) + { + var white = new Texture2DRegion(assets.Load(GameAssets.Textures.White)); + + // Тайлсет из дефов: есть поверхность — текстура из атласа, нет — тонированный тайл (вода). + var tiles = new TileSet(); + var tileByDef = new Dictionary(); + foreach (var terrain in content.Terrains.All) + { + tileByDef[terrain] = terrain.Surface is { } surface + ? tiles.Add(atlases.GetRegion(device, surface)) + : tiles.Add(white, terrain.Tint); + } + + // Рельеф детерминирован сидом и масштабом деталей — и для нового мира, и для загрузки. + var heights = WorldGenerator.Generate( + _config.Width, + _config.Height, + _config.Seed, + _config.SmoothPasses + ); + var grid = new TileGrid(_config.Width, _config.Height); + var cells = _config.Width * _config.Height; + _cellFertility = new float[cells]; + _cellLand = new bool[cells]; + _cellOccluderBase = new bool[cells]; + _cellOccluder = new bool[cells]; + for (var x = 0; x < _config.Width; x++) + { + for (var y = 0; y < _config.Height; y++) + { + var terrain = content.Terrains.Classify(heights[x, y]); + grid[x, y] = tileByDef[terrain]; + var cell = y * _config.Width + x; + _cellFertility[cell] = terrain.Fertility; + _cellLand[cell] = terrain.IsLand; + _cellOccluderBase[cell] = terrain.BlocksLight; + if (scatterRandom is not null) + { + ScatterSpawner.Spawn( + this, + content, + _plants, + terrain, + new Point(x, y), + CellSize, + scatterRandom + ); + } + } + } + + Store.CreateEntity(new Tilemap(grid, tiles, CellSize)); + } + + // Поле «ближайший берег» по клеткам мира: для каждой клетки — мировая позиция ближайшей береговой + // клетки (суша, граничащая с водой), найденная многоисточниковым BFS за O(клеток). Звери читают его + // за O(1) при поиске водопоя (см. AnimalDecisionSystem.TryFindShore), не сканируя береговую линию. + // Пусто, если воды/берега в мире нет — тогда пить негде (зверь не пойдёт). + private Vector2[] ComputeShoreField() + { + var w = _config.Width; + var h = _config.Height; + var count = w * h; + + // BFS: nearest[c] — индекс ближайшей береговой клетки. Источники — все береговые клетки (шаг 0). + var nearest = new int[count]; + Array.Fill(nearest, -1); + var queue = new Queue(); + for (var cell = 0; cell < count; cell++) + { + if (!_cellLand[cell]) + { + continue; + } + + var x = cell % w; + var y = cell / w; + var nearWater = + (x > 0 && !_cellLand[cell - 1]) + || (x < w - 1 && !_cellLand[cell + 1]) + || (y > 0 && !_cellLand[cell - w]) + || (y < h - 1 && !_cellLand[cell + w]); + if (nearWater) + { + nearest[cell] = cell; + queue.Enqueue(cell); + } + } + + if (queue.Count == 0) + { + return []; // нет берега + } + + // Волна по 4-связности: каждой клетке достаётся источник ближайшего фронта (берег по шагам сетки). + while (queue.Count > 0) + { + var cell = queue.Dequeue(); + var src = nearest[cell]; + var x = cell % w; + var y = cell / w; + if (x > 0 && nearest[cell - 1] < 0) + { + nearest[cell - 1] = src; + queue.Enqueue(cell - 1); + } + + if (x < w - 1 && nearest[cell + 1] < 0) + { + nearest[cell + 1] = src; + queue.Enqueue(cell + 1); + } + + if (y > 0 && nearest[cell - w] < 0) + { + nearest[cell - w] = src; + queue.Enqueue(cell - w); + } + + if (y < h - 1 && nearest[cell + w] < 0) + { + nearest[cell + w] = src; + queue.Enqueue(cell + w); + } + } + + var field = new Vector2[count]; + for (var cell = 0; cell < count; cell++) + { + var src = nearest[cell]; + if (src >= 0) + { + field[cell] = new Vector2((src % w + 0.5f) * CellSize, (src / w + 0.5f) * CellSize); + } + } + + return field; + } + + // Затеняют ли зрелые деревья землю. Сейчас выключено: одиночное дерево = 1 клетка-окклюдер, а спрайт + // кроны крупнее и смещён, плюс направленная тень тянется «хвостом» — на открытой почве это читается как + // странные оторванные пятна. Вернуть, когда у кроны будет настоящий многоклеточный след затенения. + private static readonly bool TreesCastShade = false; + + // Сетка окклюдеров для лайтмапа: светонепроницаемый террейн (сейчас такого нет) + опц. зрелые деревья. + private bool[] BuildOccluders() + { + Array.Copy(_cellOccluderBase, _cellOccluder, _cellOccluder.Length); + if (!TreesCastShade) + { + return _cellOccluder; // ровное освещение — без странных теней от отдельных деревьев + } + + Store + .Query() + .ForEachEntity( + (ref PlantGrowth grow, ref Transform2D transform, Entity _) => + { + var species = _plants[grow.Species]; + if ( + species.Def.TrunkRadiusCells <= 0f + || grow.Stage < species.Stages.Length - 1 + ) + { + return; // затеняют только зрелые деревья (со стволом) + } + + var cx = Math.Clamp( + (int)(transform.Position.X / CellSize), + 0, + _config.Width - 1 + ); + var cy = Math.Clamp( + (int)(transform.Position.Y / CellSize), + 0, + _config.Height - 1 + ); + _cellOccluder[cy * _config.Width + cx] = true; + } + ); + return _cellOccluder; + } +} diff --git a/src/LittleSim/Scenes/WorldScene.Ui.cs b/src/LittleSim/Scenes/WorldScene.Ui.cs new file mode 100644 index 0000000..b36864c --- /dev/null +++ b/src/LittleSim/Scenes/WorldScene.Ui.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using Friflo.Engine.ECS; +using LittleSim.App; +using LittleSim.Content; +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.Formulas; +using MrGameEng.Genetics; +using MrGameEng.Graphics; +using MrGameEng.Host; +using MrGameEng.Input; +using MrGameEng.Inspector; +using MrGameEng.Lighting; +using MrGameEng.Tilemaps; +using MrGameEng.UI; +using Myra.Graphics2D; +using Myra.Graphics2D.Brushes; +using Myra.Graphics2D.UI; + +namespace LittleSim.Scenes; + +// Часть WorldScene: HUD-хелперы, полоса скорости, горячие клавиши, переходы. +public sealed partial class WorldScene +{ + // Переход к «ответственному» за событие (двойной клик в журнале): выбирает сущность и центрирует на + // ней камеру, если она ещё существует (актёр мог умереть). Камера зажимается к границам при отрисовке. + private void FocusEntity(Entity camera, int id) + { + if ( + id < 0 + || !Store.TryGetEntityById(id, out var target) + || target.IsNull + || !target.HasComponent() + ) + { + return; + } + + _selection.Select(id); + ref var cam = ref camera.GetComponent(); + cam.Position = target.GetComponent().Position; + } + + // Кнопка-переключатель оверлея HUD: подсвечивается как активная, пока оверлей открыт. + private static TextButton HudToggle(string text, Action onClick, Func isOpen) + { + var button = Ui.ChoiceButton(text); + button.Click += (_, _) => + { + onClick(); + Ui.SetChoiceActive(button, isOpen()); + }; + Ui.SetChoiceActive(button, isOpen()); + return button; + } + + private HorizontalStackPanel BuildSpeedBar(GameContent content) + { + var bar = Ui.Row(6); + bar.HorizontalAlignment = HorizontalAlignment.Center; + bar.VerticalAlignment = VerticalAlignment.Bottom; + bar.Margin = new Thickness(0, 0, 0, 12); + + var hover = new SolidBrush(new Color(40, 54, 72)); // подсветка кнопок скорости при наведении + var pause = new TextButton + { + Text = content.Languages.Get("speed.pause"), + Padding = new Thickness(8, 4), + OverBackground = hover, + }; + pause.Click += (_, _) => _speed.Pause(); + bar.Widgets.Add(pause); + _speedRefreshers.Add(() => pause.TextColor = _speed.IsPaused ? Ui.Accent : Ui.Muted); + + for (var i = 0; i < _speed.Steps.Count; i++) + { + var index = i; + var button = new TextButton + { + Text = $"x{_speed.Steps[i]:0}", + Padding = new Thickness(8, 4), + OverBackground = hover, + }; + button.Click += (_, _) => _speed.SetStep(index); + bar.Widgets.Add(button); + _speedRefreshers.Add(() => + button.TextColor = + !_speed.IsPaused && _speed.StepIndex == index ? Ui.Accent : Ui.Muted + ); + } + + void Refresh() + { + foreach (var refresh in _speedRefreshers) + { + refresh(); + } + } + + _speed.Changed += Refresh; + RegisterUnload(() => _speed.Changed -= Refresh); + Refresh(); + return bar; + } + + private void Hotkeys(InputManager input) + { + if (input.IsKeyPressed(Keys.Escape)) + { + // Esc сначала снимает выделение (если есть), и только потом открывает меню-паузу. + if (_selection.HasSelection) + { + _selection.Clear(); + } + else + { + _pause.Toggle(); + } + } + + if (_pause.IsOpen) + { + return; + } + + if (input.IsKeyPressed(Keys.Space)) + { + _speed.TogglePause(); + } + + if (input.IsKeyPressed(Keys.D1)) + { + _speed.SetStep(0); + } + + if (input.IsKeyPressed(Keys.D2) && _speed.Steps.Count > 1) + { + _speed.SetStep(1); + } + + if (input.IsKeyPressed(Keys.D3) && _speed.Steps.Count > 2) + { + _speed.SetStep(2); + } + } + + private void Switch(Scene scene) + { + if (!Context.Scenes.IsTransitioning) + { + _speed.Resume(); + Context.Scenes.Switch(scene, Transitions.Fade(0.5f)); + } + } +} diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs index 14a8de5..17ac42a 100644 --- a/src/LittleSim/Scenes/WorldScene.cs +++ b/src/LittleSim/Scenes/WorldScene.cs @@ -33,7 +33,7 @@ namespace LittleSim.Scenes; /// почве, размножение по Менделю и смерть), климат и день/ночь. UI: HUD, полоса скорости /// (пауза/x1/x3/x6 + горячие клавиши), меню-пауза (Esc), дев-консоль и ECS-инспектор (F1). /// -public sealed class WorldScene : Scene +public sealed partial class WorldScene : Scene { public const int CellSize = 16; @@ -493,1403 +493,4 @@ public sealed class WorldScene : Scene + (_save is null ? " (new)" : " (loaded)") ); } - - /// - /// Строит сущность- (тайлсет из дефов рельефа + сетка по карте высот) и - /// заполняет по-клеточные массивы плодородности/суши для жизненного цикла. Если - /// задан (новый мир) — рассыпает растительность по дефам; - /// при загрузке передаётся null (растения восстанавливаются из сейва отдельно). - /// - private void BuildTerrain( - GameContent content, - ModAtlases atlases, - Microsoft.Xna.Framework.Graphics.GraphicsDevice device, - AssetManager assets, - Random? scatterRandom - ) - { - var white = new Texture2DRegion(assets.Load(GameAssets.Textures.White)); - - // Тайлсет из дефов: есть поверхность — текстура из атласа, нет — тонированный тайл (вода). - var tiles = new TileSet(); - var tileByDef = new Dictionary(); - foreach (var terrain in content.Terrains.All) - { - tileByDef[terrain] = terrain.Surface is { } surface - ? tiles.Add(atlases.GetRegion(device, surface)) - : tiles.Add(white, terrain.Tint); - } - - // Рельеф детерминирован сидом и масштабом деталей — и для нового мира, и для загрузки. - var heights = WorldGenerator.Generate( - _config.Width, - _config.Height, - _config.Seed, - _config.SmoothPasses - ); - var grid = new TileGrid(_config.Width, _config.Height); - var cells = _config.Width * _config.Height; - _cellFertility = new float[cells]; - _cellLand = new bool[cells]; - _cellOccluderBase = new bool[cells]; - _cellOccluder = new bool[cells]; - for (var x = 0; x < _config.Width; x++) - { - for (var y = 0; y < _config.Height; y++) - { - var terrain = content.Terrains.Classify(heights[x, y]); - grid[x, y] = tileByDef[terrain]; - var cell = y * _config.Width + x; - _cellFertility[cell] = terrain.Fertility; - _cellLand[cell] = terrain.IsLand; - _cellOccluderBase[cell] = terrain.BlocksLight; - if (scatterRandom is not null) - { - ScatterSpawner.Spawn( - this, - content, - _plants, - terrain, - new Point(x, y), - CellSize, - scatterRandom - ); - } - } - } - - Store.CreateEntity(new Tilemap(grid, tiles, CellSize)); - } - - // Переход к «ответственному» за событие (двойной клик в журнале): выбирает сущность и центрирует на - // ней камеру, если она ещё существует (актёр мог умереть). Камера зажимается к границам при отрисовке. - private void FocusEntity(Entity camera, int id) - { - if ( - id < 0 - || !Store.TryGetEntityById(id, out var target) - || target.IsNull - || !target.HasComponent() - ) - { - return; - } - - _selection.Select(id); - ref var cam = ref camera.GetComponent(); - cam.Position = target.GetComponent().Position; - } - - // Кнопка-переключатель оверлея HUD: подсвечивается как активная, пока оверлей открыт. - private static TextButton HudToggle(string text, Action onClick, Func isOpen) - { - var button = Ui.ChoiceButton(text); - button.Click += (_, _) => - { - onClick(); - Ui.SetChoiceActive(button, isOpen()); - }; - Ui.SetChoiceActive(button, isOpen()); - return button; - } - - private HorizontalStackPanel BuildSpeedBar(GameContent content) - { - var bar = Ui.Row(6); - bar.HorizontalAlignment = HorizontalAlignment.Center; - bar.VerticalAlignment = VerticalAlignment.Bottom; - bar.Margin = new Thickness(0, 0, 0, 12); - - var hover = new SolidBrush(new Color(40, 54, 72)); // подсветка кнопок скорости при наведении - var pause = new TextButton - { - Text = content.Languages.Get("speed.pause"), - Padding = new Thickness(8, 4), - OverBackground = hover, - }; - pause.Click += (_, _) => _speed.Pause(); - bar.Widgets.Add(pause); - _speedRefreshers.Add(() => pause.TextColor = _speed.IsPaused ? Ui.Accent : Ui.Muted); - - for (var i = 0; i < _speed.Steps.Count; i++) - { - var index = i; - var button = new TextButton - { - Text = $"x{_speed.Steps[i]:0}", - Padding = new Thickness(8, 4), - OverBackground = hover, - }; - button.Click += (_, _) => _speed.SetStep(index); - bar.Widgets.Add(button); - _speedRefreshers.Add(() => - button.TextColor = - !_speed.IsPaused && _speed.StepIndex == index ? Ui.Accent : Ui.Muted - ); - } - - void Refresh() - { - foreach (var refresh in _speedRefreshers) - { - refresh(); - } - } - - _speed.Changed += Refresh; - RegisterUnload(() => _speed.Changed -= Refresh); - Refresh(); - return bar; - } - - private void Hotkeys(InputManager input) - { - if (input.IsKeyPressed(Keys.Escape)) - { - // Esc сначала снимает выделение (если есть), и только потом открывает меню-паузу. - if (_selection.HasSelection) - { - _selection.Clear(); - } - else - { - _pause.Toggle(); - } - } - - if (_pause.IsOpen) - { - return; - } - - if (input.IsKeyPressed(Keys.Space)) - { - _speed.TogglePause(); - } - - if (input.IsKeyPressed(Keys.D1)) - { - _speed.SetStep(0); - } - - if (input.IsKeyPressed(Keys.D2) && _speed.Steps.Count > 1) - { - _speed.SetStep(1); - } - - if (input.IsKeyPressed(Keys.D3) && _speed.Steps.Count > 2) - { - _speed.SetStep(2); - } - } - - private string SaveWorld() - { - var save = new WorldSave - { - Name = _config.Name, - Width = _config.Width, - Height = _config.Height, - Seed = _config.Seed, - SmoothPasses = _config.SmoothPasses, - SavedUtc = DateTime.UtcNow, - ElapsedSeconds = Context.Clock.TotalTime, - }; - - // Снимок всей популяции растений: вид (деф), позиция, возраст, стадия, почва и геном. - Store - .Query() - .ForEachEntity( - ( - ref PlantGrowth grow, - ref PlantOrganism org, - ref Transform2D transform, - Entity _ - ) => - { - save.Plants.Add( - new PlantSave - { - Species = _plants[grow.Species].Def.DefName, - X = transform.Position.X, - Y = transform.Position.Y, - AgeDays = grow.AgeDays, - Stage = grow.Stage, - CellFertility = grow.CellFertility, - Genome = org.Genome.ToDictionary(), - } - ); - } - ); - - SaveAnimals(save); - - // Население — живой счётчик животных на момент сохранения (а не статичный пресет конфига). - save.Population = save.Animals.Count; - - new SaveStore().Write(save); - Log.Info( - $"World '{_config.Name}' saved ({save.Plants.Count} plants, " - + $"{save.Animals.Count} animals, {save.Corpses.Count} corpses, {save.Eggs.Count} eggs)" - ); - return _config.Name; - } - - // Снимок популяции животных и трупов: всё нужное, чтобы эволюция/состояние пережили сейв/лоад - // (а не пересыпались из сида). Health/Mood/Pregnant берём через entity — запрос ограничен 5 типами. - private void SaveAnimals(WorldSave save) - { - Store - .Query() - .ForEachEntity( - ( - ref AnimalOrganism org, - ref AnimalGrowth grow, - ref AnimalNeeds needs, - ref Transform2D transform, - Entity entity - ) => - { - var record = new AnimalSave - { - Species = _animals[org.Species].Def.DefName, - X = transform.Position.X, - Y = transform.Position.Y, - Generation = org.Generation, - Stage = grow.Stage, - AgeDays = grow.AgeDays, - Genome = org.Genome.ToDictionary(), - Needs = needs.Values, - Starve = needs.Starve, - }; - - if (entity.GetComponent().State is { } health) - { - record.BloodLevel = health.BloodLevel; - record.PartHp = health.Parts.Select(p => p.Hp).ToArray(); - foreach (var hediff in health.Hediffs) - { - record.Hediffs.Add( - new HediffSave - { - DefName = hediff.Def.DefName, - Severity = hediff.Severity, - Immunity = hediff.Immunity, - } - ); - } - } - - var mood = entity.GetComponent(); - record.Mood = mood.Value; - record.MoodActive = mood.Active; - if (mood.Thoughts is not null) - { - foreach (var thought in mood.Thoughts) - { - record.Thoughts.Add( - new ThoughtSave - { - DefName = thought.Def.DefName, - RemainingDays = thought.RemainingDays, - } - ); - } - } - - if (entity.TryGetComponent(out var preg)) - { - record.Pregnant = true; - record.FatherGenome = preg.FatherGenome.ToDictionary(); - record.DueInDays = preg.DueInDays; - record.Litter = preg.Litter; - } - - if (entity.TryGetComponent(out var sk)) - { - record.SkillLevels = sk.Levels ?? []; - record.SkillPassions = sk.Passions is null - ? [] - : Array.ConvertAll(sk.Passions, p => (int)p); - } - - save.Animals.Add(record); - } - ); - - Store - .Query() - .ForEachEntity( - (ref Corpse corpse, ref Transform2D transform, Entity _) => - save.Corpses.Add( - new CorpseSave - { - Species = _animals[corpse.Species].Def.DefName, - X = transform.Position.X, - Y = transform.Position.Y, - RotDays = corpse.RotDays, - BodySize = corpse.BodySize, - Meat = corpse.Meat, - } - ) - ); - - Store - .Query() - .ForEachEntity( - (ref Egg egg, ref Transform2D transform, Entity _) => - save.Eggs.Add( - new EggSave - { - Species = _animals[egg.Species].Def.DefName, - X = transform.Position.X, - Y = transform.Position.Y, - Generation = egg.Generation, - IncubateDays = egg.IncubateDays, - Genome = egg.Genome?.ToDictionary() ?? new(), - } - ) - ); - } - - private void RestorePlants(GameContent content) - { - var fallback = new Random(_config.Seed); // для старых/битых сейвов без генома - foreach (var plant in _save!.Plants) - { - if (!content.Defs.TryGet(plant.Species, out var def)) - { - continue; // вид пропал (мод убрали) — пропускаем растение - } - - var index = _plants.IndexOf(def); - var genome = plant.Genome is { Count: > 0 } - ? new Genome(plant.Genome) - : _plants[index].Template.Generate(fallback); - PlantFactory.Create( - Store, - _plants, - index, - new Vector2(plant.X, plant.Y), - plant.AgeDays, - genome, - plant.CellFertility, - CellSize - ); - } - } - - // Восстанавливает популяцию животных и трупы из сейва: фабрика собирает особь из генома (геном - // несёт и пол), затем динамическое состояние (нужды/стадия/здоровье/настроение/беременность) - // накладывается поверх. Старый/битый сейв без генома — особь регенерируется из шаблона вида. - private void RestoreAnimals(GameContent content) - { - var fallback = new Random(_config.Seed); - foreach (var saved in _save!.Animals) - { - if (!content.Defs.TryGet(saved.Species, out var def)) - { - continue; // вид пропал (мод убрали) — пропускаем особь - } - - var index = _animals.IndexOf(def); - var genome = saved.Genome is { Count: > 0 } - ? new Genome(saved.Genome) - : _animals.GenerateGenome(index, fallback); - var entity = AnimalFactory.Create( - Store, - _animals, - _needs, - _skills, - index, - new Vector2(saved.X, saved.Y), - saved.AgeDays, - genome, - saved.Generation, - CellSize - ); - RestoreAnimalState(entity, saved, content); - } - - foreach (var saved in _save.Corpses) - { - if (!content.Defs.TryGet(saved.Species, out var def)) - { - continue; - } - - var corpse = AnimalFactory.CreateCorpse( - Store, - _animals, - _animals.IndexOf(def), - new Vector2(saved.X, saved.Y), - saved.BodySize, - saved.Meat, - CellSize - ); - ref var data = ref corpse.GetComponent(); - data.RotDays = saved.RotDays; - data.Stage = -1; // форсируем пере-тинт стадии в CorpseSystem на следующем тике - } - - foreach (var saved in _save.Eggs) - { - if (!content.Defs.TryGet(saved.Species, out var def)) - { - continue; // вид пропал (мод убрали) — пропускаем яйцо - } - - var index = _animals.IndexOf(def); - var genome = saved.Genome is { Count: > 0 } - ? new Genome(saved.Genome) - : _animals.GenerateGenome(index, fallback); - AnimalFactory.CreateEgg( - Store, - _animals, - index, - new Vector2(saved.X, saved.Y), - genome, - saved.Generation, - saved.IncubateDays, - CellSize - ); - } - - Log.Info( - $"Restored {_save.Animals.Count} animals, {_save.Corpses.Count} corpses, {_save.Eggs.Count} eggs from save" - ); - } - - // Накладывает на свежесозданную особь сохранённое динамическое состояние. Ref-компоненты правим до - // AddComponent(Pregnant) (структурное изменение инвалидирует ссылки); Health — managed, безопасен. - private void RestoreAnimalState(Entity entity, AnimalSave saved, GameContent content) - { - ref var needs = ref entity.GetComponent(); - if (saved.Needs is { Length: > 0 } && saved.Needs.Length == _needs.Count) - { - needs.Values = saved.Needs; // состав совпал — иначе остаются свежие из фабрики - } - - needs.Starve = saved.Starve; - entity.GetComponent().Stage = saved.Stage; - - ref var mood = ref entity.GetComponent(); - mood.Value = saved.Mood; - mood.Active = saved.MoodActive; - mood.Thoughts.Clear(); - foreach (var thought in saved.Thoughts) - { - if (content.Defs.TryGet(thought.DefName, out var tdef)) - { - mood.Thoughts.Add( - new ThoughtInstance { Def = tdef, RemainingDays = thought.RemainingDays } - ); - } - } - - if (entity.GetComponent().State is { } health) - { - health.BloodLevel = saved.BloodLevel; - // HP частей применяем только при точном совпадении анатомии (как для нужд/навыков): иначе - // мод сменил состав/порядок частей тела, и значения легли бы не на те части — оставляем свежее. - if (saved.PartHp.Length == health.Parts.Length) - { - for (var i = 0; i < health.Parts.Length; i++) - { - health.Parts[i].Hp = saved.PartHp[i]; - } - } - else if (saved.PartHp.Length > 0) - { - Log.Warning( - $"Скелет вида '{saved.Species}' изменился ({saved.PartHp.Length}→{health.Parts.Length} частей) — HP частей не восстановлены." - ); - } - - foreach (var hediff in saved.Hediffs) - { - if (content.Defs.TryGet(hediff.DefName, out var hdef)) - { - health.AddHediff(hdef, hediff.Severity, hediff.Immunity); - } - } - - health.RecomputeCapacities(); - } - - if (saved.Pregnant && saved.FatherGenome is { Count: > 0 }) - { - entity.AddComponent( - new Pregnant - { - FatherGenome = new Genome(saved.FatherGenome), - DueInDays = saved.DueInDays, - Litter = saved.Litter, - } - ); - } - - // Навыки: накладываем сохранённые уровни/страсть, если состав совпал (иначе — свежие из фабрики). - if (entity.TryGetComponent(out var skills) && skills.Levels is not null) - { - if (saved.SkillLevels.Length == skills.Levels.Length) - { - saved.SkillLevels.CopyTo(skills.Levels, 0); - } - - if (skills.Passions is not null && saved.SkillPassions.Length == skills.Passions.Length) - { - for (var i = 0; i < skills.Passions.Length; i++) - { - skills.Passions[i] = (byte)Math.Clamp(saved.SkillPassions[i], 0, 255); - } - } - } - } - - // Детерминированный начальный спавн животных по данным: каждый вид с SpawnPer1000Cells > 0 - // рассыпается по клеткам суши с плотностью из дефа; геном и возраст особи — из шаблона вида. - private void SpawnAnimals() - { - var landCells = 0; - for (var i = 0; i < _cellLand.Length; i++) - { - if (_cellLand[i]) - { - landCells++; - } - } - - if (_animals.Count == 0 || landCells == 0) - { - return; - } - - // Свой офсет сида (0x5EED занят системой решений животных — нельзя связывать их RNG-потоки). - var random = new Random(_config.Seed + 0x5A1D); - var spawned = 0; - for (var s = 0; s < _animals.Count; s++) - { - var def = _animals[s].Def; - if (def.SpawnPer1000Cells <= 0f) - { - continue; // вид не спавнится сам (напр. появляется только в сейве/событием) - } - - var count = Math.Max(1, (int)MathF.Round(landCells / 1000f * def.SpawnPer1000Cells)); - var maxAge = - (def.Genome.TryGetValue("GeneLifespan", out var lifespan) ? lifespan : 160f) - * 0.85f; - var placed = 0; - var attempts = 0; - while (placed < count && attempts < count * 50) - { - attempts++; - var cell = random.Next(_cellLand.Length); - if (!_cellLand[cell]) - { - continue; - } - - var cx = cell % _config.Width; - var cy = cell / _config.Width; - var px = (cx + 0.2f + random.NextSingle() * 0.6f) * CellSize; - var py = (cy + 0.2f + random.NextSingle() * 0.6f) * CellSize; - AnimalFactory.Create( - Store, - _animals, - _needs, - _skills, - s, - new Vector2(px, py), - ageDays: random.NextSingle() * maxAge, - _animals.GenerateGenome(s, random), - generation: 0, - CellSize - ); - placed++; - } - - spawned += placed; - } - - Log.Info($"Spawned {spawned} animals ({_animals.Count} species)"); - } - - // Поле «ближайший берег» по клеткам мира: для каждой клетки — мировая позиция ближайшей береговой - // клетки (суша, граничащая с водой), найденная многоисточниковым BFS за O(клеток). Звери читают его - // за O(1) при поиске водопоя (см. AnimalDecisionSystem.TryFindShore), не сканируя береговую линию. - // Пусто, если воды/берега в мире нет — тогда пить негде (зверь не пойдёт). - private Vector2[] ComputeShoreField() - { - var w = _config.Width; - var h = _config.Height; - var count = w * h; - - // BFS: nearest[c] — индекс ближайшей береговой клетки. Источники — все береговые клетки (шаг 0). - var nearest = new int[count]; - Array.Fill(nearest, -1); - var queue = new Queue(); - for (var cell = 0; cell < count; cell++) - { - if (!_cellLand[cell]) - { - continue; - } - - var x = cell % w; - var y = cell / w; - var nearWater = - (x > 0 && !_cellLand[cell - 1]) - || (x < w - 1 && !_cellLand[cell + 1]) - || (y > 0 && !_cellLand[cell - w]) - || (y < h - 1 && !_cellLand[cell + w]); - if (nearWater) - { - nearest[cell] = cell; - queue.Enqueue(cell); - } - } - - if (queue.Count == 0) - { - return []; // нет берега - } - - // Волна по 4-связности: каждой клетке достаётся источник ближайшего фронта (берег по шагам сетки). - while (queue.Count > 0) - { - var cell = queue.Dequeue(); - var src = nearest[cell]; - var x = cell % w; - var y = cell / w; - if (x > 0 && nearest[cell - 1] < 0) - { - nearest[cell - 1] = src; - queue.Enqueue(cell - 1); - } - - if (x < w - 1 && nearest[cell + 1] < 0) - { - nearest[cell + 1] = src; - queue.Enqueue(cell + 1); - } - - if (y > 0 && nearest[cell - w] < 0) - { - nearest[cell - w] = src; - queue.Enqueue(cell - w); - } - - if (y < h - 1 && nearest[cell + w] < 0) - { - nearest[cell + w] = src; - queue.Enqueue(cell + w); - } - } - - var field = new Vector2[count]; - for (var cell = 0; cell < count; cell++) - { - var src = nearest[cell]; - if (src >= 0) - { - field[cell] = new Vector2((src % w + 0.5f) * CellSize, (src / w + 0.5f) * CellSize); - } - } - - return field; - } - - // Затеняют ли зрелые деревья землю. Сейчас выключено: одиночное дерево = 1 клетка-окклюдер, а спрайт - // кроны крупнее и смещён, плюс направленная тень тянется «хвостом» — на открытой почве это читается как - // странные оторванные пятна. Вернуть, когда у кроны будет настоящий многоклеточный след затенения. - private static readonly bool TreesCastShade = false; - - // Сетка окклюдеров для лайтмапа: светонепроницаемый террейн (сейчас такого нет) + опц. зрелые деревья. - private bool[] BuildOccluders() - { - Array.Copy(_cellOccluderBase, _cellOccluder, _cellOccluder.Length); - if (!TreesCastShade) - { - return _cellOccluder; // ровное освещение — без странных теней от отдельных деревьев - } - - Store - .Query() - .ForEachEntity( - (ref PlantGrowth grow, ref Transform2D transform, Entity _) => - { - var species = _plants[grow.Species]; - if ( - species.Def.TrunkRadiusCells <= 0f - || grow.Stage < species.Stages.Length - 1 - ) - { - return; // затеняют только зрелые деревья (со стволом) - } - - var cx = Math.Clamp( - (int)(transform.Position.X / CellSize), - 0, - _config.Width - 1 - ); - var cy = Math.Clamp( - (int)(transform.Position.Y / CellSize), - 0, - _config.Height - 1 - ); - _cellOccluder[cy * _config.Width + cx] = true; - } - ); - return _cellOccluder; - } - - private void Switch(Scene scene) - { - if (!Context.Scenes.IsTransitioning) - { - _speed.Resume(); - Context.Scenes.Switch(scene, Transitions.Fade(0.5f)); - } - } - - private void RegisterCommands(DevConsole console, GameContent content, ModAtlases atlases) - { - ContentCommands.Register(console, content, atlases); - console.Register( - "regen", - "regen [seed] — regenerate the world with a new seed", - (c, args) => - { - 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(_config with { Seed = seed }), - Transitions.Fade(0.6f) - ); - } - ); - console.Register( - "formula", - "formula — compile and evaluate an expression (gene-formula engine demo)", - (c, args) => - { - if (args.Length == 0) - { - c.WriteLine( - "usage: formula e.g. formula clamp(lerp(0, 10, 0.5), 0, 8)" - ); - return; - } - - var expression = string.Join(' ', args); - try - { - c.WriteLine($"{expression} = {Formula.Compile(expression).Evaluate()}"); - } - catch (FormulaException e) - { - c.WriteLine($"error: {e.Message}"); - } - } - ); - console.Register( - "gene", - "gene [seed] — generate a genome from Gene defs, show alleles/traits and a bred child", - (c, args) => RunGeneDemo(c, content, args) - ); - console.Register( - "plant", - "plant [seed] — sample a species genome and show its gene-driven traits/products", - (c, args) => RunPlantDemo(c, content, args) - ); - console.Register( - "animal", - "animal [seed] — sample an animal genome and show its gene-driven traits", - (c, args) => RunAnimalDemo(c, content, args) - ); - console.Register( - "diet", - "diet [species] — gene-derived diet (herbivory/carnivory/omnivory), role and hunting stats", - (c, args) => RunDiet(c, content, args) - ); - console.Register( - "popstats", - "popstats [species] — live population trait means and generation span (selection drift)", - (c, args) => RunPopStats(c, args) - ); - console.Register( - "body", - "body — dump a species body parts and a healthy individual's capacities", - (c, args) => RunBodyDemo(c, content, args) - ); - console.Register( - "infect", - "infect [hediff] — give every animal a disease (default Fever) to test the health system", - (c, args) => RunInfect(c, content, args) - ); - console.Register( - "intel", - "intel [consciousness] — effective intelligence (brain x consciousness) and which needs it gates", - (c, args) => RunIntel(c, content, args) - ); - console.Register( - "mood", - "mood [species] — live population mood (avg/min/max) and content/stressed counts", - (c, args) => RunMood(c, args) - ); - console.Register( - "skills", - "skills — mean skill levels across living animals (grow from practice, decay unused)", - (c, _) => RunSkills(c) - ); - console.Register( - "menu", - "menu — return to the main menu", - (_, _) => Switch(new MainMenuScene()) - ); - } - - // Демонстрация генной системы (фаза G2): из Gene-дефов генерируем геном, печатаем аллели, - // выраженные значения и признаки (вычисленные формулами), затем скрещиваем двух особей. - private static void RunGeneDemo(DevConsole console, GameContent content, string[] args) - { - var genes = content.Defs.All(); - if (genes.Count == 0) - { - console.WriteLine("no Gene defs loaded"); - return; - } - - var registry = genes.ToDictionary(g => g.DefName, g => g, StringComparer.Ordinal); - var seed = args.Length > 0 ? int.Parse(args[0]) : Random.Shared.Next(); - var random = new Random(seed); - - console.WriteLine($"genome from {genes.Count} genes, seed {seed}:"); - var parentA = Genome.Generate(genes, random); - foreach (var gene in genes) - { - var allele = parentA[gene.DefName]; - console.WriteLine( - $" {gene.DefName}: [{allele.A:0.##}, {allele.B:0.##}] -> {parentA.Express(gene):0.###}" - ); - } - - console.WriteLine("traits:"); - foreach (var (trait, value) in Phenotype.Compute(parentA, registry).OrderBy(t => t.Key)) - { - console.WriteLine($" {trait} = {value:0.###}"); - } - - var parentB = Genome.Generate(genes, random); - var child = Genome.Breed(parentA, parentB, registry, random); - var childTraits = Phenotype.Compute(child, registry); - console.WriteLine( - $"bred child: {child.Alleles.Count} genes, " - + $"vigor={childTraits.GetValueOrDefault("vigor"):0.###}, " - + $"lifespan={childTraits.GetValueOrDefault("lifespan"):0.#}, " - + $"variant={childTraits.GetValueOrDefault("variant"):0}" - ); - } - - // Демонстрация генного контента (фаза G4): по виду берём его набор генов, генерируем особь и - // печатаем признаки роста/жизни и продукты (плоды/добыча с количеством от генов). - private void RunPlantDemo(DevConsole console, GameContent content, string[] args) - { - var species = content.Defs.NamesOf("Plant"); - if (args.Length == 0) - { - console.WriteLine("usage: plant [seed] e.g. plant TreeOakA"); - console.WriteLine($"species: {string.Join(", ", species)}"); - return; - } - - if (!content.Defs.TryGet(args[0], out var def)) - { - console.WriteLine($"no plant '{args[0]}'; species: {string.Join(", ", species)}"); - return; - } - - var index = _plants.IndexOf(def); - var seed = args.Length > 1 ? int.Parse(args[1]) : Random.Shared.Next(); - var genome = _plants[index].Template.Generate(new Random(seed)); - var traits = PlantPhenotype.FromTraits(Phenotype.Compute(genome, _plants.GeneRegistry)); - - console.WriteLine( - $"{def.DefName} (seed {seed}){(traits.IsVariant ? " [variant morph]" : "")}:" - ); - console.WriteLine( - $" vigor {traits.Vigor:0.##}, lifespan {traits.Lifespan:0} d, " - + $"optimalLight {traits.OptimalLight:0.##}, leafHue {traits.LeafHue:0.##}" - ); - var (tMin, tLow, tHigh, tMax) = traits.TemperatureBand(); - console.WriteLine( - $" temp: grows {tMin:0.#}..{tMax:0.#}°C, optimal {tLow:0.#}..{tHigh:0.#}°C " - + $"(cold {traits.ColdHardiness:0.#}, heat {traits.HeatHardiness:0.#})" - ); - console.WriteLine( - $" defense: toxicity {traits.Toxicity:0.##}, thorns {traits.Thorns:0.##}, " - + $"palatability {traits.Palatability:0.##} (growth ×{traits.DefenseGrowthFactor():0.##})" - ); - console.WriteLine($" mass (mature): {def.BaseMassKg:0.##} kg"); - - if (def.HarvestProduct is { } harvest) - { - console.WriteLine( - $" harvest: {ProductLabel(content, harvest)} x{traits.HarvestAmount:0.#}" - ); - } - - if (def.FruitProduct is { } fruit && traits.FruitYield >= 1f) - { - var season = content.Languages.Get( - $"season.{((Season)traits.FruitSeason).ToString().ToLowerInvariant()}" - ); - console.WriteLine( - $" fruit: {ProductLabel(content, fruit)} x{traits.FruitYield:0.#} in {season}" - ); - } - else - { - console.WriteLine(" barren (no fruit)"); - } - } - - // Демонстрация животного (фаза A1): по виду берём его набор генов, генерируем особь и печатаем - // признаки, вычисленные формулами генов (тело/мозг/метаболизм/чувства/мех). - private void RunAnimalDemo(DevConsole console, GameContent content, string[] args) - { - var species = content.Defs.NamesOf("Animal"); - if (args.Length == 0) - { - console.WriteLine("usage: animal [seed] e.g. animal Deer"); - console.WriteLine($"species: {string.Join(", ", species)}"); - return; - } - - if (!content.Defs.TryGet(args[0], out var def)) - { - console.WriteLine($"no animal '{args[0]}'; species: {string.Join(", ", species)}"); - return; - } - - var index = _animals.IndexOf(def); - var seed = args.Length > 1 ? int.Parse(args[1]) : Random.Shared.Next(); - var genome = _animals.GenerateGenome(index, new Random(seed)); - var traits = AnimalPhenotype.FromTraits(Phenotype.Compute(genome, _animals.GeneRegistry)); - var sex = AnimalFactory.IsMaleGenome(genome) ? "male" : "female"; - - console.WriteLine($"{def.DefName} ({sex}, seed {seed}):"); - console.WriteLine( - $" body {traits.BodySize:0.##} cells, maturity {traits.MaturityAge:0} d, " - + $"lifespan {traits.Lifespan:0} d, brain {traits.BrainSize:0.##}, metabolism {traits.Metabolism:0.##}" - ); - console.WriteLine( - $" move {traits.MoveSpeed:0.##}, vision {traits.Vision:0.##}, blood {traits.BloodVolume:0.##}, " - + $"insulation {traits.Insulation:0.##}, furHue {traits.FurHue:0.##}" - ); - console.WriteLine( - $" diet: herb {traits.Herbivory:0.##} / carn {traits.Carnivory:0.##} / omni {traits.Omnivory:0.##}, " - + $"toxinTolerance {traits.ToxinTolerance:0.##}" - ); - console.WriteLine( - $" reproduction: {(traits.Oviparous ? "oviparous (lays eggs)" : "viviparous (live birth)")}, " - + $"gestation {traits.GestationDays:0} d, litter {traits.LitterSize:0.#}" - ); - console.WriteLine( - $" behaviour: insulation {traits.Insulation:0.##}, sociability {traits.Sociability:0.##}" - ); - var adultMass = Mass.OfAnimal(def, traits, 1f); - console.WriteLine( - $" mass: {adultMass:0.#} kg (adult), carries ~{Mass.CarryCapacity(adultMass, 1f):0.#} kg" - ); - } - - // Диета из генов (предатор-кластер): по каждому виду печатает herbivory/carnivory/omnivory, - // выведенный рацион (ест растения/мясо), роль и боевые параметры — проверка, что хищники заданы. - private void RunDiet(DevConsole console, GameContent content, string[] args) - { - var species = content.Defs.NamesOf("Animal"); - for (var s = 0; s < _animals.Count; s++) - { - var sp = _animals[s]; - if ( - args.Length > 0 - && !string.Equals(sp.Def.DefName, args[0], StringComparison.OrdinalIgnoreCase) - ) - { - continue; - } - - var genome = _animals.GenerateGenome(s, new Random(0x01E7 + s)); - var traits = AnimalPhenotype.FromTraits( - Phenotype.Compute(genome, _animals.GeneRegistry) - ); - var (eatsPlants, eatsMeat) = AnimalFactory.Diet(traits, sp.Def); - var role = - eatsMeat ? (eatsPlants ? "omnivore" : "carnivore") - : eatsPlants ? "herbivore" - : "none"; - console.WriteLine( - $"{sp.Def.DefName}: {role} — herb {traits.Herbivory:0.##} / carn {traits.Carnivory:0.##} / " - + $"omni {traits.Omnivory:0.##} -> plants={eatsPlants} meat={eatsMeat}, spawn {sp.Def.SpawnPer1000Cells:0.##}/1000" - ); - if (eatsMeat && sp.Def.AttackDamage > 0f) - { - console.WriteLine( - $" hunt: dmg {sp.Def.AttackDamage:0}/d, bleed {sp.Def.AttackBleed:0.#}/d, " - + $"bloodloss {sp.Def.AttackBloodLoss:0.#}/d, range {sp.Def.AttackRangeCells:0.#} cells" - ); - } - } - - if (args.Length == 0) - { - console.WriteLine($"species: {string.Join(", ", species)}"); - } - } - - // Наблюдаемость отбора (фаза A2): средние ключевых признаков живой популяции и размах поколений — - // видно дрейф генов под отбором. Без аргумента — все виды; с аргументом — один вид. - private void RunPopStats(DevConsole console, string[] args) - { - if (_animals.Count == 0) - { - console.WriteLine("no animal species"); - return; - } - - var count = new int[_animals.Count]; - var body = new double[_animals.Count]; - var brain = new double[_animals.Count]; - var life = new double[_animals.Count]; - var move = new double[_animals.Count]; - var tox = new double[_animals.Count]; - var maxGen = new int[_animals.Count]; - - Store - .Query() - .ForEachEntity( - (ref AnimalOrganism o, Entity _) => - { - var s = o.Species; - count[s]++; - body[s] += o.Traits.BodySize; - brain[s] += o.Traits.BrainSize; - life[s] += o.Traits.Lifespan; - move[s] += o.Traits.MoveSpeed; - tox[s] += o.Traits.ToxinTolerance; - if (o.Generation > maxGen[s]) - { - maxGen[s] = o.Generation; - } - } - ); - - var filter = args.Length > 0 ? args[0] : null; - var any = false; - for (var s = 0; s < _animals.Count; s++) - { - var name = _animals[s].Def.DefName; - if ( - count[s] == 0 - || ( - filter is not null - && !string.Equals(name, filter, StringComparison.OrdinalIgnoreCase) - ) - ) - { - continue; - } - - any = true; - var c = count[s]; - console.WriteLine( - $"{name}: n={c}, gen 0..{maxGen[s]}, body {body[s] / c:0.##}, " - + $"brain {brain[s] / c:0.##}, lifespan {life[s] / c:0} d, move {move[s] / c:0.##}, " - + $"toxinTol {tox[s] / c:0.##}" - ); - } - - if (!any) - { - console.WriteLine(filter is null ? "no animals alive" : $"no '{filter}' alive"); - } - - // Другая сторона гонки вооружений: средняя защита живых растений (коэволюция C2). Без фильтра. - if (filter is null) - { - var pn = 0; - double pTox = 0, - pThorn = 0, - pPalat = 0; - Store - .Query() - .ForEachEntity( - (ref PlantOrganism o, Entity _) => - { - pn++; - pTox += o.Traits.Toxicity; - pThorn += o.Traits.Thorns; - pPalat += o.Traits.Palatability; - } - ); - if (pn > 0) - { - console.WriteLine( - $"plants: n={pn}, toxicity {pTox / pn:0.###}, thorns {pThorn / pn:0.###}, " - + $"palatability {pPalat / pn:0.###}" - ); - } - } - } - - // Наблюдаемость здоровья (фаза A5): дерево частей тела вида и способности здоровой особи. - private void RunBodyDemo(DevConsole console, GameContent content, string[] args) - { - var species = content.Defs.NamesOf("Animal"); - if (args.Length == 0) - { - console.WriteLine("usage: body e.g. body Deer"); - console.WriteLine($"species: {string.Join(", ", species)}"); - return; - } - - if (!content.Defs.TryGet(args[0], out var def)) - { - console.WriteLine($"no animal '{args[0]}'; species: {string.Join(", ", species)}"); - return; - } - - if (string.IsNullOrEmpty(def.Body) || !content.Defs.TryGet(def.Body, out var body)) - { - console.WriteLine($"{def.DefName}: no body"); - return; - } - - console.WriteLine($"{def.DefName} body '{body.DefName}': {body.Parts.Length} parts"); - foreach (var part in body.Parts) - { - var caps = - part.Capacities.Count > 0 - ? " [" - + string.Join( - ", ", - part.Capacities.Select(kv => $"{kv.Key} {kv.Value:0.##}") - ) - + "]" - : ""; - console.WriteLine( - $" {part.Name}{(part.Vital ? " *vital" : "")} hp{part.MaxHp:0}{caps}" - ); - } - - var state = HealthState.Create(body, 1f); - console.WriteLine( - "capacities (healthy): " - + string.Join( - ", ", - state.Capacities.OrderBy(k => k.Key).Select(kv => $"{kv.Key} {kv.Value:0.##}") - ) - ); - } - - // Тест системы здоровья (фаза A6): навешивает болезнь всем живым животным. - private void RunInfect(DevConsole console, GameContent content, string[] args) - { - var name = args.Length > 0 ? args[0] : "Fever"; - if (!content.Defs.TryGet(name, out var def)) - { - console.WriteLine($"no hediff '{name}'"); - return; - } - - var count = 0; - Store - .Query() - .ForEachEntity( - (ref Health h, Entity _) => - { - h.State?.Add(def); - count++; - } - ); - console.WriteLine($"infected {count} animals with {def.DefName}"); - } - - // Наблюдаемость интеллекта (фаза A7): мозг особи (ген) × сознание = эффективный интеллект, который - // гейтит набор нужд/действий. Показывает активные/отключённые нужды при заданном уровне сознания - // (по умолчанию 1 — здоров; меньше — имитация повреждения мозга/болезни/боли). - private void RunIntel(DevConsole console, GameContent content, string[] args) - { - var species = content.Defs.NamesOf("Animal"); - if (args.Length == 0) - { - console.WriteLine( - "usage: intel [consciousness 0..1] [seed] e.g. intel Deer 0.6" - ); - console.WriteLine($"species: {string.Join(", ", species)}"); - return; - } - - if (!content.Defs.TryGet(args[0], out var def)) - { - console.WriteLine($"no animal '{args[0]}'; species: {string.Join(", ", species)}"); - return; - } - - var consciousness = - args.Length > 1 - ? Math.Clamp( - float.Parse(args[1], System.Globalization.CultureInfo.InvariantCulture), - 0f, - 1f - ) - : 1f; - var seed = args.Length > 2 ? int.Parse(args[2]) : Random.Shared.Next(); - var index = _animals.IndexOf(def); - var genome = _animals.GenerateGenome(index, new Random(seed)); - var traits = AnimalPhenotype.FromTraits(Phenotype.Compute(genome, _animals.GeneRegistry)); - var intelligence = traits.BrainSize * consciousness; - - console.WriteLine( - $"{def.DefName} (seed {seed}): brain {traits.BrainSize:0.##} x consciousness {consciousness:0.##} " - + $"= intelligence {intelligence:0.###}" - ); - var active = new List(); - var gated = new List(); - for (var k = 0; k < _needs.Count; k++) - { - var need = _needs[k]; - (need.MinBrain > intelligence ? gated : active).Add( - $"{need.DefName}({need.MinBrain:0.##})" - ); - } - - console.WriteLine($" active: {(active.Count > 0 ? string.Join(", ", active) : "—")}"); - console.WriteLine($" gated: {(gated.Count > 0 ? string.Join(", ", gated) : "—")}"); - } - - // Наблюдаемость настроения (фаза A8): среднее/мин/макс настроения живой популяции по виду и счётчики - // довольных (>0.7) / угнетённых (<0.35). Считает только особей с активным настроением (тир 4+). - private void RunMood(DevConsole console, string[] args) - { - if (_animals.Count == 0) - { - console.WriteLine("no animal species"); - return; - } - - var count = new int[_animals.Count]; - var sum = new double[_animals.Count]; - var min = new float[_animals.Count]; - var max = new float[_animals.Count]; - var content_ = new int[_animals.Count]; - var stressed = new int[_animals.Count]; - Array.Fill(min, 1f); - - Store - .Query() - .ForEachEntity( - (ref AnimalOrganism o, ref Mood m, Entity _) => - { - if (!m.Active) - { - return; // тир ниже зверя — настроения нет - } - - var s = o.Species; - count[s]++; - sum[s] += m.Value; - min[s] = MathF.Min(min[s], m.Value); - max[s] = MathF.Max(max[s], m.Value); - if (m.Value > 0.7f) - { - content_[s]++; - } - else if (m.Value < 0.35f) - { - stressed[s]++; - } - } - ); - - var filter = args.Length > 0 ? args[0] : null; - var any = false; - for (var s = 0; s < _animals.Count; s++) - { - var name = _animals[s].Def.DefName; - if ( - count[s] == 0 - || ( - filter is not null - && !string.Equals(name, filter, StringComparison.OrdinalIgnoreCase) - ) - ) - { - continue; - } - - any = true; - console.WriteLine( - $"{name}: n={count[s]}, mood {sum[s] / count[s]:0.##} (min {min[s]:0.##}, max {max[s]:0.##}), " - + $"content {content_[s]}, stressed {stressed[s]}" - ); - } - - if (!any) - { - console.WriteLine(filter is null ? "no animals with mood" : $"no '{filter}' with mood"); - } - } - - // Наблюдаемость навыков: средний уровень каждого навыка по живым животным (растут от практики). - private void RunSkills(DevConsole console) - { - if (_skills.Count == 0) - { - console.WriteLine("no skill defs loaded"); - return; - } - - var sum = new double[_skills.Count]; - var n = 0; - Store - .Query() - .ForEachEntity( - (ref Skills s, Entity _) => - { - if (s.Levels is null) - { - return; - } - - n++; - for (var i = 0; i < _skills.Count && i < s.Levels.Length; i++) - { - sum[i] += s.Levels[i]; - } - } - ); - - console.WriteLine($"skills over {n} animals (mean level):"); - for (var i = 0; i < _skills.Count; i++) - { - console.WriteLine( - $" {_skills[i].DefName}: {(n > 0 ? sum[i] / n : 0):0.###} " - + $"(learn {_skills[i].LearnRate:0.##}/d, decay {_skills[i].DecayPerDay:0.###}/d)" - ); - } - } - - private static string ProductLabel(GameContent content, string productDefName) => - content.Defs.TryGet(productDefName, out var product) - ? content.Languages.Get(product.Label) - : productDefName; } diff --git a/src/LittleSim/Sim/AnimalCapacities.cs b/src/LittleSim/Sim/AnimalCapacities.cs new file mode 100644 index 0000000..12488e6 --- /dev/null +++ b/src/LittleSim/Sim/AnimalCapacities.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; + +namespace LittleSim.Sim; + +/// Идентификаторы способностей (capacities) организма — на них ссылаются вклады частей тела. +public static class AnimalCapacities +{ + public const string Consciousness = "Consciousness"; + public const string Moving = "Moving"; + public const string Breathing = "Breathing"; + public const string BloodPumping = "BloodPumping"; + public const string BloodFiltration = "BloodFiltration"; + public const string Sight = "Sight"; + public const string Hearing = "Hearing"; + public const string Smell = "Smell"; + public const string Touch = "Touch"; + public const string Talking = "Talking"; + public const string Eating = "Eating"; + public const string Digestion = "Digestion"; + + /// Порядок отображения способностей в UI (сверху вниз). Прочие модовые — после, как есть. + public static readonly string[] DisplayOrder = + [ + Consciousness, + Moving, + Sight, + Hearing, + Smell, + Touch, + Talking, + Eating, + Breathing, + BloodPumping, + BloodFiltration, + Digestion, + ]; +} diff --git a/src/LittleSim/Sim/AnimalOrganism.cs b/src/LittleSim/Sim/AnimalOrganism.cs deleted file mode 100644 index 111f292..0000000 --- a/src/LittleSim/Sim/AnimalOrganism.cs +++ /dev/null @@ -1,759 +0,0 @@ -using System.Collections.Generic; -using Friflo.Engine.ECS; -using LittleSim.Content; -using Microsoft.Xna.Framework; -using MrGameEng.Genetics; - -namespace LittleSim.Sim; - -/// -/// Признаки (фенотип) животного, вычисленные из генома формулами генов (см. Defs/Genes/): -/// готовые значения, которые читают системы, не касаясь генов напрямую. Зеркало -/// . Имена полей соответствуют именам признаков в эффектах генов. -/// -public struct AnimalPhenotype -{ - /// Размер тела в клетках (масштаб спрайта; позже — HP/расход энергии). - public float BodySize; - - /// Скорость обмена веществ — множитель расхода нужд. - public float Metabolism; - - /// Скорость передвижения (множитель). - public float MoveSpeed; - - /// Объём крови (множитель) — масштаб скорости кровопотери. - public float BloodVolume; - - /// Дальность зрения (множитель радиуса поиска). - public float Vision; - - /// Острота слуха (множитель радиуса). - public float Hearing; - - /// Острота обоняния (множитель радиуса). - public float Smell; - - /// Острота осязания (множитель радиуса; чувство ближнего контакта). - public float Touch; - - /// Камуфляж [0..1] — снижает обнаружение этой особи зрением (заметность = 1−camouflage). - public float Camouflage; - - /// Запах [0..1] — повышает обнаружение этой особи обонянием. - public float Scent; - - /// Шумность [0..1] — повышает обнаружение этой особи слухом при движении. - public float Noise; - - /// Размер мозга (0..1) — основа интеллекта. - public float BrainSize; - - /// Теплоизоляция мехом (0..1). - public float Insulation; - - /// Оттенок меха (0..1) — сдвиг тинта спрайта. - public float FurHue; - - /// Возраст созревания (игровых дней) — порог стадии Adult. - public float MaturityAge; - - /// Травоядность [0..1] — способность/тяга питаться растениями. - public float Herbivory; - - /// Хищничество [0..1] — тяга охотиться и питаться мясом (живая добыча/падаль). - public float Carnivory; - - /// Всеядность [0..1] — генералист: включает оба источника пищи (растения и мясо). - public float Omnivory; - - /// - /// Стиль питания [0..1]: 0 — «пастьба» (graze), часто подтапливает сытость мелкими приёмами; - /// 1 — «обжорство» (gorge), ищет еду лишь при сильном голоде и ест до полной сытости (редкие - /// крупные приёмы, как у хищников). Задаёт пороги начала/конца кормёжки в . - /// - public float FeedingStyle; - - /// Устойчивость к растительным ядам [0..1] — снижает дозу отравления токсичным кормом. - public float ToxinTolerance; - - /// Социальность [0..1] — тяга держаться стаи/стада (когезия + спокойствие в группе). - public float Sociability; - - /// Продолжительность жизни (игровых дней). - public float Lifespan; - - /// Сезон гона (0 весна … 3 зима). - public int BreedingSeason; - - /// Срок вынашивания (игровых дней). - public float GestationDays; - - /// Размер помёта. - public float LitterSize; - - /// Тип рождения [0..1]: ≥0.5 — яйцекладущий (откладывает яйца), иначе живородящий. - public float EggLaying; - - /// Яйцекладущий ли вид (тип рождения) — определяет цикл зачатия. - public readonly bool Oviparous => EggLaying >= 0.5f; - - /// Собирает фенотип из карты признаков, посчитанной . - public static AnimalPhenotype FromTraits(IReadOnlyDictionary traits) - { - float T(string name) => traits.GetValueOrDefault(name); - // Острота чувств по умолчанию 1 (вид без гена не «слепнет»); заметность — мягкие дефолты. - float Acuity(string name) => traits.TryGetValue(name, out var v) ? v : 1f; - return new AnimalPhenotype - { - BodySize = T("bodySize"), - Metabolism = T("metabolism"), - MoveSpeed = T("moveSpeed"), - BloodVolume = T("bloodVolume"), - Vision = T("vision"), - Hearing = Acuity("hearing"), - Smell = Acuity("smell"), - Touch = Acuity("touch"), - Camouflage = T("camouflage"), - Scent = traits.GetValueOrDefault("scent", 0.5f), - Noise = traits.GetValueOrDefault("noise", 0.5f), - BrainSize = T("brainSize"), - Insulation = T("insulation"), - FurHue = T("furHue"), - MaturityAge = T("maturityAge"), - Herbivory = T("herbivory"), - Carnivory = T("carnivory"), - Omnivory = T("omnivory"), - FeedingStyle = traits.GetValueOrDefault("feedingStyle", 0.5f), - ToxinTolerance = T("toxinTolerance"), - Sociability = T("sociability"), - Lifespan = T("lifespan"), - BreedingSeason = (int)MathF.Round(Math.Clamp(T("breedingSeason"), 0f, 3f)), - GestationDays = T("gestationDays"), - LitterSize = T("litterSize"), - EggLaying = T("eggLaying"), - }; - } -} - -/// -/// Компонент-организм животного: управляемый (для размножения, сейва и -/// пересчёта), выраженный фенотип , индекс вида в -/// и номер поколения (для popstats и видимого дрейфа). Геном — ссылочный объект, поэтому -/// компонент несёт ссылку, а не копию (как у растений). Зеркало . -/// -public struct AnimalOrganism : IComponent -{ - /// Геном особи (аллели по генам); источник истины для признаков и размножения. - public Genome Genome; - - /// Выраженные признаки — то, что читают системы. - public AnimalPhenotype Traits; - - /// Индекс вида в . - public int Species; - - /// Пол: самец (XY) или самка (XX) — выведен из локуса GeneSex при создании. - public bool IsMale; - - /// Ест ли растения (диета по генам/дефу, неизменна после рождения — кэш вместо пересчёта на тик). - public bool EatsPlants; - - /// Ест ли мясо (диета по генам/дефу, неизменна после рождения — кэш вместо пересчёта на тик). - public bool EatsMeat; - - /// Номер поколения: max(родителей) + 1; у основателей 0. - public int Generation; -} - -/// -/// Состояние роста животного (фаза A3): текущая стадия и накопленный возраст в игровых днях. Плоские -/// данные — переход между стадиями (Baby→Juvenile→Adult→Senior), смену спрайта и размера выполняет -/// , читая пороги из генов (возраст созревания, продолжительность жизни). -/// Зеркало . -/// -public struct AnimalGrowth : IComponent -{ - /// Текущая стадия: 0 Baby, 1 Juvenile, 2 Adult, 3 Senior. - public int Stage; - - /// Накопленный возраст в игровых днях. - public float AgeDays; -} - -/// Инстанс части тела особи: ссылка на деф части и текущий/максимальный HP (масштаб по размеру). -public struct PartInstance -{ - /// Деф части тела (анатомия вида). - public Content.BodyPartDef Def; - - /// Текущий HP. - public float Hp; - - /// Максимальный HP (база × размер тела). - public float MaxHp; - - /// Доля сохранности [0,1]. - public readonly float Fraction => MaxHp > 0f ? Math.Clamp(Hp / MaxHp, 0f, 1f) : 0f; -} - -/// Инстанс хедифа на особи: деф + текущая тяжесть и набранный иммунитет (гонка, фаза A6). -public struct Hediff -{ - /// Деф хедифа (гон/болезнь/…). - public HediffDef Def; - - /// Текущая тяжесть. - public float Severity; - - /// Набранный иммунитет [0,1]; при 1 — выздоровление. - public float Immunity; -} - -/// -/// Состояние здоровья особи (фазы A4–A6): активные хедифы (инстансы) + части тела, кровь и вычисленные -/// способности (capacities). Managed-объект, как (Friflo допускает ссылку в -/// компоненте). Болезни (A6) — первый источник урона: снижают способности и могут убить. Растёт по -/// фазам — контейнер один. -/// -public sealed class HealthState -{ - private readonly List _hediffs = []; - - /// Активные хедифы (гон, болезни). - public IReadOnlyList Hediffs => _hediffs; - - /// Части тела особи (из анатомии вида); пусто — у вида нет тела. - public PartInstance[] Parts { get; private set; } = []; - - /// Уровень крови [0,1]; ниже порога — потеря сознания и смерть (когда появятся раны). - public float BloodLevel { get; set; } = 1f; - - /// Боль [0,1] — снижает сознание (когда появятся раны). - public float Pain { get; set; } - - /// Погибла ли особь по здоровью (vital-часть уничтожена / кровь на нуле). - public bool Dead { get; set; } - - /// Вычисленные способности (capacity id → [0,1]); у здоровой особи все = 1. - public Dictionary Capacities { get; private set; } = new(); - - /// Способность по id (1, если не вычислена — напр. у вида без тела), для множителей действий. - public float Capacity(string id) => Capacities.TryGetValue(id, out var v) ? v : 1f; - - /// - /// Пересчитывает боль (из хедифов) и способности: базовые из частей/крови/боли, затем модификаторы - /// способностей от хедифов (лерп 1→factor по тяжести), затем общий множитель - /// (старение/сенесценс: дряхлеющий организм слабее по всем способностям). Вызывать после изменения - /// частей/крови/хедифов; =1 — без возрастного спада. - /// - public void RecomputeCapacities(float capacityScale = 1f) - { - var pain = 0f; - foreach (var h in _hediffs) - { - pain += h.Def.Pain * h.Severity; - } - - Pain = Math.Clamp(pain, 0f, 1f); - - // Множитель старения применяется внутри Compute. Пишем в собственный словарь Capacities - // (переиспользуется, очищается внутри Compute) — без аллокации карты способностей на пересчёт. - var caps = Capacities; - CapacityCalc.Compute(Parts, BloodLevel, Pain, capacityScale, caps); - foreach (var h in _hediffs) - { - if (h.Def.CapMods.Count == 0) - { - continue; - } - - var t = Math.Clamp(h.Severity, 0f, 1f); - foreach (var (capacity, factor) in h.Def.CapMods) - { - caps[capacity] = caps.GetValueOrDefault(capacity, 1f) * (1f + (factor - 1f) * t); - } - } - } - - /// - /// Прогрессирует болезни (тяжесть и иммунитет растут со временем); снимает выздоровевшие - /// (иммунитет ≥ 1); возвращает true, если какая-то болезнь достигла летальной тяжести. - /// — множитель набора иммунитета (способность «фильтрация крови»: - /// почки/печень выводят токсины и помогают бороться с болезнью; повреждённые → медленнее). - /// - public bool AdvanceHediffs(float days, float immunityScale = 1f) - { - var lethal = false; - for (var i = _hediffs.Count - 1; i >= 0; i--) - { - var h = _hediffs[i]; - if (h.Def.SeverityPerDay <= 0f && h.Def.ImmunityPerDay <= 0f) - { - continue; // статичный хедиф (гон) не прогрессирует - } - - h.Severity += h.Def.SeverityPerDay * days; - h.Immunity += h.Def.ImmunityPerDay * days * immunityScale; - if (h.Immunity >= 1f) - { - _hediffs.RemoveAt(i); // иммунитет победил — выздоровление - continue; - } - - if (h.Def.LethalSeverity > 0f && h.Severity >= h.Def.LethalSeverity) - { - lethal = true; - } - - _hediffs[i] = h; - } - - return lethal; - } - - /// Строит здоровье особи: части из анатомии вида (HP × размер тела) + начальные способности. - public static HealthState Create(Content.BodyDef? body, float bodySize) - { - var state = new HealthState(); - if (body is { Parts.Length: > 0 }) - { - var scale = MathF.Max(0.2f, bodySize); - state.Parts = new PartInstance[body.Parts.Length]; - for (var i = 0; i < body.Parts.Length; i++) - { - var hp = body.Parts[i].MaxHp * scale; - state.Parts[i] = new PartInstance - { - Def = body.Parts[i], - Hp = hp, - MaxHp = hp, - }; - } - } - - state.RecomputeCapacities(); - return state; - } - - /// Несёт ли особь хедиф с данным именем дефа. - public bool Has(string defName) - { - foreach (var h in _hediffs) - { - if (h.Def.DefName == defName) - { - return true; - } - } - - return false; - } - - /// Навешивает хедиф (если ещё не навешан) с начальной тяжестью из дефа. - public void Add(HediffDef def) - { - if (!Has(def.DefName)) - { - _hediffs.Add(new Hediff { Def = def, Severity = def.InitialSeverity }); - } - } - - /// Восстанавливает хедиф с заданной тяжестью и иммунитетом (для загрузки сейва). - public void AddHediff(HediffDef def, float severity, float immunity) - { - if (!Has(def.DefName)) - { - _hediffs.Add( - new Hediff - { - Def = def, - Severity = severity, - Immunity = immunity, - } - ); - } - } - - /// Снимает хедиф по имени дефа; возвращает, был ли он. - public bool Remove(string defName) - { - for (var i = 0; i < _hediffs.Count; i++) - { - if (_hediffs[i].Def.DefName == defName) - { - _hediffs.RemoveAt(i); - return true; - } - } - - return false; - } - - /// - /// Травма (укус хищника): повреждает часть тела (выбор по весу попадания coverage), вызывает - /// мгновенную кровопотерю и навешивает/усиливает кровотечение, затем пересчитывает способности. - /// Первый травматический источник урона — оживляет каркас здоровья A5. - /// - public void ApplyInjury( - float partDamage, - float bloodLoss, - HediffDef? bleeding, - float bleedSeverity, - Random rng - ) - { - if (Parts.Length > 0 && partDamage > 0f) - { - var idx = PickPart(rng); - var part = Parts[idx]; - part.Hp = MathF.Max(0f, part.Hp - partDamage); - Parts[idx] = part; - } - - BloodLevel = Math.Clamp(BloodLevel - bloodLoss, 0f, 1f); - if (bleeding is not null && bleedSeverity > 0f) - { - Intensify(bleeding, bleedSeverity); - } - - RecomputeCapacities(); - } - - // Выбор задетой части по весу попадания (coverage); детерминируется переданным rng. - private int PickPart(Random rng) - { - var total = 0f; - foreach (var p in Parts) - { - total += p.Def?.Coverage ?? 0f; - } - - if (total <= 0f) - { - return rng.Next(Parts.Length); - } - - var roll = (float)rng.NextDouble() * total; - for (var i = 0; i < Parts.Length; i++) - { - if (Parts[i].Def is null) - { - continue; - } - - roll -= Parts[i].Def.Coverage; - if (roll <= 0f) - { - return i; - } - } - - return Parts.Length - 1; - } - - /// - /// Навешивает хедиф или усиливает существующий: тяжесть растёт (до 1), а заживление (иммунитет) - /// сбрасывается — свежая рана начинает затягиваться заново. Для повторных укусов/ранений. - /// - public void Intensify(HediffDef def, float addSeverity) - { - for (var i = 0; i < _hediffs.Count; i++) - { - if (_hediffs[i].Def.DefName == def.DefName) - { - var h = _hediffs[i]; - h.Severity = Math.Clamp(h.Severity + addSeverity, 0f, 1f); - h.Immunity = 0f; // свежая рана — заживление с нуля - _hediffs[i] = h; - return; - } - } - - _hediffs.Add(new Hediff { Def = def, Severity = Math.Clamp(addSeverity, 0f, 1f) }); - } -} - -/// Здоровье особи: managed-состояние со списком хедифов (см. ). -public struct Health : IComponent -{ - /// Состояние здоровья (хедифы; позже части тела/кровь). - public HealthState State; -} - -/// -/// Труп животного (фаза A5): отдельная сущность после смерти. Разлагается во времени по стадиям -/// (Fresh→Rotting→Skeleton→исчезает), скорость зависит от температуры. Несёт запас мяса (∝ размер тела) -/// для будущей разделки/падальщиков. Управляется . -/// -public struct Corpse : IComponent -{ - /// Индекс вида в (для спрайта, разделки, сейва). - public int Species; - - /// Накоплено игровых дней разложения. - public float RotDays; - - /// Стадия: 0 свежий, 1 гниющий, 2 скелет. - public int Stage; - - /// Доступное мясо (∝ масса тела) — для разделки/падальщиков (фаза-горизонт). - public float Meat; - - /// Линейный размер тела (для масштаба спрайта трупа), независимо от запаса мяса. - public float BodySize; -} - -/// -/// Яйцо (тип рождения «яйцекладка»): отдельная сущность, отложенная яйцекладущей самкой вместо живых -/// родов. Несёт уже скрещённый геном будущего детёныша, вид и поколение; инкубируется во времени и -/// вылупляется в детёныша (). Геном — managed-ссылка (как у трупа/беременности). -/// -public struct Egg : IComponent -{ - /// Индекс вида в (для спрайта при вылуплении и сейва). - public int Species; - - /// Геном будущего детёныша (уже скрещён из родителей на момент кладки). - public Genome Genome; - - /// Поколение будущего детёныша. - public int Generation; - - /// Остаток инкубации в игровых днях; ≤0 — вылупление. - public float IncubateDays; -} - -/// Вид запомненного: угроза (избегать), корм/вода/партнёр (идти) — расширяемо. -public enum MemoryKind : byte -{ - Threat, - Food, - Water, - Mate, -} - -/// Запись памяти: что, где (воспринятая позиция — грубая, если по запаху), насколько уверенно -/// (падает со временем — забывание), сколько дней назад. -public struct MemoryEntry -{ - public MemoryKind Kind; - public Vector2 Pos; - public float Confidence; - public float AgeDays; -} - -/// -/// Память особи (объём ∝ мозгу): ограниченный набор записей о замеченном (сейчас — угрозы). Чувства -/// пишут сюда воспринятое; со временем уверенность падает и запись забывается; ИИ использует память, -/// когда цель не воспринимается сейчас (избегает зоны недавнего хищника). Список — managed-ссылка. -/// -public struct Memory : IComponent -{ - /// Активные записи (упорядочены по добавлению; слабейшая вытесняется при переполнении). - public List Entries; - - /// Сколько записей вмещает (∝ размеру мозга; 0 — память отсутствует). - public int Capacity; - - /// Запоминает/освежает наблюдение: близкое того же вида — обновляет, иначе добавляет (с - /// вытеснением слабейшего при переполнении). Вне ёмкости (Capacity 0) — ничего не помнит. - public readonly void Remember(MemoryKind kind, Vector2 pos, float mergeDist) - { - if (Entries is null || Capacity <= 0) - { - return; - } - - var mergeSq = mergeDist * mergeDist; - for (var i = 0; i < Entries.Count; i++) - { - if (Entries[i].Kind == kind && Vector2.DistanceSquared(Entries[i].Pos, pos) <= mergeSq) - { - Entries[i] = new MemoryEntry - { - Kind = kind, - Pos = pos, - Confidence = 1f, - AgeDays = 0f, - }; - return; - } - } - - if (Entries.Count >= Capacity) - { - var weakest = 0; - for (var i = 1; i < Entries.Count; i++) - { - if (Entries[i].Confidence < Entries[weakest].Confidence) - { - weakest = i; - } - } - - Entries.RemoveAt(weakest); - } - - Entries.Add( - new MemoryEntry - { - Kind = kind, - Pos = pos, - Confidence = 1f, - AgeDays = 0f, - } - ); - } -} - -/// Id навыков-эффектов (на них ссылается код начисления XP и применения бонуса). -public static class AnimalSkills -{ - /// Добыча корма: эффективность выпаса/поедания растений. - public const string Foraging = "Foraging"; - - /// Охота: урон по добыче. - public const string Hunting = "Hunting"; - - /// Уклонение: скорость бегства от хищника. - public const string Evasion = "Evasion"; -} - -/// -/// Навыки особи (как нужды/способности — managed-массивы по индексам ): -/// уровни [0..1] (растут от практики, угасают без неё) и страсть к каждому навыку (скорость обучения). -/// Есть у животных; те же навыки получат будущие люди. Добавление навыка не меняет структуру. -/// -public struct Skills : IComponent -{ - /// Уровни навыков [0..1] по индексам . - public float[] Levels; - - /// Страсть к каждому навыку (0 нет / 1 интерес / 2 страсть) — множитель обучения. - public byte[] Passions; -} - -/// -/// Ноша существа (задел под перенос предметов/хаул): суммарная масса переносимого груза в кг. Перегруз -/// сверх грузоподъёмности () замедляет движение. Компонент появляется -/// у существа, когда оно что-то несёт; пока источников груза нет — спящий каркас под будущий инвентарь. -/// -public struct Carrier : IComponent -{ - /// Суммарная масса переносимого груза (кг). - public float CarriedKg; -} - -/// -/// Проглоченное семя (эндозоохория): травоядное, съевшее плодоносящее растение, какое-то время несёт -/// его семя в кишечнике, затем «высаживает» его в другом месте () — -/// зверь становится разносчиком флоры. Геном — managed-ссылка (клон родительского растения). -/// -public struct GutSeed : IComponent -{ - /// Индекс вида растения в . - public int PlantSpecies; - - /// Геном будущего ростка (клон съеденного растения). - public Genome Genome; - - /// Остаток времени до высадки в игровых днях; ≤0 — росток падает на землю. - public float DropInDays; -} - -/// -/// Кратковременная «вспышка урона» (презентация, как в RimWorld): при получении удара выставляется -/// время свечения; подкрашивает спрайт красным и гасит свечение. -/// Есть у всех животных (выставляется в 0 при создании), чтобы удар не делал структурного добавления. -/// -public struct DamageFlash : IComponent -{ - /// Остаток реального времени свечения (сек); 0 — не подсвечен. - public float Seconds; -} - -/// -/// Личная хроника особи (фича UI «История»): ограниченный кольцевой список событий о ней (спаривание, -/// роды, охота, кормёжка, бегство, рост). Пишет , читает инспектор. Список — -/// managed-ссылка (как мысли/память); старые записи вытесняются по достижении . -/// -public struct CreatureLog : IComponent -{ - /// Записи в порядке добавления (новые в конце). - public List Entries; - - /// Сколько записей хранится (старейшие вытесняются). - public int Capacity; -} - -/// Инстанс мысли настроения на особи (фаза A8): деф события + остаток времени до угасания. -public struct ThoughtInstance -{ - /// Деф мысли (сдвиг настроения и длительность). - public Content.ThoughtDef Def; - - /// Сколько игровых дней мысль ещё действует. - public float RemainingDays; -} - -/// -/// Настроение особи (фаза A8): значение [0,1] и активные событийные мысли. Считается -/// из непрерывного фона (нужды/боль) и суммы мыслей. Есть только у -/// тира 4+ ( по размеру мозга) — у примитивных настроения нет. Низкое настроение -/// влияет на поведение (стресс не даёт спариваться) и видно в инспекторе. Список мыслей — managed-ссылка -/// (как AnimalNeeds.Values): события добавляют мысли через . -/// -public struct Mood : IComponent -{ - /// Текущее настроение [0,1]; у неактивного (тир < 4) держится нейтральным. - public float Value; - - /// Есть ли у особи настроение (гейт тиром мозга) — иначе фон/мысли не считаются. - public bool Active; - - /// Активные событийные мысли (затухают со временем). - public List Thoughts; - - /// - /// Вешает событийную мысль (или обновляет её длительность, если уже висит — без накопления - /// дубликатов). Вызывается из систем-источников событий (роды, спаривание, утоление жажды). - /// - public readonly void Add(Content.ThoughtDef? def) - { - if (Thoughts is null || def is null) - { - return; - } - - for (var i = 0; i < Thoughts.Count; i++) - { - if (Thoughts[i].Def == def) - { - var refreshed = Thoughts[i]; - refreshed.RemainingDays = def.DurationDays; - Thoughts[i] = refreshed; - return; - } - } - - Thoughts.Add(new ThoughtInstance { Def = def, RemainingDays = def.DurationDays }); - } -} - -/// -/// Беременность самки (фаза A4): managed-ссылка на геном отца («слепок» на момент зачатия), остаток -/// срока вынашивания и размер помёта. По истечении срока -/// рожает детёнышей через и снимает компонент. -/// -public struct Pregnant : IComponent -{ - /// Геном отца на момент зачатия. - public Genome FatherGenome; - - /// Остаток срока вынашивания (игровых дней). - public float DueInDays; - - /// Сколько детёнышей родится. - public int Litter; -} diff --git a/src/LittleSim/Sim/AnimalSystems.cs b/src/LittleSim/Sim/AnimalSystems.cs deleted file mode 100644 index 3b7e166..0000000 --- a/src/LittleSim/Sim/AnimalSystems.cs +++ /dev/null @@ -1,3208 +0,0 @@ -using Friflo.Engine.ECS; -using Friflo.Engine.ECS.Systems; -using LittleSim.Content; -using Microsoft.Xna.Framework; -using MrGameEng.AI; -using MrGameEng.Core; -using MrGameEng.Genetics; -using MrGameEng.Graphics; - -namespace LittleSim.Sim; - -/// -/// Id поведений-исполнителей (реестр действий ИИ). Дефы нужд () ссылаются на -/// них; выбирает действие, исполняет. -/// Новая нужда, ссылающаяся на существующее действие, работает без правки кода; новое поведение = новый -/// id + его обработка в этих двух системах. -/// -public static class AnimalActions -{ - public const string Wander = "Wander"; - public const string Eat = "Eat"; - public const string Drink = "Drink"; - public const string Sleep = "Sleep"; - public const string Mate = "Mate"; - - /// Охота: преследовать живую добычу и кусать (урон) либо есть труп — утоляет голод хищника. - public const string Hunt = "Hunt"; - - /// Бегство: уходить от ближайшего хищника (реакция жертвы, важнее прочих нужд). - public const string Flee = "Flee"; - - // — Холостые (idle) активности: чем зверь занят, когда нет срочных нужд. Выбираются внутри ветки - // Wander с фиксированной длительностью (см. AnimalDecisionSystem) — придают поведению разнообразие. - - /// Отдых: стоит/лежит на месте, понемногу восстанавливая силы. - public const string Rest = "Rest"; - - /// Копание в земле в поисках корня/личинок (травоядные/всеядные) — даёт чуть сытости. - public const string Dig = "Dig"; - - /// Валяние в грязи у воды (комфорт/терморегуляция) — стоит на месте у кромки. - public const string Wallow = "Wallow"; -} - -/// -/// Id событийных мыслей настроения (фаза A8). Системы-источники события вешают мысль с этим id (через -/// ); её сила/длительность/метка — данные (thoughts.json). Новая мысль на -/// новое событие = код-триггер + деф; правка чисел существующей — только JSON. -/// -public static class AnimalThoughts -{ - public const string GaveBirth = "GaveBirth"; - public const string Mated = "Mated"; - public const string QuenchedThirst = "QuenchedThirst"; -} - -/// -/// Нужды зверя как ДАННЫЕ (рефактор расширяемости): значения по индексам из -/// (managed-массив — добавление нужды не меняет структуру) + счётчик истощения. Падают/растут в -/// по , восполняются исполнением действия. -/// -public struct AnimalNeeds : IComponent -{ - /// Значения нужд [0,1] по индексам . - public float[] Values; - - /// Накоплено игровых дней истощения (летальная нужда на нуле); порог — смерть (A3). - public float Starve; -} - -/// -/// «Мозг» зверя: выбранное действие (id поведения), его цель и таймер до пересмотра. Решение принимает -/// (движковый ), исполняет -/// . -/// -public struct AnimalBrain : IComponent -{ - /// Id текущего действия (см. ). - public string Action; - - /// Куда идти (позиция растения/воды/партнёра/точки блуждания). - public Vector2 Target; - - /// Id растения-цели для ; -1 — нет. - public int TargetPlant; - - /// Id партнёра для ; -1 — нет. - public int TargetMate; - - /// Id цели для — живой добычи или трупа; -1 — нет. - public int TargetPrey; - - /// Цель — труп (падаль), а не живая добыча: при достижении едим, а не атакуем. - public bool TargetIsCorpse; - - /// Секунды до следующего пересмотра решения. - public float DecideIn; - - /// Игровые секунды до следующего удара по добыче (кулдаун дискретных атак, как в RimWorld). - public float AttackCooldown; - - /// Текущее направление блуждания (коррелированное — плавно дрейфует, не дёргается каждый раз). - public Vector2 Heading; - - /// Игровое время (TotalTime), до которого длится текущая холостая активность (отдых/копание/…). - public float ActivityUntil; -} - -/// -/// Снимок нужд зверя для соображений utility-выбора (доступ по индексу нужды) с гейтом интеллектом -/// (фаза A7): нужда, чей выше эффективного интеллекта особи -/// (brainSize × Consciousness), в выбор не входит. -/// -public readonly struct AnimalContext( - float[] values, - NeedSet needs, - float intelligence, - int overrideIndex = -1, - float overrideValue = 0f -) -{ - private readonly float[] _values = values; - private readonly NeedSet _needs = needs; - private readonly float _intelligence = intelligence; - - // Переопределение значения одной нужды (стиль питания: «эффективный голод» с гистерезисом — - // см. AnimalDecisionSystem). Override имеет приоритет над сырым значением и над гейтом мозга. - private readonly int _overrideIndex = overrideIndex; - private readonly float _overrideValue = overrideValue; - - /// - /// Значение нужды по индексу. Если для этой нужды задан override (стиль питания) — он и возвращается. - /// Если её порог мозга выше интеллекта особи — нужда недоступна и возвращается её «не-срочный» край - /// (deplete → 1 «сыта», drive → 0 «без влечения»), чтобы соображение дало ~0 и действие не выбралось. - /// Индекс вне диапазона → 0. - /// - public float Get(int index) - { - if (index == _overrideIndex) - { - return _overrideValue; - } - - if (index >= _values.Length) - { - return 0f; - } - - if (_needs[index].MinBrain > _intelligence) - { - return _needs.IsDrive(index) ? 0f : 1f; - } - - return _values[index]; - } -} - -/// -/// Падение/рост нужд со временем (игровые дни через ): deplete-нужды убывают -/// (× ген обмена веществ), drive-нужды растут под своим хедифом (гон) и спадают вне его. Летальные -/// нужды на нуле копят счётчик истощения. Полностью управляется данными . -/// -public sealed class AnimalNeedsSystem( - GameClock clock, - float secondsPerDay, - NeedSet needs, - AnimalSet animals -) : QuerySystem -{ - // Прибавка к обмену веществ при устойчивости к яду 1.0 (цена детоксикации, коэволюция C2). - private const float ToleranceMetabolicCost = 0.5f; - - protected override void OnUpdate() - { - var days = clock.DeltaTime / secondsPerDay; - if (days <= 0f) - { - return; - } - - foreach (var (needsChunk, organisms, healths, growths, _) in Query.Chunks) - { - var n = needsChunk.Span; - var o = organisms.Span; - var h = healths.Span; - var g = growths.Span; - for (var i = 0; i < n.Length; i++) - { - var values = n[i].Values; - if (values is null || values.Length < needs.Count) - { - continue; // защита от старого/пустого состояния - } - - // Обмен веществ = ген × цена устойчивости к яду (C2) × множитель Клайбера по массе тела: - // расход ∝ масса^0.75 = (линейный размер/эталон)^2.25 (масса ∝ размер³), нормирован на - // эталон вида → молодняк экономнее, крупные прожорливее. Эталон предпосчитан (без словаря). - var sp = animals[o[i].Species]; - var stageScale = sp.Stages[Math.Clamp(g[i].Stage, 0, sp.Stages.Length - 1)].Scale; - var sizeRatio = MathF.Max( - 0.01f, - o[i].Traits.BodySize * stageScale / sp.RefBodySize - ); - var metabolism = - MathF.Max(0.1f, o[i].Traits.Metabolism) - * (1f + ToleranceMetabolicCost * Math.Clamp(o[i].Traits.ToxinTolerance, 0f, 1f)) - * Mass.KleiberFactorFromSizeRatio(sizeRatio); - var lethalEmpty = false; - for (var k = 0; k < needs.Count; k++) - { - var def = needs[k]; - if (needs.IsDrive(k)) - { - var rising = def.RisesUnder is null || h[i].State.Has(def.RisesUnder); - var delta = (rising ? def.RisePerDay : -def.DecayPerDay) * days; - values[k] = Math.Clamp(values[k] + delta, 0f, 1f); - } - else - { - values[k] = Math.Clamp( - values[k] - def.DecayPerDay * metabolism * days, - 0f, - 1f - ); - if (def.Lethal && values[k] <= 0f) - { - lethalEmpty = true; - } - } - } - - ref var starve = ref n[i].Starve; - starve = lethalEmpty ? starve + days : MathF.Max(0f, starve - days * 2f); - } - } - } -} - -/// -/// Гон (фаза A4): взрослым в их брачный сезон навешивает хедиф Rut, вне сезона снимает. Рост влечения -/// под этим хедифом считает (нужда Mating с risesUnder=Rut) — -/// сезонность и влечение разнесены: эта система отвечает только за состояние. -/// -public sealed class AnimalRutSystem(AnimalSet animals, Climate climate, HediffDef rut) - : QuerySystem -{ - protected override void OnUpdate() - { - var season = (int)climate.Season; - foreach (var (growths, organisms, healths, _) in Query.Chunks) - { - var g = growths.Span; - var o = organisms.Span; - var h = healths.Span; - for (var i = 0; i < g.Length; i++) - { - var inRut = - AnimalFactory.IsAdult(animals[o[i].Species], g[i].Stage) - && season == o[i].Traits.BreedingSeason; - if (inRut) - { - h[i].State.Add(rut); - } - else - { - h[i].State.Remove(rut.DefName); - } - } - } - } -} - -/// -/// Слой ВЫБОРА: раз в секунд каждый зверь выбирает действие движковым -/// , построенным ИЗ ДАННЫХ (по соображению на -/// нужду), и находит цель действия (еда по диете, вода, партнёр). Решение пишется в -/// ; исполняет . -/// -public sealed class AnimalDecisionSystem : BaseSystem -{ - private const float Interval = 0.6f; - private const float MateMoodFloor = 0.35f; // ниже — стресс/истощение, зверь не спаривается (A8) - private const float HerdMinSociability = 0.3f; // ниже — вид не стадный (когезия/безопасность выкл.) - private const float SafetyInNumbers = 0.5f; // макс. снижение радиуса реакции на угрозу в группе - private const int HerdFullCount = 6; // размер группы, при котором эффект «безопасность в числе» полон - - // Стиль питания (gorge↔graze): пороги начала/конца кормёжки лерпуются по гену feedingStyle [0..1]. - // graze (0): начинает есть уже при лёгком голоде и наедается не до полного — частые мелкие приёмы. - // gorge (1): ищет еду лишь при сильном голоде, но ест до полной сытости — редкие крупные приёмы. - private const float FeedStartGraze = 0.8f; - private const float FeedStartGorge = 0.4f; - private const float FeedFullGraze = 0.9f; - private const float FeedFullGorge = 1.0f; - - // Блуждание/стадность (boids): коррелированный дрейф направления + расхождение (личное пространство) и - // лишь слабое притяжение к центру группы на удалении — без этого стадо схлопывалось в одну точку. - private const float WanderTurn = 0.5f; // макс. поворот направления за пересмотр (рад) — плавные пути - private const float RoamCells = 11f; // длина «отрезка» блуждания в клетках - private const float SeparationCells = 2.2f; // ближе этого к соседу — расходимся - private const float SeparationWeight = 1.6f; // сила расхождения - private const float CohesionStartCells = 7f; // дальше этого от центра группы — слабо подтягиваемся - private const float CohesionWeight = 0.45f; // сила когезии (намеренно мала) - private const float EdgeMarginCells = 2.5f; // у края мира разворачиваем направление внутрь - - // Холостые активности: длительность (игровые секунды) каждой, пока зверь ей занят. - private const float RoamMinSec = 3f; - private const float RoamMaxSec = 6f; - private const float RestMinSec = 5f; - private const float RestMaxSec = 11f; - private const float DigMinSec = 3f; - private const float DigMaxSec = 7f; - private const float WallowMinSec = 5f; - private const float WallowMaxSec = 10f; - private const float WallowWaterCells = 4f; // валяться можно лишь у воды (в этом радиусе от кромки) - - private readonly UtilityAi _brain; - private readonly EntityStore _store; - private readonly WorldEvents _events; - private readonly GameClock _clock; - private readonly AnimalSet _animalSet; - private readonly NeedSet _needs; - private readonly int _hungerNeed; // индекс убывающей нужды «голод» (для гистерезиса кормёжки); -1 — нет - private readonly int _restNeed; // индекс нужды «отдых» (для веса холостого отдыха); -1 — нет - private readonly int _cellSize; - private readonly Vector2[] _shoreByCell; // поле «ближайший берег» по клеткам (O(1) поиск водопоя) - private readonly int _worldWidth; - private readonly int _worldHeight; - private readonly Random _rng; - private readonly ArchetypeQuery< - AnimalNeeds, - AnimalBrain, - AnimalOrganism, - AnimalGrowth, - Transform2D - > _animals; - private readonly ArchetypeQuery _plants; - private readonly ArchetypeQuery _corpses; - - // Пространственные индексы + переиспользуемые буферы запросов: поиск корма/угрозы/добычи/партнёра/ - // стада берёт только ближние сущности вместо прохода по всем (было O(растений)+O(животных) на каждое - // решение). В ячейке — предпосчитанные признаки (без дорефетча). Сетку животных перестраиваем каждый - // кадр (они двигаются), сетки растений/трупов — раз в StaticGridRebuildSeconds (они статичны). - private const int GridCells = 10; // размер ячейки сетки в клетках мира (~радиус восприятия) - private const float HearingRangeMult = 1.4f; // слух шире зрения - private const float SmellRangeMult = 1.8f; // запах — дальше всех (но грубый, см. Detect) - private const float RememberedThreatAvoidCells = 14f; // в этой зоне от памяти об угрозе зверь уходит прочь - - // Каденция перестройки СТАТИЧНЫХ сеток (растения/трупы): они не двигаются (меняются лишь при - // рождении/смерти), поэтому полный обход тысяч растений на каждый кадр был основной ценой системы. - // Перестраиваем раз в эти игровые секунды (≈ как часто одна особь и так пересматривает решение); - // удалённые сущности в устаревшей сетке безопасны — потребитель сверяется со store перед действием. - private const float StaticGridRebuildSeconds = 0.5f; - private readonly SpatialGrid _plantGrid; - private readonly List _plantBuf = []; - private readonly SpatialGrid _animalGrid; - private readonly List _animalBuf = []; - private readonly SpatialGrid _corpseGrid; - private readonly List _corpseBuf = []; - private float _staticGridAccum; // накоплено игровых секунд с последней перестройки статичных сеток - private bool _staticGridsBuilt; // первая перестройка обязательна (до неё сетки пусты) - private readonly float _smellSnap; // размер «обонятельной» ячейки (грубая локализация по запаху) - - public AnimalDecisionSystem( - EntityStore store, - GameClock clock, - AnimalSet animals, - NeedSet needs, - int cellSize, - Vector2[] shoreByCell, - int worldWidth, - int worldHeight, - WorldEvents events, - int seed - ) - { - _brain = BuildBrain(needs); - _store = store; - _events = events; - _clock = clock; - _animalSet = animals; - _needs = needs; - _hungerNeed = needs.NeedForAction(AnimalActions.Eat); - _restNeed = needs.NeedForAction(AnimalActions.Sleep); - _cellSize = cellSize; - _shoreByCell = shoreByCell; - _worldWidth = worldWidth; - _worldHeight = worldHeight; - _rng = new Random(seed); - _plantGrid = new SpatialGrid(cellSize * GridCells); - _animalGrid = new SpatialGrid(cellSize * GridCells); - _corpseGrid = new SpatialGrid(cellSize * GridCells); - _smellSnap = cellSize * GridCells; - _animals = store.Query< - AnimalNeeds, - AnimalBrain, - AnimalOrganism, - AnimalGrowth, - Transform2D - >(); - _plants = store.Query(); - _corpses = store.Query(); - } - - // Строит utility-reasoner из данных: на каждую нужду — действие с соображением. Deplete: чем ниже - // значение, тем привлекательнее ((1-v)^3). Drive: чем выше влечение, тем привлекательнее (v). - private static UtilityAi BuildBrain(NeedSet needs) - { - var actions = new List>(needs.Count + 1); - for (var i = 0; i < needs.Count; i++) - { - var index = i; - var def = needs[i]; - var consideration = needs.IsDrive(i) - ? new Consideration(def.DefName, c => c.Get(index)) - : new Consideration( - def.DefName, - c => c.Get(index), - curve: ResponseCurve.Polynomial(exponent: 3f, slope: -1f, xShift: 1f) - ); - actions.Add(new UtilityAction(def.Action, consideration)); - } - - actions.Add( - new UtilityAction( - AnimalActions.Wander, - new Consideration("idle", _ => 0.12f) - ) - ); - return new UtilityAi(actions.ToArray()); - } - - protected override void OnUpdateGroup() - { - var delta = _clock.DeltaTime; - if (delta <= 0f) - { - return; // пауза — решения не пересматриваются (как и прочие sim-системы) - } - - var clockNow = (float)_clock.TotalTime; // игровое время для таймеров холостых активностей - RebuildAnimalGrid(); // животные двигаются — индекс свежий каждый кадр - // Растения/трупы статичны — индекс перестраиваем по каденции, а не каждый кадр. - _staticGridAccum += delta; - if (!_staticGridsBuilt || _staticGridAccum >= StaticGridRebuildSeconds) - { - RebuildStaticGrids(); - _staticGridAccum = 0f; - _staticGridsBuilt = true; - } - - foreach ( - var (needsChunk, brains, organisms, growths, transforms, entities) in _animals.Chunks - ) - { - var n = needsChunk.Span; - var b = brains.Span; - var o = organisms.Span; - var gr = growths.Span; - var t = transforms.Span; - for (var i = 0; i < b.Length; i++) - { - ref var brain = ref b[i]; - brain.DecideIn -= delta; - if (brain.DecideIn > 0f) - { - continue; - } - - brain.DecideIn = Interval + _rng.NextSingle() * Interval; // джиттер — расфазировать скан - var prevAction = brain.Action; // для журнала: события на смене действия (начал/закончил) - var pos = t[i].Position; - var adult = AnimalFactory.IsAdult(_animalSet[o[i].Species], gr[i].Stage); - var self = entities.EntityAt(i); - var health = self.GetComponent().State; - // Эффективный интеллект (мозг × сознание) гейтит доступные нужды/действия (фаза A7): - // больной/раненый зверь с упавшим сознанием теряет высшие нужды (сон/секс) → рефлексы. - var intelligence = AnimalFactory.Intelligence(o[i].Traits, health); - // Радиусы восприятия по чувствам: зрение (корм/партнёр), плюс слух и обоняние (угроза/добыча). - // Острота — гены чувств × способность органа (раны/хедифы их режут). Зрение узкое, слух шире, - // запах дальше всех, но грубый (см. Detect). Радиусы детерминированы. - var baseRadius = VisionRadius(o[i].Species, o[i].Traits.Vision); - var sight = health?.Capacity(AnimalCapacities.Sight) ?? 1f; - var hearing = health?.Capacity(AnimalCapacities.Hearing) ?? 1f; - var smell = health?.Capacity(AnimalCapacities.Smell) ?? 1f; - var seeRadius = baseRadius * MathF.Max(0.2f, sight); - var senseBase = _animalSet[o[i].Species].Def.VisionCells * _cellSize; - var ranges = new SenseRanges( - seeRadius, - senseBase * o[i].Traits.Hearing * MathF.Max(0.2f, hearing) * HearingRangeMult, - senseBase * o[i].Traits.Smell * MathF.Max(0.2f, smell) * SmellRangeMult - ); - // Соседей по сетке собираем ОДИН раз на решение (на макс. радиусе чувств): стадность, - // бегство, поиск добычи и партнёра дальше читают этот буфер со своими фильтрами дистанции — - // вместо 2–4 повторных обходов сетки и копирований за одно решение. - _animalGrid.Collect(pos, ranges.Max(), _animalBuf); - - // Настроение (A8): стресс/истощение (низкое настроение) подавляет тягу к спариванию. - var mood = self.GetComponent(); - - // Стадность (boids): социальные виды считают центр/размер группы соплеменников (когезия и - // «безопасность в числе») и вектор расхождения herdAway (личное пространство — чтобы стадо - // держалось вместе, но не схлопывалось в точку). - var sociability = o[i].Traits.Sociability; - var herdCount = 0; - var herdCenter = pos; - var herdAway = Vector2.Zero; - if (sociability >= HerdMinSociability) - { - HerdScan( - pos, - seeRadius, - o[i].Species, - self.Id, - out herdCenter, - out herdCount, - out herdAway - ); - } - - var safety = - herdCount > 0 - ? 1f - SafetyInNumbers * MathF.Min(1f, herdCount / (float)HerdFullCount) - : 1f; - - // Бегство (предатор-кластер): жертва, заметившая рядом хищника, спасается — это важнее - // любых нужд (инстинкт выживания). Сам хищник от добычи не бежит (см. IsThreatTo). - if ( - TryFindThreat( - pos, - ranges.Scaled(safety), - o[i].Species, - o[i].EatsMeat, - o[i].Traits.BodySize, - out var threatPos - ) - ) - { - self.GetComponent().Remember(MemoryKind.Threat, threatPos, _smellSnap); - if (prevAction != AnimalActions.Flee) - { - _events.Fled(self, o[i].Species); // начало бегства — в личную хронику - } - - brain.Action = AnimalActions.Flee; - brain.TargetPlant = -1; - brain.TargetPrey = -1; - var away = pos - threatPos; - var len = away.Length(); - brain.Target = - len > 0.001f - ? pos + away / len * (ranges.Max() + _cellSize) - : pos + new Vector2(_cellSize, 0f); - continue; - } - - var eatsPlants = o[i].EatsPlants; // диета кэширована при рождении (без пересчёта на тик) - var eatsMeat = o[i].EatsMeat; - // Стиль питания: «эффективный голод» с гистерезисом. gorge не лезет за едой, пока сытость - // выше порога начала, но начав — ест до полного; graze подтапливает часто и до меньшего. - var hunger = EffectiveHunger( - n[i].Values, - o[i].Traits.FeedingStyle, - brain.Action is AnimalActions.Eat or AnimalActions.Hunt - ); - var name = - _brain - .Select( - new AnimalContext( - n[i].Values, - _needs, - intelligence, - _hungerNeed, - hunger - ) - ) - ?.Name - ?? AnimalActions.Wander; - - switch (name) - { - // Хищник/всеядное голодает → ищет труп (падаль) или живую добычу и охотится. - case AnimalActions.Eat - when eatsMeat - && TryFindKill( - pos, - ranges, - o[i].Species, - o[i].Traits, - self.Id, - out var preyId, - out var preyPos, - out var preyIsCorpse - ): - brain.Action = AnimalActions.Hunt; - brain.TargetPlant = -1; - brain.TargetPrey = preyId; - brain.TargetIsCorpse = preyIsCorpse; - brain.Target = preyPos; - // Начало охоты на ЖИВУЮ добычу (падальщество не считаем охотой) — заметное событие. - if ( - prevAction != AnimalActions.Hunt - && !preyIsCorpse - && _store.TryGetEntityById(preyId, out var preyEntity) - && !preyEntity.IsNull - && preyEntity.TryGetComponent(out var preyOrg) - ) - { - _events.HuntStarted(self, o[i].Species, preyOrg.Species); - } - - break; - case AnimalActions.Eat - when eatsPlants - && TryFindForage( - pos, - seeRadius, - intelligence, - o[i].Traits.ToxinTolerance, - out var plant, - out var pp - ): - brain.Action = AnimalActions.Eat; - brain.TargetPlant = plant; - brain.Target = pp; - break; - case AnimalActions.Drink when TryFindShore(pos, out var water): - brain.Action = AnimalActions.Drink; - brain.TargetPlant = -1; - brain.Target = water; - break; - case AnimalActions.Sleep: - brain.Action = AnimalActions.Sleep; - brain.TargetPlant = -1; - break; - case AnimalActions.Mate - when adult - && (!mood.Active || mood.Value >= MateMoodFloor) - && TryFindMate( - pos, - o[i].IsMale, - o[i].Species, - seeRadius, - out var mateId, - out var matePos - ): - brain.Action = AnimalActions.Mate; - brain.TargetPlant = -1; - brain.TargetMate = mateId; - brain.Target = matePos; - break; - default: - brain.TargetPlant = -1; - brain.TargetPrey = -1; - // Память: вблизи места недавнего хищника бросаем холостые дела и уходим прочь. - if (TryRememberedThreat(self, pos, out var dangerPos)) - { - var flee = pos - dangerPos; - var d = flee.Length(); - brain.Action = AnimalActions.Wander; - brain.Heading = d > 0.001f ? flee / d : RandomDir(); - brain.ActivityUntil = clockNow + RoamMinSec; - brain.Target = - pos + brain.Heading * (_cellSize * (4f + _rng.NextSingle() * 4f)); - } - else - { - // Конечный автомат холостых дел: держим текущую активность до её таймера, - // затем выбираем новую (блуждание/отдых/копание/валяние) с весами по виду. - if (!IsIdle(brain.Action) || clockNow >= brain.ActivityUntil) - { - PickIdleActivity(ref brain, self, o[i], pos, n[i].Values, clockNow); - } - - if (brain.Action == AnimalActions.Wander) - { - SteerRoam( - ref brain, - pos, - herdCenter, - herdCount, - herdAway, - sociability - ); - } - else - { - brain.Target = pos; // отдых/копание/валяние — стоим на месте - } - } - - break; - } - - // Бытовое в личную хронику: завершение приёма пищи/водопоя (по смене действия — без - // спама на каждый тик). Начало охоты/бегства логируется выше у своих веток. - if (prevAction == AnimalActions.Eat && brain.Action != AnimalActions.Eat) - { - _events.Ate(self, o[i].Species); - } - else if (prevAction == AnimalActions.Drink && brain.Action != AnimalActions.Drink) - { - _events.Drank(self, o[i].Species); - } - } - } - } - - // «Эффективный голод» для utility-выбора по стилю питания (gorge↔graze) с гистерезисом: - // — кормится сейчас → особь «голодна», пока сытость не достигнет порога полного (gorge ест до 1, - // graze — до меньшего): начатый приём пищи доводится до конца, а не бросается на полпути; - // — не кормится → особь «сыта» (1), пока сытость выше порога начала; ниже — падает в «голод» - // пропорционально, и чем выше стиль (gorge), тем ниже порог начала (ест реже, но крупнее). - // Возвращает значение [0,1] для нужды голода; при отсутствии нужды — 1 (override не применится). - private float EffectiveHunger(float[] values, float feedingStyle, bool feedingNow) - { - if (_hungerNeed < 0 || values is null || _hungerNeed >= values.Length) - { - return 1f; - } - - var sat = values[_hungerNeed]; - var style = Math.Clamp(feedingStyle, 0f, 1f); - if (feedingNow) - { - var full = FeedFullGraze + (FeedFullGorge - FeedFullGraze) * style; - return full > 0f ? Math.Clamp(sat / full, 0f, 1f) : 1f; - } - - var start = FeedStartGraze + (FeedStartGorge - FeedStartGraze) * style; - return sat >= start ? 1f - : start > 0f ? sat / start - : 0f; - } - - // Радиус восприятия особи: базовая дальность вида (данные) × ген зрения × размер клетки. - private float VisionRadius(int species, float vision) => - MathF.Max(1f, _animalSet[species].Def.VisionCells) * MathF.Max(0.2f, vision) * _cellSize; - - // Ближайшая запомненная угроза в зоне избегания (для блуждания — уйти от опасного места). - private bool TryRememberedThreat(Entity self, Vector2 pos, out Vector2 threatPos) - { - threatPos = default; - if (!self.TryGetComponent(out var mem) || mem.Entries is null) - { - return false; - } - - var bestSq = RememberedThreatAvoidCells * _cellSize; - bestSq *= bestSq; - var found = false; - foreach (var e in mem.Entries) - { - if (e.Kind != MemoryKind.Threat) - { - continue; - } - - var sq = Vector2.DistanceSquared(pos, e.Pos); - if (sq < bestSq) - { - bestSq = sq; - threatPos = e.Pos; - found = true; - } - } - - return found; - } - - // Центр/размер группы соплеменников в радиусе (когезия/безопасность) и вектор расхождения away — - // сумма направлений «прочь» от соседей ближе SeparationCells (личное пространство, чтобы стадо не - // схлопывалось в точку). O(n) на особь. Сам исключается по id; центр — средняя позиция соседей. - private void HerdScan( - Vector2 from, - float radius, - int species, - int selfId, - out Vector2 center, - out int count, - out Vector2 away - ) - { - var sum = Vector2.Zero; - away = Vector2.Zero; - count = 0; - var radiusSq = radius * radius; - var sepDist = SeparationCells * _cellSize; - var sepSq = sepDist * sepDist; - // _animalBuf уже собран в DecideIn на макс. радиусе чувств — фильтруем по своему радиусу. - foreach (var e in _animalBuf) - { - if (e.Species != species || e.Id == selfId) - { - continue; - } - - var distSq = Vector2.DistanceSquared(from, e.Pos); - if (distSq <= radiusSq) - { - sum += e.Pos; - count++; - } - - if (distSq <= sepSq && distSq > 0.0001f) - { - // ближе личного пространства — добавляем направление прочь, тем сильнее, чем ближе сосед. - var d = MathF.Sqrt(distSq); - away += (from - e.Pos) / d * (1f - d / sepDist); - } - } - - center = count > 0 ? sum / count : from; - } - - // Холостая ли активность (то, чем зверь занят без срочных нужд): блуждание/отдых/копание/валяние. - private static bool IsIdle(string action) => - action == AnimalActions.Wander - || action == AnimalActions.Rest - || action == AnimalActions.Dig - || action == AnimalActions.Wallow; - - // Случайное единичное направление. - private Vector2 RandomDir() - { - var a = _rng.NextSingle() * MathF.Tau; - return new Vector2(MathF.Cos(a), MathF.Sin(a)); - } - - // Поворот вектора на угол (рад). - private static Vector2 Rotate(Vector2 v, float radians) - { - var c = MathF.Cos(radians); - var s = MathF.Sin(radians); - return new Vector2(v.X * c - v.Y * s, v.X * s + v.Y * c); - } - - // Выбор холостой активности с весами по виду/состоянию/окружению и её длительность. Блуждание — всегда; - // отдых — охотнее у подуставших; копание — травоядным/всеядным; валяние — только у воды. Начало - // характерной активности (кроме блуждания) пишется в личную хронику особи. - private void PickIdleActivity( - ref AnimalBrain brain, - Entity self, - in AnimalOrganism org, - Vector2 pos, - float[] values, - float now - ) - { - var roam = 1.0f; - var rest = 0.5f; - if (_restNeed >= 0 && values is not null && _restNeed < values.Length) - { - rest += (1f - values[_restNeed]) * 0.8f; // ниже отдых — охотнее приляжет (но не до сна) - } - - var dig = org.EatsPlants ? 0.7f : 0.1f; - var wallowRange = WallowWaterCells * _cellSize; - var nearWater = - TryFindShore(pos, out var water) - && Vector2.DistanceSquared(pos, water) <= wallowRange * wallowRange; - var wallow = nearWater ? 0.5f : 0f; - - var roll = _rng.NextSingle() * (roam + rest + dig + wallow); - string action; - float min, - max; - if ((roll -= roam) < 0f) - { - action = AnimalActions.Wander; - (min, max) = (RoamMinSec, RoamMaxSec); - } - else if ((roll -= rest) < 0f) - { - action = AnimalActions.Rest; - (min, max) = (RestMinSec, RestMaxSec); - } - else if (roll - dig < 0f) - { - action = AnimalActions.Dig; - (min, max) = (DigMinSec, DigMaxSec); - } - else - { - action = AnimalActions.Wallow; - (min, max) = (WallowMinSec, WallowMaxSec); - } - - brain.Action = action; - brain.ActivityUntil = now + min + _rng.NextSingle() * (max - min); - switch (action) - { - case AnimalActions.Rest: - _events.Activity(self, org.Species, "event.rest"); - break; - case AnimalActions.Dig: - _events.Activity(self, org.Species, "event.dig"); - break; - case AnimalActions.Wallow: - _events.Activity(self, org.Species, "event.wallow"); - break; - } - } - - // Рулёжка блуждания (boids): плавный дрейф направления + расхождение (личное пространство) и лишь - // слабая когезия к центру группы на удалении; у края мира заворачиваем внутрь. Цель ставится далеко - // по направлению, поэтому зверь идёт, а не топчется на месте. - private void SteerRoam( - ref AnimalBrain brain, - Vector2 pos, - Vector2 herdCenter, - int herdCount, - Vector2 herdAway, - float sociability - ) - { - var dir = brain.Heading; - if (dir.LengthSquared() < 0.0001f) - { - dir = RandomDir(); - } - - dir = Rotate(dir, (_rng.NextSingle() - 0.5f) * 2f * WanderTurn); // коррелированный дрейф - dir += herdAway * SeparationWeight; // расхождение в стаде - if (sociability >= HerdMinSociability && herdCount > 0) - { - var toCenter = herdCenter - pos; - var d = toCenter.Length(); - if (d > CohesionStartCells * _cellSize) - { - dir += toCenter / d * CohesionWeight; // слабая когезия лишь на удалении - } - } - - var margin = EdgeMarginCells * _cellSize; - var w = _worldWidth * _cellSize; - var h = _worldHeight * _cellSize; - if (pos.X < margin && dir.X < 0f) - { - dir.X = MathF.Abs(dir.X); - } - else if (pos.X > w - margin && dir.X > 0f) - { - dir.X = -MathF.Abs(dir.X); - } - - if (pos.Y < margin && dir.Y < 0f) - { - dir.Y = MathF.Abs(dir.Y); - } - else if (pos.Y > h - margin && dir.Y > 0f) - { - dir.Y = -MathF.Abs(dir.Y); - } - - if (dir.LengthSquared() < 0.0001f) - { - dir = RandomDir(); - } - - dir.Normalize(); - brain.Heading = dir; - brain.Target = pos + dir * (_cellSize * RoamCells); - } - - // Ближайший хищник-угроза в радиусе восприятия (для бегства жертвы). Угроза = другой вид, который - // ест мясо и достаточно крупный (см. IsThreatTo). O(n) на особь — как поиск корма. - private bool TryFindThreat( - Vector2 from, - in SenseRanges ranges, - int selfSpecies, - bool selfEatsMeat, - float selfBody, - out Vector2 threatPos - ) - { - var bestSq = float.MaxValue; - threatPos = default; - var threatId = -1; - // _animalBuf уже собран в DecideIn; обнаружение всё равно гейтится Detect по чувствам. - foreach (var e in _animalBuf) - { - if ( - e.Species == selfSpecies - || !IsThreatTo(selfEatsMeat, selfBody, e.EatsMeat, e.BodySize) - ) - { - continue; - } - - var sq = Vector2.DistanceSquared(from, e.Pos); - // Обнаружение по чувствам: зрение (минус камуфляж) / слух (× шум) / запах (× запах, грубая зона). - if (!Detect(sq, e.Pos, ranges, e.Camouflage, e.Scent, e.Noise, out var perceived)) - { - continue; - } - - if (sq < bestSq || (sq == bestSq && e.Id < threatId)) - { - bestSq = sq; - threatPos = perceived; - threatId = e.Id; - } - } - - return threatId >= 0; - } - - // Является ли вид-кандидат угрозой особи: он ест мясо и не мельче её. Чистая жертва (не мясоед) - // бежит от любого хищника не мельче себя; сам хищник — лишь от заметно более крупного мясоеда. - private static bool IsThreatTo( - bool selfEatsMeat, - float selfBody, - bool otherEatsMeat, - float otherBody - ) - { - if (!otherEatsMeat) - { - return false; - } - - return selfEatsMeat ? otherBody > selfBody * 1.1f : otherBody >= selfBody * 0.9f; - } - - /// Радиусы восприятия особи по чувствам (уже с остротой генов и способностью органов). - private readonly record struct SenseRanges(float Sight, float Hearing, float Smell) - { - public float Max() => MathF.Max(Sight, MathF.Max(Hearing, Smell)); - - public SenseRanges Scaled(float f) => new(Sight * f, Hearing * f, Smell * f); - } - - // Обнаружена ли цель и где она «видится» наблюдателю. Зрение/слух дают точную позицию (зрение глушит - // камуфляж цели, слух усиливает её шум); запах добивает дальше всех, но даёт лишь ГРУБУЮ зону — - // позиция округляется до ячейки запаха (зверь идёт «примерно туда», пока ближе не сработает зрение). - private bool Detect( - float dist2, - Vector2 truePos, - in SenseRanges r, - float camouflage, - float scent, - float noise, - out Vector2 perceived - ) - { - perceived = truePos; - var sight = r.Sight * (1f - Math.Clamp(camouflage, 0f, 1f)); - if (sight > 0f && dist2 <= sight * sight) - { - return true; - } - - var hear = r.Hearing * Math.Clamp(noise, 0f, 1f); - if (hear > 0f && dist2 <= hear * hear) - { - return true; - } - - var smell = r.Smell * Math.Clamp(scent, 0f, 1f); - if (smell > 0f && dist2 <= smell * smell) - { - perceived = SnapToSmellCell(truePos); // грубая зона запаха - return true; - } - - return false; - } - - // Округляет позицию до центра «обонятельной» ячейки — детерминированная грубая локализация по запаху. - private Vector2 SnapToSmellCell(Vector2 p) => - new( - MathF.Floor(p.X / _smellSnap) * _smellSnap + _smellSnap * 0.5f, - MathF.Floor(p.Y / _smellSnap) * _smellSnap + _smellSnap * 0.5f - ); - - // Ближайшая «мясная» цель: труп (падаль, приоритет — даровая еда без риска) или живая добыча. - // Возвращает id, позицию и флаг трупа. Добыча — другой вид не крупнее охотника (см. IsPreyFor). - private bool TryFindKill( - Vector2 from, - in SenseRanges ranges, - int selfSpecies, - in AnimalPhenotype selfTraits, - int selfId, - out int targetId, - out Vector2 position, - out bool isCorpse - ) - { - targetId = -1; - position = default; - isCorpse = false; - var reach = ranges.Max(); - - // 1) Падаль с остатком мяса — находится в пределах любого чувства (запах хорошо ведёт к падали). - // Кандидаты — из сетки трупов (ближние ячейки), а не полным сканом всех трупов мира. - var bestCorpseSq = reach * reach; - var corpseId = -1; - var corpsePos = default(Vector2); - _corpseGrid.Collect(from, reach, _corpseBuf); - foreach (var c in _corpseBuf) - { - if (c.Meat <= 0f) - { - continue; - } - - var sq = Vector2.DistanceSquared(from, c.Pos); - if (sq < bestCorpseSq || (sq == bestCorpseSq && c.Id < corpseId)) - { - bestCorpseSq = sq; - corpseId = c.Id; - corpsePos = c.Pos; - } - } - - // 2) Живая добыча (из сетки животных, по чувствам). _animalBuf уже собран в DecideIn на reach. - var bestPreySq = float.MaxValue; - var preyId = -1; - var preyPos = default(Vector2); - var selfBody = selfTraits.BodySize; - foreach (var e in _animalBuf) - { - if (e.Species == selfSpecies || e.Id == selfId || !IsPreyFor(selfBody, e.BodySize)) - { - continue; - } - - var sq = Vector2.DistanceSquared(from, e.Pos); - if (!Detect(sq, e.Pos, ranges, e.Camouflage, e.Scent, e.Noise, out var perceived)) - { - continue; - } - - if (sq < bestPreySq || (sq == bestPreySq && e.Id < preyId)) - { - bestPreySq = sq; - preyId = e.Id; - preyPos = perceived; - } - } - - // Предпочитаем падаль, если она не намного дальше живой добычи (даровая еда, без риска). - if (corpseId >= 0 && (preyId < 0 || bestCorpseSq <= bestPreySq * 2f)) - { - targetId = corpseId; - position = corpsePos; - isCorpse = true; - return true; - } - - if (preyId >= 0) - { - targetId = preyId; - position = preyPos; - isCorpse = false; - return true; - } - - return false; - } - - // Подходит ли особь в добычу охотнику данного размера: важно лишь, что она не крупнее охотника - // (на превосходящих не нападаем). Вид/мясоедство добычи не важны — хищник ест и травоядных, и прочих. - private static bool IsPreyFor(float hunterBody, float otherBody) => - otherBody <= hunterBody * 1.25f; - - // Ближайшее растение в радиусе (квадрат расстояния); ничьи — по меньшему id (детерминизм). - // Порог интеллекта, с которого травоядное РАЗБИРАЕТ корм (избегает ядовитого/невкусного). Ниже — - // рефлекторное выедание ближайшего растения (тир мозга 4, как сон/мысли; больной зверь с упавшим - // сознанием падает ниже и снова жрёт что попало — последствие гейтинга A7). - private const float ForageSmartMinBrain = 0.4f; - - // Вес, с которым воспринимаемый яд (с поправкой на устойчивость) отталкивает от растения, и штраф за - // дальность — чтобы умный зверь предпочитал близкий и безопасный корм, а не бежал через всю карту. - private const float ToxinAvoidWeight = 1.2f; - private const float ForageDistanceWeight = 0.5f; - - // Выбор корма: умный (избегает яда/невкусного) если интеллект ≥ порога, иначе рефлекс — ближайшее. - private bool TryFindForage( - Vector2 from, - float radius, - float intelligence, - float toxinTolerance, - out int plantId, - out Vector2 position - ) => - intelligence >= ForageSmartMinBrain - ? TryFindBestPlant(from, radius, toxinTolerance, out plantId, out position) - : TryFindPlant(from, radius, out plantId, out position); - - // Перестраивает индекс животных из текущих позиций (раз в кадр — звери двигаются). СРАЗУ считает - // признаки, нужные ИИ (мясоед/взрослость и пр.), чтобы запросы их не пересчитывали. - private void RebuildAnimalGrid() - { - _animalGrid.Clear(); - foreach (var (_, _, organisms, growths, transforms, entities) in _animals.Chunks) - { - var o = organisms.Span; - var g = growths.Span; - var t = transforms.Span; - for (var i = 0; i < t.Length; i++) - { - var sp = _animalSet[o[i].Species]; - _animalGrid.Add( - new NeighborEntry( - entities.EntityAt(i).Id, - t[i].Position, - o[i].Species, - o[i].Traits.BodySize, - o[i].EatsMeat, // диета кэширована при рождении - o[i].IsMale, - AnimalFactory.IsAdult(sp, g[i].Stage), - o[i].Traits.Camouflage, - o[i].Traits.Scent, - o[i].Traits.Noise - ) - ); - } - } - } - - // Перестраивает индексы растений и трупов (по каденции — они статичны). Устаревшая запись об удалённой - // сущности безопасна: потребитель (Graze/Hunt/TryFind*) сверяется со store и пропускает исчезнувшую. - private void RebuildStaticGrids() - { - _plantGrid.Clear(); - foreach (var (transforms, _, organisms, entities) in _plants.Chunks) - { - var t = transforms.Span; - var o = organisms.Span; - for (var i = 0; i < t.Length; i++) - { - ref readonly var tr = ref o[i].Traits; - _plantGrid.Add( - new ForageEntry( - entities.EntityAt(i).Id, - t[i].Position, - tr.Toxicity, - tr.Palatability - ) - ); - } - } - - _corpseGrid.Clear(); - foreach (var (transforms, corpses, entities) in _corpses.Chunks) - { - var t = transforms.Span; - var c = corpses.Span; - for (var i = 0; i < t.Length; i++) - { - _corpseGrid.Add(new CorpseEntry(entities.EntityAt(i).Id, t[i].Position, c[i].Meat)); - } - } - } - - // Рефлекс: ближайшее растение в радиусе (ничья — меньший id, детерминизм). Кандидаты — из сетки. - private bool TryFindPlant(Vector2 from, float radius, out int plantId, out Vector2 position) - { - var bestSq = radius * radius; - plantId = -1; - position = default; - _plantGrid.Collect(from, radius, _plantBuf); - foreach (var e in _plantBuf) - { - var sq = Vector2.DistanceSquared(from, e.Pos); - if (sq < bestSq || (sq == bestSq && e.Id < plantId)) - { - bestSq = sq; - plantId = e.Id; - position = e.Pos; - } - } - - return plantId >= 0; - } - - // Умный выбор: среди растений в радиусе максимизируем привлекательность = вкусность − - // воспринимаемый_яд − штраф_за_дальность. Воспринимаемый яд = toxicity × (1 − устойчивость): чем - // выше устойчивость зверя, тем меньше его отпугивает токсичный корм. Если всё в округе невкусно/ - // ядовито, голод всё равно заставит выбрать наименее плохой (берётся максимум, даже отрицательный). - private bool TryFindBestPlant( - Vector2 from, - float radius, - float toxinTolerance, - out int plantId, - out Vector2 position - ) - { - var radiusSq = radius * radius; - var tol = Math.Clamp(toxinTolerance, 0f, 1f); - var bestScore = float.NegativeInfinity; - plantId = -1; - position = default; - _plantGrid.Collect(from, radius, _plantBuf); - foreach (var e in _plantBuf) - { - var sq = Vector2.DistanceSquared(from, e.Pos); - if (sq > radiusSq) - { - continue; - } - - var perceivedToxin = e.Toxicity * (1f - tol); - var score = - e.Palatability - - ToxinAvoidWeight * perceivedToxin - - ForageDistanceWeight * (radius > 0f ? MathF.Sqrt(sq) / radius : 0f); - if (score > bestScore || (score == bestScore && e.Id < plantId)) - { - bestScore = score; - plantId = e.Id; - position = e.Pos; - } - } - - return plantId >= 0; - } - - // Ближайшая кромка воды (без лимита по зрению — звери «помнят» водопои; упрощение фазы A2). Берег - // статичен, поэтому ближайшая береговая точка предпосчитана по клеткам (поле _shoreByCell, BFS на - // старте) — поиск за O(1) вместо линейного скана всей береговой линии на каждое решение «пить». - private bool TryFindShore(Vector2 from, out Vector2 position) - { - position = default; - if (_shoreByCell.Length == 0) - { - return false; // в мире нет воды/берега — пить негде - } - - var cx = Math.Clamp((int)(from.X / _cellSize), 0, _worldWidth - 1); - var cy = Math.Clamp((int)(from.Y / _cellSize), 0, _worldHeight - 1); - position = _shoreByCell[cy * _worldWidth + cx]; - return true; - } - - // Ближайший подходящий партнёр в радиусе зрения: ТОГО ЖЕ вида, взрослый, противоположного пола. - private bool TryFindMate( - Vector2 from, - bool selfMale, - int selfSpecies, - float radius, - out int mateId, - out Vector2 position - ) - { - var bestSq = radius * radius; - mateId = -1; - position = default; - // _animalBuf уже собран в DecideIn на макс. радиусе чувств — фильтруем по радиусу спаривания. - foreach (var e in _animalBuf) - { - if (e.Species != selfSpecies || e.IsMale == selfMale || !e.IsAdult) - { - continue; // другой вид / тот же пол / не взрослый (self отсеивается по полу) - } - - // Влечение партнёра не проверяется (учёт придёт с половым отбором). self отсекается совпадением - // пола и sq≈0; межвидовые пары исключены (раньше волк/курица могли «спариться»). - var sq = Vector2.DistanceSquared(from, e.Pos); - if (sq > 0.01f && (sq < bestSq || (sq == bestSq && e.Id < mateId))) - { - bestSq = sq; - mateId = e.Id; - position = e.Pos; - } - } - - return mateId >= 0; - } -} - -/// -/// Слой ИСПОЛНЕНИЯ: ведёт зверя к цели (скорость = базовая × ген moveSpeed) и при достижении исполняет -/// действие по его id — ест растение (выедает; трава до нуля исчезает → ёмкость среды), пьёт у воды, -/// спит, спаривается. Восполняемую нужду находит по действию через . Структурные -/// изменения (гибель растений, беременность) применяются после прохода. -/// -public sealed class AnimalActionSystem : BaseSystem -{ - private const float GrazeKillAge = 1f; // трава с возрастом ниже этого после выедания исчезает - private const float ThirstReliefBelow = 0.15f; // ниже этого жажда — питьё даёт мысль «напилась» - private const float FleeSpeedMult = 1.2f; // страх подгоняет жертву чуть быстрее обычного шага - private const float PostReproductiveFrac = 0.9f; // доля жизни, после которой самка уже не зачинает - private const float SeedIngestChance = 0.15f; // шанс проглотить семя при выедании зрелого растения - private const float GutSeedDropDays = 2f; // через сколько дней проглоченное семя высаживается - private const float IdleRestFactor = 0.3f; // доля скорости восстановления сил при холостом отдыхе - private const float DigForageFactor = 0.25f; // доля сытости с копания относительно нормальной кормёжки - - private readonly EntityStore _store; - private readonly PlantSet _plants; - private readonly AnimalSet _animalSet; - private readonly NeedSet _needs; - private readonly SkillSet _skills; - private readonly ThoughtSet _thoughts; - private readonly WorldEvents _events; - private readonly GameClock _clock; - private readonly float _secondsPerDay; - private readonly int _cellSize; - private readonly RectF _bounds; - private readonly HediffDef? _bleeding; - private readonly HediffDef? _poisoned; - private readonly Random _rng; - - // Кэш индексов навыков-эффектов (−1, если навык не определён модом — тогда эффекта/XP нет). - private readonly int _forageSkill; - private readonly int _huntSkill; - private readonly int _evadeSkill; - - private readonly ArchetypeQuery< - AnimalBrain, - AnimalNeeds, - AnimalOrganism, - Transform2D, - Health - > _query; - private readonly List _eaten = []; - private readonly HashSet _eatenSet = []; // O(1)-проверка членства (порядок даёт _eaten) - private readonly List _consumed = []; // трупы, доеденные до нуля - private readonly HashSet _consumedSet = []; // O(1)-проверка членства (порядок даёт _consumed) - private readonly List _kills = []; // добыча, забитая в этом проходе (смерть после итерации) - private readonly HashSet _killsSet = []; // O(1)-проверка членства (порядок даёт _kills) - private readonly List<(Entity Self, Entity Partner)> _matings = []; - private readonly HashSet<(int, int)> _matedPairs = []; // обработанные пары за проход (дедуп (a,b)/(b,a)) - private readonly List<(Entity Eater, GutSeed Seed)> _ingested = []; // проглоченные семена (эндозоохория) - - public AnimalActionSystem( - EntityStore store, - PlantSet plants, - AnimalSet animals, - NeedSet needs, - SkillSet skills, - ThoughtSet thoughts, - WorldEvents events, - GameClock clock, - float secondsPerDay, - int cellSize, - RectF bounds, - HediffDef? bleeding, - HediffDef? poisoned, - int seed - ) - { - _store = store; - _plants = plants; - _animalSet = animals; - _needs = needs; - _skills = skills; - _forageSkill = skills.IndexOf(AnimalSkills.Foraging); - _huntSkill = skills.IndexOf(AnimalSkills.Hunting); - _evadeSkill = skills.IndexOf(AnimalSkills.Evasion); - _thoughts = thoughts; - _events = events; - _clock = clock; - _secondsPerDay = secondsPerDay; - _cellSize = cellSize; - _bounds = bounds; - _bleeding = bleeding; - _poisoned = poisoned; - _rng = new Random(seed); - _query = store.Query(); - } - - protected override void OnUpdateGroup() - { - var seconds = _clock.DeltaTime; - if (seconds <= 0f) - { - return; - } - - var days = seconds / _secondsPerDay; - _eaten.Clear(); - _eatenSet.Clear(); - _consumed.Clear(); - _consumedSet.Clear(); - _kills.Clear(); - _killsSet.Clear(); - _matings.Clear(); - _ingested.Clear(); - - foreach ( - var (brains, needsChunk, organisms, transforms, healths, entities) in _query.Chunks - ) - { - var b = brains.Span; - var n = needsChunk.Span; - var o = organisms.Span; - var t = transforms.Span; - var hh = healths.Span; - for (var i = 0; i < b.Length; i++) - { - ref var brain = ref b[i]; - var values = n[i].Values; - ref var pos = ref t[i].Position; - var def = _animalSet[o[i].Species].Def; - // Способность Moving (падает от ран/болезней) — множитель к скорости: больные медленнее. - var moving = hh[i].State.Capacity(AnimalCapacities.Moving); - var speed = def.BaseSpeed * MathF.Max(0.2f, o[i].Traits.MoveSpeed) * moving; - // Питание (челюсть) масштабирует укус, пищеварение (желудок) — усвоение: их произведение - // даёт сытость с корма; раненая челюсть/больной желудок → зверь голоднее (опция 1). - var eating = hh[i].State.Capacity(AnimalCapacities.Eating); - var foodEff = eating * hh[i].State.Capacity(AnimalCapacities.Digestion); - var self = entities.EntityAt(i); - self.TryGetComponent(out var skills); // один лукап на особь (эффекты + XP) - // Ноша замедляет (задел под перенос): перегруз сверх грузоподъёмности роняет скорость. - if (self.TryGetComponent(out var carrier) && carrier.CarriedKg > 0f) - { - var sp = _animalSet[o[i].Species]; - var stageScale = entities.EntityAt(i).TryGetComponent(out var gr) - ? sp.Stages[Math.Clamp(gr.Stage, 0, sp.Stages.Length - 1)].Scale - : 1f; - var capacity = Mass.CarryCapacity( - Mass.OfAnimal(sp.Def, o[i].Traits, stageScale), - moving - ); - speed *= Mass.LoadSpeedFactor(carrier.CarriedKg, capacity); - } - - switch (brain.Action) - { - case AnimalActions.Sleep: - Refill(values, AnimalActions.Sleep, days); - break; - - case AnimalActions.Eat: - if (MoveTo(ref pos, brain.Target, speed * seconds)) - { - // Навык добычи корма: опытный форажир извлекает больше сытости; растёт от практики. - var forageF = SkillFactor(_forageSkill, skills); - Refill(values, AnimalActions.Eat, days, foodEff * forageF); - Graze( - brain.TargetPlant, - days, - def.ForageBiteDays * MathF.Max(0.2f, eating) - ); - // Коэволюция: защита растения бьёт по поедателю (яд/шипы). - ApplyPlantDefense( - brain.TargetPlant, - hh[i].State, - o[i].Traits.ToxinTolerance, - days - ); - // Эндозоохория: шанс проглотить семя зрелого растения (высадит позже). - TryIngestSeed(brain.TargetPlant, self); - GrantXp(_forageSkill, skills, days); - } - - break; - - case AnimalActions.Drink: - if (MoveTo(ref pos, brain.Target, speed * seconds)) - { - // Утоление сильной жажды — событийная мысль настроения (A8); уровень берём - // ДО восполнения, чтобы радость была от облегчения, а не от каждого глотка. - var thirst = _needs.NeedForAction(AnimalActions.Drink); - var parched = - thirst >= 0 - && thirst < values.Length - && values[thirst] < ThirstReliefBelow; - Refill(values, AnimalActions.Drink, days); - if (parched) - { - entities - .EntityAt(i) - .GetComponent() - .Add(_thoughts.Get(AnimalThoughts.QuenchedThirst)); - } - } - - break; - - case AnimalActions.Mate: - if ( - brain.TargetMate >= 0 - && _store.TryGetEntityById(brain.TargetMate, out var partner) - && !partner.IsNull - && partner.HasComponent() - ) - { - var partnerPos = partner.GetComponent().Position; - if (MoveTo(ref pos, partnerPos, speed * seconds)) - { - _matings.Add((entities.EntityAt(i), partner)); - } - } - - break; - - case AnimalActions.Hunt: - { - // Сила удара: размер тела особи (со стадией) относительно эталона вида — крупная/ - // взрослая особь бьёт сильнее, детёныш слабее. Дальше домножается на навык охоты. - var spH = _animalSet[o[i].Species]; - var stageScaleH = self.TryGetComponent(out var grH) - ? spH.Stages[Math.Clamp(grH.Stage, 0, spH.Stages.Length - 1)].Scale - : 1f; - var strength = MathF.Max( - 0.2f, - o[i].Traits.BodySize * stageScaleH / spH.RefBodySize - ); - // Навык охоты: опытный хищник бьёт сильнее; растёт от практики. - Hunt( - ref brain, - ref pos, - values, - def, - speed * seconds, - seconds, - days, - foodEff, - SkillFactor(_huntSkill, skills), - strength, - self, - o[i].Species - ); - GrantXp(_huntSkill, skills, days); - break; - } - - case AnimalActions.Flee: - // Навык уклонения: опытная жертва убегает быстрее; растёт от практики бегства. - MoveTo( - ref pos, - brain.Target, - speed * FleeSpeedMult * SkillFactor(_evadeSkill, skills) * seconds - ); - GrantXp(_evadeSkill, skills, days); - break; - - case AnimalActions.Rest: - // Отдых на месте: понемногу восстанавливает силы (меньше, чем полноценный сон). - Refill(values, AnimalActions.Sleep, days, IdleRestFactor); - break; - - case AnimalActions.Dig: - // Копание в земле: травоядные/всеядные выкапывают корешки/личинок — немного сытости. - if (o[i].EatsPlants) - { - Refill(values, AnimalActions.Eat, days, DigForageFactor * foodEff); - } - - break; - - case AnimalActions.Wallow: - // Валяние в грязи: стоит на месте у воды (комфорт/терморегуляция) — без эффекта на нужды. - break; - - default: // Wander — блуждание к цели - MoveTo(ref pos, brain.Target, speed * seconds); - break; - } - - pos.X = Math.Clamp(pos.X, _bounds.Left, _bounds.Right); - pos.Y = Math.Clamp(pos.Y, _bounds.Top, _bounds.Bottom); - } - } - - foreach (var plant in _eaten) - { - plant.DeleteEntity(); - } - - foreach (var corpse in _consumed) - { - if (!corpse.IsNull) - { - corpse.DeleteEntity(); // труп доеден до нуля - } - } - - foreach (var prey in _kills) - { - AnimalFactory.Die(_store, _animalSet, prey, _cellSize); // забитая добыча → труп - } - - foreach (var (eater, seed) in _ingested) - { - if (!eater.IsNull && !eater.HasComponent()) - { - eater.AddComponent(seed); // структурное добавление — после прохода - } - } - - ApplyMatings(); - } - - // Эндозоохория: при выедании ЗРЕЛОГО растения шанс проглотить его семя — зверь понесёт его и - // высадит позже в другом месте (SeedDispersalSystem). Геном клонируется (отдельная особь-росток). - private void TryIngestSeed(int plantId, Entity eater) - { - if ( - eater.IsNull - || eater.HasComponent() - || plantId < 0 - || _rng.NextSingle() >= SeedIngestChance - || !_store.TryGetEntityById(plantId, out var plant) - || plant.IsNull - || !plant.HasComponent() - || !plant.HasComponent() - ) - { - return; - } - - ref readonly var grow = ref plant.GetComponent(); - if (grow.Stage < _plants[grow.Species].Stages.Length - 1) - { - return; // только зрелое растение даёт семя - } - - var genome = new Genome(plant.GetComponent().Genome.ToDictionary()); - _ingested.Add( - ( - eater, - new GutSeed - { - PlantSpecies = grow.Species, - Genome = genome, - DropInDays = GutSeedDropDays, - } - ) - ); - } - - // Охота/падальщество: ведёт к цели и при контакте либо ест труп (утоляет голод, расходует мясо), - // либо кусает живую добычу — урон части тела, острая кровопотеря, рана-кровотечение (первый - // травматический урон). Добитая добыча помечается на смерть (труп оставит общий путь Die). - // Минимальная видимая длительность красной вспышки урона у жертвы (реального времени). - private const float DamageFlashSeconds = 0.35f; - - private void Hunt( - ref AnimalBrain brain, - ref Vector2 pos, - float[] values, - AnimalDef def, - float step, - float seconds, - float days, - float foodEff, - float huntFactor, - float strength, - Entity hunter, - int hunterSpecies - ) - { - if ( - brain.TargetPrey < 0 - || !_store.TryGetEntityById(brain.TargetPrey, out var target) - || target.IsNull - || !target.HasComponent() - ) - { - return; // цель исчезла — действие пересмотрят на следующем решении - } - - var targetPos = target.GetComponent().Position; - var range = def.AttackRangeCells * _cellSize; - var inRange = Vector2.DistanceSquared(pos, targetPos) <= range * range; - - if (brain.TargetIsCorpse) - { - if (!target.HasComponent()) - { - return; - } - - if (!inRange) - { - MoveTo(ref pos, targetPos, step); - return; - } - - ref var corpse = ref target.GetComponent(); - Refill(values, AnimalActions.Eat, days, foodEff); - corpse.Meat -= def.ForageBiteDays * days; // расход мяса той же «силой укуса», что и выедание - if (corpse.Meat <= 0f && _consumedSet.Add(target)) - { - _consumed.Add(target); - } - - return; - } - - // Живая добыча. - if (!target.HasComponent() || !target.HasComponent()) - { - return; - } - - // Дискретные удары (как в RimWorld): кулдаун идёт и при сближении — первый контакт бьёт сразу. - brain.AttackCooldown -= seconds; - if (!inRange) - { - MoveTo(ref pos, targetPos, step); - return; - } - - if (brain.AttackCooldown > 0f) - { - return; // в контакте, но удар ещё на перезарядке - } - - brain.AttackCooldown = MathF.Max(0.05f, def.AttackIntervalSec); - - var preyHealth = target.GetComponent().State; - if (preyHealth is null) - { - return; - } - - // Сила удара = база дефа × сила особи (размер×стадия) × навык охоты; кровопотеря/кровотечение — ×силу. - var damage = def.AttackDamage * strength * huntFactor; - preyHealth.ApplyInjury( - damage, - def.AttackBloodLoss * strength, - _bleeding, - def.AttackBleed * strength, - _rng - ); - - // Красная вспышка жертвы (презентация) + удар в боевую хронику обоих участников. - if (target.HasComponent()) - { - target.GetComponent().Seconds = DamageFlashSeconds; - } - - _events.Struck( - hunter, - hunterSpecies, - target, - target.GetComponent().Species, - damage - ); - - if ( - preyHealth.BloodLevel <= 0f - || preyHealth.Capacity(AnimalCapacities.Consciousness) <= 0.01f - ) - { - if (_killsSet.Add(target)) - { - _kills.Add(target); // добита — смерть после прохода (структурное изменение) - _events.Killed( - hunter, - hunterSpecies, - target.GetComponent().Species - ); - } - } - } - - // Восполняет нужду, которую утоляет действие (по NeedSet), на её FeedPerDay × эффективность - // (питание/пищеварение для еды — слабая челюсть/больной желудок дают меньше сытости с того же корма). - private void Refill(float[] values, string action, float days, float efficiency = 1f) - { - var index = _needs.NeedForAction(action); - if (index < 0 || values is null || index >= values.Length) - { - return; - } - - values[index] = Math.Clamp( - values[index] + _needs[index].FeedPerDay * days * efficiency, - 0f, - 1f - ); - } - - // Бонус-множитель от уровня навыка: 0.7 (новичок) … 1.3 (мастер). Навык не определён модом → 1 (без эффекта). - private static float SkillFactor(int idx, in Skills s) - { - if (idx < 0 || s.Levels is null || idx >= s.Levels.Length) - { - return 1f; - } - - return 0.7f + 0.6f * s.Levels[idx]; - } - - // Начисляет опыт навыку от практики: рост ∝ скорость обучения × страсть × (1−уровень) (убывающая отдача). - private void GrantXp(int idx, in Skills s, float days) - { - if (idx < 0 || s.Levels is null || idx >= s.Levels.Length) - { - return; - } - - var passion = s.Passions is not null && idx < s.Passions.Length ? s.Passions[idx] : (byte)0; - var gain = - _skills[idx].LearnRate - * SkillSet.PassionMultiplier(passion) - * days - * (1f - s.Levels[idx]); - s.Levels[idx] = Math.Clamp(s.Levels[idx] + gain, 0f, 1f); - } - - // Двигает позицию к цели на step; возвращает true, если уже у цели (можно исполнять действие). - private static bool MoveTo(ref Vector2 pos, Vector2 target, float step) - { - var delta = target - pos; - var dist = delta.Length(); - if (dist <= step || dist < 0.001f) - { - pos = dist < 0.001f ? pos : target; - return true; - } - - pos += delta / dist * step; - return false; - } - - // Выедание: убавляет рост растения; траву (без ствола), выеденную до нуля, помечает на гибель. - private void Graze(int plantId, float days, float bitePerDay) - { - if ( - plantId < 0 - || !_store.TryGetEntityById(plantId, out var plant) - || plant.IsNull - || !plant.HasComponent() - ) - { - return; - } - - ref var grow = ref plant.GetComponent(); - grow.AgeDays = MathF.Max(0f, grow.AgeDays - bitePerDay * days); - var isGrass = _plants[grow.Species].Def.TrunkRadiusCells <= 0f; - if (isGrass && grow.AgeDays <= GrazeKillAge && _eatenSet.Add(plant)) - { - _eaten.Add(plant); - } - } - - // Доза яда за день укуса при токсичности 1 и нулевой устойчивости (масштабируется тяжести Poisoned). - private const float PoisonDosePerDay = 1.6f; - - // Порог шипов, выше которого укус о растение травмирует, и масштабы урона/кровопотери от шипов. - private const float ThornThreshold = 0.2f; - private const float ThornPartDamage = 4f; - private const float ThornBloodLoss = 0.02f; - - // Коэволюция: при поедании растение «отвечает» — токсичность отравляет (тем сильнее, чем ниже - // устойчивость зверя к яду), шипы наносят лёгкую травму. Доза яда копится в хедифе Poisoned; если - // зверь ест яд быстрее, чем выводит, тяжесть дойдёт до летальной. Детерминированно (общий _rng). - private void ApplyPlantDefense( - int plantId, - HealthState? health, - float toxinTolerance, - float days - ) - { - if ( - health is null - || plantId < 0 - || !_store.TryGetEntityById(plantId, out var plant) - || plant.IsNull - || !plant.HasComponent() - ) - { - return; - } - - ref readonly var traits = ref plant.GetComponent().Traits; - - var poisoned = false; - if (_poisoned is not null && traits.Toxicity > 0f) - { - var dose = traits.Toxicity * (1f - Math.Clamp(toxinTolerance, 0f, 1f)); - if (dose > 0f) - { - health.Intensify(_poisoned, dose * PoisonDosePerDay * days); - poisoned = true; - } - } - - if (traits.Thorns > ThornThreshold) - { - // ApplyInjury сам пересчитывает способности (учтёт и свежий яд). - health.ApplyInjury( - traits.Thorns * ThornPartDamage * days, - traits.Thorns * ThornBloodLoss * days, - _bleeding, - traits.Thorns * 0.15f * days, - _rng - ); - } - else if (poisoned) - { - health.RecomputeCapacities(); // отравление меняет capacity-моды — применить сразу - } - } - - // Применяет спаривания после прохода: самка пары беременеет геномом самца (структурное добавление — - // после итерации), влечение обоих обнуляется. Двойные события на одну самку отсеиваются. - private void ApplyMatings() - { - var mateNeed = _needs.NeedForAction(AnimalActions.Mate); - _matedPairs.Clear(); - foreach (var (a, b) in _matings) - { - if ( - a.IsNull - || b.IsNull - || !a.HasComponent() - || !b.HasComponent() - ) - { - continue; - } - - // Оба партнёра независимо ставят себя в очередь, так что пара приходит дважды — (a,b) и (b,a). - // Дедупим по неупорядоченному ключу: иначе ResetMating/мысль «спаривание» применяются повторно. - var pairKey = a.Id < b.Id ? (a.Id, b.Id) : (b.Id, a.Id); - if (!_matedPairs.Add(pairKey)) - { - continue; - } - - var aMale = a.GetComponent().IsMale; - var bMale = b.GetComponent().IsMale; - if (aMale == bMale) - { - continue; // один пол — не пара (страховка) - } - - var mother = aMale ? b : a; - var father = aMale ? a : b; - if (!mother.HasComponent()) - { - ref readonly var mom = ref mother.GetComponent(); - // Плодовитость падает со старостью (тот же фактор сенесценса): дряхлая самка приносит - // меньший помёт, а после порога пострепродуктивного возраста уже не зачинает. - var momAge = mother.HasComponent() - ? mother.GetComponent().AgeDays - : 0f; - var ageFrac = mom.Traits.Lifespan > 0f ? momAge / mom.Traits.Lifespan : 0f; - if (ageFrac <= PostReproductiveFrac) - { - var fert = AnimalHealthSystem.SenescenceFactor(momAge, mom.Traits.Lifespan); - mother.AddComponent( - new Pregnant - { - FatherGenome = father.GetComponent().Genome, - DueInDays = MathF.Max(1f, mom.Traits.GestationDays), - Litter = Math.Max(1, (int)MathF.Round(mom.Traits.LitterSize * fert)), - } - ); - _events.Pregnant(mother, mom.Species); - } - } - - ResetMating(a, mateNeed); - ResetMating(b, mateNeed); - // Спаривание — положительная событийная мысль обоим партнёрам (A8). - var mated = _thoughts.Get(AnimalThoughts.Mated); - a.GetComponent().Add(mated); - b.GetComponent().Add(mated); - _events.Mated( - a, - a.GetComponent().Species, - b, - b.GetComponent().Species - ); - } - } - - private static void ResetMating(Entity entity, int mateNeed) - { - if (mateNeed < 0 || !entity.HasComponent()) - { - return; - } - - var values = entity.GetComponent().Values; - if (values is not null && mateNeed < values.Length) - { - values[mateNeed] = 0f; - } - } -} - -/// -/// Настроение (фаза A8): для особей тира 4+ (мозг ≥ порога) считает настроение из непрерывного фона -/// (удовлетворённость убывающих нужд минус боль из здоровья) и суммы затухающих событийных мыслей. -/// Мысли угасают со временем. У примитивных (тир < 4) настроения нет (Active=false, мысли сбрасываются). -/// Гейт интеллектом — тем же порогом тира 4, что и высшие нужды (сон/секс). Низкое настроение влияет на -/// поведение (стресс не даёт спариваться — см. ) и видно в консоли/инспекторе. -/// -public sealed class AnimalMoodSystem(GameClock clock, float secondsPerDay, NeedSet needs) - : QuerySystem -{ - /// Порог размера мозга (тир 4), с которого у особи появляется настроение. - public const float MoodMinBrain = 0.40f; - private const float NeutralBase = 0.35f; // настроение при полном истощении нужд (без боли/мыслей) - private const float NeedsWeight = 0.55f; // вклад средней удовлетворённости убывающих нужд - private const float PainWeight = 0.4f; // боль из здоровья тянет настроение вниз - - protected override void OnUpdate() - { - var days = clock.DeltaTime / secondsPerDay; - if (days <= 0f) - { - return; - } - - foreach (var (moods, needsChunk, organisms, healths, _) in Query.Chunks) - { - var m = moods.Span; - var n = needsChunk.Span; - var o = organisms.Span; - var h = healths.Span; - for (var i = 0; i < m.Length; i++) - { - ref var mood = ref m[i]; - if (o[i].Traits.BrainSize < MoodMinBrain) - { - mood.Active = false; // тир ниже зверя — настроения нет - mood.Value = 0.5f; - mood.Thoughts?.Clear(); - continue; - } - - mood.Active = true; - // 1) Затухание событийных мыслей + сумма их сдвигов. - var thoughtSum = 0f; - var list = mood.Thoughts; - if (list is not null) - { - for (var k = list.Count - 1; k >= 0; k--) - { - var t = list[k]; - t.RemainingDays -= days; - if (t.RemainingDays <= 0f) - { - list.RemoveAt(k); - continue; - } - - list[k] = t; - thoughtSum += t.Def.MoodOffset; - } - } - - // 2) Непрерывный фон: средняя удовлетворённость убывающих нужд минус боль из здоровья. - var wellbeing = AvgDeplete(n[i].Values); - var pain = h[i].State?.Pain ?? 0f; - mood.Value = Math.Clamp( - NeutralBase + NeedsWeight * wellbeing - PainWeight * pain + thoughtSum, - 0f, - 1f - ); - } - } - } - - // Средняя удовлетворённость убывающих нужд (голод/жажда/усталость) [0,1]; нет нужд → 1 (нейтрально). - private float AvgDeplete(float[] values) - { - if (values is null) - { - return 1f; - } - - var sum = 0f; - var count = 0; - for (var k = 0; k < needs.Count && k < values.Length; k++) - { - if (needs.IsDeplete(k)) - { - sum += values[k]; - count++; - } - } - - return count > 0 ? sum / count : 1f; - } -} - -/// -/// Презентация состояния нужд: голодный/уставший зверь тускнеет (яркость падает с самой острой -/// убывающей нуждой), сохраняя оттенок меха из генов. Читает симуляцию, пишет только -/// — граница sim/presentation цела. -/// -public sealed class AnimalAppearanceSystem(NeedSet needs) - : QuerySystem -{ - private const float MinBrightness = 0.5f; - - protected override void OnUpdate() - { - foreach (var (needsChunk, organisms, sprites, _) in Query.Chunks) - { - var n = needsChunk.Span; - var o = organisms.Span; - var s = sprites.Span; - for (var i = 0; i < n.Length; i++) - { - var values = n[i].Values; - var worst = 1f; - if (values is not null) - { - for (var k = 0; k < needs.Count && k < values.Length; k++) - { - if (needs.IsDeplete(k)) - { - worst = MathF.Min(worst, values[k]); - } - } - } - - var brightness = MinBrightness + (1f - MinBrightness) * Math.Clamp(worst, 0f, 1f); - s[i].Color = AnimalFactory.FurTint(o[i].Traits) * brightness; - } - } - } -} - -/// -/// Красная вспышка урона (презентация, как в RimWorld): особь, получившую удар, кратко подкрашивает -/// красным и гасит свечение по РЕАЛЬНОМУ времени (видно и на высокой скорости). Запускать ПОСЛЕ -/// (та задаёт базовый цвет от нужд) — вспышка подмешивается поверх. -/// Время свечения выставляет при ударе. Граница sim/presentation цела. -/// -public sealed class DamageFlashSystem(GameClock clock) : QuerySystem -{ - private static readonly Color FlashColor = new(255, 60, 60); - private const float MaxIntensity = 0.85f; // насколько краснит на пике - private const float FlashDuration = 0.35f; // полная длительность свечения (сек реального времени) - - protected override void OnUpdate() - { - var dt = clock.UnscaledDeltaTime; - foreach (var (sprites, flashes, _) in Query.Chunks) - { - var s = sprites.Span; - var f = flashes.Span; - for (var i = 0; i < f.Length; i++) - { - ref var flash = ref f[i]; - if (flash.Seconds <= 0f) - { - continue; - } - - flash.Seconds = MathF.Max(0f, flash.Seconds - dt); - var t = Math.Clamp(flash.Seconds / FlashDuration, 0f, 1f) * MaxIntensity; - s[i].Color = Color.Lerp(s[i].Color, FlashColor, t); - } - } - } -} - -/// -/// Разворот спрайта по направлению движения (как в RimWorld): зверь смотрит туда, куда идёт. Восток — -/// базовая текстура, запад — её зеркало (флип по X), север/юг — отдельные текстуры (если есть у вида). -/// Направление берётся из цели «мозга» () относительно позиции; стоящий -/// зверь сохраняет прежний разворот. При смене региона пересчитывает центр-origin и масштаб (текстуры -/// направлений могут отличаться шириной). Презентация (читает sim, пишет только Sprite/Transform-scale). -/// -public sealed class AnimalFacingSystem(AnimalSet animals, int cellSize) - : QuerySystem -{ - private const float MinMove = 0.5f; // короче — считаем стоящим, разворот не трогаем - - protected override void OnUpdate() - { - foreach (var (brains, organisms, growths, transforms, sprites, _) in Query.Chunks) - { - var b = brains.Span; - var o = organisms.Span; - var g = growths.Span; - var t = transforms.Span; - var s = sprites.Span; - for (var i = 0; i < b.Length; i++) - { - var dir = b[i].Target - t[i].Position; - if (dir.LengthSquared() < MinMove * MinMove) - { - continue; // стоит/у цели — сохраняем текущий разворот - } - - var sp = animals[o[i].Species]; - var dirs = sp.LookDirs(g[i].Stage, o[i].IsMale); - Texture2DRegion region; - SpriteFlip flip; - if (MathF.Abs(dir.X) >= MathF.Abs(dir.Y)) - { - region = dirs.East; - flip = dir.X < 0f ? SpriteFlip.X : SpriteFlip.None; // запад = зеркало востока - } - else - { - region = dir.Y < 0f ? dirs.North : dirs.South; // экранный Y вниз: вверх = север - flip = SpriteFlip.None; - } - - ref var sprite = ref s[i]; - if (sprite.Region == region && sprite.Flip == flip) - { - continue; // разворот не изменился - } - - sprite.Region = region; - sprite.Origin = new Vector2(region.Width / 2f, region.Height / 2f); - sprite.Flip = flip; - t[i].Scale = new Vector2( - AnimalFactory.Scale(sp, o[i].Traits, g[i].Stage, region, cellSize) - ); - } - } - } -} - -/// -/// «Раскачка» при ходьбе (презентация): идущий зверь покачивается из стороны в сторону (наклон спрайта -/// через ), стоящий — плавно выпрямляется. Делает движение живее, не -/// затрагивая симуляцию (она вращение не использует). Фаза покачивания идёт по игровому времени (стоп на -/// паузе, быстрее на ускорении) и сдвинута по id особи — звери качаются вразнобой. Граница sim/presentation -/// цела: система пишет только поворот трансформа. -/// -public sealed class AnimalBobSystem(GameClock clock) : QuerySystem -{ - private const float SwaySpeed = 9f; // скорость набега фазы (рад/сек игрового времени) - private const float SwayAmplitude = 0.08f; // амплитуда наклона (рад ≈ 4.6°) - private const float MinMoveSq = 0.5f * 0.5f; // ближе к цели — считаем стоящим - private const float UprightDecay = 0.85f; // как быстро выпрямляется стоя (за кадр) - - private float _phase; - - protected override void OnUpdate() - { - var dt = clock.DeltaTime; - if (dt <= 0f) - { - return; // пауза — не качаем и не выпрямляем - } - - _phase += dt * SwaySpeed; - foreach (var (brains, transforms, entities) in Query.Chunks) - { - var b = brains.Span; - var t = transforms.Span; - for (var i = 0; i < b.Length; i++) - { - ref var rot = ref t[i].Rotation; - if ((b[i].Target - t[i].Position).LengthSquared() > MinMoveSq) - { - var offset = entities.EntityAt(i).Id * 0.7f; // десинхрон между особями - rot = MathF.Sin(_phase + offset) * SwayAmplitude; - } - else - { - rot *= UprightDecay; // у цели/стоя — плавно к вертикали - } - } - } - } -} - -/// -/// Рост и стадии (фаза A3): копит возраст в игровых днях, переключает стадию (Baby→Juvenile→Adult→ -/// Senior) по порогам из генов и при смене стадии меняет спрайт (детёныш/самка/самец) и размер. -/// -public sealed class AnimalGrowthSystem( - GameClock clock, - float secondsPerDay, - AnimalSet animals, - int cellSize, - WorldEvents events -) : QuerySystem -{ - protected override void OnUpdate() - { - var days = clock.DeltaTime / secondsPerDay; - if (days <= 0f) - { - return; - } - - foreach (var (growths, organisms, transforms, sprites, entities) in Query.Chunks) - { - var g = growths.Span; - var o = organisms.Span; - var t = transforms.Span; - var s = sprites.Span; - for (var i = 0; i < g.Length; i++) - { - ref var grow = ref g[i]; - grow.AgeDays += days; - ref readonly var org = ref o[i]; - var sp = animals[org.Species]; - var stage = AnimalFactory.StageAt( - grow.AgeDays, - org.Traits.MaturityAge, - org.Traits.Lifespan, - sp.Stages - ); - if (stage == grow.Stage) - { - continue; - } - - grow.Stage = stage; - var region = AnimalFactory.RegionFor(sp, stage, org.IsMale); - ref var sprite = ref s[i]; - sprite.Region = region; - sprite.Origin = new Vector2(region.Width / 2f, region.Height / 2f); - t[i].Scale = new Vector2( - AnimalFactory.Scale(sp, org.Traits, stage, region, cellSize) - ); - events.Grew(entities.EntityAt(i), org.Species, sp.Stages[stage].Name); // в хронику особи - } - } - } -} - -/// -/// Смертность (фаза A3/A5): гибель от старости (возраст превысил продолжительность жизни) или истощения -/// (счётчик голода/жажды превысил порог). Замыкает контур ёмкости среды. Фаза A5: вместо деспавна -/// оставляет (через ), затем удаляет особь. -/// -public sealed class AnimalMortalitySystem : BaseSystem -{ - private const float LethalStarveDays = 3f; - - private readonly EntityStore _store; - private readonly AnimalSet _animals; - private readonly WorldEvents _events; - private readonly int _cellSize; - private readonly ArchetypeQuery _query; - private readonly List _deaths = []; - - public AnimalMortalitySystem( - EntityStore store, - AnimalSet animals, - WorldEvents events, - int cellSize - ) - { - _store = store; - _animals = animals; - _events = events; - _cellSize = cellSize; - _query = store.Query(); - } - - protected override void OnUpdateGroup() - { - _deaths.Clear(); - foreach (var (growths, organisms, needsChunk, entities) in _query.Chunks) - { - var g = growths.Span; - var o = organisms.Span; - var n = needsChunk.Span; - for (var i = 0; i < g.Length; i++) - { - var ageDeath = g[i].AgeDays > o[i].Traits.Lifespan; - var starveDeath = n[i].Starve >= LethalStarveDays; - if (ageDeath || starveDeath) - { - _deaths.Add(entities.EntityAt(i)); - _events.Died(o[i].Species, ageDeath ? "event.death.age" : "event.death.starve"); - } - } - } - - foreach (var dead in _deaths) - { - AnimalFactory.Die(_store, _animals, dead, _cellSize); - } - } -} - -/// -/// Разложение трупа (фаза A5): копит дни гниения (ускоряется в тепле, замедляется на морозе), -/// переключает стадии Fresh→Rotting→Skeleton по порогам и тинтит спрайт; по достижении предела труп -/// исчезает. Скелет рисуется бледным тинтом падали (отдельного спрайта пока нет — см. docs). -/// -public sealed class CorpseSystem : BaseSystem -{ - private const float RottingDay = 2f; - private const float SkeletonDay = 6f; - private const float GoneDay = 14f; - - private static readonly Color FreshTint = new(176, 148, 128); - private static readonly Color RottingTint = new(120, 118, 98); - private static readonly Color SkeletonTint = new(228, 226, 214); - - private readonly GameClock _clock; - private readonly float _secondsPerDay; - private readonly Climate _climate; - private readonly ArchetypeQuery _query; - private readonly List _gone = []; - - public CorpseSystem(EntityStore store, GameClock clock, float secondsPerDay, Climate climate) - { - _clock = clock; - _secondsPerDay = secondsPerDay; - _climate = climate; - _query = store.Query(); - } - - protected override void OnUpdateGroup() - { - var days = _clock.DeltaTime / _secondsPerDay; - if (days <= 0f) - { - return; - } - - // Тепло ускоряет гниение, около нуля и ниже — резко замедляет (заморозка). - var factor = Math.Clamp((_climate.Temperature + 5f) / 20f, 0.1f, 2.5f); - var advance = days * factor; - _gone.Clear(); - - foreach (var (corpses, sprites, entities) in _query.Chunks) - { - var c = corpses.Span; - var s = sprites.Span; - for (var i = 0; i < c.Length; i++) - { - ref var corpse = ref c[i]; - corpse.RotDays += advance; - if (corpse.RotDays >= GoneDay) - { - _gone.Add(entities.EntityAt(i)); - continue; - } - - var stage = - corpse.RotDays >= SkeletonDay ? 2 - : corpse.RotDays >= RottingDay ? 1 - : 0; - if (stage != corpse.Stage) - { - corpse.Stage = stage; - s[i].Color = - stage == 2 ? SkeletonTint - : stage == 1 ? RottingTint - : FreshTint; - } - } - } - - foreach (var entity in _gone) - { - entity.DeleteEntity(); - } - } -} - -/// -/// Беременность и роды (фаза A4): тикает срок вынашивания; по истечении рожает помёт — каждый детёныш = -/// генома матери и отца (пол наследуется без YY), стадия Baby, поколение -/// матери + 1, рядом с матерью. Компонент снимается. Потолок численности -/// страхует от взрыва популяции. -/// -public sealed class AnimalPregnancySystem : BaseSystem -{ - private readonly EntityStore _store; - private readonly AnimalSet _animals; - private readonly NeedSet _needs; - private readonly SkillSet _skills; - private readonly ThoughtSet _thoughts; - private readonly WorldEvents _events; - private readonly GameClock _clock; - private readonly float _secondsPerDay; - private readonly int _cellSize; - private readonly int _maxAnimals; - private readonly Random _rng; - private readonly ArchetypeQuery _query; - private readonly ArchetypeQuery _census; - private readonly List _births = []; - - public AnimalPregnancySystem( - EntityStore store, - AnimalSet animals, - NeedSet needs, - SkillSet skills, - ThoughtSet thoughts, - WorldEvents events, - GameClock clock, - float secondsPerDay, - int cellSize, - int maxAnimals, - int seed - ) - { - _store = store; - _animals = animals; - _needs = needs; - _skills = skills; - _thoughts = thoughts; - _events = events; - _clock = clock; - _secondsPerDay = secondsPerDay; - _cellSize = cellSize; - _maxAnimals = maxAnimals; - _rng = new Random(seed); - _query = store.Query(); - _census = store.Query(); - } - - protected override void OnUpdateGroup() - { - var days = _clock.DeltaTime / _secondsPerDay; - if (days <= 0f) - { - return; - } - - _births.Clear(); - foreach (var (pregnancies, organisms, transforms, entities) in _query.Chunks) - { - var p = pregnancies.Span; - var o = organisms.Span; - var t = transforms.Span; - for (var i = 0; i < p.Length; i++) - { - ref var preg = ref p[i]; - preg.DueInDays -= days; - if (preg.DueInDays > 0f) - { - continue; - } - - _births.Add( - new Birth - { - Mother = entities.EntityAt(i), - Species = o[i].Species, - MotherGenome = o[i].Genome, - FatherGenome = preg.FatherGenome, - Litter = preg.Litter, - Generation = o[i].Generation + 1, - Position = t[i].Position, - Oviparous = o[i].Traits.Oviparous, - IncubateDays = MathF.Max(1f, o[i].Traits.GestationDays), - } - ); - } - } - - var count = _census.Count; - foreach (var birth in _births) - { - if (birth.Mother.HasComponent()) - { - birth.Mother.RemoveComponent(); - } - - // Роды — сильная положительная мысль матери (A8). - if (birth.Mother.HasComponent()) - { - birth.Mother.GetComponent().Add(_thoughts.Get(AnimalThoughts.GaveBirth)); - } - - var bornLive = 0; - for (var k = 0; k < birth.Litter && count < _maxAnimals; k++) - { - var child = Genome.Breed( - birth.MotherGenome, - birth.FatherGenome, - _animals.GeneRegistry, - _rng - ); - var offset = - new Vector2(_rng.NextSingle() - 0.5f, _rng.NextSingle() - 0.5f) * _cellSize; - if (birth.Oviparous) - { - // Яйцекладка: вместо живого детёныша откладываем яйцо с его геномом — вылупится позже. - AnimalFactory.CreateEgg( - _store, - _animals, - birth.Species, - birth.Position + offset, - child, - birth.Generation, - birth.IncubateDays, - _cellSize - ); - } - else - { - AnimalFactory.Create( - _store, - _animals, - _needs, - _skills, - birth.Species, - birth.Position + offset, - ageDays: 0f, - child, - birth.Generation, - _cellSize - ); - bornLive++; - } - - count++; - } - - // Живые роды — заметное событие (яйцекладка отметится позже при вылуплении в EggSystem). - if (bornLive > 0) - { - _events.Born(birth.Mother, birth.Species, bornLive); - } - } - } - - private struct Birth - { - public Entity Mother; - public int Species; - public Genome MotherGenome; - public Genome FatherGenome; - public int Litter; - public int Generation; - public Vector2 Position; - public bool Oviparous; - public float IncubateDays; - } -} - -/// -/// Инкубация яиц (тип рождения «яйцекладка»): тикает остаток инкубации каждого яйца; по истечении -/// вылупляет детёныша ( из хранимого генома, стадия Baby) рядом с -/// яйцом и удаляет яйцо. Зеркало родов для яйцекладущих видов. -/// -public sealed class EggSystem : BaseSystem -{ - private readonly EntityStore _store; - private readonly AnimalSet _animals; - private readonly NeedSet _needs; - private readonly SkillSet _skills; - private readonly WorldEvents _events; - private readonly GameClock _clock; - private readonly float _secondsPerDay; - private readonly int _cellSize; - private readonly Random _rng; - private readonly ArchetypeQuery _query; - private readonly List _hatched = []; - - public EggSystem( - EntityStore store, - AnimalSet animals, - NeedSet needs, - SkillSet skills, - WorldEvents events, - GameClock clock, - float secondsPerDay, - int cellSize, - int seed - ) - { - _store = store; - _animals = animals; - _needs = needs; - _skills = skills; - _events = events; - _clock = clock; - _secondsPerDay = secondsPerDay; - _cellSize = cellSize; - _rng = new Random(seed); - _query = store.Query(); - } - - protected override void OnUpdateGroup() - { - var days = _clock.DeltaTime / _secondsPerDay; - if (days <= 0f) - { - return; - } - - _hatched.Clear(); - foreach (var (eggs, transforms, entities) in _query.Chunks) - { - var e = eggs.Span; - for (var i = 0; i < e.Length; i++) - { - ref var egg = ref e[i]; - egg.IncubateDays -= days; - if (egg.IncubateDays > 0f) - { - continue; - } - - var offset = - new Vector2(_rng.NextSingle() - 0.5f, _rng.NextSingle() - 0.5f) * _cellSize; - AnimalFactory.Create( - _store, - _animals, - _needs, - _skills, - egg.Species, - transforms.Span[i].Position + offset, - ageDays: 0f, - egg.Genome, - egg.Generation, - _cellSize - ); - _events.Hatched(egg.Species, 1); - _hatched.Add(entities.EntityAt(i)); - } - } - - foreach (var egg in _hatched) - { - egg.DeleteEntity(); - } - } -} - -/// -/// Здоровье и болезни (фаза A6): медленный тик (~игровой час). Фоновое заражение, прогресс болезней -/// (гонка тяжесть↔иммунитет — выздоровление или смерть), пересчёт способностей с учётом хедифов. Смерть -/// по здоровью (летальная болезнь / кровь на нуле / потеря сознания) оставляет труп. Первый реальный -/// источник урона — оживляет каркас A5 (больные звери теряют Moving → медленнее). -/// -public sealed class AnimalHealthSystem : BaseSystem -{ - private const float TickDays = 1f / 24f; // ~игровой час - private const float BloodRecoveryPerDay = 0.25f; // восстановление крови в покое (нет кровотечений) - - private readonly EntityStore _store; - private readonly AnimalSet _animals; - private readonly WorldEvents _events; - private readonly int _cellSize; - private readonly GameClock _clock; - private readonly float _secondsPerDay; - private readonly Climate _climate; - private readonly HediffDef[] _ambient; - private readonly HediffDef? _hypothermia; - private readonly HediffDef? _heatstroke; - private readonly HediffDef? _infection; - private readonly Random _rng; - private readonly ArchetypeQuery _query; - private readonly List _deaths = []; - private float _accum; - - // Сенесценс (старение): спад способностей начинается с этой доли продолжительности жизни и достигает - // максимума к её концу. Дряхлеющий зверь слабее (медленнее, хуже видит/фильтрует кровь) и уязвимее. - private const float SenescenceStart = 0.6f; - private const float SenescenceMax = 0.45f; - - // Термокомфорт (°C) при теплоизоляции 0.5 и размере тела 1; границы сдвигаются мехом и размером тела. - private const float ColdComfort = 2f; // ниже — мёрзнет - private const float HeatComfort = 30f; // выше — перегревается - private const float ColdInsulationBonus = 30f; // мех (insulation 0..1) расширяет холодоустойчивость - private const float HeatInsulationPenalty = 14f; // тот же мех ухудшает жароустойчивость - private const float ColdBodyBonus = 8f; // крупное тело (тепловая инерция) держит холод лучше - private const float ThermalRatePerDegree = 0.06f; // прирост тяжести за °C за пределом комфорта в день - - // Заражение раны: шанс в день при кровотечении тяжести 1 подхватить инфекцию. - private const float InfectionChancePerDay = 0.25f; - - public AnimalHealthSystem( - EntityStore store, - AnimalSet animals, - WorldEvents events, - int cellSize, - GameClock clock, - float secondsPerDay, - Climate climate, - HediffDef[] ambient, - HediffDef? hypothermia, - HediffDef? heatstroke, - HediffDef? infection, - int seed - ) - { - _store = store; - _animals = animals; - _events = events; - _cellSize = cellSize; - _clock = clock; - _secondsPerDay = secondsPerDay; - _climate = climate; - _ambient = ambient; - _hypothermia = hypothermia; - _heatstroke = heatstroke; - _infection = infection; - _rng = new Random(seed); - _query = store.Query(); - } - - /// Множитель способностей от старения (1 — молод/расцвет, <1 — дряхлеет к концу жизни). - public static float SenescenceFactor(float ageDays, float lifespan) - { - if (lifespan <= 0f) - { - return 1f; - } - - var frac = (ageDays / lifespan - SenescenceStart) / MathF.Max(0.01f, 1f - SenescenceStart); - return 1f - SenescenceMax * Math.Clamp(frac, 0f, 1f); - } - - protected override void OnUpdateGroup() - { - _accum += _clock.DeltaTime / _secondsPerDay; - if (_accum < TickDays) - { - return; - } - - var days = _accum; - _accum = 0f; - _deaths.Clear(); - - foreach (var (healths, organisms, growths, entities) in _query.Chunks) - { - var h = healths.Span; - var o = organisms.Span; - var g = growths.Span; - for (var i = 0; i < h.Length; i++) - { - var state = h[i].State; - if (state is null) - { - continue; - } - - foreach (var disease in _ambient) - { - if ( - !state.Has(disease.DefName) - && _rng.NextSingle() < disease.AmbientPerDay * days - ) - { - state.Add(disease); - } - } - - // Терморегуляция: вне комфорта (зависит от GeneInsulation + размера тела) копится тяжесть - // гипотермии/теплового удара; в комфорте immunityPerDay их рассасывает. Мех расширяет - // холодоустойчивость, но ухудшает жароустойчивость; крупное тело держит холод. - var insul = Math.Clamp(o[i].Traits.Insulation, 0f, 1f); - var coldLimit = - ColdComfort - - ColdInsulationBonus * insul - - ColdBodyBonus * MathF.Max(0f, o[i].Traits.BodySize - 1f); - var heatLimit = HeatComfort - HeatInsulationPenalty * insul; - var temp = _climate.Temperature; - if (temp < coldLimit && _hypothermia is not null) - { - state.Intensify(_hypothermia, (coldLimit - temp) * ThermalRatePerDegree * days); - } - else if (temp > heatLimit && _heatstroke is not null) - { - state.Intensify(_heatstroke, (temp - heatLimit) * ThermalRatePerDegree * days); - } - - // Заражение раны: пока есть кровотечение, шанс (∝ его тяжести) подхватить инфекцию. - if (_infection is not null && !state.Has(_infection.DefName)) - { - var bleedSeverity = 0f; - foreach (var hd in state.Hediffs) - { - if (hd.Def.BloodLossPerDay > 0f && hd.Severity > bleedSeverity) - { - bleedSeverity = hd.Severity; - } - } - - if ( - bleedSeverity > 0f - && _rng.NextSingle() < InfectionChancePerDay * bleedSeverity * days - ) - { - state.Add(_infection); - } - } - - // Фильтрация крови (почки/печень) ускоряет вывод токсинов и набор иммунитета: повреждённый - // орган → медленнее выздоровление от яда/болезни/инфекции (для тела без неё Capacity вернёт 1). - var lethal = state.AdvanceHediffs( - days, - state.Capacity(AnimalCapacities.BloodFiltration) - ); - // Кровопотеря от ран (кровотечения), иначе — постепенное восстановление крови в покое. - var bleed = 0f; - foreach (var hd in state.Hediffs) - { - bleed += hd.Def.BloodLossPerDay * hd.Severity; - } - - if (bleed > 0f) - { - state.BloodLevel = Math.Clamp(state.BloodLevel - bleed * days, 0f, 1f); - } - else if (state.BloodLevel < 1f) - { - state.BloodLevel = Math.Clamp( - state.BloodLevel + BloodRecoveryPerDay * days, - 0f, - 1f - ); - } - - // Старение: к концу жизни способности дряхлеют (медленнее/слабее, уязвимее к ранам/болезни). - var senescence = SenescenceFactor(g[i].AgeDays, o[i].Traits.Lifespan); - state.RecomputeCapacities(senescence); - if ( - lethal - || state.BloodLevel <= 0f - || state.Capacity(AnimalCapacities.Consciousness) <= 0.01f - ) - { - _deaths.Add(entities.EntityAt(i)); - _events.Died(o[i].Species, "event.death.disease"); - } - } - } - - foreach (var dead in _deaths) - { - AnimalFactory.Die(_store, _animals, dead, _cellSize); - } - } -} - -/// -/// Разнос семян (эндозоохория): тикает таймер проглоченного семени (); по -/// истечении высаживает росток того вида растения там, где сейчас зверь (если клетка — суша), -/// геномом-клоном съеденного растения, и снимает компонент. Делает травоядных разносчиками флоры — -/// растения расселяются вдоль кормовых маршрутов. -/// -public sealed class SeedDispersalSystem : BaseSystem -{ - private readonly EntityStore _store; - private readonly PlantSet _plants; - private readonly GameClock _clock; - private readonly float _secondsPerDay; - private readonly int _width; - private readonly int _height; - private readonly int _cellSize; - private readonly float[] _cellFertility; - private readonly bool[] _cellLand; - private readonly ArchetypeQuery _query; - private readonly List _dropped = []; - - public SeedDispersalSystem( - EntityStore store, - PlantSet plants, - GameClock clock, - float secondsPerDay, - int width, - int height, - int cellSize, - float[] cellFertility, - bool[] cellLand - ) - { - _store = store; - _plants = plants; - _clock = clock; - _secondsPerDay = secondsPerDay; - _width = width; - _height = height; - _cellSize = cellSize; - _cellFertility = cellFertility; - _cellLand = cellLand; - _query = store.Query(); - } - - protected override void OnUpdateGroup() - { - var days = _clock.DeltaTime / _secondsPerDay; - if (days <= 0f) - { - return; - } - - _dropped.Clear(); - foreach (var (seeds, transforms, entities) in _query.Chunks) - { - var s = seeds.Span; - var t = transforms.Span; - for (var i = 0; i < s.Length; i++) - { - ref var seed = ref s[i]; - seed.DropInDays -= days; - if (seed.DropInDays > 0f) - { - continue; - } - - _dropped.Add(entities.EntityAt(i)); - var pos = t[i].Position; - var cx = Math.Clamp((int)(pos.X / _cellSize), 0, _width - 1); - var cy = Math.Clamp((int)(pos.Y / _cellSize), 0, _height - 1); - var cell = cy * _width + cx; - if (!_cellLand[cell]) - { - continue; // упало в воду — семя пропадает - } - - PlantFactory.Create( - _store, - _plants, - seed.PlantSpecies, - pos, - ageDays: 0f, - seed.Genome, - _cellFertility[cell], - _cellSize - ); - } - } - - foreach (var eater in _dropped) - { - if (!eater.IsNull && eater.HasComponent()) - { - eater.RemoveComponent(); - } - } - } -} - -/// -/// Угасание навыков без практики: каждый уровень медленно сползает к полу своего -/// (рост идёт от использования в ). Так навык, которым не пользуются, -/// постепенно забывается — есть давление на специализацию. -/// -public sealed class AnimalSkillSystem(GameClock clock, float secondsPerDay, SkillSet skills) - : QuerySystem -{ - protected override void OnUpdate() - { - var days = clock.DeltaTime / secondsPerDay; - if (days <= 0f) - { - return; - } - - foreach (var (skillChunk, _) in Query.Chunks) - { - var s = skillChunk.Span; - for (var i = 0; i < s.Length; i++) - { - var levels = s[i].Levels; - if (levels is null) - { - continue; - } - - for (var k = 0; k < skills.Count && k < levels.Length; k++) - { - levels[k] = MathF.Max( - skills[k].FloorLevel, - levels[k] - skills[k].DecayPerDay * days - ); - } - } - } - } -} - -/// -/// Забывание (память): уверенность каждой записи падает со временем, состарившиеся стираются. Свежие -/// наблюдения записывает/освежает ; эта система только «выветривает» -/// память, давая ей конечный срок. -/// -public sealed class AnimalMemorySystem(GameClock clock, float secondsPerDay) : QuerySystem -{ - private const float ForgetPerDay = 0.5f; // уверенность падает (память живёт ~2 дня без подтверждения) - - protected override void OnUpdate() - { - var days = clock.DeltaTime / secondsPerDay; - if (days <= 0f) - { - return; - } - - foreach (var (memories, _) in Query.Chunks) - { - var m = memories.Span; - for (var i = 0; i < m.Length; i++) - { - var entries = m[i].Entries; - if (entries is null) - { - continue; - } - - for (var k = entries.Count - 1; k >= 0; k--) - { - var e = entries[k]; - e.Confidence -= ForgetPerDay * days; - e.AgeDays += days; - if (e.Confidence <= 0f) - { - entries.RemoveAt(k); - } - else - { - entries[k] = e; - } - } - } - } - } -} diff --git a/src/LittleSim/Sim/Animals/AnimalActionSystem.cs b/src/LittleSim/Sim/Animals/AnimalActionSystem.cs new file mode 100644 index 0000000..13599a1 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalActionSystem.cs @@ -0,0 +1,720 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Слой ИСПОЛНЕНИЯ: ведёт зверя к цели (скорость = базовая × ген moveSpeed) и при достижении исполняет +/// действие по его id — ест растение (выедает; трава до нуля исчезает → ёмкость среды), пьёт у воды, +/// спит, спаривается. Восполняемую нужду находит по действию через . Структурные +/// изменения (гибель растений, беременность) применяются после прохода. +/// +public sealed class AnimalActionSystem : BaseSystem +{ + private const float GrazeKillAge = 1f; // трава с возрастом ниже этого после выедания исчезает + private const float ThirstReliefBelow = 0.15f; // ниже этого жажда — питьё даёт мысль «напилась» + private const float FleeSpeedMult = 1.2f; // страх подгоняет жертву чуть быстрее обычного шага + private const float PostReproductiveFrac = 0.9f; // доля жизни, после которой самка уже не зачинает + private const float SeedIngestChance = 0.15f; // шанс проглотить семя при выедании зрелого растения + private const float GutSeedDropDays = 2f; // через сколько дней проглоченное семя высаживается + private const float IdleRestFactor = 0.3f; // доля скорости восстановления сил при холостом отдыхе + private const float DigForageFactor = 0.25f; // доля сытости с копания относительно нормальной кормёжки + + private readonly EntityStore _store; + private readonly PlantSet _plants; + private readonly AnimalSet _animalSet; + private readonly NeedSet _needs; + private readonly SkillSet _skills; + private readonly ThoughtSet _thoughts; + private readonly WorldEvents _events; + private readonly GameClock _clock; + private readonly float _secondsPerDay; + private readonly int _cellSize; + private readonly RectF _bounds; + private readonly HediffDef? _bleeding; + private readonly HediffDef? _poisoned; + private readonly Random _rng; + + // Кэш индексов навыков-эффектов (−1, если навык не определён модом — тогда эффекта/XP нет). + private readonly int _forageSkill; + private readonly int _huntSkill; + private readonly int _evadeSkill; + + private readonly ArchetypeQuery< + AnimalBrain, + AnimalNeeds, + AnimalOrganism, + Transform2D, + Health + > _query; + private readonly List _eaten = []; + private readonly HashSet _eatenSet = []; // O(1)-проверка членства (порядок даёт _eaten) + private readonly List _consumed = []; // трупы, доеденные до нуля + private readonly HashSet _consumedSet = []; // O(1)-проверка членства (порядок даёт _consumed) + private readonly List _kills = []; // добыча, забитая в этом проходе (смерть после итерации) + private readonly HashSet _killsSet = []; // O(1)-проверка членства (порядок даёт _kills) + private readonly List<(Entity Self, Entity Partner)> _matings = []; + private readonly HashSet<(int, int)> _matedPairs = []; // обработанные пары за проход (дедуп (a,b)/(b,a)) + private readonly List<(Entity Eater, GutSeed Seed)> _ingested = []; // проглоченные семена (эндозоохория) + + public AnimalActionSystem( + EntityStore store, + PlantSet plants, + AnimalSet animals, + NeedSet needs, + SkillSet skills, + ThoughtSet thoughts, + WorldEvents events, + GameClock clock, + float secondsPerDay, + int cellSize, + RectF bounds, + HediffDef? bleeding, + HediffDef? poisoned, + int seed + ) + { + _store = store; + _plants = plants; + _animalSet = animals; + _needs = needs; + _skills = skills; + _forageSkill = skills.IndexOf(AnimalSkills.Foraging); + _huntSkill = skills.IndexOf(AnimalSkills.Hunting); + _evadeSkill = skills.IndexOf(AnimalSkills.Evasion); + _thoughts = thoughts; + _events = events; + _clock = clock; + _secondsPerDay = secondsPerDay; + _cellSize = cellSize; + _bounds = bounds; + _bleeding = bleeding; + _poisoned = poisoned; + _rng = new Random(seed); + _query = store.Query(); + } + + protected override void OnUpdateGroup() + { + var seconds = _clock.DeltaTime; + if (seconds <= 0f) + { + return; + } + + var days = seconds / _secondsPerDay; + _eaten.Clear(); + _eatenSet.Clear(); + _consumed.Clear(); + _consumedSet.Clear(); + _kills.Clear(); + _killsSet.Clear(); + _matings.Clear(); + _ingested.Clear(); + + foreach ( + var (brains, needsChunk, organisms, transforms, healths, entities) in _query.Chunks + ) + { + var b = brains.Span; + var n = needsChunk.Span; + var o = organisms.Span; + var t = transforms.Span; + var hh = healths.Span; + for (var i = 0; i < b.Length; i++) + { + ref var brain = ref b[i]; + var values = n[i].Values; + ref var pos = ref t[i].Position; + var def = _animalSet[o[i].Species].Def; + // Способность Moving (падает от ран/болезней) — множитель к скорости: больные медленнее. + var moving = hh[i].State.Capacity(AnimalCapacities.Moving); + var speed = def.BaseSpeed * MathF.Max(0.2f, o[i].Traits.MoveSpeed) * moving; + // Питание (челюсть) масштабирует укус, пищеварение (желудок) — усвоение: их произведение + // даёт сытость с корма; раненая челюсть/больной желудок → зверь голоднее (опция 1). + var eating = hh[i].State.Capacity(AnimalCapacities.Eating); + var foodEff = eating * hh[i].State.Capacity(AnimalCapacities.Digestion); + var self = entities.EntityAt(i); + self.TryGetComponent(out var skills); // один лукап на особь (эффекты + XP) + // Ноша замедляет (задел под перенос): перегруз сверх грузоподъёмности роняет скорость. + if (self.TryGetComponent(out var carrier) && carrier.CarriedKg > 0f) + { + var sp = _animalSet[o[i].Species]; + var stageScale = entities.EntityAt(i).TryGetComponent(out var gr) + ? sp.Stages[Math.Clamp(gr.Stage, 0, sp.Stages.Length - 1)].Scale + : 1f; + var capacity = Mass.CarryCapacity( + Mass.OfAnimal(sp.Def, o[i].Traits, stageScale), + moving + ); + speed *= Mass.LoadSpeedFactor(carrier.CarriedKg, capacity); + } + + switch (brain.Action) + { + case AnimalActions.Sleep: + Refill(values, AnimalActions.Sleep, days); + break; + + case AnimalActions.Eat: + if (MoveTo(ref pos, brain.Target, speed * seconds)) + { + // Навык добычи корма: опытный форажир извлекает больше сытости; растёт от практики. + var forageF = SkillFactor(_forageSkill, skills); + Refill(values, AnimalActions.Eat, days, foodEff * forageF); + Graze( + brain.TargetPlant, + days, + def.ForageBiteDays * MathF.Max(0.2f, eating) + ); + // Коэволюция: защита растения бьёт по поедателю (яд/шипы). + ApplyPlantDefense( + brain.TargetPlant, + hh[i].State, + o[i].Traits.ToxinTolerance, + days + ); + // Эндозоохория: шанс проглотить семя зрелого растения (высадит позже). + TryIngestSeed(brain.TargetPlant, self); + GrantXp(_forageSkill, skills, days); + } + + break; + + case AnimalActions.Drink: + if (MoveTo(ref pos, brain.Target, speed * seconds)) + { + // Утоление сильной жажды — событийная мысль настроения (A8); уровень берём + // ДО восполнения, чтобы радость была от облегчения, а не от каждого глотка. + var thirst = _needs.NeedForAction(AnimalActions.Drink); + var parched = + thirst >= 0 + && thirst < values.Length + && values[thirst] < ThirstReliefBelow; + Refill(values, AnimalActions.Drink, days); + if (parched) + { + entities + .EntityAt(i) + .GetComponent() + .Add(_thoughts.Get(AnimalThoughts.QuenchedThirst)); + } + } + + break; + + case AnimalActions.Mate: + if ( + brain.TargetMate >= 0 + && _store.TryGetEntityById(brain.TargetMate, out var partner) + && !partner.IsNull + && partner.HasComponent() + ) + { + var partnerPos = partner.GetComponent().Position; + if (MoveTo(ref pos, partnerPos, speed * seconds)) + { + _matings.Add((entities.EntityAt(i), partner)); + } + } + + break; + + case AnimalActions.Hunt: + { + // Сила удара: размер тела особи (со стадией) относительно эталона вида — крупная/ + // взрослая особь бьёт сильнее, детёныш слабее. Дальше домножается на навык охоты. + var spH = _animalSet[o[i].Species]; + var stageScaleH = self.TryGetComponent(out var grH) + ? spH.Stages[Math.Clamp(grH.Stage, 0, spH.Stages.Length - 1)].Scale + : 1f; + var strength = MathF.Max( + 0.2f, + o[i].Traits.BodySize * stageScaleH / spH.RefBodySize + ); + // Навык охоты: опытный хищник бьёт сильнее; растёт от практики. + Hunt( + ref brain, + ref pos, + values, + def, + speed * seconds, + seconds, + days, + foodEff, + SkillFactor(_huntSkill, skills), + strength, + self, + o[i].Species + ); + GrantXp(_huntSkill, skills, days); + break; + } + + case AnimalActions.Flee: + // Навык уклонения: опытная жертва убегает быстрее; растёт от практики бегства. + MoveTo( + ref pos, + brain.Target, + speed * FleeSpeedMult * SkillFactor(_evadeSkill, skills) * seconds + ); + GrantXp(_evadeSkill, skills, days); + break; + + case AnimalActions.Rest: + // Отдых на месте: понемногу восстанавливает силы (меньше, чем полноценный сон). + Refill(values, AnimalActions.Sleep, days, IdleRestFactor); + break; + + case AnimalActions.Dig: + // Копание в земле: травоядные/всеядные выкапывают корешки/личинок — немного сытости. + if (o[i].EatsPlants) + { + Refill(values, AnimalActions.Eat, days, DigForageFactor * foodEff); + } + + break; + + case AnimalActions.Wallow: + // Валяние в грязи: стоит на месте у воды (комфорт/терморегуляция) — без эффекта на нужды. + break; + + default: // Wander — блуждание к цели + MoveTo(ref pos, brain.Target, speed * seconds); + break; + } + + pos.X = Math.Clamp(pos.X, _bounds.Left, _bounds.Right); + pos.Y = Math.Clamp(pos.Y, _bounds.Top, _bounds.Bottom); + } + } + + foreach (var plant in _eaten) + { + plant.DeleteEntity(); + } + + foreach (var corpse in _consumed) + { + if (!corpse.IsNull) + { + corpse.DeleteEntity(); // труп доеден до нуля + } + } + + foreach (var prey in _kills) + { + AnimalFactory.Die(_store, _animalSet, prey, _cellSize); // забитая добыча → труп + } + + foreach (var (eater, seed) in _ingested) + { + if (!eater.IsNull && !eater.HasComponent()) + { + eater.AddComponent(seed); // структурное добавление — после прохода + } + } + + ApplyMatings(); + } + + // Эндозоохория: при выедании ЗРЕЛОГО растения шанс проглотить его семя — зверь понесёт его и + // высадит позже в другом месте (SeedDispersalSystem). Геном клонируется (отдельная особь-росток). + private void TryIngestSeed(int plantId, Entity eater) + { + if ( + eater.IsNull + || eater.HasComponent() + || plantId < 0 + || _rng.NextSingle() >= SeedIngestChance + || !_store.TryGetEntityById(plantId, out var plant) + || plant.IsNull + || !plant.HasComponent() + || !plant.HasComponent() + ) + { + return; + } + + ref readonly var grow = ref plant.GetComponent(); + if (grow.Stage < _plants[grow.Species].Stages.Length - 1) + { + return; // только зрелое растение даёт семя + } + + var genome = new Genome(plant.GetComponent().Genome.ToDictionary()); + _ingested.Add( + ( + eater, + new GutSeed + { + PlantSpecies = grow.Species, + Genome = genome, + DropInDays = GutSeedDropDays, + } + ) + ); + } + + // Охота/падальщество: ведёт к цели и при контакте либо ест труп (утоляет голод, расходует мясо), + // либо кусает живую добычу — урон части тела, острая кровопотеря, рана-кровотечение (первый + // травматический урон). Добитая добыча помечается на смерть (труп оставит общий путь Die). + // Минимальная видимая длительность красной вспышки урона у жертвы (реального времени). + private const float DamageFlashSeconds = 0.35f; + + private void Hunt( + ref AnimalBrain brain, + ref Vector2 pos, + float[] values, + AnimalDef def, + float step, + float seconds, + float days, + float foodEff, + float huntFactor, + float strength, + Entity hunter, + int hunterSpecies + ) + { + if ( + brain.TargetPrey < 0 + || !_store.TryGetEntityById(brain.TargetPrey, out var target) + || target.IsNull + || !target.HasComponent() + ) + { + return; // цель исчезла — действие пересмотрят на следующем решении + } + + var targetPos = target.GetComponent().Position; + var range = def.AttackRangeCells * _cellSize; + var inRange = Vector2.DistanceSquared(pos, targetPos) <= range * range; + + if (brain.TargetIsCorpse) + { + if (!target.HasComponent()) + { + return; + } + + if (!inRange) + { + MoveTo(ref pos, targetPos, step); + return; + } + + ref var corpse = ref target.GetComponent(); + Refill(values, AnimalActions.Eat, days, foodEff); + corpse.Meat -= def.ForageBiteDays * days; // расход мяса той же «силой укуса», что и выедание + if (corpse.Meat <= 0f && _consumedSet.Add(target)) + { + _consumed.Add(target); + } + + return; + } + + // Живая добыча. + if (!target.HasComponent() || !target.HasComponent()) + { + return; + } + + // Дискретные удары (как в RimWorld): кулдаун идёт и при сближении — первый контакт бьёт сразу. + brain.AttackCooldown -= seconds; + if (!inRange) + { + MoveTo(ref pos, targetPos, step); + return; + } + + if (brain.AttackCooldown > 0f) + { + return; // в контакте, но удар ещё на перезарядке + } + + brain.AttackCooldown = MathF.Max(0.05f, def.AttackIntervalSec); + + var preyHealth = target.GetComponent().State; + if (preyHealth is null) + { + return; + } + + // Сила удара = база дефа × сила особи (размер×стадия) × навык охоты; кровопотеря/кровотечение — ×силу. + var damage = def.AttackDamage * strength * huntFactor; + preyHealth.ApplyInjury( + damage, + def.AttackBloodLoss * strength, + _bleeding, + def.AttackBleed * strength, + _rng + ); + + // Красная вспышка жертвы (презентация) + удар в боевую хронику обоих участников. + if (target.HasComponent()) + { + target.GetComponent().Seconds = DamageFlashSeconds; + } + + _events.Struck( + hunter, + hunterSpecies, + target, + target.GetComponent().Species, + damage + ); + + if ( + preyHealth.BloodLevel <= 0f + || preyHealth.Capacity(AnimalCapacities.Consciousness) <= 0.01f + ) + { + if (_killsSet.Add(target)) + { + _kills.Add(target); // добита — смерть после прохода (структурное изменение) + _events.Killed( + hunter, + hunterSpecies, + target.GetComponent().Species + ); + } + } + } + + // Восполняет нужду, которую утоляет действие (по NeedSet), на её FeedPerDay × эффективность + // (питание/пищеварение для еды — слабая челюсть/больной желудок дают меньше сытости с того же корма). + private void Refill(float[] values, string action, float days, float efficiency = 1f) + { + var index = _needs.NeedForAction(action); + if (index < 0 || values is null || index >= values.Length) + { + return; + } + + values[index] = Math.Clamp( + values[index] + _needs[index].FeedPerDay * days * efficiency, + 0f, + 1f + ); + } + + // Бонус-множитель от уровня навыка: 0.7 (новичок) … 1.3 (мастер). Навык не определён модом → 1 (без эффекта). + private static float SkillFactor(int idx, in Skills s) + { + if (idx < 0 || s.Levels is null || idx >= s.Levels.Length) + { + return 1f; + } + + return 0.7f + 0.6f * s.Levels[idx]; + } + + // Начисляет опыт навыку от практики: рост ∝ скорость обучения × страсть × (1−уровень) (убывающая отдача). + private void GrantXp(int idx, in Skills s, float days) + { + if (idx < 0 || s.Levels is null || idx >= s.Levels.Length) + { + return; + } + + var passion = s.Passions is not null && idx < s.Passions.Length ? s.Passions[idx] : (byte)0; + var gain = + _skills[idx].LearnRate + * SkillSet.PassionMultiplier(passion) + * days + * (1f - s.Levels[idx]); + s.Levels[idx] = Math.Clamp(s.Levels[idx] + gain, 0f, 1f); + } + + // Двигает позицию к цели на step; возвращает true, если уже у цели (можно исполнять действие). + private static bool MoveTo(ref Vector2 pos, Vector2 target, float step) + { + var delta = target - pos; + var dist = delta.Length(); + if (dist <= step || dist < 0.001f) + { + pos = dist < 0.001f ? pos : target; + return true; + } + + pos += delta / dist * step; + return false; + } + + // Выедание: убавляет рост растения; траву (без ствола), выеденную до нуля, помечает на гибель. + private void Graze(int plantId, float days, float bitePerDay) + { + if ( + plantId < 0 + || !_store.TryGetEntityById(plantId, out var plant) + || plant.IsNull + || !plant.HasComponent() + ) + { + return; + } + + ref var grow = ref plant.GetComponent(); + grow.AgeDays = MathF.Max(0f, grow.AgeDays - bitePerDay * days); + var isGrass = _plants[grow.Species].Def.TrunkRadiusCells <= 0f; + if (isGrass && grow.AgeDays <= GrazeKillAge && _eatenSet.Add(plant)) + { + _eaten.Add(plant); + } + } + + // Доза яда за день укуса при токсичности 1 и нулевой устойчивости (масштабируется тяжести Poisoned). + private const float PoisonDosePerDay = 1.6f; + + // Порог шипов, выше которого укус о растение травмирует, и масштабы урона/кровопотери от шипов. + private const float ThornThreshold = 0.2f; + private const float ThornPartDamage = 4f; + private const float ThornBloodLoss = 0.02f; + + // Коэволюция: при поедании растение «отвечает» — токсичность отравляет (тем сильнее, чем ниже + // устойчивость зверя к яду), шипы наносят лёгкую травму. Доза яда копится в хедифе Poisoned; если + // зверь ест яд быстрее, чем выводит, тяжесть дойдёт до летальной. Детерминированно (общий _rng). + private void ApplyPlantDefense( + int plantId, + HealthState? health, + float toxinTolerance, + float days + ) + { + if ( + health is null + || plantId < 0 + || !_store.TryGetEntityById(plantId, out var plant) + || plant.IsNull + || !plant.HasComponent() + ) + { + return; + } + + ref readonly var traits = ref plant.GetComponent().Traits; + + var poisoned = false; + if (_poisoned is not null && traits.Toxicity > 0f) + { + var dose = traits.Toxicity * (1f - Math.Clamp(toxinTolerance, 0f, 1f)); + if (dose > 0f) + { + health.Intensify(_poisoned, dose * PoisonDosePerDay * days); + poisoned = true; + } + } + + if (traits.Thorns > ThornThreshold) + { + // ApplyInjury сам пересчитывает способности (учтёт и свежий яд). + health.ApplyInjury( + traits.Thorns * ThornPartDamage * days, + traits.Thorns * ThornBloodLoss * days, + _bleeding, + traits.Thorns * 0.15f * days, + _rng + ); + } + else if (poisoned) + { + health.RecomputeCapacities(); // отравление меняет capacity-моды — применить сразу + } + } + + // Применяет спаривания после прохода: самка пары беременеет геномом самца (структурное добавление — + // после итерации), влечение обоих обнуляется. Двойные события на одну самку отсеиваются. + private void ApplyMatings() + { + var mateNeed = _needs.NeedForAction(AnimalActions.Mate); + _matedPairs.Clear(); + foreach (var (a, b) in _matings) + { + if ( + a.IsNull + || b.IsNull + || !a.HasComponent() + || !b.HasComponent() + ) + { + continue; + } + + // Оба партнёра независимо ставят себя в очередь, так что пара приходит дважды — (a,b) и (b,a). + // Дедупим по неупорядоченному ключу: иначе ResetMating/мысль «спаривание» применяются повторно. + var pairKey = a.Id < b.Id ? (a.Id, b.Id) : (b.Id, a.Id); + if (!_matedPairs.Add(pairKey)) + { + continue; + } + + var aMale = a.GetComponent().IsMale; + var bMale = b.GetComponent().IsMale; + if (aMale == bMale) + { + continue; // один пол — не пара (страховка) + } + + var mother = aMale ? b : a; + var father = aMale ? a : b; + if (!mother.HasComponent()) + { + ref readonly var mom = ref mother.GetComponent(); + // Плодовитость падает со старостью (тот же фактор сенесценса): дряхлая самка приносит + // меньший помёт, а после порога пострепродуктивного возраста уже не зачинает. + var momAge = mother.HasComponent() + ? mother.GetComponent().AgeDays + : 0f; + var ageFrac = mom.Traits.Lifespan > 0f ? momAge / mom.Traits.Lifespan : 0f; + if (ageFrac <= PostReproductiveFrac) + { + var fert = AnimalHealthSystem.SenescenceFactor(momAge, mom.Traits.Lifespan); + mother.AddComponent( + new Pregnant + { + FatherGenome = father.GetComponent().Genome, + DueInDays = MathF.Max(1f, mom.Traits.GestationDays), + Litter = Math.Max(1, (int)MathF.Round(mom.Traits.LitterSize * fert)), + } + ); + _events.Pregnant(mother, mom.Species); + } + } + + ResetMating(a, mateNeed); + ResetMating(b, mateNeed); + // Спаривание — положительная событийная мысль обоим партнёрам (A8). + var mated = _thoughts.Get(AnimalThoughts.Mated); + a.GetComponent().Add(mated); + b.GetComponent().Add(mated); + _events.Mated( + a, + a.GetComponent().Species, + b, + b.GetComponent().Species + ); + } + } + + private static void ResetMating(Entity entity, int mateNeed) + { + if (mateNeed < 0 || !entity.HasComponent()) + { + return; + } + + var values = entity.GetComponent().Values; + if (values is not null && mateNeed < values.Length) + { + values[mateNeed] = 0f; + } + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalActions.cs b/src/LittleSim/Sim/Animals/AnimalActions.cs new file mode 100644 index 0000000..dc72b12 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalActions.cs @@ -0,0 +1,43 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Id поведений-исполнителей (реестр действий ИИ). Дефы нужд () ссылаются на +/// них; выбирает действие, исполняет. +/// Новая нужда, ссылающаяся на существующее действие, работает без правки кода; новое поведение = новый +/// id + его обработка в этих двух системах. +/// +public static class AnimalActions +{ + public const string Wander = "Wander"; + public const string Eat = "Eat"; + public const string Drink = "Drink"; + public const string Sleep = "Sleep"; + public const string Mate = "Mate"; + + /// Охота: преследовать живую добычу и кусать (урон) либо есть труп — утоляет голод хищника. + public const string Hunt = "Hunt"; + + /// Бегство: уходить от ближайшего хищника (реакция жертвы, важнее прочих нужд). + public const string Flee = "Flee"; + + // — Холостые (idle) активности: чем зверь занят, когда нет срочных нужд. Выбираются внутри ветки + // Wander с фиксированной длительностью (см. AnimalDecisionSystem) — придают поведению разнообразие. + + /// Отдых: стоит/лежит на месте, понемногу восстанавливая силы. + public const string Rest = "Rest"; + + /// Копание в земле в поисках корня/личинок (травоядные/всеядные) — даёт чуть сытости. + public const string Dig = "Dig"; + + /// Валяние в грязи у воды (комфорт/терморегуляция) — стоит на месте у кромки. + public const string Wallow = "Wallow"; +} diff --git a/src/LittleSim/Sim/Animals/AnimalAppearanceSystem.cs b/src/LittleSim/Sim/Animals/AnimalAppearanceSystem.cs new file mode 100644 index 0000000..14c70e6 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalAppearanceSystem.cs @@ -0,0 +1,49 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Презентация состояния нужд: голодный/уставший зверь тускнеет (яркость падает с самой острой +/// убывающей нуждой), сохраняя оттенок меха из генов. Читает симуляцию, пишет только +/// — граница sim/presentation цела. +/// +public sealed class AnimalAppearanceSystem(NeedSet needs) + : QuerySystem +{ + private const float MinBrightness = 0.5f; + + protected override void OnUpdate() + { + foreach (var (needsChunk, organisms, sprites, _) in Query.Chunks) + { + var n = needsChunk.Span; + var o = organisms.Span; + var s = sprites.Span; + for (var i = 0; i < n.Length; i++) + { + var values = n[i].Values; + var worst = 1f; + if (values is not null) + { + for (var k = 0; k < needs.Count && k < values.Length; k++) + { + if (needs.IsDeplete(k)) + { + worst = MathF.Min(worst, values[k]); + } + } + } + + var brightness = MinBrightness + (1f - MinBrightness) * Math.Clamp(worst, 0f, 1f); + s[i].Color = AnimalFactory.FurTint(o[i].Traits) * brightness; + } + } + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalBobSystem.cs b/src/LittleSim/Sim/Animals/AnimalBobSystem.cs new file mode 100644 index 0000000..75910c6 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalBobSystem.cs @@ -0,0 +1,56 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// «Раскачка» при ходьбе (презентация): идущий зверь покачивается из стороны в сторону (наклон спрайта +/// через ), стоящий — плавно выпрямляется. Делает движение живее, не +/// затрагивая симуляцию (она вращение не использует). Фаза покачивания идёт по игровому времени (стоп на +/// паузе, быстрее на ускорении) и сдвинута по id особи — звери качаются вразнобой. Граница sim/presentation +/// цела: система пишет только поворот трансформа. +/// +public sealed class AnimalBobSystem(GameClock clock) : QuerySystem +{ + private const float SwaySpeed = 9f; // скорость набега фазы (рад/сек игрового времени) + private const float SwayAmplitude = 0.08f; // амплитуда наклона (рад ≈ 4.6°) + private const float MinMoveSq = 0.5f * 0.5f; // ближе к цели — считаем стоящим + private const float UprightDecay = 0.85f; // как быстро выпрямляется стоя (за кадр) + + private float _phase; + + protected override void OnUpdate() + { + var dt = clock.DeltaTime; + if (dt <= 0f) + { + return; // пауза — не качаем и не выпрямляем + } + + _phase += dt * SwaySpeed; + foreach (var (brains, transforms, entities) in Query.Chunks) + { + var b = brains.Span; + var t = transforms.Span; + for (var i = 0; i < b.Length; i++) + { + ref var rot = ref t[i].Rotation; + if ((b[i].Target - t[i].Position).LengthSquared() > MinMoveSq) + { + var offset = entities.EntityAt(i).Id * 0.7f; // десинхрон между особями + rot = MathF.Sin(_phase + offset) * SwayAmplitude; + } + else + { + rot *= UprightDecay; // у цели/стоя — плавно к вертикали + } + } + } + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalBrain.cs b/src/LittleSim/Sim/Animals/AnimalBrain.cs new file mode 100644 index 0000000..b95edc4 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalBrain.cs @@ -0,0 +1,48 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// «Мозг» зверя: выбранное действие (id поведения), его цель и таймер до пересмотра. Решение принимает +/// (движковый ), исполняет +/// . +/// +public struct AnimalBrain : IComponent +{ + /// Id текущего действия (см. ). + public string Action; + + /// Куда идти (позиция растения/воды/партнёра/точки блуждания). + public Vector2 Target; + + /// Id растения-цели для ; -1 — нет. + public int TargetPlant; + + /// Id партнёра для ; -1 — нет. + public int TargetMate; + + /// Id цели для — живой добычи или трупа; -1 — нет. + public int TargetPrey; + + /// Цель — труп (падаль), а не живая добыча: при достижении едим, а не атакуем. + public bool TargetIsCorpse; + + /// Секунды до следующего пересмотра решения. + public float DecideIn; + + /// Игровые секунды до следующего удара по добыче (кулдаун дискретных атак, как в RimWorld). + public float AttackCooldown; + + /// Текущее направление блуждания (коррелированное — плавно дрейфует, не дёргается каждый раз). + public Vector2 Heading; + + /// Игровое время (TotalTime), до которого длится текущая холостая активность (отдых/копание/…). + public float ActivityUntil; +} diff --git a/src/LittleSim/Sim/Animals/AnimalContext.cs b/src/LittleSim/Sim/Animals/AnimalContext.cs new file mode 100644 index 0000000..13c1ad0 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalContext.cs @@ -0,0 +1,59 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Снимок нужд зверя для соображений utility-выбора (доступ по индексу нужды) с гейтом интеллектом +/// (фаза A7): нужда, чей выше эффективного интеллекта особи +/// (brainSize × Consciousness), в выбор не входит. +/// +public readonly struct AnimalContext( + float[] values, + NeedSet needs, + float intelligence, + int overrideIndex = -1, + float overrideValue = 0f +) +{ + private readonly float[] _values = values; + private readonly NeedSet _needs = needs; + private readonly float _intelligence = intelligence; + + // Переопределение значения одной нужды (стиль питания: «эффективный голод» с гистерезисом — + // см. AnimalDecisionSystem). Override имеет приоритет над сырым значением и над гейтом мозга. + private readonly int _overrideIndex = overrideIndex; + private readonly float _overrideValue = overrideValue; + + /// + /// Значение нужды по индексу. Если для этой нужды задан override (стиль питания) — он и возвращается. + /// Если её порог мозга выше интеллекта особи — нужда недоступна и возвращается её «не-срочный» край + /// (deplete → 1 «сыта», drive → 0 «без влечения»), чтобы соображение дало ~0 и действие не выбралось. + /// Индекс вне диапазона → 0. + /// + public float Get(int index) + { + if (index == _overrideIndex) + { + return _overrideValue; + } + + if (index >= _values.Length) + { + return 0f; + } + + if (_needs[index].MinBrain > _intelligence) + { + return _needs.IsDrive(index) ? 0f : 1f; + } + + return _values[index]; + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalDecisionSystem.cs b/src/LittleSim/Sim/Animals/AnimalDecisionSystem.cs new file mode 100644 index 0000000..1afa885 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalDecisionSystem.cs @@ -0,0 +1,1110 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Слой ВЫБОРА: раз в секунд каждый зверь выбирает действие движковым +/// , построенным ИЗ ДАННЫХ (по соображению на +/// нужду), и находит цель действия (еда по диете, вода, партнёр). Решение пишется в +/// ; исполняет . +/// +public sealed class AnimalDecisionSystem : BaseSystem +{ + private const float Interval = 0.6f; + private const float MateMoodFloor = 0.35f; // ниже — стресс/истощение, зверь не спаривается (A8) + private const float HerdMinSociability = 0.3f; // ниже — вид не стадный (когезия/безопасность выкл.) + private const float SafetyInNumbers = 0.5f; // макс. снижение радиуса реакции на угрозу в группе + private const int HerdFullCount = 6; // размер группы, при котором эффект «безопасность в числе» полон + + // Стиль питания (gorge↔graze): пороги начала/конца кормёжки лерпуются по гену feedingStyle [0..1]. + // graze (0): начинает есть уже при лёгком голоде и наедается не до полного — частые мелкие приёмы. + // gorge (1): ищет еду лишь при сильном голоде, но ест до полной сытости — редкие крупные приёмы. + private const float FeedStartGraze = 0.8f; + private const float FeedStartGorge = 0.4f; + private const float FeedFullGraze = 0.9f; + private const float FeedFullGorge = 1.0f; + + // Блуждание/стадность (boids): коррелированный дрейф направления + расхождение (личное пространство) и + // лишь слабое притяжение к центру группы на удалении — без этого стадо схлопывалось в одну точку. + private const float WanderTurn = 0.5f; // макс. поворот направления за пересмотр (рад) — плавные пути + private const float RoamCells = 11f; // длина «отрезка» блуждания в клетках + private const float SeparationCells = 2.2f; // ближе этого к соседу — расходимся + private const float SeparationWeight = 1.6f; // сила расхождения + private const float CohesionStartCells = 7f; // дальше этого от центра группы — слабо подтягиваемся + private const float CohesionWeight = 0.45f; // сила когезии (намеренно мала) + private const float EdgeMarginCells = 2.5f; // у края мира разворачиваем направление внутрь + + // Холостые активности: длительность (игровые секунды) каждой, пока зверь ей занят. + private const float RoamMinSec = 3f; + private const float RoamMaxSec = 6f; + private const float RestMinSec = 5f; + private const float RestMaxSec = 11f; + private const float DigMinSec = 3f; + private const float DigMaxSec = 7f; + private const float WallowMinSec = 5f; + private const float WallowMaxSec = 10f; + private const float WallowWaterCells = 4f; // валяться можно лишь у воды (в этом радиусе от кромки) + + private readonly UtilityAi _brain; + private readonly EntityStore _store; + private readonly WorldEvents _events; + private readonly GameClock _clock; + private readonly AnimalSet _animalSet; + private readonly NeedSet _needs; + private readonly int _hungerNeed; // индекс убывающей нужды «голод» (для гистерезиса кормёжки); -1 — нет + private readonly int _restNeed; // индекс нужды «отдых» (для веса холостого отдыха); -1 — нет + private readonly int _cellSize; + private readonly Vector2[] _shoreByCell; // поле «ближайший берег» по клеткам (O(1) поиск водопоя) + private readonly int _worldWidth; + private readonly int _worldHeight; + private readonly Random _rng; + private readonly ArchetypeQuery< + AnimalNeeds, + AnimalBrain, + AnimalOrganism, + AnimalGrowth, + Transform2D + > _animals; + private readonly ArchetypeQuery _plants; + private readonly ArchetypeQuery _corpses; + + // Пространственные индексы + переиспользуемые буферы запросов: поиск корма/угрозы/добычи/партнёра/ + // стада берёт только ближние сущности вместо прохода по всем (было O(растений)+O(животных) на каждое + // решение). В ячейке — предпосчитанные признаки (без дорефетча). Сетку животных перестраиваем каждый + // кадр (они двигаются), сетки растений/трупов — раз в StaticGridRebuildSeconds (они статичны). + private const int GridCells = 10; // размер ячейки сетки в клетках мира (~радиус восприятия) + private const float HearingRangeMult = 1.4f; // слух шире зрения + private const float SmellRangeMult = 1.8f; // запах — дальше всех (но грубый, см. Detect) + private const float RememberedThreatAvoidCells = 14f; // в этой зоне от памяти об угрозе зверь уходит прочь + + // Каденция перестройки СТАТИЧНЫХ сеток (растения/трупы): они не двигаются (меняются лишь при + // рождении/смерти), поэтому полный обход тысяч растений на каждый кадр был основной ценой системы. + // Перестраиваем раз в эти игровые секунды (≈ как часто одна особь и так пересматривает решение); + // удалённые сущности в устаревшей сетке безопасны — потребитель сверяется со store перед действием. + private const float StaticGridRebuildSeconds = 0.5f; + private readonly SpatialGrid _plantGrid; + private readonly List _plantBuf = []; + private readonly SpatialGrid _animalGrid; + private readonly List _animalBuf = []; + private readonly SpatialGrid _corpseGrid; + private readonly List _corpseBuf = []; + private float _staticGridAccum; // накоплено игровых секунд с последней перестройки статичных сеток + private bool _staticGridsBuilt; // первая перестройка обязательна (до неё сетки пусты) + private readonly float _smellSnap; // размер «обонятельной» ячейки (грубая локализация по запаху) + + public AnimalDecisionSystem( + EntityStore store, + GameClock clock, + AnimalSet animals, + NeedSet needs, + int cellSize, + Vector2[] shoreByCell, + int worldWidth, + int worldHeight, + WorldEvents events, + int seed + ) + { + _brain = BuildBrain(needs); + _store = store; + _events = events; + _clock = clock; + _animalSet = animals; + _needs = needs; + _hungerNeed = needs.NeedForAction(AnimalActions.Eat); + _restNeed = needs.NeedForAction(AnimalActions.Sleep); + _cellSize = cellSize; + _shoreByCell = shoreByCell; + _worldWidth = worldWidth; + _worldHeight = worldHeight; + _rng = new Random(seed); + _plantGrid = new SpatialGrid(cellSize * GridCells); + _animalGrid = new SpatialGrid(cellSize * GridCells); + _corpseGrid = new SpatialGrid(cellSize * GridCells); + _smellSnap = cellSize * GridCells; + _animals = store.Query< + AnimalNeeds, + AnimalBrain, + AnimalOrganism, + AnimalGrowth, + Transform2D + >(); + _plants = store.Query(); + _corpses = store.Query(); + } + + // Строит utility-reasoner из данных: на каждую нужду — действие с соображением. Deplete: чем ниже + // значение, тем привлекательнее ((1-v)^3). Drive: чем выше влечение, тем привлекательнее (v). + private static UtilityAi BuildBrain(NeedSet needs) + { + var actions = new List>(needs.Count + 1); + for (var i = 0; i < needs.Count; i++) + { + var index = i; + var def = needs[i]; + var consideration = needs.IsDrive(i) + ? new Consideration(def.DefName, c => c.Get(index)) + : new Consideration( + def.DefName, + c => c.Get(index), + curve: ResponseCurve.Polynomial(exponent: 3f, slope: -1f, xShift: 1f) + ); + actions.Add(new UtilityAction(def.Action, consideration)); + } + + actions.Add( + new UtilityAction( + AnimalActions.Wander, + new Consideration("idle", _ => 0.12f) + ) + ); + return new UtilityAi(actions.ToArray()); + } + + protected override void OnUpdateGroup() + { + var delta = _clock.DeltaTime; + if (delta <= 0f) + { + return; // пауза — решения не пересматриваются (как и прочие sim-системы) + } + + var clockNow = (float)_clock.TotalTime; // игровое время для таймеров холостых активностей + RebuildAnimalGrid(); // животные двигаются — индекс свежий каждый кадр + // Растения/трупы статичны — индекс перестраиваем по каденции, а не каждый кадр. + _staticGridAccum += delta; + if (!_staticGridsBuilt || _staticGridAccum >= StaticGridRebuildSeconds) + { + RebuildStaticGrids(); + _staticGridAccum = 0f; + _staticGridsBuilt = true; + } + + foreach ( + var (needsChunk, brains, organisms, growths, transforms, entities) in _animals.Chunks + ) + { + var n = needsChunk.Span; + var b = brains.Span; + var o = organisms.Span; + var gr = growths.Span; + var t = transforms.Span; + for (var i = 0; i < b.Length; i++) + { + ref var brain = ref b[i]; + brain.DecideIn -= delta; + if (brain.DecideIn > 0f) + { + continue; + } + + brain.DecideIn = Interval + _rng.NextSingle() * Interval; // джиттер — расфазировать скан + var prevAction = brain.Action; // для журнала: события на смене действия (начал/закончил) + var pos = t[i].Position; + var adult = AnimalFactory.IsAdult(_animalSet[o[i].Species], gr[i].Stage); + var self = entities.EntityAt(i); + var health = self.GetComponent().State; + // Эффективный интеллект (мозг × сознание) гейтит доступные нужды/действия (фаза A7): + // больной/раненый зверь с упавшим сознанием теряет высшие нужды (сон/секс) → рефлексы. + var intelligence = AnimalFactory.Intelligence(o[i].Traits, health); + // Радиусы восприятия по чувствам: зрение (корм/партнёр), плюс слух и обоняние (угроза/добыча). + // Острота — гены чувств × способность органа (раны/хедифы их режут). Зрение узкое, слух шире, + // запах дальше всех, но грубый (см. Detect). Радиусы детерминированы. + var baseRadius = VisionRadius(o[i].Species, o[i].Traits.Vision); + var sight = health?.Capacity(AnimalCapacities.Sight) ?? 1f; + var hearing = health?.Capacity(AnimalCapacities.Hearing) ?? 1f; + var smell = health?.Capacity(AnimalCapacities.Smell) ?? 1f; + var seeRadius = baseRadius * MathF.Max(0.2f, sight); + var senseBase = _animalSet[o[i].Species].Def.VisionCells * _cellSize; + var ranges = new SenseRanges( + seeRadius, + senseBase * o[i].Traits.Hearing * MathF.Max(0.2f, hearing) * HearingRangeMult, + senseBase * o[i].Traits.Smell * MathF.Max(0.2f, smell) * SmellRangeMult + ); + // Соседей по сетке собираем ОДИН раз на решение (на макс. радиусе чувств): стадность, + // бегство, поиск добычи и партнёра дальше читают этот буфер со своими фильтрами дистанции — + // вместо 2–4 повторных обходов сетки и копирований за одно решение. + _animalGrid.Collect(pos, ranges.Max(), _animalBuf); + + // Настроение (A8): стресс/истощение (низкое настроение) подавляет тягу к спариванию. + var mood = self.GetComponent(); + + // Стадность (boids): социальные виды считают центр/размер группы соплеменников (когезия и + // «безопасность в числе») и вектор расхождения herdAway (личное пространство — чтобы стадо + // держалось вместе, но не схлопывалось в точку). + var sociability = o[i].Traits.Sociability; + var herdCount = 0; + var herdCenter = pos; + var herdAway = Vector2.Zero; + if (sociability >= HerdMinSociability) + { + HerdScan( + pos, + seeRadius, + o[i].Species, + self.Id, + out herdCenter, + out herdCount, + out herdAway + ); + } + + var safety = + herdCount > 0 + ? 1f - SafetyInNumbers * MathF.Min(1f, herdCount / (float)HerdFullCount) + : 1f; + + // Бегство (предатор-кластер): жертва, заметившая рядом хищника, спасается — это важнее + // любых нужд (инстинкт выживания). Сам хищник от добычи не бежит (см. IsThreatTo). + if ( + TryFindThreat( + pos, + ranges.Scaled(safety), + o[i].Species, + o[i].EatsMeat, + o[i].Traits.BodySize, + out var threatPos + ) + ) + { + self.GetComponent().Remember(MemoryKind.Threat, threatPos, _smellSnap); + if (prevAction != AnimalActions.Flee) + { + _events.Fled(self, o[i].Species); // начало бегства — в личную хронику + } + + brain.Action = AnimalActions.Flee; + brain.TargetPlant = -1; + brain.TargetPrey = -1; + var away = pos - threatPos; + var len = away.Length(); + brain.Target = + len > 0.001f + ? pos + away / len * (ranges.Max() + _cellSize) + : pos + new Vector2(_cellSize, 0f); + continue; + } + + var eatsPlants = o[i].EatsPlants; // диета кэширована при рождении (без пересчёта на тик) + var eatsMeat = o[i].EatsMeat; + // Стиль питания: «эффективный голод» с гистерезисом. gorge не лезет за едой, пока сытость + // выше порога начала, но начав — ест до полного; graze подтапливает часто и до меньшего. + var hunger = EffectiveHunger( + n[i].Values, + o[i].Traits.FeedingStyle, + brain.Action is AnimalActions.Eat or AnimalActions.Hunt + ); + var name = + _brain + .Select( + new AnimalContext( + n[i].Values, + _needs, + intelligence, + _hungerNeed, + hunger + ) + ) + ?.Name + ?? AnimalActions.Wander; + + switch (name) + { + // Хищник/всеядное голодает → ищет труп (падаль) или живую добычу и охотится. + case AnimalActions.Eat + when eatsMeat + && TryFindKill( + pos, + ranges, + o[i].Species, + o[i].Traits, + self.Id, + out var preyId, + out var preyPos, + out var preyIsCorpse + ): + brain.Action = AnimalActions.Hunt; + brain.TargetPlant = -1; + brain.TargetPrey = preyId; + brain.TargetIsCorpse = preyIsCorpse; + brain.Target = preyPos; + // Начало охоты на ЖИВУЮ добычу (падальщество не считаем охотой) — заметное событие. + if ( + prevAction != AnimalActions.Hunt + && !preyIsCorpse + && _store.TryGetEntityById(preyId, out var preyEntity) + && !preyEntity.IsNull + && preyEntity.TryGetComponent(out var preyOrg) + ) + { + _events.HuntStarted(self, o[i].Species, preyOrg.Species); + } + + break; + case AnimalActions.Eat + when eatsPlants + && TryFindForage( + pos, + seeRadius, + intelligence, + o[i].Traits.ToxinTolerance, + out var plant, + out var pp + ): + brain.Action = AnimalActions.Eat; + brain.TargetPlant = plant; + brain.Target = pp; + break; + case AnimalActions.Drink when TryFindShore(pos, out var water): + brain.Action = AnimalActions.Drink; + brain.TargetPlant = -1; + brain.Target = water; + break; + case AnimalActions.Sleep: + brain.Action = AnimalActions.Sleep; + brain.TargetPlant = -1; + break; + case AnimalActions.Mate + when adult + && (!mood.Active || mood.Value >= MateMoodFloor) + && TryFindMate( + pos, + o[i].IsMale, + o[i].Species, + seeRadius, + out var mateId, + out var matePos + ): + brain.Action = AnimalActions.Mate; + brain.TargetPlant = -1; + brain.TargetMate = mateId; + brain.Target = matePos; + break; + default: + brain.TargetPlant = -1; + brain.TargetPrey = -1; + // Память: вблизи места недавнего хищника бросаем холостые дела и уходим прочь. + if (TryRememberedThreat(self, pos, out var dangerPos)) + { + var flee = pos - dangerPos; + var d = flee.Length(); + brain.Action = AnimalActions.Wander; + brain.Heading = d > 0.001f ? flee / d : RandomDir(); + brain.ActivityUntil = clockNow + RoamMinSec; + brain.Target = + pos + brain.Heading * (_cellSize * (4f + _rng.NextSingle() * 4f)); + } + else + { + // Конечный автомат холостых дел: держим текущую активность до её таймера, + // затем выбираем новую (блуждание/отдых/копание/валяние) с весами по виду. + if (!IsIdle(brain.Action) || clockNow >= brain.ActivityUntil) + { + PickIdleActivity(ref brain, self, o[i], pos, n[i].Values, clockNow); + } + + if (brain.Action == AnimalActions.Wander) + { + SteerRoam( + ref brain, + pos, + herdCenter, + herdCount, + herdAway, + sociability + ); + } + else + { + brain.Target = pos; // отдых/копание/валяние — стоим на месте + } + } + + break; + } + + // Бытовое в личную хронику: завершение приёма пищи/водопоя (по смене действия — без + // спама на каждый тик). Начало охоты/бегства логируется выше у своих веток. + if (prevAction == AnimalActions.Eat && brain.Action != AnimalActions.Eat) + { + _events.Ate(self, o[i].Species); + } + else if (prevAction == AnimalActions.Drink && brain.Action != AnimalActions.Drink) + { + _events.Drank(self, o[i].Species); + } + } + } + } + + // «Эффективный голод» для utility-выбора по стилю питания (gorge↔graze) с гистерезисом: + // — кормится сейчас → особь «голодна», пока сытость не достигнет порога полного (gorge ест до 1, + // graze — до меньшего): начатый приём пищи доводится до конца, а не бросается на полпути; + // — не кормится → особь «сыта» (1), пока сытость выше порога начала; ниже — падает в «голод» + // пропорционально, и чем выше стиль (gorge), тем ниже порог начала (ест реже, но крупнее). + // Возвращает значение [0,1] для нужды голода; при отсутствии нужды — 1 (override не применится). + private float EffectiveHunger(float[] values, float feedingStyle, bool feedingNow) + { + if (_hungerNeed < 0 || values is null || _hungerNeed >= values.Length) + { + return 1f; + } + + var sat = values[_hungerNeed]; + var style = Math.Clamp(feedingStyle, 0f, 1f); + if (feedingNow) + { + var full = FeedFullGraze + (FeedFullGorge - FeedFullGraze) * style; + return full > 0f ? Math.Clamp(sat / full, 0f, 1f) : 1f; + } + + var start = FeedStartGraze + (FeedStartGorge - FeedStartGraze) * style; + return sat >= start ? 1f + : start > 0f ? sat / start + : 0f; + } + + // Радиус восприятия особи: базовая дальность вида (данные) × ген зрения × размер клетки. + private float VisionRadius(int species, float vision) => + MathF.Max(1f, _animalSet[species].Def.VisionCells) * MathF.Max(0.2f, vision) * _cellSize; + + // Ближайшая запомненная угроза в зоне избегания (для блуждания — уйти от опасного места). + private bool TryRememberedThreat(Entity self, Vector2 pos, out Vector2 threatPos) + { + threatPos = default; + if (!self.TryGetComponent(out var mem) || mem.Entries is null) + { + return false; + } + + var bestSq = RememberedThreatAvoidCells * _cellSize; + bestSq *= bestSq; + var found = false; + foreach (var e in mem.Entries) + { + if (e.Kind != MemoryKind.Threat) + { + continue; + } + + var sq = Vector2.DistanceSquared(pos, e.Pos); + if (sq < bestSq) + { + bestSq = sq; + threatPos = e.Pos; + found = true; + } + } + + return found; + } + + // Центр/размер группы соплеменников в радиусе (когезия/безопасность) и вектор расхождения away — + // сумма направлений «прочь» от соседей ближе SeparationCells (личное пространство, чтобы стадо не + // схлопывалось в точку). O(n) на особь. Сам исключается по id; центр — средняя позиция соседей. + private void HerdScan( + Vector2 from, + float radius, + int species, + int selfId, + out Vector2 center, + out int count, + out Vector2 away + ) + { + var sum = Vector2.Zero; + away = Vector2.Zero; + count = 0; + var radiusSq = radius * radius; + var sepDist = SeparationCells * _cellSize; + var sepSq = sepDist * sepDist; + // _animalBuf уже собран в DecideIn на макс. радиусе чувств — фильтруем по своему радиусу. + foreach (var e in _animalBuf) + { + if (e.Species != species || e.Id == selfId) + { + continue; + } + + var distSq = Vector2.DistanceSquared(from, e.Pos); + if (distSq <= radiusSq) + { + sum += e.Pos; + count++; + } + + if (distSq <= sepSq && distSq > 0.0001f) + { + // ближе личного пространства — добавляем направление прочь, тем сильнее, чем ближе сосед. + var d = MathF.Sqrt(distSq); + away += (from - e.Pos) / d * (1f - d / sepDist); + } + } + + center = count > 0 ? sum / count : from; + } + + // Холостая ли активность (то, чем зверь занят без срочных нужд): блуждание/отдых/копание/валяние. + private static bool IsIdle(string action) => + action == AnimalActions.Wander + || action == AnimalActions.Rest + || action == AnimalActions.Dig + || action == AnimalActions.Wallow; + + // Случайное единичное направление. + private Vector2 RandomDir() + { + var a = _rng.NextSingle() * MathF.Tau; + return new Vector2(MathF.Cos(a), MathF.Sin(a)); + } + + // Поворот вектора на угол (рад). + private static Vector2 Rotate(Vector2 v, float radians) + { + var c = MathF.Cos(radians); + var s = MathF.Sin(radians); + return new Vector2(v.X * c - v.Y * s, v.X * s + v.Y * c); + } + + // Выбор холостой активности с весами по виду/состоянию/окружению и её длительность. Блуждание — всегда; + // отдых — охотнее у подуставших; копание — травоядным/всеядным; валяние — только у воды. Начало + // характерной активности (кроме блуждания) пишется в личную хронику особи. + private void PickIdleActivity( + ref AnimalBrain brain, + Entity self, + in AnimalOrganism org, + Vector2 pos, + float[] values, + float now + ) + { + var roam = 1.0f; + var rest = 0.5f; + if (_restNeed >= 0 && values is not null && _restNeed < values.Length) + { + rest += (1f - values[_restNeed]) * 0.8f; // ниже отдых — охотнее приляжет (но не до сна) + } + + var dig = org.EatsPlants ? 0.7f : 0.1f; + var wallowRange = WallowWaterCells * _cellSize; + var nearWater = + TryFindShore(pos, out var water) + && Vector2.DistanceSquared(pos, water) <= wallowRange * wallowRange; + var wallow = nearWater ? 0.5f : 0f; + + var roll = _rng.NextSingle() * (roam + rest + dig + wallow); + string action; + float min, + max; + if ((roll -= roam) < 0f) + { + action = AnimalActions.Wander; + (min, max) = (RoamMinSec, RoamMaxSec); + } + else if ((roll -= rest) < 0f) + { + action = AnimalActions.Rest; + (min, max) = (RestMinSec, RestMaxSec); + } + else if (roll - dig < 0f) + { + action = AnimalActions.Dig; + (min, max) = (DigMinSec, DigMaxSec); + } + else + { + action = AnimalActions.Wallow; + (min, max) = (WallowMinSec, WallowMaxSec); + } + + brain.Action = action; + brain.ActivityUntil = now + min + _rng.NextSingle() * (max - min); + switch (action) + { + case AnimalActions.Rest: + _events.Activity(self, org.Species, "event.rest"); + break; + case AnimalActions.Dig: + _events.Activity(self, org.Species, "event.dig"); + break; + case AnimalActions.Wallow: + _events.Activity(self, org.Species, "event.wallow"); + break; + } + } + + // Рулёжка блуждания (boids): плавный дрейф направления + расхождение (личное пространство) и лишь + // слабая когезия к центру группы на удалении; у края мира заворачиваем внутрь. Цель ставится далеко + // по направлению, поэтому зверь идёт, а не топчется на месте. + private void SteerRoam( + ref AnimalBrain brain, + Vector2 pos, + Vector2 herdCenter, + int herdCount, + Vector2 herdAway, + float sociability + ) + { + var dir = brain.Heading; + if (dir.LengthSquared() < 0.0001f) + { + dir = RandomDir(); + } + + dir = Rotate(dir, (_rng.NextSingle() - 0.5f) * 2f * WanderTurn); // коррелированный дрейф + dir += herdAway * SeparationWeight; // расхождение в стаде + if (sociability >= HerdMinSociability && herdCount > 0) + { + var toCenter = herdCenter - pos; + var d = toCenter.Length(); + if (d > CohesionStartCells * _cellSize) + { + dir += toCenter / d * CohesionWeight; // слабая когезия лишь на удалении + } + } + + var margin = EdgeMarginCells * _cellSize; + var w = _worldWidth * _cellSize; + var h = _worldHeight * _cellSize; + if (pos.X < margin && dir.X < 0f) + { + dir.X = MathF.Abs(dir.X); + } + else if (pos.X > w - margin && dir.X > 0f) + { + dir.X = -MathF.Abs(dir.X); + } + + if (pos.Y < margin && dir.Y < 0f) + { + dir.Y = MathF.Abs(dir.Y); + } + else if (pos.Y > h - margin && dir.Y > 0f) + { + dir.Y = -MathF.Abs(dir.Y); + } + + if (dir.LengthSquared() < 0.0001f) + { + dir = RandomDir(); + } + + dir.Normalize(); + brain.Heading = dir; + brain.Target = pos + dir * (_cellSize * RoamCells); + } + + // Ближайший хищник-угроза в радиусе восприятия (для бегства жертвы). Угроза = другой вид, который + // ест мясо и достаточно крупный (см. IsThreatTo). O(n) на особь — как поиск корма. + private bool TryFindThreat( + Vector2 from, + in SenseRanges ranges, + int selfSpecies, + bool selfEatsMeat, + float selfBody, + out Vector2 threatPos + ) + { + var bestSq = float.MaxValue; + threatPos = default; + var threatId = -1; + // _animalBuf уже собран в DecideIn; обнаружение всё равно гейтится Detect по чувствам. + foreach (var e in _animalBuf) + { + if ( + e.Species == selfSpecies + || !IsThreatTo(selfEatsMeat, selfBody, e.EatsMeat, e.BodySize) + ) + { + continue; + } + + var sq = Vector2.DistanceSquared(from, e.Pos); + // Обнаружение по чувствам: зрение (минус камуфляж) / слух (× шум) / запах (× запах, грубая зона). + if (!Detect(sq, e.Pos, ranges, e.Camouflage, e.Scent, e.Noise, out var perceived)) + { + continue; + } + + if (sq < bestSq || (sq == bestSq && e.Id < threatId)) + { + bestSq = sq; + threatPos = perceived; + threatId = e.Id; + } + } + + return threatId >= 0; + } + + // Является ли вид-кандидат угрозой особи: он ест мясо и не мельче её. Чистая жертва (не мясоед) + // бежит от любого хищника не мельче себя; сам хищник — лишь от заметно более крупного мясоеда. + private static bool IsThreatTo( + bool selfEatsMeat, + float selfBody, + bool otherEatsMeat, + float otherBody + ) + { + if (!otherEatsMeat) + { + return false; + } + + return selfEatsMeat ? otherBody > selfBody * 1.1f : otherBody >= selfBody * 0.9f; + } + + /// Радиусы восприятия особи по чувствам (уже с остротой генов и способностью органов). + private readonly record struct SenseRanges(float Sight, float Hearing, float Smell) + { + public float Max() => MathF.Max(Sight, MathF.Max(Hearing, Smell)); + + public SenseRanges Scaled(float f) => new(Sight * f, Hearing * f, Smell * f); + } + + // Обнаружена ли цель и где она «видится» наблюдателю. Зрение/слух дают точную позицию (зрение глушит + // камуфляж цели, слух усиливает её шум); запах добивает дальше всех, но даёт лишь ГРУБУЮ зону — + // позиция округляется до ячейки запаха (зверь идёт «примерно туда», пока ближе не сработает зрение). + private bool Detect( + float dist2, + Vector2 truePos, + in SenseRanges r, + float camouflage, + float scent, + float noise, + out Vector2 perceived + ) + { + perceived = truePos; + var sight = r.Sight * (1f - Math.Clamp(camouflage, 0f, 1f)); + if (sight > 0f && dist2 <= sight * sight) + { + return true; + } + + var hear = r.Hearing * Math.Clamp(noise, 0f, 1f); + if (hear > 0f && dist2 <= hear * hear) + { + return true; + } + + var smell = r.Smell * Math.Clamp(scent, 0f, 1f); + if (smell > 0f && dist2 <= smell * smell) + { + perceived = SnapToSmellCell(truePos); // грубая зона запаха + return true; + } + + return false; + } + + // Округляет позицию до центра «обонятельной» ячейки — детерминированная грубая локализация по запаху. + private Vector2 SnapToSmellCell(Vector2 p) => + new( + MathF.Floor(p.X / _smellSnap) * _smellSnap + _smellSnap * 0.5f, + MathF.Floor(p.Y / _smellSnap) * _smellSnap + _smellSnap * 0.5f + ); + + // Ближайшая «мясная» цель: труп (падаль, приоритет — даровая еда без риска) или живая добыча. + // Возвращает id, позицию и флаг трупа. Добыча — другой вид не крупнее охотника (см. IsPreyFor). + private bool TryFindKill( + Vector2 from, + in SenseRanges ranges, + int selfSpecies, + in AnimalPhenotype selfTraits, + int selfId, + out int targetId, + out Vector2 position, + out bool isCorpse + ) + { + targetId = -1; + position = default; + isCorpse = false; + var reach = ranges.Max(); + + // 1) Падаль с остатком мяса — находится в пределах любого чувства (запах хорошо ведёт к падали). + // Кандидаты — из сетки трупов (ближние ячейки), а не полным сканом всех трупов мира. + var bestCorpseSq = reach * reach; + var corpseId = -1; + var corpsePos = default(Vector2); + _corpseGrid.Collect(from, reach, _corpseBuf); + foreach (var c in _corpseBuf) + { + if (c.Meat <= 0f) + { + continue; + } + + var sq = Vector2.DistanceSquared(from, c.Pos); + if (sq < bestCorpseSq || (sq == bestCorpseSq && c.Id < corpseId)) + { + bestCorpseSq = sq; + corpseId = c.Id; + corpsePos = c.Pos; + } + } + + // 2) Живая добыча (из сетки животных, по чувствам). _animalBuf уже собран в DecideIn на reach. + var bestPreySq = float.MaxValue; + var preyId = -1; + var preyPos = default(Vector2); + var selfBody = selfTraits.BodySize; + foreach (var e in _animalBuf) + { + if (e.Species == selfSpecies || e.Id == selfId || !IsPreyFor(selfBody, e.BodySize)) + { + continue; + } + + var sq = Vector2.DistanceSquared(from, e.Pos); + if (!Detect(sq, e.Pos, ranges, e.Camouflage, e.Scent, e.Noise, out var perceived)) + { + continue; + } + + if (sq < bestPreySq || (sq == bestPreySq && e.Id < preyId)) + { + bestPreySq = sq; + preyId = e.Id; + preyPos = perceived; + } + } + + // Предпочитаем падаль, если она не намного дальше живой добычи (даровая еда, без риска). + if (corpseId >= 0 && (preyId < 0 || bestCorpseSq <= bestPreySq * 2f)) + { + targetId = corpseId; + position = corpsePos; + isCorpse = true; + return true; + } + + if (preyId >= 0) + { + targetId = preyId; + position = preyPos; + isCorpse = false; + return true; + } + + return false; + } + + // Подходит ли особь в добычу охотнику данного размера: важно лишь, что она не крупнее охотника + // (на превосходящих не нападаем). Вид/мясоедство добычи не важны — хищник ест и травоядных, и прочих. + private static bool IsPreyFor(float hunterBody, float otherBody) => + otherBody <= hunterBody * 1.25f; + + // Ближайшее растение в радиусе (квадрат расстояния); ничьи — по меньшему id (детерминизм). + // Порог интеллекта, с которого травоядное РАЗБИРАЕТ корм (избегает ядовитого/невкусного). Ниже — + // рефлекторное выедание ближайшего растения (тир мозга 4, как сон/мысли; больной зверь с упавшим + // сознанием падает ниже и снова жрёт что попало — последствие гейтинга A7). + private const float ForageSmartMinBrain = 0.4f; + + // Вес, с которым воспринимаемый яд (с поправкой на устойчивость) отталкивает от растения, и штраф за + // дальность — чтобы умный зверь предпочитал близкий и безопасный корм, а не бежал через всю карту. + private const float ToxinAvoidWeight = 1.2f; + private const float ForageDistanceWeight = 0.5f; + + // Выбор корма: умный (избегает яда/невкусного) если интеллект ≥ порога, иначе рефлекс — ближайшее. + private bool TryFindForage( + Vector2 from, + float radius, + float intelligence, + float toxinTolerance, + out int plantId, + out Vector2 position + ) => + intelligence >= ForageSmartMinBrain + ? TryFindBestPlant(from, radius, toxinTolerance, out plantId, out position) + : TryFindPlant(from, radius, out plantId, out position); + + // Перестраивает индекс животных из текущих позиций (раз в кадр — звери двигаются). СРАЗУ считает + // признаки, нужные ИИ (мясоед/взрослость и пр.), чтобы запросы их не пересчитывали. + private void RebuildAnimalGrid() + { + _animalGrid.Clear(); + foreach (var (_, _, organisms, growths, transforms, entities) in _animals.Chunks) + { + var o = organisms.Span; + var g = growths.Span; + var t = transforms.Span; + for (var i = 0; i < t.Length; i++) + { + var sp = _animalSet[o[i].Species]; + _animalGrid.Add( + new NeighborEntry( + entities.EntityAt(i).Id, + t[i].Position, + o[i].Species, + o[i].Traits.BodySize, + o[i].EatsMeat, // диета кэширована при рождении + o[i].IsMale, + AnimalFactory.IsAdult(sp, g[i].Stage), + o[i].Traits.Camouflage, + o[i].Traits.Scent, + o[i].Traits.Noise + ) + ); + } + } + } + + // Перестраивает индексы растений и трупов (по каденции — они статичны). Устаревшая запись об удалённой + // сущности безопасна: потребитель (Graze/Hunt/TryFind*) сверяется со store и пропускает исчезнувшую. + private void RebuildStaticGrids() + { + _plantGrid.Clear(); + foreach (var (transforms, _, organisms, entities) in _plants.Chunks) + { + var t = transforms.Span; + var o = organisms.Span; + for (var i = 0; i < t.Length; i++) + { + ref readonly var tr = ref o[i].Traits; + _plantGrid.Add( + new ForageEntry( + entities.EntityAt(i).Id, + t[i].Position, + tr.Toxicity, + tr.Palatability + ) + ); + } + } + + _corpseGrid.Clear(); + foreach (var (transforms, corpses, entities) in _corpses.Chunks) + { + var t = transforms.Span; + var c = corpses.Span; + for (var i = 0; i < t.Length; i++) + { + _corpseGrid.Add(new CorpseEntry(entities.EntityAt(i).Id, t[i].Position, c[i].Meat)); + } + } + } + + // Рефлекс: ближайшее растение в радиусе (ничья — меньший id, детерминизм). Кандидаты — из сетки. + private bool TryFindPlant(Vector2 from, float radius, out int plantId, out Vector2 position) + { + var bestSq = radius * radius; + plantId = -1; + position = default; + _plantGrid.Collect(from, radius, _plantBuf); + foreach (var e in _plantBuf) + { + var sq = Vector2.DistanceSquared(from, e.Pos); + if (sq < bestSq || (sq == bestSq && e.Id < plantId)) + { + bestSq = sq; + plantId = e.Id; + position = e.Pos; + } + } + + return plantId >= 0; + } + + // Умный выбор: среди растений в радиусе максимизируем привлекательность = вкусность − + // воспринимаемый_яд − штраф_за_дальность. Воспринимаемый яд = toxicity × (1 − устойчивость): чем + // выше устойчивость зверя, тем меньше его отпугивает токсичный корм. Если всё в округе невкусно/ + // ядовито, голод всё равно заставит выбрать наименее плохой (берётся максимум, даже отрицательный). + private bool TryFindBestPlant( + Vector2 from, + float radius, + float toxinTolerance, + out int plantId, + out Vector2 position + ) + { + var radiusSq = radius * radius; + var tol = Math.Clamp(toxinTolerance, 0f, 1f); + var bestScore = float.NegativeInfinity; + plantId = -1; + position = default; + _plantGrid.Collect(from, radius, _plantBuf); + foreach (var e in _plantBuf) + { + var sq = Vector2.DistanceSquared(from, e.Pos); + if (sq > radiusSq) + { + continue; + } + + var perceivedToxin = e.Toxicity * (1f - tol); + var score = + e.Palatability + - ToxinAvoidWeight * perceivedToxin + - ForageDistanceWeight * (radius > 0f ? MathF.Sqrt(sq) / radius : 0f); + if (score > bestScore || (score == bestScore && e.Id < plantId)) + { + bestScore = score; + plantId = e.Id; + position = e.Pos; + } + } + + return plantId >= 0; + } + + // Ближайшая кромка воды (без лимита по зрению — звери «помнят» водопои; упрощение фазы A2). Берег + // статичен, поэтому ближайшая береговая точка предпосчитана по клеткам (поле _shoreByCell, BFS на + // старте) — поиск за O(1) вместо линейного скана всей береговой линии на каждое решение «пить». + private bool TryFindShore(Vector2 from, out Vector2 position) + { + position = default; + if (_shoreByCell.Length == 0) + { + return false; // в мире нет воды/берега — пить негде + } + + var cx = Math.Clamp((int)(from.X / _cellSize), 0, _worldWidth - 1); + var cy = Math.Clamp((int)(from.Y / _cellSize), 0, _worldHeight - 1); + position = _shoreByCell[cy * _worldWidth + cx]; + return true; + } + + // Ближайший подходящий партнёр в радиусе зрения: ТОГО ЖЕ вида, взрослый, противоположного пола. + private bool TryFindMate( + Vector2 from, + bool selfMale, + int selfSpecies, + float radius, + out int mateId, + out Vector2 position + ) + { + var bestSq = radius * radius; + mateId = -1; + position = default; + // _animalBuf уже собран в DecideIn на макс. радиусе чувств — фильтруем по радиусу спаривания. + foreach (var e in _animalBuf) + { + if (e.Species != selfSpecies || e.IsMale == selfMale || !e.IsAdult) + { + continue; // другой вид / тот же пол / не взрослый (self отсеивается по полу) + } + + // Влечение партнёра не проверяется (учёт придёт с половым отбором). self отсекается совпадением + // пола и sq≈0; межвидовые пары исключены (раньше волк/курица могли «спариться»). + var sq = Vector2.DistanceSquared(from, e.Pos); + if (sq > 0.01f && (sq < bestSq || (sq == bestSq && e.Id < mateId))) + { + bestSq = sq; + mateId = e.Id; + position = e.Pos; + } + } + + return mateId >= 0; + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalFacingSystem.cs b/src/LittleSim/Sim/Animals/AnimalFacingSystem.cs new file mode 100644 index 0000000..ddb21f6 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalFacingSystem.cs @@ -0,0 +1,71 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Разворот спрайта по направлению движения (как в RimWorld): зверь смотрит туда, куда идёт. Восток — +/// базовая текстура, запад — её зеркало (флип по X), север/юг — отдельные текстуры (если есть у вида). +/// Направление берётся из цели «мозга» () относительно позиции; стоящий +/// зверь сохраняет прежний разворот. При смене региона пересчитывает центр-origin и масштаб (текстуры +/// направлений могут отличаться шириной). Презентация (читает sim, пишет только Sprite/Transform-scale). +/// +public sealed class AnimalFacingSystem(AnimalSet animals, int cellSize) + : QuerySystem +{ + private const float MinMove = 0.5f; // короче — считаем стоящим, разворот не трогаем + + protected override void OnUpdate() + { + foreach (var (brains, organisms, growths, transforms, sprites, _) in Query.Chunks) + { + var b = brains.Span; + var o = organisms.Span; + var g = growths.Span; + var t = transforms.Span; + var s = sprites.Span; + for (var i = 0; i < b.Length; i++) + { + var dir = b[i].Target - t[i].Position; + if (dir.LengthSquared() < MinMove * MinMove) + { + continue; // стоит/у цели — сохраняем текущий разворот + } + + var sp = animals[o[i].Species]; + var dirs = sp.LookDirs(g[i].Stage, o[i].IsMale); + Texture2DRegion region; + SpriteFlip flip; + if (MathF.Abs(dir.X) >= MathF.Abs(dir.Y)) + { + region = dirs.East; + flip = dir.X < 0f ? SpriteFlip.X : SpriteFlip.None; // запад = зеркало востока + } + else + { + region = dir.Y < 0f ? dirs.North : dirs.South; // экранный Y вниз: вверх = север + flip = SpriteFlip.None; + } + + ref var sprite = ref s[i]; + if (sprite.Region == region && sprite.Flip == flip) + { + continue; // разворот не изменился + } + + sprite.Region = region; + sprite.Origin = new Vector2(region.Width / 2f, region.Height / 2f); + sprite.Flip = flip; + t[i].Scale = new Vector2( + AnimalFactory.Scale(sp, o[i].Traits, g[i].Stage, region, cellSize) + ); + } + } + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalGrowth.cs b/src/LittleSim/Sim/Animals/AnimalGrowth.cs new file mode 100644 index 0000000..b1ae378 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalGrowth.cs @@ -0,0 +1,18 @@ +using Friflo.Engine.ECS; + +namespace LittleSim.Sim; + +/// +/// Состояние роста животного (фаза A3): текущая стадия и накопленный возраст в игровых днях. Плоские +/// данные — переход между стадиями (Baby→Juvenile→Adult→Senior), смену спрайта и размера выполняет +/// , читая пороги из генов (возраст созревания, продолжительность жизни). +/// Зеркало . +/// +public struct AnimalGrowth : IComponent +{ + /// Текущая стадия: 0 Baby, 1 Juvenile, 2 Adult, 3 Senior. + public int Stage; + + /// Накопленный возраст в игровых днях. + public float AgeDays; +} diff --git a/src/LittleSim/Sim/Animals/AnimalGrowthSystem.cs b/src/LittleSim/Sim/Animals/AnimalGrowthSystem.cs new file mode 100644 index 0000000..6152380 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalGrowthSystem.cs @@ -0,0 +1,67 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Рост и стадии (фаза A3): копит возраст в игровых днях, переключает стадию (Baby→Juvenile→Adult→ +/// Senior) по порогам из генов и при смене стадии меняет спрайт (детёныш/самка/самец) и размер. +/// +public sealed class AnimalGrowthSystem( + GameClock clock, + float secondsPerDay, + AnimalSet animals, + int cellSize, + WorldEvents events +) : QuerySystem +{ + protected override void OnUpdate() + { + var days = clock.DeltaTime / secondsPerDay; + if (days <= 0f) + { + return; + } + + foreach (var (growths, organisms, transforms, sprites, entities) in Query.Chunks) + { + var g = growths.Span; + var o = organisms.Span; + var t = transforms.Span; + var s = sprites.Span; + for (var i = 0; i < g.Length; i++) + { + ref var grow = ref g[i]; + grow.AgeDays += days; + ref readonly var org = ref o[i]; + var sp = animals[org.Species]; + var stage = AnimalFactory.StageAt( + grow.AgeDays, + org.Traits.MaturityAge, + org.Traits.Lifespan, + sp.Stages + ); + if (stage == grow.Stage) + { + continue; + } + + grow.Stage = stage; + var region = AnimalFactory.RegionFor(sp, stage, org.IsMale); + ref var sprite = ref s[i]; + sprite.Region = region; + sprite.Origin = new Vector2(region.Width / 2f, region.Height / 2f); + t[i].Scale = new Vector2( + AnimalFactory.Scale(sp, org.Traits, stage, region, cellSize) + ); + events.Grew(entities.EntityAt(i), org.Species, sp.Stages[stage].Name); // в хронику особи + } + } + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalHealthSystem.cs b/src/LittleSim/Sim/Animals/AnimalHealthSystem.cs new file mode 100644 index 0000000..77e4084 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalHealthSystem.cs @@ -0,0 +1,219 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Здоровье и болезни (фаза A6): медленный тик (~игровой час). Фоновое заражение, прогресс болезней +/// (гонка тяжесть↔иммунитет — выздоровление или смерть), пересчёт способностей с учётом хедифов. Смерть +/// по здоровью (летальная болезнь / кровь на нуле / потеря сознания) оставляет труп. Первый реальный +/// источник урона — оживляет каркас A5 (больные звери теряют Moving → медленнее). +/// +public sealed class AnimalHealthSystem : BaseSystem +{ + private const float TickDays = 1f / 24f; // ~игровой час + private const float BloodRecoveryPerDay = 0.25f; // восстановление крови в покое (нет кровотечений) + + private readonly EntityStore _store; + private readonly AnimalSet _animals; + private readonly WorldEvents _events; + private readonly int _cellSize; + private readonly GameClock _clock; + private readonly float _secondsPerDay; + private readonly Climate _climate; + private readonly HediffDef[] _ambient; + private readonly HediffDef? _hypothermia; + private readonly HediffDef? _heatstroke; + private readonly HediffDef? _infection; + private readonly Random _rng; + private readonly ArchetypeQuery _query; + private readonly List _deaths = []; + private float _accum; + + // Сенесценс (старение): спад способностей начинается с этой доли продолжительности жизни и достигает + // максимума к её концу. Дряхлеющий зверь слабее (медленнее, хуже видит/фильтрует кровь) и уязвимее. + private const float SenescenceStart = 0.6f; + private const float SenescenceMax = 0.45f; + + // Термокомфорт (°C) при теплоизоляции 0.5 и размере тела 1; границы сдвигаются мехом и размером тела. + private const float ColdComfort = 2f; // ниже — мёрзнет + private const float HeatComfort = 30f; // выше — перегревается + private const float ColdInsulationBonus = 30f; // мех (insulation 0..1) расширяет холодоустойчивость + private const float HeatInsulationPenalty = 14f; // тот же мех ухудшает жароустойчивость + private const float ColdBodyBonus = 8f; // крупное тело (тепловая инерция) держит холод лучше + private const float ThermalRatePerDegree = 0.06f; // прирост тяжести за °C за пределом комфорта в день + + // Заражение раны: шанс в день при кровотечении тяжести 1 подхватить инфекцию. + private const float InfectionChancePerDay = 0.25f; + + public AnimalHealthSystem( + EntityStore store, + AnimalSet animals, + WorldEvents events, + int cellSize, + GameClock clock, + float secondsPerDay, + Climate climate, + HediffDef[] ambient, + HediffDef? hypothermia, + HediffDef? heatstroke, + HediffDef? infection, + int seed + ) + { + _store = store; + _animals = animals; + _events = events; + _cellSize = cellSize; + _clock = clock; + _secondsPerDay = secondsPerDay; + _climate = climate; + _ambient = ambient; + _hypothermia = hypothermia; + _heatstroke = heatstroke; + _infection = infection; + _rng = new Random(seed); + _query = store.Query(); + } + + /// Множитель способностей от старения (1 — молод/расцвет, <1 — дряхлеет к концу жизни). + public static float SenescenceFactor(float ageDays, float lifespan) + { + if (lifespan <= 0f) + { + return 1f; + } + + var frac = (ageDays / lifespan - SenescenceStart) / MathF.Max(0.01f, 1f - SenescenceStart); + return 1f - SenescenceMax * Math.Clamp(frac, 0f, 1f); + } + + protected override void OnUpdateGroup() + { + _accum += _clock.DeltaTime / _secondsPerDay; + if (_accum < TickDays) + { + return; + } + + var days = _accum; + _accum = 0f; + _deaths.Clear(); + + foreach (var (healths, organisms, growths, entities) in _query.Chunks) + { + var h = healths.Span; + var o = organisms.Span; + var g = growths.Span; + for (var i = 0; i < h.Length; i++) + { + var state = h[i].State; + if (state is null) + { + continue; + } + + foreach (var disease in _ambient) + { + if ( + !state.Has(disease.DefName) + && _rng.NextSingle() < disease.AmbientPerDay * days + ) + { + state.Add(disease); + } + } + + // Терморегуляция: вне комфорта (зависит от GeneInsulation + размера тела) копится тяжесть + // гипотермии/теплового удара; в комфорте immunityPerDay их рассасывает. Мех расширяет + // холодоустойчивость, но ухудшает жароустойчивость; крупное тело держит холод. + var insul = Math.Clamp(o[i].Traits.Insulation, 0f, 1f); + var coldLimit = + ColdComfort + - ColdInsulationBonus * insul + - ColdBodyBonus * MathF.Max(0f, o[i].Traits.BodySize - 1f); + var heatLimit = HeatComfort - HeatInsulationPenalty * insul; + var temp = _climate.Temperature; + if (temp < coldLimit && _hypothermia is not null) + { + state.Intensify(_hypothermia, (coldLimit - temp) * ThermalRatePerDegree * days); + } + else if (temp > heatLimit && _heatstroke is not null) + { + state.Intensify(_heatstroke, (temp - heatLimit) * ThermalRatePerDegree * days); + } + + // Заражение раны: пока есть кровотечение, шанс (∝ его тяжести) подхватить инфекцию. + if (_infection is not null && !state.Has(_infection.DefName)) + { + var bleedSeverity = 0f; + foreach (var hd in state.Hediffs) + { + if (hd.Def.BloodLossPerDay > 0f && hd.Severity > bleedSeverity) + { + bleedSeverity = hd.Severity; + } + } + + if ( + bleedSeverity > 0f + && _rng.NextSingle() < InfectionChancePerDay * bleedSeverity * days + ) + { + state.Add(_infection); + } + } + + // Фильтрация крови (почки/печень) ускоряет вывод токсинов и набор иммунитета: повреждённый + // орган → медленнее выздоровление от яда/болезни/инфекции (для тела без неё Capacity вернёт 1). + var lethal = state.AdvanceHediffs( + days, + state.Capacity(AnimalCapacities.BloodFiltration) + ); + // Кровопотеря от ран (кровотечения), иначе — постепенное восстановление крови в покое. + var bleed = 0f; + foreach (var hd in state.Hediffs) + { + bleed += hd.Def.BloodLossPerDay * hd.Severity; + } + + if (bleed > 0f) + { + state.BloodLevel = Math.Clamp(state.BloodLevel - bleed * days, 0f, 1f); + } + else if (state.BloodLevel < 1f) + { + state.BloodLevel = Math.Clamp( + state.BloodLevel + BloodRecoveryPerDay * days, + 0f, + 1f + ); + } + + // Старение: к концу жизни способности дряхлеют (медленнее/слабее, уязвимее к ранам/болезни). + var senescence = SenescenceFactor(g[i].AgeDays, o[i].Traits.Lifespan); + state.RecomputeCapacities(senescence); + if ( + lethal + || state.BloodLevel <= 0f + || state.Capacity(AnimalCapacities.Consciousness) <= 0.01f + ) + { + _deaths.Add(entities.EntityAt(i)); + _events.Died(o[i].Species, "event.death.disease"); + } + } + } + + foreach (var dead in _deaths) + { + AnimalFactory.Die(_store, _animals, dead, _cellSize); + } + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalMemorySystem.cs b/src/LittleSim/Sim/Animals/AnimalMemorySystem.cs new file mode 100644 index 0000000..fa59766 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalMemorySystem.cs @@ -0,0 +1,57 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Забывание (память): уверенность каждой записи падает со временем, состарившиеся стираются. Свежие +/// наблюдения записывает/освежает ; эта система только «выветривает» +/// память, давая ей конечный срок. +/// +public sealed class AnimalMemorySystem(GameClock clock, float secondsPerDay) : QuerySystem +{ + private const float ForgetPerDay = 0.5f; // уверенность падает (память живёт ~2 дня без подтверждения) + + protected override void OnUpdate() + { + var days = clock.DeltaTime / secondsPerDay; + if (days <= 0f) + { + return; + } + + foreach (var (memories, _) in Query.Chunks) + { + var m = memories.Span; + for (var i = 0; i < m.Length; i++) + { + var entries = m[i].Entries; + if (entries is null) + { + continue; + } + + for (var k = entries.Count - 1; k >= 0; k--) + { + var e = entries[k]; + e.Confidence -= ForgetPerDay * days; + e.AgeDays += days; + if (e.Confidence <= 0f) + { + entries.RemoveAt(k); + } + else + { + entries[k] = e; + } + } + } + } + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalMoodSystem.cs b/src/LittleSim/Sim/Animals/AnimalMoodSystem.cs new file mode 100644 index 0000000..4befaa1 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalMoodSystem.cs @@ -0,0 +1,107 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Настроение (фаза A8): для особей тира 4+ (мозг ≥ порога) считает настроение из непрерывного фона +/// (удовлетворённость убывающих нужд минус боль из здоровья) и суммы затухающих событийных мыслей. +/// Мысли угасают со временем. У примитивных (тир < 4) настроения нет (Active=false, мысли сбрасываются). +/// Гейт интеллектом — тем же порогом тира 4, что и высшие нужды (сон/секс). Низкое настроение влияет на +/// поведение (стресс не даёт спариваться — см. ) и видно в консоли/инспекторе. +/// +public sealed class AnimalMoodSystem(GameClock clock, float secondsPerDay, NeedSet needs) + : QuerySystem +{ + /// Порог размера мозга (тир 4), с которого у особи появляется настроение. + public const float MoodMinBrain = 0.40f; + private const float NeutralBase = 0.35f; // настроение при полном истощении нужд (без боли/мыслей) + private const float NeedsWeight = 0.55f; // вклад средней удовлетворённости убывающих нужд + private const float PainWeight = 0.4f; // боль из здоровья тянет настроение вниз + + protected override void OnUpdate() + { + var days = clock.DeltaTime / secondsPerDay; + if (days <= 0f) + { + return; + } + + foreach (var (moods, needsChunk, organisms, healths, _) in Query.Chunks) + { + var m = moods.Span; + var n = needsChunk.Span; + var o = organisms.Span; + var h = healths.Span; + for (var i = 0; i < m.Length; i++) + { + ref var mood = ref m[i]; + if (o[i].Traits.BrainSize < MoodMinBrain) + { + mood.Active = false; // тир ниже зверя — настроения нет + mood.Value = 0.5f; + mood.Thoughts?.Clear(); + continue; + } + + mood.Active = true; + // 1) Затухание событийных мыслей + сумма их сдвигов. + var thoughtSum = 0f; + var list = mood.Thoughts; + if (list is not null) + { + for (var k = list.Count - 1; k >= 0; k--) + { + var t = list[k]; + t.RemainingDays -= days; + if (t.RemainingDays <= 0f) + { + list.RemoveAt(k); + continue; + } + + list[k] = t; + thoughtSum += t.Def.MoodOffset; + } + } + + // 2) Непрерывный фон: средняя удовлетворённость убывающих нужд минус боль из здоровья. + var wellbeing = AvgDeplete(n[i].Values); + var pain = h[i].State?.Pain ?? 0f; + mood.Value = Math.Clamp( + NeutralBase + NeedsWeight * wellbeing - PainWeight * pain + thoughtSum, + 0f, + 1f + ); + } + } + } + + // Средняя удовлетворённость убывающих нужд (голод/жажда/усталость) [0,1]; нет нужд → 1 (нейтрально). + private float AvgDeplete(float[] values) + { + if (values is null) + { + return 1f; + } + + var sum = 0f; + var count = 0; + for (var k = 0; k < needs.Count && k < values.Length; k++) + { + if (needs.IsDeplete(k)) + { + sum += values[k]; + count++; + } + } + + return count > 0 ? sum / count : 1f; + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalMortalitySystem.cs b/src/LittleSim/Sim/Animals/AnimalMortalitySystem.cs new file mode 100644 index 0000000..1a942e2 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalMortalitySystem.cs @@ -0,0 +1,67 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Смертность (фаза A3/A5): гибель от старости (возраст превысил продолжительность жизни) или истощения +/// (счётчик голода/жажды превысил порог). Замыкает контур ёмкости среды. Фаза A5: вместо деспавна +/// оставляет (через ), затем удаляет особь. +/// +public sealed class AnimalMortalitySystem : BaseSystem +{ + private const float LethalStarveDays = 3f; + + private readonly EntityStore _store; + private readonly AnimalSet _animals; + private readonly WorldEvents _events; + private readonly int _cellSize; + private readonly ArchetypeQuery _query; + private readonly List _deaths = []; + + public AnimalMortalitySystem( + EntityStore store, + AnimalSet animals, + WorldEvents events, + int cellSize + ) + { + _store = store; + _animals = animals; + _events = events; + _cellSize = cellSize; + _query = store.Query(); + } + + protected override void OnUpdateGroup() + { + _deaths.Clear(); + foreach (var (growths, organisms, needsChunk, entities) in _query.Chunks) + { + var g = growths.Span; + var o = organisms.Span; + var n = needsChunk.Span; + for (var i = 0; i < g.Length; i++) + { + var ageDeath = g[i].AgeDays > o[i].Traits.Lifespan; + var starveDeath = n[i].Starve >= LethalStarveDays; + if (ageDeath || starveDeath) + { + _deaths.Add(entities.EntityAt(i)); + _events.Died(o[i].Species, ageDeath ? "event.death.age" : "event.death.starve"); + } + } + } + + foreach (var dead in _deaths) + { + AnimalFactory.Die(_store, _animals, dead, _cellSize); + } + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalNeeds.cs b/src/LittleSim/Sim/Animals/AnimalNeeds.cs new file mode 100644 index 0000000..2d69da3 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalNeeds.cs @@ -0,0 +1,24 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Нужды зверя как ДАННЫЕ (рефактор расширяемости): значения по индексам из +/// (managed-массив — добавление нужды не меняет структуру) + счётчик истощения. Падают/растут в +/// по , восполняются исполнением действия. +/// +public struct AnimalNeeds : IComponent +{ + /// Значения нужд [0,1] по индексам . + public float[] Values; + + /// Накоплено игровых дней истощения (летальная нужда на нуле); порог — смерть (A3). + public float Starve; +} diff --git a/src/LittleSim/Sim/Animals/AnimalNeedsSystem.cs b/src/LittleSim/Sim/Animals/AnimalNeedsSystem.cs new file mode 100644 index 0000000..b99a962 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalNeedsSystem.cs @@ -0,0 +1,91 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Падение/рост нужд со временем (игровые дни через ): deplete-нужды убывают +/// (× ген обмена веществ), drive-нужды растут под своим хедифом (гон) и спадают вне его. Летальные +/// нужды на нуле копят счётчик истощения. Полностью управляется данными . +/// +public sealed class AnimalNeedsSystem( + GameClock clock, + float secondsPerDay, + NeedSet needs, + AnimalSet animals +) : QuerySystem +{ + // Прибавка к обмену веществ при устойчивости к яду 1.0 (цена детоксикации, коэволюция C2). + private const float ToleranceMetabolicCost = 0.5f; + + protected override void OnUpdate() + { + var days = clock.DeltaTime / secondsPerDay; + if (days <= 0f) + { + return; + } + + foreach (var (needsChunk, organisms, healths, growths, _) in Query.Chunks) + { + var n = needsChunk.Span; + var o = organisms.Span; + var h = healths.Span; + var g = growths.Span; + for (var i = 0; i < n.Length; i++) + { + var values = n[i].Values; + if (values is null || values.Length < needs.Count) + { + continue; // защита от старого/пустого состояния + } + + // Обмен веществ = ген × цена устойчивости к яду (C2) × множитель Клайбера по массе тела: + // расход ∝ масса^0.75 = (линейный размер/эталон)^2.25 (масса ∝ размер³), нормирован на + // эталон вида → молодняк экономнее, крупные прожорливее. Эталон предпосчитан (без словаря). + var sp = animals[o[i].Species]; + var stageScale = sp.Stages[Math.Clamp(g[i].Stage, 0, sp.Stages.Length - 1)].Scale; + var sizeRatio = MathF.Max( + 0.01f, + o[i].Traits.BodySize * stageScale / sp.RefBodySize + ); + var metabolism = + MathF.Max(0.1f, o[i].Traits.Metabolism) + * (1f + ToleranceMetabolicCost * Math.Clamp(o[i].Traits.ToxinTolerance, 0f, 1f)) + * Mass.KleiberFactorFromSizeRatio(sizeRatio); + var lethalEmpty = false; + for (var k = 0; k < needs.Count; k++) + { + var def = needs[k]; + if (needs.IsDrive(k)) + { + var rising = def.RisesUnder is null || h[i].State.Has(def.RisesUnder); + var delta = (rising ? def.RisePerDay : -def.DecayPerDay) * days; + values[k] = Math.Clamp(values[k] + delta, 0f, 1f); + } + else + { + values[k] = Math.Clamp( + values[k] - def.DecayPerDay * metabolism * days, + 0f, + 1f + ); + if (def.Lethal && values[k] <= 0f) + { + lethalEmpty = true; + } + } + } + + ref var starve = ref n[i].Starve; + starve = lethalEmpty ? starve + days : MathF.Max(0f, starve - days * 2f); + } + } + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalOrganism.cs b/src/LittleSim/Sim/Animals/AnimalOrganism.cs new file mode 100644 index 0000000..79ff202 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalOrganism.cs @@ -0,0 +1,34 @@ +using Friflo.Engine.ECS; +using MrGameEng.Genetics; + +namespace LittleSim.Sim; + +/// +/// Компонент-организм животного: управляемый (для размножения, сейва и +/// пересчёта), выраженный фенотип , индекс вида в +/// и номер поколения (для popstats и видимого дрейфа). Геном — ссылочный объект, поэтому +/// компонент несёт ссылку, а не копию (как у растений). Зеркало . +/// +public struct AnimalOrganism : IComponent +{ + /// Геном особи (аллели по генам); источник истины для признаков и размножения. + public Genome Genome; + + /// Выраженные признаки — то, что читают системы. + public AnimalPhenotype Traits; + + /// Индекс вида в . + public int Species; + + /// Пол: самец (XY) или самка (XX) — выведен из локуса GeneSex при создании. + public bool IsMale; + + /// Ест ли растения (диета по генам/дефу, неизменна после рождения — кэш вместо пересчёта на тик). + public bool EatsPlants; + + /// Ест ли мясо (диета по генам/дефу, неизменна после рождения — кэш вместо пересчёта на тик). + public bool EatsMeat; + + /// Номер поколения: max(родителей) + 1; у основателей 0. + public int Generation; +} diff --git a/src/LittleSim/Sim/Animals/AnimalPhenotype.cs b/src/LittleSim/Sim/Animals/AnimalPhenotype.cs new file mode 100644 index 0000000..b993325 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalPhenotype.cs @@ -0,0 +1,134 @@ +using System.Collections.Generic; +using MrGameEng.Genetics; + +namespace LittleSim.Sim; + +/// +/// Признаки (фенотип) животного, вычисленные из генома формулами генов (см. Defs/Genes/): +/// готовые значения, которые читают системы, не касаясь генов напрямую. Зеркало +/// . Имена полей соответствуют именам признаков в эффектах генов. +/// +public struct AnimalPhenotype +{ + /// Размер тела в клетках (масштаб спрайта; позже — HP/расход энергии). + public float BodySize; + + /// Скорость обмена веществ — множитель расхода нужд. + public float Metabolism; + + /// Скорость передвижения (множитель). + public float MoveSpeed; + + /// Объём крови (множитель) — масштаб скорости кровопотери. + public float BloodVolume; + + /// Дальность зрения (множитель радиуса поиска). + public float Vision; + + /// Острота слуха (множитель радиуса). + public float Hearing; + + /// Острота обоняния (множитель радиуса). + public float Smell; + + /// Острота осязания (множитель радиуса; чувство ближнего контакта). + public float Touch; + + /// Камуфляж [0..1] — снижает обнаружение этой особи зрением (заметность = 1−camouflage). + public float Camouflage; + + /// Запах [0..1] — повышает обнаружение этой особи обонянием. + public float Scent; + + /// Шумность [0..1] — повышает обнаружение этой особи слухом при движении. + public float Noise; + + /// Размер мозга (0..1) — основа интеллекта. + public float BrainSize; + + /// Теплоизоляция мехом (0..1). + public float Insulation; + + /// Оттенок меха (0..1) — сдвиг тинта спрайта. + public float FurHue; + + /// Возраст созревания (игровых дней) — порог стадии Adult. + public float MaturityAge; + + /// Травоядность [0..1] — способность/тяга питаться растениями. + public float Herbivory; + + /// Хищничество [0..1] — тяга охотиться и питаться мясом (живая добыча/падаль). + public float Carnivory; + + /// Всеядность [0..1] — генералист: включает оба источника пищи (растения и мясо). + public float Omnivory; + + /// + /// Стиль питания [0..1]: 0 — «пастьба» (graze), часто подтапливает сытость мелкими приёмами; + /// 1 — «обжорство» (gorge), ищет еду лишь при сильном голоде и ест до полной сытости (редкие + /// крупные приёмы, как у хищников). Задаёт пороги начала/конца кормёжки в . + /// + public float FeedingStyle; + + /// Устойчивость к растительным ядам [0..1] — снижает дозу отравления токсичным кормом. + public float ToxinTolerance; + + /// Социальность [0..1] — тяга держаться стаи/стада (когезия + спокойствие в группе). + public float Sociability; + + /// Продолжительность жизни (игровых дней). + public float Lifespan; + + /// Сезон гона (0 весна … 3 зима). + public int BreedingSeason; + + /// Срок вынашивания (игровых дней). + public float GestationDays; + + /// Размер помёта. + public float LitterSize; + + /// Тип рождения [0..1]: ≥0.5 — яйцекладущий (откладывает яйца), иначе живородящий. + public float EggLaying; + + /// Яйцекладущий ли вид (тип рождения) — определяет цикл зачатия. + public readonly bool Oviparous => EggLaying >= 0.5f; + + /// Собирает фенотип из карты признаков, посчитанной . + public static AnimalPhenotype FromTraits(IReadOnlyDictionary traits) + { + float T(string name) => traits.GetValueOrDefault(name); + // Острота чувств по умолчанию 1 (вид без гена не «слепнет»); заметность — мягкие дефолты. + float Acuity(string name) => traits.TryGetValue(name, out var v) ? v : 1f; + return new AnimalPhenotype + { + BodySize = T("bodySize"), + Metabolism = T("metabolism"), + MoveSpeed = T("moveSpeed"), + BloodVolume = T("bloodVolume"), + Vision = T("vision"), + Hearing = Acuity("hearing"), + Smell = Acuity("smell"), + Touch = Acuity("touch"), + Camouflage = T("camouflage"), + Scent = traits.GetValueOrDefault("scent", 0.5f), + Noise = traits.GetValueOrDefault("noise", 0.5f), + BrainSize = T("brainSize"), + Insulation = T("insulation"), + FurHue = T("furHue"), + MaturityAge = T("maturityAge"), + Herbivory = T("herbivory"), + Carnivory = T("carnivory"), + Omnivory = T("omnivory"), + FeedingStyle = traits.GetValueOrDefault("feedingStyle", 0.5f), + ToxinTolerance = T("toxinTolerance"), + Sociability = T("sociability"), + Lifespan = T("lifespan"), + BreedingSeason = (int)MathF.Round(Math.Clamp(T("breedingSeason"), 0f, 3f)), + GestationDays = T("gestationDays"), + LitterSize = T("litterSize"), + EggLaying = T("eggLaying"), + }; + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalPregnancySystem.cs b/src/LittleSim/Sim/Animals/AnimalPregnancySystem.cs new file mode 100644 index 0000000..ea1880a --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalPregnancySystem.cs @@ -0,0 +1,183 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Беременность и роды (фаза A4): тикает срок вынашивания; по истечении рожает помёт — каждый детёныш = +/// генома матери и отца (пол наследуется без YY), стадия Baby, поколение +/// матери + 1, рядом с матерью. Компонент снимается. Потолок численности +/// страхует от взрыва популяции. +/// +public sealed class AnimalPregnancySystem : BaseSystem +{ + private readonly EntityStore _store; + private readonly AnimalSet _animals; + private readonly NeedSet _needs; + private readonly SkillSet _skills; + private readonly ThoughtSet _thoughts; + private readonly WorldEvents _events; + private readonly GameClock _clock; + private readonly float _secondsPerDay; + private readonly int _cellSize; + private readonly int _maxAnimals; + private readonly Random _rng; + private readonly ArchetypeQuery _query; + private readonly ArchetypeQuery _census; + private readonly List _births = []; + + public AnimalPregnancySystem( + EntityStore store, + AnimalSet animals, + NeedSet needs, + SkillSet skills, + ThoughtSet thoughts, + WorldEvents events, + GameClock clock, + float secondsPerDay, + int cellSize, + int maxAnimals, + int seed + ) + { + _store = store; + _animals = animals; + _needs = needs; + _skills = skills; + _thoughts = thoughts; + _events = events; + _clock = clock; + _secondsPerDay = secondsPerDay; + _cellSize = cellSize; + _maxAnimals = maxAnimals; + _rng = new Random(seed); + _query = store.Query(); + _census = store.Query(); + } + + protected override void OnUpdateGroup() + { + var days = _clock.DeltaTime / _secondsPerDay; + if (days <= 0f) + { + return; + } + + _births.Clear(); + foreach (var (pregnancies, organisms, transforms, entities) in _query.Chunks) + { + var p = pregnancies.Span; + var o = organisms.Span; + var t = transforms.Span; + for (var i = 0; i < p.Length; i++) + { + ref var preg = ref p[i]; + preg.DueInDays -= days; + if (preg.DueInDays > 0f) + { + continue; + } + + _births.Add( + new Birth + { + Mother = entities.EntityAt(i), + Species = o[i].Species, + MotherGenome = o[i].Genome, + FatherGenome = preg.FatherGenome, + Litter = preg.Litter, + Generation = o[i].Generation + 1, + Position = t[i].Position, + Oviparous = o[i].Traits.Oviparous, + IncubateDays = MathF.Max(1f, o[i].Traits.GestationDays), + } + ); + } + } + + var count = _census.Count; + foreach (var birth in _births) + { + if (birth.Mother.HasComponent()) + { + birth.Mother.RemoveComponent(); + } + + // Роды — сильная положительная мысль матери (A8). + if (birth.Mother.HasComponent()) + { + birth.Mother.GetComponent().Add(_thoughts.Get(AnimalThoughts.GaveBirth)); + } + + var bornLive = 0; + for (var k = 0; k < birth.Litter && count < _maxAnimals; k++) + { + var child = Genome.Breed( + birth.MotherGenome, + birth.FatherGenome, + _animals.GeneRegistry, + _rng + ); + var offset = + new Vector2(_rng.NextSingle() - 0.5f, _rng.NextSingle() - 0.5f) * _cellSize; + if (birth.Oviparous) + { + // Яйцекладка: вместо живого детёныша откладываем яйцо с его геномом — вылупится позже. + AnimalFactory.CreateEgg( + _store, + _animals, + birth.Species, + birth.Position + offset, + child, + birth.Generation, + birth.IncubateDays, + _cellSize + ); + } + else + { + AnimalFactory.Create( + _store, + _animals, + _needs, + _skills, + birth.Species, + birth.Position + offset, + ageDays: 0f, + child, + birth.Generation, + _cellSize + ); + bornLive++; + } + + count++; + } + + // Живые роды — заметное событие (яйцекладка отметится позже при вылуплении в EggSystem). + if (bornLive > 0) + { + _events.Born(birth.Mother, birth.Species, bornLive); + } + } + } + + private struct Birth + { + public Entity Mother; + public int Species; + public Genome MotherGenome; + public Genome FatherGenome; + public int Litter; + public int Generation; + public Vector2 Position; + public bool Oviparous; + public float IncubateDays; + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalRutSystem.cs b/src/LittleSim/Sim/Animals/AnimalRutSystem.cs new file mode 100644 index 0000000..db56a41 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalRutSystem.cs @@ -0,0 +1,44 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Гон (фаза A4): взрослым в их брачный сезон навешивает хедиф Rut, вне сезона снимает. Рост влечения +/// под этим хедифом считает (нужда Mating с risesUnder=Rut) — +/// сезонность и влечение разнесены: эта система отвечает только за состояние. +/// +public sealed class AnimalRutSystem(AnimalSet animals, Climate climate, HediffDef rut) + : QuerySystem +{ + protected override void OnUpdate() + { + var season = (int)climate.Season; + foreach (var (growths, organisms, healths, _) in Query.Chunks) + { + var g = growths.Span; + var o = organisms.Span; + var h = healths.Span; + for (var i = 0; i < g.Length; i++) + { + var inRut = + AnimalFactory.IsAdult(animals[o[i].Species], g[i].Stage) + && season == o[i].Traits.BreedingSeason; + if (inRut) + { + h[i].State.Add(rut); + } + else + { + h[i].State.Remove(rut.DefName); + } + } + } + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalSkillSystem.cs b/src/LittleSim/Sim/Animals/AnimalSkillSystem.cs new file mode 100644 index 0000000..ceaebcc --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalSkillSystem.cs @@ -0,0 +1,49 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Угасание навыков без практики: каждый уровень медленно сползает к полу своего +/// (рост идёт от использования в ). Так навык, которым не пользуются, +/// постепенно забывается — есть давление на специализацию. +/// +public sealed class AnimalSkillSystem(GameClock clock, float secondsPerDay, SkillSet skills) + : QuerySystem +{ + protected override void OnUpdate() + { + var days = clock.DeltaTime / secondsPerDay; + if (days <= 0f) + { + return; + } + + foreach (var (skillChunk, _) in Query.Chunks) + { + var s = skillChunk.Span; + for (var i = 0; i < s.Length; i++) + { + var levels = s[i].Levels; + if (levels is null) + { + continue; + } + + for (var k = 0; k < skills.Count && k < levels.Length; k++) + { + levels[k] = MathF.Max( + skills[k].FloorLevel, + levels[k] - skills[k].DecayPerDay * days + ); + } + } + } + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalSkills.cs b/src/LittleSim/Sim/Animals/AnimalSkills.cs new file mode 100644 index 0000000..760df2a --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalSkills.cs @@ -0,0 +1,14 @@ +namespace LittleSim.Sim; + +/// Id навыков-эффектов (на них ссылается код начисления XP и применения бонуса). +public static class AnimalSkills +{ + /// Добыча корма: эффективность выпаса/поедания растений. + public const string Foraging = "Foraging"; + + /// Охота: урон по добыче. + public const string Hunting = "Hunting"; + + /// Уклонение: скорость бегства от хищника. + public const string Evasion = "Evasion"; +} diff --git a/src/LittleSim/Sim/Animals/AnimalThoughts.cs b/src/LittleSim/Sim/Animals/AnimalThoughts.cs new file mode 100644 index 0000000..d450149 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalThoughts.cs @@ -0,0 +1,22 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Id событийных мыслей настроения (фаза A8). Системы-источники события вешают мысль с этим id (через +/// ); её сила/длительность/метка — данные (thoughts.json). Новая мысль на +/// новое событие = код-триггер + деф; правка чисел существующей — только JSON. +/// +public static class AnimalThoughts +{ + public const string GaveBirth = "GaveBirth"; + public const string Mated = "Mated"; + public const string QuenchedThirst = "QuenchedThirst"; +} diff --git a/src/LittleSim/Sim/Animals/Carrier.cs b/src/LittleSim/Sim/Animals/Carrier.cs new file mode 100644 index 0000000..52ee584 --- /dev/null +++ b/src/LittleSim/Sim/Animals/Carrier.cs @@ -0,0 +1,14 @@ +using Friflo.Engine.ECS; + +namespace LittleSim.Sim; + +/// +/// Ноша существа (задел под перенос предметов/хаул): суммарная масса переносимого груза в кг. Перегруз +/// сверх грузоподъёмности () замедляет движение. Компонент появляется +/// у существа, когда оно что-то несёт; пока источников груза нет — спящий каркас под будущий инвентарь. +/// +public struct Carrier : IComponent +{ + /// Суммарная масса переносимого груза (кг). + public float CarriedKg; +} diff --git a/src/LittleSim/Sim/Animals/Corpse.cs b/src/LittleSim/Sim/Animals/Corpse.cs new file mode 100644 index 0000000..0240c80 --- /dev/null +++ b/src/LittleSim/Sim/Animals/Corpse.cs @@ -0,0 +1,26 @@ +using Friflo.Engine.ECS; + +namespace LittleSim.Sim; + +/// +/// Труп животного (фаза A5): отдельная сущность после смерти. Разлагается во времени по стадиям +/// (Fresh→Rotting→Skeleton→исчезает), скорость зависит от температуры. Несёт запас мяса (∝ размер тела) +/// для будущей разделки/падальщиков. Управляется . +/// +public struct Corpse : IComponent +{ + /// Индекс вида в (для спрайта, разделки, сейва). + public int Species; + + /// Накоплено игровых дней разложения. + public float RotDays; + + /// Стадия: 0 свежий, 1 гниющий, 2 скелет. + public int Stage; + + /// Доступное мясо (∝ масса тела) — для разделки/падальщиков (фаза-горизонт). + public float Meat; + + /// Линейный размер тела (для масштаба спрайта трупа), независимо от запаса мяса. + public float BodySize; +} diff --git a/src/LittleSim/Sim/Animals/CorpseSystem.cs b/src/LittleSim/Sim/Animals/CorpseSystem.cs new file mode 100644 index 0000000..a69723b --- /dev/null +++ b/src/LittleSim/Sim/Animals/CorpseSystem.cs @@ -0,0 +1,88 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Разложение трупа (фаза A5): копит дни гниения (ускоряется в тепле, замедляется на морозе), +/// переключает стадии Fresh→Rotting→Skeleton по порогам и тинтит спрайт; по достижении предела труп +/// исчезает. Скелет рисуется бледным тинтом падали (отдельного спрайта пока нет — см. docs). +/// +public sealed class CorpseSystem : BaseSystem +{ + private const float RottingDay = 2f; + private const float SkeletonDay = 6f; + private const float GoneDay = 14f; + + private static readonly Color FreshTint = new(176, 148, 128); + private static readonly Color RottingTint = new(120, 118, 98); + private static readonly Color SkeletonTint = new(228, 226, 214); + + private readonly GameClock _clock; + private readonly float _secondsPerDay; + private readonly Climate _climate; + private readonly ArchetypeQuery _query; + private readonly List _gone = []; + + public CorpseSystem(EntityStore store, GameClock clock, float secondsPerDay, Climate climate) + { + _clock = clock; + _secondsPerDay = secondsPerDay; + _climate = climate; + _query = store.Query(); + } + + protected override void OnUpdateGroup() + { + var days = _clock.DeltaTime / _secondsPerDay; + if (days <= 0f) + { + return; + } + + // Тепло ускоряет гниение, около нуля и ниже — резко замедляет (заморозка). + var factor = Math.Clamp((_climate.Temperature + 5f) / 20f, 0.1f, 2.5f); + var advance = days * factor; + _gone.Clear(); + + foreach (var (corpses, sprites, entities) in _query.Chunks) + { + var c = corpses.Span; + var s = sprites.Span; + for (var i = 0; i < c.Length; i++) + { + ref var corpse = ref c[i]; + corpse.RotDays += advance; + if (corpse.RotDays >= GoneDay) + { + _gone.Add(entities.EntityAt(i)); + continue; + } + + var stage = + corpse.RotDays >= SkeletonDay ? 2 + : corpse.RotDays >= RottingDay ? 1 + : 0; + if (stage != corpse.Stage) + { + corpse.Stage = stage; + s[i].Color = + stage == 2 ? SkeletonTint + : stage == 1 ? RottingTint + : FreshTint; + } + } + } + + foreach (var entity in _gone) + { + entity.DeleteEntity(); + } + } +} diff --git a/src/LittleSim/Sim/Animals/CreatureLog.cs b/src/LittleSim/Sim/Animals/CreatureLog.cs new file mode 100644 index 0000000..5f14041 --- /dev/null +++ b/src/LittleSim/Sim/Animals/CreatureLog.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using Friflo.Engine.ECS; + +namespace LittleSim.Sim; + +/// +/// Личная хроника особи (фича UI «История»): ограниченный кольцевой список событий о ней (спаривание, +/// роды, охота, кормёжка, бегство, рост). Пишет , читает инспектор. Список — +/// managed-ссылка (как мысли/память); старые записи вытесняются по достижении . +/// +public struct CreatureLog : IComponent +{ + /// Записи в порядке добавления (новые в конце). + public List Entries; + + /// Сколько записей хранится (старейшие вытесняются). + public int Capacity; +} diff --git a/src/LittleSim/Sim/Animals/DamageFlash.cs b/src/LittleSim/Sim/Animals/DamageFlash.cs new file mode 100644 index 0000000..8337992 --- /dev/null +++ b/src/LittleSim/Sim/Animals/DamageFlash.cs @@ -0,0 +1,14 @@ +using Friflo.Engine.ECS; + +namespace LittleSim.Sim; + +/// +/// Кратковременная «вспышка урона» (презентация, как в RimWorld): при получении удара выставляется +/// время свечения; подкрашивает спрайт красным и гасит свечение. +/// Есть у всех животных (выставляется в 0 при создании), чтобы удар не делал структурного добавления. +/// +public struct DamageFlash : IComponent +{ + /// Остаток реального времени свечения (сек); 0 — не подсвечен. + public float Seconds; +} diff --git a/src/LittleSim/Sim/Animals/DamageFlashSystem.cs b/src/LittleSim/Sim/Animals/DamageFlashSystem.cs new file mode 100644 index 0000000..f2dbf02 --- /dev/null +++ b/src/LittleSim/Sim/Animals/DamageFlashSystem.cs @@ -0,0 +1,45 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Красная вспышка урона (презентация, как в RimWorld): особь, получившую удар, кратко подкрашивает +/// красным и гасит свечение по РЕАЛЬНОМУ времени (видно и на высокой скорости). Запускать ПОСЛЕ +/// (та задаёт базовый цвет от нужд) — вспышка подмешивается поверх. +/// Время свечения выставляет при ударе. Граница sim/presentation цела. +/// +public sealed class DamageFlashSystem(GameClock clock) : QuerySystem +{ + private static readonly Color FlashColor = new(255, 60, 60); + private const float MaxIntensity = 0.85f; // насколько краснит на пике + private const float FlashDuration = 0.35f; // полная длительность свечения (сек реального времени) + + protected override void OnUpdate() + { + var dt = clock.UnscaledDeltaTime; + foreach (var (sprites, flashes, _) in Query.Chunks) + { + var s = sprites.Span; + var f = flashes.Span; + for (var i = 0; i < f.Length; i++) + { + ref var flash = ref f[i]; + if (flash.Seconds <= 0f) + { + continue; + } + + flash.Seconds = MathF.Max(0f, flash.Seconds - dt); + var t = Math.Clamp(flash.Seconds / FlashDuration, 0f, 1f) * MaxIntensity; + s[i].Color = Color.Lerp(s[i].Color, FlashColor, t); + } + } + } +} diff --git a/src/LittleSim/Sim/Animals/Egg.cs b/src/LittleSim/Sim/Animals/Egg.cs new file mode 100644 index 0000000..6fb722b --- /dev/null +++ b/src/LittleSim/Sim/Animals/Egg.cs @@ -0,0 +1,24 @@ +using Friflo.Engine.ECS; +using MrGameEng.Genetics; + +namespace LittleSim.Sim; + +/// +/// Яйцо (тип рождения «яйцекладка»): отдельная сущность, отложенная яйцекладущей самкой вместо живых +/// родов. Несёт уже скрещённый геном будущего детёныша, вид и поколение; инкубируется во времени и +/// вылупляется в детёныша (). Геном — managed-ссылка (как у трупа/беременности). +/// +public struct Egg : IComponent +{ + /// Индекс вида в (для спрайта при вылуплении и сейва). + public int Species; + + /// Геном будущего детёныша (уже скрещён из родителей на момент кладки). + public Genome Genome; + + /// Поколение будущего детёныша. + public int Generation; + + /// Остаток инкубации в игровых днях; ≤0 — вылупление. + public float IncubateDays; +} diff --git a/src/LittleSim/Sim/Animals/EggSystem.cs b/src/LittleSim/Sim/Animals/EggSystem.cs new file mode 100644 index 0000000..98c9c10 --- /dev/null +++ b/src/LittleSim/Sim/Animals/EggSystem.cs @@ -0,0 +1,100 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Инкубация яиц (тип рождения «яйцекладка»): тикает остаток инкубации каждого яйца; по истечении +/// вылупляет детёныша ( из хранимого генома, стадия Baby) рядом с +/// яйцом и удаляет яйцо. Зеркало родов для яйцекладущих видов. +/// +public sealed class EggSystem : BaseSystem +{ + private readonly EntityStore _store; + private readonly AnimalSet _animals; + private readonly NeedSet _needs; + private readonly SkillSet _skills; + private readonly WorldEvents _events; + private readonly GameClock _clock; + private readonly float _secondsPerDay; + private readonly int _cellSize; + private readonly Random _rng; + private readonly ArchetypeQuery _query; + private readonly List _hatched = []; + + public EggSystem( + EntityStore store, + AnimalSet animals, + NeedSet needs, + SkillSet skills, + WorldEvents events, + GameClock clock, + float secondsPerDay, + int cellSize, + int seed + ) + { + _store = store; + _animals = animals; + _needs = needs; + _skills = skills; + _events = events; + _clock = clock; + _secondsPerDay = secondsPerDay; + _cellSize = cellSize; + _rng = new Random(seed); + _query = store.Query(); + } + + protected override void OnUpdateGroup() + { + var days = _clock.DeltaTime / _secondsPerDay; + if (days <= 0f) + { + return; + } + + _hatched.Clear(); + foreach (var (eggs, transforms, entities) in _query.Chunks) + { + var e = eggs.Span; + for (var i = 0; i < e.Length; i++) + { + ref var egg = ref e[i]; + egg.IncubateDays -= days; + if (egg.IncubateDays > 0f) + { + continue; + } + + var offset = + new Vector2(_rng.NextSingle() - 0.5f, _rng.NextSingle() - 0.5f) * _cellSize; + AnimalFactory.Create( + _store, + _animals, + _needs, + _skills, + egg.Species, + transforms.Span[i].Position + offset, + ageDays: 0f, + egg.Genome, + egg.Generation, + _cellSize + ); + _events.Hatched(egg.Species, 1); + _hatched.Add(entities.EntityAt(i)); + } + } + + foreach (var egg in _hatched) + { + egg.DeleteEntity(); + } + } +} diff --git a/src/LittleSim/Sim/Animals/GutSeed.cs b/src/LittleSim/Sim/Animals/GutSeed.cs new file mode 100644 index 0000000..f1ee70f --- /dev/null +++ b/src/LittleSim/Sim/Animals/GutSeed.cs @@ -0,0 +1,21 @@ +using Friflo.Engine.ECS; +using MrGameEng.Genetics; + +namespace LittleSim.Sim; + +/// +/// Проглоченное семя (эндозоохория): травоядное, съевшее плодоносящее растение, какое-то время несёт +/// его семя в кишечнике, затем «высаживает» его в другом месте () — +/// зверь становится разносчиком флоры. Геном — managed-ссылка (клон родительского растения). +/// +public struct GutSeed : IComponent +{ + /// Индекс вида растения в . + public int PlantSpecies; + + /// Геном будущего ростка (клон съеденного растения). + public Genome Genome; + + /// Остаток времени до высадки в игровых днях; ≤0 — росток падает на землю. + public float DropInDays; +} diff --git a/src/LittleSim/Sim/Animals/Health.cs b/src/LittleSim/Sim/Animals/Health.cs new file mode 100644 index 0000000..9196b0e --- /dev/null +++ b/src/LittleSim/Sim/Animals/Health.cs @@ -0,0 +1,10 @@ +using Friflo.Engine.ECS; + +namespace LittleSim.Sim; + +/// Здоровье особи: managed-состояние со списком хедифов (см. ). +public struct Health : IComponent +{ + /// Состояние здоровья (хедифы; позже части тела/кровь). + public HealthState State; +} diff --git a/src/LittleSim/Sim/Animals/HealthState.cs b/src/LittleSim/Sim/Animals/HealthState.cs new file mode 100644 index 0000000..c7c544a --- /dev/null +++ b/src/LittleSim/Sim/Animals/HealthState.cs @@ -0,0 +1,268 @@ +using System.Collections.Generic; +using LittleSim.Content; + +namespace LittleSim.Sim; + +/// +/// Состояние здоровья особи (фазы A4–A6): активные хедифы (инстансы) + части тела, кровь и вычисленные +/// способности (capacities). Managed-объект, как (Friflo допускает ссылку в +/// компоненте). Болезни (A6) — первый источник урона: снижают способности и могут убить. Растёт по +/// фазам — контейнер один. +/// +public sealed class HealthState +{ + private readonly List _hediffs = []; + + /// Активные хедифы (гон, болезни). + public IReadOnlyList Hediffs => _hediffs; + + /// Части тела особи (из анатомии вида); пусто — у вида нет тела. + public PartInstance[] Parts { get; private set; } = []; + + /// Уровень крови [0,1]; ниже порога — потеря сознания и смерть (когда появятся раны). + public float BloodLevel { get; set; } = 1f; + + /// Боль [0,1] — снижает сознание (когда появятся раны). + public float Pain { get; set; } + + /// Погибла ли особь по здоровью (vital-часть уничтожена / кровь на нуле). + public bool Dead { get; set; } + + /// Вычисленные способности (capacity id → [0,1]); у здоровой особи все = 1. + public Dictionary Capacities { get; private set; } = new(); + + /// Способность по id (1, если не вычислена — напр. у вида без тела), для множителей действий. + public float Capacity(string id) => Capacities.TryGetValue(id, out var v) ? v : 1f; + + /// + /// Пересчитывает боль (из хедифов) и способности: базовые из частей/крови/боли, затем модификаторы + /// способностей от хедифов (лерп 1→factor по тяжести), затем общий множитель + /// (старение/сенесценс: дряхлеющий организм слабее по всем способностям). Вызывать после изменения + /// частей/крови/хедифов; =1 — без возрастного спада. + /// + public void RecomputeCapacities(float capacityScale = 1f) + { + var pain = 0f; + foreach (var h in _hediffs) + { + pain += h.Def.Pain * h.Severity; + } + + Pain = Math.Clamp(pain, 0f, 1f); + + // Множитель старения применяется внутри Compute. Пишем в собственный словарь Capacities + // (переиспользуется, очищается внутри Compute) — без аллокации карты способностей на пересчёт. + var caps = Capacities; + CapacityCalc.Compute(Parts, BloodLevel, Pain, capacityScale, caps); + foreach (var h in _hediffs) + { + if (h.Def.CapMods.Count == 0) + { + continue; + } + + var t = Math.Clamp(h.Severity, 0f, 1f); + foreach (var (capacity, factor) in h.Def.CapMods) + { + caps[capacity] = caps.GetValueOrDefault(capacity, 1f) * (1f + (factor - 1f) * t); + } + } + } + + /// + /// Прогрессирует болезни (тяжесть и иммунитет растут со временем); снимает выздоровевшие + /// (иммунитет ≥ 1); возвращает true, если какая-то болезнь достигла летальной тяжести. + /// — множитель набора иммунитета (способность «фильтрация крови»: + /// почки/печень выводят токсины и помогают бороться с болезнью; повреждённые → медленнее). + /// + public bool AdvanceHediffs(float days, float immunityScale = 1f) + { + var lethal = false; + for (var i = _hediffs.Count - 1; i >= 0; i--) + { + var h = _hediffs[i]; + if (h.Def.SeverityPerDay <= 0f && h.Def.ImmunityPerDay <= 0f) + { + continue; // статичный хедиф (гон) не прогрессирует + } + + h.Severity += h.Def.SeverityPerDay * days; + h.Immunity += h.Def.ImmunityPerDay * days * immunityScale; + if (h.Immunity >= 1f) + { + _hediffs.RemoveAt(i); // иммунитет победил — выздоровление + continue; + } + + if (h.Def.LethalSeverity > 0f && h.Severity >= h.Def.LethalSeverity) + { + lethal = true; + } + + _hediffs[i] = h; + } + + return lethal; + } + + /// Строит здоровье особи: части из анатомии вида (HP × размер тела) + начальные способности. + public static HealthState Create(Content.BodyDef? body, float bodySize) + { + var state = new HealthState(); + if (body is { Parts.Length: > 0 }) + { + var scale = MathF.Max(0.2f, bodySize); + state.Parts = new PartInstance[body.Parts.Length]; + for (var i = 0; i < body.Parts.Length; i++) + { + var hp = body.Parts[i].MaxHp * scale; + state.Parts[i] = new PartInstance + { + Def = body.Parts[i], + Hp = hp, + MaxHp = hp, + }; + } + } + + state.RecomputeCapacities(); + return state; + } + + /// Несёт ли особь хедиф с данным именем дефа. + public bool Has(string defName) + { + foreach (var h in _hediffs) + { + if (h.Def.DefName == defName) + { + return true; + } + } + + return false; + } + + /// Навешивает хедиф (если ещё не навешан) с начальной тяжестью из дефа. + public void Add(HediffDef def) + { + if (!Has(def.DefName)) + { + _hediffs.Add(new Hediff { Def = def, Severity = def.InitialSeverity }); + } + } + + /// Восстанавливает хедиф с заданной тяжестью и иммунитетом (для загрузки сейва). + public void AddHediff(HediffDef def, float severity, float immunity) + { + if (!Has(def.DefName)) + { + _hediffs.Add( + new Hediff + { + Def = def, + Severity = severity, + Immunity = immunity, + } + ); + } + } + + /// Снимает хедиф по имени дефа; возвращает, был ли он. + public bool Remove(string defName) + { + for (var i = 0; i < _hediffs.Count; i++) + { + if (_hediffs[i].Def.DefName == defName) + { + _hediffs.RemoveAt(i); + return true; + } + } + + return false; + } + + /// + /// Травма (укус хищника): повреждает часть тела (выбор по весу попадания coverage), вызывает + /// мгновенную кровопотерю и навешивает/усиливает кровотечение, затем пересчитывает способности. + /// Первый травматический источник урона — оживляет каркас здоровья A5. + /// + public void ApplyInjury( + float partDamage, + float bloodLoss, + HediffDef? bleeding, + float bleedSeverity, + Random rng + ) + { + if (Parts.Length > 0 && partDamage > 0f) + { + var idx = PickPart(rng); + var part = Parts[idx]; + part.Hp = MathF.Max(0f, part.Hp - partDamage); + Parts[idx] = part; + } + + BloodLevel = Math.Clamp(BloodLevel - bloodLoss, 0f, 1f); + if (bleeding is not null && bleedSeverity > 0f) + { + Intensify(bleeding, bleedSeverity); + } + + RecomputeCapacities(); + } + + // Выбор задетой части по весу попадания (coverage); детерминируется переданным rng. + private int PickPart(Random rng) + { + var total = 0f; + foreach (var p in Parts) + { + total += p.Def?.Coverage ?? 0f; + } + + if (total <= 0f) + { + return rng.Next(Parts.Length); + } + + var roll = (float)rng.NextDouble() * total; + for (var i = 0; i < Parts.Length; i++) + { + if (Parts[i].Def is null) + { + continue; + } + + roll -= Parts[i].Def.Coverage; + if (roll <= 0f) + { + return i; + } + } + + return Parts.Length - 1; + } + + /// + /// Навешивает хедиф или усиливает существующий: тяжесть растёт (до 1), а заживление (иммунитет) + /// сбрасывается — свежая рана начинает затягиваться заново. Для повторных укусов/ранений. + /// + public void Intensify(HediffDef def, float addSeverity) + { + for (var i = 0; i < _hediffs.Count; i++) + { + if (_hediffs[i].Def.DefName == def.DefName) + { + var h = _hediffs[i]; + h.Severity = Math.Clamp(h.Severity + addSeverity, 0f, 1f); + h.Immunity = 0f; // свежая рана — заживление с нуля + _hediffs[i] = h; + return; + } + } + + _hediffs.Add(new Hediff { Def = def, Severity = Math.Clamp(addSeverity, 0f, 1f) }); + } +} diff --git a/src/LittleSim/Sim/Animals/Hediff.cs b/src/LittleSim/Sim/Animals/Hediff.cs new file mode 100644 index 0000000..62a067a --- /dev/null +++ b/src/LittleSim/Sim/Animals/Hediff.cs @@ -0,0 +1,16 @@ +using LittleSim.Content; + +namespace LittleSim.Sim; + +/// Инстанс хедифа на особи: деф + текущая тяжесть и набранный иммунитет (гонка, фаза A6). +public struct Hediff +{ + /// Деф хедифа (гон/болезнь/…). + public HediffDef Def; + + /// Текущая тяжесть. + public float Severity; + + /// Набранный иммунитет [0,1]; при 1 — выздоровление. + public float Immunity; +} diff --git a/src/LittleSim/Sim/Animals/Memory.cs b/src/LittleSim/Sim/Animals/Memory.cs new file mode 100644 index 0000000..abc3aac --- /dev/null +++ b/src/LittleSim/Sim/Animals/Memory.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using Friflo.Engine.ECS; +using Microsoft.Xna.Framework; + +namespace LittleSim.Sim; + +/// +/// Память особи (объём ∝ мозгу): ограниченный набор записей о замеченном (сейчас — угрозы). Чувства +/// пишут сюда воспринятое; со временем уверенность падает и запись забывается; ИИ использует память, +/// когда цель не воспринимается сейчас (избегает зоны недавнего хищника). Список — managed-ссылка. +/// +public struct Memory : IComponent +{ + /// Активные записи (упорядочены по добавлению; слабейшая вытесняется при переполнении). + public List Entries; + + /// Сколько записей вмещает (∝ размеру мозга; 0 — память отсутствует). + public int Capacity; + + /// Запоминает/освежает наблюдение: близкое того же вида — обновляет, иначе добавляет (с + /// вытеснением слабейшего при переполнении). Вне ёмкости (Capacity 0) — ничего не помнит. + public readonly void Remember(MemoryKind kind, Vector2 pos, float mergeDist) + { + if (Entries is null || Capacity <= 0) + { + return; + } + + var mergeSq = mergeDist * mergeDist; + for (var i = 0; i < Entries.Count; i++) + { + if (Entries[i].Kind == kind && Vector2.DistanceSquared(Entries[i].Pos, pos) <= mergeSq) + { + Entries[i] = new MemoryEntry + { + Kind = kind, + Pos = pos, + Confidence = 1f, + AgeDays = 0f, + }; + return; + } + } + + if (Entries.Count >= Capacity) + { + var weakest = 0; + for (var i = 1; i < Entries.Count; i++) + { + if (Entries[i].Confidence < Entries[weakest].Confidence) + { + weakest = i; + } + } + + Entries.RemoveAt(weakest); + } + + Entries.Add( + new MemoryEntry + { + Kind = kind, + Pos = pos, + Confidence = 1f, + AgeDays = 0f, + } + ); + } +} diff --git a/src/LittleSim/Sim/Animals/MemoryEntry.cs b/src/LittleSim/Sim/Animals/MemoryEntry.cs new file mode 100644 index 0000000..525150d --- /dev/null +++ b/src/LittleSim/Sim/Animals/MemoryEntry.cs @@ -0,0 +1,13 @@ +using Microsoft.Xna.Framework; + +namespace LittleSim.Sim; + +/// Запись памяти: что, где (воспринятая позиция — грубая, если по запаху), насколько уверенно +/// (падает со временем — забывание), сколько дней назад. +public struct MemoryEntry +{ + public MemoryKind Kind; + public Vector2 Pos; + public float Confidence; + public float AgeDays; +} diff --git a/src/LittleSim/Sim/Animals/MemoryKind.cs b/src/LittleSim/Sim/Animals/MemoryKind.cs new file mode 100644 index 0000000..d3c6d32 --- /dev/null +++ b/src/LittleSim/Sim/Animals/MemoryKind.cs @@ -0,0 +1,10 @@ +namespace LittleSim.Sim; + +/// Вид запомненного: угроза (избегать), корм/вода/партнёр (идти) — расширяемо. +public enum MemoryKind : byte +{ + Threat, + Food, + Water, + Mate, +} diff --git a/src/LittleSim/Sim/Animals/Mood.cs b/src/LittleSim/Sim/Animals/Mood.cs new file mode 100644 index 0000000..164922e --- /dev/null +++ b/src/LittleSim/Sim/Animals/Mood.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using Friflo.Engine.ECS; + +namespace LittleSim.Sim; + +/// +/// Настроение особи (фаза A8): значение [0,1] и активные событийные мысли. Считается +/// из непрерывного фона (нужды/боль) и суммы мыслей. Есть только у +/// тира 4+ ( по размеру мозга) — у примитивных настроения нет. Низкое настроение +/// влияет на поведение (стресс не даёт спариваться) и видно в инспекторе. Список мыслей — managed-ссылка +/// (как AnimalNeeds.Values): события добавляют мысли через . +/// +public struct Mood : IComponent +{ + /// Текущее настроение [0,1]; у неактивного (тир < 4) держится нейтральным. + public float Value; + + /// Есть ли у особи настроение (гейт тиром мозга) — иначе фон/мысли не считаются. + public bool Active; + + /// Активные событийные мысли (затухают со временем). + public List Thoughts; + + /// + /// Вешает событийную мысль (или обновляет её длительность, если уже висит — без накопления + /// дубликатов). Вызывается из систем-источников событий (роды, спаривание, утоление жажды). + /// + public readonly void Add(Content.ThoughtDef? def) + { + if (Thoughts is null || def is null) + { + return; + } + + for (var i = 0; i < Thoughts.Count; i++) + { + if (Thoughts[i].Def == def) + { + var refreshed = Thoughts[i]; + refreshed.RemainingDays = def.DurationDays; + Thoughts[i] = refreshed; + return; + } + } + + Thoughts.Add(new ThoughtInstance { Def = def, RemainingDays = def.DurationDays }); + } +} diff --git a/src/LittleSim/Sim/Animals/PartInstance.cs b/src/LittleSim/Sim/Animals/PartInstance.cs new file mode 100644 index 0000000..ed7b5fc --- /dev/null +++ b/src/LittleSim/Sim/Animals/PartInstance.cs @@ -0,0 +1,19 @@ +using LittleSim.Content; + +namespace LittleSim.Sim; + +/// Инстанс части тела особи: ссылка на деф части и текущий/максимальный HP (масштаб по размеру). +public struct PartInstance +{ + /// Деф части тела (анатомия вида). + public Content.BodyPartDef Def; + + /// Текущий HP. + public float Hp; + + /// Максимальный HP (база × размер тела). + public float MaxHp; + + /// Доля сохранности [0,1]. + public readonly float Fraction => MaxHp > 0f ? Math.Clamp(Hp / MaxHp, 0f, 1f) : 0f; +} diff --git a/src/LittleSim/Sim/Animals/Pregnant.cs b/src/LittleSim/Sim/Animals/Pregnant.cs new file mode 100644 index 0000000..2d90186 --- /dev/null +++ b/src/LittleSim/Sim/Animals/Pregnant.cs @@ -0,0 +1,21 @@ +using Friflo.Engine.ECS; +using MrGameEng.Genetics; + +namespace LittleSim.Sim; + +/// +/// Беременность самки (фаза A4): managed-ссылка на геном отца («слепок» на момент зачатия), остаток +/// срока вынашивания и размер помёта. По истечении срока +/// рожает детёнышей через и снимает компонент. +/// +public struct Pregnant : IComponent +{ + /// Геном отца на момент зачатия. + public Genome FatherGenome; + + /// Остаток срока вынашивания (игровых дней). + public float DueInDays; + + /// Сколько детёнышей родится. + public int Litter; +} diff --git a/src/LittleSim/Sim/Animals/SeedDispersalSystem.cs b/src/LittleSim/Sim/Animals/SeedDispersalSystem.cs new file mode 100644 index 0000000..f186e2c --- /dev/null +++ b/src/LittleSim/Sim/Animals/SeedDispersalSystem.cs @@ -0,0 +1,109 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Разнос семян (эндозоохория): тикает таймер проглоченного семени (); по +/// истечении высаживает росток того вида растения там, где сейчас зверь (если клетка — суша), +/// геномом-клоном съеденного растения, и снимает компонент. Делает травоядных разносчиками флоры — +/// растения расселяются вдоль кормовых маршрутов. +/// +public sealed class SeedDispersalSystem : BaseSystem +{ + private readonly EntityStore _store; + private readonly PlantSet _plants; + private readonly GameClock _clock; + private readonly float _secondsPerDay; + private readonly int _width; + private readonly int _height; + private readonly int _cellSize; + private readonly float[] _cellFertility; + private readonly bool[] _cellLand; + private readonly ArchetypeQuery _query; + private readonly List _dropped = []; + + public SeedDispersalSystem( + EntityStore store, + PlantSet plants, + GameClock clock, + float secondsPerDay, + int width, + int height, + int cellSize, + float[] cellFertility, + bool[] cellLand + ) + { + _store = store; + _plants = plants; + _clock = clock; + _secondsPerDay = secondsPerDay; + _width = width; + _height = height; + _cellSize = cellSize; + _cellFertility = cellFertility; + _cellLand = cellLand; + _query = store.Query(); + } + + protected override void OnUpdateGroup() + { + var days = _clock.DeltaTime / _secondsPerDay; + if (days <= 0f) + { + return; + } + + _dropped.Clear(); + foreach (var (seeds, transforms, entities) in _query.Chunks) + { + var s = seeds.Span; + var t = transforms.Span; + for (var i = 0; i < s.Length; i++) + { + ref var seed = ref s[i]; + seed.DropInDays -= days; + if (seed.DropInDays > 0f) + { + continue; + } + + _dropped.Add(entities.EntityAt(i)); + var pos = t[i].Position; + var cx = Math.Clamp((int)(pos.X / _cellSize), 0, _width - 1); + var cy = Math.Clamp((int)(pos.Y / _cellSize), 0, _height - 1); + var cell = cy * _width + cx; + if (!_cellLand[cell]) + { + continue; // упало в воду — семя пропадает + } + + PlantFactory.Create( + _store, + _plants, + seed.PlantSpecies, + pos, + ageDays: 0f, + seed.Genome, + _cellFertility[cell], + _cellSize + ); + } + } + + foreach (var eater in _dropped) + { + if (!eater.IsNull && eater.HasComponent()) + { + eater.RemoveComponent(); + } + } + } +} diff --git a/src/LittleSim/Sim/Animals/Skills.cs b/src/LittleSim/Sim/Animals/Skills.cs new file mode 100644 index 0000000..296c1da --- /dev/null +++ b/src/LittleSim/Sim/Animals/Skills.cs @@ -0,0 +1,17 @@ +using Friflo.Engine.ECS; + +namespace LittleSim.Sim; + +/// +/// Навыки особи (как нужды/способности — managed-массивы по индексам ): +/// уровни [0..1] (растут от практики, угасают без неё) и страсть к каждому навыку (скорость обучения). +/// Есть у животных; те же навыки получат будущие люди. Добавление навыка не меняет структуру. +/// +public struct Skills : IComponent +{ + /// Уровни навыков [0..1] по индексам . + public float[] Levels; + + /// Страсть к каждому навыку (0 нет / 1 интерес / 2 страсть) — множитель обучения. + public byte[] Passions; +} diff --git a/src/LittleSim/Sim/Animals/ThoughtInstance.cs b/src/LittleSim/Sim/Animals/ThoughtInstance.cs new file mode 100644 index 0000000..65911f5 --- /dev/null +++ b/src/LittleSim/Sim/Animals/ThoughtInstance.cs @@ -0,0 +1,11 @@ +namespace LittleSim.Sim; + +/// Инстанс мысли настроения на особи (фаза A8): деф события + остаток времени до угасания. +public struct ThoughtInstance +{ + /// Деф мысли (сдвиг настроения и длительность). + public Content.ThoughtDef Def; + + /// Сколько игровых дней мысль ещё действует. + public float RemainingDays; +} diff --git a/src/LittleSim/Sim/CapacityCalc.cs b/src/LittleSim/Sim/CapacityCalc.cs index 3dba29f..d9797af 100644 --- a/src/LittleSim/Sim/CapacityCalc.cs +++ b/src/LittleSim/Sim/CapacityCalc.cs @@ -2,40 +2,6 @@ using System.Collections.Generic; namespace LittleSim.Sim; -/// Идентификаторы способностей (capacities) организма — на них ссылаются вклады частей тела. -public static class AnimalCapacities -{ - public const string Consciousness = "Consciousness"; - public const string Moving = "Moving"; - public const string Breathing = "Breathing"; - public const string BloodPumping = "BloodPumping"; - public const string BloodFiltration = "BloodFiltration"; - public const string Sight = "Sight"; - public const string Hearing = "Hearing"; - public const string Smell = "Smell"; - public const string Touch = "Touch"; - public const string Talking = "Talking"; - public const string Eating = "Eating"; - public const string Digestion = "Digestion"; - - /// Порядок отображения способностей в UI (сверху вниз). Прочие модовые — после, как есть. - public static readonly string[] DisplayOrder = - [ - Consciousness, - Moving, - Sight, - Hearing, - Smell, - Touch, - Talking, - Eating, - Breathing, - BloodPumping, - BloodFiltration, - Digestion, - ]; -} - /// /// Обобщённый расчёт способностей (capacities) из частей тела и крови (фаза A5). Сырой вклад способности /// = Σ(доля части × доля её HP). Затем известные способности домножаются по зависимостям (кровь → diff --git a/src/LittleSim/Sim/EventCategory.cs b/src/LittleSim/Sim/EventCategory.cs new file mode 100644 index 0000000..c928ff5 --- /dev/null +++ b/src/LittleSim/Sim/EventCategory.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using Friflo.Engine.ECS; +using LittleSim.Content; +using MrGameEng.Core; +using MrGameEng.Mods; + +namespace LittleSim.Sim; + +/// Категория события — определяет цвет строки в журнале/истории и группировку. +public enum EventCategory : byte +{ + /// Размножение: спаривание, беременность, роды, вылупление. + Reproduction, + + /// Охота и бой: начало охоты, убийство добычи. + Combat, + + /// Смерть: голод/жажда, старость, болезнь, хищник. + Death, + + /// Бытовое: кормёжка, водопой, бегство, смена стадии (только в истории особи). + Routine, +} diff --git a/src/LittleSim/Sim/Fruiting.cs b/src/LittleSim/Sim/Fruiting.cs new file mode 100644 index 0000000..2360661 --- /dev/null +++ b/src/LittleSim/Sim/Fruiting.cs @@ -0,0 +1,14 @@ +using Friflo.Engine.ECS; + +namespace LittleSim.Sim; + +/// +/// Плодоношение растения: сколько зрелых плодов сейчас на нём. Копится у зрелого растения в его +/// сезон плодоношения (признак ) до потолка +/// , вне сезона опадает. Управляется PlantFruitingSystem. +/// +public struct Fruiting : IComponent +{ + /// Накоплено зрелых плодов (0..FruitYield). + public float RipeFruit; +} diff --git a/src/LittleSim/Sim/GodCameraSystem.cs b/src/LittleSim/Sim/GodCameraSystem.cs new file mode 100644 index 0000000..554c324 --- /dev/null +++ b/src/LittleSim/Sim/GodCameraSystem.cs @@ -0,0 +1,71 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; +using MrGameEng.Graphics; +using MrGameEng.Input; + +namespace LittleSim.Sim; + +/// +/// Камера бога: WASD/стрелки — панорамирование, колесо — зум к курсору (мировая точка под мышью +/// остаётся под мышью). Молчит, пока открыта консоль или активен +/// (например, открыто меню-пауза). +/// +public sealed class GodCameraSystem( + Entity cameraEntity, + InputManager input, + Renderer2D renderer, + MrGameEng.DevConsole.DevConsole? console, + Func? blocked = null +) : BaseSystem +{ + protected override void OnUpdateGroup() + { + if (console?.IsOpen == true || blocked?.Invoke() == true) + { + return; + } + + ref var camera = ref cameraEntity.GetComponent(); + + var pan = new Vector2( + Axis(input, Keys.A, Keys.Left, Keys.D, Keys.Right), + Axis(input, Keys.W, Keys.Up, Keys.S, Keys.Down) + ); + if (pan != Vector2.Zero) + { + pan.Normalize(); + camera.Position += pan * (500f / camera.Zoom) * Tick.deltaTime; + } + + if (input.WheelDelta != 0) + { + var oldZoom = camera.Zoom; + var newZoom = Math.Clamp(oldZoom * (1f + input.WheelDelta * 0.001f), 0.4f, 8f); + if (newZoom != oldZoom) + { + // Точка мира под курсором до зума; после — сдвигаем камеру, чтобы она осталась там же. + // Якорь и базовая позиция берутся из одного кадра камеры: WorldCenter — это позиция + // ПОСЛЕ клампа к границам (то, что реально нарисовано), а не сырой camera.Position — + // иначе у краёв карты/при широком обзоре зум «уезжает» от курсора. + var anchor = renderer.ScreenToWorld( + new Vector2(input.MousePosition.X, input.MousePosition.Y) + ); + var effective = renderer.Camera.WorldCenter; + camera.Zoom = newZoom; + camera.Position = anchor - (anchor - effective) * (oldZoom / newZoom); + } + } + } + + 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); +} diff --git a/src/LittleSim/Sim/HudSystem.cs b/src/LittleSim/Sim/HudSystem.cs new file mode 100644 index 0000000..d39dcdc --- /dev/null +++ b/src/LittleSim/Sim/HudSystem.cs @@ -0,0 +1,63 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; +using MrGameEng.Graphics; +using MrGameEng.Input; + +namespace LittleSim.Sim; + +/// +/// Строка даты/времени/сезона в HUD (верх-слева). Обновляется ~4 раза в секунду; смена языка +/// подхватывается сама. Сид и FPS убраны (сид виден в меню, FPS — в окне производительности), +/// подсказки управления вынесены отдельной строкой сцены. +/// +public sealed class HudSystem( + MrGameEng.Core.EngineContext context, + MrGameEng.Mods.LanguageManager languages, + Myra.Graphics2D.UI.Label label, + MrGameEng.Core.Calendar? calendar = null, + MrGameEng.Core.Climate? climate = null +) : BaseSystem +{ + // Ключи локализации сезонов предсобраны (Spring..Winter), чтобы не строить строку + // "season."+ToString().ToLowerInvariant() на каждое обновление HUD. + private static readonly string[] SeasonKeys = BuildSeasonKeys(); + + private float _accumulated; + + private static string[] BuildSeasonKeys() + { + var names = Enum.GetNames(); + var keys = new string[names.Length]; + for (var i = 0; i < names.Length; i++) + { + keys[i] = "season." + names[i].ToLowerInvariant(); + } + + return keys; + } + + protected override void OnUpdateGroup() + { + _accumulated += context.Clock.UnscaledDeltaTime; + if (_accumulated < 0.25f) + { + return; + } + + _accumulated = 0f; + var text = calendar is null + ? "" + : languages.Format("hud.datetime", calendar.Day, calendar.Hour, calendar.Minute); + if (climate is not null) + { + var season = languages.Get(SeasonKeys[(int)climate.Season]); + text += + " · " + + languages.Format("hud.climate", season, (int)MathF.Round(climate.Temperature)); + } + + label.Text = text; + } +} diff --git a/src/LittleSim/Sim/LogEntry.cs b/src/LittleSim/Sim/LogEntry.cs new file mode 100644 index 0000000..3636c8d --- /dev/null +++ b/src/LittleSim/Sim/LogEntry.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using Friflo.Engine.ECS; +using LittleSim.Content; +using MrGameEng.Core; +using MrGameEng.Mods; + +namespace LittleSim.Sim; + +/// +/// Одна запись журнала: готовая (локализованная) строка, категория, отметка времени календаря и id +/// «ответственной» сущности (для перехода камерой/выбора по двойному клику); -1 — не к кому переходить +/// (напр. смерть — особь исчезла). +/// +public readonly record struct LogEntry( + string Text, + EventCategory Category, + int Day, + int Hour, + int Minute, + int SubjectId +); diff --git a/src/LittleSim/Sim/PawnAction.cs b/src/LittleSim/Sim/PawnAction.cs new file mode 100644 index 0000000..0e06f70 --- /dev/null +++ b/src/LittleSim/Sim/PawnAction.cs @@ -0,0 +1,17 @@ +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, +} diff --git a/src/LittleSim/Sim/PawnAppearanceSystem.cs b/src/LittleSim/Sim/PawnAppearanceSystem.cs new file mode 100644 index 0000000..a55c147 --- /dev/null +++ b/src/LittleSim/Sim/PawnAppearanceSystem.cs @@ -0,0 +1,31 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Презентация состояния ИИ: уставший житель темнеет (альфа падает с энергией). Система читает +/// симуляцию и пишет только в — никаких 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; + } + } + } +} diff --git a/src/LittleSim/Sim/PawnBrain.cs b/src/LittleSim/Sim/PawnBrain.cs index 95ec032..66d581b 100644 --- a/src/LittleSim/Sim/PawnBrain.cs +++ b/src/LittleSim/Sim/PawnBrain.cs @@ -6,26 +6,6 @@ using MrGameEng.Graphics; namespace LittleSim.Sim; -/// Что житель делает прямо сейчас — результат utility-выбора. -public enum PawnAction -{ - /// Бродит по миру, тратя энергию. - Wander, - - /// Стоит и восстанавливает энергию. - Rest, -} - -/// -/// Потребности жителя. Пока одна — энергия (0 — без сил, 1 — полон сил); со временем -/// расходуется при блуждании и восстанавливается на отдыхе. Голод/жажда добавятся так же. -/// -public struct PawnNeeds : IComponent -{ - /// Запас сил в диапазоне [0, 1]. - public float Energy; -} - /// /// «Мозг» жителя: выбранное действие и таймер до следующего пересмотра решения. /// Решение принимает через движковый . @@ -38,124 +18,3 @@ public struct PawnBrain : IComponent /// Секунды до следующего пересмотра решения — не пересчитываем выбор каждый кадр. 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/PawnContext.cs b/src/LittleSim/Sim/PawnContext.cs new file mode 100644 index 0000000..73d557e --- /dev/null +++ b/src/LittleSim/Sim/PawnContext.cs @@ -0,0 +1,10 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// Снимок состояния жителя, который читают соображения utility-выбора. +public readonly record struct PawnContext(float Energy); diff --git a/src/LittleSim/Sim/PawnDecisionSystem.cs b/src/LittleSim/Sim/PawnDecisionSystem.cs new file mode 100644 index 0000000..52d2021 --- /dev/null +++ b/src/LittleSim/Sim/PawnDecisionSystem.cs @@ -0,0 +1,72 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// 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; + } +} diff --git a/src/LittleSim/Sim/PawnId.cs b/src/LittleSim/Sim/PawnId.cs new file mode 100644 index 0000000..9d20b86 --- /dev/null +++ b/src/LittleSim/Sim/PawnId.cs @@ -0,0 +1,14 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; +using MrGameEng.Graphics; +using MrGameEng.Input; + +namespace LittleSim.Sim; + +/// Имя дефа жителя — нужно сохранению, чтобы при загрузке пересобрать спрайт из атласа. +public struct PawnId : IComponent +{ + public string DefName; +} diff --git a/src/LittleSim/Sim/PawnNeeds.cs b/src/LittleSim/Sim/PawnNeeds.cs new file mode 100644 index 0000000..33d9e7f --- /dev/null +++ b/src/LittleSim/Sim/PawnNeeds.cs @@ -0,0 +1,17 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Потребности жителя. Пока одна — энергия (0 — без сил, 1 — полон сил); со временем +/// расходуется при блуждании и восстанавливается на отдыхе. Голод/жажда добавятся так же. +/// +public struct PawnNeeds : IComponent +{ + /// Запас сил в диапазоне [0, 1]. + public float Energy; +} diff --git a/src/LittleSim/Sim/PawnNeedsSystem.cs b/src/LittleSim/Sim/PawnNeedsSystem.cs new file mode 100644 index 0000000..e346fe4 --- /dev/null +++ b/src/LittleSim/Sim/PawnNeedsSystem.cs @@ -0,0 +1,36 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Расход и восстановление энергии по текущему действию: блуждающий устаёт, отдыхающий +/// восстанавливается. Скорости подобраны так, что отдых короткий, а блуждание долгое. +/// +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/PlantOrganism.cs b/src/LittleSim/Sim/PlantOrganism.cs index 8860ce4..bcd97b6 100644 --- a/src/LittleSim/Sim/PlantOrganism.cs +++ b/src/LittleSim/Sim/PlantOrganism.cs @@ -1,108 +1,8 @@ -using System.Collections.Generic; using Friflo.Engine.ECS; using MrGameEng.Genetics; namespace LittleSim.Sim; -/// -/// Растительные признаки (фенотип), вычисленные из генома формулами генов (см. Defs/Genes/): -/// готовые значения, которые читают системы роста и жизненного цикла, не касаясь генов напрямую. -/// Имена полей соответствуют именам признаков в эффектах генов. -/// -public struct PlantPhenotype -{ - public float OptimalLight; - public float LightTolerance; - public float OptimalTemperature; - public float TemperatureTolerance; - public float ColdHardiness; - public float HeatHardiness; - public float OptimalFertility; - public float FertilityTolerance; - public float Vigor; - public float Lifespan; - public float DispersalRange; - public float ReproduceInterval; - public float SelfPollination; - public float MutationRate; - - // --- Контент (G4): плодоношение, добыча, цвет --- - public float FruitYield; - public int FruitSeason; - public float HarvestAmount; - public float LeafHue; - - // --- Защита (коэволюция с травоядными) --- - /// Токсичность [0..1]: отравляет поедателя; цена — замедление роста. - public float Toxicity; - - /// Шипы [0..1]: травмируют поедателя; цена — замедление роста. - public float Thorns; - - /// Вкусность [0..1]: обратная привлекательность для умных травоядных (избегание в C2). - public float Palatability; - - /// Морфа: рецессивный вариант (выраженное значение гена морфы ≈ 1) — другой тинт спрайта. - public bool IsVariant; - - /// - /// Четыре точки температурной трапеции роста (модель RimWorld): жёсткий минимум, нижний край плато, - /// верхний край плато, жёсткий максимум. Плато — оптимум ± толерантность; жёсткие края отстоят от - /// плато на морозо-/жаростойкость. Кормит . - /// - public readonly (float Min, float OptimalLow, float OptimalHigh, float Max) TemperatureBand() - { - var low = OptimalTemperature - TemperatureTolerance; - var high = OptimalTemperature + TemperatureTolerance; - return (low - ColdHardiness, low, high, high + HeatHardiness); - } - - /// Собирает фенотип из карты признаков, посчитанной . - public static PlantPhenotype FromTraits(IReadOnlyDictionary traits) - { - float T(string name) => traits.GetValueOrDefault(name); - return new PlantPhenotype - { - OptimalLight = T("optimalLight"), - LightTolerance = T("lightTolerance"), - OptimalTemperature = T("optimalTemperature"), - TemperatureTolerance = T("temperatureTolerance"), - ColdHardiness = T("coldHardiness"), - HeatHardiness = T("heatHardiness"), - OptimalFertility = T("optimalFertility"), - FertilityTolerance = T("fertilityTolerance"), - Vigor = T("vigor"), - Lifespan = T("lifespan"), - DispersalRange = T("dispersalRange"), - ReproduceInterval = T("reproduceInterval"), - SelfPollination = T("selfPollination"), - MutationRate = T("mutationRate"), - FruitYield = T("fruitYield"), - FruitSeason = (int)MathF.Round(Math.Clamp(T("fruitSeason"), 0f, 3f)), - HarvestAmount = T("harvestAmount"), - LeafHue = T("leafHue"), - Toxicity = T("toxicity"), - Thorns = T("thorns"), - Palatability = T("palatability"), - IsVariant = T("variant") >= 0.5f, - }; - } - - // Цена защиты: токсичность и шипы отнимают ресурсы у роста. Без этой платы отбор гнал бы защиту к - // максимуму у всех растений и коэволюция бы встала — а так под слабым выпасом выгоднее расти быстрее. - private const float ToxicityGrowthCost = 0.4f; - private const float ThornsGrowthCost = 0.3f; - private const float MinGrowthFactor = 0.2f; - - /// Множитель скорости роста с учётом цены защиты (1 — без защиты, ≥ MinGrowthFactor). - public readonly float DefenseGrowthFactor() => - Math.Clamp( - 1f - ToxicityGrowthCost * Toxicity - ThornsGrowthCost * Thorns, - MinGrowthFactor, - 1f - ); -} - /// /// Компонент-организм растения: его управляемый (для размножения, сейва и /// пересчёта) и выраженный фенотип , по которому работают системы. Заменяет @@ -117,26 +17,3 @@ public struct PlantOrganism : IComponent /// Выраженные признаки — то, что читают системы роста/жизненного цикла. public PlantPhenotype Traits; } - -/// -/// Плодоношение растения: сколько зрелых плодов сейчас на нём. Копится у зрелого растения в его -/// сезон плодоношения (признак ) до потолка -/// , вне сезона опадает. Управляется PlantFruitingSystem. -/// -public struct Fruiting : IComponent -{ - /// Накоплено зрелых плодов (0..FruitYield). - public float RipeFruit; -} - -/// -/// Накопленный температурный стресс растения в игровых днях. Пока температура вне диапазона роста -/// (трапеция даёт ноль — слишком холодно или жарко), -/// стресс копится; внутри диапазона — рассасывается. Превышение порога убивает растение -/// (вымерзание/перегрев). Управляется PlantLifecycleSystem. -/// -public struct TemperatureStress : IComponent -{ - /// Накоплено дней дормантности вне диапазона роста; при превышении лимита — гибель. - public float Days; -} diff --git a/src/LittleSim/Sim/PlantPhenotype.cs b/src/LittleSim/Sim/PlantPhenotype.cs new file mode 100644 index 0000000..bdde37b --- /dev/null +++ b/src/LittleSim/Sim/PlantPhenotype.cs @@ -0,0 +1,102 @@ +using System.Collections.Generic; + +namespace LittleSim.Sim; + +/// +/// Растительные признаки (фенотип), вычисленные из генома формулами генов (см. Defs/Genes/): +/// готовые значения, которые читают системы роста и жизненного цикла, не касаясь генов напрямую. +/// Имена полей соответствуют именам признаков в эффектах генов. +/// +public struct PlantPhenotype +{ + public float OptimalLight; + public float LightTolerance; + public float OptimalTemperature; + public float TemperatureTolerance; + public float ColdHardiness; + public float HeatHardiness; + public float OptimalFertility; + public float FertilityTolerance; + public float Vigor; + public float Lifespan; + public float DispersalRange; + public float ReproduceInterval; + public float SelfPollination; + public float MutationRate; + + // --- Контент (G4): плодоношение, добыча, цвет --- + public float FruitYield; + public int FruitSeason; + public float HarvestAmount; + public float LeafHue; + + // --- Защита (коэволюция с травоядными) --- + /// Токсичность [0..1]: отравляет поедателя; цена — замедление роста. + public float Toxicity; + + /// Шипы [0..1]: травмируют поедателя; цена — замедление роста. + public float Thorns; + + /// Вкусность [0..1]: обратная привлекательность для умных травоядных (избегание в C2). + public float Palatability; + + /// Морфа: рецессивный вариант (выраженное значение гена морфы ≈ 1) — другой тинт спрайта. + public bool IsVariant; + + /// + /// Четыре точки температурной трапеции роста (модель RimWorld): жёсткий минимум, нижний край плато, + /// верхний край плато, жёсткий максимум. Плато — оптимум ± толерантность; жёсткие края отстоят от + /// плато на морозо-/жаростойкость. Кормит . + /// + public readonly (float Min, float OptimalLow, float OptimalHigh, float Max) TemperatureBand() + { + var low = OptimalTemperature - TemperatureTolerance; + var high = OptimalTemperature + TemperatureTolerance; + return (low - ColdHardiness, low, high, high + HeatHardiness); + } + + /// Собирает фенотип из карты признаков, посчитанной . + public static PlantPhenotype FromTraits(IReadOnlyDictionary traits) + { + float T(string name) => traits.GetValueOrDefault(name); + return new PlantPhenotype + { + OptimalLight = T("optimalLight"), + LightTolerance = T("lightTolerance"), + OptimalTemperature = T("optimalTemperature"), + TemperatureTolerance = T("temperatureTolerance"), + ColdHardiness = T("coldHardiness"), + HeatHardiness = T("heatHardiness"), + OptimalFertility = T("optimalFertility"), + FertilityTolerance = T("fertilityTolerance"), + Vigor = T("vigor"), + Lifespan = T("lifespan"), + DispersalRange = T("dispersalRange"), + ReproduceInterval = T("reproduceInterval"), + SelfPollination = T("selfPollination"), + MutationRate = T("mutationRate"), + FruitYield = T("fruitYield"), + FruitSeason = (int)MathF.Round(Math.Clamp(T("fruitSeason"), 0f, 3f)), + HarvestAmount = T("harvestAmount"), + LeafHue = T("leafHue"), + Toxicity = T("toxicity"), + Thorns = T("thorns"), + Palatability = T("palatability"), + IsVariant = T("variant") >= 0.5f, + }; + } + + // Цена защиты: токсичность и шипы отнимают ресурсы у роста. Без этой платы отбор гнал бы защиту к + // максимуму у всех растений и коэволюция бы встала — а так под слабым выпасом выгоднее расти быстрее. + private const float ToxicityGrowthCost = 0.4f; + private const float ThornsGrowthCost = 0.3f; + private const float MinGrowthFactor = 0.2f; + + /// Множитель скорости роста с учётом цены защиты (1 — без защиты, ≥ MinGrowthFactor). + public readonly float DefenseGrowthFactor() => + Math.Clamp( + 1f - ToxicityGrowthCost * Toxicity - ThornsGrowthCost * Thorns, + MinGrowthFactor, + 1f + ); +} diff --git a/src/LittleSim/Sim/Selection.cs b/src/LittleSim/Sim/Selection.cs index 46e4602..cfcb760 100644 --- a/src/LittleSim/Sim/Selection.cs +++ b/src/LittleSim/Sim/Selection.cs @@ -28,109 +28,3 @@ public sealed class Selection /// Снять выделение. public void Clear() => EntityId = -1; } - -/// -/// Пикинг мышью: ЛКМ выбирает верхнюю сущность под курсором (ближайшую по ограничивающей окружности -/// спрайта), клик по пустому месте или ПКМ снимает выделение. Молчит, пока ввод захвачен оверлеем -/// (консоль/инспектор) или активен (меню-пауза), и игнорирует клики над -/// UI-панелями (). Выбор работает и на паузе — мир можно осматривать стоя. -/// -public sealed class SelectionSystem( - EntityStore store, - InputManager input, - Renderer2D renderer, - Selection selection, - Func blocked, - Func overUi -) : BaseSystem -{ - protected override void OnUpdateGroup() - { - if (blocked() || overUi()) - { - return; // меню-пауза/консоль или клик пришёлся на виджет UI — это не пикинг мира - } - - if (input.IsMousePressed(MouseButton.Right)) - { - selection.Clear(); - return; - } - - if (!input.IsMousePressed(MouseButton.Left)) - { - return; - } - - var mouse = input.MousePosition; - - var world = renderer.ScreenToWorld(new Vector2(mouse.X, mouse.Y)); - if (TryPick(world, out var id)) - { - selection.Select(id); - } - else - { - selection.Clear(); // клик по пустой земле снимает выделение - } - } - - // Ближайший осматриваемый объект (растение или животное), чья ограничивающая окружность спрайта - // накрывает точку мира. Запросы ограничены PlantGrowth/AnimalOrganism — так из пикинга выпадают - // спрайты рамки выделения, трупы и прочий не-контент. Из двух кандидатов берётся более близкий. - private bool TryPick(Vector2 world, out int id) - { - id = -1; - var best = float.MaxValue; - foreach ( - var (transforms, sprites, _, entities) in store - .Query() - .Chunks - ) - { - Scan(world, transforms.Span, sprites.Span, entities, ref id, ref best); - } - - foreach ( - var (transforms, sprites, _, entities) in store - .Query() - .Chunks - ) - { - Scan(world, transforms.Span, sprites.Span, entities, ref id, ref best); - } - - return id >= 0; - } - - // Обновляет лучшего кандидата (id/дистанцию) по чанку спрайтов: точка должна попасть в окружность. - private static void Scan( - Vector2 world, - Span transforms, - Span sprites, - ChunkEntities entities, - ref int id, - ref float best - ) - { - for (var i = 0; i < transforms.Length; i++) - { - if (sprites[i].Region is not { } region) - { - continue; - } - - var (center, radius) = CullingMath.SpriteBoundingCircle( - in transforms[i], - region, - sprites[i].Origin - ); - var distance = Vector2.DistanceSquared(center, world); - if (distance <= radius * radius && distance < best) - { - best = distance; - id = entities.EntityAt(i).Id; - } - } - } -} diff --git a/src/LittleSim/Sim/SelectionSystem.cs b/src/LittleSim/Sim/SelectionSystem.cs new file mode 100644 index 0000000..be1b691 --- /dev/null +++ b/src/LittleSim/Sim/SelectionSystem.cs @@ -0,0 +1,114 @@ +using System; +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using MrGameEng.Graphics; +using MrGameEng.Input; + +namespace LittleSim.Sim; + +/// +/// Пикинг мышью: ЛКМ выбирает верхнюю сущность под курсором (ближайшую по ограничивающей окружности +/// спрайта), клик по пустому месте или ПКМ снимает выделение. Молчит, пока ввод захвачен оверлеем +/// (консоль/инспектор) или активен (меню-пауза), и игнорирует клики над +/// UI-панелями (). Выбор работает и на паузе — мир можно осматривать стоя. +/// +public sealed class SelectionSystem( + EntityStore store, + InputManager input, + Renderer2D renderer, + Selection selection, + Func blocked, + Func overUi +) : BaseSystem +{ + protected override void OnUpdateGroup() + { + if (blocked() || overUi()) + { + return; // меню-пауза/консоль или клик пришёлся на виджет UI — это не пикинг мира + } + + if (input.IsMousePressed(MouseButton.Right)) + { + selection.Clear(); + return; + } + + if (!input.IsMousePressed(MouseButton.Left)) + { + return; + } + + var mouse = input.MousePosition; + + var world = renderer.ScreenToWorld(new Vector2(mouse.X, mouse.Y)); + if (TryPick(world, out var id)) + { + selection.Select(id); + } + else + { + selection.Clear(); // клик по пустой земле снимает выделение + } + } + + // Ближайший осматриваемый объект (растение или животное), чья ограничивающая окружность спрайта + // накрывает точку мира. Запросы ограничены PlantGrowth/AnimalOrganism — так из пикинга выпадают + // спрайты рамки выделения, трупы и прочий не-контент. Из двух кандидатов берётся более близкий. + private bool TryPick(Vector2 world, out int id) + { + id = -1; + var best = float.MaxValue; + foreach ( + var (transforms, sprites, _, entities) in store + .Query() + .Chunks + ) + { + Scan(world, transforms.Span, sprites.Span, entities, ref id, ref best); + } + + foreach ( + var (transforms, sprites, _, entities) in store + .Query() + .Chunks + ) + { + Scan(world, transforms.Span, sprites.Span, entities, ref id, ref best); + } + + return id >= 0; + } + + // Обновляет лучшего кандидата (id/дистанцию) по чанку спрайтов: точка должна попасть в окружность. + private static void Scan( + Vector2 world, + Span transforms, + Span sprites, + ChunkEntities entities, + ref int id, + ref float best + ) + { + for (var i = 0; i < transforms.Length; i++) + { + if (sprites[i].Region is not { } region) + { + continue; + } + + var (center, radius) = CullingMath.SpriteBoundingCircle( + in transforms[i], + region, + sprites[i].Origin + ); + var distance = Vector2.DistanceSquared(center, world); + if (distance <= radius * radius && distance < best) + { + best = distance; + id = entities.EntityAt(i).Id; + } + } + } +} diff --git a/src/LittleSim/Sim/SeparationSystem.cs b/src/LittleSim/Sim/SeparationSystem.cs new file mode 100644 index 0000000..d9ee231 --- /dev/null +++ b/src/LittleSim/Sim/SeparationSystem.cs @@ -0,0 +1,59 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; +using MrGameEng.Graphics; +using MrGameEng.Input; + +namespace LittleSim.Sim; + +/// +/// Расталкивание пересёкшихся круговых коллайдеров по парам кадра: животные не +/// наслаиваются друг на друга и выталкиваются из статичных препятствий (деревьев). +/// Статичность определяется отсутствием компонента . +/// Регистрировать после системы коллизий. +/// +public sealed class SeparationSystem(MrGameEng.Collisions.CollisionWorld world) : BaseSystem +{ + protected override void OnUpdateGroup() + { + foreach (var pair in world.Pairs) + { + ref var ta = ref pair.A.GetComponent(); + ref var tb = ref pair.B.GetComponent(); + var sum = + pair.A.GetComponent().Radius + + pair.B.GetComponent().Radius; + + var delta = tb.Position - ta.Position; + var distance = delta.Length(); + var overlap = sum - distance; + if (overlap <= 0f) + { + continue; + } + + var direction = distance > 0.001f ? delta / distance : Vector2.UnitX; + var aStatic = !pair.A.HasComponent(); + var bStatic = !pair.B.HasComponent(); + if (aStatic && bStatic) + { + continue; + } + + if (aStatic) + { + tb.Position += direction * overlap; + } + else if (bStatic) + { + ta.Position -= direction * overlap; + } + else + { + ta.Position -= direction * (overlap * 0.5f); + tb.Position += direction * (overlap * 0.5f); + } + } + } +} diff --git a/src/LittleSim/Sim/SimSystems.cs b/src/LittleSim/Sim/SimSystems.cs deleted file mode 100644 index b62de67..0000000 --- a/src/LittleSim/Sim/SimSystems.cs +++ /dev/null @@ -1,235 +0,0 @@ -using Friflo.Engine.ECS; -using Friflo.Engine.ECS.Systems; -using Microsoft.Xna.Framework; -using Microsoft.Xna.Framework.Input; -using MrGameEng.Graphics; -using MrGameEng.Input; - -namespace LittleSim.Sim; - -/// Житель бродит: направление меняется по таймеру, движение детерминировано сидом. -public struct Wander : IComponent -{ - public Vector2 Direction; - public float ChangeIn; -} - -/// Имя дефа жителя — нужно сохранению, чтобы при загрузке пересобрать спрайт из атласа. -public struct PawnId : IComponent -{ - public string DefName; -} - -/// -/// Блуждание жителей. Один Random на систему, сид от мира — при одинаковом сиде -/// и порядке обновления симуляция воспроизводима. Отдыхающие жители (см. -/// ) стоят на месте — таймер смены направления тоже замирает. -/// -public sealed class WanderSystem(int seed, RectF worldBounds) - : QuerySystem -{ - private const float Speed = 22f; - - private readonly Random _random = new(seed); - - protected override void OnUpdate() - { - var delta = Tick.deltaTime; - 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) - { - var angle = _random.NextSingle() * MathF.Tau; - wander.Direction = new Vector2(MathF.Cos(angle), MathF.Sin(angle)); - wander.ChangeIn = 1f + _random.NextSingle() * 3f; - } - - ref var position = ref t[i].Position; - position += wander.Direction * (Speed * delta); - position.X = Math.Clamp(position.X, worldBounds.Left, worldBounds.Right); - position.Y = Math.Clamp(position.Y, worldBounds.Top, worldBounds.Bottom); - } - } - } -} - -/// -/// Расталкивание пересёкшихся круговых коллайдеров по парам кадра: животные не -/// наслаиваются друг на друга и выталкиваются из статичных препятствий (деревьев). -/// Статичность определяется отсутствием компонента . -/// Регистрировать после системы коллизий. -/// -public sealed class SeparationSystem(MrGameEng.Collisions.CollisionWorld world) : BaseSystem -{ - protected override void OnUpdateGroup() - { - foreach (var pair in world.Pairs) - { - ref var ta = ref pair.A.GetComponent(); - ref var tb = ref pair.B.GetComponent(); - var sum = - pair.A.GetComponent().Radius - + pair.B.GetComponent().Radius; - - var delta = tb.Position - ta.Position; - var distance = delta.Length(); - var overlap = sum - distance; - if (overlap <= 0f) - { - continue; - } - - var direction = distance > 0.001f ? delta / distance : Vector2.UnitX; - var aStatic = !pair.A.HasComponent(); - var bStatic = !pair.B.HasComponent(); - if (aStatic && bStatic) - { - continue; - } - - if (aStatic) - { - tb.Position += direction * overlap; - } - else if (bStatic) - { - ta.Position -= direction * overlap; - } - else - { - ta.Position -= direction * (overlap * 0.5f); - tb.Position += direction * (overlap * 0.5f); - } - } - } -} - -/// -/// Камера бога: WASD/стрелки — панорамирование, колесо — зум к курсору (мировая точка под мышью -/// остаётся под мышью). Молчит, пока открыта консоль или активен -/// (например, открыто меню-пауза). -/// -public sealed class GodCameraSystem( - Entity cameraEntity, - InputManager input, - Renderer2D renderer, - MrGameEng.DevConsole.DevConsole? console, - Func? blocked = null -) : BaseSystem -{ - protected override void OnUpdateGroup() - { - if (console?.IsOpen == true || blocked?.Invoke() == true) - { - return; - } - - ref var camera = ref cameraEntity.GetComponent(); - - var pan = new Vector2( - Axis(input, Keys.A, Keys.Left, Keys.D, Keys.Right), - Axis(input, Keys.W, Keys.Up, Keys.S, Keys.Down) - ); - if (pan != Vector2.Zero) - { - pan.Normalize(); - camera.Position += pan * (500f / camera.Zoom) * Tick.deltaTime; - } - - if (input.WheelDelta != 0) - { - var oldZoom = camera.Zoom; - var newZoom = Math.Clamp(oldZoom * (1f + input.WheelDelta * 0.001f), 0.4f, 8f); - if (newZoom != oldZoom) - { - // Точка мира под курсором до зума; после — сдвигаем камеру, чтобы она осталась там же. - // Якорь и базовая позиция берутся из одного кадра камеры: WorldCenter — это позиция - // ПОСЛЕ клампа к границам (то, что реально нарисовано), а не сырой camera.Position — - // иначе у краёв карты/при широком обзоре зум «уезжает» от курсора. - var anchor = renderer.ScreenToWorld( - new Vector2(input.MousePosition.X, input.MousePosition.Y) - ); - var effective = renderer.Camera.WorldCenter; - camera.Zoom = newZoom; - camera.Position = anchor - (anchor - effective) * (oldZoom / newZoom); - } - } - } - - 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); -} - -/// -/// Строка даты/времени/сезона в HUD (верх-слева). Обновляется ~4 раза в секунду; смена языка -/// подхватывается сама. Сид и FPS убраны (сид виден в меню, FPS — в окне производительности), -/// подсказки управления вынесены отдельной строкой сцены. -/// -public sealed class HudSystem( - MrGameEng.Core.EngineContext context, - MrGameEng.Mods.LanguageManager languages, - Myra.Graphics2D.UI.Label label, - MrGameEng.Core.Calendar? calendar = null, - MrGameEng.Core.Climate? climate = null -) : BaseSystem -{ - // Ключи локализации сезонов предсобраны (Spring..Winter), чтобы не строить строку - // "season."+ToString().ToLowerInvariant() на каждое обновление HUD. - private static readonly string[] SeasonKeys = BuildSeasonKeys(); - - private float _accumulated; - - private static string[] BuildSeasonKeys() - { - var names = Enum.GetNames(); - var keys = new string[names.Length]; - for (var i = 0; i < names.Length; i++) - { - keys[i] = "season." + names[i].ToLowerInvariant(); - } - - return keys; - } - - protected override void OnUpdateGroup() - { - _accumulated += context.Clock.UnscaledDeltaTime; - if (_accumulated < 0.25f) - { - return; - } - - _accumulated = 0f; - var text = calendar is null - ? "" - : languages.Format("hud.datetime", calendar.Day, calendar.Hour, calendar.Minute); - if (climate is not null) - { - var season = languages.Get(SeasonKeys[(int)climate.Season]); - text += - " · " - + languages.Format("hud.climate", season, (int)MathF.Round(climate.Temperature)); - } - - label.Text = text; - } -} diff --git a/src/LittleSim/Sim/TemperatureStress.cs b/src/LittleSim/Sim/TemperatureStress.cs new file mode 100644 index 0000000..6fc2d78 --- /dev/null +++ b/src/LittleSim/Sim/TemperatureStress.cs @@ -0,0 +1,15 @@ +using Friflo.Engine.ECS; + +namespace LittleSim.Sim; + +/// +/// Накопленный температурный стресс растения в игровых днях. Пока температура вне диапазона роста +/// (трапеция даёт ноль — слишком холодно или жарко), +/// стресс копится; внутри диапазона — рассасывается. Превышение порога убивает растение +/// (вымерзание/перегрев). Управляется PlantLifecycleSystem. +/// +public struct TemperatureStress : IComponent +{ + /// Накоплено дней дормантности вне диапазона роста; при превышении лимита — гибель. + public float Days; +} diff --git a/src/LittleSim/Sim/Wander.cs b/src/LittleSim/Sim/Wander.cs new file mode 100644 index 0000000..ce1d239 --- /dev/null +++ b/src/LittleSim/Sim/Wander.cs @@ -0,0 +1,15 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; +using MrGameEng.Graphics; +using MrGameEng.Input; + +namespace LittleSim.Sim; + +/// Житель бродит: направление меняется по таймеру, движение детерминировано сидом. +public struct Wander : IComponent +{ + public Vector2 Direction; + public float ChangeIn; +} diff --git a/src/LittleSim/Sim/WanderSystem.cs b/src/LittleSim/Sim/WanderSystem.cs new file mode 100644 index 0000000..4997379 --- /dev/null +++ b/src/LittleSim/Sim/WanderSystem.cs @@ -0,0 +1,53 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; +using MrGameEng.Graphics; +using MrGameEng.Input; + +namespace LittleSim.Sim; + +/// +/// Блуждание жителей. Один Random на систему, сид от мира — при одинаковом сиде +/// и порядке обновления симуляция воспроизводима. Отдыхающие жители (см. +/// ) стоят на месте — таймер смены направления тоже замирает. +/// +public sealed class WanderSystem(int seed, RectF worldBounds) + : QuerySystem +{ + private const float Speed = 22f; + + private readonly Random _random = new(seed); + + protected override void OnUpdate() + { + var delta = Tick.deltaTime; + 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) + { + var angle = _random.NextSingle() * MathF.Tau; + wander.Direction = new Vector2(MathF.Cos(angle), MathF.Sin(angle)); + wander.ChangeIn = 1f + _random.NextSingle() * 3f; + } + + ref var position = ref t[i].Position; + position += wander.Direction * (Speed * delta); + position.X = Math.Clamp(position.X, worldBounds.Left, worldBounds.Right); + position.Y = Math.Clamp(position.Y, worldBounds.Top, worldBounds.Bottom); + } + } + } +} diff --git a/src/LittleSim/Sim/WorldEvents.cs b/src/LittleSim/Sim/WorldEvents.cs index 9745093..e96062c 100644 --- a/src/LittleSim/Sim/WorldEvents.cs +++ b/src/LittleSim/Sim/WorldEvents.cs @@ -6,36 +6,6 @@ using MrGameEng.Mods; namespace LittleSim.Sim; -/// Категория события — определяет цвет строки в журнале/истории и группировку. -public enum EventCategory : byte -{ - /// Размножение: спаривание, беременность, роды, вылупление. - Reproduction, - - /// Охота и бой: начало охоты, убийство добычи. - Combat, - - /// Смерть: голод/жажда, старость, болезнь, хищник. - Death, - - /// Бытовое: кормёжка, водопой, бегство, смена стадии (только в истории особи). - Routine, -} - -/// -/// Одна запись журнала: готовая (локализованная) строка, категория, отметка времени календаря и id -/// «ответственной» сущности (для перехода камерой/выбора по двойному клику); -1 — не к кому переходить -/// (напр. смерть — особь исчезла). -/// -public readonly record struct LogEntry( - string Text, - EventCategory Category, - int Day, - int Hour, - int Minute, - int SubjectId -); - /// /// Журнал событий мира и фабрика записей истории особей (фича UI «События»/«История»). Системы-источники /// (спаривание/роды/охота/смерть/рост) зовут типизированные методы — те собирают локализованную строку, diff --git a/src/LittleSim/UI/CallbackSystem.cs b/src/LittleSim/UI/CallbackSystem.cs new file mode 100644 index 0000000..ed418fb --- /dev/null +++ b/src/LittleSim/UI/CallbackSystem.cs @@ -0,0 +1,17 @@ +using System; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using Myra.Graphics2D; +using Myra.Graphics2D.Brushes; +using Myra.Graphics2D.UI; + +namespace LittleSim.UI; + +/// +/// Минимальная система, вызывающая колбэк каждый кадр обновления. Сцены меню используют её +/// для опроса ввода (Esc/горячие клавиши) без отдельного класса-системы на каждую сцену. +/// +internal sealed class CallbackSystem(Action update) : BaseSystem +{ + protected override void OnUpdateGroup() => update(); +} diff --git a/src/LittleSim/UI/DevSpawner.cs b/src/LittleSim/UI/DevSpawner.cs index be726a8..032f582 100644 --- a/src/LittleSim/UI/DevSpawner.cs +++ b/src/LittleSim/UI/DevSpawner.cs @@ -255,45 +255,3 @@ public sealed class DevSpawner private string PlantLabel(string defName) => _content.Defs.TryGet(defName, out var def) ? def.Label : defName; } - -/// -/// Управление дев-спавнером: горячая клавиша F9 открывает/закрывает панель; при взведённом спавн- -/// инструменте ЛКМ по миру (не над UI) создаёт сущность в курсоре, ПКМ снимает инструмент. -/// -public sealed class DevSpawnerSystem(Renderer2D renderer, DevSpawner spawner, Func overUi) - : BaseSystem -{ - private bool _prevToggle; - private bool _prevLeft; - private bool _prevRight; - - protected override void OnUpdateGroup() - { - var keyboard = Keyboard.GetState(); - var toggle = keyboard.IsKeyDown(Keys.F9); - if (toggle && !_prevToggle) - { - spawner.Toggle(); - } - - _prevToggle = toggle; - - var mouse = Mouse.GetState(); - var left = mouse.LeftButton == ButtonState.Pressed; - var right = mouse.RightButton == ButtonState.Pressed; - - if (spawner.ArmedSpawn && left && !_prevLeft && !overUi()) - { - var world = renderer.ScreenToWorld(new Vector2(mouse.X, mouse.Y)); - spawner.SpawnAt(world); - } - - if (right && !_prevRight) - { - spawner.Disarm(); - } - - _prevLeft = left; - _prevRight = right; - } -} diff --git a/src/LittleSim/UI/DevSpawnerSystem.cs b/src/LittleSim/UI/DevSpawnerSystem.cs new file mode 100644 index 0000000..8da91e0 --- /dev/null +++ b/src/LittleSim/UI/DevSpawnerSystem.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using LittleSim.Sim; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; +using MrGameEng.Graphics; +using Myra.Graphics2D; +using Myra.Graphics2D.Brushes; +using Myra.Graphics2D.UI; + +namespace LittleSim.UI; + +/// +/// Управление дев-спавнером: горячая клавиша F9 открывает/закрывает панель; при взведённом спавн- +/// инструменте ЛКМ по миру (не над UI) создаёт сущность в курсоре, ПКМ снимает инструмент. +/// +public sealed class DevSpawnerSystem(Renderer2D renderer, DevSpawner spawner, Func overUi) + : BaseSystem +{ + private bool _prevToggle; + private bool _prevLeft; + private bool _prevRight; + + protected override void OnUpdateGroup() + { + var keyboard = Keyboard.GetState(); + var toggle = keyboard.IsKeyDown(Keys.F9); + if (toggle && !_prevToggle) + { + spawner.Toggle(); + } + + _prevToggle = toggle; + + var mouse = Mouse.GetState(); + var left = mouse.LeftButton == ButtonState.Pressed; + var right = mouse.RightButton == ButtonState.Pressed; + + if (spawner.ArmedSpawn && left && !_prevLeft && !overUi()) + { + var world = renderer.ScreenToWorld(new Vector2(mouse.X, mouse.Y)); + spawner.SpawnAt(world); + } + + if (right && !_prevRight) + { + spawner.Disarm(); + } + + _prevLeft = left; + _prevRight = right; + } +} diff --git a/src/LittleSim/UI/InspectPanel.cs b/src/LittleSim/UI/InspectPanel.cs index 7e694de..8ae872e 100644 --- a/src/LittleSim/UI/InspectPanel.cs +++ b/src/LittleSim/UI/InspectPanel.cs @@ -704,62 +704,3 @@ internal sealed class InspectPanel ? _content.Languages.Get(product.Label) : productDefName; } - -/// -/// Полоса-индикатор показателя в стиле RimWorld: тёмная дорожка фиксированной ширины, цветная заливка -/// длиной fraction, поверх — подпись слева и значение справа. Самодостаточный виджет (ничего не -/// обновляет — пересоздаётся при каждой пересборке тела панели), цвет заливки задаёт вызывающий. -/// -internal sealed class StatBar : Panel -{ - private static readonly IBrush Track = new SolidBrush(new Color(28, 34, 44)); - private static readonly IBrush TrackBorder = new SolidBrush(new Color(64, 78, 98)); - private const int BarHeight = 22; - - public StatBar(int width, string name, float fraction, string value, Color fill) - { - Width = width; - Height = BarHeight; - Background = Track; - Border = TrackBorder; - BorderThickness = new Thickness(1); - - var fillWidth = (int)MathF.Round((width - 2) * Math.Clamp(fraction, 0f, 1f)); - if (fillWidth > 0) - { - Widgets.Add( - new Panel - { - Background = new SolidBrush(fill), - Width = fillWidth, - Height = BarHeight - 2, - Left = 1, - Top = 1, - HorizontalAlignment = HorizontalAlignment.Left, - VerticalAlignment = VerticalAlignment.Top, - } - ); - } - - Widgets.Add( - new Label - { - Text = name, - TextColor = Color.White, - Left = 8, - HorizontalAlignment = HorizontalAlignment.Left, - VerticalAlignment = VerticalAlignment.Center, - } - ); - Widgets.Add( - new Label - { - Text = value, - TextColor = Color.White, - Margin = new Thickness(0, 0, 8, 0), - HorizontalAlignment = HorizontalAlignment.Right, - VerticalAlignment = VerticalAlignment.Center, - } - ); - } -} diff --git a/src/LittleSim/UI/Menu.cs b/src/LittleSim/UI/Menu.cs index 258da48..10368e7 100644 --- a/src/LittleSim/UI/Menu.cs +++ b/src/LittleSim/UI/Menu.cs @@ -175,12 +175,3 @@ internal static class Ui return panel; } } - -/// -/// Минимальная система, вызывающая колбэк каждый кадр обновления. Сцены меню используют её -/// для опроса ввода (Esc/горячие клавиши) без отдельного класса-системы на каждую сцену. -/// -internal sealed class CallbackSystem(Action update) : BaseSystem -{ - protected override void OnUpdateGroup() => update(); -} diff --git a/src/LittleSim/UI/ScaledUiExtensions.cs b/src/LittleSim/UI/ScaledUiExtensions.cs new file mode 100644 index 0000000..1589061 --- /dev/null +++ b/src/LittleSim/UI/ScaledUiExtensions.cs @@ -0,0 +1,20 @@ +using LittleSim.App; +using Microsoft.Xna.Framework; +using MrGameEng.Core; +using MrGameEng.Graphics; +using MrGameEng.UI; +using Myra.Graphics2D.UI; + +namespace LittleSim.UI; + +/// Расширение сцены: создаёт Myra-десктоп и сразу применяет масштаб UI из настроек. +internal static class ScaledUiExtensions +{ + /// Как UseUI, но десктоп уже масштабирован по . + public static Desktop UseScaledUI(this Scene scene) + { + var desktop = scene.UseUI(); + UiScaling.Apply(scene.Context, desktop, GameSettingsStore.Load().UiScale); + return desktop; + } +} diff --git a/src/LittleSim/UI/StatBar.cs b/src/LittleSim/UI/StatBar.cs new file mode 100644 index 0000000..8fafaff --- /dev/null +++ b/src/LittleSim/UI/StatBar.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using Friflo.Engine.ECS; +using LittleSim.Content; +using LittleSim.Sim; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Graphics; +using MrGameEng.Mods; +using Myra.Graphics2D; +using Myra.Graphics2D.Brushes; +using Myra.Graphics2D.UI; + +namespace LittleSim.UI; + +/// +/// Полоса-индикатор показателя в стиле RimWorld: тёмная дорожка фиксированной ширины, цветная заливка +/// длиной fraction, поверх — подпись слева и значение справа. Самодостаточный виджет (ничего не +/// обновляет — пересоздаётся при каждой пересборке тела панели), цвет заливки задаёт вызывающий. +/// +internal sealed class StatBar : Panel +{ + private static readonly IBrush Track = new SolidBrush(new Color(28, 34, 44)); + private static readonly IBrush TrackBorder = new SolidBrush(new Color(64, 78, 98)); + private const int BarHeight = 22; + + public StatBar(int width, string name, float fraction, string value, Color fill) + { + Width = width; + Height = BarHeight; + Background = Track; + Border = TrackBorder; + BorderThickness = new Thickness(1); + + var fillWidth = (int)MathF.Round((width - 2) * Math.Clamp(fraction, 0f, 1f)); + if (fillWidth > 0) + { + Widgets.Add( + new Panel + { + Background = new SolidBrush(fill), + Width = fillWidth, + Height = BarHeight - 2, + Left = 1, + Top = 1, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Top, + } + ); + } + + Widgets.Add( + new Label + { + Text = name, + TextColor = Color.White, + Left = 8, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + } + ); + Widgets.Add( + new Label + { + Text = value, + TextColor = Color.White, + Margin = new Thickness(0, 0, 8, 0), + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Center, + } + ); + } +} diff --git a/src/LittleSim/UI/UiScaling.cs b/src/LittleSim/UI/UiScaling.cs index 887b1e3..83d4402 100644 --- a/src/LittleSim/UI/UiScaling.cs +++ b/src/LittleSim/UI/UiScaling.cs @@ -33,15 +33,3 @@ internal static class UiScaling }; } } - -/// Расширение сцены: создаёт Myra-десктоп и сразу применяет масштаб UI из настроек. -internal static class ScaledUiExtensions -{ - /// Как UseUI, но десктоп уже масштабирован по . - public static Desktop UseScaledUI(this Scene scene) - { - var desktop = scene.UseUI(); - UiScaling.Apply(scene.Context, desktop, GameSettingsStore.Load().UiScale); - return desktop; - } -}