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