Животные A2: нужды и ИИ (выбор + исполнение)

AnimalNeeds (голод/жажда/отдых) падают по гену метаболизма. Два слоя ИИ: AnimalDecisionSystem (движковый UtilityAi выбирает действие и ищет ближайшую еду/воду) и AnimalActionSystem (движение к цели со скоростью по гену + утоление: выедание растений с гибелью выеденной травы, питьё у кромки воды, сон). AnimalAppearanceSystem тускнеет с острой нуждой. Кромка воды считается из террейна; команда popstats для наблюдаемости дрейфа генов.

Сборка чистая. Контур ёмкости среды частичный — смерть от голода придёт в A3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-14 04:13:50 +03:00
co-authored by Claude Opus 4.8
parent 5e61491960
commit e35e620655
4 changed files with 576 additions and 8 deletions
+108
View File
@@ -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<Vector2>();
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 <species> [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<AnimalOrganism>()
.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<ProductDef>(productDefName, out var product)
? content.Languages.Get(product.Label)
+16 -5
View File
@@ -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)
/// <summary>
/// Цвет животного из генов: лёгкий тёплый/холодный сдвиг меха (ген furHue) поверх текстуры —
/// субтильный, чтобы не перекрашивать уже цветной спрайт зверя (как leafHue у растений).
/// Публичен, чтобы <see cref="AnimalAppearanceSystem"/> домножал его на яркость по нуждам.
/// </summary>
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);
+443
View File
@@ -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;
/// <summary>Что животное делает прямо сейчас — результат utility-выбора (фаза A2).</summary>
public enum AnimalAction
{
/// <summary>Бродит по миру (поведение по умолчанию, когда нужды удовлетворены).</summary>
Wander,
/// <summary>Идёт к растению-цели и ест его (утоляет голод).</summary>
Eat,
/// <summary>Идёт к воде и пьёт (утоляет жажду).</summary>
Drink,
/// <summary>Стоит и спит (восстанавливает отдых).</summary>
Sleep,
}
/// <summary>
/// Простые нужды зверя (фаза A2), 1 — удовлетворена, 0 — критично. Падают со временем
/// (<see cref="AnimalNeedsSystem"/>), восполняются исполнением действия (<see cref="AnimalActionSystem"/>).
/// Гейтинг набора нужд интеллектом придёт в A7; секс/размножение — в A4.
/// </summary>
public struct AnimalNeeds : IComponent
{
/// <summary>Сытость [0,1].</summary>
public float Hunger;
/// <summary>Утолённость жажды [0,1].</summary>
public float Thirst;
/// <summary>Отдых/бодрость [0,1].</summary>
public float Rest;
/// <summary>Наименьшая (самая острая) нужда — для выбора и внешнего вида.</summary>
public readonly float Worst() => MathF.Min(Hunger, MathF.Min(Thirst, Rest));
}
/// <summary>
/// «Мозг» зверя: выбранное действие, его цель и таймер до пересмотра решения. Решение принимает
/// <see cref="AnimalDecisionSystem"/> (движковый <see cref="UtilityAi{TContext}"/>), исполняет
/// <see cref="AnimalActionSystem"/>. Зеркало <see cref="PawnBrain"/>, но с целью и набором действий.
/// </summary>
public struct AnimalBrain : IComponent
{
/// <summary>Текущее действие.</summary>
public AnimalAction Action;
/// <summary>Куда идти (позиция растения/воды/точки блуждания).</summary>
public Vector2 Target;
/// <summary>Id растения-цели для <see cref="AnimalAction.Eat"/>; -1 — нет.</summary>
public int TargetPlant;
/// <summary>Секунды до следующего пересмотра решения.</summary>
public float DecideIn;
}
/// <summary>Снимок состояния зверя для соображений utility-выбора.</summary>
public readonly record struct AnimalContext(float Hunger, float Thirst, float Rest);
/// <summary>
/// Падение нужд со временем (игровые дни через <see cref="GameClock"/>, с учётом паузы/скорости):
/// голод/жажда/отдых убывают со скоростью базовых темпов × ген обмена веществ. Восполнение —
/// в <see cref="AnimalActionSystem"/>. Смерти от голода ещё нет (придёт в A3).
/// </summary>
public sealed class AnimalNeedsSystem(GameClock clock, float secondsPerDay)
: QuerySystem<AnimalNeeds, AnimalOrganism>
{
// Темпы убывания за игровой день при метаболизме 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);
}
}
}
}
/// <summary>
/// Слой ВЫБОРА (фаза A2): раз в <see cref="Interval"/> секунд каждый зверь выбирает действие движковым
/// <see cref="UtilityAi{TContext}"/> по нуждам и находит цель — ближайшее растение в радиусе зрения
/// (еда) или ближайшую кромку воды (питьё). Чистое решение пишется в <see cref="AnimalBrain"/>;
/// исполняет его <see cref="AnimalActionSystem"/>. Сид от мира → выбор детерминирован.
/// </summary>
public sealed class AnimalDecisionSystem : BaseSystem
{
private const float Interval = 0.6f;
// Базовый радиус зрения в клетках (масштабируется геном vision).
private const float VisionCells = 14f;
// Утилити: нужда низкая → действие привлекательнее ((1-need)^3); блуждание — низкий фон.
private readonly UtilityAi<AnimalContext> _brain = new(
new UtilityAction<AnimalContext>(
"eat",
new Consideration<AnimalContext>(
"hungry",
c => c.Hunger,
curve: ResponseCurve.Polynomial(exponent: 3f, slope: -1f, xShift: 1f)
)
),
new UtilityAction<AnimalContext>(
"drink",
new Consideration<AnimalContext>(
"thirsty",
c => c.Thirst,
curve: ResponseCurve.Polynomial(exponent: 3f, slope: -1f, xShift: 1f)
)
),
new UtilityAction<AnimalContext>(
"sleep",
new Consideration<AnimalContext>(
"tired",
c => c.Rest,
curve: ResponseCurve.Polynomial(exponent: 3f, slope: -1f, xShift: 1f)
)
),
new UtilityAction<AnimalContext>(
"wander",
new Consideration<AnimalContext>("idle", _ => 0.12f)
)
);
private readonly GameClock _clock;
private readonly int _cellSize;
private readonly Vector2[] _shore;
private readonly Random _rng;
private readonly ArchetypeQuery<AnimalNeeds, AnimalBrain, AnimalOrganism, Transform2D> _animals;
private readonly ArchetypeQuery<Transform2D, PlantGrowth> _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<AnimalNeeds, AnimalBrain, AnimalOrganism, Transform2D>();
_plants = store.Query<Transform2D, PlantGrowth>();
}
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;
}
}
/// <summary>
/// Слой ИСПОЛНЕНИЯ (фаза A2): двигает зверя к цели со скоростью базовый_темп × ген moveSpeed и при
/// достижении исполняет действие — ест растение (убавляет его рост; трава, выеденная до нуля, исчезает →
/// контур ёмкости среды), пьёт у воды, спит на месте. Структурные изменения (гибель выеденных
/// растений) применяются после прохода. Зеркало связки движение+нужды жителей.
/// </summary>
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<AnimalBrain, AnimalNeeds, AnimalOrganism, Transform2D> _query;
private readonly List<Entity> _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<AnimalBrain, AnimalNeeds, AnimalOrganism, Transform2D>();
}
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<PlantGrowth>()
)
{
return;
}
ref var grow = ref plant.GetComponent<PlantGrowth>();
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);
}
}
}
/// <summary>
/// Презентация состояния нужд: голодный/уставший зверь тускнеет (яркость падает с самой острой
/// нуждой), сохраняя оттенок меха из генов. Читает симуляцию, пишет только <see cref="Sprite"/> —
/// граница sim/presentation цела. Зеркало <see cref="PawnAppearanceSystem"/>.
/// </summary>
public sealed class AnimalAppearanceSystem : QuerySystem<AnimalNeeds, AnimalOrganism, Sprite>
{
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;
}
}
}
}