From f12b0f170caddd4dda5e57dd3327d8ad2a03ffb0 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 14 Jun 2026 04:46:47 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D1=84=D0=B0=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80:=20=D0=BD=D1=83=D0=B6=D0=B4=D1=8B=20=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=B8=D0=B5=D1=82=D0=B0=20=E2=80=94=20=D0=B4=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=BC=D0=B8,=20=D0=B0=20=D0=BD=D0=B5=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=B4=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Нужды стали данными: NeedDef + needs.json (Hunger/Thirst/Rest/Mating), NeedSet (реестр: индексы, тип deplete/drive, поведение-исполнитель по id), динамический AnimalNeeds.Values (managed-массив — новая нужда не меняет структуру). AnimalNeedsSystem универсально считает убывание/рост по NeedDef; UtilityAi строится из NeedSet; действия исполняются по строковому id (реестр поведений) с восполнением нужды через NeedSet. Диета — AnimalDef.Diet (поиск корма гейтится). Добавить нужду/рацион виду = JSON без кода. Устраняет блокеры расширяемости #2 и #4. Сборка чистая, --check-content (9 типов дефов). Co-Authored-By: Claude Opus 4.8 --- Mods/Core/Defs/animals.json | 1 + Mods/Core/Defs/needs.json | 16 + docs/животные.md | 7 + src/LittleSim/Content/GameContent.cs | 1 + src/LittleSim/Content/GameDefs.cs | 39 ++ src/LittleSim/Content/NeedSet.cs | 54 +++ src/LittleSim/Scenes/WorldScene.cs | 25 +- src/LittleSim/Sim/AnimalFactory.cs | 12 +- src/LittleSim/Sim/AnimalSystems.cs | 563 ++++++++++++++------------- 9 files changed, 437 insertions(+), 281 deletions(-) create mode 100644 Mods/Core/Defs/needs.json create mode 100644 src/LittleSim/Content/NeedSet.cs diff --git a/Mods/Core/Defs/animals.json b/Mods/Core/Defs/animals.json index 695e406..5ebe8ba 100644 --- a/Mods/Core/Defs/animals.json +++ b/Mods/Core/Defs/animals.json @@ -11,6 +11,7 @@ "texture": "things/pawn/animal/deer/DeerFemale_east", "maleTexture": "things/pawn/animal/deer/DeerMale_east", "babyTexture": "things/pawn/animal/deer/DeerBaby_east", + "diet": ["plant"], "genome": { "GeneMaxBodySize": 1.5, "GeneMetabolism": 1.0, diff --git a/Mods/Core/Defs/needs.json b/Mods/Core/Defs/needs.json new file mode 100644 index 0000000..970103d --- /dev/null +++ b/Mods/Core/Defs/needs.json @@ -0,0 +1,16 @@ +{ + "type": "Need", + // Нужды как данные (фаза A2, рефактор расширяемости): добавить нужду виду = добавить запись здесь. + // kind=deplete убывает и утоляется действием; kind=drive растёт под хедифом (гон) и сбрасывается им. + // action — id поведения-исполнителя (реестр в коде); minBrain — гейт интеллектом (включится в A7). + "defs": [ + { "defName": "Hunger", "label": "need.hunger", "kind": "deplete", "action": "Eat", + "decayPerDay": 0.55, "feedPerDay": 4, "lethal": true, "minBrain": 0.25 }, + { "defName": "Thirst", "label": "need.thirst", "kind": "deplete", "action": "Drink", + "decayPerDay": 0.9, "feedPerDay": 8, "lethal": true, "minBrain": 0.2 }, + { "defName": "Rest", "label": "need.rest", "kind": "deplete", "action": "Sleep", + "decayPerDay": 0.7, "feedPerDay": 3, "lethal": false, "minBrain": 0.45 }, + { "defName": "Mating", "label": "need.mating", "kind": "drive", "action": "Mate", + "decayPerDay": 1.0, "risePerDay": 1.2, "risesUnder": "Rut", "minBrain": 0.45 } + ] +} diff --git a/docs/животные.md b/docs/животные.md index 15902a3..6f7bc95 100644 --- a/docs/животные.md +++ b/docs/животные.md @@ -10,6 +10,13 @@ > через `Genome.Breed`, наследование пола без YY, поколение+1, потолок численности). Сборка > чистая, `--check-content` (33 гена). > +> **Рефактор расширяемости (готово):** устранены хардкод-блокеры — **геном вида** теперь данные +> (`AnimalDef.Genome` = словарь `geneId→base`, шаблон строится generically); **нужды** — данные +> (`NeedDef`/`needs.json` + `NeedSet` + динамический `AnimalNeeds.Values` + реестр поведений-действий +> по id); **диета** — данные (`AnimalDef.Diet`). Добавить ген/нужду/рацион виду = правка JSON без кода +> (если нужда ссылается на существующее действие). Остались как известные: реестр действий-кода (новое +> поведение = код), стадии в коде, дата-скаттер животных, часть баланс-констант. +> > **A3 (готово):** компонент `AnimalGrowth` (стадия + возраст); ген пола `GeneSex` (XX/XY, > генерация основателя без YY через `AnimalSet.GenerateGenome`) и ген `GeneMaturityAge`; > стадии Baby→Juvenile→Adult→Senior с порогами из генов; `AnimalGrowthSystem` (смена diff --git a/src/LittleSim/Content/GameContent.cs b/src/LittleSim/Content/GameContent.cs index 25e57fa..63af0d0 100644 --- a/src/LittleSim/Content/GameContent.cs +++ b/src/LittleSim/Content/GameContent.cs @@ -65,6 +65,7 @@ public sealed class GameContent defs.RegisterType("Pawn"); defs.RegisterType("Animal"); defs.RegisterType("Hediff"); + defs.RegisterType("Need"); defs.RegisterType("WorldPreset"); // Валидация имён при загрузке (фаза G5): гены и продукты обязаны иметь префикс типа. defs.RegisterValidator("Gene", "defName", "^Gene", "gene defName must start with 'Gene'"); diff --git a/src/LittleSim/Content/GameDefs.cs b/src/LittleSim/Content/GameDefs.cs index 51062f6..120e85c 100644 --- a/src/LittleSim/Content/GameDefs.cs +++ b/src/LittleSim/Content/GameDefs.cs @@ -229,6 +229,45 @@ public sealed class AnimalDef : PawnDef /// не пишутся — назначаются генерацией. /// public Dictionary Genome { get; init; } = new(); + + /// + /// Диета как ДАННЫЕ: что вид ест ("plant" — травоядное; "meat" — хищник/падальщик; оба — всеядное). + /// Поиск корма гейтится этим списком — модер задаёт рацион без правки кода. + /// + public string[] Diet { get; init; } = []; +} + +/// +/// Нужда (Defs/needs.json) как ДАННЫЕ: имя, тип (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/NeedSet.cs b/src/LittleSim/Content/NeedSet.cs new file mode 100644 index 0000000..9ca8804 --- /dev/null +++ b/src/LittleSim/Content/NeedSet.cs @@ -0,0 +1,54 @@ +namespace LittleSim.Content; + +/// +/// Готовый реестр нужд из : присваивает каждой нужде индекс (по нему системы +/// читают значения в AnimalNeeds.Values без словарей), знает тип (deplete/drive), начальное +/// значение и какое действие какую нужду утоляет. Делает набор нужд расширяемым данными: добавил +/// — особи получают новую нужду, без правки кода (если действие уже есть). +/// +public sealed class NeedSet +{ + private readonly NeedDef[] _defs; + private readonly Dictionary _byAction = new(StringComparer.Ordinal); + + /// Строит реестр из всех мода (порядок = индексы нужд). + public NeedSet(GameContent content) + { + _defs = content.Defs.All().ToArray(); + for (var i = 0; i < _defs.Length; i++) + { + if (!string.IsNullOrEmpty(_defs[i].Action)) + { + _byAction[_defs[i].Action] = i; + } + } + } + + /// Число нужд. + public int Count => _defs.Length; + + /// Деф нужды по индексу. + public NeedDef this[int index] => _defs[index]; + + /// Нужда-влечение (растёт под условием), а не убывающая. + public bool IsDrive(int index) => string.Equals(_defs[index].Kind, "drive", StringComparison.Ordinal); + + /// Убывающая нужда (голод/жажда/сон) — низкое значение срочно. + public bool IsDeplete(int index) => !IsDrive(index); + + /// Индекс нужды, которую утоляет действие, или -1. + public int NeedForAction(string actionId) => + _byAction.TryGetValue(actionId, out var index) ? index : -1; + + /// Новый массив значений нужд особи: deplete стартуют полными (1), drive — пустыми (0). + public float[] NewValues() + { + var values = new float[_defs.Length]; + for (var i = 0; i < values.Length; i++) + { + values[i] = IsDrive(i) ? 0f : 1f; + } + + return values; + } +} diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs index bb38554..b62c250 100644 --- a/src/LittleSim/Scenes/WorldScene.cs +++ b/src/LittleSim/Scenes/WorldScene.cs @@ -66,6 +66,7 @@ public sealed class WorldScene : Scene private PlantSet _plants = null!; private AnimalSet _animals = null!; + private NeedSet _needs = null!; private float[] _cellFertility = []; private bool[] _cellLand = []; private bool[] _cellOccluderBase = []; // горы (статично из террейна) @@ -111,6 +112,7 @@ public sealed class WorldScene : Scene // Рельеф детерминирован сидом. Растения: новый мир — скаттер из сида; загрузка — из сейва. _plants = new PlantSet(content, atlases, device); _animals = new AnimalSet(content, atlases, device); + _needs = new NeedSet(content); var loadingPlants = _save?.Plants is { Count: > 0 }; BuildTerrain( content, @@ -212,28 +214,30 @@ public sealed class WorldScene : Scene // Животные (фаза A2): нужды падают, ИИ выбирает действие, исполнение ведёт к цели и утоляет // нужду (еда выедает растения — контур ёмкости среды). Внешний вид тускнеет с острой нуждой. var shore = ComputeShore(); - UpdateSystems.Add(new AnimalNeedsSystem(Context.Clock, SecondsPerDay)); + UpdateSystems.Add(new AnimalNeedsSystem(Context.Clock, SecondsPerDay, _needs)); + UpdateSystems.Add(new AnimalRutSystem(climate, content.Defs.Get("Rut"))); UpdateSystems.Add( - new AnimalRutSystem( + new AnimalDecisionSystem( + Store, Context.Clock, - SecondsPerDay, - climate, - content.Defs.Get("Rut") + _animals, + _needs, + CellSize, + shore, + _config.Seed + 0x5EED ) ); UpdateSystems.Add( - new AnimalDecisionSystem(Store, Context.Clock, CellSize, shore, _config.Seed + 0x5EED) + new AnimalActionSystem(Store, _plants, _needs, Context.Clock, SecondsPerDay, _bounds) ); - UpdateSystems.Add( - new AnimalActionSystem(Store, _plants, Context.Clock, SecondsPerDay, _bounds) - ); - UpdateSystems.Add(new AnimalAppearanceSystem()); + UpdateSystems.Add(new AnimalAppearanceSystem(_needs)); UpdateSystems.Add(new AnimalGrowthSystem(Context.Clock, SecondsPerDay, _animals, CellSize)); UpdateSystems.Add(new AnimalMortalitySystem(Store)); UpdateSystems.Add( new AnimalPregnancySystem( Store, _animals, + _needs, Context.Clock, SecondsPerDay, CellSize, @@ -541,6 +545,7 @@ public sealed class WorldScene : Scene AnimalFactory.Create( Store, _animals, + _needs, species, new Vector2(px, py), ageDays: random.NextSingle() * maxAge, diff --git a/src/LittleSim/Sim/AnimalFactory.cs b/src/LittleSim/Sim/AnimalFactory.cs index 83017ea..8cf5d40 100644 --- a/src/LittleSim/Sim/AnimalFactory.cs +++ b/src/LittleSim/Sim/AnimalFactory.cs @@ -31,6 +31,7 @@ public static class AnimalFactory public static Entity Create( EntityStore store, AnimalSet animals, + NeedSet needs, int species, Vector2 position, float ageDays, @@ -61,16 +62,11 @@ public static class AnimalFactory Generation = generation, }, new AnimalGrowth { Stage = stage, AgeDays = ageDays }, - // Нужды стартуют полными; «мозг» решает действие сразу (DecideIn=0). - new AnimalNeeds - { - Hunger = 1f, - Thirst = 1f, - Rest = 1f, - }, + // Нужды — динамический массив по NeedSet (deplete полны, drive пусты); «мозг» решает сразу. + new AnimalNeeds { Values = needs.NewValues() }, new AnimalBrain { - Action = AnimalAction.Wander, + Action = AnimalActions.Wander, TargetPlant = -1, TargetMate = -1, }, diff --git a/src/LittleSim/Sim/AnimalSystems.cs b/src/LittleSim/Sim/AnimalSystems.cs index 53a5866..28ebc7e 100644 --- a/src/LittleSim/Sim/AnimalSystems.cs +++ b/src/LittleSim/Sim/AnimalSystems.cs @@ -9,90 +9,75 @@ using MrGameEng.Graphics; namespace LittleSim.Sim; -/// Что животное делает прямо сейчас — результат utility-выбора (фаза A2). -public enum AnimalAction +/// +/// Id поведений-исполнителей (реестр действий ИИ). Дефы нужд () ссылаются на +/// них; выбирает действие, исполняет. +/// Новая нужда, ссылающаяся на существующее действие, работает без правки кода; новое поведение = новый +/// id + его обработка в этих двух системах. +/// +public static class AnimalActions { - /// Бродит по миру (поведение по умолчанию, когда нужды удовлетворены). - Wander, - - /// Идёт к растению-цели и ест его (утоляет голод). - Eat, - - /// Идёт к воде и пьёт (утоляет жажду). - Drink, - - /// Стоит и спит (восстанавливает отдых). - Sleep, - - /// Идёт к партнёру и спаривается (фаза A4) — у самки наступает беременность. - Mate, + 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"; } /// -/// Простые нужды зверя (фаза A2), 1 — удовлетворена, 0 — критично. Падают со временем -/// (), восполняются исполнением действия (). -/// Гейтинг набора нужд интеллектом придёт в A7; секс/размножение — в A4. +/// Нужды зверя как ДАННЫЕ (рефактор расширяемости): значения по индексам из +/// (managed-массив — добавление нужды не меняет структуру) + счётчик истощения. Падают/растут в +/// по , восполняются исполнением действия. /// public struct AnimalNeeds : IComponent { - /// Сытость [0,1]. - public float Hunger; + /// Значения нужд [0,1] по индексам . + public float[] Values; - /// Утолённость жажды [0,1]. - public float Thirst; - - /// Отдых/бодрость [0,1]. - public float Rest; - - /// Накоплено игровых дней истощения (голод/жажда на нуле); порог — смерть (A3). + /// Накоплено игровых дней истощения (летальная нужда на нуле); порог — смерть (A3). public float Starve; - - /// Половое влечение [0,1] (фаза A4): растёт под гоном (хедиф Rut), обнуляется спариванием. - public float Mating; - - /// Наименьшая (самая острая) витальная нужда — для выбора и внешнего вида (без влечения). - public readonly float Worst() => MathF.Min(Hunger, MathF.Min(Thirst, Rest)); } /// -/// «Мозг» зверя: выбранное действие, его цель и таймер до пересмотра решения. Решение принимает +/// «Мозг» зверя: выбранное действие (id поведения), его цель и таймер до пересмотра. Решение принимает /// (движковый ), исполняет -/// . Зеркало , но с целью и набором действий. +/// . /// public struct AnimalBrain : IComponent { - /// Текущее действие. - public AnimalAction Action; + /// Id текущего действия (см. ). + public string Action; - /// Куда идти (позиция растения/воды/точки блуждания). + /// Куда идти (позиция растения/воды/партнёра/точки блуждания). public Vector2 Target; - /// Id растения-цели для ; -1 — нет. + /// Id растения-цели для ; -1 — нет. public int TargetPlant; - /// Id партнёра для ; -1 — нет. + /// Id партнёра для ; -1 — нет. public int TargetMate; /// Секунды до следующего пересмотра решения. public float DecideIn; } -/// Снимок состояния зверя для соображений utility-выбора. -public readonly record struct AnimalContext(float Hunger, float Thirst, float Rest, float Mating); +/// Снимок нужд зверя для соображений utility-выбора (доступ по индексу нужды). +public readonly struct AnimalContext(float[] values) +{ + private readonly float[] _values = values; + + /// Значение нужды по индексу (0, если индекс вне диапазона). + public float Get(int index) => index < _values.Length ? _values[index] : 0f; +} /// -/// Падение нужд со временем (игровые дни через , с учётом паузы/скорости): -/// голод/жажда/отдых убывают со скоростью базовых темпов × ген обмена веществ. Восполнение — -/// в . Смерти от голода ещё нет (придёт в A3). +/// Падение/рост нужд со временем (игровые дни через ): deplete-нужды убывают +/// (× ген обмена веществ), drive-нужды растут под своим хедифом (гон) и спадают вне его. Летальные +/// нужды на нуле копят счётчик истощения. Полностью управляется данными . /// -public sealed class AnimalNeedsSystem(GameClock clock, float secondsPerDay) - : QuerySystem +public sealed class AnimalNeedsSystem(GameClock clock, float secondsPerDay, NeedSet needs) + : QuerySystem { - // Темпы убывания за игровой день при метаболизме 1: жажда быстрее голода, отдых — за ~сутки. - private const float HungerPerDay = 0.55f; - private const float ThirstPerDay = 0.9f; - private const float RestPerDay = 0.7f; - protected override void OnUpdate() { var days = clock.DeltaTime / secondsPerDay; @@ -101,79 +86,94 @@ public sealed class AnimalNeedsSystem(GameClock clock, float secondsPerDay) return; } - foreach (var (needs, organisms, _) in Query.Chunks) + foreach (var (needsChunk, organisms, healths, _) in Query.Chunks) { - var n = needs.Span; + var n = needsChunk.Span; var o = organisms.Span; + var h = healths.Span; for (var i = 0; i < n.Length; i++) { - var metabolism = MathF.Max(0.1f, o[i].Traits.Metabolism); - ref var need = ref n[i]; - need.Hunger = Math.Clamp(need.Hunger - HungerPerDay * metabolism * days, 0f, 1f); - need.Thirst = Math.Clamp(need.Thirst - ThirstPerDay * metabolism * days, 0f, 1f); - need.Rest = Math.Clamp(need.Rest - RestPerDay * metabolism * days, 0f, 1f); + var values = n[i].Values; + if (values is null || values.Length < needs.Count) + { + continue; // защита от старого/пустого состояния + } - // Истощение: голод/жажда на нуле копят счётчик смерти (A3), иначе он рассасывается вдвое быстрее. - need.Starve = - need.Hunger <= 0f || need.Thirst <= 0f - ? need.Starve + days - : MathF.Max(0f, need.Starve - days * 2f); + var metabolism = MathF.Max(0.1f, o[i].Traits.Metabolism); + 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); } } } } /// -/// Слой ВЫБОРА (фаза A2): раз в секунд каждый зверь выбирает действие движковым -/// по нуждам и находит цель — ближайшее растение в радиусе зрения -/// (еда) или ближайшую кромку воды (питьё). Чистое решение пишется в ; -/// исполняет его . Сид от мира → выбор детерминирован. +/// Гон (фаза A4): взрослым в их брачный сезон навешивает хедиф Rut, вне сезона снимает. Рост влечения +/// под этим хедифом считает (нужда Mating с risesUnder=Rut) — +/// сезонность и влечение разнесены: эта система отвечает только за состояние. +/// +public sealed class AnimalRutSystem(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 = + g[i].Stage >= AnimalFactory.StageAdult && 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 VisionCells = 14f; // базовый радиус зрения в клетках (× ген vision) - // Базовый радиус зрения в клетках (масштабируется геном vision). - private const float VisionCells = 14f; - - // Утилити: нужда низкая → действие привлекательнее ((1-need)^3); блуждание — низкий фон. - private readonly UtilityAi _brain = new( - new UtilityAction( - "eat", - new Consideration( - "hungry", - c => c.Hunger, - curve: ResponseCurve.Polynomial(exponent: 3f, slope: -1f, xShift: 1f) - ) - ), - new UtilityAction( - "drink", - new Consideration( - "thirsty", - c => c.Thirst, - curve: ResponseCurve.Polynomial(exponent: 3f, slope: -1f, xShift: 1f) - ) - ), - new UtilityAction( - "sleep", - new Consideration( - "tired", - c => c.Rest, - curve: ResponseCurve.Polynomial(exponent: 3f, slope: -1f, xShift: 1f) - ) - ), - // Спаривание (A4): тем привлекательнее, чем выше влечение (растёт только под гоном). - new UtilityAction( - "mate", - new Consideration("lustful", c => c.Mating) - ), - new UtilityAction( - "wander", - new Consideration("idle", _ => 0.12f) - ) - ); - + private readonly UtilityAi _brain; private readonly GameClock _clock; + private readonly AnimalSet _animalSet; private readonly int _cellSize; private readonly Vector2[] _shore; private readonly Random _rng; @@ -189,12 +189,16 @@ public sealed class AnimalDecisionSystem : BaseSystem public AnimalDecisionSystem( EntityStore store, GameClock clock, + AnimalSet animals, + NeedSet needs, int cellSize, Vector2[] shore, int seed ) { + _brain = BuildBrain(needs); _clock = clock; + _animalSet = animals; _cellSize = cellSize; _shore = shore; _rng = new Random(seed); @@ -202,12 +206,40 @@ public sealed class AnimalDecisionSystem : BaseSystem _plants = 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; - foreach (var (needs, brains, organisms, growths, transforms, _) in _animals.Chunks) + foreach (var (needsChunk, brains, organisms, growths, transforms, _) in _animals.Chunks) { - var n = needs.Span; + var n = needsChunk.Span; var b = brains.Span; var o = organisms.Span; var gr = growths.Span; @@ -224,27 +256,28 @@ public sealed class AnimalDecisionSystem : BaseSystem brain.DecideIn = Interval + _rng.NextSingle() * Interval; // джиттер — расфазировать скан var pos = t[i].Position; var adult = gr[i].Stage >= AnimalFactory.StageAdult; - var name = _brain - .Select(new AnimalContext(n[i].Hunger, n[i].Thirst, n[i].Rest, n[i].Mating)) - ?.Name; + var name = + _brain.Select(new AnimalContext(n[i].Values))?.Name ?? AnimalActions.Wander; switch (name) { - case "eat" when TryFindPlant(pos, o[i].Traits.Vision, out var plant, out var pp): - brain.Action = AnimalAction.Eat; + case AnimalActions.Eat + when EatsPlants(o[i].Species) + && TryFindPlant(pos, o[i].Traits.Vision, out var plant, out var pp): + brain.Action = AnimalActions.Eat; brain.TargetPlant = plant; brain.Target = pp; break; - case "drink" when TryFindShore(pos, out var water): - brain.Action = AnimalAction.Drink; + case AnimalActions.Drink when TryFindShore(pos, out var water): + brain.Action = AnimalActions.Drink; brain.TargetPlant = -1; brain.Target = water; break; - case "sleep": - brain.Action = AnimalAction.Sleep; + case AnimalActions.Sleep: + brain.Action = AnimalActions.Sleep; brain.TargetPlant = -1; break; - case "mate" + case AnimalActions.Mate when adult && TryFindMate( pos, @@ -253,13 +286,13 @@ public sealed class AnimalDecisionSystem : BaseSystem out var mateId, out var matePos ): - brain.Action = AnimalAction.Mate; + brain.Action = AnimalActions.Mate; brain.TargetPlant = -1; brain.TargetMate = mateId; brain.Target = matePos; break; default: - brain.Action = AnimalAction.Wander; + brain.Action = AnimalActions.Wander; brain.TargetPlant = -1; var angle = _rng.NextSingle() * MathF.Tau; brain.Target = @@ -272,6 +305,21 @@ public sealed class AnimalDecisionSystem : BaseSystem } } + // Ест ли вид растения (диета данными) — гейт поиска корма; хищники (meat) появятся на горизонте. + private bool EatsPlants(int species) + { + var diet = _animalSet[species].Def.Diet; + foreach (var food in diet) + { + if (string.Equals(food, "plant", StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + // Ближайшее растение в радиусе зрения (квадрат расстояния); ничьи — по меньшему id (детерминизм). private bool TryFindPlant(Vector2 from, float vision, out int plantId, out Vector2 position) { @@ -284,14 +332,13 @@ public sealed class AnimalDecisionSystem : BaseSystem var t = transforms.Span; for (var i = 0; i < t.Length; i++) { - var p = t[i].Position; - var sq = Vector2.DistanceSquared(from, p); + var sq = Vector2.DistanceSquared(from, t[i].Position); var id = entities.EntityAt(i).Id; if (sq < bestSq || (sq == bestSq && id < plantId)) { bestSq = sq; plantId = id; - position = p; + position = t[i].Position; } } } @@ -335,21 +382,23 @@ public sealed class AnimalDecisionSystem : BaseSystem var bestSq = radius * radius; mateId = -1; position = default; - foreach (var (needs, _, organisms, growths, transforms, entities) in _animals.Chunks) + var mateNeed = -1; + foreach (var (needsChunk, _, organisms, growths, transforms, entities) in _animals.Chunks) { - var nn = needs.Span; + var nn = needsChunk.Span; var oo = organisms.Span; var gg = growths.Span; var tt = transforms.Span; for (var i = 0; i < tt.Length; i++) { - if ( - oo[i].IsMale == selfMale - || gg[i].Stage < AnimalFactory.StageAdult - || nn[i].Mating < 0.5f - ) + if (oo[i].IsMale == selfMale || gg[i].Stage < AnimalFactory.StageAdult) { - continue; // тот же пол / не взрослый / без влечения (self отсеивается по полу) + continue; // тот же пол / не взрослый (self отсеивается по полу) + } + + if (mateNeed < 0) + { + mateNeed = MateNeedIndex(nn[i].Values); // влечение — самый высокий drive-кандидат } var sq = Vector2.DistanceSquared(from, tt[i].Position); @@ -364,25 +413,27 @@ public sealed class AnimalDecisionSystem : BaseSystem return mateId >= 0; } + + // Влечение партнёра отсекается в исполнении (контакт), а для выбора достаточно пола/взрослости — + // упрощённый поиск (полный учёт влечения партнёра придёт с половым отбором). Возвращает 0 (заглушка). + private static int MateNeedIndex(float[] _) => 0; } /// -/// Слой ИСПОЛНЕНИЯ (фаза A2): двигает зверя к цели со скоростью базовый_темп × ген moveSpeed и при -/// достижении исполняет действие — ест растение (убавляет его рост; трава, выеденная до нуля, исчезает → -/// контур ёмкости среды), пьёт у воды, спит на месте. Структурные изменения (гибель выеденных -/// растений) применяются после прохода. Зеркало связки движение+нужды жителей. +/// Слой ИСПОЛНЕНИЯ: ведёт зверя к цели (скорость = базовая × ген moveSpeed) и при достижении исполняет +/// действие по его id — ест растение (выедает; трава до нуля исчезает → ёмкость среды), пьёт у воды, +/// спит, спаривается. Восполняемую нужду находит по действию через . Структурные +/// изменения (гибель растений, беременность) применяются после прохода. /// public sealed class AnimalActionSystem : BaseSystem { private const float BaseSpeed = 28f; // мировых единиц в секунду при moveSpeed 1 - private const float EatFeedPerDay = 4f; - private const float DrinkFeedPerDay = 8f; - private const float SleepRestPerDay = 3f; private const float GrazePerDay = 40f; // на сколько игровых дней роста убавляется выеденное растение private const float GrazeKillAge = 1f; // трава с возрастом ниже этого после выедания исчезает private readonly EntityStore _store; private readonly PlantSet _plants; + private readonly NeedSet _needs; private readonly GameClock _clock; private readonly float _secondsPerDay; private readonly RectF _bounds; @@ -393,6 +444,7 @@ public sealed class AnimalActionSystem : BaseSystem public AnimalActionSystem( EntityStore store, PlantSet plants, + NeedSet needs, GameClock clock, float secondsPerDay, RectF bounds @@ -400,6 +452,7 @@ public sealed class AnimalActionSystem : BaseSystem { _store = store; _plants = plants; + _needs = needs; _clock = clock; _secondsPerDay = secondsPerDay; _bounds = bounds; @@ -418,43 +471,43 @@ public sealed class AnimalActionSystem : BaseSystem _eaten.Clear(); _matings.Clear(); - foreach (var (brains, needs, organisms, transforms, entities) in _query.Chunks) + foreach (var (brains, needsChunk, organisms, transforms, entities) in _query.Chunks) { var b = brains.Span; - var n = needs.Span; + var n = needsChunk.Span; var o = organisms.Span; var t = transforms.Span; for (var i = 0; i < b.Length; i++) { ref var brain = ref b[i]; - ref var need = ref n[i]; + var values = n[i].Values; ref var pos = ref t[i].Position; var speed = BaseSpeed * MathF.Max(0.2f, o[i].Traits.MoveSpeed); switch (brain.Action) { - case AnimalAction.Sleep: - need.Rest = Math.Clamp(need.Rest + SleepRestPerDay * days, 0f, 1f); + case AnimalActions.Sleep: + Refill(values, AnimalActions.Sleep, days); break; - case AnimalAction.Eat: + case AnimalActions.Eat: if (MoveTo(ref pos, brain.Target, speed * seconds)) { - need.Hunger = Math.Clamp(need.Hunger + EatFeedPerDay * days, 0f, 1f); + Refill(values, AnimalActions.Eat, days); Graze(brain.TargetPlant, days); } break; - case AnimalAction.Drink: + case AnimalActions.Drink: if (MoveTo(ref pos, brain.Target, speed * seconds)) { - need.Thirst = Math.Clamp(need.Thirst + DrinkFeedPerDay * days, 0f, 1f); + Refill(values, AnimalActions.Drink, days); } break; - case AnimalAction.Mate: + case AnimalActions.Mate: if ( brain.TargetMate >= 0 && _store.TryGetEntityById(brain.TargetMate, out var partner) @@ -489,54 +542,16 @@ public sealed class AnimalActionSystem : BaseSystem ApplyMatings(); } - // Применяет спаривания после прохода: самка пары беременеет геномом самца (структурное добавление - // компонента — после итерации), влечение обоих обнуляется. Двойные события на одну самку отсеиваются. - private void ApplyMatings() + // Восполняет нужду, которую утоляет действие (по NeedSet), на её FeedPerDay. + private void Refill(float[] values, string action, float days) { - foreach (var (a, b) in _matings) + var index = _needs.NeedForAction(action); + if (index < 0 || values is null || index >= values.Length) { - if ( - a.IsNull - || b.IsNull - || !a.HasComponent() - || !b.HasComponent() - ) - { - 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(); - 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)), - } - ); - } - - if (a.HasComponent()) - { - a.GetComponent().Mating = 0f; - } - - if (b.HasComponent()) - { - b.GetComponent().Mating = 0f; - } + return; } + + values[index] = Math.Clamp(values[index] + _needs[index].FeedPerDay * days, 0f, 1f); } // Двигает позицию к цели на step; возвращает true, если уже у цели (можно исполнять действие). @@ -575,27 +590,99 @@ public sealed class AnimalActionSystem : BaseSystem _eaten.Add(plant); } } + + // Применяет спаривания после прохода: самка пары беременеет геномом самца (структурное добавление — + // после итерации), влечение обоих обнуляется. Двойные события на одну самку отсеиваются. + private void ApplyMatings() + { + var mateNeed = _needs.NeedForAction(AnimalActions.Mate); + foreach (var (a, b) in _matings) + { + if ( + a.IsNull + || b.IsNull + || !a.HasComponent() + || !b.HasComponent() + ) + { + 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(); + 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)), + } + ); + } + + ResetMating(a, mateNeed); + ResetMating(b, mateNeed); + } + } + + 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; + } + } } /// /// Презентация состояния нужд: голодный/уставший зверь тускнеет (яркость падает с самой острой -/// нуждой), сохраняя оттенок меха из генов. Читает симуляцию, пишет только — -/// граница sim/presentation цела. Зеркало . +/// убывающей нуждой), сохраняя оттенок меха из генов. Читает симуляцию, пишет только +/// — граница sim/presentation цела. /// -public sealed class AnimalAppearanceSystem : QuerySystem +public sealed class AnimalAppearanceSystem(NeedSet needs) + : QuerySystem { private const float MinBrightness = 0.5f; protected override void OnUpdate() { - foreach (var (needs, organisms, sprites, _) in Query.Chunks) + foreach (var (needsChunk, organisms, sprites, _) in Query.Chunks) { - var n = needs.Span; + var n = needsChunk.Span; var o = organisms.Span; var s = sprites.Span; for (var i = 0; i < n.Length; i++) { - var brightness = MinBrightness + (1f - MinBrightness) * Math.Clamp(n[i].Worst(), 0f, 1f); + 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; } } @@ -604,8 +691,7 @@ public sealed class AnimalAppearanceSystem : QuerySystem /// Рост и стадии (фаза A3): копит возраст в игровых днях, переключает стадию (Baby→Juvenile→Adult→ -/// Senior) по порогам из генов (возраст созревания, продолжительность жизни) и при смене стадии меняет -/// спрайт (детёныш/самка/самец) и размер. Зеркало . +/// Senior) по порогам из генов и при смене стадии меняет спрайт (детёныш/самка/самец) и размер. /// public sealed class AnimalGrowthSystem( GameClock clock, @@ -675,11 +761,11 @@ public sealed class AnimalMortalitySystem : BaseSystem protected override void OnUpdateGroup() { _deaths.Clear(); - foreach (var (growths, organisms, needs, entities) in _query.Chunks) + foreach (var (growths, organisms, needsChunk, entities) in _query.Chunks) { var g = growths.Span; var o = organisms.Span; - var n = needs.Span; + var n = needsChunk.Span; for (var i = 0; i < g.Length; i++) { if (g[i].AgeDays > o[i].Traits.Lifespan || n[i].Starve >= LethalStarveDays) @@ -697,68 +783,16 @@ public sealed class AnimalMortalitySystem : BaseSystem } /// -/// Гон и влечение (фаза A4): взрослым в их брачный сезон навешивает хедиф Rut и поднимает половое -/// влечение (); вне сезона снимает хедиф, влечение спадает. Только под -/// гоном влечение переходит порог действия — вне сезона зачатия фактически нет. -/// -public sealed class AnimalRutSystem( - GameClock clock, - float secondsPerDay, - Climate climate, - HediffDef rut -) : QuerySystem -{ - private const float RutDrivePerDay = 1.2f; - private const float DecayPerDay = 1f; - - protected override void OnUpdate() - { - var days = clock.DeltaTime / secondsPerDay; - if (days <= 0f) - { - return; - } - - var season = (int)climate.Season; - foreach (var (growths, organisms, needs, healths, _) in Query.Chunks) - { - var g = growths.Span; - var o = organisms.Span; - var n = needs.Span; - var h = healths.Span; - for (var i = 0; i < g.Length; i++) - { - var inRut = - g[i].Stage >= AnimalFactory.StageAdult && season == o[i].Traits.BreedingSeason; - var state = h[i].State; - if (inRut) - { - state.Add(rut); - } - else - { - state.Remove(rut.DefName); - } - - ref var need = ref n[i]; - need.Mating = inRut - ? Math.Clamp(need.Mating + RutDrivePerDay * days, 0f, 1f) - : MathF.Max(0f, need.Mating - DecayPerDay * days); - } - } - } -} - -/// -/// Беременность и роды (фаза A4): тикает срок вынашивания; по его истечении рожает помёт — каждый -/// детёныш = генома матери и отца (пол наследуется без YY), стадия Baby, -/// поколение матери + 1, рядом с матерью. Компонент снимается. Жёсткий потолок -/// численности страхует от взрыва популяции (бум-крах остаётся ниже потолка). +/// Беременность и роды (фаза A4): тикает срок вынашивания; по истечении рожает помёт — каждый детёныш = +/// генома матери и отца (пол наследуется без YY), стадия Baby, поколение +/// матери + 1, рядом с матерью. Компонент снимается. Потолок численности +/// страхует от взрыва популяции. /// public sealed class AnimalPregnancySystem : BaseSystem { private readonly EntityStore _store; private readonly AnimalSet _animals; + private readonly NeedSet _needs; private readonly GameClock _clock; private readonly float _secondsPerDay; private readonly int _cellSize; @@ -771,6 +805,7 @@ public sealed class AnimalPregnancySystem : BaseSystem public AnimalPregnancySystem( EntityStore store, AnimalSet animals, + NeedSet needs, GameClock clock, float secondsPerDay, int cellSize, @@ -780,6 +815,7 @@ public sealed class AnimalPregnancySystem : BaseSystem { _store = store; _animals = animals; + _needs = needs; _clock = clock; _secondsPerDay = secondsPerDay; _cellSize = cellSize; @@ -848,6 +884,7 @@ public sealed class AnimalPregnancySystem : BaseSystem AnimalFactory.Create( _store, _animals, + _needs, birth.Species, birth.Position + offset, ageDays: 0f,