From cdb8439edf41dc445625ad0e5365574e7554e06e Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 14 Jun 2026 22:20:59 +0300 Subject: [PATCH] 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; + } +}