Животные A8 часть 1: настроение — ядро (ThoughtDef + Mood + MoodSystem)

Тип дефа ThoughtDef + thoughts.json (роды/спаривание/жажда — сила и длительность данными). Компонент Mood (значение 0..1, активность по тиру мозга, список затухающих мыслей) на всех животных; AnimalMoodSystem (тир 4+: настроение из непрерывного фона — средняя удовлетворённость убывающих нужд минус боль — плюс сумма мыслей). Реестр ThoughtSet + id-константы AnimalThoughts; триггеры: роды (PregnancySystem), спаривание и утоление жажды (ActionSystem). Низкое настроение подавляет спаривание (DecisionSystem). Команда mood [species]. Локализация thought.* ru/en. Сборка + --check-content чистые (11 типов дефов). Осталось A8.2 — вкладки инспектора.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-14 06:13:46 +03:00
co-authored by Claude Opus 4.8
parent 26bf83db64
commit 1b12831817
11 changed files with 360 additions and 4 deletions
+1
View File
@@ -66,6 +66,7 @@ public sealed class GameContent
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): гены и продукты обязаны иметь префикс типа.
+15
View File
@@ -320,6 +320,21 @@ public sealed class NeedDef : Def
public float MinBrain { get; init; }
}
/// <summary>
/// Мысль настроения (Defs/thoughts.json) как ДАННЫЕ (фаза 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 (масштабируется размером тела), флаг жизненной важности и вклады в
+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);
}
+83
View File
@@ -214,6 +214,7 @@ public sealed class WorldScene : Scene
// Животные (фаза 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"))
@@ -229,6 +230,8 @@ public sealed class WorldScene : Scene
_config.Seed + 0x3EAD
)
);
// Настроение (A8) до выбора решения: стресс (низкое настроение) подавляет спаривание.
UpdateSystems.Add(new AnimalMoodSystem(Context.Clock, SecondsPerDay, _needs));
UpdateSystems.Add(
new AnimalDecisionSystem(
Store,
@@ -246,6 +249,7 @@ public sealed class WorldScene : Scene
_plants,
_animals,
_needs,
thoughts,
Context.Clock,
SecondsPerDay,
_bounds
@@ -260,6 +264,7 @@ public sealed class WorldScene : Scene
Store,
_animals,
_needs,
thoughts,
Context.Clock,
SecondsPerDay,
CellSize,
@@ -738,6 +743,11 @@ public sealed class WorldScene : Scene
"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",
@@ -1071,6 +1081,79 @@ public sealed class WorldScene : Scene
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)
+8 -1
View File
@@ -64,7 +64,14 @@ public static class AnimalFactory
TargetPlant = -1,
TargetMate = -1,
},
new Health { State = HealthState.Create(sp.Body, traits.BodySize) }
new Health { State = HealthState.Create(sp.Body, traits.BodySize) },
// Настроение (фаза A8): нейтральное на старте, активность по тиру мозга считает MoodSystem.
new Mood
{
Value = 0.5f,
Active = false,
Thoughts = [],
}
);
}
+54
View File
@@ -326,6 +326,60 @@ public struct Corpse : IComponent
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"/>
+146 -1
View File
@@ -24,6 +24,18 @@ public static class AnimalActions
public const string Mate = "Mate";
}
/// <summary>
/// Id событийных мыслей настроения (фаза A8). Системы-источники события вешают мысль с этим id (через
/// <see cref="Content.ThoughtSet"/>); её сила/длительность/метка — данные (thoughts.json). Новая мысль на
/// новое событие = код-триггер + деф; правка чисел существующей — только JSON.
/// </summary>
public static class AnimalThoughts
{
public const string GaveBirth = "GaveBirth";
public const string Mated = "Mated";
public const string QuenchedThirst = "QuenchedThirst";
}
/// <summary>
/// Нужды зверя как ДАННЫЕ (рефактор расширяемости): значения по индексам из <see cref="NeedSet"/>
/// (managed-массив — добавление нужды не меняет структуру) + счётчик истощения. Падают/растут в
@@ -197,6 +209,7 @@ public sealed class AnimalRutSystem(AnimalSet animals, Climate climate, HediffDe
public sealed class AnimalDecisionSystem : BaseSystem
{
private const float Interval = 0.6f;
private const float MateMoodFloor = 0.35f; // ниже — стресс/истощение, зверь не спаривается (A8)
private readonly UtilityAi<AnimalContext> _brain;
private readonly GameClock _clock;
@@ -294,12 +307,15 @@ public sealed class AnimalDecisionSystem : BaseSystem
var pos = t[i].Position;
var adult = AnimalFactory.IsAdult(_animalSet[o[i].Species], gr[i].Stage);
var radius = VisionRadius(o[i].Species, o[i].Traits.Vision);
var self = entities.EntityAt(i);
// Эффективный интеллект (мозг × сознание) гейтит доступные нужды/действия (фаза A7):
// больной/раненый зверь с упавшим сознанием теряет высшие нужды (сон/секс) → рефлексы.
var intelligence = AnimalFactory.Intelligence(
o[i].Traits,
entities.EntityAt(i).GetComponent<Health>().State
self.GetComponent<Health>().State
);
// Настроение (A8): стресс/истощение (низкое настроение) подавляет тягу к спариванию.
var mood = self.GetComponent<Mood>();
var name =
_brain.Select(new AnimalContext(n[i].Values, _needs, intelligence))?.Name
?? AnimalActions.Wander;
@@ -324,6 +340,7 @@ public sealed class AnimalDecisionSystem : BaseSystem
break;
case AnimalActions.Mate
when adult
&& (!mood.Active || mood.Value >= MateMoodFloor)
&& TryFindMate(
pos,
o[i].IsMale,
@@ -478,11 +495,13 @@ public sealed class AnimalDecisionSystem : BaseSystem
public sealed class AnimalActionSystem : BaseSystem
{
private const float GrazeKillAge = 1f; // трава с возрастом ниже этого после выедания исчезает
private const float ThirstReliefBelow = 0.15f; // ниже этого жажда — питьё даёт мысль «напилась»
private readonly EntityStore _store;
private readonly PlantSet _plants;
private readonly AnimalSet _animalSet;
private readonly NeedSet _needs;
private readonly ThoughtSet _thoughts;
private readonly GameClock _clock;
private readonly float _secondsPerDay;
private readonly RectF _bounds;
@@ -501,6 +520,7 @@ public sealed class AnimalActionSystem : BaseSystem
PlantSet plants,
AnimalSet animals,
NeedSet needs,
ThoughtSet thoughts,
GameClock clock,
float secondsPerDay,
RectF bounds
@@ -510,6 +530,7 @@ public sealed class AnimalActionSystem : BaseSystem
_plants = plants;
_animalSet = animals;
_needs = needs;
_thoughts = thoughts;
_clock = clock;
_secondsPerDay = secondsPerDay;
_bounds = bounds;
@@ -565,7 +586,21 @@ public sealed class AnimalActionSystem : BaseSystem
case AnimalActions.Drink:
if (MoveTo(ref pos, brain.Target, speed * seconds))
{
// Утоление сильной жажды — событийная мысль настроения (A8); уровень берём
// ДО восполнения, чтобы радость была от облегчения, а не от каждого глотка.
var thirst = _needs.NeedForAction(AnimalActions.Drink);
var parched =
thirst >= 0
&& thirst < values.Length
&& values[thirst] < ThirstReliefBelow;
Refill(values, AnimalActions.Drink, days);
if (parched)
{
entities
.EntityAt(i)
.GetComponent<Mood>()
.Add(_thoughts.Get(AnimalThoughts.QuenchedThirst));
}
}
break;
@@ -695,6 +730,10 @@ public sealed class AnimalActionSystem : BaseSystem
ResetMating(a, mateNeed);
ResetMating(b, mateNeed);
// Спаривание — положительная событийная мысль обоим партнёрам (A8).
var mated = _thoughts.Get(AnimalThoughts.Mated);
a.GetComponent<Mood>().Add(mated);
b.GetComponent<Mood>().Add(mated);
}
}
@@ -713,6 +752,103 @@ public sealed class AnimalActionSystem : BaseSystem
}
}
/// <summary>
/// Настроение (фаза A8): для особей тира 4+ (мозг ≥ порога) считает настроение из непрерывного фона
/// (удовлетворённость убывающих нужд минус боль из здоровья) и суммы затухающих событийных мыслей.
/// Мысли угасают со временем. У примитивных (тир &lt; 4) настроения нет (Active=false, мысли сбрасываются).
/// Гейт интеллектом — тем же порогом тира 4, что и высшие нужды (сон/секс). Низкое настроение влияет на
/// поведение (стресс не даёт спариваться — см. <see cref="AnimalDecisionSystem"/>) и видно в консоли/инспекторе.
/// </summary>
public sealed class AnimalMoodSystem(GameClock clock, float secondsPerDay, NeedSet needs)
: QuerySystem<Mood, AnimalNeeds, AnimalOrganism, Health>
{
/// <summary>Порог размера мозга (тир 4), с которого у особи появляется настроение.</summary>
public const float MoodMinBrain = 0.40f;
private const float NeutralBase = 0.35f; // настроение при полном истощении нужд (без боли/мыслей)
private const float NeedsWeight = 0.55f; // вклад средней удовлетворённости убывающих нужд
private const float PainWeight = 0.4f; // боль из здоровья тянет настроение вниз
protected override void OnUpdate()
{
var days = clock.DeltaTime / secondsPerDay;
if (days <= 0f)
{
return;
}
foreach (var (moods, needsChunk, organisms, healths, _) in Query.Chunks)
{
var m = moods.Span;
var n = needsChunk.Span;
var o = organisms.Span;
var h = healths.Span;
for (var i = 0; i < m.Length; i++)
{
ref var mood = ref m[i];
if (o[i].Traits.BrainSize < MoodMinBrain)
{
mood.Active = false; // тир ниже зверя — настроения нет
mood.Value = 0.5f;
mood.Thoughts?.Clear();
continue;
}
mood.Active = true;
// 1) Затухание событийных мыслей + сумма их сдвигов.
var thoughtSum = 0f;
var list = mood.Thoughts;
if (list is not null)
{
for (var k = list.Count - 1; k >= 0; k--)
{
var t = list[k];
t.RemainingDays -= days;
if (t.RemainingDays <= 0f)
{
list.RemoveAt(k);
continue;
}
list[k] = t;
thoughtSum += t.Def.MoodOffset;
}
}
// 2) Непрерывный фон: средняя удовлетворённость убывающих нужд минус боль из здоровья.
var wellbeing = AvgDeplete(n[i].Values);
var pain = h[i].State?.Pain ?? 0f;
mood.Value = Math.Clamp(
NeutralBase + NeedsWeight * wellbeing - PainWeight * pain + thoughtSum,
0f,
1f
);
}
}
}
// Средняя удовлетворённость убывающих нужд (голод/жажда/усталость) [0,1]; нет нужд → 1 (нейтрально).
private float AvgDeplete(float[] values)
{
if (values is null)
{
return 1f;
}
var sum = 0f;
var count = 0;
for (var k = 0; k < needs.Count && k < values.Length; k++)
{
if (needs.IsDeplete(k))
{
sum += values[k];
count++;
}
}
return count > 0 ? sum / count : 1f;
}
}
/// <summary>
/// Презентация состояния нужд: голодный/уставший зверь тускнеет (яркость падает с самой острой
/// убывающей нуждой), сохраняя оттенок меха из генов. Читает симуляцию, пишет только
@@ -957,6 +1093,7 @@ public sealed class AnimalPregnancySystem : BaseSystem
private readonly EntityStore _store;
private readonly AnimalSet _animals;
private readonly NeedSet _needs;
private readonly ThoughtSet _thoughts;
private readonly GameClock _clock;
private readonly float _secondsPerDay;
private readonly int _cellSize;
@@ -970,6 +1107,7 @@ public sealed class AnimalPregnancySystem : BaseSystem
EntityStore store,
AnimalSet animals,
NeedSet needs,
ThoughtSet thoughts,
GameClock clock,
float secondsPerDay,
int cellSize,
@@ -980,6 +1118,7 @@ public sealed class AnimalPregnancySystem : BaseSystem
_store = store;
_animals = animals;
_needs = needs;
_thoughts = thoughts;
_clock = clock;
_secondsPerDay = secondsPerDay;
_cellSize = cellSize;
@@ -1035,6 +1174,12 @@ public sealed class AnimalPregnancySystem : BaseSystem
birth.Mother.RemoveComponent<Pregnant>();
}
// Роды — сильная положительная мысль матери (A8).
if (birth.Mother.HasComponent<Mood>())
{
birth.Mother.GetComponent<Mood>().Add(_thoughts.Get(AnimalThoughts.GaveBirth));
}
for (var k = 0; k < birth.Litter && count < _maxAnimals; k++)
{
var child = Genome.Breed(