Compare commits

...
9 Commits
Author SHA1 Message Date
Leonid PershinandClaude Opus 4.8 d196437462 Review pass: fix stale comments (drop mountain refs, update AnimalSet summary)
Code review of the session''s work found the simulation deterministic, save/load
complete, def/localization consistent, and no real correctness bugs (the rest of
the flagged items were defensive-paranoia false positives). Only fixes needed were
stale comments: AnimalSet still described the Phase-A1 single-sprite era (now
directional sprites + stages/sex), and two occluder comments referenced the removed
mountains.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 00:15:05 +03:00
Leonid PershinandClaude Opus 4.8 65ecd2c7b2 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>
2026-06-15 00:08:52 +03:00
Leonid PershinandClaude Opus 4.8 f60c55399b Senses stage 3: bounded animal memory (remember & avoid threats)
Memory component holds a small ring of entries {kind, perceived pos, confidence,
age}, capacity = brainSize x 16 (tiny brains remember nothing). On detecting a
threat the animal records it (Memory.Remember, merge-by-proximity, evict weakest);
AnimalMemorySystem forgets entries over ~2 days. While wandering, an animal steers
away from the nearest remembered threat (danger zone lingers after the predator is
out of sight). Component is general (Food/Water/Mate kinds reserved for later) and
transient (not serialized, like AI targets). Build + --check-content clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 00:08:32 +03:00
Leonid PershinandClaude Opus 4.8 3031bf2954 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>
2026-06-15 00:02:59 +03:00
Leonid PershinandClaude Opus 4.8 70e79a5c30 Senses stage 1: smell/touch capacities, organs, acuity + detectability genes
Foundation for the perception system. Adds Smell and Touch capacities (CapacityCalc
dependency chain x consciousness) with organs on the Quadruped body (nose->Smell,
skin->Touch; eyes/ears already give Sight/Hearing) so hediffs and organ damage
reduce them for free. New genes: acuity GeneHearing/GeneSmell/GeneTouch (radius
multipliers, default 1 if a species omits them) and detectability GeneCamouflage
(lower sight), GeneScent (raise smell), GeneNoise (raise hearing) on AnimalPhenotype.
Deer/wolf/boar/chicken get sensible values (wolf smells well, deer is alert +
dappled). cap.smell/cap.touch localized. Build + --check-content clean
(48 genes, 47 traits). Multi-sense detection + memory come next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 23:55:56 +03:00
Leonid PershinandClaude Opus 4.8 dc617521c2 Merge branch 'animal-bob' into main
Walking sway for animals (AnimalBobSystem) to make movement feel livelier.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 23:29:15 +03:00
Leonid PershinandClaude Opus 4.8 0c07eba021 Liven up animal movement with a walking sway (presentation only)
AnimalBobSystem tilts a moving animal side-to-side via Transform2D.Rotation (a
gentle waddle) and eases it upright when standing. Phase runs on game time (frozen
on pause, faster when sped up) and is offset per entity id so animals sway out of
sync. Purely presentational -- the simulation ignores rotation, and the facing
system writes scale (not rotation), so no conflict. Corpses/eggs have no brain so
they stay still. Build + --check-content clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 23:29:14 +03:00
Leonid PershinandClaude Opus 4.8 405ed192a8 Merge branch 'remove-mountains' into main
Remove mountain terrain (forest covers the high elevation band for now).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 23:24:19 +03:00
Leonid PershinandClaude Opus 4.8 cad4b5f38f Remove mountains from the map (forest covers the high band for now)
Dropped the Mountain terrain def and extended Forest to maxHeight 1.01, so the
former mountain elevations generate forest instead. No terrain blocks light now;
the occluder mechanism stays generic (mature trees still cast shadows) -- updated
the stale "mountain" comments. Removed the unused terrain.mountain localization.
Build + --check-content clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 23:24:19 +03:00
14 changed files with 444 additions and 42 deletions
+24
View File
@@ -23,6 +23,12 @@
"GeneMoveSpeed": 1.2,
"GeneBloodVolume": 1.4,
"GeneVision": 1.3,
"GeneHearing": 1.4,
"GeneSmell": 1.2,
"GeneTouch": 1.0,
"GeneCamouflage": 0.35,
"GeneScent": 0.5,
"GeneNoise": 0.4,
"GeneBrainSize": 0.45,
"GeneInsulation": 0.55,
"GeneFurColor": 0.45,
@@ -59,6 +65,12 @@
"GeneMoveSpeed": 1.4,
"GeneBloodVolume": 1.3,
"GeneVision": 1.5,
"GeneHearing": 1.5,
"GeneSmell": 1.8,
"GeneTouch": 1.0,
"GeneCamouflage": 0.2,
"GeneScent": 0.6,
"GeneNoise": 0.5,
"GeneBrainSize": 0.5,
"GeneInsulation": 0.6,
"GeneFurColor": 0.7,
@@ -93,6 +105,12 @@
"GeneMoveSpeed": 1.0,
"GeneBloodVolume": 1.2,
"GeneVision": 1.0,
"GeneHearing": 1.2,
"GeneSmell": 1.6,
"GeneTouch": 1.0,
"GeneCamouflage": 0.2,
"GeneScent": 0.7,
"GeneNoise": 0.7,
"GeneBrainSize": 0.42,
"GeneInsulation": 0.5,
"GeneFurColor": 0.85,
@@ -126,6 +144,12 @@
"GeneMoveSpeed": 0.9,
"GeneBloodVolume": 0.7,
"GeneVision": 1.1,
"GeneHearing": 1.0,
"GeneSmell": 0.6,
"GeneTouch": 1.0,
"GeneCamouflage": 0.2,
"GeneScent": 0.4,
"GeneNoise": 0.6,
"GeneBrainSize": 0.3,
"GeneInsulation": 0.45,
"GeneFurColor": 0.6,
+4
View File
@@ -33,6 +33,10 @@
"capacities": { "Hearing": 0.5 } },
{ "name": "earRight", "parent": "head", "coverage": 0.01, "maxHp": 8,
"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,
"capacities": { "Eating": 1.0, "Talking": 0.5 } },
{ "name": "tongue", "parent": "head", "coverage": 0.01, "maxHp": 8,
+23
View File
@@ -18,6 +18,29 @@
{ "defName": "GeneVision", "parent": "BaseNumericGene", "label": "gene.vision",
"default": 1.0, "min": 0.2, "max": 3.0, "tags": ["animal", "senses"],
"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",
"default": 0.45, "min": 0.0, "max": 1.0, "spread": 0.03, "tags": ["animal", "brain"],
"effects": { "brainSize": "value" } },
+2 -4
View File
@@ -19,7 +19,7 @@
{ "chance": 0.20, "options": ["Strawberry", "Raspberry"] },
{ "chance": 0.14, "options": ["DandelionA", "DandelionB", "DandelionC", "DaylilyA", "DaylilyB", "RoseA"] }
] },
{ "defName": "Forest", "label": "terrain.forest", "maxHeight": 0.85, "color": [44, 110, 50],
{ "defName": "Forest", "label": "terrain.forest", "maxHeight": 1.01, "color": [44, 110, 50],
"isLand": true, "surface": "terrain/surfaces/soil", "fertility": 1.6,
"scatter": [
{ "chance": 0.35, "options": ["TreeOakA", "TreeOakB", "TreeBirchA", "TreeGrayPineA",
@@ -28,8 +28,6 @@
{ "chance": 0.16, "options": ["BushA", "BushB", "BushC"] },
{ "chance": 0.14, "options": ["Strawberry", "Raspberry"] },
{ "chance": 0.16, "options": ["GlowstoolA", "GlowstoolB", "TimbershroomA", "TimbershroomB", "NutrifungusA"] }
] },
{ "defName": "Mountain", "label": "terrain.mountain", "maxHeight": 1.01, "color": [136, 132, 128],
"surface": "terrain/surfaces/roughhewnrock", "fertility": 0.2, "blocksLight": true }
] }
]
}
+2 -1
View File
@@ -70,6 +70,8 @@
"cap.moving": "moving",
"cap.sight": "sight",
"cap.hearing": "hearing",
"cap.smell": "smell",
"cap.touch": "touch",
"cap.talking": "talking",
"cap.eating": "eating",
"cap.breathing": "breathing",
@@ -146,7 +148,6 @@
"terrain.sand": "sand",
"terrain.grass": "grass",
"terrain.forest": "forest",
"terrain.mountain": "mountains",
"plant.oak": "oak",
"plant.birch": "birch",
"plant.pine": "pine",
+2 -1
View File
@@ -70,6 +70,8 @@
"cap.moving": "движение",
"cap.sight": "зрение",
"cap.hearing": "слух",
"cap.smell": "обоняние",
"cap.touch": "осязание",
"cap.talking": "речь",
"cap.eating": "питание",
"cap.breathing": "дыхание",
@@ -146,7 +148,6 @@
"terrain.sand": "песок",
"terrain.grass": "трава",
"terrain.forest": "лес",
"terrain.mountain": "горы",
"plant.oak": "дуб",
"plant.birch": "берёза",
"plant.pine": "сосна",
+2 -1
View File
@@ -8,7 +8,8 @@ namespace LittleSim.Content;
/// Готовая (resolved) таблица животных для сцены: по каждому <see cref="AnimalDef"/> — загруженный
/// регион атласа и набор генов вида (<see cref="GenomeTemplate"/>) для генерации особей. Строится
/// один раз при загрузке сцены (нужны атлас и устройство), как <see cref="PlantSet"/>. Системы и
/// фабрика индексируются в неё без словарей. Фаза A1 — один спрайт на вид; стадии/пол придут позже.
/// фабрика индексируются в неё без словарей. Несёт направленные спрайты (восток/север/юг + флип) по
/// полу/стадии, спрайты трупа/яйца, анатомию, шаблон генома и предпосчёты для горячих путей.
/// </summary>
public sealed class AnimalSet
{
+1 -1
View File
@@ -27,7 +27,7 @@ public sealed class TerrainDef : Def
/// <summary>Суша: здесь появляются жители, животные и растения.</summary>
public bool IsLand { get; init; }
/// <summary>Загораживает свет (горы) — базовый окклюдер для лайтмапа/теней.</summary>
/// <summary>Светонепроницаемый террейн — базовый окклюдер лайтмапа/теней (сейчас такого нет).</summary>
public bool BlocksLight { get; init; }
/// <summary>Ключ текстуры поверхности для тайловой сцены; null — тонированный тайл.</summary>
+5 -3
View File
@@ -70,8 +70,8 @@ public sealed class WorldScene : Scene
private SkillSet _skills = null!;
private float[] _cellFertility = [];
private bool[] _cellLand = [];
private bool[] _cellOccluderBase = []; // горы (статично из террейна)
private bool[] _cellOccluder = []; // горы + зрелые деревья (пересобирается лайтмапом)
private bool[] _cellOccluderBase = []; // светонепроницаемый террейн (статично; сейчас такого нет)
private bool[] _cellOccluder = []; // террейн-окклюдеры + зрелые деревья (пересобирается лайтмапом)
/// <summary>Новый мир из конфига.</summary>
public WorldScene(WorldConfig config)
@@ -270,6 +270,7 @@ public sealed class WorldScene : Scene
var thoughts = new ThoughtSet(content);
UpdateSystems.Add(new AnimalNeedsSystem(Context.Clock, SecondsPerDay, _needs, _animals));
UpdateSystems.Add(new AnimalSkillSystem(Context.Clock, SecondsPerDay, _skills));
UpdateSystems.Add(new AnimalMemorySystem(Context.Clock, SecondsPerDay));
UpdateSystems.Add(
new AnimalRutSystem(_animals, climate, content.Defs.Get<HediffDef>("Rut"))
);
@@ -323,6 +324,7 @@ public sealed class WorldScene : Scene
UpdateSystems.Add(new AnimalAppearanceSystem(_needs));
UpdateSystems.Add(new AnimalGrowthSystem(Context.Clock, SecondsPerDay, _animals, CellSize));
UpdateSystems.Add(new AnimalFacingSystem(_animals, CellSize));
UpdateSystems.Add(new AnimalBobSystem(Context.Clock));
UpdateSystems.Add(new AnimalMortalitySystem(Store, _animals, CellSize));
UpdateSystems.Add(new CorpseSystem(Store, Context.Clock, SecondsPerDay, climate));
UpdateSystems.Add(
@@ -992,7 +994,7 @@ public sealed class WorldScene : Scene
return shore.ToArray();
}
// Текущая сетка окклюдеров для лайтмапа: статичные горы + клетки со зрелыми деревьями.
// Текущая сетка окклюдеров для лайтмапа: светонепроницаемый террейн (сейчас нет) + зрелые деревья.
private bool[] BuildOccluders()
{
Array.Copy(_cellOccluderBase, _cellOccluder, _cellOccluder.Length);
+12
View File
@@ -83,9 +83,21 @@ public static class AnimalFactory
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;
}
/// <summary>Сколько записей памяти даёт единица размера мозга (объём памяти = brainSize × это).</summary>
public const float MemoryPerBrain = 16f;
// Стабильный сид из генома (сумма аллелей — порядконезависима) для детерминированной страсти к навыкам.
private static int SkillSeed(Genome genome)
{
+110
View File
@@ -1,6 +1,7 @@
using System.Collections.Generic;
using Friflo.Engine.ECS;
using LittleSim.Content;
using Microsoft.Xna.Framework;
using MrGameEng.Genetics;
namespace LittleSim.Sim;
@@ -27,6 +28,24 @@ public struct AnimalPhenotype
/// <summary>Дальность зрения (множитель радиуса поиска).</summary>
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>
public float BrainSize;
@@ -76,6 +95,8 @@ public struct AnimalPhenotype
public static AnimalPhenotype FromTraits(IReadOnlyDictionary<string, float> traits)
{
float T(string name) => traits.GetValueOrDefault(name);
// Острота чувств по умолчанию 1 (вид без гена не «слепнет»); заметность — мягкие дефолты.
float Acuity(string name) => traits.TryGetValue(name, out var v) ? v : 1f;
return new AnimalPhenotype
{
BodySize = T("bodySize"),
@@ -83,6 +104,12 @@ public struct AnimalPhenotype
MoveSpeed = T("moveSpeed"),
BloodVolume = T("bloodVolume"),
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"),
Insulation = T("insulation"),
FurHue = T("furHue"),
@@ -483,6 +510,89 @@ public struct Egg : IComponent
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>
public static class AnimalSkills
{
+245 -28
View File
@@ -268,10 +268,14 @@ 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 const float RememberedThreatAvoidCells = 14f; // в этой зоне от памяти об угрозе зверь уходит прочь
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 +296,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 +366,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,19 +393,24 @@ 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
)
)
{
self.GetComponent<Memory>().Remember(MemoryKind.Threat, threatPos, _smellSnap);
brain.Action = AnimalActions.Flee;
brain.TargetPlant = -1;
brain.TargetPrey = -1;
@@ -401,7 +418,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 +438,7 @@ public sealed class AnimalDecisionSystem : BaseSystem
when eatsMeat
&& TryFindKill(
pos,
seeRadius,
ranges,
o[i].Species,
o[i].Traits,
self.Id,
@@ -476,11 +493,21 @@ public sealed class AnimalDecisionSystem : BaseSystem
default:
brain.Action = AnimalActions.Wander;
brain.TargetPlant = -1;
// Стадность: социальный зверь вдали от группы бредёт к её центру (когезия), рядом —
// мельтешит случайно (с расхождением). Несоциальный — обычное случайное блуждание.
var toCenter = herdCenter - pos;
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
&& herdCount > 0
&& centerDist > HerdSeparation * _cellSize
@@ -511,6 +538,37 @@ public sealed class AnimalDecisionSystem : BaseSystem
private float VisionRadius(int species, float vision) =>
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) на особь,
// как и прочие сканы. Сам исключается по id; центр — средняя позиция соседей того же вида.
private void HerdScan(
@@ -547,18 +605,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 +628,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 +662,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 +729,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 +757,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 +771,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 +874,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
)
);
}
@@ -1688,6 +1812,52 @@ public sealed class AnimalFacingSystem(AnimalSet animals, int cellSize)
}
}
/// <summary>
/// «Раскачка» при ходьбе (презентация): идущий зверь покачивается из стороны в сторону (наклон спрайта
/// через <see cref="Transform2D.Rotation"/>), стоящий — плавно выпрямляется. Делает движение живее, не
/// затрагивая симуляцию (она вращение не использует). Фаза покачивания идёт по игровому времени (стоп на
/// паузе, быстрее на ускорении) и сдвинута по id особи — звери качаются вразнобой. Граница sim/presentation
/// цела: система пишет только поворот трансформа.
/// </summary>
public sealed class AnimalBobSystem(GameClock clock) : QuerySystem<AnimalBrain, Transform2D>
{
private const float SwaySpeed = 9f; // скорость набега фазы (рад/сек игрового времени)
private const float SwayAmplitude = 0.08f; // амплитуда наклона (рад ≈ 4.6°)
private const float MinMoveSq = 0.5f * 0.5f; // ближе к цели — считаем стоящим
private const float UprightDecay = 0.85f; // как быстро выпрямляется стоя (за кадр)
private float _phase;
protected override void OnUpdate()
{
var dt = clock.DeltaTime;
if (dt <= 0f)
{
return; // пауза — не качаем и не выпрямляем
}
_phase += dt * SwaySpeed;
foreach (var (brains, transforms, entities) in Query.Chunks)
{
var b = brains.Span;
var t = transforms.Span;
for (var i = 0; i < b.Length; i++)
{
ref var rot = ref t[i].Rotation;
if ((b[i].Target - t[i].Position).LengthSquared() > MinMoveSq)
{
var offset = entities.EntityAt(i).Id * 0.7f; // десинхрон между особями
rot = MathF.Sin(_phase + offset) * SwayAmplitude;
}
else
{
rot *= UprightDecay; // у цели/стоя — плавно к вертикали
}
}
}
}
}
/// <summary>
/// Рост и стадии (фаза A3): копит возраст в игровых днях, переключает стадию (Baby→Juvenile→Adult→
/// Senior) по порогам из генов и при смене стадии меняет спрайт (детёныш/самка/самец) и размер.
@@ -2458,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 Sight = "Sight";
public const string Hearing = "Hearing";
public const string Smell = "Smell";
public const string Touch = "Touch";
public const string Talking = "Talking";
public const string Eating = "Eating";
public const string Digestion = "Digestion";
@@ -23,6 +25,8 @@ public static class AnimalCapacities
Moving,
Sight,
Hearing,
Smell,
Touch,
Talking,
Eating,
Breathing,
@@ -99,6 +103,8 @@ public static class CapacityCalc
[AnimalCapacities.Moving] = Raw(AnimalCapacities.Moving) * cons * pump,
[AnimalCapacities.Sight] = Raw(AnimalCapacities.Sight) * 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.Eating] = Raw(AnimalCapacities.Eating) * cons,
[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)
: 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>