Животные A1: фундамент — AnimalDef, гены зверя, спавн оленя

Новый тип дефа Animal (AnimalDef : PawnDef) + animals.json (Deer). 8 организм-агностичных животных генов в genes.json. AnimalPhenotype/AnimalOrganism, AnimalSet (шаблон генома вида) и AnimalFactory — зеркала растительных PlantSet/PlantFactory. Детерминированный спавн стада оленей по суше в WorldScene с рендером и тинтом меха по гену; консольная команда 'animal <species>'.

Проверено: сборка чистая, --check-content (7 типов дефов, 28 генов).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-14 04:03:51 +03:00
co-authored by Claude Opus 4.8
parent 516e263c77
commit 5e61491960
8 changed files with 445 additions and 1 deletions
+26
View File
@@ -0,0 +1,26 @@
{
"type": "Animal",
// Виды-животные (фаза A1): подтип Pawn (kind=animal) + базовый геном вида из общих Gene-дефов
// (см. genes.json). Особь при спавне получает аллели вокруг этих баз, фенотип — формулами генов.
// Пол, стадии роста, размножение, здоровье и поведение приходят в фазах A2+.
"defs": [
{
"defName": "Deer",
"label": "pawn.deer",
"kind": "animal",
"texture": "things/pawn/animal/deer/DeerFemale_east",
"genome": {
"bodySize": 1.5,
"metabolism": 1.0,
"moveSpeed": 1.2,
"bloodVolume": 1.4,
"vision": 1.3,
"brainSize": 0.45,
"insulation": 0.55,
"furColor": 0.45,
"lifespan": 360,
"spread": 0.08
}
}
]
}
+26
View File
@@ -74,6 +74,32 @@
"default": 0, "min": 0, "max": 1, "spread": 0, "mutationChance": 0, "tags": ["derived"],
"effects": { "hardiness": "gsum('Gene.*Tolerance')" } },
// --- Животные (фаза A1): организм-агностичные гены зверя (их несёт геном животного) ---
{ "defName": "GeneMaxBodySize", "parent": "BaseNumericGene", "label": "gene.bodySize",
"default": 1.0, "min": 0.1, "max": 4.0, "tags": ["animal", "body"],
"effects": { "bodySize": "value" } },
{ "defName": "GeneMetabolism", "parent": "BaseNumericGene", "label": "gene.metabolism",
"default": 1.0, "min": 0.2, "max": 3.0, "tags": ["animal", "metabolism"],
"effects": { "metabolism": "value" } },
{ "defName": "GeneMoveSpeed", "parent": "BaseNumericGene", "label": "gene.moveSpeed",
"default": 1.0, "min": 0.2, "max": 3.0, "tags": ["animal", "movement"],
"effects": { "moveSpeed": "value" } },
{ "defName": "GeneBloodVolume", "parent": "BaseNumericGene", "label": "gene.bloodVolume",
"default": 1.0, "min": 0.2, "max": 3.0, "tags": ["animal", "health"],
"effects": { "bloodVolume": "value" } },
{ "defName": "GeneVision", "parent": "BaseNumericGene", "label": "gene.vision",
"default": 1.0, "min": 0.2, "max": 3.0, "tags": ["animal", "senses"],
"effects": { "vision": "value" } },
{ "defName": "GeneBrainSize", "parent": "BaseNumericGene", "label": "gene.brainSize",
"default": 0.45, "min": 0.0, "max": 1.0, "tags": ["animal", "brain"],
"effects": { "brainSize": "value" } },
{ "defName": "GeneInsulation", "parent": "BaseNumericGene", "label": "gene.insulation",
"default": 0.5, "min": 0.0, "max": 1.0, "tags": ["animal", "temperature"],
"effects": { "insulation": "value" } },
{ "defName": "GeneFurColor", "parent": "BaseNumericGene", "label": "gene.furColor",
"default": 0.5, "min": 0.0, "max": 1.0, "spread": 0.06, "tags": ["animal", "morphology", "color"],
"effects": { "furHue": "value" } },
// Дискретный ген морфы: вариант 0 доминирует, 1 (рецессивный) виден только в гомозиготе.
{ "defName": "GeneMorph", "kind": "Discrete", "label": "gene.morph",
"variants": 2, "variantWeights": [0.82, 0.18], "mutationChance": 0.05, "tags": ["morphology"],
+97
View File
@@ -0,0 +1,97 @@
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>Спрайт вида (фаза A1 — единый; стадии/пол позже).</summary>
public required Texture2DRegion Region { get; init; }
/// <summary>Набор генов вида: базовые значения для генерации особи.</summary>
public required GenomeTemplate Template { get; init; }
}
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];
_species[i] = new Species
{
Def = def,
Region = atlases.GetRegion(device, def.Texture),
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];
// Переводит базовый геном вида (числа из animals.json) в набор генов: каждое число — центр
// аллелей соответствующего общего GeneDef. Зеркало PlantSet.BuildTemplate.
private static GenomeTemplate BuildTemplate(
AnimalGenomeDef g,
IReadOnlyDictionary<string, GeneDef> genes
)
{
GeneDef Gene(string id) =>
genes.TryGetValue(id, out var def)
? def
: throw new KeyNotFoundException(
$"Gene def '{id}' not found — is Mods/Core/Defs/genes.json loaded?"
);
GenomeTemplate.Entry Numeric(string id, float baseValue) =>
new(Gene(id), baseValue, g.Spread);
return new GenomeTemplate(
[
Numeric("GeneMaxBodySize", g.BodySize),
Numeric("GeneMetabolism", g.Metabolism),
Numeric("GeneMoveSpeed", g.MoveSpeed),
Numeric("GeneBloodVolume", g.BloodVolume),
Numeric("GeneVision", g.Vision),
new GenomeTemplate.Entry(Gene("GeneBrainSize"), g.BrainSize, 0.03f),
Numeric("GeneInsulation", g.Insulation),
new GenomeTemplate.Entry(Gene("GeneFurColor"), g.FurColor, 0.06f),
Numeric("GeneLifespan", g.Lifespan),
]
);
}
}
+1
View File
@@ -63,6 +63,7 @@ public sealed class GameContent
defs.RegisterType<ProductDef>("Product");
defs.RegisterType<PlantDef>("Plant");
defs.RegisterType<PawnDef>("Pawn");
defs.RegisterType<AnimalDef>("Animal");
defs.RegisterType<WorldPresetDef>("WorldPreset");
// Валидация имён при загрузке (фаза G5): гены и продукты обязаны иметь префикс типа.
defs.RegisterValidator("Gene", "defName", "^Gene", "gene defName must start with 'Gene'");
+51 -1
View File
@@ -191,7 +191,7 @@ public sealed class WorldPresetDef : Def
}
/// <summary>Существо (Defs/pawns.json): текстура, размер и вид (житель/животное).</summary>
public sealed class PawnDef : Def
public class PawnDef : Def
{
/// <summary>Вид существа: жители ("being") живут в мировой сцене, животные ("animal") — в террейне.</summary>
public const string KindBeing = "being";
@@ -208,3 +208,53 @@ public sealed class PawnDef : Def
/// <summary>Вид: <see cref="KindBeing"/> или <see cref="KindAnimal"/>.</summary>
public string Kind { get; init; } = "";
}
/// <summary>
/// Базовый геном вида-животного (вложенный объект <see cref="AnimalDef.Genome"/>): центры аллелей
/// животных генов (см. <c>genes.json</c>). Особь при спавне получает аллели рядом с этими базами
/// (разброс — <see cref="Spread"/>), а фенотип считается формулами генов над аллелями. Зеркало
/// растительного <see cref="GenomeDef"/>, только для зверя.
/// </summary>
public sealed class AnimalGenomeDef
{
/// <summary>Размер тела в клетках — масштаб спрайта и (позже) HP/расхода энергии.</summary>
public float BodySize { get; init; } = 1f;
/// <summary>Скорость обмена веществ — множитель расхода нужд (голод/жажда).</summary>
public float Metabolism { get; init; } = 1f;
/// <summary>Скорость передвижения (множитель).</summary>
public float MoveSpeed { get; init; } = 1f;
/// <summary>Объём крови (множитель) — масштаб скорости кровопотери в системе здоровья.</summary>
public float BloodVolume { get; init; } = 1f;
/// <summary>Дальность зрения (множитель радиуса поиска корма/воды/партнёра).</summary>
public float Vision { get; init; } = 1f;
/// <summary>Размер мозга (0..1) — основа интеллекта (гейт нужд/действий).</summary>
public float BrainSize { get; init; } = 0.45f;
/// <summary>Теплоизоляция мехом (0..1) — терморегуляция.</summary>
public float Insulation { get; init; } = 0.5f;
/// <summary>Оттенок меха (0..1) — сдвиг тинта спрайта (как leafHue у растений).</summary>
public float FurColor { get; init; } = 0.5f;
/// <summary>Продолжительность жизни (игровых дней) до смерти от старости.</summary>
public float Lifespan { get; init; } = 160f;
/// <summary>Доля разброса аллелей вокруг базы при генерации особи.</summary>
public float Spread { get; init; } = 0.08f;
}
/// <summary>
/// Вид-животное (Defs/animals.json): подтип <see cref="PawnDef"/> (текстура/размер/вид) плюс базовый
/// геном вида. Поведение, здоровье и стадии живут на компонентах сущности (фазы A2+), а не на типе
/// дефа — существующие 9 животных-<see cref="PawnDef"/> остаются валидными.
/// </summary>
public sealed class AnimalDef : PawnDef
{
/// <summary>Базовый геном вида: центры аллелей животных генов.</summary>
public AnimalGenomeDef Genome { get; init; } = new();
}
+103
View File
@@ -65,6 +65,7 @@ public sealed class WorldScene : Scene
private readonly List<Action> _speedRefreshers = [];
private PlantSet _plants = null!;
private AnimalSet _animals = null!;
private float[] _cellFertility = [];
private bool[] _cellLand = [];
private bool[] _cellOccluderBase = []; // горы (статично из террейна)
@@ -109,6 +110,7 @@ public sealed class WorldScene : Scene
// Рельеф детерминирован сидом. Растения: новый мир — скаттер из сида; загрузка — из сейва.
_plants = new PlantSet(content, atlases, device);
_animals = new AnimalSet(content, atlases, device);
var loadingPlants = _save?.Plants is { Count: > 0 };
BuildTerrain(
content,
@@ -122,6 +124,10 @@ public sealed class WorldScene : Scene
RestorePlants(content);
}
// Животные (фаза A1): детерминированный начальный спавн по суше. Пока без сейва/симуляции —
// регенерируются из сида и для нового, и для загруженного мира.
SpawnAnimals(content);
// Освещение: лайтмап (день/ночь × окклюзия от гор/крон + точечные) множится поверх мира,
// а Lighting.SampleAt даёт локальный свет системе роста (подлесок под кронами растёт хуже).
var lighting = this.UseLighting(
@@ -454,6 +460,64 @@ public sealed class WorldScene : Scene
}
}
// Детерминированный начальный спавн оленей по клеткам суши (фаза A1): количество — лёгкое стадо,
// масштаб по площади суши; геном каждой особи генерируется из шаблона вида (разброс + аллели).
private void SpawnAnimals(GameContent content)
{
if (_animals.Count == 0 || !content.Defs.TryGet<AnimalDef>("Deer", out var deerDef))
{
return;
}
var species = _animals.IndexOf(deerDef);
var template = _animals[species].Template;
var random = new Random(_config.Seed + 0x5EED);
var landCells = 0;
for (var i = 0; i < _cellLand.Length; i++)
{
if (_cellLand[i])
{
landCells++;
}
}
if (landCells == 0)
{
return;
}
var count = Math.Clamp(landCells / 400, 6, 40);
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,
species,
new Vector2(px, py),
template.Generate(random),
generation: 0,
CellSize
);
placed++;
}
Log.Info($"Spawned {placed} deer");
}
// Текущая сетка окклюдеров для лайтмапа: статичные горы + клетки со зрелыми деревьями.
private bool[] BuildOccluders()
{
@@ -552,6 +616,11 @@ 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(
"menu",
"menu — return to the main menu",
@@ -659,6 +728,40 @@ 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[index].Template.Generate(new Random(seed));
var traits = AnimalPhenotype.FromTraits(Phenotype.Compute(genome, _animals.GeneRegistry));
console.WriteLine($"{def.DefName} (seed {seed}):");
console.WriteLine(
$" body {traits.BodySize:0.##} cells, 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.##}"
);
}
private static string ProductLabel(GameContent content, string productDefName) =>
content.Defs.TryGet<ProductDef>(productDefName, out var product)
? content.Languages.Get(product.Label)
+62
View File
@@ -0,0 +1,62 @@
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,
int species,
Vector2 position,
Genome genome,
int generation,
int cellSize
)
{
var sp = animals[species];
var traits = AnimalPhenotype.FromTraits(Phenotype.Compute(genome, animals.GeneRegistry));
var sprite = new Sprite(sp.Region, GameLayers.Beings);
sprite.CenterOrigin();
sprite.Color = Tint(traits);
// Размер спрайта задаёт ген размера тела; деф-fallback — на случай отсутствия гена.
var sizeCells = traits.BodySize > 0f ? traits.BodySize : sp.Def.SizeCells;
return store.CreateEntity(
new Transform2D(position, scale: new Vector2(cellSize * sizeCells / sp.Region.Width)),
sprite,
new AnimalOrganism
{
Genome = genome,
Traits = traits,
Species = species,
Generation = generation,
}
);
}
// Цвет животного из генов: лёгкий тёплый/холодный сдвиг меха (ген furHue) поверх текстуры —
// субтильный, чтобы не перекрашивать уже цветной спрайт зверя (как leafHue у растений).
private static Color Tint(in AnimalPhenotype traits)
{
var fur = Color.Lerp(FurWarm, FurCool, Math.Clamp(traits.FurHue, 0f, 1f));
return Color.Lerp(Color.White, fur, 0.5f);
}
}
+79
View File
@@ -0,0 +1,79 @@
using System.Collections.Generic;
using Friflo.Engine.ECS;
using MrGameEng.Genetics;
namespace LittleSim.Sim;
/// <summary>
/// Признаки (фенотип) животного, вычисленные из генома формулами генов (см. <c>genes.json</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>Продолжительность жизни (игровых дней).</summary>
public float Lifespan;
/// <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"),
Lifespan = T("lifespan"),
};
}
}
/// <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>Номер поколения: max(родителей) + 1; у основателей 0.</summary>
public int Generation;
}