Животные A2: нужды и ИИ (выбор + исполнение)
AnimalNeeds (голод/жажда/отдых) падают по гену метаболизма. Два слоя ИИ: AnimalDecisionSystem (движковый UtilityAi выбирает действие и ищет ближайшую еду/воду) и AnimalActionSystem (движение к цели со скоростью по гену + утоление: выедание растений с гибелью выеденной травы, питьё у кромки воды, сон). AnimalAppearanceSystem тускнеет с острой нуждой. Кромка воды считается из террейна; команда popstats для наблюдаемости дрейфа генов. Сборка чистая. Контур ёмкости среды частичный — смерть от голода придёт в A3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
5e61491960
commit
e35e620655
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user