From 58380adc3172335457a7daabb806605835ab57fd Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 14 Jun 2026 22:15:11 +0300 Subject: [PATCH] Skills system: practice-grown abilities with passion (animals now, humans later) Generic data-driven skills (SkillDef + skills.json: Foraging, Hunting, Evasion), mirroring the needs registry. Skills component (per-skill level [0..1] + passion) on every animal, built by AnimalFactory; passion is rolled deterministically from the genome (no RNG param threaded). Levels grow from PRACTICE (XP on the matching action, scaled by passion''s learn-rate) and slowly decay toward a floor when unused (AnimalSkillSystem), so animals specialize. Effects wired now: Foraging -> satiety from grazing, Hunting -> attack damage, Evasion -> flee speed. A missing skill def -> factor 1 (no effect), so it is safe to mod the set. Inspector gains a Skills tab (level% + passion stars); `skills` console command shows live mean levels; skills are saved/loaded (levels+passions). Same model will carry to future humans. Build + --check-content clean. Co-Authored-By: Claude Opus 4.8 --- Mods/Core/Defs/Skills/Skills.json | 14 +++ Mods/Core/Languages/en/ui.json | 5 + Mods/Core/Languages/ru/ui.json | 5 + src/LittleSim/App/WorldSave.cs | 6 ++ src/LittleSim/Content/GameContent.cs | 1 + src/LittleSim/Content/GameDefs.cs | 21 ++++ src/LittleSim/Content/SkillSet.cs | 81 ++++++++++++++++ src/LittleSim/Scenes/WorldScene.cs | 78 +++++++++++++++ src/LittleSim/Sim/AnimalFactory.cs | 25 ++++- src/LittleSim/Sim/AnimalOrganism.cs | 27 ++++++ src/LittleSim/Sim/AnimalSystems.cs | 140 ++++++++++++++++++++++++--- src/LittleSim/UI/InspectPanel.cs | 48 ++++++++- 12 files changed, 437 insertions(+), 14 deletions(-) create mode 100644 Mods/Core/Defs/Skills/Skills.json create mode 100644 src/LittleSim/Content/SkillSet.cs diff --git a/Mods/Core/Defs/Skills/Skills.json b/Mods/Core/Defs/Skills/Skills.json new file mode 100644 index 0000000..1307daf --- /dev/null +++ b/Mods/Core/Defs/Skills/Skills.json @@ -0,0 +1,14 @@ +{ + "type": "Skill", + // Навыки как данные: уровень особи [0..1] растёт от практики (XP при соответствующем действии, + // ускоряется страстью) и медленно угасает к floorLevel. Эффект навыка — код по его id (добыча→выпас, + // охота→урон, уклонение→бегство). Сейчас навыки у животных; те же дефы пригодятся будущему человеку. + "defs": [ + { "defName": "Foraging", "label": "skill.foraging", "category": "survival", + "learnRate": 0.6, "decayPerDay": 0.015, "floorLevel": 0.0 }, + { "defName": "Hunting", "label": "skill.hunting", "category": "survival", + "learnRate": 0.5, "decayPerDay": 0.02, "floorLevel": 0.0 }, + { "defName": "Evasion", "label": "skill.evasion", "category": "survival", + "learnRate": 0.7, "decayPerDay": 0.02, "floorLevel": 0.0 } + ] +} diff --git a/Mods/Core/Languages/en/ui.json b/Mods/Core/Languages/en/ui.json index e5a4aa7..c56c010 100644 --- a/Mods/Core/Languages/en/ui.json +++ b/Mods/Core/Languages/en/ui.json @@ -11,6 +11,7 @@ "inspect.tab.genes": "Genes", "inspect.tab.products": "Products", "inspect.tab.needs": "Needs", + "inspect.tab.skills": "Skills", "inspect.tab.health": "Health", "inspect.tab.mood": "Mood", "inspect.stage": "Stage: {0} ({1}/{2})", @@ -42,6 +43,10 @@ "inspect.animal.sex": "Sex: {0} · generation {1}", "inspect.animal.pregnant": "Pregnant: {0:0.0} d to birth", "inspect.needline": "{0}: {1:0}%", + "inspect.skillline": "{0}: {1:0}% {2}", + "skill.foraging": "foraging", + "skill.hunting": "hunting", + "skill.evasion": "evasion", "inspect.health.blood": "Blood: {0:0}% · pain: {1:0}%", "inspect.health.caps": "— Capacities —", "inspect.health.cap": "{0}: {1:0}%", diff --git a/Mods/Core/Languages/ru/ui.json b/Mods/Core/Languages/ru/ui.json index e5e1bb3..5485b5b 100644 --- a/Mods/Core/Languages/ru/ui.json +++ b/Mods/Core/Languages/ru/ui.json @@ -11,6 +11,7 @@ "inspect.tab.genes": "Гены", "inspect.tab.products": "Продукты", "inspect.tab.needs": "Нужды", + "inspect.tab.skills": "Навыки", "inspect.tab.health": "Здоровье", "inspect.tab.mood": "Настроение", "inspect.stage": "Стадия: {0} ({1}/{2})", @@ -42,6 +43,10 @@ "inspect.animal.sex": "Пол: {0} · поколение {1}", "inspect.animal.pregnant": "Беременна: {0:0.0} дн до родов", "inspect.needline": "{0}: {1:0}%", + "inspect.skillline": "{0}: {1:0}% {2}", + "skill.foraging": "добыча корма", + "skill.hunting": "охота", + "skill.evasion": "уклонение", "inspect.health.blood": "Кровь: {0:0}% · боль: {1:0}%", "inspect.health.caps": "— Способности —", "inspect.health.cap": "{0}: {1:0}%", diff --git a/src/LittleSim/App/WorldSave.cs b/src/LittleSim/App/WorldSave.cs index 752672c..3442b56 100644 --- a/src/LittleSim/App/WorldSave.cs +++ b/src/LittleSim/App/WorldSave.cs @@ -149,6 +149,12 @@ public sealed class AnimalSave /// Размер помёта. public int Litter { get; set; } + + /// Уровни навыков [0..1] по индексам SkillSet. + public float[] SkillLevels { get; set; } = []; + + /// Страсть к навыкам (0/1/2) по индексам SkillSet. + public int[] SkillPassions { get; set; } = []; } /// Сериализуемый труп (фаза A5): вид (для спрайта), позиция, таймер/стадия разложения, мясо. diff --git a/src/LittleSim/Content/GameContent.cs b/src/LittleSim/Content/GameContent.cs index 90803d7..736e443 100644 --- a/src/LittleSim/Content/GameContent.cs +++ b/src/LittleSim/Content/GameContent.cs @@ -68,6 +68,7 @@ public sealed class GameContent defs.RegisterType("Need"); defs.RegisterType("Thought"); defs.RegisterType("Body"); + defs.RegisterType("Skill"); defs.RegisterType("WorldPreset"); // Валидация имён при загрузке (фаза G5): гены и продукты обязаны иметь префикс типа. defs.RegisterValidator("Gene", "defName", "^Gene", "gene defName must start with 'Gene'"); diff --git a/src/LittleSim/Content/GameDefs.cs b/src/LittleSim/Content/GameDefs.cs index 32a427c..d833ffb 100644 --- a/src/LittleSim/Content/GameDefs.cs +++ b/src/LittleSim/Content/GameDefs.cs @@ -368,6 +368,27 @@ public sealed class ThoughtDef : Def public float DurationDays { get; init; } = 1f; } +/// +/// Навык (Defs/Skills/) как ДАННЫЕ: уровень особи [0..1] растёт от практики (XP при действии, +/// ускоряется страстью) и медленно угасает без неё к полу . Эффект навыка — +/// код по его id (как у нужд/действий): добыча→выпас, охота→урон, уклонение→бегство. Модер добавляет +/// навык строкой JSON; новый эффект — код. — ключ локализации (skill.*). +/// +public sealed class SkillDef : Def +{ + /// Категория (для группировки в UI), напр. "survival". + public string Category { get; init; } = ""; + + /// Скорость обучения за игровой день непрерывной практики (при страсти ×1). + public float LearnRate { get; init; } = 0.5f; + + /// Угасание уровня за игровой день без практики. + public float DecayPerDay { get; init; } = 0.02f; + + /// Пол угасания (ниже не падает) — «не забывается совсем». + public float FloorLevel { get; init; } +} + /// /// Часть тела/орган (вложенный объект ) как ДАННЫЕ: иерархия (родитель), /// вес попадания, максимум HP (масштабируется размером тела), флаг жизненной важности и вклады в diff --git a/src/LittleSim/Content/SkillSet.cs b/src/LittleSim/Content/SkillSet.cs new file mode 100644 index 0000000..67ee459 --- /dev/null +++ b/src/LittleSim/Content/SkillSet.cs @@ -0,0 +1,81 @@ +namespace LittleSim.Content; + +/// +/// Готовый реестр навыков из : каждому навыку — индекс (по нему системы читают +/// уровни в Skills.Levels без словарей), плюс генерация стартовых уровней и страсти особи. +/// Делает набор навыков расширяемым данными (как ): добавил — +/// у особей появляется навык, без правки кода (если его эффект/начисление XP уже есть в коде). +/// +public sealed class SkillSet +{ + // Веса страсти при генерации особи: нет / интерес / страсть (множители обучения см. PassionMultiplier). + private static readonly float[] PassionWeights = [0.6f, 0.3f, 0.1f]; + + private readonly SkillDef[] _defs; + private readonly Dictionary _byId = new(StringComparer.Ordinal); + + /// Строит реестр из всех мода (порядок = индексы навыков). + public SkillSet(GameContent content) + { + _defs = content.Defs.All().ToArray(); + for (var i = 0; i < _defs.Length; i++) + { + _byId[_defs[i].DefName] = i; + } + } + + /// Число навыков. + public int Count => _defs.Length; + + /// Деф навыка по индексу. + public SkillDef this[int index] => _defs[index]; + + /// Индекс навыка по id, или -1. + public int IndexOf(string id) => _byId.TryGetValue(id, out var index) ? index : -1; + + /// Стартовые уровни особи (с пола угасания каждого навыка). + public float[] NewLevels() + { + var levels = new float[_defs.Length]; + for (var i = 0; i < levels.Length; i++) + { + levels[i] = _defs[i].FloorLevel; + } + + return levels; + } + + /// Бросает страсть к каждому навыку (0 нет / 1 интерес / 2 страсть) детерминированно по rng. + public byte[] RollPassions(Random rng) + { + var passions = new byte[_defs.Length]; + for (var i = 0; i < passions.Length; i++) + { + var roll = rng.NextSingle(); + byte p = 0; + var acc = 0f; + for (byte k = 0; k < PassionWeights.Length; k++) + { + acc += PassionWeights[k]; + if (roll < acc) + { + p = k; + break; + } + } + + passions[i] = p; + } + + return passions; + } + + /// Множитель скорости обучения по уровню страсти (нет/интерес/страсть). + public static float PassionMultiplier(byte passion) => + passion switch + { + 0 => 0.35f, + 1 => 1.0f, + _ => 1.7f, + }; +} diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs index b548a6d..3758720 100644 --- a/src/LittleSim/Scenes/WorldScene.cs +++ b/src/LittleSim/Scenes/WorldScene.cs @@ -67,6 +67,7 @@ public sealed class WorldScene : Scene private PlantSet _plants = null!; private AnimalSet _animals = null!; private NeedSet _needs = null!; + private SkillSet _skills = null!; private float[] _cellFertility = []; private bool[] _cellLand = []; private bool[] _cellOccluderBase = []; // горы (статично из террейна) @@ -113,6 +114,7 @@ public sealed class WorldScene : Scene _plants = new PlantSet(content, atlases, device); _animals = new AnimalSet(content, atlases, device); _needs = new NeedSet(content); + _skills = new SkillSet(content); var loadingPlants = _save?.Plants is { Count: > 0 }; BuildTerrain( content, @@ -175,6 +177,7 @@ public sealed class WorldScene : Scene _plants, _animals, _needs, + _skills, content, climate, _selection, @@ -242,6 +245,7 @@ public sealed class WorldScene : Scene var shore = ComputeShore(); var thoughts = new ThoughtSet(content); UpdateSystems.Add(new AnimalNeedsSystem(Context.Clock, SecondsPerDay, _needs, _animals)); + UpdateSystems.Add(new AnimalSkillSystem(Context.Clock, SecondsPerDay, _skills)); UpdateSystems.Add( new AnimalRutSystem(_animals, climate, content.Defs.Get("Rut")) ); @@ -281,6 +285,7 @@ public sealed class WorldScene : Scene _plants, _animals, _needs, + _skills, thoughts, Context.Clock, SecondsPerDay, @@ -300,6 +305,7 @@ public sealed class WorldScene : Scene Store, _animals, _needs, + _skills, thoughts, Context.Clock, SecondsPerDay, @@ -313,6 +319,7 @@ public sealed class WorldScene : Scene Store, _animals, _needs, + _skills, Context.Clock, SecondsPerDay, CellSize, @@ -632,6 +639,14 @@ public sealed class WorldScene : Scene record.Litter = preg.Litter; } + if (entity.TryGetComponent(out var sk)) + { + record.SkillLevels = sk.Levels ?? []; + record.SkillPassions = sk.Passions is null + ? [] + : Array.ConvertAll(sk.Passions, p => (int)p); + } + save.Animals.Add(record); } ); @@ -719,6 +734,7 @@ public sealed class WorldScene : Scene Store, _animals, _needs, + _skills, index, new Vector2(saved.X, saved.Y), saved.AgeDays, @@ -835,6 +851,23 @@ public sealed class WorldScene : Scene } ); } + + // Навыки: накладываем сохранённые уровни/страсть, если состав совпал (иначе — свежие из фабрики). + if (entity.TryGetComponent(out var skills) && skills.Levels is not null) + { + if (saved.SkillLevels.Length == skills.Levels.Length) + { + saved.SkillLevels.CopyTo(skills.Levels, 0); + } + + if (skills.Passions is not null && saved.SkillPassions.Length == skills.Passions.Length) + { + for (var i = 0; i < skills.Passions.Length; i++) + { + skills.Passions[i] = (byte)Math.Clamp(saved.SkillPassions[i], 0, 255); + } + } + } } // Детерминированный начальный спавн животных по данным: каждый вид с SpawnPer1000Cells > 0 @@ -888,6 +921,7 @@ public sealed class WorldScene : Scene Store, _animals, _needs, + _skills, s, new Vector2(px, py), ageDays: random.NextSingle() * maxAge, @@ -1066,6 +1100,11 @@ public sealed class WorldScene : Scene "mood [species] — live population mood (avg/min/max) and content/stressed counts", (c, args) => RunMood(c, args) ); + console.Register( + "skills", + "skills — mean skill levels across living animals (grow from practice, decay unused)", + (c, _) => RunSkills(c) + ); console.Register( "menu", "menu — return to the main menu", @@ -1566,6 +1605,45 @@ public sealed class WorldScene : Scene } } + // Наблюдаемость навыков: средний уровень каждого навыка по живым животным (растут от практики). + private void RunSkills(DevConsole console) + { + if (_skills.Count == 0) + { + console.WriteLine("no skill defs loaded"); + return; + } + + var sum = new double[_skills.Count]; + var n = 0; + Store + .Query() + .ForEachEntity( + (ref Skills s, Entity _) => + { + if (s.Levels is null) + { + return; + } + + n++; + for (var i = 0; i < _skills.Count && i < s.Levels.Length; i++) + { + sum[i] += s.Levels[i]; + } + } + ); + + console.WriteLine($"skills over {n} animals (mean level):"); + for (var i = 0; i < _skills.Count; i++) + { + console.WriteLine( + $" {_skills[i].DefName}: {(n > 0 ? sum[i] / n : 0):0.###} " + + $"(learn {_skills[i].LearnRate:0.##}/d, decay {_skills[i].DecayPerDay:0.###}/d)" + ); + } + } + private static string ProductLabel(GameContent content, string productDefName) => content.Defs.TryGet(productDefName, out var product) ? content.Languages.Get(product.Label) diff --git a/src/LittleSim/Sim/AnimalFactory.cs b/src/LittleSim/Sim/AnimalFactory.cs index 666ff0f..1d15c7c 100644 --- a/src/LittleSim/Sim/AnimalFactory.cs +++ b/src/LittleSim/Sim/AnimalFactory.cs @@ -23,6 +23,7 @@ public static class AnimalFactory EntityStore store, AnimalSet animals, NeedSet needs, + SkillSet skills, int species, Vector2 position, float ageDays, @@ -41,7 +42,7 @@ public static class AnimalFactory sprite.CenterOrigin(); sprite.Color = FurTint(traits); - return store.CreateEntity( + var entity = store.CreateEntity( new Transform2D( position, scale: new Vector2(Scale(sp, traits, stage, region, cellSize)) @@ -73,6 +74,28 @@ public static class AnimalFactory Thoughts = [], } ); + + // Навыки: стартовые уровни + страсть (детерминированно по геному — воспроизводимо без RNG-параметра). + entity.AddComponent( + new Skills + { + Levels = skills.NewLevels(), + Passions = skills.RollPassions(new Random(SkillSeed(genome) ^ species)), + } + ); + return entity; + } + + // Стабильный сид из генома (сумма аллелей — порядконезависима) для детерминированной страсти к навыкам. + private static int SkillSeed(Genome genome) + { + var acc = 0; + foreach (var (_, allele) in genome.ToDictionary()) + { + acc += (int)(allele.A * 7919f) + (int)(allele.B * 104729f); + } + + return acc; } // Тинт свежего трупа (стадии гниения/скелета задаёт CorpseSystem). diff --git a/src/LittleSim/Sim/AnimalOrganism.cs b/src/LittleSim/Sim/AnimalOrganism.cs index 394eac3..61e7102 100644 --- a/src/LittleSim/Sim/AnimalOrganism.cs +++ b/src/LittleSim/Sim/AnimalOrganism.cs @@ -491,6 +491,33 @@ public struct Egg : IComponent public float IncubateDays; } +/// Id навыков-эффектов (на них ссылается код начисления XP и применения бонуса). +public static class AnimalSkills +{ + /// Добыча корма: эффективность выпаса/поедания растений. + public const string Foraging = "Foraging"; + + /// Охота: урон по добыче. + public const string Hunting = "Hunting"; + + /// Уклонение: скорость бегства от хищника. + public const string Evasion = "Evasion"; +} + +/// +/// Навыки особи (как нужды/способности — managed-массивы по индексам ): +/// уровни [0..1] (растут от практики, угасают без неё) и страсть к каждому навыку (скорость обучения). +/// Есть у животных; те же навыки получат будущие люди. Добавление навыка не меняет структуру. +/// +public struct Skills : IComponent +{ + /// Уровни навыков [0..1] по индексам . + public float[] Levels; + + /// Страсть к каждому навыку (0 нет / 1 интерес / 2 страсть) — множитель обучения. + public byte[] Passions; +} + /// /// Ноша существа (задел под перенос предметов/хаул): суммарная масса переносимого груза в кг. Перегруз /// сверх грузоподъёмности () замедляет движение. Компонент появляется diff --git a/src/LittleSim/Sim/AnimalSystems.cs b/src/LittleSim/Sim/AnimalSystems.cs index c9ef9f1..4b03c52 100644 --- a/src/LittleSim/Sim/AnimalSystems.cs +++ b/src/LittleSim/Sim/AnimalSystems.cs @@ -884,6 +884,7 @@ public sealed class AnimalActionSystem : BaseSystem private readonly PlantSet _plants; private readonly AnimalSet _animalSet; private readonly NeedSet _needs; + private readonly SkillSet _skills; private readonly ThoughtSet _thoughts; private readonly GameClock _clock; private readonly float _secondsPerDay; @@ -892,6 +893,12 @@ public sealed class AnimalActionSystem : BaseSystem private readonly HediffDef? _bleeding; private readonly HediffDef? _poisoned; private readonly Random _rng; + + // Кэш индексов навыков-эффектов (−1, если навык не определён модом — тогда эффекта/XP нет). + private readonly int _forageSkill; + private readonly int _huntSkill; + private readonly int _evadeSkill; + private readonly ArchetypeQuery< AnimalBrain, AnimalNeeds, @@ -910,6 +917,7 @@ public sealed class AnimalActionSystem : BaseSystem PlantSet plants, AnimalSet animals, NeedSet needs, + SkillSet skills, ThoughtSet thoughts, GameClock clock, float secondsPerDay, @@ -924,6 +932,10 @@ public sealed class AnimalActionSystem : BaseSystem _plants = plants; _animalSet = animals; _needs = needs; + _skills = skills; + _forageSkill = skills.IndexOf(AnimalSkills.Foraging); + _huntSkill = skills.IndexOf(AnimalSkills.Hunting); + _evadeSkill = skills.IndexOf(AnimalSkills.Evasion); _thoughts = thoughts; _clock = clock; _secondsPerDay = secondsPerDay; @@ -972,11 +984,9 @@ public sealed class AnimalActionSystem : BaseSystem // даёт сытость с корма; раненая челюсть/больной желудок → зверь голоднее (опция 1). var eating = hh[i].State.Capacity(AnimalCapacities.Eating); var foodEff = eating * hh[i].State.Capacity(AnimalCapacities.Digestion); + var self = entities.EntityAt(i); // Ноша замедляет (задел под перенос): перегруз сверх грузоподъёмности роняет скорость. - if ( - entities.EntityAt(i).TryGetComponent(out var carrier) - && carrier.CarriedKg > 0f - ) + if (self.TryGetComponent(out var carrier) && carrier.CarriedKg > 0f) { var sp = _animalSet[o[i].Species]; var stageScale = entities.EntityAt(i).TryGetComponent(out var gr) @@ -998,7 +1008,9 @@ public sealed class AnimalActionSystem : BaseSystem case AnimalActions.Eat: if (MoveTo(ref pos, brain.Target, speed * seconds)) { - Refill(values, AnimalActions.Eat, days, foodEff); + // Навык добычи корма: опытный форажир извлекает больше сытости; растёт от практики. + var forageF = SkillFactor(_forageSkill, self); + Refill(values, AnimalActions.Eat, days, foodEff * forageF); Graze( brain.TargetPlant, days, @@ -1012,7 +1024,8 @@ public sealed class AnimalActionSystem : BaseSystem days ); // Эндозоохория: шанс проглотить семя зрелого растения (высадит позже). - TryIngestSeed(brain.TargetPlant, entities.EntityAt(i)); + TryIngestSeed(brain.TargetPlant, self); + GrantXp(_forageSkill, self, days); } break; @@ -1057,11 +1070,28 @@ public sealed class AnimalActionSystem : BaseSystem break; case AnimalActions.Hunt: - Hunt(ref brain, ref pos, values, def, speed * seconds, days, foodEff); + // Навык охоты: опытный хищник наносит больше урона; растёт от практики. + Hunt( + ref brain, + ref pos, + values, + def, + speed * seconds, + days, + foodEff, + SkillFactor(_huntSkill, self) + ); + GrantXp(_huntSkill, self, days); break; case AnimalActions.Flee: - MoveTo(ref pos, brain.Target, speed * FleeSpeedMult * seconds); + // Навык уклонения: опытная жертва убегает быстрее; растёт от практики бегства. + MoveTo( + ref pos, + brain.Target, + speed * FleeSpeedMult * SkillFactor(_evadeSkill, self) * seconds + ); + GrantXp(_evadeSkill, self, days); break; default: // Wander @@ -1151,7 +1181,8 @@ public sealed class AnimalActionSystem : BaseSystem AnimalDef def, float step, float days, - float foodEff + float foodEff, + float huntFactor ) { if ( @@ -1211,10 +1242,10 @@ public sealed class AnimalActionSystem : BaseSystem } preyHealth.ApplyInjury( - def.AttackDamage * days, - def.AttackBloodLoss * days, + def.AttackDamage * days * huntFactor, + def.AttackBloodLoss * days * huntFactor, _bleeding, - def.AttackBleed * days, + def.AttackBleed * days * huntFactor, _rng ); if ( @@ -1246,6 +1277,44 @@ public sealed class AnimalActionSystem : BaseSystem ); } + // Бонус-множитель от уровня навыка: 0.7 (новичок) … 1.3 (мастер). Навык не определён модом → 1 (без эффекта). + private float SkillFactor(int idx, Entity self) + { + if ( + idx < 0 + || !self.TryGetComponent(out var s) + || s.Levels is null + || idx >= s.Levels.Length + ) + { + return 1f; + } + + return 0.7f + 0.6f * s.Levels[idx]; + } + + // Начисляет опыт навыку от практики: рост ∝ скорость обучения × страсть × (1−уровень) (убывающая отдача). + private void GrantXp(int idx, Entity self, float days) + { + if ( + idx < 0 + || !self.TryGetComponent(out var s) + || s.Levels is null + || idx >= s.Levels.Length + ) + { + return; + } + + var passion = s.Passions is not null && idx < s.Passions.Length ? s.Passions[idx] : (byte)0; + var gain = + _skills[idx].LearnRate + * SkillSet.PassionMultiplier(passion) + * days + * (1f - s.Levels[idx]); + s.Levels[idx] = Math.Clamp(s.Levels[idx] + gain, 0f, 1f); + } + // Двигает позицию к цели на step; возвращает true, если уже у цели (можно исполнять действие). private static bool MoveTo(ref Vector2 pos, Vector2 target, float step) { @@ -1742,6 +1811,7 @@ public sealed class AnimalPregnancySystem : BaseSystem private readonly EntityStore _store; private readonly AnimalSet _animals; private readonly NeedSet _needs; + private readonly SkillSet _skills; private readonly ThoughtSet _thoughts; private readonly GameClock _clock; private readonly float _secondsPerDay; @@ -1756,6 +1826,7 @@ public sealed class AnimalPregnancySystem : BaseSystem EntityStore store, AnimalSet animals, NeedSet needs, + SkillSet skills, ThoughtSet thoughts, GameClock clock, float secondsPerDay, @@ -1767,6 +1838,7 @@ public sealed class AnimalPregnancySystem : BaseSystem _store = store; _animals = animals; _needs = needs; + _skills = skills; _thoughts = thoughts; _clock = clock; _secondsPerDay = secondsPerDay; @@ -1861,6 +1933,7 @@ public sealed class AnimalPregnancySystem : BaseSystem _store, _animals, _needs, + _skills, birth.Species, birth.Position + offset, ageDays: 0f, @@ -1899,6 +1972,7 @@ public sealed class EggSystem : BaseSystem private readonly EntityStore _store; private readonly AnimalSet _animals; private readonly NeedSet _needs; + private readonly SkillSet _skills; private readonly GameClock _clock; private readonly float _secondsPerDay; private readonly int _cellSize; @@ -1910,6 +1984,7 @@ public sealed class EggSystem : BaseSystem EntityStore store, AnimalSet animals, NeedSet needs, + SkillSet skills, GameClock clock, float secondsPerDay, int cellSize, @@ -1919,6 +1994,7 @@ public sealed class EggSystem : BaseSystem _store = store; _animals = animals; _needs = needs; + _skills = skills; _clock = clock; _secondsPerDay = secondsPerDay; _cellSize = cellSize; @@ -1953,6 +2029,7 @@ public sealed class EggSystem : BaseSystem _store, _animals, _needs, + _skills, egg.Species, transforms.Span[i].Position + offset, ageDays: 0f, @@ -2274,3 +2351,42 @@ public sealed class SeedDispersalSystem : BaseSystem } } } + +/// +/// Угасание навыков без практики: каждый уровень медленно сползает к полу своего +/// (рост идёт от использования в ). Так навык, которым не пользуются, +/// постепенно забывается — есть давление на специализацию. +/// +public sealed class AnimalSkillSystem(GameClock clock, float secondsPerDay, SkillSet skills) + : QuerySystem +{ + protected override void OnUpdate() + { + var days = clock.DeltaTime / secondsPerDay; + if (days <= 0f) + { + return; + } + + foreach (var (skillChunk, _) in Query.Chunks) + { + var s = skillChunk.Span; + for (var i = 0; i < s.Length; i++) + { + var levels = s[i].Levels; + if (levels is null) + { + continue; + } + + for (var k = 0; k < skills.Count && k < levels.Length; k++) + { + levels[k] = MathF.Max( + skills[k].FloorLevel, + levels[k] - skills[k].DecayPerDay * days + ); + } + } + } + } +} diff --git a/src/LittleSim/UI/InspectPanel.cs b/src/LittleSim/UI/InspectPanel.cs index 9b8dd8c..3949fe8 100644 --- a/src/LittleSim/UI/InspectPanel.cs +++ b/src/LittleSim/UI/InspectPanel.cs @@ -31,17 +31,26 @@ internal sealed class InspectPanel Genes, Products, Needs, + Skills, 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 static readonly Tab[] AnimalTabs = + [ + Tab.Overview, + Tab.Needs, + Tab.Skills, + Tab.Health, + Tab.Mood, + ]; private readonly EntityStore _store; private readonly PlantSet _plants; private readonly AnimalSet _animals; private readonly NeedSet _needs; + private readonly SkillSet _skills; private readonly GameContent _content; private readonly Climate _climate; private readonly Selection _selection; @@ -58,6 +67,7 @@ internal sealed class InspectPanel PlantSet plants, AnimalSet animals, NeedSet needs, + SkillSet skills, GameContent content, Climate climate, Selection selection, @@ -68,6 +78,7 @@ internal sealed class InspectPanel _plants = plants; _animals = animals; _needs = needs; + _skills = skills; _content = content; _climate = climate; _selection = selection; @@ -81,6 +92,7 @@ internal sealed class InspectPanel AddTab(tabBar, Tab.Genes, "inspect.tab.genes"); AddTab(tabBar, Tab.Products, "inspect.tab.products"); AddTab(tabBar, Tab.Needs, "inspect.tab.needs"); + AddTab(tabBar, Tab.Skills, "inspect.tab.skills"); AddTab(tabBar, Tab.Health, "inspect.tab.health"); AddTab(tabBar, Tab.Mood, "inspect.tab.mood"); @@ -246,6 +258,9 @@ internal sealed class InspectPanel case Tab.Needs: BuildAnimalNeeds(text, entity); break; + case Tab.Skills: + BuildAnimalSkills(text, entity); + break; case Tab.Health: BuildAnimalHealth(text, entity); break; @@ -446,6 +461,37 @@ internal sealed class InspectPanel } } + // — Животное: Навыки — уровень каждого навыка (%) и страсть (★/★★), растут от практики. + private void BuildAnimalSkills(StringBuilder text, Entity entity) + { + var languages = _content.Languages; + if (!entity.TryGetComponent(out var sk) || sk.Levels is null) + { + text.AppendLine(languages.Get("inspect.health.none")); + return; + } + + for (var i = 0; i < _skills.Count && i < sk.Levels.Length; i++) + { + var passion = + sk.Passions is not null && i < sk.Passions.Length ? sk.Passions[i] : (byte)0; + var stars = passion switch + { + 2 => "★★", + 1 => "★", + _ => "", + }; + text.AppendLine( + languages.Format( + "inspect.skillline", + languages.Get(_skills[i].Label), + sk.Levels[i] * 100f, + stars + ) + ); + } + } + // — Животное: Здоровье — кровь/боль, ключевые способности, активные хедифы (раны/болезни/гон). private void BuildAnimalHealth(StringBuilder text, Entity entity) {