diff --git a/src/LittleSim.Web/ConnectionStatus.cs b/src/LittleSim.Web/ConnectionStatus.cs new file mode 100644 index 0000000..0e31260 --- /dev/null +++ b/src/LittleSim.Web/ConnectionStatus.cs @@ -0,0 +1,17 @@ +namespace LittleSim.Web; + +/// Состояние соединения веб-клиента — его читает оверлей подключения (Blazor). +public enum ConnectionStatus +{ + /// Сервер ещё не выбран — ждём, пока пользователь нажмёт «Подключиться». + Idle, + + /// Идёт первая попытка подключения. + Connecting, + + /// Соединение установлено, снапшоты приходят. + Connected, + + /// Соединение оборвалось/не удалось — ждём следующей попытки (бэкофф). + Reconnecting, +} diff --git a/src/LittleSim.Web/LittleSim.Web.csproj b/src/LittleSim.Web/LittleSim.Web.csproj index cd01e60..38cc428 100644 --- a/src/LittleSim.Web/LittleSim.Web.csproj +++ b/src/LittleSim.Web/LittleSim.Web.csproj @@ -18,6 +18,7 @@ + diff --git a/src/LittleSim.Web/WorldViewScene.cs b/src/LittleSim.Web/WorldViewScene.cs index 8db2755..4f415b0 100644 --- a/src/LittleSim.Web/WorldViewScene.cs +++ b/src/LittleSim.Web/WorldViewScene.cs @@ -9,22 +9,6 @@ using MrGameEng.Net; namespace LittleSim.Web; -/// Состояние соединения веб-клиента — его читает оверлей подключения (Blazor). -public enum ConnectionStatus -{ - /// Сервер ещё не выбран — ждём, пока пользователь нажмёт «Подключиться». - Idle, - - /// Идёт первая попытка подключения. - Connecting, - - /// Соединение установлено, снапшоты приходят. - Connected, - - /// Соединение оборвалось/не удалось — ждём следующей попытки (бэкофф). - Reconnecting, -} - /// /// Браузерный клиент мира LittleSim: по команде подключается к /// дедикейтед-серверу (LittleSim.Server --listen), применяет дельта-снапшоты в свой diff --git a/src/LittleSim/App/GameSettings.cs b/src/LittleSim/App/GameSettings.cs index 0326c4d..60c1594 100644 --- a/src/LittleSim/App/GameSettings.cs +++ b/src/LittleSim/App/GameSettings.cs @@ -1,8 +1,3 @@ -using System.Text.Json; -using Microsoft.Xna.Framework; -using MrGameEng.Audio; -using MrGameEng.Mods; - namespace LittleSim.App; /// @@ -35,62 +30,3 @@ public sealed class GameSettings /// Режим разработчика: открывает дев-консоль (backtick) и дев-спавнер. По умолчанию включён. public bool DeveloperMode { get; set; } = true; } - -/// Чтение/запись и применение их к движку. -public static class GameSettingsStore -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - WriteIndented = true, - PropertyNameCaseInsensitive = true, - }; - - /// Загружает настройки из или возвращает значения по умолчанию. - public static GameSettings Load() - { - try - { - if (File.Exists(GamePaths.SettingsFile)) - { - return JsonSerializer.Deserialize( - File.ReadAllText(GamePaths.SettingsFile), - JsonOptions - ) ?? new GameSettings(); - } - } - catch (Exception exception) when (exception is IOException or JsonException) - { - // Битый файл настроек не должен ронять запуск — откатываемся к умолчаниям. - } - - return new GameSettings(); - } - - /// Сохраняет настройки в . - public static void Save(GameSettings settings) - { - Directory.CreateDirectory(GamePaths.Root); - File.WriteAllText(GamePaths.SettingsFile, JsonSerializer.Serialize(settings, JsonOptions)); - } - - /// - /// Применяет настройки к движку: язык (), полноэкранный - /// режим/VSync/размер окна () и громкость (). - /// - public static void Apply( - GameSettings settings, - GraphicsDeviceManager graphics, - LanguageManager languages, - AudioManager audio - ) - { - languages.SetLanguage(settings.Language); - audio.MasterVolume = settings.Volume; - - graphics.IsFullScreen = settings.Fullscreen; - graphics.SynchronizeWithVerticalRetrace = settings.VSync; - graphics.PreferredBackBufferWidth = settings.Width; - graphics.PreferredBackBufferHeight = settings.Height; - graphics.ApplyChanges(); - } -} diff --git a/src/LittleSim/App/GameSettingsStore.cs b/src/LittleSim/App/GameSettingsStore.cs new file mode 100644 index 0000000..96e9012 --- /dev/null +++ b/src/LittleSim/App/GameSettingsStore.cs @@ -0,0 +1,65 @@ +using System.Text.Json; +using Microsoft.Xna.Framework; +using MrGameEng.Audio; +using MrGameEng.Mods; + +namespace LittleSim.App; + +/// Чтение/запись и применение их к движку. +public static class GameSettingsStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + }; + + /// Загружает настройки из или возвращает значения по умолчанию. + public static GameSettings Load() + { + try + { + if (File.Exists(GamePaths.SettingsFile)) + { + return JsonSerializer.Deserialize( + File.ReadAllText(GamePaths.SettingsFile), + JsonOptions + ) ?? new GameSettings(); + } + } + catch (Exception exception) when (exception is IOException or JsonException) + { + // Битый файл настроек не должен ронять запуск — откатываемся к умолчаниям. + } + + return new GameSettings(); + } + + /// Сохраняет настройки в . + public static void Save(GameSettings settings) + { + Directory.CreateDirectory(GamePaths.Root); + File.WriteAllText(GamePaths.SettingsFile, JsonSerializer.Serialize(settings, JsonOptions)); + } + + /// + /// Применяет настройки к движку: язык (), полноэкранный + /// режим/VSync/размер окна () и громкость (). + /// + public static void Apply( + GameSettings settings, + GraphicsDeviceManager graphics, + LanguageManager languages, + AudioManager audio + ) + { + languages.SetLanguage(settings.Language); + audio.MasterVolume = settings.Volume; + + graphics.IsFullScreen = settings.Fullscreen; + graphics.SynchronizeWithVerticalRetrace = settings.VSync; + graphics.PreferredBackBufferWidth = settings.Width; + graphics.PreferredBackBufferHeight = settings.Height; + graphics.ApplyChanges(); + } +} diff --git a/src/LittleSim/App/Saves/AnimalSave.cs b/src/LittleSim/App/Saves/AnimalSave.cs new file mode 100644 index 0000000..808d17c --- /dev/null +++ b/src/LittleSim/App/Saves/AnimalSave.cs @@ -0,0 +1,75 @@ +using MrGameEng.Genetics; + +namespace LittleSim.App; + +/// +/// Сериализуемое состояние одного животного (фазы A1–A8): вид, позиция, геном (источник признаков и +/// пола), поколение, стадия/возраст, нужды, здоровье (кровь, HP частей, хедифы), настроение и +/// беременность. Спрайт пересобирается фабрикой из дефа. Чтобы популяция (эволюция/состояние) +/// переживала сейв/лоад, а не пересыпалась заново из сида. +/// +public sealed class AnimalSave +{ + /// Имя дефа вида — по нему берётся индекс/стадии/текстуры/тело. + public string Species { get; set; } = ""; + + /// Позиция X в мировых координатах. + public float X { get; set; } + + /// Позиция Y в мировых координатах. + public float Y { get; set; } + + /// Номер поколения (для popstats/дрейфа). + public int Generation { get; set; } + + /// Текущая стадия роста. + public int Stage { get; set; } + + /// Накопленный возраст в игровых днях. + public float AgeDays { get; set; } + + /// Геном особи: аллели по id генов (переменного состава; несёт и пол через GeneSex). + public Dictionary Genome { get; set; } = new(); + + /// Значения нужд по индексам NeedSet. + public float[] Needs { get; set; } = []; + + /// Накоплено дней истощения. + public float Starve { get; set; } + + /// Уровень крови [0,1]. + public float BloodLevel { get; set; } = 1f; + + /// HP частей тела по порядку анатомии вида (пусто — у вида нет тела). + public float[] PartHp { get; set; } = []; + + /// Активные хедифы (гон/болезни). + public List Hediffs { get; set; } = []; + + /// Текущее настроение [0,1]. + public float Mood { get; set; } + + /// Активно ли настроение (тир 4+). + public bool MoodActive { get; set; } + + /// Активные событийные мысли. + public List Thoughts { get; set; } = []; + + /// Беременна ли самка. + public bool Pregnant { get; set; } + + /// Геном отца (если беременна) — «слепок» на момент зачатия. + public Dictionary FatherGenome { get; set; } = new(); + + /// Остаток срока вынашивания (игровых дней). + public float DueInDays { get; set; } + + /// Размер помёта. + public int Litter { get; set; } + + /// Уровни навыков [0..1] по индексам SkillSet. + public float[] SkillLevels { get; set; } = []; + + /// Страсть к навыкам (0/1/2) по индексам SkillSet. + public int[] SkillPassions { get; set; } = []; +} diff --git a/src/LittleSim/App/Saves/CorpseSave.cs b/src/LittleSim/App/Saves/CorpseSave.cs new file mode 100644 index 0000000..e3b3142 --- /dev/null +++ b/src/LittleSim/App/Saves/CorpseSave.cs @@ -0,0 +1,23 @@ +namespace LittleSim.App; + +/// Сериализуемый труп (фаза A5): вид (для спрайта), позиция, таймер/стадия разложения, мясо. +public sealed class CorpseSave +{ + /// Имя дефа вида — по нему берётся спрайт трупа. + public string Species { get; set; } = ""; + + /// Позиция X в мировых координатах. + public float X { get; set; } + + /// Позиция Y в мировых координатах. + public float Y { get; set; } + + /// Накоплено игровых дней разложения. + public float RotDays { get; set; } + + /// Размер тела (для масштаба спрайта). + public float BodySize { get; set; } + + /// Доступное мясо. + public float Meat { get; set; } +} diff --git a/src/LittleSim/App/Saves/EggSave.cs b/src/LittleSim/App/Saves/EggSave.cs new file mode 100644 index 0000000..117201e --- /dev/null +++ b/src/LittleSim/App/Saves/EggSave.cs @@ -0,0 +1,25 @@ +using MrGameEng.Genetics; + +namespace LittleSim.App; + +/// Сериализуемое яйцо (тип рождения «яйцекладка»): вид, позиция, геном детёныша, поколение, инкубация. +public sealed class EggSave +{ + /// Имя дефа вида — по нему берётся индекс/спрайт/тело при вылуплении. + public string Species { get; set; } = ""; + + /// Позиция X в мировых координатах. + public float X { get; set; } + + /// Позиция Y в мировых координатах. + public float Y { get; set; } + + /// Поколение будущего детёныша. + public int Generation { get; set; } + + /// Остаток инкубации в игровых днях. + public float IncubateDays { get; set; } + + /// Геном будущего детёныша (уже скрещён на момент кладки). + public Dictionary Genome { get; set; } = new(); +} diff --git a/src/LittleSim/App/Saves/HediffSave.cs b/src/LittleSim/App/Saves/HediffSave.cs new file mode 100644 index 0000000..5da9a96 --- /dev/null +++ b/src/LittleSim/App/Saves/HediffSave.cs @@ -0,0 +1,14 @@ +namespace LittleSim.App; + +/// Сериализуемый хедиф особи (фаза A6): деф (по имени), тяжесть и набранный иммунитет. +public sealed class HediffSave +{ + /// Имя дефа хедифа (гон/болезнь). + public string DefName { get; set; } = ""; + + /// Текущая тяжесть. + public float Severity { get; set; } + + /// Набранный иммунитет [0,1]. + public float Immunity { get; set; } +} diff --git a/src/LittleSim/App/Saves/PlantSave.cs b/src/LittleSim/App/Saves/PlantSave.cs new file mode 100644 index 0000000..0a024e5 --- /dev/null +++ b/src/LittleSim/App/Saves/PlantSave.cs @@ -0,0 +1,28 @@ +using MrGameEng.Genetics; + +namespace LittleSim.App; + +/// Сериализуемое состояние одного растения: вид (деф), позиция, возраст, стадия, почва и геном. +public sealed class PlantSave +{ + /// Имя дефа вида — по нему при загрузке берётся индекс/стадии/текстуры. + public string Species { get; set; } = ""; + + /// Позиция X в мировых координатах. + public float X { get; set; } + + /// Позиция Y в мировых координатах. + public float Y { get; set; } + + /// Накопленный возраст в игровых днях. + public float AgeDays { get; set; } + + /// Текущая стадия роста. + public int Stage { get; set; } + + /// Плодородность клетки. + public float CellFertility { get; set; } + + /// Геном особи: аллели по id генов (переменного состава). + public Dictionary Genome { get; set; } = new(); +} diff --git a/src/LittleSim/App/Saves/SaveStore.cs b/src/LittleSim/App/Saves/SaveStore.cs new file mode 100644 index 0000000..e691bf4 --- /dev/null +++ b/src/LittleSim/App/Saves/SaveStore.cs @@ -0,0 +1,92 @@ +using System.Text.Json; + +namespace LittleSim.App; + +/// Список/чтение/запись/удаление файлов сохранений Saves/*.json. +public sealed class SaveStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + IncludeFields = true, // на случай публичных полей в сохраняемых sim-структурах + }; + + private readonly string _directory; + + /// Создаёт хранилище поверх каталога (по умолчанию ). + public SaveStore(string? directory = null) => + _directory = directory ?? GamePaths.SavesDirectory; + + /// Запись сохранения: один файл на имя мира (повторное сохранение перезаписывает). + public readonly record struct Entry(string FileName, WorldSave Save); + + /// Все сохранения, новые сверху. Битые файлы пропускаются. + public IReadOnlyList List() + { + if (!Directory.Exists(_directory)) + { + return []; + } + + var entries = new List(); + foreach (var file in Directory.EnumerateFiles(_directory, "*.json")) + { + try + { + var save = JsonSerializer.Deserialize( + File.ReadAllText(file), + JsonOptions + ); + // Сейв из будущей (несовместимой) версии прочитать корректно нельзя — пропускаем, + // чтобы не выдать пользователю молча битый мир. + if (save is not null && save.Version <= WorldSave.CurrentVersion) + { + entries.Add(new Entry(Path.GetFileName(file), save)); + } + } + catch (Exception exception) when (exception is IOException or JsonException) + { + // Битый файл сохранения не должен ломать список — пропускаем. + } + } + + entries.Sort((a, b) => b.Save.SavedUtc.CompareTo(a.Save.SavedUtc)); + return entries; + } + + /// Пишет сохранение в файл по имени мира и возвращает имя файла. + public string Write(WorldSave save) + { + Directory.CreateDirectory(_directory); + var fileName = Sanitize(save.Name) + ".json"; + File.WriteAllText( + Path.Combine(_directory, fileName), + JsonSerializer.Serialize(save, JsonOptions) + ); + return fileName; + } + + /// Удаляет файл сохранения по имени. + public void Delete(string fileName) + { + var path = Path.Combine(_directory, fileName); + if (File.Exists(path)) + { + File.Delete(path); + } + } + + // Набор недопустимых для имени файла символов — кэшируем один раз (раньше массив аллоцировался + // и линейно сканировался на КАЖДЫЙ символ имени). + private static readonly HashSet InvalidFileNameChars = new( + Path.GetInvalidFileNameChars() + ); + + private static string Sanitize(string name) + { + var trimmed = name.Trim(); + var safe = string.Concat(trimmed.Select(c => InvalidFileNameChars.Contains(c) ? '_' : c)); + return string.IsNullOrEmpty(safe) ? "world" : safe; + } +} diff --git a/src/LittleSim/App/Saves/ThoughtSave.cs b/src/LittleSim/App/Saves/ThoughtSave.cs new file mode 100644 index 0000000..15735d7 --- /dev/null +++ b/src/LittleSim/App/Saves/ThoughtSave.cs @@ -0,0 +1,11 @@ +namespace LittleSim.App; + +/// Сериализуемая событийная мысль (фаза A8): деф (по имени) и остаток времени до угасания. +public sealed class ThoughtSave +{ + /// Имя дефа мысли. + public string DefName { get; set; } = ""; + + /// Сколько игровых дней мысль ещё действует. + public float RemainingDays { get; set; } +} diff --git a/src/LittleSim/App/Saves/WorldSave.cs b/src/LittleSim/App/Saves/WorldSave.cs new file mode 100644 index 0000000..44353e9 --- /dev/null +++ b/src/LittleSim/App/Saves/WorldSave.cs @@ -0,0 +1,64 @@ +namespace LittleSim.App; + +/// +/// Полное состояние мира для сохранения/загрузки: конфиг мира (имя/размер/сид/сглаживание), +/// прошедшее время симуляции и снимок всех жителей. Рельеф не сохраняется — он +/// детерминированно регенерируется из сида при загрузке. +/// +public sealed class WorldSave +{ + /// Текущая версия схемы сейва (поднимать при несовместимых изменениях полей). + public const int CurrentVersion = 1; + + /// Версия схемы файла. Старые сейвы без поля совместимы (схема аддитивна) и читаются как + /// текущая версия; проверка нужна, чтобы отклонить файл из БУДУЩЕЙ (несовместимой) версии. + public int Version { get; set; } = CurrentVersion; + + /// Имя мира (и основа имени файла). + public string Name { get; set; } = ""; + + /// Ширина мира в клетках. + public int Width { get; set; } + + /// Высота мира в клетках. + public int Height { get; set; } + + /// Сид генерации. + public int Seed { get; set; } + + /// Число проходов сглаживания рельефа. + public int SmoothPasses { get; set; } = 4; + + /// Население на момент сохранения. + public int Population { get; set; } + + /// Момент сохранения (UTC). + public DateTime SavedUtc { get; set; } + + /// Прошедшее время симуляции в секундах. + public double ElapsedSeconds { get; set; } + + /// Снимок всех растений (геномы, позиции, возраст, стадия). + public List Plants { get; set; } = []; + + /// Снимок всех животных (геном, стадия/возраст, нужды, здоровье, настроение, беременность). + public List Animals { get; set; } = []; + + /// Снимок трупов (вид, позиция, таймер/стадия разложения, мясо). + public List Corpses { get; set; } = []; + + /// Снимок яиц (вид, позиция, геном детёныша, поколение, инкубация). + public List Eggs { get; set; } = []; + + /// Конфиг мира для пересоздания сцены. + public WorldConfig ToConfig() => + new() + { + Name = Name, + Width = Width, + Height = Height, + Seed = Seed, + SmoothPasses = SmoothPasses, + Population = Population, + }; +} diff --git a/src/LittleSim/App/WorldSave.cs b/src/LittleSim/App/WorldSave.cs deleted file mode 100644 index e5710ae..0000000 --- a/src/LittleSim/App/WorldSave.cs +++ /dev/null @@ -1,320 +0,0 @@ -using System.Text.Json; -using MrGameEng.Genetics; - -namespace LittleSim.App; - -/// Сериализуемое состояние одного растения: вид (деф), позиция, возраст, стадия, почва и геном. -public sealed class PlantSave -{ - /// Имя дефа вида — по нему при загрузке берётся индекс/стадии/текстуры. - public string Species { get; set; } = ""; - - /// Позиция X в мировых координатах. - public float X { get; set; } - - /// Позиция Y в мировых координатах. - public float Y { get; set; } - - /// Накопленный возраст в игровых днях. - public float AgeDays { get; set; } - - /// Текущая стадия роста. - public int Stage { get; set; } - - /// Плодородность клетки. - public float CellFertility { get; set; } - - /// Геном особи: аллели по id генов (переменного состава). - public Dictionary Genome { get; set; } = new(); -} - -/// Сериализуемый хедиф особи (фаза A6): деф (по имени), тяжесть и набранный иммунитет. -public sealed class HediffSave -{ - /// Имя дефа хедифа (гон/болезнь). - public string DefName { get; set; } = ""; - - /// Текущая тяжесть. - public float Severity { get; set; } - - /// Набранный иммунитет [0,1]. - public float Immunity { get; set; } -} - -/// Сериализуемая событийная мысль (фаза A8): деф (по имени) и остаток времени до угасания. -public sealed class ThoughtSave -{ - /// Имя дефа мысли. - public string DefName { get; set; } = ""; - - /// Сколько игровых дней мысль ещё действует. - public float RemainingDays { get; set; } -} - -/// -/// Сериализуемое состояние одного животного (фазы A1–A8): вид, позиция, геном (источник признаков и -/// пола), поколение, стадия/возраст, нужды, здоровье (кровь, HP частей, хедифы), настроение и -/// беременность. Спрайт пересобирается фабрикой из дефа. Чтобы популяция (эволюция/состояние) -/// переживала сейв/лоад, а не пересыпалась заново из сида. -/// -public sealed class AnimalSave -{ - /// Имя дефа вида — по нему берётся индекс/стадии/текстуры/тело. - public string Species { get; set; } = ""; - - /// Позиция X в мировых координатах. - public float X { get; set; } - - /// Позиция Y в мировых координатах. - public float Y { get; set; } - - /// Номер поколения (для popstats/дрейфа). - public int Generation { get; set; } - - /// Текущая стадия роста. - public int Stage { get; set; } - - /// Накопленный возраст в игровых днях. - public float AgeDays { get; set; } - - /// Геном особи: аллели по id генов (переменного состава; несёт и пол через GeneSex). - public Dictionary Genome { get; set; } = new(); - - /// Значения нужд по индексам NeedSet. - public float[] Needs { get; set; } = []; - - /// Накоплено дней истощения. - public float Starve { get; set; } - - /// Уровень крови [0,1]. - public float BloodLevel { get; set; } = 1f; - - /// HP частей тела по порядку анатомии вида (пусто — у вида нет тела). - public float[] PartHp { get; set; } = []; - - /// Активные хедифы (гон/болезни). - public List Hediffs { get; set; } = []; - - /// Текущее настроение [0,1]. - public float Mood { get; set; } - - /// Активно ли настроение (тир 4+). - public bool MoodActive { get; set; } - - /// Активные событийные мысли. - public List Thoughts { get; set; } = []; - - /// Беременна ли самка. - public bool Pregnant { get; set; } - - /// Геном отца (если беременна) — «слепок» на момент зачатия. - public Dictionary FatherGenome { get; set; } = new(); - - /// Остаток срока вынашивания (игровых дней). - public float DueInDays { get; set; } - - /// Размер помёта. - public int Litter { get; set; } - - /// Уровни навыков [0..1] по индексам SkillSet. - public float[] SkillLevels { get; set; } = []; - - /// Страсть к навыкам (0/1/2) по индексам SkillSet. - public int[] SkillPassions { get; set; } = []; -} - -/// Сериализуемый труп (фаза A5): вид (для спрайта), позиция, таймер/стадия разложения, мясо. -public sealed class CorpseSave -{ - /// Имя дефа вида — по нему берётся спрайт трупа. - public string Species { get; set; } = ""; - - /// Позиция X в мировых координатах. - public float X { get; set; } - - /// Позиция Y в мировых координатах. - public float Y { get; set; } - - /// Накоплено игровых дней разложения. - public float RotDays { get; set; } - - /// Размер тела (для масштаба спрайта). - public float BodySize { get; set; } - - /// Доступное мясо. - public float Meat { get; set; } -} - -/// Сериализуемое яйцо (тип рождения «яйцекладка»): вид, позиция, геном детёныша, поколение, инкубация. -public sealed class EggSave -{ - /// Имя дефа вида — по нему берётся индекс/спрайт/тело при вылуплении. - public string Species { get; set; } = ""; - - /// Позиция X в мировых координатах. - public float X { get; set; } - - /// Позиция Y в мировых координатах. - public float Y { get; set; } - - /// Поколение будущего детёныша. - public int Generation { get; set; } - - /// Остаток инкубации в игровых днях. - public float IncubateDays { get; set; } - - /// Геном будущего детёныша (уже скрещён на момент кладки). - public Dictionary Genome { get; set; } = new(); -} - -/// -/// Полное состояние мира для сохранения/загрузки: конфиг мира (имя/размер/сид/сглаживание), -/// прошедшее время симуляции и снимок всех жителей. Рельеф не сохраняется — он -/// детерминированно регенерируется из сида при загрузке. -/// -public sealed class WorldSave -{ - /// Текущая версия схемы сейва (поднимать при несовместимых изменениях полей). - public const int CurrentVersion = 1; - - /// Версия схемы файла. Старые сейвы без поля совместимы (схема аддитивна) и читаются как - /// текущая версия; проверка нужна, чтобы отклонить файл из БУДУЩЕЙ (несовместимой) версии. - public int Version { get; set; } = CurrentVersion; - - /// Имя мира (и основа имени файла). - public string Name { get; set; } = ""; - - /// Ширина мира в клетках. - public int Width { get; set; } - - /// Высота мира в клетках. - public int Height { get; set; } - - /// Сид генерации. - public int Seed { get; set; } - - /// Число проходов сглаживания рельефа. - public int SmoothPasses { get; set; } = 4; - - /// Население на момент сохранения. - public int Population { get; set; } - - /// Момент сохранения (UTC). - public DateTime SavedUtc { get; set; } - - /// Прошедшее время симуляции в секундах. - public double ElapsedSeconds { get; set; } - - /// Снимок всех растений (геномы, позиции, возраст, стадия). - public List Plants { get; set; } = []; - - /// Снимок всех животных (геном, стадия/возраст, нужды, здоровье, настроение, беременность). - public List Animals { get; set; } = []; - - /// Снимок трупов (вид, позиция, таймер/стадия разложения, мясо). - public List Corpses { get; set; } = []; - - /// Снимок яиц (вид, позиция, геном детёныша, поколение, инкубация). - public List Eggs { get; set; } = []; - - /// Конфиг мира для пересоздания сцены. - public WorldConfig ToConfig() => - new() - { - Name = Name, - Width = Width, - Height = Height, - Seed = Seed, - SmoothPasses = SmoothPasses, - Population = Population, - }; -} - -/// Список/чтение/запись/удаление файлов сохранений Saves/*.json. -public sealed class SaveStore -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - WriteIndented = true, - PropertyNameCaseInsensitive = true, - IncludeFields = true, // на случай публичных полей в сохраняемых sim-структурах - }; - - private readonly string _directory; - - /// Создаёт хранилище поверх каталога (по умолчанию ). - public SaveStore(string? directory = null) => - _directory = directory ?? GamePaths.SavesDirectory; - - /// Запись сохранения: один файл на имя мира (повторное сохранение перезаписывает). - public readonly record struct Entry(string FileName, WorldSave Save); - - /// Все сохранения, новые сверху. Битые файлы пропускаются. - public IReadOnlyList List() - { - if (!Directory.Exists(_directory)) - { - return []; - } - - var entries = new List(); - foreach (var file in Directory.EnumerateFiles(_directory, "*.json")) - { - try - { - var save = JsonSerializer.Deserialize( - File.ReadAllText(file), - JsonOptions - ); - // Сейв из будущей (несовместимой) версии прочитать корректно нельзя — пропускаем, - // чтобы не выдать пользователю молча битый мир. - if (save is not null && save.Version <= WorldSave.CurrentVersion) - { - entries.Add(new Entry(Path.GetFileName(file), save)); - } - } - catch (Exception exception) when (exception is IOException or JsonException) - { - // Битый файл сохранения не должен ломать список — пропускаем. - } - } - - entries.Sort((a, b) => b.Save.SavedUtc.CompareTo(a.Save.SavedUtc)); - return entries; - } - - /// Пишет сохранение в файл по имени мира и возвращает имя файла. - public string Write(WorldSave save) - { - Directory.CreateDirectory(_directory); - var fileName = Sanitize(save.Name) + ".json"; - File.WriteAllText( - Path.Combine(_directory, fileName), - JsonSerializer.Serialize(save, JsonOptions) - ); - return fileName; - } - - /// Удаляет файл сохранения по имени. - public void Delete(string fileName) - { - var path = Path.Combine(_directory, fileName); - if (File.Exists(path)) - { - File.Delete(path); - } - } - - // Набор недопустимых для имени файла символов — кэшируем один раз (раньше массив аллоцировался - // и линейно сканировался на КАЖДЫЙ символ имени). - private static readonly HashSet InvalidFileNameChars = new( - Path.GetInvalidFileNameChars() - ); - - private static string Sanitize(string name) - { - var trimmed = name.Trim(); - var safe = string.Concat(trimmed.Select(c => InvalidFileNameChars.Contains(c) ? '_' : c)); - return string.IsNullOrEmpty(safe) ? "world" : safe; - } -} diff --git a/src/LittleSim/Content/Defs/AnimalDef.cs b/src/LittleSim/Content/Defs/AnimalDef.cs new file mode 100644 index 0000000..6023b87 --- /dev/null +++ b/src/LittleSim/Content/Defs/AnimalDef.cs @@ -0,0 +1,73 @@ +namespace LittleSim.Content; + +/// +/// Вид-животное (Defs/Animals/): подтип (текстура/размер/вид) плюс геном +/// вида. Поведение, здоровье и стадии живут на компонентах сущности (фазы A2+), а не на типе дефа — +/// существующие 9 животных- остаются валидными. +/// +public sealed class AnimalDef : PawnDef +{ + /// Текстура взрослого самца (фаза A3); пусто — берётся (самка/общая). + public string MaleTexture { get; init; } = ""; + + /// Текстура детёныша/подростка (фаза A3); пусто — берётся . + public string BabyTexture { get; init; } = ""; + + /// Текстура трупа (фаза A5); пусто — берётся . + public string CorpseTexture { get; init; } = ""; + + /// Текстура яйца (тип рождения «яйцекладка»); по умолчанию — общий спрайт семени-точки. + public string EggTexture { get; init; } = "things/plant/seed_default"; + + /// Эталонная масса (кг) взрослой особи базового размера вида; масса особи ∝ (размер/эталон)³. + public float BaseMassKg { get; init; } = 40f; + + /// Имя — анатомия вида (части тела/органы); пусто — без частей тела. + public string Body { get; init; } = ""; + + /// + /// Геном вида как ДАННЫЕ: ключ — id гена (), значение — + /// центр аллелей особи. Состав открыт — модер добавляет/убирает ген одной строкой JSON, без правки + /// кода; разброс берётся из самого гена (GeneDef.Spread). Дискретные гены (напр. пол) сюда + /// не пишутся — назначаются генерацией. + /// + public Dictionary Genome { get; init; } = new(); + + /// + /// Диета как ДАННЫЕ: что вид ест ("plant" — травоядное; "meat" — хищник/падальщик; оба — всеядное). + /// Поиск корма гейтится этим списком — модер задаёт рацион без правки кода. + /// + public string[] Diet { get; init; } = []; + + /// + /// Стадии роста (данные); пусто — встроенный набор Baby→Juvenile→Adult→Senior по умолчанию. + /// + public AnimalStageDef[] Stages { get; init; } = []; + + /// Плотность стартового спавна: голов на 1000 клеток суши (0 — вид не спавнится сам). + public float SpawnPer1000Cells { get; init; } + + /// Базовая скорость передвижения (мировых единиц/сек при гене moveSpeed = 1). + public float BaseSpeed { get; init; } = 28f; + + /// Базовый радиус восприятия в клетках (× ген vision) — поиск корма/воды/партнёра. + public float VisionCells { get; init; } = 14f; + + /// Сила выедания: на сколько игровых дней роста убавляется растение за день кормёжки. + public float ForageBiteDays { get; init; } = 40f; + + /// Дальность атаки в клетках: на этом расстоянии хищник кусает добычу (контакт). + public float AttackRangeCells { get; init; } = 1.2f; + + /// Игровые секунды между ударами (кулдаун дискретной атаки, как в RimWorld). + public float AttackIntervalSec { get; init; } = 1.8f; + + /// Урон части тела добычи ЗА ОДИН УДАР (×силу: размер×навык). 0 — вид не атакует. + public float AttackDamage { get; init; } + + /// Прирост тяжести раны-кровотечения у добычи за один удар (×силу). + public float AttackBleed { get; init; } + + /// Острая кровопотеря добычи за один удар (доля объёма крови, ×силу) — основной путь к смерти. + public float AttackBloodLoss { get; init; } +} diff --git a/src/LittleSim/Content/Defs/AnimalStageDef.cs b/src/LittleSim/Content/Defs/AnimalStageDef.cs new file mode 100644 index 0000000..708845d --- /dev/null +++ b/src/LittleSim/Content/Defs/AnimalStageDef.cs @@ -0,0 +1,28 @@ +namespace LittleSim.Content; + +/// +/// Стадия роста животного как ДАННЫЕ (вложенный объект ): возраст входа = +/// × якорь ( — созревание или продолжительность жизни), +/// масштаб тела и набор текстур ("baby"/"adult"). Стадии перечисляются по возрастанию возраста входа. +/// Модер задаёт любой набор стадий (личинка/куколка/имаго и т.п.) без правки кода. +/// +public sealed class AnimalStageDef +{ + /// Имя стадии (для тултипов/отладки). + public string Name { get; init; } = ""; + + /// Множитель к якорю: возраст входа = EnterAt × (созревание | продолжительность жизни). + public float EnterAt { get; init; } + + /// Якорь возраста входа: "maturity" (созревание) или "lifespan" (продолжительность жизни). + public string RelativeTo { get; init; } = "maturity"; + + /// Масштаб размера тела на этой стадии (× ген размера). + public float Scale { get; init; } = 1f; + + /// Набор текстур: "baby" (детёныш) или "adult" (по полу — самец/самка). + public string Texture { get; init; } = "adult"; + + /// Взрослая стадия — способна размножаться. + public bool Adult { get; init; } +} diff --git a/src/LittleSim/Content/Defs/BodyDef.cs b/src/LittleSim/Content/Defs/BodyDef.cs new file mode 100644 index 0000000..e01708d --- /dev/null +++ b/src/LittleSim/Content/Defs/BodyDef.cs @@ -0,0 +1,13 @@ +using MrGameEng.Mods; + +namespace LittleSim.Content; + +/// +/// Анатомия вида (Defs/Bodies/): дерево частей тела/органов. Используется системой здоровья (фаза +/// A5): из неё строятся инстансы частей особи, по их HP/крови считаются способности (capacities). +/// +public sealed class BodyDef : Def +{ + /// Части тела/органы вида. + public BodyPartDef[] Parts { get; init; } = []; +} diff --git a/src/LittleSim/Content/Defs/BodyPartDef.cs b/src/LittleSim/Content/Defs/BodyPartDef.cs new file mode 100644 index 0000000..a4e10b6 --- /dev/null +++ b/src/LittleSim/Content/Defs/BodyPartDef.cs @@ -0,0 +1,27 @@ +namespace LittleSim.Content; + +/// +/// Часть тела/орган (вложенный объект ) как ДАННЫЕ: иерархия (родитель), +/// вес попадания, максимум HP (масштабируется размером тела), флаг жизненной важности и вклады в +/// способности (capacity id → доля). Модер задаёт любую анатомию (8 ног, два сердца) без правки кода. +/// +public sealed class BodyPartDef +{ + /// Имя части (уникально в теле); на него ссылается детей. + public string Name { get; init; } = ""; + + /// Имя родительской части ("" — корневая, напр. торс). + public string Parent { get; init; } = ""; + + /// Вес попадания (доля тела) — для будущего выбора задетой части. + public float Coverage { get; init; } = 0.1f; + + /// Базовый максимум HP (× размер тела при создании особи). + public float MaxHp { get; init; } = 10f; + + /// Жизненно важная: уничтожение → смерть. + public bool Vital { get; init; } + + /// Вклады в способности: capacity id (см. AnimalCapacities) → доля (сумма по телу ≈ 1). + public Dictionary Capacities { get; init; } = new(); +} diff --git a/src/LittleSim/Content/Defs/DevToolDef.cs b/src/LittleSim/Content/Defs/DevToolDef.cs new file mode 100644 index 0000000..859a5c7 --- /dev/null +++ b/src/LittleSim/Content/Defs/DevToolDef.cs @@ -0,0 +1,21 @@ +using MrGameEng.Mods; + +namespace LittleSim.Content; + +/// +/// Пункт дев-инструмента (Defs/DevTools/) — ЧТОБЫ МОДЫ ДОБАВЛЯЛИ кнопки в дев-спавнер без правки кода. +/// — id обработчика из реестра (spawn-animal/spawn-plant/infect/damage/kill/…), +/// — параметр (defName вида/хедифа). Виды животных/растений спавнер берёт из их +/// дефов сам (модовый контент появляется автоматически); DevToolDef добавляет ПРОЧИЕ действия. +/// +public sealed class DevToolDef : Def +{ + /// Категория для группировки в панели (например "tool"). + public string Category { get; init; } = "tool"; + + /// Id обработчика-инструмента из реестра кода (spawn-animal/spawn-plant/infect/damage/kill). + public string Kind { get; init; } = ""; + + /// Параметр инструмента: defName вида/хедифа и т.п. (по смыслу Kind). + public string Target { get; init; } = ""; +} diff --git a/src/LittleSim/Content/Defs/GenomeDef.cs b/src/LittleSim/Content/Defs/GenomeDef.cs new file mode 100644 index 0000000..00450a0 --- /dev/null +++ b/src/LittleSim/Content/Defs/GenomeDef.cs @@ -0,0 +1,78 @@ +namespace LittleSim.Content; + +/// +/// Базовый геном вида (вложенный объект ): оптимумы и толерантности по +/// факторам среды + базовая бодрость роста. Особь при спавне получает аллели рядом с этими базами +/// (разброс — ); фенотип = среднее аллелей. +/// +public sealed class GenomeDef +{ + /// Оптимальная освещённость (0..1) и её толерантность. + public float OptimalLight { get; init; } = 0.7f; + + /// Ширина толерантности по свету. + public float LightTolerance { get; init; } = 0.4f; + + /// Оптимальная температура (°C) и её толерантность. + public float OptimalTemperature { get; init; } = 18f; + + /// Ширина толерантности по температуре (°C) — половина плато полного роста вокруг оптимума. + public float TemperatureTolerance { get; init; } = 12f; + + /// Морозостойкость (°C): на сколько ниже плато рост спадает до нуля (жёсткая нижняя граница). + public float ColdHardiness { get; init; } = 8f; + + /// Жаростойкость (°C): на сколько выше плато рост спадает до нуля (жёсткая верхняя граница). + public float HeatHardiness { get; init; } = 10f; + + /// Оптимальная плодородность почвы и её толерантность. + public float OptimalFertility { get; init; } = 1f; + + /// Ширина толерантности по плодородности. + public float FertilityTolerance { get; init; } = 0.8f; + + /// Базовая бодрость роста (множитель скорости в идеальных условиях). + public float Vigor { get; init; } = 1f; + + /// Продолжительность жизни (игровых дней) до смерти от старости. + public float Lifespan { get; init; } = 80f; + + /// Дальность расселения семян (в клетках). + public float DispersalRange { get; init; } = 2f; + + /// Средний интервал между попытками дать семя (игровых дней). + public float ReproduceInterval { get; init; } = 8f; + + /// Способность к самоопылению (0..1): шанс дать семя без партнёра. + public float SelfPollination { get; init; } = 0.4f; + + /// Темп мутаций (0..1): вероятность сдвига аллеля у потомка. + public float MutationRate { get; init; } = 0.05f; + + /// Доля рецессивного аллеля морфы в стартовой популяции (0..1). + public float VariantChance { get; init; } = 0.15f; + + /// Доля разброса аллелей вокруг базы при генерации особи. + public float Spread { get; init; } = 0.08f; + + /// Плодовитость: сколько плодов несёт зрелое растение в сезон (0 — не плодоносит). + public float FruitYield { get; init; } + + /// Сезон плодоношения (0 — весна, 1 — лето, 2 — осень, 3 — зима). + public float FruitSeason { get; init; } = 1f; + + /// Сколько продукта даёт сбор растения (масштаб ). + public float HarvestAmount { get; init; } = 1f; + + /// Оттенок листвы (0..1) — сдвиг тинта спрайта; ген цвета. + public float LeafHue { get; init; } = 0.33f; + + /// Токсичность (0..1): отравляет поедателя (коэволюция); тормозит рост (цена защиты). + public float Toxicity { get; init; } + + /// Шипы (0..1): травмируют поедателя; тормозят рост (цена защиты). + public float Thorns { get; init; } + + /// Вкусность (0..1): обратная привлекательность для умных травоядных (избегание в C2). + public float Palatability { get; init; } = 1f; +} diff --git a/src/LittleSim/Content/Defs/HediffDef.cs b/src/LittleSim/Content/Defs/HediffDef.cs new file mode 100644 index 0000000..8b9e50d --- /dev/null +++ b/src/LittleSim/Content/Defs/HediffDef.cs @@ -0,0 +1,39 @@ +using MrGameEng.Mods; + +namespace LittleSim.Content; + +/// +/// Состояние/хедиф организма (Defs/Hediffs/): гон, болезни, позже раны/возрастные эффекты. Фаза A6: +/// болезнь прогрессирует (), параллельно растёт иммунитет +/// () — гонка: иммунитет→1 раньше = выздоровление, severity→ +/// раньше = смерть. Снижает способности () и даёт боль. +/// +public sealed class HediffDef : Def +{ + /// Начальная тяжесть при навешивании (гон — 1; болезнь — малая, напр. 0.05). + public float InitialSeverity { get; init; } = 1f; + + /// Прирост тяжести за игровой день (0 — статичный хедиф вроде гона). + public float SeverityPerDay { get; init; } + + /// Прирост иммунитета за день (0 — иммунитета нет, не выздоравливает сам). + public float ImmunityPerDay { get; init; } + + /// Тяжесть, при которой наступает смерть (0 — не летально). + public float LethalSeverity { get; init; } + + /// Боль при тяжести 1 (масштабируется тяжестью) — вход в сознание/настроение. + public float Pain { get; init; } + + /// Шанс подхватить фоном за день (0 — не фоновая болезнь). + public float AmbientPerDay { get; init; } + + /// + /// Кровопотеря за игровой день при тяжести 1 (масштабируется тяжестью): доля объёма крови, которую + /// рана теряет в сутки. 0 — хедиф не вызывает кровотечения. Кровь на нуле → смерть (см. система здоровья). + /// + public float BloodLossPerDay { get; init; } + + /// Модификаторы способностей: capacity id → множитель при тяжести 1 (лерп 1→factor по severity). + public Dictionary CapMods { get; init; } = new(); +} diff --git a/src/LittleSim/Content/Defs/NeedDef.cs b/src/LittleSim/Content/Defs/NeedDef.cs new file mode 100644 index 0000000..ebba91e --- /dev/null +++ b/src/LittleSim/Content/Defs/NeedDef.cs @@ -0,0 +1,36 @@ +using MrGameEng.Mods; + +namespace LittleSim.Content; + +/// +/// Нужда (Defs/Needs/) как ДАННЫЕ: имя, тип (deplete — убывает, низкое = срочно; drive — растёт под +/// условием, высокое = срочно), действие-исполнитель (по id, см. реестр поведений), темпы и порог +/// интеллекта. Модер добавляет нужду виду/всем одной записью; если она ссылается на существующее +/// действие — без правки кода. Хранилище нужд особи — динамическое (см. NeedSet/AnimalNeeds). +/// +public sealed class NeedDef : Def +{ + /// Тип: "deplete" (убывает, утоляется действием) или "drive" (растёт под условием). + public string Kind { get; init; } = "deplete"; + + /// Id действия-исполнителя, удовлетворяющего нужду (Eat/Drink/Sleep/Mate/…). + public string Action { get; init; } = ""; + + /// Темп убывания за игровой день (drive: темп спада, когда условие отсутствует). + public float DecayPerDay { get; init; } = 0.5f; + + /// drive: темп роста за день, пока активно условие . + public float RisePerDay { get; init; } = 1f; + + /// deplete: насколько за день восполняет нужду исполнение действия. + public float FeedPerDay { get; init; } = 4f; + + /// drive: id хедифа, под которым нужда растёт (напр. "Rut"); null — растёт всегда. + public string? RisesUnder { get; init; } + + /// deplete: на нуле копит счётчик голодной смерти (A3). + public bool Lethal { get; init; } + + /// Порог размера мозга, с которого нужда активна (гейт интеллектом — фаза A7). + public float MinBrain { get; init; } +} diff --git a/src/LittleSim/Content/Defs/PawnDef.cs b/src/LittleSim/Content/Defs/PawnDef.cs new file mode 100644 index 0000000..f479423 --- /dev/null +++ b/src/LittleSim/Content/Defs/PawnDef.cs @@ -0,0 +1,22 @@ +using MrGameEng.Mods; + +namespace LittleSim.Content; + +/// Существо (Defs/Pawns/): текстура, размер и вид (житель/животное). Не sealed — наследует. +public class PawnDef : Def +{ + /// Вид существа: жители ("being") живут в мировой сцене, животные ("animal") — в террейне. + public const string KindBeing = "being"; + + /// Вид животного. + public const string KindAnimal = "animal"; + + /// Ключ текстуры (путь региона в атласах). + public string Texture { get; init; } = ""; + + /// Размер спрайта в клетках. + public float SizeCells { get; init; } = 1f; + + /// Вид: или . + public string Kind { get; init; } = ""; +} diff --git a/src/LittleSim/Content/Defs/PlantDef.cs b/src/LittleSim/Content/Defs/PlantDef.cs new file mode 100644 index 0000000..0403610 --- /dev/null +++ b/src/LittleSim/Content/Defs/PlantDef.cs @@ -0,0 +1,31 @@ +using MrGameEng.Mods; + +namespace LittleSim.Content; + +/// Растение (Defs/Plants/): текстура, размер, опциональный ствол-препятствие, стадии роста, геном. +public sealed class PlantDef : Def +{ + /// Ключ текстуры (путь региона в атласах); база для стадий без своей текстуры. + public string Texture { get; init; } = ""; + + /// Базовый размер спрайта в клетках; база для стадий без своего размера. + public float SizeCells { get; init; } = 1f; + + /// Радиус коллайдера ствола в клетках; 0 — без коллайдера (трава, кусты). + public float TrunkRadiusCells { get; init; } + + /// Стадии роста по порядку; пусто — одна терминальная стадия из базовой текстуры. + public List Stages { get; init; } = []; + + /// Базовый геном вида: оптимумы/толерантности по среде и бодрость роста. + public GenomeDef Genome { get; init; } = new(); + + /// Имя , выдаваемого при сборе растения (дерево/трава); null — несборное. + public string? HarvestProduct { get; init; } + + /// Имя , выдаваемого плодами (ягоды/жёлудь); null — не плодоносит. + public string? FruitProduct { get; init; } + + /// Эталонная масса (кг) зрелого растения базового размера; масса особи ∝ (размер/эталон)³. + public float BaseMassKg { get; init; } = 20f; +} diff --git a/src/LittleSim/Content/Defs/PlantGrowthStage.cs b/src/LittleSim/Content/Defs/PlantGrowthStage.cs new file mode 100644 index 0000000..f394e7c --- /dev/null +++ b/src/LittleSim/Content/Defs/PlantGrowthStage.cs @@ -0,0 +1,21 @@ +namespace LittleSim.Content; + +/// +/// Одна стадия роста растения (вложенный объект ): свой спрайт и +/// размер, и сколько игровых дней растение проводит на этой стадии до перехода к следующей. +/// Растёт на месте — последняя стадия терминальная (её игнорируется). +/// +public sealed class PlantGrowthStage +{ + /// Ключ текстуры стадии; null — берётся базовая . + public string? Texture { get; init; } + + /// Размер спрайта в клетках на этой стадии; 0 — берётся базовый . + public float SizeCells { get; init; } + + /// Сколько игровых дней расти на этой стадии до перехода к следующей. + public float GrowDays { get; init; } + + /// Ключ локализации названия стадии (опционально, для будущих тултипов). + public string? Label { get; init; } +} diff --git a/src/LittleSim/Content/Defs/ProductDef.cs b/src/LittleSim/Content/Defs/ProductDef.cs new file mode 100644 index 0000000..fa84091 --- /dev/null +++ b/src/LittleSim/Content/Defs/ProductDef.cs @@ -0,0 +1,17 @@ +using MrGameEng.Mods; + +namespace LittleSim.Content; + +/// +/// Продукт (Defs/Products/): что растение даёт при сборе (древесина, трава) или плодоношении +/// (ягоды, жёлудь). Источник истины — деф; растения ссылаются на него по имени, а количество +/// определяют гены. — ключ локализации (product.*). +/// +public sealed class ProductDef : Def +{ + /// Категория продукта (например "material" или "food") — для будущей экономики/инвентаря. + public string Kind { get; init; } = ""; + + /// Масса одной единицы продукта (кг) — основа под перенос/инвентарь (грузоподъёмность). + public float MassKg { get; init; } = 1f; +} diff --git a/src/LittleSim/Content/Defs/ScatterEntry.cs b/src/LittleSim/Content/Defs/ScatterEntry.cs new file mode 100644 index 0000000..81785f8 --- /dev/null +++ b/src/LittleSim/Content/Defs/ScatterEntry.cs @@ -0,0 +1,11 @@ +namespace LittleSim.Content; + +/// Запись скаттера: с вероятностью на клетку ставится одно растение из . +public sealed class ScatterEntry +{ + /// Вероятность на клетку (0..1); записи бросаются независимо. + public float Chance { get; init; } + + /// Имена , из которых выбирается равновероятно. + public List Options { get; init; } = []; +} diff --git a/src/LittleSim/Content/Defs/SkillDef.cs b/src/LittleSim/Content/Defs/SkillDef.cs new file mode 100644 index 0000000..9736988 --- /dev/null +++ b/src/LittleSim/Content/Defs/SkillDef.cs @@ -0,0 +1,24 @@ +using MrGameEng.Mods; + +namespace LittleSim.Content; + +/// +/// Навык (Defs/Skills/) как ДАННЫЕ: уровень особи [0..1] растёт от практики (XP при действии, +/// ускоряется страстью) и медленно угасает без неё к полу . Эффект навыка — +/// код по его id (как у нужд/действий): добыча→выпас, охота→урон, уклонение→бегство. Модер добавляет +/// навык строкой JSON; новый эффект — код. — ключ локализации (skill.*). +/// +public sealed class SkillDef : Def +{ + /// Категория (для группировки в UI), напр. "survival". + public string Category { get; init; } = ""; + + /// Скорость обучения за игровой день непрерывной практики (при страсти ×1). + public float LearnRate { get; init; } = 0.5f; + + /// Угасание уровня за игровой день без практики. + public float DecayPerDay { get; init; } = 0.02f; + + /// Пол угасания (ниже не падает) — «не забывается совсем». + public float FloorLevel { get; init; } +} diff --git a/src/LittleSim/Content/Defs/TerrainDef.cs b/src/LittleSim/Content/Defs/TerrainDef.cs new file mode 100644 index 0000000..85f3d1b --- /dev/null +++ b/src/LittleSim/Content/Defs/TerrainDef.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Serialization; +using Microsoft.Xna.Framework; +using MrGameEng.Mods; + +namespace LittleSim.Content; + +/// Тип клетки рельефа (Defs/Terrain/): порог высоты, цвет, поверхность, скаттер растений. +public sealed class TerrainDef : Def +{ + /// Верхняя граница высоты (клетка относится к первому дефу, чей порог выше её высоты). + public float MaxHeight { get; init; } + + /// Цвет клетки [r, g, b] для сцен с тонированными квадратами и тайлов без поверхности. + [JsonPropertyName("color")] + public List Rgb { get; init; } = []; + + /// Суша: здесь появляются жители, животные и растения. + public bool IsLand { get; init; } + + /// Светонепроницаемый террейн — базовый окклюдер лайтмапа/теней (сейчас такого нет). + public bool BlocksLight { get; init; } + + /// Ключ текстуры поверхности для тайловой сцены; null — тонированный тайл. + public string? Surface { get; init; } + + /// Растительность, рассыпаемая по клеткам этого террейна. + public List Scatter { get; init; } = []; + + /// + /// Плодородность почвы — множитель скорости роста растений на этом террейне (1 — норма, + /// больше — быстрее). Богатая лесная почва растит быстрее песка/камня. + /// + public float Fertility { get; init; } = 1f; + + /// Цвет тонировки из . + public Color Tint => Rgb is [var r, var g, var b, ..] ? new(r, g, b) : Color.Magenta; +} diff --git a/src/LittleSim/Content/Defs/ThoughtDef.cs b/src/LittleSim/Content/Defs/ThoughtDef.cs new file mode 100644 index 0000000..571d4b5 --- /dev/null +++ b/src/LittleSim/Content/Defs/ThoughtDef.cs @@ -0,0 +1,18 @@ +using MrGameEng.Mods; + +namespace LittleSim.Content; + +/// +/// Мысль настроения (Defs/Thoughts/) как ДАННЫЕ (фаза A8, модель thoughts RimWorld): событие +/// (роды/спаривание/утоление жажды/…) вешает на особь временный сдвиг настроения, затухающий за +/// . Триггеры событий — код (реестр AnimalThoughts), а величины/метка — +/// данные: модер правит силу и длительность мысли одной записью JSON, без пересборки. +/// +public sealed class ThoughtDef : Def +{ + /// Сдвиг настроения [-1,1] (положительный — радость, отрицательный — горе). + public float MoodOffset { get; init; } + + /// Сколько игровых дней мысль живёт, прежде чем угаснуть. + public float DurationDays { get; init; } = 1f; +} diff --git a/src/LittleSim/Content/Defs/WorldPresetDef.cs b/src/LittleSim/Content/Defs/WorldPresetDef.cs new file mode 100644 index 0000000..a4e4970 --- /dev/null +++ b/src/LittleSim/Content/Defs/WorldPresetDef.cs @@ -0,0 +1,23 @@ +using MrGameEng.Mods; + +namespace LittleSim.Content; + +/// +/// Пресет размера мира (Defs/WorldPresets/): габариты сетки и население. Меню «Новый +/// мир» строит список размеров из этих дефов — новая механика приходит данными мода, а не +/// хардкодом. — ключ локализации (preset.*). +/// +public sealed class WorldPresetDef : Def +{ + /// Ширина мира в клетках. + public int Width { get; init; } + + /// Высота мира в клетках. + public int Height { get; init; } + + /// Число жителей при генерации. + public int Population { get; init; } + + /// Порядок в списке UI (по возрастанию). + public int Order { get; init; } +} diff --git a/src/LittleSim/Content/GameDefs.cs b/src/LittleSim/Content/GameDefs.cs deleted file mode 100644 index 648577f..0000000 --- a/src/LittleSim/Content/GameDefs.cs +++ /dev/null @@ -1,483 +0,0 @@ -using System.Text.Json.Serialization; -using Microsoft.Xna.Framework; -using MrGameEng.Mods; - -namespace LittleSim.Content; - -/// Запись скаттера: с вероятностью на клетку ставится одно растение из . -public sealed class ScatterEntry -{ - /// Вероятность на клетку (0..1); записи бросаются независимо. - public float Chance { get; init; } - - /// Имена , из которых выбирается равновероятно. - public List Options { get; init; } = []; -} - -/// Тип клетки рельефа (Defs/Terrain/): порог высоты, цвет, поверхность, скаттер растений. -public sealed class TerrainDef : Def -{ - /// Верхняя граница высоты (клетка относится к первому дефу, чей порог выше её высоты). - public float MaxHeight { get; init; } - - /// Цвет клетки [r, g, b] для сцен с тонированными квадратами и тайлов без поверхности. - [JsonPropertyName("color")] - public List Rgb { get; init; } = []; - - /// Суша: здесь появляются жители, животные и растения. - public bool IsLand { get; init; } - - /// Светонепроницаемый террейн — базовый окклюдер лайтмапа/теней (сейчас такого нет). - public bool BlocksLight { get; init; } - - /// Ключ текстуры поверхности для тайловой сцены; null — тонированный тайл. - public string? Surface { get; init; } - - /// Растительность, рассыпаемая по клеткам этого террейна. - public List Scatter { get; init; } = []; - - /// - /// Плодородность почвы — множитель скорости роста растений на этом террейне (1 — норма, - /// больше — быстрее). Богатая лесная почва растит быстрее песка/камня. - /// - public float Fertility { get; init; } = 1f; - - /// Цвет тонировки из . - public Color Tint => Rgb is [var r, var g, var b, ..] ? new(r, g, b) : Color.Magenta; -} - -/// -/// Одна стадия роста растения (вложенный объект ): свой спрайт и -/// размер, и сколько игровых дней растение проводит на этой стадии до перехода к следующей. -/// Растёт на месте — последняя стадия терминальная (её игнорируется). -/// -public sealed class PlantGrowthStage -{ - /// Ключ текстуры стадии; null — берётся базовая . - public string? Texture { get; init; } - - /// Размер спрайта в клетках на этой стадии; 0 — берётся базовый . - public float SizeCells { get; init; } - - /// Сколько игровых дней расти на этой стадии до перехода к следующей. - public float GrowDays { get; init; } - - /// Ключ локализации названия стадии (опционально, для будущих тултипов). - public string? Label { get; init; } -} - -/// -/// Продукт (Defs/Products/): что растение даёт при сборе (древесина, трава) или плодоношении -/// (ягоды, жёлудь). Источник истины — деф; растения ссылаются на него по имени, а количество -/// определяют гены. — ключ локализации (product.*). -/// -public sealed class ProductDef : Def -{ - /// Категория продукта (например "material" или "food") — для будущей экономики/инвентаря. - public string Kind { get; init; } = ""; - - /// Масса одной единицы продукта (кг) — основа под перенос/инвентарь (грузоподъёмность). - public float MassKg { get; init; } = 1f; -} - -/// -/// Базовый геном вида (вложенный объект ): оптимумы и толерантности по -/// факторам среды + базовая бодрость роста. Особь при спавне получает аллели рядом с этими базами -/// (разброс — ); фенотип = среднее аллелей. -/// -public sealed class GenomeDef -{ - /// Оптимальная освещённость (0..1) и её толерантность. - public float OptimalLight { get; init; } = 0.7f; - - /// Ширина толерантности по свету. - public float LightTolerance { get; init; } = 0.4f; - - /// Оптимальная температура (°C) и её толерантность. - public float OptimalTemperature { get; init; } = 18f; - - /// Ширина толерантности по температуре (°C) — половина плато полного роста вокруг оптимума. - public float TemperatureTolerance { get; init; } = 12f; - - /// Морозостойкость (°C): на сколько ниже плато рост спадает до нуля (жёсткая нижняя граница). - public float ColdHardiness { get; init; } = 8f; - - /// Жаростойкость (°C): на сколько выше плато рост спадает до нуля (жёсткая верхняя граница). - public float HeatHardiness { get; init; } = 10f; - - /// Оптимальная плодородность почвы и её толерантность. - public float OptimalFertility { get; init; } = 1f; - - /// Ширина толерантности по плодородности. - public float FertilityTolerance { get; init; } = 0.8f; - - /// Базовая бодрость роста (множитель скорости в идеальных условиях). - public float Vigor { get; init; } = 1f; - - /// Продолжительность жизни (игровых дней) до смерти от старости. - public float Lifespan { get; init; } = 80f; - - /// Дальность расселения семян (в клетках). - public float DispersalRange { get; init; } = 2f; - - /// Средний интервал между попытками дать семя (игровых дней). - public float ReproduceInterval { get; init; } = 8f; - - /// Способность к самоопылению (0..1): шанс дать семя без партнёра. - public float SelfPollination { get; init; } = 0.4f; - - /// Темп мутаций (0..1): вероятность сдвига аллеля у потомка. - public float MutationRate { get; init; } = 0.05f; - - /// Доля рецессивного аллеля морфы в стартовой популяции (0..1). - public float VariantChance { get; init; } = 0.15f; - - /// Доля разброса аллелей вокруг базы при генерации особи. - public float Spread { get; init; } = 0.08f; - - /// Плодовитость: сколько плодов несёт зрелое растение в сезон (0 — не плодоносит). - public float FruitYield { get; init; } - - /// Сезон плодоношения (0 — весна, 1 — лето, 2 — осень, 3 — зима). - public float FruitSeason { get; init; } = 1f; - - /// Сколько продукта даёт сбор растения (масштаб ). - public float HarvestAmount { get; init; } = 1f; - - /// Оттенок листвы (0..1) — сдвиг тинта спрайта; ген цвета. - public float LeafHue { get; init; } = 0.33f; - - /// Токсичность (0..1): отравляет поедателя (коэволюция); тормозит рост (цена защиты). - public float Toxicity { get; init; } - - /// Шипы (0..1): травмируют поедателя; тормозят рост (цена защиты). - public float Thorns { get; init; } - - /// Вкусность (0..1): обратная привлекательность для умных травоядных (избегание в C2). - public float Palatability { get; init; } = 1f; -} - -/// Растение (Defs/Plants/): текстура, размер, опциональный ствол-препятствие, стадии роста, геном. -public sealed class PlantDef : Def -{ - /// Ключ текстуры (путь региона в атласах); база для стадий без своей текстуры. - public string Texture { get; init; } = ""; - - /// Базовый размер спрайта в клетках; база для стадий без своего размера. - public float SizeCells { get; init; } = 1f; - - /// Радиус коллайдера ствола в клетках; 0 — без коллайдера (трава, кусты). - public float TrunkRadiusCells { get; init; } - - /// Стадии роста по порядку; пусто — одна терминальная стадия из базовой текстуры. - public List Stages { get; init; } = []; - - /// Базовый геном вида: оптимумы/толерантности по среде и бодрость роста. - public GenomeDef Genome { get; init; } = new(); - - /// Имя , выдаваемого при сборе растения (дерево/трава); null — несборное. - public string? HarvestProduct { get; init; } - - /// Имя , выдаваемого плодами (ягоды/жёлудь); null — не плодоносит. - public string? FruitProduct { get; init; } - - /// Эталонная масса (кг) зрелого растения базового размера; масса особи ∝ (размер/эталон)³. - public float BaseMassKg { get; init; } = 20f; -} - -/// -/// Пресет размера мира (Defs/WorldPresets/): габариты сетки и население. Меню «Новый -/// мир» строит список размеров из этих дефов — новая механика приходит данными мода, а не -/// хардкодом. — ключ локализации (preset.*). -/// -public sealed class WorldPresetDef : Def -{ - /// Ширина мира в клетках. - public int Width { get; init; } - - /// Высота мира в клетках. - public int Height { get; init; } - - /// Число жителей при генерации. - public int Population { get; init; } - - /// Порядок в списке UI (по возрастанию). - public int Order { get; init; } -} - -/// Существо (Defs/Pawns/): текстура, размер и вид (житель/животное). Не sealed — наследует. -public class PawnDef : Def -{ - /// Вид существа: жители ("being") живут в мировой сцене, животные ("animal") — в террейне. - public const string KindBeing = "being"; - - /// Вид животного. - public const string KindAnimal = "animal"; - - /// Ключ текстуры (путь региона в атласах). - public string Texture { get; init; } = ""; - - /// Размер спрайта в клетках. - public float SizeCells { get; init; } = 1f; - - /// Вид: или . - public string Kind { get; init; } = ""; -} - -/// -/// Стадия роста животного как ДАННЫЕ (вложенный объект ): возраст входа = -/// × якорь ( — созревание или продолжительность жизни), -/// масштаб тела и набор текстур ("baby"/"adult"). Стадии перечисляются по возрастанию возраста входа. -/// Модер задаёт любой набор стадий (личинка/куколка/имаго и т.п.) без правки кода. -/// -public sealed class AnimalStageDef -{ - /// Имя стадии (для тултипов/отладки). - public string Name { get; init; } = ""; - - /// Множитель к якорю: возраст входа = EnterAt × (созревание | продолжительность жизни). - public float EnterAt { get; init; } - - /// Якорь возраста входа: "maturity" (созревание) или "lifespan" (продолжительность жизни). - public string RelativeTo { get; init; } = "maturity"; - - /// Масштаб размера тела на этой стадии (× ген размера). - public float Scale { get; init; } = 1f; - - /// Набор текстур: "baby" (детёныш) или "adult" (по полу — самец/самка). - public string Texture { get; init; } = "adult"; - - /// Взрослая стадия — способна размножаться. - public bool Adult { get; init; } -} - -/// -/// Вид-животное (Defs/Animals/): подтип (текстура/размер/вид) плюс геном -/// вида. Поведение, здоровье и стадии живут на компонентах сущности (фазы A2+), а не на типе дефа — -/// существующие 9 животных- остаются валидными. -/// -public sealed class AnimalDef : PawnDef -{ - /// Текстура взрослого самца (фаза A3); пусто — берётся (самка/общая). - public string MaleTexture { get; init; } = ""; - - /// Текстура детёныша/подростка (фаза A3); пусто — берётся . - public string BabyTexture { get; init; } = ""; - - /// Текстура трупа (фаза A5); пусто — берётся . - public string CorpseTexture { get; init; } = ""; - - /// Текстура яйца (тип рождения «яйцекладка»); по умолчанию — общий спрайт семени-точки. - public string EggTexture { get; init; } = "things/plant/seed_default"; - - /// Эталонная масса (кг) взрослой особи базового размера вида; масса особи ∝ (размер/эталон)³. - public float BaseMassKg { get; init; } = 40f; - - /// Имя — анатомия вида (части тела/органы); пусто — без частей тела. - public string Body { get; init; } = ""; - - /// - /// Геном вида как ДАННЫЕ: ключ — id гена (), значение — - /// центр аллелей особи. Состав открыт — модер добавляет/убирает ген одной строкой JSON, без правки - /// кода; разброс берётся из самого гена (GeneDef.Spread). Дискретные гены (напр. пол) сюда - /// не пишутся — назначаются генерацией. - /// - public Dictionary Genome { get; init; } = new(); - - /// - /// Диета как ДАННЫЕ: что вид ест ("plant" — травоядное; "meat" — хищник/падальщик; оба — всеядное). - /// Поиск корма гейтится этим списком — модер задаёт рацион без правки кода. - /// - public string[] Diet { get; init; } = []; - - /// - /// Стадии роста (данные); пусто — встроенный набор Baby→Juvenile→Adult→Senior по умолчанию. - /// - public AnimalStageDef[] Stages { get; init; } = []; - - /// Плотность стартового спавна: голов на 1000 клеток суши (0 — вид не спавнится сам). - public float SpawnPer1000Cells { get; init; } - - /// Базовая скорость передвижения (мировых единиц/сек при гене moveSpeed = 1). - public float BaseSpeed { get; init; } = 28f; - - /// Базовый радиус восприятия в клетках (× ген vision) — поиск корма/воды/партнёра. - public float VisionCells { get; init; } = 14f; - - /// Сила выедания: на сколько игровых дней роста убавляется растение за день кормёжки. - public float ForageBiteDays { get; init; } = 40f; - - /// Дальность атаки в клетках: на этом расстоянии хищник кусает добычу (контакт). - public float AttackRangeCells { get; init; } = 1.2f; - - /// Игровые секунды между ударами (кулдаун дискретной атаки, как в RimWorld). - public float AttackIntervalSec { get; init; } = 1.8f; - - /// Урон части тела добычи ЗА ОДИН УДАР (×силу: размер×навык). 0 — вид не атакует. - public float AttackDamage { get; init; } - - /// Прирост тяжести раны-кровотечения у добычи за один удар (×силу). - public float AttackBleed { get; init; } - - /// Острая кровопотеря добычи за один удар (доля объёма крови, ×силу) — основной путь к смерти. - public float AttackBloodLoss { get; init; } -} - -/// -/// Нужда (Defs/Needs/) как ДАННЫЕ: имя, тип (deplete — убывает, низкое = срочно; drive — растёт под -/// условием, высокое = срочно), действие-исполнитель (по id, см. реестр поведений), темпы и порог -/// интеллекта. Модер добавляет нужду виду/всем одной записью; если она ссылается на существующее -/// действие — без правки кода. Хранилище нужд особи — динамическое (см. NeedSet/AnimalNeeds). -/// -public sealed class NeedDef : Def -{ - /// Тип: "deplete" (убывает, утоляется действием) или "drive" (растёт под условием). - public string Kind { get; init; } = "deplete"; - - /// Id действия-исполнителя, удовлетворяющего нужду (Eat/Drink/Sleep/Mate/…). - public string Action { get; init; } = ""; - - /// Темп убывания за игровой день (drive: темп спада, когда условие отсутствует). - public float DecayPerDay { get; init; } = 0.5f; - - /// drive: темп роста за день, пока активно условие . - public float RisePerDay { get; init; } = 1f; - - /// deplete: насколько за день восполняет нужду исполнение действия. - public float FeedPerDay { get; init; } = 4f; - - /// drive: id хедифа, под которым нужда растёт (напр. "Rut"); null — растёт всегда. - public string? RisesUnder { get; init; } - - /// deplete: на нуле копит счётчик голодной смерти (A3). - public bool Lethal { get; init; } - - /// Порог размера мозга, с которого нужда активна (гейт интеллектом — фаза A7). - public float MinBrain { get; init; } -} - -/// -/// Мысль настроения (Defs/Thoughts/) как ДАННЫЕ (фаза A8, модель thoughts RimWorld): событие -/// (роды/спаривание/утоление жажды/…) вешает на особь временный сдвиг настроения, затухающий за -/// . Триггеры событий — код (реестр AnimalThoughts), а величины/метка — -/// данные: модер правит силу и длительность мысли одной записью JSON, без пересборки. -/// -public sealed class ThoughtDef : Def -{ - /// Сдвиг настроения [-1,1] (положительный — радость, отрицательный — горе). - public float MoodOffset { get; init; } - - /// Сколько игровых дней мысль живёт, прежде чем угаснуть. - public float DurationDays { get; init; } = 1f; -} - -/// -/// Пункт дев-инструмента (Defs/DevTools/) — ЧТОБЫ МОДЫ ДОБАВЛЯЛИ кнопки в дев-спавнер без правки кода. -/// — id обработчика из реестра (spawn-animal/spawn-plant/infect/damage/kill/…), -/// — параметр (defName вида/хедифа). Виды животных/растений спавнер берёт из их -/// дефов сам (модовый контент появляется автоматически); DevToolDef добавляет ПРОЧИЕ действия. -/// -public sealed class DevToolDef : Def -{ - /// Категория для группировки в панели (например "tool"). - public string Category { get; init; } = "tool"; - - /// Id обработчика-инструмента из реестра кода (spawn-animal/spawn-plant/infect/damage/kill). - public string Kind { get; init; } = ""; - - /// Параметр инструмента: defName вида/хедифа и т.п. (по смыслу Kind). - public string Target { get; init; } = ""; -} - -/// -/// Навык (Defs/Skills/) как ДАННЫЕ: уровень особи [0..1] растёт от практики (XP при действии, -/// ускоряется страстью) и медленно угасает без неё к полу . Эффект навыка — -/// код по его id (как у нужд/действий): добыча→выпас, охота→урон, уклонение→бегство. Модер добавляет -/// навык строкой JSON; новый эффект — код. — ключ локализации (skill.*). -/// -public sealed class SkillDef : Def -{ - /// Категория (для группировки в UI), напр. "survival". - public string Category { get; init; } = ""; - - /// Скорость обучения за игровой день непрерывной практики (при страсти ×1). - public float LearnRate { get; init; } = 0.5f; - - /// Угасание уровня за игровой день без практики. - public float DecayPerDay { get; init; } = 0.02f; - - /// Пол угасания (ниже не падает) — «не забывается совсем». - public float FloorLevel { get; init; } -} - -/// -/// Часть тела/орган (вложенный объект ) как ДАННЫЕ: иерархия (родитель), -/// вес попадания, максимум HP (масштабируется размером тела), флаг жизненной важности и вклады в -/// способности (capacity id → доля). Модер задаёт любую анатомию (8 ног, два сердца) без правки кода. -/// -public sealed class BodyPartDef -{ - /// Имя части (уникально в теле); на него ссылается детей. - public string Name { get; init; } = ""; - - /// Имя родительской части ("" — корневая, напр. торс). - public string Parent { get; init; } = ""; - - /// Вес попадания (доля тела) — для будущего выбора задетой части. - public float Coverage { get; init; } = 0.1f; - - /// Базовый максимум HP (× размер тела при создании особи). - public float MaxHp { get; init; } = 10f; - - /// Жизненно важная: уничтожение → смерть. - public bool Vital { get; init; } - - /// Вклады в способности: capacity id (см. AnimalCapacities) → доля (сумма по телу ≈ 1). - public Dictionary Capacities { get; init; } = new(); -} - -/// -/// Анатомия вида (Defs/Bodies/): дерево частей тела/органов. Используется системой здоровья (фаза -/// A5): из неё строятся инстансы частей особи, по их HP/крови считаются способности (capacities). -/// -public sealed class BodyDef : Def -{ - /// Части тела/органы вида. - public BodyPartDef[] Parts { get; init; } = []; -} - -/// -/// Состояние/хедиф организма (Defs/Hediffs/): гон, болезни, позже раны/возрастные эффекты. Фаза A6: -/// болезнь прогрессирует (), параллельно растёт иммунитет -/// () — гонка: иммунитет→1 раньше = выздоровление, severity→ -/// раньше = смерть. Снижает способности () и даёт боль. -/// -public sealed class HediffDef : Def -{ - /// Начальная тяжесть при навешивании (гон — 1; болезнь — малая, напр. 0.05). - public float InitialSeverity { get; init; } = 1f; - - /// Прирост тяжести за игровой день (0 — статичный хедиф вроде гона). - public float SeverityPerDay { get; init; } - - /// Прирост иммунитета за день (0 — иммунитета нет, не выздоравливает сам). - public float ImmunityPerDay { get; init; } - - /// Тяжесть, при которой наступает смерть (0 — не летально). - public float LethalSeverity { get; init; } - - /// Боль при тяжести 1 (масштабируется тяжестью) — вход в сознание/настроение. - public float Pain { get; init; } - - /// Шанс подхватить фоном за день (0 — не фоновая болезнь). - public float AmbientPerDay { get; init; } - - /// - /// Кровопотеря за игровой день при тяжести 1 (масштабируется тяжестью): доля объёма крови, которую - /// рана теряет в сутки. 0 — хедиф не вызывает кровотечения. Кровь на нуле → смерть (см. система здоровья). - /// - public float BloodLossPerDay { get; init; } - - /// Модификаторы способностей: capacity id → множитель при тяжести 1 (лерп 1→factor по severity). - public Dictionary CapMods { get; init; } = new(); -} diff --git a/src/LittleSim/Net/NetLerp.cs b/src/LittleSim/Net/NetLerp.cs new file mode 100644 index 0000000..331ad50 --- /dev/null +++ b/src/LittleSim/Net/NetLerp.cs @@ -0,0 +1,25 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using MrGameEng.Graphics; + +namespace LittleSim.Net; + +/// +/// Сглаживание сетевой позиции между снапшотами. Сервер шлёт ~10 снапшотов в секунду, +/// а рендер идёт на частоте кадра — без сглаживания жители телепортируются рывками. +/// Снапшот пишет в ; система ловит это (позиция +/// разошлась с нарисованной), запоминает цель и каждый кадр экспоненциально подтягивает +/// видимую позицию к цели, записывая её обратно в трансформ для рендера. +/// +public struct NetLerp : IComponent +{ + /// Нарисованная (сглаженная) позиция прошлого кадра. + public Vector2 Visual; + + /// Последняя серверная позиция — цель сглаживания. + public Vector2 Target; + + /// Ложь до первого кадра: стартуем точно с серверной позиции, без подлёта. + public bool Initialized; +} diff --git a/src/LittleSim/Net/NetSmoothing.cs b/src/LittleSim/Net/NetSmoothingSystem.cs similarity index 57% rename from src/LittleSim/Net/NetSmoothing.cs rename to src/LittleSim/Net/NetSmoothingSystem.cs index 281eba1..dae7ab5 100644 --- a/src/LittleSim/Net/NetSmoothing.cs +++ b/src/LittleSim/Net/NetSmoothingSystem.cs @@ -5,25 +5,6 @@ using MrGameEng.Graphics; namespace LittleSim.Net; -/// -/// Сглаживание сетевой позиции между снапшотами. Сервер шлёт ~10 снапшотов в секунду, -/// а рендер идёт на частоте кадра — без сглаживания жители телепортируются рывками. -/// Снапшот пишет в ; система ловит это (позиция -/// разошлась с нарисованной), запоминает цель и каждый кадр экспоненциально подтягивает -/// видимую позицию к цели, записывая её обратно в трансформ для рендера. -/// -public struct NetLerp : IComponent -{ - /// Нарисованная (сглаженная) позиция прошлого кадра. - public Vector2 Visual; - - /// Последняя серверная позиция — цель сглаживания. - public Vector2 Target; - - /// Ложь до первого кадра: стартуем точно с серверной позиции, без подлёта. - public bool Initialized; -} - /// /// Двигает к и пишет результат в /// . Ставится после прокачки сети и до рендера. diff --git a/src/LittleSim/Scenes/ContentHelpers.cs b/src/LittleSim/Scenes/ContentCommands.cs similarity index 58% rename from src/LittleSim/Scenes/ContentHelpers.cs rename to src/LittleSim/Scenes/ContentCommands.cs index cb33af2..ad96cfb 100644 --- a/src/LittleSim/Scenes/ContentHelpers.cs +++ b/src/LittleSim/Scenes/ContentCommands.cs @@ -10,69 +10,6 @@ using MrGameEng.Lighting; namespace LittleSim.Scenes; -/// Спавн растительности по скаттеру дефов террейна. -internal static class ScatterSpawner -{ - // Ниже этой световой пригодности (при полном дневном свете клетки) растение не сеем: теневому виду - // (грибы, optimalLight≈0.15) в открытой клетке не место — без полога крон ему тут не выжить, поэтому - // он и не должен появляться, пока такой ниши нет. Солнечные виды (≥0.7 пригодности) проходят свободно. - private const float ShadeSpawnThreshold = 0.15f; - - /// - /// Бросает каждую запись скаттера клетки независимо; при успехе ставит одно растение со - /// случайным смещением и случайной (детерминированной сидом) зрелостью — мир после генерации - /// разновозрастный. Теневые виды не сеются в ярко освещённые клетки (см. ). - /// Стадия и спрайт берутся из , дальше растением управляет . - /// - public static void Spawn( - Scene scene, - GameContent content, - PlantSet plants, - TerrainDef terrain, - Point cell, - int cellSize, - Random random - ) - { - // Статичная освещённость клетки в полдень: затенена только светонепроницаемым террейном (полог - // крон сейчас не учитывается). По ней решаем, годится ли клетка теневому виду. - var exposure = terrain.BlocksLight ? LightmapBuilder.OccluderShade : 1f; - - foreach (var entry in terrain.Scatter) - { - if (entry.Options.Count == 0 || random.NextSingle() >= entry.Chance) - { - continue; - } - - var def = content.Defs.Get(entry.Options[random.Next(entry.Options.Count)]); - if ( - Suitability.Gaussian(exposure, def.Genome.OptimalLight, def.Genome.LightTolerance) - < ShadeSpawnThreshold - ) - { - continue; // теневому виду в ярко освещённой клетке не место - } - - var index = plants.IndexOf(def); - var px = (cell.X + 0.3f + random.NextSingle() * 0.4f) * cellSize; - var py = (cell.Y + 0.3f + random.NextSingle() * 0.4f) * cellSize; - var age = random.NextSingle() * plants[index].MaturityDays; - - PlantFactory.Create( - scene.Store, - plants, - index, - new Vector2(px, py), - age, - plants[index].Template.Generate(random), - terrain.Fertility, - cellSize - ); - } - } -} - /// Консольные команды контента: моды, язык, дефы, атласы (общие для сцен). internal static class ContentCommands { diff --git a/src/LittleSim/Scenes/ScatterSpawner.cs b/src/LittleSim/Scenes/ScatterSpawner.cs new file mode 100644 index 0000000..6ae0f96 --- /dev/null +++ b/src/LittleSim/Scenes/ScatterSpawner.cs @@ -0,0 +1,74 @@ +using Friflo.Engine.ECS; +using LittleSim.Content; +using LittleSim.Sim; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.DevConsole; +using MrGameEng.Graphics; +using MrGameEng.Lighting; + +namespace LittleSim.Scenes; + +/// Спавн растительности по скаттеру дефов террейна. +internal static class ScatterSpawner +{ + // Ниже этой световой пригодности (при полном дневном свете клетки) растение не сеем: теневому виду + // (грибы, optimalLight≈0.15) в открытой клетке не место — без полога крон ему тут не выжить, поэтому + // он и не должен появляться, пока такой ниши нет. Солнечные виды (≥0.7 пригодности) проходят свободно. + private const float ShadeSpawnThreshold = 0.15f; + + /// + /// Бросает каждую запись скаттера клетки независимо; при успехе ставит одно растение со + /// случайным смещением и случайной (детерминированной сидом) зрелостью — мир после генерации + /// разновозрастный. Теневые виды не сеются в ярко освещённые клетки (см. ). + /// Стадия и спрайт берутся из , дальше растением управляет . + /// + public static void Spawn( + Scene scene, + GameContent content, + PlantSet plants, + TerrainDef terrain, + Point cell, + int cellSize, + Random random + ) + { + // Статичная освещённость клетки в полдень: затенена только светонепроницаемым террейном (полог + // крон сейчас не учитывается). По ней решаем, годится ли клетка теневому виду. + var exposure = terrain.BlocksLight ? LightmapBuilder.OccluderShade : 1f; + + foreach (var entry in terrain.Scatter) + { + if (entry.Options.Count == 0 || random.NextSingle() >= entry.Chance) + { + continue; + } + + var def = content.Defs.Get(entry.Options[random.Next(entry.Options.Count)]); + if ( + Suitability.Gaussian(exposure, def.Genome.OptimalLight, def.Genome.LightTolerance) + < ShadeSpawnThreshold + ) + { + continue; // теневому виду в ярко освещённой клетке не место + } + + var index = plants.IndexOf(def); + var px = (cell.X + 0.3f + random.NextSingle() * 0.4f) * cellSize; + var py = (cell.Y + 0.3f + random.NextSingle() * 0.4f) * cellSize; + var age = random.NextSingle() * plants[index].MaturityDays; + + PlantFactory.Create( + scene.Store, + plants, + index, + new Vector2(px, py), + age, + plants[index].Template.Generate(random), + terrain.Fertility, + cellSize + ); + } + } +} diff --git a/src/LittleSim/Scenes/WorldScene.Commands.cs b/src/LittleSim/Scenes/WorldScene.Commands.cs new file mode 100644 index 0000000..664b6a3 --- /dev/null +++ b/src/LittleSim/Scenes/WorldScene.Commands.cs @@ -0,0 +1,669 @@ +using System; +using System.Collections.Generic; +using Friflo.Engine.ECS; +using LittleSim.App; +using LittleSim.Content; +using LittleSim.Sim; +using LittleSim.UI; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; +using MrGameEng.Assets; +using MrGameEng.Core; +using MrGameEng.DevConsole; +using MrGameEng.Formulas; +using MrGameEng.Genetics; +using MrGameEng.Graphics; +using MrGameEng.Host; +using MrGameEng.Input; +using MrGameEng.Inspector; +using MrGameEng.Lighting; +using MrGameEng.Tilemaps; +using MrGameEng.UI; +using Myra.Graphics2D; +using Myra.Graphics2D.Brushes; +using Myra.Graphics2D.UI; + +namespace LittleSim.Scenes; + +// Часть WorldScene: регистрация и обработчики команд дев-консоли. +public sealed partial class WorldScene +{ + private void RegisterCommands(DevConsole console, GameContent content, ModAtlases atlases) + { + ContentCommands.Register(console, content, atlases); + console.Register( + "regen", + "regen [seed] — regenerate the world with a new seed", + (c, args) => + { + if (Context.Scenes.IsTransitioning) + { + return; + } + + var seed = args.Length > 0 ? int.Parse(args[0]) : Random.Shared.Next(); + c.WriteLine($"regenerating world, seed {seed}"); + Context.Scenes.Switch( + new WorldScene(_config with { Seed = seed }), + Transitions.Fade(0.6f) + ); + } + ); + console.Register( + "formula", + "formula — compile and evaluate an expression (gene-formula engine demo)", + (c, args) => + { + if (args.Length == 0) + { + c.WriteLine( + "usage: formula e.g. formula clamp(lerp(0, 10, 0.5), 0, 8)" + ); + return; + } + + var expression = string.Join(' ', args); + try + { + c.WriteLine($"{expression} = {Formula.Compile(expression).Evaluate()}"); + } + catch (FormulaException e) + { + c.WriteLine($"error: {e.Message}"); + } + } + ); + console.Register( + "gene", + "gene [seed] — generate a genome from Gene defs, show alleles/traits and a bred child", + (c, args) => RunGeneDemo(c, content, args) + ); + console.Register( + "plant", + "plant [seed] — sample a species genome and show its gene-driven traits/products", + (c, args) => RunPlantDemo(c, content, args) + ); + console.Register( + "animal", + "animal [seed] — sample an animal genome and show its gene-driven traits", + (c, args) => RunAnimalDemo(c, content, args) + ); + console.Register( + "diet", + "diet [species] — gene-derived diet (herbivory/carnivory/omnivory), role and hunting stats", + (c, args) => RunDiet(c, content, args) + ); + console.Register( + "popstats", + "popstats [species] — live population trait means and generation span (selection drift)", + (c, args) => RunPopStats(c, args) + ); + console.Register( + "body", + "body — dump a species body parts and a healthy individual's capacities", + (c, args) => RunBodyDemo(c, content, args) + ); + console.Register( + "infect", + "infect [hediff] — give every animal a disease (default Fever) to test the health system", + (c, args) => RunInfect(c, content, args) + ); + console.Register( + "intel", + "intel [consciousness] — effective intelligence (brain x consciousness) and which needs it gates", + (c, args) => RunIntel(c, content, args) + ); + console.Register( + "mood", + "mood [species] — live population mood (avg/min/max) and content/stressed counts", + (c, args) => RunMood(c, args) + ); + console.Register( + "skills", + "skills — mean skill levels across living animals (grow from practice, decay unused)", + (c, _) => RunSkills(c) + ); + console.Register( + "menu", + "menu — return to the main menu", + (_, _) => Switch(new MainMenuScene()) + ); + } + + // Демонстрация генной системы (фаза G2): из Gene-дефов генерируем геном, печатаем аллели, + // выраженные значения и признаки (вычисленные формулами), затем скрещиваем двух особей. + private static void RunGeneDemo(DevConsole console, GameContent content, string[] args) + { + var genes = content.Defs.All(); + if (genes.Count == 0) + { + console.WriteLine("no Gene defs loaded"); + return; + } + + var registry = genes.ToDictionary(g => g.DefName, g => g, StringComparer.Ordinal); + var seed = args.Length > 0 ? int.Parse(args[0]) : Random.Shared.Next(); + var random = new Random(seed); + + console.WriteLine($"genome from {genes.Count} genes, seed {seed}:"); + var parentA = Genome.Generate(genes, random); + foreach (var gene in genes) + { + var allele = parentA[gene.DefName]; + console.WriteLine( + $" {gene.DefName}: [{allele.A:0.##}, {allele.B:0.##}] -> {parentA.Express(gene):0.###}" + ); + } + + console.WriteLine("traits:"); + foreach (var (trait, value) in Phenotype.Compute(parentA, registry).OrderBy(t => t.Key)) + { + console.WriteLine($" {trait} = {value:0.###}"); + } + + var parentB = Genome.Generate(genes, random); + var child = Genome.Breed(parentA, parentB, registry, random); + var childTraits = Phenotype.Compute(child, registry); + console.WriteLine( + $"bred child: {child.Alleles.Count} genes, " + + $"vigor={childTraits.GetValueOrDefault("vigor"):0.###}, " + + $"lifespan={childTraits.GetValueOrDefault("lifespan"):0.#}, " + + $"variant={childTraits.GetValueOrDefault("variant"):0}" + ); + } + + // Демонстрация генного контента (фаза G4): по виду берём его набор генов, генерируем особь и + // печатаем признаки роста/жизни и продукты (плоды/добыча с количеством от генов). + private void RunPlantDemo(DevConsole console, GameContent content, string[] args) + { + var species = content.Defs.NamesOf("Plant"); + if (args.Length == 0) + { + console.WriteLine("usage: plant [seed] e.g. plant TreeOakA"); + console.WriteLine($"species: {string.Join(", ", species)}"); + return; + } + + if (!content.Defs.TryGet(args[0], out var def)) + { + console.WriteLine($"no plant '{args[0]}'; species: {string.Join(", ", species)}"); + return; + } + + var index = _plants.IndexOf(def); + var seed = args.Length > 1 ? int.Parse(args[1]) : Random.Shared.Next(); + var genome = _plants[index].Template.Generate(new Random(seed)); + var traits = PlantPhenotype.FromTraits(Phenotype.Compute(genome, _plants.GeneRegistry)); + + console.WriteLine( + $"{def.DefName} (seed {seed}){(traits.IsVariant ? " [variant morph]" : "")}:" + ); + console.WriteLine( + $" vigor {traits.Vigor:0.##}, lifespan {traits.Lifespan:0} d, " + + $"optimalLight {traits.OptimalLight:0.##}, leafHue {traits.LeafHue:0.##}" + ); + var (tMin, tLow, tHigh, tMax) = traits.TemperatureBand(); + console.WriteLine( + $" temp: grows {tMin:0.#}..{tMax:0.#}°C, optimal {tLow:0.#}..{tHigh:0.#}°C " + + $"(cold {traits.ColdHardiness:0.#}, heat {traits.HeatHardiness:0.#})" + ); + console.WriteLine( + $" defense: toxicity {traits.Toxicity:0.##}, thorns {traits.Thorns:0.##}, " + + $"palatability {traits.Palatability:0.##} (growth ×{traits.DefenseGrowthFactor():0.##})" + ); + console.WriteLine($" mass (mature): {def.BaseMassKg:0.##} kg"); + + if (def.HarvestProduct is { } harvest) + { + console.WriteLine( + $" harvest: {ProductLabel(content, harvest)} x{traits.HarvestAmount:0.#}" + ); + } + + if (def.FruitProduct is { } fruit && traits.FruitYield >= 1f) + { + var season = content.Languages.Get( + $"season.{((Season)traits.FruitSeason).ToString().ToLowerInvariant()}" + ); + console.WriteLine( + $" fruit: {ProductLabel(content, fruit)} x{traits.FruitYield:0.#} in {season}" + ); + } + else + { + console.WriteLine(" barren (no fruit)"); + } + } + + // Демонстрация животного (фаза A1): по виду берём его набор генов, генерируем особь и печатаем + // признаки, вычисленные формулами генов (тело/мозг/метаболизм/чувства/мех). + private void RunAnimalDemo(DevConsole console, GameContent content, string[] args) + { + var species = content.Defs.NamesOf("Animal"); + if (args.Length == 0) + { + console.WriteLine("usage: animal [seed] e.g. animal Deer"); + console.WriteLine($"species: {string.Join(", ", species)}"); + return; + } + + if (!content.Defs.TryGet(args[0], out var def)) + { + console.WriteLine($"no animal '{args[0]}'; species: {string.Join(", ", species)}"); + return; + } + + var index = _animals.IndexOf(def); + var seed = args.Length > 1 ? int.Parse(args[1]) : Random.Shared.Next(); + var genome = _animals.GenerateGenome(index, new Random(seed)); + var traits = AnimalPhenotype.FromTraits(Phenotype.Compute(genome, _animals.GeneRegistry)); + var sex = AnimalFactory.IsMaleGenome(genome) ? "male" : "female"; + + console.WriteLine($"{def.DefName} ({sex}, seed {seed}):"); + console.WriteLine( + $" body {traits.BodySize:0.##} cells, maturity {traits.MaturityAge:0} d, " + + $"lifespan {traits.Lifespan:0} d, brain {traits.BrainSize:0.##}, metabolism {traits.Metabolism:0.##}" + ); + console.WriteLine( + $" move {traits.MoveSpeed:0.##}, vision {traits.Vision:0.##}, blood {traits.BloodVolume:0.##}, " + + $"insulation {traits.Insulation:0.##}, furHue {traits.FurHue:0.##}" + ); + console.WriteLine( + $" diet: herb {traits.Herbivory:0.##} / carn {traits.Carnivory:0.##} / omni {traits.Omnivory:0.##}, " + + $"toxinTolerance {traits.ToxinTolerance:0.##}" + ); + console.WriteLine( + $" reproduction: {(traits.Oviparous ? "oviparous (lays eggs)" : "viviparous (live birth)")}, " + + $"gestation {traits.GestationDays:0} d, litter {traits.LitterSize:0.#}" + ); + console.WriteLine( + $" behaviour: insulation {traits.Insulation:0.##}, sociability {traits.Sociability:0.##}" + ); + var adultMass = Mass.OfAnimal(def, traits, 1f); + console.WriteLine( + $" mass: {adultMass:0.#} kg (adult), carries ~{Mass.CarryCapacity(adultMass, 1f):0.#} kg" + ); + } + + // Диета из генов (предатор-кластер): по каждому виду печатает herbivory/carnivory/omnivory, + // выведенный рацион (ест растения/мясо), роль и боевые параметры — проверка, что хищники заданы. + private void RunDiet(DevConsole console, GameContent content, string[] args) + { + var species = content.Defs.NamesOf("Animal"); + for (var s = 0; s < _animals.Count; s++) + { + var sp = _animals[s]; + if ( + args.Length > 0 + && !string.Equals(sp.Def.DefName, args[0], StringComparison.OrdinalIgnoreCase) + ) + { + continue; + } + + var genome = _animals.GenerateGenome(s, new Random(0x01E7 + s)); + var traits = AnimalPhenotype.FromTraits( + Phenotype.Compute(genome, _animals.GeneRegistry) + ); + var (eatsPlants, eatsMeat) = AnimalFactory.Diet(traits, sp.Def); + var role = + eatsMeat ? (eatsPlants ? "omnivore" : "carnivore") + : eatsPlants ? "herbivore" + : "none"; + console.WriteLine( + $"{sp.Def.DefName}: {role} — herb {traits.Herbivory:0.##} / carn {traits.Carnivory:0.##} / " + + $"omni {traits.Omnivory:0.##} -> plants={eatsPlants} meat={eatsMeat}, spawn {sp.Def.SpawnPer1000Cells:0.##}/1000" + ); + if (eatsMeat && sp.Def.AttackDamage > 0f) + { + console.WriteLine( + $" hunt: dmg {sp.Def.AttackDamage:0}/d, bleed {sp.Def.AttackBleed:0.#}/d, " + + $"bloodloss {sp.Def.AttackBloodLoss:0.#}/d, range {sp.Def.AttackRangeCells:0.#} cells" + ); + } + } + + if (args.Length == 0) + { + console.WriteLine($"species: {string.Join(", ", species)}"); + } + } + + // Наблюдаемость отбора (фаза A2): средние ключевых признаков живой популяции и размах поколений — + // видно дрейф генов под отбором. Без аргумента — все виды; с аргументом — один вид. + private void RunPopStats(DevConsole console, string[] args) + { + if (_animals.Count == 0) + { + console.WriteLine("no animal species"); + return; + } + + var count = new int[_animals.Count]; + var body = new double[_animals.Count]; + var brain = new double[_animals.Count]; + var life = new double[_animals.Count]; + var move = new double[_animals.Count]; + var tox = new double[_animals.Count]; + var maxGen = new int[_animals.Count]; + + Store + .Query() + .ForEachEntity( + (ref AnimalOrganism o, Entity _) => + { + var s = o.Species; + count[s]++; + body[s] += o.Traits.BodySize; + brain[s] += o.Traits.BrainSize; + life[s] += o.Traits.Lifespan; + move[s] += o.Traits.MoveSpeed; + tox[s] += o.Traits.ToxinTolerance; + if (o.Generation > maxGen[s]) + { + maxGen[s] = o.Generation; + } + } + ); + + var filter = args.Length > 0 ? args[0] : null; + var any = false; + for (var s = 0; s < _animals.Count; s++) + { + var name = _animals[s].Def.DefName; + if ( + count[s] == 0 + || ( + filter is not null + && !string.Equals(name, filter, StringComparison.OrdinalIgnoreCase) + ) + ) + { + continue; + } + + any = true; + var c = count[s]; + console.WriteLine( + $"{name}: n={c}, gen 0..{maxGen[s]}, body {body[s] / c:0.##}, " + + $"brain {brain[s] / c:0.##}, lifespan {life[s] / c:0} d, move {move[s] / c:0.##}, " + + $"toxinTol {tox[s] / c:0.##}" + ); + } + + if (!any) + { + console.WriteLine(filter is null ? "no animals alive" : $"no '{filter}' alive"); + } + + // Другая сторона гонки вооружений: средняя защита живых растений (коэволюция C2). Без фильтра. + if (filter is null) + { + var pn = 0; + double pTox = 0, + pThorn = 0, + pPalat = 0; + Store + .Query() + .ForEachEntity( + (ref PlantOrganism o, Entity _) => + { + pn++; + pTox += o.Traits.Toxicity; + pThorn += o.Traits.Thorns; + pPalat += o.Traits.Palatability; + } + ); + if (pn > 0) + { + console.WriteLine( + $"plants: n={pn}, toxicity {pTox / pn:0.###}, thorns {pThorn / pn:0.###}, " + + $"palatability {pPalat / pn:0.###}" + ); + } + } + } + + // Наблюдаемость здоровья (фаза A5): дерево частей тела вида и способности здоровой особи. + private void RunBodyDemo(DevConsole console, GameContent content, string[] args) + { + var species = content.Defs.NamesOf("Animal"); + if (args.Length == 0) + { + console.WriteLine("usage: body e.g. body Deer"); + console.WriteLine($"species: {string.Join(", ", species)}"); + return; + } + + if (!content.Defs.TryGet(args[0], out var def)) + { + console.WriteLine($"no animal '{args[0]}'; species: {string.Join(", ", species)}"); + return; + } + + if (string.IsNullOrEmpty(def.Body) || !content.Defs.TryGet(def.Body, out var body)) + { + console.WriteLine($"{def.DefName}: no body"); + return; + } + + console.WriteLine($"{def.DefName} body '{body.DefName}': {body.Parts.Length} parts"); + foreach (var part in body.Parts) + { + var caps = + part.Capacities.Count > 0 + ? " [" + + string.Join( + ", ", + part.Capacities.Select(kv => $"{kv.Key} {kv.Value:0.##}") + ) + + "]" + : ""; + console.WriteLine( + $" {part.Name}{(part.Vital ? " *vital" : "")} hp{part.MaxHp:0}{caps}" + ); + } + + var state = HealthState.Create(body, 1f); + console.WriteLine( + "capacities (healthy): " + + string.Join( + ", ", + state.Capacities.OrderBy(k => k.Key).Select(kv => $"{kv.Key} {kv.Value:0.##}") + ) + ); + } + + // Тест системы здоровья (фаза A6): навешивает болезнь всем живым животным. + private void RunInfect(DevConsole console, GameContent content, string[] args) + { + var name = args.Length > 0 ? args[0] : "Fever"; + if (!content.Defs.TryGet(name, out var def)) + { + console.WriteLine($"no hediff '{name}'"); + return; + } + + var count = 0; + Store + .Query() + .ForEachEntity( + (ref Health h, Entity _) => + { + h.State?.Add(def); + count++; + } + ); + console.WriteLine($"infected {count} animals with {def.DefName}"); + } + + // Наблюдаемость интеллекта (фаза A7): мозг особи (ген) × сознание = эффективный интеллект, который + // гейтит набор нужд/действий. Показывает активные/отключённые нужды при заданном уровне сознания + // (по умолчанию 1 — здоров; меньше — имитация повреждения мозга/болезни/боли). + private void RunIntel(DevConsole console, GameContent content, string[] args) + { + var species = content.Defs.NamesOf("Animal"); + if (args.Length == 0) + { + console.WriteLine( + "usage: intel [consciousness 0..1] [seed] e.g. intel Deer 0.6" + ); + console.WriteLine($"species: {string.Join(", ", species)}"); + return; + } + + if (!content.Defs.TryGet(args[0], out var def)) + { + console.WriteLine($"no animal '{args[0]}'; species: {string.Join(", ", species)}"); + return; + } + + var consciousness = + args.Length > 1 + ? Math.Clamp( + float.Parse(args[1], System.Globalization.CultureInfo.InvariantCulture), + 0f, + 1f + ) + : 1f; + var seed = args.Length > 2 ? int.Parse(args[2]) : Random.Shared.Next(); + var index = _animals.IndexOf(def); + var genome = _animals.GenerateGenome(index, new Random(seed)); + var traits = AnimalPhenotype.FromTraits(Phenotype.Compute(genome, _animals.GeneRegistry)); + var intelligence = traits.BrainSize * consciousness; + + console.WriteLine( + $"{def.DefName} (seed {seed}): brain {traits.BrainSize:0.##} x consciousness {consciousness:0.##} " + + $"= intelligence {intelligence:0.###}" + ); + var active = new List(); + var gated = new List(); + for (var k = 0; k < _needs.Count; k++) + { + var need = _needs[k]; + (need.MinBrain > intelligence ? gated : active).Add( + $"{need.DefName}({need.MinBrain:0.##})" + ); + } + + console.WriteLine($" active: {(active.Count > 0 ? string.Join(", ", active) : "—")}"); + console.WriteLine($" gated: {(gated.Count > 0 ? string.Join(", ", gated) : "—")}"); + } + + // Наблюдаемость настроения (фаза A8): среднее/мин/макс настроения живой популяции по виду и счётчики + // довольных (>0.7) / угнетённых (<0.35). Считает только особей с активным настроением (тир 4+). + private void RunMood(DevConsole console, string[] args) + { + if (_animals.Count == 0) + { + console.WriteLine("no animal species"); + return; + } + + var count = new int[_animals.Count]; + var sum = new double[_animals.Count]; + var min = new float[_animals.Count]; + var max = new float[_animals.Count]; + var content_ = new int[_animals.Count]; + var stressed = new int[_animals.Count]; + Array.Fill(min, 1f); + + Store + .Query() + .ForEachEntity( + (ref AnimalOrganism o, ref Mood m, Entity _) => + { + if (!m.Active) + { + return; // тир ниже зверя — настроения нет + } + + var s = o.Species; + count[s]++; + sum[s] += m.Value; + min[s] = MathF.Min(min[s], m.Value); + max[s] = MathF.Max(max[s], m.Value); + if (m.Value > 0.7f) + { + content_[s]++; + } + else if (m.Value < 0.35f) + { + stressed[s]++; + } + } + ); + + var filter = args.Length > 0 ? args[0] : null; + var any = false; + for (var s = 0; s < _animals.Count; s++) + { + var name = _animals[s].Def.DefName; + if ( + count[s] == 0 + || ( + filter is not null + && !string.Equals(name, filter, StringComparison.OrdinalIgnoreCase) + ) + ) + { + continue; + } + + any = true; + console.WriteLine( + $"{name}: n={count[s]}, mood {sum[s] / count[s]:0.##} (min {min[s]:0.##}, max {max[s]:0.##}), " + + $"content {content_[s]}, stressed {stressed[s]}" + ); + } + + if (!any) + { + console.WriteLine(filter is null ? "no animals with mood" : $"no '{filter}' with mood"); + } + } + + // Наблюдаемость навыков: средний уровень каждого навыка по живым животным (растут от практики). + private void RunSkills(DevConsole console) + { + if (_skills.Count == 0) + { + console.WriteLine("no skill defs loaded"); + return; + } + + var sum = new double[_skills.Count]; + var n = 0; + Store + .Query() + .ForEachEntity( + (ref Skills s, Entity _) => + { + if (s.Levels is null) + { + return; + } + + n++; + for (var i = 0; i < _skills.Count && i < s.Levels.Length; i++) + { + sum[i] += s.Levels[i]; + } + } + ); + + console.WriteLine($"skills over {n} animals (mean level):"); + for (var i = 0; i < _skills.Count; i++) + { + console.WriteLine( + $" {_skills[i].DefName}: {(n > 0 ? sum[i] / n : 0):0.###} " + + $"(learn {_skills[i].LearnRate:0.##}/d, decay {_skills[i].DecayPerDay:0.###}/d)" + ); + } + } + + private static string ProductLabel(GameContent content, string productDefName) => + content.Defs.TryGet(productDefName, out var product) + ? content.Languages.Get(product.Label) + : productDefName; +} diff --git a/src/LittleSim/Scenes/WorldScene.SaveLoad.cs b/src/LittleSim/Scenes/WorldScene.SaveLoad.cs new file mode 100644 index 0000000..b161c59 --- /dev/null +++ b/src/LittleSim/Scenes/WorldScene.SaveLoad.cs @@ -0,0 +1,463 @@ +using System; +using System.Collections.Generic; +using Friflo.Engine.ECS; +using LittleSim.App; +using LittleSim.Content; +using LittleSim.Sim; +using LittleSim.UI; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; +using MrGameEng.Assets; +using MrGameEng.Core; +using MrGameEng.DevConsole; +using MrGameEng.Formulas; +using MrGameEng.Genetics; +using MrGameEng.Graphics; +using MrGameEng.Host; +using MrGameEng.Input; +using MrGameEng.Inspector; +using MrGameEng.Lighting; +using MrGameEng.Tilemaps; +using MrGameEng.UI; +using Myra.Graphics2D; +using Myra.Graphics2D.Brushes; +using Myra.Graphics2D.UI; + +namespace LittleSim.Scenes; + +// Часть WorldScene: сохранение/загрузка мира и стартовый спавн животных. +public sealed partial class WorldScene +{ + private string SaveWorld() + { + var save = new WorldSave + { + Name = _config.Name, + Width = _config.Width, + Height = _config.Height, + Seed = _config.Seed, + SmoothPasses = _config.SmoothPasses, + SavedUtc = DateTime.UtcNow, + ElapsedSeconds = Context.Clock.TotalTime, + }; + + // Снимок всей популяции растений: вид (деф), позиция, возраст, стадия, почва и геном. + Store + .Query() + .ForEachEntity( + ( + ref PlantGrowth grow, + ref PlantOrganism org, + ref Transform2D transform, + Entity _ + ) => + { + save.Plants.Add( + new PlantSave + { + Species = _plants[grow.Species].Def.DefName, + X = transform.Position.X, + Y = transform.Position.Y, + AgeDays = grow.AgeDays, + Stage = grow.Stage, + CellFertility = grow.CellFertility, + Genome = org.Genome.ToDictionary(), + } + ); + } + ); + + SaveAnimals(save); + + // Население — живой счётчик животных на момент сохранения (а не статичный пресет конфига). + save.Population = save.Animals.Count; + + new SaveStore().Write(save); + Log.Info( + $"World '{_config.Name}' saved ({save.Plants.Count} plants, " + + $"{save.Animals.Count} animals, {save.Corpses.Count} corpses, {save.Eggs.Count} eggs)" + ); + return _config.Name; + } + + // Снимок популяции животных и трупов: всё нужное, чтобы эволюция/состояние пережили сейв/лоад + // (а не пересыпались из сида). Health/Mood/Pregnant берём через entity — запрос ограничен 5 типами. + private void SaveAnimals(WorldSave save) + { + Store + .Query() + .ForEachEntity( + ( + ref AnimalOrganism org, + ref AnimalGrowth grow, + ref AnimalNeeds needs, + ref Transform2D transform, + Entity entity + ) => + { + var record = new AnimalSave + { + Species = _animals[org.Species].Def.DefName, + X = transform.Position.X, + Y = transform.Position.Y, + Generation = org.Generation, + Stage = grow.Stage, + AgeDays = grow.AgeDays, + Genome = org.Genome.ToDictionary(), + Needs = needs.Values, + Starve = needs.Starve, + }; + + if (entity.GetComponent().State is { } health) + { + record.BloodLevel = health.BloodLevel; + record.PartHp = health.Parts.Select(p => p.Hp).ToArray(); + foreach (var hediff in health.Hediffs) + { + record.Hediffs.Add( + new HediffSave + { + DefName = hediff.Def.DefName, + Severity = hediff.Severity, + Immunity = hediff.Immunity, + } + ); + } + } + + var mood = entity.GetComponent(); + record.Mood = mood.Value; + record.MoodActive = mood.Active; + if (mood.Thoughts is not null) + { + foreach (var thought in mood.Thoughts) + { + record.Thoughts.Add( + new ThoughtSave + { + DefName = thought.Def.DefName, + RemainingDays = thought.RemainingDays, + } + ); + } + } + + if (entity.TryGetComponent(out var preg)) + { + record.Pregnant = true; + record.FatherGenome = preg.FatherGenome.ToDictionary(); + record.DueInDays = preg.DueInDays; + record.Litter = preg.Litter; + } + + if (entity.TryGetComponent(out var sk)) + { + record.SkillLevels = sk.Levels ?? []; + record.SkillPassions = sk.Passions is null + ? [] + : Array.ConvertAll(sk.Passions, p => (int)p); + } + + save.Animals.Add(record); + } + ); + + Store + .Query() + .ForEachEntity( + (ref Corpse corpse, ref Transform2D transform, Entity _) => + save.Corpses.Add( + new CorpseSave + { + Species = _animals[corpse.Species].Def.DefName, + X = transform.Position.X, + Y = transform.Position.Y, + RotDays = corpse.RotDays, + BodySize = corpse.BodySize, + Meat = corpse.Meat, + } + ) + ); + + Store + .Query() + .ForEachEntity( + (ref Egg egg, ref Transform2D transform, Entity _) => + save.Eggs.Add( + new EggSave + { + Species = _animals[egg.Species].Def.DefName, + X = transform.Position.X, + Y = transform.Position.Y, + Generation = egg.Generation, + IncubateDays = egg.IncubateDays, + Genome = egg.Genome?.ToDictionary() ?? new(), + } + ) + ); + } + + private void RestorePlants(GameContent content) + { + var fallback = new Random(_config.Seed); // для старых/битых сейвов без генома + foreach (var plant in _save!.Plants) + { + if (!content.Defs.TryGet(plant.Species, out var def)) + { + continue; // вид пропал (мод убрали) — пропускаем растение + } + + var index = _plants.IndexOf(def); + var genome = plant.Genome is { Count: > 0 } + ? new Genome(plant.Genome) + : _plants[index].Template.Generate(fallback); + PlantFactory.Create( + Store, + _plants, + index, + new Vector2(plant.X, plant.Y), + plant.AgeDays, + genome, + plant.CellFertility, + CellSize + ); + } + } + + // Восстанавливает популяцию животных и трупы из сейва: фабрика собирает особь из генома (геном + // несёт и пол), затем динамическое состояние (нужды/стадия/здоровье/настроение/беременность) + // накладывается поверх. Старый/битый сейв без генома — особь регенерируется из шаблона вида. + private void RestoreAnimals(GameContent content) + { + var fallback = new Random(_config.Seed); + foreach (var saved in _save!.Animals) + { + if (!content.Defs.TryGet(saved.Species, out var def)) + { + continue; // вид пропал (мод убрали) — пропускаем особь + } + + var index = _animals.IndexOf(def); + var genome = saved.Genome is { Count: > 0 } + ? new Genome(saved.Genome) + : _animals.GenerateGenome(index, fallback); + var entity = AnimalFactory.Create( + Store, + _animals, + _needs, + _skills, + index, + new Vector2(saved.X, saved.Y), + saved.AgeDays, + genome, + saved.Generation, + CellSize + ); + RestoreAnimalState(entity, saved, content); + } + + foreach (var saved in _save.Corpses) + { + if (!content.Defs.TryGet(saved.Species, out var def)) + { + continue; + } + + var corpse = AnimalFactory.CreateCorpse( + Store, + _animals, + _animals.IndexOf(def), + new Vector2(saved.X, saved.Y), + saved.BodySize, + saved.Meat, + CellSize + ); + ref var data = ref corpse.GetComponent(); + data.RotDays = saved.RotDays; + data.Stage = -1; // форсируем пере-тинт стадии в CorpseSystem на следующем тике + } + + foreach (var saved in _save.Eggs) + { + if (!content.Defs.TryGet(saved.Species, out var def)) + { + continue; // вид пропал (мод убрали) — пропускаем яйцо + } + + var index = _animals.IndexOf(def); + var genome = saved.Genome is { Count: > 0 } + ? new Genome(saved.Genome) + : _animals.GenerateGenome(index, fallback); + AnimalFactory.CreateEgg( + Store, + _animals, + index, + new Vector2(saved.X, saved.Y), + genome, + saved.Generation, + saved.IncubateDays, + CellSize + ); + } + + Log.Info( + $"Restored {_save.Animals.Count} animals, {_save.Corpses.Count} corpses, {_save.Eggs.Count} eggs from save" + ); + } + + // Накладывает на свежесозданную особь сохранённое динамическое состояние. Ref-компоненты правим до + // AddComponent(Pregnant) (структурное изменение инвалидирует ссылки); Health — managed, безопасен. + private void RestoreAnimalState(Entity entity, AnimalSave saved, GameContent content) + { + ref var needs = ref entity.GetComponent(); + if (saved.Needs is { Length: > 0 } && saved.Needs.Length == _needs.Count) + { + needs.Values = saved.Needs; // состав совпал — иначе остаются свежие из фабрики + } + + needs.Starve = saved.Starve; + entity.GetComponent().Stage = saved.Stage; + + ref var mood = ref entity.GetComponent(); + mood.Value = saved.Mood; + mood.Active = saved.MoodActive; + mood.Thoughts.Clear(); + foreach (var thought in saved.Thoughts) + { + if (content.Defs.TryGet(thought.DefName, out var tdef)) + { + mood.Thoughts.Add( + new ThoughtInstance { Def = tdef, RemainingDays = thought.RemainingDays } + ); + } + } + + if (entity.GetComponent().State is { } health) + { + health.BloodLevel = saved.BloodLevel; + // HP частей применяем только при точном совпадении анатомии (как для нужд/навыков): иначе + // мод сменил состав/порядок частей тела, и значения легли бы не на те части — оставляем свежее. + if (saved.PartHp.Length == health.Parts.Length) + { + for (var i = 0; i < health.Parts.Length; i++) + { + health.Parts[i].Hp = saved.PartHp[i]; + } + } + else if (saved.PartHp.Length > 0) + { + Log.Warning( + $"Скелет вида '{saved.Species}' изменился ({saved.PartHp.Length}→{health.Parts.Length} частей) — HP частей не восстановлены." + ); + } + + foreach (var hediff in saved.Hediffs) + { + if (content.Defs.TryGet(hediff.DefName, out var hdef)) + { + health.AddHediff(hdef, hediff.Severity, hediff.Immunity); + } + } + + health.RecomputeCapacities(); + } + + if (saved.Pregnant && saved.FatherGenome is { Count: > 0 }) + { + entity.AddComponent( + new Pregnant + { + FatherGenome = new Genome(saved.FatherGenome), + DueInDays = saved.DueInDays, + Litter = saved.Litter, + } + ); + } + + // Навыки: накладываем сохранённые уровни/страсть, если состав совпал (иначе — свежие из фабрики). + if (entity.TryGetComponent(out var skills) && skills.Levels is not null) + { + if (saved.SkillLevels.Length == skills.Levels.Length) + { + saved.SkillLevels.CopyTo(skills.Levels, 0); + } + + if (skills.Passions is not null && saved.SkillPassions.Length == skills.Passions.Length) + { + for (var i = 0; i < skills.Passions.Length; i++) + { + skills.Passions[i] = (byte)Math.Clamp(saved.SkillPassions[i], 0, 255); + } + } + } + } + + // Детерминированный начальный спавн животных по данным: каждый вид с SpawnPer1000Cells > 0 + // рассыпается по клеткам суши с плотностью из дефа; геном и возраст особи — из шаблона вида. + private void SpawnAnimals() + { + var landCells = 0; + for (var i = 0; i < _cellLand.Length; i++) + { + if (_cellLand[i]) + { + landCells++; + } + } + + if (_animals.Count == 0 || landCells == 0) + { + return; + } + + // Свой офсет сида (0x5EED занят системой решений животных — нельзя связывать их RNG-потоки). + var random = new Random(_config.Seed + 0x5A1D); + var spawned = 0; + for (var s = 0; s < _animals.Count; s++) + { + var def = _animals[s].Def; + if (def.SpawnPer1000Cells <= 0f) + { + continue; // вид не спавнится сам (напр. появляется только в сейве/событием) + } + + var count = Math.Max(1, (int)MathF.Round(landCells / 1000f * def.SpawnPer1000Cells)); + var maxAge = + (def.Genome.TryGetValue("GeneLifespan", out var lifespan) ? lifespan : 160f) + * 0.85f; + var placed = 0; + var attempts = 0; + while (placed < count && attempts < count * 50) + { + attempts++; + var cell = random.Next(_cellLand.Length); + if (!_cellLand[cell]) + { + continue; + } + + var cx = cell % _config.Width; + var cy = cell / _config.Width; + var px = (cx + 0.2f + random.NextSingle() * 0.6f) * CellSize; + var py = (cy + 0.2f + random.NextSingle() * 0.6f) * CellSize; + AnimalFactory.Create( + Store, + _animals, + _needs, + _skills, + s, + new Vector2(px, py), + ageDays: random.NextSingle() * maxAge, + _animals.GenerateGenome(s, random), + generation: 0, + CellSize + ); + placed++; + } + + spawned += placed; + } + + Log.Info($"Spawned {spawned} animals ({_animals.Count} species)"); + } +} diff --git a/src/LittleSim/Scenes/WorldScene.Terrain.cs b/src/LittleSim/Scenes/WorldScene.Terrain.cs new file mode 100644 index 0000000..aa590d3 --- /dev/null +++ b/src/LittleSim/Scenes/WorldScene.Terrain.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Generic; +using Friflo.Engine.ECS; +using LittleSim.App; +using LittleSim.Content; +using LittleSim.Sim; +using LittleSim.UI; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; +using MrGameEng.Assets; +using MrGameEng.Core; +using MrGameEng.DevConsole; +using MrGameEng.Formulas; +using MrGameEng.Genetics; +using MrGameEng.Graphics; +using MrGameEng.Host; +using MrGameEng.Input; +using MrGameEng.Inspector; +using MrGameEng.Lighting; +using MrGameEng.Tilemaps; +using MrGameEng.UI; +using Myra.Graphics2D; +using Myra.Graphics2D.Brushes; +using Myra.Graphics2D.UI; + +namespace LittleSim.Scenes; + +// Часть WorldScene: генерация рельефа, поле берега и окклюдеры освещения. +public sealed partial class WorldScene +{ + /// + /// Строит сущность- (тайлсет из дефов рельефа + сетка по карте высот) и + /// заполняет по-клеточные массивы плодородности/суши для жизненного цикла. Если + /// задан (новый мир) — рассыпает растительность по дефам; + /// при загрузке передаётся null (растения восстанавливаются из сейва отдельно). + /// + private void BuildTerrain( + GameContent content, + ModAtlases atlases, + Microsoft.Xna.Framework.Graphics.GraphicsDevice device, + AssetManager assets, + Random? scatterRandom + ) + { + var white = new Texture2DRegion(assets.Load(GameAssets.Textures.White)); + + // Тайлсет из дефов: есть поверхность — текстура из атласа, нет — тонированный тайл (вода). + var tiles = new TileSet(); + var tileByDef = new Dictionary(); + foreach (var terrain in content.Terrains.All) + { + tileByDef[terrain] = terrain.Surface is { } surface + ? tiles.Add(atlases.GetRegion(device, surface)) + : tiles.Add(white, terrain.Tint); + } + + // Рельеф детерминирован сидом и масштабом деталей — и для нового мира, и для загрузки. + var heights = WorldGenerator.Generate( + _config.Width, + _config.Height, + _config.Seed, + _config.SmoothPasses + ); + var grid = new TileGrid(_config.Width, _config.Height); + var cells = _config.Width * _config.Height; + _cellFertility = new float[cells]; + _cellLand = new bool[cells]; + _cellOccluderBase = new bool[cells]; + _cellOccluder = new bool[cells]; + for (var x = 0; x < _config.Width; x++) + { + for (var y = 0; y < _config.Height; y++) + { + var terrain = content.Terrains.Classify(heights[x, y]); + grid[x, y] = tileByDef[terrain]; + var cell = y * _config.Width + x; + _cellFertility[cell] = terrain.Fertility; + _cellLand[cell] = terrain.IsLand; + _cellOccluderBase[cell] = terrain.BlocksLight; + if (scatterRandom is not null) + { + ScatterSpawner.Spawn( + this, + content, + _plants, + terrain, + new Point(x, y), + CellSize, + scatterRandom + ); + } + } + } + + Store.CreateEntity(new Tilemap(grid, tiles, CellSize)); + } + + // Поле «ближайший берег» по клеткам мира: для каждой клетки — мировая позиция ближайшей береговой + // клетки (суша, граничащая с водой), найденная многоисточниковым BFS за O(клеток). Звери читают его + // за O(1) при поиске водопоя (см. AnimalDecisionSystem.TryFindShore), не сканируя береговую линию. + // Пусто, если воды/берега в мире нет — тогда пить негде (зверь не пойдёт). + private Vector2[] ComputeShoreField() + { + var w = _config.Width; + var h = _config.Height; + var count = w * h; + + // BFS: nearest[c] — индекс ближайшей береговой клетки. Источники — все береговые клетки (шаг 0). + var nearest = new int[count]; + Array.Fill(nearest, -1); + var queue = new Queue(); + for (var cell = 0; cell < count; cell++) + { + if (!_cellLand[cell]) + { + continue; + } + + var x = cell % w; + var y = cell / w; + var nearWater = + (x > 0 && !_cellLand[cell - 1]) + || (x < w - 1 && !_cellLand[cell + 1]) + || (y > 0 && !_cellLand[cell - w]) + || (y < h - 1 && !_cellLand[cell + w]); + if (nearWater) + { + nearest[cell] = cell; + queue.Enqueue(cell); + } + } + + if (queue.Count == 0) + { + return []; // нет берега + } + + // Волна по 4-связности: каждой клетке достаётся источник ближайшего фронта (берег по шагам сетки). + while (queue.Count > 0) + { + var cell = queue.Dequeue(); + var src = nearest[cell]; + var x = cell % w; + var y = cell / w; + if (x > 0 && nearest[cell - 1] < 0) + { + nearest[cell - 1] = src; + queue.Enqueue(cell - 1); + } + + if (x < w - 1 && nearest[cell + 1] < 0) + { + nearest[cell + 1] = src; + queue.Enqueue(cell + 1); + } + + if (y > 0 && nearest[cell - w] < 0) + { + nearest[cell - w] = src; + queue.Enqueue(cell - w); + } + + if (y < h - 1 && nearest[cell + w] < 0) + { + nearest[cell + w] = src; + queue.Enqueue(cell + w); + } + } + + var field = new Vector2[count]; + for (var cell = 0; cell < count; cell++) + { + var src = nearest[cell]; + if (src >= 0) + { + field[cell] = new Vector2((src % w + 0.5f) * CellSize, (src / w + 0.5f) * CellSize); + } + } + + return field; + } + + // Затеняют ли зрелые деревья землю. Сейчас выключено: одиночное дерево = 1 клетка-окклюдер, а спрайт + // кроны крупнее и смещён, плюс направленная тень тянется «хвостом» — на открытой почве это читается как + // странные оторванные пятна. Вернуть, когда у кроны будет настоящий многоклеточный след затенения. + private static readonly bool TreesCastShade = false; + + // Сетка окклюдеров для лайтмапа: светонепроницаемый террейн (сейчас такого нет) + опц. зрелые деревья. + private bool[] BuildOccluders() + { + Array.Copy(_cellOccluderBase, _cellOccluder, _cellOccluder.Length); + if (!TreesCastShade) + { + return _cellOccluder; // ровное освещение — без странных теней от отдельных деревьев + } + + Store + .Query() + .ForEachEntity( + (ref PlantGrowth grow, ref Transform2D transform, Entity _) => + { + var species = _plants[grow.Species]; + if ( + species.Def.TrunkRadiusCells <= 0f + || grow.Stage < species.Stages.Length - 1 + ) + { + return; // затеняют только зрелые деревья (со стволом) + } + + var cx = Math.Clamp( + (int)(transform.Position.X / CellSize), + 0, + _config.Width - 1 + ); + var cy = Math.Clamp( + (int)(transform.Position.Y / CellSize), + 0, + _config.Height - 1 + ); + _cellOccluder[cy * _config.Width + cx] = true; + } + ); + return _cellOccluder; + } +} diff --git a/src/LittleSim/Scenes/WorldScene.Ui.cs b/src/LittleSim/Scenes/WorldScene.Ui.cs new file mode 100644 index 0000000..b36864c --- /dev/null +++ b/src/LittleSim/Scenes/WorldScene.Ui.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using Friflo.Engine.ECS; +using LittleSim.App; +using LittleSim.Content; +using LittleSim.Sim; +using LittleSim.UI; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; +using MrGameEng.Assets; +using MrGameEng.Core; +using MrGameEng.DevConsole; +using MrGameEng.Formulas; +using MrGameEng.Genetics; +using MrGameEng.Graphics; +using MrGameEng.Host; +using MrGameEng.Input; +using MrGameEng.Inspector; +using MrGameEng.Lighting; +using MrGameEng.Tilemaps; +using MrGameEng.UI; +using Myra.Graphics2D; +using Myra.Graphics2D.Brushes; +using Myra.Graphics2D.UI; + +namespace LittleSim.Scenes; + +// Часть WorldScene: HUD-хелперы, полоса скорости, горячие клавиши, переходы. +public sealed partial class WorldScene +{ + // Переход к «ответственному» за событие (двойной клик в журнале): выбирает сущность и центрирует на + // ней камеру, если она ещё существует (актёр мог умереть). Камера зажимается к границам при отрисовке. + private void FocusEntity(Entity camera, int id) + { + if ( + id < 0 + || !Store.TryGetEntityById(id, out var target) + || target.IsNull + || !target.HasComponent() + ) + { + return; + } + + _selection.Select(id); + ref var cam = ref camera.GetComponent(); + cam.Position = target.GetComponent().Position; + } + + // Кнопка-переключатель оверлея HUD: подсвечивается как активная, пока оверлей открыт. + private static TextButton HudToggle(string text, Action onClick, Func isOpen) + { + var button = Ui.ChoiceButton(text); + button.Click += (_, _) => + { + onClick(); + Ui.SetChoiceActive(button, isOpen()); + }; + Ui.SetChoiceActive(button, isOpen()); + return button; + } + + private HorizontalStackPanel BuildSpeedBar(GameContent content) + { + var bar = Ui.Row(6); + bar.HorizontalAlignment = HorizontalAlignment.Center; + bar.VerticalAlignment = VerticalAlignment.Bottom; + bar.Margin = new Thickness(0, 0, 0, 12); + + var hover = new SolidBrush(new Color(40, 54, 72)); // подсветка кнопок скорости при наведении + var pause = new TextButton + { + Text = content.Languages.Get("speed.pause"), + Padding = new Thickness(8, 4), + OverBackground = hover, + }; + pause.Click += (_, _) => _speed.Pause(); + bar.Widgets.Add(pause); + _speedRefreshers.Add(() => pause.TextColor = _speed.IsPaused ? Ui.Accent : Ui.Muted); + + for (var i = 0; i < _speed.Steps.Count; i++) + { + var index = i; + var button = new TextButton + { + Text = $"x{_speed.Steps[i]:0}", + Padding = new Thickness(8, 4), + OverBackground = hover, + }; + button.Click += (_, _) => _speed.SetStep(index); + bar.Widgets.Add(button); + _speedRefreshers.Add(() => + button.TextColor = + !_speed.IsPaused && _speed.StepIndex == index ? Ui.Accent : Ui.Muted + ); + } + + void Refresh() + { + foreach (var refresh in _speedRefreshers) + { + refresh(); + } + } + + _speed.Changed += Refresh; + RegisterUnload(() => _speed.Changed -= Refresh); + Refresh(); + return bar; + } + + private void Hotkeys(InputManager input) + { + if (input.IsKeyPressed(Keys.Escape)) + { + // Esc сначала снимает выделение (если есть), и только потом открывает меню-паузу. + if (_selection.HasSelection) + { + _selection.Clear(); + } + else + { + _pause.Toggle(); + } + } + + if (_pause.IsOpen) + { + return; + } + + if (input.IsKeyPressed(Keys.Space)) + { + _speed.TogglePause(); + } + + if (input.IsKeyPressed(Keys.D1)) + { + _speed.SetStep(0); + } + + if (input.IsKeyPressed(Keys.D2) && _speed.Steps.Count > 1) + { + _speed.SetStep(1); + } + + if (input.IsKeyPressed(Keys.D3) && _speed.Steps.Count > 2) + { + _speed.SetStep(2); + } + } + + private void Switch(Scene scene) + { + if (!Context.Scenes.IsTransitioning) + { + _speed.Resume(); + Context.Scenes.Switch(scene, Transitions.Fade(0.5f)); + } + } +} diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs index 14a8de5..17ac42a 100644 --- a/src/LittleSim/Scenes/WorldScene.cs +++ b/src/LittleSim/Scenes/WorldScene.cs @@ -33,7 +33,7 @@ namespace LittleSim.Scenes; /// почве, размножение по Менделю и смерть), климат и день/ночь. UI: HUD, полоса скорости /// (пауза/x1/x3/x6 + горячие клавиши), меню-пауза (Esc), дев-консоль и ECS-инспектор (F1). /// -public sealed class WorldScene : Scene +public sealed partial class WorldScene : Scene { public const int CellSize = 16; @@ -493,1403 +493,4 @@ public sealed class WorldScene : Scene + (_save is null ? " (new)" : " (loaded)") ); } - - /// - /// Строит сущность- (тайлсет из дефов рельефа + сетка по карте высот) и - /// заполняет по-клеточные массивы плодородности/суши для жизненного цикла. Если - /// задан (новый мир) — рассыпает растительность по дефам; - /// при загрузке передаётся null (растения восстанавливаются из сейва отдельно). - /// - private void BuildTerrain( - GameContent content, - ModAtlases atlases, - Microsoft.Xna.Framework.Graphics.GraphicsDevice device, - AssetManager assets, - Random? scatterRandom - ) - { - var white = new Texture2DRegion(assets.Load(GameAssets.Textures.White)); - - // Тайлсет из дефов: есть поверхность — текстура из атласа, нет — тонированный тайл (вода). - var tiles = new TileSet(); - var tileByDef = new Dictionary(); - foreach (var terrain in content.Terrains.All) - { - tileByDef[terrain] = terrain.Surface is { } surface - ? tiles.Add(atlases.GetRegion(device, surface)) - : tiles.Add(white, terrain.Tint); - } - - // Рельеф детерминирован сидом и масштабом деталей — и для нового мира, и для загрузки. - var heights = WorldGenerator.Generate( - _config.Width, - _config.Height, - _config.Seed, - _config.SmoothPasses - ); - var grid = new TileGrid(_config.Width, _config.Height); - var cells = _config.Width * _config.Height; - _cellFertility = new float[cells]; - _cellLand = new bool[cells]; - _cellOccluderBase = new bool[cells]; - _cellOccluder = new bool[cells]; - for (var x = 0; x < _config.Width; x++) - { - for (var y = 0; y < _config.Height; y++) - { - var terrain = content.Terrains.Classify(heights[x, y]); - grid[x, y] = tileByDef[terrain]; - var cell = y * _config.Width + x; - _cellFertility[cell] = terrain.Fertility; - _cellLand[cell] = terrain.IsLand; - _cellOccluderBase[cell] = terrain.BlocksLight; - if (scatterRandom is not null) - { - ScatterSpawner.Spawn( - this, - content, - _plants, - terrain, - new Point(x, y), - CellSize, - scatterRandom - ); - } - } - } - - Store.CreateEntity(new Tilemap(grid, tiles, CellSize)); - } - - // Переход к «ответственному» за событие (двойной клик в журнале): выбирает сущность и центрирует на - // ней камеру, если она ещё существует (актёр мог умереть). Камера зажимается к границам при отрисовке. - private void FocusEntity(Entity camera, int id) - { - if ( - id < 0 - || !Store.TryGetEntityById(id, out var target) - || target.IsNull - || !target.HasComponent() - ) - { - return; - } - - _selection.Select(id); - ref var cam = ref camera.GetComponent(); - cam.Position = target.GetComponent().Position; - } - - // Кнопка-переключатель оверлея HUD: подсвечивается как активная, пока оверлей открыт. - private static TextButton HudToggle(string text, Action onClick, Func isOpen) - { - var button = Ui.ChoiceButton(text); - button.Click += (_, _) => - { - onClick(); - Ui.SetChoiceActive(button, isOpen()); - }; - Ui.SetChoiceActive(button, isOpen()); - return button; - } - - private HorizontalStackPanel BuildSpeedBar(GameContent content) - { - var bar = Ui.Row(6); - bar.HorizontalAlignment = HorizontalAlignment.Center; - bar.VerticalAlignment = VerticalAlignment.Bottom; - bar.Margin = new Thickness(0, 0, 0, 12); - - var hover = new SolidBrush(new Color(40, 54, 72)); // подсветка кнопок скорости при наведении - var pause = new TextButton - { - Text = content.Languages.Get("speed.pause"), - Padding = new Thickness(8, 4), - OverBackground = hover, - }; - pause.Click += (_, _) => _speed.Pause(); - bar.Widgets.Add(pause); - _speedRefreshers.Add(() => pause.TextColor = _speed.IsPaused ? Ui.Accent : Ui.Muted); - - for (var i = 0; i < _speed.Steps.Count; i++) - { - var index = i; - var button = new TextButton - { - Text = $"x{_speed.Steps[i]:0}", - Padding = new Thickness(8, 4), - OverBackground = hover, - }; - button.Click += (_, _) => _speed.SetStep(index); - bar.Widgets.Add(button); - _speedRefreshers.Add(() => - button.TextColor = - !_speed.IsPaused && _speed.StepIndex == index ? Ui.Accent : Ui.Muted - ); - } - - void Refresh() - { - foreach (var refresh in _speedRefreshers) - { - refresh(); - } - } - - _speed.Changed += Refresh; - RegisterUnload(() => _speed.Changed -= Refresh); - Refresh(); - return bar; - } - - private void Hotkeys(InputManager input) - { - if (input.IsKeyPressed(Keys.Escape)) - { - // Esc сначала снимает выделение (если есть), и только потом открывает меню-паузу. - if (_selection.HasSelection) - { - _selection.Clear(); - } - else - { - _pause.Toggle(); - } - } - - if (_pause.IsOpen) - { - return; - } - - if (input.IsKeyPressed(Keys.Space)) - { - _speed.TogglePause(); - } - - if (input.IsKeyPressed(Keys.D1)) - { - _speed.SetStep(0); - } - - if (input.IsKeyPressed(Keys.D2) && _speed.Steps.Count > 1) - { - _speed.SetStep(1); - } - - if (input.IsKeyPressed(Keys.D3) && _speed.Steps.Count > 2) - { - _speed.SetStep(2); - } - } - - private string SaveWorld() - { - var save = new WorldSave - { - Name = _config.Name, - Width = _config.Width, - Height = _config.Height, - Seed = _config.Seed, - SmoothPasses = _config.SmoothPasses, - SavedUtc = DateTime.UtcNow, - ElapsedSeconds = Context.Clock.TotalTime, - }; - - // Снимок всей популяции растений: вид (деф), позиция, возраст, стадия, почва и геном. - Store - .Query() - .ForEachEntity( - ( - ref PlantGrowth grow, - ref PlantOrganism org, - ref Transform2D transform, - Entity _ - ) => - { - save.Plants.Add( - new PlantSave - { - Species = _plants[grow.Species].Def.DefName, - X = transform.Position.X, - Y = transform.Position.Y, - AgeDays = grow.AgeDays, - Stage = grow.Stage, - CellFertility = grow.CellFertility, - Genome = org.Genome.ToDictionary(), - } - ); - } - ); - - SaveAnimals(save); - - // Население — живой счётчик животных на момент сохранения (а не статичный пресет конфига). - save.Population = save.Animals.Count; - - new SaveStore().Write(save); - Log.Info( - $"World '{_config.Name}' saved ({save.Plants.Count} plants, " - + $"{save.Animals.Count} animals, {save.Corpses.Count} corpses, {save.Eggs.Count} eggs)" - ); - return _config.Name; - } - - // Снимок популяции животных и трупов: всё нужное, чтобы эволюция/состояние пережили сейв/лоад - // (а не пересыпались из сида). Health/Mood/Pregnant берём через entity — запрос ограничен 5 типами. - private void SaveAnimals(WorldSave save) - { - Store - .Query() - .ForEachEntity( - ( - ref AnimalOrganism org, - ref AnimalGrowth grow, - ref AnimalNeeds needs, - ref Transform2D transform, - Entity entity - ) => - { - var record = new AnimalSave - { - Species = _animals[org.Species].Def.DefName, - X = transform.Position.X, - Y = transform.Position.Y, - Generation = org.Generation, - Stage = grow.Stage, - AgeDays = grow.AgeDays, - Genome = org.Genome.ToDictionary(), - Needs = needs.Values, - Starve = needs.Starve, - }; - - if (entity.GetComponent().State is { } health) - { - record.BloodLevel = health.BloodLevel; - record.PartHp = health.Parts.Select(p => p.Hp).ToArray(); - foreach (var hediff in health.Hediffs) - { - record.Hediffs.Add( - new HediffSave - { - DefName = hediff.Def.DefName, - Severity = hediff.Severity, - Immunity = hediff.Immunity, - } - ); - } - } - - var mood = entity.GetComponent(); - record.Mood = mood.Value; - record.MoodActive = mood.Active; - if (mood.Thoughts is not null) - { - foreach (var thought in mood.Thoughts) - { - record.Thoughts.Add( - new ThoughtSave - { - DefName = thought.Def.DefName, - RemainingDays = thought.RemainingDays, - } - ); - } - } - - if (entity.TryGetComponent(out var preg)) - { - record.Pregnant = true; - record.FatherGenome = preg.FatherGenome.ToDictionary(); - record.DueInDays = preg.DueInDays; - record.Litter = preg.Litter; - } - - if (entity.TryGetComponent(out var sk)) - { - record.SkillLevels = sk.Levels ?? []; - record.SkillPassions = sk.Passions is null - ? [] - : Array.ConvertAll(sk.Passions, p => (int)p); - } - - save.Animals.Add(record); - } - ); - - Store - .Query() - .ForEachEntity( - (ref Corpse corpse, ref Transform2D transform, Entity _) => - save.Corpses.Add( - new CorpseSave - { - Species = _animals[corpse.Species].Def.DefName, - X = transform.Position.X, - Y = transform.Position.Y, - RotDays = corpse.RotDays, - BodySize = corpse.BodySize, - Meat = corpse.Meat, - } - ) - ); - - Store - .Query() - .ForEachEntity( - (ref Egg egg, ref Transform2D transform, Entity _) => - save.Eggs.Add( - new EggSave - { - Species = _animals[egg.Species].Def.DefName, - X = transform.Position.X, - Y = transform.Position.Y, - Generation = egg.Generation, - IncubateDays = egg.IncubateDays, - Genome = egg.Genome?.ToDictionary() ?? new(), - } - ) - ); - } - - private void RestorePlants(GameContent content) - { - var fallback = new Random(_config.Seed); // для старых/битых сейвов без генома - foreach (var plant in _save!.Plants) - { - if (!content.Defs.TryGet(plant.Species, out var def)) - { - continue; // вид пропал (мод убрали) — пропускаем растение - } - - var index = _plants.IndexOf(def); - var genome = plant.Genome is { Count: > 0 } - ? new Genome(plant.Genome) - : _plants[index].Template.Generate(fallback); - PlantFactory.Create( - Store, - _plants, - index, - new Vector2(plant.X, plant.Y), - plant.AgeDays, - genome, - plant.CellFertility, - CellSize - ); - } - } - - // Восстанавливает популяцию животных и трупы из сейва: фабрика собирает особь из генома (геном - // несёт и пол), затем динамическое состояние (нужды/стадия/здоровье/настроение/беременность) - // накладывается поверх. Старый/битый сейв без генома — особь регенерируется из шаблона вида. - private void RestoreAnimals(GameContent content) - { - var fallback = new Random(_config.Seed); - foreach (var saved in _save!.Animals) - { - if (!content.Defs.TryGet(saved.Species, out var def)) - { - continue; // вид пропал (мод убрали) — пропускаем особь - } - - var index = _animals.IndexOf(def); - var genome = saved.Genome is { Count: > 0 } - ? new Genome(saved.Genome) - : _animals.GenerateGenome(index, fallback); - var entity = AnimalFactory.Create( - Store, - _animals, - _needs, - _skills, - index, - new Vector2(saved.X, saved.Y), - saved.AgeDays, - genome, - saved.Generation, - CellSize - ); - RestoreAnimalState(entity, saved, content); - } - - foreach (var saved in _save.Corpses) - { - if (!content.Defs.TryGet(saved.Species, out var def)) - { - continue; - } - - var corpse = AnimalFactory.CreateCorpse( - Store, - _animals, - _animals.IndexOf(def), - new Vector2(saved.X, saved.Y), - saved.BodySize, - saved.Meat, - CellSize - ); - ref var data = ref corpse.GetComponent(); - data.RotDays = saved.RotDays; - data.Stage = -1; // форсируем пере-тинт стадии в CorpseSystem на следующем тике - } - - foreach (var saved in _save.Eggs) - { - if (!content.Defs.TryGet(saved.Species, out var def)) - { - continue; // вид пропал (мод убрали) — пропускаем яйцо - } - - var index = _animals.IndexOf(def); - var genome = saved.Genome is { Count: > 0 } - ? new Genome(saved.Genome) - : _animals.GenerateGenome(index, fallback); - AnimalFactory.CreateEgg( - Store, - _animals, - index, - new Vector2(saved.X, saved.Y), - genome, - saved.Generation, - saved.IncubateDays, - CellSize - ); - } - - Log.Info( - $"Restored {_save.Animals.Count} animals, {_save.Corpses.Count} corpses, {_save.Eggs.Count} eggs from save" - ); - } - - // Накладывает на свежесозданную особь сохранённое динамическое состояние. Ref-компоненты правим до - // AddComponent(Pregnant) (структурное изменение инвалидирует ссылки); Health — managed, безопасен. - private void RestoreAnimalState(Entity entity, AnimalSave saved, GameContent content) - { - ref var needs = ref entity.GetComponent(); - if (saved.Needs is { Length: > 0 } && saved.Needs.Length == _needs.Count) - { - needs.Values = saved.Needs; // состав совпал — иначе остаются свежие из фабрики - } - - needs.Starve = saved.Starve; - entity.GetComponent().Stage = saved.Stage; - - ref var mood = ref entity.GetComponent(); - mood.Value = saved.Mood; - mood.Active = saved.MoodActive; - mood.Thoughts.Clear(); - foreach (var thought in saved.Thoughts) - { - if (content.Defs.TryGet(thought.DefName, out var tdef)) - { - mood.Thoughts.Add( - new ThoughtInstance { Def = tdef, RemainingDays = thought.RemainingDays } - ); - } - } - - if (entity.GetComponent().State is { } health) - { - health.BloodLevel = saved.BloodLevel; - // HP частей применяем только при точном совпадении анатомии (как для нужд/навыков): иначе - // мод сменил состав/порядок частей тела, и значения легли бы не на те части — оставляем свежее. - if (saved.PartHp.Length == health.Parts.Length) - { - for (var i = 0; i < health.Parts.Length; i++) - { - health.Parts[i].Hp = saved.PartHp[i]; - } - } - else if (saved.PartHp.Length > 0) - { - Log.Warning( - $"Скелет вида '{saved.Species}' изменился ({saved.PartHp.Length}→{health.Parts.Length} частей) — HP частей не восстановлены." - ); - } - - foreach (var hediff in saved.Hediffs) - { - if (content.Defs.TryGet(hediff.DefName, out var hdef)) - { - health.AddHediff(hdef, hediff.Severity, hediff.Immunity); - } - } - - health.RecomputeCapacities(); - } - - if (saved.Pregnant && saved.FatherGenome is { Count: > 0 }) - { - entity.AddComponent( - new Pregnant - { - FatherGenome = new Genome(saved.FatherGenome), - DueInDays = saved.DueInDays, - Litter = saved.Litter, - } - ); - } - - // Навыки: накладываем сохранённые уровни/страсть, если состав совпал (иначе — свежие из фабрики). - if (entity.TryGetComponent(out var skills) && skills.Levels is not null) - { - if (saved.SkillLevels.Length == skills.Levels.Length) - { - saved.SkillLevels.CopyTo(skills.Levels, 0); - } - - if (skills.Passions is not null && saved.SkillPassions.Length == skills.Passions.Length) - { - for (var i = 0; i < skills.Passions.Length; i++) - { - skills.Passions[i] = (byte)Math.Clamp(saved.SkillPassions[i], 0, 255); - } - } - } - } - - // Детерминированный начальный спавн животных по данным: каждый вид с SpawnPer1000Cells > 0 - // рассыпается по клеткам суши с плотностью из дефа; геном и возраст особи — из шаблона вида. - private void SpawnAnimals() - { - var landCells = 0; - for (var i = 0; i < _cellLand.Length; i++) - { - if (_cellLand[i]) - { - landCells++; - } - } - - if (_animals.Count == 0 || landCells == 0) - { - return; - } - - // Свой офсет сида (0x5EED занят системой решений животных — нельзя связывать их RNG-потоки). - var random = new Random(_config.Seed + 0x5A1D); - var spawned = 0; - for (var s = 0; s < _animals.Count; s++) - { - var def = _animals[s].Def; - if (def.SpawnPer1000Cells <= 0f) - { - continue; // вид не спавнится сам (напр. появляется только в сейве/событием) - } - - var count = Math.Max(1, (int)MathF.Round(landCells / 1000f * def.SpawnPer1000Cells)); - var maxAge = - (def.Genome.TryGetValue("GeneLifespan", out var lifespan) ? lifespan : 160f) - * 0.85f; - var placed = 0; - var attempts = 0; - while (placed < count && attempts < count * 50) - { - attempts++; - var cell = random.Next(_cellLand.Length); - if (!_cellLand[cell]) - { - continue; - } - - var cx = cell % _config.Width; - var cy = cell / _config.Width; - var px = (cx + 0.2f + random.NextSingle() * 0.6f) * CellSize; - var py = (cy + 0.2f + random.NextSingle() * 0.6f) * CellSize; - AnimalFactory.Create( - Store, - _animals, - _needs, - _skills, - s, - new Vector2(px, py), - ageDays: random.NextSingle() * maxAge, - _animals.GenerateGenome(s, random), - generation: 0, - CellSize - ); - placed++; - } - - spawned += placed; - } - - Log.Info($"Spawned {spawned} animals ({_animals.Count} species)"); - } - - // Поле «ближайший берег» по клеткам мира: для каждой клетки — мировая позиция ближайшей береговой - // клетки (суша, граничащая с водой), найденная многоисточниковым BFS за O(клеток). Звери читают его - // за O(1) при поиске водопоя (см. AnimalDecisionSystem.TryFindShore), не сканируя береговую линию. - // Пусто, если воды/берега в мире нет — тогда пить негде (зверь не пойдёт). - private Vector2[] ComputeShoreField() - { - var w = _config.Width; - var h = _config.Height; - var count = w * h; - - // BFS: nearest[c] — индекс ближайшей береговой клетки. Источники — все береговые клетки (шаг 0). - var nearest = new int[count]; - Array.Fill(nearest, -1); - var queue = new Queue(); - for (var cell = 0; cell < count; cell++) - { - if (!_cellLand[cell]) - { - continue; - } - - var x = cell % w; - var y = cell / w; - var nearWater = - (x > 0 && !_cellLand[cell - 1]) - || (x < w - 1 && !_cellLand[cell + 1]) - || (y > 0 && !_cellLand[cell - w]) - || (y < h - 1 && !_cellLand[cell + w]); - if (nearWater) - { - nearest[cell] = cell; - queue.Enqueue(cell); - } - } - - if (queue.Count == 0) - { - return []; // нет берега - } - - // Волна по 4-связности: каждой клетке достаётся источник ближайшего фронта (берег по шагам сетки). - while (queue.Count > 0) - { - var cell = queue.Dequeue(); - var src = nearest[cell]; - var x = cell % w; - var y = cell / w; - if (x > 0 && nearest[cell - 1] < 0) - { - nearest[cell - 1] = src; - queue.Enqueue(cell - 1); - } - - if (x < w - 1 && nearest[cell + 1] < 0) - { - nearest[cell + 1] = src; - queue.Enqueue(cell + 1); - } - - if (y > 0 && nearest[cell - w] < 0) - { - nearest[cell - w] = src; - queue.Enqueue(cell - w); - } - - if (y < h - 1 && nearest[cell + w] < 0) - { - nearest[cell + w] = src; - queue.Enqueue(cell + w); - } - } - - var field = new Vector2[count]; - for (var cell = 0; cell < count; cell++) - { - var src = nearest[cell]; - if (src >= 0) - { - field[cell] = new Vector2((src % w + 0.5f) * CellSize, (src / w + 0.5f) * CellSize); - } - } - - return field; - } - - // Затеняют ли зрелые деревья землю. Сейчас выключено: одиночное дерево = 1 клетка-окклюдер, а спрайт - // кроны крупнее и смещён, плюс направленная тень тянется «хвостом» — на открытой почве это читается как - // странные оторванные пятна. Вернуть, когда у кроны будет настоящий многоклеточный след затенения. - private static readonly bool TreesCastShade = false; - - // Сетка окклюдеров для лайтмапа: светонепроницаемый террейн (сейчас такого нет) + опц. зрелые деревья. - private bool[] BuildOccluders() - { - Array.Copy(_cellOccluderBase, _cellOccluder, _cellOccluder.Length); - if (!TreesCastShade) - { - return _cellOccluder; // ровное освещение — без странных теней от отдельных деревьев - } - - Store - .Query() - .ForEachEntity( - (ref PlantGrowth grow, ref Transform2D transform, Entity _) => - { - var species = _plants[grow.Species]; - if ( - species.Def.TrunkRadiusCells <= 0f - || grow.Stage < species.Stages.Length - 1 - ) - { - return; // затеняют только зрелые деревья (со стволом) - } - - var cx = Math.Clamp( - (int)(transform.Position.X / CellSize), - 0, - _config.Width - 1 - ); - var cy = Math.Clamp( - (int)(transform.Position.Y / CellSize), - 0, - _config.Height - 1 - ); - _cellOccluder[cy * _config.Width + cx] = true; - } - ); - return _cellOccluder; - } - - private void Switch(Scene scene) - { - if (!Context.Scenes.IsTransitioning) - { - _speed.Resume(); - Context.Scenes.Switch(scene, Transitions.Fade(0.5f)); - } - } - - private void RegisterCommands(DevConsole console, GameContent content, ModAtlases atlases) - { - ContentCommands.Register(console, content, atlases); - console.Register( - "regen", - "regen [seed] — regenerate the world with a new seed", - (c, args) => - { - if (Context.Scenes.IsTransitioning) - { - return; - } - - var seed = args.Length > 0 ? int.Parse(args[0]) : Random.Shared.Next(); - c.WriteLine($"regenerating world, seed {seed}"); - Context.Scenes.Switch( - new WorldScene(_config with { Seed = seed }), - Transitions.Fade(0.6f) - ); - } - ); - console.Register( - "formula", - "formula — compile and evaluate an expression (gene-formula engine demo)", - (c, args) => - { - if (args.Length == 0) - { - c.WriteLine( - "usage: formula e.g. formula clamp(lerp(0, 10, 0.5), 0, 8)" - ); - return; - } - - var expression = string.Join(' ', args); - try - { - c.WriteLine($"{expression} = {Formula.Compile(expression).Evaluate()}"); - } - catch (FormulaException e) - { - c.WriteLine($"error: {e.Message}"); - } - } - ); - console.Register( - "gene", - "gene [seed] — generate a genome from Gene defs, show alleles/traits and a bred child", - (c, args) => RunGeneDemo(c, content, args) - ); - console.Register( - "plant", - "plant [seed] — sample a species genome and show its gene-driven traits/products", - (c, args) => RunPlantDemo(c, content, args) - ); - console.Register( - "animal", - "animal [seed] — sample an animal genome and show its gene-driven traits", - (c, args) => RunAnimalDemo(c, content, args) - ); - console.Register( - "diet", - "diet [species] — gene-derived diet (herbivory/carnivory/omnivory), role and hunting stats", - (c, args) => RunDiet(c, content, args) - ); - console.Register( - "popstats", - "popstats [species] — live population trait means and generation span (selection drift)", - (c, args) => RunPopStats(c, args) - ); - console.Register( - "body", - "body — dump a species body parts and a healthy individual's capacities", - (c, args) => RunBodyDemo(c, content, args) - ); - console.Register( - "infect", - "infect [hediff] — give every animal a disease (default Fever) to test the health system", - (c, args) => RunInfect(c, content, args) - ); - console.Register( - "intel", - "intel [consciousness] — effective intelligence (brain x consciousness) and which needs it gates", - (c, args) => RunIntel(c, content, args) - ); - console.Register( - "mood", - "mood [species] — live population mood (avg/min/max) and content/stressed counts", - (c, args) => RunMood(c, args) - ); - console.Register( - "skills", - "skills — mean skill levels across living animals (grow from practice, decay unused)", - (c, _) => RunSkills(c) - ); - console.Register( - "menu", - "menu — return to the main menu", - (_, _) => Switch(new MainMenuScene()) - ); - } - - // Демонстрация генной системы (фаза G2): из Gene-дефов генерируем геном, печатаем аллели, - // выраженные значения и признаки (вычисленные формулами), затем скрещиваем двух особей. - private static void RunGeneDemo(DevConsole console, GameContent content, string[] args) - { - var genes = content.Defs.All(); - if (genes.Count == 0) - { - console.WriteLine("no Gene defs loaded"); - return; - } - - var registry = genes.ToDictionary(g => g.DefName, g => g, StringComparer.Ordinal); - var seed = args.Length > 0 ? int.Parse(args[0]) : Random.Shared.Next(); - var random = new Random(seed); - - console.WriteLine($"genome from {genes.Count} genes, seed {seed}:"); - var parentA = Genome.Generate(genes, random); - foreach (var gene in genes) - { - var allele = parentA[gene.DefName]; - console.WriteLine( - $" {gene.DefName}: [{allele.A:0.##}, {allele.B:0.##}] -> {parentA.Express(gene):0.###}" - ); - } - - console.WriteLine("traits:"); - foreach (var (trait, value) in Phenotype.Compute(parentA, registry).OrderBy(t => t.Key)) - { - console.WriteLine($" {trait} = {value:0.###}"); - } - - var parentB = Genome.Generate(genes, random); - var child = Genome.Breed(parentA, parentB, registry, random); - var childTraits = Phenotype.Compute(child, registry); - console.WriteLine( - $"bred child: {child.Alleles.Count} genes, " - + $"vigor={childTraits.GetValueOrDefault("vigor"):0.###}, " - + $"lifespan={childTraits.GetValueOrDefault("lifespan"):0.#}, " - + $"variant={childTraits.GetValueOrDefault("variant"):0}" - ); - } - - // Демонстрация генного контента (фаза G4): по виду берём его набор генов, генерируем особь и - // печатаем признаки роста/жизни и продукты (плоды/добыча с количеством от генов). - private void RunPlantDemo(DevConsole console, GameContent content, string[] args) - { - var species = content.Defs.NamesOf("Plant"); - if (args.Length == 0) - { - console.WriteLine("usage: plant [seed] e.g. plant TreeOakA"); - console.WriteLine($"species: {string.Join(", ", species)}"); - return; - } - - if (!content.Defs.TryGet(args[0], out var def)) - { - console.WriteLine($"no plant '{args[0]}'; species: {string.Join(", ", species)}"); - return; - } - - var index = _plants.IndexOf(def); - var seed = args.Length > 1 ? int.Parse(args[1]) : Random.Shared.Next(); - var genome = _plants[index].Template.Generate(new Random(seed)); - var traits = PlantPhenotype.FromTraits(Phenotype.Compute(genome, _plants.GeneRegistry)); - - console.WriteLine( - $"{def.DefName} (seed {seed}){(traits.IsVariant ? " [variant morph]" : "")}:" - ); - console.WriteLine( - $" vigor {traits.Vigor:0.##}, lifespan {traits.Lifespan:0} d, " - + $"optimalLight {traits.OptimalLight:0.##}, leafHue {traits.LeafHue:0.##}" - ); - var (tMin, tLow, tHigh, tMax) = traits.TemperatureBand(); - console.WriteLine( - $" temp: grows {tMin:0.#}..{tMax:0.#}°C, optimal {tLow:0.#}..{tHigh:0.#}°C " - + $"(cold {traits.ColdHardiness:0.#}, heat {traits.HeatHardiness:0.#})" - ); - console.WriteLine( - $" defense: toxicity {traits.Toxicity:0.##}, thorns {traits.Thorns:0.##}, " - + $"palatability {traits.Palatability:0.##} (growth ×{traits.DefenseGrowthFactor():0.##})" - ); - console.WriteLine($" mass (mature): {def.BaseMassKg:0.##} kg"); - - if (def.HarvestProduct is { } harvest) - { - console.WriteLine( - $" harvest: {ProductLabel(content, harvest)} x{traits.HarvestAmount:0.#}" - ); - } - - if (def.FruitProduct is { } fruit && traits.FruitYield >= 1f) - { - var season = content.Languages.Get( - $"season.{((Season)traits.FruitSeason).ToString().ToLowerInvariant()}" - ); - console.WriteLine( - $" fruit: {ProductLabel(content, fruit)} x{traits.FruitYield:0.#} in {season}" - ); - } - else - { - console.WriteLine(" barren (no fruit)"); - } - } - - // Демонстрация животного (фаза A1): по виду берём его набор генов, генерируем особь и печатаем - // признаки, вычисленные формулами генов (тело/мозг/метаболизм/чувства/мех). - private void RunAnimalDemo(DevConsole console, GameContent content, string[] args) - { - var species = content.Defs.NamesOf("Animal"); - if (args.Length == 0) - { - console.WriteLine("usage: animal [seed] e.g. animal Deer"); - console.WriteLine($"species: {string.Join(", ", species)}"); - return; - } - - if (!content.Defs.TryGet(args[0], out var def)) - { - console.WriteLine($"no animal '{args[0]}'; species: {string.Join(", ", species)}"); - return; - } - - var index = _animals.IndexOf(def); - var seed = args.Length > 1 ? int.Parse(args[1]) : Random.Shared.Next(); - var genome = _animals.GenerateGenome(index, new Random(seed)); - var traits = AnimalPhenotype.FromTraits(Phenotype.Compute(genome, _animals.GeneRegistry)); - var sex = AnimalFactory.IsMaleGenome(genome) ? "male" : "female"; - - console.WriteLine($"{def.DefName} ({sex}, seed {seed}):"); - console.WriteLine( - $" body {traits.BodySize:0.##} cells, maturity {traits.MaturityAge:0} d, " - + $"lifespan {traits.Lifespan:0} d, brain {traits.BrainSize:0.##}, metabolism {traits.Metabolism:0.##}" - ); - console.WriteLine( - $" move {traits.MoveSpeed:0.##}, vision {traits.Vision:0.##}, blood {traits.BloodVolume:0.##}, " - + $"insulation {traits.Insulation:0.##}, furHue {traits.FurHue:0.##}" - ); - console.WriteLine( - $" diet: herb {traits.Herbivory:0.##} / carn {traits.Carnivory:0.##} / omni {traits.Omnivory:0.##}, " - + $"toxinTolerance {traits.ToxinTolerance:0.##}" - ); - console.WriteLine( - $" reproduction: {(traits.Oviparous ? "oviparous (lays eggs)" : "viviparous (live birth)")}, " - + $"gestation {traits.GestationDays:0} d, litter {traits.LitterSize:0.#}" - ); - console.WriteLine( - $" behaviour: insulation {traits.Insulation:0.##}, sociability {traits.Sociability:0.##}" - ); - var adultMass = Mass.OfAnimal(def, traits, 1f); - console.WriteLine( - $" mass: {adultMass:0.#} kg (adult), carries ~{Mass.CarryCapacity(adultMass, 1f):0.#} kg" - ); - } - - // Диета из генов (предатор-кластер): по каждому виду печатает herbivory/carnivory/omnivory, - // выведенный рацион (ест растения/мясо), роль и боевые параметры — проверка, что хищники заданы. - private void RunDiet(DevConsole console, GameContent content, string[] args) - { - var species = content.Defs.NamesOf("Animal"); - for (var s = 0; s < _animals.Count; s++) - { - var sp = _animals[s]; - if ( - args.Length > 0 - && !string.Equals(sp.Def.DefName, args[0], StringComparison.OrdinalIgnoreCase) - ) - { - continue; - } - - var genome = _animals.GenerateGenome(s, new Random(0x01E7 + s)); - var traits = AnimalPhenotype.FromTraits( - Phenotype.Compute(genome, _animals.GeneRegistry) - ); - var (eatsPlants, eatsMeat) = AnimalFactory.Diet(traits, sp.Def); - var role = - eatsMeat ? (eatsPlants ? "omnivore" : "carnivore") - : eatsPlants ? "herbivore" - : "none"; - console.WriteLine( - $"{sp.Def.DefName}: {role} — herb {traits.Herbivory:0.##} / carn {traits.Carnivory:0.##} / " - + $"omni {traits.Omnivory:0.##} -> plants={eatsPlants} meat={eatsMeat}, spawn {sp.Def.SpawnPer1000Cells:0.##}/1000" - ); - if (eatsMeat && sp.Def.AttackDamage > 0f) - { - console.WriteLine( - $" hunt: dmg {sp.Def.AttackDamage:0}/d, bleed {sp.Def.AttackBleed:0.#}/d, " - + $"bloodloss {sp.Def.AttackBloodLoss:0.#}/d, range {sp.Def.AttackRangeCells:0.#} cells" - ); - } - } - - if (args.Length == 0) - { - console.WriteLine($"species: {string.Join(", ", species)}"); - } - } - - // Наблюдаемость отбора (фаза A2): средние ключевых признаков живой популяции и размах поколений — - // видно дрейф генов под отбором. Без аргумента — все виды; с аргументом — один вид. - private void RunPopStats(DevConsole console, string[] args) - { - if (_animals.Count == 0) - { - console.WriteLine("no animal species"); - return; - } - - var count = new int[_animals.Count]; - var body = new double[_animals.Count]; - var brain = new double[_animals.Count]; - var life = new double[_animals.Count]; - var move = new double[_animals.Count]; - var tox = new double[_animals.Count]; - var maxGen = new int[_animals.Count]; - - Store - .Query() - .ForEachEntity( - (ref AnimalOrganism o, Entity _) => - { - var s = o.Species; - count[s]++; - body[s] += o.Traits.BodySize; - brain[s] += o.Traits.BrainSize; - life[s] += o.Traits.Lifespan; - move[s] += o.Traits.MoveSpeed; - tox[s] += o.Traits.ToxinTolerance; - if (o.Generation > maxGen[s]) - { - maxGen[s] = o.Generation; - } - } - ); - - var filter = args.Length > 0 ? args[0] : null; - var any = false; - for (var s = 0; s < _animals.Count; s++) - { - var name = _animals[s].Def.DefName; - if ( - count[s] == 0 - || ( - filter is not null - && !string.Equals(name, filter, StringComparison.OrdinalIgnoreCase) - ) - ) - { - continue; - } - - any = true; - var c = count[s]; - console.WriteLine( - $"{name}: n={c}, gen 0..{maxGen[s]}, body {body[s] / c:0.##}, " - + $"brain {brain[s] / c:0.##}, lifespan {life[s] / c:0} d, move {move[s] / c:0.##}, " - + $"toxinTol {tox[s] / c:0.##}" - ); - } - - if (!any) - { - console.WriteLine(filter is null ? "no animals alive" : $"no '{filter}' alive"); - } - - // Другая сторона гонки вооружений: средняя защита живых растений (коэволюция C2). Без фильтра. - if (filter is null) - { - var pn = 0; - double pTox = 0, - pThorn = 0, - pPalat = 0; - Store - .Query() - .ForEachEntity( - (ref PlantOrganism o, Entity _) => - { - pn++; - pTox += o.Traits.Toxicity; - pThorn += o.Traits.Thorns; - pPalat += o.Traits.Palatability; - } - ); - if (pn > 0) - { - console.WriteLine( - $"plants: n={pn}, toxicity {pTox / pn:0.###}, thorns {pThorn / pn:0.###}, " - + $"palatability {pPalat / pn:0.###}" - ); - } - } - } - - // Наблюдаемость здоровья (фаза A5): дерево частей тела вида и способности здоровой особи. - private void RunBodyDemo(DevConsole console, GameContent content, string[] args) - { - var species = content.Defs.NamesOf("Animal"); - if (args.Length == 0) - { - console.WriteLine("usage: body e.g. body Deer"); - console.WriteLine($"species: {string.Join(", ", species)}"); - return; - } - - if (!content.Defs.TryGet(args[0], out var def)) - { - console.WriteLine($"no animal '{args[0]}'; species: {string.Join(", ", species)}"); - return; - } - - if (string.IsNullOrEmpty(def.Body) || !content.Defs.TryGet(def.Body, out var body)) - { - console.WriteLine($"{def.DefName}: no body"); - return; - } - - console.WriteLine($"{def.DefName} body '{body.DefName}': {body.Parts.Length} parts"); - foreach (var part in body.Parts) - { - var caps = - part.Capacities.Count > 0 - ? " [" - + string.Join( - ", ", - part.Capacities.Select(kv => $"{kv.Key} {kv.Value:0.##}") - ) - + "]" - : ""; - console.WriteLine( - $" {part.Name}{(part.Vital ? " *vital" : "")} hp{part.MaxHp:0}{caps}" - ); - } - - var state = HealthState.Create(body, 1f); - console.WriteLine( - "capacities (healthy): " - + string.Join( - ", ", - state.Capacities.OrderBy(k => k.Key).Select(kv => $"{kv.Key} {kv.Value:0.##}") - ) - ); - } - - // Тест системы здоровья (фаза A6): навешивает болезнь всем живым животным. - private void RunInfect(DevConsole console, GameContent content, string[] args) - { - var name = args.Length > 0 ? args[0] : "Fever"; - if (!content.Defs.TryGet(name, out var def)) - { - console.WriteLine($"no hediff '{name}'"); - return; - } - - var count = 0; - Store - .Query() - .ForEachEntity( - (ref Health h, Entity _) => - { - h.State?.Add(def); - count++; - } - ); - console.WriteLine($"infected {count} animals with {def.DefName}"); - } - - // Наблюдаемость интеллекта (фаза A7): мозг особи (ген) × сознание = эффективный интеллект, который - // гейтит набор нужд/действий. Показывает активные/отключённые нужды при заданном уровне сознания - // (по умолчанию 1 — здоров; меньше — имитация повреждения мозга/болезни/боли). - private void RunIntel(DevConsole console, GameContent content, string[] args) - { - var species = content.Defs.NamesOf("Animal"); - if (args.Length == 0) - { - console.WriteLine( - "usage: intel [consciousness 0..1] [seed] e.g. intel Deer 0.6" - ); - console.WriteLine($"species: {string.Join(", ", species)}"); - return; - } - - if (!content.Defs.TryGet(args[0], out var def)) - { - console.WriteLine($"no animal '{args[0]}'; species: {string.Join(", ", species)}"); - return; - } - - var consciousness = - args.Length > 1 - ? Math.Clamp( - float.Parse(args[1], System.Globalization.CultureInfo.InvariantCulture), - 0f, - 1f - ) - : 1f; - var seed = args.Length > 2 ? int.Parse(args[2]) : Random.Shared.Next(); - var index = _animals.IndexOf(def); - var genome = _animals.GenerateGenome(index, new Random(seed)); - var traits = AnimalPhenotype.FromTraits(Phenotype.Compute(genome, _animals.GeneRegistry)); - var intelligence = traits.BrainSize * consciousness; - - console.WriteLine( - $"{def.DefName} (seed {seed}): brain {traits.BrainSize:0.##} x consciousness {consciousness:0.##} " - + $"= intelligence {intelligence:0.###}" - ); - var active = new List(); - var gated = new List(); - for (var k = 0; k < _needs.Count; k++) - { - var need = _needs[k]; - (need.MinBrain > intelligence ? gated : active).Add( - $"{need.DefName}({need.MinBrain:0.##})" - ); - } - - console.WriteLine($" active: {(active.Count > 0 ? string.Join(", ", active) : "—")}"); - console.WriteLine($" gated: {(gated.Count > 0 ? string.Join(", ", gated) : "—")}"); - } - - // Наблюдаемость настроения (фаза A8): среднее/мин/макс настроения живой популяции по виду и счётчики - // довольных (>0.7) / угнетённых (<0.35). Считает только особей с активным настроением (тир 4+). - private void RunMood(DevConsole console, string[] args) - { - if (_animals.Count == 0) - { - console.WriteLine("no animal species"); - return; - } - - var count = new int[_animals.Count]; - var sum = new double[_animals.Count]; - var min = new float[_animals.Count]; - var max = new float[_animals.Count]; - var content_ = new int[_animals.Count]; - var stressed = new int[_animals.Count]; - Array.Fill(min, 1f); - - Store - .Query() - .ForEachEntity( - (ref AnimalOrganism o, ref Mood m, Entity _) => - { - if (!m.Active) - { - return; // тир ниже зверя — настроения нет - } - - var s = o.Species; - count[s]++; - sum[s] += m.Value; - min[s] = MathF.Min(min[s], m.Value); - max[s] = MathF.Max(max[s], m.Value); - if (m.Value > 0.7f) - { - content_[s]++; - } - else if (m.Value < 0.35f) - { - stressed[s]++; - } - } - ); - - var filter = args.Length > 0 ? args[0] : null; - var any = false; - for (var s = 0; s < _animals.Count; s++) - { - var name = _animals[s].Def.DefName; - if ( - count[s] == 0 - || ( - filter is not null - && !string.Equals(name, filter, StringComparison.OrdinalIgnoreCase) - ) - ) - { - continue; - } - - any = true; - console.WriteLine( - $"{name}: n={count[s]}, mood {sum[s] / count[s]:0.##} (min {min[s]:0.##}, max {max[s]:0.##}), " - + $"content {content_[s]}, stressed {stressed[s]}" - ); - } - - if (!any) - { - console.WriteLine(filter is null ? "no animals with mood" : $"no '{filter}' with mood"); - } - } - - // Наблюдаемость навыков: средний уровень каждого навыка по живым животным (растут от практики). - private void RunSkills(DevConsole console) - { - if (_skills.Count == 0) - { - console.WriteLine("no skill defs loaded"); - return; - } - - var sum = new double[_skills.Count]; - var n = 0; - Store - .Query() - .ForEachEntity( - (ref Skills s, Entity _) => - { - if (s.Levels is null) - { - return; - } - - n++; - for (var i = 0; i < _skills.Count && i < s.Levels.Length; i++) - { - sum[i] += s.Levels[i]; - } - } - ); - - console.WriteLine($"skills over {n} animals (mean level):"); - for (var i = 0; i < _skills.Count; i++) - { - console.WriteLine( - $" {_skills[i].DefName}: {(n > 0 ? sum[i] / n : 0):0.###} " - + $"(learn {_skills[i].LearnRate:0.##}/d, decay {_skills[i].DecayPerDay:0.###}/d)" - ); - } - } - - private static string ProductLabel(GameContent content, string productDefName) => - content.Defs.TryGet(productDefName, out var product) - ? content.Languages.Get(product.Label) - : productDefName; } diff --git a/src/LittleSim/Sim/AnimalCapacities.cs b/src/LittleSim/Sim/AnimalCapacities.cs new file mode 100644 index 0000000..12488e6 --- /dev/null +++ b/src/LittleSim/Sim/AnimalCapacities.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; + +namespace LittleSim.Sim; + +/// Идентификаторы способностей (capacities) организма — на них ссылаются вклады частей тела. +public static class AnimalCapacities +{ + public const string Consciousness = "Consciousness"; + public const string Moving = "Moving"; + public const string Breathing = "Breathing"; + public const string BloodPumping = "BloodPumping"; + public const string BloodFiltration = "BloodFiltration"; + public const string Sight = "Sight"; + public const string Hearing = "Hearing"; + public const string Smell = "Smell"; + public const string Touch = "Touch"; + public const string Talking = "Talking"; + public const string Eating = "Eating"; + public const string Digestion = "Digestion"; + + /// Порядок отображения способностей в UI (сверху вниз). Прочие модовые — после, как есть. + public static readonly string[] DisplayOrder = + [ + Consciousness, + Moving, + Sight, + Hearing, + Smell, + Touch, + Talking, + Eating, + Breathing, + BloodPumping, + BloodFiltration, + Digestion, + ]; +} diff --git a/src/LittleSim/Sim/AnimalOrganism.cs b/src/LittleSim/Sim/AnimalOrganism.cs deleted file mode 100644 index 111f292..0000000 --- a/src/LittleSim/Sim/AnimalOrganism.cs +++ /dev/null @@ -1,759 +0,0 @@ -using System.Collections.Generic; -using Friflo.Engine.ECS; -using LittleSim.Content; -using Microsoft.Xna.Framework; -using MrGameEng.Genetics; - -namespace LittleSim.Sim; - -/// -/// Признаки (фенотип) животного, вычисленные из генома формулами генов (см. Defs/Genes/): -/// готовые значения, которые читают системы, не касаясь генов напрямую. Зеркало -/// . Имена полей соответствуют именам признаков в эффектах генов. -/// -public struct AnimalPhenotype -{ - /// Размер тела в клетках (масштаб спрайта; позже — HP/расход энергии). - public float BodySize; - - /// Скорость обмена веществ — множитель расхода нужд. - public float Metabolism; - - /// Скорость передвижения (множитель). - public float MoveSpeed; - - /// Объём крови (множитель) — масштаб скорости кровопотери. - public float BloodVolume; - - /// Дальность зрения (множитель радиуса поиска). - public float Vision; - - /// Острота слуха (множитель радиуса). - public float Hearing; - - /// Острота обоняния (множитель радиуса). - public float Smell; - - /// Острота осязания (множитель радиуса; чувство ближнего контакта). - public float Touch; - - /// Камуфляж [0..1] — снижает обнаружение этой особи зрением (заметность = 1−camouflage). - public float Camouflage; - - /// Запах [0..1] — повышает обнаружение этой особи обонянием. - public float Scent; - - /// Шумность [0..1] — повышает обнаружение этой особи слухом при движении. - public float Noise; - - /// Размер мозга (0..1) — основа интеллекта. - public float BrainSize; - - /// Теплоизоляция мехом (0..1). - public float Insulation; - - /// Оттенок меха (0..1) — сдвиг тинта спрайта. - public float FurHue; - - /// Возраст созревания (игровых дней) — порог стадии Adult. - public float MaturityAge; - - /// Травоядность [0..1] — способность/тяга питаться растениями. - public float Herbivory; - - /// Хищничество [0..1] — тяга охотиться и питаться мясом (живая добыча/падаль). - public float Carnivory; - - /// Всеядность [0..1] — генералист: включает оба источника пищи (растения и мясо). - public float Omnivory; - - /// - /// Стиль питания [0..1]: 0 — «пастьба» (graze), часто подтапливает сытость мелкими приёмами; - /// 1 — «обжорство» (gorge), ищет еду лишь при сильном голоде и ест до полной сытости (редкие - /// крупные приёмы, как у хищников). Задаёт пороги начала/конца кормёжки в . - /// - public float FeedingStyle; - - /// Устойчивость к растительным ядам [0..1] — снижает дозу отравления токсичным кормом. - public float ToxinTolerance; - - /// Социальность [0..1] — тяга держаться стаи/стада (когезия + спокойствие в группе). - public float Sociability; - - /// Продолжительность жизни (игровых дней). - public float Lifespan; - - /// Сезон гона (0 весна … 3 зима). - public int BreedingSeason; - - /// Срок вынашивания (игровых дней). - public float GestationDays; - - /// Размер помёта. - public float LitterSize; - - /// Тип рождения [0..1]: ≥0.5 — яйцекладущий (откладывает яйца), иначе живородящий. - public float EggLaying; - - /// Яйцекладущий ли вид (тип рождения) — определяет цикл зачатия. - public readonly bool Oviparous => EggLaying >= 0.5f; - - /// Собирает фенотип из карты признаков, посчитанной . - public static AnimalPhenotype FromTraits(IReadOnlyDictionary traits) - { - float T(string name) => traits.GetValueOrDefault(name); - // Острота чувств по умолчанию 1 (вид без гена не «слепнет»); заметность — мягкие дефолты. - float Acuity(string name) => traits.TryGetValue(name, out var v) ? v : 1f; - return new AnimalPhenotype - { - BodySize = T("bodySize"), - Metabolism = T("metabolism"), - MoveSpeed = T("moveSpeed"), - BloodVolume = T("bloodVolume"), - Vision = T("vision"), - Hearing = Acuity("hearing"), - Smell = Acuity("smell"), - Touch = Acuity("touch"), - Camouflage = T("camouflage"), - Scent = traits.GetValueOrDefault("scent", 0.5f), - Noise = traits.GetValueOrDefault("noise", 0.5f), - BrainSize = T("brainSize"), - Insulation = T("insulation"), - FurHue = T("furHue"), - MaturityAge = T("maturityAge"), - Herbivory = T("herbivory"), - Carnivory = T("carnivory"), - Omnivory = T("omnivory"), - FeedingStyle = traits.GetValueOrDefault("feedingStyle", 0.5f), - ToxinTolerance = T("toxinTolerance"), - Sociability = T("sociability"), - Lifespan = T("lifespan"), - BreedingSeason = (int)MathF.Round(Math.Clamp(T("breedingSeason"), 0f, 3f)), - GestationDays = T("gestationDays"), - LitterSize = T("litterSize"), - EggLaying = T("eggLaying"), - }; - } -} - -/// -/// Компонент-организм животного: управляемый (для размножения, сейва и -/// пересчёта), выраженный фенотип , индекс вида в -/// и номер поколения (для popstats и видимого дрейфа). Геном — ссылочный объект, поэтому -/// компонент несёт ссылку, а не копию (как у растений). Зеркало . -/// -public struct AnimalOrganism : IComponent -{ - /// Геном особи (аллели по генам); источник истины для признаков и размножения. - public Genome Genome; - - /// Выраженные признаки — то, что читают системы. - public AnimalPhenotype Traits; - - /// Индекс вида в . - public int Species; - - /// Пол: самец (XY) или самка (XX) — выведен из локуса GeneSex при создании. - public bool IsMale; - - /// Ест ли растения (диета по генам/дефу, неизменна после рождения — кэш вместо пересчёта на тик). - public bool EatsPlants; - - /// Ест ли мясо (диета по генам/дефу, неизменна после рождения — кэш вместо пересчёта на тик). - public bool EatsMeat; - - /// Номер поколения: max(родителей) + 1; у основателей 0. - public int Generation; -} - -/// -/// Состояние роста животного (фаза A3): текущая стадия и накопленный возраст в игровых днях. Плоские -/// данные — переход между стадиями (Baby→Juvenile→Adult→Senior), смену спрайта и размера выполняет -/// , читая пороги из генов (возраст созревания, продолжительность жизни). -/// Зеркало . -/// -public struct AnimalGrowth : IComponent -{ - /// Текущая стадия: 0 Baby, 1 Juvenile, 2 Adult, 3 Senior. - public int Stage; - - /// Накопленный возраст в игровых днях. - public float AgeDays; -} - -/// Инстанс части тела особи: ссылка на деф части и текущий/максимальный HP (масштаб по размеру). -public struct PartInstance -{ - /// Деф части тела (анатомия вида). - public Content.BodyPartDef Def; - - /// Текущий HP. - public float Hp; - - /// Максимальный HP (база × размер тела). - public float MaxHp; - - /// Доля сохранности [0,1]. - public readonly float Fraction => MaxHp > 0f ? Math.Clamp(Hp / MaxHp, 0f, 1f) : 0f; -} - -/// Инстанс хедифа на особи: деф + текущая тяжесть и набранный иммунитет (гонка, фаза A6). -public struct Hediff -{ - /// Деф хедифа (гон/болезнь/…). - public HediffDef Def; - - /// Текущая тяжесть. - public float Severity; - - /// Набранный иммунитет [0,1]; при 1 — выздоровление. - public float Immunity; -} - -/// -/// Состояние здоровья особи (фазы A4–A6): активные хедифы (инстансы) + части тела, кровь и вычисленные -/// способности (capacities). Managed-объект, как (Friflo допускает ссылку в -/// компоненте). Болезни (A6) — первый источник урона: снижают способности и могут убить. Растёт по -/// фазам — контейнер один. -/// -public sealed class HealthState -{ - private readonly List _hediffs = []; - - /// Активные хедифы (гон, болезни). - public IReadOnlyList Hediffs => _hediffs; - - /// Части тела особи (из анатомии вида); пусто — у вида нет тела. - public PartInstance[] Parts { get; private set; } = []; - - /// Уровень крови [0,1]; ниже порога — потеря сознания и смерть (когда появятся раны). - public float BloodLevel { get; set; } = 1f; - - /// Боль [0,1] — снижает сознание (когда появятся раны). - public float Pain { get; set; } - - /// Погибла ли особь по здоровью (vital-часть уничтожена / кровь на нуле). - public bool Dead { get; set; } - - /// Вычисленные способности (capacity id → [0,1]); у здоровой особи все = 1. - public Dictionary Capacities { get; private set; } = new(); - - /// Способность по id (1, если не вычислена — напр. у вида без тела), для множителей действий. - public float Capacity(string id) => Capacities.TryGetValue(id, out var v) ? v : 1f; - - /// - /// Пересчитывает боль (из хедифов) и способности: базовые из частей/крови/боли, затем модификаторы - /// способностей от хедифов (лерп 1→factor по тяжести), затем общий множитель - /// (старение/сенесценс: дряхлеющий организм слабее по всем способностям). Вызывать после изменения - /// частей/крови/хедифов; =1 — без возрастного спада. - /// - public void RecomputeCapacities(float capacityScale = 1f) - { - var pain = 0f; - foreach (var h in _hediffs) - { - pain += h.Def.Pain * h.Severity; - } - - Pain = Math.Clamp(pain, 0f, 1f); - - // Множитель старения применяется внутри Compute. Пишем в собственный словарь Capacities - // (переиспользуется, очищается внутри Compute) — без аллокации карты способностей на пересчёт. - var caps = Capacities; - CapacityCalc.Compute(Parts, BloodLevel, Pain, capacityScale, caps); - foreach (var h in _hediffs) - { - if (h.Def.CapMods.Count == 0) - { - continue; - } - - var t = Math.Clamp(h.Severity, 0f, 1f); - foreach (var (capacity, factor) in h.Def.CapMods) - { - caps[capacity] = caps.GetValueOrDefault(capacity, 1f) * (1f + (factor - 1f) * t); - } - } - } - - /// - /// Прогрессирует болезни (тяжесть и иммунитет растут со временем); снимает выздоровевшие - /// (иммунитет ≥ 1); возвращает true, если какая-то болезнь достигла летальной тяжести. - /// — множитель набора иммунитета (способность «фильтрация крови»: - /// почки/печень выводят токсины и помогают бороться с болезнью; повреждённые → медленнее). - /// - public bool AdvanceHediffs(float days, float immunityScale = 1f) - { - var lethal = false; - for (var i = _hediffs.Count - 1; i >= 0; i--) - { - var h = _hediffs[i]; - if (h.Def.SeverityPerDay <= 0f && h.Def.ImmunityPerDay <= 0f) - { - continue; // статичный хедиф (гон) не прогрессирует - } - - h.Severity += h.Def.SeverityPerDay * days; - h.Immunity += h.Def.ImmunityPerDay * days * immunityScale; - if (h.Immunity >= 1f) - { - _hediffs.RemoveAt(i); // иммунитет победил — выздоровление - continue; - } - - if (h.Def.LethalSeverity > 0f && h.Severity >= h.Def.LethalSeverity) - { - lethal = true; - } - - _hediffs[i] = h; - } - - return lethal; - } - - /// Строит здоровье особи: части из анатомии вида (HP × размер тела) + начальные способности. - public static HealthState Create(Content.BodyDef? body, float bodySize) - { - var state = new HealthState(); - if (body is { Parts.Length: > 0 }) - { - var scale = MathF.Max(0.2f, bodySize); - state.Parts = new PartInstance[body.Parts.Length]; - for (var i = 0; i < body.Parts.Length; i++) - { - var hp = body.Parts[i].MaxHp * scale; - state.Parts[i] = new PartInstance - { - Def = body.Parts[i], - Hp = hp, - MaxHp = hp, - }; - } - } - - state.RecomputeCapacities(); - return state; - } - - /// Несёт ли особь хедиф с данным именем дефа. - public bool Has(string defName) - { - foreach (var h in _hediffs) - { - if (h.Def.DefName == defName) - { - return true; - } - } - - return false; - } - - /// Навешивает хедиф (если ещё не навешан) с начальной тяжестью из дефа. - public void Add(HediffDef def) - { - if (!Has(def.DefName)) - { - _hediffs.Add(new Hediff { Def = def, Severity = def.InitialSeverity }); - } - } - - /// Восстанавливает хедиф с заданной тяжестью и иммунитетом (для загрузки сейва). - public void AddHediff(HediffDef def, float severity, float immunity) - { - if (!Has(def.DefName)) - { - _hediffs.Add( - new Hediff - { - Def = def, - Severity = severity, - Immunity = immunity, - } - ); - } - } - - /// Снимает хедиф по имени дефа; возвращает, был ли он. - public bool Remove(string defName) - { - for (var i = 0; i < _hediffs.Count; i++) - { - if (_hediffs[i].Def.DefName == defName) - { - _hediffs.RemoveAt(i); - return true; - } - } - - return false; - } - - /// - /// Травма (укус хищника): повреждает часть тела (выбор по весу попадания coverage), вызывает - /// мгновенную кровопотерю и навешивает/усиливает кровотечение, затем пересчитывает способности. - /// Первый травматический источник урона — оживляет каркас здоровья A5. - /// - public void ApplyInjury( - float partDamage, - float bloodLoss, - HediffDef? bleeding, - float bleedSeverity, - Random rng - ) - { - if (Parts.Length > 0 && partDamage > 0f) - { - var idx = PickPart(rng); - var part = Parts[idx]; - part.Hp = MathF.Max(0f, part.Hp - partDamage); - Parts[idx] = part; - } - - BloodLevel = Math.Clamp(BloodLevel - bloodLoss, 0f, 1f); - if (bleeding is not null && bleedSeverity > 0f) - { - Intensify(bleeding, bleedSeverity); - } - - RecomputeCapacities(); - } - - // Выбор задетой части по весу попадания (coverage); детерминируется переданным rng. - private int PickPart(Random rng) - { - var total = 0f; - foreach (var p in Parts) - { - total += p.Def?.Coverage ?? 0f; - } - - if (total <= 0f) - { - return rng.Next(Parts.Length); - } - - var roll = (float)rng.NextDouble() * total; - for (var i = 0; i < Parts.Length; i++) - { - if (Parts[i].Def is null) - { - continue; - } - - roll -= Parts[i].Def.Coverage; - if (roll <= 0f) - { - return i; - } - } - - return Parts.Length - 1; - } - - /// - /// Навешивает хедиф или усиливает существующий: тяжесть растёт (до 1), а заживление (иммунитет) - /// сбрасывается — свежая рана начинает затягиваться заново. Для повторных укусов/ранений. - /// - public void Intensify(HediffDef def, float addSeverity) - { - for (var i = 0; i < _hediffs.Count; i++) - { - if (_hediffs[i].Def.DefName == def.DefName) - { - var h = _hediffs[i]; - h.Severity = Math.Clamp(h.Severity + addSeverity, 0f, 1f); - h.Immunity = 0f; // свежая рана — заживление с нуля - _hediffs[i] = h; - return; - } - } - - _hediffs.Add(new Hediff { Def = def, Severity = Math.Clamp(addSeverity, 0f, 1f) }); - } -} - -/// Здоровье особи: managed-состояние со списком хедифов (см. ). -public struct Health : IComponent -{ - /// Состояние здоровья (хедифы; позже части тела/кровь). - public HealthState State; -} - -/// -/// Труп животного (фаза A5): отдельная сущность после смерти. Разлагается во времени по стадиям -/// (Fresh→Rotting→Skeleton→исчезает), скорость зависит от температуры. Несёт запас мяса (∝ размер тела) -/// для будущей разделки/падальщиков. Управляется . -/// -public struct Corpse : IComponent -{ - /// Индекс вида в (для спрайта, разделки, сейва). - public int Species; - - /// Накоплено игровых дней разложения. - public float RotDays; - - /// Стадия: 0 свежий, 1 гниющий, 2 скелет. - public int Stage; - - /// Доступное мясо (∝ масса тела) — для разделки/падальщиков (фаза-горизонт). - public float Meat; - - /// Линейный размер тела (для масштаба спрайта трупа), независимо от запаса мяса. - public float BodySize; -} - -/// -/// Яйцо (тип рождения «яйцекладка»): отдельная сущность, отложенная яйцекладущей самкой вместо живых -/// родов. Несёт уже скрещённый геном будущего детёныша, вид и поколение; инкубируется во времени и -/// вылупляется в детёныша (). Геном — managed-ссылка (как у трупа/беременности). -/// -public struct Egg : IComponent -{ - /// Индекс вида в (для спрайта при вылуплении и сейва). - public int Species; - - /// Геном будущего детёныша (уже скрещён из родителей на момент кладки). - public Genome Genome; - - /// Поколение будущего детёныша. - public int Generation; - - /// Остаток инкубации в игровых днях; ≤0 — вылупление. - public float IncubateDays; -} - -/// Вид запомненного: угроза (избегать), корм/вода/партнёр (идти) — расширяемо. -public enum MemoryKind : byte -{ - Threat, - Food, - Water, - Mate, -} - -/// Запись памяти: что, где (воспринятая позиция — грубая, если по запаху), насколько уверенно -/// (падает со временем — забывание), сколько дней назад. -public struct MemoryEntry -{ - public MemoryKind Kind; - public Vector2 Pos; - public float Confidence; - public float AgeDays; -} - -/// -/// Память особи (объём ∝ мозгу): ограниченный набор записей о замеченном (сейчас — угрозы). Чувства -/// пишут сюда воспринятое; со временем уверенность падает и запись забывается; ИИ использует память, -/// когда цель не воспринимается сейчас (избегает зоны недавнего хищника). Список — managed-ссылка. -/// -public struct Memory : IComponent -{ - /// Активные записи (упорядочены по добавлению; слабейшая вытесняется при переполнении). - public List Entries; - - /// Сколько записей вмещает (∝ размеру мозга; 0 — память отсутствует). - public int Capacity; - - /// Запоминает/освежает наблюдение: близкое того же вида — обновляет, иначе добавляет (с - /// вытеснением слабейшего при переполнении). Вне ёмкости (Capacity 0) — ничего не помнит. - public readonly void Remember(MemoryKind kind, Vector2 pos, float mergeDist) - { - if (Entries is null || Capacity <= 0) - { - return; - } - - var mergeSq = mergeDist * mergeDist; - for (var i = 0; i < Entries.Count; i++) - { - if (Entries[i].Kind == kind && Vector2.DistanceSquared(Entries[i].Pos, pos) <= mergeSq) - { - Entries[i] = new MemoryEntry - { - Kind = kind, - Pos = pos, - Confidence = 1f, - AgeDays = 0f, - }; - return; - } - } - - if (Entries.Count >= Capacity) - { - var weakest = 0; - for (var i = 1; i < Entries.Count; i++) - { - if (Entries[i].Confidence < Entries[weakest].Confidence) - { - weakest = i; - } - } - - Entries.RemoveAt(weakest); - } - - Entries.Add( - new MemoryEntry - { - Kind = kind, - Pos = pos, - Confidence = 1f, - AgeDays = 0f, - } - ); - } -} - -/// Id навыков-эффектов (на них ссылается код начисления XP и применения бонуса). -public static class AnimalSkills -{ - /// Добыча корма: эффективность выпаса/поедания растений. - public const string Foraging = "Foraging"; - - /// Охота: урон по добыче. - public const string Hunting = "Hunting"; - - /// Уклонение: скорость бегства от хищника. - public const string Evasion = "Evasion"; -} - -/// -/// Навыки особи (как нужды/способности — managed-массивы по индексам ): -/// уровни [0..1] (растут от практики, угасают без неё) и страсть к каждому навыку (скорость обучения). -/// Есть у животных; те же навыки получат будущие люди. Добавление навыка не меняет структуру. -/// -public struct Skills : IComponent -{ - /// Уровни навыков [0..1] по индексам . - public float[] Levels; - - /// Страсть к каждому навыку (0 нет / 1 интерес / 2 страсть) — множитель обучения. - public byte[] Passions; -} - -/// -/// Ноша существа (задел под перенос предметов/хаул): суммарная масса переносимого груза в кг. Перегруз -/// сверх грузоподъёмности () замедляет движение. Компонент появляется -/// у существа, когда оно что-то несёт; пока источников груза нет — спящий каркас под будущий инвентарь. -/// -public struct Carrier : IComponent -{ - /// Суммарная масса переносимого груза (кг). - public float CarriedKg; -} - -/// -/// Проглоченное семя (эндозоохория): травоядное, съевшее плодоносящее растение, какое-то время несёт -/// его семя в кишечнике, затем «высаживает» его в другом месте () — -/// зверь становится разносчиком флоры. Геном — managed-ссылка (клон родительского растения). -/// -public struct GutSeed : IComponent -{ - /// Индекс вида растения в . - public int PlantSpecies; - - /// Геном будущего ростка (клон съеденного растения). - public Genome Genome; - - /// Остаток времени до высадки в игровых днях; ≤0 — росток падает на землю. - public float DropInDays; -} - -/// -/// Кратковременная «вспышка урона» (презентация, как в RimWorld): при получении удара выставляется -/// время свечения; подкрашивает спрайт красным и гасит свечение. -/// Есть у всех животных (выставляется в 0 при создании), чтобы удар не делал структурного добавления. -/// -public struct DamageFlash : IComponent -{ - /// Остаток реального времени свечения (сек); 0 — не подсвечен. - public float Seconds; -} - -/// -/// Личная хроника особи (фича UI «История»): ограниченный кольцевой список событий о ней (спаривание, -/// роды, охота, кормёжка, бегство, рост). Пишет , читает инспектор. Список — -/// managed-ссылка (как мысли/память); старые записи вытесняются по достижении . -/// -public struct CreatureLog : IComponent -{ - /// Записи в порядке добавления (новые в конце). - public List Entries; - - /// Сколько записей хранится (старейшие вытесняются). - public int Capacity; -} - -/// Инстанс мысли настроения на особи (фаза A8): деф события + остаток времени до угасания. -public struct ThoughtInstance -{ - /// Деф мысли (сдвиг настроения и длительность). - public Content.ThoughtDef Def; - - /// Сколько игровых дней мысль ещё действует. - public float RemainingDays; -} - -/// -/// Настроение особи (фаза A8): значение [0,1] и активные событийные мысли. Считается -/// из непрерывного фона (нужды/боль) и суммы мыслей. Есть только у -/// тира 4+ ( по размеру мозга) — у примитивных настроения нет. Низкое настроение -/// влияет на поведение (стресс не даёт спариваться) и видно в инспекторе. Список мыслей — managed-ссылка -/// (как AnimalNeeds.Values): события добавляют мысли через . -/// -public struct Mood : IComponent -{ - /// Текущее настроение [0,1]; у неактивного (тир < 4) держится нейтральным. - public float Value; - - /// Есть ли у особи настроение (гейт тиром мозга) — иначе фон/мысли не считаются. - public bool Active; - - /// Активные событийные мысли (затухают со временем). - public List Thoughts; - - /// - /// Вешает событийную мысль (или обновляет её длительность, если уже висит — без накопления - /// дубликатов). Вызывается из систем-источников событий (роды, спаривание, утоление жажды). - /// - public readonly void Add(Content.ThoughtDef? def) - { - if (Thoughts is null || def is null) - { - return; - } - - for (var i = 0; i < Thoughts.Count; i++) - { - if (Thoughts[i].Def == def) - { - var refreshed = Thoughts[i]; - refreshed.RemainingDays = def.DurationDays; - Thoughts[i] = refreshed; - return; - } - } - - Thoughts.Add(new ThoughtInstance { Def = def, RemainingDays = def.DurationDays }); - } -} - -/// -/// Беременность самки (фаза A4): managed-ссылка на геном отца («слепок» на момент зачатия), остаток -/// срока вынашивания и размер помёта. По истечении срока -/// рожает детёнышей через и снимает компонент. -/// -public struct Pregnant : IComponent -{ - /// Геном отца на момент зачатия. - public Genome FatherGenome; - - /// Остаток срока вынашивания (игровых дней). - public float DueInDays; - - /// Сколько детёнышей родится. - public int Litter; -} diff --git a/src/LittleSim/Sim/AnimalSystems.cs b/src/LittleSim/Sim/AnimalSystems.cs deleted file mode 100644 index 3b7e166..0000000 --- a/src/LittleSim/Sim/AnimalSystems.cs +++ /dev/null @@ -1,3208 +0,0 @@ -using Friflo.Engine.ECS; -using Friflo.Engine.ECS.Systems; -using LittleSim.Content; -using Microsoft.Xna.Framework; -using MrGameEng.AI; -using MrGameEng.Core; -using MrGameEng.Genetics; -using MrGameEng.Graphics; - -namespace LittleSim.Sim; - -/// -/// Id поведений-исполнителей (реестр действий ИИ). Дефы нужд () ссылаются на -/// них; выбирает действие, исполняет. -/// Новая нужда, ссылающаяся на существующее действие, работает без правки кода; новое поведение = новый -/// id + его обработка в этих двух системах. -/// -public static class AnimalActions -{ - public const string Wander = "Wander"; - public const string Eat = "Eat"; - public const string Drink = "Drink"; - public const string Sleep = "Sleep"; - public const string Mate = "Mate"; - - /// Охота: преследовать живую добычу и кусать (урон) либо есть труп — утоляет голод хищника. - public const string Hunt = "Hunt"; - - /// Бегство: уходить от ближайшего хищника (реакция жертвы, важнее прочих нужд). - public const string Flee = "Flee"; - - // — Холостые (idle) активности: чем зверь занят, когда нет срочных нужд. Выбираются внутри ветки - // Wander с фиксированной длительностью (см. AnimalDecisionSystem) — придают поведению разнообразие. - - /// Отдых: стоит/лежит на месте, понемногу восстанавливая силы. - public const string Rest = "Rest"; - - /// Копание в земле в поисках корня/личинок (травоядные/всеядные) — даёт чуть сытости. - public const string Dig = "Dig"; - - /// Валяние в грязи у воды (комфорт/терморегуляция) — стоит на месте у кромки. - public const string Wallow = "Wallow"; -} - -/// -/// Id событийных мыслей настроения (фаза A8). Системы-источники события вешают мысль с этим id (через -/// ); её сила/длительность/метка — данные (thoughts.json). Новая мысль на -/// новое событие = код-триггер + деф; правка чисел существующей — только JSON. -/// -public static class AnimalThoughts -{ - public const string GaveBirth = "GaveBirth"; - public const string Mated = "Mated"; - public const string QuenchedThirst = "QuenchedThirst"; -} - -/// -/// Нужды зверя как ДАННЫЕ (рефактор расширяемости): значения по индексам из -/// (managed-массив — добавление нужды не меняет структуру) + счётчик истощения. Падают/растут в -/// по , восполняются исполнением действия. -/// -public struct AnimalNeeds : IComponent -{ - /// Значения нужд [0,1] по индексам . - public float[] Values; - - /// Накоплено игровых дней истощения (летальная нужда на нуле); порог — смерть (A3). - public float Starve; -} - -/// -/// «Мозг» зверя: выбранное действие (id поведения), его цель и таймер до пересмотра. Решение принимает -/// (движковый ), исполняет -/// . -/// -public struct AnimalBrain : IComponent -{ - /// Id текущего действия (см. ). - public string Action; - - /// Куда идти (позиция растения/воды/партнёра/точки блуждания). - public Vector2 Target; - - /// Id растения-цели для ; -1 — нет. - public int TargetPlant; - - /// Id партнёра для ; -1 — нет. - public int TargetMate; - - /// Id цели для — живой добычи или трупа; -1 — нет. - public int TargetPrey; - - /// Цель — труп (падаль), а не живая добыча: при достижении едим, а не атакуем. - public bool TargetIsCorpse; - - /// Секунды до следующего пересмотра решения. - public float DecideIn; - - /// Игровые секунды до следующего удара по добыче (кулдаун дискретных атак, как в RimWorld). - public float AttackCooldown; - - /// Текущее направление блуждания (коррелированное — плавно дрейфует, не дёргается каждый раз). - public Vector2 Heading; - - /// Игровое время (TotalTime), до которого длится текущая холостая активность (отдых/копание/…). - public float ActivityUntil; -} - -/// -/// Снимок нужд зверя для соображений utility-выбора (доступ по индексу нужды) с гейтом интеллектом -/// (фаза A7): нужда, чей выше эффективного интеллекта особи -/// (brainSize × Consciousness), в выбор не входит. -/// -public readonly struct AnimalContext( - float[] values, - NeedSet needs, - float intelligence, - int overrideIndex = -1, - float overrideValue = 0f -) -{ - private readonly float[] _values = values; - private readonly NeedSet _needs = needs; - private readonly float _intelligence = intelligence; - - // Переопределение значения одной нужды (стиль питания: «эффективный голод» с гистерезисом — - // см. AnimalDecisionSystem). Override имеет приоритет над сырым значением и над гейтом мозга. - private readonly int _overrideIndex = overrideIndex; - private readonly float _overrideValue = overrideValue; - - /// - /// Значение нужды по индексу. Если для этой нужды задан override (стиль питания) — он и возвращается. - /// Если её порог мозга выше интеллекта особи — нужда недоступна и возвращается её «не-срочный» край - /// (deplete → 1 «сыта», drive → 0 «без влечения»), чтобы соображение дало ~0 и действие не выбралось. - /// Индекс вне диапазона → 0. - /// - public float Get(int index) - { - if (index == _overrideIndex) - { - return _overrideValue; - } - - if (index >= _values.Length) - { - return 0f; - } - - if (_needs[index].MinBrain > _intelligence) - { - return _needs.IsDrive(index) ? 0f : 1f; - } - - return _values[index]; - } -} - -/// -/// Падение/рост нужд со временем (игровые дни через ): deplete-нужды убывают -/// (× ген обмена веществ), drive-нужды растут под своим хедифом (гон) и спадают вне его. Летальные -/// нужды на нуле копят счётчик истощения. Полностью управляется данными . -/// -public sealed class AnimalNeedsSystem( - GameClock clock, - float secondsPerDay, - NeedSet needs, - AnimalSet animals -) : QuerySystem -{ - // Прибавка к обмену веществ при устойчивости к яду 1.0 (цена детоксикации, коэволюция C2). - private const float ToleranceMetabolicCost = 0.5f; - - protected override void OnUpdate() - { - var days = clock.DeltaTime / secondsPerDay; - if (days <= 0f) - { - return; - } - - foreach (var (needsChunk, organisms, healths, growths, _) in Query.Chunks) - { - var n = needsChunk.Span; - var o = organisms.Span; - var h = healths.Span; - var g = growths.Span; - for (var i = 0; i < n.Length; i++) - { - var values = n[i].Values; - if (values is null || values.Length < needs.Count) - { - continue; // защита от старого/пустого состояния - } - - // Обмен веществ = ген × цена устойчивости к яду (C2) × множитель Клайбера по массе тела: - // расход ∝ масса^0.75 = (линейный размер/эталон)^2.25 (масса ∝ размер³), нормирован на - // эталон вида → молодняк экономнее, крупные прожорливее. Эталон предпосчитан (без словаря). - var sp = animals[o[i].Species]; - var stageScale = sp.Stages[Math.Clamp(g[i].Stage, 0, sp.Stages.Length - 1)].Scale; - var sizeRatio = MathF.Max( - 0.01f, - o[i].Traits.BodySize * stageScale / sp.RefBodySize - ); - var metabolism = - MathF.Max(0.1f, o[i].Traits.Metabolism) - * (1f + ToleranceMetabolicCost * Math.Clamp(o[i].Traits.ToxinTolerance, 0f, 1f)) - * Mass.KleiberFactorFromSizeRatio(sizeRatio); - var lethalEmpty = false; - for (var k = 0; k < needs.Count; k++) - { - var def = needs[k]; - if (needs.IsDrive(k)) - { - var rising = def.RisesUnder is null || h[i].State.Has(def.RisesUnder); - var delta = (rising ? def.RisePerDay : -def.DecayPerDay) * days; - values[k] = Math.Clamp(values[k] + delta, 0f, 1f); - } - else - { - values[k] = Math.Clamp( - values[k] - def.DecayPerDay * metabolism * days, - 0f, - 1f - ); - if (def.Lethal && values[k] <= 0f) - { - lethalEmpty = true; - } - } - } - - ref var starve = ref n[i].Starve; - starve = lethalEmpty ? starve + days : MathF.Max(0f, starve - days * 2f); - } - } - } -} - -/// -/// Гон (фаза A4): взрослым в их брачный сезон навешивает хедиф Rut, вне сезона снимает. Рост влечения -/// под этим хедифом считает (нужда Mating с risesUnder=Rut) — -/// сезонность и влечение разнесены: эта система отвечает только за состояние. -/// -public sealed class AnimalRutSystem(AnimalSet animals, Climate climate, HediffDef rut) - : QuerySystem -{ - protected override void OnUpdate() - { - var season = (int)climate.Season; - foreach (var (growths, organisms, healths, _) in Query.Chunks) - { - var g = growths.Span; - var o = organisms.Span; - var h = healths.Span; - for (var i = 0; i < g.Length; i++) - { - var inRut = - AnimalFactory.IsAdult(animals[o[i].Species], g[i].Stage) - && season == o[i].Traits.BreedingSeason; - if (inRut) - { - h[i].State.Add(rut); - } - else - { - h[i].State.Remove(rut.DefName); - } - } - } - } -} - -/// -/// Слой ВЫБОРА: раз в секунд каждый зверь выбирает действие движковым -/// , построенным ИЗ ДАННЫХ (по соображению на -/// нужду), и находит цель действия (еда по диете, вода, партнёр). Решение пишется в -/// ; исполняет . -/// -public sealed class AnimalDecisionSystem : BaseSystem -{ - private const float Interval = 0.6f; - private const float MateMoodFloor = 0.35f; // ниже — стресс/истощение, зверь не спаривается (A8) - private const float HerdMinSociability = 0.3f; // ниже — вид не стадный (когезия/безопасность выкл.) - private const float SafetyInNumbers = 0.5f; // макс. снижение радиуса реакции на угрозу в группе - private const int HerdFullCount = 6; // размер группы, при котором эффект «безопасность в числе» полон - - // Стиль питания (gorge↔graze): пороги начала/конца кормёжки лерпуются по гену feedingStyle [0..1]. - // graze (0): начинает есть уже при лёгком голоде и наедается не до полного — частые мелкие приёмы. - // gorge (1): ищет еду лишь при сильном голоде, но ест до полной сытости — редкие крупные приёмы. - private const float FeedStartGraze = 0.8f; - private const float FeedStartGorge = 0.4f; - private const float FeedFullGraze = 0.9f; - private const float FeedFullGorge = 1.0f; - - // Блуждание/стадность (boids): коррелированный дрейф направления + расхождение (личное пространство) и - // лишь слабое притяжение к центру группы на удалении — без этого стадо схлопывалось в одну точку. - private const float WanderTurn = 0.5f; // макс. поворот направления за пересмотр (рад) — плавные пути - private const float RoamCells = 11f; // длина «отрезка» блуждания в клетках - private const float SeparationCells = 2.2f; // ближе этого к соседу — расходимся - private const float SeparationWeight = 1.6f; // сила расхождения - private const float CohesionStartCells = 7f; // дальше этого от центра группы — слабо подтягиваемся - private const float CohesionWeight = 0.45f; // сила когезии (намеренно мала) - private const float EdgeMarginCells = 2.5f; // у края мира разворачиваем направление внутрь - - // Холостые активности: длительность (игровые секунды) каждой, пока зверь ей занят. - private const float RoamMinSec = 3f; - private const float RoamMaxSec = 6f; - private const float RestMinSec = 5f; - private const float RestMaxSec = 11f; - private const float DigMinSec = 3f; - private const float DigMaxSec = 7f; - private const float WallowMinSec = 5f; - private const float WallowMaxSec = 10f; - private const float WallowWaterCells = 4f; // валяться можно лишь у воды (в этом радиусе от кромки) - - private readonly UtilityAi _brain; - private readonly EntityStore _store; - private readonly WorldEvents _events; - private readonly GameClock _clock; - private readonly AnimalSet _animalSet; - private readonly NeedSet _needs; - private readonly int _hungerNeed; // индекс убывающей нужды «голод» (для гистерезиса кормёжки); -1 — нет - private readonly int _restNeed; // индекс нужды «отдых» (для веса холостого отдыха); -1 — нет - private readonly int _cellSize; - private readonly Vector2[] _shoreByCell; // поле «ближайший берег» по клеткам (O(1) поиск водопоя) - private readonly int _worldWidth; - private readonly int _worldHeight; - private readonly Random _rng; - private readonly ArchetypeQuery< - AnimalNeeds, - AnimalBrain, - AnimalOrganism, - AnimalGrowth, - Transform2D - > _animals; - private readonly ArchetypeQuery _plants; - private readonly ArchetypeQuery _corpses; - - // Пространственные индексы + переиспользуемые буферы запросов: поиск корма/угрозы/добычи/партнёра/ - // стада берёт только ближние сущности вместо прохода по всем (было O(растений)+O(животных) на каждое - // решение). В ячейке — предпосчитанные признаки (без дорефетча). Сетку животных перестраиваем каждый - // кадр (они двигаются), сетки растений/трупов — раз в StaticGridRebuildSeconds (они статичны). - private const int GridCells = 10; // размер ячейки сетки в клетках мира (~радиус восприятия) - private const float HearingRangeMult = 1.4f; // слух шире зрения - private const float SmellRangeMult = 1.8f; // запах — дальше всех (но грубый, см. Detect) - private const float RememberedThreatAvoidCells = 14f; // в этой зоне от памяти об угрозе зверь уходит прочь - - // Каденция перестройки СТАТИЧНЫХ сеток (растения/трупы): они не двигаются (меняются лишь при - // рождении/смерти), поэтому полный обход тысяч растений на каждый кадр был основной ценой системы. - // Перестраиваем раз в эти игровые секунды (≈ как часто одна особь и так пересматривает решение); - // удалённые сущности в устаревшей сетке безопасны — потребитель сверяется со store перед действием. - private const float StaticGridRebuildSeconds = 0.5f; - private readonly SpatialGrid _plantGrid; - private readonly List _plantBuf = []; - private readonly SpatialGrid _animalGrid; - private readonly List _animalBuf = []; - private readonly SpatialGrid _corpseGrid; - private readonly List _corpseBuf = []; - private float _staticGridAccum; // накоплено игровых секунд с последней перестройки статичных сеток - private bool _staticGridsBuilt; // первая перестройка обязательна (до неё сетки пусты) - private readonly float _smellSnap; // размер «обонятельной» ячейки (грубая локализация по запаху) - - public AnimalDecisionSystem( - EntityStore store, - GameClock clock, - AnimalSet animals, - NeedSet needs, - int cellSize, - Vector2[] shoreByCell, - int worldWidth, - int worldHeight, - WorldEvents events, - int seed - ) - { - _brain = BuildBrain(needs); - _store = store; - _events = events; - _clock = clock; - _animalSet = animals; - _needs = needs; - _hungerNeed = needs.NeedForAction(AnimalActions.Eat); - _restNeed = needs.NeedForAction(AnimalActions.Sleep); - _cellSize = cellSize; - _shoreByCell = shoreByCell; - _worldWidth = worldWidth; - _worldHeight = worldHeight; - _rng = new Random(seed); - _plantGrid = new SpatialGrid(cellSize * GridCells); - _animalGrid = new SpatialGrid(cellSize * GridCells); - _corpseGrid = new SpatialGrid(cellSize * GridCells); - _smellSnap = cellSize * GridCells; - _animals = store.Query< - AnimalNeeds, - AnimalBrain, - AnimalOrganism, - AnimalGrowth, - Transform2D - >(); - _plants = store.Query(); - _corpses = store.Query(); - } - - // Строит utility-reasoner из данных: на каждую нужду — действие с соображением. Deplete: чем ниже - // значение, тем привлекательнее ((1-v)^3). Drive: чем выше влечение, тем привлекательнее (v). - private static UtilityAi BuildBrain(NeedSet needs) - { - var actions = new List>(needs.Count + 1); - for (var i = 0; i < needs.Count; i++) - { - var index = i; - var def = needs[i]; - var consideration = needs.IsDrive(i) - ? new Consideration(def.DefName, c => c.Get(index)) - : new Consideration( - def.DefName, - c => c.Get(index), - curve: ResponseCurve.Polynomial(exponent: 3f, slope: -1f, xShift: 1f) - ); - actions.Add(new UtilityAction(def.Action, consideration)); - } - - actions.Add( - new UtilityAction( - AnimalActions.Wander, - new Consideration("idle", _ => 0.12f) - ) - ); - return new UtilityAi(actions.ToArray()); - } - - protected override void OnUpdateGroup() - { - var delta = _clock.DeltaTime; - if (delta <= 0f) - { - return; // пауза — решения не пересматриваются (как и прочие sim-системы) - } - - var clockNow = (float)_clock.TotalTime; // игровое время для таймеров холостых активностей - RebuildAnimalGrid(); // животные двигаются — индекс свежий каждый кадр - // Растения/трупы статичны — индекс перестраиваем по каденции, а не каждый кадр. - _staticGridAccum += delta; - if (!_staticGridsBuilt || _staticGridAccum >= StaticGridRebuildSeconds) - { - RebuildStaticGrids(); - _staticGridAccum = 0f; - _staticGridsBuilt = true; - } - - foreach ( - var (needsChunk, brains, organisms, growths, transforms, entities) in _animals.Chunks - ) - { - var n = needsChunk.Span; - var b = brains.Span; - var o = organisms.Span; - var gr = growths.Span; - var t = transforms.Span; - for (var i = 0; i < b.Length; i++) - { - ref var brain = ref b[i]; - brain.DecideIn -= delta; - if (brain.DecideIn > 0f) - { - continue; - } - - brain.DecideIn = Interval + _rng.NextSingle() * Interval; // джиттер — расфазировать скан - var prevAction = brain.Action; // для журнала: события на смене действия (начал/закончил) - var pos = t[i].Position; - var adult = AnimalFactory.IsAdult(_animalSet[o[i].Species], gr[i].Stage); - var self = entities.EntityAt(i); - var health = self.GetComponent().State; - // Эффективный интеллект (мозг × сознание) гейтит доступные нужды/действия (фаза A7): - // больной/раненый зверь с упавшим сознанием теряет высшие нужды (сон/секс) → рефлексы. - var intelligence = AnimalFactory.Intelligence(o[i].Traits, health); - // Радиусы восприятия по чувствам: зрение (корм/партнёр), плюс слух и обоняние (угроза/добыча). - // Острота — гены чувств × способность органа (раны/хедифы их режут). Зрение узкое, слух шире, - // запах дальше всех, но грубый (см. Detect). Радиусы детерминированы. - var baseRadius = VisionRadius(o[i].Species, o[i].Traits.Vision); - var sight = health?.Capacity(AnimalCapacities.Sight) ?? 1f; - var hearing = health?.Capacity(AnimalCapacities.Hearing) ?? 1f; - var smell = health?.Capacity(AnimalCapacities.Smell) ?? 1f; - var seeRadius = baseRadius * MathF.Max(0.2f, sight); - var senseBase = _animalSet[o[i].Species].Def.VisionCells * _cellSize; - var ranges = new SenseRanges( - seeRadius, - senseBase * o[i].Traits.Hearing * MathF.Max(0.2f, hearing) * HearingRangeMult, - senseBase * o[i].Traits.Smell * MathF.Max(0.2f, smell) * SmellRangeMult - ); - // Соседей по сетке собираем ОДИН раз на решение (на макс. радиусе чувств): стадность, - // бегство, поиск добычи и партнёра дальше читают этот буфер со своими фильтрами дистанции — - // вместо 2–4 повторных обходов сетки и копирований за одно решение. - _animalGrid.Collect(pos, ranges.Max(), _animalBuf); - - // Настроение (A8): стресс/истощение (низкое настроение) подавляет тягу к спариванию. - var mood = self.GetComponent(); - - // Стадность (boids): социальные виды считают центр/размер группы соплеменников (когезия и - // «безопасность в числе») и вектор расхождения herdAway (личное пространство — чтобы стадо - // держалось вместе, но не схлопывалось в точку). - var sociability = o[i].Traits.Sociability; - var herdCount = 0; - var herdCenter = pos; - var herdAway = Vector2.Zero; - if (sociability >= HerdMinSociability) - { - HerdScan( - pos, - seeRadius, - o[i].Species, - self.Id, - out herdCenter, - out herdCount, - out herdAway - ); - } - - var safety = - herdCount > 0 - ? 1f - SafetyInNumbers * MathF.Min(1f, herdCount / (float)HerdFullCount) - : 1f; - - // Бегство (предатор-кластер): жертва, заметившая рядом хищника, спасается — это важнее - // любых нужд (инстинкт выживания). Сам хищник от добычи не бежит (см. IsThreatTo). - if ( - TryFindThreat( - pos, - ranges.Scaled(safety), - o[i].Species, - o[i].EatsMeat, - o[i].Traits.BodySize, - out var threatPos - ) - ) - { - self.GetComponent().Remember(MemoryKind.Threat, threatPos, _smellSnap); - if (prevAction != AnimalActions.Flee) - { - _events.Fled(self, o[i].Species); // начало бегства — в личную хронику - } - - brain.Action = AnimalActions.Flee; - brain.TargetPlant = -1; - brain.TargetPrey = -1; - var away = pos - threatPos; - var len = away.Length(); - brain.Target = - len > 0.001f - ? pos + away / len * (ranges.Max() + _cellSize) - : pos + new Vector2(_cellSize, 0f); - continue; - } - - var eatsPlants = o[i].EatsPlants; // диета кэширована при рождении (без пересчёта на тик) - var eatsMeat = o[i].EatsMeat; - // Стиль питания: «эффективный голод» с гистерезисом. gorge не лезет за едой, пока сытость - // выше порога начала, но начав — ест до полного; graze подтапливает часто и до меньшего. - var hunger = EffectiveHunger( - n[i].Values, - o[i].Traits.FeedingStyle, - brain.Action is AnimalActions.Eat or AnimalActions.Hunt - ); - var name = - _brain - .Select( - new AnimalContext( - n[i].Values, - _needs, - intelligence, - _hungerNeed, - hunger - ) - ) - ?.Name - ?? AnimalActions.Wander; - - switch (name) - { - // Хищник/всеядное голодает → ищет труп (падаль) или живую добычу и охотится. - case AnimalActions.Eat - when eatsMeat - && TryFindKill( - pos, - ranges, - o[i].Species, - o[i].Traits, - self.Id, - out var preyId, - out var preyPos, - out var preyIsCorpse - ): - brain.Action = AnimalActions.Hunt; - brain.TargetPlant = -1; - brain.TargetPrey = preyId; - brain.TargetIsCorpse = preyIsCorpse; - brain.Target = preyPos; - // Начало охоты на ЖИВУЮ добычу (падальщество не считаем охотой) — заметное событие. - if ( - prevAction != AnimalActions.Hunt - && !preyIsCorpse - && _store.TryGetEntityById(preyId, out var preyEntity) - && !preyEntity.IsNull - && preyEntity.TryGetComponent(out var preyOrg) - ) - { - _events.HuntStarted(self, o[i].Species, preyOrg.Species); - } - - break; - case AnimalActions.Eat - when eatsPlants - && TryFindForage( - pos, - seeRadius, - intelligence, - o[i].Traits.ToxinTolerance, - out var plant, - out var pp - ): - brain.Action = AnimalActions.Eat; - brain.TargetPlant = plant; - brain.Target = pp; - break; - case AnimalActions.Drink when TryFindShore(pos, out var water): - brain.Action = AnimalActions.Drink; - brain.TargetPlant = -1; - brain.Target = water; - break; - case AnimalActions.Sleep: - brain.Action = AnimalActions.Sleep; - brain.TargetPlant = -1; - break; - case AnimalActions.Mate - when adult - && (!mood.Active || mood.Value >= MateMoodFloor) - && TryFindMate( - pos, - o[i].IsMale, - o[i].Species, - seeRadius, - out var mateId, - out var matePos - ): - brain.Action = AnimalActions.Mate; - brain.TargetPlant = -1; - brain.TargetMate = mateId; - brain.Target = matePos; - break; - default: - brain.TargetPlant = -1; - brain.TargetPrey = -1; - // Память: вблизи места недавнего хищника бросаем холостые дела и уходим прочь. - if (TryRememberedThreat(self, pos, out var dangerPos)) - { - var flee = pos - dangerPos; - var d = flee.Length(); - brain.Action = AnimalActions.Wander; - brain.Heading = d > 0.001f ? flee / d : RandomDir(); - brain.ActivityUntil = clockNow + RoamMinSec; - brain.Target = - pos + brain.Heading * (_cellSize * (4f + _rng.NextSingle() * 4f)); - } - else - { - // Конечный автомат холостых дел: держим текущую активность до её таймера, - // затем выбираем новую (блуждание/отдых/копание/валяние) с весами по виду. - if (!IsIdle(brain.Action) || clockNow >= brain.ActivityUntil) - { - PickIdleActivity(ref brain, self, o[i], pos, n[i].Values, clockNow); - } - - if (brain.Action == AnimalActions.Wander) - { - SteerRoam( - ref brain, - pos, - herdCenter, - herdCount, - herdAway, - sociability - ); - } - else - { - brain.Target = pos; // отдых/копание/валяние — стоим на месте - } - } - - break; - } - - // Бытовое в личную хронику: завершение приёма пищи/водопоя (по смене действия — без - // спама на каждый тик). Начало охоты/бегства логируется выше у своих веток. - if (prevAction == AnimalActions.Eat && brain.Action != AnimalActions.Eat) - { - _events.Ate(self, o[i].Species); - } - else if (prevAction == AnimalActions.Drink && brain.Action != AnimalActions.Drink) - { - _events.Drank(self, o[i].Species); - } - } - } - } - - // «Эффективный голод» для utility-выбора по стилю питания (gorge↔graze) с гистерезисом: - // — кормится сейчас → особь «голодна», пока сытость не достигнет порога полного (gorge ест до 1, - // graze — до меньшего): начатый приём пищи доводится до конца, а не бросается на полпути; - // — не кормится → особь «сыта» (1), пока сытость выше порога начала; ниже — падает в «голод» - // пропорционально, и чем выше стиль (gorge), тем ниже порог начала (ест реже, но крупнее). - // Возвращает значение [0,1] для нужды голода; при отсутствии нужды — 1 (override не применится). - private float EffectiveHunger(float[] values, float feedingStyle, bool feedingNow) - { - if (_hungerNeed < 0 || values is null || _hungerNeed >= values.Length) - { - return 1f; - } - - var sat = values[_hungerNeed]; - var style = Math.Clamp(feedingStyle, 0f, 1f); - if (feedingNow) - { - var full = FeedFullGraze + (FeedFullGorge - FeedFullGraze) * style; - return full > 0f ? Math.Clamp(sat / full, 0f, 1f) : 1f; - } - - var start = FeedStartGraze + (FeedStartGorge - FeedStartGraze) * style; - return sat >= start ? 1f - : start > 0f ? sat / start - : 0f; - } - - // Радиус восприятия особи: базовая дальность вида (данные) × ген зрения × размер клетки. - private float VisionRadius(int species, float vision) => - MathF.Max(1f, _animalSet[species].Def.VisionCells) * MathF.Max(0.2f, vision) * _cellSize; - - // Ближайшая запомненная угроза в зоне избегания (для блуждания — уйти от опасного места). - private bool TryRememberedThreat(Entity self, Vector2 pos, out Vector2 threatPos) - { - threatPos = default; - if (!self.TryGetComponent(out var mem) || mem.Entries is null) - { - return false; - } - - var bestSq = RememberedThreatAvoidCells * _cellSize; - bestSq *= bestSq; - var found = false; - foreach (var e in mem.Entries) - { - if (e.Kind != MemoryKind.Threat) - { - continue; - } - - var sq = Vector2.DistanceSquared(pos, e.Pos); - if (sq < bestSq) - { - bestSq = sq; - threatPos = e.Pos; - found = true; - } - } - - return found; - } - - // Центр/размер группы соплеменников в радиусе (когезия/безопасность) и вектор расхождения away — - // сумма направлений «прочь» от соседей ближе SeparationCells (личное пространство, чтобы стадо не - // схлопывалось в точку). O(n) на особь. Сам исключается по id; центр — средняя позиция соседей. - private void HerdScan( - Vector2 from, - float radius, - int species, - int selfId, - out Vector2 center, - out int count, - out Vector2 away - ) - { - var sum = Vector2.Zero; - away = Vector2.Zero; - count = 0; - var radiusSq = radius * radius; - var sepDist = SeparationCells * _cellSize; - var sepSq = sepDist * sepDist; - // _animalBuf уже собран в DecideIn на макс. радиусе чувств — фильтруем по своему радиусу. - foreach (var e in _animalBuf) - { - if (e.Species != species || e.Id == selfId) - { - continue; - } - - var distSq = Vector2.DistanceSquared(from, e.Pos); - if (distSq <= radiusSq) - { - sum += e.Pos; - count++; - } - - if (distSq <= sepSq && distSq > 0.0001f) - { - // ближе личного пространства — добавляем направление прочь, тем сильнее, чем ближе сосед. - var d = MathF.Sqrt(distSq); - away += (from - e.Pos) / d * (1f - d / sepDist); - } - } - - center = count > 0 ? sum / count : from; - } - - // Холостая ли активность (то, чем зверь занят без срочных нужд): блуждание/отдых/копание/валяние. - private static bool IsIdle(string action) => - action == AnimalActions.Wander - || action == AnimalActions.Rest - || action == AnimalActions.Dig - || action == AnimalActions.Wallow; - - // Случайное единичное направление. - private Vector2 RandomDir() - { - var a = _rng.NextSingle() * MathF.Tau; - return new Vector2(MathF.Cos(a), MathF.Sin(a)); - } - - // Поворот вектора на угол (рад). - private static Vector2 Rotate(Vector2 v, float radians) - { - var c = MathF.Cos(radians); - var s = MathF.Sin(radians); - return new Vector2(v.X * c - v.Y * s, v.X * s + v.Y * c); - } - - // Выбор холостой активности с весами по виду/состоянию/окружению и её длительность. Блуждание — всегда; - // отдых — охотнее у подуставших; копание — травоядным/всеядным; валяние — только у воды. Начало - // характерной активности (кроме блуждания) пишется в личную хронику особи. - private void PickIdleActivity( - ref AnimalBrain brain, - Entity self, - in AnimalOrganism org, - Vector2 pos, - float[] values, - float now - ) - { - var roam = 1.0f; - var rest = 0.5f; - if (_restNeed >= 0 && values is not null && _restNeed < values.Length) - { - rest += (1f - values[_restNeed]) * 0.8f; // ниже отдых — охотнее приляжет (но не до сна) - } - - var dig = org.EatsPlants ? 0.7f : 0.1f; - var wallowRange = WallowWaterCells * _cellSize; - var nearWater = - TryFindShore(pos, out var water) - && Vector2.DistanceSquared(pos, water) <= wallowRange * wallowRange; - var wallow = nearWater ? 0.5f : 0f; - - var roll = _rng.NextSingle() * (roam + rest + dig + wallow); - string action; - float min, - max; - if ((roll -= roam) < 0f) - { - action = AnimalActions.Wander; - (min, max) = (RoamMinSec, RoamMaxSec); - } - else if ((roll -= rest) < 0f) - { - action = AnimalActions.Rest; - (min, max) = (RestMinSec, RestMaxSec); - } - else if (roll - dig < 0f) - { - action = AnimalActions.Dig; - (min, max) = (DigMinSec, DigMaxSec); - } - else - { - action = AnimalActions.Wallow; - (min, max) = (WallowMinSec, WallowMaxSec); - } - - brain.Action = action; - brain.ActivityUntil = now + min + _rng.NextSingle() * (max - min); - switch (action) - { - case AnimalActions.Rest: - _events.Activity(self, org.Species, "event.rest"); - break; - case AnimalActions.Dig: - _events.Activity(self, org.Species, "event.dig"); - break; - case AnimalActions.Wallow: - _events.Activity(self, org.Species, "event.wallow"); - break; - } - } - - // Рулёжка блуждания (boids): плавный дрейф направления + расхождение (личное пространство) и лишь - // слабая когезия к центру группы на удалении; у края мира заворачиваем внутрь. Цель ставится далеко - // по направлению, поэтому зверь идёт, а не топчется на месте. - private void SteerRoam( - ref AnimalBrain brain, - Vector2 pos, - Vector2 herdCenter, - int herdCount, - Vector2 herdAway, - float sociability - ) - { - var dir = brain.Heading; - if (dir.LengthSquared() < 0.0001f) - { - dir = RandomDir(); - } - - dir = Rotate(dir, (_rng.NextSingle() - 0.5f) * 2f * WanderTurn); // коррелированный дрейф - dir += herdAway * SeparationWeight; // расхождение в стаде - if (sociability >= HerdMinSociability && herdCount > 0) - { - var toCenter = herdCenter - pos; - var d = toCenter.Length(); - if (d > CohesionStartCells * _cellSize) - { - dir += toCenter / d * CohesionWeight; // слабая когезия лишь на удалении - } - } - - var margin = EdgeMarginCells * _cellSize; - var w = _worldWidth * _cellSize; - var h = _worldHeight * _cellSize; - if (pos.X < margin && dir.X < 0f) - { - dir.X = MathF.Abs(dir.X); - } - else if (pos.X > w - margin && dir.X > 0f) - { - dir.X = -MathF.Abs(dir.X); - } - - if (pos.Y < margin && dir.Y < 0f) - { - dir.Y = MathF.Abs(dir.Y); - } - else if (pos.Y > h - margin && dir.Y > 0f) - { - dir.Y = -MathF.Abs(dir.Y); - } - - if (dir.LengthSquared() < 0.0001f) - { - dir = RandomDir(); - } - - dir.Normalize(); - brain.Heading = dir; - brain.Target = pos + dir * (_cellSize * RoamCells); - } - - // Ближайший хищник-угроза в радиусе восприятия (для бегства жертвы). Угроза = другой вид, который - // ест мясо и достаточно крупный (см. IsThreatTo). O(n) на особь — как поиск корма. - private bool TryFindThreat( - Vector2 from, - in SenseRanges ranges, - int selfSpecies, - bool selfEatsMeat, - float selfBody, - out Vector2 threatPos - ) - { - var bestSq = float.MaxValue; - threatPos = default; - var threatId = -1; - // _animalBuf уже собран в DecideIn; обнаружение всё равно гейтится Detect по чувствам. - foreach (var e in _animalBuf) - { - if ( - e.Species == selfSpecies - || !IsThreatTo(selfEatsMeat, selfBody, e.EatsMeat, e.BodySize) - ) - { - continue; - } - - var sq = Vector2.DistanceSquared(from, e.Pos); - // Обнаружение по чувствам: зрение (минус камуфляж) / слух (× шум) / запах (× запах, грубая зона). - if (!Detect(sq, e.Pos, ranges, e.Camouflage, e.Scent, e.Noise, out var perceived)) - { - continue; - } - - if (sq < bestSq || (sq == bestSq && e.Id < threatId)) - { - bestSq = sq; - threatPos = perceived; - threatId = e.Id; - } - } - - return threatId >= 0; - } - - // Является ли вид-кандидат угрозой особи: он ест мясо и не мельче её. Чистая жертва (не мясоед) - // бежит от любого хищника не мельче себя; сам хищник — лишь от заметно более крупного мясоеда. - private static bool IsThreatTo( - bool selfEatsMeat, - float selfBody, - bool otherEatsMeat, - float otherBody - ) - { - if (!otherEatsMeat) - { - return false; - } - - return selfEatsMeat ? otherBody > selfBody * 1.1f : otherBody >= selfBody * 0.9f; - } - - /// Радиусы восприятия особи по чувствам (уже с остротой генов и способностью органов). - private readonly record struct SenseRanges(float Sight, float Hearing, float Smell) - { - public float Max() => MathF.Max(Sight, MathF.Max(Hearing, Smell)); - - public SenseRanges Scaled(float f) => new(Sight * f, Hearing * f, Smell * f); - } - - // Обнаружена ли цель и где она «видится» наблюдателю. Зрение/слух дают точную позицию (зрение глушит - // камуфляж цели, слух усиливает её шум); запах добивает дальше всех, но даёт лишь ГРУБУЮ зону — - // позиция округляется до ячейки запаха (зверь идёт «примерно туда», пока ближе не сработает зрение). - private bool Detect( - float dist2, - Vector2 truePos, - in SenseRanges r, - float camouflage, - float scent, - float noise, - out Vector2 perceived - ) - { - perceived = truePos; - var sight = r.Sight * (1f - Math.Clamp(camouflage, 0f, 1f)); - if (sight > 0f && dist2 <= sight * sight) - { - return true; - } - - var hear = r.Hearing * Math.Clamp(noise, 0f, 1f); - if (hear > 0f && dist2 <= hear * hear) - { - return true; - } - - var smell = r.Smell * Math.Clamp(scent, 0f, 1f); - if (smell > 0f && dist2 <= smell * smell) - { - perceived = SnapToSmellCell(truePos); // грубая зона запаха - return true; - } - - return false; - } - - // Округляет позицию до центра «обонятельной» ячейки — детерминированная грубая локализация по запаху. - private Vector2 SnapToSmellCell(Vector2 p) => - new( - MathF.Floor(p.X / _smellSnap) * _smellSnap + _smellSnap * 0.5f, - MathF.Floor(p.Y / _smellSnap) * _smellSnap + _smellSnap * 0.5f - ); - - // Ближайшая «мясная» цель: труп (падаль, приоритет — даровая еда без риска) или живая добыча. - // Возвращает id, позицию и флаг трупа. Добыча — другой вид не крупнее охотника (см. IsPreyFor). - private bool TryFindKill( - Vector2 from, - in SenseRanges ranges, - int selfSpecies, - in AnimalPhenotype selfTraits, - int selfId, - out int targetId, - out Vector2 position, - out bool isCorpse - ) - { - targetId = -1; - position = default; - isCorpse = false; - var reach = ranges.Max(); - - // 1) Падаль с остатком мяса — находится в пределах любого чувства (запах хорошо ведёт к падали). - // Кандидаты — из сетки трупов (ближние ячейки), а не полным сканом всех трупов мира. - var bestCorpseSq = reach * reach; - var corpseId = -1; - var corpsePos = default(Vector2); - _corpseGrid.Collect(from, reach, _corpseBuf); - foreach (var c in _corpseBuf) - { - if (c.Meat <= 0f) - { - continue; - } - - var sq = Vector2.DistanceSquared(from, c.Pos); - if (sq < bestCorpseSq || (sq == bestCorpseSq && c.Id < corpseId)) - { - bestCorpseSq = sq; - corpseId = c.Id; - corpsePos = c.Pos; - } - } - - // 2) Живая добыча (из сетки животных, по чувствам). _animalBuf уже собран в DecideIn на reach. - var bestPreySq = float.MaxValue; - var preyId = -1; - var preyPos = default(Vector2); - var selfBody = selfTraits.BodySize; - foreach (var e in _animalBuf) - { - if (e.Species == selfSpecies || e.Id == selfId || !IsPreyFor(selfBody, e.BodySize)) - { - continue; - } - - var sq = Vector2.DistanceSquared(from, e.Pos); - if (!Detect(sq, e.Pos, ranges, e.Camouflage, e.Scent, e.Noise, out var perceived)) - { - continue; - } - - if (sq < bestPreySq || (sq == bestPreySq && e.Id < preyId)) - { - bestPreySq = sq; - preyId = e.Id; - preyPos = perceived; - } - } - - // Предпочитаем падаль, если она не намного дальше живой добычи (даровая еда, без риска). - if (corpseId >= 0 && (preyId < 0 || bestCorpseSq <= bestPreySq * 2f)) - { - targetId = corpseId; - position = corpsePos; - isCorpse = true; - return true; - } - - if (preyId >= 0) - { - targetId = preyId; - position = preyPos; - isCorpse = false; - return true; - } - - return false; - } - - // Подходит ли особь в добычу охотнику данного размера: важно лишь, что она не крупнее охотника - // (на превосходящих не нападаем). Вид/мясоедство добычи не важны — хищник ест и травоядных, и прочих. - private static bool IsPreyFor(float hunterBody, float otherBody) => - otherBody <= hunterBody * 1.25f; - - // Ближайшее растение в радиусе (квадрат расстояния); ничьи — по меньшему id (детерминизм). - // Порог интеллекта, с которого травоядное РАЗБИРАЕТ корм (избегает ядовитого/невкусного). Ниже — - // рефлекторное выедание ближайшего растения (тир мозга 4, как сон/мысли; больной зверь с упавшим - // сознанием падает ниже и снова жрёт что попало — последствие гейтинга A7). - private const float ForageSmartMinBrain = 0.4f; - - // Вес, с которым воспринимаемый яд (с поправкой на устойчивость) отталкивает от растения, и штраф за - // дальность — чтобы умный зверь предпочитал близкий и безопасный корм, а не бежал через всю карту. - private const float ToxinAvoidWeight = 1.2f; - private const float ForageDistanceWeight = 0.5f; - - // Выбор корма: умный (избегает яда/невкусного) если интеллект ≥ порога, иначе рефлекс — ближайшее. - private bool TryFindForage( - Vector2 from, - float radius, - float intelligence, - float toxinTolerance, - out int plantId, - out Vector2 position - ) => - intelligence >= ForageSmartMinBrain - ? TryFindBestPlant(from, radius, toxinTolerance, out plantId, out position) - : TryFindPlant(from, radius, out plantId, out position); - - // Перестраивает индекс животных из текущих позиций (раз в кадр — звери двигаются). СРАЗУ считает - // признаки, нужные ИИ (мясоед/взрослость и пр.), чтобы запросы их не пересчитывали. - private void RebuildAnimalGrid() - { - _animalGrid.Clear(); - foreach (var (_, _, organisms, growths, transforms, entities) in _animals.Chunks) - { - var o = organisms.Span; - var g = growths.Span; - var t = transforms.Span; - for (var i = 0; i < t.Length; i++) - { - var sp = _animalSet[o[i].Species]; - _animalGrid.Add( - new NeighborEntry( - entities.EntityAt(i).Id, - t[i].Position, - o[i].Species, - o[i].Traits.BodySize, - o[i].EatsMeat, // диета кэширована при рождении - o[i].IsMale, - AnimalFactory.IsAdult(sp, g[i].Stage), - o[i].Traits.Camouflage, - o[i].Traits.Scent, - o[i].Traits.Noise - ) - ); - } - } - } - - // Перестраивает индексы растений и трупов (по каденции — они статичны). Устаревшая запись об удалённой - // сущности безопасна: потребитель (Graze/Hunt/TryFind*) сверяется со store и пропускает исчезнувшую. - private void RebuildStaticGrids() - { - _plantGrid.Clear(); - foreach (var (transforms, _, organisms, entities) in _plants.Chunks) - { - var t = transforms.Span; - var o = organisms.Span; - for (var i = 0; i < t.Length; i++) - { - ref readonly var tr = ref o[i].Traits; - _plantGrid.Add( - new ForageEntry( - entities.EntityAt(i).Id, - t[i].Position, - tr.Toxicity, - tr.Palatability - ) - ); - } - } - - _corpseGrid.Clear(); - foreach (var (transforms, corpses, entities) in _corpses.Chunks) - { - var t = transforms.Span; - var c = corpses.Span; - for (var i = 0; i < t.Length; i++) - { - _corpseGrid.Add(new CorpseEntry(entities.EntityAt(i).Id, t[i].Position, c[i].Meat)); - } - } - } - - // Рефлекс: ближайшее растение в радиусе (ничья — меньший id, детерминизм). Кандидаты — из сетки. - private bool TryFindPlant(Vector2 from, float radius, out int plantId, out Vector2 position) - { - var bestSq = radius * radius; - plantId = -1; - position = default; - _plantGrid.Collect(from, radius, _plantBuf); - foreach (var e in _plantBuf) - { - var sq = Vector2.DistanceSquared(from, e.Pos); - if (sq < bestSq || (sq == bestSq && e.Id < plantId)) - { - bestSq = sq; - plantId = e.Id; - position = e.Pos; - } - } - - return plantId >= 0; - } - - // Умный выбор: среди растений в радиусе максимизируем привлекательность = вкусность − - // воспринимаемый_яд − штраф_за_дальность. Воспринимаемый яд = toxicity × (1 − устойчивость): чем - // выше устойчивость зверя, тем меньше его отпугивает токсичный корм. Если всё в округе невкусно/ - // ядовито, голод всё равно заставит выбрать наименее плохой (берётся максимум, даже отрицательный). - private bool TryFindBestPlant( - Vector2 from, - float radius, - float toxinTolerance, - out int plantId, - out Vector2 position - ) - { - var radiusSq = radius * radius; - var tol = Math.Clamp(toxinTolerance, 0f, 1f); - var bestScore = float.NegativeInfinity; - plantId = -1; - position = default; - _plantGrid.Collect(from, radius, _plantBuf); - foreach (var e in _plantBuf) - { - var sq = Vector2.DistanceSquared(from, e.Pos); - if (sq > radiusSq) - { - continue; - } - - var perceivedToxin = e.Toxicity * (1f - tol); - var score = - e.Palatability - - ToxinAvoidWeight * perceivedToxin - - ForageDistanceWeight * (radius > 0f ? MathF.Sqrt(sq) / radius : 0f); - if (score > bestScore || (score == bestScore && e.Id < plantId)) - { - bestScore = score; - plantId = e.Id; - position = e.Pos; - } - } - - return plantId >= 0; - } - - // Ближайшая кромка воды (без лимита по зрению — звери «помнят» водопои; упрощение фазы A2). Берег - // статичен, поэтому ближайшая береговая точка предпосчитана по клеткам (поле _shoreByCell, BFS на - // старте) — поиск за O(1) вместо линейного скана всей береговой линии на каждое решение «пить». - private bool TryFindShore(Vector2 from, out Vector2 position) - { - position = default; - if (_shoreByCell.Length == 0) - { - return false; // в мире нет воды/берега — пить негде - } - - var cx = Math.Clamp((int)(from.X / _cellSize), 0, _worldWidth - 1); - var cy = Math.Clamp((int)(from.Y / _cellSize), 0, _worldHeight - 1); - position = _shoreByCell[cy * _worldWidth + cx]; - return true; - } - - // Ближайший подходящий партнёр в радиусе зрения: ТОГО ЖЕ вида, взрослый, противоположного пола. - private bool TryFindMate( - Vector2 from, - bool selfMale, - int selfSpecies, - float radius, - out int mateId, - out Vector2 position - ) - { - var bestSq = radius * radius; - mateId = -1; - position = default; - // _animalBuf уже собран в DecideIn на макс. радиусе чувств — фильтруем по радиусу спаривания. - foreach (var e in _animalBuf) - { - if (e.Species != selfSpecies || e.IsMale == selfMale || !e.IsAdult) - { - continue; // другой вид / тот же пол / не взрослый (self отсеивается по полу) - } - - // Влечение партнёра не проверяется (учёт придёт с половым отбором). self отсекается совпадением - // пола и sq≈0; межвидовые пары исключены (раньше волк/курица могли «спариться»). - var sq = Vector2.DistanceSquared(from, e.Pos); - if (sq > 0.01f && (sq < bestSq || (sq == bestSq && e.Id < mateId))) - { - bestSq = sq; - mateId = e.Id; - position = e.Pos; - } - } - - return mateId >= 0; - } -} - -/// -/// Слой ИСПОЛНЕНИЯ: ведёт зверя к цели (скорость = базовая × ген moveSpeed) и при достижении исполняет -/// действие по его id — ест растение (выедает; трава до нуля исчезает → ёмкость среды), пьёт у воды, -/// спит, спаривается. Восполняемую нужду находит по действию через . Структурные -/// изменения (гибель растений, беременность) применяются после прохода. -/// -public sealed class AnimalActionSystem : BaseSystem -{ - private const float GrazeKillAge = 1f; // трава с возрастом ниже этого после выедания исчезает - private const float ThirstReliefBelow = 0.15f; // ниже этого жажда — питьё даёт мысль «напилась» - private const float FleeSpeedMult = 1.2f; // страх подгоняет жертву чуть быстрее обычного шага - private const float PostReproductiveFrac = 0.9f; // доля жизни, после которой самка уже не зачинает - private const float SeedIngestChance = 0.15f; // шанс проглотить семя при выедании зрелого растения - private const float GutSeedDropDays = 2f; // через сколько дней проглоченное семя высаживается - private const float IdleRestFactor = 0.3f; // доля скорости восстановления сил при холостом отдыхе - private const float DigForageFactor = 0.25f; // доля сытости с копания относительно нормальной кормёжки - - private readonly EntityStore _store; - private readonly PlantSet _plants; - private readonly AnimalSet _animalSet; - private readonly NeedSet _needs; - private readonly SkillSet _skills; - private readonly ThoughtSet _thoughts; - private readonly WorldEvents _events; - private readonly GameClock _clock; - private readonly float _secondsPerDay; - private readonly int _cellSize; - private readonly RectF _bounds; - private readonly HediffDef? _bleeding; - private readonly HediffDef? _poisoned; - private readonly Random _rng; - - // Кэш индексов навыков-эффектов (−1, если навык не определён модом — тогда эффекта/XP нет). - private readonly int _forageSkill; - private readonly int _huntSkill; - private readonly int _evadeSkill; - - private readonly ArchetypeQuery< - AnimalBrain, - AnimalNeeds, - AnimalOrganism, - Transform2D, - Health - > _query; - private readonly List _eaten = []; - private readonly HashSet _eatenSet = []; // O(1)-проверка членства (порядок даёт _eaten) - private readonly List _consumed = []; // трупы, доеденные до нуля - private readonly HashSet _consumedSet = []; // O(1)-проверка членства (порядок даёт _consumed) - private readonly List _kills = []; // добыча, забитая в этом проходе (смерть после итерации) - private readonly HashSet _killsSet = []; // O(1)-проверка членства (порядок даёт _kills) - private readonly List<(Entity Self, Entity Partner)> _matings = []; - private readonly HashSet<(int, int)> _matedPairs = []; // обработанные пары за проход (дедуп (a,b)/(b,a)) - private readonly List<(Entity Eater, GutSeed Seed)> _ingested = []; // проглоченные семена (эндозоохория) - - public AnimalActionSystem( - EntityStore store, - PlantSet plants, - AnimalSet animals, - NeedSet needs, - SkillSet skills, - ThoughtSet thoughts, - WorldEvents events, - GameClock clock, - float secondsPerDay, - int cellSize, - RectF bounds, - HediffDef? bleeding, - HediffDef? poisoned, - int seed - ) - { - _store = store; - _plants = plants; - _animalSet = animals; - _needs = needs; - _skills = skills; - _forageSkill = skills.IndexOf(AnimalSkills.Foraging); - _huntSkill = skills.IndexOf(AnimalSkills.Hunting); - _evadeSkill = skills.IndexOf(AnimalSkills.Evasion); - _thoughts = thoughts; - _events = events; - _clock = clock; - _secondsPerDay = secondsPerDay; - _cellSize = cellSize; - _bounds = bounds; - _bleeding = bleeding; - _poisoned = poisoned; - _rng = new Random(seed); - _query = store.Query(); - } - - protected override void OnUpdateGroup() - { - var seconds = _clock.DeltaTime; - if (seconds <= 0f) - { - return; - } - - var days = seconds / _secondsPerDay; - _eaten.Clear(); - _eatenSet.Clear(); - _consumed.Clear(); - _consumedSet.Clear(); - _kills.Clear(); - _killsSet.Clear(); - _matings.Clear(); - _ingested.Clear(); - - foreach ( - var (brains, needsChunk, organisms, transforms, healths, entities) in _query.Chunks - ) - { - var b = brains.Span; - var n = needsChunk.Span; - var o = organisms.Span; - var t = transforms.Span; - var hh = healths.Span; - for (var i = 0; i < b.Length; i++) - { - ref var brain = ref b[i]; - var values = n[i].Values; - ref var pos = ref t[i].Position; - var def = _animalSet[o[i].Species].Def; - // Способность Moving (падает от ран/болезней) — множитель к скорости: больные медленнее. - var moving = hh[i].State.Capacity(AnimalCapacities.Moving); - var speed = def.BaseSpeed * MathF.Max(0.2f, o[i].Traits.MoveSpeed) * moving; - // Питание (челюсть) масштабирует укус, пищеварение (желудок) — усвоение: их произведение - // даёт сытость с корма; раненая челюсть/больной желудок → зверь голоднее (опция 1). - var eating = hh[i].State.Capacity(AnimalCapacities.Eating); - var foodEff = eating * hh[i].State.Capacity(AnimalCapacities.Digestion); - var self = entities.EntityAt(i); - self.TryGetComponent(out var skills); // один лукап на особь (эффекты + XP) - // Ноша замедляет (задел под перенос): перегруз сверх грузоподъёмности роняет скорость. - if (self.TryGetComponent(out var carrier) && carrier.CarriedKg > 0f) - { - var sp = _animalSet[o[i].Species]; - var stageScale = entities.EntityAt(i).TryGetComponent(out var gr) - ? sp.Stages[Math.Clamp(gr.Stage, 0, sp.Stages.Length - 1)].Scale - : 1f; - var capacity = Mass.CarryCapacity( - Mass.OfAnimal(sp.Def, o[i].Traits, stageScale), - moving - ); - speed *= Mass.LoadSpeedFactor(carrier.CarriedKg, capacity); - } - - switch (brain.Action) - { - case AnimalActions.Sleep: - Refill(values, AnimalActions.Sleep, days); - break; - - case AnimalActions.Eat: - if (MoveTo(ref pos, brain.Target, speed * seconds)) - { - // Навык добычи корма: опытный форажир извлекает больше сытости; растёт от практики. - var forageF = SkillFactor(_forageSkill, skills); - Refill(values, AnimalActions.Eat, days, foodEff * forageF); - Graze( - brain.TargetPlant, - days, - def.ForageBiteDays * MathF.Max(0.2f, eating) - ); - // Коэволюция: защита растения бьёт по поедателю (яд/шипы). - ApplyPlantDefense( - brain.TargetPlant, - hh[i].State, - o[i].Traits.ToxinTolerance, - days - ); - // Эндозоохория: шанс проглотить семя зрелого растения (высадит позже). - TryIngestSeed(brain.TargetPlant, self); - GrantXp(_forageSkill, skills, days); - } - - break; - - case AnimalActions.Drink: - if (MoveTo(ref pos, brain.Target, speed * seconds)) - { - // Утоление сильной жажды — событийная мысль настроения (A8); уровень берём - // ДО восполнения, чтобы радость была от облегчения, а не от каждого глотка. - var thirst = _needs.NeedForAction(AnimalActions.Drink); - var parched = - thirst >= 0 - && thirst < values.Length - && values[thirst] < ThirstReliefBelow; - Refill(values, AnimalActions.Drink, days); - if (parched) - { - entities - .EntityAt(i) - .GetComponent() - .Add(_thoughts.Get(AnimalThoughts.QuenchedThirst)); - } - } - - break; - - case AnimalActions.Mate: - if ( - brain.TargetMate >= 0 - && _store.TryGetEntityById(brain.TargetMate, out var partner) - && !partner.IsNull - && partner.HasComponent() - ) - { - var partnerPos = partner.GetComponent().Position; - if (MoveTo(ref pos, partnerPos, speed * seconds)) - { - _matings.Add((entities.EntityAt(i), partner)); - } - } - - break; - - case AnimalActions.Hunt: - { - // Сила удара: размер тела особи (со стадией) относительно эталона вида — крупная/ - // взрослая особь бьёт сильнее, детёныш слабее. Дальше домножается на навык охоты. - var spH = _animalSet[o[i].Species]; - var stageScaleH = self.TryGetComponent(out var grH) - ? spH.Stages[Math.Clamp(grH.Stage, 0, spH.Stages.Length - 1)].Scale - : 1f; - var strength = MathF.Max( - 0.2f, - o[i].Traits.BodySize * stageScaleH / spH.RefBodySize - ); - // Навык охоты: опытный хищник бьёт сильнее; растёт от практики. - Hunt( - ref brain, - ref pos, - values, - def, - speed * seconds, - seconds, - days, - foodEff, - SkillFactor(_huntSkill, skills), - strength, - self, - o[i].Species - ); - GrantXp(_huntSkill, skills, days); - break; - } - - case AnimalActions.Flee: - // Навык уклонения: опытная жертва убегает быстрее; растёт от практики бегства. - MoveTo( - ref pos, - brain.Target, - speed * FleeSpeedMult * SkillFactor(_evadeSkill, skills) * seconds - ); - GrantXp(_evadeSkill, skills, days); - break; - - case AnimalActions.Rest: - // Отдых на месте: понемногу восстанавливает силы (меньше, чем полноценный сон). - Refill(values, AnimalActions.Sleep, days, IdleRestFactor); - break; - - case AnimalActions.Dig: - // Копание в земле: травоядные/всеядные выкапывают корешки/личинок — немного сытости. - if (o[i].EatsPlants) - { - Refill(values, AnimalActions.Eat, days, DigForageFactor * foodEff); - } - - break; - - case AnimalActions.Wallow: - // Валяние в грязи: стоит на месте у воды (комфорт/терморегуляция) — без эффекта на нужды. - break; - - default: // Wander — блуждание к цели - MoveTo(ref pos, brain.Target, speed * seconds); - break; - } - - pos.X = Math.Clamp(pos.X, _bounds.Left, _bounds.Right); - pos.Y = Math.Clamp(pos.Y, _bounds.Top, _bounds.Bottom); - } - } - - foreach (var plant in _eaten) - { - plant.DeleteEntity(); - } - - foreach (var corpse in _consumed) - { - if (!corpse.IsNull) - { - corpse.DeleteEntity(); // труп доеден до нуля - } - } - - foreach (var prey in _kills) - { - AnimalFactory.Die(_store, _animalSet, prey, _cellSize); // забитая добыча → труп - } - - foreach (var (eater, seed) in _ingested) - { - if (!eater.IsNull && !eater.HasComponent()) - { - eater.AddComponent(seed); // структурное добавление — после прохода - } - } - - ApplyMatings(); - } - - // Эндозоохория: при выедании ЗРЕЛОГО растения шанс проглотить его семя — зверь понесёт его и - // высадит позже в другом месте (SeedDispersalSystem). Геном клонируется (отдельная особь-росток). - private void TryIngestSeed(int plantId, Entity eater) - { - if ( - eater.IsNull - || eater.HasComponent() - || plantId < 0 - || _rng.NextSingle() >= SeedIngestChance - || !_store.TryGetEntityById(plantId, out var plant) - || plant.IsNull - || !plant.HasComponent() - || !plant.HasComponent() - ) - { - return; - } - - ref readonly var grow = ref plant.GetComponent(); - if (grow.Stage < _plants[grow.Species].Stages.Length - 1) - { - return; // только зрелое растение даёт семя - } - - var genome = new Genome(plant.GetComponent().Genome.ToDictionary()); - _ingested.Add( - ( - eater, - new GutSeed - { - PlantSpecies = grow.Species, - Genome = genome, - DropInDays = GutSeedDropDays, - } - ) - ); - } - - // Охота/падальщество: ведёт к цели и при контакте либо ест труп (утоляет голод, расходует мясо), - // либо кусает живую добычу — урон части тела, острая кровопотеря, рана-кровотечение (первый - // травматический урон). Добитая добыча помечается на смерть (труп оставит общий путь Die). - // Минимальная видимая длительность красной вспышки урона у жертвы (реального времени). - private const float DamageFlashSeconds = 0.35f; - - private void Hunt( - ref AnimalBrain brain, - ref Vector2 pos, - float[] values, - AnimalDef def, - float step, - float seconds, - float days, - float foodEff, - float huntFactor, - float strength, - Entity hunter, - int hunterSpecies - ) - { - if ( - brain.TargetPrey < 0 - || !_store.TryGetEntityById(brain.TargetPrey, out var target) - || target.IsNull - || !target.HasComponent() - ) - { - return; // цель исчезла — действие пересмотрят на следующем решении - } - - var targetPos = target.GetComponent().Position; - var range = def.AttackRangeCells * _cellSize; - var inRange = Vector2.DistanceSquared(pos, targetPos) <= range * range; - - if (brain.TargetIsCorpse) - { - if (!target.HasComponent()) - { - return; - } - - if (!inRange) - { - MoveTo(ref pos, targetPos, step); - return; - } - - ref var corpse = ref target.GetComponent(); - Refill(values, AnimalActions.Eat, days, foodEff); - corpse.Meat -= def.ForageBiteDays * days; // расход мяса той же «силой укуса», что и выедание - if (corpse.Meat <= 0f && _consumedSet.Add(target)) - { - _consumed.Add(target); - } - - return; - } - - // Живая добыча. - if (!target.HasComponent() || !target.HasComponent()) - { - return; - } - - // Дискретные удары (как в RimWorld): кулдаун идёт и при сближении — первый контакт бьёт сразу. - brain.AttackCooldown -= seconds; - if (!inRange) - { - MoveTo(ref pos, targetPos, step); - return; - } - - if (brain.AttackCooldown > 0f) - { - return; // в контакте, но удар ещё на перезарядке - } - - brain.AttackCooldown = MathF.Max(0.05f, def.AttackIntervalSec); - - var preyHealth = target.GetComponent().State; - if (preyHealth is null) - { - return; - } - - // Сила удара = база дефа × сила особи (размер×стадия) × навык охоты; кровопотеря/кровотечение — ×силу. - var damage = def.AttackDamage * strength * huntFactor; - preyHealth.ApplyInjury( - damage, - def.AttackBloodLoss * strength, - _bleeding, - def.AttackBleed * strength, - _rng - ); - - // Красная вспышка жертвы (презентация) + удар в боевую хронику обоих участников. - if (target.HasComponent()) - { - target.GetComponent().Seconds = DamageFlashSeconds; - } - - _events.Struck( - hunter, - hunterSpecies, - target, - target.GetComponent().Species, - damage - ); - - if ( - preyHealth.BloodLevel <= 0f - || preyHealth.Capacity(AnimalCapacities.Consciousness) <= 0.01f - ) - { - if (_killsSet.Add(target)) - { - _kills.Add(target); // добита — смерть после прохода (структурное изменение) - _events.Killed( - hunter, - hunterSpecies, - target.GetComponent().Species - ); - } - } - } - - // Восполняет нужду, которую утоляет действие (по NeedSet), на её FeedPerDay × эффективность - // (питание/пищеварение для еды — слабая челюсть/больной желудок дают меньше сытости с того же корма). - private void Refill(float[] values, string action, float days, float efficiency = 1f) - { - var index = _needs.NeedForAction(action); - if (index < 0 || values is null || index >= values.Length) - { - return; - } - - values[index] = Math.Clamp( - values[index] + _needs[index].FeedPerDay * days * efficiency, - 0f, - 1f - ); - } - - // Бонус-множитель от уровня навыка: 0.7 (новичок) … 1.3 (мастер). Навык не определён модом → 1 (без эффекта). - private static float SkillFactor(int idx, in Skills s) - { - if (idx < 0 || s.Levels is null || idx >= s.Levels.Length) - { - return 1f; - } - - return 0.7f + 0.6f * s.Levels[idx]; - } - - // Начисляет опыт навыку от практики: рост ∝ скорость обучения × страсть × (1−уровень) (убывающая отдача). - private void GrantXp(int idx, in Skills s, float days) - { - if (idx < 0 || s.Levels is null || idx >= s.Levels.Length) - { - return; - } - - var passion = s.Passions is not null && idx < s.Passions.Length ? s.Passions[idx] : (byte)0; - var gain = - _skills[idx].LearnRate - * SkillSet.PassionMultiplier(passion) - * days - * (1f - s.Levels[idx]); - s.Levels[idx] = Math.Clamp(s.Levels[idx] + gain, 0f, 1f); - } - - // Двигает позицию к цели на step; возвращает true, если уже у цели (можно исполнять действие). - private static bool MoveTo(ref Vector2 pos, Vector2 target, float step) - { - var delta = target - pos; - var dist = delta.Length(); - if (dist <= step || dist < 0.001f) - { - pos = dist < 0.001f ? pos : target; - return true; - } - - pos += delta / dist * step; - return false; - } - - // Выедание: убавляет рост растения; траву (без ствола), выеденную до нуля, помечает на гибель. - private void Graze(int plantId, float days, float bitePerDay) - { - if ( - plantId < 0 - || !_store.TryGetEntityById(plantId, out var plant) - || plant.IsNull - || !plant.HasComponent() - ) - { - return; - } - - ref var grow = ref plant.GetComponent(); - grow.AgeDays = MathF.Max(0f, grow.AgeDays - bitePerDay * days); - var isGrass = _plants[grow.Species].Def.TrunkRadiusCells <= 0f; - if (isGrass && grow.AgeDays <= GrazeKillAge && _eatenSet.Add(plant)) - { - _eaten.Add(plant); - } - } - - // Доза яда за день укуса при токсичности 1 и нулевой устойчивости (масштабируется тяжести Poisoned). - private const float PoisonDosePerDay = 1.6f; - - // Порог шипов, выше которого укус о растение травмирует, и масштабы урона/кровопотери от шипов. - private const float ThornThreshold = 0.2f; - private const float ThornPartDamage = 4f; - private const float ThornBloodLoss = 0.02f; - - // Коэволюция: при поедании растение «отвечает» — токсичность отравляет (тем сильнее, чем ниже - // устойчивость зверя к яду), шипы наносят лёгкую травму. Доза яда копится в хедифе Poisoned; если - // зверь ест яд быстрее, чем выводит, тяжесть дойдёт до летальной. Детерминированно (общий _rng). - private void ApplyPlantDefense( - int plantId, - HealthState? health, - float toxinTolerance, - float days - ) - { - if ( - health is null - || plantId < 0 - || !_store.TryGetEntityById(plantId, out var plant) - || plant.IsNull - || !plant.HasComponent() - ) - { - return; - } - - ref readonly var traits = ref plant.GetComponent().Traits; - - var poisoned = false; - if (_poisoned is not null && traits.Toxicity > 0f) - { - var dose = traits.Toxicity * (1f - Math.Clamp(toxinTolerance, 0f, 1f)); - if (dose > 0f) - { - health.Intensify(_poisoned, dose * PoisonDosePerDay * days); - poisoned = true; - } - } - - if (traits.Thorns > ThornThreshold) - { - // ApplyInjury сам пересчитывает способности (учтёт и свежий яд). - health.ApplyInjury( - traits.Thorns * ThornPartDamage * days, - traits.Thorns * ThornBloodLoss * days, - _bleeding, - traits.Thorns * 0.15f * days, - _rng - ); - } - else if (poisoned) - { - health.RecomputeCapacities(); // отравление меняет capacity-моды — применить сразу - } - } - - // Применяет спаривания после прохода: самка пары беременеет геномом самца (структурное добавление — - // после итерации), влечение обоих обнуляется. Двойные события на одну самку отсеиваются. - private void ApplyMatings() - { - var mateNeed = _needs.NeedForAction(AnimalActions.Mate); - _matedPairs.Clear(); - foreach (var (a, b) in _matings) - { - if ( - a.IsNull - || b.IsNull - || !a.HasComponent() - || !b.HasComponent() - ) - { - continue; - } - - // Оба партнёра независимо ставят себя в очередь, так что пара приходит дважды — (a,b) и (b,a). - // Дедупим по неупорядоченному ключу: иначе ResetMating/мысль «спаривание» применяются повторно. - var pairKey = a.Id < b.Id ? (a.Id, b.Id) : (b.Id, a.Id); - if (!_matedPairs.Add(pairKey)) - { - continue; - } - - var aMale = a.GetComponent().IsMale; - var bMale = b.GetComponent().IsMale; - if (aMale == bMale) - { - continue; // один пол — не пара (страховка) - } - - var mother = aMale ? b : a; - var father = aMale ? a : b; - if (!mother.HasComponent()) - { - ref readonly var mom = ref mother.GetComponent(); - // Плодовитость падает со старостью (тот же фактор сенесценса): дряхлая самка приносит - // меньший помёт, а после порога пострепродуктивного возраста уже не зачинает. - var momAge = mother.HasComponent() - ? mother.GetComponent().AgeDays - : 0f; - var ageFrac = mom.Traits.Lifespan > 0f ? momAge / mom.Traits.Lifespan : 0f; - if (ageFrac <= PostReproductiveFrac) - { - var fert = AnimalHealthSystem.SenescenceFactor(momAge, mom.Traits.Lifespan); - mother.AddComponent( - new Pregnant - { - FatherGenome = father.GetComponent().Genome, - DueInDays = MathF.Max(1f, mom.Traits.GestationDays), - Litter = Math.Max(1, (int)MathF.Round(mom.Traits.LitterSize * fert)), - } - ); - _events.Pregnant(mother, mom.Species); - } - } - - ResetMating(a, mateNeed); - ResetMating(b, mateNeed); - // Спаривание — положительная событийная мысль обоим партнёрам (A8). - var mated = _thoughts.Get(AnimalThoughts.Mated); - a.GetComponent().Add(mated); - b.GetComponent().Add(mated); - _events.Mated( - a, - a.GetComponent().Species, - b, - b.GetComponent().Species - ); - } - } - - private static void ResetMating(Entity entity, int mateNeed) - { - if (mateNeed < 0 || !entity.HasComponent()) - { - return; - } - - var values = entity.GetComponent().Values; - if (values is not null && mateNeed < values.Length) - { - values[mateNeed] = 0f; - } - } -} - -/// -/// Настроение (фаза A8): для особей тира 4+ (мозг ≥ порога) считает настроение из непрерывного фона -/// (удовлетворённость убывающих нужд минус боль из здоровья) и суммы затухающих событийных мыслей. -/// Мысли угасают со временем. У примитивных (тир < 4) настроения нет (Active=false, мысли сбрасываются). -/// Гейт интеллектом — тем же порогом тира 4, что и высшие нужды (сон/секс). Низкое настроение влияет на -/// поведение (стресс не даёт спариваться — см. ) и видно в консоли/инспекторе. -/// -public sealed class AnimalMoodSystem(GameClock clock, float secondsPerDay, NeedSet needs) - : QuerySystem -{ - /// Порог размера мозга (тир 4), с которого у особи появляется настроение. - public const float MoodMinBrain = 0.40f; - private const float NeutralBase = 0.35f; // настроение при полном истощении нужд (без боли/мыслей) - private const float NeedsWeight = 0.55f; // вклад средней удовлетворённости убывающих нужд - private const float PainWeight = 0.4f; // боль из здоровья тянет настроение вниз - - protected override void OnUpdate() - { - var days = clock.DeltaTime / secondsPerDay; - if (days <= 0f) - { - return; - } - - foreach (var (moods, needsChunk, organisms, healths, _) in Query.Chunks) - { - var m = moods.Span; - var n = needsChunk.Span; - var o = organisms.Span; - var h = healths.Span; - for (var i = 0; i < m.Length; i++) - { - ref var mood = ref m[i]; - if (o[i].Traits.BrainSize < MoodMinBrain) - { - mood.Active = false; // тир ниже зверя — настроения нет - mood.Value = 0.5f; - mood.Thoughts?.Clear(); - continue; - } - - mood.Active = true; - // 1) Затухание событийных мыслей + сумма их сдвигов. - var thoughtSum = 0f; - var list = mood.Thoughts; - if (list is not null) - { - for (var k = list.Count - 1; k >= 0; k--) - { - var t = list[k]; - t.RemainingDays -= days; - if (t.RemainingDays <= 0f) - { - list.RemoveAt(k); - continue; - } - - list[k] = t; - thoughtSum += t.Def.MoodOffset; - } - } - - // 2) Непрерывный фон: средняя удовлетворённость убывающих нужд минус боль из здоровья. - var wellbeing = AvgDeplete(n[i].Values); - var pain = h[i].State?.Pain ?? 0f; - mood.Value = Math.Clamp( - NeutralBase + NeedsWeight * wellbeing - PainWeight * pain + thoughtSum, - 0f, - 1f - ); - } - } - } - - // Средняя удовлетворённость убывающих нужд (голод/жажда/усталость) [0,1]; нет нужд → 1 (нейтрально). - private float AvgDeplete(float[] values) - { - if (values is null) - { - return 1f; - } - - var sum = 0f; - var count = 0; - for (var k = 0; k < needs.Count && k < values.Length; k++) - { - if (needs.IsDeplete(k)) - { - sum += values[k]; - count++; - } - } - - return count > 0 ? sum / count : 1f; - } -} - -/// -/// Презентация состояния нужд: голодный/уставший зверь тускнеет (яркость падает с самой острой -/// убывающей нуждой), сохраняя оттенок меха из генов. Читает симуляцию, пишет только -/// — граница sim/presentation цела. -/// -public sealed class AnimalAppearanceSystem(NeedSet needs) - : QuerySystem -{ - private const float MinBrightness = 0.5f; - - protected override void OnUpdate() - { - foreach (var (needsChunk, organisms, sprites, _) in Query.Chunks) - { - var n = needsChunk.Span; - var o = organisms.Span; - var s = sprites.Span; - for (var i = 0; i < n.Length; i++) - { - var values = n[i].Values; - var worst = 1f; - if (values is not null) - { - for (var k = 0; k < needs.Count && k < values.Length; k++) - { - if (needs.IsDeplete(k)) - { - worst = MathF.Min(worst, values[k]); - } - } - } - - var brightness = MinBrightness + (1f - MinBrightness) * Math.Clamp(worst, 0f, 1f); - s[i].Color = AnimalFactory.FurTint(o[i].Traits) * brightness; - } - } - } -} - -/// -/// Красная вспышка урона (презентация, как в RimWorld): особь, получившую удар, кратко подкрашивает -/// красным и гасит свечение по РЕАЛЬНОМУ времени (видно и на высокой скорости). Запускать ПОСЛЕ -/// (та задаёт базовый цвет от нужд) — вспышка подмешивается поверх. -/// Время свечения выставляет при ударе. Граница sim/presentation цела. -/// -public sealed class DamageFlashSystem(GameClock clock) : QuerySystem -{ - private static readonly Color FlashColor = new(255, 60, 60); - private const float MaxIntensity = 0.85f; // насколько краснит на пике - private const float FlashDuration = 0.35f; // полная длительность свечения (сек реального времени) - - protected override void OnUpdate() - { - var dt = clock.UnscaledDeltaTime; - foreach (var (sprites, flashes, _) in Query.Chunks) - { - var s = sprites.Span; - var f = flashes.Span; - for (var i = 0; i < f.Length; i++) - { - ref var flash = ref f[i]; - if (flash.Seconds <= 0f) - { - continue; - } - - flash.Seconds = MathF.Max(0f, flash.Seconds - dt); - var t = Math.Clamp(flash.Seconds / FlashDuration, 0f, 1f) * MaxIntensity; - s[i].Color = Color.Lerp(s[i].Color, FlashColor, t); - } - } - } -} - -/// -/// Разворот спрайта по направлению движения (как в RimWorld): зверь смотрит туда, куда идёт. Восток — -/// базовая текстура, запад — её зеркало (флип по X), север/юг — отдельные текстуры (если есть у вида). -/// Направление берётся из цели «мозга» () относительно позиции; стоящий -/// зверь сохраняет прежний разворот. При смене региона пересчитывает центр-origin и масштаб (текстуры -/// направлений могут отличаться шириной). Презентация (читает sim, пишет только Sprite/Transform-scale). -/// -public sealed class AnimalFacingSystem(AnimalSet animals, int cellSize) - : QuerySystem -{ - private const float MinMove = 0.5f; // короче — считаем стоящим, разворот не трогаем - - protected override void OnUpdate() - { - foreach (var (brains, organisms, growths, transforms, sprites, _) in Query.Chunks) - { - var b = brains.Span; - var o = organisms.Span; - var g = growths.Span; - var t = transforms.Span; - var s = sprites.Span; - for (var i = 0; i < b.Length; i++) - { - var dir = b[i].Target - t[i].Position; - if (dir.LengthSquared() < MinMove * MinMove) - { - continue; // стоит/у цели — сохраняем текущий разворот - } - - var sp = animals[o[i].Species]; - var dirs = sp.LookDirs(g[i].Stage, o[i].IsMale); - Texture2DRegion region; - SpriteFlip flip; - if (MathF.Abs(dir.X) >= MathF.Abs(dir.Y)) - { - region = dirs.East; - flip = dir.X < 0f ? SpriteFlip.X : SpriteFlip.None; // запад = зеркало востока - } - else - { - region = dir.Y < 0f ? dirs.North : dirs.South; // экранный Y вниз: вверх = север - flip = SpriteFlip.None; - } - - ref var sprite = ref s[i]; - if (sprite.Region == region && sprite.Flip == flip) - { - continue; // разворот не изменился - } - - sprite.Region = region; - sprite.Origin = new Vector2(region.Width / 2f, region.Height / 2f); - sprite.Flip = flip; - t[i].Scale = new Vector2( - AnimalFactory.Scale(sp, o[i].Traits, g[i].Stage, region, cellSize) - ); - } - } - } -} - -/// -/// «Раскачка» при ходьбе (презентация): идущий зверь покачивается из стороны в сторону (наклон спрайта -/// через ), стоящий — плавно выпрямляется. Делает движение живее, не -/// затрагивая симуляцию (она вращение не использует). Фаза покачивания идёт по игровому времени (стоп на -/// паузе, быстрее на ускорении) и сдвинута по id особи — звери качаются вразнобой. Граница sim/presentation -/// цела: система пишет только поворот трансформа. -/// -public sealed class AnimalBobSystem(GameClock clock) : QuerySystem -{ - private const float SwaySpeed = 9f; // скорость набега фазы (рад/сек игрового времени) - private const float SwayAmplitude = 0.08f; // амплитуда наклона (рад ≈ 4.6°) - private const float MinMoveSq = 0.5f * 0.5f; // ближе к цели — считаем стоящим - private const float UprightDecay = 0.85f; // как быстро выпрямляется стоя (за кадр) - - private float _phase; - - protected override void OnUpdate() - { - var dt = clock.DeltaTime; - if (dt <= 0f) - { - return; // пауза — не качаем и не выпрямляем - } - - _phase += dt * SwaySpeed; - foreach (var (brains, transforms, entities) in Query.Chunks) - { - var b = brains.Span; - var t = transforms.Span; - for (var i = 0; i < b.Length; i++) - { - ref var rot = ref t[i].Rotation; - if ((b[i].Target - t[i].Position).LengthSquared() > MinMoveSq) - { - var offset = entities.EntityAt(i).Id * 0.7f; // десинхрон между особями - rot = MathF.Sin(_phase + offset) * SwayAmplitude; - } - else - { - rot *= UprightDecay; // у цели/стоя — плавно к вертикали - } - } - } - } -} - -/// -/// Рост и стадии (фаза A3): копит возраст в игровых днях, переключает стадию (Baby→Juvenile→Adult→ -/// Senior) по порогам из генов и при смене стадии меняет спрайт (детёныш/самка/самец) и размер. -/// -public sealed class AnimalGrowthSystem( - GameClock clock, - float secondsPerDay, - AnimalSet animals, - int cellSize, - WorldEvents events -) : QuerySystem -{ - protected override void OnUpdate() - { - var days = clock.DeltaTime / secondsPerDay; - if (days <= 0f) - { - return; - } - - foreach (var (growths, organisms, transforms, sprites, entities) in Query.Chunks) - { - var g = growths.Span; - var o = organisms.Span; - var t = transforms.Span; - var s = sprites.Span; - for (var i = 0; i < g.Length; i++) - { - ref var grow = ref g[i]; - grow.AgeDays += days; - ref readonly var org = ref o[i]; - var sp = animals[org.Species]; - var stage = AnimalFactory.StageAt( - grow.AgeDays, - org.Traits.MaturityAge, - org.Traits.Lifespan, - sp.Stages - ); - if (stage == grow.Stage) - { - continue; - } - - grow.Stage = stage; - var region = AnimalFactory.RegionFor(sp, stage, org.IsMale); - ref var sprite = ref s[i]; - sprite.Region = region; - sprite.Origin = new Vector2(region.Width / 2f, region.Height / 2f); - t[i].Scale = new Vector2( - AnimalFactory.Scale(sp, org.Traits, stage, region, cellSize) - ); - events.Grew(entities.EntityAt(i), org.Species, sp.Stages[stage].Name); // в хронику особи - } - } - } -} - -/// -/// Смертность (фаза A3/A5): гибель от старости (возраст превысил продолжительность жизни) или истощения -/// (счётчик голода/жажды превысил порог). Замыкает контур ёмкости среды. Фаза A5: вместо деспавна -/// оставляет (через ), затем удаляет особь. -/// -public sealed class AnimalMortalitySystem : BaseSystem -{ - private const float LethalStarveDays = 3f; - - private readonly EntityStore _store; - private readonly AnimalSet _animals; - private readonly WorldEvents _events; - private readonly int _cellSize; - private readonly ArchetypeQuery _query; - private readonly List _deaths = []; - - public AnimalMortalitySystem( - EntityStore store, - AnimalSet animals, - WorldEvents events, - int cellSize - ) - { - _store = store; - _animals = animals; - _events = events; - _cellSize = cellSize; - _query = store.Query(); - } - - protected override void OnUpdateGroup() - { - _deaths.Clear(); - foreach (var (growths, organisms, needsChunk, entities) in _query.Chunks) - { - var g = growths.Span; - var o = organisms.Span; - var n = needsChunk.Span; - for (var i = 0; i < g.Length; i++) - { - var ageDeath = g[i].AgeDays > o[i].Traits.Lifespan; - var starveDeath = n[i].Starve >= LethalStarveDays; - if (ageDeath || starveDeath) - { - _deaths.Add(entities.EntityAt(i)); - _events.Died(o[i].Species, ageDeath ? "event.death.age" : "event.death.starve"); - } - } - } - - foreach (var dead in _deaths) - { - AnimalFactory.Die(_store, _animals, dead, _cellSize); - } - } -} - -/// -/// Разложение трупа (фаза A5): копит дни гниения (ускоряется в тепле, замедляется на морозе), -/// переключает стадии Fresh→Rotting→Skeleton по порогам и тинтит спрайт; по достижении предела труп -/// исчезает. Скелет рисуется бледным тинтом падали (отдельного спрайта пока нет — см. docs). -/// -public sealed class CorpseSystem : BaseSystem -{ - private const float RottingDay = 2f; - private const float SkeletonDay = 6f; - private const float GoneDay = 14f; - - private static readonly Color FreshTint = new(176, 148, 128); - private static readonly Color RottingTint = new(120, 118, 98); - private static readonly Color SkeletonTint = new(228, 226, 214); - - private readonly GameClock _clock; - private readonly float _secondsPerDay; - private readonly Climate _climate; - private readonly ArchetypeQuery _query; - private readonly List _gone = []; - - public CorpseSystem(EntityStore store, GameClock clock, float secondsPerDay, Climate climate) - { - _clock = clock; - _secondsPerDay = secondsPerDay; - _climate = climate; - _query = store.Query(); - } - - protected override void OnUpdateGroup() - { - var days = _clock.DeltaTime / _secondsPerDay; - if (days <= 0f) - { - return; - } - - // Тепло ускоряет гниение, около нуля и ниже — резко замедляет (заморозка). - var factor = Math.Clamp((_climate.Temperature + 5f) / 20f, 0.1f, 2.5f); - var advance = days * factor; - _gone.Clear(); - - foreach (var (corpses, sprites, entities) in _query.Chunks) - { - var c = corpses.Span; - var s = sprites.Span; - for (var i = 0; i < c.Length; i++) - { - ref var corpse = ref c[i]; - corpse.RotDays += advance; - if (corpse.RotDays >= GoneDay) - { - _gone.Add(entities.EntityAt(i)); - continue; - } - - var stage = - corpse.RotDays >= SkeletonDay ? 2 - : corpse.RotDays >= RottingDay ? 1 - : 0; - if (stage != corpse.Stage) - { - corpse.Stage = stage; - s[i].Color = - stage == 2 ? SkeletonTint - : stage == 1 ? RottingTint - : FreshTint; - } - } - } - - foreach (var entity in _gone) - { - entity.DeleteEntity(); - } - } -} - -/// -/// Беременность и роды (фаза A4): тикает срок вынашивания; по истечении рожает помёт — каждый детёныш = -/// генома матери и отца (пол наследуется без YY), стадия Baby, поколение -/// матери + 1, рядом с матерью. Компонент снимается. Потолок численности -/// страхует от взрыва популяции. -/// -public sealed class AnimalPregnancySystem : BaseSystem -{ - private readonly EntityStore _store; - private readonly AnimalSet _animals; - private readonly NeedSet _needs; - private readonly SkillSet _skills; - private readonly ThoughtSet _thoughts; - private readonly WorldEvents _events; - private readonly GameClock _clock; - private readonly float _secondsPerDay; - private readonly int _cellSize; - private readonly int _maxAnimals; - private readonly Random _rng; - private readonly ArchetypeQuery _query; - private readonly ArchetypeQuery _census; - private readonly List _births = []; - - public AnimalPregnancySystem( - EntityStore store, - AnimalSet animals, - NeedSet needs, - SkillSet skills, - ThoughtSet thoughts, - WorldEvents events, - GameClock clock, - float secondsPerDay, - int cellSize, - int maxAnimals, - int seed - ) - { - _store = store; - _animals = animals; - _needs = needs; - _skills = skills; - _thoughts = thoughts; - _events = events; - _clock = clock; - _secondsPerDay = secondsPerDay; - _cellSize = cellSize; - _maxAnimals = maxAnimals; - _rng = new Random(seed); - _query = store.Query(); - _census = store.Query(); - } - - protected override void OnUpdateGroup() - { - var days = _clock.DeltaTime / _secondsPerDay; - if (days <= 0f) - { - return; - } - - _births.Clear(); - foreach (var (pregnancies, organisms, transforms, entities) in _query.Chunks) - { - var p = pregnancies.Span; - var o = organisms.Span; - var t = transforms.Span; - for (var i = 0; i < p.Length; i++) - { - ref var preg = ref p[i]; - preg.DueInDays -= days; - if (preg.DueInDays > 0f) - { - continue; - } - - _births.Add( - new Birth - { - Mother = entities.EntityAt(i), - Species = o[i].Species, - MotherGenome = o[i].Genome, - FatherGenome = preg.FatherGenome, - Litter = preg.Litter, - Generation = o[i].Generation + 1, - Position = t[i].Position, - Oviparous = o[i].Traits.Oviparous, - IncubateDays = MathF.Max(1f, o[i].Traits.GestationDays), - } - ); - } - } - - var count = _census.Count; - foreach (var birth in _births) - { - if (birth.Mother.HasComponent()) - { - birth.Mother.RemoveComponent(); - } - - // Роды — сильная положительная мысль матери (A8). - if (birth.Mother.HasComponent()) - { - birth.Mother.GetComponent().Add(_thoughts.Get(AnimalThoughts.GaveBirth)); - } - - var bornLive = 0; - for (var k = 0; k < birth.Litter && count < _maxAnimals; k++) - { - var child = Genome.Breed( - birth.MotherGenome, - birth.FatherGenome, - _animals.GeneRegistry, - _rng - ); - var offset = - new Vector2(_rng.NextSingle() - 0.5f, _rng.NextSingle() - 0.5f) * _cellSize; - if (birth.Oviparous) - { - // Яйцекладка: вместо живого детёныша откладываем яйцо с его геномом — вылупится позже. - AnimalFactory.CreateEgg( - _store, - _animals, - birth.Species, - birth.Position + offset, - child, - birth.Generation, - birth.IncubateDays, - _cellSize - ); - } - else - { - AnimalFactory.Create( - _store, - _animals, - _needs, - _skills, - birth.Species, - birth.Position + offset, - ageDays: 0f, - child, - birth.Generation, - _cellSize - ); - bornLive++; - } - - count++; - } - - // Живые роды — заметное событие (яйцекладка отметится позже при вылуплении в EggSystem). - if (bornLive > 0) - { - _events.Born(birth.Mother, birth.Species, bornLive); - } - } - } - - private struct Birth - { - public Entity Mother; - public int Species; - public Genome MotherGenome; - public Genome FatherGenome; - public int Litter; - public int Generation; - public Vector2 Position; - public bool Oviparous; - public float IncubateDays; - } -} - -/// -/// Инкубация яиц (тип рождения «яйцекладка»): тикает остаток инкубации каждого яйца; по истечении -/// вылупляет детёныша ( из хранимого генома, стадия Baby) рядом с -/// яйцом и удаляет яйцо. Зеркало родов для яйцекладущих видов. -/// -public sealed class EggSystem : BaseSystem -{ - private readonly EntityStore _store; - private readonly AnimalSet _animals; - private readonly NeedSet _needs; - private readonly SkillSet _skills; - private readonly WorldEvents _events; - private readonly GameClock _clock; - private readonly float _secondsPerDay; - private readonly int _cellSize; - private readonly Random _rng; - private readonly ArchetypeQuery _query; - private readonly List _hatched = []; - - public EggSystem( - EntityStore store, - AnimalSet animals, - NeedSet needs, - SkillSet skills, - WorldEvents events, - GameClock clock, - float secondsPerDay, - int cellSize, - int seed - ) - { - _store = store; - _animals = animals; - _needs = needs; - _skills = skills; - _events = events; - _clock = clock; - _secondsPerDay = secondsPerDay; - _cellSize = cellSize; - _rng = new Random(seed); - _query = store.Query(); - } - - protected override void OnUpdateGroup() - { - var days = _clock.DeltaTime / _secondsPerDay; - if (days <= 0f) - { - return; - } - - _hatched.Clear(); - foreach (var (eggs, transforms, entities) in _query.Chunks) - { - var e = eggs.Span; - for (var i = 0; i < e.Length; i++) - { - ref var egg = ref e[i]; - egg.IncubateDays -= days; - if (egg.IncubateDays > 0f) - { - continue; - } - - var offset = - new Vector2(_rng.NextSingle() - 0.5f, _rng.NextSingle() - 0.5f) * _cellSize; - AnimalFactory.Create( - _store, - _animals, - _needs, - _skills, - egg.Species, - transforms.Span[i].Position + offset, - ageDays: 0f, - egg.Genome, - egg.Generation, - _cellSize - ); - _events.Hatched(egg.Species, 1); - _hatched.Add(entities.EntityAt(i)); - } - } - - foreach (var egg in _hatched) - { - egg.DeleteEntity(); - } - } -} - -/// -/// Здоровье и болезни (фаза A6): медленный тик (~игровой час). Фоновое заражение, прогресс болезней -/// (гонка тяжесть↔иммунитет — выздоровление или смерть), пересчёт способностей с учётом хедифов. Смерть -/// по здоровью (летальная болезнь / кровь на нуле / потеря сознания) оставляет труп. Первый реальный -/// источник урона — оживляет каркас A5 (больные звери теряют Moving → медленнее). -/// -public sealed class AnimalHealthSystem : BaseSystem -{ - private const float TickDays = 1f / 24f; // ~игровой час - private const float BloodRecoveryPerDay = 0.25f; // восстановление крови в покое (нет кровотечений) - - private readonly EntityStore _store; - private readonly AnimalSet _animals; - private readonly WorldEvents _events; - private readonly int _cellSize; - private readonly GameClock _clock; - private readonly float _secondsPerDay; - private readonly Climate _climate; - private readonly HediffDef[] _ambient; - private readonly HediffDef? _hypothermia; - private readonly HediffDef? _heatstroke; - private readonly HediffDef? _infection; - private readonly Random _rng; - private readonly ArchetypeQuery _query; - private readonly List _deaths = []; - private float _accum; - - // Сенесценс (старение): спад способностей начинается с этой доли продолжительности жизни и достигает - // максимума к её концу. Дряхлеющий зверь слабее (медленнее, хуже видит/фильтрует кровь) и уязвимее. - private const float SenescenceStart = 0.6f; - private const float SenescenceMax = 0.45f; - - // Термокомфорт (°C) при теплоизоляции 0.5 и размере тела 1; границы сдвигаются мехом и размером тела. - private const float ColdComfort = 2f; // ниже — мёрзнет - private const float HeatComfort = 30f; // выше — перегревается - private const float ColdInsulationBonus = 30f; // мех (insulation 0..1) расширяет холодоустойчивость - private const float HeatInsulationPenalty = 14f; // тот же мех ухудшает жароустойчивость - private const float ColdBodyBonus = 8f; // крупное тело (тепловая инерция) держит холод лучше - private const float ThermalRatePerDegree = 0.06f; // прирост тяжести за °C за пределом комфорта в день - - // Заражение раны: шанс в день при кровотечении тяжести 1 подхватить инфекцию. - private const float InfectionChancePerDay = 0.25f; - - public AnimalHealthSystem( - EntityStore store, - AnimalSet animals, - WorldEvents events, - int cellSize, - GameClock clock, - float secondsPerDay, - Climate climate, - HediffDef[] ambient, - HediffDef? hypothermia, - HediffDef? heatstroke, - HediffDef? infection, - int seed - ) - { - _store = store; - _animals = animals; - _events = events; - _cellSize = cellSize; - _clock = clock; - _secondsPerDay = secondsPerDay; - _climate = climate; - _ambient = ambient; - _hypothermia = hypothermia; - _heatstroke = heatstroke; - _infection = infection; - _rng = new Random(seed); - _query = store.Query(); - } - - /// Множитель способностей от старения (1 — молод/расцвет, <1 — дряхлеет к концу жизни). - public static float SenescenceFactor(float ageDays, float lifespan) - { - if (lifespan <= 0f) - { - return 1f; - } - - var frac = (ageDays / lifespan - SenescenceStart) / MathF.Max(0.01f, 1f - SenescenceStart); - return 1f - SenescenceMax * Math.Clamp(frac, 0f, 1f); - } - - protected override void OnUpdateGroup() - { - _accum += _clock.DeltaTime / _secondsPerDay; - if (_accum < TickDays) - { - return; - } - - var days = _accum; - _accum = 0f; - _deaths.Clear(); - - foreach (var (healths, organisms, growths, entities) in _query.Chunks) - { - var h = healths.Span; - var o = organisms.Span; - var g = growths.Span; - for (var i = 0; i < h.Length; i++) - { - var state = h[i].State; - if (state is null) - { - continue; - } - - foreach (var disease in _ambient) - { - if ( - !state.Has(disease.DefName) - && _rng.NextSingle() < disease.AmbientPerDay * days - ) - { - state.Add(disease); - } - } - - // Терморегуляция: вне комфорта (зависит от GeneInsulation + размера тела) копится тяжесть - // гипотермии/теплового удара; в комфорте immunityPerDay их рассасывает. Мех расширяет - // холодоустойчивость, но ухудшает жароустойчивость; крупное тело держит холод. - var insul = Math.Clamp(o[i].Traits.Insulation, 0f, 1f); - var coldLimit = - ColdComfort - - ColdInsulationBonus * insul - - ColdBodyBonus * MathF.Max(0f, o[i].Traits.BodySize - 1f); - var heatLimit = HeatComfort - HeatInsulationPenalty * insul; - var temp = _climate.Temperature; - if (temp < coldLimit && _hypothermia is not null) - { - state.Intensify(_hypothermia, (coldLimit - temp) * ThermalRatePerDegree * days); - } - else if (temp > heatLimit && _heatstroke is not null) - { - state.Intensify(_heatstroke, (temp - heatLimit) * ThermalRatePerDegree * days); - } - - // Заражение раны: пока есть кровотечение, шанс (∝ его тяжести) подхватить инфекцию. - if (_infection is not null && !state.Has(_infection.DefName)) - { - var bleedSeverity = 0f; - foreach (var hd in state.Hediffs) - { - if (hd.Def.BloodLossPerDay > 0f && hd.Severity > bleedSeverity) - { - bleedSeverity = hd.Severity; - } - } - - if ( - bleedSeverity > 0f - && _rng.NextSingle() < InfectionChancePerDay * bleedSeverity * days - ) - { - state.Add(_infection); - } - } - - // Фильтрация крови (почки/печень) ускоряет вывод токсинов и набор иммунитета: повреждённый - // орган → медленнее выздоровление от яда/болезни/инфекции (для тела без неё Capacity вернёт 1). - var lethal = state.AdvanceHediffs( - days, - state.Capacity(AnimalCapacities.BloodFiltration) - ); - // Кровопотеря от ран (кровотечения), иначе — постепенное восстановление крови в покое. - var bleed = 0f; - foreach (var hd in state.Hediffs) - { - bleed += hd.Def.BloodLossPerDay * hd.Severity; - } - - if (bleed > 0f) - { - state.BloodLevel = Math.Clamp(state.BloodLevel - bleed * days, 0f, 1f); - } - else if (state.BloodLevel < 1f) - { - state.BloodLevel = Math.Clamp( - state.BloodLevel + BloodRecoveryPerDay * days, - 0f, - 1f - ); - } - - // Старение: к концу жизни способности дряхлеют (медленнее/слабее, уязвимее к ранам/болезни). - var senescence = SenescenceFactor(g[i].AgeDays, o[i].Traits.Lifespan); - state.RecomputeCapacities(senescence); - if ( - lethal - || state.BloodLevel <= 0f - || state.Capacity(AnimalCapacities.Consciousness) <= 0.01f - ) - { - _deaths.Add(entities.EntityAt(i)); - _events.Died(o[i].Species, "event.death.disease"); - } - } - } - - foreach (var dead in _deaths) - { - AnimalFactory.Die(_store, _animals, dead, _cellSize); - } - } -} - -/// -/// Разнос семян (эндозоохория): тикает таймер проглоченного семени (); по -/// истечении высаживает росток того вида растения там, где сейчас зверь (если клетка — суша), -/// геномом-клоном съеденного растения, и снимает компонент. Делает травоядных разносчиками флоры — -/// растения расселяются вдоль кормовых маршрутов. -/// -public sealed class SeedDispersalSystem : BaseSystem -{ - private readonly EntityStore _store; - private readonly PlantSet _plants; - private readonly GameClock _clock; - private readonly float _secondsPerDay; - private readonly int _width; - private readonly int _height; - private readonly int _cellSize; - private readonly float[] _cellFertility; - private readonly bool[] _cellLand; - private readonly ArchetypeQuery _query; - private readonly List _dropped = []; - - public SeedDispersalSystem( - EntityStore store, - PlantSet plants, - GameClock clock, - float secondsPerDay, - int width, - int height, - int cellSize, - float[] cellFertility, - bool[] cellLand - ) - { - _store = store; - _plants = plants; - _clock = clock; - _secondsPerDay = secondsPerDay; - _width = width; - _height = height; - _cellSize = cellSize; - _cellFertility = cellFertility; - _cellLand = cellLand; - _query = store.Query(); - } - - protected override void OnUpdateGroup() - { - var days = _clock.DeltaTime / _secondsPerDay; - if (days <= 0f) - { - return; - } - - _dropped.Clear(); - foreach (var (seeds, transforms, entities) in _query.Chunks) - { - var s = seeds.Span; - var t = transforms.Span; - for (var i = 0; i < s.Length; i++) - { - ref var seed = ref s[i]; - seed.DropInDays -= days; - if (seed.DropInDays > 0f) - { - continue; - } - - _dropped.Add(entities.EntityAt(i)); - var pos = t[i].Position; - var cx = Math.Clamp((int)(pos.X / _cellSize), 0, _width - 1); - var cy = Math.Clamp((int)(pos.Y / _cellSize), 0, _height - 1); - var cell = cy * _width + cx; - if (!_cellLand[cell]) - { - continue; // упало в воду — семя пропадает - } - - PlantFactory.Create( - _store, - _plants, - seed.PlantSpecies, - pos, - ageDays: 0f, - seed.Genome, - _cellFertility[cell], - _cellSize - ); - } - } - - foreach (var eater in _dropped) - { - if (!eater.IsNull && eater.HasComponent()) - { - eater.RemoveComponent(); - } - } - } -} - -/// -/// Угасание навыков без практики: каждый уровень медленно сползает к полу своего -/// (рост идёт от использования в ). Так навык, которым не пользуются, -/// постепенно забывается — есть давление на специализацию. -/// -public sealed class AnimalSkillSystem(GameClock clock, float secondsPerDay, SkillSet skills) - : QuerySystem -{ - protected override void OnUpdate() - { - var days = clock.DeltaTime / secondsPerDay; - if (days <= 0f) - { - return; - } - - foreach (var (skillChunk, _) in Query.Chunks) - { - var s = skillChunk.Span; - for (var i = 0; i < s.Length; i++) - { - var levels = s[i].Levels; - if (levels is null) - { - continue; - } - - for (var k = 0; k < skills.Count && k < levels.Length; k++) - { - levels[k] = MathF.Max( - skills[k].FloorLevel, - levels[k] - skills[k].DecayPerDay * days - ); - } - } - } - } -} - -/// -/// Забывание (память): уверенность каждой записи падает со временем, состарившиеся стираются. Свежие -/// наблюдения записывает/освежает ; эта система только «выветривает» -/// память, давая ей конечный срок. -/// -public sealed class AnimalMemorySystem(GameClock clock, float secondsPerDay) : QuerySystem -{ - private const float ForgetPerDay = 0.5f; // уверенность падает (память живёт ~2 дня без подтверждения) - - protected override void OnUpdate() - { - var days = clock.DeltaTime / secondsPerDay; - if (days <= 0f) - { - return; - } - - foreach (var (memories, _) in Query.Chunks) - { - var m = memories.Span; - for (var i = 0; i < m.Length; i++) - { - var entries = m[i].Entries; - if (entries is null) - { - continue; - } - - for (var k = entries.Count - 1; k >= 0; k--) - { - var e = entries[k]; - e.Confidence -= ForgetPerDay * days; - e.AgeDays += days; - if (e.Confidence <= 0f) - { - entries.RemoveAt(k); - } - else - { - entries[k] = e; - } - } - } - } - } -} diff --git a/src/LittleSim/Sim/Animals/AnimalActionSystem.cs b/src/LittleSim/Sim/Animals/AnimalActionSystem.cs new file mode 100644 index 0000000..13599a1 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalActionSystem.cs @@ -0,0 +1,720 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Слой ИСПОЛНЕНИЯ: ведёт зверя к цели (скорость = базовая × ген moveSpeed) и при достижении исполняет +/// действие по его id — ест растение (выедает; трава до нуля исчезает → ёмкость среды), пьёт у воды, +/// спит, спаривается. Восполняемую нужду находит по действию через . Структурные +/// изменения (гибель растений, беременность) применяются после прохода. +/// +public sealed class AnimalActionSystem : BaseSystem +{ + private const float GrazeKillAge = 1f; // трава с возрастом ниже этого после выедания исчезает + private const float ThirstReliefBelow = 0.15f; // ниже этого жажда — питьё даёт мысль «напилась» + private const float FleeSpeedMult = 1.2f; // страх подгоняет жертву чуть быстрее обычного шага + private const float PostReproductiveFrac = 0.9f; // доля жизни, после которой самка уже не зачинает + private const float SeedIngestChance = 0.15f; // шанс проглотить семя при выедании зрелого растения + private const float GutSeedDropDays = 2f; // через сколько дней проглоченное семя высаживается + private const float IdleRestFactor = 0.3f; // доля скорости восстановления сил при холостом отдыхе + private const float DigForageFactor = 0.25f; // доля сытости с копания относительно нормальной кормёжки + + private readonly EntityStore _store; + private readonly PlantSet _plants; + private readonly AnimalSet _animalSet; + private readonly NeedSet _needs; + private readonly SkillSet _skills; + private readonly ThoughtSet _thoughts; + private readonly WorldEvents _events; + private readonly GameClock _clock; + private readonly float _secondsPerDay; + private readonly int _cellSize; + private readonly RectF _bounds; + private readonly HediffDef? _bleeding; + private readonly HediffDef? _poisoned; + private readonly Random _rng; + + // Кэш индексов навыков-эффектов (−1, если навык не определён модом — тогда эффекта/XP нет). + private readonly int _forageSkill; + private readonly int _huntSkill; + private readonly int _evadeSkill; + + private readonly ArchetypeQuery< + AnimalBrain, + AnimalNeeds, + AnimalOrganism, + Transform2D, + Health + > _query; + private readonly List _eaten = []; + private readonly HashSet _eatenSet = []; // O(1)-проверка членства (порядок даёт _eaten) + private readonly List _consumed = []; // трупы, доеденные до нуля + private readonly HashSet _consumedSet = []; // O(1)-проверка членства (порядок даёт _consumed) + private readonly List _kills = []; // добыча, забитая в этом проходе (смерть после итерации) + private readonly HashSet _killsSet = []; // O(1)-проверка членства (порядок даёт _kills) + private readonly List<(Entity Self, Entity Partner)> _matings = []; + private readonly HashSet<(int, int)> _matedPairs = []; // обработанные пары за проход (дедуп (a,b)/(b,a)) + private readonly List<(Entity Eater, GutSeed Seed)> _ingested = []; // проглоченные семена (эндозоохория) + + public AnimalActionSystem( + EntityStore store, + PlantSet plants, + AnimalSet animals, + NeedSet needs, + SkillSet skills, + ThoughtSet thoughts, + WorldEvents events, + GameClock clock, + float secondsPerDay, + int cellSize, + RectF bounds, + HediffDef? bleeding, + HediffDef? poisoned, + int seed + ) + { + _store = store; + _plants = plants; + _animalSet = animals; + _needs = needs; + _skills = skills; + _forageSkill = skills.IndexOf(AnimalSkills.Foraging); + _huntSkill = skills.IndexOf(AnimalSkills.Hunting); + _evadeSkill = skills.IndexOf(AnimalSkills.Evasion); + _thoughts = thoughts; + _events = events; + _clock = clock; + _secondsPerDay = secondsPerDay; + _cellSize = cellSize; + _bounds = bounds; + _bleeding = bleeding; + _poisoned = poisoned; + _rng = new Random(seed); + _query = store.Query(); + } + + protected override void OnUpdateGroup() + { + var seconds = _clock.DeltaTime; + if (seconds <= 0f) + { + return; + } + + var days = seconds / _secondsPerDay; + _eaten.Clear(); + _eatenSet.Clear(); + _consumed.Clear(); + _consumedSet.Clear(); + _kills.Clear(); + _killsSet.Clear(); + _matings.Clear(); + _ingested.Clear(); + + foreach ( + var (brains, needsChunk, organisms, transforms, healths, entities) in _query.Chunks + ) + { + var b = brains.Span; + var n = needsChunk.Span; + var o = organisms.Span; + var t = transforms.Span; + var hh = healths.Span; + for (var i = 0; i < b.Length; i++) + { + ref var brain = ref b[i]; + var values = n[i].Values; + ref var pos = ref t[i].Position; + var def = _animalSet[o[i].Species].Def; + // Способность Moving (падает от ран/болезней) — множитель к скорости: больные медленнее. + var moving = hh[i].State.Capacity(AnimalCapacities.Moving); + var speed = def.BaseSpeed * MathF.Max(0.2f, o[i].Traits.MoveSpeed) * moving; + // Питание (челюсть) масштабирует укус, пищеварение (желудок) — усвоение: их произведение + // даёт сытость с корма; раненая челюсть/больной желудок → зверь голоднее (опция 1). + var eating = hh[i].State.Capacity(AnimalCapacities.Eating); + var foodEff = eating * hh[i].State.Capacity(AnimalCapacities.Digestion); + var self = entities.EntityAt(i); + self.TryGetComponent(out var skills); // один лукап на особь (эффекты + XP) + // Ноша замедляет (задел под перенос): перегруз сверх грузоподъёмности роняет скорость. + if (self.TryGetComponent(out var carrier) && carrier.CarriedKg > 0f) + { + var sp = _animalSet[o[i].Species]; + var stageScale = entities.EntityAt(i).TryGetComponent(out var gr) + ? sp.Stages[Math.Clamp(gr.Stage, 0, sp.Stages.Length - 1)].Scale + : 1f; + var capacity = Mass.CarryCapacity( + Mass.OfAnimal(sp.Def, o[i].Traits, stageScale), + moving + ); + speed *= Mass.LoadSpeedFactor(carrier.CarriedKg, capacity); + } + + switch (brain.Action) + { + case AnimalActions.Sleep: + Refill(values, AnimalActions.Sleep, days); + break; + + case AnimalActions.Eat: + if (MoveTo(ref pos, brain.Target, speed * seconds)) + { + // Навык добычи корма: опытный форажир извлекает больше сытости; растёт от практики. + var forageF = SkillFactor(_forageSkill, skills); + Refill(values, AnimalActions.Eat, days, foodEff * forageF); + Graze( + brain.TargetPlant, + days, + def.ForageBiteDays * MathF.Max(0.2f, eating) + ); + // Коэволюция: защита растения бьёт по поедателю (яд/шипы). + ApplyPlantDefense( + brain.TargetPlant, + hh[i].State, + o[i].Traits.ToxinTolerance, + days + ); + // Эндозоохория: шанс проглотить семя зрелого растения (высадит позже). + TryIngestSeed(brain.TargetPlant, self); + GrantXp(_forageSkill, skills, days); + } + + break; + + case AnimalActions.Drink: + if (MoveTo(ref pos, brain.Target, speed * seconds)) + { + // Утоление сильной жажды — событийная мысль настроения (A8); уровень берём + // ДО восполнения, чтобы радость была от облегчения, а не от каждого глотка. + var thirst = _needs.NeedForAction(AnimalActions.Drink); + var parched = + thirst >= 0 + && thirst < values.Length + && values[thirst] < ThirstReliefBelow; + Refill(values, AnimalActions.Drink, days); + if (parched) + { + entities + .EntityAt(i) + .GetComponent() + .Add(_thoughts.Get(AnimalThoughts.QuenchedThirst)); + } + } + + break; + + case AnimalActions.Mate: + if ( + brain.TargetMate >= 0 + && _store.TryGetEntityById(brain.TargetMate, out var partner) + && !partner.IsNull + && partner.HasComponent() + ) + { + var partnerPos = partner.GetComponent().Position; + if (MoveTo(ref pos, partnerPos, speed * seconds)) + { + _matings.Add((entities.EntityAt(i), partner)); + } + } + + break; + + case AnimalActions.Hunt: + { + // Сила удара: размер тела особи (со стадией) относительно эталона вида — крупная/ + // взрослая особь бьёт сильнее, детёныш слабее. Дальше домножается на навык охоты. + var spH = _animalSet[o[i].Species]; + var stageScaleH = self.TryGetComponent(out var grH) + ? spH.Stages[Math.Clamp(grH.Stage, 0, spH.Stages.Length - 1)].Scale + : 1f; + var strength = MathF.Max( + 0.2f, + o[i].Traits.BodySize * stageScaleH / spH.RefBodySize + ); + // Навык охоты: опытный хищник бьёт сильнее; растёт от практики. + Hunt( + ref brain, + ref pos, + values, + def, + speed * seconds, + seconds, + days, + foodEff, + SkillFactor(_huntSkill, skills), + strength, + self, + o[i].Species + ); + GrantXp(_huntSkill, skills, days); + break; + } + + case AnimalActions.Flee: + // Навык уклонения: опытная жертва убегает быстрее; растёт от практики бегства. + MoveTo( + ref pos, + brain.Target, + speed * FleeSpeedMult * SkillFactor(_evadeSkill, skills) * seconds + ); + GrantXp(_evadeSkill, skills, days); + break; + + case AnimalActions.Rest: + // Отдых на месте: понемногу восстанавливает силы (меньше, чем полноценный сон). + Refill(values, AnimalActions.Sleep, days, IdleRestFactor); + break; + + case AnimalActions.Dig: + // Копание в земле: травоядные/всеядные выкапывают корешки/личинок — немного сытости. + if (o[i].EatsPlants) + { + Refill(values, AnimalActions.Eat, days, DigForageFactor * foodEff); + } + + break; + + case AnimalActions.Wallow: + // Валяние в грязи: стоит на месте у воды (комфорт/терморегуляция) — без эффекта на нужды. + break; + + default: // Wander — блуждание к цели + MoveTo(ref pos, brain.Target, speed * seconds); + break; + } + + pos.X = Math.Clamp(pos.X, _bounds.Left, _bounds.Right); + pos.Y = Math.Clamp(pos.Y, _bounds.Top, _bounds.Bottom); + } + } + + foreach (var plant in _eaten) + { + plant.DeleteEntity(); + } + + foreach (var corpse in _consumed) + { + if (!corpse.IsNull) + { + corpse.DeleteEntity(); // труп доеден до нуля + } + } + + foreach (var prey in _kills) + { + AnimalFactory.Die(_store, _animalSet, prey, _cellSize); // забитая добыча → труп + } + + foreach (var (eater, seed) in _ingested) + { + if (!eater.IsNull && !eater.HasComponent()) + { + eater.AddComponent(seed); // структурное добавление — после прохода + } + } + + ApplyMatings(); + } + + // Эндозоохория: при выедании ЗРЕЛОГО растения шанс проглотить его семя — зверь понесёт его и + // высадит позже в другом месте (SeedDispersalSystem). Геном клонируется (отдельная особь-росток). + private void TryIngestSeed(int plantId, Entity eater) + { + if ( + eater.IsNull + || eater.HasComponent() + || plantId < 0 + || _rng.NextSingle() >= SeedIngestChance + || !_store.TryGetEntityById(plantId, out var plant) + || plant.IsNull + || !plant.HasComponent() + || !plant.HasComponent() + ) + { + return; + } + + ref readonly var grow = ref plant.GetComponent(); + if (grow.Stage < _plants[grow.Species].Stages.Length - 1) + { + return; // только зрелое растение даёт семя + } + + var genome = new Genome(plant.GetComponent().Genome.ToDictionary()); + _ingested.Add( + ( + eater, + new GutSeed + { + PlantSpecies = grow.Species, + Genome = genome, + DropInDays = GutSeedDropDays, + } + ) + ); + } + + // Охота/падальщество: ведёт к цели и при контакте либо ест труп (утоляет голод, расходует мясо), + // либо кусает живую добычу — урон части тела, острая кровопотеря, рана-кровотечение (первый + // травматический урон). Добитая добыча помечается на смерть (труп оставит общий путь Die). + // Минимальная видимая длительность красной вспышки урона у жертвы (реального времени). + private const float DamageFlashSeconds = 0.35f; + + private void Hunt( + ref AnimalBrain brain, + ref Vector2 pos, + float[] values, + AnimalDef def, + float step, + float seconds, + float days, + float foodEff, + float huntFactor, + float strength, + Entity hunter, + int hunterSpecies + ) + { + if ( + brain.TargetPrey < 0 + || !_store.TryGetEntityById(brain.TargetPrey, out var target) + || target.IsNull + || !target.HasComponent() + ) + { + return; // цель исчезла — действие пересмотрят на следующем решении + } + + var targetPos = target.GetComponent().Position; + var range = def.AttackRangeCells * _cellSize; + var inRange = Vector2.DistanceSquared(pos, targetPos) <= range * range; + + if (brain.TargetIsCorpse) + { + if (!target.HasComponent()) + { + return; + } + + if (!inRange) + { + MoveTo(ref pos, targetPos, step); + return; + } + + ref var corpse = ref target.GetComponent(); + Refill(values, AnimalActions.Eat, days, foodEff); + corpse.Meat -= def.ForageBiteDays * days; // расход мяса той же «силой укуса», что и выедание + if (corpse.Meat <= 0f && _consumedSet.Add(target)) + { + _consumed.Add(target); + } + + return; + } + + // Живая добыча. + if (!target.HasComponent() || !target.HasComponent()) + { + return; + } + + // Дискретные удары (как в RimWorld): кулдаун идёт и при сближении — первый контакт бьёт сразу. + brain.AttackCooldown -= seconds; + if (!inRange) + { + MoveTo(ref pos, targetPos, step); + return; + } + + if (brain.AttackCooldown > 0f) + { + return; // в контакте, но удар ещё на перезарядке + } + + brain.AttackCooldown = MathF.Max(0.05f, def.AttackIntervalSec); + + var preyHealth = target.GetComponent().State; + if (preyHealth is null) + { + return; + } + + // Сила удара = база дефа × сила особи (размер×стадия) × навык охоты; кровопотеря/кровотечение — ×силу. + var damage = def.AttackDamage * strength * huntFactor; + preyHealth.ApplyInjury( + damage, + def.AttackBloodLoss * strength, + _bleeding, + def.AttackBleed * strength, + _rng + ); + + // Красная вспышка жертвы (презентация) + удар в боевую хронику обоих участников. + if (target.HasComponent()) + { + target.GetComponent().Seconds = DamageFlashSeconds; + } + + _events.Struck( + hunter, + hunterSpecies, + target, + target.GetComponent().Species, + damage + ); + + if ( + preyHealth.BloodLevel <= 0f + || preyHealth.Capacity(AnimalCapacities.Consciousness) <= 0.01f + ) + { + if (_killsSet.Add(target)) + { + _kills.Add(target); // добита — смерть после прохода (структурное изменение) + _events.Killed( + hunter, + hunterSpecies, + target.GetComponent().Species + ); + } + } + } + + // Восполняет нужду, которую утоляет действие (по NeedSet), на её FeedPerDay × эффективность + // (питание/пищеварение для еды — слабая челюсть/больной желудок дают меньше сытости с того же корма). + private void Refill(float[] values, string action, float days, float efficiency = 1f) + { + var index = _needs.NeedForAction(action); + if (index < 0 || values is null || index >= values.Length) + { + return; + } + + values[index] = Math.Clamp( + values[index] + _needs[index].FeedPerDay * days * efficiency, + 0f, + 1f + ); + } + + // Бонус-множитель от уровня навыка: 0.7 (новичок) … 1.3 (мастер). Навык не определён модом → 1 (без эффекта). + private static float SkillFactor(int idx, in Skills s) + { + if (idx < 0 || s.Levels is null || idx >= s.Levels.Length) + { + return 1f; + } + + return 0.7f + 0.6f * s.Levels[idx]; + } + + // Начисляет опыт навыку от практики: рост ∝ скорость обучения × страсть × (1−уровень) (убывающая отдача). + private void GrantXp(int idx, in Skills s, float days) + { + if (idx < 0 || s.Levels is null || idx >= s.Levels.Length) + { + return; + } + + var passion = s.Passions is not null && idx < s.Passions.Length ? s.Passions[idx] : (byte)0; + var gain = + _skills[idx].LearnRate + * SkillSet.PassionMultiplier(passion) + * days + * (1f - s.Levels[idx]); + s.Levels[idx] = Math.Clamp(s.Levels[idx] + gain, 0f, 1f); + } + + // Двигает позицию к цели на step; возвращает true, если уже у цели (можно исполнять действие). + private static bool MoveTo(ref Vector2 pos, Vector2 target, float step) + { + var delta = target - pos; + var dist = delta.Length(); + if (dist <= step || dist < 0.001f) + { + pos = dist < 0.001f ? pos : target; + return true; + } + + pos += delta / dist * step; + return false; + } + + // Выедание: убавляет рост растения; траву (без ствола), выеденную до нуля, помечает на гибель. + private void Graze(int plantId, float days, float bitePerDay) + { + if ( + plantId < 0 + || !_store.TryGetEntityById(plantId, out var plant) + || plant.IsNull + || !plant.HasComponent() + ) + { + return; + } + + ref var grow = ref plant.GetComponent(); + grow.AgeDays = MathF.Max(0f, grow.AgeDays - bitePerDay * days); + var isGrass = _plants[grow.Species].Def.TrunkRadiusCells <= 0f; + if (isGrass && grow.AgeDays <= GrazeKillAge && _eatenSet.Add(plant)) + { + _eaten.Add(plant); + } + } + + // Доза яда за день укуса при токсичности 1 и нулевой устойчивости (масштабируется тяжести Poisoned). + private const float PoisonDosePerDay = 1.6f; + + // Порог шипов, выше которого укус о растение травмирует, и масштабы урона/кровопотери от шипов. + private const float ThornThreshold = 0.2f; + private const float ThornPartDamage = 4f; + private const float ThornBloodLoss = 0.02f; + + // Коэволюция: при поедании растение «отвечает» — токсичность отравляет (тем сильнее, чем ниже + // устойчивость зверя к яду), шипы наносят лёгкую травму. Доза яда копится в хедифе Poisoned; если + // зверь ест яд быстрее, чем выводит, тяжесть дойдёт до летальной. Детерминированно (общий _rng). + private void ApplyPlantDefense( + int plantId, + HealthState? health, + float toxinTolerance, + float days + ) + { + if ( + health is null + || plantId < 0 + || !_store.TryGetEntityById(plantId, out var plant) + || plant.IsNull + || !plant.HasComponent() + ) + { + return; + } + + ref readonly var traits = ref plant.GetComponent().Traits; + + var poisoned = false; + if (_poisoned is not null && traits.Toxicity > 0f) + { + var dose = traits.Toxicity * (1f - Math.Clamp(toxinTolerance, 0f, 1f)); + if (dose > 0f) + { + health.Intensify(_poisoned, dose * PoisonDosePerDay * days); + poisoned = true; + } + } + + if (traits.Thorns > ThornThreshold) + { + // ApplyInjury сам пересчитывает способности (учтёт и свежий яд). + health.ApplyInjury( + traits.Thorns * ThornPartDamage * days, + traits.Thorns * ThornBloodLoss * days, + _bleeding, + traits.Thorns * 0.15f * days, + _rng + ); + } + else if (poisoned) + { + health.RecomputeCapacities(); // отравление меняет capacity-моды — применить сразу + } + } + + // Применяет спаривания после прохода: самка пары беременеет геномом самца (структурное добавление — + // после итерации), влечение обоих обнуляется. Двойные события на одну самку отсеиваются. + private void ApplyMatings() + { + var mateNeed = _needs.NeedForAction(AnimalActions.Mate); + _matedPairs.Clear(); + foreach (var (a, b) in _matings) + { + if ( + a.IsNull + || b.IsNull + || !a.HasComponent() + || !b.HasComponent() + ) + { + continue; + } + + // Оба партнёра независимо ставят себя в очередь, так что пара приходит дважды — (a,b) и (b,a). + // Дедупим по неупорядоченному ключу: иначе ResetMating/мысль «спаривание» применяются повторно. + var pairKey = a.Id < b.Id ? (a.Id, b.Id) : (b.Id, a.Id); + if (!_matedPairs.Add(pairKey)) + { + continue; + } + + var aMale = a.GetComponent().IsMale; + var bMale = b.GetComponent().IsMale; + if (aMale == bMale) + { + continue; // один пол — не пара (страховка) + } + + var mother = aMale ? b : a; + var father = aMale ? a : b; + if (!mother.HasComponent()) + { + ref readonly var mom = ref mother.GetComponent(); + // Плодовитость падает со старостью (тот же фактор сенесценса): дряхлая самка приносит + // меньший помёт, а после порога пострепродуктивного возраста уже не зачинает. + var momAge = mother.HasComponent() + ? mother.GetComponent().AgeDays + : 0f; + var ageFrac = mom.Traits.Lifespan > 0f ? momAge / mom.Traits.Lifespan : 0f; + if (ageFrac <= PostReproductiveFrac) + { + var fert = AnimalHealthSystem.SenescenceFactor(momAge, mom.Traits.Lifespan); + mother.AddComponent( + new Pregnant + { + FatherGenome = father.GetComponent().Genome, + DueInDays = MathF.Max(1f, mom.Traits.GestationDays), + Litter = Math.Max(1, (int)MathF.Round(mom.Traits.LitterSize * fert)), + } + ); + _events.Pregnant(mother, mom.Species); + } + } + + ResetMating(a, mateNeed); + ResetMating(b, mateNeed); + // Спаривание — положительная событийная мысль обоим партнёрам (A8). + var mated = _thoughts.Get(AnimalThoughts.Mated); + a.GetComponent().Add(mated); + b.GetComponent().Add(mated); + _events.Mated( + a, + a.GetComponent().Species, + b, + b.GetComponent().Species + ); + } + } + + private static void ResetMating(Entity entity, int mateNeed) + { + if (mateNeed < 0 || !entity.HasComponent()) + { + return; + } + + var values = entity.GetComponent().Values; + if (values is not null && mateNeed < values.Length) + { + values[mateNeed] = 0f; + } + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalActions.cs b/src/LittleSim/Sim/Animals/AnimalActions.cs new file mode 100644 index 0000000..dc72b12 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalActions.cs @@ -0,0 +1,43 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Id поведений-исполнителей (реестр действий ИИ). Дефы нужд () ссылаются на +/// них; выбирает действие, исполняет. +/// Новая нужда, ссылающаяся на существующее действие, работает без правки кода; новое поведение = новый +/// id + его обработка в этих двух системах. +/// +public static class AnimalActions +{ + public const string Wander = "Wander"; + public const string Eat = "Eat"; + public const string Drink = "Drink"; + public const string Sleep = "Sleep"; + public const string Mate = "Mate"; + + /// Охота: преследовать живую добычу и кусать (урон) либо есть труп — утоляет голод хищника. + public const string Hunt = "Hunt"; + + /// Бегство: уходить от ближайшего хищника (реакция жертвы, важнее прочих нужд). + public const string Flee = "Flee"; + + // — Холостые (idle) активности: чем зверь занят, когда нет срочных нужд. Выбираются внутри ветки + // Wander с фиксированной длительностью (см. AnimalDecisionSystem) — придают поведению разнообразие. + + /// Отдых: стоит/лежит на месте, понемногу восстанавливая силы. + public const string Rest = "Rest"; + + /// Копание в земле в поисках корня/личинок (травоядные/всеядные) — даёт чуть сытости. + public const string Dig = "Dig"; + + /// Валяние в грязи у воды (комфорт/терморегуляция) — стоит на месте у кромки. + public const string Wallow = "Wallow"; +} diff --git a/src/LittleSim/Sim/Animals/AnimalAppearanceSystem.cs b/src/LittleSim/Sim/Animals/AnimalAppearanceSystem.cs new file mode 100644 index 0000000..14c70e6 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalAppearanceSystem.cs @@ -0,0 +1,49 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Презентация состояния нужд: голодный/уставший зверь тускнеет (яркость падает с самой острой +/// убывающей нуждой), сохраняя оттенок меха из генов. Читает симуляцию, пишет только +/// — граница sim/presentation цела. +/// +public sealed class AnimalAppearanceSystem(NeedSet needs) + : QuerySystem +{ + private const float MinBrightness = 0.5f; + + protected override void OnUpdate() + { + foreach (var (needsChunk, organisms, sprites, _) in Query.Chunks) + { + var n = needsChunk.Span; + var o = organisms.Span; + var s = sprites.Span; + for (var i = 0; i < n.Length; i++) + { + var values = n[i].Values; + var worst = 1f; + if (values is not null) + { + for (var k = 0; k < needs.Count && k < values.Length; k++) + { + if (needs.IsDeplete(k)) + { + worst = MathF.Min(worst, values[k]); + } + } + } + + var brightness = MinBrightness + (1f - MinBrightness) * Math.Clamp(worst, 0f, 1f); + s[i].Color = AnimalFactory.FurTint(o[i].Traits) * brightness; + } + } + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalBobSystem.cs b/src/LittleSim/Sim/Animals/AnimalBobSystem.cs new file mode 100644 index 0000000..75910c6 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalBobSystem.cs @@ -0,0 +1,56 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// «Раскачка» при ходьбе (презентация): идущий зверь покачивается из стороны в сторону (наклон спрайта +/// через ), стоящий — плавно выпрямляется. Делает движение живее, не +/// затрагивая симуляцию (она вращение не использует). Фаза покачивания идёт по игровому времени (стоп на +/// паузе, быстрее на ускорении) и сдвинута по id особи — звери качаются вразнобой. Граница sim/presentation +/// цела: система пишет только поворот трансформа. +/// +public sealed class AnimalBobSystem(GameClock clock) : QuerySystem +{ + private const float SwaySpeed = 9f; // скорость набега фазы (рад/сек игрового времени) + private const float SwayAmplitude = 0.08f; // амплитуда наклона (рад ≈ 4.6°) + private const float MinMoveSq = 0.5f * 0.5f; // ближе к цели — считаем стоящим + private const float UprightDecay = 0.85f; // как быстро выпрямляется стоя (за кадр) + + private float _phase; + + protected override void OnUpdate() + { + var dt = clock.DeltaTime; + if (dt <= 0f) + { + return; // пауза — не качаем и не выпрямляем + } + + _phase += dt * SwaySpeed; + foreach (var (brains, transforms, entities) in Query.Chunks) + { + var b = brains.Span; + var t = transforms.Span; + for (var i = 0; i < b.Length; i++) + { + ref var rot = ref t[i].Rotation; + if ((b[i].Target - t[i].Position).LengthSquared() > MinMoveSq) + { + var offset = entities.EntityAt(i).Id * 0.7f; // десинхрон между особями + rot = MathF.Sin(_phase + offset) * SwayAmplitude; + } + else + { + rot *= UprightDecay; // у цели/стоя — плавно к вертикали + } + } + } + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalBrain.cs b/src/LittleSim/Sim/Animals/AnimalBrain.cs new file mode 100644 index 0000000..b95edc4 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalBrain.cs @@ -0,0 +1,48 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// «Мозг» зверя: выбранное действие (id поведения), его цель и таймер до пересмотра. Решение принимает +/// (движковый ), исполняет +/// . +/// +public struct AnimalBrain : IComponent +{ + /// Id текущего действия (см. ). + public string Action; + + /// Куда идти (позиция растения/воды/партнёра/точки блуждания). + public Vector2 Target; + + /// Id растения-цели для ; -1 — нет. + public int TargetPlant; + + /// Id партнёра для ; -1 — нет. + public int TargetMate; + + /// Id цели для — живой добычи или трупа; -1 — нет. + public int TargetPrey; + + /// Цель — труп (падаль), а не живая добыча: при достижении едим, а не атакуем. + public bool TargetIsCorpse; + + /// Секунды до следующего пересмотра решения. + public float DecideIn; + + /// Игровые секунды до следующего удара по добыче (кулдаун дискретных атак, как в RimWorld). + public float AttackCooldown; + + /// Текущее направление блуждания (коррелированное — плавно дрейфует, не дёргается каждый раз). + public Vector2 Heading; + + /// Игровое время (TotalTime), до которого длится текущая холостая активность (отдых/копание/…). + public float ActivityUntil; +} diff --git a/src/LittleSim/Sim/Animals/AnimalContext.cs b/src/LittleSim/Sim/Animals/AnimalContext.cs new file mode 100644 index 0000000..13c1ad0 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalContext.cs @@ -0,0 +1,59 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Снимок нужд зверя для соображений utility-выбора (доступ по индексу нужды) с гейтом интеллектом +/// (фаза A7): нужда, чей выше эффективного интеллекта особи +/// (brainSize × Consciousness), в выбор не входит. +/// +public readonly struct AnimalContext( + float[] values, + NeedSet needs, + float intelligence, + int overrideIndex = -1, + float overrideValue = 0f +) +{ + private readonly float[] _values = values; + private readonly NeedSet _needs = needs; + private readonly float _intelligence = intelligence; + + // Переопределение значения одной нужды (стиль питания: «эффективный голод» с гистерезисом — + // см. AnimalDecisionSystem). Override имеет приоритет над сырым значением и над гейтом мозга. + private readonly int _overrideIndex = overrideIndex; + private readonly float _overrideValue = overrideValue; + + /// + /// Значение нужды по индексу. Если для этой нужды задан override (стиль питания) — он и возвращается. + /// Если её порог мозга выше интеллекта особи — нужда недоступна и возвращается её «не-срочный» край + /// (deplete → 1 «сыта», drive → 0 «без влечения»), чтобы соображение дало ~0 и действие не выбралось. + /// Индекс вне диапазона → 0. + /// + public float Get(int index) + { + if (index == _overrideIndex) + { + return _overrideValue; + } + + if (index >= _values.Length) + { + return 0f; + } + + if (_needs[index].MinBrain > _intelligence) + { + return _needs.IsDrive(index) ? 0f : 1f; + } + + return _values[index]; + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalDecisionSystem.cs b/src/LittleSim/Sim/Animals/AnimalDecisionSystem.cs new file mode 100644 index 0000000..1afa885 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalDecisionSystem.cs @@ -0,0 +1,1110 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Слой ВЫБОРА: раз в секунд каждый зверь выбирает действие движковым +/// , построенным ИЗ ДАННЫХ (по соображению на +/// нужду), и находит цель действия (еда по диете, вода, партнёр). Решение пишется в +/// ; исполняет . +/// +public sealed class AnimalDecisionSystem : BaseSystem +{ + private const float Interval = 0.6f; + private const float MateMoodFloor = 0.35f; // ниже — стресс/истощение, зверь не спаривается (A8) + private const float HerdMinSociability = 0.3f; // ниже — вид не стадный (когезия/безопасность выкл.) + private const float SafetyInNumbers = 0.5f; // макс. снижение радиуса реакции на угрозу в группе + private const int HerdFullCount = 6; // размер группы, при котором эффект «безопасность в числе» полон + + // Стиль питания (gorge↔graze): пороги начала/конца кормёжки лерпуются по гену feedingStyle [0..1]. + // graze (0): начинает есть уже при лёгком голоде и наедается не до полного — частые мелкие приёмы. + // gorge (1): ищет еду лишь при сильном голоде, но ест до полной сытости — редкие крупные приёмы. + private const float FeedStartGraze = 0.8f; + private const float FeedStartGorge = 0.4f; + private const float FeedFullGraze = 0.9f; + private const float FeedFullGorge = 1.0f; + + // Блуждание/стадность (boids): коррелированный дрейф направления + расхождение (личное пространство) и + // лишь слабое притяжение к центру группы на удалении — без этого стадо схлопывалось в одну точку. + private const float WanderTurn = 0.5f; // макс. поворот направления за пересмотр (рад) — плавные пути + private const float RoamCells = 11f; // длина «отрезка» блуждания в клетках + private const float SeparationCells = 2.2f; // ближе этого к соседу — расходимся + private const float SeparationWeight = 1.6f; // сила расхождения + private const float CohesionStartCells = 7f; // дальше этого от центра группы — слабо подтягиваемся + private const float CohesionWeight = 0.45f; // сила когезии (намеренно мала) + private const float EdgeMarginCells = 2.5f; // у края мира разворачиваем направление внутрь + + // Холостые активности: длительность (игровые секунды) каждой, пока зверь ей занят. + private const float RoamMinSec = 3f; + private const float RoamMaxSec = 6f; + private const float RestMinSec = 5f; + private const float RestMaxSec = 11f; + private const float DigMinSec = 3f; + private const float DigMaxSec = 7f; + private const float WallowMinSec = 5f; + private const float WallowMaxSec = 10f; + private const float WallowWaterCells = 4f; // валяться можно лишь у воды (в этом радиусе от кромки) + + private readonly UtilityAi _brain; + private readonly EntityStore _store; + private readonly WorldEvents _events; + private readonly GameClock _clock; + private readonly AnimalSet _animalSet; + private readonly NeedSet _needs; + private readonly int _hungerNeed; // индекс убывающей нужды «голод» (для гистерезиса кормёжки); -1 — нет + private readonly int _restNeed; // индекс нужды «отдых» (для веса холостого отдыха); -1 — нет + private readonly int _cellSize; + private readonly Vector2[] _shoreByCell; // поле «ближайший берег» по клеткам (O(1) поиск водопоя) + private readonly int _worldWidth; + private readonly int _worldHeight; + private readonly Random _rng; + private readonly ArchetypeQuery< + AnimalNeeds, + AnimalBrain, + AnimalOrganism, + AnimalGrowth, + Transform2D + > _animals; + private readonly ArchetypeQuery _plants; + private readonly ArchetypeQuery _corpses; + + // Пространственные индексы + переиспользуемые буферы запросов: поиск корма/угрозы/добычи/партнёра/ + // стада берёт только ближние сущности вместо прохода по всем (было O(растений)+O(животных) на каждое + // решение). В ячейке — предпосчитанные признаки (без дорефетча). Сетку животных перестраиваем каждый + // кадр (они двигаются), сетки растений/трупов — раз в StaticGridRebuildSeconds (они статичны). + private const int GridCells = 10; // размер ячейки сетки в клетках мира (~радиус восприятия) + private const float HearingRangeMult = 1.4f; // слух шире зрения + private const float SmellRangeMult = 1.8f; // запах — дальше всех (но грубый, см. Detect) + private const float RememberedThreatAvoidCells = 14f; // в этой зоне от памяти об угрозе зверь уходит прочь + + // Каденция перестройки СТАТИЧНЫХ сеток (растения/трупы): они не двигаются (меняются лишь при + // рождении/смерти), поэтому полный обход тысяч растений на каждый кадр был основной ценой системы. + // Перестраиваем раз в эти игровые секунды (≈ как часто одна особь и так пересматривает решение); + // удалённые сущности в устаревшей сетке безопасны — потребитель сверяется со store перед действием. + private const float StaticGridRebuildSeconds = 0.5f; + private readonly SpatialGrid _plantGrid; + private readonly List _plantBuf = []; + private readonly SpatialGrid _animalGrid; + private readonly List _animalBuf = []; + private readonly SpatialGrid _corpseGrid; + private readonly List _corpseBuf = []; + private float _staticGridAccum; // накоплено игровых секунд с последней перестройки статичных сеток + private bool _staticGridsBuilt; // первая перестройка обязательна (до неё сетки пусты) + private readonly float _smellSnap; // размер «обонятельной» ячейки (грубая локализация по запаху) + + public AnimalDecisionSystem( + EntityStore store, + GameClock clock, + AnimalSet animals, + NeedSet needs, + int cellSize, + Vector2[] shoreByCell, + int worldWidth, + int worldHeight, + WorldEvents events, + int seed + ) + { + _brain = BuildBrain(needs); + _store = store; + _events = events; + _clock = clock; + _animalSet = animals; + _needs = needs; + _hungerNeed = needs.NeedForAction(AnimalActions.Eat); + _restNeed = needs.NeedForAction(AnimalActions.Sleep); + _cellSize = cellSize; + _shoreByCell = shoreByCell; + _worldWidth = worldWidth; + _worldHeight = worldHeight; + _rng = new Random(seed); + _plantGrid = new SpatialGrid(cellSize * GridCells); + _animalGrid = new SpatialGrid(cellSize * GridCells); + _corpseGrid = new SpatialGrid(cellSize * GridCells); + _smellSnap = cellSize * GridCells; + _animals = store.Query< + AnimalNeeds, + AnimalBrain, + AnimalOrganism, + AnimalGrowth, + Transform2D + >(); + _plants = store.Query(); + _corpses = store.Query(); + } + + // Строит utility-reasoner из данных: на каждую нужду — действие с соображением. Deplete: чем ниже + // значение, тем привлекательнее ((1-v)^3). Drive: чем выше влечение, тем привлекательнее (v). + private static UtilityAi BuildBrain(NeedSet needs) + { + var actions = new List>(needs.Count + 1); + for (var i = 0; i < needs.Count; i++) + { + var index = i; + var def = needs[i]; + var consideration = needs.IsDrive(i) + ? new Consideration(def.DefName, c => c.Get(index)) + : new Consideration( + def.DefName, + c => c.Get(index), + curve: ResponseCurve.Polynomial(exponent: 3f, slope: -1f, xShift: 1f) + ); + actions.Add(new UtilityAction(def.Action, consideration)); + } + + actions.Add( + new UtilityAction( + AnimalActions.Wander, + new Consideration("idle", _ => 0.12f) + ) + ); + return new UtilityAi(actions.ToArray()); + } + + protected override void OnUpdateGroup() + { + var delta = _clock.DeltaTime; + if (delta <= 0f) + { + return; // пауза — решения не пересматриваются (как и прочие sim-системы) + } + + var clockNow = (float)_clock.TotalTime; // игровое время для таймеров холостых активностей + RebuildAnimalGrid(); // животные двигаются — индекс свежий каждый кадр + // Растения/трупы статичны — индекс перестраиваем по каденции, а не каждый кадр. + _staticGridAccum += delta; + if (!_staticGridsBuilt || _staticGridAccum >= StaticGridRebuildSeconds) + { + RebuildStaticGrids(); + _staticGridAccum = 0f; + _staticGridsBuilt = true; + } + + foreach ( + var (needsChunk, brains, organisms, growths, transforms, entities) in _animals.Chunks + ) + { + var n = needsChunk.Span; + var b = brains.Span; + var o = organisms.Span; + var gr = growths.Span; + var t = transforms.Span; + for (var i = 0; i < b.Length; i++) + { + ref var brain = ref b[i]; + brain.DecideIn -= delta; + if (brain.DecideIn > 0f) + { + continue; + } + + brain.DecideIn = Interval + _rng.NextSingle() * Interval; // джиттер — расфазировать скан + var prevAction = brain.Action; // для журнала: события на смене действия (начал/закончил) + var pos = t[i].Position; + var adult = AnimalFactory.IsAdult(_animalSet[o[i].Species], gr[i].Stage); + var self = entities.EntityAt(i); + var health = self.GetComponent().State; + // Эффективный интеллект (мозг × сознание) гейтит доступные нужды/действия (фаза A7): + // больной/раненый зверь с упавшим сознанием теряет высшие нужды (сон/секс) → рефлексы. + var intelligence = AnimalFactory.Intelligence(o[i].Traits, health); + // Радиусы восприятия по чувствам: зрение (корм/партнёр), плюс слух и обоняние (угроза/добыча). + // Острота — гены чувств × способность органа (раны/хедифы их режут). Зрение узкое, слух шире, + // запах дальше всех, но грубый (см. Detect). Радиусы детерминированы. + var baseRadius = VisionRadius(o[i].Species, o[i].Traits.Vision); + var sight = health?.Capacity(AnimalCapacities.Sight) ?? 1f; + var hearing = health?.Capacity(AnimalCapacities.Hearing) ?? 1f; + var smell = health?.Capacity(AnimalCapacities.Smell) ?? 1f; + var seeRadius = baseRadius * MathF.Max(0.2f, sight); + var senseBase = _animalSet[o[i].Species].Def.VisionCells * _cellSize; + var ranges = new SenseRanges( + seeRadius, + senseBase * o[i].Traits.Hearing * MathF.Max(0.2f, hearing) * HearingRangeMult, + senseBase * o[i].Traits.Smell * MathF.Max(0.2f, smell) * SmellRangeMult + ); + // Соседей по сетке собираем ОДИН раз на решение (на макс. радиусе чувств): стадность, + // бегство, поиск добычи и партнёра дальше читают этот буфер со своими фильтрами дистанции — + // вместо 2–4 повторных обходов сетки и копирований за одно решение. + _animalGrid.Collect(pos, ranges.Max(), _animalBuf); + + // Настроение (A8): стресс/истощение (низкое настроение) подавляет тягу к спариванию. + var mood = self.GetComponent(); + + // Стадность (boids): социальные виды считают центр/размер группы соплеменников (когезия и + // «безопасность в числе») и вектор расхождения herdAway (личное пространство — чтобы стадо + // держалось вместе, но не схлопывалось в точку). + var sociability = o[i].Traits.Sociability; + var herdCount = 0; + var herdCenter = pos; + var herdAway = Vector2.Zero; + if (sociability >= HerdMinSociability) + { + HerdScan( + pos, + seeRadius, + o[i].Species, + self.Id, + out herdCenter, + out herdCount, + out herdAway + ); + } + + var safety = + herdCount > 0 + ? 1f - SafetyInNumbers * MathF.Min(1f, herdCount / (float)HerdFullCount) + : 1f; + + // Бегство (предатор-кластер): жертва, заметившая рядом хищника, спасается — это важнее + // любых нужд (инстинкт выживания). Сам хищник от добычи не бежит (см. IsThreatTo). + if ( + TryFindThreat( + pos, + ranges.Scaled(safety), + o[i].Species, + o[i].EatsMeat, + o[i].Traits.BodySize, + out var threatPos + ) + ) + { + self.GetComponent().Remember(MemoryKind.Threat, threatPos, _smellSnap); + if (prevAction != AnimalActions.Flee) + { + _events.Fled(self, o[i].Species); // начало бегства — в личную хронику + } + + brain.Action = AnimalActions.Flee; + brain.TargetPlant = -1; + brain.TargetPrey = -1; + var away = pos - threatPos; + var len = away.Length(); + brain.Target = + len > 0.001f + ? pos + away / len * (ranges.Max() + _cellSize) + : pos + new Vector2(_cellSize, 0f); + continue; + } + + var eatsPlants = o[i].EatsPlants; // диета кэширована при рождении (без пересчёта на тик) + var eatsMeat = o[i].EatsMeat; + // Стиль питания: «эффективный голод» с гистерезисом. gorge не лезет за едой, пока сытость + // выше порога начала, но начав — ест до полного; graze подтапливает часто и до меньшего. + var hunger = EffectiveHunger( + n[i].Values, + o[i].Traits.FeedingStyle, + brain.Action is AnimalActions.Eat or AnimalActions.Hunt + ); + var name = + _brain + .Select( + new AnimalContext( + n[i].Values, + _needs, + intelligence, + _hungerNeed, + hunger + ) + ) + ?.Name + ?? AnimalActions.Wander; + + switch (name) + { + // Хищник/всеядное голодает → ищет труп (падаль) или живую добычу и охотится. + case AnimalActions.Eat + when eatsMeat + && TryFindKill( + pos, + ranges, + o[i].Species, + o[i].Traits, + self.Id, + out var preyId, + out var preyPos, + out var preyIsCorpse + ): + brain.Action = AnimalActions.Hunt; + brain.TargetPlant = -1; + brain.TargetPrey = preyId; + brain.TargetIsCorpse = preyIsCorpse; + brain.Target = preyPos; + // Начало охоты на ЖИВУЮ добычу (падальщество не считаем охотой) — заметное событие. + if ( + prevAction != AnimalActions.Hunt + && !preyIsCorpse + && _store.TryGetEntityById(preyId, out var preyEntity) + && !preyEntity.IsNull + && preyEntity.TryGetComponent(out var preyOrg) + ) + { + _events.HuntStarted(self, o[i].Species, preyOrg.Species); + } + + break; + case AnimalActions.Eat + when eatsPlants + && TryFindForage( + pos, + seeRadius, + intelligence, + o[i].Traits.ToxinTolerance, + out var plant, + out var pp + ): + brain.Action = AnimalActions.Eat; + brain.TargetPlant = plant; + brain.Target = pp; + break; + case AnimalActions.Drink when TryFindShore(pos, out var water): + brain.Action = AnimalActions.Drink; + brain.TargetPlant = -1; + brain.Target = water; + break; + case AnimalActions.Sleep: + brain.Action = AnimalActions.Sleep; + brain.TargetPlant = -1; + break; + case AnimalActions.Mate + when adult + && (!mood.Active || mood.Value >= MateMoodFloor) + && TryFindMate( + pos, + o[i].IsMale, + o[i].Species, + seeRadius, + out var mateId, + out var matePos + ): + brain.Action = AnimalActions.Mate; + brain.TargetPlant = -1; + brain.TargetMate = mateId; + brain.Target = matePos; + break; + default: + brain.TargetPlant = -1; + brain.TargetPrey = -1; + // Память: вблизи места недавнего хищника бросаем холостые дела и уходим прочь. + if (TryRememberedThreat(self, pos, out var dangerPos)) + { + var flee = pos - dangerPos; + var d = flee.Length(); + brain.Action = AnimalActions.Wander; + brain.Heading = d > 0.001f ? flee / d : RandomDir(); + brain.ActivityUntil = clockNow + RoamMinSec; + brain.Target = + pos + brain.Heading * (_cellSize * (4f + _rng.NextSingle() * 4f)); + } + else + { + // Конечный автомат холостых дел: держим текущую активность до её таймера, + // затем выбираем новую (блуждание/отдых/копание/валяние) с весами по виду. + if (!IsIdle(brain.Action) || clockNow >= brain.ActivityUntil) + { + PickIdleActivity(ref brain, self, o[i], pos, n[i].Values, clockNow); + } + + if (brain.Action == AnimalActions.Wander) + { + SteerRoam( + ref brain, + pos, + herdCenter, + herdCount, + herdAway, + sociability + ); + } + else + { + brain.Target = pos; // отдых/копание/валяние — стоим на месте + } + } + + break; + } + + // Бытовое в личную хронику: завершение приёма пищи/водопоя (по смене действия — без + // спама на каждый тик). Начало охоты/бегства логируется выше у своих веток. + if (prevAction == AnimalActions.Eat && brain.Action != AnimalActions.Eat) + { + _events.Ate(self, o[i].Species); + } + else if (prevAction == AnimalActions.Drink && brain.Action != AnimalActions.Drink) + { + _events.Drank(self, o[i].Species); + } + } + } + } + + // «Эффективный голод» для utility-выбора по стилю питания (gorge↔graze) с гистерезисом: + // — кормится сейчас → особь «голодна», пока сытость не достигнет порога полного (gorge ест до 1, + // graze — до меньшего): начатый приём пищи доводится до конца, а не бросается на полпути; + // — не кормится → особь «сыта» (1), пока сытость выше порога начала; ниже — падает в «голод» + // пропорционально, и чем выше стиль (gorge), тем ниже порог начала (ест реже, но крупнее). + // Возвращает значение [0,1] для нужды голода; при отсутствии нужды — 1 (override не применится). + private float EffectiveHunger(float[] values, float feedingStyle, bool feedingNow) + { + if (_hungerNeed < 0 || values is null || _hungerNeed >= values.Length) + { + return 1f; + } + + var sat = values[_hungerNeed]; + var style = Math.Clamp(feedingStyle, 0f, 1f); + if (feedingNow) + { + var full = FeedFullGraze + (FeedFullGorge - FeedFullGraze) * style; + return full > 0f ? Math.Clamp(sat / full, 0f, 1f) : 1f; + } + + var start = FeedStartGraze + (FeedStartGorge - FeedStartGraze) * style; + return sat >= start ? 1f + : start > 0f ? sat / start + : 0f; + } + + // Радиус восприятия особи: базовая дальность вида (данные) × ген зрения × размер клетки. + private float VisionRadius(int species, float vision) => + MathF.Max(1f, _animalSet[species].Def.VisionCells) * MathF.Max(0.2f, vision) * _cellSize; + + // Ближайшая запомненная угроза в зоне избегания (для блуждания — уйти от опасного места). + private bool TryRememberedThreat(Entity self, Vector2 pos, out Vector2 threatPos) + { + threatPos = default; + if (!self.TryGetComponent(out var mem) || mem.Entries is null) + { + return false; + } + + var bestSq = RememberedThreatAvoidCells * _cellSize; + bestSq *= bestSq; + var found = false; + foreach (var e in mem.Entries) + { + if (e.Kind != MemoryKind.Threat) + { + continue; + } + + var sq = Vector2.DistanceSquared(pos, e.Pos); + if (sq < bestSq) + { + bestSq = sq; + threatPos = e.Pos; + found = true; + } + } + + return found; + } + + // Центр/размер группы соплеменников в радиусе (когезия/безопасность) и вектор расхождения away — + // сумма направлений «прочь» от соседей ближе SeparationCells (личное пространство, чтобы стадо не + // схлопывалось в точку). O(n) на особь. Сам исключается по id; центр — средняя позиция соседей. + private void HerdScan( + Vector2 from, + float radius, + int species, + int selfId, + out Vector2 center, + out int count, + out Vector2 away + ) + { + var sum = Vector2.Zero; + away = Vector2.Zero; + count = 0; + var radiusSq = radius * radius; + var sepDist = SeparationCells * _cellSize; + var sepSq = sepDist * sepDist; + // _animalBuf уже собран в DecideIn на макс. радиусе чувств — фильтруем по своему радиусу. + foreach (var e in _animalBuf) + { + if (e.Species != species || e.Id == selfId) + { + continue; + } + + var distSq = Vector2.DistanceSquared(from, e.Pos); + if (distSq <= radiusSq) + { + sum += e.Pos; + count++; + } + + if (distSq <= sepSq && distSq > 0.0001f) + { + // ближе личного пространства — добавляем направление прочь, тем сильнее, чем ближе сосед. + var d = MathF.Sqrt(distSq); + away += (from - e.Pos) / d * (1f - d / sepDist); + } + } + + center = count > 0 ? sum / count : from; + } + + // Холостая ли активность (то, чем зверь занят без срочных нужд): блуждание/отдых/копание/валяние. + private static bool IsIdle(string action) => + action == AnimalActions.Wander + || action == AnimalActions.Rest + || action == AnimalActions.Dig + || action == AnimalActions.Wallow; + + // Случайное единичное направление. + private Vector2 RandomDir() + { + var a = _rng.NextSingle() * MathF.Tau; + return new Vector2(MathF.Cos(a), MathF.Sin(a)); + } + + // Поворот вектора на угол (рад). + private static Vector2 Rotate(Vector2 v, float radians) + { + var c = MathF.Cos(radians); + var s = MathF.Sin(radians); + return new Vector2(v.X * c - v.Y * s, v.X * s + v.Y * c); + } + + // Выбор холостой активности с весами по виду/состоянию/окружению и её длительность. Блуждание — всегда; + // отдых — охотнее у подуставших; копание — травоядным/всеядным; валяние — только у воды. Начало + // характерной активности (кроме блуждания) пишется в личную хронику особи. + private void PickIdleActivity( + ref AnimalBrain brain, + Entity self, + in AnimalOrganism org, + Vector2 pos, + float[] values, + float now + ) + { + var roam = 1.0f; + var rest = 0.5f; + if (_restNeed >= 0 && values is not null && _restNeed < values.Length) + { + rest += (1f - values[_restNeed]) * 0.8f; // ниже отдых — охотнее приляжет (но не до сна) + } + + var dig = org.EatsPlants ? 0.7f : 0.1f; + var wallowRange = WallowWaterCells * _cellSize; + var nearWater = + TryFindShore(pos, out var water) + && Vector2.DistanceSquared(pos, water) <= wallowRange * wallowRange; + var wallow = nearWater ? 0.5f : 0f; + + var roll = _rng.NextSingle() * (roam + rest + dig + wallow); + string action; + float min, + max; + if ((roll -= roam) < 0f) + { + action = AnimalActions.Wander; + (min, max) = (RoamMinSec, RoamMaxSec); + } + else if ((roll -= rest) < 0f) + { + action = AnimalActions.Rest; + (min, max) = (RestMinSec, RestMaxSec); + } + else if (roll - dig < 0f) + { + action = AnimalActions.Dig; + (min, max) = (DigMinSec, DigMaxSec); + } + else + { + action = AnimalActions.Wallow; + (min, max) = (WallowMinSec, WallowMaxSec); + } + + brain.Action = action; + brain.ActivityUntil = now + min + _rng.NextSingle() * (max - min); + switch (action) + { + case AnimalActions.Rest: + _events.Activity(self, org.Species, "event.rest"); + break; + case AnimalActions.Dig: + _events.Activity(self, org.Species, "event.dig"); + break; + case AnimalActions.Wallow: + _events.Activity(self, org.Species, "event.wallow"); + break; + } + } + + // Рулёжка блуждания (boids): плавный дрейф направления + расхождение (личное пространство) и лишь + // слабая когезия к центру группы на удалении; у края мира заворачиваем внутрь. Цель ставится далеко + // по направлению, поэтому зверь идёт, а не топчется на месте. + private void SteerRoam( + ref AnimalBrain brain, + Vector2 pos, + Vector2 herdCenter, + int herdCount, + Vector2 herdAway, + float sociability + ) + { + var dir = brain.Heading; + if (dir.LengthSquared() < 0.0001f) + { + dir = RandomDir(); + } + + dir = Rotate(dir, (_rng.NextSingle() - 0.5f) * 2f * WanderTurn); // коррелированный дрейф + dir += herdAway * SeparationWeight; // расхождение в стаде + if (sociability >= HerdMinSociability && herdCount > 0) + { + var toCenter = herdCenter - pos; + var d = toCenter.Length(); + if (d > CohesionStartCells * _cellSize) + { + dir += toCenter / d * CohesionWeight; // слабая когезия лишь на удалении + } + } + + var margin = EdgeMarginCells * _cellSize; + var w = _worldWidth * _cellSize; + var h = _worldHeight * _cellSize; + if (pos.X < margin && dir.X < 0f) + { + dir.X = MathF.Abs(dir.X); + } + else if (pos.X > w - margin && dir.X > 0f) + { + dir.X = -MathF.Abs(dir.X); + } + + if (pos.Y < margin && dir.Y < 0f) + { + dir.Y = MathF.Abs(dir.Y); + } + else if (pos.Y > h - margin && dir.Y > 0f) + { + dir.Y = -MathF.Abs(dir.Y); + } + + if (dir.LengthSquared() < 0.0001f) + { + dir = RandomDir(); + } + + dir.Normalize(); + brain.Heading = dir; + brain.Target = pos + dir * (_cellSize * RoamCells); + } + + // Ближайший хищник-угроза в радиусе восприятия (для бегства жертвы). Угроза = другой вид, который + // ест мясо и достаточно крупный (см. IsThreatTo). O(n) на особь — как поиск корма. + private bool TryFindThreat( + Vector2 from, + in SenseRanges ranges, + int selfSpecies, + bool selfEatsMeat, + float selfBody, + out Vector2 threatPos + ) + { + var bestSq = float.MaxValue; + threatPos = default; + var threatId = -1; + // _animalBuf уже собран в DecideIn; обнаружение всё равно гейтится Detect по чувствам. + foreach (var e in _animalBuf) + { + if ( + e.Species == selfSpecies + || !IsThreatTo(selfEatsMeat, selfBody, e.EatsMeat, e.BodySize) + ) + { + continue; + } + + var sq = Vector2.DistanceSquared(from, e.Pos); + // Обнаружение по чувствам: зрение (минус камуфляж) / слух (× шум) / запах (× запах, грубая зона). + if (!Detect(sq, e.Pos, ranges, e.Camouflage, e.Scent, e.Noise, out var perceived)) + { + continue; + } + + if (sq < bestSq || (sq == bestSq && e.Id < threatId)) + { + bestSq = sq; + threatPos = perceived; + threatId = e.Id; + } + } + + return threatId >= 0; + } + + // Является ли вид-кандидат угрозой особи: он ест мясо и не мельче её. Чистая жертва (не мясоед) + // бежит от любого хищника не мельче себя; сам хищник — лишь от заметно более крупного мясоеда. + private static bool IsThreatTo( + bool selfEatsMeat, + float selfBody, + bool otherEatsMeat, + float otherBody + ) + { + if (!otherEatsMeat) + { + return false; + } + + return selfEatsMeat ? otherBody > selfBody * 1.1f : otherBody >= selfBody * 0.9f; + } + + /// Радиусы восприятия особи по чувствам (уже с остротой генов и способностью органов). + private readonly record struct SenseRanges(float Sight, float Hearing, float Smell) + { + public float Max() => MathF.Max(Sight, MathF.Max(Hearing, Smell)); + + public SenseRanges Scaled(float f) => new(Sight * f, Hearing * f, Smell * f); + } + + // Обнаружена ли цель и где она «видится» наблюдателю. Зрение/слух дают точную позицию (зрение глушит + // камуфляж цели, слух усиливает её шум); запах добивает дальше всех, но даёт лишь ГРУБУЮ зону — + // позиция округляется до ячейки запаха (зверь идёт «примерно туда», пока ближе не сработает зрение). + private bool Detect( + float dist2, + Vector2 truePos, + in SenseRanges r, + float camouflage, + float scent, + float noise, + out Vector2 perceived + ) + { + perceived = truePos; + var sight = r.Sight * (1f - Math.Clamp(camouflage, 0f, 1f)); + if (sight > 0f && dist2 <= sight * sight) + { + return true; + } + + var hear = r.Hearing * Math.Clamp(noise, 0f, 1f); + if (hear > 0f && dist2 <= hear * hear) + { + return true; + } + + var smell = r.Smell * Math.Clamp(scent, 0f, 1f); + if (smell > 0f && dist2 <= smell * smell) + { + perceived = SnapToSmellCell(truePos); // грубая зона запаха + return true; + } + + return false; + } + + // Округляет позицию до центра «обонятельной» ячейки — детерминированная грубая локализация по запаху. + private Vector2 SnapToSmellCell(Vector2 p) => + new( + MathF.Floor(p.X / _smellSnap) * _smellSnap + _smellSnap * 0.5f, + MathF.Floor(p.Y / _smellSnap) * _smellSnap + _smellSnap * 0.5f + ); + + // Ближайшая «мясная» цель: труп (падаль, приоритет — даровая еда без риска) или живая добыча. + // Возвращает id, позицию и флаг трупа. Добыча — другой вид не крупнее охотника (см. IsPreyFor). + private bool TryFindKill( + Vector2 from, + in SenseRanges ranges, + int selfSpecies, + in AnimalPhenotype selfTraits, + int selfId, + out int targetId, + out Vector2 position, + out bool isCorpse + ) + { + targetId = -1; + position = default; + isCorpse = false; + var reach = ranges.Max(); + + // 1) Падаль с остатком мяса — находится в пределах любого чувства (запах хорошо ведёт к падали). + // Кандидаты — из сетки трупов (ближние ячейки), а не полным сканом всех трупов мира. + var bestCorpseSq = reach * reach; + var corpseId = -1; + var corpsePos = default(Vector2); + _corpseGrid.Collect(from, reach, _corpseBuf); + foreach (var c in _corpseBuf) + { + if (c.Meat <= 0f) + { + continue; + } + + var sq = Vector2.DistanceSquared(from, c.Pos); + if (sq < bestCorpseSq || (sq == bestCorpseSq && c.Id < corpseId)) + { + bestCorpseSq = sq; + corpseId = c.Id; + corpsePos = c.Pos; + } + } + + // 2) Живая добыча (из сетки животных, по чувствам). _animalBuf уже собран в DecideIn на reach. + var bestPreySq = float.MaxValue; + var preyId = -1; + var preyPos = default(Vector2); + var selfBody = selfTraits.BodySize; + foreach (var e in _animalBuf) + { + if (e.Species == selfSpecies || e.Id == selfId || !IsPreyFor(selfBody, e.BodySize)) + { + continue; + } + + var sq = Vector2.DistanceSquared(from, e.Pos); + if (!Detect(sq, e.Pos, ranges, e.Camouflage, e.Scent, e.Noise, out var perceived)) + { + continue; + } + + if (sq < bestPreySq || (sq == bestPreySq && e.Id < preyId)) + { + bestPreySq = sq; + preyId = e.Id; + preyPos = perceived; + } + } + + // Предпочитаем падаль, если она не намного дальше живой добычи (даровая еда, без риска). + if (corpseId >= 0 && (preyId < 0 || bestCorpseSq <= bestPreySq * 2f)) + { + targetId = corpseId; + position = corpsePos; + isCorpse = true; + return true; + } + + if (preyId >= 0) + { + targetId = preyId; + position = preyPos; + isCorpse = false; + return true; + } + + return false; + } + + // Подходит ли особь в добычу охотнику данного размера: важно лишь, что она не крупнее охотника + // (на превосходящих не нападаем). Вид/мясоедство добычи не важны — хищник ест и травоядных, и прочих. + private static bool IsPreyFor(float hunterBody, float otherBody) => + otherBody <= hunterBody * 1.25f; + + // Ближайшее растение в радиусе (квадрат расстояния); ничьи — по меньшему id (детерминизм). + // Порог интеллекта, с которого травоядное РАЗБИРАЕТ корм (избегает ядовитого/невкусного). Ниже — + // рефлекторное выедание ближайшего растения (тир мозга 4, как сон/мысли; больной зверь с упавшим + // сознанием падает ниже и снова жрёт что попало — последствие гейтинга A7). + private const float ForageSmartMinBrain = 0.4f; + + // Вес, с которым воспринимаемый яд (с поправкой на устойчивость) отталкивает от растения, и штраф за + // дальность — чтобы умный зверь предпочитал близкий и безопасный корм, а не бежал через всю карту. + private const float ToxinAvoidWeight = 1.2f; + private const float ForageDistanceWeight = 0.5f; + + // Выбор корма: умный (избегает яда/невкусного) если интеллект ≥ порога, иначе рефлекс — ближайшее. + private bool TryFindForage( + Vector2 from, + float radius, + float intelligence, + float toxinTolerance, + out int plantId, + out Vector2 position + ) => + intelligence >= ForageSmartMinBrain + ? TryFindBestPlant(from, radius, toxinTolerance, out plantId, out position) + : TryFindPlant(from, radius, out plantId, out position); + + // Перестраивает индекс животных из текущих позиций (раз в кадр — звери двигаются). СРАЗУ считает + // признаки, нужные ИИ (мясоед/взрослость и пр.), чтобы запросы их не пересчитывали. + private void RebuildAnimalGrid() + { + _animalGrid.Clear(); + foreach (var (_, _, organisms, growths, transforms, entities) in _animals.Chunks) + { + var o = organisms.Span; + var g = growths.Span; + var t = transforms.Span; + for (var i = 0; i < t.Length; i++) + { + var sp = _animalSet[o[i].Species]; + _animalGrid.Add( + new NeighborEntry( + entities.EntityAt(i).Id, + t[i].Position, + o[i].Species, + o[i].Traits.BodySize, + o[i].EatsMeat, // диета кэширована при рождении + o[i].IsMale, + AnimalFactory.IsAdult(sp, g[i].Stage), + o[i].Traits.Camouflage, + o[i].Traits.Scent, + o[i].Traits.Noise + ) + ); + } + } + } + + // Перестраивает индексы растений и трупов (по каденции — они статичны). Устаревшая запись об удалённой + // сущности безопасна: потребитель (Graze/Hunt/TryFind*) сверяется со store и пропускает исчезнувшую. + private void RebuildStaticGrids() + { + _plantGrid.Clear(); + foreach (var (transforms, _, organisms, entities) in _plants.Chunks) + { + var t = transforms.Span; + var o = organisms.Span; + for (var i = 0; i < t.Length; i++) + { + ref readonly var tr = ref o[i].Traits; + _plantGrid.Add( + new ForageEntry( + entities.EntityAt(i).Id, + t[i].Position, + tr.Toxicity, + tr.Palatability + ) + ); + } + } + + _corpseGrid.Clear(); + foreach (var (transforms, corpses, entities) in _corpses.Chunks) + { + var t = transforms.Span; + var c = corpses.Span; + for (var i = 0; i < t.Length; i++) + { + _corpseGrid.Add(new CorpseEntry(entities.EntityAt(i).Id, t[i].Position, c[i].Meat)); + } + } + } + + // Рефлекс: ближайшее растение в радиусе (ничья — меньший id, детерминизм). Кандидаты — из сетки. + private bool TryFindPlant(Vector2 from, float radius, out int plantId, out Vector2 position) + { + var bestSq = radius * radius; + plantId = -1; + position = default; + _plantGrid.Collect(from, radius, _plantBuf); + foreach (var e in _plantBuf) + { + var sq = Vector2.DistanceSquared(from, e.Pos); + if (sq < bestSq || (sq == bestSq && e.Id < plantId)) + { + bestSq = sq; + plantId = e.Id; + position = e.Pos; + } + } + + return plantId >= 0; + } + + // Умный выбор: среди растений в радиусе максимизируем привлекательность = вкусность − + // воспринимаемый_яд − штраф_за_дальность. Воспринимаемый яд = toxicity × (1 − устойчивость): чем + // выше устойчивость зверя, тем меньше его отпугивает токсичный корм. Если всё в округе невкусно/ + // ядовито, голод всё равно заставит выбрать наименее плохой (берётся максимум, даже отрицательный). + private bool TryFindBestPlant( + Vector2 from, + float radius, + float toxinTolerance, + out int plantId, + out Vector2 position + ) + { + var radiusSq = radius * radius; + var tol = Math.Clamp(toxinTolerance, 0f, 1f); + var bestScore = float.NegativeInfinity; + plantId = -1; + position = default; + _plantGrid.Collect(from, radius, _plantBuf); + foreach (var e in _plantBuf) + { + var sq = Vector2.DistanceSquared(from, e.Pos); + if (sq > radiusSq) + { + continue; + } + + var perceivedToxin = e.Toxicity * (1f - tol); + var score = + e.Palatability + - ToxinAvoidWeight * perceivedToxin + - ForageDistanceWeight * (radius > 0f ? MathF.Sqrt(sq) / radius : 0f); + if (score > bestScore || (score == bestScore && e.Id < plantId)) + { + bestScore = score; + plantId = e.Id; + position = e.Pos; + } + } + + return plantId >= 0; + } + + // Ближайшая кромка воды (без лимита по зрению — звери «помнят» водопои; упрощение фазы A2). Берег + // статичен, поэтому ближайшая береговая точка предпосчитана по клеткам (поле _shoreByCell, BFS на + // старте) — поиск за O(1) вместо линейного скана всей береговой линии на каждое решение «пить». + private bool TryFindShore(Vector2 from, out Vector2 position) + { + position = default; + if (_shoreByCell.Length == 0) + { + return false; // в мире нет воды/берега — пить негде + } + + var cx = Math.Clamp((int)(from.X / _cellSize), 0, _worldWidth - 1); + var cy = Math.Clamp((int)(from.Y / _cellSize), 0, _worldHeight - 1); + position = _shoreByCell[cy * _worldWidth + cx]; + return true; + } + + // Ближайший подходящий партнёр в радиусе зрения: ТОГО ЖЕ вида, взрослый, противоположного пола. + private bool TryFindMate( + Vector2 from, + bool selfMale, + int selfSpecies, + float radius, + out int mateId, + out Vector2 position + ) + { + var bestSq = radius * radius; + mateId = -1; + position = default; + // _animalBuf уже собран в DecideIn на макс. радиусе чувств — фильтруем по радиусу спаривания. + foreach (var e in _animalBuf) + { + if (e.Species != selfSpecies || e.IsMale == selfMale || !e.IsAdult) + { + continue; // другой вид / тот же пол / не взрослый (self отсеивается по полу) + } + + // Влечение партнёра не проверяется (учёт придёт с половым отбором). self отсекается совпадением + // пола и sq≈0; межвидовые пары исключены (раньше волк/курица могли «спариться»). + var sq = Vector2.DistanceSquared(from, e.Pos); + if (sq > 0.01f && (sq < bestSq || (sq == bestSq && e.Id < mateId))) + { + bestSq = sq; + mateId = e.Id; + position = e.Pos; + } + } + + return mateId >= 0; + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalFacingSystem.cs b/src/LittleSim/Sim/Animals/AnimalFacingSystem.cs new file mode 100644 index 0000000..ddb21f6 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalFacingSystem.cs @@ -0,0 +1,71 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Разворот спрайта по направлению движения (как в RimWorld): зверь смотрит туда, куда идёт. Восток — +/// базовая текстура, запад — её зеркало (флип по X), север/юг — отдельные текстуры (если есть у вида). +/// Направление берётся из цели «мозга» () относительно позиции; стоящий +/// зверь сохраняет прежний разворот. При смене региона пересчитывает центр-origin и масштаб (текстуры +/// направлений могут отличаться шириной). Презентация (читает sim, пишет только Sprite/Transform-scale). +/// +public sealed class AnimalFacingSystem(AnimalSet animals, int cellSize) + : QuerySystem +{ + private const float MinMove = 0.5f; // короче — считаем стоящим, разворот не трогаем + + protected override void OnUpdate() + { + foreach (var (brains, organisms, growths, transforms, sprites, _) in Query.Chunks) + { + var b = brains.Span; + var o = organisms.Span; + var g = growths.Span; + var t = transforms.Span; + var s = sprites.Span; + for (var i = 0; i < b.Length; i++) + { + var dir = b[i].Target - t[i].Position; + if (dir.LengthSquared() < MinMove * MinMove) + { + continue; // стоит/у цели — сохраняем текущий разворот + } + + var sp = animals[o[i].Species]; + var dirs = sp.LookDirs(g[i].Stage, o[i].IsMale); + Texture2DRegion region; + SpriteFlip flip; + if (MathF.Abs(dir.X) >= MathF.Abs(dir.Y)) + { + region = dirs.East; + flip = dir.X < 0f ? SpriteFlip.X : SpriteFlip.None; // запад = зеркало востока + } + else + { + region = dir.Y < 0f ? dirs.North : dirs.South; // экранный Y вниз: вверх = север + flip = SpriteFlip.None; + } + + ref var sprite = ref s[i]; + if (sprite.Region == region && sprite.Flip == flip) + { + continue; // разворот не изменился + } + + sprite.Region = region; + sprite.Origin = new Vector2(region.Width / 2f, region.Height / 2f); + sprite.Flip = flip; + t[i].Scale = new Vector2( + AnimalFactory.Scale(sp, o[i].Traits, g[i].Stage, region, cellSize) + ); + } + } + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalGrowth.cs b/src/LittleSim/Sim/Animals/AnimalGrowth.cs new file mode 100644 index 0000000..b1ae378 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalGrowth.cs @@ -0,0 +1,18 @@ +using Friflo.Engine.ECS; + +namespace LittleSim.Sim; + +/// +/// Состояние роста животного (фаза A3): текущая стадия и накопленный возраст в игровых днях. Плоские +/// данные — переход между стадиями (Baby→Juvenile→Adult→Senior), смену спрайта и размера выполняет +/// , читая пороги из генов (возраст созревания, продолжительность жизни). +/// Зеркало . +/// +public struct AnimalGrowth : IComponent +{ + /// Текущая стадия: 0 Baby, 1 Juvenile, 2 Adult, 3 Senior. + public int Stage; + + /// Накопленный возраст в игровых днях. + public float AgeDays; +} diff --git a/src/LittleSim/Sim/Animals/AnimalGrowthSystem.cs b/src/LittleSim/Sim/Animals/AnimalGrowthSystem.cs new file mode 100644 index 0000000..6152380 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalGrowthSystem.cs @@ -0,0 +1,67 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Рост и стадии (фаза A3): копит возраст в игровых днях, переключает стадию (Baby→Juvenile→Adult→ +/// Senior) по порогам из генов и при смене стадии меняет спрайт (детёныш/самка/самец) и размер. +/// +public sealed class AnimalGrowthSystem( + GameClock clock, + float secondsPerDay, + AnimalSet animals, + int cellSize, + WorldEvents events +) : QuerySystem +{ + protected override void OnUpdate() + { + var days = clock.DeltaTime / secondsPerDay; + if (days <= 0f) + { + return; + } + + foreach (var (growths, organisms, transforms, sprites, entities) in Query.Chunks) + { + var g = growths.Span; + var o = organisms.Span; + var t = transforms.Span; + var s = sprites.Span; + for (var i = 0; i < g.Length; i++) + { + ref var grow = ref g[i]; + grow.AgeDays += days; + ref readonly var org = ref o[i]; + var sp = animals[org.Species]; + var stage = AnimalFactory.StageAt( + grow.AgeDays, + org.Traits.MaturityAge, + org.Traits.Lifespan, + sp.Stages + ); + if (stage == grow.Stage) + { + continue; + } + + grow.Stage = stage; + var region = AnimalFactory.RegionFor(sp, stage, org.IsMale); + ref var sprite = ref s[i]; + sprite.Region = region; + sprite.Origin = new Vector2(region.Width / 2f, region.Height / 2f); + t[i].Scale = new Vector2( + AnimalFactory.Scale(sp, org.Traits, stage, region, cellSize) + ); + events.Grew(entities.EntityAt(i), org.Species, sp.Stages[stage].Name); // в хронику особи + } + } + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalHealthSystem.cs b/src/LittleSim/Sim/Animals/AnimalHealthSystem.cs new file mode 100644 index 0000000..77e4084 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalHealthSystem.cs @@ -0,0 +1,219 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Здоровье и болезни (фаза A6): медленный тик (~игровой час). Фоновое заражение, прогресс болезней +/// (гонка тяжесть↔иммунитет — выздоровление или смерть), пересчёт способностей с учётом хедифов. Смерть +/// по здоровью (летальная болезнь / кровь на нуле / потеря сознания) оставляет труп. Первый реальный +/// источник урона — оживляет каркас A5 (больные звери теряют Moving → медленнее). +/// +public sealed class AnimalHealthSystem : BaseSystem +{ + private const float TickDays = 1f / 24f; // ~игровой час + private const float BloodRecoveryPerDay = 0.25f; // восстановление крови в покое (нет кровотечений) + + private readonly EntityStore _store; + private readonly AnimalSet _animals; + private readonly WorldEvents _events; + private readonly int _cellSize; + private readonly GameClock _clock; + private readonly float _secondsPerDay; + private readonly Climate _climate; + private readonly HediffDef[] _ambient; + private readonly HediffDef? _hypothermia; + private readonly HediffDef? _heatstroke; + private readonly HediffDef? _infection; + private readonly Random _rng; + private readonly ArchetypeQuery _query; + private readonly List _deaths = []; + private float _accum; + + // Сенесценс (старение): спад способностей начинается с этой доли продолжительности жизни и достигает + // максимума к её концу. Дряхлеющий зверь слабее (медленнее, хуже видит/фильтрует кровь) и уязвимее. + private const float SenescenceStart = 0.6f; + private const float SenescenceMax = 0.45f; + + // Термокомфорт (°C) при теплоизоляции 0.5 и размере тела 1; границы сдвигаются мехом и размером тела. + private const float ColdComfort = 2f; // ниже — мёрзнет + private const float HeatComfort = 30f; // выше — перегревается + private const float ColdInsulationBonus = 30f; // мех (insulation 0..1) расширяет холодоустойчивость + private const float HeatInsulationPenalty = 14f; // тот же мех ухудшает жароустойчивость + private const float ColdBodyBonus = 8f; // крупное тело (тепловая инерция) держит холод лучше + private const float ThermalRatePerDegree = 0.06f; // прирост тяжести за °C за пределом комфорта в день + + // Заражение раны: шанс в день при кровотечении тяжести 1 подхватить инфекцию. + private const float InfectionChancePerDay = 0.25f; + + public AnimalHealthSystem( + EntityStore store, + AnimalSet animals, + WorldEvents events, + int cellSize, + GameClock clock, + float secondsPerDay, + Climate climate, + HediffDef[] ambient, + HediffDef? hypothermia, + HediffDef? heatstroke, + HediffDef? infection, + int seed + ) + { + _store = store; + _animals = animals; + _events = events; + _cellSize = cellSize; + _clock = clock; + _secondsPerDay = secondsPerDay; + _climate = climate; + _ambient = ambient; + _hypothermia = hypothermia; + _heatstroke = heatstroke; + _infection = infection; + _rng = new Random(seed); + _query = store.Query(); + } + + /// Множитель способностей от старения (1 — молод/расцвет, <1 — дряхлеет к концу жизни). + public static float SenescenceFactor(float ageDays, float lifespan) + { + if (lifespan <= 0f) + { + return 1f; + } + + var frac = (ageDays / lifespan - SenescenceStart) / MathF.Max(0.01f, 1f - SenescenceStart); + return 1f - SenescenceMax * Math.Clamp(frac, 0f, 1f); + } + + protected override void OnUpdateGroup() + { + _accum += _clock.DeltaTime / _secondsPerDay; + if (_accum < TickDays) + { + return; + } + + var days = _accum; + _accum = 0f; + _deaths.Clear(); + + foreach (var (healths, organisms, growths, entities) in _query.Chunks) + { + var h = healths.Span; + var o = organisms.Span; + var g = growths.Span; + for (var i = 0; i < h.Length; i++) + { + var state = h[i].State; + if (state is null) + { + continue; + } + + foreach (var disease in _ambient) + { + if ( + !state.Has(disease.DefName) + && _rng.NextSingle() < disease.AmbientPerDay * days + ) + { + state.Add(disease); + } + } + + // Терморегуляция: вне комфорта (зависит от GeneInsulation + размера тела) копится тяжесть + // гипотермии/теплового удара; в комфорте immunityPerDay их рассасывает. Мех расширяет + // холодоустойчивость, но ухудшает жароустойчивость; крупное тело держит холод. + var insul = Math.Clamp(o[i].Traits.Insulation, 0f, 1f); + var coldLimit = + ColdComfort + - ColdInsulationBonus * insul + - ColdBodyBonus * MathF.Max(0f, o[i].Traits.BodySize - 1f); + var heatLimit = HeatComfort - HeatInsulationPenalty * insul; + var temp = _climate.Temperature; + if (temp < coldLimit && _hypothermia is not null) + { + state.Intensify(_hypothermia, (coldLimit - temp) * ThermalRatePerDegree * days); + } + else if (temp > heatLimit && _heatstroke is not null) + { + state.Intensify(_heatstroke, (temp - heatLimit) * ThermalRatePerDegree * days); + } + + // Заражение раны: пока есть кровотечение, шанс (∝ его тяжести) подхватить инфекцию. + if (_infection is not null && !state.Has(_infection.DefName)) + { + var bleedSeverity = 0f; + foreach (var hd in state.Hediffs) + { + if (hd.Def.BloodLossPerDay > 0f && hd.Severity > bleedSeverity) + { + bleedSeverity = hd.Severity; + } + } + + if ( + bleedSeverity > 0f + && _rng.NextSingle() < InfectionChancePerDay * bleedSeverity * days + ) + { + state.Add(_infection); + } + } + + // Фильтрация крови (почки/печень) ускоряет вывод токсинов и набор иммунитета: повреждённый + // орган → медленнее выздоровление от яда/болезни/инфекции (для тела без неё Capacity вернёт 1). + var lethal = state.AdvanceHediffs( + days, + state.Capacity(AnimalCapacities.BloodFiltration) + ); + // Кровопотеря от ран (кровотечения), иначе — постепенное восстановление крови в покое. + var bleed = 0f; + foreach (var hd in state.Hediffs) + { + bleed += hd.Def.BloodLossPerDay * hd.Severity; + } + + if (bleed > 0f) + { + state.BloodLevel = Math.Clamp(state.BloodLevel - bleed * days, 0f, 1f); + } + else if (state.BloodLevel < 1f) + { + state.BloodLevel = Math.Clamp( + state.BloodLevel + BloodRecoveryPerDay * days, + 0f, + 1f + ); + } + + // Старение: к концу жизни способности дряхлеют (медленнее/слабее, уязвимее к ранам/болезни). + var senescence = SenescenceFactor(g[i].AgeDays, o[i].Traits.Lifespan); + state.RecomputeCapacities(senescence); + if ( + lethal + || state.BloodLevel <= 0f + || state.Capacity(AnimalCapacities.Consciousness) <= 0.01f + ) + { + _deaths.Add(entities.EntityAt(i)); + _events.Died(o[i].Species, "event.death.disease"); + } + } + } + + foreach (var dead in _deaths) + { + AnimalFactory.Die(_store, _animals, dead, _cellSize); + } + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalMemorySystem.cs b/src/LittleSim/Sim/Animals/AnimalMemorySystem.cs new file mode 100644 index 0000000..fa59766 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalMemorySystem.cs @@ -0,0 +1,57 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Забывание (память): уверенность каждой записи падает со временем, состарившиеся стираются. Свежие +/// наблюдения записывает/освежает ; эта система только «выветривает» +/// память, давая ей конечный срок. +/// +public sealed class AnimalMemorySystem(GameClock clock, float secondsPerDay) : QuerySystem +{ + private const float ForgetPerDay = 0.5f; // уверенность падает (память живёт ~2 дня без подтверждения) + + protected override void OnUpdate() + { + var days = clock.DeltaTime / secondsPerDay; + if (days <= 0f) + { + return; + } + + foreach (var (memories, _) in Query.Chunks) + { + var m = memories.Span; + for (var i = 0; i < m.Length; i++) + { + var entries = m[i].Entries; + if (entries is null) + { + continue; + } + + for (var k = entries.Count - 1; k >= 0; k--) + { + var e = entries[k]; + e.Confidence -= ForgetPerDay * days; + e.AgeDays += days; + if (e.Confidence <= 0f) + { + entries.RemoveAt(k); + } + else + { + entries[k] = e; + } + } + } + } + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalMoodSystem.cs b/src/LittleSim/Sim/Animals/AnimalMoodSystem.cs new file mode 100644 index 0000000..4befaa1 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalMoodSystem.cs @@ -0,0 +1,107 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Настроение (фаза A8): для особей тира 4+ (мозг ≥ порога) считает настроение из непрерывного фона +/// (удовлетворённость убывающих нужд минус боль из здоровья) и суммы затухающих событийных мыслей. +/// Мысли угасают со временем. У примитивных (тир < 4) настроения нет (Active=false, мысли сбрасываются). +/// Гейт интеллектом — тем же порогом тира 4, что и высшие нужды (сон/секс). Низкое настроение влияет на +/// поведение (стресс не даёт спариваться — см. ) и видно в консоли/инспекторе. +/// +public sealed class AnimalMoodSystem(GameClock clock, float secondsPerDay, NeedSet needs) + : QuerySystem +{ + /// Порог размера мозга (тир 4), с которого у особи появляется настроение. + public const float MoodMinBrain = 0.40f; + private const float NeutralBase = 0.35f; // настроение при полном истощении нужд (без боли/мыслей) + private const float NeedsWeight = 0.55f; // вклад средней удовлетворённости убывающих нужд + private const float PainWeight = 0.4f; // боль из здоровья тянет настроение вниз + + protected override void OnUpdate() + { + var days = clock.DeltaTime / secondsPerDay; + if (days <= 0f) + { + return; + } + + foreach (var (moods, needsChunk, organisms, healths, _) in Query.Chunks) + { + var m = moods.Span; + var n = needsChunk.Span; + var o = organisms.Span; + var h = healths.Span; + for (var i = 0; i < m.Length; i++) + { + ref var mood = ref m[i]; + if (o[i].Traits.BrainSize < MoodMinBrain) + { + mood.Active = false; // тир ниже зверя — настроения нет + mood.Value = 0.5f; + mood.Thoughts?.Clear(); + continue; + } + + mood.Active = true; + // 1) Затухание событийных мыслей + сумма их сдвигов. + var thoughtSum = 0f; + var list = mood.Thoughts; + if (list is not null) + { + for (var k = list.Count - 1; k >= 0; k--) + { + var t = list[k]; + t.RemainingDays -= days; + if (t.RemainingDays <= 0f) + { + list.RemoveAt(k); + continue; + } + + list[k] = t; + thoughtSum += t.Def.MoodOffset; + } + } + + // 2) Непрерывный фон: средняя удовлетворённость убывающих нужд минус боль из здоровья. + var wellbeing = AvgDeplete(n[i].Values); + var pain = h[i].State?.Pain ?? 0f; + mood.Value = Math.Clamp( + NeutralBase + NeedsWeight * wellbeing - PainWeight * pain + thoughtSum, + 0f, + 1f + ); + } + } + } + + // Средняя удовлетворённость убывающих нужд (голод/жажда/усталость) [0,1]; нет нужд → 1 (нейтрально). + private float AvgDeplete(float[] values) + { + if (values is null) + { + return 1f; + } + + var sum = 0f; + var count = 0; + for (var k = 0; k < needs.Count && k < values.Length; k++) + { + if (needs.IsDeplete(k)) + { + sum += values[k]; + count++; + } + } + + return count > 0 ? sum / count : 1f; + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalMortalitySystem.cs b/src/LittleSim/Sim/Animals/AnimalMortalitySystem.cs new file mode 100644 index 0000000..1a942e2 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalMortalitySystem.cs @@ -0,0 +1,67 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Смертность (фаза A3/A5): гибель от старости (возраст превысил продолжительность жизни) или истощения +/// (счётчик голода/жажды превысил порог). Замыкает контур ёмкости среды. Фаза A5: вместо деспавна +/// оставляет (через ), затем удаляет особь. +/// +public sealed class AnimalMortalitySystem : BaseSystem +{ + private const float LethalStarveDays = 3f; + + private readonly EntityStore _store; + private readonly AnimalSet _animals; + private readonly WorldEvents _events; + private readonly int _cellSize; + private readonly ArchetypeQuery _query; + private readonly List _deaths = []; + + public AnimalMortalitySystem( + EntityStore store, + AnimalSet animals, + WorldEvents events, + int cellSize + ) + { + _store = store; + _animals = animals; + _events = events; + _cellSize = cellSize; + _query = store.Query(); + } + + protected override void OnUpdateGroup() + { + _deaths.Clear(); + foreach (var (growths, organisms, needsChunk, entities) in _query.Chunks) + { + var g = growths.Span; + var o = organisms.Span; + var n = needsChunk.Span; + for (var i = 0; i < g.Length; i++) + { + var ageDeath = g[i].AgeDays > o[i].Traits.Lifespan; + var starveDeath = n[i].Starve >= LethalStarveDays; + if (ageDeath || starveDeath) + { + _deaths.Add(entities.EntityAt(i)); + _events.Died(o[i].Species, ageDeath ? "event.death.age" : "event.death.starve"); + } + } + } + + foreach (var dead in _deaths) + { + AnimalFactory.Die(_store, _animals, dead, _cellSize); + } + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalNeeds.cs b/src/LittleSim/Sim/Animals/AnimalNeeds.cs new file mode 100644 index 0000000..2d69da3 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalNeeds.cs @@ -0,0 +1,24 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Нужды зверя как ДАННЫЕ (рефактор расширяемости): значения по индексам из +/// (managed-массив — добавление нужды не меняет структуру) + счётчик истощения. Падают/растут в +/// по , восполняются исполнением действия. +/// +public struct AnimalNeeds : IComponent +{ + /// Значения нужд [0,1] по индексам . + public float[] Values; + + /// Накоплено игровых дней истощения (летальная нужда на нуле); порог — смерть (A3). + public float Starve; +} diff --git a/src/LittleSim/Sim/Animals/AnimalNeedsSystem.cs b/src/LittleSim/Sim/Animals/AnimalNeedsSystem.cs new file mode 100644 index 0000000..b99a962 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalNeedsSystem.cs @@ -0,0 +1,91 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Падение/рост нужд со временем (игровые дни через ): deplete-нужды убывают +/// (× ген обмена веществ), drive-нужды растут под своим хедифом (гон) и спадают вне его. Летальные +/// нужды на нуле копят счётчик истощения. Полностью управляется данными . +/// +public sealed class AnimalNeedsSystem( + GameClock clock, + float secondsPerDay, + NeedSet needs, + AnimalSet animals +) : QuerySystem +{ + // Прибавка к обмену веществ при устойчивости к яду 1.0 (цена детоксикации, коэволюция C2). + private const float ToleranceMetabolicCost = 0.5f; + + protected override void OnUpdate() + { + var days = clock.DeltaTime / secondsPerDay; + if (days <= 0f) + { + return; + } + + foreach (var (needsChunk, organisms, healths, growths, _) in Query.Chunks) + { + var n = needsChunk.Span; + var o = organisms.Span; + var h = healths.Span; + var g = growths.Span; + for (var i = 0; i < n.Length; i++) + { + var values = n[i].Values; + if (values is null || values.Length < needs.Count) + { + continue; // защита от старого/пустого состояния + } + + // Обмен веществ = ген × цена устойчивости к яду (C2) × множитель Клайбера по массе тела: + // расход ∝ масса^0.75 = (линейный размер/эталон)^2.25 (масса ∝ размер³), нормирован на + // эталон вида → молодняк экономнее, крупные прожорливее. Эталон предпосчитан (без словаря). + var sp = animals[o[i].Species]; + var stageScale = sp.Stages[Math.Clamp(g[i].Stage, 0, sp.Stages.Length - 1)].Scale; + var sizeRatio = MathF.Max( + 0.01f, + o[i].Traits.BodySize * stageScale / sp.RefBodySize + ); + var metabolism = + MathF.Max(0.1f, o[i].Traits.Metabolism) + * (1f + ToleranceMetabolicCost * Math.Clamp(o[i].Traits.ToxinTolerance, 0f, 1f)) + * Mass.KleiberFactorFromSizeRatio(sizeRatio); + var lethalEmpty = false; + for (var k = 0; k < needs.Count; k++) + { + var def = needs[k]; + if (needs.IsDrive(k)) + { + var rising = def.RisesUnder is null || h[i].State.Has(def.RisesUnder); + var delta = (rising ? def.RisePerDay : -def.DecayPerDay) * days; + values[k] = Math.Clamp(values[k] + delta, 0f, 1f); + } + else + { + values[k] = Math.Clamp( + values[k] - def.DecayPerDay * metabolism * days, + 0f, + 1f + ); + if (def.Lethal && values[k] <= 0f) + { + lethalEmpty = true; + } + } + } + + ref var starve = ref n[i].Starve; + starve = lethalEmpty ? starve + days : MathF.Max(0f, starve - days * 2f); + } + } + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalOrganism.cs b/src/LittleSim/Sim/Animals/AnimalOrganism.cs new file mode 100644 index 0000000..79ff202 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalOrganism.cs @@ -0,0 +1,34 @@ +using Friflo.Engine.ECS; +using MrGameEng.Genetics; + +namespace LittleSim.Sim; + +/// +/// Компонент-организм животного: управляемый (для размножения, сейва и +/// пересчёта), выраженный фенотип , индекс вида в +/// и номер поколения (для popstats и видимого дрейфа). Геном — ссылочный объект, поэтому +/// компонент несёт ссылку, а не копию (как у растений). Зеркало . +/// +public struct AnimalOrganism : IComponent +{ + /// Геном особи (аллели по генам); источник истины для признаков и размножения. + public Genome Genome; + + /// Выраженные признаки — то, что читают системы. + public AnimalPhenotype Traits; + + /// Индекс вида в . + public int Species; + + /// Пол: самец (XY) или самка (XX) — выведен из локуса GeneSex при создании. + public bool IsMale; + + /// Ест ли растения (диета по генам/дефу, неизменна после рождения — кэш вместо пересчёта на тик). + public bool EatsPlants; + + /// Ест ли мясо (диета по генам/дефу, неизменна после рождения — кэш вместо пересчёта на тик). + public bool EatsMeat; + + /// Номер поколения: max(родителей) + 1; у основателей 0. + public int Generation; +} diff --git a/src/LittleSim/Sim/Animals/AnimalPhenotype.cs b/src/LittleSim/Sim/Animals/AnimalPhenotype.cs new file mode 100644 index 0000000..b993325 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalPhenotype.cs @@ -0,0 +1,134 @@ +using System.Collections.Generic; +using MrGameEng.Genetics; + +namespace LittleSim.Sim; + +/// +/// Признаки (фенотип) животного, вычисленные из генома формулами генов (см. Defs/Genes/): +/// готовые значения, которые читают системы, не касаясь генов напрямую. Зеркало +/// . Имена полей соответствуют именам признаков в эффектах генов. +/// +public struct AnimalPhenotype +{ + /// Размер тела в клетках (масштаб спрайта; позже — HP/расход энергии). + public float BodySize; + + /// Скорость обмена веществ — множитель расхода нужд. + public float Metabolism; + + /// Скорость передвижения (множитель). + public float MoveSpeed; + + /// Объём крови (множитель) — масштаб скорости кровопотери. + public float BloodVolume; + + /// Дальность зрения (множитель радиуса поиска). + public float Vision; + + /// Острота слуха (множитель радиуса). + public float Hearing; + + /// Острота обоняния (множитель радиуса). + public float Smell; + + /// Острота осязания (множитель радиуса; чувство ближнего контакта). + public float Touch; + + /// Камуфляж [0..1] — снижает обнаружение этой особи зрением (заметность = 1−camouflage). + public float Camouflage; + + /// Запах [0..1] — повышает обнаружение этой особи обонянием. + public float Scent; + + /// Шумность [0..1] — повышает обнаружение этой особи слухом при движении. + public float Noise; + + /// Размер мозга (0..1) — основа интеллекта. + public float BrainSize; + + /// Теплоизоляция мехом (0..1). + public float Insulation; + + /// Оттенок меха (0..1) — сдвиг тинта спрайта. + public float FurHue; + + /// Возраст созревания (игровых дней) — порог стадии Adult. + public float MaturityAge; + + /// Травоядность [0..1] — способность/тяга питаться растениями. + public float Herbivory; + + /// Хищничество [0..1] — тяга охотиться и питаться мясом (живая добыча/падаль). + public float Carnivory; + + /// Всеядность [0..1] — генералист: включает оба источника пищи (растения и мясо). + public float Omnivory; + + /// + /// Стиль питания [0..1]: 0 — «пастьба» (graze), часто подтапливает сытость мелкими приёмами; + /// 1 — «обжорство» (gorge), ищет еду лишь при сильном голоде и ест до полной сытости (редкие + /// крупные приёмы, как у хищников). Задаёт пороги начала/конца кормёжки в . + /// + public float FeedingStyle; + + /// Устойчивость к растительным ядам [0..1] — снижает дозу отравления токсичным кормом. + public float ToxinTolerance; + + /// Социальность [0..1] — тяга держаться стаи/стада (когезия + спокойствие в группе). + public float Sociability; + + /// Продолжительность жизни (игровых дней). + public float Lifespan; + + /// Сезон гона (0 весна … 3 зима). + public int BreedingSeason; + + /// Срок вынашивания (игровых дней). + public float GestationDays; + + /// Размер помёта. + public float LitterSize; + + /// Тип рождения [0..1]: ≥0.5 — яйцекладущий (откладывает яйца), иначе живородящий. + public float EggLaying; + + /// Яйцекладущий ли вид (тип рождения) — определяет цикл зачатия. + public readonly bool Oviparous => EggLaying >= 0.5f; + + /// Собирает фенотип из карты признаков, посчитанной . + public static AnimalPhenotype FromTraits(IReadOnlyDictionary traits) + { + float T(string name) => traits.GetValueOrDefault(name); + // Острота чувств по умолчанию 1 (вид без гена не «слепнет»); заметность — мягкие дефолты. + float Acuity(string name) => traits.TryGetValue(name, out var v) ? v : 1f; + return new AnimalPhenotype + { + BodySize = T("bodySize"), + Metabolism = T("metabolism"), + MoveSpeed = T("moveSpeed"), + BloodVolume = T("bloodVolume"), + Vision = T("vision"), + Hearing = Acuity("hearing"), + Smell = Acuity("smell"), + Touch = Acuity("touch"), + Camouflage = T("camouflage"), + Scent = traits.GetValueOrDefault("scent", 0.5f), + Noise = traits.GetValueOrDefault("noise", 0.5f), + BrainSize = T("brainSize"), + Insulation = T("insulation"), + FurHue = T("furHue"), + MaturityAge = T("maturityAge"), + Herbivory = T("herbivory"), + Carnivory = T("carnivory"), + Omnivory = T("omnivory"), + FeedingStyle = traits.GetValueOrDefault("feedingStyle", 0.5f), + ToxinTolerance = T("toxinTolerance"), + Sociability = T("sociability"), + Lifespan = T("lifespan"), + BreedingSeason = (int)MathF.Round(Math.Clamp(T("breedingSeason"), 0f, 3f)), + GestationDays = T("gestationDays"), + LitterSize = T("litterSize"), + EggLaying = T("eggLaying"), + }; + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalPregnancySystem.cs b/src/LittleSim/Sim/Animals/AnimalPregnancySystem.cs new file mode 100644 index 0000000..ea1880a --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalPregnancySystem.cs @@ -0,0 +1,183 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Беременность и роды (фаза A4): тикает срок вынашивания; по истечении рожает помёт — каждый детёныш = +/// генома матери и отца (пол наследуется без YY), стадия Baby, поколение +/// матери + 1, рядом с матерью. Компонент снимается. Потолок численности +/// страхует от взрыва популяции. +/// +public sealed class AnimalPregnancySystem : BaseSystem +{ + private readonly EntityStore _store; + private readonly AnimalSet _animals; + private readonly NeedSet _needs; + private readonly SkillSet _skills; + private readonly ThoughtSet _thoughts; + private readonly WorldEvents _events; + private readonly GameClock _clock; + private readonly float _secondsPerDay; + private readonly int _cellSize; + private readonly int _maxAnimals; + private readonly Random _rng; + private readonly ArchetypeQuery _query; + private readonly ArchetypeQuery _census; + private readonly List _births = []; + + public AnimalPregnancySystem( + EntityStore store, + AnimalSet animals, + NeedSet needs, + SkillSet skills, + ThoughtSet thoughts, + WorldEvents events, + GameClock clock, + float secondsPerDay, + int cellSize, + int maxAnimals, + int seed + ) + { + _store = store; + _animals = animals; + _needs = needs; + _skills = skills; + _thoughts = thoughts; + _events = events; + _clock = clock; + _secondsPerDay = secondsPerDay; + _cellSize = cellSize; + _maxAnimals = maxAnimals; + _rng = new Random(seed); + _query = store.Query(); + _census = store.Query(); + } + + protected override void OnUpdateGroup() + { + var days = _clock.DeltaTime / _secondsPerDay; + if (days <= 0f) + { + return; + } + + _births.Clear(); + foreach (var (pregnancies, organisms, transforms, entities) in _query.Chunks) + { + var p = pregnancies.Span; + var o = organisms.Span; + var t = transforms.Span; + for (var i = 0; i < p.Length; i++) + { + ref var preg = ref p[i]; + preg.DueInDays -= days; + if (preg.DueInDays > 0f) + { + continue; + } + + _births.Add( + new Birth + { + Mother = entities.EntityAt(i), + Species = o[i].Species, + MotherGenome = o[i].Genome, + FatherGenome = preg.FatherGenome, + Litter = preg.Litter, + Generation = o[i].Generation + 1, + Position = t[i].Position, + Oviparous = o[i].Traits.Oviparous, + IncubateDays = MathF.Max(1f, o[i].Traits.GestationDays), + } + ); + } + } + + var count = _census.Count; + foreach (var birth in _births) + { + if (birth.Mother.HasComponent()) + { + birth.Mother.RemoveComponent(); + } + + // Роды — сильная положительная мысль матери (A8). + if (birth.Mother.HasComponent()) + { + birth.Mother.GetComponent().Add(_thoughts.Get(AnimalThoughts.GaveBirth)); + } + + var bornLive = 0; + for (var k = 0; k < birth.Litter && count < _maxAnimals; k++) + { + var child = Genome.Breed( + birth.MotherGenome, + birth.FatherGenome, + _animals.GeneRegistry, + _rng + ); + var offset = + new Vector2(_rng.NextSingle() - 0.5f, _rng.NextSingle() - 0.5f) * _cellSize; + if (birth.Oviparous) + { + // Яйцекладка: вместо живого детёныша откладываем яйцо с его геномом — вылупится позже. + AnimalFactory.CreateEgg( + _store, + _animals, + birth.Species, + birth.Position + offset, + child, + birth.Generation, + birth.IncubateDays, + _cellSize + ); + } + else + { + AnimalFactory.Create( + _store, + _animals, + _needs, + _skills, + birth.Species, + birth.Position + offset, + ageDays: 0f, + child, + birth.Generation, + _cellSize + ); + bornLive++; + } + + count++; + } + + // Живые роды — заметное событие (яйцекладка отметится позже при вылуплении в EggSystem). + if (bornLive > 0) + { + _events.Born(birth.Mother, birth.Species, bornLive); + } + } + } + + private struct Birth + { + public Entity Mother; + public int Species; + public Genome MotherGenome; + public Genome FatherGenome; + public int Litter; + public int Generation; + public Vector2 Position; + public bool Oviparous; + public float IncubateDays; + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalRutSystem.cs b/src/LittleSim/Sim/Animals/AnimalRutSystem.cs new file mode 100644 index 0000000..db56a41 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalRutSystem.cs @@ -0,0 +1,44 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Гон (фаза A4): взрослым в их брачный сезон навешивает хедиф Rut, вне сезона снимает. Рост влечения +/// под этим хедифом считает (нужда Mating с risesUnder=Rut) — +/// сезонность и влечение разнесены: эта система отвечает только за состояние. +/// +public sealed class AnimalRutSystem(AnimalSet animals, Climate climate, HediffDef rut) + : QuerySystem +{ + protected override void OnUpdate() + { + var season = (int)climate.Season; + foreach (var (growths, organisms, healths, _) in Query.Chunks) + { + var g = growths.Span; + var o = organisms.Span; + var h = healths.Span; + for (var i = 0; i < g.Length; i++) + { + var inRut = + AnimalFactory.IsAdult(animals[o[i].Species], g[i].Stage) + && season == o[i].Traits.BreedingSeason; + if (inRut) + { + h[i].State.Add(rut); + } + else + { + h[i].State.Remove(rut.DefName); + } + } + } + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalSkillSystem.cs b/src/LittleSim/Sim/Animals/AnimalSkillSystem.cs new file mode 100644 index 0000000..ceaebcc --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalSkillSystem.cs @@ -0,0 +1,49 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Угасание навыков без практики: каждый уровень медленно сползает к полу своего +/// (рост идёт от использования в ). Так навык, которым не пользуются, +/// постепенно забывается — есть давление на специализацию. +/// +public sealed class AnimalSkillSystem(GameClock clock, float secondsPerDay, SkillSet skills) + : QuerySystem +{ + protected override void OnUpdate() + { + var days = clock.DeltaTime / secondsPerDay; + if (days <= 0f) + { + return; + } + + foreach (var (skillChunk, _) in Query.Chunks) + { + var s = skillChunk.Span; + for (var i = 0; i < s.Length; i++) + { + var levels = s[i].Levels; + if (levels is null) + { + continue; + } + + for (var k = 0; k < skills.Count && k < levels.Length; k++) + { + levels[k] = MathF.Max( + skills[k].FloorLevel, + levels[k] - skills[k].DecayPerDay * days + ); + } + } + } + } +} diff --git a/src/LittleSim/Sim/Animals/AnimalSkills.cs b/src/LittleSim/Sim/Animals/AnimalSkills.cs new file mode 100644 index 0000000..760df2a --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalSkills.cs @@ -0,0 +1,14 @@ +namespace LittleSim.Sim; + +/// Id навыков-эффектов (на них ссылается код начисления XP и применения бонуса). +public static class AnimalSkills +{ + /// Добыча корма: эффективность выпаса/поедания растений. + public const string Foraging = "Foraging"; + + /// Охота: урон по добыче. + public const string Hunting = "Hunting"; + + /// Уклонение: скорость бегства от хищника. + public const string Evasion = "Evasion"; +} diff --git a/src/LittleSim/Sim/Animals/AnimalThoughts.cs b/src/LittleSim/Sim/Animals/AnimalThoughts.cs new file mode 100644 index 0000000..d450149 --- /dev/null +++ b/src/LittleSim/Sim/Animals/AnimalThoughts.cs @@ -0,0 +1,22 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Id событийных мыслей настроения (фаза A8). Системы-источники события вешают мысль с этим id (через +/// ); её сила/длительность/метка — данные (thoughts.json). Новая мысль на +/// новое событие = код-триггер + деф; правка чисел существующей — только JSON. +/// +public static class AnimalThoughts +{ + public const string GaveBirth = "GaveBirth"; + public const string Mated = "Mated"; + public const string QuenchedThirst = "QuenchedThirst"; +} diff --git a/src/LittleSim/Sim/Animals/Carrier.cs b/src/LittleSim/Sim/Animals/Carrier.cs new file mode 100644 index 0000000..52ee584 --- /dev/null +++ b/src/LittleSim/Sim/Animals/Carrier.cs @@ -0,0 +1,14 @@ +using Friflo.Engine.ECS; + +namespace LittleSim.Sim; + +/// +/// Ноша существа (задел под перенос предметов/хаул): суммарная масса переносимого груза в кг. Перегруз +/// сверх грузоподъёмности () замедляет движение. Компонент появляется +/// у существа, когда оно что-то несёт; пока источников груза нет — спящий каркас под будущий инвентарь. +/// +public struct Carrier : IComponent +{ + /// Суммарная масса переносимого груза (кг). + public float CarriedKg; +} diff --git a/src/LittleSim/Sim/Animals/Corpse.cs b/src/LittleSim/Sim/Animals/Corpse.cs new file mode 100644 index 0000000..0240c80 --- /dev/null +++ b/src/LittleSim/Sim/Animals/Corpse.cs @@ -0,0 +1,26 @@ +using Friflo.Engine.ECS; + +namespace LittleSim.Sim; + +/// +/// Труп животного (фаза A5): отдельная сущность после смерти. Разлагается во времени по стадиям +/// (Fresh→Rotting→Skeleton→исчезает), скорость зависит от температуры. Несёт запас мяса (∝ размер тела) +/// для будущей разделки/падальщиков. Управляется . +/// +public struct Corpse : IComponent +{ + /// Индекс вида в (для спрайта, разделки, сейва). + public int Species; + + /// Накоплено игровых дней разложения. + public float RotDays; + + /// Стадия: 0 свежий, 1 гниющий, 2 скелет. + public int Stage; + + /// Доступное мясо (∝ масса тела) — для разделки/падальщиков (фаза-горизонт). + public float Meat; + + /// Линейный размер тела (для масштаба спрайта трупа), независимо от запаса мяса. + public float BodySize; +} diff --git a/src/LittleSim/Sim/Animals/CorpseSystem.cs b/src/LittleSim/Sim/Animals/CorpseSystem.cs new file mode 100644 index 0000000..a69723b --- /dev/null +++ b/src/LittleSim/Sim/Animals/CorpseSystem.cs @@ -0,0 +1,88 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Разложение трупа (фаза A5): копит дни гниения (ускоряется в тепле, замедляется на морозе), +/// переключает стадии Fresh→Rotting→Skeleton по порогам и тинтит спрайт; по достижении предела труп +/// исчезает. Скелет рисуется бледным тинтом падали (отдельного спрайта пока нет — см. docs). +/// +public sealed class CorpseSystem : BaseSystem +{ + private const float RottingDay = 2f; + private const float SkeletonDay = 6f; + private const float GoneDay = 14f; + + private static readonly Color FreshTint = new(176, 148, 128); + private static readonly Color RottingTint = new(120, 118, 98); + private static readonly Color SkeletonTint = new(228, 226, 214); + + private readonly GameClock _clock; + private readonly float _secondsPerDay; + private readonly Climate _climate; + private readonly ArchetypeQuery _query; + private readonly List _gone = []; + + public CorpseSystem(EntityStore store, GameClock clock, float secondsPerDay, Climate climate) + { + _clock = clock; + _secondsPerDay = secondsPerDay; + _climate = climate; + _query = store.Query(); + } + + protected override void OnUpdateGroup() + { + var days = _clock.DeltaTime / _secondsPerDay; + if (days <= 0f) + { + return; + } + + // Тепло ускоряет гниение, около нуля и ниже — резко замедляет (заморозка). + var factor = Math.Clamp((_climate.Temperature + 5f) / 20f, 0.1f, 2.5f); + var advance = days * factor; + _gone.Clear(); + + foreach (var (corpses, sprites, entities) in _query.Chunks) + { + var c = corpses.Span; + var s = sprites.Span; + for (var i = 0; i < c.Length; i++) + { + ref var corpse = ref c[i]; + corpse.RotDays += advance; + if (corpse.RotDays >= GoneDay) + { + _gone.Add(entities.EntityAt(i)); + continue; + } + + var stage = + corpse.RotDays >= SkeletonDay ? 2 + : corpse.RotDays >= RottingDay ? 1 + : 0; + if (stage != corpse.Stage) + { + corpse.Stage = stage; + s[i].Color = + stage == 2 ? SkeletonTint + : stage == 1 ? RottingTint + : FreshTint; + } + } + } + + foreach (var entity in _gone) + { + entity.DeleteEntity(); + } + } +} diff --git a/src/LittleSim/Sim/Animals/CreatureLog.cs b/src/LittleSim/Sim/Animals/CreatureLog.cs new file mode 100644 index 0000000..5f14041 --- /dev/null +++ b/src/LittleSim/Sim/Animals/CreatureLog.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using Friflo.Engine.ECS; + +namespace LittleSim.Sim; + +/// +/// Личная хроника особи (фича UI «История»): ограниченный кольцевой список событий о ней (спаривание, +/// роды, охота, кормёжка, бегство, рост). Пишет , читает инспектор. Список — +/// managed-ссылка (как мысли/память); старые записи вытесняются по достижении . +/// +public struct CreatureLog : IComponent +{ + /// Записи в порядке добавления (новые в конце). + public List Entries; + + /// Сколько записей хранится (старейшие вытесняются). + public int Capacity; +} diff --git a/src/LittleSim/Sim/Animals/DamageFlash.cs b/src/LittleSim/Sim/Animals/DamageFlash.cs new file mode 100644 index 0000000..8337992 --- /dev/null +++ b/src/LittleSim/Sim/Animals/DamageFlash.cs @@ -0,0 +1,14 @@ +using Friflo.Engine.ECS; + +namespace LittleSim.Sim; + +/// +/// Кратковременная «вспышка урона» (презентация, как в RimWorld): при получении удара выставляется +/// время свечения; подкрашивает спрайт красным и гасит свечение. +/// Есть у всех животных (выставляется в 0 при создании), чтобы удар не делал структурного добавления. +/// +public struct DamageFlash : IComponent +{ + /// Остаток реального времени свечения (сек); 0 — не подсвечен. + public float Seconds; +} diff --git a/src/LittleSim/Sim/Animals/DamageFlashSystem.cs b/src/LittleSim/Sim/Animals/DamageFlashSystem.cs new file mode 100644 index 0000000..f2dbf02 --- /dev/null +++ b/src/LittleSim/Sim/Animals/DamageFlashSystem.cs @@ -0,0 +1,45 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Красная вспышка урона (презентация, как в RimWorld): особь, получившую удар, кратко подкрашивает +/// красным и гасит свечение по РЕАЛЬНОМУ времени (видно и на высокой скорости). Запускать ПОСЛЕ +/// (та задаёт базовый цвет от нужд) — вспышка подмешивается поверх. +/// Время свечения выставляет при ударе. Граница sim/presentation цела. +/// +public sealed class DamageFlashSystem(GameClock clock) : QuerySystem +{ + private static readonly Color FlashColor = new(255, 60, 60); + private const float MaxIntensity = 0.85f; // насколько краснит на пике + private const float FlashDuration = 0.35f; // полная длительность свечения (сек реального времени) + + protected override void OnUpdate() + { + var dt = clock.UnscaledDeltaTime; + foreach (var (sprites, flashes, _) in Query.Chunks) + { + var s = sprites.Span; + var f = flashes.Span; + for (var i = 0; i < f.Length; i++) + { + ref var flash = ref f[i]; + if (flash.Seconds <= 0f) + { + continue; + } + + flash.Seconds = MathF.Max(0f, flash.Seconds - dt); + var t = Math.Clamp(flash.Seconds / FlashDuration, 0f, 1f) * MaxIntensity; + s[i].Color = Color.Lerp(s[i].Color, FlashColor, t); + } + } + } +} diff --git a/src/LittleSim/Sim/Animals/Egg.cs b/src/LittleSim/Sim/Animals/Egg.cs new file mode 100644 index 0000000..6fb722b --- /dev/null +++ b/src/LittleSim/Sim/Animals/Egg.cs @@ -0,0 +1,24 @@ +using Friflo.Engine.ECS; +using MrGameEng.Genetics; + +namespace LittleSim.Sim; + +/// +/// Яйцо (тип рождения «яйцекладка»): отдельная сущность, отложенная яйцекладущей самкой вместо живых +/// родов. Несёт уже скрещённый геном будущего детёныша, вид и поколение; инкубируется во времени и +/// вылупляется в детёныша (). Геном — managed-ссылка (как у трупа/беременности). +/// +public struct Egg : IComponent +{ + /// Индекс вида в (для спрайта при вылуплении и сейва). + public int Species; + + /// Геном будущего детёныша (уже скрещён из родителей на момент кладки). + public Genome Genome; + + /// Поколение будущего детёныша. + public int Generation; + + /// Остаток инкубации в игровых днях; ≤0 — вылупление. + public float IncubateDays; +} diff --git a/src/LittleSim/Sim/Animals/EggSystem.cs b/src/LittleSim/Sim/Animals/EggSystem.cs new file mode 100644 index 0000000..98c9c10 --- /dev/null +++ b/src/LittleSim/Sim/Animals/EggSystem.cs @@ -0,0 +1,100 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Инкубация яиц (тип рождения «яйцекладка»): тикает остаток инкубации каждого яйца; по истечении +/// вылупляет детёныша ( из хранимого генома, стадия Baby) рядом с +/// яйцом и удаляет яйцо. Зеркало родов для яйцекладущих видов. +/// +public sealed class EggSystem : BaseSystem +{ + private readonly EntityStore _store; + private readonly AnimalSet _animals; + private readonly NeedSet _needs; + private readonly SkillSet _skills; + private readonly WorldEvents _events; + private readonly GameClock _clock; + private readonly float _secondsPerDay; + private readonly int _cellSize; + private readonly Random _rng; + private readonly ArchetypeQuery _query; + private readonly List _hatched = []; + + public EggSystem( + EntityStore store, + AnimalSet animals, + NeedSet needs, + SkillSet skills, + WorldEvents events, + GameClock clock, + float secondsPerDay, + int cellSize, + int seed + ) + { + _store = store; + _animals = animals; + _needs = needs; + _skills = skills; + _events = events; + _clock = clock; + _secondsPerDay = secondsPerDay; + _cellSize = cellSize; + _rng = new Random(seed); + _query = store.Query(); + } + + protected override void OnUpdateGroup() + { + var days = _clock.DeltaTime / _secondsPerDay; + if (days <= 0f) + { + return; + } + + _hatched.Clear(); + foreach (var (eggs, transforms, entities) in _query.Chunks) + { + var e = eggs.Span; + for (var i = 0; i < e.Length; i++) + { + ref var egg = ref e[i]; + egg.IncubateDays -= days; + if (egg.IncubateDays > 0f) + { + continue; + } + + var offset = + new Vector2(_rng.NextSingle() - 0.5f, _rng.NextSingle() - 0.5f) * _cellSize; + AnimalFactory.Create( + _store, + _animals, + _needs, + _skills, + egg.Species, + transforms.Span[i].Position + offset, + ageDays: 0f, + egg.Genome, + egg.Generation, + _cellSize + ); + _events.Hatched(egg.Species, 1); + _hatched.Add(entities.EntityAt(i)); + } + } + + foreach (var egg in _hatched) + { + egg.DeleteEntity(); + } + } +} diff --git a/src/LittleSim/Sim/Animals/GutSeed.cs b/src/LittleSim/Sim/Animals/GutSeed.cs new file mode 100644 index 0000000..f1ee70f --- /dev/null +++ b/src/LittleSim/Sim/Animals/GutSeed.cs @@ -0,0 +1,21 @@ +using Friflo.Engine.ECS; +using MrGameEng.Genetics; + +namespace LittleSim.Sim; + +/// +/// Проглоченное семя (эндозоохория): травоядное, съевшее плодоносящее растение, какое-то время несёт +/// его семя в кишечнике, затем «высаживает» его в другом месте () — +/// зверь становится разносчиком флоры. Геном — managed-ссылка (клон родительского растения). +/// +public struct GutSeed : IComponent +{ + /// Индекс вида растения в . + public int PlantSpecies; + + /// Геном будущего ростка (клон съеденного растения). + public Genome Genome; + + /// Остаток времени до высадки в игровых днях; ≤0 — росток падает на землю. + public float DropInDays; +} diff --git a/src/LittleSim/Sim/Animals/Health.cs b/src/LittleSim/Sim/Animals/Health.cs new file mode 100644 index 0000000..9196b0e --- /dev/null +++ b/src/LittleSim/Sim/Animals/Health.cs @@ -0,0 +1,10 @@ +using Friflo.Engine.ECS; + +namespace LittleSim.Sim; + +/// Здоровье особи: managed-состояние со списком хедифов (см. ). +public struct Health : IComponent +{ + /// Состояние здоровья (хедифы; позже части тела/кровь). + public HealthState State; +} diff --git a/src/LittleSim/Sim/Animals/HealthState.cs b/src/LittleSim/Sim/Animals/HealthState.cs new file mode 100644 index 0000000..c7c544a --- /dev/null +++ b/src/LittleSim/Sim/Animals/HealthState.cs @@ -0,0 +1,268 @@ +using System.Collections.Generic; +using LittleSim.Content; + +namespace LittleSim.Sim; + +/// +/// Состояние здоровья особи (фазы A4–A6): активные хедифы (инстансы) + части тела, кровь и вычисленные +/// способности (capacities). Managed-объект, как (Friflo допускает ссылку в +/// компоненте). Болезни (A6) — первый источник урона: снижают способности и могут убить. Растёт по +/// фазам — контейнер один. +/// +public sealed class HealthState +{ + private readonly List _hediffs = []; + + /// Активные хедифы (гон, болезни). + public IReadOnlyList Hediffs => _hediffs; + + /// Части тела особи (из анатомии вида); пусто — у вида нет тела. + public PartInstance[] Parts { get; private set; } = []; + + /// Уровень крови [0,1]; ниже порога — потеря сознания и смерть (когда появятся раны). + public float BloodLevel { get; set; } = 1f; + + /// Боль [0,1] — снижает сознание (когда появятся раны). + public float Pain { get; set; } + + /// Погибла ли особь по здоровью (vital-часть уничтожена / кровь на нуле). + public bool Dead { get; set; } + + /// Вычисленные способности (capacity id → [0,1]); у здоровой особи все = 1. + public Dictionary Capacities { get; private set; } = new(); + + /// Способность по id (1, если не вычислена — напр. у вида без тела), для множителей действий. + public float Capacity(string id) => Capacities.TryGetValue(id, out var v) ? v : 1f; + + /// + /// Пересчитывает боль (из хедифов) и способности: базовые из частей/крови/боли, затем модификаторы + /// способностей от хедифов (лерп 1→factor по тяжести), затем общий множитель + /// (старение/сенесценс: дряхлеющий организм слабее по всем способностям). Вызывать после изменения + /// частей/крови/хедифов; =1 — без возрастного спада. + /// + public void RecomputeCapacities(float capacityScale = 1f) + { + var pain = 0f; + foreach (var h in _hediffs) + { + pain += h.Def.Pain * h.Severity; + } + + Pain = Math.Clamp(pain, 0f, 1f); + + // Множитель старения применяется внутри Compute. Пишем в собственный словарь Capacities + // (переиспользуется, очищается внутри Compute) — без аллокации карты способностей на пересчёт. + var caps = Capacities; + CapacityCalc.Compute(Parts, BloodLevel, Pain, capacityScale, caps); + foreach (var h in _hediffs) + { + if (h.Def.CapMods.Count == 0) + { + continue; + } + + var t = Math.Clamp(h.Severity, 0f, 1f); + foreach (var (capacity, factor) in h.Def.CapMods) + { + caps[capacity] = caps.GetValueOrDefault(capacity, 1f) * (1f + (factor - 1f) * t); + } + } + } + + /// + /// Прогрессирует болезни (тяжесть и иммунитет растут со временем); снимает выздоровевшие + /// (иммунитет ≥ 1); возвращает true, если какая-то болезнь достигла летальной тяжести. + /// — множитель набора иммунитета (способность «фильтрация крови»: + /// почки/печень выводят токсины и помогают бороться с болезнью; повреждённые → медленнее). + /// + public bool AdvanceHediffs(float days, float immunityScale = 1f) + { + var lethal = false; + for (var i = _hediffs.Count - 1; i >= 0; i--) + { + var h = _hediffs[i]; + if (h.Def.SeverityPerDay <= 0f && h.Def.ImmunityPerDay <= 0f) + { + continue; // статичный хедиф (гон) не прогрессирует + } + + h.Severity += h.Def.SeverityPerDay * days; + h.Immunity += h.Def.ImmunityPerDay * days * immunityScale; + if (h.Immunity >= 1f) + { + _hediffs.RemoveAt(i); // иммунитет победил — выздоровление + continue; + } + + if (h.Def.LethalSeverity > 0f && h.Severity >= h.Def.LethalSeverity) + { + lethal = true; + } + + _hediffs[i] = h; + } + + return lethal; + } + + /// Строит здоровье особи: части из анатомии вида (HP × размер тела) + начальные способности. + public static HealthState Create(Content.BodyDef? body, float bodySize) + { + var state = new HealthState(); + if (body is { Parts.Length: > 0 }) + { + var scale = MathF.Max(0.2f, bodySize); + state.Parts = new PartInstance[body.Parts.Length]; + for (var i = 0; i < body.Parts.Length; i++) + { + var hp = body.Parts[i].MaxHp * scale; + state.Parts[i] = new PartInstance + { + Def = body.Parts[i], + Hp = hp, + MaxHp = hp, + }; + } + } + + state.RecomputeCapacities(); + return state; + } + + /// Несёт ли особь хедиф с данным именем дефа. + public bool Has(string defName) + { + foreach (var h in _hediffs) + { + if (h.Def.DefName == defName) + { + return true; + } + } + + return false; + } + + /// Навешивает хедиф (если ещё не навешан) с начальной тяжестью из дефа. + public void Add(HediffDef def) + { + if (!Has(def.DefName)) + { + _hediffs.Add(new Hediff { Def = def, Severity = def.InitialSeverity }); + } + } + + /// Восстанавливает хедиф с заданной тяжестью и иммунитетом (для загрузки сейва). + public void AddHediff(HediffDef def, float severity, float immunity) + { + if (!Has(def.DefName)) + { + _hediffs.Add( + new Hediff + { + Def = def, + Severity = severity, + Immunity = immunity, + } + ); + } + } + + /// Снимает хедиф по имени дефа; возвращает, был ли он. + public bool Remove(string defName) + { + for (var i = 0; i < _hediffs.Count; i++) + { + if (_hediffs[i].Def.DefName == defName) + { + _hediffs.RemoveAt(i); + return true; + } + } + + return false; + } + + /// + /// Травма (укус хищника): повреждает часть тела (выбор по весу попадания coverage), вызывает + /// мгновенную кровопотерю и навешивает/усиливает кровотечение, затем пересчитывает способности. + /// Первый травматический источник урона — оживляет каркас здоровья A5. + /// + public void ApplyInjury( + float partDamage, + float bloodLoss, + HediffDef? bleeding, + float bleedSeverity, + Random rng + ) + { + if (Parts.Length > 0 && partDamage > 0f) + { + var idx = PickPart(rng); + var part = Parts[idx]; + part.Hp = MathF.Max(0f, part.Hp - partDamage); + Parts[idx] = part; + } + + BloodLevel = Math.Clamp(BloodLevel - bloodLoss, 0f, 1f); + if (bleeding is not null && bleedSeverity > 0f) + { + Intensify(bleeding, bleedSeverity); + } + + RecomputeCapacities(); + } + + // Выбор задетой части по весу попадания (coverage); детерминируется переданным rng. + private int PickPart(Random rng) + { + var total = 0f; + foreach (var p in Parts) + { + total += p.Def?.Coverage ?? 0f; + } + + if (total <= 0f) + { + return rng.Next(Parts.Length); + } + + var roll = (float)rng.NextDouble() * total; + for (var i = 0; i < Parts.Length; i++) + { + if (Parts[i].Def is null) + { + continue; + } + + roll -= Parts[i].Def.Coverage; + if (roll <= 0f) + { + return i; + } + } + + return Parts.Length - 1; + } + + /// + /// Навешивает хедиф или усиливает существующий: тяжесть растёт (до 1), а заживление (иммунитет) + /// сбрасывается — свежая рана начинает затягиваться заново. Для повторных укусов/ранений. + /// + public void Intensify(HediffDef def, float addSeverity) + { + for (var i = 0; i < _hediffs.Count; i++) + { + if (_hediffs[i].Def.DefName == def.DefName) + { + var h = _hediffs[i]; + h.Severity = Math.Clamp(h.Severity + addSeverity, 0f, 1f); + h.Immunity = 0f; // свежая рана — заживление с нуля + _hediffs[i] = h; + return; + } + } + + _hediffs.Add(new Hediff { Def = def, Severity = Math.Clamp(addSeverity, 0f, 1f) }); + } +} diff --git a/src/LittleSim/Sim/Animals/Hediff.cs b/src/LittleSim/Sim/Animals/Hediff.cs new file mode 100644 index 0000000..62a067a --- /dev/null +++ b/src/LittleSim/Sim/Animals/Hediff.cs @@ -0,0 +1,16 @@ +using LittleSim.Content; + +namespace LittleSim.Sim; + +/// Инстанс хедифа на особи: деф + текущая тяжесть и набранный иммунитет (гонка, фаза A6). +public struct Hediff +{ + /// Деф хедифа (гон/болезнь/…). + public HediffDef Def; + + /// Текущая тяжесть. + public float Severity; + + /// Набранный иммунитет [0,1]; при 1 — выздоровление. + public float Immunity; +} diff --git a/src/LittleSim/Sim/Animals/Memory.cs b/src/LittleSim/Sim/Animals/Memory.cs new file mode 100644 index 0000000..abc3aac --- /dev/null +++ b/src/LittleSim/Sim/Animals/Memory.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using Friflo.Engine.ECS; +using Microsoft.Xna.Framework; + +namespace LittleSim.Sim; + +/// +/// Память особи (объём ∝ мозгу): ограниченный набор записей о замеченном (сейчас — угрозы). Чувства +/// пишут сюда воспринятое; со временем уверенность падает и запись забывается; ИИ использует память, +/// когда цель не воспринимается сейчас (избегает зоны недавнего хищника). Список — managed-ссылка. +/// +public struct Memory : IComponent +{ + /// Активные записи (упорядочены по добавлению; слабейшая вытесняется при переполнении). + public List Entries; + + /// Сколько записей вмещает (∝ размеру мозга; 0 — память отсутствует). + public int Capacity; + + /// Запоминает/освежает наблюдение: близкое того же вида — обновляет, иначе добавляет (с + /// вытеснением слабейшего при переполнении). Вне ёмкости (Capacity 0) — ничего не помнит. + public readonly void Remember(MemoryKind kind, Vector2 pos, float mergeDist) + { + if (Entries is null || Capacity <= 0) + { + return; + } + + var mergeSq = mergeDist * mergeDist; + for (var i = 0; i < Entries.Count; i++) + { + if (Entries[i].Kind == kind && Vector2.DistanceSquared(Entries[i].Pos, pos) <= mergeSq) + { + Entries[i] = new MemoryEntry + { + Kind = kind, + Pos = pos, + Confidence = 1f, + AgeDays = 0f, + }; + return; + } + } + + if (Entries.Count >= Capacity) + { + var weakest = 0; + for (var i = 1; i < Entries.Count; i++) + { + if (Entries[i].Confidence < Entries[weakest].Confidence) + { + weakest = i; + } + } + + Entries.RemoveAt(weakest); + } + + Entries.Add( + new MemoryEntry + { + Kind = kind, + Pos = pos, + Confidence = 1f, + AgeDays = 0f, + } + ); + } +} diff --git a/src/LittleSim/Sim/Animals/MemoryEntry.cs b/src/LittleSim/Sim/Animals/MemoryEntry.cs new file mode 100644 index 0000000..525150d --- /dev/null +++ b/src/LittleSim/Sim/Animals/MemoryEntry.cs @@ -0,0 +1,13 @@ +using Microsoft.Xna.Framework; + +namespace LittleSim.Sim; + +/// Запись памяти: что, где (воспринятая позиция — грубая, если по запаху), насколько уверенно +/// (падает со временем — забывание), сколько дней назад. +public struct MemoryEntry +{ + public MemoryKind Kind; + public Vector2 Pos; + public float Confidence; + public float AgeDays; +} diff --git a/src/LittleSim/Sim/Animals/MemoryKind.cs b/src/LittleSim/Sim/Animals/MemoryKind.cs new file mode 100644 index 0000000..d3c6d32 --- /dev/null +++ b/src/LittleSim/Sim/Animals/MemoryKind.cs @@ -0,0 +1,10 @@ +namespace LittleSim.Sim; + +/// Вид запомненного: угроза (избегать), корм/вода/партнёр (идти) — расширяемо. +public enum MemoryKind : byte +{ + Threat, + Food, + Water, + Mate, +} diff --git a/src/LittleSim/Sim/Animals/Mood.cs b/src/LittleSim/Sim/Animals/Mood.cs new file mode 100644 index 0000000..164922e --- /dev/null +++ b/src/LittleSim/Sim/Animals/Mood.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using Friflo.Engine.ECS; + +namespace LittleSim.Sim; + +/// +/// Настроение особи (фаза A8): значение [0,1] и активные событийные мысли. Считается +/// из непрерывного фона (нужды/боль) и суммы мыслей. Есть только у +/// тира 4+ ( по размеру мозга) — у примитивных настроения нет. Низкое настроение +/// влияет на поведение (стресс не даёт спариваться) и видно в инспекторе. Список мыслей — managed-ссылка +/// (как AnimalNeeds.Values): события добавляют мысли через . +/// +public struct Mood : IComponent +{ + /// Текущее настроение [0,1]; у неактивного (тир < 4) держится нейтральным. + public float Value; + + /// Есть ли у особи настроение (гейт тиром мозга) — иначе фон/мысли не считаются. + public bool Active; + + /// Активные событийные мысли (затухают со временем). + public List Thoughts; + + /// + /// Вешает событийную мысль (или обновляет её длительность, если уже висит — без накопления + /// дубликатов). Вызывается из систем-источников событий (роды, спаривание, утоление жажды). + /// + public readonly void Add(Content.ThoughtDef? def) + { + if (Thoughts is null || def is null) + { + return; + } + + for (var i = 0; i < Thoughts.Count; i++) + { + if (Thoughts[i].Def == def) + { + var refreshed = Thoughts[i]; + refreshed.RemainingDays = def.DurationDays; + Thoughts[i] = refreshed; + return; + } + } + + Thoughts.Add(new ThoughtInstance { Def = def, RemainingDays = def.DurationDays }); + } +} diff --git a/src/LittleSim/Sim/Animals/PartInstance.cs b/src/LittleSim/Sim/Animals/PartInstance.cs new file mode 100644 index 0000000..ed7b5fc --- /dev/null +++ b/src/LittleSim/Sim/Animals/PartInstance.cs @@ -0,0 +1,19 @@ +using LittleSim.Content; + +namespace LittleSim.Sim; + +/// Инстанс части тела особи: ссылка на деф части и текущий/максимальный HP (масштаб по размеру). +public struct PartInstance +{ + /// Деф части тела (анатомия вида). + public Content.BodyPartDef Def; + + /// Текущий HP. + public float Hp; + + /// Максимальный HP (база × размер тела). + public float MaxHp; + + /// Доля сохранности [0,1]. + public readonly float Fraction => MaxHp > 0f ? Math.Clamp(Hp / MaxHp, 0f, 1f) : 0f; +} diff --git a/src/LittleSim/Sim/Animals/Pregnant.cs b/src/LittleSim/Sim/Animals/Pregnant.cs new file mode 100644 index 0000000..2d90186 --- /dev/null +++ b/src/LittleSim/Sim/Animals/Pregnant.cs @@ -0,0 +1,21 @@ +using Friflo.Engine.ECS; +using MrGameEng.Genetics; + +namespace LittleSim.Sim; + +/// +/// Беременность самки (фаза A4): managed-ссылка на геном отца («слепок» на момент зачатия), остаток +/// срока вынашивания и размер помёта. По истечении срока +/// рожает детёнышей через и снимает компонент. +/// +public struct Pregnant : IComponent +{ + /// Геном отца на момент зачатия. + public Genome FatherGenome; + + /// Остаток срока вынашивания (игровых дней). + public float DueInDays; + + /// Сколько детёнышей родится. + public int Litter; +} diff --git a/src/LittleSim/Sim/Animals/SeedDispersalSystem.cs b/src/LittleSim/Sim/Animals/SeedDispersalSystem.cs new file mode 100644 index 0000000..f186e2c --- /dev/null +++ b/src/LittleSim/Sim/Animals/SeedDispersalSystem.cs @@ -0,0 +1,109 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Разнос семян (эндозоохория): тикает таймер проглоченного семени (); по +/// истечении высаживает росток того вида растения там, где сейчас зверь (если клетка — суша), +/// геномом-клоном съеденного растения, и снимает компонент. Делает травоядных разносчиками флоры — +/// растения расселяются вдоль кормовых маршрутов. +/// +public sealed class SeedDispersalSystem : BaseSystem +{ + private readonly EntityStore _store; + private readonly PlantSet _plants; + private readonly GameClock _clock; + private readonly float _secondsPerDay; + private readonly int _width; + private readonly int _height; + private readonly int _cellSize; + private readonly float[] _cellFertility; + private readonly bool[] _cellLand; + private readonly ArchetypeQuery _query; + private readonly List _dropped = []; + + public SeedDispersalSystem( + EntityStore store, + PlantSet plants, + GameClock clock, + float secondsPerDay, + int width, + int height, + int cellSize, + float[] cellFertility, + bool[] cellLand + ) + { + _store = store; + _plants = plants; + _clock = clock; + _secondsPerDay = secondsPerDay; + _width = width; + _height = height; + _cellSize = cellSize; + _cellFertility = cellFertility; + _cellLand = cellLand; + _query = store.Query(); + } + + protected override void OnUpdateGroup() + { + var days = _clock.DeltaTime / _secondsPerDay; + if (days <= 0f) + { + return; + } + + _dropped.Clear(); + foreach (var (seeds, transforms, entities) in _query.Chunks) + { + var s = seeds.Span; + var t = transforms.Span; + for (var i = 0; i < s.Length; i++) + { + ref var seed = ref s[i]; + seed.DropInDays -= days; + if (seed.DropInDays > 0f) + { + continue; + } + + _dropped.Add(entities.EntityAt(i)); + var pos = t[i].Position; + var cx = Math.Clamp((int)(pos.X / _cellSize), 0, _width - 1); + var cy = Math.Clamp((int)(pos.Y / _cellSize), 0, _height - 1); + var cell = cy * _width + cx; + if (!_cellLand[cell]) + { + continue; // упало в воду — семя пропадает + } + + PlantFactory.Create( + _store, + _plants, + seed.PlantSpecies, + pos, + ageDays: 0f, + seed.Genome, + _cellFertility[cell], + _cellSize + ); + } + } + + foreach (var eater in _dropped) + { + if (!eater.IsNull && eater.HasComponent()) + { + eater.RemoveComponent(); + } + } + } +} diff --git a/src/LittleSim/Sim/Animals/Skills.cs b/src/LittleSim/Sim/Animals/Skills.cs new file mode 100644 index 0000000..296c1da --- /dev/null +++ b/src/LittleSim/Sim/Animals/Skills.cs @@ -0,0 +1,17 @@ +using Friflo.Engine.ECS; + +namespace LittleSim.Sim; + +/// +/// Навыки особи (как нужды/способности — managed-массивы по индексам ): +/// уровни [0..1] (растут от практики, угасают без неё) и страсть к каждому навыку (скорость обучения). +/// Есть у животных; те же навыки получат будущие люди. Добавление навыка не меняет структуру. +/// +public struct Skills : IComponent +{ + /// Уровни навыков [0..1] по индексам . + public float[] Levels; + + /// Страсть к каждому навыку (0 нет / 1 интерес / 2 страсть) — множитель обучения. + public byte[] Passions; +} diff --git a/src/LittleSim/Sim/Animals/ThoughtInstance.cs b/src/LittleSim/Sim/Animals/ThoughtInstance.cs new file mode 100644 index 0000000..65911f5 --- /dev/null +++ b/src/LittleSim/Sim/Animals/ThoughtInstance.cs @@ -0,0 +1,11 @@ +namespace LittleSim.Sim; + +/// Инстанс мысли настроения на особи (фаза A8): деф события + остаток времени до угасания. +public struct ThoughtInstance +{ + /// Деф мысли (сдвиг настроения и длительность). + public Content.ThoughtDef Def; + + /// Сколько игровых дней мысль ещё действует. + public float RemainingDays; +} diff --git a/src/LittleSim/Sim/CapacityCalc.cs b/src/LittleSim/Sim/CapacityCalc.cs index 3dba29f..d9797af 100644 --- a/src/LittleSim/Sim/CapacityCalc.cs +++ b/src/LittleSim/Sim/CapacityCalc.cs @@ -2,40 +2,6 @@ using System.Collections.Generic; namespace LittleSim.Sim; -/// Идентификаторы способностей (capacities) организма — на них ссылаются вклады частей тела. -public static class AnimalCapacities -{ - public const string Consciousness = "Consciousness"; - public const string Moving = "Moving"; - public const string Breathing = "Breathing"; - public const string BloodPumping = "BloodPumping"; - public const string BloodFiltration = "BloodFiltration"; - public const string Sight = "Sight"; - public const string Hearing = "Hearing"; - public const string Smell = "Smell"; - public const string Touch = "Touch"; - public const string Talking = "Talking"; - public const string Eating = "Eating"; - public const string Digestion = "Digestion"; - - /// Порядок отображения способностей в UI (сверху вниз). Прочие модовые — после, как есть. - public static readonly string[] DisplayOrder = - [ - Consciousness, - Moving, - Sight, - Hearing, - Smell, - Touch, - Talking, - Eating, - Breathing, - BloodPumping, - BloodFiltration, - Digestion, - ]; -} - /// /// Обобщённый расчёт способностей (capacities) из частей тела и крови (фаза A5). Сырой вклад способности /// = Σ(доля части × доля её HP). Затем известные способности домножаются по зависимостям (кровь → diff --git a/src/LittleSim/Sim/EventCategory.cs b/src/LittleSim/Sim/EventCategory.cs new file mode 100644 index 0000000..c928ff5 --- /dev/null +++ b/src/LittleSim/Sim/EventCategory.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using Friflo.Engine.ECS; +using LittleSim.Content; +using MrGameEng.Core; +using MrGameEng.Mods; + +namespace LittleSim.Sim; + +/// Категория события — определяет цвет строки в журнале/истории и группировку. +public enum EventCategory : byte +{ + /// Размножение: спаривание, беременность, роды, вылупление. + Reproduction, + + /// Охота и бой: начало охоты, убийство добычи. + Combat, + + /// Смерть: голод/жажда, старость, болезнь, хищник. + Death, + + /// Бытовое: кормёжка, водопой, бегство, смена стадии (только в истории особи). + Routine, +} diff --git a/src/LittleSim/Sim/Fruiting.cs b/src/LittleSim/Sim/Fruiting.cs new file mode 100644 index 0000000..2360661 --- /dev/null +++ b/src/LittleSim/Sim/Fruiting.cs @@ -0,0 +1,14 @@ +using Friflo.Engine.ECS; + +namespace LittleSim.Sim; + +/// +/// Плодоношение растения: сколько зрелых плодов сейчас на нём. Копится у зрелого растения в его +/// сезон плодоношения (признак ) до потолка +/// , вне сезона опадает. Управляется PlantFruitingSystem. +/// +public struct Fruiting : IComponent +{ + /// Накоплено зрелых плодов (0..FruitYield). + public float RipeFruit; +} diff --git a/src/LittleSim/Sim/GodCameraSystem.cs b/src/LittleSim/Sim/GodCameraSystem.cs new file mode 100644 index 0000000..554c324 --- /dev/null +++ b/src/LittleSim/Sim/GodCameraSystem.cs @@ -0,0 +1,71 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; +using MrGameEng.Graphics; +using MrGameEng.Input; + +namespace LittleSim.Sim; + +/// +/// Камера бога: WASD/стрелки — панорамирование, колесо — зум к курсору (мировая точка под мышью +/// остаётся под мышью). Молчит, пока открыта консоль или активен +/// (например, открыто меню-пауза). +/// +public sealed class GodCameraSystem( + Entity cameraEntity, + InputManager input, + Renderer2D renderer, + MrGameEng.DevConsole.DevConsole? console, + Func? blocked = null +) : BaseSystem +{ + protected override void OnUpdateGroup() + { + if (console?.IsOpen == true || blocked?.Invoke() == true) + { + return; + } + + ref var camera = ref cameraEntity.GetComponent(); + + var pan = new Vector2( + Axis(input, Keys.A, Keys.Left, Keys.D, Keys.Right), + Axis(input, Keys.W, Keys.Up, Keys.S, Keys.Down) + ); + if (pan != Vector2.Zero) + { + pan.Normalize(); + camera.Position += pan * (500f / camera.Zoom) * Tick.deltaTime; + } + + if (input.WheelDelta != 0) + { + var oldZoom = camera.Zoom; + var newZoom = Math.Clamp(oldZoom * (1f + input.WheelDelta * 0.001f), 0.4f, 8f); + if (newZoom != oldZoom) + { + // Точка мира под курсором до зума; после — сдвигаем камеру, чтобы она осталась там же. + // Якорь и базовая позиция берутся из одного кадра камеры: WorldCenter — это позиция + // ПОСЛЕ клампа к границам (то, что реально нарисовано), а не сырой camera.Position — + // иначе у краёв карты/при широком обзоре зум «уезжает» от курсора. + var anchor = renderer.ScreenToWorld( + new Vector2(input.MousePosition.X, input.MousePosition.Y) + ); + var effective = renderer.Camera.WorldCenter; + camera.Zoom = newZoom; + camera.Position = anchor - (anchor - effective) * (oldZoom / newZoom); + } + } + } + + private static float Axis( + InputManager input, + Keys negativeA, + Keys negativeB, + Keys positiveA, + Keys positiveB + ) => + (input.IsKeyDown(positiveA) || input.IsKeyDown(positiveB) ? 1f : 0f) + - (input.IsKeyDown(negativeA) || input.IsKeyDown(negativeB) ? 1f : 0f); +} diff --git a/src/LittleSim/Sim/HudSystem.cs b/src/LittleSim/Sim/HudSystem.cs new file mode 100644 index 0000000..d39dcdc --- /dev/null +++ b/src/LittleSim/Sim/HudSystem.cs @@ -0,0 +1,63 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; +using MrGameEng.Graphics; +using MrGameEng.Input; + +namespace LittleSim.Sim; + +/// +/// Строка даты/времени/сезона в HUD (верх-слева). Обновляется ~4 раза в секунду; смена языка +/// подхватывается сама. Сид и FPS убраны (сид виден в меню, FPS — в окне производительности), +/// подсказки управления вынесены отдельной строкой сцены. +/// +public sealed class HudSystem( + MrGameEng.Core.EngineContext context, + MrGameEng.Mods.LanguageManager languages, + Myra.Graphics2D.UI.Label label, + MrGameEng.Core.Calendar? calendar = null, + MrGameEng.Core.Climate? climate = null +) : BaseSystem +{ + // Ключи локализации сезонов предсобраны (Spring..Winter), чтобы не строить строку + // "season."+ToString().ToLowerInvariant() на каждое обновление HUD. + private static readonly string[] SeasonKeys = BuildSeasonKeys(); + + private float _accumulated; + + private static string[] BuildSeasonKeys() + { + var names = Enum.GetNames(); + var keys = new string[names.Length]; + for (var i = 0; i < names.Length; i++) + { + keys[i] = "season." + names[i].ToLowerInvariant(); + } + + return keys; + } + + protected override void OnUpdateGroup() + { + _accumulated += context.Clock.UnscaledDeltaTime; + if (_accumulated < 0.25f) + { + return; + } + + _accumulated = 0f; + var text = calendar is null + ? "" + : languages.Format("hud.datetime", calendar.Day, calendar.Hour, calendar.Minute); + if (climate is not null) + { + var season = languages.Get(SeasonKeys[(int)climate.Season]); + text += + " · " + + languages.Format("hud.climate", season, (int)MathF.Round(climate.Temperature)); + } + + label.Text = text; + } +} diff --git a/src/LittleSim/Sim/LogEntry.cs b/src/LittleSim/Sim/LogEntry.cs new file mode 100644 index 0000000..3636c8d --- /dev/null +++ b/src/LittleSim/Sim/LogEntry.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using Friflo.Engine.ECS; +using LittleSim.Content; +using MrGameEng.Core; +using MrGameEng.Mods; + +namespace LittleSim.Sim; + +/// +/// Одна запись журнала: готовая (локализованная) строка, категория, отметка времени календаря и id +/// «ответственной» сущности (для перехода камерой/выбора по двойному клику); -1 — не к кому переходить +/// (напр. смерть — особь исчезла). +/// +public readonly record struct LogEntry( + string Text, + EventCategory Category, + int Day, + int Hour, + int Minute, + int SubjectId +); diff --git a/src/LittleSim/Sim/PawnAction.cs b/src/LittleSim/Sim/PawnAction.cs new file mode 100644 index 0000000..0e06f70 --- /dev/null +++ b/src/LittleSim/Sim/PawnAction.cs @@ -0,0 +1,17 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// Что житель делает прямо сейчас — результат utility-выбора. +public enum PawnAction +{ + /// Бродит по миру, тратя энергию. + Wander, + + /// Стоит и восстанавливает энергию. + Rest, +} diff --git a/src/LittleSim/Sim/PawnAppearanceSystem.cs b/src/LittleSim/Sim/PawnAppearanceSystem.cs new file mode 100644 index 0000000..a55c147 --- /dev/null +++ b/src/LittleSim/Sim/PawnAppearanceSystem.cs @@ -0,0 +1,31 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Презентация состояния ИИ: уставший житель темнеет (альфа падает с энергией). Система читает +/// симуляцию и пишет только в — никаких draw-вызовов, граница sim/presentation цела. +/// +public sealed class PawnAppearanceSystem : QuerySystem +{ + private const float MinBrightness = 0.45f; + + protected override void OnUpdate() + { + foreach (var (needs, sprites, _) in Query.Chunks) + { + var n = needs.Span; + var s = sprites.Span; + for (var i = 0; i < n.Length; i++) + { + var brightness = + MinBrightness + (1f - MinBrightness) * Math.Clamp(n[i].Energy, 0f, 1f); + s[i].Color = Color.White * brightness; + } + } + } +} diff --git a/src/LittleSim/Sim/PawnBrain.cs b/src/LittleSim/Sim/PawnBrain.cs index 95ec032..66d581b 100644 --- a/src/LittleSim/Sim/PawnBrain.cs +++ b/src/LittleSim/Sim/PawnBrain.cs @@ -6,26 +6,6 @@ using MrGameEng.Graphics; namespace LittleSim.Sim; -/// Что житель делает прямо сейчас — результат utility-выбора. -public enum PawnAction -{ - /// Бродит по миру, тратя энергию. - Wander, - - /// Стоит и восстанавливает энергию. - Rest, -} - -/// -/// Потребности жителя. Пока одна — энергия (0 — без сил, 1 — полон сил); со временем -/// расходуется при блуждании и восстанавливается на отдыхе. Голод/жажда добавятся так же. -/// -public struct PawnNeeds : IComponent -{ - /// Запас сил в диапазоне [0, 1]. - public float Energy; -} - /// /// «Мозг» жителя: выбранное действие и таймер до следующего пересмотра решения. /// Решение принимает через движковый . @@ -38,124 +18,3 @@ public struct PawnBrain : IComponent /// Секунды до следующего пересмотра решения — не пересчитываем выбор каждый кадр. public float DecideIn; } - -/// Снимок состояния жителя, который читают соображения utility-выбора. -public readonly record struct PawnContext(float Energy); - -/// -/// Utility-выбор действия жителя на движковом . Reasoner строится -/// один раз; каждый житель пересматривает решение раз в секунд, а -/// не каждый кадр (энергия меняется медленно — частить незачем, и решение не дёргается на границе). -/// Выбор детерминирован: при равенстве очков побеждает первое действие. -/// -public sealed class PawnDecisionSystem : QuerySystem -{ - private const float ReevaluateInterval = 0.75f; - - private readonly UtilityAi _brain = new( - // Отдых тянет к себе только когда сил мало: (1 − energy)^3 быстро спадает к нулю выше ~0.3. - new UtilityAction( - "rest", - new Consideration( - "tired", - c => c.Energy, - curve: ResponseCurve.Polynomial(exponent: 3f, slope: -1f, xShift: 1f) - ) - ), - // Блуждание — поведение по умолчанию: тем привлекательнее, чем больше сил. - new UtilityAction( - "wander", - new Consideration("rested", c => c.Energy) - ) - ); - - protected override void OnUpdate() - { - var delta = Tick.deltaTime; - foreach (var (needs, brains, _) in Query.Chunks) - { - var n = needs.Span; - var b = brains.Span; - for (var i = 0; i < n.Length; i++) - { - ref var brain = ref b[i]; - brain.DecideIn -= delta; - if (brain.DecideIn > 0f) - { - continue; - } - - brain.DecideIn = ReevaluateInterval; - var choice = _brain.Select(new PawnContext(n[i].Energy)); - brain.Action = choice?.Name == "rest" ? PawnAction.Rest : PawnAction.Wander; - } - } - } - - /// Очки последнего вычисленного выбора, по порядку действий — для дебаг-команды. - public (string Name, float Score)[] Inspect(float energy) - { - _brain.Select(new PawnContext(energy)); - var scores = _brain.LastScores; - var result = new (string, float)[_brain.Actions.Count]; - for (var i = 0; i < result.Length; i++) - { - result[i] = (_brain.Actions[i].Name, scores[i]); - } - - return result; - } -} - -/// -/// Презентация состояния ИИ: уставший житель темнеет (альфа падает с энергией). Система читает -/// симуляцию и пишет только в — никаких draw-вызовов, граница sim/presentation цела. -/// -public sealed class PawnAppearanceSystem : QuerySystem -{ - private const float MinBrightness = 0.45f; - - protected override void OnUpdate() - { - foreach (var (needs, sprites, _) in Query.Chunks) - { - var n = needs.Span; - var s = sprites.Span; - for (var i = 0; i < n.Length; i++) - { - var brightness = - MinBrightness + (1f - MinBrightness) * Math.Clamp(n[i].Energy, 0f, 1f); - s[i].Color = Color.White * brightness; - } - } - } -} - -/// -/// Расход и восстановление энергии по текущему действию: блуждающий устаёт, отдыхающий -/// восстанавливается. Скорости подобраны так, что отдых короткий, а блуждание долгое. -/// -public sealed class PawnNeedsSystem : QuerySystem -{ - private const float DrainPerSecond = 0.04f; - private const float RegenPerSecond = 0.2f; - - protected override void OnUpdate() - { - var delta = Tick.deltaTime; - foreach (var (needs, brains, _) in Query.Chunks) - { - var n = needs.Span; - var b = brains.Span; - for (var i = 0; i < n.Length; i++) - { - ref var energy = ref n[i].Energy; - energy += - b[i].Action == PawnAction.Rest - ? RegenPerSecond * delta - : -DrainPerSecond * delta; - energy = Math.Clamp(energy, 0f, 1f); - } - } - } -} diff --git a/src/LittleSim/Sim/PawnContext.cs b/src/LittleSim/Sim/PawnContext.cs new file mode 100644 index 0000000..73d557e --- /dev/null +++ b/src/LittleSim/Sim/PawnContext.cs @@ -0,0 +1,10 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// Снимок состояния жителя, который читают соображения utility-выбора. +public readonly record struct PawnContext(float Energy); diff --git a/src/LittleSim/Sim/PawnDecisionSystem.cs b/src/LittleSim/Sim/PawnDecisionSystem.cs new file mode 100644 index 0000000..52d2021 --- /dev/null +++ b/src/LittleSim/Sim/PawnDecisionSystem.cs @@ -0,0 +1,72 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Utility-выбор действия жителя на движковом . Reasoner строится +/// один раз; каждый житель пересматривает решение раз в секунд, а +/// не каждый кадр (энергия меняется медленно — частить незачем, и решение не дёргается на границе). +/// Выбор детерминирован: при равенстве очков побеждает первое действие. +/// +public sealed class PawnDecisionSystem : QuerySystem +{ + private const float ReevaluateInterval = 0.75f; + + private readonly UtilityAi _brain = new( + // Отдых тянет к себе только когда сил мало: (1 − energy)^3 быстро спадает к нулю выше ~0.3. + new UtilityAction( + "rest", + new Consideration( + "tired", + c => c.Energy, + curve: ResponseCurve.Polynomial(exponent: 3f, slope: -1f, xShift: 1f) + ) + ), + // Блуждание — поведение по умолчанию: тем привлекательнее, чем больше сил. + new UtilityAction( + "wander", + new Consideration("rested", c => c.Energy) + ) + ); + + protected override void OnUpdate() + { + var delta = Tick.deltaTime; + foreach (var (needs, brains, _) in Query.Chunks) + { + var n = needs.Span; + var b = brains.Span; + for (var i = 0; i < n.Length; i++) + { + ref var brain = ref b[i]; + brain.DecideIn -= delta; + if (brain.DecideIn > 0f) + { + continue; + } + + brain.DecideIn = ReevaluateInterval; + var choice = _brain.Select(new PawnContext(n[i].Energy)); + brain.Action = choice?.Name == "rest" ? PawnAction.Rest : PawnAction.Wander; + } + } + } + + /// Очки последнего вычисленного выбора, по порядку действий — для дебаг-команды. + public (string Name, float Score)[] Inspect(float energy) + { + _brain.Select(new PawnContext(energy)); + var scores = _brain.LastScores; + var result = new (string, float)[_brain.Actions.Count]; + for (var i = 0; i < result.Length; i++) + { + result[i] = (_brain.Actions[i].Name, scores[i]); + } + + return result; + } +} diff --git a/src/LittleSim/Sim/PawnId.cs b/src/LittleSim/Sim/PawnId.cs new file mode 100644 index 0000000..9d20b86 --- /dev/null +++ b/src/LittleSim/Sim/PawnId.cs @@ -0,0 +1,14 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; +using MrGameEng.Graphics; +using MrGameEng.Input; + +namespace LittleSim.Sim; + +/// Имя дефа жителя — нужно сохранению, чтобы при загрузке пересобрать спрайт из атласа. +public struct PawnId : IComponent +{ + public string DefName; +} diff --git a/src/LittleSim/Sim/PawnNeeds.cs b/src/LittleSim/Sim/PawnNeeds.cs new file mode 100644 index 0000000..33d9e7f --- /dev/null +++ b/src/LittleSim/Sim/PawnNeeds.cs @@ -0,0 +1,17 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Потребности жителя. Пока одна — энергия (0 — без сил, 1 — полон сил); со временем +/// расходуется при блуждании и восстанавливается на отдыхе. Голод/жажда добавятся так же. +/// +public struct PawnNeeds : IComponent +{ + /// Запас сил в диапазоне [0, 1]. + public float Energy; +} diff --git a/src/LittleSim/Sim/PawnNeedsSystem.cs b/src/LittleSim/Sim/PawnNeedsSystem.cs new file mode 100644 index 0000000..e346fe4 --- /dev/null +++ b/src/LittleSim/Sim/PawnNeedsSystem.cs @@ -0,0 +1,36 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Расход и восстановление энергии по текущему действию: блуждающий устаёт, отдыхающий +/// восстанавливается. Скорости подобраны так, что отдых короткий, а блуждание долгое. +/// +public sealed class PawnNeedsSystem : QuerySystem +{ + private const float DrainPerSecond = 0.04f; + private const float RegenPerSecond = 0.2f; + + protected override void OnUpdate() + { + var delta = Tick.deltaTime; + foreach (var (needs, brains, _) in Query.Chunks) + { + var n = needs.Span; + var b = brains.Span; + for (var i = 0; i < n.Length; i++) + { + ref var energy = ref n[i].Energy; + energy += + b[i].Action == PawnAction.Rest + ? RegenPerSecond * delta + : -DrainPerSecond * delta; + energy = Math.Clamp(energy, 0f, 1f); + } + } + } +} diff --git a/src/LittleSim/Sim/PlantOrganism.cs b/src/LittleSim/Sim/PlantOrganism.cs index 8860ce4..bcd97b6 100644 --- a/src/LittleSim/Sim/PlantOrganism.cs +++ b/src/LittleSim/Sim/PlantOrganism.cs @@ -1,108 +1,8 @@ -using System.Collections.Generic; using Friflo.Engine.ECS; using MrGameEng.Genetics; namespace LittleSim.Sim; -/// -/// Растительные признаки (фенотип), вычисленные из генома формулами генов (см. Defs/Genes/): -/// готовые значения, которые читают системы роста и жизненного цикла, не касаясь генов напрямую. -/// Имена полей соответствуют именам признаков в эффектах генов. -/// -public struct PlantPhenotype -{ - public float OptimalLight; - public float LightTolerance; - public float OptimalTemperature; - public float TemperatureTolerance; - public float ColdHardiness; - public float HeatHardiness; - public float OptimalFertility; - public float FertilityTolerance; - public float Vigor; - public float Lifespan; - public float DispersalRange; - public float ReproduceInterval; - public float SelfPollination; - public float MutationRate; - - // --- Контент (G4): плодоношение, добыча, цвет --- - public float FruitYield; - public int FruitSeason; - public float HarvestAmount; - public float LeafHue; - - // --- Защита (коэволюция с травоядными) --- - /// Токсичность [0..1]: отравляет поедателя; цена — замедление роста. - public float Toxicity; - - /// Шипы [0..1]: травмируют поедателя; цена — замедление роста. - public float Thorns; - - /// Вкусность [0..1]: обратная привлекательность для умных травоядных (избегание в C2). - public float Palatability; - - /// Морфа: рецессивный вариант (выраженное значение гена морфы ≈ 1) — другой тинт спрайта. - public bool IsVariant; - - /// - /// Четыре точки температурной трапеции роста (модель RimWorld): жёсткий минимум, нижний край плато, - /// верхний край плато, жёсткий максимум. Плато — оптимум ± толерантность; жёсткие края отстоят от - /// плато на морозо-/жаростойкость. Кормит . - /// - public readonly (float Min, float OptimalLow, float OptimalHigh, float Max) TemperatureBand() - { - var low = OptimalTemperature - TemperatureTolerance; - var high = OptimalTemperature + TemperatureTolerance; - return (low - ColdHardiness, low, high, high + HeatHardiness); - } - - /// Собирает фенотип из карты признаков, посчитанной . - public static PlantPhenotype FromTraits(IReadOnlyDictionary traits) - { - float T(string name) => traits.GetValueOrDefault(name); - return new PlantPhenotype - { - OptimalLight = T("optimalLight"), - LightTolerance = T("lightTolerance"), - OptimalTemperature = T("optimalTemperature"), - TemperatureTolerance = T("temperatureTolerance"), - ColdHardiness = T("coldHardiness"), - HeatHardiness = T("heatHardiness"), - OptimalFertility = T("optimalFertility"), - FertilityTolerance = T("fertilityTolerance"), - Vigor = T("vigor"), - Lifespan = T("lifespan"), - DispersalRange = T("dispersalRange"), - ReproduceInterval = T("reproduceInterval"), - SelfPollination = T("selfPollination"), - MutationRate = T("mutationRate"), - FruitYield = T("fruitYield"), - FruitSeason = (int)MathF.Round(Math.Clamp(T("fruitSeason"), 0f, 3f)), - HarvestAmount = T("harvestAmount"), - LeafHue = T("leafHue"), - Toxicity = T("toxicity"), - Thorns = T("thorns"), - Palatability = T("palatability"), - IsVariant = T("variant") >= 0.5f, - }; - } - - // Цена защиты: токсичность и шипы отнимают ресурсы у роста. Без этой платы отбор гнал бы защиту к - // максимуму у всех растений и коэволюция бы встала — а так под слабым выпасом выгоднее расти быстрее. - private const float ToxicityGrowthCost = 0.4f; - private const float ThornsGrowthCost = 0.3f; - private const float MinGrowthFactor = 0.2f; - - /// Множитель скорости роста с учётом цены защиты (1 — без защиты, ≥ MinGrowthFactor). - public readonly float DefenseGrowthFactor() => - Math.Clamp( - 1f - ToxicityGrowthCost * Toxicity - ThornsGrowthCost * Thorns, - MinGrowthFactor, - 1f - ); -} - /// /// Компонент-организм растения: его управляемый (для размножения, сейва и /// пересчёта) и выраженный фенотип , по которому работают системы. Заменяет @@ -117,26 +17,3 @@ public struct PlantOrganism : IComponent /// Выраженные признаки — то, что читают системы роста/жизненного цикла. public PlantPhenotype Traits; } - -/// -/// Плодоношение растения: сколько зрелых плодов сейчас на нём. Копится у зрелого растения в его -/// сезон плодоношения (признак ) до потолка -/// , вне сезона опадает. Управляется PlantFruitingSystem. -/// -public struct Fruiting : IComponent -{ - /// Накоплено зрелых плодов (0..FruitYield). - public float RipeFruit; -} - -/// -/// Накопленный температурный стресс растения в игровых днях. Пока температура вне диапазона роста -/// (трапеция даёт ноль — слишком холодно или жарко), -/// стресс копится; внутри диапазона — рассасывается. Превышение порога убивает растение -/// (вымерзание/перегрев). Управляется PlantLifecycleSystem. -/// -public struct TemperatureStress : IComponent -{ - /// Накоплено дней дормантности вне диапазона роста; при превышении лимита — гибель. - public float Days; -} diff --git a/src/LittleSim/Sim/PlantPhenotype.cs b/src/LittleSim/Sim/PlantPhenotype.cs new file mode 100644 index 0000000..bdde37b --- /dev/null +++ b/src/LittleSim/Sim/PlantPhenotype.cs @@ -0,0 +1,102 @@ +using System.Collections.Generic; + +namespace LittleSim.Sim; + +/// +/// Растительные признаки (фенотип), вычисленные из генома формулами генов (см. Defs/Genes/): +/// готовые значения, которые читают системы роста и жизненного цикла, не касаясь генов напрямую. +/// Имена полей соответствуют именам признаков в эффектах генов. +/// +public struct PlantPhenotype +{ + public float OptimalLight; + public float LightTolerance; + public float OptimalTemperature; + public float TemperatureTolerance; + public float ColdHardiness; + public float HeatHardiness; + public float OptimalFertility; + public float FertilityTolerance; + public float Vigor; + public float Lifespan; + public float DispersalRange; + public float ReproduceInterval; + public float SelfPollination; + public float MutationRate; + + // --- Контент (G4): плодоношение, добыча, цвет --- + public float FruitYield; + public int FruitSeason; + public float HarvestAmount; + public float LeafHue; + + // --- Защита (коэволюция с травоядными) --- + /// Токсичность [0..1]: отравляет поедателя; цена — замедление роста. + public float Toxicity; + + /// Шипы [0..1]: травмируют поедателя; цена — замедление роста. + public float Thorns; + + /// Вкусность [0..1]: обратная привлекательность для умных травоядных (избегание в C2). + public float Palatability; + + /// Морфа: рецессивный вариант (выраженное значение гена морфы ≈ 1) — другой тинт спрайта. + public bool IsVariant; + + /// + /// Четыре точки температурной трапеции роста (модель RimWorld): жёсткий минимум, нижний край плато, + /// верхний край плато, жёсткий максимум. Плато — оптимум ± толерантность; жёсткие края отстоят от + /// плато на морозо-/жаростойкость. Кормит . + /// + public readonly (float Min, float OptimalLow, float OptimalHigh, float Max) TemperatureBand() + { + var low = OptimalTemperature - TemperatureTolerance; + var high = OptimalTemperature + TemperatureTolerance; + return (low - ColdHardiness, low, high, high + HeatHardiness); + } + + /// Собирает фенотип из карты признаков, посчитанной . + public static PlantPhenotype FromTraits(IReadOnlyDictionary traits) + { + float T(string name) => traits.GetValueOrDefault(name); + return new PlantPhenotype + { + OptimalLight = T("optimalLight"), + LightTolerance = T("lightTolerance"), + OptimalTemperature = T("optimalTemperature"), + TemperatureTolerance = T("temperatureTolerance"), + ColdHardiness = T("coldHardiness"), + HeatHardiness = T("heatHardiness"), + OptimalFertility = T("optimalFertility"), + FertilityTolerance = T("fertilityTolerance"), + Vigor = T("vigor"), + Lifespan = T("lifespan"), + DispersalRange = T("dispersalRange"), + ReproduceInterval = T("reproduceInterval"), + SelfPollination = T("selfPollination"), + MutationRate = T("mutationRate"), + FruitYield = T("fruitYield"), + FruitSeason = (int)MathF.Round(Math.Clamp(T("fruitSeason"), 0f, 3f)), + HarvestAmount = T("harvestAmount"), + LeafHue = T("leafHue"), + Toxicity = T("toxicity"), + Thorns = T("thorns"), + Palatability = T("palatability"), + IsVariant = T("variant") >= 0.5f, + }; + } + + // Цена защиты: токсичность и шипы отнимают ресурсы у роста. Без этой платы отбор гнал бы защиту к + // максимуму у всех растений и коэволюция бы встала — а так под слабым выпасом выгоднее расти быстрее. + private const float ToxicityGrowthCost = 0.4f; + private const float ThornsGrowthCost = 0.3f; + private const float MinGrowthFactor = 0.2f; + + /// Множитель скорости роста с учётом цены защиты (1 — без защиты, ≥ MinGrowthFactor). + public readonly float DefenseGrowthFactor() => + Math.Clamp( + 1f - ToxicityGrowthCost * Toxicity - ThornsGrowthCost * Thorns, + MinGrowthFactor, + 1f + ); +} diff --git a/src/LittleSim/Sim/Selection.cs b/src/LittleSim/Sim/Selection.cs index 46e4602..cfcb760 100644 --- a/src/LittleSim/Sim/Selection.cs +++ b/src/LittleSim/Sim/Selection.cs @@ -28,109 +28,3 @@ public sealed class Selection /// Снять выделение. public void Clear() => EntityId = -1; } - -/// -/// Пикинг мышью: ЛКМ выбирает верхнюю сущность под курсором (ближайшую по ограничивающей окружности -/// спрайта), клик по пустому месте или ПКМ снимает выделение. Молчит, пока ввод захвачен оверлеем -/// (консоль/инспектор) или активен (меню-пауза), и игнорирует клики над -/// UI-панелями (). Выбор работает и на паузе — мир можно осматривать стоя. -/// -public sealed class SelectionSystem( - EntityStore store, - InputManager input, - Renderer2D renderer, - Selection selection, - Func blocked, - Func overUi -) : BaseSystem -{ - protected override void OnUpdateGroup() - { - if (blocked() || overUi()) - { - return; // меню-пауза/консоль или клик пришёлся на виджет UI — это не пикинг мира - } - - if (input.IsMousePressed(MouseButton.Right)) - { - selection.Clear(); - return; - } - - if (!input.IsMousePressed(MouseButton.Left)) - { - return; - } - - var mouse = input.MousePosition; - - var world = renderer.ScreenToWorld(new Vector2(mouse.X, mouse.Y)); - if (TryPick(world, out var id)) - { - selection.Select(id); - } - else - { - selection.Clear(); // клик по пустой земле снимает выделение - } - } - - // Ближайший осматриваемый объект (растение или животное), чья ограничивающая окружность спрайта - // накрывает точку мира. Запросы ограничены PlantGrowth/AnimalOrganism — так из пикинга выпадают - // спрайты рамки выделения, трупы и прочий не-контент. Из двух кандидатов берётся более близкий. - private bool TryPick(Vector2 world, out int id) - { - id = -1; - var best = float.MaxValue; - foreach ( - var (transforms, sprites, _, entities) in store - .Query() - .Chunks - ) - { - Scan(world, transforms.Span, sprites.Span, entities, ref id, ref best); - } - - foreach ( - var (transforms, sprites, _, entities) in store - .Query() - .Chunks - ) - { - Scan(world, transforms.Span, sprites.Span, entities, ref id, ref best); - } - - return id >= 0; - } - - // Обновляет лучшего кандидата (id/дистанцию) по чанку спрайтов: точка должна попасть в окружность. - private static void Scan( - Vector2 world, - Span transforms, - Span sprites, - ChunkEntities entities, - ref int id, - ref float best - ) - { - for (var i = 0; i < transforms.Length; i++) - { - if (sprites[i].Region is not { } region) - { - continue; - } - - var (center, radius) = CullingMath.SpriteBoundingCircle( - in transforms[i], - region, - sprites[i].Origin - ); - var distance = Vector2.DistanceSquared(center, world); - if (distance <= radius * radius && distance < best) - { - best = distance; - id = entities.EntityAt(i).Id; - } - } - } -} diff --git a/src/LittleSim/Sim/SelectionSystem.cs b/src/LittleSim/Sim/SelectionSystem.cs new file mode 100644 index 0000000..be1b691 --- /dev/null +++ b/src/LittleSim/Sim/SelectionSystem.cs @@ -0,0 +1,114 @@ +using System; +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using MrGameEng.Graphics; +using MrGameEng.Input; + +namespace LittleSim.Sim; + +/// +/// Пикинг мышью: ЛКМ выбирает верхнюю сущность под курсором (ближайшую по ограничивающей окружности +/// спрайта), клик по пустому месте или ПКМ снимает выделение. Молчит, пока ввод захвачен оверлеем +/// (консоль/инспектор) или активен (меню-пауза), и игнорирует клики над +/// UI-панелями (). Выбор работает и на паузе — мир можно осматривать стоя. +/// +public sealed class SelectionSystem( + EntityStore store, + InputManager input, + Renderer2D renderer, + Selection selection, + Func blocked, + Func overUi +) : BaseSystem +{ + protected override void OnUpdateGroup() + { + if (blocked() || overUi()) + { + return; // меню-пауза/консоль или клик пришёлся на виджет UI — это не пикинг мира + } + + if (input.IsMousePressed(MouseButton.Right)) + { + selection.Clear(); + return; + } + + if (!input.IsMousePressed(MouseButton.Left)) + { + return; + } + + var mouse = input.MousePosition; + + var world = renderer.ScreenToWorld(new Vector2(mouse.X, mouse.Y)); + if (TryPick(world, out var id)) + { + selection.Select(id); + } + else + { + selection.Clear(); // клик по пустой земле снимает выделение + } + } + + // Ближайший осматриваемый объект (растение или животное), чья ограничивающая окружность спрайта + // накрывает точку мира. Запросы ограничены PlantGrowth/AnimalOrganism — так из пикинга выпадают + // спрайты рамки выделения, трупы и прочий не-контент. Из двух кандидатов берётся более близкий. + private bool TryPick(Vector2 world, out int id) + { + id = -1; + var best = float.MaxValue; + foreach ( + var (transforms, sprites, _, entities) in store + .Query() + .Chunks + ) + { + Scan(world, transforms.Span, sprites.Span, entities, ref id, ref best); + } + + foreach ( + var (transforms, sprites, _, entities) in store + .Query() + .Chunks + ) + { + Scan(world, transforms.Span, sprites.Span, entities, ref id, ref best); + } + + return id >= 0; + } + + // Обновляет лучшего кандидата (id/дистанцию) по чанку спрайтов: точка должна попасть в окружность. + private static void Scan( + Vector2 world, + Span transforms, + Span sprites, + ChunkEntities entities, + ref int id, + ref float best + ) + { + for (var i = 0; i < transforms.Length; i++) + { + if (sprites[i].Region is not { } region) + { + continue; + } + + var (center, radius) = CullingMath.SpriteBoundingCircle( + in transforms[i], + region, + sprites[i].Origin + ); + var distance = Vector2.DistanceSquared(center, world); + if (distance <= radius * radius && distance < best) + { + best = distance; + id = entities.EntityAt(i).Id; + } + } + } +} diff --git a/src/LittleSim/Sim/SeparationSystem.cs b/src/LittleSim/Sim/SeparationSystem.cs new file mode 100644 index 0000000..d9ee231 --- /dev/null +++ b/src/LittleSim/Sim/SeparationSystem.cs @@ -0,0 +1,59 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; +using MrGameEng.Graphics; +using MrGameEng.Input; + +namespace LittleSim.Sim; + +/// +/// Расталкивание пересёкшихся круговых коллайдеров по парам кадра: животные не +/// наслаиваются друг на друга и выталкиваются из статичных препятствий (деревьев). +/// Статичность определяется отсутствием компонента . +/// Регистрировать после системы коллизий. +/// +public sealed class SeparationSystem(MrGameEng.Collisions.CollisionWorld world) : BaseSystem +{ + protected override void OnUpdateGroup() + { + foreach (var pair in world.Pairs) + { + ref var ta = ref pair.A.GetComponent(); + ref var tb = ref pair.B.GetComponent(); + var sum = + pair.A.GetComponent().Radius + + pair.B.GetComponent().Radius; + + var delta = tb.Position - ta.Position; + var distance = delta.Length(); + var overlap = sum - distance; + if (overlap <= 0f) + { + continue; + } + + var direction = distance > 0.001f ? delta / distance : Vector2.UnitX; + var aStatic = !pair.A.HasComponent(); + var bStatic = !pair.B.HasComponent(); + if (aStatic && bStatic) + { + continue; + } + + if (aStatic) + { + tb.Position += direction * overlap; + } + else if (bStatic) + { + ta.Position -= direction * overlap; + } + else + { + ta.Position -= direction * (overlap * 0.5f); + tb.Position += direction * (overlap * 0.5f); + } + } + } +} diff --git a/src/LittleSim/Sim/SimSystems.cs b/src/LittleSim/Sim/SimSystems.cs deleted file mode 100644 index b62de67..0000000 --- a/src/LittleSim/Sim/SimSystems.cs +++ /dev/null @@ -1,235 +0,0 @@ -using Friflo.Engine.ECS; -using Friflo.Engine.ECS.Systems; -using Microsoft.Xna.Framework; -using Microsoft.Xna.Framework.Input; -using MrGameEng.Graphics; -using MrGameEng.Input; - -namespace LittleSim.Sim; - -/// Житель бродит: направление меняется по таймеру, движение детерминировано сидом. -public struct Wander : IComponent -{ - public Vector2 Direction; - public float ChangeIn; -} - -/// Имя дефа жителя — нужно сохранению, чтобы при загрузке пересобрать спрайт из атласа. -public struct PawnId : IComponent -{ - public string DefName; -} - -/// -/// Блуждание жителей. Один Random на систему, сид от мира — при одинаковом сиде -/// и порядке обновления симуляция воспроизводима. Отдыхающие жители (см. -/// ) стоят на месте — таймер смены направления тоже замирает. -/// -public sealed class WanderSystem(int seed, RectF worldBounds) - : QuerySystem -{ - private const float Speed = 22f; - - private readonly Random _random = new(seed); - - protected override void OnUpdate() - { - var delta = Tick.deltaTime; - foreach (var (transforms, wanders, brains, _) in Query.Chunks) - { - var t = transforms.Span; - var w = wanders.Span; - var b = brains.Span; - for (var i = 0; i < t.Length; i++) - { - if (b[i].Action == PawnAction.Rest) - { - continue; - } - - ref var wander = ref w[i]; - wander.ChangeIn -= delta; - if (wander.ChangeIn <= 0f) - { - var angle = _random.NextSingle() * MathF.Tau; - wander.Direction = new Vector2(MathF.Cos(angle), MathF.Sin(angle)); - wander.ChangeIn = 1f + _random.NextSingle() * 3f; - } - - ref var position = ref t[i].Position; - position += wander.Direction * (Speed * delta); - position.X = Math.Clamp(position.X, worldBounds.Left, worldBounds.Right); - position.Y = Math.Clamp(position.Y, worldBounds.Top, worldBounds.Bottom); - } - } - } -} - -/// -/// Расталкивание пересёкшихся круговых коллайдеров по парам кадра: животные не -/// наслаиваются друг на друга и выталкиваются из статичных препятствий (деревьев). -/// Статичность определяется отсутствием компонента . -/// Регистрировать после системы коллизий. -/// -public sealed class SeparationSystem(MrGameEng.Collisions.CollisionWorld world) : BaseSystem -{ - protected override void OnUpdateGroup() - { - foreach (var pair in world.Pairs) - { - ref var ta = ref pair.A.GetComponent(); - ref var tb = ref pair.B.GetComponent(); - var sum = - pair.A.GetComponent().Radius - + pair.B.GetComponent().Radius; - - var delta = tb.Position - ta.Position; - var distance = delta.Length(); - var overlap = sum - distance; - if (overlap <= 0f) - { - continue; - } - - var direction = distance > 0.001f ? delta / distance : Vector2.UnitX; - var aStatic = !pair.A.HasComponent(); - var bStatic = !pair.B.HasComponent(); - if (aStatic && bStatic) - { - continue; - } - - if (aStatic) - { - tb.Position += direction * overlap; - } - else if (bStatic) - { - ta.Position -= direction * overlap; - } - else - { - ta.Position -= direction * (overlap * 0.5f); - tb.Position += direction * (overlap * 0.5f); - } - } - } -} - -/// -/// Камера бога: WASD/стрелки — панорамирование, колесо — зум к курсору (мировая точка под мышью -/// остаётся под мышью). Молчит, пока открыта консоль или активен -/// (например, открыто меню-пауза). -/// -public sealed class GodCameraSystem( - Entity cameraEntity, - InputManager input, - Renderer2D renderer, - MrGameEng.DevConsole.DevConsole? console, - Func? blocked = null -) : BaseSystem -{ - protected override void OnUpdateGroup() - { - if (console?.IsOpen == true || blocked?.Invoke() == true) - { - return; - } - - ref var camera = ref cameraEntity.GetComponent(); - - var pan = new Vector2( - Axis(input, Keys.A, Keys.Left, Keys.D, Keys.Right), - Axis(input, Keys.W, Keys.Up, Keys.S, Keys.Down) - ); - if (pan != Vector2.Zero) - { - pan.Normalize(); - camera.Position += pan * (500f / camera.Zoom) * Tick.deltaTime; - } - - if (input.WheelDelta != 0) - { - var oldZoom = camera.Zoom; - var newZoom = Math.Clamp(oldZoom * (1f + input.WheelDelta * 0.001f), 0.4f, 8f); - if (newZoom != oldZoom) - { - // Точка мира под курсором до зума; после — сдвигаем камеру, чтобы она осталась там же. - // Якорь и базовая позиция берутся из одного кадра камеры: WorldCenter — это позиция - // ПОСЛЕ клампа к границам (то, что реально нарисовано), а не сырой camera.Position — - // иначе у краёв карты/при широком обзоре зум «уезжает» от курсора. - var anchor = renderer.ScreenToWorld( - new Vector2(input.MousePosition.X, input.MousePosition.Y) - ); - var effective = renderer.Camera.WorldCenter; - camera.Zoom = newZoom; - camera.Position = anchor - (anchor - effective) * (oldZoom / newZoom); - } - } - } - - private static float Axis( - InputManager input, - Keys negativeA, - Keys negativeB, - Keys positiveA, - Keys positiveB - ) => - (input.IsKeyDown(positiveA) || input.IsKeyDown(positiveB) ? 1f : 0f) - - (input.IsKeyDown(negativeA) || input.IsKeyDown(negativeB) ? 1f : 0f); -} - -/// -/// Строка даты/времени/сезона в HUD (верх-слева). Обновляется ~4 раза в секунду; смена языка -/// подхватывается сама. Сид и FPS убраны (сид виден в меню, FPS — в окне производительности), -/// подсказки управления вынесены отдельной строкой сцены. -/// -public sealed class HudSystem( - MrGameEng.Core.EngineContext context, - MrGameEng.Mods.LanguageManager languages, - Myra.Graphics2D.UI.Label label, - MrGameEng.Core.Calendar? calendar = null, - MrGameEng.Core.Climate? climate = null -) : BaseSystem -{ - // Ключи локализации сезонов предсобраны (Spring..Winter), чтобы не строить строку - // "season."+ToString().ToLowerInvariant() на каждое обновление HUD. - private static readonly string[] SeasonKeys = BuildSeasonKeys(); - - private float _accumulated; - - private static string[] BuildSeasonKeys() - { - var names = Enum.GetNames(); - var keys = new string[names.Length]; - for (var i = 0; i < names.Length; i++) - { - keys[i] = "season." + names[i].ToLowerInvariant(); - } - - return keys; - } - - protected override void OnUpdateGroup() - { - _accumulated += context.Clock.UnscaledDeltaTime; - if (_accumulated < 0.25f) - { - return; - } - - _accumulated = 0f; - var text = calendar is null - ? "" - : languages.Format("hud.datetime", calendar.Day, calendar.Hour, calendar.Minute); - if (climate is not null) - { - var season = languages.Get(SeasonKeys[(int)climate.Season]); - text += - " · " - + languages.Format("hud.climate", season, (int)MathF.Round(climate.Temperature)); - } - - label.Text = text; - } -} diff --git a/src/LittleSim/Sim/TemperatureStress.cs b/src/LittleSim/Sim/TemperatureStress.cs new file mode 100644 index 0000000..6fc2d78 --- /dev/null +++ b/src/LittleSim/Sim/TemperatureStress.cs @@ -0,0 +1,15 @@ +using Friflo.Engine.ECS; + +namespace LittleSim.Sim; + +/// +/// Накопленный температурный стресс растения в игровых днях. Пока температура вне диапазона роста +/// (трапеция даёт ноль — слишком холодно или жарко), +/// стресс копится; внутри диапазона — рассасывается. Превышение порога убивает растение +/// (вымерзание/перегрев). Управляется PlantLifecycleSystem. +/// +public struct TemperatureStress : IComponent +{ + /// Накоплено дней дормантности вне диапазона роста; при превышении лимита — гибель. + public float Days; +} diff --git a/src/LittleSim/Sim/Wander.cs b/src/LittleSim/Sim/Wander.cs new file mode 100644 index 0000000..ce1d239 --- /dev/null +++ b/src/LittleSim/Sim/Wander.cs @@ -0,0 +1,15 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; +using MrGameEng.Graphics; +using MrGameEng.Input; + +namespace LittleSim.Sim; + +/// Житель бродит: направление меняется по таймеру, движение детерминировано сидом. +public struct Wander : IComponent +{ + public Vector2 Direction; + public float ChangeIn; +} diff --git a/src/LittleSim/Sim/WanderSystem.cs b/src/LittleSim/Sim/WanderSystem.cs new file mode 100644 index 0000000..4997379 --- /dev/null +++ b/src/LittleSim/Sim/WanderSystem.cs @@ -0,0 +1,53 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; +using MrGameEng.Graphics; +using MrGameEng.Input; + +namespace LittleSim.Sim; + +/// +/// Блуждание жителей. Один Random на систему, сид от мира — при одинаковом сиде +/// и порядке обновления симуляция воспроизводима. Отдыхающие жители (см. +/// ) стоят на месте — таймер смены направления тоже замирает. +/// +public sealed class WanderSystem(int seed, RectF worldBounds) + : QuerySystem +{ + private const float Speed = 22f; + + private readonly Random _random = new(seed); + + protected override void OnUpdate() + { + var delta = Tick.deltaTime; + foreach (var (transforms, wanders, brains, _) in Query.Chunks) + { + var t = transforms.Span; + var w = wanders.Span; + var b = brains.Span; + for (var i = 0; i < t.Length; i++) + { + if (b[i].Action == PawnAction.Rest) + { + continue; + } + + ref var wander = ref w[i]; + wander.ChangeIn -= delta; + if (wander.ChangeIn <= 0f) + { + var angle = _random.NextSingle() * MathF.Tau; + wander.Direction = new Vector2(MathF.Cos(angle), MathF.Sin(angle)); + wander.ChangeIn = 1f + _random.NextSingle() * 3f; + } + + ref var position = ref t[i].Position; + position += wander.Direction * (Speed * delta); + position.X = Math.Clamp(position.X, worldBounds.Left, worldBounds.Right); + position.Y = Math.Clamp(position.Y, worldBounds.Top, worldBounds.Bottom); + } + } + } +} diff --git a/src/LittleSim/Sim/WorldEvents.cs b/src/LittleSim/Sim/WorldEvents.cs index 9745093..e96062c 100644 --- a/src/LittleSim/Sim/WorldEvents.cs +++ b/src/LittleSim/Sim/WorldEvents.cs @@ -6,36 +6,6 @@ using MrGameEng.Mods; namespace LittleSim.Sim; -/// Категория события — определяет цвет строки в журнале/истории и группировку. -public enum EventCategory : byte -{ - /// Размножение: спаривание, беременность, роды, вылупление. - Reproduction, - - /// Охота и бой: начало охоты, убийство добычи. - Combat, - - /// Смерть: голод/жажда, старость, болезнь, хищник. - Death, - - /// Бытовое: кормёжка, водопой, бегство, смена стадии (только в истории особи). - Routine, -} - -/// -/// Одна запись журнала: готовая (локализованная) строка, категория, отметка времени календаря и id -/// «ответственной» сущности (для перехода камерой/выбора по двойному клику); -1 — не к кому переходить -/// (напр. смерть — особь исчезла). -/// -public readonly record struct LogEntry( - string Text, - EventCategory Category, - int Day, - int Hour, - int Minute, - int SubjectId -); - /// /// Журнал событий мира и фабрика записей истории особей (фича UI «События»/«История»). Системы-источники /// (спаривание/роды/охота/смерть/рост) зовут типизированные методы — те собирают локализованную строку, diff --git a/src/LittleSim/UI/CallbackSystem.cs b/src/LittleSim/UI/CallbackSystem.cs new file mode 100644 index 0000000..ed418fb --- /dev/null +++ b/src/LittleSim/UI/CallbackSystem.cs @@ -0,0 +1,17 @@ +using System; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using Myra.Graphics2D; +using Myra.Graphics2D.Brushes; +using Myra.Graphics2D.UI; + +namespace LittleSim.UI; + +/// +/// Минимальная система, вызывающая колбэк каждый кадр обновления. Сцены меню используют её +/// для опроса ввода (Esc/горячие клавиши) без отдельного класса-системы на каждую сцену. +/// +internal sealed class CallbackSystem(Action update) : BaseSystem +{ + protected override void OnUpdateGroup() => update(); +} diff --git a/src/LittleSim/UI/DevSpawner.cs b/src/LittleSim/UI/DevSpawner.cs index be726a8..032f582 100644 --- a/src/LittleSim/UI/DevSpawner.cs +++ b/src/LittleSim/UI/DevSpawner.cs @@ -255,45 +255,3 @@ public sealed class DevSpawner private string PlantLabel(string defName) => _content.Defs.TryGet(defName, out var def) ? def.Label : defName; } - -/// -/// Управление дев-спавнером: горячая клавиша F9 открывает/закрывает панель; при взведённом спавн- -/// инструменте ЛКМ по миру (не над UI) создаёт сущность в курсоре, ПКМ снимает инструмент. -/// -public sealed class DevSpawnerSystem(Renderer2D renderer, DevSpawner spawner, Func overUi) - : BaseSystem -{ - private bool _prevToggle; - private bool _prevLeft; - private bool _prevRight; - - protected override void OnUpdateGroup() - { - var keyboard = Keyboard.GetState(); - var toggle = keyboard.IsKeyDown(Keys.F9); - if (toggle && !_prevToggle) - { - spawner.Toggle(); - } - - _prevToggle = toggle; - - var mouse = Mouse.GetState(); - var left = mouse.LeftButton == ButtonState.Pressed; - var right = mouse.RightButton == ButtonState.Pressed; - - if (spawner.ArmedSpawn && left && !_prevLeft && !overUi()) - { - var world = renderer.ScreenToWorld(new Vector2(mouse.X, mouse.Y)); - spawner.SpawnAt(world); - } - - if (right && !_prevRight) - { - spawner.Disarm(); - } - - _prevLeft = left; - _prevRight = right; - } -} diff --git a/src/LittleSim/UI/DevSpawnerSystem.cs b/src/LittleSim/UI/DevSpawnerSystem.cs new file mode 100644 index 0000000..8da91e0 --- /dev/null +++ b/src/LittleSim/UI/DevSpawnerSystem.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using LittleSim.Sim; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; +using MrGameEng.Graphics; +using Myra.Graphics2D; +using Myra.Graphics2D.Brushes; +using Myra.Graphics2D.UI; + +namespace LittleSim.UI; + +/// +/// Управление дев-спавнером: горячая клавиша F9 открывает/закрывает панель; при взведённом спавн- +/// инструменте ЛКМ по миру (не над UI) создаёт сущность в курсоре, ПКМ снимает инструмент. +/// +public sealed class DevSpawnerSystem(Renderer2D renderer, DevSpawner spawner, Func overUi) + : BaseSystem +{ + private bool _prevToggle; + private bool _prevLeft; + private bool _prevRight; + + protected override void OnUpdateGroup() + { + var keyboard = Keyboard.GetState(); + var toggle = keyboard.IsKeyDown(Keys.F9); + if (toggle && !_prevToggle) + { + spawner.Toggle(); + } + + _prevToggle = toggle; + + var mouse = Mouse.GetState(); + var left = mouse.LeftButton == ButtonState.Pressed; + var right = mouse.RightButton == ButtonState.Pressed; + + if (spawner.ArmedSpawn && left && !_prevLeft && !overUi()) + { + var world = renderer.ScreenToWorld(new Vector2(mouse.X, mouse.Y)); + spawner.SpawnAt(world); + } + + if (right && !_prevRight) + { + spawner.Disarm(); + } + + _prevLeft = left; + _prevRight = right; + } +} diff --git a/src/LittleSim/UI/InspectPanel.cs b/src/LittleSim/UI/InspectPanel.cs index 7e694de..8ae872e 100644 --- a/src/LittleSim/UI/InspectPanel.cs +++ b/src/LittleSim/UI/InspectPanel.cs @@ -704,62 +704,3 @@ internal sealed class InspectPanel ? _content.Languages.Get(product.Label) : productDefName; } - -/// -/// Полоса-индикатор показателя в стиле RimWorld: тёмная дорожка фиксированной ширины, цветная заливка -/// длиной fraction, поверх — подпись слева и значение справа. Самодостаточный виджет (ничего не -/// обновляет — пересоздаётся при каждой пересборке тела панели), цвет заливки задаёт вызывающий. -/// -internal sealed class StatBar : Panel -{ - private static readonly IBrush Track = new SolidBrush(new Color(28, 34, 44)); - private static readonly IBrush TrackBorder = new SolidBrush(new Color(64, 78, 98)); - private const int BarHeight = 22; - - public StatBar(int width, string name, float fraction, string value, Color fill) - { - Width = width; - Height = BarHeight; - Background = Track; - Border = TrackBorder; - BorderThickness = new Thickness(1); - - var fillWidth = (int)MathF.Round((width - 2) * Math.Clamp(fraction, 0f, 1f)); - if (fillWidth > 0) - { - Widgets.Add( - new Panel - { - Background = new SolidBrush(fill), - Width = fillWidth, - Height = BarHeight - 2, - Left = 1, - Top = 1, - HorizontalAlignment = HorizontalAlignment.Left, - VerticalAlignment = VerticalAlignment.Top, - } - ); - } - - Widgets.Add( - new Label - { - Text = name, - TextColor = Color.White, - Left = 8, - HorizontalAlignment = HorizontalAlignment.Left, - VerticalAlignment = VerticalAlignment.Center, - } - ); - Widgets.Add( - new Label - { - Text = value, - TextColor = Color.White, - Margin = new Thickness(0, 0, 8, 0), - HorizontalAlignment = HorizontalAlignment.Right, - VerticalAlignment = VerticalAlignment.Center, - } - ); - } -} diff --git a/src/LittleSim/UI/Menu.cs b/src/LittleSim/UI/Menu.cs index 258da48..10368e7 100644 --- a/src/LittleSim/UI/Menu.cs +++ b/src/LittleSim/UI/Menu.cs @@ -175,12 +175,3 @@ internal static class Ui return panel; } } - -/// -/// Минимальная система, вызывающая колбэк каждый кадр обновления. Сцены меню используют её -/// для опроса ввода (Esc/горячие клавиши) без отдельного класса-системы на каждую сцену. -/// -internal sealed class CallbackSystem(Action update) : BaseSystem -{ - protected override void OnUpdateGroup() => update(); -} diff --git a/src/LittleSim/UI/ScaledUiExtensions.cs b/src/LittleSim/UI/ScaledUiExtensions.cs new file mode 100644 index 0000000..1589061 --- /dev/null +++ b/src/LittleSim/UI/ScaledUiExtensions.cs @@ -0,0 +1,20 @@ +using LittleSim.App; +using Microsoft.Xna.Framework; +using MrGameEng.Core; +using MrGameEng.Graphics; +using MrGameEng.UI; +using Myra.Graphics2D.UI; + +namespace LittleSim.UI; + +/// Расширение сцены: создаёт Myra-десктоп и сразу применяет масштаб UI из настроек. +internal static class ScaledUiExtensions +{ + /// Как UseUI, но десктоп уже масштабирован по . + public static Desktop UseScaledUI(this Scene scene) + { + var desktop = scene.UseUI(); + UiScaling.Apply(scene.Context, desktop, GameSettingsStore.Load().UiScale); + return desktop; + } +} diff --git a/src/LittleSim/UI/StatBar.cs b/src/LittleSim/UI/StatBar.cs new file mode 100644 index 0000000..8fafaff --- /dev/null +++ b/src/LittleSim/UI/StatBar.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using Friflo.Engine.ECS; +using LittleSim.Content; +using LittleSim.Sim; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Graphics; +using MrGameEng.Mods; +using Myra.Graphics2D; +using Myra.Graphics2D.Brushes; +using Myra.Graphics2D.UI; + +namespace LittleSim.UI; + +/// +/// Полоса-индикатор показателя в стиле RimWorld: тёмная дорожка фиксированной ширины, цветная заливка +/// длиной fraction, поверх — подпись слева и значение справа. Самодостаточный виджет (ничего не +/// обновляет — пересоздаётся при каждой пересборке тела панели), цвет заливки задаёт вызывающий. +/// +internal sealed class StatBar : Panel +{ + private static readonly IBrush Track = new SolidBrush(new Color(28, 34, 44)); + private static readonly IBrush TrackBorder = new SolidBrush(new Color(64, 78, 98)); + private const int BarHeight = 22; + + public StatBar(int width, string name, float fraction, string value, Color fill) + { + Width = width; + Height = BarHeight; + Background = Track; + Border = TrackBorder; + BorderThickness = new Thickness(1); + + var fillWidth = (int)MathF.Round((width - 2) * Math.Clamp(fraction, 0f, 1f)); + if (fillWidth > 0) + { + Widgets.Add( + new Panel + { + Background = new SolidBrush(fill), + Width = fillWidth, + Height = BarHeight - 2, + Left = 1, + Top = 1, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Top, + } + ); + } + + Widgets.Add( + new Label + { + Text = name, + TextColor = Color.White, + Left = 8, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + } + ); + Widgets.Add( + new Label + { + Text = value, + TextColor = Color.White, + Margin = new Thickness(0, 0, 8, 0), + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Center, + } + ); + } +} diff --git a/src/LittleSim/UI/UiScaling.cs b/src/LittleSim/UI/UiScaling.cs index 887b1e3..83d4402 100644 --- a/src/LittleSim/UI/UiScaling.cs +++ b/src/LittleSim/UI/UiScaling.cs @@ -33,15 +33,3 @@ internal static class UiScaling }; } } - -/// Расширение сцены: создаёт Myra-десктоп и сразу применяет масштаб UI из настроек. -internal static class ScaledUiExtensions -{ - /// Как UseUI, но десктоп уже масштабирован по . - public static Desktop UseScaledUI(this Scene scene) - { - var desktop = scene.UseUI(); - UiScaling.Apply(scene.Context, desktop, GameSettingsStore.Load().UiScale); - return desktop; - } -}