Животные A8 часть 2: инспектор животных (вкладки нужды/здоровье/настроение)

InspectPanel осматривает и животных: вкладки Обзор (стадия/возраст/пол/поколение/беременность), Нужды, Здоровье (кровь/боль, способности, хедифы), Настроение (значение + мысли). Набор вкладок по типу выбранного. SelectionSystem пикает и животных (запрос расширен PlantGrowth → +AnimalOrganism). Локализация inspect.*/need.*/hediff.*/cap.*/animalstage.* ru+en. Базовый план зоологии A1-A8 закрыт. Сборка + --check-content чистые.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-14 06:19:23 +03:00
co-authored by Claude Opus 4.8
parent 1b12831817
commit 003f235440
6 changed files with 367 additions and 45 deletions
+10 -1
View File
@@ -163,7 +163,16 @@ public sealed class WorldScene : Scene
atlases.GetRegion(device, "ui/overlays/selectionbracket"),
GameLayers.Selection
);
_inspect = new InspectPanel(Store, _plants, content, climate, _selection, brackets);
_inspect = new InspectPanel(
Store,
_plants,
_animals,
_needs,
content,
climate,
_selection,
brackets
);
desktop.Root = Ui.Screen(hudLabel, _inspect.Panel, speedBar, _pause.Root);
this.UseInspector(renderer);
+45 -23
View File
@@ -1,3 +1,4 @@
using System;
using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems;
using Microsoft.Xna.Framework;
@@ -74,41 +75,62 @@ public sealed class SelectionSystem(
}
}
// Ближайшее растение, чья ограничивающая окружность спрайта накрывает точку мира. Запрос
// ограничен PlantGrowth — так из пикинга выпадают спрайты рамки выделения и прочий не-контент.
// Ближайший осматриваемый объект (растение или животное), чья ограничивающая окружность спрайта
// накрывает точку мира. Запросы ограничены PlantGrowth/AnimalOrganism — так из пикинга выпадают
// спрайты рамки выделения, трупы и прочий не-контент. Из двух кандидатов берётся более близкий.
private bool TryPick(Vector2 world, out int id)
{
id = -1;
var bestDistance = float.MaxValue;
var best = float.MaxValue;
foreach (
var (transforms, sprites, _, entities) in store
.Query<Transform2D, Sprite, PlantGrowth>()
.Chunks
)
{
var t = transforms.Span;
var s = sprites.Span;
for (var i = 0; i < t.Length; i++)
{
if (s[i].Region is not { } region)
{
continue;
}
Scan(world, transforms.Span, sprites.Span, entities, ref id, ref best);
}
var (center, radius) = CullingMath.SpriteBoundingCircle(
in t[i],
region,
s[i].Origin
);
var distance = Vector2.DistanceSquared(center, world);
if (distance <= radius * radius && distance < bestDistance)
{
bestDistance = distance;
id = entities.EntityAt(i).Id;
}
}
foreach (
var (transforms, sprites, _, entities) in store
.Query<Transform2D, Sprite, AnimalOrganism>()
.Chunks
)
{
Scan(world, transforms.Span, sprites.Span, entities, ref id, ref best);
}
return id >= 0;
}
// Обновляет лучшего кандидата (id/дистанцию) по чанку спрайтов: точка должна попасть в окружность.
private static void Scan(
Vector2 world,
Span<Transform2D> transforms,
Span<Sprite> sprites,
ChunkEntities entities,
ref int id,
ref float best
)
{
for (var i = 0; i < transforms.Length; i++)
{
if (sprites[i].Region is not { } region)
{
continue;
}
var (center, radius) = CullingMath.SpriteBoundingCircle(
in transforms[i],
region,
sprites[i].Origin
);
var distance = Vector2.DistanceSquared(center, world);
if (distance <= radius * radius && distance < best)
{
best = distance;
id = entities.EntityAt(i).Id;
}
}
}
}
+237 -19
View File
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Text;
using Friflo.Engine.ECS;
@@ -16,10 +17,11 @@ namespace LittleSim.UI;
/// <summary>
/// Панель осмотра выбранной сущности (инфо-карточка в стиле RimWorld): угловая рамка вокруг объекта
/// (<see cref="SelectionBrackets"/>) и боковая панель в левом нижнем углу с вкладками — «Обзор»,
/// «Гены», «Продукты». Источник выделения — <see cref="Selection"/>; панель только читает компоненты
/// и обновляется каждый кадр (<see cref="Refresh"/>), оставаясь живой даже на паузе. Все строки идут
/// через <see cref="LanguageManager"/> (ключи <c>inspect.*</c>).
/// (<see cref="SelectionBrackets"/>) и боковая панель в левом нижнем углу с вкладками. Набор вкладок
/// зависит от типа: растение — «Обзор/Гены/Продукты», животное — «Обзор/Нужды/Здоровье/Настроение».
/// Источник выделения — <see cref="Selection"/>; панель только читает компоненты и обновляется каждый
/// кадр (<see cref="Refresh"/>), оставаясь живой даже на паузе. Все строки идут через
/// <see cref="LanguageManager"/> (ключи <c>inspect.*</c>).
/// </summary>
internal sealed class InspectPanel
{
@@ -28,10 +30,18 @@ internal sealed class InspectPanel
Overview,
Genes,
Products,
Needs,
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 readonly EntityStore _store;
private readonly PlantSet _plants;
private readonly AnimalSet _animals;
private readonly NeedSet _needs;
private readonly GameContent _content;
private readonly Climate _climate;
private readonly Selection _selection;
@@ -46,6 +56,8 @@ internal sealed class InspectPanel
public InspectPanel(
EntityStore store,
PlantSet plants,
AnimalSet animals,
NeedSet needs,
GameContent content,
Climate climate,
Selection selection,
@@ -54,6 +66,8 @@ internal sealed class InspectPanel
{
_store = store;
_plants = plants;
_animals = animals;
_needs = needs;
_content = content;
_climate = climate;
_selection = selection;
@@ -66,6 +80,9 @@ internal sealed class InspectPanel
AddTab(tabBar, Tab.Overview, "inspect.tab.overview");
AddTab(tabBar, Tab.Genes, "inspect.tab.genes");
AddTab(tabBar, Tab.Products, "inspect.tab.products");
AddTab(tabBar, Tab.Needs, "inspect.tab.needs");
AddTab(tabBar, Tab.Health, "inspect.tab.health");
AddTab(tabBar, Tab.Mood, "inspect.tab.mood");
_panel = new VerticalStackPanel
{
@@ -99,7 +116,7 @@ internal sealed class InspectPanel
/// <summary>
/// Подтягивает выделение каждый кадр: проверяет, жива ли сущность (иначе снимает выбор), двигает
/// рамку к объекту и пересобирает текст активной вкладки. Не-растения панель не показывает.
/// рамку к объекту и пересобирает текст активной вкладки. Осматриваются растения и животные.
/// </summary>
public void Refresh()
{
@@ -107,19 +124,19 @@ internal sealed class InspectPanel
!_selection.HasSelection
|| !_store.TryGetEntityById(_selection.EntityId, out var entity)
|| entity.IsNull
|| !entity.HasComponent<PlantGrowth>()
|| !entity.HasComponent<PlantOrganism>()
|| !entity.HasComponent<Transform2D>()
|| !entity.HasComponent<Sprite>()
)
{
if (_selection.HasSelection)
{
_selection.Clear(); // сущность исчезла (растение погибло)
}
ClearSelection();
return;
}
_panel.Visible = false;
_brackets.Hide();
var isPlant = entity.HasComponent<PlantGrowth>() && entity.HasComponent<PlantOrganism>();
var isAnimal = entity.HasComponent<AnimalOrganism>();
if (!isPlant && !isAnimal)
{
ClearSelection(); // не контент (труп/прочее) — пока не осматривается
return;
}
@@ -135,10 +152,29 @@ internal sealed class InspectPanel
_brackets.Show(center, radius);
}
UpdateText(entity);
if (isPlant)
{
UpdatePlant(entity);
}
else
{
UpdateAnimal(entity);
}
_panel.Visible = true;
}
private void ClearSelection()
{
if (_selection.HasSelection)
{
_selection.Clear(); // сущность исчезла (растение/зверь погибли) или не осматривается
}
_panel.Visible = false;
_brackets.Hide();
}
private void AddTab(HorizontalStackPanel bar, Tab tab, string labelKey)
{
var button = new TextButton { Text = _content.Languages.Get(labelKey) };
@@ -147,7 +183,23 @@ internal sealed class InspectPanel
_tabs.Add((tab, button));
}
private void UpdateText(Entity entity)
// Показывает только вкладки текущего типа, подсвечивает активную; если активная не из набора —
// откатывает на первую (Обзор) при смене типа выбранного объекта.
private void ShowTabs(Tab[] valid)
{
if (Array.IndexOf(valid, _tab) < 0)
{
_tab = valid[0];
}
foreach (var (tab, button) in _tabs)
{
button.Visible = Array.IndexOf(valid, tab) >= 0;
button.TextColor = tab == _tab ? Ui.Accent : Ui.Muted;
}
}
private void UpdatePlant(Entity entity)
{
var languages = _content.Languages;
ref readonly var grow = ref entity.GetComponent<PlantGrowth>();
@@ -157,10 +209,7 @@ internal sealed class InspectPanel
var traits = org.Traits;
_title.Text = languages.Get(def.Label) + (traits.IsVariant ? " *" : "");
foreach (var (tab, button) in _tabs)
{
button.TextColor = tab == _tab ? Ui.Accent : Ui.Muted;
}
ShowTabs(PlantTabs);
var text = new StringBuilder();
switch (_tab)
@@ -179,6 +228,35 @@ internal sealed class InspectPanel
_body.Text = text.ToString().TrimEnd();
}
private void UpdateAnimal(Entity entity)
{
var languages = _content.Languages;
ref readonly var org = ref entity.GetComponent<AnimalOrganism>();
var sp = _animals[org.Species];
_title.Text = languages.Get(sp.Def.Label);
ShowTabs(AnimalTabs);
var text = new StringBuilder();
switch (_tab)
{
case Tab.Overview:
BuildAnimalOverview(text, entity, org, sp);
break;
case Tab.Needs:
BuildAnimalNeeds(text, entity);
break;
case Tab.Health:
BuildAnimalHealth(text, entity);
break;
case Tab.Mood:
BuildAnimalMood(text, entity);
break;
}
_body.Text = text.ToString().TrimEnd();
}
private void BuildOverview(
StringBuilder text,
Entity entity,
@@ -296,6 +374,146 @@ internal sealed class InspectPanel
}
}
// — Животное: Обзор — стадия, возраст, пол/поколение, беременность.
private void BuildAnimalOverview(
StringBuilder text,
Entity entity,
in AnimalOrganism org,
AnimalSet.Species sp
)
{
var languages = _content.Languages;
var grow = entity.GetComponent<AnimalGrowth>();
var stageName = languages.Get(
"animalstage." + sp.Stages[grow.Stage].Name.ToLowerInvariant()
);
text.AppendLine(
languages.Format("inspect.stage", stageName, grow.Stage + 1, sp.Stages.Length)
);
text.AppendLine(languages.Format("inspect.age", grow.AgeDays, org.Traits.Lifespan));
var sex = languages.Get(org.IsMale ? "inspect.sex.male" : "inspect.sex.female");
text.AppendLine(languages.Format("inspect.animal.sex", sex, org.Generation));
if (entity.TryGetComponent<Pregnant>(out var preg))
{
text.AppendLine(languages.Format("inspect.animal.pregnant", preg.DueInDays));
}
}
// — Животное: Нужды — значение каждой нужды (голод/жажда/отдых/влечение) в процентах.
private void BuildAnimalNeeds(StringBuilder text, Entity entity)
{
var languages = _content.Languages;
var values = entity.GetComponent<AnimalNeeds>().Values;
if (values is null)
{
return;
}
for (var k = 0; k < _needs.Count && k < values.Length; k++)
{
text.AppendLine(
languages.Format(
"inspect.needline",
languages.Get(_needs[k].Label),
values[k] * 100f
)
);
}
}
// — Животное: Здоровье — кровь/боль, ключевые способности, активные хедифы (раны/болезни/гон).
private void BuildAnimalHealth(StringBuilder text, Entity entity)
{
var languages = _content.Languages;
var state = entity.GetComponent<Health>().State;
if (state is null)
{
text.AppendLine(languages.Get("inspect.health.none"));
return;
}
text.AppendLine(
languages.Format("inspect.health.blood", state.BloodLevel * 100f, state.Pain * 100f)
);
if (state.Parts.Length > 0)
{
text.AppendLine(languages.Get("inspect.health.caps"));
AppendCapacity(text, state, AnimalCapacities.Consciousness, "cap.consciousness");
AppendCapacity(text, state, AnimalCapacities.Moving, "cap.moving");
AppendCapacity(text, state, AnimalCapacities.Sight, "cap.sight");
}
if (state.Hediffs.Count > 0)
{
text.AppendLine(languages.Get("inspect.health.hediffs"));
foreach (var hediff in state.Hediffs)
{
text.AppendLine(
languages.Format(
"inspect.health.hediffline",
languages.Get(hediff.Def.Label),
hediff.Severity * 100f
)
);
}
}
else
{
text.AppendLine(languages.Get("inspect.health.none"));
}
}
private void AppendCapacity(
StringBuilder text,
HealthState state,
string capId,
string labelKey
)
{
var languages = _content.Languages;
text.AppendLine(
languages.Format(
"inspect.health.cap",
languages.Get(labelKey),
state.Capacity(capId) * 100f
)
);
}
// — Животное: Настроение — значение и активные событийные мысли с их сдвигом (тир 4+).
private void BuildAnimalMood(StringBuilder text, Entity entity)
{
var languages = _content.Languages;
var mood = entity.GetComponent<Mood>();
if (!mood.Active)
{
text.AppendLine(languages.Get("inspect.mood.none"));
return;
}
text.AppendLine(languages.Format("inspect.mood.value", mood.Value * 100f));
if (mood.Thoughts is { Count: > 0 })
{
text.AppendLine(languages.Get("inspect.mood.thoughts"));
foreach (var thought in mood.Thoughts)
{
var pct = thought.Def.MoodOffset * 100f;
var offset = (pct >= 0f ? "+" : "") + pct.ToString("0") + "%";
text.AppendLine(
languages.Format(
"inspect.mood.thoughtline",
languages.Get(thought.Def.Label),
offset
)
);
}
}
else
{
text.AppendLine(languages.Get("inspect.mood.calm"));
}
}
private string StageLabel(PlantDef def, int stage) =>
stage < def.Stages.Count && def.Stages[stage].Label is { } key
? _content.Languages.Get(key)