Dev spawner: RimWorld-style moddable spawn/damage tool (dev mode only)

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 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-14 22:20:59 +03:00
co-authored by Claude Opus 4.8
parent 58380adc31
commit cdb8439edf
6 changed files with 363 additions and 3 deletions
+9
View File
@@ -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",
+9
View File
@@ -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": "Пауза",
+1
View File
@@ -69,6 +69,7 @@ public sealed class GameContent
defs.RegisterType<ThoughtDef>("Thought");
defs.RegisterType<BodyDef>("Body");
defs.RegisterType<SkillDef>("Skill");
defs.RegisterType<DevToolDef>("DevTool");
defs.RegisterType<WorldPresetDef>("WorldPreset");
// Валидация имён при загрузке (фаза G5): гены и продукты обязаны иметь префикс типа.
defs.RegisterValidator("Gene", "defName", "^Gene", "gene defName must start with 'Gene'");
+18
View File
@@ -368,6 +368,24 @@ public sealed class ThoughtDef : Def
public float DurationDays { get; init; } = 1f;
}
/// <summary>
/// Пункт дев-инструмента (Defs/DevTools/) — ЧТОБЫ МОДЫ ДОБАВЛЯЛИ кнопки в дев-спавнер без правки кода.
/// <see cref="Kind"/> — id обработчика из реестра (spawn-animal/spawn-plant/infect/damage/kill/…),
/// <see cref="Target"/> — параметр (defName вида/хедифа). Виды животных/растений спавнер берёт из их
/// дефов сам (модовый контент появляется автоматически); DevToolDef добавляет ПРОЧИЕ действия.
/// </summary>
public sealed class DevToolDef : Def
{
/// <summary>Категория для группировки в панели (например "tool").</summary>
public string Category { get; init; } = "tool";
/// <summary>Id обработчика-инструмента из реестра кода (spawn-animal/spawn-plant/infect/damage/kill).</summary>
public string Kind { get; init; } = "";
/// <summary>Параметр инструмента: defName вида/хедифа и т.п. (по смыслу Kind).</summary>
public string Target { get; init; } = "";
}
/// <summary>
/// Навык (Defs/Skills/) как ДАННЫЕ: уровень особи [0..1] растёт от практики (XP при действии,
/// ускоряется страстью) и медленно угасает без неё к полу <see cref="FloorLevel"/>. Эффект навыка —
+27 -3
View File
@@ -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(
+299
View File
@@ -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;
/// <summary>
/// Дев-спавнер в духе RimWorld (только в режиме разработчика): боковая панель со списком по категориям.
/// Виды животных/растений берутся ИЗ ДЕФОВ (модовый контент появляется сам); «инструменты» (урон/смерть/
/// болезнь) и прочие модовые пункты — из <see cref="DevToolDef"/>. Спавн-пункт «берётся в руку»: клик по
/// миру создаёт сущность в курсоре (ПКМ снимает). Инструменты-действия применяются к выбранной особи
/// сразу по нажатию. Так удобно сравнивать сущности и наносить им урон. Управляет <see cref="DevSpawnerSystem"/>.
/// </summary>
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<HediffDef>("Fever", out var f) ? f : null;
_bleeding = content.Defs.TryGet<HediffDef>("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<DevToolDef>())
{
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();
}
/// <summary>Виджет панели (добавляется в корень экрана сцены).</summary>
public Widget Panel => _panel;
/// <summary>Взведён ли спавн-инструмент (клик по миру создаёт сущность).</summary>
public bool ArmedSpawn => _armedKind is "spawn-animal" or "spawn-plant";
/// <summary>Показать/скрыть панель (горячая клавиша из системы).</summary>
public void Toggle() => _panel.Visible = !_panel.Visible;
/// <summary>Снять «инструмент с руки» (ПКМ).</summary>
public void Disarm()
{
_armedKind = null;
_armedTarget = null;
UpdateTitle();
}
/// <summary>Создаёт взведённую сущность в точке мира (вызывает система по клику ЛКМ).</summary>
public void SpawnAt(Vector2 world)
{
if (
_armedKind == "spawn-animal"
&& _content.Defs.TryGet<AnimalDef>(_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<PlantDef>(_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<AnimalOrganism>():
AnimalFactory.Die(_store, _animals, e, _cellSize);
break;
case "damage" when e.TryGetComponent<Health>(out var h) && h.State is not null:
h.State.ApplyInjury(15f, 0.08f, _bleeding, 1.5f, _rng);
break;
case "infect" when e.TryGetComponent<Health>(out var h2) && h2.State is not null:
var def =
target.Length > 0 && _content.Defs.TryGet<HediffDef>(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<AnimalDef>(defName, out var def) ? def.Label : defName;
private string PlantLabel(string defName) =>
_content.Defs.TryGet<PlantDef>(defName, out var def) ? def.Label : defName;
}
/// <summary>
/// Управление дев-спавнером: горячая клавиша F9 открывает/закрывает панель; при взведённом спавн-
/// инструменте ЛКМ по миру (не над UI) создаёт сущность в курсоре, ПКМ снимает инструмент.
/// </summary>
public sealed class DevSpawnerSystem(Renderer2D renderer, DevSpawner spawner, Func<bool> 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;
}
}