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; } } }