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:
co-authored by
Claude Opus 4.8
parent
58380adc31
commit
cdb8439edf
@@ -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",
|
||||
|
||||
@@ -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": "Пауза",
|
||||
|
||||
@@ -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'");
|
||||
|
||||
@@ -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"/>. Эффект навыка —
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user