From e35e6206559263fd8de3b2e715133e9942d04c60 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 14 Jun 2026 04:13:50 +0300 Subject: [PATCH] =?UTF-8?q?=D0=96=D0=B8=D0=B2=D0=BE=D1=82=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20A2:=20=D0=BD=D1=83=D0=B6=D0=B4=D1=8B=20=D0=B8=20=D0=98?= =?UTF-8?q?=D0=98=20(=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20+=20=D0=B8=D1=81?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AnimalNeeds (голод/жажда/отдых) падают по гену метаболизма. Два слоя ИИ: AnimalDecisionSystem (движковый UtilityAi выбирает действие и ищет ближайшую еду/воду) и AnimalActionSystem (движение к цели со скоростью по гену + утоление: выедание растений с гибелью выеденной травы, питьё у кромки воды, сон). AnimalAppearanceSystem тускнеет с острой нуждой. Кромка воды считается из террейна; команда popstats для наблюдаемости дрейфа генов. Сборка чистая. Контур ёмкости среды частичный — смерть от голода придёт в A3. Co-Authored-By: Claude Opus 4.8 --- docs/животные.md | 12 +- src/LittleSim/Scenes/WorldScene.cs | 108 +++++++ src/LittleSim/Sim/AnimalFactory.cs | 21 +- src/LittleSim/Sim/AnimalSystems.cs | 443 +++++++++++++++++++++++++++++ 4 files changed, 576 insertions(+), 8 deletions(-) create mode 100644 src/LittleSim/Sim/AnimalSystems.cs diff --git a/docs/животные.md b/docs/животные.md index 9162caa..244fc7a 100644 --- a/docs/животные.md +++ b/docs/животные.md @@ -1,14 +1,20 @@ # Животные (зоология) -> Статус: **A1 реализована**, остальное по плану. Это живой план — правится по мере +> Статус: **A1–A2 реализованы**, остальное по плану. Это живой план — правится по мере > реализации фаз A1–A8. Стиль и правила — как в [концепте](concept.md) и [модах](mods.md). > > **A1 (готово):** тип дефа `Animal` (`AnimalDef : PawnDef`) + `animals.json` (олень); > 8 животных генов в `genes.json`; `AnimalPhenotype`/`AnimalOrganism`; `AnimalSet` > (шаблон генома вида) + `AnimalFactory`; детерминированный спавн стада оленей по суше в > `WorldScene` с рендером и тинтом меха по гену; консольная команда `animal `. -> Проверено: `--check-content` (7 типов дефов, 28 генов). Визуальная проверка (олени в мире, -> команда `animal Deer`) — вручную: «Новый мир» → консоль. +> +> **A2 (готово):** нужды `AnimalNeeds` (голод/жажда/отдых) с падением по гену метаболизма; +> два слоя ИИ — `AnimalDecisionSystem` (движковый `UtilityAi`, выбор + поиск ближайшей +> еды/воды) и `AnimalActionSystem` (движение к цели + утоление: выедание растений, питьё у +> кромки воды, сон); `AnimalAppearanceSystem` (яркость по острой нужде); кромка воды из +> террейна; команда `popstats [species]` (средние признаков + размах поколений). Контур +> ёмкости среды частичный (трава выедается, но смерть от голода — в A3). Сборка чистая; +> визуальная проверка (олени ищут корм/воду, пасутся) — вручную: «Новый мир». ## Видение diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs index 238cfb8..0811b73 100644 --- a/src/LittleSim/Scenes/WorldScene.cs +++ b/src/LittleSim/Scenes/WorldScene.cs @@ -209,6 +209,18 @@ public sealed class WorldScene : Scene _config.Seed ) ); + // Животные (фаза A2): нужды падают, ИИ выбирает действие, исполнение ведёт к цели и утоляет + // нужду (еда выедает растения — контур ёмкости среды). Внешний вид тускнеет с острой нуждой. + var shore = ComputeShore(); + UpdateSystems.Add(new AnimalNeedsSystem(Context.Clock, SecondsPerDay)); + UpdateSystems.Add( + new AnimalDecisionSystem(Store, Context.Clock, CellSize, shore, _config.Seed + 0x5EED) + ); + UpdateSystems.Add( + new AnimalActionSystem(Store, _plants, Context.Clock, SecondsPerDay, _bounds) + ); + UpdateSystems.Add(new AnimalAppearanceSystem()); + UpdateSystems.Add( new GodCameraSystem(camera, input, renderer, console, () => _pause.IsOpen) ); @@ -518,6 +530,35 @@ public sealed class WorldScene : Scene Log.Info($"Spawned {placed} deer"); } + // Кромка воды: центры клеток суши, граничащих с водой — куда звери ходят пить (вода непроходима). + private Vector2[] ComputeShore() + { + var shore = new List(); + for (var y = 0; y < _config.Height; y++) + { + for (var x = 0; x < _config.Width; x++) + { + var cell = y * _config.Width + x; + if (!_cellLand[cell]) + { + continue; + } + + var nearWater = + (x > 0 && !_cellLand[cell - 1]) + || (x < _config.Width - 1 && !_cellLand[cell + 1]) + || (y > 0 && !_cellLand[cell - _config.Width]) + || (y < _config.Height - 1 && !_cellLand[cell + _config.Width]); + if (nearWater) + { + shore.Add(new Vector2((x + 0.5f) * CellSize, (y + 0.5f) * CellSize)); + } + } + } + + return shore.ToArray(); + } + // Текущая сетка окклюдеров для лайтмапа: статичные горы + клетки со зрелыми деревьями. private bool[] BuildOccluders() { @@ -621,6 +662,11 @@ public sealed class WorldScene : Scene "animal [seed] — sample an animal genome and show its gene-driven traits", (c, args) => RunAnimalDemo(c, content, args) ); + console.Register( + "popstats", + "popstats [species] — live population trait means and generation span (selection drift)", + (c, args) => RunPopStats(c, args) + ); console.Register( "menu", "menu — return to the main menu", @@ -762,6 +808,68 @@ public sealed class WorldScene : Scene ); } + // Наблюдаемость отбора (фаза 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 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; + 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.##}" + ); + } + + if (!any) + { + console.WriteLine(filter is null ? "no animals alive" : $"no '{filter}' alive"); + } + } + private static string ProductLabel(GameContent content, string productDefName) => content.Defs.TryGet(productDefName, out var product) ? content.Languages.Get(product.Label) diff --git a/src/LittleSim/Sim/AnimalFactory.cs b/src/LittleSim/Sim/AnimalFactory.cs index ea7d312..06b1d3e 100644 --- a/src/LittleSim/Sim/AnimalFactory.cs +++ b/src/LittleSim/Sim/AnimalFactory.cs @@ -34,7 +34,7 @@ public static class AnimalFactory var sprite = new Sprite(sp.Region, GameLayers.Beings); sprite.CenterOrigin(); - sprite.Color = Tint(traits); + sprite.Color = FurTint(traits); // Размер спрайта задаёт ген размера тела; деф-fallback — на случай отсутствия гена. var sizeCells = traits.BodySize > 0f ? traits.BodySize : sp.Def.SizeCells; @@ -48,13 +48,24 @@ public static class AnimalFactory Traits = traits, Species = species, Generation = generation, - } + }, + // Нужды стартуют полными; «мозг» решает действие сразу (DecideIn=0). + new AnimalNeeds + { + Hunger = 1f, + Thirst = 1f, + Rest = 1f, + }, + new AnimalBrain { Action = AnimalAction.Wander, TargetPlant = -1 } ); } - // Цвет животного из генов: лёгкий тёплый/холодный сдвиг меха (ген furHue) поверх текстуры — - // субтильный, чтобы не перекрашивать уже цветной спрайт зверя (как leafHue у растений). - private static Color Tint(in AnimalPhenotype traits) + /// + /// Цвет животного из генов: лёгкий тёплый/холодный сдвиг меха (ген furHue) поверх текстуры — + /// субтильный, чтобы не перекрашивать уже цветной спрайт зверя (как leafHue у растений). + /// Публичен, чтобы домножал его на яркость по нуждам. + /// + public static Color FurTint(in AnimalPhenotype traits) { var fur = Color.Lerp(FurWarm, FurCool, Math.Clamp(traits.FurHue, 0f, 1f)); return Color.Lerp(Color.White, fur, 0.5f); diff --git a/src/LittleSim/Sim/AnimalSystems.cs b/src/LittleSim/Sim/AnimalSystems.cs new file mode 100644 index 0000000..6bc5a16 --- /dev/null +++ b/src/LittleSim/Sim/AnimalSystems.cs @@ -0,0 +1,443 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// Что животное делает прямо сейчас — результат utility-выбора (фаза A2). +public enum AnimalAction +{ + /// Бродит по миру (поведение по умолчанию, когда нужды удовлетворены). + Wander, + + /// Идёт к растению-цели и ест его (утоляет голод). + Eat, + + /// Идёт к воде и пьёт (утоляет жажду). + Drink, + + /// Стоит и спит (восстанавливает отдых). + Sleep, +} + +/// +/// Простые нужды зверя (фаза A2), 1 — удовлетворена, 0 — критично. Падают со временем +/// (), восполняются исполнением действия (). +/// Гейтинг набора нужд интеллектом придёт в A7; секс/размножение — в A4. +/// +public struct AnimalNeeds : IComponent +{ + /// Сытость [0,1]. + public float Hunger; + + /// Утолённость жажды [0,1]. + public float Thirst; + + /// Отдых/бодрость [0,1]. + public float Rest; + + /// Наименьшая (самая острая) нужда — для выбора и внешнего вида. + public readonly float Worst() => MathF.Min(Hunger, MathF.Min(Thirst, Rest)); +} + +/// +/// «Мозг» зверя: выбранное действие, его цель и таймер до пересмотра решения. Решение принимает +/// (движковый ), исполняет +/// . Зеркало , но с целью и набором действий. +/// +public struct AnimalBrain : IComponent +{ + /// Текущее действие. + public AnimalAction Action; + + /// Куда идти (позиция растения/воды/точки блуждания). + public Vector2 Target; + + /// Id растения-цели для ; -1 — нет. + public int TargetPlant; + + /// Секунды до следующего пересмотра решения. + public float DecideIn; +} + +/// Снимок состояния зверя для соображений utility-выбора. +public readonly record struct AnimalContext(float Hunger, float Thirst, float Rest); + +/// +/// Падение нужд со временем (игровые дни через , с учётом паузы/скорости): +/// голод/жажда/отдых убывают со скоростью базовых темпов × ген обмена веществ. Восполнение — +/// в . Смерти от голода ещё нет (придёт в A3). +/// +public sealed class AnimalNeedsSystem(GameClock clock, float secondsPerDay) + : 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; + if (days <= 0f) + { + return; + } + + foreach (var (needs, organisms, _) in Query.Chunks) + { + var n = needs.Span; + var o = organisms.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); + } + } + } +} + +/// +/// Слой ВЫБОРА (фаза A2): раз в секунд каждый зверь выбирает действие движковым +/// по нуждам и находит цель — ближайшее растение в радиусе зрения +/// (еда) или ближайшую кромку воды (питьё). Чистое решение пишется в ; +/// исполняет его . Сид от мира → выбор детерминирован. +/// +public sealed class AnimalDecisionSystem : BaseSystem +{ + private const float Interval = 0.6f; + + // Базовый радиус зрения в клетках (масштабируется геном 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) + ) + ), + new UtilityAction( + "wander", + new Consideration("idle", _ => 0.12f) + ) + ); + + private readonly GameClock _clock; + private readonly int _cellSize; + private readonly Vector2[] _shore; + private readonly Random _rng; + private readonly ArchetypeQuery _animals; + private readonly ArchetypeQuery _plants; + + public AnimalDecisionSystem( + EntityStore store, + GameClock clock, + int cellSize, + Vector2[] shore, + int seed + ) + { + _clock = clock; + _cellSize = cellSize; + _shore = shore; + _rng = new Random(seed); + _animals = store.Query(); + _plants = store.Query(); + } + + protected override void OnUpdateGroup() + { + var delta = _clock.DeltaTime; + foreach (var (needs, brains, organisms, transforms, _) in _animals.Chunks) + { + var n = needs.Span; + var b = brains.Span; + var o = organisms.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 pos = t[i].Position; + var name = _brain + .Select(new AnimalContext(n[i].Hunger, n[i].Thirst, n[i].Rest)) + ?.Name; + + switch (name) + { + case "eat" when TryFindPlant(pos, o[i].Traits.Vision, out var plant, out var pp): + brain.Action = AnimalAction.Eat; + brain.TargetPlant = plant; + brain.Target = pp; + break; + case "drink" when TryFindShore(pos, out var water): + brain.Action = AnimalAction.Drink; + brain.TargetPlant = -1; + brain.Target = water; + break; + case "sleep": + brain.Action = AnimalAction.Sleep; + brain.TargetPlant = -1; + break; + default: + brain.Action = AnimalAction.Wander; + brain.TargetPlant = -1; + var angle = _rng.NextSingle() * MathF.Tau; + brain.Target = + pos + + new Vector2(MathF.Cos(angle), MathF.Sin(angle)) + * (_cellSize * (4f + _rng.NextSingle() * 6f)); + break; + } + } + } + } + + // Ближайшее растение в радиусе зрения (квадрат расстояния); ничьи — по меньшему id (детерминизм). + private bool TryFindPlant(Vector2 from, float vision, out int plantId, out Vector2 position) + { + var radius = VisionCells * MathF.Max(0.2f, vision) * _cellSize; + var bestSq = radius * radius; + plantId = -1; + position = default; + foreach (var (transforms, _, entities) in _plants.Chunks) + { + var t = transforms.Span; + for (var i = 0; i < t.Length; i++) + { + var p = t[i].Position; + var sq = Vector2.DistanceSquared(from, p); + var id = entities.EntityAt(i).Id; + if (sq < bestSq || (sq == bestSq && id < plantId)) + { + bestSq = sq; + plantId = id; + position = p; + } + } + } + + return plantId >= 0; + } + + // Ближайшая кромка воды (без лимита по зрению — звери «помнят» водопои; упрощение фазы A2). + private bool TryFindShore(Vector2 from, out Vector2 position) + { + position = default; + if (_shore.Length == 0) + { + return false; + } + + var bestSq = float.MaxValue; + foreach (var cell in _shore) + { + var sq = Vector2.DistanceSquared(from, cell); + if (sq < bestSq) + { + bestSq = sq; + position = cell; + } + } + + return true; + } +} + +/// +/// Слой ИСПОЛНЕНИЯ (фаза A2): двигает зверя к цели со скоростью базовый_темп × ген moveSpeed и при +/// достижении исполняет действие — ест растение (убавляет его рост; трава, выеденная до нуля, исчезает → +/// контур ёмкости среды), пьёт у воды, спит на месте. Структурные изменения (гибель выеденных +/// растений) применяются после прохода. Зеркало связки движение+нужды жителей. +/// +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 GameClock _clock; + private readonly float _secondsPerDay; + private readonly RectF _bounds; + private readonly ArchetypeQuery _query; + private readonly List _eaten = []; + + public AnimalActionSystem( + EntityStore store, + PlantSet plants, + GameClock clock, + float secondsPerDay, + RectF bounds + ) + { + _store = store; + _plants = plants; + _clock = clock; + _secondsPerDay = secondsPerDay; + _bounds = bounds; + _query = store.Query(); + } + + protected override void OnUpdateGroup() + { + var seconds = _clock.DeltaTime; + if (seconds <= 0f) + { + return; + } + + var days = seconds / _secondsPerDay; + _eaten.Clear(); + + foreach (var (brains, needs, organisms, transforms, _) in _query.Chunks) + { + var b = brains.Span; + var n = needs.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]; + 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); + break; + + case AnimalAction.Eat: + if (MoveTo(ref pos, brain.Target, speed * seconds)) + { + need.Hunger = Math.Clamp(need.Hunger + EatFeedPerDay * days, 0f, 1f); + Graze(brain.TargetPlant, days); + } + + break; + + case AnimalAction.Drink: + if (MoveTo(ref pos, brain.Target, speed * seconds)) + { + need.Thirst = Math.Clamp(need.Thirst + DrinkFeedPerDay * days, 0f, 1f); + } + + 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(); + } + } + + // Двигает позицию к цели на 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) + { + 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 - GrazePerDay * days); + var isGrass = _plants[grow.Species].Def.TrunkRadiusCells <= 0f; + if (isGrass && grow.AgeDays <= GrazeKillAge && !_eaten.Contains(plant)) + { + _eaten.Add(plant); + } + } +} + +/// +/// Презентация состояния нужд: голодный/уставший зверь тускнеет (яркость падает с самой острой +/// нуждой), сохраняя оттенок меха из генов. Читает симуляцию, пишет только — +/// граница sim/presentation цела. Зеркало . +/// +public sealed class AnimalAppearanceSystem : QuerySystem +{ + private const float MinBrightness = 0.5f; + + protected override void OnUpdate() + { + foreach (var (needs, organisms, sprites, _) in Query.Chunks) + { + var n = needs.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); + s[i].Color = AnimalFactory.FurTint(o[i].Traits) * brightness; + } + } + } +}