Files
ai-images/Source/AIImages/Window_AIImage.cs

1507 lines
56 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Linq;
using System.Threading;
using AIImages.Helpers;
using AIImages.Models;
using AIImages.Patches;
using AIImages.Services;
using RimWorld;
using UnityEngine;
using Verse;
#pragma warning disable IDE1006 // Naming Styles
namespace AIImages
{
/// <summary>
/// Окно для просмотра персонажа и генерации AI изображений
/// </summary>
[System.Diagnostics.CodeAnalysis.SuppressMessage(
"Style",
"IDE1006:Naming Styles",
Justification = "RimWorld Window naming convention"
)]
[System.Diagnostics.CodeAnalysis.SuppressMessage(
"Minor Code Smell",
"S101:Types should be named in PascalCase",
Justification = "RimWorld Window naming convention"
)]
public class Window_AIImage : Window
{
private Pawn pawn;
private PawnAppearanceData appearanceData;
private StableDiffusionSettings generationSettings;
private Texture2D generatedImage;
private bool isGenerating = false;
private string generationStatus = "";
private CancellationTokenSource cancellationTokenSource;
// Прогресс генерации
private double generationProgress = 0.0;
private int currentStep = 0;
private int totalSteps = 0;
private double etaSeconds = 0.0;
// Сервисы (получаем через DI)
private readonly IPawnDescriptionService pawnDescriptionService;
private readonly IPromptGeneratorService promptGeneratorService;
private readonly IStableDiffusionApiService apiService;
public Window_AIImage(Pawn pawn)
{
this.pawn = pawn;
this.doCloseX = true;
this.doCloseButton = false; // Убираем дублирующую кнопку "Закрыть"
this.forcePause = false;
this.absorbInputAroundWindow = false;
this.draggable = true;
this.preventCameraMotion = false;
// Получаем сервисы через DI контейнер
var services = AIImagesMod.Services;
pawnDescriptionService = services.PawnDescriptionService;
promptGeneratorService = services.PromptGeneratorService;
apiService = services.ApiService;
// Извлекаем данные персонажа
RefreshPawnData();
}
public override Vector2 InitialSize => new Vector2(900f, 800f);
private Vector2 mainScrollPosition = Vector2.zero;
private Vector2 promptScrollPosition = Vector2.zero;
private Vector2 negativePromptScrollPosition = Vector2.zero;
private Vector2 logEntriesScrollPosition = Vector2.zero;
private float copiedMessageTime = 0f;
// Состояние сворачиваемых секций промптов
private bool showPositivePrompt = false;
private bool showNegativePrompt = false;
private bool showLogEntries = true; // Развёрнута по умолчанию
/// <summary>
/// Обновляет данные персонажа
/// </summary>
private void RefreshPawnData()
{
DebugLogger.Log($"[AI Images] RefreshPawnData called for {pawn?.Name}");
appearanceData = pawnDescriptionService.ExtractAppearanceData(pawn);
generationSettings = AIImagesMod.Settings.ToStableDiffusionSettings();
// Загружаем сохраненный портрет, если есть
LoadSavedPortrait();
DebugLogger.Log($"[AI Images] RefreshPawnData completed for {pawn?.Name}");
}
/// <summary>
/// Загружает сохраненный портрет персонажа
/// </summary>
private void LoadSavedPortrait()
{
DebugLogger.Log($"[AI Images] LoadSavedPortrait called for {pawn?.Name}");
if (PawnPortraitHelper.HasPortrait(pawn))
{
DebugLogger.Log($"[AI Images] Portrait found for {pawn?.Name}, loading...");
generatedImage = PawnPortraitHelper.LoadPortrait(pawn);
if (generatedImage != null)
{
generationStatus = "AIImages.Generation.LoadedFromSave".Translate();
DebugLogger.Log($"[AI Images] Successfully loaded portrait for {pawn?.Name}");
DebugLogger.Log(
$"[AI Images] generatedImage is now set: {generatedImage != null}, size: {generatedImage.width}x{generatedImage.height}"
);
}
else
{
DebugLogger.Warning(
$"[AI Images] Failed to load portrait texture for {pawn?.Name}"
);
}
}
else
{
DebugLogger.Log($"[AI Images] No saved portrait found for {pawn?.Name}");
}
}
/// <summary>
/// Освобождает ресурсы при закрытии окна
/// </summary>
public override void PreClose()
{
base.PreClose();
// Отменяем генерацию, если она идет
if (isGenerating && cancellationTokenSource != null)
{
cancellationTokenSource.Cancel();
}
// Освобождаем CancellationTokenSource
cancellationTokenSource?.Dispose();
}
/// <summary>
/// Обновляет текущую пешку в окне
/// </summary>
public void UpdatePawn(Pawn newPawn)
{
DebugLogger.Log(
$"[AI Images] UpdatePawn called - switching from {pawn?.Name} to {newPawn?.Name}"
);
this.pawn = newPawn;
// Очищаем старое изображение при смене персонажа
generatedImage = null;
generationStatus = "";
generationProgress = 0.0;
currentStep = 0;
totalSteps = 0;
etaSeconds = 0.0;
DebugLogger.Log($"[AI Images] Cleared generated image for {newPawn?.Name}");
// Сбрасываем состояние сворачиваемых секций
showPositivePrompt = false;
showNegativePrompt = false;
// Обновляем данные персонажа (включая загрузку портрета)
RefreshPawnData();
// Принудительно обновляем окно
this.windowRect = new Rect(
this.windowRect.x,
this.windowRect.y,
this.InitialSize.x,
this.InitialSize.y
);
// Принудительно перерисовываем окно
this.SetInitialSizeAndPosition();
DebugLogger.Log($"[AI Images] UpdatePawn completed for {newPawn?.Name}");
}
/// <summary>
/// Получить текущую пешку
/// </summary>
public Pawn CurrentPawn => pawn;
/// <summary>
/// Генерирует изображение для пешки с описанием события
/// </summary>
public void GenerateImageForEvent(string eventDescription)
{
if (pawn == null || isGenerating)
return;
// Устанавливаем описание события
if (appearanceData != null)
{
appearanceData.EventDescription = eventDescription;
}
// Запускаем генерацию
StartGeneration();
}
/// <summary>
/// Отладочный метод для проверки состояния всех пешек
/// </summary>
public static void DebugAllPawns()
{
DebugLogger.Log("[AI Images] === DEBUG: Checking all pawns ===");
if (Current.Game?.Maps == null)
{
DebugLogger.Log("[AI Images] No game or maps available");
return;
}
int totalPawns = 0;
int pawnsWithComponents = 0;
int pawnsWithPortraits = 0;
foreach (var map in Current.Game.Maps)
{
foreach (var pawn in map.mapPawns.AllPawns)
{
if (pawn.RaceProps?.Humanlike == true)
{
totalPawns++;
var (hasComponent, hasPortrait) = CheckPawnPortraitStatus(pawn);
if (hasComponent)
pawnsWithComponents++;
if (hasPortrait)
pawnsWithPortraits++;
}
}
}
LogDebugSummary(totalPawns, pawnsWithComponents, pawnsWithPortraits);
}
private static (bool hasComponent, bool hasPortrait) CheckPawnPortraitStatus(Pawn pawn)
{
var comp = PawnPortraitHelper.GetPortraitComp(pawn);
if (comp != null)
{
if (comp.HasPortrait)
{
DebugLogger.Log(
$"[AI Images] {pawn.Name}: Has component with portrait '{comp.PortraitPath}'"
);
return (true, true);
}
else
{
DebugLogger.Log($"[AI Images] {pawn.Name}: Has component but no portrait");
return (true, false);
}
}
else
{
DebugLogger.Warning($"[AI Images] {pawn.Name}: No portrait component found!");
return (false, false);
}
}
private static void LogDebugSummary(
int totalPawns,
int pawnsWithComponents,
int pawnsWithPortraits
)
{
DebugLogger.Log($"[AI Images] === DEBUG SUMMARY ===");
DebugLogger.Log($"[AI Images] Total humanlike pawns: {totalPawns}");
DebugLogger.Log($"[AI Images] Pawns with components: {pawnsWithComponents}");
DebugLogger.Log($"[AI Images] Pawns with portraits: {pawnsWithPortraits}");
DebugLogger.Log($"[AI Images] === END DEBUG ===");
}
/// <summary>
/// Вызывается каждый кадр для обновления окна
/// </summary>
public override void WindowUpdate()
{
base.WindowUpdate();
// Проверяем, изменилась ли выбранная пешка
Pawn selectedPawn = Find.Selector.SelectedPawns.FirstOrDefault(p => p.Spawned);
// Если выбрана новая пешка, обновляем окно
if (selectedPawn != null && selectedPawn != pawn)
{
UpdatePawn(selectedPawn);
}
// Уменьшаем таймер сообщения о копировании
if (copiedMessageTime > 0f)
{
copiedMessageTime -= Time.deltaTime;
}
}
/// <summary>
/// Асинхронная генерация изображения с поддержкой отмены
/// </summary>
private async System.Threading.Tasks.Task GenerateImageAsync()
{
if (isGenerating)
return;
// Создаем новый CancellationTokenSource
cancellationTokenSource?.Dispose();
cancellationTokenSource = new CancellationTokenSource();
isGenerating = true;
generationStatus = "AIImages.Generation.InProgress".Translate();
generationProgress = 0.0;
currentStep = 0;
totalSteps = generationSettings.Steps;
try
{
// Генерируем промпты - если есть описание события, используем его
string positivePrompt;
if (!string.IsNullOrEmpty(appearanceData?.EventDescription))
{
positivePrompt = promptGeneratorService.GeneratePositivePromptWithEvent(
appearanceData,
generationSettings,
appearanceData.EventDescription
);
}
else
{
positivePrompt = promptGeneratorService.GeneratePositivePrompt(
appearanceData,
generationSettings
);
}
string negativePrompt = promptGeneratorService.GenerateNegativePrompt(
generationSettings
);
// Создаем запрос
var request = new GenerationRequest
{
Prompt = positivePrompt,
NegativePrompt = negativePrompt,
Steps = generationSettings.Steps,
CfgScale = generationSettings.CfgScale,
Width = generationSettings.Width,
Height = generationSettings.Height,
Sampler = generationSettings.Sampler,
Scheduler = generationSettings.Scheduler,
Seed = generationSettings.Seed,
Model = AIImagesMod.Settings.apiEndpoint,
SaveImagesToServer = AIImagesMod.Settings.saveImagesToServer,
};
// Создаем отдельный CancellationTokenSource для мониторинга прогресса
var progressCts = new CancellationTokenSource();
var progressTask = MonitorProgressAsync(progressCts.Token);
// Генерируем изображение с поддержкой отмены
var result = await apiService.GenerateImageAsync(
request,
cancellationTokenSource.Token
);
// Останавливаем мониторинг прогресса
progressCts.Cancel();
try
{
await progressTask;
}
catch (OperationCanceledException)
{
// Ожидаемое исключение при остановке мониторинга
}
finally
{
progressCts.Dispose();
}
if (result.Success)
{
DebugLogger.Log($"[AI Images] Generation successful for {pawn?.Name}");
DebugLogger.Log(
$"[AI Images] Image data size: {result.ImageData?.Length ?? 0} bytes"
);
DebugLogger.Log($"[AI Images] Saved path: {result.SavedPath}");
// Загружаем текстуру
generatedImage = new Texture2D(2, 2);
generatedImage.LoadImage(result.ImageData);
generationStatus = "AIImages.Generation.Success".Translate();
generationProgress = 1.0;
// Сохраняем путь к портрету на персонаже
DebugLogger.Log(
$"[AI Images] About to save portrait path for {pawn?.Name}: {result.SavedPath}"
);
PawnPortraitHelper.SavePortraitPath(pawn, result.SavedPath);
Messages.Message(
"AIImages.Generation.SavedTo".Translate(result.SavedPath),
MessageTypeDefOf.PositiveEvent
);
DebugLogger.Log(
$"[AI Images] Portrait generation and saving completed for {pawn?.Name}"
);
}
else
{
generationStatus =
$"AIImages.Generation.Failed".Translate() + ": {result.ErrorMessage}";
Messages.Message(generationStatus, MessageTypeDefOf.RejectInput);
}
}
catch (OperationCanceledException)
{
generationStatus = "AIImages.Generation.Cancelled".Translate();
DebugLogger.Log("[AI Images] Generation cancelled by user");
}
catch (Exception ex)
{
generationStatus = $"Error: {ex.Message}";
DebugLogger.Error($"[AI Images] Generation error: {ex}");
Messages.Message(
$"AIImages.Generation.Error".Translate() + ": {ex.Message}",
MessageTypeDefOf.RejectInput
);
}
finally
{
isGenerating = false;
}
}
/// <summary>
/// Мониторит прогресс генерации и обновляет UI
/// </summary>
private async System.Threading.Tasks.Task MonitorProgressAsync(
CancellationToken cancellationToken
)
{
try
{
while (!cancellationToken.IsCancellationRequested)
{
var progress = await apiService.GetProgressAsync(cancellationToken);
if (progress != null && progress.IsActive)
{
generationProgress = progress.Progress;
currentStep = progress.CurrentStep;
totalSteps = progress.TotalSteps;
etaSeconds = progress.EtaRelative;
DebugLogger.Log(
$"[AI Images] Progress: {progress.Progress:P} - Step {progress.CurrentStep}/{progress.TotalSteps} - ETA: {progress.EtaRelative:F1}s"
);
}
// Обновляем каждые 500ms
await System.Threading.Tasks.Task.Delay(500, cancellationToken);
}
}
catch (OperationCanceledException)
{
// Ожидаемое исключение при остановке
}
catch (Exception ex)
{
DebugLogger.Warning($"[AI Images] Progress monitoring error: {ex.Message}");
}
}
/// <summary>
/// Запускает генерацию изображения (обертка для безопасного fire-and-forget)
/// </summary>
private void StartGeneration()
{
AsyncHelper.FireAndForget(GenerateImageAsync(), "Image Generation");
}
/// <summary>
/// Отменяет генерацию изображения
/// </summary>
private void CancelGeneration()
{
if (isGenerating && cancellationTokenSource != null)
{
cancellationTokenSource.Cancel();
generationStatus = "AIImages.Generation.Cancelling".Translate();
}
}
public override void DoWindowContents(Rect inRect)
{
// Рассчитываем общую высоту контента
float totalContentHeight = CalculateTotalContentHeight();
Rect viewRect = new Rect(0f, 0f, inRect.width - 20f, totalContentHeight);
Widgets.BeginScrollView(inRect, ref mainScrollPosition, viewRect);
float curY = 0f;
// Заголовок
Text.Font = GameFont.Medium;
Widgets.Label(
new Rect(0f, curY, viewRect.width, 40f),
"AIImages.Window.Title".Translate()
);
curY += 45f;
// Имя пешки
Text.Font = GameFont.Small;
Widgets.Label(
new Rect(0f, curY, viewRect.width, 30f),
"AIImages.Window.PawnLabel".Translate(pawn.NameShortColored.Resolve())
);
curY += 40f;
// Разделитель
Widgets.DrawLineHorizontal(0f, curY, viewRect.width);
curY += 10f;
// Разделяем на две колонки: левая - информация, правая - изображение
float leftColumnWidth = viewRect.width * 0.35f;
float rightColumnWidth = viewRect.width * 0.62f;
float columnGap = viewRect.width * 0.03f;
// Определяем высоту колонок (берем большую из двух)
float columnHeight = Mathf.Max(CalculateContentHeight(), CalculateRightColumnHeight());
// Левая колонка - информация
Rect leftColumnRect = new Rect(0f, curY, leftColumnWidth, columnHeight);
DrawLeftColumn(leftColumnRect);
// Правая колонка - превью и управление
Rect rightColumnRect = new Rect(
leftColumnWidth + columnGap,
curY,
rightColumnWidth,
columnHeight
);
DrawRightColumn(rightColumnRect);
Widgets.EndScrollView();
}
private void DrawLeftColumn(Rect rect)
{
float contentY = rect.y;
// Портрет персонажа (если есть)
if (generatedImage != null)
{
float portraitSize = Mathf.Min(rect.width, 250f);
Rect portraitRect = new Rect(
rect.x + (rect.width - portraitSize) / 2f,
contentY,
portraitSize,
portraitSize
);
// Рамка вокруг портрета
Widgets.DrawBox(portraitRect);
GUI.DrawTexture(portraitRect.ContractedBy(2f), generatedImage);
contentY += portraitSize + 15f;
}
// Заголовок секции
Text.Font = GameFont.Medium;
Widgets.Label(
new Rect(rect.x, contentY, rect.width, 30f),
"AIImages.CharacterInfo.Title".Translate()
);
contentY += 35f;
// Разделитель
Widgets.DrawLineHorizontal(rect.x, contentY, rect.width);
contentY += 10f;
// Информация о персонаже
DrawCharacterInfoContent(rect, contentY);
}
/// <summary>
/// Отрисовывает информацию о персонаже в компактном виде
/// </summary>
private void DrawCharacterInfoContent(Rect parentRect, float startY)
{
float contentY = startY;
float lineHeight = 22f;
contentY = DrawBasicInfo(parentRect, contentY, lineHeight);
contentY = DrawTraits(parentRect, contentY, lineHeight);
contentY = DrawGenes(parentRect, contentY, lineHeight);
contentY = DrawHediffs(parentRect, contentY, lineHeight);
contentY = DrawApparel(parentRect, contentY, lineHeight);
contentY = DrawLogEntries(parentRect, contentY, lineHeight);
}
/// <summary>
/// Отрисовывает базовую информацию о персонаже
/// </summary>
private float DrawBasicInfo(Rect parentRect, float startY, float lineHeight)
{
float contentY = startY;
float labelWidth = parentRect.width * 0.45f;
float valueWidth = parentRect.width * 0.50f;
Text.Font = GameFont.Tiny;
var info = new[]
{
("AIImages.Info.Gender".Translate(), appearanceData.Gender.ToString()),
("AIImages.Info.Age".Translate(), appearanceData.Age.ToString()),
("AIImages.Info.BodyType".Translate(), appearanceData.BodyType),
(
"AIImages.Info.SkinTone".Translate(),
ColorDescriptionService.GetSkinToneDescription(pawn)
),
("AIImages.Info.Hair".Translate(), appearanceData.HairStyle),
(
"AIImages.Info.HairColor".Translate(),
ColorDescriptionService.GetHairColorDescription(pawn)
),
};
foreach (var (label, value) in info)
{
// Подсветка строк через одну
var infoArray = info.ToArray();
int index = Array.IndexOf(infoArray, (label, value));
if ((index % 2) == 0)
{
Widgets.DrawBoxSolid(
new Rect(parentRect.x, contentY, parentRect.width, lineHeight),
new Color(0.15f, 0.15f, 0.15f, 0.3f)
);
}
Widgets.Label(
new Rect(parentRect.x + 5f, contentY, labelWidth, lineHeight),
label + ":"
);
Widgets.Label(
new Rect(parentRect.x + labelWidth + 10f, contentY, valueWidth, lineHeight),
value
);
contentY += lineHeight;
}
return contentY;
}
/// <summary>
/// Отрисовывает черты характера персонажа
/// </summary>
private float DrawTraits(Rect parentRect, float startY, float lineHeight)
{
float contentY = startY;
if (pawn.story?.traits?.allTraits == null || !pawn.story.traits.allTraits.Any())
return contentY;
contentY += 15f;
Text.Font = GameFont.Small;
Widgets.Label(
new Rect(parentRect.x + 5f, contentY, parentRect.width - 10f, lineHeight),
"AIImages.Info.Traits".Translate() + ":"
);
contentY += lineHeight + 2f;
Text.Font = GameFont.Tiny;
var traitLabels = pawn.story.traits.allTraits.Select(trait => trait.LabelCap);
foreach (var traitLabel in traitLabels)
{
Widgets.Label(
new Rect(parentRect.x + 15f, contentY, parentRect.width - 20f, lineHeight),
"• " + traitLabel
);
contentY += lineHeight;
}
return contentY;
}
/// <summary>
/// Отрисовывает гены персонажа
/// </summary>
private float DrawGenes(Rect parentRect, float startY, float lineHeight)
{
float contentY = startY;
if (pawn.genes?.GenesListForReading == null || !pawn.genes.GenesListForReading.Any())
return contentY;
contentY += 15f;
Text.Font = GameFont.Small;
Widgets.Label(
new Rect(parentRect.x + 5f, contentY, parentRect.width - 10f, lineHeight),
"AIImages.Info.Genes".Translate() + ":"
);
contentY += lineHeight + 2f;
Text.Font = GameFont.Tiny;
var activeGenes = pawn.genes.GenesListForReading.Where(gene => gene.Active).ToList();
foreach (var gene in activeGenes)
{
string geneInfo = BuildGeneInfo(gene);
float textHeight = Text.CalcHeight(geneInfo, parentRect.width - 25f);
Widgets.Label(
new Rect(parentRect.x + 15f, contentY, parentRect.width - 25f, textHeight),
"• " + geneInfo
);
contentY += textHeight;
}
return contentY;
}
/// <summary>
/// Строит информационную строку для гена
/// </summary>
private string BuildGeneInfo(Gene gene)
{
string geneLabel = gene.Label ?? gene.def.LabelCap;
string geneInfo = geneLabel;
if (!string.IsNullOrEmpty(gene.def.description))
{
string description = gene.def.description;
if (description.Length > 200)
{
description = description.Substring(0, 197) + "...";
}
geneInfo += " - " + description;
}
return geneInfo;
}
/// <summary>
/// Отрисовывает хедифы (состояния здоровья) персонажа
/// </summary>
private float DrawHediffs(Rect parentRect, float startY, float lineHeight)
{
float contentY = startY;
if (pawn.health?.hediffSet?.hediffs == null || !pawn.health.hediffSet.hediffs.Any())
return contentY;
contentY += 15f;
Text.Font = GameFont.Small;
Widgets.Label(
new Rect(parentRect.x + 5f, contentY, parentRect.width - 10f, lineHeight),
"AIImages.Info.Hediffs".Translate() + ":"
);
contentY += lineHeight + 2f;
Text.Font = GameFont.Tiny;
var hediffLabels = pawn
.health.hediffSet.hediffs.Where(hediff => hediff.Visible)
.Select(hediff => hediff.LabelCap);
foreach (var hediffLabel in hediffLabels)
{
Widgets.Label(
new Rect(parentRect.x + 15f, contentY, parentRect.width - 20f, lineHeight),
"• " + hediffLabel
);
contentY += lineHeight;
}
return contentY;
}
/// <summary>
/// Отрисовывает одежду персонажа
/// </summary>
private float DrawApparel(Rect parentRect, float startY, float lineHeight)
{
float contentY = startY;
var apparel = pawn.apparel?.WornApparel;
if (apparel == null || !apparel.Any())
return contentY;
contentY += 15f;
Text.Font = GameFont.Small;
Widgets.Label(
new Rect(parentRect.x + 5f, contentY, parentRect.width - 10f, lineHeight),
"AIImages.Info.Apparel".Translate() + ":"
);
contentY += lineHeight + 2f;
Text.Font = GameFont.Tiny;
foreach (var item in apparel)
{
var colorDesc = ColorDescriptionService.GetApparelColorDescription(item.DrawColor);
string apparelLabel = $"• {colorDesc} {item.def.label}";
float apparelHeight = Text.CalcHeight(apparelLabel, parentRect.width - 25f);
Widgets.Label(
new Rect(parentRect.x + 15f, contentY, parentRect.width - 25f, apparelHeight),
apparelLabel
);
contentY += apparelHeight;
}
return contentY;
}
/// <summary>
/// Отрисовывает список записей лога пешки
/// </summary>
private float DrawLogEntries(Rect parentRect, float startY, float lineHeight)
{
float contentY = startY;
// Получаем записи лога
var entries = PawnLogPatch.GetAllLogEntries(pawn).ToList();
int entriesCount = entries?.Count ?? 0;
// Отладочная информация (временно)
if (entriesCount == 0 && pawn != null)
{
// Пробуем проверить, есть ли вообще доступ к логам
try
{
var logsProp = HarmonyLib.AccessTools.Property(typeof(Pawn), "logs");
var hasLogs = logsProp != null;
// Выводим в консоль для отладки
DebugLogger.Log(
$"[AI Images] Log entries count: {entriesCount}, Has logs property: {hasLogs}, Pawn: {pawn?.Name}"
);
}
catch (System.Exception ex)
{
DebugLogger.Warning($"[AI Images] Debug error: {ex.Message}");
}
}
contentY += 15f;
// Заголовок секции - всегда показываем, даже если записей нет
Text.Font = GameFont.Small;
Rect headerRect = new Rect(
parentRect.x + 5f,
contentY,
parentRect.width - 10f,
lineHeight + 5f
);
// Кнопка сворачивания/разворачивания
string headerText = "AIImages.Log.Entries".Translate() + $" ({entriesCount})";
if (Widgets.ButtonText(headerRect, headerText))
{
showLogEntries = !showLogEntries;
}
contentY += lineHeight + 10f;
// Разделитель
Widgets.DrawLineHorizontal(parentRect.x, contentY, parentRect.width);
contentY += 10f;
// Список записей (если развёрнуто и есть записи)
if (showLogEntries && entries != null && entries.Any())
{
float entryWidth = parentRect.width - 25f;
float scrollViewHeight = Mathf.Min(entries.Count * 30f + 20f, 300f);
Rect scrollViewRect = new Rect(
parentRect.x + 5f,
contentY,
entryWidth + 15f,
scrollViewHeight
);
Rect viewRect = new Rect(0f, 0f, entryWidth, entries.Count * 30f);
Widgets.BeginScrollView(scrollViewRect, ref logEntriesScrollPosition, viewRect);
Text.Font = GameFont.Tiny;
float entryY = 0f;
const float entryHeight = 28f;
const float buttonSize = 20f;
foreach (var entry in entries.Take(50)) // Ограничиваем для производительности
{
string entryText = entry.ToGameStringFromPOV(pawn);
if (string.IsNullOrEmpty(entryText))
continue;
// Обрезаем длинный текст
string shortText =
entryText.Length > 60 ? entryText.Substring(0, 57) + "..." : entryText;
Rect entryRect = new Rect(
0f,
entryY,
entryWidth - buttonSize - 5f,
entryHeight
);
Rect buttonRect = new Rect(
entryWidth - buttonSize,
entryY + (entryHeight - buttonSize) / 2f,
buttonSize,
buttonSize
);
// Фон записи
Widgets.DrawBoxSolid(entryRect, new Color(0.15f, 0.15f, 0.15f, 0.3f));
// Текст записи
Widgets.Label(entryRect.ContractedBy(3f), shortText);
// Кнопка генерации изображения
Texture2D iconTexture = ContentFinder<Texture2D>.Get(
"UI/Commands/AIImage",
true
);
if (iconTexture != null)
{
if (Widgets.ButtonImage(buttonRect, iconTexture))
{
GenerateImageForEvent(entryText);
}
TooltipHandler.TipRegion(
buttonRect,
"AIImages.Log.GenerateImage".Translate()
);
}
entryY += entryHeight;
}
Widgets.EndScrollView();
contentY += scrollViewHeight + 10f;
}
return contentY;
}
private void DrawRightColumn(Rect rect)
{
float curY = rect.y;
curY = DrawImageTypeSelector(rect, curY);
curY = DrawImagePreview(rect, curY);
curY = DrawGenerationStatus(rect, curY);
curY = DrawProgressBar(rect, curY);
curY = DrawGenerationButton(rect, curY);
// Сворачиваемая секция с позитивным промптом
curY = DrawPromptSection(
rect,
curY,
"AIImages.Prompt.PositiveTitle".Translate(),
ref showPositivePrompt,
() =>
promptGeneratorService.GeneratePositivePrompt(
appearanceData,
generationSettings
),
new Color(0.1f, 0.3f, 0.1f, 0.5f),
ref promptScrollPosition
);
curY += 5f;
// Сворачиваемая секция с негативным промптом
curY = DrawPromptSection(
rect,
curY,
"AIImages.Prompt.NegativeTitle".Translate(),
ref showNegativePrompt,
() => promptGeneratorService.GenerateNegativePrompt(generationSettings),
new Color(0.3f, 0.1f, 0.1f, 0.5f),
ref negativePromptScrollPosition
);
curY += 10f;
// Кнопка обновления данных
if (
Widgets.ButtonText(
new Rect(rect.x, curY, rect.width, 30f),
"AIImages.Window.Refresh".Translate()
)
)
{
RefreshPawnData();
}
// Сообщение о копировании
if (copiedMessageTime > 0f)
{
curY += 35f;
GUI.color = new Color(0f, 1f, 0f, copiedMessageTime / 2f);
Widgets.Label(
new Rect(rect.x, curY, rect.width, 25f),
"AIImages.Prompt.Copied".Translate()
);
GUI.color = Color.white;
}
}
private float CalculateContentHeight()
{
float height = CalculatePortraitHeight();
height += CalculateBasicInfoHeight();
height += CalculateTraitsHeight();
height += CalculateGenesHeight();
height += CalculateHediffsHeight();
height += CalculateApparelHeight();
height += 50f; // Дополнительный отступ
return height;
}
/// <summary>
/// Рассчитывает высоту портрета персонажа
/// </summary>
private float CalculatePortraitHeight()
{
if (generatedImage != null)
{
float portraitSize = 250f; // Максимальный размер портрета
return portraitSize + 15f;
}
return 0f;
}
/// <summary>
/// Рассчитывает высоту базовой информации
/// </summary>
private float CalculateBasicInfoHeight()
{
float height = 35f; // Заголовок "Информация о персонаже"
height += 10f; // Разделитель
height += 6 * 22f; // Базовая информация (6 строк по 22px)
return height;
}
/// <summary>
/// Рассчитывает высоту секции черт характера
/// </summary>
private float CalculateTraitsHeight()
{
if (pawn.story?.traits?.allTraits == null || !pawn.story.traits.allTraits.Any())
return 0f;
float height = 15f; // Отступ
height += 22f; // Заголовок "Черты характера"
height += 2f; // Отступ
height += pawn.story.traits.allTraits.Count * 22f; // Каждая черта
return height;
}
/// <summary>
/// Рассчитывает высоту секции генов
/// </summary>
private float CalculateGenesHeight()
{
if (pawn.genes?.GenesListForReading == null || !pawn.genes.GenesListForReading.Any())
return 0f;
float height = 15f; // Отступ
height += 22f; // Заголовок "Гены"
height += 2f; // Отступ
// Рассчитываем высоту для каждого гена с учетом описания
Text.Font = GameFont.Tiny;
var activeGenes = pawn.genes.GenesListForReading.Where(gene => gene.Active).ToList();
float approximateColumnWidth = 900f * 0.35f; // Примерная ширина левой колонки
float labelWidth = approximateColumnWidth - 25f;
foreach (var gene in activeGenes)
{
string geneInfo = BuildGeneInfo(gene);
height += Text.CalcHeight("• " + geneInfo, labelWidth);
}
return height;
}
/// <summary>
/// Рассчитывает высоту секции хедифов
/// </summary>
private float CalculateHediffsHeight()
{
if (pawn.health?.hediffSet?.hediffs == null || !pawn.health.hediffSet.hediffs.Any())
return 0f;
float height = 15f; // Отступ
height += 22f; // Заголовок "Хедифы"
height += 2f; // Отступ
height += pawn.health.hediffSet.hediffs.Count(hediff => hediff.Visible) * 22f; // Каждый видимый хедиф
return height;
}
/// <summary>
/// Рассчитывает высоту секции одежды
/// </summary>
private float CalculateApparelHeight()
{
var apparel = pawn.apparel?.WornApparel;
if (apparel == null || !apparel.Any())
return 0f;
float height = 15f; // Отступ
height += 22f; // Заголовок "Одежда"
height += 2f; // Отступ
height += apparel.Count * 26f; // Примерно по 22-30px на предмет одежды
return height;
}
private float CalculateRightColumnHeight()
{
float height = 0f;
// Превью изображения
if (generatedImage != null)
{
height += 410f; // 400f изображение + 10f отступ
}
else if (!isGenerating)
{
height += 310f; // 300f placeholder + 10f отступ
}
// Статус генерации
if (!string.IsNullOrEmpty(generationStatus))
{
height += 30f;
}
// Прогресс бар
if (isGenerating && generationProgress > 0.0)
{
height += 30f;
}
// Кнопка генерации
height += 40f;
// Позитивный промпт
height += 36f; // Заголовок (32f + 4f отступ)
if (showPositivePrompt)
{
height += 140f; // Бокс развернут (120f + 10f отступ + запас)
}
height += 5f; // Отступ между промптами
// Негативный промпт
height += 36f; // Заголовок (32f + 4f отступ)
if (showNegativePrompt)
{
height += 140f; // Бокс развернут (120f + 10f отступ + запас)
}
height += 10f; // Отступ после промптов
// Кнопка обновления
height += 40f; // Увеличено с 35f до 40f
// Сообщение о копировании
if (copiedMessageTime > 0f)
{
height += 40f;
}
return height + 100f; // Увеличен дополнительный отступ снизу
}
private float CalculateTotalContentHeight()
{
float height = 0f;
// Заголовок и имя пешки
height += 45f + 40f;
// Разделитель
height += 10f;
// Высота колонок (берем большую)
float columnHeight = Mathf.Max(CalculateContentHeight(), CalculateRightColumnHeight());
height += columnHeight;
// Дополнительный отступ снизу
height += 30f;
return height;
}
/// <summary>
/// /// Отрисовывает секцию с промптом
/// </summary>
private float DrawPromptSection(
Rect parentRect,
float startY,
string title,
ref bool expanded,
System.Func<string> getPromptFunc,
Color backgroundColor,
ref Vector2 scrollPosition
)
{
float curY = startY;
float headerHeight = 32f;
// Получаем промпт
string prompt = getPromptFunc();
// Рисуем заголовок с фоном
Rect headerRect = new Rect(parentRect.x, curY, parentRect.width, headerHeight);
Widgets.DrawBoxSolid(headerRect, new Color(0.25f, 0.25f, 0.25f, 0.8f));
Widgets.DrawBox(headerRect);
// Иконка раскрытия
string icon = expanded ? "▼" : "►";
Text.Font = GameFont.Small;
Widgets.Label(new Rect(parentRect.x + 8f, curY + 6f, 20f, headerHeight), icon);
// Заголовок
Widgets.Label(
new Rect(parentRect.x + 30f, curY + 6f, parentRect.width - 110f, headerHeight),
title
);
// Кнопка копирования в заголовке (увеличена ширина)
Rect copyButtonRect = new Rect(
parentRect.x + parentRect.width - 100f,
curY + 4f,
95f,
24f
);
if (Widgets.ButtonText(copyButtonRect, "📋 " + "AIImages.Copy".Translate()))
{
GUIUtility.systemCopyBuffer = prompt;
copiedMessageTime = 2f;
}
// Клик на остальной области для раскрытия/сворачивания
Rect clickableHeaderRect = new Rect(
parentRect.x,
curY,
parentRect.width - 105f,
headerHeight
);
if (Widgets.ButtonInvisible(clickableHeaderRect))
{
expanded = !expanded;
}
curY += headerHeight + 4f;
// Рисуем содержимое если развернуто
if (expanded)
{
float promptBoxHeight = 120f;
Text.Font = GameFont.Tiny;
float actualPromptHeight = Text.CalcHeight(prompt, parentRect.width - 20f);
Rect promptOuterRect = new Rect(
parentRect.x,
curY,
parentRect.width,
promptBoxHeight
);
Rect promptViewRect = new Rect(0f, 0f, parentRect.width - 20f, actualPromptHeight);
// Рисуем фон
Widgets.DrawBoxSolid(promptOuterRect, backgroundColor);
Widgets.DrawBox(promptOuterRect);
// Рисуем промпт с прокруткой
Widgets.BeginScrollView(
promptOuterRect.ContractedBy(5f),
ref scrollPosition,
promptViewRect
);
var prevAnchor = Text.Anchor;
Text.Anchor = TextAnchor.UpperLeft;
Text.WordWrap = true;
Widgets.Label(
new Rect(0f, 0f, promptViewRect.width, promptViewRect.height),
prompt
);
Text.Anchor = prevAnchor;
Text.WordWrap = true;
Widgets.EndScrollView();
curY += promptBoxHeight + 10f;
}
return curY;
}
/// <summary>
/// Отрисовывает селектор типа изображения (портрет/полное тело)
/// </summary>
private float DrawImageTypeSelector(Rect rect, float curY)
{
Text.Font = GameFont.Small;
float selectorHeight = 30f;
// Заголовок
Widgets.Label(
new Rect(rect.x, curY, rect.width * 0.4f, selectorHeight),
"AIImages.ImageType.Label".Translate() + ":"
);
// Радио-кнопки для выбора типа
float radioX = rect.x + rect.width * 0.45f;
float radioWidth = rect.width * 0.25f;
float radioSpacing = 5f;
// Портрет
Rect portraitRect = new Rect(radioX, curY + 2f, radioWidth, selectorHeight - 4f);
bool isPortrait = generationSettings.ImageType == Models.ImageType.Portrait;
if (
Widgets.RadioButtonLabeled(
portraitRect,
"AIImages.ImageType.Portrait".Translate(),
isPortrait
) && !isPortrait
)
{
generationSettings.ImageType = Models.ImageType.Portrait;
// Обновляем настройки в глобальных настройках
AIImagesMod.Settings.imageType = Models.ImageType.Portrait;
// При смене на портрет, можно изменить размеры на более квадратные
if (generationSettings.Width > generationSettings.Height)
{
generationSettings.Height = generationSettings.Width;
}
}
// Полное тело
Rect fullBodyRect = new Rect(
radioX + radioWidth + radioSpacing,
curY + 2f,
radioWidth,
selectorHeight - 4f
);
bool isFullBody = generationSettings.ImageType == Models.ImageType.FullBody;
if (
Widgets.RadioButtonLabeled(
fullBodyRect,
"AIImages.ImageType.FullBody".Translate(),
isFullBody
) && !isFullBody
)
{
generationSettings.ImageType = Models.ImageType.FullBody;
// Обновляем настройки в глобальных настройках
AIImagesMod.Settings.imageType = Models.ImageType.FullBody;
// При смене на полное тело, можно увеличить высоту
if (generationSettings.Width >= generationSettings.Height)
{
generationSettings.Height = (int)(generationSettings.Width * 1.5f);
}
}
return curY + selectorHeight + 10f;
}
private float DrawImagePreview(Rect rect, float curY)
{
if (generatedImage != null)
{
float imageSize = Mathf.Min(rect.width, 400f);
Rect imageRect = new Rect(
rect.x + (rect.width - imageSize) / 2f,
curY,
imageSize,
imageSize
);
GUI.DrawTexture(imageRect, generatedImage);
return curY + imageSize + 10f;
}
float placeholderSize = Mathf.Min(rect.width, 300f);
Rect placeholderRect = new Rect(
rect.x + (rect.width - placeholderSize) / 2f,
curY,
placeholderSize,
placeholderSize
);
Widgets.DrawBoxSolid(placeholderRect, new Color(0.2f, 0.2f, 0.2f));
Text.Anchor = TextAnchor.MiddleCenter;
Widgets.Label(placeholderRect, "AIImages.Generation.NoImage".Translate());
Text.Anchor = TextAnchor.UpperLeft;
return curY + placeholderSize + 10f;
}
private float DrawGenerationStatus(Rect rect, float curY)
{
if (string.IsNullOrEmpty(generationStatus))
return curY;
Text.Font = GameFont.Small;
float statusHeight = Text.CalcHeight(generationStatus, rect.width);
Widgets.Label(new Rect(rect.x, curY, rect.width, statusHeight), generationStatus);
return curY + statusHeight + 10f;
}
private float DrawProgressBar(Rect rect, float curY)
{
if (!isGenerating || generationProgress <= 0.0)
return curY;
Rect progressBarRect = new Rect(rect.x, curY, rect.width, 24f);
string progressText;
if (totalSteps > 0)
{
progressText =
$"{(generationProgress * 100):F1}% - Step {currentStep}/{totalSteps}";
if (etaSeconds > 0)
{
progressText += $" - ETA: {etaSeconds:F0}s";
}
}
else
{
progressText = $"{(generationProgress * 100):F1}%";
}
Widgets.FillableBar(progressBarRect, (float)generationProgress);
Text.Font = GameFont.Tiny;
Text.Anchor = TextAnchor.MiddleCenter;
Widgets.Label(progressBarRect, progressText);
Text.Anchor = TextAnchor.UpperLeft;
return curY + 30f;
}
private float DrawGenerationButton(Rect rect, float curY)
{
Text.Font = GameFont.Small;
string buttonLabel = isGenerating
? "AIImages.Generation.Cancel".Translate()
: "AIImages.Generation.Generate".Translate();
// Основная кнопка генерации (занимает 40% ширины)
float buttonWidth = rect.width * 0.4f;
if (Widgets.ButtonText(new Rect(rect.x, curY, buttonWidth, 35f), buttonLabel))
{
if (isGenerating)
CancelGeneration();
else
StartGeneration();
}
// Кнопка галереи
float galleryButtonWidth = rect.width * 0.35f;
float galleryButtonX = rect.x + buttonWidth + 10f;
int imageCount = PawnPortraitHelper.GetPortraitCount(pawn);
string galleryLabel = "AIImages.Gallery.OpenGallery".Translate();
if (imageCount > 0)
{
galleryLabel += " " + "AIImages.Gallery.ImagesCount".Translate(imageCount);
}
if (
Widgets.ButtonText(
new Rect(galleryButtonX, curY, galleryButtonWidth, 35f),
galleryLabel
)
)
{
var galleryWindow = new Window_AIGallery(pawn);
Find.WindowStack.Add(galleryWindow);
}
// Отладочная кнопка (занимает 20% ширины)
float debugButtonWidth = rect.width * 0.2f;
float debugButtonX = galleryButtonX + galleryButtonWidth + 10f;
if (Widgets.ButtonText(new Rect(debugButtonX, curY, debugButtonWidth, 35f), "Debug"))
{
DebugAllPawns();
}
return curY + 40f;
}
}
}