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 = true; 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 promptScrollPosition = Vector2.zero; private Vector2 negativePromptScrollPosition = Vector2.zero; private float copiedMessageTime = 0f; /// /// Обновляет данные персонажа /// 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, }; // Создаем отдельный 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 ); GUI.DrawTexture(portraitRect, generatedImage); contentY += portraitSize + 15f; } // Секция "Внешность" Text.Font = GameFont.Medium; Widgets.Label( new Rect(10f, contentY, scrollViewRect.width - 20f, 30f), "AIImages.Appearance.SectionTitle".Translate() ); contentY += 35f; Text.Font = GameFont.Small; string appearanceText = pawnDescriptionService.GetAppearanceDescription(pawn); float appearanceHeight = Text.CalcHeight(appearanceText, scrollViewRect.width - 30f); Widgets.Label( new Rect(20f, contentY, scrollViewRect.width - 30f, appearanceHeight), appearanceText ); contentY += appearanceHeight + 20f; // Разделитель Widgets.DrawLineHorizontal(10f, contentY, scrollViewRect.width - 20f); contentY += 15f; // Секция "Одежда" Text.Font = GameFont.Medium; Widgets.Label( new Rect(10f, contentY, scrollViewRect.width - 20f, 30f), "AIImages.Apparel.SectionTitle".Translate() ); contentY += 35f; Text.Font = GameFont.Small; string apparelText = pawnDescriptionService.GetApparelDescription(pawn); float apparelHeight = Text.CalcHeight(apparelText, scrollViewRect.width - 30f); Widgets.Label( new Rect(20f, contentY, scrollViewRect.width - 30f, apparelHeight), apparelText ); Widgets.EndScrollView(); } private void DrawRightColumn(Rect rect) { float curY = 0f; curY = DrawImagePreview(rect, curY); curY = DrawGenerationStatus(rect, curY); curY = DrawProgressBar(rect, curY); curY = DrawGenerationButton(rect, curY); // Позитивный промпт секция Text.Font = GameFont.Medium; Widgets.Label( new Rect(rect.x, rect.y + curY, rect.width, 30f), "AIImages.Prompt.PositiveTitle".Translate() ); curY += 35f; // Получаем позитивный промпт Text.Font = GameFont.Tiny; string positivePrompt = promptGeneratorService.GeneratePositivePrompt( appearanceData, generationSettings ); // Фиксированная высота для области промпта float promptBoxHeight = 100f; float actualPositiveHeight = Text.CalcHeight(positivePrompt, rect.width - 20f); Rect positiveOuterRect = new Rect(rect.x, rect.y + curY, rect.width, promptBoxHeight); Rect positiveViewRect = new Rect(0f, 0f, rect.width - 20f, actualPositiveHeight); // Рисуем фон Widgets.DrawBoxSolid(positiveOuterRect, new Color(0.1f, 0.3f, 0.1f, 0.5f)); // Рисуем промпт с прокруткой Widgets.BeginScrollView( positiveOuterRect.ContractedBy(5f), ref promptScrollPosition, positiveViewRect ); Widgets.Label( new Rect(0f, 0f, positiveViewRect.width, positiveViewRect.height), positivePrompt ); Widgets.EndScrollView(); curY += promptBoxHeight + 10f; // Негативный промпт секция Text.Font = GameFont.Medium; Widgets.Label( new Rect(rect.x, rect.y + curY, rect.width, 30f), "AIImages.Prompt.NegativeTitle".Translate() ); curY += 35f; // Получаем негативный промпт Text.Font = GameFont.Tiny; string negativePrompt = promptGeneratorService.GenerateNegativePrompt( generationSettings ); float actualNegativeHeight = Text.CalcHeight(negativePrompt, rect.width - 20f); Rect negativeOuterRect = new Rect(rect.x, rect.y + curY, rect.width, promptBoxHeight); Rect negativeViewRect = new Rect(0f, 0f, rect.width - 20f, actualNegativeHeight); // Рисуем фон (красноватый для негативного) Widgets.DrawBoxSolid(negativeOuterRect, new Color(0.3f, 0.1f, 0.1f, 0.5f)); // Рисуем промпт с прокруткой Widgets.BeginScrollView( negativeOuterRect.ContractedBy(5f), ref negativePromptScrollPosition, negativeViewRect ); Widgets.Label( new Rect(0f, 0f, negativeViewRect.width, negativeViewRect.height), negativePrompt ); Widgets.EndScrollView(); curY += promptBoxHeight + 10f; // Кнопки копирования промптов if ( Widgets.ButtonText( new Rect(rect.x, rect.y + curY, rect.width / 2f - 5f, 30f), "AIImages.Prompt.CopyPositive".Translate() ) ) { string positiveForCopy = promptGeneratorService.GeneratePositivePrompt( appearanceData, generationSettings ); GUIUtility.systemCopyBuffer = positiveForCopy; copiedMessageTime = 2f; } if ( Widgets.ButtonText( new Rect( rect.x + rect.width / 2f + 5f, rect.y + curY, rect.width / 2f - 5f, 30f ), "AIImages.Prompt.CopyNegative".Translate() ) ) { string negativeForCopy = promptGeneratorService.GenerateNegativePrompt( generationSettings ); GUIUtility.systemCopyBuffer = negativeForCopy; copiedMessageTime = 2f; } curY += 35f; // Кнопка обновления данных if ( Widgets.ButtonText( new Rect(rect.x, rect.y + 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, rect.y + curY, rect.width, 25f), "AIImages.Prompt.Copied".Translate() ); GUI.color = Color.white; } } 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; // Позитивный промпт заголовок height += 35f; // Позитивный промпт контент height += 100f + 10f; // Негативный промпт заголовок height += 35f; // Негативный промпт контент height += 100f + 10f; // Кнопки копирования height += 35f; // Кнопка обновления height += 35f; // Сообщение о копировании if (copiedMessageTime > 0f) { height += 30f; } return height; } 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; } } }