From 3031bf2954c1b96a6b622334d2d41760a521f335 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Mon, 15 Jun 2026 00:02:59 +0300 Subject: [PATCH] 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 --- src/LittleSim/Sim/AnimalSystems.cs | 131 +++++++++++++++++++++++------ src/LittleSim/Sim/SpatialGrid.cs | 9 +- 2 files changed, 112 insertions(+), 28 deletions(-) diff --git a/src/LittleSim/Sim/AnimalSystems.cs b/src/LittleSim/Sim/AnimalSystems.cs index 595d782..9655bb1 100644 --- a/src/LittleSim/Sim/AnimalSystems.cs +++ b/src/LittleSim/Sim/AnimalSystems.cs @@ -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 _plantGrid; private readonly List _plantBuf = []; private readonly SpatialGrid _animalGrid; private readonly List _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(cellSize * GridCells); _animalGrid = new SpatialGrid(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(); @@ -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; } + /// Радиусы восприятия особи по чувствам (уже с остротой генов и способностью органов). + 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 ) ); } diff --git a/src/LittleSim/Sim/SpatialGrid.cs b/src/LittleSim/Sim/SpatialGrid.cs index 856265a..5958629 100644 --- a/src/LittleSim/Sim/SpatialGrid.cs +++ b/src/LittleSim/Sim/SpatialGrid.cs @@ -14,8 +14,8 @@ public interface ISpatialEntry public readonly record struct ForageEntry(int Id, Vector2 Pos, float Toxicity, float Palatability) : ISpatialEntry; -/// Сосед-животное для ИИ: позиция/id + предпосчитанные признаки (вид, размер, мясоед, пол, взрослость), -/// чтобы запросы угрозы/добычи/партнёра/стада не дорефетчили компоненты. +/// Сосед-животное для ИИ: позиция/id + предпосчитанные признаки (вид, размер, мясоед, пол, взрослость) +/// и заметность (камуфляж/запах/шум) — чтобы запросы угрозы/добычи/партнёра/стада не дорефетчили компоненты. 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; ///