diff --git a/src/LittleSim/Content/AnimalSet.cs b/src/LittleSim/Content/AnimalSet.cs
index ca3fbb8..50681be 100644
--- a/src/LittleSim/Content/AnimalSet.cs
+++ b/src/LittleSim/Content/AnimalSet.cs
@@ -41,6 +41,10 @@ public sealed class AnimalSet
/// Набор генов вида: базовые значения для генерации особи.
public required GenomeTemplate Template { get; init; }
+
+ /// Эталонный размер тела вида (база GeneMaxBodySize) — предпосчитан для горячих путей
+ /// (масса/метаболизм Клайбера каждый кадр), чтобы не делать строковый словарный лукап в цикле.
+ public required float RefBodySize { get; init; }
}
// Встроенный набор стадий по умолчанию (если вид не задал свой): как было до выноса в данные.
@@ -121,6 +125,10 @@ public sealed class AnimalSet
Stages = def.Stages.Length > 0 ? def.Stages : DefaultStages,
Body = content.Defs.TryGet(def.Body, out var body) ? body : null,
Template = BuildTemplate(def.Genome, genes),
+ RefBodySize =
+ def.Genome.TryGetValue("GeneMaxBodySize", out var refSize) && refSize > 0f
+ ? refSize
+ : 1f,
};
_index[def] = i;
}
diff --git a/src/LittleSim/Sim/AnimalFactory.cs b/src/LittleSim/Sim/AnimalFactory.cs
index 1d15c7c..4ce20a1 100644
--- a/src/LittleSim/Sim/AnimalFactory.cs
+++ b/src/LittleSim/Sim/AnimalFactory.cs
@@ -90,7 +90,7 @@ public static class AnimalFactory
private static int SkillSeed(Genome genome)
{
var acc = 0;
- foreach (var (_, allele) in genome.ToDictionary())
+ foreach (var (_, allele) in genome.Alleles) // IReadOnlyDictionary без копии — без аллокации
{
acc += (int)(allele.A * 7919f) + (int)(allele.B * 104729f);
}
diff --git a/src/LittleSim/Sim/AnimalOrganism.cs b/src/LittleSim/Sim/AnimalOrganism.cs
index 61e7102..2a53ad3 100644
--- a/src/LittleSim/Sim/AnimalOrganism.cs
+++ b/src/LittleSim/Sim/AnimalOrganism.cs
@@ -216,7 +216,8 @@ public sealed class HealthState
Pain = Math.Clamp(pain, 0f, 1f);
- var caps = CapacityCalc.Compute(Parts, BloodLevel, Pain);
+ // Множитель старения применяется внутри Compute (без второго прохода/аллокации списка ключей).
+ var caps = CapacityCalc.Compute(Parts, BloodLevel, Pain, capacityScale);
foreach (var h in _hediffs)
{
if (h.Def.CapMods.Count == 0)
@@ -231,15 +232,6 @@ public sealed class HealthState
}
}
- if (capacityScale < 1f)
- {
- var scale = Math.Clamp(capacityScale, 0f, 1f);
- foreach (var capacity in new List(caps.Keys))
- {
- caps[capacity] *= scale;
- }
- }
-
Capacities = caps;
}
diff --git a/src/LittleSim/Sim/AnimalSystems.cs b/src/LittleSim/Sim/AnimalSystems.cs
index 4b03c52..a4f73d7 100644
--- a/src/LittleSim/Sim/AnimalSystems.cs
+++ b/src/LittleSim/Sim/AnimalSystems.cs
@@ -155,15 +155,18 @@ public sealed class AnimalNeedsSystem(
}
// Обмен веществ = ген × цена устойчивости к яду (C2) × множитель Клайбера по массе тела:
- // расход ∝ масса^0.75, нормирован на эталон вида → молодняк экономнее, крупные особи
- // прожорливее (масса растёт с размером/возрастом). Множитель к расходу убывающих нужд.
+ // расход ∝ масса^0.75 = (линейный размер/эталон)^2.25 (масса ∝ размер³), нормирован на
+ // эталон вида → молодняк экономнее, крупные прожорливее. Эталон предпосчитан (без словаря).
var sp = animals[o[i].Species];
var stageScale = sp.Stages[Math.Clamp(g[i].Stage, 0, sp.Stages.Length - 1)].Scale;
- var mass = Mass.OfAnimal(sp.Def, o[i].Traits, stageScale);
+ var sizeRatio = MathF.Max(
+ 0.01f,
+ o[i].Traits.BodySize * stageScale / sp.RefBodySize
+ );
var metabolism =
MathF.Max(0.1f, o[i].Traits.Metabolism)
* (1f + ToleranceMetabolicCost * Math.Clamp(o[i].Traits.ToxinTolerance, 0f, 1f))
- * Mass.KleiberFactor(mass, sp.Def.BaseMassKg);
+ * MathF.Pow(sizeRatio, 2.25f);
var lethalEmpty = false;
for (var k = 0; k < needs.Count; k++)
{
@@ -261,6 +264,12 @@ public sealed class AnimalDecisionSystem : BaseSystem
private readonly ArchetypeQuery _plants;
private readonly ArchetypeQuery _corpses;
+ // Пространственный индекс растений (перестраивается раз в кадр) + переиспользуемый буфер запроса —
+ // поиск корма берёт только ближние растения вместо прохода по всем (O(растений) на каждое решение).
+ private const int PlantGridCells = 10; // размер ячейки сетки в клетках мира (~радиус восприятия)
+ private readonly PlantGrid _plantGrid;
+ private readonly List _plantBuf = [];
+
public AnimalDecisionSystem(
EntityStore store,
GameClock clock,
@@ -278,6 +287,7 @@ public sealed class AnimalDecisionSystem : BaseSystem
_cellSize = cellSize;
_shore = shore;
_rng = new Random(seed);
+ _plantGrid = new PlantGrid(cellSize * PlantGridCells);
_animals = store.Query<
AnimalNeeds,
AnimalBrain,
@@ -320,6 +330,7 @@ public sealed class AnimalDecisionSystem : BaseSystem
protected override void OnUpdateGroup()
{
var delta = _clock.DeltaTime;
+ RebuildPlantGrid();
foreach (
var (needsChunk, brains, organisms, growths, transforms, entities) in _animals.Chunks
)
@@ -717,25 +728,42 @@ public sealed class AnimalDecisionSystem : BaseSystem
? TryFindBestPlant(from, radius, toxinTolerance, out plantId, out position)
: TryFindPlant(from, radius, out plantId, out position);
- // Рефлекс: ближайшее растение в радиусе (ничья — меньший id, детерминизм).
+ // Перестраивает пространственный индекс растений из их текущих позиций (раз в кадр, линейно).
+ private void RebuildPlantGrid()
+ {
+ _plantGrid.Clear();
+ foreach (var (transforms, _, organisms, entities) in _plants.Chunks)
+ {
+ var t = transforms.Span;
+ var o = organisms.Span;
+ for (var i = 0; i < t.Length; i++)
+ {
+ ref readonly var tr = ref o[i].Traits;
+ _plantGrid.Add(
+ entities.EntityAt(i).Id,
+ t[i].Position,
+ tr.Toxicity,
+ tr.Palatability
+ );
+ }
+ }
+ }
+
+ // Рефлекс: ближайшее растение в радиусе (ничья — меньший id, детерминизм). Кандидаты — из сетки.
private bool TryFindPlant(Vector2 from, float radius, out int plantId, out Vector2 position)
{
var bestSq = radius * radius;
plantId = -1;
position = default;
- foreach (var (transforms, _, _, entities) in _plants.Chunks)
+ _plantGrid.Collect(from, radius, _plantBuf);
+ foreach (var e in _plantBuf)
{
- var t = transforms.Span;
- for (var i = 0; i < t.Length; i++)
+ var sq = Vector2.DistanceSquared(from, e.Pos);
+ if (sq < bestSq || (sq == bestSq && e.Id < plantId))
{
- var sq = Vector2.DistanceSquared(from, t[i].Position);
- var id = entities.EntityAt(i).Id;
- if (sq < bestSq || (sq == bestSq && id < plantId))
- {
- bestSq = sq;
- plantId = id;
- position = t[i].Position;
- }
+ bestSq = sq;
+ plantId = e.Id;
+ position = e.Pos;
}
}
@@ -759,31 +787,25 @@ public sealed class AnimalDecisionSystem : BaseSystem
var bestScore = float.NegativeInfinity;
plantId = -1;
position = default;
- foreach (var (transforms, _, organisms, entities) in _plants.Chunks)
+ _plantGrid.Collect(from, radius, _plantBuf);
+ foreach (var e in _plantBuf)
{
- var t = transforms.Span;
- var o = organisms.Span;
- for (var i = 0; i < t.Length; i++)
+ var sq = Vector2.DistanceSquared(from, e.Pos);
+ if (sq > radiusSq)
{
- var sq = Vector2.DistanceSquared(from, t[i].Position);
- if (sq > radiusSq)
- {
- continue;
- }
+ continue;
+ }
- ref readonly var tr = ref o[i].Traits;
- var perceivedToxin = tr.Toxicity * (1f - tol);
- var score =
- tr.Palatability
- - ToxinAvoidWeight * perceivedToxin
- - ForageDistanceWeight * (radius > 0f ? MathF.Sqrt(sq) / radius : 0f);
- var id = entities.EntityAt(i).Id;
- if (score > bestScore || (score == bestScore && id < plantId))
- {
- bestScore = score;
- plantId = id;
- position = t[i].Position;
- }
+ var perceivedToxin = e.Toxicity * (1f - tol);
+ var score =
+ e.Palatability
+ - ToxinAvoidWeight * perceivedToxin
+ - ForageDistanceWeight * (radius > 0f ? MathF.Sqrt(sq) / radius : 0f);
+ if (score > bestScore || (score == bestScore && e.Id < plantId))
+ {
+ bestScore = score;
+ plantId = e.Id;
+ position = e.Pos;
}
}
@@ -985,6 +1007,7 @@ public sealed class AnimalActionSystem : BaseSystem
var eating = hh[i].State.Capacity(AnimalCapacities.Eating);
var foodEff = eating * hh[i].State.Capacity(AnimalCapacities.Digestion);
var self = entities.EntityAt(i);
+ self.TryGetComponent(out var skills); // один лукап на особь (эффекты + XP)
// Ноша замедляет (задел под перенос): перегруз сверх грузоподъёмности роняет скорость.
if (self.TryGetComponent(out var carrier) && carrier.CarriedKg > 0f)
{
@@ -1009,7 +1032,7 @@ public sealed class AnimalActionSystem : BaseSystem
if (MoveTo(ref pos, brain.Target, speed * seconds))
{
// Навык добычи корма: опытный форажир извлекает больше сытости; растёт от практики.
- var forageF = SkillFactor(_forageSkill, self);
+ var forageF = SkillFactor(_forageSkill, skills);
Refill(values, AnimalActions.Eat, days, foodEff * forageF);
Graze(
brain.TargetPlant,
@@ -1025,7 +1048,7 @@ public sealed class AnimalActionSystem : BaseSystem
);
// Эндозоохория: шанс проглотить семя зрелого растения (высадит позже).
TryIngestSeed(brain.TargetPlant, self);
- GrantXp(_forageSkill, self, days);
+ GrantXp(_forageSkill, skills, days);
}
break;
@@ -1079,9 +1102,9 @@ public sealed class AnimalActionSystem : BaseSystem
speed * seconds,
days,
foodEff,
- SkillFactor(_huntSkill, self)
+ SkillFactor(_huntSkill, skills)
);
- GrantXp(_huntSkill, self, days);
+ GrantXp(_huntSkill, skills, days);
break;
case AnimalActions.Flee:
@@ -1089,9 +1112,9 @@ public sealed class AnimalActionSystem : BaseSystem
MoveTo(
ref pos,
brain.Target,
- speed * FleeSpeedMult * SkillFactor(_evadeSkill, self) * seconds
+ speed * FleeSpeedMult * SkillFactor(_evadeSkill, skills) * seconds
);
- GrantXp(_evadeSkill, self, days);
+ GrantXp(_evadeSkill, skills, days);
break;
default: // Wander
@@ -1278,14 +1301,9 @@ public sealed class AnimalActionSystem : BaseSystem
}
// Бонус-множитель от уровня навыка: 0.7 (новичок) … 1.3 (мастер). Навык не определён модом → 1 (без эффекта).
- private float SkillFactor(int idx, Entity self)
+ private static float SkillFactor(int idx, in Skills s)
{
- if (
- idx < 0
- || !self.TryGetComponent(out var s)
- || s.Levels is null
- || idx >= s.Levels.Length
- )
+ if (idx < 0 || s.Levels is null || idx >= s.Levels.Length)
{
return 1f;
}
@@ -1294,14 +1312,9 @@ public sealed class AnimalActionSystem : BaseSystem
}
// Начисляет опыт навыку от практики: рост ∝ скорость обучения × страсть × (1−уровень) (убывающая отдача).
- private void GrantXp(int idx, Entity self, float days)
+ private void GrantXp(int idx, in Skills s, float days)
{
- if (
- idx < 0
- || !self.TryGetComponent(out var s)
- || s.Levels is null
- || idx >= s.Levels.Length
- )
+ if (idx < 0 || s.Levels is null || idx >= s.Levels.Length)
{
return;
}
diff --git a/src/LittleSim/Sim/CapacityCalc.cs b/src/LittleSim/Sim/CapacityCalc.cs
index 2e2af92..ea120d5 100644
--- a/src/LittleSim/Sim/CapacityCalc.cs
+++ b/src/LittleSim/Sim/CapacityCalc.cs
@@ -40,8 +40,14 @@ public static class AnimalCapacities
///
public static class CapacityCalc
{
- /// Считает карту способностей из инстансов частей, уровня крови (0..1) и боли (0..1).
- public static Dictionary Compute(PartInstance[] parts, float blood, float pain)
+ /// Считает карту способностей из инстансов частей, уровня крови (0..1) и боли (0..1);
+ /// — общий множитель (старение), применяется к итогу без лишних аллокаций.
+ public static Dictionary Compute(
+ PartInstance[] parts,
+ float blood,
+ float pain,
+ float capacityScale = 1f
+ )
{
// Сырой вклад каждой способности (Σ доля·HP) и НАБОР объявленных телом способностей: вид имеет
// только те, к которым его части вообще причастны (у оленя нет манипуляции). Объявленность —
@@ -100,10 +106,12 @@ public static class CapacityCalc
// В результат — только объявленные телом способности: известные по формуле зависимостей, прочие
// (модовые) — сырым вкладом. Так у вида видны ровно его способности, а не весь каталог.
+ var scale = Clamp01(capacityScale);
var result = new Dictionary(System.StringComparer.Ordinal);
foreach (var capacity in declared)
{
- result[capacity] = computed.TryGetValue(capacity, out var v) ? v : Raw(capacity);
+ var value = computed.TryGetValue(capacity, out var v) ? v : Raw(capacity);
+ result[capacity] = scale < 1f ? value * scale : value;
}
return result;
diff --git a/src/LittleSim/Sim/PlantGrid.cs b/src/LittleSim/Sim/PlantGrid.cs
new file mode 100644
index 0000000..6eca745
--- /dev/null
+++ b/src/LittleSim/Sim/PlantGrid.cs
@@ -0,0 +1,73 @@
+using System.Collections.Generic;
+using Microsoft.Xna.Framework;
+
+namespace LittleSim.Sim;
+
+///
+/// Пространственный индекс растений (равномерная сетка ячеек) для радиус-запросов поиска корма: вместо
+/// прохода по ВСЕМ растениям на каждое решение травоядного (O(растений) на особь) — только растения в
+/// ближних ячейках. Перестраивается раз в кадр из позиций растений (O(растений) линейно). В ячейке лежит
+/// то, что нужно поиску — позиция, id, токсичность и вкусность — без дорефетча компонентов. Списки ячеек
+/// переиспользуются (пул), чтобы перестройка не аллоцировала. Порядок обхода детерминирован (вложенные
+/// циклы по ячейкам + порядок вставки), а выбор всё равно ломает ничьи по меньшему id — детерминизм цел.
+///
+public sealed class PlantGrid
+{
+ /// Запись растения в ячейке: id, позиция и признаки, нужные поиску корма.
+ public readonly record struct Entry(int Id, Vector2 Pos, float Toxicity, float Palatability);
+
+ private readonly float _cell;
+ private readonly Dictionary<(int, int), List> _cells = new();
+ private readonly Stack> _pool = new();
+
+ public PlantGrid(float cellSize) => _cell = cellSize > 0f ? cellSize : 1f;
+
+ /// Очищает сетку (списки ячеек возвращаются в пул для переиспользования).
+ public void Clear()
+ {
+ foreach (var list in _cells.Values)
+ {
+ list.Clear();
+ _pool.Push(list);
+ }
+
+ _cells.Clear();
+ }
+
+ /// Добавляет растение в его ячейку.
+ public void Add(int id, Vector2 pos, float toxicity, float palatability)
+ {
+ var key = CellOf(pos);
+ if (!_cells.TryGetValue(key, out var list))
+ {
+ list = _pool.Count > 0 ? _pool.Pop() : new List();
+ _cells[key] = list;
+ }
+
+ list.Add(new Entry(id, pos, toxicity, palatability));
+ }
+
+ /// Собирает в растения из ячеек, перекрывающих радиус (точную
+ /// дистанцию проверяет вызывающий). Буфер переиспользуется — без аллокаций на запрос.
+ public void Collect(Vector2 center, float radius, List results)
+ {
+ results.Clear();
+ var minX = (int)MathF.Floor((center.X - radius) / _cell);
+ var maxX = (int)MathF.Floor((center.X + radius) / _cell);
+ var minY = (int)MathF.Floor((center.Y - radius) / _cell);
+ var maxY = (int)MathF.Floor((center.Y + radius) / _cell);
+ for (var cx = minX; cx <= maxX; cx++)
+ {
+ for (var cy = minY; cy <= maxY; cy++)
+ {
+ if (_cells.TryGetValue((cx, cy), out var list))
+ {
+ results.AddRange(list);
+ }
+ }
+ }
+ }
+
+ private (int, int) CellOf(Vector2 p) =>
+ ((int)MathF.Floor(p.X / _cell), (int)MathF.Floor(p.Y / _cell));
+}