From d0a071456389c07f2cc2ddaff86537af86ce650c Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Tue, 16 Jun 2026 14:27:46 +0300 Subject: [PATCH] Enhance animal genetics and UI for feeding styles - Added "GeneFeedingStyle" to animal definitions, allowing for varied feeding behaviors (grazing vs. gorging). - Updated animal JSON files to include feeding style genes with appropriate values. - Enhanced UI localization for feeding styles in both English and Russian, providing clearer descriptions and inspection options. - Introduced new event logging features in the world scene to track animal behaviors related to feeding. - Improved animal decision-making systems to utilize the new feeding style attributes for more dynamic behavior. This update aims to enrich gameplay by introducing more nuanced animal behaviors and enhancing the user interface for better clarity and interaction. --- Mods/Core/Defs/Animals/Animals.json | 18 +- Mods/Core/Defs/Genes/Animal.json | 7 + Mods/Core/Languages/en/ui.json | 36 ++ Mods/Core/Languages/ru/ui.json | 36 ++ src/LittleSim.Web/NetContract.cs | 33 +- src/LittleSim/App/PerfReport.cs | 107 ++++ src/LittleSim/App/WorldSave.cs | 58 +- src/LittleSim/Content/GameDefs.cs | 9 +- src/LittleSim/Content/NeedSet.cs | 5 +- src/LittleSim/Net/NetSchema.cs | 34 +- src/LittleSim/Scenes/WorldScene.cs | 230 +++++-- src/LittleSim/Sim/AnimalFactory.cs | 11 + src/LittleSim/Sim/AnimalOrganism.cs | 47 +- src/LittleSim/Sim/AnimalSystems.cs | 781 +++++++++++++++++++---- src/LittleSim/Sim/CapacityCalc.cs | 61 +- src/LittleSim/Sim/Mass.cs | 10 + src/LittleSim/Sim/PlantFruitingSystem.cs | 13 +- src/LittleSim/Sim/PlantGrowthSystem.cs | 14 +- src/LittleSim/Sim/SimSystems.cs | 18 +- src/LittleSim/Sim/SpatialGrid.cs | 3 + src/LittleSim/Sim/WorldEvents.cs | 197 ++++++ src/LittleSim/UI/EventsPanel.cs | 122 ++++ src/LittleSim/UI/InspectPanel.cs | 77 ++- src/LittleSim/UI/PerfPanel.cs | 154 +++++ 24 files changed, 1825 insertions(+), 256 deletions(-) create mode 100644 src/LittleSim/App/PerfReport.cs create mode 100644 src/LittleSim/Sim/WorldEvents.cs create mode 100644 src/LittleSim/UI/EventsPanel.cs create mode 100644 src/LittleSim/UI/PerfPanel.cs 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, + } + ); + } + } +}