From cf2bc77882b54d7f746fc0cbce065952c0b4b33d Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 14 Jun 2026 21:57:21 +0300 Subject: [PATCH 1/3] Developer-mode setting gates the dev console GameSettings.DeveloperMode (default ON) + a toggle in the settings panel. WorldScene only stands up the dev console (and, later, the dev spawner) when it is enabled, so without dev mode the backtick key does nothing. GodCameraSystem''s console arg is now nullable (no console when dev mode off). Localized settings.devmode ru/en. Build clean. Co-Authored-By: Claude Opus 4.8 --- Mods/Core/Languages/en/ui.json | 1 + Mods/Core/Languages/ru/ui.json | 1 + src/LittleSim/App/GameSettings.cs | 3 ++ src/LittleSim/Scenes/WorldScene.cs | 60 +++++++++++++++++------------- src/LittleSim/Sim/SimSystems.cs | 4 +- src/LittleSim/UI/SettingsPanel.cs | 10 +++++ 6 files changed, 52 insertions(+), 27 deletions(-) diff --git a/Mods/Core/Languages/en/ui.json b/Mods/Core/Languages/en/ui.json index dbaca85..e5a4aa7 100644 --- a/Mods/Core/Languages/en/ui.json +++ b/Mods/Core/Languages/en/ui.json @@ -108,6 +108,7 @@ "settings.resolution": "Resolution", "settings.volume": "Volume", "settings.uiscale": "UI scale", + "settings.devmode": "Developer mode", "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..e5e1bb3 100644 --- a/Mods/Core/Languages/ru/ui.json +++ b/Mods/Core/Languages/ru/ui.json @@ -108,6 +108,7 @@ "settings.resolution": "Разрешение", "settings.volume": "Громкость", "settings.uiscale": "Масштаб UI", + "settings.devmode": "Режим разработчика", "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/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs index a4ac37c..b548a6d 100644 --- a/src/LittleSim/Scenes/WorldScene.cs +++ b/src/LittleSim/Scenes/WorldScene.cs @@ -183,31 +183,41 @@ public sealed class WorldScene : Scene desktop.Root = Ui.Screen(hudLabel, _inspect.Panel, speedBar, _pause.Root); 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}"); - } - ); + + // Режим разработчика (настройка, по умолчанию вкл): без него дев-консоль и спавнер не поднимаются. + var devMode = GameSettingsStore.Load().DeveloperMode; + 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}"); + } + ); + } UpdateSystems.Add(new PlantGrowthSystem(_plants, calendar, climate, lighting, CellSize)); UpdateSystems.Add(new PlantFruitingSystem(_plants, calendar, climate)); 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/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)); From 58380adc3172335457a7daabb806605835ab57fd Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 14 Jun 2026 22:15:11 +0300 Subject: [PATCH 2/3] 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) { From cdb8439edf41dc445625ad0e5365574e7554e06e Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 14 Jun 2026 22:20:59 +0300 Subject: [PATCH 3/3] Dev spawner: RimWorld-style moddable spawn/damage tool (dev mode only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A side panel (F9, only when developer mode is on) lists entries by category: animals and plants are auto-populated from their defs (so modded content shows up automatically), plus action tools. Spawn entries arm a "tool in hand" — left-click the world to spawn at the cursor (right-click clears); action tools (injure/kill/ infect) apply to the currently selected creature, so you can line up entities and damage them to compare. Moddable via a new DevToolDef def type: mods add buttons referencing a code handler kind (spawn-animal/plant/infect/damage/kill) + target, without touching code. Localized dev.* ru/en. Build + --check-content clean (13 def types). Co-Authored-By: Claude Opus 4.8 --- Mods/Core/Languages/en/ui.json | 9 + Mods/Core/Languages/ru/ui.json | 9 + src/LittleSim/Content/GameContent.cs | 1 + src/LittleSim/Content/GameDefs.cs | 18 ++ src/LittleSim/Scenes/WorldScene.cs | 30 ++- src/LittleSim/UI/DevSpawner.cs | 299 +++++++++++++++++++++++++++ 6 files changed, 363 insertions(+), 3 deletions(-) create mode 100644 src/LittleSim/UI/DevSpawner.cs diff --git a/Mods/Core/Languages/en/ui.json b/Mods/Core/Languages/en/ui.json index c56c010..1a7272b 100644 --- a/Mods/Core/Languages/en/ui.json +++ b/Mods/Core/Languages/en/ui.json @@ -114,6 +114,15 @@ "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 5485b5b..8550b50 100644 --- a/Mods/Core/Languages/ru/ui.json +++ b/Mods/Core/Languages/ru/ui.json @@ -114,6 +114,15 @@ "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/Content/GameContent.cs b/src/LittleSim/Content/GameContent.cs index 736e443..77c6749 100644 --- a/src/LittleSim/Content/GameContent.cs +++ b/src/LittleSim/Content/GameContent.cs @@ -69,6 +69,7 @@ public sealed class GameContent 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 d833ffb..9a764da 100644 --- a/src/LittleSim/Content/GameDefs.cs +++ b/src/LittleSim/Content/GameDefs.cs @@ -368,6 +368,24 @@ 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 при действии, /// ускоряется страстью) и медленно угасает без неё к полу . Эффект навыка — diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs index 3758720..5d7211d 100644 --- a/src/LittleSim/Scenes/WorldScene.cs +++ b/src/LittleSim/Scenes/WorldScene.cs @@ -183,12 +183,29 @@ public sealed class WorldScene : Scene _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 devMode = GameSettingsStore.Load().DeveloperMode; MrGameEng.DevConsole.DevConsole? console = null; if (devMode) { @@ -222,6 +239,13 @@ public sealed class WorldScene : Scene ); } + 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)); UpdateSystems.Add( 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; + } +}