Events jump-to-creature, UI effects, sane mating & mushroom habitat

- Event log: double-click an entry to focus camera + select the responsible creature; hover highlight, fade-in, hint line.

- UI effects: fade-in for events/perf overlays, hover on inspect tabs and speed buttons, breathing selection bracket.

- Mating: same-species only (no boar x chicken); event strings reworded gender/case-neutral (fixes 'кабан спарилась с курица').

- Mushrooms: shade plants no longer scattered into bright cells (no canopy -> they don't appear), matching the low-light niche.

- Bump engine: lighter night floor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-16 15:34:21 +03:00
co-authored by Claude Opus 4.8
parent 010d401b75
commit 9f5f5e2ca9
10 changed files with 197 additions and 70 deletions
+15 -14
View File
@@ -210,30 +210,31 @@
"hud.perf": "Perf", "hud.perf": "Perf",
"events.title": "Event log", "events.title": "Event log",
"events.empty": "Nothing has happened yet", "events.empty": "Nothing has happened yet",
"events.hint": "Double-click — jump to the creature",
"perf.title": "Performance", "perf.title": "Performance",
"perf.fps": "{0} FPS · {1:0.0} ms/frame", "perf.fps": "{0} FPS · {1:0.0} ms/frame",
"perf.root": "{0}: {1:0.000} ms · systems {2}", "perf.root": "{0}: {1:0.000} ms · systems {2}",
"perf.systemline": "{0} {1:0.000} ms {2:0}%", "perf.systemline": "{0} {1:0.000} ms {2:0}%",
"perf.hint": "Refreshes ~5/s · costliest systems shown", "perf.hint": "Refreshes ~5/s · costliest systems shown",
"event.mated": "{0} mated with {1}", "event.mated": "Pair: {0} + {1}",
"event.pregnant": "{0} became pregnant", "event.pregnant": "{0}: pregnant",
"event.birth": "{0} gave birth ×{1}", "event.birth": "{0}: birth ×{1}",
"event.hatch": "{0}: eggs hatched ×{1}", "event.hatch": "{0}: hatched ×{1}",
"event.hunt": "{0} started hunting: target — {1}", "event.hunt": "Hunt: {0} → {1}",
"event.injured": "{0} wounded: {1}", "event.injured": "Wound: {0} → {1}",
"event.struck": "{0} struck {1} (dmg {2:0})", "event.struck": "Hit: {0} → {1} ({2:0})",
"event.tookhit": "{0}: hit by {1} (dmg {2:0})", "event.tookhit": "Hit: {1} → {0} ({2:0})",
"event.killed": "{0} killed prey: {1}", "event.killed": "Kill: {0} → {1}",
"event.ate": "{0} ate", "event.ate": "{0} is feeding",
"event.drank": "{0} drank", "event.drank": "{0} is drinking",
"event.rest": "{0} is resting", "event.rest": "{0} is resting",
"event.dig": "{0} digs for food", "event.dig": "{0} digs for food",
"event.wallow": "{0} wallows in mud", "event.wallow": "{0} wallows in mud",
"event.fled": "{0} is fleeing", "event.fled": "{0} is fleeing",
"event.grew": "{0}: new stage — {1}", "event.grew": "{0}: new stage — {1}",
"event.death.starve": "{0} died of hunger/thirst", "event.death.starve": "Death: {0} (hunger/thirst)",
"event.death.age": "{0} died of old age", "event.death.age": "Death: {0} (old age)",
"event.death.disease": "{0} died of disease", "event.death.disease": "Death: {0} (disease)",
"net.hud": "Multiplayer: {0} | beings: {1} | Esc — back to menu", "net.hud": "Multiplayer: {0} | beings: {1} | Esc — back to menu",
"net.connecting": "connecting…", "net.connecting": "connecting…",
"net.connected": "connected", "net.connected": "connected",
+15 -14
View File
@@ -210,30 +210,31 @@
"hud.perf": "Произв.", "hud.perf": "Произв.",
"events.title": "Журнал событий", "events.title": "Журнал событий",
"events.empty": "Пока ничего не произошло", "events.empty": "Пока ничего не произошло",
"events.hint": "Двойной клик — перейти к существу",
"perf.title": "Производительность", "perf.title": "Производительность",
"perf.fps": "{0} FPS · {1:0.0} мс/кадр", "perf.fps": "{0} FPS · {1:0.0} мс/кадр",
"perf.root": "{0}: {1:0.000} мс · систем {2}", "perf.root": "{0}: {1:0.000} мс · систем {2}",
"perf.systemline": "{0} {1:0.000} мс {2:0}%", "perf.systemline": "{0} {1:0.000} мс {2:0}%",
"perf.hint": "Обновляется ~5 раз/с · показаны самые дорогие системы", "perf.hint": "Обновляется ~5 раз/с · показаны самые дорогие системы",
"event.mated": "{0} спарилась с {1}", "event.mated": "Пара: {0} + {1}",
"event.pregnant": "{0} забеременела", "event.pregnant": "{0}: беременность",
"event.birth": "{0} принесла потомство ×{1}", "event.birth": "{0}: роды ×{1}",
"event.hatch": "{0}: вылупились детёныши ×{1}", "event.hatch": "{0}: вылупление ×{1}",
"event.hunt": "{0} начала охоту: цель — {1}", "event.hunt": "Охота: {0} → {1}",
"event.injured": "{0} ранила: {1}", "event.injured": "Ранение: {0} → {1}",
"event.struck": "{0} ударил {1} (урон {2:0})", "event.struck": "Удар: {0} → {1} ({2:0})",
"event.tookhit": "{0}: удар от {1} (урон {2:0})", "event.tookhit": "Удар: {1} → {0} ({2:0})",
"event.killed": "{0} убила добычу: {1}", "event.killed": "Добыча: {0} → {1}",
"event.ate": "{0} поела", "event.ate": "{0} кормится",
"event.drank": "{0} попила", "event.drank": "{0} пьёт воду",
"event.rest": "{0} отдыхает", "event.rest": "{0} отдыхает",
"event.dig": "{0} роет землю в поисках корма", "event.dig": "{0} роет землю в поисках корма",
"event.wallow": "{0} валяется в грязи", "event.wallow": "{0} валяется в грязи",
"event.fled": "{0} спасается бегством", "event.fled": "{0} спасается бегством",
"event.grew": "{0}: новая стадия — {1}", "event.grew": "{0}: новая стадия — {1}",
"event.death.starve": "{0} погибла от голода/жажды", "event.death.starve": "Гибель: {0} (голод/жажда)",
"event.death.age": "{0} умерла от старости", "event.death.age": "Гибель: {0} (старость)",
"event.death.disease": "{0} погибла от болезни", "event.death.disease": "Гибель: {0} (болезнь)",
"net.hud": "Мультиплеер: {0} | жителей: {1} | Esc — в меню", "net.hud": "Мультиплеер: {0} | жителей: {1} | Esc — в меню",
"net.connecting": "подключение…", "net.connecting": "подключение…",
"net.connected": "подключено", "net.connected": "подключено",
+1 -1
Submodule engine updated: 96e19c7c61...46f3931f85
+21 -2
View File
@@ -2,20 +2,27 @@ using Friflo.Engine.ECS;
using LittleSim.Content; using LittleSim.Content;
using LittleSim.Sim; using LittleSim.Sim;
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework;
using MrGameEng.AI;
using MrGameEng.Core; using MrGameEng.Core;
using MrGameEng.DevConsole; using MrGameEng.DevConsole;
using MrGameEng.Graphics; using MrGameEng.Graphics;
using MrGameEng.Lighting;
namespace LittleSim.Scenes; namespace LittleSim.Scenes;
/// <summary>Спавн растительности по скаттеру дефов террейна.</summary> /// <summary>Спавн растительности по скаттеру дефов террейна.</summary>
internal static class ScatterSpawner internal static class ScatterSpawner
{ {
// Ниже этой световой пригодности (при полном дневном свете клетки) растение не сеем: теневому виду
// (грибы, optimalLight≈0.15) в открытой клетке не место — без полога крон ему тут не выжить, поэтому
// он и не должен появляться, пока такой ниши нет. Солнечные виды (≥0.7 пригодности) проходят свободно.
private const float ShadeSpawnThreshold = 0.15f;
/// <summary> /// <summary>
/// Бросает каждую запись скаттера клетки независимо; при успехе ставит одно растение со /// Бросает каждую запись скаттера клетки независимо; при успехе ставит одно растение со
/// случайным смещением и случайной (детерминированной сидом) зрелостью — мир после генерации /// случайным смещением и случайной (детерминированной сидом) зрелостью — мир после генерации
/// разновозрастный. Стадия и спрайт берутся из <paramref name="plants"/>, дальше растением /// разновозрастный. Теневые виды не сеются в ярко освещённые клетки (см. <see cref="ShadeSpawnThreshold"/>).
/// управляет <see cref="PlantGrowthSystem"/>. /// Стадия и спрайт берутся из <paramref name="plants"/>, дальше растением управляет <see cref="PlantGrowthSystem"/>.
/// </summary> /// </summary>
public static void Spawn( public static void Spawn(
Scene scene, Scene scene,
@@ -27,6 +34,10 @@ internal static class ScatterSpawner
Random random Random random
) )
{ {
// Статичная освещённость клетки в полдень: затенена только светонепроницаемым террейном (полог
// крон сейчас не учитывается). По ней решаем, годится ли клетка теневому виду.
var exposure = terrain.BlocksLight ? LightmapBuilder.OccluderShade : 1f;
foreach (var entry in terrain.Scatter) foreach (var entry in terrain.Scatter)
{ {
if (entry.Options.Count == 0 || random.NextSingle() >= entry.Chance) if (entry.Options.Count == 0 || random.NextSingle() >= entry.Chance)
@@ -35,6 +46,14 @@ internal static class ScatterSpawner
} }
var def = content.Defs.Get<PlantDef>(entry.Options[random.Next(entry.Options.Count)]); var def = content.Defs.Get<PlantDef>(entry.Options[random.Next(entry.Options.Count)]);
if (
Suitability.Gaussian(exposure, def.Genome.OptimalLight, def.Genome.LightTolerance)
< ShadeSpawnThreshold
)
{
continue; // теневому виду в ярко освещённой клетке не место
}
var index = plants.IndexOf(def); var index = plants.IndexOf(def);
var px = (cell.X + 0.3f + random.NextSingle() * 0.4f) * cellSize; var px = (cell.X + 0.3f + random.NextSingle() * 0.4f) * cellSize;
var py = (cell.Y + 0.3f + random.NextSingle() * 0.4f) * cellSize; var py = (cell.Y + 0.3f + random.NextSingle() * 0.4f) * cellSize;
+40 -4
View File
@@ -247,8 +247,14 @@ public sealed class WorldScene : Scene
hudChildren.Add(spawner.Panel); hudChildren.Add(spawner.Panel);
} }
// Журнал событий — доступен всем (кнопка «События»). // Журнал событий — доступен всем (кнопка «События»). Двойной клик по записи ведёт камеру к
_eventsPanel = new EventsPanel(content.Languages, _events); // «ответственному» и выбирает его (если он ещё жив).
_eventsPanel = new EventsPanel(
content.Languages,
_events,
Context.Clock,
id => FocusEntity(camera, id)
);
hudChildren.Add(_eventsPanel.Panel); hudChildren.Add(_eventsPanel.Panel);
hudButtons.Widgets.Add( hudButtons.Widgets.Add(
HudToggle( HudToggle(
@@ -555,6 +561,25 @@ public sealed class WorldScene : Scene
Store.CreateEntity(new Tilemap(grid, tiles, CellSize)); Store.CreateEntity(new Tilemap(grid, tiles, CellSize));
} }
// Переход к «ответственному» за событие (двойной клик в журнале): выбирает сущность и центрирует на
// ней камеру, если она ещё существует (актёр мог умереть). Камера зажимается к границам при отрисовке.
private void FocusEntity(Entity camera, int id)
{
if (
id < 0
|| !Store.TryGetEntityById(id, out var target)
|| target.IsNull
|| !target.HasComponent<Transform2D>()
)
{
return;
}
_selection.Select(id);
ref var cam = ref camera.GetComponent<Camera>();
cam.Position = target.GetComponent<Transform2D>().Position;
}
// Кнопка-переключатель оверлея HUD: подсвечивается как активная, пока оверлей открыт. // Кнопка-переключатель оверлея HUD: подсвечивается как активная, пока оверлей открыт.
private static TextButton HudToggle(string text, Action onClick, Func<bool> isOpen) private static TextButton HudToggle(string text, Action onClick, Func<bool> isOpen)
{ {
@@ -575,7 +600,13 @@ public sealed class WorldScene : Scene
bar.VerticalAlignment = VerticalAlignment.Bottom; bar.VerticalAlignment = VerticalAlignment.Bottom;
bar.Margin = new Thickness(0, 0, 0, 12); bar.Margin = new Thickness(0, 0, 0, 12);
var pause = new TextButton { Text = content.Languages.Get("speed.pause") }; var hover = new SolidBrush(new Color(40, 54, 72)); // подсветка кнопок скорости при наведении
var pause = new TextButton
{
Text = content.Languages.Get("speed.pause"),
Padding = new Thickness(8, 4),
OverBackground = hover,
};
pause.Click += (_, _) => _speed.Pause(); pause.Click += (_, _) => _speed.Pause();
bar.Widgets.Add(pause); bar.Widgets.Add(pause);
_speedRefreshers.Add(() => pause.TextColor = _speed.IsPaused ? Ui.Accent : Ui.Muted); _speedRefreshers.Add(() => pause.TextColor = _speed.IsPaused ? Ui.Accent : Ui.Muted);
@@ -583,7 +614,12 @@ public sealed class WorldScene : Scene
for (var i = 0; i < _speed.Steps.Count; i++) for (var i = 0; i < _speed.Steps.Count; i++)
{ {
var index = i; var index = i;
var button = new TextButton { Text = $"x{_speed.Steps[i]:0}" }; var button = new TextButton
{
Text = $"x{_speed.Steps[i]:0}",
Padding = new Thickness(8, 4),
OverBackground = hover,
};
button.Click += (_, _) => _speed.SetStep(index); button.Click += (_, _) => _speed.SetStep(index);
bar.Widgets.Add(button); bar.Widgets.Add(button);
_speedRefreshers.Add(() => _speedRefreshers.Add(() =>
+7 -5
View File
@@ -638,6 +638,7 @@ public sealed class AnimalDecisionSystem : BaseSystem
&& TryFindMate( && TryFindMate(
pos, pos,
o[i].IsMale, o[i].IsMale,
o[i].Species,
seeRadius, seeRadius,
out var mateId, out var mateId,
out var matePos out var matePos
@@ -1333,10 +1334,11 @@ public sealed class AnimalDecisionSystem : BaseSystem
return true; return true;
} }
// Ближайший подходящий партнёр в радиусе зрения: взрослый, противоположного пола, с влечением > 0.5. // Ближайший подходящий партнёр в радиусе зрения: ТОГО ЖЕ вида, взрослый, противоположного пола.
private bool TryFindMate( private bool TryFindMate(
Vector2 from, Vector2 from,
bool selfMale, bool selfMale,
int selfSpecies,
float radius, float radius,
out int mateId, out int mateId,
out Vector2 position out Vector2 position
@@ -1348,13 +1350,13 @@ public sealed class AnimalDecisionSystem : BaseSystem
// _animalBuf уже собран в DecideIn на макс. радиусе чувств — фильтруем по радиусу спаривания. // _animalBuf уже собран в DecideIn на макс. радиусе чувств — фильтруем по радиусу спаривания.
foreach (var e in _animalBuf) foreach (var e in _animalBuf)
{ {
if (e.IsMale == selfMale || !e.IsAdult) if (e.Species != selfSpecies || e.IsMale == selfMale || !e.IsAdult)
{ {
continue; // тот же пол / не взрослый (self отсеивается по полу) continue; // другой вид / тот же пол / не взрослый (self отсеивается по полу)
} }
// Влечение партнёра не проверяется (учёт придёт с половым отбором); вид не фильтруется — // Влечение партнёра не проверяется (учёт придёт с половым отбором). self отсекается совпадением
// межвидовые пары возможны, как и раньше. self отсекается совпадением пола и sq≈0. // пола и sq≈0; межвидовые пары исключены (раньше волк/курица могли «спариться»).
var sq = Vector2.DistanceSquared(from, e.Pos); var sq = Vector2.DistanceSquared(from, e.Pos);
if (sq > 0.01f && (sq < bestSq || (sq == bestSq && e.Id < mateId))) if (sq > 0.01f && (sq < bestSq || (sq == bestSq && e.Id < mateId)))
{ {
+20 -14
View File
@@ -22,13 +22,18 @@ public enum EventCategory : byte
Routine, Routine,
} }
/// <summary>Одна запись журнала: готовая (локализованная) строка, категория и отметка времени календаря.</summary> /// <summary>
/// Одна запись журнала: готовая (локализованная) строка, категория, отметка времени календаря и id
/// «ответственной» сущности (для перехода камерой/выбора по двойному клику); -1 — не к кому переходить
/// (напр. смерть — особь исчезла).
/// </summary>
public readonly record struct LogEntry( public readonly record struct LogEntry(
string Text, string Text,
EventCategory Category, EventCategory Category,
int Day, int Day,
int Hour, int Hour,
int Minute int Minute,
int SubjectId
); );
/// <summary> /// <summary>
@@ -72,7 +77,7 @@ public sealed class WorldEvents
public void Mated(Entity a, int aSpecies, Entity b, int bSpecies) public void Mated(Entity a, int aSpecies, Entity b, int bSpecies)
{ {
var text = _lang.Format("event.mated", Label(aSpecies), Label(bSpecies)); var text = _lang.Format("event.mated", Label(aSpecies), Label(bSpecies));
Global(EventCategory.Reproduction, text); Global(EventCategory.Reproduction, text, a.Id);
Creature(a, EventCategory.Reproduction, text); Creature(a, EventCategory.Reproduction, text);
Creature( Creature(
b, b,
@@ -84,32 +89,32 @@ public sealed class WorldEvents
public void Pregnant(Entity mother, int species) public void Pregnant(Entity mother, int species)
{ {
var text = _lang.Format("event.pregnant", Label(species)); var text = _lang.Format("event.pregnant", Label(species));
Global(EventCategory.Reproduction, text); Global(EventCategory.Reproduction, text, mother.Id);
Creature(mother, EventCategory.Reproduction, text); Creature(mother, EventCategory.Reproduction, text);
} }
public void Born(Entity mother, int species, int litter) public void Born(Entity mother, int species, int litter)
{ {
var text = _lang.Format("event.birth", Label(species), litter); var text = _lang.Format("event.birth", Label(species), litter);
Global(EventCategory.Reproduction, text); Global(EventCategory.Reproduction, text, mother.Id);
Creature(mother, EventCategory.Reproduction, text); Creature(mother, EventCategory.Reproduction, text);
} }
public void Hatched(int species, int count) => public void Hatched(int species, int count) =>
Global(EventCategory.Reproduction, _lang.Format("event.hatch", Label(species), count)); Global(EventCategory.Reproduction, _lang.Format("event.hatch", Label(species), count), -1);
// — Охота и бой — // — Охота и бой —
public void HuntStarted(Entity hunter, int hunterSpecies, int preySpecies) public void HuntStarted(Entity hunter, int hunterSpecies, int preySpecies)
{ {
var text = _lang.Format("event.hunt", Label(hunterSpecies), Label(preySpecies)); var text = _lang.Format("event.hunt", Label(hunterSpecies), Label(preySpecies));
Global(EventCategory.Combat, text); Global(EventCategory.Combat, text, hunter.Id);
Creature(hunter, EventCategory.Combat, text); Creature(hunter, EventCategory.Combat, text);
} }
public void Killed(Entity hunter, int hunterSpecies, int preySpecies) public void Killed(Entity hunter, int hunterSpecies, int preySpecies)
{ {
var text = _lang.Format("event.killed", Label(hunterSpecies), Label(preySpecies)); var text = _lang.Format("event.killed", Label(hunterSpecies), Label(preySpecies));
Global(EventCategory.Combat, text); Global(EventCategory.Combat, text, hunter.Id);
Creature(hunter, EventCategory.Combat, text); Creature(hunter, EventCategory.Combat, text);
} }
@@ -138,7 +143,7 @@ public sealed class WorldEvents
// — Смерть (умершая особь исчезает, её хроника — тоже; пишем только в глобальный журнал) — // — Смерть (умершая особь исчезает, её хроника — тоже; пишем только в глобальный журнал) —
public void Died(int species, string causeKey) => public void Died(int species, string causeKey) =>
Global(EventCategory.Death, _lang.Format(causeKey, Label(species))); Global(EventCategory.Death, _lang.Format(causeKey, Label(species)), -1); // особь исчезла
// — Бытовое (только личная хроника особи, чтобы не засорять глобальный журнал) — // — Бытовое (только личная хроника особи, чтобы не засорять глобальный журнал) —
public void Ate(Entity e, int species) => public void Ate(Entity e, int species) =>
@@ -167,12 +172,13 @@ public sealed class WorldEvents
private string Label(int species) => _lang.Get(_animals[species].Def.Label); private string Label(int species) => _lang.Get(_animals[species].Def.Label);
private LogEntry Make(EventCategory category, string text) => private LogEntry Make(EventCategory category, string text, int subjectId) =>
new(text, category, _calendar.Day, _calendar.Hour, _calendar.Minute); new(text, category, _calendar.Day, _calendar.Hour, _calendar.Minute, subjectId);
private void Global(EventCategory category, string text) // subjectId — кого выбирать/куда вести камеру по двойному клику (-1 — не к кому, напр. смерть).
private void Global(EventCategory category, string text, int subjectId)
{ {
_global.Add(Make(category, text)); _global.Add(Make(category, text, subjectId));
if (_global.Count > _capacity) if (_global.Count > _capacity)
{ {
_global.RemoveAt(0); // вытесняем старейшее _global.RemoveAt(0); // вытесняем старейшее
@@ -188,7 +194,7 @@ public sealed class WorldEvents
return; return;
} }
log.Entries.Add(Make(category, text)); log.Entries.Add(Make(category, text, e.Id)); // субъект хроники — сама особь
if (log.Entries.Count > log.Capacity) if (log.Entries.Count > log.Capacity)
{ {
log.Entries.RemoveAt(0); log.Entries.RemoveAt(0);
+59 -11
View File
@@ -1,6 +1,7 @@
using System; using System;
using LittleSim.Sim; using LittleSim.Sim;
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework;
using MrGameEng.Core;
using MrGameEng.Mods; using MrGameEng.Mods;
using Myra.Graphics2D; using Myra.Graphics2D;
using Myra.Graphics2D.Brushes; using Myra.Graphics2D.Brushes;
@@ -11,31 +12,44 @@ namespace LittleSim.UI;
/// <summary> /// <summary>
/// Оверлей глобального журнала событий мира (фича UI «События»): последние заметные события (спаривание, /// Оверлей глобального журнала событий мира (фича UI «События»): последние заметные события (спаривание,
/// роды/вылупление, начало охоты, убийство, смерти) новыми сверху, цвет строки — по категории. Источник — /// роды/вылупление, начало охоты, убийство, смерти) новыми сверху, цвет строки — по категории. Источник —
/// <see cref="WorldEvents"/>; тело перестраивается лишь при изменении версии журнала (новое событие), /// <see cref="WorldEvents"/>; тело перестраивается лишь при изменении версии журнала (новое событие).
/// поэтому открытое окно почти ничего не стоит на паузе. Переключается кнопкой HUD. /// Кликабельные записи подсвечиваются при наведении, по двойному клику ведут камеру к «ответственному».
/// Окно плавно проявляется при открытии. Переключается кнопкой HUD.
/// </summary> /// </summary>
internal sealed class EventsPanel internal sealed class EventsPanel
{ {
private const int MaxShown = 60; // сколько последних записей показывать private const int MaxShown = 60; // сколько последних записей показывать
private const float FadeSeconds = 0.16f; // длительность плавного появления
// Цвета строк по категории (приглушённые — читаемо на тёмном фоне). // Цвета строк по категории (приглушённые — читаемо на тёмном фоне).
private static readonly Color Reproduction = new(120, 186, 120); private static readonly Color Reproduction = new(120, 186, 120);
private static readonly Color Combat = new(214, 150, 86); private static readonly Color Combat = new(214, 150, 86);
private static readonly Color Death = new(196, 92, 86); private static readonly Color Death = new(196, 92, 86);
private static readonly Color Routine = new(150, 160, 175); private static readonly Color Routine = new(150, 160, 175);
private static readonly IBrush RowHover = new SolidBrush(new Color(96, 134, 168, 60));
private readonly LanguageManager _lang; private readonly LanguageManager _lang;
private readonly WorldEvents _events; private readonly WorldEvents _events;
private readonly GameClock _clock;
private readonly Action<int> _onFocus; // двойной клик по записи: перейти/выбрать «ответственного»
private readonly VerticalStackPanel _panel; private readonly VerticalStackPanel _panel;
private readonly VerticalStackPanel _body; private readonly VerticalStackPanel _body;
private int _builtVersion = -1; private int _builtVersion = -1;
private float _opacity;
public EventsPanel(LanguageManager lang, WorldEvents events) public EventsPanel(
LanguageManager lang,
WorldEvents events,
GameClock clock,
Action<int> onFocus
)
{ {
_lang = lang; _lang = lang;
_events = events; _events = events;
_clock = clock;
_onFocus = onFocus;
_body = new VerticalStackPanel { Spacing = 2 }; _body = new VerticalStackPanel { Spacing = 1 };
var scroll = new ScrollViewer var scroll = new ScrollViewer
{ {
Content = _body, Content = _body,
@@ -59,6 +73,8 @@ internal sealed class EventsPanel
_panel.Widgets.Add(new Label { Text = _lang.Get("events.title"), TextColor = Ui.Accent }); _panel.Widgets.Add(new Label { Text = _lang.Get("events.title"), TextColor = Ui.Accent });
_panel.Widgets.Add(new HorizontalSeparator()); _panel.Widgets.Add(new HorizontalSeparator());
_panel.Widgets.Add(scroll); _panel.Widgets.Add(scroll);
_panel.Widgets.Add(new HorizontalSeparator());
_panel.Widgets.Add(new Label { Text = _lang.Get("events.hint"), TextColor = Ui.Muted });
} }
/// <summary>Корневой виджет — добавляется в HUD.</summary> /// <summary>Корневой виджет — добавляется в HUD.</summary>
@@ -67,20 +83,33 @@ internal sealed class EventsPanel
/// <summary>Открыт ли журнал.</summary> /// <summary>Открыт ли журнал.</summary>
public bool IsOpen => _panel.Visible; public bool IsOpen => _panel.Visible;
/// <summary>Показать/скрыть; при открытии форсирует пересборку тела.</summary> /// <summary>Показать/скрыть; при открытии запускает плавное появление и форсирует пересборку тела.</summary>
public void Toggle() public void Toggle()
{ {
_panel.Visible = !_panel.Visible; _panel.Visible = !_panel.Visible;
if (_panel.Visible) if (_panel.Visible)
{ {
_opacity = 0f;
_panel.Opacity = 0f;
_builtVersion = -1; // пересобрать при следующем Refresh _builtVersion = -1; // пересобрать при следующем Refresh
} }
} }
/// <summary>Вызывать каждый кадр: перестраивает тело лишь при новом событии (по версии журнала).</summary> /// <summary>Вызывать каждый кадр: плавное появление + пересборка тела при новом событии (по версии).</summary>
public void Refresh() public void Refresh()
{ {
if (!_panel.Visible || _events.Version == _builtVersion) if (!_panel.Visible)
{
return;
}
if (_opacity < 1f)
{
_opacity = MathF.Min(1f, _opacity + _clock.UnscaledDeltaTime / FadeSeconds);
_panel.Opacity = _opacity;
}
if (_events.Version == _builtVersion)
{ {
return; return;
} }
@@ -100,14 +129,33 @@ internal sealed class EventsPanel
{ {
var entry = entries[entries.Count - 1 - i]; var entry = entries[entries.Count - 1 - i];
var stamp = _lang.Format("log.stamp", entry.Day, entry.Hour, entry.Minute); var stamp = _lang.Format("log.stamp", entry.Day, entry.Hour, entry.Minute);
_body.Widgets.Add( var color = ColorOf(entry.Category);
new Label var row = new Label
{ {
Text = stamp + " " + entry.Text, Text = stamp + " " + entry.Text,
TextColor = ColorOf(entry.Category), TextColor = color,
Wrap = true, Wrap = true,
Padding = new Thickness(4, 2),
};
// Запись с «ответственным» — кликабельна: подсветка при наведении + переход по двойному клику.
var subjectId = entry.SubjectId;
if (subjectId >= 0)
{
var baseColor = color;
row.MouseEntered += (_, _) =>
{
row.Background = RowHover;
row.TextColor = Color.Lerp(baseColor, Color.White, 0.45f);
};
row.MouseLeft += (_, _) =>
{
row.Background = null;
row.TextColor = baseColor;
};
row.TouchDoubleClick += (_, _) => _onFocus(subjectId);
} }
);
_body.Widgets.Add(row);
} }
} }
+4 -1
View File
@@ -194,7 +194,9 @@ internal sealed class InspectPanel
region, region,
sprite.Origin sprite.Origin
); );
_brackets.Show(center, radius); // позиция брекета — каждый кадр (дёшево, следит за движением) // Лёгкая «дышащая» пульсация рамки выделения (живее статичной скобки), по реальному времени.
var pulse = 1f + 0.06f * MathF.Sin((float)_clock.UnscaledTotalTime * 5f);
_brackets.Show(center, radius * pulse); // позиция/размер каждый кадр (дёшево, следит за движением)
} }
// Тело вкладки пересобираем только при смене выбора/вкладки или по таймеру (значения нужд/HP // Тело вкладки пересобираем только при смене выбора/вкладки или по таймеру (значения нужд/HP
@@ -241,6 +243,7 @@ internal sealed class InspectPanel
{ {
Text = _content.Languages.Get(labelKey), Text = _content.Languages.Get(labelKey),
Padding = new Thickness(6, 3), Padding = new Thickness(6, 3),
OverBackground = new SolidBrush(new Color(40, 54, 72)), // подсветка при наведении
}; };
button.Click += (_, _) => _tab = tab; button.Click += (_, _) => _tab = tab;
bar.Widgets.Add(button); bar.Widgets.Add(button);
+12 -1
View File
@@ -22,6 +22,7 @@ internal sealed class PerfPanel
{ {
private const int TopSystems = 12; // сколько самых дорогих систем показывать на корень private const int TopSystems = 12; // сколько самых дорогих систем показывать на корень
private const double RefreshInterval = 0.2; // сек реального времени между пересборками тела (5 Гц) private const double RefreshInterval = 0.2; // сек реального времени между пересборками тела (5 Гц)
private const float FadeSeconds = 0.16f; // длительность плавного появления
private readonly LanguageManager _lang; private readonly LanguageManager _lang;
private readonly GameClock _clock; private readonly GameClock _clock;
@@ -35,6 +36,7 @@ internal sealed class PerfPanel
private float _accum; // накоплено реального времени для окна усреднения FPS private float _accum; // накоплено реального времени для окна усреднения FPS
private int _frames; private int _frames;
private double _nextBuildAt; private double _nextBuildAt;
private float _opacity;
public PerfPanel(LanguageManager lang, GameClock clock, params SystemRoot[] roots) public PerfPanel(LanguageManager lang, GameClock clock, params SystemRoot[] roots)
{ {
@@ -92,6 +94,8 @@ internal sealed class PerfPanel
_panel.Visible = !_panel.Visible; _panel.Visible = !_panel.Visible;
if (_panel.Visible) if (_panel.Visible)
{ {
_opacity = 0f;
_panel.Opacity = 0f; // плавно проявим в Refresh
PerfReport.EnableMonitoring(_roots); PerfReport.EnableMonitoring(_roots);
} }
} }
@@ -99,7 +103,14 @@ internal sealed class PerfPanel
/// <summary>Вызывать каждый кадр: считает FPS (всегда) и перестраивает тело по троттлингу (если открыто).</summary> /// <summary>Вызывать каждый кадр: считает FPS (всегда) и перестраивает тело по троттлингу (если открыто).</summary>
public void Refresh() public void Refresh()
{ {
_accum += _clock.UnscaledDeltaTime; var dt = _clock.UnscaledDeltaTime;
if (_panel.Visible && _opacity < 1f)
{
_opacity = MathF.Min(1f, _opacity + dt / FadeSeconds);
_panel.Opacity = _opacity;
}
_accum += dt;
_frames++; _frames++;
var now = _clock.UnscaledTotalTime; var now = _clock.UnscaledTotalTime;
if (now < _nextBuildAt) if (now < _nextBuildAt)