Enhance animal genetics and UI for feeding styles

- Added "GeneFeedingStyle" to animal definitions, allowing for varied feeding behaviors (grazing vs. gorging).
- Updated animal JSON files to include feeding style genes with appropriate values.
- Enhanced UI localization for feeding styles in both English and Russian, providing clearer descriptions and inspection options.
- Introduced new event logging features in the world scene to track animal behaviors related to feeding.
- Improved animal decision-making systems to utilize the new feeding style attributes for more dynamic behavior.

This update aims to enrich gameplay by introducing more nuanced animal behaviors and enhancing the user interface for better clarity and interaction.
This commit is contained in:
Leonid Pershin
2026-06-16 14:27:46 +03:00
parent 92d0302138
commit d0a0714563
24 changed files with 1825 additions and 256 deletions
+12 -6
View File
@@ -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
}
}
+7
View File
@@ -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.
+36
View File
@@ -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",
+36
View File
@@ -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": "подключено",
+31 -2
View File
@@ -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
/// <summary>Порт сервера по умолчанию (NetSchema.DefaultPort).</summary>
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)
/// <summary>Transform2D → NetTransform, PawnNeeds → NetPawnNeeds.</summary>
public static ReplicationSchema Create() =>
new ReplicationSchema().Register<NetTransform>().Register<NetPawnNeeds>();
public static ReplicationSchema Create()
{
VerifyLayout();
return new ReplicationSchema().Register<NetTransform>().Register<NetPawnNeeds>();
}
/// <summary>Сверяет байт-раскладку зеркальных структур с десктопным контрактом (NetSchema.cs).</summary>
private static void VerifyLayout()
{
Require<NetTransform>(TransformWireSize);
Require<NetPawnNeeds>(PawnNeedsWireSize);
static void Require<T>(int expected)
where T : struct
{
var actual = Unsafe.SizeOf<T>();
if (actual != expected)
{
throw new InvalidOperationException(
$"Размер зеркального {typeof(T).Name} = {actual} Б, ожидался {expected} Б: "
+ "wire-контракт разошёлся — синхронизируй NetContract.cs и LittleSim/Net/NetSchema.cs."
);
}
}
}
}
+107
View File
@@ -0,0 +1,107 @@
using System.Collections.Generic;
using Friflo.Engine.ECS.Systems;
using MrGameEng.DevConsole;
namespace LittleSim.App;
/// <summary>
/// Дев-инструмент: per-system тайминги Friflo. Включает мониторинг (<c>SetMonitorPerf</c>) на корнях
/// систем и печатает в дев-консоль командой <c>perf</c> отсортированную по времени таблицу систем
/// (среднее мс/кадр по последним кадрам + доля от корня). Доступно только в режиме разработчика.
/// </summary>
public static class PerfReport
{
private const int DefaultTop = 15; // сколько самых дорогих систем печатать по умолчанию
/// <summary>Окно усреднения времени (последние N обновлений) — используют и консоль, и perf-окно.</summary>
public const int AvgFrames = 30;
private static readonly List<(string Name, double AvgMs, int Updates)> Scratch = []; // без аллокаций на вызов
/// <summary>Включает мониторинг на корнях и регистрирует команду <c>perf [N]</c> (топ-N систем).</summary>
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);
}
}
);
}
/// <summary>Включает per-system мониторинг Friflo на корнях (идемпотентно; можно звать повторно для
/// систем, добавленных позже). Публично — чтобы perf-окно тоже могло его утвердить.</summary>
public static void EnableMonitoring(params SystemRoot[] roots)
{
foreach (var root in roots)
{
root.SetMonitorPerf(true);
}
}
/// <summary>
/// Заполняет <paramref name="into"/> строками (имя, среднее мс/кадр, число обновлений) по листовым
/// системам корня, сортированными по времени убыв.; возвращает суммарное среднее мс/кадр корня
/// (0, если данных ещё нет). Общий сбор для консольной команды и perf-окна — без дублирования.
/// </summary>
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));
}
}
}
}
+17 -41
View File
@@ -28,40 +28,6 @@ public sealed class PlantSave
public Dictionary<string, Allele> Genome { get; set; } = new();
}
/// <summary>Сериализуемое состояние одного жителя (sim-компоненты; спрайт пересобирается из дефа по <see cref="DefName"/>).</summary>
public sealed class PawnSave
{
/// <summary>Имя дефа жителя — по нему при загрузке берётся текстура из атласов.</summary>
public string DefName { get; set; } = "";
/// <summary>Позиция X в мировых координатах.</summary>
public float X { get; set; }
/// <summary>Позиция Y в мировых координатах.</summary>
public float Y { get; set; }
/// <summary>Масштаб трансформа (зависит от размера дефа и клетки).</summary>
public float Scale { get; set; } = 1f;
/// <summary>Запас энергии [0, 1].</summary>
public float Energy { get; set; }
/// <summary>Текущее действие (значение <see cref="Sim.PawnAction"/>).</summary>
public int Action { get; set; }
/// <summary>Секунды до следующего пересмотра решения.</summary>
public float DecideIn { get; set; }
/// <summary>Направление блуждания X.</summary>
public float DirX { get; set; }
/// <summary>Направление блуждания Y.</summary>
public float DirY { get; set; }
/// <summary>Секунды до смены направления.</summary>
public float ChangeIn { get; set; }
}
/// <summary>Сериализуемый хедиф особи (фаза A6): деф (по имени), тяжесть и набранный иммунитет.</summary>
public sealed class HediffSave
{
@@ -208,6 +174,13 @@ public sealed class EggSave
/// </summary>
public sealed class WorldSave
{
/// <summary>Текущая версия схемы сейва (поднимать при несовместимых изменениях полей).</summary>
public const int CurrentVersion = 1;
/// <summary>Версия схемы файла. Старые сейвы без поля совместимы (схема аддитивна) и читаются как
/// текущая версия; проверка нужна, чтобы отклонить файл из БУДУЩЕЙ (несовместимой) версии.</summary>
public int Version { get; set; } = CurrentVersion;
/// <summary>Имя мира (и основа имени файла).</summary>
public string Name { get; set; } = "";
@@ -232,9 +205,6 @@ public sealed class WorldSave
/// <summary>Прошедшее время симуляции в секундах.</summary>
public double ElapsedSeconds { get; set; }
/// <summary>Снимок жителей.</summary>
public List<PawnSave> Pawns { get; set; } = [];
/// <summary>Снимок всех растений (геномы, позиции, возраст, стадия).</summary>
public List<PlantSave> 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<char> 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;
}
}
+6 -3
View File
@@ -310,13 +310,16 @@ public sealed class AnimalDef : PawnDef
/// <summary>Дальность атаки в клетках: на этом расстоянии хищник кусает добычу (контакт).</summary>
public float AttackRangeCells { get; init; } = 1.2f;
/// <summary>Урон части тела добычи за игровой день укусов (0 — вид не атакует, не хищник по бою).</summary>
/// <summary>Игровые секунды между ударами (кулдаун дискретной атаки, как в RimWorld).</summary>
public float AttackIntervalSec { get; init; } = 1.8f;
/// <summary>Урон части тела добычи ЗА ОДИН УДАР (×силу: размер×навык). 0 — вид не атакует.</summary>
public float AttackDamage { get; init; }
/// <summary>Прирост тяжести раны-кровотечения у добычи за игровой день укусов.</summary>
/// <summary>Прирост тяжести раны-кровотечения у добычи за один удар (×силу).</summary>
public float AttackBleed { get; init; }
/// <summary>Острая кровопотеря добычи за игровой день укусов (доля объёма крови) — основной путь к смерти.</summary>
/// <summary>Острая кровопотеря добычи за один удар (доля объёма крови, ×силу) — основной путь к смерти.</summary>
public float AttackBloodLoss { get; init; }
}
+4 -1
View File
@@ -9,14 +9,17 @@ namespace LittleSim.Content;
public sealed class NeedSet
{
private readonly NeedDef[] _defs;
private readonly bool[] _isDrive; // предрасчёт типа нужды — горячие циклы не сравнивают строки на тик
private readonly Dictionary<string, int> _byAction = new(StringComparer.Ordinal);
/// <summary>Строит реестр из всех <see cref="NeedDef"/> мода (порядок = индексы нужд).</summary>
public NeedSet(GameContent content)
{
_defs = content.Defs.All<NeedDef>().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];
/// <summary>Нужда-влечение (растёт под условием), а не убывающая.</summary>
public bool IsDrive(int index) => string.Equals(_defs[index].Kind, "drive", StringComparison.Ordinal);
public bool IsDrive(int index) => _isDrive[index];
/// <summary>Убывающая нужда (голод/жажда/сон) — низкое значение срочно.</summary>
public bool IsDeplete(int index) => !IsDrive(index);
+32 -2
View File
@@ -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
/// <summary>Порт сервера по умолчанию.</summary>
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)
/// <summary>Схема репликации: позиция/масштаб жителя и его потребности.</summary>
public static ReplicationSchema Create() =>
new ReplicationSchema().Register<Transform2D>().Register<PawnNeeds>();
public static ReplicationSchema Create()
{
VerifyLayout();
return new ReplicationSchema().Register<Transform2D>().Register<PawnNeeds>();
}
/// <summary>Сверяет фактическую байт-раскладку реплицируемых структур с контрактом (см. NetContract.cs).</summary>
private static void VerifyLayout()
{
Require<Transform2D>(TransformWireSize);
Require<PawnNeeds>(PawnNeedsWireSize);
static void Require<T>(int expected)
where T : struct
{
var actual = Unsafe.SizeOf<T>();
if (actual != expected)
{
throw new InvalidOperationException(
$"Размер реплицируемого {typeof(T).Name} = {actual} Б, ожидался {expected} Б: "
+ "wire-контракт изменился — синхронизируй NetSchema и LittleSim.Web/NetContract.cs."
);
}
}
}
}
+184 -26
View File
@@ -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<Action> _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<Widget> { 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<bool> 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,10 +941,21 @@ public sealed class WorldScene : Scene
if (entity.GetComponent<Health>().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)
{
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<Vector2>();
for (var y = 0; y < _config.Height; y++)
var w = _config.Width;
var h = _config.Height;
var count = w * h;
// BFS: nearest[c] — индекс ближайшей береговой клетки. Источники — все береговые клетки (шаг 0).
var nearest = new int[count];
Array.Fill(nearest, -1);
var queue = new Queue<int>();
for (var cell = 0; cell < count; cell++)
{
for (var x = 0; x < _config.Width; x++)
{
var cell = y * _config.Width + x;
if (!_cellLand[cell])
{
continue;
}
var x = cell % w;
var y = cell / w;
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]);
|| (x < w - 1 && !_cellLand[cell + 1])
|| (y > 0 && !_cellLand[cell - w])
|| (y < h - 1 && !_cellLand[cell + w]);
if (nearWater)
{
shore.Add(new Vector2((x + 0.5f) * CellSize, (y + 0.5f) * CellSize));
}
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 клетка-окклюдер, а спрайт
+11
View File
@@ -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;
}
/// <summary>Сколько записей памяти даёт единица размера мозга (объём памяти = brainSize × это).</summary>
public const float MemoryPerBrain = 16f;
/// <summary>Глубина личной хроники особи (последние N событий о ней).</summary>
public const int CreatureLogCapacity = 24;
// Стабильный сид из генома (сумма аллелей — порядконезависима) для детерминированной страсти к навыкам.
private static int SkillSeed(Genome genome)
{
+43 -4
View File
@@ -67,6 +67,13 @@ public struct AnimalPhenotype
/// <summary>Всеядность [0..1] — генералист: включает оба источника пищи (растения и мясо).</summary>
public float Omnivory;
/// <summary>
/// Стиль питания [0..1]: 0 — «пастьба» (graze), часто подтапливает сытость мелкими приёмами;
/// 1 — «обжорство» (gorge), ищет еду лишь при сильном голоде и ест до полной сытости (редкие
/// крупные приёмы, как у хищников). Задаёт пороги начала/конца кормёжки в <see cref="Sim.AnimalDecisionSystem"/>.
/// </summary>
public float FeedingStyle;
/// <summary>Устойчивость к растительным ядам [0..1] — снижает дозу отравления токсичным кормом.</summary>
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
/// <summary>Пол: самец (XY) или самка (XX) — выведен из локуса GeneSex при создании.</summary>
public bool IsMale;
/// <summary>Ест ли растения (диета по генам/дефу, неизменна после рождения — кэш вместо пересчёта на тик).</summary>
public bool EatsPlants;
/// <summary>Ест ли мясо (диета по генам/дефу, неизменна после рождения — кэш вместо пересчёта на тик).</summary>
public bool EatsMeat;
/// <summary>Номер поколения: max(родителей) + 1; у основателей 0.</summary>
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;
}
/// <summary>
@@ -648,6 +662,31 @@ public struct GutSeed : IComponent
public float DropInDays;
}
/// <summary>
/// Кратковременная «вспышка урона» (презентация, как в RimWorld): при получении удара выставляется
/// время свечения; <see cref="Sim.DamageFlashSystem"/> подкрашивает спрайт красным и гасит свечение.
/// Есть у всех животных (выставляется в 0 при создании), чтобы удар не делал структурного добавления.
/// </summary>
public struct DamageFlash : IComponent
{
/// <summary>Остаток реального времени свечения (сек); 0 — не подсвечен.</summary>
public float Seconds;
}
/// <summary>
/// Личная хроника особи (фича UI «История»): ограниченный кольцевой список событий о ней (спаривание,
/// роды, охота, кормёжка, бегство, рост). Пишет <see cref="Sim.WorldEvents"/>, читает инспектор. Список —
/// managed-ссылка (как мысли/память); старые записи вытесняются по достижении <see cref="Capacity"/>.
/// </summary>
public struct CreatureLog : IComponent
{
/// <summary>Записи в порядке добавления (новые в конце).</summary>
public List<LogEntry> Entries;
/// <summary>Сколько записей хранится (старейшие вытесняются).</summary>
public int Capacity;
}
/// <summary>Инстанс мысли настроения на особи (фаза A8): деф события + остаток времени до угасания.</summary>
public struct ThoughtInstance
{
File diff suppressed because it is too large Load Diff
+37 -24
View File
@@ -44,20 +44,36 @@ public static class AnimalCapacities
/// </summary>
public static class CapacityCalc
{
/// <summary>Считает карту способностей из инстансов частей, уровня крови (0..1) и боли (0..1);
/// <paramref name="capacityScale"/> — общий множитель (старение), применяется к итогу без лишних аллокаций.</summary>
public static Dictionary<string, float> Compute(
// Переиспользуемые scratch-буферы: пересчёт способностей идёт на каждое животное на тик здоровья и на
// каждую травму — раньше это аллоцировало 4 словаря + HashSet за вызов (главный аллокатор сим-части).
// [ThreadStatic] — безопасно и при возможном распараллеливании (свой буфер на поток).
[ThreadStatic]
private static Dictionary<string, float>? _raw;
[ThreadStatic]
private static HashSet<string>? _declared;
[ThreadStatic]
private static Dictionary<string, float>? _computed;
/// <summary>Считает способности из инстансов частей, уровня крови (0..1) и боли (0..1) в переданный
/// <paramref name="result"/> (очищается и заполняется заново — без аллокаций); <paramref name="capacityScale"/>
/// — общий множитель (старение), применяется к итогу.</summary>
public static void Compute(
PartInstance[] parts,
float blood,
float pain,
float capacityScale = 1f
float capacityScale,
Dictionary<string, float> result
)
{
// Сырой вклад каждой способности (Σ доля·HP) и НАБОР объявленных телом способностей: вид имеет
// только те, к которым его части вообще причастны (у оленя нет манипуляции). Объявленность —
// статична (по дефам частей), поэтому уничтоженный орган всё равно покажет способность на 0%.
var raw = new Dictionary<string, float>(System.StringComparer.Ordinal);
var declared = new HashSet<string>(System.StringComparer.Ordinal);
var raw = _raw ??= new Dictionary<string, float>(System.StringComparer.Ordinal);
var declared = _declared ??= new HashSet<string>(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<string, float>(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<string, float>(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<string, float>(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;
}
}
+10
View File
@@ -42,6 +42,16 @@ public static class Mass
public static float CarryCapacity(float bodyMassKg, float capability) =>
bodyMassKg * CarryFraction * MathF.Max(0f, capability);
/// <summary>Показатель Клайбера для линейного размера: масса^0.75 = (размер)^2.25 (масса ∝ размер³).</summary>
public const float KleiberSizeExponent = 2.25f;
/// <summary>
/// Метаболический множитель по закону Клайбера от отношения ЛИНЕЙНЫХ размеров (размер/эталон): расход
/// энергии ∝ масса^0.75 = размер^2.25. Дешевле массовой формы (без возведения массы) — для горячего цикла.
/// </summary>
public static float KleiberFactorFromSizeRatio(float sizeRatio) =>
MathF.Pow(MathF.Max(0f, sizeRatio), KleiberSizeExponent);
/// <summary>
/// Метаболический множитель по закону Клайбера: расход энергии ∝ масса^0.75. Нормирован на эталонную
/// массу вида (взрослый базового размера = 1), поэтому крупные особи прожорливее, а молодняк — экономнее,
+10 -3
View File
@@ -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)
+11 -3
View File
@@ -24,14 +24,22 @@ public sealed class PlantGrowthSystem(
int cellSize
) : QuerySystem<PlantGrowth, PlantOrganism, Sprite, Transform2D>
{
// Рост медленный, поэтому обрабатываем его пачками раз в 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;
+17 -1
View File
@@ -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<MrGameEng.Core.Season>();
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));
+3
View File
@@ -14,6 +14,9 @@ public interface ISpatialEntry
public readonly record struct ForageEntry(int Id, Vector2 Pos, float Toxicity, float Palatability)
: ISpatialEntry;
/// <summary>Труп для поиска падали хищником: позиция/id + остаток мяса (нулевые отсеиваются вызывающим).</summary>
public readonly record struct CorpseEntry(int Id, Vector2 Pos, float Meat) : ISpatialEntry;
/// <summary>Сосед-животное для ИИ: позиция/id + предпосчитанные признаки (вид, размер, мясоед, пол, взрослость)
/// и заметность (камуфляж/запах/шум) — чтобы запросы угрозы/добычи/партнёра/стада не дорефетчили компоненты.</summary>
public readonly record struct NeighborEntry(
+197
View File
@@ -0,0 +1,197 @@
using System.Collections.Generic;
using Friflo.Engine.ECS;
using LittleSim.Content;
using MrGameEng.Core;
using MrGameEng.Mods;
namespace LittleSim.Sim;
/// <summary>Категория события — определяет цвет строки в журнале/истории и группировку.</summary>
public enum EventCategory : byte
{
/// <summary>Размножение: спаривание, беременность, роды, вылупление.</summary>
Reproduction,
/// <summary>Охота и бой: начало охоты, убийство добычи.</summary>
Combat,
/// <summary>Смерть: голод/жажда, старость, болезнь, хищник.</summary>
Death,
/// <summary>Бытовое: кормёжка, водопой, бегство, смена стадии (только в истории особи).</summary>
Routine,
}
/// <summary>Одна запись журнала: готовая (локализованная) строка, категория и отметка времени календаря.</summary>
public readonly record struct LogEntry(
string Text,
EventCategory Category,
int Day,
int Hour,
int Minute
);
/// <summary>
/// Журнал событий мира и фабрика записей истории особей (фича UI «События»/«История»). Системы-источники
/// (спаривание/роды/охота/смерть/рост) зовут типизированные методы — те собирают локализованную строку,
/// ставят отметку времени календаря и кладут запись в глобальный кольцевой буфер (заметные события) и/или
/// в <see cref="CreatureLog"/> конкретной особи (её личная хроника, включая бытовое). Это presentation-
/// сторона: журнал ничего не возвращает в симуляцию, поэтому на детерминизм не влияет. Строки формируются
/// в момент события на текущем языке (как HUD/мысли); смена языка коснётся лишь новых записей.
/// </summary>
public sealed class WorldEvents
{
private const int DefaultCapacity = 200; // глубина глобального журнала (старые вытесняются)
private readonly LanguageManager _lang;
private readonly AnimalSet _animals;
private readonly Calendar _calendar;
private readonly List<LogEntry> _global = [];
private readonly int _capacity;
public WorldEvents(
LanguageManager lang,
AnimalSet animals,
Calendar calendar,
int capacity = DefaultCapacity
)
{
_lang = lang;
_animals = animals;
_calendar = calendar;
_capacity = capacity;
}
/// <summary>Глобальные записи в порядке добавления (старые сверху списка, новые в конце).</summary>
public IReadOnlyList<LogEntry> Recent => _global;
/// <summary>Счётчик версии: растёт при каждом новом глобальном событии — UI перерисовывается лишь по нему.</summary>
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<CreatureLog>(out var log) || log.Entries is null)
{
return;
}
log.Entries.Add(Make(category, text));
if (log.Entries.Count > log.Capacity)
{
log.Entries.RemoveAt(0);
}
}
}
+122
View File
@@ -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;
/// <summary>
/// Оверлей глобального журнала событий мира (фича UI «События»): последние заметные события (спаривание,
/// роды/вылупление, начало охоты, убийство, смерти) новыми сверху, цвет строки — по категории. Источник —
/// <see cref="WorldEvents"/>; тело перестраивается лишь при изменении версии журнала (новое событие),
/// поэтому открытое окно почти ничего не стоит на паузе. Переключается кнопкой HUD.
/// </summary>
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);
}
/// <summary>Корневой виджет — добавляется в HUD.</summary>
public Widget Panel => _panel;
/// <summary>Открыт ли журнал.</summary>
public bool IsOpen => _panel.Visible;
/// <summary>Показать/скрыть; при открытии форсирует пересборку тела.</summary>
public void Toggle()
{
_panel.Visible = !_panel.Visible;
if (_panel.Visible)
{
_builtVersion = -1; // пересобрать при следующем Refresh
}
}
/// <summary>Вызывать каждый кадр: перестраивает тело лишь при новом событии (по версии журнала).</summary>
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,
};
}
+73 -4
View File
@@ -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<CreatureLog>(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);
+154
View File
@@ -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;
/// <summary>
/// Оверлей производительности (дев-режим): FPS, мс/кадр и таблица самых дорогих систем по корням
/// (<see cref="SystemRoot"/>), переключается кнопкой HUD. Данные берутся из общего <see cref="PerfReport"/>
/// (та же выборка, что и у консольной команды <c>perf</c>). Тело перестраивается по троттлингу (~5 Гц),
/// FPS считается всегда по нескалированному времени; скрытое окно тело не строит. Презентация — только
/// читает мониторинг Friflo, ничего в симуляции не трогает.
/// </summary>
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,
}
);
}
/// <summary>Корневой виджет — добавляется в HUD.</summary>
public Widget Panel => _panel;
/// <summary>Открыто ли окно.</summary>
public bool IsOpen => _panel.Visible;
/// <summary>Показать/скрыть. На открытии пере-утверждает мониторинг (на случай поздних систем).</summary>
public void Toggle()
{
_panel.Visible = !_panel.Visible;
if (_panel.Visible)
{
PerfReport.EnableMonitoring(_roots);
}
}
/// <summary>Вызывать каждый кадр: считает FPS (всегда) и перестраивает тело по троттлингу (если открыто).</summary>
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,
}
);
}
}
}