Merge branch 'animals' into main: animal/zoology system (A1-A8 + serialization)

Brings the full animal foundation onto main and folds its new defs into the
Defs/ subfolder layout introduced on main:
- animal genes -> Defs/Genes/Animal.json (split out of genes.json)
- animals/bodies/hediffs/needs/thoughts -> own Defs/<Type>/ subfolders
- ProductMeat/Bone/Leather + bigger world presets auto-merged via rename detection
- PawnDef un-sealed (AnimalDef : PawnDef); def-location doc comments updated to subfolders
- engine pointer kept at main's newer 96e19c7 (only adds directional sun shadows)

Build + --check-content clean: 11 def types, 33 genes, 35 plants, 32 traits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-14 12:48:49 +03:00
co-authored by Claude Opus 4.8
25 changed files with 4348 additions and 51 deletions
+117
View File
@@ -62,6 +62,117 @@ public sealed class PawnSave
public float ChangeIn { get; set; }
}
/// <summary>Сериализуемый хедиф особи (фаза A6): деф (по имени), тяжесть и набранный иммунитет.</summary>
public sealed class HediffSave
{
/// <summary>Имя дефа хедифа (гон/болезнь).</summary>
public string DefName { get; set; } = "";
/// <summary>Текущая тяжесть.</summary>
public float Severity { get; set; }
/// <summary>Набранный иммунитет [0,1].</summary>
public float Immunity { get; set; }
}
/// <summary>Сериализуемая событийная мысль (фаза A8): деф (по имени) и остаток времени до угасания.</summary>
public sealed class ThoughtSave
{
/// <summary>Имя дефа мысли.</summary>
public string DefName { get; set; } = "";
/// <summary>Сколько игровых дней мысль ещё действует.</summary>
public float RemainingDays { get; set; }
}
/// <summary>
/// Сериализуемое состояние одного животного (фазы A1–A8): вид, позиция, геном (источник признаков и
/// пола), поколение, стадия/возраст, нужды, здоровье (кровь, HP частей, хедифы), настроение и
/// беременность. Спрайт пересобирается фабрикой из дефа. Чтобы популяция (эволюция/состояние)
/// переживала сейв/лоад, а не пересыпалась заново из сида.
/// </summary>
public sealed class AnimalSave
{
/// <summary>Имя дефа вида — по нему берётся индекс/стадии/текстуры/тело.</summary>
public string Species { get; set; } = "";
/// <summary>Позиция X в мировых координатах.</summary>
public float X { get; set; }
/// <summary>Позиция Y в мировых координатах.</summary>
public float Y { get; set; }
/// <summary>Номер поколения (для popstats/дрейфа).</summary>
public int Generation { get; set; }
/// <summary>Текущая стадия роста.</summary>
public int Stage { get; set; }
/// <summary>Накопленный возраст в игровых днях.</summary>
public float AgeDays { get; set; }
/// <summary>Геном особи: аллели по id генов (переменного состава; несёт и пол через GeneSex).</summary>
public Dictionary<string, Allele> Genome { get; set; } = new();
/// <summary>Значения нужд по индексам NeedSet.</summary>
public float[] Needs { get; set; } = [];
/// <summary>Накоплено дней истощения.</summary>
public float Starve { get; set; }
/// <summary>Уровень крови [0,1].</summary>
public float BloodLevel { get; set; } = 1f;
/// <summary>HP частей тела по порядку анатомии вида (пусто — у вида нет тела).</summary>
public float[] PartHp { get; set; } = [];
/// <summary>Активные хедифы (гон/болезни).</summary>
public List<HediffSave> Hediffs { get; set; } = [];
/// <summary>Текущее настроение [0,1].</summary>
public float Mood { get; set; }
/// <summary>Активно ли настроение (тир 4+).</summary>
public bool MoodActive { get; set; }
/// <summary>Активные событийные мысли.</summary>
public List<ThoughtSave> Thoughts { get; set; } = [];
/// <summary>Беременна ли самка.</summary>
public bool Pregnant { get; set; }
/// <summary>Геном отца (если беременна) — «слепок» на момент зачатия.</summary>
public Dictionary<string, Allele> FatherGenome { get; set; } = new();
/// <summary>Остаток срока вынашивания (игровых дней).</summary>
public float DueInDays { get; set; }
/// <summary>Размер помёта.</summary>
public int Litter { get; set; }
}
/// <summary>Сериализуемый труп (фаза A5): вид (для спрайта), позиция, таймер/стадия разложения, мясо.</summary>
public sealed class CorpseSave
{
/// <summary>Имя дефа вида — по нему берётся спрайт трупа.</summary>
public string Species { get; set; } = "";
/// <summary>Позиция X в мировых координатах.</summary>
public float X { get; set; }
/// <summary>Позиция Y в мировых координатах.</summary>
public float Y { get; set; }
/// <summary>Накоплено игровых дней разложения.</summary>
public float RotDays { get; set; }
/// <summary>Размер тела (для масштаба спрайта).</summary>
public float BodySize { get; set; }
/// <summary>Доступное мясо.</summary>
public float Meat { get; set; }
}
/// <summary>
/// Полное состояние мира для сохранения/загрузки: конфиг мира (имя/размер/сид/сглаживание),
/// прошедшее время симуляции и снимок всех жителей. Рельеф не сохраняется — он
@@ -99,6 +210,12 @@ public sealed class WorldSave
/// <summary>Снимок всех растений (геномы, позиции, возраст, стадия).</summary>
public List<PlantSave> Plants { get; set; } = [];
/// <summary>Снимок всех животных (геном, стадия/возраст, нужды, здоровье, настроение, беременность).</summary>
public List<AnimalSave> Animals { get; set; } = [];
/// <summary>Снимок трупов (вид, позиция, таймер/стадия разложения, мясо).</summary>
public List<CorpseSave> Corpses { get; set; } = [];
/// <summary>Конфиг мира для пересоздания сцены.</summary>
public WorldConfig ToConfig() =>
new()
+138
View File
@@ -0,0 +1,138 @@
using Microsoft.Xna.Framework.Graphics;
using MrGameEng.Genetics;
using MrGameEng.Graphics;
namespace LittleSim.Content;
/// <summary>
/// Готовая (resolved) таблица животных для сцены: по каждому <see cref="AnimalDef"/> — загруженный
/// регион атласа и набор генов вида (<see cref="GenomeTemplate"/>) для генерации особей. Строится
/// один раз при загрузке сцены (нужны атлас и устройство), как <see cref="PlantSet"/>. Системы и
/// фабрика индексируются в неё без словарей. Фаза A1 — один спрайт на вид; стадии/пол придут позже.
/// </summary>
public sealed class AnimalSet
{
/// <summary>Вид животного: его деф, спрайты по полу/стадии и набор генов для генерации особи.</summary>
public sealed class Species
{
/// <summary>Исходный деф животного.</summary>
public required AnimalDef Def { get; init; }
/// <summary>Спрайт взрослой самки (он же общий/fallback).</summary>
public required Texture2DRegion Female { get; init; }
/// <summary>Спрайт взрослого самца (fallback — самка).</summary>
public required Texture2DRegion Male { get; init; }
/// <summary>Спрайт детёныша/подростка (fallback — самка).</summary>
public required Texture2DRegion Baby { get; init; }
/// <summary>Спрайт трупа (fallback — самка).</summary>
public required Texture2DRegion Corpse { get; init; }
/// <summary>Стадии роста (из дефа или встроенный дефолт), по возрастанию возраста входа.</summary>
public required AnimalStageDef[] Stages { get; init; }
/// <summary>Анатомия вида (части тела) или null, если вид без тела.</summary>
public required BodyDef? Body { get; init; }
/// <summary>Набор генов вида: базовые значения для генерации особи.</summary>
public required GenomeTemplate Template { get; init; }
}
// Встроенный набор стадий по умолчанию (если вид не задал свой): как было до выноса в данные.
private static readonly AnimalStageDef[] DefaultStages =
[
new() { Name = "Baby", EnterAt = 0f, RelativeTo = "maturity", Scale = 0.45f, Texture = "baby" },
new() { Name = "Juvenile", EnterAt = 0.22f, RelativeTo = "maturity", Scale = 0.7f, Texture = "baby" },
new() { Name = "Adult", EnterAt = 1f, RelativeTo = "maturity", Scale = 1f, Texture = "adult", Adult = true },
new() { Name = "Senior", EnterAt = 0.8f, RelativeTo = "lifespan", Scale = 0.92f, Texture = "adult", Adult = true },
];
private readonly Species[] _species;
private readonly Dictionary<AnimalDef, int> _index = new();
/// <summary>Общий реестр генов (по id) для скрещивания и вычисления признаков животных.</summary>
public IReadOnlyDictionary<string, GeneDef> GeneRegistry { get; }
/// <summary>Строит таблицу из всех (не-abstract) <see cref="AnimalDef"/> мода.</summary>
public AnimalSet(GameContent content, ModAtlases atlases, GraphicsDevice device)
{
var genes = content
.Defs.All<GeneDef>()
.ToDictionary(g => g.DefName, g => g, StringComparer.Ordinal);
GeneRegistry = genes;
var defs = content.Defs.All<AnimalDef>().ToArray();
_species = new Species[defs.Length];
for (var i = 0; i < defs.Length; i++)
{
var def = defs[i];
var female = atlases.GetRegion(device, def.Texture);
_species[i] = new Species
{
Def = def,
Female = female,
Male = string.IsNullOrEmpty(def.MaleTexture)
? female
: atlases.GetRegion(device, def.MaleTexture),
Baby = string.IsNullOrEmpty(def.BabyTexture)
? female
: atlases.GetRegion(device, def.BabyTexture),
Corpse = string.IsNullOrEmpty(def.CorpseTexture)
? female
: atlases.GetRegion(device, def.CorpseTexture),
Stages = def.Stages.Length > 0 ? def.Stages : DefaultStages,
Body = content.Defs.TryGet<BodyDef>(def.Body, out var body) ? body : null,
Template = BuildTemplate(def.Genome, genes),
};
_index[def] = i;
}
}
/// <summary>Число видов в таблице.</summary>
public int Count => _species.Length;
/// <summary>Вид по индексу.</summary>
public Species this[int index] => _species[index];
/// <summary>Индекс вида по дефу.</summary>
public int IndexOf(AnimalDef def) => _index[def];
/// <summary>
/// Генерирует геном новой особи-основателя вида: аллели из шаблона + явно назначенный пол
/// (XX самка / XY самец, 50/50) — чтобы не возник невозможный YY (см. Defs/Genes/Animal.json GeneSex).
/// Скрещивание (A4) наследует пол само (мать XX отдаёт только X).
/// </summary>
public Genome GenerateGenome(int species, Random random)
{
var genome = _species[species].Template.Generate(random);
genome["GeneSex"] = random.NextSingle() < 0.5f ? new Allele(0f, 0f) : new Allele(0f, 1f);
return genome;
}
// Строит шаблон генома вида из ДАННЫХ (geneId→base из Defs/Animals/) generically: разброс берётся из
// самого гена. Состав открыт — модер добавляет ген строкой JSON, без правки кода. Дискретные гены
// (база/разброс не нужны — варианты из GeneDef) тоже поддержаны.
private static GenomeTemplate BuildTemplate(
IReadOnlyDictionary<string, float> genome,
IReadOnlyDictionary<string, GeneDef> genes
)
{
var entries = new List<GenomeTemplate.Entry>(genome.Count);
foreach (var (geneId, baseValue) in genome)
{
if (!genes.TryGetValue(geneId, out var gene))
{
throw new KeyNotFoundException(
$"Animal genome references unknown gene '{geneId}' — check Defs/Genes/."
);
}
var spread = gene.Kind == GeneKind.Discrete ? 0f : gene.Spread;
entries.Add(new GenomeTemplate.Entry(gene, baseValue, spread));
}
return new GenomeTemplate(entries);
}
}
+5
View File
@@ -63,6 +63,11 @@ public sealed class GameContent
defs.RegisterType<ProductDef>("Product");
defs.RegisterType<PlantDef>("Plant");
defs.RegisterType<PawnDef>("Pawn");
defs.RegisterType<AnimalDef>("Animal");
defs.RegisterType<HediffDef>("Hediff");
defs.RegisterType<NeedDef>("Need");
defs.RegisterType<ThoughtDef>("Thought");
defs.RegisterType<BodyDef>("Body");
defs.RegisterType<WorldPresetDef>("WorldPreset");
// Валидация имён при загрузке (фаза G5): гены и продукты обязаны иметь префикс типа.
defs.RegisterValidator("Gene", "defName", "^Gene", "gene defName must start with 'Gene'");
+194 -2
View File
@@ -190,8 +190,8 @@ public sealed class WorldPresetDef : Def
public int Order { get; init; }
}
/// <summary>Существо (Defs/Pawns/): текстура, размер и вид (житель/животное).</summary>
public sealed class PawnDef : Def
/// <summary>Существо (Defs/Pawns/): текстура, размер и вид (житель/животное). Не sealed — <see cref="AnimalDef"/> наследует.</summary>
public class PawnDef : Def
{
/// <summary>Вид существа: жители ("being") живут в мировой сцене, животные ("animal") — в террейне.</summary>
public const string KindBeing = "being";
@@ -208,3 +208,195 @@ public sealed class PawnDef : Def
/// <summary>Вид: <see cref="KindBeing"/> или <see cref="KindAnimal"/>.</summary>
public string Kind { get; init; } = "";
}
/// <summary>
/// Стадия роста животного как ДАННЫЕ (вложенный объект <see cref="AnimalDef.Stages"/>): возраст входа =
/// <see cref="EnterAt"/> × якорь (<see cref="RelativeTo"/> — созревание или продолжительность жизни),
/// масштаб тела и набор текстур ("baby"/"adult"). Стадии перечисляются по возрастанию возраста входа.
/// Модер задаёт любой набор стадий (личинка/куколка/имаго и т.п.) без правки кода.
/// </summary>
public sealed class AnimalStageDef
{
/// <summary>Имя стадии (для тултипов/отладки).</summary>
public string Name { get; init; } = "";
/// <summary>Множитель к якорю: возраст входа = EnterAt × (созревание | продолжительность жизни).</summary>
public float EnterAt { get; init; }
/// <summary>Якорь возраста входа: "maturity" (созревание) или "lifespan" (продолжительность жизни).</summary>
public string RelativeTo { get; init; } = "maturity";
/// <summary>Масштаб размера тела на этой стадии (× ген размера).</summary>
public float Scale { get; init; } = 1f;
/// <summary>Набор текстур: "baby" (детёныш) или "adult" (по полу — самец/самка).</summary>
public string Texture { get; init; } = "adult";
/// <summary>Взрослая стадия — способна размножаться.</summary>
public bool Adult { get; init; }
}
/// <summary>
/// Вид-животное (Defs/Animals/): подтип <see cref="PawnDef"/> (текстура/размер/вид) плюс геном
/// вида. Поведение, здоровье и стадии живут на компонентах сущности (фазы A2+), а не на типе дефа —
/// существующие 9 животных-<see cref="PawnDef"/> остаются валидными.
/// </summary>
public sealed class AnimalDef : PawnDef
{
/// <summary>Текстура взрослого самца (фаза A3); пусто — берётся <see cref="PawnDef.Texture"/> (самка/общая).</summary>
public string MaleTexture { get; init; } = "";
/// <summary>Текстура детёныша/подростка (фаза A3); пусто — берётся <see cref="PawnDef.Texture"/>.</summary>
public string BabyTexture { get; init; } = "";
/// <summary>Текстура трупа (фаза A5); пусто — берётся <see cref="PawnDef.Texture"/>.</summary>
public string CorpseTexture { get; init; } = "";
/// <summary>Имя <see cref="BodyDef"/> — анатомия вида (части тела/органы); пусто — без частей тела.</summary>
public string Body { get; init; } = "";
/// <summary>
/// Геном вида как ДАННЫЕ: ключ — id гена (<see cref="MrGameEng.Genetics.GeneDef"/>), значение —
/// центр аллелей особи. Состав открыт — модер добавляет/убирает ген одной строкой JSON, без правки
/// кода; разброс берётся из самого гена (<c>GeneDef.Spread</c>). Дискретные гены (напр. пол) сюда
/// не пишутся — назначаются генерацией.
/// </summary>
public Dictionary<string, float> Genome { get; init; } = new();
/// <summary>
/// Диета как ДАННЫЕ: что вид ест ("plant" — травоядное; "meat" — хищник/падальщик; оба — всеядное).
/// Поиск корма гейтится этим списком — модер задаёт рацион без правки кода.
/// </summary>
public string[] Diet { get; init; } = [];
/// <summary>
/// Стадии роста (данные); пусто — встроенный набор Baby→Juvenile→Adult→Senior по умолчанию.
/// </summary>
public AnimalStageDef[] Stages { get; init; } = [];
/// <summary>Плотность стартового спавна: голов на 1000 клеток суши (0 — вид не спавнится сам).</summary>
public float SpawnPer1000Cells { get; init; }
/// <summary>Базовая скорость передвижения (мировых единиц/сек при гене moveSpeed = 1).</summary>
public float BaseSpeed { get; init; } = 28f;
/// <summary>Базовый радиус восприятия в клетках (× ген vision) — поиск корма/воды/партнёра.</summary>
public float VisionCells { get; init; } = 14f;
/// <summary>Сила выедания: на сколько игровых дней роста убавляется растение за день кормёжки.</summary>
public float ForageBiteDays { get; init; } = 40f;
}
/// <summary>
/// Нужда (Defs/Needs/) как ДАННЫЕ: имя, тип (deplete — убывает, низкое = срочно; drive — растёт под
/// условием, высокое = срочно), действие-исполнитель (по id, см. реестр поведений), темпы и порог
/// интеллекта. Модер добавляет нужду виду/всем одной записью; если она ссылается на существующее
/// действие — без правки кода. Хранилище нужд особи — динамическое (см. <c>NeedSet</c>/<c>AnimalNeeds</c>).
/// </summary>
public sealed class NeedDef : Def
{
/// <summary>Тип: "deplete" (убывает, утоляется действием) или "drive" (растёт под условием).</summary>
public string Kind { get; init; } = "deplete";
/// <summary>Id действия-исполнителя, удовлетворяющего нужду (Eat/Drink/Sleep/Mate/…).</summary>
public string Action { get; init; } = "";
/// <summary>Темп убывания за игровой день (drive: темп спада, когда условие отсутствует).</summary>
public float DecayPerDay { get; init; } = 0.5f;
/// <summary>drive: темп роста за день, пока активно условие <see cref="RisesUnder"/>.</summary>
public float RisePerDay { get; init; } = 1f;
/// <summary>deplete: насколько за день восполняет нужду исполнение действия.</summary>
public float FeedPerDay { get; init; } = 4f;
/// <summary>drive: id хедифа, под которым нужда растёт (напр. "Rut"); null — растёт всегда.</summary>
public string? RisesUnder { get; init; }
/// <summary>deplete: на нуле копит счётчик голодной смерти (A3).</summary>
public bool Lethal { get; init; }
/// <summary>Порог размера мозга, с которого нужда активна (гейт интеллектом — фаза A7).</summary>
public float MinBrain { get; init; }
}
/// <summary>
/// Мысль настроения (Defs/Thoughts/) как ДАННЫЕ (фаза A8, модель thoughts RimWorld): событие
/// (роды/спаривание/утоление жажды/…) вешает на особь временный сдвиг настроения, затухающий за
/// <see cref="DurationDays"/>. Триггеры событий — код (реестр <c>AnimalThoughts</c>), а величины/метка —
/// данные: модер правит силу и длительность мысли одной записью JSON, без пересборки.
/// </summary>
public sealed class ThoughtDef : Def
{
/// <summary>Сдвиг настроения [-1,1] (положительный — радость, отрицательный — горе).</summary>
public float MoodOffset { get; init; }
/// <summary>Сколько игровых дней мысль живёт, прежде чем угаснуть.</summary>
public float DurationDays { get; init; } = 1f;
}
/// <summary>
/// Часть тела/орган (вложенный объект <see cref="BodyDef.Parts"/>) как ДАННЫЕ: иерархия (родитель),
/// вес попадания, максимум HP (масштабируется размером тела), флаг жизненной важности и вклады в
/// способности (capacity id → доля). Модер задаёт любую анатомию (8 ног, два сердца) без правки кода.
/// </summary>
public sealed class BodyPartDef
{
/// <summary>Имя части (уникально в теле); на него ссылается <see cref="Parent"/> детей.</summary>
public string Name { get; init; } = "";
/// <summary>Имя родительской части ("" — корневая, напр. торс).</summary>
public string Parent { get; init; } = "";
/// <summary>Вес попадания (доля тела) — для будущего выбора задетой части.</summary>
public float Coverage { get; init; } = 0.1f;
/// <summary>Базовый максимум HP (× размер тела при создании особи).</summary>
public float MaxHp { get; init; } = 10f;
/// <summary>Жизненно важная: уничтожение → смерть.</summary>
public bool Vital { get; init; }
/// <summary>Вклады в способности: capacity id (см. <c>AnimalCapacities</c>) → доля (сумма по телу ≈ 1).</summary>
public Dictionary<string, float> Capacities { get; init; } = new();
}
/// <summary>
/// Анатомия вида (Defs/Bodies/): дерево частей тела/органов. Используется системой здоровья (фаза
/// A5): из неё строятся инстансы частей особи, по их HP/крови считаются способности (capacities).
/// </summary>
public sealed class BodyDef : Def
{
/// <summary>Части тела/органы вида.</summary>
public BodyPartDef[] Parts { get; init; } = [];
}
/// <summary>
/// Состояние/хедиф организма (Defs/Hediffs/): гон, болезни, позже раны/возрастные эффекты. Фаза A6:
/// болезнь прогрессирует (<see cref="SeverityPerDay"/>), параллельно растёт иммунитет
/// (<see cref="ImmunityPerDay"/>) — гонка: иммунитет→1 раньше = выздоровление, severity→
/// <see cref="LethalSeverity"/> раньше = смерть. Снижает способности (<see cref="CapMods"/>) и даёт боль.
/// </summary>
public sealed class HediffDef : Def
{
/// <summary>Начальная тяжесть при навешивании (гон — 1; болезнь — малая, напр. 0.05).</summary>
public float InitialSeverity { get; init; } = 1f;
/// <summary>Прирост тяжести за игровой день (0 — статичный хедиф вроде гона).</summary>
public float SeverityPerDay { get; init; }
/// <summary>Прирост иммунитета за день (0 — иммунитета нет, не выздоравливает сам).</summary>
public float ImmunityPerDay { get; init; }
/// <summary>Тяжесть, при которой наступает смерть (0 — не летально).</summary>
public float LethalSeverity { get; init; }
/// <summary>Боль при тяжести 1 (масштабируется тяжестью) — вход в сознание/настроение.</summary>
public float Pain { get; init; }
/// <summary>Шанс подхватить фоном за день (0 — не фоновая болезнь).</summary>
public float AmbientPerDay { get; init; }
/// <summary>Модификаторы способностей: capacity id → множитель при тяжести 1 (лерп 1→factor по severity).</summary>
public Dictionary<string, float> CapMods { get; init; } = new();
}
+54
View File
@@ -0,0 +1,54 @@
namespace LittleSim.Content;
/// <summary>
/// Готовый реестр нужд из <see cref="NeedDef"/>: присваивает каждой нужде индекс (по нему системы
/// читают значения в <c>AnimalNeeds.Values</c> без словарей), знает тип (deplete/drive), начальное
/// значение и какое действие какую нужду утоляет. Делает набор нужд расширяемым данными: добавил
/// <see cref="NeedDef"/> — особи получают новую нужду, без правки кода (если действие уже есть).
/// </summary>
public sealed class NeedSet
{
private readonly NeedDef[] _defs;
private readonly Dictionary<string, int> _byAction = new(StringComparer.Ordinal);
/// <summary>Строит реестр из всех <see cref="NeedDef"/> мода (порядок = индексы нужд).</summary>
public NeedSet(GameContent content)
{
_defs = content.Defs.All<NeedDef>().ToArray();
for (var i = 0; i < _defs.Length; i++)
{
if (!string.IsNullOrEmpty(_defs[i].Action))
{
_byAction[_defs[i].Action] = i;
}
}
}
/// <summary>Число нужд.</summary>
public int Count => _defs.Length;
/// <summary>Деф нужды по индексу.</summary>
public NeedDef this[int index] => _defs[index];
/// <summary>Нужда-влечение (растёт под условием), а не убывающая.</summary>
public bool IsDrive(int index) => string.Equals(_defs[index].Kind, "drive", StringComparison.Ordinal);
/// <summary>Убывающая нужда (голод/жажда/сон) — низкое значение срочно.</summary>
public bool IsDeplete(int index) => !IsDrive(index);
/// <summary>Индекс нужды, которую утоляет действие, или -1.</summary>
public int NeedForAction(string actionId) =>
_byAction.TryGetValue(actionId, out var index) ? index : -1;
/// <summary>Новый массив значений нужд особи: deplete стартуют полными (1), drive — пустыми (0).</summary>
public float[] NewValues()
{
var values = new float[_defs.Length];
for (var i = 0; i < values.Length; i++)
{
values[i] = IsDrive(i) ? 0f : 1f;
}
return values;
}
}
+22
View File
@@ -0,0 +1,22 @@
namespace LittleSim.Content;
/// <summary>
/// Реестр мыслей настроения (<see cref="ThoughtDef"/>) с доступом по id (фаза A8): системы-источники
/// событий (роды/спаривание/жажда) берут нужную мысль по строковому id (см. <c>AnimalThoughts</c>) и
/// вешают её на особь. Метка/сила/длительность мысли — данные; модер правит их в thoughts.json. Зеркало
/// <see cref="NeedSet"/> по духу. Удалил модер мысль из дефов — <see cref="Get"/> вернёт null (no-op).
/// </summary>
public sealed class ThoughtSet
{
private readonly Dictionary<string, ThoughtDef> _byId;
/// <summary>Строит реестр из всех <see cref="ThoughtDef"/> мода.</summary>
public ThoughtSet(GameContent content) =>
_byId = content.Defs.All<ThoughtDef>().ToDictionary(t => t.DefName, StringComparer.Ordinal);
/// <summary>Число мыслей.</summary>
public int Count => _byId.Count;
/// <summary>Деф мысли по id или null, если такой нет (мод убрал).</summary>
public ThoughtDef? Get(string id) => _byId.GetValueOrDefault(id);
}
+1 -1
View File
@@ -16,7 +16,7 @@ public static class GameLayers
/// <summary>Слой-оверлей поверх мира: рамка выделения (рисуется после существ, без Y-сорта).</summary>
public static LayerId Selection { get; private set; }
/// <summary>Регистрирует слои при первом вызове.</summary>
/// <summary>Регистрирует слои при первом вызове (порядок = порядок отрисовки).</summary>
public static void EnsureRegistered(Renderer2D renderer)
{
if (_registered)
+726 -2
View File
@@ -65,6 +65,8 @@ public sealed class WorldScene : Scene
private readonly List<Action> _speedRefreshers = [];
private PlantSet _plants = null!;
private AnimalSet _animals = null!;
private NeedSet _needs = null!;
private float[] _cellFertility = [];
private bool[] _cellLand = [];
private bool[] _cellOccluderBase = []; // горы (статично из террейна)
@@ -109,6 +111,8 @@ public sealed class WorldScene : Scene
// Рельеф детерминирован сидом. Растения: новый мир — скаттер из сида; загрузка — из сейва.
_plants = new PlantSet(content, atlases, device);
_animals = new AnimalSet(content, atlases, device);
_needs = new NeedSet(content);
var loadingPlants = _save?.Plants is { Count: > 0 };
BuildTerrain(
content,
@@ -122,6 +126,17 @@ public sealed class WorldScene : Scene
RestorePlants(content);
}
// Животные: из сейва (сохранённая популяция — эволюция/состояние не теряются) либо
// детерминированный спавн из сида (новый мир или старый сейв без животных).
if (_save?.Animals is { Count: > 0 })
{
RestoreAnimals(content);
}
else
{
SpawnAnimals();
}
// Освещение: лайтмап (день/ночь × окклюзия от гор/крон + точечные) множится поверх мира,
// а Lighting.SampleAt даёт локальный свет системе роста (подлесок под кронами растёт хуже).
var lighting = this.UseLighting(
@@ -155,7 +170,16 @@ public sealed class WorldScene : Scene
atlases.GetRegion(device, "ui/overlays/selectionbracket"),
GameLayers.Selection
);
_inspect = new InspectPanel(Store, _plants, content, climate, _selection, brackets);
_inspect = new InspectPanel(
Store,
_plants,
_animals,
_needs,
content,
climate,
_selection,
brackets
);
desktop.Root = Ui.Screen(hudLabel, _inspect.Panel, speedBar, _pause.Root);
this.UseInspector(renderer);
@@ -203,6 +227,68 @@ public sealed class WorldScene : Scene
_config.Seed
)
);
// Животные (фаза A2): нужды падают, ИИ выбирает действие, исполнение ведёт к цели и утоляет
// нужду (еда выедает растения — контур ёмкости среды). Внешний вид тускнеет с острой нуждой.
var shore = ComputeShore();
var thoughts = new ThoughtSet(content);
UpdateSystems.Add(new AnimalNeedsSystem(Context.Clock, SecondsPerDay, _needs));
UpdateSystems.Add(
new AnimalRutSystem(_animals, climate, content.Defs.Get<HediffDef>("Rut"))
);
UpdateSystems.Add(
new AnimalHealthSystem(
Store,
_animals,
CellSize,
Context.Clock,
SecondsPerDay,
content.Defs.All<HediffDef>().Where(h => h.AmbientPerDay > 0f).ToArray(),
_config.Seed + 0x3EAD
)
);
// Настроение (A8) до выбора решения: стресс (низкое настроение) подавляет спаривание.
UpdateSystems.Add(new AnimalMoodSystem(Context.Clock, SecondsPerDay, _needs));
UpdateSystems.Add(
new AnimalDecisionSystem(
Store,
Context.Clock,
_animals,
_needs,
CellSize,
shore,
_config.Seed + 0x5EED
)
);
UpdateSystems.Add(
new AnimalActionSystem(
Store,
_plants,
_animals,
_needs,
thoughts,
Context.Clock,
SecondsPerDay,
_bounds
)
);
UpdateSystems.Add(new AnimalAppearanceSystem(_needs));
UpdateSystems.Add(new AnimalGrowthSystem(Context.Clock, SecondsPerDay, _animals, CellSize));
UpdateSystems.Add(new AnimalMortalitySystem(Store, _animals, CellSize));
UpdateSystems.Add(new CorpseSystem(Store, Context.Clock, SecondsPerDay, climate));
UpdateSystems.Add(
new AnimalPregnancySystem(
Store,
_animals,
_needs,
thoughts,
Context.Clock,
SecondsPerDay,
CellSize,
maxAnimals: 500,
_config.Seed + 0x4BED
)
);
UpdateSystems.Add(
new GodCameraSystem(camera, input, renderer, console, () => _pause.IsOpen)
);
@@ -422,11 +508,108 @@ public sealed class WorldScene : Scene
}
);
SaveAnimals(save);
new SaveStore().Write(save);
Log.Info($"World '{_config.Name}' saved ({save.Plants.Count} plants)");
Log.Info(
$"World '{_config.Name}' saved ({save.Plants.Count} plants, "
+ $"{save.Animals.Count} animals, {save.Corpses.Count} corpses)"
);
return _config.Name;
}
// Снимок популяции животных и трупов: всё нужное, чтобы эволюция/состояние пережили сейв/лоад
// (а не пересыпались из сида). Health/Mood/Pregnant берём через entity — запрос ограничен 5 типами.
private void SaveAnimals(WorldSave save)
{
Store
.Query<AnimalOrganism, AnimalGrowth, AnimalNeeds, Transform2D>()
.ForEachEntity(
(
ref AnimalOrganism org,
ref AnimalGrowth grow,
ref AnimalNeeds needs,
ref Transform2D transform,
Entity entity
) =>
{
var record = new AnimalSave
{
Species = _animals[org.Species].Def.DefName,
X = transform.Position.X,
Y = transform.Position.Y,
Generation = org.Generation,
Stage = grow.Stage,
AgeDays = grow.AgeDays,
Genome = org.Genome.ToDictionary(),
Needs = needs.Values,
Starve = needs.Starve,
};
if (entity.GetComponent<Health>().State is { } health)
{
record.BloodLevel = health.BloodLevel;
record.PartHp = health.Parts.Select(p => p.Hp).ToArray();
foreach (var hediff in health.Hediffs)
{
record.Hediffs.Add(
new HediffSave
{
DefName = hediff.Def.DefName,
Severity = hediff.Severity,
Immunity = hediff.Immunity,
}
);
}
}
var mood = entity.GetComponent<Mood>();
record.Mood = mood.Value;
record.MoodActive = mood.Active;
if (mood.Thoughts is not null)
{
foreach (var thought in mood.Thoughts)
{
record.Thoughts.Add(
new ThoughtSave
{
DefName = thought.Def.DefName,
RemainingDays = thought.RemainingDays,
}
);
}
}
if (entity.TryGetComponent<Pregnant>(out var preg))
{
record.Pregnant = true;
record.FatherGenome = preg.FatherGenome.ToDictionary();
record.DueInDays = preg.DueInDays;
record.Litter = preg.Litter;
}
save.Animals.Add(record);
}
);
Store
.Query<Corpse, Transform2D>()
.ForEachEntity(
(ref Corpse corpse, ref Transform2D transform, Entity _) =>
save.Corpses.Add(
new CorpseSave
{
Species = _animals[corpse.Species].Def.DefName,
X = transform.Position.X,
Y = transform.Position.Y,
RotDays = corpse.RotDays,
BodySize = corpse.Meat / AnimalFactory.MeatPerBodySize,
Meat = corpse.Meat,
}
)
);
}
private void RestorePlants(GameContent content)
{
var fallback = new Random(_config.Seed); // для старых/битых сейвов без генома
@@ -454,6 +637,218 @@ public sealed class WorldScene : Scene
}
}
// Восстанавливает популяцию животных и трупы из сейва: фабрика собирает особь из генома (геном
// несёт и пол), затем динамическое состояние (нужды/стадия/здоровье/настроение/беременность)
// накладывается поверх. Старый/битый сейв без генома — особь регенерируется из шаблона вида.
private void RestoreAnimals(GameContent content)
{
var fallback = new Random(_config.Seed);
foreach (var saved in _save!.Animals)
{
if (!content.Defs.TryGet<AnimalDef>(saved.Species, out var def))
{
continue; // вид пропал (мод убрали) — пропускаем особь
}
var index = _animals.IndexOf(def);
var genome = saved.Genome is { Count: > 0 }
? new Genome(saved.Genome)
: _animals.GenerateGenome(index, fallback);
var entity = AnimalFactory.Create(
Store,
_animals,
_needs,
index,
new Vector2(saved.X, saved.Y),
saved.AgeDays,
genome,
saved.Generation,
CellSize
);
RestoreAnimalState(entity, saved, content);
}
foreach (var saved in _save.Corpses)
{
if (!content.Defs.TryGet<AnimalDef>(saved.Species, out var def))
{
continue;
}
var corpse = AnimalFactory.CreateCorpse(
Store,
_animals,
_animals.IndexOf(def),
new Vector2(saved.X, saved.Y),
saved.BodySize,
CellSize
);
ref var data = ref corpse.GetComponent<Corpse>();
data.RotDays = saved.RotDays;
data.Stage = -1; // форсируем пере-тинт стадии в CorpseSystem на следующем тике
data.Meat = saved.Meat;
}
Log.Info(
$"Restored {_save.Animals.Count} animals, {_save.Corpses.Count} corpses from save"
);
}
// Накладывает на свежесозданную особь сохранённое динамическое состояние. Ref-компоненты правим до
// AddComponent(Pregnant) (структурное изменение инвалидирует ссылки); Health — managed, безопасен.
private void RestoreAnimalState(Entity entity, AnimalSave saved, GameContent content)
{
ref var needs = ref entity.GetComponent<AnimalNeeds>();
if (saved.Needs is { Length: > 0 } && saved.Needs.Length == _needs.Count)
{
needs.Values = saved.Needs; // состав совпал — иначе остаются свежие из фабрики
}
needs.Starve = saved.Starve;
entity.GetComponent<AnimalGrowth>().Stage = saved.Stage;
ref var mood = ref entity.GetComponent<Mood>();
mood.Value = saved.Mood;
mood.Active = saved.MoodActive;
mood.Thoughts.Clear();
foreach (var thought in saved.Thoughts)
{
if (content.Defs.TryGet<ThoughtDef>(thought.DefName, out var tdef))
{
mood.Thoughts.Add(
new ThoughtInstance { Def = tdef, RemainingDays = thought.RemainingDays }
);
}
}
if (entity.GetComponent<Health>().State is { } health)
{
health.BloodLevel = saved.BloodLevel;
for (var i = 0; i < health.Parts.Length && i < saved.PartHp.Length; i++)
{
health.Parts[i].Hp = saved.PartHp[i];
}
foreach (var hediff in saved.Hediffs)
{
if (content.Defs.TryGet<HediffDef>(hediff.DefName, out var hdef))
{
health.AddHediff(hdef, hediff.Severity, hediff.Immunity);
}
}
health.RecomputeCapacities();
}
if (saved.Pregnant && saved.FatherGenome is { Count: > 0 })
{
entity.AddComponent(
new Pregnant
{
FatherGenome = new Genome(saved.FatherGenome),
DueInDays = saved.DueInDays,
Litter = saved.Litter,
}
);
}
}
// Детерминированный начальный спавн животных по данным: каждый вид с SpawnPer1000Cells > 0
// рассыпается по клеткам суши с плотностью из дефа; геном и возраст особи — из шаблона вида.
private void SpawnAnimals()
{
var landCells = 0;
for (var i = 0; i < _cellLand.Length; i++)
{
if (_cellLand[i])
{
landCells++;
}
}
if (_animals.Count == 0 || landCells == 0)
{
return;
}
var random = new Random(_config.Seed + 0x5EED);
var spawned = 0;
for (var s = 0; s < _animals.Count; s++)
{
var def = _animals[s].Def;
if (def.SpawnPer1000Cells <= 0f)
{
continue; // вид не спавнится сам (напр. появляется только в сейве/событием)
}
var count = Math.Max(1, (int)MathF.Round(landCells / 1000f * def.SpawnPer1000Cells));
var maxAge =
(def.Genome.TryGetValue("GeneLifespan", out var lifespan) ? lifespan : 160f)
* 0.85f;
var placed = 0;
var attempts = 0;
while (placed < count && attempts < count * 50)
{
attempts++;
var cell = random.Next(_cellLand.Length);
if (!_cellLand[cell])
{
continue;
}
var cx = cell % _config.Width;
var cy = cell / _config.Width;
var px = (cx + 0.2f + random.NextSingle() * 0.6f) * CellSize;
var py = (cy + 0.2f + random.NextSingle() * 0.6f) * CellSize;
AnimalFactory.Create(
Store,
_animals,
_needs,
s,
new Vector2(px, py),
ageDays: random.NextSingle() * maxAge,
_animals.GenerateGenome(s, random),
generation: 0,
CellSize
);
placed++;
}
spawned += placed;
}
Log.Info($"Spawned {spawned} animals ({_animals.Count} species)");
}
// Кромка воды: центры клеток суши, граничащих с водой — куда звери ходят пить (вода непроходима).
private Vector2[] ComputeShore()
{
var shore = new List<Vector2>();
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 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));
}
}
}
return shore.ToArray();
}
// Текущая сетка окклюдеров для лайтмапа: статичные горы + клетки со зрелыми деревьями.
private bool[] BuildOccluders()
{
@@ -552,6 +947,36 @@ public sealed class WorldScene : Scene
"plant <species> [seed] — sample a species genome and show its gene-driven traits/products",
(c, args) => RunPlantDemo(c, content, args)
);
console.Register(
"animal",
"animal <species> [seed] — sample an animal genome and show its gene-driven traits",
(c, args) => RunAnimalDemo(c, content, args)
);
console.Register(
"popstats",
"popstats [species] — live population trait means and generation span (selection drift)",
(c, args) => RunPopStats(c, args)
);
console.Register(
"body",
"body <species> — dump a species body parts and a healthy individual's capacities",
(c, args) => RunBodyDemo(c, content, args)
);
console.Register(
"infect",
"infect [hediff] — give every animal a disease (default Fever) to test the health system",
(c, args) => RunInfect(c, content, args)
);
console.Register(
"intel",
"intel <species> [consciousness] — effective intelligence (brain x consciousness) and which needs it gates",
(c, args) => RunIntel(c, content, args)
);
console.Register(
"mood",
"mood [species] — live population mood (avg/min/max) and content/stressed counts",
(c, args) => RunMood(c, args)
);
console.Register(
"menu",
"menu — return to the main menu",
@@ -659,6 +1084,305 @@ public sealed class WorldScene : Scene
}
}
// Демонстрация животного (фаза A1): по виду берём его набор генов, генерируем особь и печатаем
// признаки, вычисленные формулами генов (тело/мозг/метаболизм/чувства/мех).
private void RunAnimalDemo(DevConsole console, GameContent content, string[] args)
{
var species = content.Defs.NamesOf("Animal");
if (args.Length == 0)
{
console.WriteLine("usage: animal <species> [seed] e.g. animal Deer");
console.WriteLine($"species: {string.Join(", ", species)}");
return;
}
if (!content.Defs.TryGet<AnimalDef>(args[0], out var def))
{
console.WriteLine($"no animal '{args[0]}'; species: {string.Join(", ", species)}");
return;
}
var index = _animals.IndexOf(def);
var seed = args.Length > 1 ? int.Parse(args[1]) : Random.Shared.Next();
var genome = _animals.GenerateGenome(index, new Random(seed));
var traits = AnimalPhenotype.FromTraits(Phenotype.Compute(genome, _animals.GeneRegistry));
var sex = AnimalFactory.IsMaleGenome(genome) ? "male" : "female";
console.WriteLine($"{def.DefName} ({sex}, seed {seed}):");
console.WriteLine(
$" body {traits.BodySize:0.##} cells, maturity {traits.MaturityAge:0} d, "
+ $"lifespan {traits.Lifespan:0} d, brain {traits.BrainSize:0.##}, metabolism {traits.Metabolism:0.##}"
);
console.WriteLine(
$" move {traits.MoveSpeed:0.##}, vision {traits.Vision:0.##}, blood {traits.BloodVolume:0.##}, "
+ $"insulation {traits.Insulation:0.##}, furHue {traits.FurHue:0.##}"
);
}
// Наблюдаемость отбора (фаза A2): средние ключевых признаков живой популяции и размах поколений —
// видно дрейф генов под отбором. Без аргумента — все виды; с аргументом — один вид.
private void RunPopStats(DevConsole console, string[] args)
{
if (_animals.Count == 0)
{
console.WriteLine("no animal species");
return;
}
var count = new int[_animals.Count];
var body = new double[_animals.Count];
var brain = new double[_animals.Count];
var life = new double[_animals.Count];
var move = new double[_animals.Count];
var maxGen = new int[_animals.Count];
Store
.Query<AnimalOrganism>()
.ForEachEntity(
(ref AnimalOrganism o, Entity _) =>
{
var s = o.Species;
count[s]++;
body[s] += o.Traits.BodySize;
brain[s] += o.Traits.BrainSize;
life[s] += o.Traits.Lifespan;
move[s] += o.Traits.MoveSpeed;
if (o.Generation > maxGen[s])
{
maxGen[s] = o.Generation;
}
}
);
var filter = args.Length > 0 ? args[0] : null;
var any = false;
for (var s = 0; s < _animals.Count; s++)
{
var name = _animals[s].Def.DefName;
if (
count[s] == 0
|| (
filter is not null
&& !string.Equals(name, filter, StringComparison.OrdinalIgnoreCase)
)
)
{
continue;
}
any = true;
var c = count[s];
console.WriteLine(
$"{name}: n={c}, gen 0..{maxGen[s]}, body {body[s] / c:0.##}, "
+ $"brain {brain[s] / c:0.##}, lifespan {life[s] / c:0} d, move {move[s] / c:0.##}"
);
}
if (!any)
{
console.WriteLine(filter is null ? "no animals alive" : $"no '{filter}' alive");
}
}
// Наблюдаемость здоровья (фаза A5): дерево частей тела вида и способности здоровой особи.
private void RunBodyDemo(DevConsole console, GameContent content, string[] args)
{
var species = content.Defs.NamesOf("Animal");
if (args.Length == 0)
{
console.WriteLine("usage: body <species> e.g. body Deer");
console.WriteLine($"species: {string.Join(", ", species)}");
return;
}
if (!content.Defs.TryGet<AnimalDef>(args[0], out var def))
{
console.WriteLine($"no animal '{args[0]}'; species: {string.Join(", ", species)}");
return;
}
if (string.IsNullOrEmpty(def.Body) || !content.Defs.TryGet<BodyDef>(def.Body, out var body))
{
console.WriteLine($"{def.DefName}: no body");
return;
}
console.WriteLine($"{def.DefName} body '{body.DefName}': {body.Parts.Length} parts");
foreach (var part in body.Parts)
{
var caps =
part.Capacities.Count > 0
? " ["
+ string.Join(
", ",
part.Capacities.Select(kv => $"{kv.Key} {kv.Value:0.##}")
)
+ "]"
: "";
console.WriteLine(
$" {part.Name}{(part.Vital ? " *vital" : "")} hp{part.MaxHp:0}{caps}"
);
}
var state = HealthState.Create(body, 1f);
console.WriteLine(
"capacities (healthy): "
+ string.Join(
", ",
state.Capacities.OrderBy(k => k.Key).Select(kv => $"{kv.Key} {kv.Value:0.##}")
)
);
}
// Тест системы здоровья (фаза A6): навешивает болезнь всем живым животным.
private void RunInfect(DevConsole console, GameContent content, string[] args)
{
var name = args.Length > 0 ? args[0] : "Fever";
if (!content.Defs.TryGet<HediffDef>(name, out var def))
{
console.WriteLine($"no hediff '{name}'");
return;
}
var count = 0;
Store
.Query<Health>()
.ForEachEntity(
(ref Health h, Entity _) =>
{
h.State?.Add(def);
count++;
}
);
console.WriteLine($"infected {count} animals with {def.DefName}");
}
// Наблюдаемость интеллекта (фаза A7): мозг особи (ген) × сознание = эффективный интеллект, который
// гейтит набор нужд/действий. Показывает активные/отключённые нужды при заданном уровне сознания
// (по умолчанию 1 — здоров; меньше — имитация повреждения мозга/болезни/боли).
private void RunIntel(DevConsole console, GameContent content, string[] args)
{
var species = content.Defs.NamesOf("Animal");
if (args.Length == 0)
{
console.WriteLine(
"usage: intel <species> [consciousness 0..1] [seed] e.g. intel Deer 0.6"
);
console.WriteLine($"species: {string.Join(", ", species)}");
return;
}
if (!content.Defs.TryGet<AnimalDef>(args[0], out var def))
{
console.WriteLine($"no animal '{args[0]}'; species: {string.Join(", ", species)}");
return;
}
var consciousness =
args.Length > 1
? Math.Clamp(
float.Parse(args[1], System.Globalization.CultureInfo.InvariantCulture),
0f,
1f
)
: 1f;
var seed = args.Length > 2 ? int.Parse(args[2]) : Random.Shared.Next();
var index = _animals.IndexOf(def);
var genome = _animals.GenerateGenome(index, new Random(seed));
var traits = AnimalPhenotype.FromTraits(Phenotype.Compute(genome, _animals.GeneRegistry));
var intelligence = traits.BrainSize * consciousness;
console.WriteLine(
$"{def.DefName} (seed {seed}): brain {traits.BrainSize:0.##} x consciousness {consciousness:0.##} "
+ $"= intelligence {intelligence:0.###}"
);
var active = new List<string>();
var gated = new List<string>();
for (var k = 0; k < _needs.Count; k++)
{
var need = _needs[k];
(need.MinBrain > intelligence ? gated : active).Add(
$"{need.DefName}({need.MinBrain:0.##})"
);
}
console.WriteLine($" active: {(active.Count > 0 ? string.Join(", ", active) : "")}");
console.WriteLine($" gated: {(gated.Count > 0 ? string.Join(", ", gated) : "")}");
}
// Наблюдаемость настроения (фаза A8): среднее/мин/макс настроения живой популяции по виду и счётчики
// довольных (>0.7) / угнетённых (<0.35). Считает только особей с активным настроением (тир 4+).
private void RunMood(DevConsole console, string[] args)
{
if (_animals.Count == 0)
{
console.WriteLine("no animal species");
return;
}
var count = new int[_animals.Count];
var sum = new double[_animals.Count];
var min = new float[_animals.Count];
var max = new float[_animals.Count];
var content_ = new int[_animals.Count];
var stressed = new int[_animals.Count];
Array.Fill(min, 1f);
Store
.Query<AnimalOrganism, Mood>()
.ForEachEntity(
(ref AnimalOrganism o, ref Mood m, Entity _) =>
{
if (!m.Active)
{
return; // тир ниже зверя — настроения нет
}
var s = o.Species;
count[s]++;
sum[s] += m.Value;
min[s] = MathF.Min(min[s], m.Value);
max[s] = MathF.Max(max[s], m.Value);
if (m.Value > 0.7f)
{
content_[s]++;
}
else if (m.Value < 0.35f)
{
stressed[s]++;
}
}
);
var filter = args.Length > 0 ? args[0] : null;
var any = false;
for (var s = 0; s < _animals.Count; s++)
{
var name = _animals[s].Def.DefName;
if (
count[s] == 0
|| (
filter is not null
&& !string.Equals(name, filter, StringComparison.OrdinalIgnoreCase)
)
)
{
continue;
}
any = true;
console.WriteLine(
$"{name}: n={count[s]}, mood {sum[s] / count[s]:0.##} (min {min[s]:0.##}, max {max[s]:0.##}), "
+ $"content {content_[s]}, stressed {stressed[s]}"
);
}
if (!any)
{
console.WriteLine(filter is null ? "no animals with mood" : $"no '{filter}' with mood");
}
}
private static string ProductLabel(GameContent content, string productDefName) =>
content.Defs.TryGet<ProductDef>(productDefName, out var product)
? content.Languages.Get(product.Label)
+187
View File
@@ -0,0 +1,187 @@
using Friflo.Engine.ECS;
using LittleSim.Content;
using LittleSim.Scenes;
using Microsoft.Xna.Framework;
using MrGameEng.Genetics;
using MrGameEng.Graphics;
namespace LittleSim.Sim;
/// <summary>
/// Единая точка создания сущности-животного: спрайт вида, признаки из генома формулами генов, тинт по
/// оттенку меха и компонент-организм. Переиспользуется начальным спавном, размножением и загрузкой —
/// чтобы все животные собирались одинаково. Зеркало <see cref="PlantFactory"/>.
/// </summary>
public static class AnimalFactory
{
// Якоря оттенка меха: тёплый рыжевато-песочный → холодный серо-голубой (по гену furColor).
private static readonly Color FurWarm = new(255, 226, 188);
private static readonly Color FurCool = new(206, 222, 235);
/// <summary>Создаёт животное вида <paramref name="species"/> заданного возраста, генома и поколения.</summary>
public static Entity Create(
EntityStore store,
AnimalSet animals,
NeedSet needs,
int species,
Vector2 position,
float ageDays,
Genome genome,
int generation,
int cellSize
)
{
var sp = animals[species];
var traits = AnimalPhenotype.FromTraits(Phenotype.Compute(genome, animals.GeneRegistry));
var isMale = IsMaleGenome(genome);
var stage = StageAt(ageDays, traits.MaturityAge, traits.Lifespan, sp.Stages);
var region = RegionFor(sp, stage, isMale);
var sprite = new Sprite(region, GameLayers.Beings);
sprite.CenterOrigin();
sprite.Color = FurTint(traits);
return store.CreateEntity(
new Transform2D(
position,
scale: new Vector2(Scale(sp, traits, stage, region, cellSize))
),
sprite,
new AnimalOrganism
{
Genome = genome,
Traits = traits,
Species = species,
IsMale = isMale,
Generation = generation,
},
new AnimalGrowth { Stage = stage, AgeDays = ageDays },
// Нужды — динамический массив по NeedSet (deplete полны, drive пусты); «мозг» решает сразу.
new AnimalNeeds { Values = needs.NewValues() },
new AnimalBrain
{
Action = AnimalActions.Wander,
TargetPlant = -1,
TargetMate = -1,
},
new Health { State = HealthState.Create(sp.Body, traits.BodySize) },
// Настроение (фаза A8): нейтральное на старте, активность по тиру мозга считает MoodSystem.
new Mood
{
Value = 0.5f,
Active = false,
Thoughts = [],
}
);
}
/// <summary>Сколько мяса даёт труп на единицу размера тела (для будущей разделки/падальщиков).</summary>
public const float MeatPerBodySize = 25f;
// Тинт свежего трупа (стадии гниения/скелета задаёт CorpseSystem).
private static readonly Color CorpseFreshTint = new(176, 148, 128);
/// <summary>Создаёт сущность-труп после смерти животного: спрайт-падаль, запас мяса (∝ размер тела).</summary>
public static Entity CreateCorpse(
EntityStore store,
AnimalSet animals,
int species,
Vector2 position,
float bodySize,
int cellSize
)
{
var sp = animals[species];
var region = sp.Corpse;
var sprite = new Sprite(region, GameLayers.Beings);
sprite.CenterOrigin();
sprite.Color = CorpseFreshTint;
var sizeCells = bodySize > 0f ? bodySize : sp.Def.SizeCells;
return store.CreateEntity(
new Transform2D(position, scale: new Vector2(cellSize * sizeCells / region.Width)),
sprite,
new Corpse
{
Species = species,
RotDays = 0f,
Stage = 0,
Meat = bodySize * MeatPerBodySize,
}
);
}
/// <summary>
/// Эффективный интеллект особи (фаза A7): размер мозга (ген) × сознание (capacity из здоровья).
/// Повреждение мозга/болезнь/боль/кровопотеря снижают Consciousness → динамически роняют интеллект,
/// что гейтит набор доступных нужд/действий (см. <see cref="AnimalContext"/>). Вид без частей тела
/// (сознание не вычисляется) считается полностью сознательным — чтобы не отключить ему всё.
/// </summary>
public static float Intelligence(in AnimalPhenotype traits, HealthState? health)
{
var consciousness = health is { Parts.Length: > 0 }
? health.Capacity(AnimalCapacities.Consciousness)
: 1f;
return traits.BrainSize * consciousness;
}
/// <summary>Пол из локуса GeneSex: наличие аллеля Y (значение ≈1) → самец; иначе самка (XX).</summary>
public static bool IsMaleGenome(Genome genome) =>
genome.Has("GeneSex") && (genome["GeneSex"].A > 0.5f || genome["GeneSex"].B > 0.5f);
/// <summary>Индекс стадии по возрасту: последняя стадия, чей возраст входа (EnterAt × якорь) ≤ возраст.</summary>
public static int StageAt(
float ageDays,
float maturity,
float lifespan,
AnimalStageDef[] stages
)
{
var stage = 0;
for (var i = 1; i < stages.Length; i++)
{
var anchor = string.Equals(stages[i].RelativeTo, "lifespan", StringComparison.Ordinal)
? lifespan
: maturity;
if (ageDays >= stages[i].EnterAt * anchor)
{
stage = i;
}
}
return stage;
}
/// <summary>Спрайт стадии: набор "baby" — текстура детёныша; иначе по полу (самец/самка).</summary>
public static Texture2DRegion RegionFor(AnimalSet.Species sp, int stage, bool isMale) =>
string.Equals(sp.Stages[stage].Texture, "baby", StringComparison.Ordinal) ? sp.Baby
: isMale ? sp.Male
: sp.Female;
/// <summary>Взрослая ли стадия (способна размножаться) — флаг из данных стадии.</summary>
public static bool IsAdult(AnimalSet.Species sp, int stage) => sp.Stages[stage].Adult;
/// <summary>Масштаб Transform2D для спрайта стадии (размер тела × множитель стадии / ширина региона).</summary>
public static float Scale(
AnimalSet.Species sp,
in AnimalPhenotype traits,
int stage,
Texture2DRegion region,
int cellSize
)
{
var sizeCells =
(traits.BodySize > 0f ? traits.BodySize : sp.Def.SizeCells) * sp.Stages[stage].Scale;
return cellSize * sizeCells / region.Width;
}
/// <summary>
/// Цвет животного из генов: лёгкий тёплый/холодный сдвиг меха (ген furHue) поверх текстуры —
/// субтильный, чтобы не перекрашивать уже цветной спрайт зверя (как leafHue у растений).
/// Публичен, чтобы <see cref="AnimalAppearanceSystem"/> домножал его на яркость по нуждам.
/// </summary>
public static Color FurTint(in AnimalPhenotype traits)
{
var fur = Color.Lerp(FurWarm, FurCool, Math.Clamp(traits.FurHue, 0f, 1f));
return Color.Lerp(Color.White, fur, 0.5f);
}
}
+417
View File
@@ -0,0 +1,417 @@
using System.Collections.Generic;
using Friflo.Engine.ECS;
using LittleSim.Content;
using MrGameEng.Genetics;
namespace LittleSim.Sim;
/// <summary>
/// Признаки (фенотип) животного, вычисленные из генома формулами генов (см. <c>Defs/Genes/</c>):
/// готовые значения, которые читают системы, не касаясь генов напрямую. Зеркало
/// <see cref="PlantPhenotype"/>. Имена полей соответствуют именам признаков в эффектах генов.
/// </summary>
public struct AnimalPhenotype
{
/// <summary>Размер тела в клетках (масштаб спрайта; позже — HP/расход энергии).</summary>
public float BodySize;
/// <summary>Скорость обмена веществ — множитель расхода нужд.</summary>
public float Metabolism;
/// <summary>Скорость передвижения (множитель).</summary>
public float MoveSpeed;
/// <summary>Объём крови (множитель) — масштаб скорости кровопотери.</summary>
public float BloodVolume;
/// <summary>Дальность зрения (множитель радиуса поиска).</summary>
public float Vision;
/// <summary>Размер мозга (0..1) — основа интеллекта.</summary>
public float BrainSize;
/// <summary>Теплоизоляция мехом (0..1).</summary>
public float Insulation;
/// <summary>Оттенок меха (0..1) — сдвиг тинта спрайта.</summary>
public float FurHue;
/// <summary>Возраст созревания (игровых дней) — порог стадии Adult.</summary>
public float MaturityAge;
/// <summary>Продолжительность жизни (игровых дней).</summary>
public float Lifespan;
/// <summary>Сезон гона (0 весна … 3 зима).</summary>
public int BreedingSeason;
/// <summary>Срок вынашивания (игровых дней).</summary>
public float GestationDays;
/// <summary>Размер помёта.</summary>
public float LitterSize;
/// <summary>Собирает фенотип из карты признаков, посчитанной <see cref="Phenotype.Compute"/>.</summary>
public static AnimalPhenotype FromTraits(IReadOnlyDictionary<string, float> traits)
{
float T(string name) => traits.GetValueOrDefault(name);
return new AnimalPhenotype
{
BodySize = T("bodySize"),
Metabolism = T("metabolism"),
MoveSpeed = T("moveSpeed"),
BloodVolume = T("bloodVolume"),
Vision = T("vision"),
BrainSize = T("brainSize"),
Insulation = T("insulation"),
FurHue = T("furHue"),
MaturityAge = T("maturityAge"),
Lifespan = T("lifespan"),
BreedingSeason = (int)MathF.Round(Math.Clamp(T("breedingSeason"), 0f, 3f)),
GestationDays = T("gestationDays"),
LitterSize = T("litterSize"),
};
}
}
/// <summary>
/// Компонент-организм животного: управляемый <see cref="Genome"/> (для размножения, сейва и
/// пересчёта), выраженный фенотип <see cref="Traits"/>, индекс вида в <see cref="Content.AnimalSet"/>
/// и номер поколения (для <c>popstats</c> и видимого дрейфа). Геном — ссылочный объект, поэтому
/// компонент несёт ссылку, а не копию (как у растений). Зеркало <see cref="PlantOrganism"/>.
/// </summary>
public struct AnimalOrganism : IComponent
{
/// <summary>Геном особи (аллели по генам); источник истины для признаков и размножения.</summary>
public Genome Genome;
/// <summary>Выраженные признаки — то, что читают системы.</summary>
public AnimalPhenotype Traits;
/// <summary>Индекс вида в <see cref="Content.AnimalSet"/>.</summary>
public int Species;
/// <summary>Пол: самец (XY) или самка (XX) — выведен из локуса GeneSex при создании.</summary>
public bool IsMale;
/// <summary>Номер поколения: max(родителей) + 1; у основателей 0.</summary>
public int Generation;
}
/// <summary>
/// Состояние роста животного (фаза A3): текущая стадия и накопленный возраст в игровых днях. Плоские
/// данные — переход между стадиями (Baby→Juvenile→Adult→Senior), смену спрайта и размера выполняет
/// <see cref="AnimalGrowthSystem"/>, читая пороги из генов (возраст созревания, продолжительность жизни).
/// Зеркало <see cref="PlantGrowth"/>.
/// </summary>
public struct AnimalGrowth : IComponent
{
/// <summary>Текущая стадия: 0 Baby, 1 Juvenile, 2 Adult, 3 Senior.</summary>
public int Stage;
/// <summary>Накопленный возраст в игровых днях.</summary>
public float AgeDays;
}
/// <summary>Инстанс части тела особи: ссылка на деф части и текущий/максимальный HP (масштаб по размеру).</summary>
public struct PartInstance
{
/// <summary>Деф части тела (анатомия вида).</summary>
public Content.BodyPartDef Def;
/// <summary>Текущий HP.</summary>
public float Hp;
/// <summary>Максимальный HP (база × размер тела).</summary>
public float MaxHp;
/// <summary>Доля сохранности [0,1].</summary>
public readonly float Fraction => MaxHp > 0f ? Math.Clamp(Hp / MaxHp, 0f, 1f) : 0f;
}
/// <summary>Инстанс хедифа на особи: деф + текущая тяжесть и набранный иммунитет (гонка, фаза A6).</summary>
public struct Hediff
{
/// <summary>Деф хедифа (гон/болезнь/…).</summary>
public HediffDef Def;
/// <summary>Текущая тяжесть.</summary>
public float Severity;
/// <summary>Набранный иммунитет [0,1]; при 1 — выздоровление.</summary>
public float Immunity;
}
/// <summary>
/// Состояние здоровья особи (фазы A4–A6): активные хедифы (инстансы) + части тела, кровь и вычисленные
/// способности (capacities). Managed-объект, как <see cref="Genome"/> (Friflo допускает ссылку в
/// компоненте). Болезни (A6) — первый источник урона: снижают способности и могут убить. Растёт по
/// фазам — контейнер один.
/// </summary>
public sealed class HealthState
{
private readonly List<Hediff> _hediffs = [];
/// <summary>Активные хедифы (гон, болезни).</summary>
public IReadOnlyList<Hediff> Hediffs => _hediffs;
/// <summary>Части тела особи (из анатомии вида); пусто — у вида нет тела.</summary>
public PartInstance[] Parts { get; private set; } = [];
/// <summary>Уровень крови [0,1]; ниже порога — потеря сознания и смерть (когда появятся раны).</summary>
public float BloodLevel { get; set; } = 1f;
/// <summary>Боль [0,1] — снижает сознание (когда появятся раны).</summary>
public float Pain { get; set; }
/// <summary>Погибла ли особь по здоровью (vital-часть уничтожена / кровь на нуле).</summary>
public bool Dead { get; set; }
/// <summary>Вычисленные способности (capacity id → [0,1]); у здоровой особи все = 1.</summary>
public Dictionary<string, float> Capacities { get; private set; } = new();
/// <summary>Способность по id (1, если не вычислена — напр. у вида без тела), для множителей действий.</summary>
public float Capacity(string id) => Capacities.TryGetValue(id, out var v) ? v : 1f;
/// <summary>
/// Пересчитывает боль (из хедифов) и способности: базовые из частей/крови/боли, затем модификаторы
/// способностей от хедифов (лерп 1→factor по тяжести). Вызывать после изменения частей/крови/хедифов.
/// </summary>
public void RecomputeCapacities()
{
var pain = 0f;
foreach (var h in _hediffs)
{
pain += h.Def.Pain * h.Severity;
}
Pain = Math.Clamp(pain, 0f, 1f);
var caps = CapacityCalc.Compute(Parts, BloodLevel, Pain);
foreach (var h in _hediffs)
{
if (h.Def.CapMods.Count == 0)
{
continue;
}
var t = Math.Clamp(h.Severity, 0f, 1f);
foreach (var (capacity, factor) in h.Def.CapMods)
{
caps[capacity] = caps.GetValueOrDefault(capacity, 1f) * (1f + (factor - 1f) * t);
}
}
Capacities = caps;
}
/// <summary>
/// Прогрессирует болезни (тяжесть и иммунитет растут со временем); снимает выздоровевшие
/// (иммунитет ≥ 1); возвращает true, если какая-то болезнь достигла летальной тяжести.
/// </summary>
public bool AdvanceHediffs(float days)
{
var lethal = false;
for (var i = _hediffs.Count - 1; i >= 0; i--)
{
var h = _hediffs[i];
if (h.Def.SeverityPerDay <= 0f && h.Def.ImmunityPerDay <= 0f)
{
continue; // статичный хедиф (гон) не прогрессирует
}
h.Severity += h.Def.SeverityPerDay * days;
h.Immunity += h.Def.ImmunityPerDay * days;
if (h.Immunity >= 1f)
{
_hediffs.RemoveAt(i); // иммунитет победил — выздоровление
continue;
}
if (h.Def.LethalSeverity > 0f && h.Severity >= h.Def.LethalSeverity)
{
lethal = true;
}
_hediffs[i] = h;
}
return lethal;
}
/// <summary>Строит здоровье особи: части из анатомии вида (HP × размер тела) + начальные способности.</summary>
public static HealthState Create(Content.BodyDef? body, float bodySize)
{
var state = new HealthState();
if (body is { Parts.Length: > 0 })
{
var scale = MathF.Max(0.2f, bodySize);
state.Parts = new PartInstance[body.Parts.Length];
for (var i = 0; i < body.Parts.Length; i++)
{
var hp = body.Parts[i].MaxHp * scale;
state.Parts[i] = new PartInstance
{
Def = body.Parts[i],
Hp = hp,
MaxHp = hp,
};
}
}
state.RecomputeCapacities();
return state;
}
/// <summary>Несёт ли особь хедиф с данным именем дефа.</summary>
public bool Has(string defName)
{
foreach (var h in _hediffs)
{
if (h.Def.DefName == defName)
{
return true;
}
}
return false;
}
/// <summary>Навешивает хедиф (если ещё не навешан) с начальной тяжестью из дефа.</summary>
public void Add(HediffDef def)
{
if (!Has(def.DefName))
{
_hediffs.Add(new Hediff { Def = def, Severity = def.InitialSeverity });
}
}
/// <summary>Восстанавливает хедиф с заданной тяжестью и иммунитетом (для загрузки сейва).</summary>
public void AddHediff(HediffDef def, float severity, float immunity)
{
if (!Has(def.DefName))
{
_hediffs.Add(
new Hediff
{
Def = def,
Severity = severity,
Immunity = immunity,
}
);
}
}
/// <summary>Снимает хедиф по имени дефа; возвращает, был ли он.</summary>
public bool Remove(string defName)
{
for (var i = 0; i < _hediffs.Count; i++)
{
if (_hediffs[i].Def.DefName == defName)
{
_hediffs.RemoveAt(i);
return true;
}
}
return false;
}
}
/// <summary>Здоровье особи: managed-состояние со списком хедифов (см. <see cref="HealthState"/>).</summary>
public struct Health : IComponent
{
/// <summary>Состояние здоровья (хедифы; позже части тела/кровь).</summary>
public HealthState State;
}
/// <summary>
/// Труп животного (фаза A5): отдельная сущность после смерти. Разлагается во времени по стадиям
/// (Fresh→Rotting→Skeleton→исчезает), скорость зависит от температуры. Несёт запас мяса (∝ размер тела)
/// для будущей разделки/падальщиков. Управляется <see cref="Sim.CorpseSystem"/>.
/// </summary>
public struct Corpse : IComponent
{
/// <summary>Индекс вида в <see cref="Content.AnimalSet"/> (для спрайта, разделки, сейва).</summary>
public int Species;
/// <summary>Накоплено игровых дней разложения.</summary>
public float RotDays;
/// <summary>Стадия: 0 свежий, 1 гниющий, 2 скелет.</summary>
public int Stage;
/// <summary>Доступное мясо (∝ размер тела) — для разделки/падальщиков (фаза-горизонт).</summary>
public float Meat;
}
/// <summary>Инстанс мысли настроения на особи (фаза A8): деф события + остаток времени до угасания.</summary>
public struct ThoughtInstance
{
/// <summary>Деф мысли (сдвиг настроения и длительность).</summary>
public Content.ThoughtDef Def;
/// <summary>Сколько игровых дней мысль ещё действует.</summary>
public float RemainingDays;
}
/// <summary>
/// Настроение особи (фаза A8): значение [0,1] и активные событийные мысли. Считается
/// <see cref="Sim.AnimalMoodSystem"/> из непрерывного фона (нужды/боль) и суммы мыслей. Есть только у
/// тира 4+ (<see cref="Active"/> по размеру мозга) — у примитивных настроения нет. Низкое настроение
/// влияет на поведение (стресс не даёт спариваться) и видно в инспекторе. Список мыслей — managed-ссылка
/// (как <c>AnimalNeeds.Values</c>): события добавляют мысли через <see cref="Add"/>.
/// </summary>
public struct Mood : IComponent
{
/// <summary>Текущее настроение [0,1]; у неактивного (тир &lt; 4) держится нейтральным.</summary>
public float Value;
/// <summary>Есть ли у особи настроение (гейт тиром мозга) — иначе фон/мысли не считаются.</summary>
public bool Active;
/// <summary>Активные событийные мысли (затухают со временем).</summary>
public List<ThoughtInstance> Thoughts;
/// <summary>
/// Вешает событийную мысль (или обновляет её длительность, если уже висит — без накопления
/// дубликатов). Вызывается из систем-источников событий (роды, спаривание, утоление жажды).
/// </summary>
public readonly void Add(Content.ThoughtDef? def)
{
if (Thoughts is null || def is null)
{
return;
}
for (var i = 0; i < Thoughts.Count; i++)
{
if (Thoughts[i].Def == def)
{
var refreshed = Thoughts[i];
refreshed.RemainingDays = def.DurationDays;
Thoughts[i] = refreshed;
return;
}
}
Thoughts.Add(new ThoughtInstance { Def = def, RemainingDays = def.DurationDays });
}
}
/// <summary>
/// Беременность самки (фаза A4): managed-ссылка на геном отца («слепок» на момент зачатия), остаток
/// срока вынашивания и размер помёта. По истечении срока <see cref="Sim.AnimalPregnancySystem"/>
/// рожает детёнышей через <see cref="Genome.Breed"/> и снимает компонент.
/// </summary>
public struct Pregnant : IComponent
{
/// <summary>Геном отца на момент зачатия.</summary>
public Genome FatherGenome;
/// <summary>Остаток срока вынашивания (игровых дней).</summary>
public float DueInDays;
/// <summary>Сколько детёнышей родится.</summary>
public int Litter;
}
File diff suppressed because it is too large Load Diff
+72
View File
@@ -0,0 +1,72 @@
using System.Collections.Generic;
namespace LittleSim.Sim;
/// <summary>Идентификаторы способностей (capacities) организма — на них ссылаются вклады частей тела.</summary>
public static class AnimalCapacities
{
public const string Consciousness = "Consciousness";
public const string Moving = "Moving";
public const string Breathing = "Breathing";
public const string BloodPumping = "BloodPumping";
public const string Sight = "Sight";
public const string Digestion = "Digestion";
}
/// <summary>
/// Обобщённый расчёт способностей (capacities) из частей тела и крови (фаза A5). Сырой вклад способности
/// = Σ(доля части × доля её HP). Затем известные способности домножаются по зависимостям (кровь →
/// сердце/лёгкие → сознание → движение/зрение), как в модели RimWorld; неизвестные (модовые) проходят
/// сырыми. У здоровой особи (полное HP, кровь 1, боль 0) все способности = 1.
/// </summary>
public static class CapacityCalc
{
/// <summary>Считает карту способностей из инстансов частей, уровня крови (0..1) и боли (0..1).</summary>
public static Dictionary<string, float> Compute(PartInstance[] parts, float blood, float pain)
{
var raw = new Dictionary<string, float>(System.StringComparer.Ordinal);
foreach (var part in parts)
{
if (part.Def is null)
{
continue;
}
var fraction = part.Fraction;
foreach (var (capacity, contribution) in part.Def.Capacities)
{
raw[capacity] = raw.GetValueOrDefault(capacity) + contribution * fraction;
}
}
float Raw(string id) => raw.GetValueOrDefault(id);
static float Clamp01(float v) => v < 0f ? 0f : v > 1f ? 1f : v;
var bloodLevel = Clamp01(blood);
var bloodPumping = Raw(AnimalCapacities.BloodPumping) * bloodLevel;
var breathing = Raw(AnimalCapacities.Breathing) * bloodLevel;
var consciousness =
Raw(AnimalCapacities.Consciousness)
* Clamp01(bloodPumping)
* Clamp01(breathing)
* (1f - Clamp01(pain));
var result = new Dictionary<string, float>(System.StringComparer.Ordinal)
{
[AnimalCapacities.BloodPumping] = bloodPumping,
[AnimalCapacities.Breathing] = breathing,
[AnimalCapacities.Consciousness] = consciousness,
[AnimalCapacities.Moving] = Raw(AnimalCapacities.Moving) * consciousness * Clamp01(bloodPumping),
[AnimalCapacities.Sight] = Raw(AnimalCapacities.Sight) * consciousness,
[AnimalCapacities.Digestion] = Raw(AnimalCapacities.Digestion) * Clamp01(bloodPumping),
};
// Модовые способности, не охваченные зависимостями, — сырыми.
foreach (var (capacity, value) in raw)
{
result.TryAdd(capacity, value);
}
return result;
}
}
+45 -23
View File
@@ -1,3 +1,4 @@
using System;
using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems;
using Microsoft.Xna.Framework;
@@ -74,41 +75,62 @@ public sealed class SelectionSystem(
}
}
// Ближайшее растение, чья ограничивающая окружность спрайта накрывает точку мира. Запрос
// ограничен PlantGrowth — так из пикинга выпадают спрайты рамки выделения и прочий не-контент.
// Ближайший осматриваемый объект (растение или животное), чья ограничивающая окружность спрайта
// накрывает точку мира. Запросы ограничены PlantGrowth/AnimalOrganism — так из пикинга выпадают
// спрайты рамки выделения, трупы и прочий не-контент. Из двух кандидатов берётся более близкий.
private bool TryPick(Vector2 world, out int id)
{
id = -1;
var bestDistance = float.MaxValue;
var best = float.MaxValue;
foreach (
var (transforms, sprites, _, entities) in store
.Query<Transform2D, Sprite, PlantGrowth>()
.Chunks
)
{
var t = transforms.Span;
var s = sprites.Span;
for (var i = 0; i < t.Length; i++)
{
if (s[i].Region is not { } region)
{
continue;
}
Scan(world, transforms.Span, sprites.Span, entities, ref id, ref best);
}
var (center, radius) = CullingMath.SpriteBoundingCircle(
in t[i],
region,
s[i].Origin
);
var distance = Vector2.DistanceSquared(center, world);
if (distance <= radius * radius && distance < bestDistance)
{
bestDistance = distance;
id = entities.EntityAt(i).Id;
}
}
foreach (
var (transforms, sprites, _, entities) in store
.Query<Transform2D, Sprite, AnimalOrganism>()
.Chunks
)
{
Scan(world, transforms.Span, sprites.Span, entities, ref id, ref best);
}
return id >= 0;
}
// Обновляет лучшего кандидата (id/дистанцию) по чанку спрайтов: точка должна попасть в окружность.
private static void Scan(
Vector2 world,
Span<Transform2D> transforms,
Span<Sprite> sprites,
ChunkEntities entities,
ref int id,
ref float best
)
{
for (var i = 0; i < transforms.Length; i++)
{
if (sprites[i].Region is not { } region)
{
continue;
}
var (center, radius) = CullingMath.SpriteBoundingCircle(
in transforms[i],
region,
sprites[i].Origin
);
var distance = Vector2.DistanceSquared(center, world);
if (distance <= radius * radius && distance < best)
{
best = distance;
id = entities.EntityAt(i).Id;
}
}
}
}
+237 -19
View File
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Text;
using Friflo.Engine.ECS;
@@ -16,10 +17,11 @@ namespace LittleSim.UI;
/// <summary>
/// Панель осмотра выбранной сущности (инфо-карточка в стиле RimWorld): угловая рамка вокруг объекта
/// (<see cref="SelectionBrackets"/>) и боковая панель в левом нижнем углу с вкладками — «Обзор»,
/// «Гены», «Продукты». Источник выделения — <see cref="Selection"/>; панель только читает компоненты
/// и обновляется каждый кадр (<see cref="Refresh"/>), оставаясь живой даже на паузе. Все строки идут
/// через <see cref="LanguageManager"/> (ключи <c>inspect.*</c>).
/// (<see cref="SelectionBrackets"/>) и боковая панель в левом нижнем углу с вкладками. Набор вкладок
/// зависит от типа: растение — «Обзор/Гены/Продукты», животное — «Обзор/Нужды/Здоровье/Настроение».
/// Источник выделения — <see cref="Selection"/>; панель только читает компоненты и обновляется каждый
/// кадр (<see cref="Refresh"/>), оставаясь живой даже на паузе. Все строки идут через
/// <see cref="LanguageManager"/> (ключи <c>inspect.*</c>).
/// </summary>
internal sealed class InspectPanel
{
@@ -28,10 +30,18 @@ internal sealed class InspectPanel
Overview,
Genes,
Products,
Needs,
Health,
Mood,
}
private static readonly Tab[] PlantTabs = [Tab.Overview, Tab.Genes, Tab.Products];
private static readonly Tab[] AnimalTabs = [Tab.Overview, Tab.Needs, Tab.Health, Tab.Mood];
private readonly EntityStore _store;
private readonly PlantSet _plants;
private readonly AnimalSet _animals;
private readonly NeedSet _needs;
private readonly GameContent _content;
private readonly Climate _climate;
private readonly Selection _selection;
@@ -46,6 +56,8 @@ internal sealed class InspectPanel
public InspectPanel(
EntityStore store,
PlantSet plants,
AnimalSet animals,
NeedSet needs,
GameContent content,
Climate climate,
Selection selection,
@@ -54,6 +66,8 @@ internal sealed class InspectPanel
{
_store = store;
_plants = plants;
_animals = animals;
_needs = needs;
_content = content;
_climate = climate;
_selection = selection;
@@ -66,6 +80,9 @@ internal sealed class InspectPanel
AddTab(tabBar, Tab.Overview, "inspect.tab.overview");
AddTab(tabBar, Tab.Genes, "inspect.tab.genes");
AddTab(tabBar, Tab.Products, "inspect.tab.products");
AddTab(tabBar, Tab.Needs, "inspect.tab.needs");
AddTab(tabBar, Tab.Health, "inspect.tab.health");
AddTab(tabBar, Tab.Mood, "inspect.tab.mood");
_panel = new VerticalStackPanel
{
@@ -99,7 +116,7 @@ internal sealed class InspectPanel
/// <summary>
/// Подтягивает выделение каждый кадр: проверяет, жива ли сущность (иначе снимает выбор), двигает
/// рамку к объекту и пересобирает текст активной вкладки. Не-растения панель не показывает.
/// рамку к объекту и пересобирает текст активной вкладки. Осматриваются растения и животные.
/// </summary>
public void Refresh()
{
@@ -107,19 +124,19 @@ internal sealed class InspectPanel
!_selection.HasSelection
|| !_store.TryGetEntityById(_selection.EntityId, out var entity)
|| entity.IsNull
|| !entity.HasComponent<PlantGrowth>()
|| !entity.HasComponent<PlantOrganism>()
|| !entity.HasComponent<Transform2D>()
|| !entity.HasComponent<Sprite>()
)
{
if (_selection.HasSelection)
{
_selection.Clear(); // сущность исчезла (растение погибло)
}
ClearSelection();
return;
}
_panel.Visible = false;
_brackets.Hide();
var isPlant = entity.HasComponent<PlantGrowth>() && entity.HasComponent<PlantOrganism>();
var isAnimal = entity.HasComponent<AnimalOrganism>();
if (!isPlant && !isAnimal)
{
ClearSelection(); // не контент (труп/прочее) — пока не осматривается
return;
}
@@ -135,10 +152,29 @@ internal sealed class InspectPanel
_brackets.Show(center, radius);
}
UpdateText(entity);
if (isPlant)
{
UpdatePlant(entity);
}
else
{
UpdateAnimal(entity);
}
_panel.Visible = true;
}
private void ClearSelection()
{
if (_selection.HasSelection)
{
_selection.Clear(); // сущность исчезла (растение/зверь погибли) или не осматривается
}
_panel.Visible = false;
_brackets.Hide();
}
private void AddTab(HorizontalStackPanel bar, Tab tab, string labelKey)
{
var button = new TextButton { Text = _content.Languages.Get(labelKey) };
@@ -147,7 +183,23 @@ internal sealed class InspectPanel
_tabs.Add((tab, button));
}
private void UpdateText(Entity entity)
// Показывает только вкладки текущего типа, подсвечивает активную; если активная не из набора —
// откатывает на первую (Обзор) при смене типа выбранного объекта.
private void ShowTabs(Tab[] valid)
{
if (Array.IndexOf(valid, _tab) < 0)
{
_tab = valid[0];
}
foreach (var (tab, button) in _tabs)
{
button.Visible = Array.IndexOf(valid, tab) >= 0;
button.TextColor = tab == _tab ? Ui.Accent : Ui.Muted;
}
}
private void UpdatePlant(Entity entity)
{
var languages = _content.Languages;
ref readonly var grow = ref entity.GetComponent<PlantGrowth>();
@@ -157,10 +209,7 @@ internal sealed class InspectPanel
var traits = org.Traits;
_title.Text = languages.Get(def.Label) + (traits.IsVariant ? " *" : "");
foreach (var (tab, button) in _tabs)
{
button.TextColor = tab == _tab ? Ui.Accent : Ui.Muted;
}
ShowTabs(PlantTabs);
var text = new StringBuilder();
switch (_tab)
@@ -179,6 +228,35 @@ internal sealed class InspectPanel
_body.Text = text.ToString().TrimEnd();
}
private void UpdateAnimal(Entity entity)
{
var languages = _content.Languages;
ref readonly var org = ref entity.GetComponent<AnimalOrganism>();
var sp = _animals[org.Species];
_title.Text = languages.Get(sp.Def.Label);
ShowTabs(AnimalTabs);
var text = new StringBuilder();
switch (_tab)
{
case Tab.Overview:
BuildAnimalOverview(text, entity, org, sp);
break;
case Tab.Needs:
BuildAnimalNeeds(text, entity);
break;
case Tab.Health:
BuildAnimalHealth(text, entity);
break;
case Tab.Mood:
BuildAnimalMood(text, entity);
break;
}
_body.Text = text.ToString().TrimEnd();
}
private void BuildOverview(
StringBuilder text,
Entity entity,
@@ -296,6 +374,146 @@ internal sealed class InspectPanel
}
}
// — Животное: Обзор — стадия, возраст, пол/поколение, беременность.
private void BuildAnimalOverview(
StringBuilder text,
Entity entity,
in AnimalOrganism org,
AnimalSet.Species sp
)
{
var languages = _content.Languages;
var grow = entity.GetComponent<AnimalGrowth>();
var stageName = languages.Get(
"animalstage." + sp.Stages[grow.Stage].Name.ToLowerInvariant()
);
text.AppendLine(
languages.Format("inspect.stage", stageName, grow.Stage + 1, sp.Stages.Length)
);
text.AppendLine(languages.Format("inspect.age", grow.AgeDays, org.Traits.Lifespan));
var sex = languages.Get(org.IsMale ? "inspect.sex.male" : "inspect.sex.female");
text.AppendLine(languages.Format("inspect.animal.sex", sex, org.Generation));
if (entity.TryGetComponent<Pregnant>(out var preg))
{
text.AppendLine(languages.Format("inspect.animal.pregnant", preg.DueInDays));
}
}
// — Животное: Нужды — значение каждой нужды (голод/жажда/отдых/влечение) в процентах.
private void BuildAnimalNeeds(StringBuilder text, Entity entity)
{
var languages = _content.Languages;
var values = entity.GetComponent<AnimalNeeds>().Values;
if (values is null)
{
return;
}
for (var k = 0; k < _needs.Count && k < values.Length; k++)
{
text.AppendLine(
languages.Format(
"inspect.needline",
languages.Get(_needs[k].Label),
values[k] * 100f
)
);
}
}
// — Животное: Здоровье — кровь/боль, ключевые способности, активные хедифы (раны/болезни/гон).
private void BuildAnimalHealth(StringBuilder text, Entity entity)
{
var languages = _content.Languages;
var state = entity.GetComponent<Health>().State;
if (state is null)
{
text.AppendLine(languages.Get("inspect.health.none"));
return;
}
text.AppendLine(
languages.Format("inspect.health.blood", state.BloodLevel * 100f, state.Pain * 100f)
);
if (state.Parts.Length > 0)
{
text.AppendLine(languages.Get("inspect.health.caps"));
AppendCapacity(text, state, AnimalCapacities.Consciousness, "cap.consciousness");
AppendCapacity(text, state, AnimalCapacities.Moving, "cap.moving");
AppendCapacity(text, state, AnimalCapacities.Sight, "cap.sight");
}
if (state.Hediffs.Count > 0)
{
text.AppendLine(languages.Get("inspect.health.hediffs"));
foreach (var hediff in state.Hediffs)
{
text.AppendLine(
languages.Format(
"inspect.health.hediffline",
languages.Get(hediff.Def.Label),
hediff.Severity * 100f
)
);
}
}
else
{
text.AppendLine(languages.Get("inspect.health.none"));
}
}
private void AppendCapacity(
StringBuilder text,
HealthState state,
string capId,
string labelKey
)
{
var languages = _content.Languages;
text.AppendLine(
languages.Format(
"inspect.health.cap",
languages.Get(labelKey),
state.Capacity(capId) * 100f
)
);
}
// — Животное: Настроение — значение и активные событийные мысли с их сдвигом (тир 4+).
private void BuildAnimalMood(StringBuilder text, Entity entity)
{
var languages = _content.Languages;
var mood = entity.GetComponent<Mood>();
if (!mood.Active)
{
text.AppendLine(languages.Get("inspect.mood.none"));
return;
}
text.AppendLine(languages.Format("inspect.mood.value", mood.Value * 100f));
if (mood.Thoughts is { Count: > 0 })
{
text.AppendLine(languages.Get("inspect.mood.thoughts"));
foreach (var thought in mood.Thoughts)
{
var pct = thought.Def.MoodOffset * 100f;
var offset = (pct >= 0f ? "+" : "") + pct.ToString("0") + "%";
text.AppendLine(
languages.Format(
"inspect.mood.thoughtline",
languages.Get(thought.Def.Label),
offset
)
);
}
}
else
{
text.AppendLine(languages.Get("inspect.mood.calm"));
}
}
private string StageLabel(PlantDef def, int stage) =>
stage < def.Stages.Count && def.Stages[stage].Label is { } key
? _content.Languages.Get(key)