Merge branch 'senses-memory' into main

Animal senses (sight/hearing/smell/touch) and memory: smell/touch capacities +
organs (reduced by hediffs/damage), acuity + detectability genes, multi-sense
threat/prey detection with a coarse smell zone, and a brain-bounded memory that
records threats and steers wandering animals away from danger zones.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-15 00:08:52 +03:00
co-authored by Claude Opus 4.8
11 changed files with 389 additions and 31 deletions
+24
View File
@@ -23,6 +23,12 @@
"GeneMoveSpeed": 1.2, "GeneMoveSpeed": 1.2,
"GeneBloodVolume": 1.4, "GeneBloodVolume": 1.4,
"GeneVision": 1.3, "GeneVision": 1.3,
"GeneHearing": 1.4,
"GeneSmell": 1.2,
"GeneTouch": 1.0,
"GeneCamouflage": 0.35,
"GeneScent": 0.5,
"GeneNoise": 0.4,
"GeneBrainSize": 0.45, "GeneBrainSize": 0.45,
"GeneInsulation": 0.55, "GeneInsulation": 0.55,
"GeneFurColor": 0.45, "GeneFurColor": 0.45,
@@ -59,6 +65,12 @@
"GeneMoveSpeed": 1.4, "GeneMoveSpeed": 1.4,
"GeneBloodVolume": 1.3, "GeneBloodVolume": 1.3,
"GeneVision": 1.5, "GeneVision": 1.5,
"GeneHearing": 1.5,
"GeneSmell": 1.8,
"GeneTouch": 1.0,
"GeneCamouflage": 0.2,
"GeneScent": 0.6,
"GeneNoise": 0.5,
"GeneBrainSize": 0.5, "GeneBrainSize": 0.5,
"GeneInsulation": 0.6, "GeneInsulation": 0.6,
"GeneFurColor": 0.7, "GeneFurColor": 0.7,
@@ -93,6 +105,12 @@
"GeneMoveSpeed": 1.0, "GeneMoveSpeed": 1.0,
"GeneBloodVolume": 1.2, "GeneBloodVolume": 1.2,
"GeneVision": 1.0, "GeneVision": 1.0,
"GeneHearing": 1.2,
"GeneSmell": 1.6,
"GeneTouch": 1.0,
"GeneCamouflage": 0.2,
"GeneScent": 0.7,
"GeneNoise": 0.7,
"GeneBrainSize": 0.42, "GeneBrainSize": 0.42,
"GeneInsulation": 0.5, "GeneInsulation": 0.5,
"GeneFurColor": 0.85, "GeneFurColor": 0.85,
@@ -126,6 +144,12 @@
"GeneMoveSpeed": 0.9, "GeneMoveSpeed": 0.9,
"GeneBloodVolume": 0.7, "GeneBloodVolume": 0.7,
"GeneVision": 1.1, "GeneVision": 1.1,
"GeneHearing": 1.0,
"GeneSmell": 0.6,
"GeneTouch": 1.0,
"GeneCamouflage": 0.2,
"GeneScent": 0.4,
"GeneNoise": 0.6,
"GeneBrainSize": 0.3, "GeneBrainSize": 0.3,
"GeneInsulation": 0.45, "GeneInsulation": 0.45,
"GeneFurColor": 0.6, "GeneFurColor": 0.6,
+4
View File
@@ -33,6 +33,10 @@
"capacities": { "Hearing": 0.5 } }, "capacities": { "Hearing": 0.5 } },
{ "name": "earRight", "parent": "head", "coverage": 0.01, "maxHp": 8, { "name": "earRight", "parent": "head", "coverage": 0.01, "maxHp": 8,
"capacities": { "Hearing": 0.5 } }, "capacities": { "Hearing": 0.5 } },
{ "name": "nose", "parent": "head", "coverage": 0.01, "maxHp": 8,
"capacities": { "Smell": 1.0 } },
{ "name": "skin", "coverage": 0.05, "maxHp": 20,
"capacities": { "Touch": 1.0 } },
{ "name": "jaw", "parent": "head", "coverage": 0.02, "maxHp": 10, { "name": "jaw", "parent": "head", "coverage": 0.02, "maxHp": 10,
"capacities": { "Eating": 1.0, "Talking": 0.5 } }, "capacities": { "Eating": 1.0, "Talking": 0.5 } },
{ "name": "tongue", "parent": "head", "coverage": 0.01, "maxHp": 8, { "name": "tongue", "parent": "head", "coverage": 0.01, "maxHp": 8,
+23
View File
@@ -18,6 +18,29 @@
{ "defName": "GeneVision", "parent": "BaseNumericGene", "label": "gene.vision", { "defName": "GeneVision", "parent": "BaseNumericGene", "label": "gene.vision",
"default": 1.0, "min": 0.2, "max": 3.0, "tags": ["animal", "senses"], "default": 1.0, "min": 0.2, "max": 3.0, "tags": ["animal", "senses"],
"effects": { "vision": "value" } }, "effects": { "vision": "value" } },
// --- Чувства (острота = множитель радиуса восприятия) и заметность (как видят/слышат/чуют ЭТУ особь) ---
// Острота работает вместе со способностью органа (глаза/уши/нос/кожа): ген × capacity × радиус чувства.
{ "defName": "GeneHearing", "parent": "BaseNumericGene", "label": "gene.hearing",
"default": 1.0, "min": 0.2, "max": 3.0, "tags": ["animal", "senses"],
"effects": { "hearing": "value" } },
{ "defName": "GeneSmell", "parent": "BaseNumericGene", "label": "gene.smell",
"default": 1.0, "min": 0.2, "max": 3.0, "tags": ["animal", "senses"],
"effects": { "smell": "value" } },
{ "defName": "GeneTouch", "parent": "BaseNumericGene", "label": "gene.touch",
"default": 1.0, "min": 0.2, "max": 3.0, "tags": ["animal", "senses"],
"effects": { "touch": "value" } },
// Заметность цели: камуфляж снижает обнаружение зрением (1−camouflage), запах повышает обонянием,
// шум при движении повышает обнаружение слухом. См. формулу радиуса в системе восприятия.
{ "defName": "GeneCamouflage", "parent": "BaseNumericGene", "label": "gene.camouflage",
"default": 0.0, "min": 0.0, "max": 1.0, "tags": ["animal", "stealth"],
"effects": { "camouflage": "value" } },
{ "defName": "GeneScent", "parent": "BaseNumericGene", "label": "gene.scent",
"default": 0.5, "min": 0.0, "max": 1.0, "tags": ["animal", "stealth"],
"effects": { "scent": "value" } },
{ "defName": "GeneNoise", "parent": "BaseNumericGene", "label": "gene.noise",
"default": 0.5, "min": 0.0, "max": 1.0, "tags": ["animal", "stealth"],
"effects": { "noise": "value" } },
{ "defName": "GeneBrainSize", "parent": "BaseNumericGene", "label": "gene.brainSize", { "defName": "GeneBrainSize", "parent": "BaseNumericGene", "label": "gene.brainSize",
"default": 0.45, "min": 0.0, "max": 1.0, "spread": 0.03, "tags": ["animal", "brain"], "default": 0.45, "min": 0.0, "max": 1.0, "spread": 0.03, "tags": ["animal", "brain"],
"effects": { "brainSize": "value" } }, "effects": { "brainSize": "value" } },
+2
View File
@@ -70,6 +70,8 @@
"cap.moving": "moving", "cap.moving": "moving",
"cap.sight": "sight", "cap.sight": "sight",
"cap.hearing": "hearing", "cap.hearing": "hearing",
"cap.smell": "smell",
"cap.touch": "touch",
"cap.talking": "talking", "cap.talking": "talking",
"cap.eating": "eating", "cap.eating": "eating",
"cap.breathing": "breathing", "cap.breathing": "breathing",
+2
View File
@@ -70,6 +70,8 @@
"cap.moving": "движение", "cap.moving": "движение",
"cap.sight": "зрение", "cap.sight": "зрение",
"cap.hearing": "слух", "cap.hearing": "слух",
"cap.smell": "обоняние",
"cap.touch": "осязание",
"cap.talking": "речь", "cap.talking": "речь",
"cap.eating": "питание", "cap.eating": "питание",
"cap.breathing": "дыхание", "cap.breathing": "дыхание",
+1
View File
@@ -270,6 +270,7 @@ public sealed class WorldScene : Scene
var thoughts = new ThoughtSet(content); var thoughts = new ThoughtSet(content);
UpdateSystems.Add(new AnimalNeedsSystem(Context.Clock, SecondsPerDay, _needs, _animals)); UpdateSystems.Add(new AnimalNeedsSystem(Context.Clock, SecondsPerDay, _needs, _animals));
UpdateSystems.Add(new AnimalSkillSystem(Context.Clock, SecondsPerDay, _skills)); UpdateSystems.Add(new AnimalSkillSystem(Context.Clock, SecondsPerDay, _skills));
UpdateSystems.Add(new AnimalMemorySystem(Context.Clock, SecondsPerDay));
UpdateSystems.Add( UpdateSystems.Add(
new AnimalRutSystem(_animals, climate, content.Defs.Get<HediffDef>("Rut")) new AnimalRutSystem(_animals, climate, content.Defs.Get<HediffDef>("Rut"))
); );
+12
View File
@@ -83,9 +83,21 @@ public static class AnimalFactory
Passions = skills.RollPassions(new Random(SkillSeed(genome) ^ species)), Passions = skills.RollPassions(new Random(SkillSeed(genome) ^ species)),
} }
); );
// Память: объём ∝ размеру мозга (примитивные ~0 — ничего не помнят).
entity.AddComponent(
new Memory
{
Entries = [],
Capacity = (int)MathF.Round(MathF.Max(0f, traits.BrainSize) * MemoryPerBrain),
}
);
return entity; return entity;
} }
/// <summary>Сколько записей памяти даёт единица размера мозга (объём памяти = brainSize × это).</summary>
public const float MemoryPerBrain = 16f;
// Стабильный сид из генома (сумма аллелей — порядконезависима) для детерминированной страсти к навыкам. // Стабильный сид из генома (сумма аллелей — порядконезависима) для детерминированной страсти к навыкам.
private static int SkillSeed(Genome genome) private static int SkillSeed(Genome genome)
{ {
+110
View File
@@ -1,6 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using Friflo.Engine.ECS; using Friflo.Engine.ECS;
using LittleSim.Content; using LittleSim.Content;
using Microsoft.Xna.Framework;
using MrGameEng.Genetics; using MrGameEng.Genetics;
namespace LittleSim.Sim; namespace LittleSim.Sim;
@@ -27,6 +28,24 @@ public struct AnimalPhenotype
/// <summary>Дальность зрения (множитель радиуса поиска).</summary> /// <summary>Дальность зрения (множитель радиуса поиска).</summary>
public float Vision; public float Vision;
/// <summary>Острота слуха (множитель радиуса).</summary>
public float Hearing;
/// <summary>Острота обоняния (множитель радиуса).</summary>
public float Smell;
/// <summary>Острота осязания (множитель радиуса; чувство ближнего контакта).</summary>
public float Touch;
/// <summary>Камуфляж [0..1] — снижает обнаружение этой особи зрением (заметность = 1camouflage).</summary>
public float Camouflage;
/// <summary>Запах [0..1] — повышает обнаружение этой особи обонянием.</summary>
public float Scent;
/// <summary>Шумность [0..1] — повышает обнаружение этой особи слухом при движении.</summary>
public float Noise;
/// <summary>Размер мозга (0..1) — основа интеллекта.</summary> /// <summary>Размер мозга (0..1) — основа интеллекта.</summary>
public float BrainSize; public float BrainSize;
@@ -76,6 +95,8 @@ public struct AnimalPhenotype
public static AnimalPhenotype FromTraits(IReadOnlyDictionary<string, float> traits) public static AnimalPhenotype FromTraits(IReadOnlyDictionary<string, float> traits)
{ {
float T(string name) => traits.GetValueOrDefault(name); float T(string name) => traits.GetValueOrDefault(name);
// Острота чувств по умолчанию 1 (вид без гена не «слепнет»); заметность — мягкие дефолты.
float Acuity(string name) => traits.TryGetValue(name, out var v) ? v : 1f;
return new AnimalPhenotype return new AnimalPhenotype
{ {
BodySize = T("bodySize"), BodySize = T("bodySize"),
@@ -83,6 +104,12 @@ public struct AnimalPhenotype
MoveSpeed = T("moveSpeed"), MoveSpeed = T("moveSpeed"),
BloodVolume = T("bloodVolume"), BloodVolume = T("bloodVolume"),
Vision = T("vision"), Vision = T("vision"),
Hearing = Acuity("hearing"),
Smell = Acuity("smell"),
Touch = Acuity("touch"),
Camouflage = T("camouflage"),
Scent = traits.GetValueOrDefault("scent", 0.5f),
Noise = traits.GetValueOrDefault("noise", 0.5f),
BrainSize = T("brainSize"), BrainSize = T("brainSize"),
Insulation = T("insulation"), Insulation = T("insulation"),
FurHue = T("furHue"), FurHue = T("furHue"),
@@ -483,6 +510,89 @@ public struct Egg : IComponent
public float IncubateDays; public float IncubateDays;
} }
/// <summary>Вид запомненного: угроза (избегать), корм/вода/партнёр (идти) — расширяемо.</summary>
public enum MemoryKind : byte
{
Threat,
Food,
Water,
Mate,
}
/// <summary>Запись памяти: что, где (воспринятая позиция — грубая, если по запаху), насколько уверенно
/// (падает со временем — забывание), сколько дней назад.</summary>
public struct MemoryEntry
{
public MemoryKind Kind;
public Vector2 Pos;
public float Confidence;
public float AgeDays;
}
/// <summary>
/// Память особи (объём ∝ мозгу): ограниченный набор записей о замеченном (сейчас — угрозы). Чувства
/// пишут сюда воспринятое; со временем уверенность падает и запись забывается; ИИ использует память,
/// когда цель не воспринимается сейчас (избегает зоны недавнего хищника). Список — managed-ссылка.
/// </summary>
public struct Memory : IComponent
{
/// <summary>Активные записи (упорядочены по добавлению; слабейшая вытесняется при переполнении).</summary>
public List<MemoryEntry> Entries;
/// <summary>Сколько записей вмещает (∝ размеру мозга; 0 — память отсутствует).</summary>
public int Capacity;
/// <summary>Запоминает/освежает наблюдение: близкое того же вида — обновляет, иначе добавляет (с
/// вытеснением слабейшего при переполнении). Вне ёмкости (Capacity 0) — ничего не помнит.</summary>
public readonly void Remember(MemoryKind kind, Vector2 pos, float mergeDist)
{
if (Entries is null || Capacity <= 0)
{
return;
}
var mergeSq = mergeDist * mergeDist;
for (var i = 0; i < Entries.Count; i++)
{
if (Entries[i].Kind == kind && Vector2.DistanceSquared(Entries[i].Pos, pos) <= mergeSq)
{
Entries[i] = new MemoryEntry
{
Kind = kind,
Pos = pos,
Confidence = 1f,
AgeDays = 0f,
};
return;
}
}
if (Entries.Count >= Capacity)
{
var weakest = 0;
for (var i = 1; i < Entries.Count; i++)
{
if (Entries[i].Confidence < Entries[weakest].Confidence)
{
weakest = i;
}
}
Entries.RemoveAt(weakest);
}
Entries.Add(
new MemoryEntry
{
Kind = kind,
Pos = pos,
Confidence = 1f,
AgeDays = 0f,
}
);
}
}
/// <summary>Id навыков-эффектов (на них ссылается код начисления XP и применения бонуса).</summary> /// <summary>Id навыков-эффектов (на них ссылается код начисления XP и применения бонуса).</summary>
public static class AnimalSkills public static class AnimalSkills
{ {
+199 -28
View File
@@ -268,10 +268,14 @@ public sealed class AnimalDecisionSystem : BaseSystem
// корма/угрозы/добычи/партнёра/стада берёт только ближние сущности вместо прохода по всем (было // корма/угрозы/добычи/партнёра/стада берёт только ближние сущности вместо прохода по всем (было
// O(растений)+O(животных) на каждое решение). В ячейке — предпосчитанные признаки (без дорефетча). // O(растений)+O(животных) на каждое решение). В ячейке — предпосчитанные признаки (без дорефетча).
private const int GridCells = 10; // размер ячейки сетки в клетках мира (~радиус восприятия) private const int GridCells = 10; // размер ячейки сетки в клетках мира (~радиус восприятия)
private const float HearingRangeMult = 1.4f; // слух шире зрения
private const float SmellRangeMult = 1.8f; // запах — дальше всех (но грубый, см. Detect)
private const float RememberedThreatAvoidCells = 14f; // в этой зоне от памяти об угрозе зверь уходит прочь
private readonly SpatialGrid<ForageEntry> _plantGrid; private readonly SpatialGrid<ForageEntry> _plantGrid;
private readonly List<ForageEntry> _plantBuf = []; private readonly List<ForageEntry> _plantBuf = [];
private readonly SpatialGrid<NeighborEntry> _animalGrid; private readonly SpatialGrid<NeighborEntry> _animalGrid;
private readonly List<NeighborEntry> _animalBuf = []; private readonly List<NeighborEntry> _animalBuf = [];
private readonly float _smellSnap; // размер «обонятельной» ячейки (грубая локализация по запаху)
public AnimalDecisionSystem( public AnimalDecisionSystem(
EntityStore store, EntityStore store,
@@ -292,6 +296,7 @@ public sealed class AnimalDecisionSystem : BaseSystem
_rng = new Random(seed); _rng = new Random(seed);
_plantGrid = new SpatialGrid<ForageEntry>(cellSize * GridCells); _plantGrid = new SpatialGrid<ForageEntry>(cellSize * GridCells);
_animalGrid = new SpatialGrid<NeighborEntry>(cellSize * GridCells); _animalGrid = new SpatialGrid<NeighborEntry>(cellSize * GridCells);
_smellSnap = cellSize * GridCells;
_animals = store.Query< _animals = store.Query<
AnimalNeeds, AnimalNeeds,
AnimalBrain, AnimalBrain,
@@ -361,13 +366,20 @@ public sealed class AnimalDecisionSystem : BaseSystem
// Эффективный интеллект (мозг × сознание) гейтит доступные нужды/действия (фаза A7): // Эффективный интеллект (мозг × сознание) гейтит доступные нужды/действия (фаза A7):
// больной/раненый зверь с упавшим сознанием теряет высшие нужды (сон/секс) → рефлексы. // больной/раненый зверь с упавшим сознанием теряет высшие нужды (сон/секс) → рефлексы.
var intelligence = AnimalFactory.Intelligence(o[i].Traits, health); var intelligence = AnimalFactory.Intelligence(o[i].Traits, health);
// Радиус восприятия по способностям: зрение ищет корм/партнёра/добычу, угрозу замечаем // Радиусы восприятия по чувствам: зрение (корм/партнёр), плюс слух и обоняние (угроза/добыча).
// зрением ИЛИ слухом (повреждённые глаза/уши сужают восприятие). // Острота — гены чувств × способность органа (раны/хедифы их режут). Зрение узкое, слух шире,
// запах дальше всех, но грубый (см. Detect). Радиусы детерминированы.
var baseRadius = VisionRadius(o[i].Species, o[i].Traits.Vision); var baseRadius = VisionRadius(o[i].Species, o[i].Traits.Vision);
var sight = health?.Capacity(AnimalCapacities.Sight) ?? 1f; var sight = health?.Capacity(AnimalCapacities.Sight) ?? 1f;
var hearing = health?.Capacity(AnimalCapacities.Hearing) ?? 1f; var hearing = health?.Capacity(AnimalCapacities.Hearing) ?? 1f;
var smell = health?.Capacity(AnimalCapacities.Smell) ?? 1f;
var seeRadius = baseRadius * MathF.Max(0.2f, sight); var seeRadius = baseRadius * MathF.Max(0.2f, sight);
var senseRadius = baseRadius * MathF.Max(0.2f, MathF.Max(sight, hearing)); var senseBase = _animalSet[o[i].Species].Def.VisionCells * _cellSize;
var ranges = new SenseRanges(
seeRadius,
senseBase * o[i].Traits.Hearing * MathF.Max(0.2f, hearing) * HearingRangeMult,
senseBase * o[i].Traits.Smell * MathF.Max(0.2f, smell) * SmellRangeMult
);
// Настроение (A8): стресс/истощение (низкое настроение) подавляет тягу к спариванию. // Настроение (A8): стресс/истощение (низкое настроение) подавляет тягу к спариванию.
var mood = self.GetComponent<Mood>(); var mood = self.GetComponent<Mood>();
@@ -381,19 +393,24 @@ public sealed class AnimalDecisionSystem : BaseSystem
HerdScan(pos, seeRadius, o[i].Species, self.Id, out herdCenter, out herdCount); HerdScan(pos, seeRadius, o[i].Species, self.Id, out herdCenter, out herdCount);
} }
var threatRadius = var safety =
herdCount > 0 herdCount > 0
? senseRadius ? 1f - SafetyInNumbers * MathF.Min(1f, herdCount / (float)HerdFullCount)
* ( : 1f;
1f
- SafetyInNumbers * MathF.Min(1f, herdCount / (float)HerdFullCount)
)
: senseRadius;
// Бегство (предатор-кластер): жертва, заметившая рядом хищника, спасается — это важнее // Бегство (предатор-кластер): жертва, заметившая рядом хищника, спасается — это важнее
// любых нужд (инстинкт выживания). Сам хищник от добычи не бежит (см. IsThreatTo). // любых нужд (инстинкт выживания). Сам хищник от добычи не бежит (см. IsThreatTo).
if (TryFindThreat(pos, threatRadius, o[i].Species, o[i].Traits, out var threatPos)) if (
TryFindThreat(
pos,
ranges.Scaled(safety),
o[i].Species,
o[i].Traits,
out var threatPos
)
)
{ {
self.GetComponent<Memory>().Remember(MemoryKind.Threat, threatPos, _smellSnap);
brain.Action = AnimalActions.Flee; brain.Action = AnimalActions.Flee;
brain.TargetPlant = -1; brain.TargetPlant = -1;
brain.TargetPrey = -1; brain.TargetPrey = -1;
@@ -401,7 +418,7 @@ public sealed class AnimalDecisionSystem : BaseSystem
var len = away.Length(); var len = away.Length();
brain.Target = brain.Target =
len > 0.001f len > 0.001f
? pos + away / len * (senseRadius + _cellSize) ? pos + away / len * (ranges.Max() + _cellSize)
: pos + new Vector2(_cellSize, 0f); : pos + new Vector2(_cellSize, 0f);
continue; continue;
} }
@@ -421,7 +438,7 @@ public sealed class AnimalDecisionSystem : BaseSystem
when eatsMeat when eatsMeat
&& TryFindKill( && TryFindKill(
pos, pos,
seeRadius, ranges,
o[i].Species, o[i].Species,
o[i].Traits, o[i].Traits,
self.Id, self.Id,
@@ -476,11 +493,21 @@ public sealed class AnimalDecisionSystem : BaseSystem
default: default:
brain.Action = AnimalActions.Wander; brain.Action = AnimalActions.Wander;
brain.TargetPlant = -1; brain.TargetPlant = -1;
// Стадность: социальный зверь вдали от группы бредёт к её центру (когезия), рядом —
// мельтешит случайно (с расхождением). Несоциальный — обычное случайное блуждание.
var toCenter = herdCenter - pos; var toCenter = herdCenter - pos;
var centerDist = toCenter.Length(); var centerDist = toCenter.Length();
if ( if (TryRememberedThreat(self, pos, out var dangerPos))
{
// Память: вблизи места недавнего хищника зверь уходит прочь (опасная зона).
var flee = pos - dangerPos;
var d = flee.Length();
brain.Target =
d > 0.001f
? pos + flee / d * (_cellSize * (4f + _rng.NextSingle() * 4f))
: pos + new Vector2(_cellSize, 0f);
}
// Стадность: социальный зверь вдали от группы бредёт к её центру (когезия), рядом —
// мельтешит случайно (с расхождением). Несоциальный — обычное случайное блуждание.
else if (
sociability >= HerdMinSociability sociability >= HerdMinSociability
&& herdCount > 0 && herdCount > 0
&& centerDist > HerdSeparation * _cellSize && centerDist > HerdSeparation * _cellSize
@@ -511,6 +538,37 @@ public sealed class AnimalDecisionSystem : BaseSystem
private float VisionRadius(int species, float vision) => private float VisionRadius(int species, float vision) =>
MathF.Max(1f, _animalSet[species].Def.VisionCells) * MathF.Max(0.2f, vision) * _cellSize; MathF.Max(1f, _animalSet[species].Def.VisionCells) * MathF.Max(0.2f, vision) * _cellSize;
// Ближайшая запомненная угроза в зоне избегания (для блуждания — уйти от опасного места).
private bool TryRememberedThreat(Entity self, Vector2 pos, out Vector2 threatPos)
{
threatPos = default;
if (!self.TryGetComponent<Memory>(out var mem) || mem.Entries is null)
{
return false;
}
var bestSq = RememberedThreatAvoidCells * _cellSize;
bestSq *= bestSq;
var found = false;
foreach (var e in mem.Entries)
{
if (e.Kind != MemoryKind.Threat)
{
continue;
}
var sq = Vector2.DistanceSquared(pos, e.Pos);
if (sq < bestSq)
{
bestSq = sq;
threatPos = e.Pos;
found = true;
}
}
return found;
}
// Центр и размер ближайшей группы соплеменников в радиусе (для когезии/безопасности). O(n) на особь, // Центр и размер ближайшей группы соплеменников в радиусе (для когезии/безопасности). O(n) на особь,
// как и прочие сканы. Сам исключается по id; центр — средняя позиция соседей того же вида. // как и прочие сканы. Сам исключается по id; центр — средняя позиция соседей того же вида.
private void HerdScan( private void HerdScan(
@@ -547,18 +605,18 @@ public sealed class AnimalDecisionSystem : BaseSystem
// ест мясо и достаточно крупный (см. IsThreatTo). O(n) на особь — как поиск корма. // ест мясо и достаточно крупный (см. IsThreatTo). O(n) на особь — как поиск корма.
private bool TryFindThreat( private bool TryFindThreat(
Vector2 from, Vector2 from,
float radius, in SenseRanges ranges,
int selfSpecies, int selfSpecies,
in AnimalPhenotype selfTraits, in AnimalPhenotype selfTraits,
out Vector2 threatPos out Vector2 threatPos
) )
{ {
var bestSq = radius * radius; var bestSq = float.MaxValue;
threatPos = default; threatPos = default;
var threatId = -1; var threatId = -1;
var (_, selfEatsMeat) = AnimalFactory.Diet(selfTraits, _animalSet[selfSpecies].Def); var (_, selfEatsMeat) = AnimalFactory.Diet(selfTraits, _animalSet[selfSpecies].Def);
var selfBody = selfTraits.BodySize; var selfBody = selfTraits.BodySize;
_animalGrid.Collect(from, radius, _animalBuf); _animalGrid.Collect(from, ranges.Max(), _animalBuf);
foreach (var e in _animalBuf) foreach (var e in _animalBuf)
{ {
if ( if (
@@ -570,10 +628,16 @@ public sealed class AnimalDecisionSystem : BaseSystem
} }
var sq = Vector2.DistanceSquared(from, e.Pos); var sq = Vector2.DistanceSquared(from, e.Pos);
// Обнаружение по чувствам: зрение (минус камуфляж) / слух (× шум) / запах (× запах, грубая зона).
if (!Detect(sq, e.Pos, ranges, e.Camouflage, e.Scent, e.Noise, out var perceived))
{
continue;
}
if (sq < bestSq || (sq == bestSq && e.Id < threatId)) if (sq < bestSq || (sq == bestSq && e.Id < threatId))
{ {
bestSq = sq; bestSq = sq;
threatPos = e.Pos; threatPos = perceived;
threatId = e.Id; threatId = e.Id;
} }
} }
@@ -598,11 +662,62 @@ public sealed class AnimalDecisionSystem : BaseSystem
return selfEatsMeat ? otherBody > selfBody * 1.1f : otherBody >= selfBody * 0.9f; return selfEatsMeat ? otherBody > selfBody * 1.1f : otherBody >= selfBody * 0.9f;
} }
/// <summary>Радиусы восприятия особи по чувствам (уже с остротой генов и способностью органов).</summary>
private readonly record struct SenseRanges(float Sight, float Hearing, float Smell)
{
public float Max() => MathF.Max(Sight, MathF.Max(Hearing, Smell));
public SenseRanges Scaled(float f) => new(Sight * f, Hearing * f, Smell * f);
}
// Обнаружена ли цель и где она «видится» наблюдателю. Зрение/слух дают точную позицию (зрение глушит
// камуфляж цели, слух усиливает её шум); запах добивает дальше всех, но даёт лишь ГРУБУЮ зону —
// позиция округляется до ячейки запаха (зверь идёт «примерно туда», пока ближе не сработает зрение).
private bool Detect(
float dist2,
Vector2 truePos,
in SenseRanges r,
float camouflage,
float scent,
float noise,
out Vector2 perceived
)
{
perceived = truePos;
var sight = r.Sight * (1f - Math.Clamp(camouflage, 0f, 1f));
if (sight > 0f && dist2 <= sight * sight)
{
return true;
}
var hear = r.Hearing * Math.Clamp(noise, 0f, 1f);
if (hear > 0f && dist2 <= hear * hear)
{
return true;
}
var smell = r.Smell * Math.Clamp(scent, 0f, 1f);
if (smell > 0f && dist2 <= smell * smell)
{
perceived = SnapToSmellCell(truePos); // грубая зона запаха
return true;
}
return false;
}
// Округляет позицию до центра «обонятельной» ячейки — детерминированная грубая локализация по запаху.
private Vector2 SnapToSmellCell(Vector2 p) =>
new(
MathF.Floor(p.X / _smellSnap) * _smellSnap + _smellSnap * 0.5f,
MathF.Floor(p.Y / _smellSnap) * _smellSnap + _smellSnap * 0.5f
);
// Ближайшая «мясная» цель: труп (падаль, приоритет — даровая еда без риска) или живая добыча. // Ближайшая «мясная» цель: труп (падаль, приоритет — даровая еда без риска) или живая добыча.
// Возвращает id, позицию и флаг трупа. Добыча — другой вид не крупнее охотника (см. IsPreyFor). // Возвращает id, позицию и флаг трупа. Добыча — другой вид не крупнее охотника (см. IsPreyFor).
private bool TryFindKill( private bool TryFindKill(
Vector2 from, Vector2 from,
float radius, in SenseRanges ranges,
int selfSpecies, int selfSpecies,
in AnimalPhenotype selfTraits, in AnimalPhenotype selfTraits,
int selfId, int selfId,
@@ -614,9 +729,10 @@ public sealed class AnimalDecisionSystem : BaseSystem
targetId = -1; targetId = -1;
position = default; position = default;
isCorpse = false; isCorpse = false;
var reach = ranges.Max();
// 1) Падаль с остатком мяса. // 1) Падаль с остатком мяса — находится в пределах любого чувства (запах хорошо ведёт к падали).
var bestCorpseSq = radius * radius; var bestCorpseSq = reach * reach;
var corpseId = -1; var corpseId = -1;
var corpsePos = default(Vector2); var corpsePos = default(Vector2);
foreach (var (transforms, corpses, entities) in _corpses.Chunks) foreach (var (transforms, corpses, entities) in _corpses.Chunks)
@@ -641,12 +757,12 @@ public sealed class AnimalDecisionSystem : BaseSystem
} }
} }
// 2) Живая добыча (из сетки животных). // 2) Живая добыча (из сетки животных, по чувствам).
var bestPreySq = radius * radius; var bestPreySq = float.MaxValue;
var preyId = -1; var preyId = -1;
var preyPos = default(Vector2); var preyPos = default(Vector2);
var selfBody = selfTraits.BodySize; var selfBody = selfTraits.BodySize;
_animalGrid.Collect(from, radius, _animalBuf); _animalGrid.Collect(from, reach, _animalBuf);
foreach (var e in _animalBuf) foreach (var e in _animalBuf)
{ {
if (e.Species == selfSpecies || e.Id == selfId || !IsPreyFor(selfBody, e.BodySize)) if (e.Species == selfSpecies || e.Id == selfId || !IsPreyFor(selfBody, e.BodySize))
@@ -655,11 +771,16 @@ public sealed class AnimalDecisionSystem : BaseSystem
} }
var sq = Vector2.DistanceSquared(from, e.Pos); var sq = Vector2.DistanceSquared(from, e.Pos);
if (!Detect(sq, e.Pos, ranges, e.Camouflage, e.Scent, e.Noise, out var perceived))
{
continue;
}
if (sq < bestPreySq || (sq == bestPreySq && e.Id < preyId)) if (sq < bestPreySq || (sq == bestPreySq && e.Id < preyId))
{ {
bestPreySq = sq; bestPreySq = sq;
preyId = e.Id; preyId = e.Id;
preyPos = e.Pos; preyPos = perceived;
} }
} }
@@ -753,7 +874,10 @@ public sealed class AnimalDecisionSystem : BaseSystem
o[i].Traits.BodySize, o[i].Traits.BodySize,
eatsMeat, eatsMeat,
o[i].IsMale, o[i].IsMale,
AnimalFactory.IsAdult(sp, g[i].Stage) AnimalFactory.IsAdult(sp, g[i].Stage),
o[i].Traits.Camouflage,
o[i].Traits.Scent,
o[i].Traits.Noise
) )
); );
} }
@@ -2504,3 +2628,50 @@ public sealed class AnimalSkillSystem(GameClock clock, float secondsPerDay, Skil
} }
} }
} }
/// <summary>
/// Забывание (память): уверенность каждой записи падает со временем, состарившиеся стираются. Свежие
/// наблюдения записывает/освежает <see cref="AnimalDecisionSystem"/>; эта система только «выветривает»
/// память, давая ей конечный срок.
/// </summary>
public sealed class AnimalMemorySystem(GameClock clock, float secondsPerDay) : QuerySystem<Memory>
{
private const float ForgetPerDay = 0.5f; // уверенность падает (память живёт ~2 дня без подтверждения)
protected override void OnUpdate()
{
var days = clock.DeltaTime / secondsPerDay;
if (days <= 0f)
{
return;
}
foreach (var (memories, _) in Query.Chunks)
{
var m = memories.Span;
for (var i = 0; i < m.Length; i++)
{
var entries = m[i].Entries;
if (entries is null)
{
continue;
}
for (var k = entries.Count - 1; k >= 0; k--)
{
var e = entries[k];
e.Confidence -= ForgetPerDay * days;
e.AgeDays += days;
if (e.Confidence <= 0f)
{
entries.RemoveAt(k);
}
else
{
entries[k] = e;
}
}
}
}
}
}
+6
View File
@@ -12,6 +12,8 @@ public static class AnimalCapacities
public const string BloodFiltration = "BloodFiltration"; public const string BloodFiltration = "BloodFiltration";
public const string Sight = "Sight"; public const string Sight = "Sight";
public const string Hearing = "Hearing"; public const string Hearing = "Hearing";
public const string Smell = "Smell";
public const string Touch = "Touch";
public const string Talking = "Talking"; public const string Talking = "Talking";
public const string Eating = "Eating"; public const string Eating = "Eating";
public const string Digestion = "Digestion"; public const string Digestion = "Digestion";
@@ -23,6 +25,8 @@ public static class AnimalCapacities
Moving, Moving,
Sight, Sight,
Hearing, Hearing,
Smell,
Touch,
Talking, Talking,
Eating, Eating,
Breathing, Breathing,
@@ -99,6 +103,8 @@ public static class CapacityCalc
[AnimalCapacities.Moving] = Raw(AnimalCapacities.Moving) * cons * pump, [AnimalCapacities.Moving] = Raw(AnimalCapacities.Moving) * cons * pump,
[AnimalCapacities.Sight] = Raw(AnimalCapacities.Sight) * cons, [AnimalCapacities.Sight] = Raw(AnimalCapacities.Sight) * cons,
[AnimalCapacities.Hearing] = Raw(AnimalCapacities.Hearing) * cons, [AnimalCapacities.Hearing] = Raw(AnimalCapacities.Hearing) * cons,
[AnimalCapacities.Smell] = Raw(AnimalCapacities.Smell) * cons,
[AnimalCapacities.Touch] = Raw(AnimalCapacities.Touch) * cons,
[AnimalCapacities.Talking] = Raw(AnimalCapacities.Talking) * cons, [AnimalCapacities.Talking] = Raw(AnimalCapacities.Talking) * cons,
[AnimalCapacities.Eating] = Raw(AnimalCapacities.Eating) * cons, [AnimalCapacities.Eating] = Raw(AnimalCapacities.Eating) * cons,
[AnimalCapacities.Digestion] = Raw(AnimalCapacities.Digestion) * pump, [AnimalCapacities.Digestion] = Raw(AnimalCapacities.Digestion) * pump,
+6 -3
View File
@@ -14,8 +14,8 @@ public interface ISpatialEntry
public readonly record struct ForageEntry(int Id, Vector2 Pos, float Toxicity, float Palatability) public readonly record struct ForageEntry(int Id, Vector2 Pos, float Toxicity, float Palatability)
: ISpatialEntry; : ISpatialEntry;
/// <summary>Сосед-животное для ИИ: позиция/id + предпосчитанные признаки (вид, размер, мясоед, пол, взрослость), /// <summary>Сосед-животное для ИИ: позиция/id + предпосчитанные признаки (вид, размер, мясоед, пол, взрослость)
/// чтобы запросы угрозы/добычи/партнёра/стада не дорефетчили компоненты.</summary> /// и заметность (камуфляж/запах/шум) — чтобы запросы угрозы/добычи/партнёра/стада не дорефетчили компоненты.</summary>
public readonly record struct NeighborEntry( public readonly record struct NeighborEntry(
int Id, int Id,
Vector2 Pos, Vector2 Pos,
@@ -23,7 +23,10 @@ public readonly record struct NeighborEntry(
float BodySize, float BodySize,
bool EatsMeat, bool EatsMeat,
bool IsMale, bool IsMale,
bool IsAdult bool IsAdult,
float Camouflage,
float Scent,
float Noise
) : ISpatialEntry; ) : ISpatialEntry;
/// <summary> /// <summary>