Senses stage 2: multi-sense detection (sight/hearing/smell) for threat & prey

Predator/prey detection now uses three senses instead of one radius. Per observer,
SenseRanges = sight (vision gene x Sight capacity), hearing (x1.4, Hearing gene/cap),
smell (x1.8, Smell gene/cap). Detect(): sight beats the target''s camouflage and
hearing scales with its noise (both give exact position); smell reaches furthest but
only a COARSE zone -- the position snaps to a smell cell, so a predator tracking by
scent heads roughly toward prey until sight takes over. Damaged eyes/ears/nose or
hediff capMods shrink the matching sense; detectability genes (camouflage/scent/noise)
ride in the animal grid entry. Deterministic (snap, not noise). Mate/forage stay on
sight for now. Build + --check-content clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-15 00:02:59 +03:00
co-authored by Claude Opus 4.8
parent 70e79a5c30
commit 3031bf2954
2 changed files with 112 additions and 28 deletions
+106 -25
View File
@@ -268,10 +268,13 @@ public sealed class AnimalDecisionSystem : BaseSystem
// корма/угрозы/добычи/партнёра/стада берёт только ближние сущности вместо прохода по всем (было
// O(растений)+O(животных) на каждое решение). В ячейке — предпосчитанные признаки (без дорефетча).
private const int GridCells = 10; // размер ячейки сетки в клетках мира (~радиус восприятия)
private const float HearingRangeMult = 1.4f; // слух шире зрения
private const float SmellRangeMult = 1.8f; // запах — дальше всех (но грубый, см. Detect)
private readonly SpatialGrid<ForageEntry> _plantGrid;
private readonly List<ForageEntry> _plantBuf = [];
private readonly SpatialGrid<NeighborEntry> _animalGrid;
private readonly List<NeighborEntry> _animalBuf = [];
private readonly float _smellSnap; // размер «обонятельной» ячейки (грубая локализация по запаху)
public AnimalDecisionSystem(
EntityStore store,
@@ -292,6 +295,7 @@ public sealed class AnimalDecisionSystem : BaseSystem
_rng = new Random(seed);
_plantGrid = new SpatialGrid<ForageEntry>(cellSize * GridCells);
_animalGrid = new SpatialGrid<NeighborEntry>(cellSize * GridCells);
_smellSnap = cellSize * GridCells;
_animals = store.Query<
AnimalNeeds,
AnimalBrain,
@@ -361,13 +365,20 @@ public sealed class AnimalDecisionSystem : BaseSystem
// Эффективный интеллект (мозг × сознание) гейтит доступные нужды/действия (фаза A7):
// больной/раненый зверь с упавшим сознанием теряет высшие нужды (сон/секс) → рефлексы.
var intelligence = AnimalFactory.Intelligence(o[i].Traits, health);
// Радиус восприятия по способностям: зрение ищет корм/партнёра/добычу, угрозу замечаем
// зрением ИЛИ слухом (повреждённые глаза/уши сужают восприятие).
// Радиусы восприятия по чувствам: зрение (корм/партнёр), плюс слух и обоняние (угроза/добыча).
// Острота — гены чувств × способность органа (раны/хедифы их режут). Зрение узкое, слух шире,
// запах дальше всех, но грубый (см. Detect). Радиусы детерминированы.
var baseRadius = VisionRadius(o[i].Species, o[i].Traits.Vision);
var sight = health?.Capacity(AnimalCapacities.Sight) ?? 1f;
var hearing = health?.Capacity(AnimalCapacities.Hearing) ?? 1f;
var smell = health?.Capacity(AnimalCapacities.Smell) ?? 1f;
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): стресс/истощение (низкое настроение) подавляет тягу к спариванию.
var mood = self.GetComponent<Mood>();
@@ -381,18 +392,22 @@ public sealed class AnimalDecisionSystem : BaseSystem
HerdScan(pos, seeRadius, o[i].Species, self.Id, out herdCenter, out herdCount);
}
var threatRadius =
var safety =
herdCount > 0
? senseRadius
* (
1f
- SafetyInNumbers * MathF.Min(1f, herdCount / (float)HerdFullCount)
)
: senseRadius;
? 1f - SafetyInNumbers * MathF.Min(1f, herdCount / (float)HerdFullCount)
: 1f;
// Бегство (предатор-кластер): жертва, заметившая рядом хищника, спасается — это важнее
// любых нужд (инстинкт выживания). Сам хищник от добычи не бежит (см. 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
)
)
{
brain.Action = AnimalActions.Flee;
brain.TargetPlant = -1;
@@ -401,7 +416,7 @@ public sealed class AnimalDecisionSystem : BaseSystem
var len = away.Length();
brain.Target =
len > 0.001f
? pos + away / len * (senseRadius + _cellSize)
? pos + away / len * (ranges.Max() + _cellSize)
: pos + new Vector2(_cellSize, 0f);
continue;
}
@@ -421,7 +436,7 @@ public sealed class AnimalDecisionSystem : BaseSystem
when eatsMeat
&& TryFindKill(
pos,
seeRadius,
ranges,
o[i].Species,
o[i].Traits,
self.Id,
@@ -547,18 +562,18 @@ public sealed class AnimalDecisionSystem : BaseSystem
// ест мясо и достаточно крупный (см. IsThreatTo). O(n) на особь — как поиск корма.
private bool TryFindThreat(
Vector2 from,
float radius,
in SenseRanges ranges,
int selfSpecies,
in AnimalPhenotype selfTraits,
out Vector2 threatPos
)
{
var bestSq = radius * radius;
var bestSq = float.MaxValue;
threatPos = default;
var threatId = -1;
var (_, selfEatsMeat) = AnimalFactory.Diet(selfTraits, _animalSet[selfSpecies].Def);
var selfBody = selfTraits.BodySize;
_animalGrid.Collect(from, radius, _animalBuf);
_animalGrid.Collect(from, ranges.Max(), _animalBuf);
foreach (var e in _animalBuf)
{
if (
@@ -570,10 +585,16 @@ public sealed class AnimalDecisionSystem : BaseSystem
}
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))
{
bestSq = sq;
threatPos = e.Pos;
threatPos = perceived;
threatId = e.Id;
}
}
@@ -598,11 +619,62 @@ public sealed class AnimalDecisionSystem : BaseSystem
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).
private bool TryFindKill(
Vector2 from,
float radius,
in SenseRanges ranges,
int selfSpecies,
in AnimalPhenotype selfTraits,
int selfId,
@@ -614,9 +686,10 @@ public sealed class AnimalDecisionSystem : BaseSystem
targetId = -1;
position = default;
isCorpse = false;
var reach = ranges.Max();
// 1) Падаль с остатком мяса.
var bestCorpseSq = radius * radius;
// 1) Падаль с остатком мяса — находится в пределах любого чувства (запах хорошо ведёт к падали).
var bestCorpseSq = reach * reach;
var corpseId = -1;
var corpsePos = default(Vector2);
foreach (var (transforms, corpses, entities) in _corpses.Chunks)
@@ -641,12 +714,12 @@ public sealed class AnimalDecisionSystem : BaseSystem
}
}
// 2) Живая добыча (из сетки животных).
var bestPreySq = radius * radius;
// 2) Живая добыча (из сетки животных, по чувствам).
var bestPreySq = float.MaxValue;
var preyId = -1;
var preyPos = default(Vector2);
var selfBody = selfTraits.BodySize;
_animalGrid.Collect(from, radius, _animalBuf);
_animalGrid.Collect(from, reach, _animalBuf);
foreach (var e in _animalBuf)
{
if (e.Species == selfSpecies || e.Id == selfId || !IsPreyFor(selfBody, e.BodySize))
@@ -655,11 +728,16 @@ public sealed class AnimalDecisionSystem : BaseSystem
}
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))
{
bestPreySq = sq;
preyId = e.Id;
preyPos = e.Pos;
preyPos = perceived;
}
}
@@ -753,7 +831,10 @@ public sealed class AnimalDecisionSystem : BaseSystem
o[i].Traits.BodySize,
eatsMeat,
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
)
);
}
+6 -3
View File
@@ -14,8 +14,8 @@ public interface ISpatialEntry
public readonly record struct ForageEntry(int Id, Vector2 Pos, float Toxicity, float Palatability)
: ISpatialEntry;
/// <summary>Сосед-животное для ИИ: позиция/id + предпосчитанные признаки (вид, размер, мясоед, пол, взрослость),
/// чтобы запросы угрозы/добычи/партнёра/стада не дорефетчили компоненты.</summary>
/// <summary>Сосед-животное для ИИ: позиция/id + предпосчитанные признаки (вид, размер, мясоед, пол, взрослость)
/// и заметность (камуфляж/запах/шум) — чтобы запросы угрозы/добычи/партнёра/стада не дорефетчили компоненты.</summary>
public readonly record struct NeighborEntry(
int Id,
Vector2 Pos,
@@ -23,7 +23,10 @@ public readonly record struct NeighborEntry(
float BodySize,
bool EatsMeat,
bool IsMale,
bool IsAdult
bool IsAdult,
float Camouflage,
float Scent,
float Noise
) : ISpatialEntry;
/// <summary>