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, + } + ); + } + } +}