using System;
using System.Linq;
using System.Threading;
using AIImages.Helpers;
using AIImages.Models;
using AIImages.Services;
using RimWorld;
using UnityEngine;
using Verse;
#pragma warning disable IDE1006 // Naming Styles
namespace AIImages
{
///
/// Окно для просмотра персонажа и генерации AI изображений
///
[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 scrollPosition = Vector2.zero;
private Vector2 rightColumnScrollPosition = Vector2.zero;
private Vector2 promptScrollPosition = Vector2.zero;
private Vector2 negativePromptScrollPosition = Vector2.zero;
private float copiedMessageTime = 0f;
// Состояние сворачиваемых секций
private bool showPositivePrompt = false;
private bool showNegativePrompt = false;
private bool showCharacterInfo = true;
///
/// Обновляет данные персонажа
///
private void RefreshPawnData()
{
appearanceData = pawnDescriptionService.ExtractAppearanceData(pawn);
generationSettings = AIImagesMod.Settings.ToStableDiffusionSettings();
// Загружаем сохраненный портрет, если есть
LoadSavedPortrait();
}
///
/// Загружает сохраненный портрет персонажа
///
private void LoadSavedPortrait()
{
if (PawnPortraitHelper.HasPortrait(pawn))
{
generatedImage = PawnPortraitHelper.LoadPortrait(pawn);
if (generatedImage != null)
{
generationStatus = "AIImages.Generation.LoadedFromSave".Translate();
}
}
}
///
/// Освобождает ресурсы при закрытии окна
///
public override void PreClose()
{
base.PreClose();
// Отменяем генерацию, если она идет
if (isGenerating && cancellationTokenSource != null)
{
cancellationTokenSource.Cancel();
}
// Освобождаем CancellationTokenSource
cancellationTokenSource?.Dispose();
}
///
/// Обновляет текущую пешку в окне
///
public void UpdatePawn(Pawn newPawn)
{
this.pawn = newPawn;
RefreshPawnData();
// Очищаем старое изображение при смене персонажа
generatedImage = null;
generationStatus = "";
generationProgress = 0.0;
}
///
/// Получить текущую пешку
///
public Pawn CurrentPawn => pawn;
///
/// Вызывается каждый кадр для обновления окна
///
public override void WindowUpdate()
{
base.WindowUpdate();
// Проверяем, изменилась ли выбранная пешка
Pawn selectedPawn = Find.Selector.SelectedPawns.FirstOrDefault(p =>
p.IsColonist && p.Spawned && p.Faction == Faction.OfPlayer
);
// Если выбрана новая колонистская пешка, обновляем окно
if (selectedPawn != null && selectedPawn != pawn)
{
UpdatePawn(selectedPawn);
}
// Уменьшаем таймер сообщения о копировании
if (copiedMessageTime > 0f)
{
copiedMessageTime -= Time.deltaTime;
}
}
///
/// Асинхронная генерация изображения с поддержкой отмены
///
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 = 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)
{
// Загружаем текстуру
generatedImage = new Texture2D(2, 2);
generatedImage.LoadImage(result.ImageData);
generationStatus = "AIImages.Generation.Success".Translate();
generationProgress = 1.0;
// Сохраняем путь к портрету на персонаже
PawnPortraitHelper.SavePortraitPath(pawn, result.SavedPath);
Messages.Message(
"AIImages.Generation.SavedTo".Translate(result.SavedPath),
MessageTypeDefOf.PositiveEvent
);
}
else
{
generationStatus =
$"AIImages.Generation.Failed".Translate() + ": {result.ErrorMessage}";
Messages.Message(generationStatus, MessageTypeDefOf.RejectInput);
}
}
catch (OperationCanceledException)
{
generationStatus = "AIImages.Generation.Cancelled".Translate();
Log.Message("[AI Images] Generation cancelled by user");
}
catch (Exception ex)
{
generationStatus = $"Error: {ex.Message}";
Log.Error($"[AI Images] Generation error: {ex}");
Messages.Message(
$"AIImages.Generation.Error".Translate() + ": {ex.Message}",
MessageTypeDefOf.RejectInput
);
}
finally
{
isGenerating = false;
}
}
///
/// Мониторит прогресс генерации и обновляет UI
///
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;
Log.Message(
$"[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)
{
Log.Warning($"[AI Images] Progress monitoring error: {ex.Message}");
}
}
///
/// Запускает генерацию изображения (обертка для безопасного fire-and-forget)
///
private void StartGeneration()
{
AsyncHelper.FireAndForget(GenerateImageAsync(), "Image Generation");
}
///
/// Отменяет генерацию изображения
///
private void CancelGeneration()
{
if (isGenerating && cancellationTokenSource != null)
{
cancellationTokenSource.Cancel();
generationStatus = "AIImages.Generation.Cancelling".Translate();
}
}
public override void DoWindowContents(Rect inRect)
{
float curY = 0f;
// Заголовок
Text.Font = GameFont.Medium;
Widgets.Label(
new Rect(0f, curY, inRect.width, 40f),
"AIImages.Window.Title".Translate()
);
curY += 45f;
// Имя пешки
Text.Font = GameFont.Small;
Widgets.Label(
new Rect(0f, curY, inRect.width, 30f),
"AIImages.Window.PawnLabel".Translate(pawn.NameShortColored.Resolve())
);
curY += 40f;
// Разделитель
Widgets.DrawLineHorizontal(0f, curY, inRect.width);
curY += 10f;
// Разделяем на две колонки: левая - информация, правая - изображение
float leftColumnWidth = inRect.width * 0.55f;
float rightColumnWidth = inRect.width * 0.42f;
float columnGap = inRect.width * 0.03f;
// Левая колонка - прокручиваемая информация
Rect leftColumnRect = new Rect(0f, curY, leftColumnWidth, inRect.height - curY);
DrawLeftColumn(leftColumnRect);
// Правая колонка - превью и управление
Rect rightColumnRect = new Rect(
leftColumnWidth + columnGap,
curY,
rightColumnWidth,
inRect.height - curY
);
DrawRightColumn(rightColumnRect);
}
private void DrawLeftColumn(Rect rect)
{
Rect scrollViewRect = new Rect(0f, 0f, rect.width - 20f, CalculateContentHeight());
Widgets.BeginScrollView(rect, ref scrollPosition, scrollViewRect);
float contentY = 0f;
// Портрет персонажа (если есть)
if (generatedImage != null)
{
float portraitSize = Mathf.Min(scrollViewRect.width - 20f, 200f);
Rect portraitRect = new Rect(
(scrollViewRect.width - portraitSize) / 2f,
contentY,
portraitSize,
portraitSize
);
// Рамка вокруг портрета
Widgets.DrawBox(portraitRect);
GUI.DrawTexture(portraitRect.ContractedBy(2f), generatedImage);
contentY += portraitSize + 20f;
}
// Сворачиваемая секция "Информация о персонаже"
DrawCollapsibleSection(
scrollViewRect,
contentY,
"AIImages.CharacterInfo.Title".Translate(),
ref showCharacterInfo,
() => DrawCharacterInfoContent(scrollViewRect)
);
Widgets.EndScrollView();
}
///
/// Отрисовывает информацию о персонаже в компактном виде
///
private float DrawCharacterInfoContent(Rect parentRect)
{
float contentHeight = 0f;
float lineHeight = 24f;
float indent = 10f;
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(appearanceData.SkinColor)
),
("AIImages.Info.Hair".Translate(), appearanceData.HairStyle),
(
"AIImages.Info.HairColor".Translate(),
ColorDescriptionService.GetHairColorDescription(appearanceData.HairColor)
),
};
foreach (var (label, value) in info)
{
Widgets.Label(
new Rect(indent, contentHeight, parentRect.width * 0.4f, lineHeight),
label + ":"
);
Widgets.Label(
new Rect(
parentRect.width * 0.42f,
contentHeight,
parentRect.width * 0.55f,
lineHeight
),
value
);
contentHeight += lineHeight;
}
// Черты характера
if (pawn.story?.traits?.allTraits != null && pawn.story.traits.allTraits.Any())
{
contentHeight += 10f;
Text.Font = GameFont.Small;
Widgets.Label(
new Rect(indent, contentHeight, parentRect.width - indent * 2, lineHeight),
"AIImages.Info.Traits".Translate() + ":"
);
contentHeight += lineHeight;
Text.Font = GameFont.Tiny;
var traitLabels = pawn.story.traits.allTraits.Select(trait => trait.LabelCap);
foreach (var traitLabel in traitLabels)
{
Widgets.Label(
new Rect(
indent * 2,
contentHeight,
parentRect.width - indent * 3,
lineHeight
),
"• " + traitLabel
);
contentHeight += lineHeight;
}
}
// Одежда
var apparel = pawn.apparel?.WornApparel;
if (apparel != null && apparel.Any())
{
contentHeight += 10f;
Text.Font = GameFont.Small;
Widgets.Label(
new Rect(indent, contentHeight, parentRect.width - indent * 2, lineHeight),
"AIImages.Info.Apparel".Translate() + ":"
);
contentHeight += lineHeight;
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 - indent * 3
);
Widgets.Label(
new Rect(
indent * 2,
contentHeight,
parentRect.width - indent * 3,
apparelHeight
),
apparelLabel
);
contentHeight += apparelHeight;
}
}
return contentHeight + 10f;
}
private void DrawRightColumn(Rect rect)
{
// Рассчитываем высоту контента для скролла
float contentHeight = CalculateRightColumnHeight();
Rect scrollViewRect = new Rect(0f, 0f, rect.width - 20f, contentHeight);
Widgets.BeginScrollView(rect, ref rightColumnScrollPosition, scrollViewRect);
float curY = 0f;
curY = DrawImagePreview(scrollViewRect, curY);
curY = DrawGenerationStatus(scrollViewRect, curY);
curY = DrawProgressBar(scrollViewRect, curY);
curY = DrawGenerationButton(scrollViewRect, curY);
// Сворачиваемая секция с позитивным промптом
curY = DrawPromptSection(
scrollViewRect,
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(
scrollViewRect,
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(0f, curY, scrollViewRect.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(0f, curY, scrollViewRect.width, 25f),
"AIImages.Prompt.Copied".Translate()
);
GUI.color = Color.white;
}
Widgets.EndScrollView();
}
private float CalculateContentHeight()
{
float height = 0f;
// Портрет персонажа (если есть)
if (generatedImage != null)
{
float portraitSize = Mathf.Min(400f, 200f);
height += portraitSize + 15f;
}
// Заголовок "Внешность"
height += 35f;
// Текст внешности
string appearanceText = pawnDescriptionService.GetAppearanceDescription(pawn);
height += Text.CalcHeight(appearanceText, 400f) + 20f;
// Разделитель
height += 15f;
// Заголовок "Одежда"
height += 35f;
// Текст одежды
string apparelText = pawnDescriptionService.GetApparelDescription(pawn);
height += Text.CalcHeight(apparelText, 400f) + 20f;
// Дополнительный отступ
height += 50f;
return height;
}
private float CalculateRightColumnHeight()
{
float height = 0f;
// Превью изображения
if (generatedImage != null)
{
height += 200f + 10f;
}
else if (!isGenerating)
{
height += 100f + 10f;
}
// Статус генерации
if (!string.IsNullOrEmpty(generationStatus))
{
height += 30f;
}
// Прогресс бар
if (isGenerating && generationProgress > 0.0)
{
height += 30f;
}
// Кнопка генерации
height += 40f;
// Позитивный промпт
height += 35f; // Заголовок
height += 100f + 10f; // Бокс
// Негативный промпт
height += 35f; // Заголовок
height += 100f + 10f; // Бокс
// Кнопки копирования
height += 35f;
// Кнопка обновления
height += 35f;
// Сообщение о копировании
if (copiedMessageTime > 0f)
{
height += 30f;
}
return height + 50f; // Дополнительный отступ
}
///
/// Отрисовывает сворачиваемую секцию с заголовком и содержимым
///
private void DrawCollapsibleSection(
Rect parentRect,
float startY,
string title,
ref bool expanded,
System.Func drawContentFunc
)
{
float curY = startY;
float headerHeight = 32f;
// Рисуем заголовок с фоном
Rect headerRect = new Rect(0f, 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(8f, curY + 6f, 20f, headerHeight), icon);
// Заголовок
Text.Font = GameFont.Small;
Widgets.Label(new Rect(30f, curY + 6f, parentRect.width - 40f, headerHeight), title);
// Клик для раскрытия/сворачивания
if (Widgets.ButtonInvisible(headerRect))
{
expanded = !expanded;
}
// Рисуем содержимое если развернуто
if (expanded)
{
drawContentFunc();
}
}
///
/// Отрисовывает секцию с промптом
///
private float DrawPromptSection(
Rect parentRect,
float startY,
string title,
ref bool expanded,
System.Func getPromptFunc,
Color backgroundColor,
ref Vector2 scrollPosition
)
{
float curY = startY;
float headerHeight = 32f;
// Получаем промпт
string prompt = getPromptFunc();
// Рисуем заголовок с фоном
Rect headerRect = new Rect(0f, 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(8f, curY + 6f, 20f, headerHeight), icon);
// Заголовок
Widgets.Label(new Rect(30f, curY + 6f, parentRect.width - 80f, headerHeight), title);
// Кнопка копирования в заголовке
Rect copyButtonRect = new Rect(parentRect.width - 70f, curY + 4f, 65f, 24f);
if (Widgets.ButtonText(copyButtonRect, "📋 " + "AIImages.Copy".Translate()))
{
GUIUtility.systemCopyBuffer = prompt;
copiedMessageTime = 2f;
}
// Клик на остальной области для раскрытия/сворачивания
Rect clickableHeaderRect = new Rect(0f, curY, parentRect.width - 75f, 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(0f, 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;
}
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,
rect.y + 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,
rect.y + 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, rect.y + 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, rect.y + 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();
if (Widgets.ButtonText(new Rect(rect.x, rect.y + curY, rect.width, 35f), buttonLabel))
{
if (isGenerating)
CancelGeneration();
else
StartGeneration();
}
return curY + 40f;
}
}
}