diff --git a/Mods/Core/Defs/Animals/Animals.json b/Mods/Core/Defs/Animals/Animals.json
index f6323d8..b39a776 100644
--- a/Mods/Core/Defs/Animals/Animals.json
+++ b/Mods/Core/Defs/Animals/Animals.json
@@ -39,6 +39,7 @@
"GeneLitterSize": 1,
"GeneHerbivory": 1.0,
"GeneToxinTolerance": 0.1,
+ "GeneFeedingStyle": 0.15,
"GeneSociability": 0.8
}
},
@@ -55,9 +56,10 @@
"baseSpeed": 32,
"visionCells": 18,
"attackRangeCells": 1.3,
- "attackDamage": 80,
- "attackBleed": 3.0,
- "attackBloodLoss": 5.0,
+ "attackIntervalSec": 1.4,
+ "attackDamage": 16,
+ "attackBleed": 0.16,
+ "attackBloodLoss": 0.06,
"baseMassKg": 45,
"genome": {
"GeneMaxBodySize": 1.6,
@@ -80,6 +82,7 @@
"GeneGestationDays": 35,
"GeneLitterSize": 4,
"GeneCarnivory": 1.0,
+ "GeneFeedingStyle": 0.9,
"GeneSociability": 0.6
}
},
@@ -95,9 +98,10 @@
"baseSpeed": 26,
"visionCells": 13,
"attackRangeCells": 1.2,
- "attackDamage": 45,
- "attackBleed": 1.8,
- "attackBloodLoss": 3.0,
+ "attackIntervalSec": 2.0,
+ "attackDamage": 9,
+ "attackBleed": 0.1,
+ "attackBloodLoss": 0.035,
"baseMassKg": 80,
"genome": {
"GeneMaxBodySize": 1.3,
@@ -123,6 +127,7 @@
"GeneCarnivory": 0.2,
"GeneOmnivory": 0.85,
"GeneToxinTolerance": 0.25,
+ "GeneFeedingStyle": 0.55,
"GeneSociability": 0.3
}
},
@@ -161,6 +166,7 @@
"GeneHerbivory": 0.8,
"GeneToxinTolerance": 0.15,
"GeneEggLaying": 1.0,
+ "GeneFeedingStyle": 0.2,
"GeneSociability": 0.55
}
}
diff --git a/Mods/Core/Defs/Genes/Animal.json b/Mods/Core/Defs/Genes/Animal.json
index 869122d..9dd3da2 100644
--- a/Mods/Core/Defs/Genes/Animal.json
+++ b/Mods/Core/Defs/Genes/Animal.json
@@ -76,6 +76,13 @@
"default": 0.0, "min": 0.0, "max": 1.0, "tags": ["animal", "diet"],
"effects": { "toxinTolerance": "value" } },
+ // Стиль питания [0..1]: 0 — пастьба (graze, частые мелкие приёмы, держит сытость высокой),
+ // 1 — обжорство (gorge, ищет еду лишь при сильном голоде и ест до полного — редкие крупные приёмы,
+ // как у хищников). Задаёт пороги начала/конца кормёжки в AnimalDecisionSystem. Эволюционирует.
+ { "defName": "GeneFeedingStyle", "parent": "BaseNumericGene", "label": "gene.feedingStyle",
+ "default": 0.5, "min": 0.0, "max": 1.0, "spread": 0.05, "tags": ["animal", "diet"],
+ "effects": { "feedingStyle": "value" } },
+
// Пол как локус (фаза A3): аллели {X=0, Y=1}; самка XX, самец XY. Эффекта нет — пол читается из
// самих аллелей (наличие Y), не из выраженного значения. mutationChance 0 (X не мутирует в Y).
// Генерация особи-основателя задаёт пол явно (XX или XY), чтобы не возник невозможный YY.
diff --git a/Mods/Core/Languages/en/ui.json b/Mods/Core/Languages/en/ui.json
index 7b3013b..1931867 100644
--- a/Mods/Core/Languages/en/ui.json
+++ b/Mods/Core/Languages/en/ui.json
@@ -196,6 +196,42 @@
"thought.gaveBirth": "gave birth",
"thought.mated": "mated",
"thought.quenchedThirst": "drank its fill",
+ "gene.feedingStyle": "feeding style",
+ "inspect.animal.feeding": "Feeding: {0}",
+ "feeding.gorge": "gorger (rare, to the full)",
+ "feeding.graze": "grazer (often, little by little)",
+ "feeding.mixed": "moderate",
+ "inspect.tab.log": "Log",
+ "log.empty": "No history yet",
+ "log.stamp": "D{0} {1:00}:{2:00}",
+ "hud.events": "Events",
+ "hud.perf": "Perf",
+ "events.title": "Event log",
+ "events.empty": "Nothing has happened yet",
+ "perf.title": "Performance",
+ "perf.fps": "{0} FPS · {1:0.0} ms/frame",
+ "perf.root": "{0}: {1:0.000} ms · systems {2}",
+ "perf.systemline": "{0} {1:0.000} ms {2:0}%",
+ "perf.hint": "Refreshes ~5/s · costliest systems shown",
+ "event.mated": "{0} mated with {1}",
+ "event.pregnant": "{0} became pregnant",
+ "event.birth": "{0} gave birth ×{1}",
+ "event.hatch": "{0}: eggs hatched ×{1}",
+ "event.hunt": "{0} started hunting: target — {1}",
+ "event.injured": "{0} wounded: {1}",
+ "event.struck": "{0} struck {1} (dmg {2:0})",
+ "event.tookhit": "{0}: hit by {1} (dmg {2:0})",
+ "event.killed": "{0} killed prey: {1}",
+ "event.ate": "{0} ate",
+ "event.drank": "{0} drank",
+ "event.rest": "{0} is resting",
+ "event.dig": "{0} digs for food",
+ "event.wallow": "{0} wallows in mud",
+ "event.fled": "{0} is fleeing",
+ "event.grew": "{0}: new stage — {1}",
+ "event.death.starve": "{0} died of hunger/thirst",
+ "event.death.age": "{0} died of old age",
+ "event.death.disease": "{0} died of disease",
"net.hud": "Multiplayer: {0} | beings: {1} | Esc — back to menu",
"net.connecting": "connecting…",
"net.connected": "connected",
diff --git a/Mods/Core/Languages/ru/ui.json b/Mods/Core/Languages/ru/ui.json
index 4f62168..0d1902e 100644
--- a/Mods/Core/Languages/ru/ui.json
+++ b/Mods/Core/Languages/ru/ui.json
@@ -196,6 +196,42 @@
"thought.gaveBirth": "родила потомство",
"thought.mated": "спарилась",
"thought.quenchedThirst": "напилась вволю",
+ "gene.feedingStyle": "стиль питания",
+ "inspect.animal.feeding": "Питание: {0}",
+ "feeding.gorge": "обжора (редко, до отвала)",
+ "feeding.graze": "пастьба (часто, понемногу)",
+ "feeding.mixed": "умеренное",
+ "inspect.tab.log": "История",
+ "log.empty": "История пуста",
+ "log.stamp": "Д{0} {1:00}:{2:00}",
+ "hud.events": "События",
+ "hud.perf": "Произв.",
+ "events.title": "Журнал событий",
+ "events.empty": "Пока ничего не произошло",
+ "perf.title": "Производительность",
+ "perf.fps": "{0} FPS · {1:0.0} мс/кадр",
+ "perf.root": "{0}: {1:0.000} мс · систем {2}",
+ "perf.systemline": "{0} {1:0.000} мс {2:0}%",
+ "perf.hint": "Обновляется ~5 раз/с · показаны самые дорогие системы",
+ "event.mated": "{0} спарилась с {1}",
+ "event.pregnant": "{0} забеременела",
+ "event.birth": "{0} принесла потомство ×{1}",
+ "event.hatch": "{0}: вылупились детёныши ×{1}",
+ "event.hunt": "{0} начала охоту: цель — {1}",
+ "event.injured": "{0} ранила: {1}",
+ "event.struck": "{0} ударил {1} (урон {2:0})",
+ "event.tookhit": "{0}: удар от {1} (урон {2:0})",
+ "event.killed": "{0} убила добычу: {1}",
+ "event.ate": "{0} поела",
+ "event.drank": "{0} попила",
+ "event.rest": "{0} отдыхает",
+ "event.dig": "{0} роет землю в поисках корма",
+ "event.wallow": "{0} валяется в грязи",
+ "event.fled": "{0} спасается бегством",
+ "event.grew": "{0}: новая стадия — {1}",
+ "event.death.starve": "{0} погибла от голода/жажды",
+ "event.death.age": "{0} умерла от старости",
+ "event.death.disease": "{0} погибла от болезни",
"net.hud": "Мультиплеер: {0} | жителей: {1} | Esc — в меню",
"net.connecting": "подключение…",
"net.connected": "подключено",
diff --git a/src/LittleSim.Web/NetContract.cs b/src/LittleSim.Web/NetContract.cs
index 5c80978..465135e 100644
--- a/src/LittleSim.Web/NetContract.cs
+++ b/src/LittleSim.Web/NetContract.cs
@@ -1,3 +1,5 @@
+using System;
+using System.Runtime.CompilerServices;
using Friflo.Engine.ECS;
using Microsoft.Xna.Framework;
using MrGameEng.Net;
@@ -36,7 +38,34 @@ public static class WebNetSchema
/// Порт сервера по умолчанию (NetSchema.DefaultPort).
public const int DefaultPort = 9050;
+ // Те же wire-размеры, что и в десктопном NetSchema.cs — проверяем, что зеркало не разошлось по раскладке.
+ private const int TransformWireSize = 20; // Vector2(8) + float(4) + Vector2(8)
+ private const int PawnNeedsWireSize = 4; // float(4)
+
/// Transform2D → NetTransform, PawnNeeds → NetPawnNeeds.
- public static ReplicationSchema Create() =>
- new ReplicationSchema().Register().Register();
+ public static ReplicationSchema Create()
+ {
+ VerifyLayout();
+ return new ReplicationSchema().Register().Register();
+ }
+
+ /// Сверяет байт-раскладку зеркальных структур с десктопным контрактом (NetSchema.cs).
+ private static void VerifyLayout()
+ {
+ Require(TransformWireSize);
+ Require(PawnNeedsWireSize);
+
+ static void Require(int expected)
+ where T : struct
+ {
+ var actual = Unsafe.SizeOf();
+ if (actual != expected)
+ {
+ throw new InvalidOperationException(
+ $"Размер зеркального {typeof(T).Name} = {actual} Б, ожидался {expected} Б: "
+ + "wire-контракт разошёлся — синхронизируй NetContract.cs и LittleSim/Net/NetSchema.cs."
+ );
+ }
+ }
+ }
}
diff --git a/src/LittleSim/App/PerfReport.cs b/src/LittleSim/App/PerfReport.cs
new file mode 100644
index 0000000..0b13f41
--- /dev/null
+++ b/src/LittleSim/App/PerfReport.cs
@@ -0,0 +1,107 @@
+using System.Collections.Generic;
+using Friflo.Engine.ECS.Systems;
+using MrGameEng.DevConsole;
+
+namespace LittleSim.App;
+
+///
+/// Дев-инструмент: per-system тайминги Friflo. Включает мониторинг (SetMonitorPerf) на корнях
+/// систем и печатает в дев-консоль командой perf отсортированную по времени таблицу систем
+/// (среднее мс/кадр по последним кадрам + доля от корня). Доступно только в режиме разработчика.
+///
+public static class PerfReport
+{
+ private const int DefaultTop = 15; // сколько самых дорогих систем печатать по умолчанию
+
+ /// Окно усреднения времени (последние N обновлений) — используют и консоль, и perf-окно.
+ public const int AvgFrames = 30;
+
+ private static readonly List<(string Name, double AvgMs, int Updates)> Scratch = []; // без аллокаций на вызов
+
+ /// Включает мониторинг на корнях и регистрирует команду perf [N] (топ-N систем).
+ public static void Register(DevConsole console, params SystemRoot[] roots)
+ {
+ EnableMonitoring(roots);
+ console.Register(
+ "perf",
+ "perf [N] — тайминги систем (среднее мс/кадр, топ-N по времени)",
+ (c, args) =>
+ {
+ // Мониторинг пере-утверждаем на случай систем, добавленных после Register (идемпотентно).
+ EnableMonitoring(roots);
+ var top =
+ args.Length > 0 && int.TryParse(args[0], out var n) && n > 0 ? n : DefaultTop;
+ foreach (var root in roots)
+ {
+ WriteRoot(c, root, top);
+ }
+ }
+ );
+ }
+
+ /// Включает per-system мониторинг Friflo на корнях (идемпотентно; можно звать повторно для
+ /// систем, добавленных позже). Публично — чтобы perf-окно тоже могло его утвердить.
+ public static void EnableMonitoring(params SystemRoot[] roots)
+ {
+ foreach (var root in roots)
+ {
+ root.SetMonitorPerf(true);
+ }
+ }
+
+ ///
+ /// Заполняет строками (имя, среднее мс/кадр, число обновлений) по листовым
+ /// системам корня, сортированными по времени убыв.; возвращает суммарное среднее мс/кадр корня
+ /// (0, если данных ещё нет). Общий сбор для консольной команды и perf-окна — без дублирования.
+ ///
+ public static double Snapshot(
+ SystemRoot root,
+ List<(string Name, double AvgMs, int Updates)> into
+ )
+ {
+ into.Clear();
+ Collect(root, into);
+ into.Sort((a, b) => b.AvgMs.CompareTo(a.AvgMs));
+ return root.Perf.UpdateCount == 0 ? 0.0 : root.Perf.LastAvgMs(AvgFrames);
+ }
+
+ private static void WriteRoot(DevConsole console, SystemRoot root, int top)
+ {
+ var total = Snapshot(root, Scratch);
+ if (root.Perf.UpdateCount == 0)
+ {
+ console.WriteLine($"[{root.Name}] нет данных — подожди кадр и повтори 'perf'.");
+ return;
+ }
+
+ console.WriteLine($"[{root.Name}] {total:0.000} мс/кадр · систем: {Scratch.Count}");
+ var shown = System.Math.Min(top, Scratch.Count);
+ for (var i = 0; i < shown; i++)
+ {
+ var (name, avg, _) = Scratch[i];
+ var pct = total > 0.0001 ? avg / total * 100.0 : 0.0;
+ console.WriteLine($" {name, -30} {avg, 8:0.000} мс {pct, 3:0}%");
+ }
+
+ if (Scratch.Count > shown)
+ {
+ console.WriteLine($" … ещё {Scratch.Count - shown} систем(ы)");
+ }
+ }
+
+ // Рекурсивно собирает листовые системы (группы разворачиваются — интересны конкретные системы).
+ private static void Collect(SystemGroup group, List<(string, double, int)> into)
+ {
+ foreach (var child in group.ChildSystems)
+ {
+ if (child is SystemGroup sub)
+ {
+ Collect(sub, into);
+ }
+ else
+ {
+ into.Add((child.Name, child.Perf.LastAvgMs(AvgFrames), child.Perf.UpdateCount));
+ }
+ }
+ }
+}
diff --git a/src/LittleSim/App/WorldSave.cs b/src/LittleSim/App/WorldSave.cs
index 3442b56..e5710ae 100644
--- a/src/LittleSim/App/WorldSave.cs
+++ b/src/LittleSim/App/WorldSave.cs
@@ -28,40 +28,6 @@ public sealed class PlantSave
public Dictionary Genome { get; set; } = new();
}
-/// Сериализуемое состояние одного жителя (sim-компоненты; спрайт пересобирается из дефа по ).
-public sealed class PawnSave
-{
- /// Имя дефа жителя — по нему при загрузке берётся текстура из атласов.
- public string DefName { get; set; } = "";
-
- /// Позиция X в мировых координатах.
- public float X { get; set; }
-
- /// Позиция Y в мировых координатах.
- public float Y { get; set; }
-
- /// Масштаб трансформа (зависит от размера дефа и клетки).
- public float Scale { get; set; } = 1f;
-
- /// Запас энергии [0, 1].
- public float Energy { get; set; }
-
- /// Текущее действие (значение ).
- public int Action { get; set; }
-
- /// Секунды до следующего пересмотра решения.
- public float DecideIn { get; set; }
-
- /// Направление блуждания X.
- public float DirX { get; set; }
-
- /// Направление блуждания Y.
- public float DirY { get; set; }
-
- /// Секунды до смены направления.
- public float ChangeIn { get; set; }
-}
-
/// Сериализуемый хедиф особи (фаза A6): деф (по имени), тяжесть и набранный иммунитет.
public sealed class HediffSave
{
@@ -208,6 +174,13 @@ public sealed class EggSave
///
public sealed class WorldSave
{
+ /// Текущая версия схемы сейва (поднимать при несовместимых изменениях полей).
+ public const int CurrentVersion = 1;
+
+ /// Версия схемы файла. Старые сейвы без поля совместимы (схема аддитивна) и читаются как
+ /// текущая версия; проверка нужна, чтобы отклонить файл из БУДУЩЕЙ (несовместимой) версии.
+ public int Version { get; set; } = CurrentVersion;
+
/// Имя мира (и основа имени файла).
public string Name { get; set; } = "";
@@ -232,9 +205,6 @@ public sealed class WorldSave
/// Прошедшее время симуляции в секундах.
public double ElapsedSeconds { get; set; }
- /// Снимок жителей.
- public List Pawns { get; set; } = [];
-
/// Снимок всех растений (геномы, позиции, возраст, стадия).
public List Plants { get; set; } = [];
@@ -296,7 +266,9 @@ public sealed class SaveStore
File.ReadAllText(file),
JsonOptions
);
- if (save is not null)
+ // Сейв из будущей (несовместимой) версии прочитать корректно нельзя — пропускаем,
+ // чтобы не выдать пользователю молча битый мир.
+ if (save is not null && save.Version <= WorldSave.CurrentVersion)
{
entries.Add(new Entry(Path.GetFileName(file), save));
}
@@ -333,12 +305,16 @@ public sealed class SaveStore
}
}
+ // Набор недопустимых для имени файла символов — кэшируем один раз (раньше массив аллоцировался
+ // и линейно сканировался на КАЖДЫЙ символ имени).
+ private static readonly HashSet InvalidFileNameChars = new(
+ Path.GetInvalidFileNameChars()
+ );
+
private static string Sanitize(string name)
{
var trimmed = name.Trim();
- var safe = string.Concat(
- trimmed.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c)
- );
+ var safe = string.Concat(trimmed.Select(c => InvalidFileNameChars.Contains(c) ? '_' : c));
return string.IsNullOrEmpty(safe) ? "world" : safe;
}
}
diff --git a/src/LittleSim/Content/GameDefs.cs b/src/LittleSim/Content/GameDefs.cs
index 94eeca7..648577f 100644
--- a/src/LittleSim/Content/GameDefs.cs
+++ b/src/LittleSim/Content/GameDefs.cs
@@ -310,13 +310,16 @@ public sealed class AnimalDef : PawnDef
/// Дальность атаки в клетках: на этом расстоянии хищник кусает добычу (контакт).
public float AttackRangeCells { get; init; } = 1.2f;
- /// Урон части тела добычи за игровой день укусов (0 — вид не атакует, не хищник по бою).
+ /// Игровые секунды между ударами (кулдаун дискретной атаки, как в RimWorld).
+ public float AttackIntervalSec { get; init; } = 1.8f;
+
+ /// Урон части тела добычи ЗА ОДИН УДАР (×силу: размер×навык). 0 — вид не атакует.
public float AttackDamage { get; init; }
- /// Прирост тяжести раны-кровотечения у добычи за игровой день укусов.
+ /// Прирост тяжести раны-кровотечения у добычи за один удар (×силу).
public float AttackBleed { get; init; }
- /// Острая кровопотеря добычи за игровой день укусов (доля объёма крови) — основной путь к смерти.
+ /// Острая кровопотеря добычи за один удар (доля объёма крови, ×силу) — основной путь к смерти.
public float AttackBloodLoss { get; init; }
}
diff --git a/src/LittleSim/Content/NeedSet.cs b/src/LittleSim/Content/NeedSet.cs
index 9ca8804..8f6c0e1 100644
--- a/src/LittleSim/Content/NeedSet.cs
+++ b/src/LittleSim/Content/NeedSet.cs
@@ -9,14 +9,17 @@ namespace LittleSim.Content;
public sealed class NeedSet
{
private readonly NeedDef[] _defs;
+ private readonly bool[] _isDrive; // предрасчёт типа нужды — горячие циклы не сравнивают строки на тик
private readonly Dictionary _byAction = new(StringComparer.Ordinal);
/// Строит реестр из всех мода (порядок = индексы нужд).
public NeedSet(GameContent content)
{
_defs = content.Defs.All().ToArray();
+ _isDrive = new bool[_defs.Length];
for (var i = 0; i < _defs.Length; i++)
{
+ _isDrive[i] = string.Equals(_defs[i].Kind, "drive", StringComparison.Ordinal);
if (!string.IsNullOrEmpty(_defs[i].Action))
{
_byAction[_defs[i].Action] = i;
@@ -31,7 +34,7 @@ public sealed class NeedSet
public NeedDef this[int index] => _defs[index];
/// Нужда-влечение (растёт под условием), а не убывающая.
- public bool IsDrive(int index) => string.Equals(_defs[index].Kind, "drive", StringComparison.Ordinal);
+ public bool IsDrive(int index) => _isDrive[index];
/// Убывающая нужда (голод/жажда/сон) — низкое значение срочно.
public bool IsDeplete(int index) => !IsDrive(index);
diff --git a/src/LittleSim/Net/NetSchema.cs b/src/LittleSim/Net/NetSchema.cs
index 6db704d..d77daa4 100644
--- a/src/LittleSim/Net/NetSchema.cs
+++ b/src/LittleSim/Net/NetSchema.cs
@@ -1,3 +1,4 @@
+using System.Runtime.CompilerServices;
using LittleSim.Sim;
using MrGameEng.Graphics;
using MrGameEng.Net;
@@ -17,7 +18,36 @@ public static class NetSchema
/// Порт сервера по умолчанию.
public const int DefaultPort = 9050;
+ // Задокументированный wire-размер реплицируемых структур (см. NetContract.cs веб-зеркала). Репликация
+ // блитит сырые байты структуры, поэтому добавленное/переставленное поле молча испортит десериализацию —
+ // проверяем размеры при построении схемы, чтобы дрейф упал на старте, а не выдал битый мир.
+ private const int TransformWireSize = 20; // Vector2(8) + float(4) + Vector2(8)
+ private const int PawnNeedsWireSize = 4; // float(4)
+
/// Схема репликации: позиция/масштаб жителя и его потребности.
- public static ReplicationSchema Create() =>
- new ReplicationSchema().Register().Register();
+ public static ReplicationSchema Create()
+ {
+ VerifyLayout();
+ return new ReplicationSchema().Register().Register();
+ }
+
+ /// Сверяет фактическую байт-раскладку реплицируемых структур с контрактом (см. NetContract.cs).
+ private static void VerifyLayout()
+ {
+ Require(TransformWireSize);
+ Require(PawnNeedsWireSize);
+
+ static void Require(int expected)
+ where T : struct
+ {
+ var actual = Unsafe.SizeOf();
+ if (actual != expected)
+ {
+ throw new InvalidOperationException(
+ $"Размер реплицируемого {typeof(T).Name} = {actual} Б, ожидался {expected} Б: "
+ + "wire-контракт изменился — синхронизируй NetSchema и LittleSim.Web/NetContract.cs."
+ );
+ }
+ }
+ }
}
diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs
index 7142043..70c4693 100644
--- a/src/LittleSim/Scenes/WorldScene.cs
+++ b/src/LittleSim/Scenes/WorldScene.cs
@@ -62,6 +62,9 @@ public sealed class WorldScene : Scene
private PauseMenu _pause = null!;
private Selection _selection = null!;
private InspectPanel _inspect = null!;
+ private PerfPanel? _perf; // оверлей производительности (только в дев-режиме)
+ private WorldEvents _events = null!; // журнал событий мира (фабрика записей истории особей)
+ private EventsPanel _eventsPanel = null!; // оверлей глобального журнала событий
private readonly List _speedRefreshers = [];
private PlantSet _plants = null!;
@@ -101,7 +104,10 @@ public sealed class WorldScene : Scene
GameLayers.EnsureRegistered(renderer);
this.UseTilemaps();
- var calendar = Context.UseCalendar(SecondsPerDay, StartHour / 24.0);
+ // Часы движка всегда стартуют с нуля, поэтому прошедшее время мира из сейва вносим
+ // смещением календаря (startDay): иначе загрузка сбрасывала время суток/сезон на день 0.
+ var startDay = StartHour / 24.0 + (_save?.ElapsedSeconds ?? 0.0) / SecondsPerDay;
+ var calendar = Context.UseCalendar(SecondsPerDay, startDay);
var climate = Context.UseClimate(
ClimateSettings.Default with
{
@@ -115,6 +121,8 @@ public sealed class WorldScene : Scene
_animals = new AnimalSet(content, atlases, device);
_needs = new NeedSet(content);
_skills = new SkillSet(content);
+ // Журнал событий мира — нужен и HUD-оверлею (ниже), и системам животных (создаются позже).
+ _events = new WorldEvents(content.Languages, _animals, calendar);
var loadingPlants = _save?.Plants is { Count: > 0 };
BuildTerrain(
content,
@@ -181,7 +189,8 @@ public sealed class WorldScene : Scene
content,
climate,
_selection,
- brackets
+ brackets,
+ Context.Clock
);
// Режим разработчика (настройка, по умолчанию вкл): без него дев-консоль и спавнер не поднимаются.
var devMode = GameSettingsStore.Load().DeveloperMode;
@@ -200,9 +209,47 @@ public sealed class WorldScene : Scene
CellSize
)
: null;
- desktop.Root = spawner is null
- ? Ui.Screen(hudLabel, _inspect.Panel, speedBar, _pause.Root)
- : Ui.Screen(hudLabel, _inspect.Panel, speedBar, _pause.Root, spawner.Panel);
+ // Кнопки-переключатели оверлеев (верх-право HUD). Perf-окно поднимается только в дев-режиме.
+ var hudButtons = new HorizontalStackPanel
+ {
+ Spacing = 6,
+ HorizontalAlignment = HorizontalAlignment.Right,
+ VerticalAlignment = VerticalAlignment.Top,
+ Margin = new Thickness(0, 8, 12, 0),
+ };
+
+ var hudChildren = new List { hudLabel, _inspect.Panel, speedBar, _pause.Root };
+ if (spawner is not null)
+ {
+ hudChildren.Add(spawner.Panel);
+ }
+
+ // Журнал событий — доступен всем (кнопка «События»).
+ _eventsPanel = new EventsPanel(content.Languages, _events);
+ hudChildren.Add(_eventsPanel.Panel);
+ hudButtons.Widgets.Add(
+ HudToggle(
+ content.Languages.Get("hud.events"),
+ () => _eventsPanel.Toggle(),
+ () => _eventsPanel.IsOpen
+ )
+ );
+
+ if (devMode)
+ {
+ _perf = new PerfPanel(content.Languages, Context.Clock, UpdateSystems, DrawSystems);
+ hudChildren.Add(_perf.Panel);
+ hudButtons.Widgets.Add(
+ HudToggle(
+ content.Languages.Get("hud.perf"),
+ () => _perf.Toggle(),
+ () => _perf.IsOpen
+ )
+ );
+ }
+
+ hudChildren.Add(hudButtons); // кнопки добавляем последними — поверх остального HUD (кликабельны)
+ desktop.Root = Ui.Screen(hudChildren.ToArray());
this.UseInspector(renderer);
@@ -211,6 +258,9 @@ public sealed class WorldScene : Scene
{
console = this.UseDevConsole();
RegisterCommands(console, content, atlases);
+ // Тайминги систем Friflo в дев-консоль: команда `perf` (см. PerfReport). Включение мониторинга
+ // на все системы — в конце OnLoad, когда они уже добавлены в корни.
+ PerfReport.Register(console, UpdateSystems, DrawSystems);
console.Register(
"light",
"light [radius] — place a point light at the cursor (night shadows demo)",
@@ -266,7 +316,7 @@ public sealed class WorldScene : Scene
);
// Животные (фаза A2): нужды падают, ИИ выбирает действие, исполнение ведёт к цели и утоляет
// нужду (еда выедает растения — контур ёмкости среды). Внешний вид тускнеет с острой нуждой.
- var shore = ComputeShore();
+ var shoreByCell = ComputeShoreField();
var thoughts = new ThoughtSet(content);
UpdateSystems.Add(new AnimalNeedsSystem(Context.Clock, SecondsPerDay, _needs, _animals));
UpdateSystems.Add(new AnimalSkillSystem(Context.Clock, SecondsPerDay, _skills));
@@ -278,6 +328,7 @@ public sealed class WorldScene : Scene
new AnimalHealthSystem(
Store,
_animals,
+ _events,
CellSize,
Context.Clock,
SecondsPerDay,
@@ -298,7 +349,10 @@ public sealed class WorldScene : Scene
_animals,
_needs,
CellSize,
- shore,
+ shoreByCell,
+ _config.Width,
+ _config.Height,
+ _events,
_config.Seed + 0x5EED
)
);
@@ -312,6 +366,7 @@ public sealed class WorldScene : Scene
_needs,
_skills,
thoughts,
+ _events,
Context.Clock,
SecondsPerDay,
CellSize,
@@ -322,10 +377,13 @@ public sealed class WorldScene : Scene
)
);
UpdateSystems.Add(new AnimalAppearanceSystem(_needs));
- UpdateSystems.Add(new AnimalGrowthSystem(Context.Clock, SecondsPerDay, _animals, CellSize));
+ UpdateSystems.Add(new DamageFlashSystem(Context.Clock)); // после внешнего вида — вспышка поверх
+ UpdateSystems.Add(
+ new AnimalGrowthSystem(Context.Clock, SecondsPerDay, _animals, CellSize, _events)
+ );
UpdateSystems.Add(new AnimalFacingSystem(_animals, CellSize));
UpdateSystems.Add(new AnimalBobSystem(Context.Clock));
- UpdateSystems.Add(new AnimalMortalitySystem(Store, _animals, CellSize));
+ UpdateSystems.Add(new AnimalMortalitySystem(Store, _animals, _events, CellSize));
UpdateSystems.Add(new CorpseSystem(Store, Context.Clock, SecondsPerDay, climate));
UpdateSystems.Add(
new AnimalPregnancySystem(
@@ -334,6 +392,7 @@ public sealed class WorldScene : Scene
_needs,
_skills,
thoughts,
+ _events,
Context.Clock,
SecondsPerDay,
CellSize,
@@ -347,6 +406,7 @@ public sealed class WorldScene : Scene
_animals,
_needs,
_skills,
+ _events,
Context.Clock,
SecondsPerDay,
CellSize,
@@ -380,7 +440,14 @@ public sealed class WorldScene : Scene
() => desktop.IsMouseOverGUI
)
);
- UpdateSystems.Add(new CallbackSystem(() => _inspect.Refresh()));
+ UpdateSystems.Add(
+ new CallbackSystem(() =>
+ {
+ _inspect.Refresh();
+ _eventsPanel.Refresh();
+ _perf?.Refresh();
+ })
+ );
UpdateSystems.Add(
new HudSystem(
Context,
@@ -394,6 +461,14 @@ public sealed class WorldScene : Scene
);
UpdateSystems.Add(new CallbackSystem(() => Hotkeys(input)));
+ // Все системы добавлены — включаем per-system мониторинг (дев-режим), чтобы команда `perf`
+ // показывала данные с первого кадра. Вне дев-режима мониторинг не трогаем (нулевой оверхед).
+ if (devMode)
+ {
+ UpdateSystems.SetMonitorPerf(true);
+ DrawSystems.SetMonitorPerf(true);
+ }
+
Log.Info(
$"World '{_config.Name}': {_config.Width}x{_config.Height}, seed {_config.Seed}"
+ (_save is null ? " (new)" : " (loaded)")
@@ -467,6 +542,19 @@ public sealed class WorldScene : Scene
Store.CreateEntity(new Tilemap(grid, tiles, CellSize));
}
+ // Кнопка-переключатель оверлея HUD: подсвечивается как активная, пока оверлей открыт.
+ private static TextButton HudToggle(string text, Action onClick, Func isOpen)
+ {
+ var button = Ui.ChoiceButton(text);
+ button.Click += (_, _) =>
+ {
+ onClick();
+ Ui.SetChoiceActive(button, isOpen());
+ };
+ Ui.SetChoiceActive(button, isOpen());
+ return button;
+ }
+
private HorizontalStackPanel BuildSpeedBar(GameContent content)
{
var bar = Ui.Row(6);
@@ -555,7 +643,6 @@ public sealed class WorldScene : Scene
Height = _config.Height,
Seed = _config.Seed,
SmoothPasses = _config.SmoothPasses,
- Population = _config.Population,
SavedUtc = DateTime.UtcNow,
ElapsedSeconds = Context.Clock.TotalTime,
};
@@ -588,6 +675,9 @@ public sealed class WorldScene : Scene
SaveAnimals(save);
+ // Население — живой счётчик животных на момент сохранения (а не статичный пресет конфига).
+ save.Population = save.Animals.Count;
+
new SaveStore().Write(save);
Log.Info(
$"World '{_config.Name}' saved ({save.Plants.Count} plants, "
@@ -851,9 +941,20 @@ public sealed class WorldScene : Scene
if (entity.GetComponent().State is { } health)
{
health.BloodLevel = saved.BloodLevel;
- for (var i = 0; i < health.Parts.Length && i < saved.PartHp.Length; i++)
+ // HP частей применяем только при точном совпадении анатомии (как для нужд/навыков): иначе
+ // мод сменил состав/порядок частей тела, и значения легли бы не на те части — оставляем свежее.
+ if (saved.PartHp.Length == health.Parts.Length)
{
- health.Parts[i].Hp = saved.PartHp[i];
+ for (var i = 0; i < health.Parts.Length; i++)
+ {
+ health.Parts[i].Hp = saved.PartHp[i];
+ }
+ }
+ else if (saved.PartHp.Length > 0)
+ {
+ Log.Warning(
+ $"Скелет вида '{saved.Species}' изменился ({saved.PartHp.Length}→{health.Parts.Length} частей) — HP частей не восстановлены."
+ );
}
foreach (var hediff in saved.Hediffs)
@@ -915,7 +1016,8 @@ public sealed class WorldScene : Scene
return;
}
- var random = new Random(_config.Seed + 0x5EED);
+ // Свой офсет сида (0x5EED занят системой решений животных — нельзя связывать их RNG-потоки).
+ var random = new Random(_config.Seed + 0x5A1D);
var spawned = 0;
for (var s = 0; s < _animals.Count; s++)
{
@@ -965,33 +1067,89 @@ public sealed class WorldScene : Scene
Log.Info($"Spawned {spawned} animals ({_animals.Count} species)");
}
- // Кромка воды: центры клеток суши, граничащих с водой — куда звери ходят пить (вода непроходима).
- private Vector2[] ComputeShore()
+ // Поле «ближайший берег» по клеткам мира: для каждой клетки — мировая позиция ближайшей береговой
+ // клетки (суша, граничащая с водой), найденная многоисточниковым BFS за O(клеток). Звери читают его
+ // за O(1) при поиске водопоя (см. AnimalDecisionSystem.TryFindShore), не сканируя береговую линию.
+ // Пусто, если воды/берега в мире нет — тогда пить негде (зверь не пойдёт).
+ private Vector2[] ComputeShoreField()
{
- var shore = new List();
- for (var y = 0; y < _config.Height; y++)
- {
- for (var x = 0; x < _config.Width; x++)
- {
- var cell = y * _config.Width + x;
- if (!_cellLand[cell])
- {
- continue;
- }
+ var w = _config.Width;
+ var h = _config.Height;
+ var count = w * h;
- var nearWater =
- (x > 0 && !_cellLand[cell - 1])
- || (x < _config.Width - 1 && !_cellLand[cell + 1])
- || (y > 0 && !_cellLand[cell - _config.Width])
- || (y < _config.Height - 1 && !_cellLand[cell + _config.Width]);
- if (nearWater)
- {
- shore.Add(new Vector2((x + 0.5f) * CellSize, (y + 0.5f) * CellSize));
- }
+ // BFS: nearest[c] — индекс ближайшей береговой клетки. Источники — все береговые клетки (шаг 0).
+ var nearest = new int[count];
+ Array.Fill(nearest, -1);
+ var queue = new Queue();
+ for (var cell = 0; cell < count; cell++)
+ {
+ if (!_cellLand[cell])
+ {
+ continue;
+ }
+
+ var x = cell % w;
+ var y = cell / w;
+ var nearWater =
+ (x > 0 && !_cellLand[cell - 1])
+ || (x < w - 1 && !_cellLand[cell + 1])
+ || (y > 0 && !_cellLand[cell - w])
+ || (y < h - 1 && !_cellLand[cell + w]);
+ if (nearWater)
+ {
+ nearest[cell] = cell;
+ queue.Enqueue(cell);
}
}
- return shore.ToArray();
+ if (queue.Count == 0)
+ {
+ return []; // нет берега
+ }
+
+ // Волна по 4-связности: каждой клетке достаётся источник ближайшего фронта (берег по шагам сетки).
+ while (queue.Count > 0)
+ {
+ var cell = queue.Dequeue();
+ var src = nearest[cell];
+ var x = cell % w;
+ var y = cell / w;
+ if (x > 0 && nearest[cell - 1] < 0)
+ {
+ nearest[cell - 1] = src;
+ queue.Enqueue(cell - 1);
+ }
+
+ if (x < w - 1 && nearest[cell + 1] < 0)
+ {
+ nearest[cell + 1] = src;
+ queue.Enqueue(cell + 1);
+ }
+
+ if (y > 0 && nearest[cell - w] < 0)
+ {
+ nearest[cell - w] = src;
+ queue.Enqueue(cell - w);
+ }
+
+ if (y < h - 1 && nearest[cell + w] < 0)
+ {
+ nearest[cell + w] = src;
+ queue.Enqueue(cell + w);
+ }
+ }
+
+ var field = new Vector2[count];
+ for (var cell = 0; cell < count; cell++)
+ {
+ var src = nearest[cell];
+ if (src >= 0)
+ {
+ field[cell] = new Vector2((src % w + 0.5f) * CellSize, (src / w + 0.5f) * CellSize);
+ }
+ }
+
+ return field;
}
// Затеняют ли зрелые деревья землю. Сейчас выключено: одиночное дерево = 1 клетка-окклюдер, а спрайт
diff --git a/src/LittleSim/Sim/AnimalFactory.cs b/src/LittleSim/Sim/AnimalFactory.cs
index 1ebfe88..ca7caa2 100644
--- a/src/LittleSim/Sim/AnimalFactory.cs
+++ b/src/LittleSim/Sim/AnimalFactory.cs
@@ -35,6 +35,7 @@ public static class AnimalFactory
var sp = animals[species];
var traits = AnimalPhenotype.FromTraits(Phenotype.Compute(genome, animals.GeneRegistry));
var isMale = IsMaleGenome(genome);
+ var (eatsPlants, eatsMeat) = Diet(traits, sp.Def); // диета фиксируется при рождении (см. EatsPlants/EatsMeat)
var stage = StageAt(ageDays, traits.MaturityAge, traits.Lifespan, sp.Stages);
var region = RegionFor(sp, stage, isMale);
@@ -54,6 +55,8 @@ public static class AnimalFactory
Traits = traits,
Species = species,
IsMale = isMale,
+ EatsPlants = eatsPlants,
+ EatsMeat = eatsMeat,
Generation = generation,
},
new AnimalGrowth { Stage = stage, AgeDays = ageDays },
@@ -92,12 +95,20 @@ public static class AnimalFactory
Capacity = (int)MathF.Round(MathF.Max(0f, traits.BrainSize) * MemoryPerBrain),
}
);
+
+ // Личная хроника (UI «История»): кольцевой список последних событий особи.
+ entity.AddComponent(new CreatureLog { Entries = [], Capacity = CreatureLogCapacity });
+ // Вспышка урона (презентация): заранее на нуле, чтобы удар лишь выставлял время (без структурных правок).
+ entity.AddComponent(new DamageFlash());
return entity;
}
/// Сколько записей памяти даёт единица размера мозга (объём памяти = brainSize × это).
public const float MemoryPerBrain = 16f;
+ /// Глубина личной хроники особи (последние N событий о ней).
+ public const int CreatureLogCapacity = 24;
+
// Стабильный сид из генома (сумма аллелей — порядконезависима) для детерминированной страсти к навыкам.
private static int SkillSeed(Genome genome)
{
diff --git a/src/LittleSim/Sim/AnimalOrganism.cs b/src/LittleSim/Sim/AnimalOrganism.cs
index 6d7b81e..111f292 100644
--- a/src/LittleSim/Sim/AnimalOrganism.cs
+++ b/src/LittleSim/Sim/AnimalOrganism.cs
@@ -67,6 +67,13 @@ public struct AnimalPhenotype
/// Всеядность [0..1] — генералист: включает оба источника пищи (растения и мясо).
public float Omnivory;
+ ///
+ /// Стиль питания [0..1]: 0 — «пастьба» (graze), часто подтапливает сытость мелкими приёмами;
+ /// 1 — «обжорство» (gorge), ищет еду лишь при сильном голоде и ест до полной сытости (редкие
+ /// крупные приёмы, как у хищников). Задаёт пороги начала/конца кормёжки в .
+ ///
+ public float FeedingStyle;
+
/// Устойчивость к растительным ядам [0..1] — снижает дозу отравления токсичным кормом.
public float ToxinTolerance;
@@ -117,6 +124,7 @@ public struct AnimalPhenotype
Herbivory = T("herbivory"),
Carnivory = T("carnivory"),
Omnivory = T("omnivory"),
+ FeedingStyle = traits.GetValueOrDefault("feedingStyle", 0.5f),
ToxinTolerance = T("toxinTolerance"),
Sociability = T("sociability"),
Lifespan = T("lifespan"),
@@ -148,6 +156,12 @@ public struct AnimalOrganism : IComponent
/// Пол: самец (XY) или самка (XX) — выведен из локуса GeneSex при создании.
public bool IsMale;
+ /// Ест ли растения (диета по генам/дефу, неизменна после рождения — кэш вместо пересчёта на тик).
+ public bool EatsPlants;
+
+ /// Ест ли мясо (диета по генам/дефу, неизменна после рождения — кэш вместо пересчёта на тик).
+ public bool EatsMeat;
+
/// Номер поколения: max(родителей) + 1; у основателей 0.
public int Generation;
}
@@ -243,8 +257,10 @@ public sealed class HealthState
Pain = Math.Clamp(pain, 0f, 1f);
- // Множитель старения применяется внутри Compute (без второго прохода/аллокации списка ключей).
- var caps = CapacityCalc.Compute(Parts, BloodLevel, Pain, capacityScale);
+ // Множитель старения применяется внутри Compute. Пишем в собственный словарь Capacities
+ // (переиспользуется, очищается внутри Compute) — без аллокации карты способностей на пересчёт.
+ var caps = Capacities;
+ CapacityCalc.Compute(Parts, BloodLevel, Pain, capacityScale, caps);
foreach (var h in _hediffs)
{
if (h.Def.CapMods.Count == 0)
@@ -258,8 +274,6 @@ public sealed class HealthState
caps[capacity] = caps.GetValueOrDefault(capacity, 1f) * (1f + (factor - 1f) * t);
}
}
-
- Capacities = caps;
}
///
@@ -648,6 +662,31 @@ public struct GutSeed : IComponent
public float DropInDays;
}
+///
+/// Кратковременная «вспышка урона» (презентация, как в RimWorld): при получении удара выставляется
+/// время свечения; подкрашивает спрайт красным и гасит свечение.
+/// Есть у всех животных (выставляется в 0 при создании), чтобы удар не делал структурного добавления.
+///
+public struct DamageFlash : IComponent
+{
+ /// Остаток реального времени свечения (сек); 0 — не подсвечен.
+ public float Seconds;
+}
+
+///
+/// Личная хроника особи (фича UI «История»): ограниченный кольцевой список событий о ней (спаривание,
+/// роды, охота, кормёжка, бегство, рост). Пишет , читает инспектор. Список —
+/// managed-ссылка (как мысли/память); старые записи вытесняются по достижении .
+///
+public struct CreatureLog : IComponent
+{
+ /// Записи в порядке добавления (новые в конце).
+ public List Entries;
+
+ /// Сколько записей хранится (старейшие вытесняются).
+ public int Capacity;
+}
+
/// Инстанс мысли настроения на особи (фаза A8): деф события + остаток времени до угасания.
public struct ThoughtInstance
{
diff --git a/src/LittleSim/Sim/AnimalSystems.cs b/src/LittleSim/Sim/AnimalSystems.cs
index 68cf691..6cb5195 100644
--- a/src/LittleSim/Sim/AnimalSystems.cs
+++ b/src/LittleSim/Sim/AnimalSystems.cs
@@ -28,6 +28,18 @@ public static class AnimalActions
/// Бегство: уходить от ближайшего хищника (реакция жертвы, важнее прочих нужд).
public const string Flee = "Flee";
+
+ // — Холостые (idle) активности: чем зверь занят, когда нет срочных нужд. Выбираются внутри ветки
+ // Wander с фиксированной длительностью (см. AnimalDecisionSystem) — придают поведению разнообразие.
+
+ /// Отдых: стоит/лежит на месте, понемногу восстанавливая силы.
+ public const string Rest = "Rest";
+
+ /// Копание в земле в поисках корня/личинок (травоядные/всеядные) — даёт чуть сытости.
+ public const string Dig = "Dig";
+
+ /// Валяние в грязи у воды (комфорт/терморегуляция) — стоит на месте у кромки.
+ public const string Wallow = "Wallow";
}
///
@@ -83,6 +95,15 @@ public struct AnimalBrain : IComponent
/// Секунды до следующего пересмотра решения.
public float DecideIn;
+
+ /// Игровые секунды до следующего удара по добыче (кулдаун дискретных атак, как в RimWorld).
+ public float AttackCooldown;
+
+ /// Текущее направление блуждания (коррелированное — плавно дрейфует, не дёргается каждый раз).
+ public Vector2 Heading;
+
+ /// Игровое время (TotalTime), до которого длится текущая холостая активность (отдых/копание/…).
+ public float ActivityUntil;
}
///
@@ -90,19 +111,36 @@ public struct AnimalBrain : IComponent
/// (фаза A7): нужда, чей выше эффективного интеллекта особи
/// (brainSize × Consciousness), в выбор не входит.
///
-public readonly struct AnimalContext(float[] values, NeedSet needs, float intelligence)
+public readonly struct AnimalContext(
+ float[] values,
+ NeedSet needs,
+ float intelligence,
+ int overrideIndex = -1,
+ float overrideValue = 0f
+)
{
private readonly float[] _values = values;
private readonly NeedSet _needs = needs;
private readonly float _intelligence = intelligence;
+ // Переопределение значения одной нужды (стиль питания: «эффективный голод» с гистерезисом —
+ // см. AnimalDecisionSystem). Override имеет приоритет над сырым значением и над гейтом мозга.
+ private readonly int _overrideIndex = overrideIndex;
+ private readonly float _overrideValue = overrideValue;
+
///
- /// Значение нужды по индексу. Если её порог мозга выше интеллекта особи — нужда недоступна и
- /// возвращается её «не-срочный» край (deplete → 1 «сыта», drive → 0 «без влечения»), чтобы
- /// соображение дало ~0 и действие не выбралось. Индекс вне диапазона → 0.
+ /// Значение нужды по индексу. Если для этой нужды задан override (стиль питания) — он и возвращается.
+ /// Если её порог мозга выше интеллекта особи — нужда недоступна и возвращается её «не-срочный» край
+ /// (deplete → 1 «сыта», drive → 0 «без влечения»), чтобы соображение дало ~0 и действие не выбралось.
+ /// Индекс вне диапазона → 0.
///
public float Get(int index)
{
+ if (index == _overrideIndex)
+ {
+ return _overrideValue;
+ }
+
if (index >= _values.Length)
{
return 0f;
@@ -166,7 +204,7 @@ public sealed class AnimalNeedsSystem(
var metabolism =
MathF.Max(0.1f, o[i].Traits.Metabolism)
* (1f + ToleranceMetabolicCost * Math.Clamp(o[i].Traits.ToxinTolerance, 0f, 1f))
- * MathF.Pow(sizeRatio, 2.25f);
+ * Mass.KleiberFactorFromSizeRatio(sizeRatio);
var lethalEmpty = false;
for (var k = 0; k < needs.Count; k++)
{
@@ -245,14 +283,48 @@ public sealed class AnimalDecisionSystem : BaseSystem
private const float HerdMinSociability = 0.3f; // ниже — вид не стадный (когезия/безопасность выкл.)
private const float SafetyInNumbers = 0.5f; // макс. снижение радиуса реакции на угрозу в группе
private const int HerdFullCount = 6; // размер группы, при котором эффект «безопасность в числе» полон
- private const float HerdSeparation = 1.5f; // ближе этого (в клетках) к центру — не сходимся плотнее
+
+ // Стиль питания (gorge↔graze): пороги начала/конца кормёжки лерпуются по гену feedingStyle [0..1].
+ // graze (0): начинает есть уже при лёгком голоде и наедается не до полного — частые мелкие приёмы.
+ // gorge (1): ищет еду лишь при сильном голоде, но ест до полной сытости — редкие крупные приёмы.
+ private const float FeedStartGraze = 0.8f;
+ private const float FeedStartGorge = 0.4f;
+ private const float FeedFullGraze = 0.9f;
+ private const float FeedFullGorge = 1.0f;
+
+ // Блуждание/стадность (boids): коррелированный дрейф направления + расхождение (личное пространство) и
+ // лишь слабое притяжение к центру группы на удалении — без этого стадо схлопывалось в одну точку.
+ private const float WanderTurn = 0.5f; // макс. поворот направления за пересмотр (рад) — плавные пути
+ private const float RoamCells = 11f; // длина «отрезка» блуждания в клетках
+ private const float SeparationCells = 2.2f; // ближе этого к соседу — расходимся
+ private const float SeparationWeight = 1.6f; // сила расхождения
+ private const float CohesionStartCells = 7f; // дальше этого от центра группы — слабо подтягиваемся
+ private const float CohesionWeight = 0.45f; // сила когезии (намеренно мала)
+ private const float EdgeMarginCells = 2.5f; // у края мира разворачиваем направление внутрь
+
+ // Холостые активности: длительность (игровые секунды) каждой, пока зверь ей занят.
+ private const float RoamMinSec = 3f;
+ private const float RoamMaxSec = 6f;
+ private const float RestMinSec = 5f;
+ private const float RestMaxSec = 11f;
+ private const float DigMinSec = 3f;
+ private const float DigMaxSec = 7f;
+ private const float WallowMinSec = 5f;
+ private const float WallowMaxSec = 10f;
+ private const float WallowWaterCells = 4f; // валяться можно лишь у воды (в этом радиусе от кромки)
private readonly UtilityAi _brain;
+ private readonly EntityStore _store;
+ private readonly WorldEvents _events;
private readonly GameClock _clock;
private readonly AnimalSet _animalSet;
private readonly NeedSet _needs;
+ private readonly int _hungerNeed; // индекс убывающей нужды «голод» (для гистерезиса кормёжки); -1 — нет
+ private readonly int _restNeed; // индекс нужды «отдых» (для веса холостого отдыха); -1 — нет
private readonly int _cellSize;
- private readonly Vector2[] _shore;
+ private readonly Vector2[] _shoreByCell; // поле «ближайший берег» по клеткам (O(1) поиск водопоя)
+ private readonly int _worldWidth;
+ private readonly int _worldHeight;
private readonly Random _rng;
private readonly ArchetypeQuery<
AnimalNeeds,
@@ -264,17 +336,28 @@ public sealed class AnimalDecisionSystem : BaseSystem
private readonly ArchetypeQuery _plants;
private readonly ArchetypeQuery _corpses;
- // Пространственные индексы (перестраиваются раз в кадр) + переиспользуемые буферы запросов: поиск
- // корма/угрозы/добычи/партнёра/стада берёт только ближние сущности вместо прохода по всем (было
- // O(растений)+O(животных) на каждое решение). В ячейке — предпосчитанные признаки (без дорефетча).
+ // Пространственные индексы + переиспользуемые буферы запросов: поиск корма/угрозы/добычи/партнёра/
+ // стада берёт только ближние сущности вместо прохода по всем (было O(растений)+O(животных) на каждое
+ // решение). В ячейке — предпосчитанные признаки (без дорефетча). Сетку животных перестраиваем каждый
+ // кадр (они двигаются), сетки растений/трупов — раз в StaticGridRebuildSeconds (они статичны).
private const int GridCells = 10; // размер ячейки сетки в клетках мира (~радиус восприятия)
private const float HearingRangeMult = 1.4f; // слух шире зрения
private const float SmellRangeMult = 1.8f; // запах — дальше всех (но грубый, см. Detect)
private const float RememberedThreatAvoidCells = 14f; // в этой зоне от памяти об угрозе зверь уходит прочь
+
+ // Каденция перестройки СТАТИЧНЫХ сеток (растения/трупы): они не двигаются (меняются лишь при
+ // рождении/смерти), поэтому полный обход тысяч растений на каждый кадр был основной ценой системы.
+ // Перестраиваем раз в эти игровые секунды (≈ как часто одна особь и так пересматривает решение);
+ // удалённые сущности в устаревшей сетке безопасны — потребитель сверяется со store перед действием.
+ private const float StaticGridRebuildSeconds = 0.5f;
private readonly SpatialGrid _plantGrid;
private readonly List _plantBuf = [];
private readonly SpatialGrid _animalGrid;
private readonly List _animalBuf = [];
+ private readonly SpatialGrid _corpseGrid;
+ private readonly List _corpseBuf = [];
+ private float _staticGridAccum; // накоплено игровых секунд с последней перестройки статичных сеток
+ private bool _staticGridsBuilt; // первая перестройка обязательна (до неё сетки пусты)
private readonly float _smellSnap; // размер «обонятельной» ячейки (грубая локализация по запаху)
public AnimalDecisionSystem(
@@ -283,19 +366,29 @@ public sealed class AnimalDecisionSystem : BaseSystem
AnimalSet animals,
NeedSet needs,
int cellSize,
- Vector2[] shore,
+ Vector2[] shoreByCell,
+ int worldWidth,
+ int worldHeight,
+ WorldEvents events,
int seed
)
{
_brain = BuildBrain(needs);
+ _store = store;
+ _events = events;
_clock = clock;
_animalSet = animals;
_needs = needs;
+ _hungerNeed = needs.NeedForAction(AnimalActions.Eat);
+ _restNeed = needs.NeedForAction(AnimalActions.Sleep);
_cellSize = cellSize;
- _shore = shore;
+ _shoreByCell = shoreByCell;
+ _worldWidth = worldWidth;
+ _worldHeight = worldHeight;
_rng = new Random(seed);
_plantGrid = new SpatialGrid(cellSize * GridCells);
_animalGrid = new SpatialGrid(cellSize * GridCells);
+ _corpseGrid = new SpatialGrid(cellSize * GridCells);
_smellSnap = cellSize * GridCells;
_animals = store.Query<
AnimalNeeds,
@@ -339,7 +432,22 @@ public sealed class AnimalDecisionSystem : BaseSystem
protected override void OnUpdateGroup()
{
var delta = _clock.DeltaTime;
- RebuildGrids();
+ if (delta <= 0f)
+ {
+ return; // пауза — решения не пересматриваются (как и прочие sim-системы)
+ }
+
+ var clockNow = (float)_clock.TotalTime; // игровое время для таймеров холостых активностей
+ RebuildAnimalGrid(); // животные двигаются — индекс свежий каждый кадр
+ // Растения/трупы статичны — индекс перестраиваем по каденции, а не каждый кадр.
+ _staticGridAccum += delta;
+ if (!_staticGridsBuilt || _staticGridAccum >= StaticGridRebuildSeconds)
+ {
+ RebuildStaticGrids();
+ _staticGridAccum = 0f;
+ _staticGridsBuilt = true;
+ }
+
foreach (
var (needsChunk, brains, organisms, growths, transforms, entities) in _animals.Chunks
)
@@ -359,6 +467,7 @@ public sealed class AnimalDecisionSystem : BaseSystem
}
brain.DecideIn = Interval + _rng.NextSingle() * Interval; // джиттер — расфазировать скан
+ var prevAction = brain.Action; // для журнала: события на смене действия (начал/закончил)
var pos = t[i].Position;
var adult = AnimalFactory.IsAdult(_animalSet[o[i].Species], gr[i].Stage);
var self = entities.EntityAt(i);
@@ -380,17 +489,32 @@ public sealed class AnimalDecisionSystem : BaseSystem
senseBase * o[i].Traits.Hearing * MathF.Max(0.2f, hearing) * HearingRangeMult,
senseBase * o[i].Traits.Smell * MathF.Max(0.2f, smell) * SmellRangeMult
);
+ // Соседей по сетке собираем ОДИН раз на решение (на макс. радиусе чувств): стадность,
+ // бегство, поиск добычи и партнёра дальше читают этот буфер со своими фильтрами дистанции —
+ // вместо 2–4 повторных обходов сетки и копирований за одно решение.
+ _animalGrid.Collect(pos, ranges.Max(), _animalBuf);
+
// Настроение (A8): стресс/истощение (низкое настроение) подавляет тягу к спариванию.
var mood = self.GetComponent();
- // Стадность (boids): социальные виды считают центр/размер ближайшей группы соплеменников —
- // для когезии при блуждании и «безопасности в числе» (спокойнее на угрозу в большой группе).
+ // Стадность (boids): социальные виды считают центр/размер группы соплеменников (когезия и
+ // «безопасность в числе») и вектор расхождения herdAway (личное пространство — чтобы стадо
+ // держалось вместе, но не схлопывалось в точку).
var sociability = o[i].Traits.Sociability;
var herdCount = 0;
var herdCenter = pos;
+ var herdAway = Vector2.Zero;
if (sociability >= HerdMinSociability)
{
- HerdScan(pos, seeRadius, o[i].Species, self.Id, out herdCenter, out herdCount);
+ HerdScan(
+ pos,
+ seeRadius,
+ o[i].Species,
+ self.Id,
+ out herdCenter,
+ out herdCount,
+ out herdAway
+ );
}
var safety =
@@ -405,12 +529,18 @@ public sealed class AnimalDecisionSystem : BaseSystem
pos,
ranges.Scaled(safety),
o[i].Species,
- o[i].Traits,
+ o[i].EatsMeat,
+ o[i].Traits.BodySize,
out var threatPos
)
)
{
self.GetComponent().Remember(MemoryKind.Threat, threatPos, _smellSnap);
+ if (prevAction != AnimalActions.Flee)
+ {
+ _events.Fled(self, o[i].Species); // начало бегства — в личную хронику
+ }
+
brain.Action = AnimalActions.Flee;
brain.TargetPlant = -1;
brain.TargetPrey = -1;
@@ -423,12 +553,27 @@ public sealed class AnimalDecisionSystem : BaseSystem
continue;
}
- var (eatsPlants, eatsMeat) = AnimalFactory.Diet(
- o[i].Traits,
- _animalSet[o[i].Species].Def
+ var eatsPlants = o[i].EatsPlants; // диета кэширована при рождении (без пересчёта на тик)
+ var eatsMeat = o[i].EatsMeat;
+ // Стиль питания: «эффективный голод» с гистерезисом. gorge не лезет за едой, пока сытость
+ // выше порога начала, но начав — ест до полного; graze подтапливает часто и до меньшего.
+ var hunger = EffectiveHunger(
+ n[i].Values,
+ o[i].Traits.FeedingStyle,
+ brain.Action is AnimalActions.Eat or AnimalActions.Hunt
);
var name =
- _brain.Select(new AnimalContext(n[i].Values, _needs, intelligence))?.Name
+ _brain
+ .Select(
+ new AnimalContext(
+ n[i].Values,
+ _needs,
+ intelligence,
+ _hungerNeed,
+ hunger
+ )
+ )
+ ?.Name
?? AnimalActions.Wander;
switch (name)
@@ -451,6 +596,18 @@ public sealed class AnimalDecisionSystem : BaseSystem
brain.TargetPrey = preyId;
brain.TargetIsCorpse = preyIsCorpse;
brain.Target = preyPos;
+ // Начало охоты на ЖИВУЮ добычу (падальщество не считаем охотой) — заметное событие.
+ if (
+ prevAction != AnimalActions.Hunt
+ && !preyIsCorpse
+ && _store.TryGetEntityById(preyId, out var preyEntity)
+ && !preyEntity.IsNull
+ && preyEntity.TryGetComponent(out var preyOrg)
+ )
+ {
+ _events.HuntStarted(self, o[i].Species, preyOrg.Species);
+ }
+
break;
case AnimalActions.Eat
when eatsPlants
@@ -491,49 +648,89 @@ public sealed class AnimalDecisionSystem : BaseSystem
brain.Target = matePos;
break;
default:
- brain.Action = AnimalActions.Wander;
brain.TargetPlant = -1;
- var toCenter = herdCenter - pos;
- var centerDist = toCenter.Length();
+ brain.TargetPrey = -1;
+ // Память: вблизи места недавнего хищника бросаем холостые дела и уходим прочь.
if (TryRememberedThreat(self, pos, out var dangerPos))
{
- // Память: вблизи места недавнего хищника зверь уходит прочь (опасная зона).
var flee = pos - dangerPos;
var d = flee.Length();
+ brain.Action = AnimalActions.Wander;
+ brain.Heading = d > 0.001f ? flee / d : RandomDir();
+ brain.ActivityUntil = clockNow + RoamMinSec;
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
- )
- {
- brain.Target =
- pos
- + toCenter
- / centerDist
- * (_cellSize * (3f + _rng.NextSingle() * 3f));
+ pos + brain.Heading * (_cellSize * (4f + _rng.NextSingle() * 4f));
}
else
{
- var angle = _rng.NextSingle() * MathF.Tau;
- brain.Target =
- pos
- + new Vector2(MathF.Cos(angle), MathF.Sin(angle))
- * (_cellSize * (4f + _rng.NextSingle() * 6f));
+ // Конечный автомат холостых дел: держим текущую активность до её таймера,
+ // затем выбираем новую (блуждание/отдых/копание/валяние) с весами по виду.
+ if (!IsIdle(brain.Action) || clockNow >= brain.ActivityUntil)
+ {
+ PickIdleActivity(ref brain, self, o[i], pos, n[i].Values, clockNow);
+ }
+
+ if (brain.Action == AnimalActions.Wander)
+ {
+ SteerRoam(
+ ref brain,
+ pos,
+ herdCenter,
+ herdCount,
+ herdAway,
+ sociability
+ );
+ }
+ else
+ {
+ brain.Target = pos; // отдых/копание/валяние — стоим на месте
+ }
}
break;
}
+
+ // Бытовое в личную хронику: завершение приёма пищи/водопоя (по смене действия — без
+ // спама на каждый тик). Начало охоты/бегства логируется выше у своих веток.
+ if (prevAction == AnimalActions.Eat && brain.Action != AnimalActions.Eat)
+ {
+ _events.Ate(self, o[i].Species);
+ }
+ else if (prevAction == AnimalActions.Drink && brain.Action != AnimalActions.Drink)
+ {
+ _events.Drank(self, o[i].Species);
+ }
}
}
}
+ // «Эффективный голод» для utility-выбора по стилю питания (gorge↔graze) с гистерезисом:
+ // — кормится сейчас → особь «голодна», пока сытость не достигнет порога полного (gorge ест до 1,
+ // graze — до меньшего): начатый приём пищи доводится до конца, а не бросается на полпути;
+ // — не кормится → особь «сыта» (1), пока сытость выше порога начала; ниже — падает в «голод»
+ // пропорционально, и чем выше стиль (gorge), тем ниже порог начала (ест реже, но крупнее).
+ // Возвращает значение [0,1] для нужды голода; при отсутствии нужды — 1 (override не применится).
+ private float EffectiveHunger(float[] values, float feedingStyle, bool feedingNow)
+ {
+ if (_hungerNeed < 0 || values is null || _hungerNeed >= values.Length)
+ {
+ return 1f;
+ }
+
+ var sat = values[_hungerNeed];
+ var style = Math.Clamp(feedingStyle, 0f, 1f);
+ if (feedingNow)
+ {
+ var full = FeedFullGraze + (FeedFullGorge - FeedFullGraze) * style;
+ return full > 0f ? Math.Clamp(sat / full, 0f, 1f) : 1f;
+ }
+
+ var start = FeedStartGraze + (FeedStartGorge - FeedStartGraze) * style;
+ return sat >= start ? 1f
+ : start > 0f ? sat / start
+ : 0f;
+ }
+
// Радиус восприятия особи: базовая дальность вида (данные) × ген зрения × размер клетки.
private float VisionRadius(int species, float vision) =>
MathF.Max(1f, _animalSet[species].Def.VisionCells) * MathF.Max(0.2f, vision) * _cellSize;
@@ -569,21 +766,26 @@ public sealed class AnimalDecisionSystem : BaseSystem
return found;
}
- // Центр и размер ближайшей группы соплеменников в радиусе (для когезии/безопасности). O(n) на особь,
- // как и прочие сканы. Сам исключается по id; центр — средняя позиция соседей того же вида.
+ // Центр/размер группы соплеменников в радиусе (когезия/безопасность) и вектор расхождения away —
+ // сумма направлений «прочь» от соседей ближе SeparationCells (личное пространство, чтобы стадо не
+ // схлопывалось в точку). O(n) на особь. Сам исключается по id; центр — средняя позиция соседей.
private void HerdScan(
Vector2 from,
float radius,
int species,
int selfId,
out Vector2 center,
- out int count
+ out int count,
+ out Vector2 away
)
{
var sum = Vector2.Zero;
+ away = Vector2.Zero;
count = 0;
var radiusSq = radius * radius;
- _animalGrid.Collect(from, radius, _animalBuf);
+ var sepDist = SeparationCells * _cellSize;
+ var sepSq = sepDist * sepDist;
+ // _animalBuf уже собран в DecideIn на макс. радиусе чувств — фильтруем по своему радиусу.
foreach (var e in _animalBuf)
{
if (e.Species != species || e.Id == selfId)
@@ -591,32 +793,189 @@ public sealed class AnimalDecisionSystem : BaseSystem
continue;
}
- if (Vector2.DistanceSquared(from, e.Pos) <= radiusSq)
+ var distSq = Vector2.DistanceSquared(from, e.Pos);
+ if (distSq <= radiusSq)
{
sum += e.Pos;
count++;
}
+
+ if (distSq <= sepSq && distSq > 0.0001f)
+ {
+ // ближе личного пространства — добавляем направление прочь, тем сильнее, чем ближе сосед.
+ var d = MathF.Sqrt(distSq);
+ away += (from - e.Pos) / d * (1f - d / sepDist);
+ }
}
center = count > 0 ? sum / count : from;
}
+ // Холостая ли активность (то, чем зверь занят без срочных нужд): блуждание/отдых/копание/валяние.
+ private static bool IsIdle(string action) =>
+ action == AnimalActions.Wander
+ || action == AnimalActions.Rest
+ || action == AnimalActions.Dig
+ || action == AnimalActions.Wallow;
+
+ // Случайное единичное направление.
+ private Vector2 RandomDir()
+ {
+ var a = _rng.NextSingle() * MathF.Tau;
+ return new Vector2(MathF.Cos(a), MathF.Sin(a));
+ }
+
+ // Поворот вектора на угол (рад).
+ private static Vector2 Rotate(Vector2 v, float radians)
+ {
+ var c = MathF.Cos(radians);
+ var s = MathF.Sin(radians);
+ return new Vector2(v.X * c - v.Y * s, v.X * s + v.Y * c);
+ }
+
+ // Выбор холостой активности с весами по виду/состоянию/окружению и её длительность. Блуждание — всегда;
+ // отдых — охотнее у подуставших; копание — травоядным/всеядным; валяние — только у воды. Начало
+ // характерной активности (кроме блуждания) пишется в личную хронику особи.
+ private void PickIdleActivity(
+ ref AnimalBrain brain,
+ Entity self,
+ in AnimalOrganism org,
+ Vector2 pos,
+ float[] values,
+ float now
+ )
+ {
+ var roam = 1.0f;
+ var rest = 0.5f;
+ if (_restNeed >= 0 && values is not null && _restNeed < values.Length)
+ {
+ rest += (1f - values[_restNeed]) * 0.8f; // ниже отдых — охотнее приляжет (но не до сна)
+ }
+
+ var dig = org.EatsPlants ? 0.7f : 0.1f;
+ var wallowRange = WallowWaterCells * _cellSize;
+ var nearWater =
+ TryFindShore(pos, out var water)
+ && Vector2.DistanceSquared(pos, water) <= wallowRange * wallowRange;
+ var wallow = nearWater ? 0.5f : 0f;
+
+ var roll = _rng.NextSingle() * (roam + rest + dig + wallow);
+ string action;
+ float min,
+ max;
+ if ((roll -= roam) < 0f)
+ {
+ action = AnimalActions.Wander;
+ (min, max) = (RoamMinSec, RoamMaxSec);
+ }
+ else if ((roll -= rest) < 0f)
+ {
+ action = AnimalActions.Rest;
+ (min, max) = (RestMinSec, RestMaxSec);
+ }
+ else if (roll - dig < 0f)
+ {
+ action = AnimalActions.Dig;
+ (min, max) = (DigMinSec, DigMaxSec);
+ }
+ else
+ {
+ action = AnimalActions.Wallow;
+ (min, max) = (WallowMinSec, WallowMaxSec);
+ }
+
+ brain.Action = action;
+ brain.ActivityUntil = now + min + _rng.NextSingle() * (max - min);
+ switch (action)
+ {
+ case AnimalActions.Rest:
+ _events.Activity(self, org.Species, "event.rest");
+ break;
+ case AnimalActions.Dig:
+ _events.Activity(self, org.Species, "event.dig");
+ break;
+ case AnimalActions.Wallow:
+ _events.Activity(self, org.Species, "event.wallow");
+ break;
+ }
+ }
+
+ // Рулёжка блуждания (boids): плавный дрейф направления + расхождение (личное пространство) и лишь
+ // слабая когезия к центру группы на удалении; у края мира заворачиваем внутрь. Цель ставится далеко
+ // по направлению, поэтому зверь идёт, а не топчется на месте.
+ private void SteerRoam(
+ ref AnimalBrain brain,
+ Vector2 pos,
+ Vector2 herdCenter,
+ int herdCount,
+ Vector2 herdAway,
+ float sociability
+ )
+ {
+ var dir = brain.Heading;
+ if (dir.LengthSquared() < 0.0001f)
+ {
+ dir = RandomDir();
+ }
+
+ dir = Rotate(dir, (_rng.NextSingle() - 0.5f) * 2f * WanderTurn); // коррелированный дрейф
+ dir += herdAway * SeparationWeight; // расхождение в стаде
+ if (sociability >= HerdMinSociability && herdCount > 0)
+ {
+ var toCenter = herdCenter - pos;
+ var d = toCenter.Length();
+ if (d > CohesionStartCells * _cellSize)
+ {
+ dir += toCenter / d * CohesionWeight; // слабая когезия лишь на удалении
+ }
+ }
+
+ var margin = EdgeMarginCells * _cellSize;
+ var w = _worldWidth * _cellSize;
+ var h = _worldHeight * _cellSize;
+ if (pos.X < margin && dir.X < 0f)
+ {
+ dir.X = MathF.Abs(dir.X);
+ }
+ else if (pos.X > w - margin && dir.X > 0f)
+ {
+ dir.X = -MathF.Abs(dir.X);
+ }
+
+ if (pos.Y < margin && dir.Y < 0f)
+ {
+ dir.Y = MathF.Abs(dir.Y);
+ }
+ else if (pos.Y > h - margin && dir.Y > 0f)
+ {
+ dir.Y = -MathF.Abs(dir.Y);
+ }
+
+ if (dir.LengthSquared() < 0.0001f)
+ {
+ dir = RandomDir();
+ }
+
+ dir.Normalize();
+ brain.Heading = dir;
+ brain.Target = pos + dir * (_cellSize * RoamCells);
+ }
+
// Ближайший хищник-угроза в радиусе восприятия (для бегства жертвы). Угроза = другой вид, который
// ест мясо и достаточно крупный (см. IsThreatTo). O(n) на особь — как поиск корма.
private bool TryFindThreat(
Vector2 from,
in SenseRanges ranges,
int selfSpecies,
- in AnimalPhenotype selfTraits,
+ bool selfEatsMeat,
+ float selfBody,
out Vector2 threatPos
)
{
var bestSq = float.MaxValue;
threatPos = default;
var threatId = -1;
- var (_, selfEatsMeat) = AnimalFactory.Diet(selfTraits, _animalSet[selfSpecies].Def);
- var selfBody = selfTraits.BodySize;
- _animalGrid.Collect(from, ranges.Max(), _animalBuf);
+ // _animalBuf уже собран в DecideIn; обнаружение всё равно гейтится Detect по чувствам.
foreach (var e in _animalBuf)
{
if (
@@ -732,37 +1091,32 @@ public sealed class AnimalDecisionSystem : BaseSystem
var reach = ranges.Max();
// 1) Падаль с остатком мяса — находится в пределах любого чувства (запах хорошо ведёт к падали).
+ // Кандидаты — из сетки трупов (ближние ячейки), а не полным сканом всех трупов мира.
var bestCorpseSq = reach * reach;
var corpseId = -1;
var corpsePos = default(Vector2);
- foreach (var (transforms, corpses, entities) in _corpses.Chunks)
+ _corpseGrid.Collect(from, reach, _corpseBuf);
+ foreach (var c in _corpseBuf)
{
- var tt = transforms.Span;
- var cc = corpses.Span;
- for (var i = 0; i < tt.Length; i++)
+ if (c.Meat <= 0f)
{
- if (cc[i].Meat <= 0f)
- {
- continue;
- }
+ continue;
+ }
- var sq = Vector2.DistanceSquared(from, tt[i].Position);
- var id = entities.EntityAt(i).Id;
- if (sq < bestCorpseSq || (sq == bestCorpseSq && id < corpseId))
- {
- bestCorpseSq = sq;
- corpseId = id;
- corpsePos = tt[i].Position;
- }
+ var sq = Vector2.DistanceSquared(from, c.Pos);
+ if (sq < bestCorpseSq || (sq == bestCorpseSq && c.Id < corpseId))
+ {
+ bestCorpseSq = sq;
+ corpseId = c.Id;
+ corpsePos = c.Pos;
}
}
- // 2) Живая добыча (из сетки животных, по чувствам).
+ // 2) Живая добыча (из сетки животных, по чувствам). _animalBuf уже собран в DecideIn на reach.
var bestPreySq = float.MaxValue;
var preyId = -1;
var preyPos = default(Vector2);
var selfBody = selfTraits.BodySize;
- _animalGrid.Collect(from, reach, _animalBuf);
foreach (var e in _animalBuf)
{
if (e.Species == selfSpecies || e.Id == selfId || !IsPreyFor(selfBody, e.BodySize))
@@ -833,9 +1187,40 @@ public sealed class AnimalDecisionSystem : BaseSystem
? TryFindBestPlant(from, radius, toxinTolerance, out plantId, out position)
: TryFindPlant(from, radius, out plantId, out position);
- // Перестраивает пространственные индексы растений и животных из текущих позиций (раз в кадр, линейно).
- // Для животных СРАЗУ считает признаки, нужные ИИ (мясоед/взрослость), чтобы запросы их не пересчитывали.
- private void RebuildGrids()
+ // Перестраивает индекс животных из текущих позиций (раз в кадр — звери двигаются). СРАЗУ считает
+ // признаки, нужные ИИ (мясоед/взрослость и пр.), чтобы запросы их не пересчитывали.
+ private void RebuildAnimalGrid()
+ {
+ _animalGrid.Clear();
+ foreach (var (_, _, organisms, growths, transforms, entities) in _animals.Chunks)
+ {
+ var o = organisms.Span;
+ var g = growths.Span;
+ var t = transforms.Span;
+ for (var i = 0; i < t.Length; i++)
+ {
+ var sp = _animalSet[o[i].Species];
+ _animalGrid.Add(
+ new NeighborEntry(
+ entities.EntityAt(i).Id,
+ t[i].Position,
+ o[i].Species,
+ o[i].Traits.BodySize,
+ o[i].EatsMeat, // диета кэширована при рождении
+ o[i].IsMale,
+ AnimalFactory.IsAdult(sp, g[i].Stage),
+ o[i].Traits.Camouflage,
+ o[i].Traits.Scent,
+ o[i].Traits.Noise
+ )
+ );
+ }
+ }
+ }
+
+ // Перестраивает индексы растений и трупов (по каденции — они статичны). Устаревшая запись об удалённой
+ // сущности безопасна: потребитель (Graze/Hunt/TryFind*) сверяется со store и пропускает исчезнувшую.
+ private void RebuildStaticGrids()
{
_plantGrid.Clear();
foreach (var (transforms, _, organisms, entities) in _plants.Chunks)
@@ -856,30 +1241,14 @@ public sealed class AnimalDecisionSystem : BaseSystem
}
}
- _animalGrid.Clear();
- foreach (var (_, _, organisms, growths, transforms, entities) in _animals.Chunks)
+ _corpseGrid.Clear();
+ foreach (var (transforms, corpses, entities) in _corpses.Chunks)
{
- var o = organisms.Span;
- var g = growths.Span;
var t = transforms.Span;
+ var c = corpses.Span;
for (var i = 0; i < t.Length; i++)
{
- var sp = _animalSet[o[i].Species];
- var (_, eatsMeat) = AnimalFactory.Diet(o[i].Traits, sp.Def);
- _animalGrid.Add(
- new NeighborEntry(
- entities.EntityAt(i).Id,
- t[i].Position,
- o[i].Species,
- o[i].Traits.BodySize,
- eatsMeat,
- o[i].IsMale,
- AnimalFactory.IsAdult(sp, g[i].Stage),
- o[i].Traits.Camouflage,
- o[i].Traits.Scent,
- o[i].Traits.Noise
- )
- );
+ _corpseGrid.Add(new CorpseEntry(entities.EntityAt(i).Id, t[i].Position, c[i].Meat));
}
}
}
@@ -947,26 +1316,20 @@ public sealed class AnimalDecisionSystem : BaseSystem
return plantId >= 0;
}
- // Ближайшая кромка воды (без лимита по зрению — звери «помнят» водопои; упрощение фазы A2).
+ // Ближайшая кромка воды (без лимита по зрению — звери «помнят» водопои; упрощение фазы A2). Берег
+ // статичен, поэтому ближайшая береговая точка предпосчитана по клеткам (поле _shoreByCell, BFS на
+ // старте) — поиск за O(1) вместо линейного скана всей береговой линии на каждое решение «пить».
private bool TryFindShore(Vector2 from, out Vector2 position)
{
position = default;
- if (_shore.Length == 0)
+ if (_shoreByCell.Length == 0)
{
- return false;
- }
-
- var bestSq = float.MaxValue;
- foreach (var cell in _shore)
- {
- var sq = Vector2.DistanceSquared(from, cell);
- if (sq < bestSq)
- {
- bestSq = sq;
- position = cell;
- }
+ return false; // в мире нет воды/берега — пить негде
}
+ var cx = Math.Clamp((int)(from.X / _cellSize), 0, _worldWidth - 1);
+ var cy = Math.Clamp((int)(from.Y / _cellSize), 0, _worldHeight - 1);
+ position = _shoreByCell[cy * _worldWidth + cx];
return true;
}
@@ -982,7 +1345,7 @@ public sealed class AnimalDecisionSystem : BaseSystem
var bestSq = radius * radius;
mateId = -1;
position = default;
- _animalGrid.Collect(from, radius, _animalBuf);
+ // _animalBuf уже собран в DecideIn на макс. радиусе чувств — фильтруем по радиусу спаривания.
foreach (var e in _animalBuf)
{
if (e.IsMale == selfMale || !e.IsAdult)
@@ -1019,6 +1382,8 @@ public sealed class AnimalActionSystem : BaseSystem
private const float PostReproductiveFrac = 0.9f; // доля жизни, после которой самка уже не зачинает
private const float SeedIngestChance = 0.15f; // шанс проглотить семя при выедании зрелого растения
private const float GutSeedDropDays = 2f; // через сколько дней проглоченное семя высаживается
+ private const float IdleRestFactor = 0.3f; // доля скорости восстановления сил при холостом отдыхе
+ private const float DigForageFactor = 0.25f; // доля сытости с копания относительно нормальной кормёжки
private readonly EntityStore _store;
private readonly PlantSet _plants;
@@ -1026,6 +1391,7 @@ public sealed class AnimalActionSystem : BaseSystem
private readonly NeedSet _needs;
private readonly SkillSet _skills;
private readonly ThoughtSet _thoughts;
+ private readonly WorldEvents _events;
private readonly GameClock _clock;
private readonly float _secondsPerDay;
private readonly int _cellSize;
@@ -1047,9 +1413,13 @@ public sealed class AnimalActionSystem : BaseSystem
Health
> _query;
private readonly List _eaten = [];
+ private readonly HashSet _eatenSet = []; // O(1)-проверка членства (порядок даёт _eaten)
private readonly List _consumed = []; // трупы, доеденные до нуля
+ private readonly HashSet _consumedSet = []; // O(1)-проверка членства (порядок даёт _consumed)
private readonly List _kills = []; // добыча, забитая в этом проходе (смерть после итерации)
+ private readonly HashSet _killsSet = []; // O(1)-проверка членства (порядок даёт _kills)
private readonly List<(Entity Self, Entity Partner)> _matings = [];
+ private readonly HashSet<(int, int)> _matedPairs = []; // обработанные пары за проход (дедуп (a,b)/(b,a))
private readonly List<(Entity Eater, GutSeed Seed)> _ingested = []; // проглоченные семена (эндозоохория)
public AnimalActionSystem(
@@ -1059,6 +1429,7 @@ public sealed class AnimalActionSystem : BaseSystem
NeedSet needs,
SkillSet skills,
ThoughtSet thoughts,
+ WorldEvents events,
GameClock clock,
float secondsPerDay,
int cellSize,
@@ -1077,6 +1448,7 @@ public sealed class AnimalActionSystem : BaseSystem
_huntSkill = skills.IndexOf(AnimalSkills.Hunting);
_evadeSkill = skills.IndexOf(AnimalSkills.Evasion);
_thoughts = thoughts;
+ _events = events;
_clock = clock;
_secondsPerDay = secondsPerDay;
_cellSize = cellSize;
@@ -1097,8 +1469,11 @@ public sealed class AnimalActionSystem : BaseSystem
var days = seconds / _secondsPerDay;
_eaten.Clear();
+ _eatenSet.Clear();
_consumed.Clear();
+ _consumedSet.Clear();
_kills.Clear();
+ _killsSet.Clear();
_matings.Clear();
_ingested.Clear();
@@ -1211,19 +1586,35 @@ public sealed class AnimalActionSystem : BaseSystem
break;
case AnimalActions.Hunt:
- // Навык охоты: опытный хищник наносит больше урона; растёт от практики.
+ {
+ // Сила удара: размер тела особи (со стадией) относительно эталона вида — крупная/
+ // взрослая особь бьёт сильнее, детёныш слабее. Дальше домножается на навык охоты.
+ var spH = _animalSet[o[i].Species];
+ var stageScaleH = self.TryGetComponent(out var grH)
+ ? spH.Stages[Math.Clamp(grH.Stage, 0, spH.Stages.Length - 1)].Scale
+ : 1f;
+ var strength = MathF.Max(
+ 0.2f,
+ o[i].Traits.BodySize * stageScaleH / spH.RefBodySize
+ );
+ // Навык охоты: опытный хищник бьёт сильнее; растёт от практики.
Hunt(
ref brain,
ref pos,
values,
def,
speed * seconds,
+ seconds,
days,
foodEff,
- SkillFactor(_huntSkill, skills)
+ SkillFactor(_huntSkill, skills),
+ strength,
+ self,
+ o[i].Species
);
GrantXp(_huntSkill, skills, days);
break;
+ }
case AnimalActions.Flee:
// Навык уклонения: опытная жертва убегает быстрее; растёт от практики бегства.
@@ -1235,7 +1626,25 @@ public sealed class AnimalActionSystem : BaseSystem
GrantXp(_evadeSkill, skills, days);
break;
- default: // Wander
+ case AnimalActions.Rest:
+ // Отдых на месте: понемногу восстанавливает силы (меньше, чем полноценный сон).
+ Refill(values, AnimalActions.Sleep, days, IdleRestFactor);
+ break;
+
+ case AnimalActions.Dig:
+ // Копание в земле: травоядные/всеядные выкапывают корешки/личинок — немного сытости.
+ if (o[i].EatsPlants)
+ {
+ Refill(values, AnimalActions.Eat, days, DigForageFactor * foodEff);
+ }
+
+ break;
+
+ case AnimalActions.Wallow:
+ // Валяние в грязи: стоит на месте у воды (комфорт/терморегуляция) — без эффекта на нужды.
+ break;
+
+ default: // Wander — блуждание к цели
MoveTo(ref pos, brain.Target, speed * seconds);
break;
}
@@ -1315,15 +1724,22 @@ public sealed class AnimalActionSystem : BaseSystem
// Охота/падальщество: ведёт к цели и при контакте либо ест труп (утоляет голод, расходует мясо),
// либо кусает живую добычу — урон части тела, острая кровопотеря, рана-кровотечение (первый
// травматический урон). Добитая добыча помечается на смерть (труп оставит общий путь Die).
+ // Минимальная видимая длительность красной вспышки урона у жертвы (реального времени).
+ private const float DamageFlashSeconds = 0.35f;
+
private void Hunt(
ref AnimalBrain brain,
ref Vector2 pos,
float[] values,
AnimalDef def,
float step,
+ float seconds,
float days,
float foodEff,
- float huntFactor
+ float huntFactor,
+ float strength,
+ Entity hunter,
+ int hunterSpecies
)
{
if (
@@ -1356,7 +1772,7 @@ public sealed class AnimalActionSystem : BaseSystem
ref var corpse = ref target.GetComponent();
Refill(values, AnimalActions.Eat, days, foodEff);
corpse.Meat -= def.ForageBiteDays * days; // расход мяса той же «силой укуса», что и выедание
- if (corpse.Meat <= 0f && !_consumed.Contains(target))
+ if (corpse.Meat <= 0f && _consumedSet.Add(target))
{
_consumed.Add(target);
}
@@ -1370,33 +1786,64 @@ public sealed class AnimalActionSystem : BaseSystem
return;
}
+ // Дискретные удары (как в RimWorld): кулдаун идёт и при сближении — первый контакт бьёт сразу.
+ brain.AttackCooldown -= seconds;
if (!inRange)
{
MoveTo(ref pos, targetPos, step);
return;
}
+ if (brain.AttackCooldown > 0f)
+ {
+ return; // в контакте, но удар ещё на перезарядке
+ }
+
+ brain.AttackCooldown = MathF.Max(0.05f, def.AttackIntervalSec);
+
var preyHealth = target.GetComponent().State;
if (preyHealth is null)
{
return;
}
+ // Сила удара = база дефа × сила особи (размер×стадия) × навык охоты; кровопотеря/кровотечение — ×силу.
+ var damage = def.AttackDamage * strength * huntFactor;
preyHealth.ApplyInjury(
- def.AttackDamage * days * huntFactor,
- def.AttackBloodLoss * days * huntFactor,
+ damage,
+ def.AttackBloodLoss * strength,
_bleeding,
- def.AttackBleed * days * huntFactor,
+ def.AttackBleed * strength,
_rng
);
+
+ // Красная вспышка жертвы (презентация) + удар в боевую хронику обоих участников.
+ if (target.HasComponent())
+ {
+ target.GetComponent().Seconds = DamageFlashSeconds;
+ }
+
+ _events.Struck(
+ hunter,
+ hunterSpecies,
+ target,
+ target.GetComponent().Species,
+ damage
+ );
+
if (
preyHealth.BloodLevel <= 0f
|| preyHealth.Capacity(AnimalCapacities.Consciousness) <= 0.01f
)
{
- if (!_kills.Contains(target))
+ if (_killsSet.Add(target))
{
_kills.Add(target); // добита — смерть после прохода (структурное изменение)
+ _events.Killed(
+ hunter,
+ hunterSpecies,
+ target.GetComponent().Species
+ );
}
}
}
@@ -1477,7 +1924,7 @@ public sealed class AnimalActionSystem : BaseSystem
ref var grow = ref plant.GetComponent();
grow.AgeDays = MathF.Max(0f, grow.AgeDays - bitePerDay * days);
var isGrass = _plants[grow.Species].Def.TrunkRadiusCells <= 0f;
- if (isGrass && grow.AgeDays <= GrazeKillAge && !_eaten.Contains(plant))
+ if (isGrass && grow.AgeDays <= GrazeKillAge && _eatenSet.Add(plant))
{
_eaten.Add(plant);
}
@@ -1547,6 +1994,7 @@ public sealed class AnimalActionSystem : BaseSystem
private void ApplyMatings()
{
var mateNeed = _needs.NeedForAction(AnimalActions.Mate);
+ _matedPairs.Clear();
foreach (var (a, b) in _matings)
{
if (
@@ -1559,6 +2007,14 @@ public sealed class AnimalActionSystem : BaseSystem
continue;
}
+ // Оба партнёра независимо ставят себя в очередь, так что пара приходит дважды — (a,b) и (b,a).
+ // Дедупим по неупорядоченному ключу: иначе ResetMating/мысль «спаривание» применяются повторно.
+ var pairKey = a.Id < b.Id ? (a.Id, b.Id) : (b.Id, a.Id);
+ if (!_matedPairs.Add(pairKey))
+ {
+ continue;
+ }
+
var aMale = a.GetComponent().IsMale;
var bMale = b.GetComponent().IsMale;
if (aMale == bMale)
@@ -1588,6 +2044,7 @@ public sealed class AnimalActionSystem : BaseSystem
Litter = Math.Max(1, (int)MathF.Round(mom.Traits.LitterSize * fert)),
}
);
+ _events.Pregnant(mother, mom.Species);
}
}
@@ -1597,6 +2054,12 @@ public sealed class AnimalActionSystem : BaseSystem
var mated = _thoughts.Get(AnimalThoughts.Mated);
a.GetComponent().Add(mated);
b.GetComponent().Add(mated);
+ _events.Mated(
+ a,
+ a.GetComponent().Species,
+ b,
+ b.GetComponent().Species
+ );
}
}
@@ -1751,6 +2214,41 @@ public sealed class AnimalAppearanceSystem(NeedSet needs)
}
}
+///
+/// Красная вспышка урона (презентация, как в RimWorld): особь, получившую удар, кратко подкрашивает
+/// красным и гасит свечение по РЕАЛЬНОМУ времени (видно и на высокой скорости). Запускать ПОСЛЕ
+/// (та задаёт базовый цвет от нужд) — вспышка подмешивается поверх.
+/// Время свечения выставляет при ударе. Граница sim/presentation цела.
+///
+public sealed class DamageFlashSystem(GameClock clock) : QuerySystem
+{
+ private static readonly Color FlashColor = new(255, 60, 60);
+ private const float MaxIntensity = 0.85f; // насколько краснит на пике
+ private const float FlashDuration = 0.35f; // полная длительность свечения (сек реального времени)
+
+ protected override void OnUpdate()
+ {
+ var dt = clock.UnscaledDeltaTime;
+ foreach (var (sprites, flashes, _) in Query.Chunks)
+ {
+ var s = sprites.Span;
+ var f = flashes.Span;
+ for (var i = 0; i < f.Length; i++)
+ {
+ ref var flash = ref f[i];
+ if (flash.Seconds <= 0f)
+ {
+ continue;
+ }
+
+ flash.Seconds = MathF.Max(0f, flash.Seconds - dt);
+ var t = Math.Clamp(flash.Seconds / FlashDuration, 0f, 1f) * MaxIntensity;
+ s[i].Color = Color.Lerp(s[i].Color, FlashColor, t);
+ }
+ }
+ }
+}
+
///
/// Разворот спрайта по направлению движения (как в RimWorld): зверь смотрит туда, куда идёт. Восток —
/// базовая текстура, запад — её зеркало (флип по X), север/юг — отдельные текстуры (если есть у вида).
@@ -1866,7 +2364,8 @@ public sealed class AnimalGrowthSystem(
GameClock clock,
float secondsPerDay,
AnimalSet animals,
- int cellSize
+ int cellSize,
+ WorldEvents events
) : QuerySystem
{
protected override void OnUpdate()
@@ -1877,7 +2376,7 @@ public sealed class AnimalGrowthSystem(
return;
}
- foreach (var (growths, organisms, transforms, sprites, _) in Query.Chunks)
+ foreach (var (growths, organisms, transforms, sprites, entities) in Query.Chunks)
{
var g = growths.Span;
var o = organisms.Span;
@@ -1908,6 +2407,7 @@ public sealed class AnimalGrowthSystem(
t[i].Scale = new Vector2(
AnimalFactory.Scale(sp, org.Traits, stage, region, cellSize)
);
+ events.Grew(entities.EntityAt(i), org.Species, sp.Stages[stage].Name); // в хронику особи
}
}
}
@@ -1924,14 +2424,21 @@ public sealed class AnimalMortalitySystem : BaseSystem
private readonly EntityStore _store;
private readonly AnimalSet _animals;
+ private readonly WorldEvents _events;
private readonly int _cellSize;
private readonly ArchetypeQuery _query;
private readonly List _deaths = [];
- public AnimalMortalitySystem(EntityStore store, AnimalSet animals, int cellSize)
+ public AnimalMortalitySystem(
+ EntityStore store,
+ AnimalSet animals,
+ WorldEvents events,
+ int cellSize
+ )
{
_store = store;
_animals = animals;
+ _events = events;
_cellSize = cellSize;
_query = store.Query();
}
@@ -1946,9 +2453,12 @@ public sealed class AnimalMortalitySystem : BaseSystem
var n = needsChunk.Span;
for (var i = 0; i < g.Length; i++)
{
- if (g[i].AgeDays > o[i].Traits.Lifespan || n[i].Starve >= LethalStarveDays)
+ var ageDeath = g[i].AgeDays > o[i].Traits.Lifespan;
+ var starveDeath = n[i].Starve >= LethalStarveDays;
+ if (ageDeath || starveDeath)
{
_deaths.Add(entities.EntityAt(i));
+ _events.Died(o[i].Species, ageDeath ? "event.death.age" : "event.death.starve");
}
}
}
@@ -2051,6 +2561,7 @@ public sealed class AnimalPregnancySystem : BaseSystem
private readonly NeedSet _needs;
private readonly SkillSet _skills;
private readonly ThoughtSet _thoughts;
+ private readonly WorldEvents _events;
private readonly GameClock _clock;
private readonly float _secondsPerDay;
private readonly int _cellSize;
@@ -2066,6 +2577,7 @@ public sealed class AnimalPregnancySystem : BaseSystem
NeedSet needs,
SkillSet skills,
ThoughtSet thoughts,
+ WorldEvents events,
GameClock clock,
float secondsPerDay,
int cellSize,
@@ -2078,6 +2590,7 @@ public sealed class AnimalPregnancySystem : BaseSystem
_needs = needs;
_skills = skills;
_thoughts = thoughts;
+ _events = events;
_clock = clock;
_secondsPerDay = secondsPerDay;
_cellSize = cellSize;
@@ -2141,6 +2654,7 @@ public sealed class AnimalPregnancySystem : BaseSystem
birth.Mother.GetComponent().Add(_thoughts.Get(AnimalThoughts.GaveBirth));
}
+ var bornLive = 0;
for (var k = 0; k < birth.Litter && count < _maxAnimals; k++)
{
var child = Genome.Breed(
@@ -2179,10 +2693,17 @@ public sealed class AnimalPregnancySystem : BaseSystem
birth.Generation,
_cellSize
);
+ bornLive++;
}
count++;
}
+
+ // Живые роды — заметное событие (яйцекладка отметится позже при вылуплении в EggSystem).
+ if (bornLive > 0)
+ {
+ _events.Born(birth.Mother, birth.Species, bornLive);
+ }
}
}
@@ -2211,6 +2732,7 @@ public sealed class EggSystem : BaseSystem
private readonly AnimalSet _animals;
private readonly NeedSet _needs;
private readonly SkillSet _skills;
+ private readonly WorldEvents _events;
private readonly GameClock _clock;
private readonly float _secondsPerDay;
private readonly int _cellSize;
@@ -2223,6 +2745,7 @@ public sealed class EggSystem : BaseSystem
AnimalSet animals,
NeedSet needs,
SkillSet skills,
+ WorldEvents events,
GameClock clock,
float secondsPerDay,
int cellSize,
@@ -2233,6 +2756,7 @@ public sealed class EggSystem : BaseSystem
_animals = animals;
_needs = needs;
_skills = skills;
+ _events = events;
_clock = clock;
_secondsPerDay = secondsPerDay;
_cellSize = cellSize;
@@ -2275,6 +2799,7 @@ public sealed class EggSystem : BaseSystem
egg.Generation,
_cellSize
);
+ _events.Hatched(egg.Species, 1);
_hatched.Add(entities.EntityAt(i));
}
}
@@ -2299,6 +2824,7 @@ public sealed class AnimalHealthSystem : BaseSystem
private readonly EntityStore _store;
private readonly AnimalSet _animals;
+ private readonly WorldEvents _events;
private readonly int _cellSize;
private readonly GameClock _clock;
private readonly float _secondsPerDay;
@@ -2331,6 +2857,7 @@ public sealed class AnimalHealthSystem : BaseSystem
public AnimalHealthSystem(
EntityStore store,
AnimalSet animals,
+ WorldEvents events,
int cellSize,
GameClock clock,
float secondsPerDay,
@@ -2344,6 +2871,7 @@ public sealed class AnimalHealthSystem : BaseSystem
{
_store = store;
_animals = animals;
+ _events = events;
_cellSize = cellSize;
_clock = clock;
_secondsPerDay = secondsPerDay;
@@ -2480,6 +3008,7 @@ public sealed class AnimalHealthSystem : BaseSystem
)
{
_deaths.Add(entities.EntityAt(i));
+ _events.Died(o[i].Species, "event.death.disease");
}
}
}
diff --git a/src/LittleSim/Sim/CapacityCalc.cs b/src/LittleSim/Sim/CapacityCalc.cs
index 5d87cea..3dba29f 100644
--- a/src/LittleSim/Sim/CapacityCalc.cs
+++ b/src/LittleSim/Sim/CapacityCalc.cs
@@ -44,20 +44,36 @@ public static class AnimalCapacities
///
public static class CapacityCalc
{
- /// Считает карту способностей из инстансов частей, уровня крови (0..1) и боли (0..1);
- /// — общий множитель (старение), применяется к итогу без лишних аллокаций.
- public static Dictionary Compute(
+ // Переиспользуемые scratch-буферы: пересчёт способностей идёт на каждое животное на тик здоровья и на
+ // каждую травму — раньше это аллоцировало 4 словаря + HashSet за вызов (главный аллокатор сим-части).
+ // [ThreadStatic] — безопасно и при возможном распараллеливании (свой буфер на поток).
+ [ThreadStatic]
+ private static Dictionary? _raw;
+
+ [ThreadStatic]
+ private static HashSet? _declared;
+
+ [ThreadStatic]
+ private static Dictionary? _computed;
+
+ /// Считает способности из инстансов частей, уровня крови (0..1) и боли (0..1) в переданный
+ /// (очищается и заполняется заново — без аллокаций);
+ /// — общий множитель (старение), применяется к итогу.
+ public static void Compute(
PartInstance[] parts,
float blood,
float pain,
- float capacityScale = 1f
+ float capacityScale,
+ Dictionary result
)
{
// Сырой вклад каждой способности (Σ доля·HP) и НАБОР объявленных телом способностей: вид имеет
// только те, к которым его части вообще причастны (у оленя нет манипуляции). Объявленность —
// статична (по дефам частей), поэтому уничтоженный орган всё равно покажет способность на 0%.
- var raw = new Dictionary(System.StringComparer.Ordinal);
- var declared = new HashSet(System.StringComparer.Ordinal);
+ var raw = _raw ??= new Dictionary(System.StringComparer.Ordinal);
+ var declared = _declared ??= new HashSet(System.StringComparer.Ordinal);
+ raw.Clear();
+ declared.Clear();
foreach (var part in parts)
{
if (part.Def is null)
@@ -94,32 +110,29 @@ public static class CapacityCalc
var pump = Clamp01(bloodPumping);
// Производные способности зависят от сознания (и кровоснабжения для движения/пищеварения).
- var computed = new Dictionary(System.StringComparer.Ordinal)
- {
- [AnimalCapacities.BloodPumping] = bloodPumping,
- [AnimalCapacities.Breathing] = breathing,
- [AnimalCapacities.BloodFiltration] = bloodFiltration,
- [AnimalCapacities.Consciousness] = consciousness,
- [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,
- };
+ var computed = _computed ??= new Dictionary(System.StringComparer.Ordinal);
+ computed.Clear();
+ computed[AnimalCapacities.BloodPumping] = bloodPumping;
+ computed[AnimalCapacities.Breathing] = breathing;
+ computed[AnimalCapacities.BloodFiltration] = bloodFiltration;
+ computed[AnimalCapacities.Consciousness] = consciousness;
+ computed[AnimalCapacities.Moving] = Raw(AnimalCapacities.Moving) * cons * pump;
+ computed[AnimalCapacities.Sight] = Raw(AnimalCapacities.Sight) * cons;
+ computed[AnimalCapacities.Hearing] = Raw(AnimalCapacities.Hearing) * cons;
+ computed[AnimalCapacities.Smell] = Raw(AnimalCapacities.Smell) * cons;
+ computed[AnimalCapacities.Touch] = Raw(AnimalCapacities.Touch) * cons;
+ computed[AnimalCapacities.Talking] = Raw(AnimalCapacities.Talking) * cons;
+ computed[AnimalCapacities.Eating] = Raw(AnimalCapacities.Eating) * cons;
+ computed[AnimalCapacities.Digestion] = Raw(AnimalCapacities.Digestion) * pump;
// В результат — только объявленные телом способности: известные по формуле зависимостей, прочие
// (модовые) — сырым вкладом. Так у вида видны ровно его способности, а не весь каталог.
var scale = Clamp01(capacityScale);
- var result = new Dictionary(System.StringComparer.Ordinal);
+ result.Clear();
foreach (var capacity in declared)
{
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/Mass.cs b/src/LittleSim/Sim/Mass.cs
index 8e9b28d..edb7880 100644
--- a/src/LittleSim/Sim/Mass.cs
+++ b/src/LittleSim/Sim/Mass.cs
@@ -42,6 +42,16 @@ public static class Mass
public static float CarryCapacity(float bodyMassKg, float capability) =>
bodyMassKg * CarryFraction * MathF.Max(0f, capability);
+ /// Показатель Клайбера для линейного размера: масса^0.75 = (размер)^2.25 (масса ∝ размер³).
+ public const float KleiberSizeExponent = 2.25f;
+
+ ///
+ /// Метаболический множитель по закону Клайбера от отношения ЛИНЕЙНЫХ размеров (размер/эталон): расход
+ /// энергии ∝ масса^0.75 = размер^2.25. Дешевле массовой формы (без возведения массы) — для горячего цикла.
+ ///
+ public static float KleiberFactorFromSizeRatio(float sizeRatio) =>
+ MathF.Pow(MathF.Max(0f, sizeRatio), KleiberSizeExponent);
+
///
/// Метаболический множитель по закону Клайбера: расход энергии ∝ масса^0.75. Нормирован на эталонную
/// массу вида (взрослый базового размера = 1), поэтому крупные особи прожорливее, а молодняк — экономнее,
diff --git a/src/LittleSim/Sim/PlantFruitingSystem.cs b/src/LittleSim/Sim/PlantFruitingSystem.cs
index 8579d00..c21348f 100644
--- a/src/LittleSim/Sim/PlantFruitingSystem.cs
+++ b/src/LittleSim/Sim/PlantFruitingSystem.cs
@@ -19,14 +19,21 @@ public sealed class PlantFruitingSystem(PlantSet plants, Calendar calendar, Clim
private const float RipenDays = 10f;
private const float DropDays = 20f;
+ // Плодоношение медленное (десятки дней) — обрабатываем пачками раз в IntervalDays, а не каждый кадр.
+ private const float IntervalDays = 0.1f;
+ private float _accum;
+
protected override void OnUpdate()
{
- var deltaDays = Tick.deltaTime / calendar.SecondsPerDay;
- if (deltaDays <= 0f)
+ _accum += Tick.deltaTime / calendar.SecondsPerDay; // на паузе delta == 0 — не копится
+ if (_accum < IntervalDays)
{
- return; // пауза
+ return;
}
+ var deltaDays = _accum;
+ _accum = 0f;
+
var season = (int)climate.Season;
foreach (var (growths, organisms, fruits, _) in Query.Chunks)
diff --git a/src/LittleSim/Sim/PlantGrowthSystem.cs b/src/LittleSim/Sim/PlantGrowthSystem.cs
index 28e5a63..ee8e083 100644
--- a/src/LittleSim/Sim/PlantGrowthSystem.cs
+++ b/src/LittleSim/Sim/PlantGrowthSystem.cs
@@ -24,14 +24,22 @@ public sealed class PlantGrowthSystem(
int cellSize
) : QuerySystem
{
+ // Рост медленный, поэтому обрабатываем его пачками раз в IntervalDays игровых дней, а не каждый кадр:
+ // полный обход всех растений с выборкой освещения на кадр был лишней нагрузкой (как и в PlantLifecycleSystem).
+ private const float IntervalDays = 0.1f;
+ private float _accum;
+
protected override void OnUpdate()
{
- var deltaDays = Tick.deltaTime / calendar.SecondsPerDay;
- if (deltaDays <= 0f)
+ _accum += Tick.deltaTime / calendar.SecondsPerDay; // на паузе delta == 0 — не копится
+ if (_accum < IntervalDays)
{
- return; // пауза — рост стоит
+ return;
}
+ var deltaDays = _accum;
+ _accum = 0f;
+
// Температура глобальная; свет — локальный (лайтмап: подлесок под кронами темнее).
var temperature = climate.Temperature;
diff --git a/src/LittleSim/Sim/SimSystems.cs b/src/LittleSim/Sim/SimSystems.cs
index 3abc96e..b187c50 100644
--- a/src/LittleSim/Sim/SimSystems.cs
+++ b/src/LittleSim/Sim/SimSystems.cs
@@ -193,9 +193,25 @@ public sealed class HudSystem(
MrGameEng.Core.Climate? climate = null
) : BaseSystem
{
+ // Ключи локализации сезонов предсобраны (Spring..Winter), чтобы не строить строку
+ // "season."+ToString().ToLowerInvariant() на каждое обновление HUD.
+ private static readonly string[] SeasonKeys = BuildSeasonKeys();
+
private float _accumulated;
private int _frames;
+ private static string[] BuildSeasonKeys()
+ {
+ var names = Enum.GetNames();
+ var keys = new string[names.Length];
+ for (var i = 0; i < names.Length; i++)
+ {
+ keys[i] = "season." + names[i].ToLowerInvariant();
+ }
+
+ return keys;
+ }
+
protected override void OnUpdateGroup()
{
_accumulated += context.Clock.UnscaledDeltaTime;
@@ -213,7 +229,7 @@ public sealed class HudSystem(
: languages.Format("hud.datetime", calendar.Day, calendar.Hour, calendar.Minute);
if (climate is not null)
{
- var season = languages.Get("season." + climate.Season.ToString().ToLowerInvariant());
+ var season = languages.Get(SeasonKeys[(int)climate.Season]);
when +=
" | "
+ languages.Format("hud.climate", season, (int)MathF.Round(climate.Temperature));
diff --git a/src/LittleSim/Sim/SpatialGrid.cs b/src/LittleSim/Sim/SpatialGrid.cs
index 5958629..3bb4a37 100644
--- a/src/LittleSim/Sim/SpatialGrid.cs
+++ b/src/LittleSim/Sim/SpatialGrid.cs
@@ -14,6 +14,9 @@ public interface ISpatialEntry
public readonly record struct ForageEntry(int Id, Vector2 Pos, float Toxicity, float Palatability)
: ISpatialEntry;
+/// Труп для поиска падали хищником: позиция/id + остаток мяса (нулевые отсеиваются вызывающим).
+public readonly record struct CorpseEntry(int Id, Vector2 Pos, float Meat) : ISpatialEntry;
+
/// Сосед-животное для ИИ: позиция/id + предпосчитанные признаки (вид, размер, мясоед, пол, взрослость)
/// и заметность (камуфляж/запах/шум) — чтобы запросы угрозы/добычи/партнёра/стада не дорефетчили компоненты.
public readonly record struct NeighborEntry(
diff --git a/src/LittleSim/Sim/WorldEvents.cs b/src/LittleSim/Sim/WorldEvents.cs
new file mode 100644
index 0000000..20a8028
--- /dev/null
+++ b/src/LittleSim/Sim/WorldEvents.cs
@@ -0,0 +1,197 @@
+using System.Collections.Generic;
+using Friflo.Engine.ECS;
+using LittleSim.Content;
+using MrGameEng.Core;
+using MrGameEng.Mods;
+
+namespace LittleSim.Sim;
+
+/// Категория события — определяет цвет строки в журнале/истории и группировку.
+public enum EventCategory : byte
+{
+ /// Размножение: спаривание, беременность, роды, вылупление.
+ Reproduction,
+
+ /// Охота и бой: начало охоты, убийство добычи.
+ Combat,
+
+ /// Смерть: голод/жажда, старость, болезнь, хищник.
+ Death,
+
+ /// Бытовое: кормёжка, водопой, бегство, смена стадии (только в истории особи).
+ Routine,
+}
+
+/// Одна запись журнала: готовая (локализованная) строка, категория и отметка времени календаря.
+public readonly record struct LogEntry(
+ string Text,
+ EventCategory Category,
+ int Day,
+ int Hour,
+ int Minute
+);
+
+///
+/// Журнал событий мира и фабрика записей истории особей (фича UI «События»/«История»). Системы-источники
+/// (спаривание/роды/охота/смерть/рост) зовут типизированные методы — те собирают локализованную строку,
+/// ставят отметку времени календаря и кладут запись в глобальный кольцевой буфер (заметные события) и/или
+/// в конкретной особи (её личная хроника, включая бытовое). Это presentation-
+/// сторона: журнал ничего не возвращает в симуляцию, поэтому на детерминизм не влияет. Строки формируются
+/// в момент события на текущем языке (как HUD/мысли); смена языка коснётся лишь новых записей.
+///
+public sealed class WorldEvents
+{
+ private const int DefaultCapacity = 200; // глубина глобального журнала (старые вытесняются)
+
+ private readonly LanguageManager _lang;
+ private readonly AnimalSet _animals;
+ private readonly Calendar _calendar;
+ private readonly List _global = [];
+ private readonly int _capacity;
+
+ public WorldEvents(
+ LanguageManager lang,
+ AnimalSet animals,
+ Calendar calendar,
+ int capacity = DefaultCapacity
+ )
+ {
+ _lang = lang;
+ _animals = animals;
+ _calendar = calendar;
+ _capacity = capacity;
+ }
+
+ /// Глобальные записи в порядке добавления (старые сверху списка, новые в конце).
+ public IReadOnlyList Recent => _global;
+
+ /// Счётчик версии: растёт при каждом новом глобальном событии — UI перерисовывается лишь по нему.
+ public int Version { get; private set; }
+
+ // — Размножение —
+ public void Mated(Entity a, int aSpecies, Entity b, int bSpecies)
+ {
+ var text = _lang.Format("event.mated", Label(aSpecies), Label(bSpecies));
+ Global(EventCategory.Reproduction, text);
+ Creature(a, EventCategory.Reproduction, text);
+ Creature(
+ b,
+ EventCategory.Reproduction,
+ _lang.Format("event.mated", Label(bSpecies), Label(aSpecies))
+ );
+ }
+
+ public void Pregnant(Entity mother, int species)
+ {
+ var text = _lang.Format("event.pregnant", Label(species));
+ Global(EventCategory.Reproduction, text);
+ Creature(mother, EventCategory.Reproduction, text);
+ }
+
+ public void Born(Entity mother, int species, int litter)
+ {
+ var text = _lang.Format("event.birth", Label(species), litter);
+ Global(EventCategory.Reproduction, text);
+ Creature(mother, EventCategory.Reproduction, text);
+ }
+
+ public void Hatched(int species, int count) =>
+ Global(EventCategory.Reproduction, _lang.Format("event.hatch", Label(species), count));
+
+ // — Охота и бой —
+ public void HuntStarted(Entity hunter, int hunterSpecies, int preySpecies)
+ {
+ var text = _lang.Format("event.hunt", Label(hunterSpecies), Label(preySpecies));
+ Global(EventCategory.Combat, text);
+ Creature(hunter, EventCategory.Combat, text);
+ }
+
+ public void Killed(Entity hunter, int hunterSpecies, int preySpecies)
+ {
+ var text = _lang.Format("event.killed", Label(hunterSpecies), Label(preySpecies));
+ Global(EventCategory.Combat, text);
+ Creature(hunter, EventCategory.Combat, text);
+ }
+
+ // Один удар (дискретная атака): в боевую хронику ОБОИХ участников (как combat-лог пешки в RimWorld),
+ // но не в глобальный журнал — иначе бой засыпал бы его. Урон округляется для читаемости.
+ public void Struck(
+ Entity attacker,
+ int attackerSpecies,
+ Entity victim,
+ int victimSpecies,
+ float damage
+ )
+ {
+ var dmg = MathF.Round(damage);
+ Creature(
+ attacker,
+ EventCategory.Combat,
+ _lang.Format("event.struck", Label(attackerSpecies), Label(victimSpecies), dmg)
+ );
+ Creature(
+ victim,
+ EventCategory.Combat,
+ _lang.Format("event.tookhit", Label(victimSpecies), Label(attackerSpecies), dmg)
+ );
+ }
+
+ // — Смерть (умершая особь исчезает, её хроника — тоже; пишем только в глобальный журнал) —
+ public void Died(int species, string causeKey) =>
+ Global(EventCategory.Death, _lang.Format(causeKey, Label(species)));
+
+ // — Бытовое (только личная хроника особи, чтобы не засорять глобальный журнал) —
+ public void Ate(Entity e, int species) =>
+ Creature(e, EventCategory.Routine, _lang.Format("event.ate", Label(species)));
+
+ public void Drank(Entity e, int species) =>
+ Creature(e, EventCategory.Routine, _lang.Format("event.drank", Label(species)));
+
+ public void Fled(Entity e, int species) =>
+ Creature(e, EventCategory.Routine, _lang.Format("event.fled", Label(species)));
+
+ // Холостая активность (отдых/копание/валяние) — в личную хронику особи по готовому ключу строки.
+ public void Activity(Entity e, int species, string key) =>
+ Creature(e, EventCategory.Routine, _lang.Format(key, Label(species)));
+
+ public void Grew(Entity e, int species, string stageName) =>
+ Creature(
+ e,
+ EventCategory.Routine,
+ _lang.Format(
+ "event.grew",
+ Label(species),
+ _lang.Get("animalstage." + stageName.ToLowerInvariant())
+ )
+ );
+
+ private string Label(int species) => _lang.Get(_animals[species].Def.Label);
+
+ private LogEntry Make(EventCategory category, string text) =>
+ new(text, category, _calendar.Day, _calendar.Hour, _calendar.Minute);
+
+ private void Global(EventCategory category, string text)
+ {
+ _global.Add(Make(category, text));
+ if (_global.Count > _capacity)
+ {
+ _global.RemoveAt(0); // вытесняем старейшее
+ }
+
+ Version++;
+ }
+
+ private void Creature(Entity e, EventCategory category, string text)
+ {
+ if (e.IsNull || !e.TryGetComponent(out var log) || log.Entries is null)
+ {
+ return;
+ }
+
+ log.Entries.Add(Make(category, text));
+ if (log.Entries.Count > log.Capacity)
+ {
+ log.Entries.RemoveAt(0);
+ }
+ }
+}
diff --git a/src/LittleSim/UI/EventsPanel.cs b/src/LittleSim/UI/EventsPanel.cs
new file mode 100644
index 0000000..d67afa1
--- /dev/null
+++ b/src/LittleSim/UI/EventsPanel.cs
@@ -0,0 +1,122 @@
+using System;
+using LittleSim.Sim;
+using Microsoft.Xna.Framework;
+using MrGameEng.Mods;
+using Myra.Graphics2D;
+using Myra.Graphics2D.Brushes;
+using Myra.Graphics2D.UI;
+
+namespace LittleSim.UI;
+
+///
+/// Оверлей глобального журнала событий мира (фича UI «События»): последние заметные события (спаривание,
+/// роды/вылупление, начало охоты, убийство, смерти) новыми сверху, цвет строки — по категории. Источник —
+/// ; тело перестраивается лишь при изменении версии журнала (новое событие),
+/// поэтому открытое окно почти ничего не стоит на паузе. Переключается кнопкой HUD.
+///
+internal sealed class EventsPanel
+{
+ private const int MaxShown = 60; // сколько последних записей показывать
+
+ // Цвета строк по категории (приглушённые — читаемо на тёмном фоне).
+ private static readonly Color Reproduction = new(120, 186, 120);
+ private static readonly Color Combat = new(214, 150, 86);
+ private static readonly Color Death = new(196, 92, 86);
+ private static readonly Color Routine = new(150, 160, 175);
+
+ private readonly LanguageManager _lang;
+ private readonly WorldEvents _events;
+ private readonly VerticalStackPanel _panel;
+ private readonly VerticalStackPanel _body;
+ private int _builtVersion = -1;
+
+ public EventsPanel(LanguageManager lang, WorldEvents events)
+ {
+ _lang = lang;
+ _events = events;
+
+ _body = new VerticalStackPanel { Spacing = 2 };
+ var scroll = new ScrollViewer
+ {
+ Content = _body,
+ Height = 360,
+ ShowHorizontalScrollBar = false,
+ };
+
+ _panel = new VerticalStackPanel
+ {
+ Spacing = 6,
+ Padding = new Thickness(14),
+ Width = 420,
+ HorizontalAlignment = HorizontalAlignment.Right,
+ VerticalAlignment = VerticalAlignment.Top,
+ Margin = new Thickness(0, 48, 12, 0),
+ Background = new SolidBrush(new Color(10, 13, 18, 240)),
+ Border = new SolidBrush(new Color(96, 134, 168)),
+ BorderThickness = new Thickness(2),
+ Visible = false,
+ };
+ _panel.Widgets.Add(new Label { Text = _lang.Get("events.title"), TextColor = Ui.Accent });
+ _panel.Widgets.Add(new HorizontalSeparator());
+ _panel.Widgets.Add(scroll);
+ }
+
+ /// Корневой виджет — добавляется в HUD.
+ public Widget Panel => _panel;
+
+ /// Открыт ли журнал.
+ public bool IsOpen => _panel.Visible;
+
+ /// Показать/скрыть; при открытии форсирует пересборку тела.
+ public void Toggle()
+ {
+ _panel.Visible = !_panel.Visible;
+ if (_panel.Visible)
+ {
+ _builtVersion = -1; // пересобрать при следующем Refresh
+ }
+ }
+
+ /// Вызывать каждый кадр: перестраивает тело лишь при новом событии (по версии журнала).
+ public void Refresh()
+ {
+ if (!_panel.Visible || _events.Version == _builtVersion)
+ {
+ return;
+ }
+
+ _builtVersion = _events.Version;
+ _body.Widgets.Clear();
+ var entries = _events.Recent;
+ if (entries.Count == 0)
+ {
+ _body.Widgets.Add(new Label { Text = _lang.Get("events.empty"), TextColor = Ui.Muted });
+ return;
+ }
+
+ // Новые сверху; ограничиваем число строк.
+ var shown = Math.Min(MaxShown, entries.Count);
+ for (var i = 0; i < shown; i++)
+ {
+ var entry = entries[entries.Count - 1 - i];
+ var stamp = _lang.Format("log.stamp", entry.Day, entry.Hour, entry.Minute);
+ _body.Widgets.Add(
+ new Label
+ {
+ Text = stamp + " " + entry.Text,
+ TextColor = ColorOf(entry.Category),
+ Wrap = true,
+ }
+ );
+ }
+ }
+
+ private static Color ColorOf(EventCategory category) =>
+ category switch
+ {
+ EventCategory.Reproduction => Reproduction,
+ EventCategory.Combat => Combat,
+ EventCategory.Death => Death,
+ _ => Routine,
+ };
+}
diff --git a/src/LittleSim/UI/InspectPanel.cs b/src/LittleSim/UI/InspectPanel.cs
index a48e9be..db89069 100644
--- a/src/LittleSim/UI/InspectPanel.cs
+++ b/src/LittleSim/UI/InspectPanel.cs
@@ -36,6 +36,7 @@ internal sealed class InspectPanel
Skills,
Health,
Mood,
+ Log,
}
private static readonly Tab[] PlantTabs = [Tab.Overview, Tab.Genes, Tab.Products];
@@ -46,6 +47,7 @@ internal sealed class InspectPanel
Tab.Skills,
Tab.Health,
Tab.Mood,
+ Tab.Log,
];
// Палитра тела панели и полос.
@@ -65,6 +67,14 @@ internal sealed class InspectPanel
private readonly Climate _climate;
private readonly Selection _selection;
private readonly SelectionBrackets _brackets;
+ private readonly GameClock _clock;
+
+ // Троттлинг пересборки тела вкладки: дерево виджетов Myra пересобиралось каждый кадр (десятки
+ // аллокаций/кадр даже на паузе). Теперь — только при смене выбора/вкладки или раз в RefreshInterval.
+ private const double RefreshInterval = 0.2; // сек реального времени (5 Гц) — значения нужд/HP живые
+ private int _builtEntityId = -1;
+ private Tab _builtTab = (Tab)(-1);
+ private double _nextBuildAt;
private readonly VerticalStackPanel _panel;
private readonly Label _title;
@@ -81,7 +91,8 @@ internal sealed class InspectPanel
GameContent content,
Climate climate,
Selection selection,
- SelectionBrackets brackets
+ SelectionBrackets brackets,
+ GameClock clock
)
{
_store = store;
@@ -93,6 +104,7 @@ internal sealed class InspectPanel
_climate = climate;
_selection = selection;
_brackets = brackets;
+ _clock = clock;
_title = new Label { TextColor = Ui.Accent, Wrap = true };
@@ -104,9 +116,10 @@ internal sealed class InspectPanel
AddTab(tabBar, Tab.Skills, "inspect.tab.skills");
AddTab(tabBar, Tab.Health, "inspect.tab.health");
AddTab(tabBar, Tab.Mood, "inspect.tab.mood");
+ AddTab(tabBar, Tab.Log, "inspect.tab.log");
- // Тело вкладки пересобирается каждый кадр; живёт в скролле фиксированной высоты, чтобы панель
- // держала постоянный размер и не «прыгала» при переключении вкладок и появлении/исчезании строк.
+ // Тело вкладки пересобирается по троттлингу (см. Refresh); живёт в скролле фиксированной высоты,
+ // чтобы панель держала постоянный размер и не «прыгала» при переключении вкладок и строк.
_bodyPanel = new VerticalStackPanel { Spacing = 3 };
var scroll = new ScrollViewer
{
@@ -181,7 +194,17 @@ internal sealed class InspectPanel
region,
sprite.Origin
);
- _brackets.Show(center, radius);
+ _brackets.Show(center, radius); // позиция брекета — каждый кадр (дёшево, следит за движением)
+ }
+
+ // Тело вкладки пересобираем только при смене выбора/вкладки или по таймеру (значения нужд/HP
+ // меняются медленно). Это снимает почти весь per-frame мусор открытой панели.
+ var now = _clock.UnscaledTotalTime;
+ var changed = _selection.EntityId != _builtEntityId || _tab != _builtTab;
+ if (!changed && now < _nextBuildAt)
+ {
+ _panel.Visible = true;
+ return;
}
_bodyPanel.Widgets.Clear();
@@ -194,6 +217,10 @@ internal sealed class InspectPanel
UpdateAnimal(entity);
}
+ // После пересборки: ShowTabs мог откатить _tab на валидную для нового типа объекта — фиксируем уже его.
+ _builtEntityId = _selection.EntityId;
+ _builtTab = _tab;
+ _nextBuildAt = now + RefreshInterval;
_panel.Visible = true;
}
@@ -334,9 +361,43 @@ internal sealed class InspectPanel
case Tab.Mood:
BuildAnimalMood(entity);
break;
+ case Tab.Log:
+ BuildAnimalLog(entity);
+ break;
}
}
+ // — Животное: История — личная хроника последних событий особи (новые сверху), цвет по категории.
+ private void BuildAnimalLog(Entity entity)
+ {
+ var languages = _content.Languages;
+ if (
+ !entity.TryGetComponent(out var log)
+ || log.Entries is null
+ || log.Entries.Count == 0
+ )
+ {
+ Line(languages.Get("log.empty"));
+ return;
+ }
+
+ for (var i = log.Entries.Count - 1; i >= 0; i--)
+ {
+ var e = log.Entries[i];
+ var stamp = languages.Format("log.stamp", e.Day, e.Hour, e.Minute);
+ Line(stamp + " " + e.Text, LogColor(e.Category));
+ }
+ }
+
+ private static Color LogColor(EventCategory category) =>
+ category switch
+ {
+ EventCategory.Reproduction => new Color(120, 186, 120),
+ EventCategory.Combat => new Color(214, 150, 86),
+ EventCategory.Death => new Color(196, 92, 86),
+ _ => Body,
+ };
+
private void BuildOverview(
Entity entity,
in PlantGrowth grow,
@@ -468,6 +529,14 @@ internal sealed class InspectPanel
var sex = languages.Get(org.IsMale ? "inspect.sex.male" : "inspect.sex.female");
Line(languages.Format("inspect.animal.sex", sex, org.Generation));
+ // Стиль питания (ген): обжора (gorge) ↔ пастьба (graze) ↔ умеренное.
+ var feeding = org.Traits.FeedingStyle;
+ var feedKey =
+ feeding >= 0.65f ? "feeding.gorge"
+ : feeding <= 0.35f ? "feeding.graze"
+ : "feeding.mixed";
+ Line(languages.Format("inspect.animal.feeding", languages.Get(feedKey)));
+
// Вес тела и грузоподъёмность (∝ массе × способность нести; четвероногие — через Moving).
var stageScale = sp.Stages[grow.Stage].Scale;
var mass = Mass.OfAnimal(sp.Def, org.Traits, stageScale);
diff --git a/src/LittleSim/UI/PerfPanel.cs b/src/LittleSim/UI/PerfPanel.cs
new file mode 100644
index 0000000..702fe6e
--- /dev/null
+++ b/src/LittleSim/UI/PerfPanel.cs
@@ -0,0 +1,154 @@
+using System;
+using System.Collections.Generic;
+using Friflo.Engine.ECS.Systems;
+using LittleSim.App;
+using Microsoft.Xna.Framework;
+using MrGameEng.Core;
+using MrGameEng.Mods;
+using Myra.Graphics2D;
+using Myra.Graphics2D.Brushes;
+using Myra.Graphics2D.UI;
+
+namespace LittleSim.UI;
+
+///
+/// Оверлей производительности (дев-режим): FPS, мс/кадр и таблица самых дорогих систем по корням
+/// (), переключается кнопкой HUD. Данные берутся из общего
+/// (та же выборка, что и у консольной команды perf). Тело перестраивается по троттлингу (~5 Гц),
+/// FPS считается всегда по нескалированному времени; скрытое окно тело не строит. Презентация — только
+/// читает мониторинг Friflo, ничего в симуляции не трогает.
+///
+internal sealed class PerfPanel
+{
+ private const int TopSystems = 12; // сколько самых дорогих систем показывать на корень
+ private const double RefreshInterval = 0.2; // сек реального времени между пересборками тела (5 Гц)
+
+ private readonly LanguageManager _lang;
+ private readonly GameClock _clock;
+ private readonly SystemRoot[] _roots;
+ private readonly List<(string Name, double AvgMs, int Updates)> _scratch = [];
+
+ private readonly VerticalStackPanel _panel;
+ private readonly Label _fps;
+ private readonly VerticalStackPanel _body;
+
+ private float _accum; // накоплено реального времени для окна усреднения FPS
+ private int _frames;
+ private double _nextBuildAt;
+
+ public PerfPanel(LanguageManager lang, GameClock clock, params SystemRoot[] roots)
+ {
+ _lang = lang;
+ _clock = clock;
+ _roots = roots;
+ PerfReport.EnableMonitoring(roots);
+
+ _fps = new Label { TextColor = Ui.Accent };
+ _body = new VerticalStackPanel { Spacing = 2 };
+ var scroll = new ScrollViewer
+ {
+ Content = _body,
+ Height = 340,
+ ShowHorizontalScrollBar = false,
+ };
+
+ _panel = new VerticalStackPanel
+ {
+ Spacing = 6,
+ Padding = new Thickness(14),
+ Width = 380,
+ HorizontalAlignment = HorizontalAlignment.Right,
+ VerticalAlignment = VerticalAlignment.Top,
+ Margin = new Thickness(0, 48, 12, 0),
+ Background = new SolidBrush(new Color(10, 13, 18, 240)),
+ Border = new SolidBrush(new Color(96, 134, 168)),
+ BorderThickness = new Thickness(2),
+ Visible = false,
+ };
+ _panel.Widgets.Add(new Label { Text = _lang.Get("perf.title"), TextColor = Ui.Accent });
+ _panel.Widgets.Add(_fps);
+ _panel.Widgets.Add(new HorizontalSeparator());
+ _panel.Widgets.Add(scroll);
+ _panel.Widgets.Add(new HorizontalSeparator());
+ _panel.Widgets.Add(
+ new Label
+ {
+ Text = _lang.Get("perf.hint"),
+ TextColor = Ui.Muted,
+ Wrap = true,
+ }
+ );
+ }
+
+ /// Корневой виджет — добавляется в HUD.
+ public Widget Panel => _panel;
+
+ /// Открыто ли окно.
+ public bool IsOpen => _panel.Visible;
+
+ /// Показать/скрыть. На открытии пере-утверждает мониторинг (на случай поздних систем).
+ public void Toggle()
+ {
+ _panel.Visible = !_panel.Visible;
+ if (_panel.Visible)
+ {
+ PerfReport.EnableMonitoring(_roots);
+ }
+ }
+
+ /// Вызывать каждый кадр: считает FPS (всегда) и перестраивает тело по троттлингу (если открыто).
+ public void Refresh()
+ {
+ _accum += _clock.UnscaledDeltaTime;
+ _frames++;
+ var now = _clock.UnscaledTotalTime;
+ if (now < _nextBuildAt)
+ {
+ return;
+ }
+
+ var fps = _accum > 0f ? (int)MathF.Round(_frames / _accum) : 0;
+ var frameMs = _frames > 0 ? _accum / _frames * 1000.0 : 0.0;
+ _accum = 0f;
+ _frames = 0;
+ _nextBuildAt = now + RefreshInterval;
+ if (!_panel.Visible)
+ {
+ return; // окно скрыто — окно усреднения сброшено, тело не строим
+ }
+
+ _fps.Text = _lang.Format("perf.fps", fps, frameMs);
+ _body.Widgets.Clear();
+ foreach (var root in _roots)
+ {
+ BuildRoot(root);
+ }
+ }
+
+ private void BuildRoot(SystemRoot root)
+ {
+ var total = PerfReport.Snapshot(root, _scratch);
+ _body.Widgets.Add(
+ new Label
+ {
+ Text = _lang.Format("perf.root", root.Name, total, _scratch.Count),
+ TextColor = Ui.Accent,
+ Margin = new Thickness(0, 6, 0, 1),
+ }
+ );
+
+ var shown = Math.Min(TopSystems, _scratch.Count);
+ for (var i = 0; i < shown; i++)
+ {
+ var (name, avg, _) = _scratch[i];
+ var pct = total > 0.0001 ? avg / total * 100.0 : 0.0;
+ _body.Widgets.Add(
+ new Label
+ {
+ Text = _lang.Format("perf.systemline", name, avg, pct),
+ TextColor = Ui.Text,
+ }
+ );
+ }
+ }
+}