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 dbaca85..1a7272b 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}%", @@ -108,6 +113,16 @@ "settings.resolution": "Resolution", "settings.volume": "Volume", "settings.uiscale": "UI scale", + "settings.devmode": "Developer mode", + "dev.title": "Dev spawner", + "dev.hint": "Pick an entry · F9 to hide", + "dev.armed": "In hand: {0} · LMB to spawn, RMB to clear", + "dev.tools": "Tools", + "dev.damage": "Injure selected", + "dev.kill": "Kill selected", + "dev.infect": "Infect selected", + "dev.animals": "Animals", + "dev.plants": "Plants", "settings.apply": "Apply", "settings.back": "Back", "pause.title": "Paused", diff --git a/Mods/Core/Languages/ru/ui.json b/Mods/Core/Languages/ru/ui.json index ada3961..8550b50 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}%", @@ -108,6 +113,16 @@ "settings.resolution": "Разрешение", "settings.volume": "Громкость", "settings.uiscale": "Масштаб UI", + "settings.devmode": "Режим разработчика", + "dev.title": "Дев-спавнер", + "dev.hint": "Выбери пункт · F9 — скрыть", + "dev.armed": "В руке: {0} · ЛКМ — спавн, ПКМ — снять", + "dev.tools": "Инструменты", + "dev.damage": "Ранить выбранного", + "dev.kill": "Убить выбранного", + "dev.infect": "Заразить выбранного", + "dev.animals": "Животные", + "dev.plants": "Растения", "settings.apply": "Применить", "settings.back": "Назад", "pause.title": "Пауза", diff --git a/src/LittleSim/App/GameSettings.cs b/src/LittleSim/App/GameSettings.cs index 27a0e88..0326c4d 100644 --- a/src/LittleSim/App/GameSettings.cs +++ b/src/LittleSim/App/GameSettings.cs @@ -31,6 +31,9 @@ public sealed class GameSettings /// Масштаб интерфейса (1 — обычный); применяется к Myra-десктопу всех сцен. public float UiScale { get; set; } = 1f; + + /// Режим разработчика: открывает дев-консоль (backtick) и дев-спавнер. По умолчанию включён. + public bool DeveloperMode { get; set; } = true; } /// Чтение/запись и применение их к движку. 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..77c6749 100644 --- a/src/LittleSim/Content/GameContent.cs +++ b/src/LittleSim/Content/GameContent.cs @@ -68,6 +68,8 @@ public sealed class GameContent defs.RegisterType("Need"); defs.RegisterType("Thought"); defs.RegisterType("Body"); + defs.RegisterType("Skill"); + defs.RegisterType("DevTool"); 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..9a764da 100644 --- a/src/LittleSim/Content/GameDefs.cs +++ b/src/LittleSim/Content/GameDefs.cs @@ -368,6 +368,45 @@ public sealed class ThoughtDef : Def public float DurationDays { get; init; } = 1f; } +/// +/// Пункт дев-инструмента (Defs/DevTools/) — ЧТОБЫ МОДЫ ДОБАВЛЯЛИ кнопки в дев-спавнер без правки кода. +/// — id обработчика из реестра (spawn-animal/spawn-plant/infect/damage/kill/…), +/// — параметр (defName вида/хедифа). Виды животных/растений спавнер берёт из их +/// дефов сам (модовый контент появляется автоматически); DevToolDef добавляет ПРОЧИЕ действия. +/// +public sealed class DevToolDef : Def +{ + /// Категория для группировки в панели (например "tool"). + public string Category { get; init; } = "tool"; + + /// Id обработчика-инструмента из реестра кода (spawn-animal/spawn-plant/infect/damage/kill). + public string Kind { get; init; } = ""; + + /// Параметр инструмента: defName вида/хедифа и т.п. (по смыслу Kind). + public string Target { get; init; } = ""; +} + +/// +/// Навык (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 a4ac37c..5d7211d 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,39 +177,74 @@ public sealed class WorldScene : Scene _plants, _animals, _needs, + _skills, content, climate, _selection, brackets ); - desktop.Root = Ui.Screen(hudLabel, _inspect.Panel, speedBar, _pause.Root); + // Режим разработчика (настройка, по умолчанию вкл): без него дев-консоль и спавнер не поднимаются. + var devMode = GameSettingsStore.Load().DeveloperMode; + var spawner = devMode + ? new DevSpawner( + Store, + _animals, + _plants, + _needs, + _skills, + content, + _selection, + _cellFertility, + _config.Width, + _config.Height, + CellSize + ) + : null; + desktop.Root = spawner is null + ? Ui.Screen(hudLabel, _inspect.Panel, speedBar, _pause.Root) + : Ui.Screen(hudLabel, _inspect.Panel, speedBar, _pause.Root, spawner.Panel); this.UseInspector(renderer); - var console = this.UseDevConsole(); - RegisterCommands(console, content, atlases); - console.Register( - "light", - "light [radius] — place a point light at the cursor (night shadows demo)", - (c, args) => - { - var radius = - args.Length > 0 - ? float.Parse(args[0], System.Globalization.CultureInfo.InvariantCulture) - : 120f; - var mouse = Mouse.GetState(); - var world = renderer.ScreenToWorld(new Vector2(mouse.X, mouse.Y)); - Store.CreateEntity( - Transform2D.At(world), - new PointLight - { - Radius = radius, - Color = Color.White, - Intensity = 0.9f, - } - ); - c.WriteLine($"light at {world.X:0},{world.Y:0} r{radius:0}"); - } - ); + + MrGameEng.DevConsole.DevConsole? console = null; + if (devMode) + { + console = this.UseDevConsole(); + RegisterCommands(console, content, atlases); + console.Register( + "light", + "light [radius] — place a point light at the cursor (night shadows demo)", + (c, args) => + { + var radius = + args.Length > 0 + ? float.Parse( + args[0], + System.Globalization.CultureInfo.InvariantCulture + ) + : 120f; + var mouse = Mouse.GetState(); + var world = renderer.ScreenToWorld(new Vector2(mouse.X, mouse.Y)); + Store.CreateEntity( + Transform2D.At(world), + new PointLight + { + Radius = radius, + Color = Color.White, + Intensity = 0.9f, + } + ); + c.WriteLine($"light at {world.X:0},{world.Y:0} r{radius:0}"); + } + ); + } + + if (spawner is not null) + { + UpdateSystems.Add( + new DevSpawnerSystem(renderer, spawner, () => desktop.IsMouseOverGUI) + ); + } UpdateSystems.Add(new PlantGrowthSystem(_plants, calendar, climate, lighting, CellSize)); UpdateSystems.Add(new PlantFruitingSystem(_plants, calendar, climate)); @@ -232,6 +269,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")) ); @@ -271,6 +309,7 @@ public sealed class WorldScene : Scene _plants, _animals, _needs, + _skills, thoughts, Context.Clock, SecondsPerDay, @@ -290,6 +329,7 @@ public sealed class WorldScene : Scene Store, _animals, _needs, + _skills, thoughts, Context.Clock, SecondsPerDay, @@ -303,6 +343,7 @@ public sealed class WorldScene : Scene Store, _animals, _needs, + _skills, Context.Clock, SecondsPerDay, CellSize, @@ -622,6 +663,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); } ); @@ -709,6 +758,7 @@ public sealed class WorldScene : Scene Store, _animals, _needs, + _skills, index, new Vector2(saved.X, saved.Y), saved.AgeDays, @@ -825,6 +875,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 @@ -878,6 +945,7 @@ public sealed class WorldScene : Scene Store, _animals, _needs, + _skills, s, new Vector2(px, py), ageDays: random.NextSingle() * maxAge, @@ -1056,6 +1124,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", @@ -1556,6 +1629,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/Sim/SimSystems.cs b/src/LittleSim/Sim/SimSystems.cs index 30f5f89..3abc96e 100644 --- a/src/LittleSim/Sim/SimSystems.cs +++ b/src/LittleSim/Sim/SimSystems.cs @@ -125,13 +125,13 @@ public sealed class GodCameraSystem( Entity cameraEntity, InputManager input, Renderer2D renderer, - MrGameEng.DevConsole.DevConsole console, + MrGameEng.DevConsole.DevConsole? console, Func? blocked = null ) : BaseSystem { protected override void OnUpdateGroup() { - if (console.IsOpen || blocked?.Invoke() == true) + if (console?.IsOpen == true || blocked?.Invoke() == true) { return; } diff --git a/src/LittleSim/UI/DevSpawner.cs b/src/LittleSim/UI/DevSpawner.cs new file mode 100644 index 0000000..be726a8 --- /dev/null +++ b/src/LittleSim/UI/DevSpawner.cs @@ -0,0 +1,299 @@ +using System; +using System.Collections.Generic; +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using LittleSim.Sim; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; +using MrGameEng.Graphics; +using Myra.Graphics2D; +using Myra.Graphics2D.Brushes; +using Myra.Graphics2D.UI; + +namespace LittleSim.UI; + +/// +/// Дев-спавнер в духе RimWorld (только в режиме разработчика): боковая панель со списком по категориям. +/// Виды животных/растений берутся ИЗ ДЕФОВ (модовый контент появляется сам); «инструменты» (урон/смерть/ +/// болезнь) и прочие модовые пункты — из . Спавн-пункт «берётся в руку»: клик по +/// миру создаёт сущность в курсоре (ПКМ снимает). Инструменты-действия применяются к выбранной особи +/// сразу по нажатию. Так удобно сравнивать сущности и наносить им урон. Управляет . +/// +public sealed class DevSpawner +{ + private readonly EntityStore _store; + private readonly AnimalSet _animals; + private readonly PlantSet _plants; + private readonly NeedSet _needs; + private readonly SkillSet _skills; + private readonly GameContent _content; + private readonly Selection _selection; + private readonly float[] _cellFertility; + private readonly int _width; + private readonly int _height; + private readonly int _cellSize; + private readonly HediffDef? _fever; + private readonly HediffDef? _bleeding; + private readonly Random _rng = new(0x5A11); + + private readonly VerticalStackPanel _panel; + private readonly Label _title; + private string? _armedKind; + private string? _armedTarget; + + public DevSpawner( + EntityStore store, + AnimalSet animals, + PlantSet plants, + NeedSet needs, + SkillSet skills, + GameContent content, + Selection selection, + float[] cellFertility, + int width, + int height, + int cellSize + ) + { + _store = store; + _animals = animals; + _plants = plants; + _needs = needs; + _skills = skills; + _content = content; + _selection = selection; + _cellFertility = cellFertility; + _width = width; + _height = height; + _cellSize = cellSize; + _fever = content.Defs.TryGet("Fever", out var f) ? f : null; + _bleeding = content.Defs.TryGet("Bleeding", out var b) ? b : null; + + var lang = content.Languages; + _title = new Label { TextColor = Ui.Accent, Wrap = true }; + + var list = new VerticalStackPanel { Spacing = 2 }; + + // Инструменты-действия (по выбранной особи) + модовые DevToolDef. + Header(list, lang.Get("dev.tools")); + Entry(list, lang.Get("dev.damage"), () => ActOnSelection("damage", "")); + Entry(list, lang.Get("dev.kill"), () => ActOnSelection("kill", "")); + Entry(list, lang.Get("dev.infect"), () => ActOnSelection("infect", "")); + foreach (var tool in content.Defs.All()) + { + var kind = tool.Kind; + var target = tool.Target; + var label = lang.Get(tool.Label); + if (kind is "spawn-animal" or "spawn-plant") + { + Entry(list, label, () => Arm(kind, target)); + } + else + { + Entry(list, label, () => ActOnSelection(kind, target)); + } + } + + // Виды — автоматически из дефов (включая модовые): «взять в руку» спавн. + Header(list, lang.Get("dev.animals")); + foreach (var name in content.Defs.NamesOf("Animal")) + { + var defName = name; + Entry(list, "+ " + lang.Get(AnimalLabel(defName)), () => Arm("spawn-animal", defName)); + } + + Header(list, lang.Get("dev.plants")); + foreach (var name in content.Defs.NamesOf("Plant")) + { + var defName = name; + Entry(list, "+ " + lang.Get(PlantLabel(defName)), () => Arm("spawn-plant", defName)); + } + + _panel = new VerticalStackPanel + { + Spacing = 6, + Padding = new Thickness(12), + Width = 250, + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Top, + Margin = new Thickness(0, 14, 12, 0), + Background = new SolidBrush(new Color(10, 13, 18, 240)), + Border = new SolidBrush(new Color(96, 134, 168)), + BorderThickness = new Thickness(2), + Visible = false, + }; + _panel.Widgets.Add(new Label { Text = lang.Get("dev.title"), TextColor = Ui.Accent }); + _panel.Widgets.Add(_title); + _panel.Widgets.Add(new HorizontalSeparator()); + _panel.Widgets.Add(new ScrollViewer { Height = 460, Content = list }); + UpdateTitle(); + } + + /// Виджет панели (добавляется в корень экрана сцены). + public Widget Panel => _panel; + + /// Взведён ли спавн-инструмент (клик по миру создаёт сущность). + public bool ArmedSpawn => _armedKind is "spawn-animal" or "spawn-plant"; + + /// Показать/скрыть панель (горячая клавиша из системы). + public void Toggle() => _panel.Visible = !_panel.Visible; + + /// Снять «инструмент с руки» (ПКМ). + public void Disarm() + { + _armedKind = null; + _armedTarget = null; + UpdateTitle(); + } + + /// Создаёт взведённую сущность в точке мира (вызывает система по клику ЛКМ). + public void SpawnAt(Vector2 world) + { + if ( + _armedKind == "spawn-animal" + && _content.Defs.TryGet(_armedTarget ?? "", out var adef) + ) + { + var idx = _animals.IndexOf(adef); + var age = adef.Genome.GetValueOrDefault("GeneMaturityAge", 90f); // взрослый + AnimalFactory.Create( + _store, + _animals, + _needs, + _skills, + idx, + world, + age, + _animals.GenerateGenome(idx, _rng), + generation: 0, + _cellSize + ); + } + else if ( + _armedKind == "spawn-plant" + && _content.Defs.TryGet(_armedTarget ?? "", out var pdef) + ) + { + var idx = _plants.IndexOf(pdef); + var cx = Math.Clamp((int)(world.X / _cellSize), 0, _width - 1); + var cy = Math.Clamp((int)(world.Y / _cellSize), 0, _height - 1); + var cell = cy * _width + cx; + var fertility = cell >= 0 && cell < _cellFertility.Length ? _cellFertility[cell] : 1f; + PlantFactory.Create( + _store, + _plants, + idx, + world, + _plants[idx].MaturityDays, // зрелое + _plants[idx].Template.Generate(_rng), + fertility, + _cellSize + ); + } + } + + // Применяет инструмент-действие к выбранной особи (урон/смерть/болезнь) — для сравнения/тестов. + private void ActOnSelection(string kind, string target) + { + if ( + !_selection.HasSelection + || !_store.TryGetEntityById(_selection.EntityId, out var e) + || e.IsNull + ) + { + return; + } + + switch (kind) + { + case "kill" when e.HasComponent(): + AnimalFactory.Die(_store, _animals, e, _cellSize); + break; + case "damage" when e.TryGetComponent(out var h) && h.State is not null: + h.State.ApplyInjury(15f, 0.08f, _bleeding, 1.5f, _rng); + break; + case "infect" when e.TryGetComponent(out var h2) && h2.State is not null: + var def = + target.Length > 0 && _content.Defs.TryGet(target, out var hd) + ? hd + : _fever; + if (def is not null) + { + h2.State.Add(def); + } + + break; + } + } + + private void Arm(string kind, string target) + { + _armedKind = kind; + _armedTarget = target; + UpdateTitle(); + } + + private void UpdateTitle() => + _title.Text = _armedKind is null + ? _content.Languages.Get("dev.hint") + : _content.Languages.Format("dev.armed", _armedTarget ?? ""); + + private static void Header(VerticalStackPanel list, string text) => + list.Widgets.Add(new Label { Text = text, TextColor = Ui.Muted }); + + private static void Entry(VerticalStackPanel list, string text, Action onClick) + { + var button = new TextButton { Text = text }; + button.Click += (_, _) => onClick(); + list.Widgets.Add(button); + } + + private string AnimalLabel(string defName) => + _content.Defs.TryGet(defName, out var def) ? def.Label : defName; + + private string PlantLabel(string defName) => + _content.Defs.TryGet(defName, out var def) ? def.Label : defName; +} + +/// +/// Управление дев-спавнером: горячая клавиша F9 открывает/закрывает панель; при взведённом спавн- +/// инструменте ЛКМ по миру (не над UI) создаёт сущность в курсоре, ПКМ снимает инструмент. +/// +public sealed class DevSpawnerSystem(Renderer2D renderer, DevSpawner spawner, Func overUi) + : BaseSystem +{ + private bool _prevToggle; + private bool _prevLeft; + private bool _prevRight; + + protected override void OnUpdateGroup() + { + var keyboard = Keyboard.GetState(); + var toggle = keyboard.IsKeyDown(Keys.F9); + if (toggle && !_prevToggle) + { + spawner.Toggle(); + } + + _prevToggle = toggle; + + var mouse = Mouse.GetState(); + var left = mouse.LeftButton == ButtonState.Pressed; + var right = mouse.RightButton == ButtonState.Pressed; + + if (spawner.ArmedSpawn && left && !_prevLeft && !overUi()) + { + var world = renderer.ScreenToWorld(new Vector2(mouse.X, mouse.Y)); + spawner.SpawnAt(world); + } + + if (right && !_prevRight) + { + spawner.Disarm(); + } + + _prevLeft = left; + _prevRight = right; + } +} 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) { diff --git a/src/LittleSim/UI/SettingsPanel.cs b/src/LittleSim/UI/SettingsPanel.cs index a1b1984..192fde6 100644 --- a/src/LittleSim/UI/SettingsPanel.cs +++ b/src/LittleSim/UI/SettingsPanel.cs @@ -98,6 +98,16 @@ internal static class SettingsPanel // Громкость — кнопки −/+ с подписью процента. column.Widgets.Add(Volume(lang.Get("settings.volume"), settings, refreshers)); + // Режим разработчика — тумблер (дев-консоль и спавнер; по умолчанию включён). + column.Widgets.Add( + Toggle( + lang.Get("settings.devmode"), + () => settings.DeveloperMode, + v => settings.DeveloperMode = v, + refreshers + ) + ); + column.Widgets.Add(new Label { Height = 8 }); var buttons = Ui.Row(10); buttons.Widgets.Add(Ui.Button(lang.Get("settings.apply"), onApply, width: 150));