Животные: сериализация — сейв/лоад популяции и трупов

Закрывает сквозное требование плана (сейв/лоад данных каждой фазы). WorldSave получил Animals (AnimalSave: геном+пол, поколение, стадия/возраст, нужды, здоровье — кровь/HP частей/хедифы, настроение+мысли, беременность+геном отца) и Corpses (CorpseSave). SaveAnimals снимает популяцию (Health/Mood/Pregnant через entity); RestoreAnimals/RestoreAnimalState собирают особь фабрикой из генома + накладывают состояние (ref-правки до AddComponent(Pregnant); труп пере-тинтится через Stage=-1). Corpse получил Species; CreateCorpse принимает индекс вида + HealthState.AddHediff(def,severity,immunity). На загрузке стадо не пересыпается при наличии животных в сейве; старый/пустой сейв регенерируется из сида. Сборка + --check-content чистые.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-14 06:59:26 +03:00
co-authored by Claude Opus 4.8
parent 003f235440
commit 4b53eb8984
6 changed files with 382 additions and 10 deletions
+14 -3
View File
@@ -1,8 +1,19 @@
# Животные (зоология) # Животные (зоология)
> Статус: **A1–A8 реализованы целиком** — базовый план зоологии закрыт; дальше экологический горизонт > Статус: **A1–A8 реализованы целиком + сериализация животных** — базовый план зоологии и его сквозное
> (хищники, опека, эпидемии, миграция) и бэклог реализма. Это живой план — стиль и правила как в > требование сейв/лоада закрыты; дальше экологический горизонт (хищники, опека, эпидемии, миграция) и
> [концепте](concept.md) и [модах](mods.md). > бэклог реализма. Это живой план — стиль и правила как в [концепте](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` теперь осматривает и животных (не только > **A8 часть 2 (готово): инспектор животных.** `InspectPanel` теперь осматривает и животных (не только
> растения): вкладки **Обзор** (стадия/возраст/пол/поколение/беременность), **Нужды** (значения нужд %), > растения): вкладки **Обзор** (стадия/возраст/пол/поколение/беременность), **Нужды** (значения нужд %),
+117
View File
@@ -62,6 +62,117 @@ public sealed class PawnSave
public float ChangeIn { get; set; } 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> /// <summary>
/// Полное состояние мира для сохранения/загрузки: конфиг мира (имя/размер/сид/сглаживание), /// Полное состояние мира для сохранения/загрузки: конфиг мира (имя/размер/сид/сглаживание),
/// прошедшее время симуляции и снимок всех жителей. Рельеф не сохраняется — он /// прошедшее время симуляции и снимок всех жителей. Рельеф не сохраняется — он
@@ -99,6 +210,12 @@ public sealed class WorldSave
/// <summary>Снимок всех растений (геномы, позиции, возраст, стадия).</summary> /// <summary>Снимок всех растений (геномы, позиции, возраст, стадия).</summary>
public List<PlantSave> Plants { get; set; } = []; public List<PlantSave> Plants { get; set; } = [];
/// <summary>Снимок всех животных (геном, стадия/возраст, нужды, здоровье, настроение, беременность).</summary>
public List<AnimalSave> Animals { get; set; } = [];
/// <summary>Снимок трупов (вид, позиция, таймер/стадия разложения, мясо).</summary>
public List<CorpseSave> Corpses { get; set; } = [];
/// <summary>Конфиг мира для пересоздания сцены.</summary> /// <summary>Конфиг мира для пересоздания сцены.</summary>
public WorldConfig ToConfig() => public WorldConfig ToConfig() =>
new() new()
+223 -3
View File
@@ -126,9 +126,16 @@ public sealed class WorldScene : Scene
RestorePlants(content); RestorePlants(content);
} }
// Животные: детерминированный начальный спавн по данным (плотность из дефов). Пока без сейва — // Животные: из сейва (сохранённая популяция — эволюция/состояние не теряются) либо
// регенерируются из сида и для нового, и для загруженного мира. // детерминированный спавн из сида (новый мир или старый сейв без животных).
if (_save?.Animals is { Count: > 0 })
{
RestoreAnimals(content);
}
else
{
SpawnAnimals(); SpawnAnimals();
}
// Освещение: лайтмап (день/ночь × окклюзия от гор/крон + точечные) множится поверх мира, // Освещение: лайтмап (день/ночь × окклюзия от гор/крон + точечные) множится поверх мира,
// а Lighting.SampleAt даёт локальный свет системе роста (подлесок под кронами растёт хуже). // а Lighting.SampleAt даёт локальный свет системе роста (подлесок под кронами растёт хуже).
@@ -501,11 +508,108 @@ public sealed class WorldScene : Scene
} }
); );
SaveAnimals(save);
new SaveStore().Write(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; 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) private void RestorePlants(GameContent content)
{ {
var fallback = new Random(_config.Seed); // для старых/битых сейвов без генома 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<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 // Детерминированный начальный спавн животных по данным: каждый вид с SpawnPer1000Cells > 0
// рассыпается по клеткам суши с плотностью из дефа; геном и возраст особи — из шаблона вида. // рассыпается по клеткам суши с плотностью из дефа; геном и возраст особи — из шаблона вида.
private void SpawnAnimals() private void SpawnAnimals()
+4 -1
View File
@@ -84,12 +84,14 @@ public static class AnimalFactory
/// <summary>Создаёт сущность-труп после смерти животного: спрайт-падаль, запас мяса (∝ размер тела).</summary> /// <summary>Создаёт сущность-труп после смерти животного: спрайт-падаль, запас мяса (∝ размер тела).</summary>
public static Entity CreateCorpse( public static Entity CreateCorpse(
EntityStore store, EntityStore store,
AnimalSet.Species sp, AnimalSet animals,
int species,
Vector2 position, Vector2 position,
float bodySize, float bodySize,
int cellSize int cellSize
) )
{ {
var sp = animals[species];
var region = sp.Corpse; var region = sp.Corpse;
var sprite = new Sprite(region, GameLayers.Beings); var sprite = new Sprite(region, GameLayers.Beings);
sprite.CenterOrigin(); sprite.CenterOrigin();
@@ -100,6 +102,7 @@ public static class AnimalFactory
sprite, sprite,
new Corpse new Corpse
{ {
Species = species,
RotDays = 0f, RotDays = 0f,
Stage = 0, Stage = 0,
Meat = bodySize * MeatPerBodySize, Meat = bodySize * MeatPerBodySize,
+19
View File
@@ -286,6 +286,22 @@ public sealed class HealthState
} }
} }
/// <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> /// <summary>Снимает хедиф по имени дефа; возвращает, был ли он.</summary>
public bool Remove(string defName) public bool Remove(string defName)
{ {
@@ -316,6 +332,9 @@ public struct Health : IComponent
/// </summary> /// </summary>
public struct Corpse : IComponent public struct Corpse : IComponent
{ {
/// <summary>Индекс вида в <see cref="Content.AnimalSet"/> (для спрайта, разделки, сейва).</summary>
public int Species;
/// <summary>Накоплено игровых дней разложения.</summary> /// <summary>Накоплено игровых дней разложения.</summary>
public float RotDays; public float RotDays;
+4 -2
View File
@@ -994,7 +994,8 @@ public sealed class AnimalMortalitySystem : BaseSystem
var position = dead.GetComponent<Transform2D>().Position; var position = dead.GetComponent<Transform2D>().Position;
AnimalFactory.CreateCorpse( AnimalFactory.CreateCorpse(
_store, _store,
_animals[org.Species], _animals,
org.Species,
position, position,
org.Traits.BodySize, org.Traits.BodySize,
_cellSize _cellSize
@@ -1317,7 +1318,8 @@ public sealed class AnimalHealthSystem : BaseSystem
var position = dead.GetComponent<Transform2D>().Position; var position = dead.GetComponent<Transform2D>().Position;
AnimalFactory.CreateCorpse( AnimalFactory.CreateCorpse(
_store, _store,
_animals[org.Species], _animals,
org.Species,
position, position,
org.Traits.BodySize, org.Traits.BodySize,
_cellSize _cellSize