diff --git a/docs/животные.md b/docs/животные.md
index d82e7f9..8acaca9 100644
--- a/docs/животные.md
+++ b/docs/животные.md
@@ -1,8 +1,19 @@
# Животные (зоология)
-> Статус: **A1–A8 реализованы целиком** — базовый план зоологии закрыт; дальше экологический горизонт
-> (хищники, опека, эпидемии, миграция) и бэклог реализма. Это живой план — стиль и правила как в
-> [концепте](concept.md) и [модах](mods.md).
+> Статус: **A1–A8 реализованы целиком + сериализация животных** — базовый план зоологии и его сквозное
+> требование сейв/лоада закрыты; дальше экологический горизонт (хищники, опека, эпидемии, миграция) и
+> бэклог реализма. Это живой план — стиль и правила как в [концепте](concept.md) и [модах](mods.md).
+>
+> **Сериализация животных (готово): сейв/лоад популяции и трупов.** `WorldSave` получил `Animals`
+> (`AnimalSave`: геном→признаки+пол, поколение, стадия/возраст, нужды+истощение, здоровье — кровь/HP
+> частей/хедифы тяжесть+иммунитет, настроение+мысли, беременность+геном отца) и `Corpses` (`CorpseSave`:
+> вид/позиция/таймер-стадия разложения/мясо). `SaveAnimals` снимает популяцию (Health/Mood/Pregnant — через
+> entity, т.к. запрос ≤5 типов); `RestoreAnimals`/`RestoreAnimalState` собирают особь фабрикой из генома и
+> накладывают динамическое состояние (ref-правки до структурного `AddComponent(Pregnant)`; труп
+> пере-тинтится через `Stage=-1`). `Corpse` получил поле `Species` (для спрайта/разделки); `CreateCorpse`
+> теперь принимает индекс вида. На загрузке стадо НЕ пересыпается, если в сейве есть животные; старый/пустой
+> сейв — регенерируется из сида (не крашит). Эволюция/болезни/беременности переживают сейв/лоад. Сборка +
+> `--check-content` чистые.
>
> **A8 часть 2 (готово): инспектор животных.** `InspectPanel` теперь осматривает и животных (не только
> растения): вкладки **Обзор** (стадия/возраст/пол/поколение/беременность), **Нужды** (значения нужд %),
diff --git a/src/LittleSim/App/WorldSave.cs b/src/LittleSim/App/WorldSave.cs
index 871c3f3..ce1b9e8 100644
--- a/src/LittleSim/App/WorldSave.cs
+++ b/src/LittleSim/App/WorldSave.cs
@@ -62,6 +62,117 @@ public sealed class PawnSave
public float ChangeIn { get; set; }
}
+/// Сериализуемый хедиф особи (фаза A6): деф (по имени), тяжесть и набранный иммунитет.
+public sealed class HediffSave
+{
+ /// Имя дефа хедифа (гон/болезнь).
+ public string DefName { get; set; } = "";
+
+ /// Текущая тяжесть.
+ public float Severity { get; set; }
+
+ /// Набранный иммунитет [0,1].
+ public float Immunity { get; set; }
+}
+
+/// Сериализуемая событийная мысль (фаза A8): деф (по имени) и остаток времени до угасания.
+public sealed class ThoughtSave
+{
+ /// Имя дефа мысли.
+ public string DefName { get; set; } = "";
+
+ /// Сколько игровых дней мысль ещё действует.
+ public float RemainingDays { get; set; }
+}
+
+///
+/// Сериализуемое состояние одного животного (фазы A1–A8): вид, позиция, геном (источник признаков и
+/// пола), поколение, стадия/возраст, нужды, здоровье (кровь, HP частей, хедифы), настроение и
+/// беременность. Спрайт пересобирается фабрикой из дефа. Чтобы популяция (эволюция/состояние)
+/// переживала сейв/лоад, а не пересыпалась заново из сида.
+///
+public sealed class AnimalSave
+{
+ /// Имя дефа вида — по нему берётся индекс/стадии/текстуры/тело.
+ public string Species { get; set; } = "";
+
+ /// Позиция X в мировых координатах.
+ public float X { get; set; }
+
+ /// Позиция Y в мировых координатах.
+ public float Y { get; set; }
+
+ /// Номер поколения (для popstats/дрейфа).
+ public int Generation { get; set; }
+
+ /// Текущая стадия роста.
+ public int Stage { get; set; }
+
+ /// Накопленный возраст в игровых днях.
+ public float AgeDays { get; set; }
+
+ /// Геном особи: аллели по id генов (переменного состава; несёт и пол через GeneSex).
+ public Dictionary Genome { get; set; } = new();
+
+ /// Значения нужд по индексам NeedSet.
+ public float[] Needs { get; set; } = [];
+
+ /// Накоплено дней истощения.
+ public float Starve { get; set; }
+
+ /// Уровень крови [0,1].
+ public float BloodLevel { get; set; } = 1f;
+
+ /// HP частей тела по порядку анатомии вида (пусто — у вида нет тела).
+ public float[] PartHp { get; set; } = [];
+
+ /// Активные хедифы (гон/болезни).
+ public List Hediffs { get; set; } = [];
+
+ /// Текущее настроение [0,1].
+ public float Mood { get; set; }
+
+ /// Активно ли настроение (тир 4+).
+ public bool MoodActive { get; set; }
+
+ /// Активные событийные мысли.
+ public List Thoughts { get; set; } = [];
+
+ /// Беременна ли самка.
+ public bool Pregnant { get; set; }
+
+ /// Геном отца (если беременна) — «слепок» на момент зачатия.
+ public Dictionary FatherGenome { get; set; } = new();
+
+ /// Остаток срока вынашивания (игровых дней).
+ public float DueInDays { get; set; }
+
+ /// Размер помёта.
+ public int Litter { get; set; }
+}
+
+/// Сериализуемый труп (фаза A5): вид (для спрайта), позиция, таймер/стадия разложения, мясо.
+public sealed class CorpseSave
+{
+ /// Имя дефа вида — по нему берётся спрайт трупа.
+ public string Species { get; set; } = "";
+
+ /// Позиция X в мировых координатах.
+ public float X { get; set; }
+
+ /// Позиция Y в мировых координатах.
+ public float Y { get; set; }
+
+ /// Накоплено игровых дней разложения.
+ public float RotDays { get; set; }
+
+ /// Размер тела (для масштаба спрайта).
+ public float BodySize { get; set; }
+
+ /// Доступное мясо.
+ public float Meat { get; set; }
+}
+
///
/// Полное состояние мира для сохранения/загрузки: конфиг мира (имя/размер/сид/сглаживание),
/// прошедшее время симуляции и снимок всех жителей. Рельеф не сохраняется — он
@@ -99,6 +210,12 @@ public sealed class WorldSave
/// Снимок всех растений (геномы, позиции, возраст, стадия).
public List Plants { get; set; } = [];
+ /// Снимок всех животных (геном, стадия/возраст, нужды, здоровье, настроение, беременность).
+ public List Animals { get; set; } = [];
+
+ /// Снимок трупов (вид, позиция, таймер/стадия разложения, мясо).
+ public List Corpses { get; set; } = [];
+
/// Конфиг мира для пересоздания сцены.
public WorldConfig ToConfig() =>
new()
diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs
index 6a38477..b47bb86 100644
--- a/src/LittleSim/Scenes/WorldScene.cs
+++ b/src/LittleSim/Scenes/WorldScene.cs
@@ -126,9 +126,16 @@ public sealed class WorldScene : Scene
RestorePlants(content);
}
- // Животные: детерминированный начальный спавн по данным (плотность из дефов). Пока без сейва —
- // регенерируются из сида и для нового, и для загруженного мира.
- SpawnAnimals();
+ // Животные: из сейва (сохранённая популяция — эволюция/состояние не теряются) либо
+ // детерминированный спавн из сида (новый мир или старый сейв без животных).
+ if (_save?.Animals is { Count: > 0 })
+ {
+ RestoreAnimals(content);
+ }
+ else
+ {
+ SpawnAnimals();
+ }
// Освещение: лайтмап (день/ночь × окклюзия от гор/крон + точечные) множится поверх мира,
// а Lighting.SampleAt даёт локальный свет системе роста (подлесок под кронами растёт хуже).
@@ -501,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()
+ .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().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();
+ 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(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()
+ .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); // для старых/битых сейвов без генома
@@ -533,6 +637,122 @@ 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(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(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();
+ 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();
+ if (saved.Needs is { Length: > 0 } && saved.Needs.Length == _needs.Count)
+ {
+ needs.Values = saved.Needs; // состав совпал — иначе остаются свежие из фабрики
+ }
+
+ needs.Starve = saved.Starve;
+ entity.GetComponent().Stage = saved.Stage;
+
+ ref var mood = ref entity.GetComponent();
+ mood.Value = saved.Mood;
+ mood.Active = saved.MoodActive;
+ mood.Thoughts.Clear();
+ foreach (var thought in saved.Thoughts)
+ {
+ if (content.Defs.TryGet(thought.DefName, out var tdef))
+ {
+ mood.Thoughts.Add(
+ new ThoughtInstance { Def = tdef, RemainingDays = thought.RemainingDays }
+ );
+ }
+ }
+
+ if (entity.GetComponent().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(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()
diff --git a/src/LittleSim/Sim/AnimalFactory.cs b/src/LittleSim/Sim/AnimalFactory.cs
index 47d581a..06c2279 100644
--- a/src/LittleSim/Sim/AnimalFactory.cs
+++ b/src/LittleSim/Sim/AnimalFactory.cs
@@ -84,12 +84,14 @@ public static class AnimalFactory
/// Создаёт сущность-труп после смерти животного: спрайт-падаль, запас мяса (∝ размер тела).
public static Entity CreateCorpse(
EntityStore store,
- AnimalSet.Species sp,
+ 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();
@@ -100,6 +102,7 @@ public static class AnimalFactory
sprite,
new Corpse
{
+ Species = species,
RotDays = 0f,
Stage = 0,
Meat = bodySize * MeatPerBodySize,
diff --git a/src/LittleSim/Sim/AnimalOrganism.cs b/src/LittleSim/Sim/AnimalOrganism.cs
index 8f61071..5409cfd 100644
--- a/src/LittleSim/Sim/AnimalOrganism.cs
+++ b/src/LittleSim/Sim/AnimalOrganism.cs
@@ -286,6 +286,22 @@ public sealed class HealthState
}
}
+ /// Восстанавливает хедиф с заданной тяжестью и иммунитетом (для загрузки сейва).
+ public void AddHediff(HediffDef def, float severity, float immunity)
+ {
+ if (!Has(def.DefName))
+ {
+ _hediffs.Add(
+ new Hediff
+ {
+ Def = def,
+ Severity = severity,
+ Immunity = immunity,
+ }
+ );
+ }
+ }
+
/// Снимает хедиф по имени дефа; возвращает, был ли он.
public bool Remove(string defName)
{
@@ -316,6 +332,9 @@ public struct Health : IComponent
///
public struct Corpse : IComponent
{
+ /// Индекс вида в (для спрайта, разделки, сейва).
+ public int Species;
+
/// Накоплено игровых дней разложения.
public float RotDays;
diff --git a/src/LittleSim/Sim/AnimalSystems.cs b/src/LittleSim/Sim/AnimalSystems.cs
index 1056f91..5cf52ed 100644
--- a/src/LittleSim/Sim/AnimalSystems.cs
+++ b/src/LittleSim/Sim/AnimalSystems.cs
@@ -994,7 +994,8 @@ public sealed class AnimalMortalitySystem : BaseSystem
var position = dead.GetComponent().Position;
AnimalFactory.CreateCorpse(
_store,
- _animals[org.Species],
+ _animals,
+ org.Species,
position,
org.Traits.BodySize,
_cellSize
@@ -1317,7 +1318,8 @@ public sealed class AnimalHealthSystem : BaseSystem
var position = dead.GetComponent().Position;
AnimalFactory.CreateCorpse(
_store,
- _animals[org.Species],
+ _animals,
+ org.Species,
position,
org.Traits.BodySize,
_cellSize