using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Windows; using System.Windows.Controls; using System.Windows.Media; using System.Windows.Media.Imaging; using System.Windows.Shapes; using System.Windows.Threading; namespace Character_Builder; public partial class MainWindow : Window { private const double StageH = 680; private const double StageW = 540; // contain-fit: baked into each layer's transform so a wide pose isn't clipped by the stage bounds private double _fit = 1, _fitCX, _fitCY; private string? _root; private List _characters = new(); private CharacterInfo? _char; private BuildDefinition _build = new(); private List _bodies = new(); private List _headFrames = new(); // base + expressions private List _accessories = new(); private List _shapes = new(); private Dictionary _hairmaskByShape = new(); private Dictionary _pathByName = new(StringComparer.OrdinalIgnoreCase); private bool _suppress; // preview / animation private readonly TranslateTransform _floatT = new(); private readonly DispatcherTimer _timer = new() { Interval = TimeSpan.FromMilliseconds(60) }; private Image? _headImage; private ImageSource? _headNormal, _headBlink; private int _phase, _blinkRestoreAt = -1; private bool _playing; private static readonly (string label, string? hex)[] Swatches = { ("✕", null), // mint / teal / cyan ("", "#B7F5E3"), ("", "#38E0C4"), ("", "#1F9E8C"), ("", "#4CC2FF"), ("", "#2C7BE5"), // purple / pink / red ("", "#7C5CFF"), ("", "#B36BFF"), ("", "#FF6FD8"), ("", "#FF7B9C"), ("", "#FF5C5C"), // warm ("", "#FF9F45"), ("", "#F5D06B"), ("", "#C9A227"), // green / natural / neutral ("", "#6BD66B"), ("", "#3C6E47"), ("", "#8A5A2B"), ("", "#4A3B33"), ("", "#1A1A1E"), ("", "#D8DCE3"), ("", "#F5F0FF"), }; public MainWindow() { InitializeComponent(); StageHost.RenderTransform = _floatT; _timer.Tick += Timer_Tick; BuildSwatches(); Loaded += (_, __) => { ApplyMaximizedInset(); InitRoot(); }; StateChanged += (_, __) => ApplyMaximizedInset(); } // WindowStyle=None + maximize overflows the screen; inset so nothing is clipped. private void ApplyMaximizedInset() => RootBorder.Padding = WindowState == WindowState.Maximized ? new Thickness(7) : new Thickness(0); // ---------- window caption ---------- private void Min_Click(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized; private void Max_Click(object sender, RoutedEventArgs e) => WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; private void Close_Click(object sender, RoutedEventArgs e) => Close(); // ---------- root / characters ---------- private void InitRoot() { _root = AssetScanner.FindRoot(); if (_root == null) { TxtStatus.Text = "Characters_Build_Docs 루트를 찾지 못했습니다. [📁 루트] 로 폴더를 지정하세요."; return; } LoadCharacters(); } private void LoadCharacters() { if (_root == null) return; _characters = AssetScanner.GetCharacters(_root); CharList.ItemsSource = _characters; TxtStatus.Text = $"루트: {_root} · 캐릭터 {_characters.Count}개"; } private void Root_Click(object sender, RoutedEventArgs e) { var dlg = new Microsoft.Win32.OpenFolderDialog { Title = "Characters_Build_Docs 폴더 선택" }; if (dlg.ShowDialog() == true) { _root = dlg.FolderName; LoadCharacters(); } } private void CharList_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (_suppress) return; if (CharList.SelectedItem is CharacterInfo ci) SelectCharacter(ci, null); } // ---------- select + populate ---------- private void SelectCharacter(CharacterInfo ci, BuildDefinition? preset) { _char = ci; StopPlay(); var parts = AssetScanner.GetParts(ci.FolderPath); _pathByName = parts.GroupBy(p => p.FileName) .ToDictionary(g => g.Key, g => g.First().FilePath, StringComparer.OrdinalIgnoreCase); _bodies = parts.Where(p => p.FileName.ToLowerInvariant().Contains("_body_")) .Where(p => !IsPartialBody(p.FileName)).ToList(); _headFrames = parts.Where(p => p.FileName.ToLowerInvariant().Contains("_head_")).ToList(); _accessories = parts.Where(p => p.FileName.ToLowerInvariant().StartsWith("acc")).ToList(); _shapes = _headFrames.Select(h => h.Shape).Where(s => !string.IsNullOrEmpty(s)).Distinct().OrderBy(s => s).ToList(); _hairmaskByShape = parts.Where(p => p.FileName.ToLowerInvariant().Contains("_hairmask_")) .GroupBy(p => p.Shape).ToDictionary(g => g.Key, g => g.First()); _suppress = true; CmbBody.ItemsSource = _bodies; CmbShape.ItemsSource = _shapes; BuildAccessoryChecks(); _suppress = false; _build = preset ?? MakeDefaultBuild(ci); TxtCharName.Text = ci.Name + (preset != null ? $" · {preset.Name}" : ""); PreviewHint.Visibility = Visibility.Collapsed; ApplyBuildToUi(); if (preset == null) AutoAlignHead(); else { UpdateFit(); RebuildStage(); } TxtStatus.Text = $"{ci.Name}: 바디 {_bodies.Count} · 머리프레임 {_headFrames.Count} · 악세 {_accessories.Count}"; } /// Component pieces (arms/legs/torso) aren't standalone bodies — hide them from the list. private static bool IsPartialBody(string fileName) { var stem = System.IO.Path.GetFileNameWithoutExtension(fileName).ToLowerInvariant(); return stem.EndsWith("_legs") || stem.EndsWith("_torso") || stem.EndsWith("_arm_l") || stem.EndsWith("_arm_r"); } private BuildDefinition MakeDefaultBuild(CharacterInfo ci) { var b = new BuildDefinition { Character = ci.Name, Name = ci.Name }; var body = _bodies.FirstOrDefault(x => x.FileName.Contains("idle_upper")) ?? _bodies.FirstOrDefault(); b.BodyFile = body?.FileName; var shape = _shapes.FirstOrDefault(); if (shape != null) { b.HeadFile = _headFrames.FirstOrDefault(h => h.Shape == shape && h.Expr == "(base)")?.FileName ?? _headFrames.FirstOrDefault(h => h.Shape == shape)?.FileName; b.ExpressionFile = _headFrames.FirstOrDefault(h => h.Shape == shape && h.Expr == "neutral")?.FileName ?? b.HeadFile; if (_hairmaskByShape.TryGetValue(shape, out var hm)) b.HairmaskFile = hm.FileName; } return b; } private void ApplyBuildToUi() { _suppress = true; CmbBody.SelectedItem = _bodies.FirstOrDefault(x => x.FileName == _build.BodyFile); var shape = ShapeOf(_build.ExpressionFile) ?? ShapeOf(_build.HeadFile) ?? _shapes.FirstOrDefault(); CmbShape.SelectedItem = shape; PopulateExpr(shape); CmbExpr.SelectedItem = _headFrames.FirstOrDefault(h => h.FileName == _build.ExpressionFile); foreach (var cb in AccPanel.Children.OfType()) { var pi = cb.Tag as PartItem; cb.IsChecked = pi != null && _build.Accessories.Any(a => a.FileName == pi.FileName); } HighlightSwatch(_build.HairColor); PopulateLayerCombo(); _suppress = false; } private string? ShapeOf(string? fileName) { if (fileName == null) return null; return _headFrames.FirstOrDefault(h => h.FileName == fileName)?.Shape; } private void PopulateExpr(string? shape) { var frames = _headFrames.Where(h => h.Shape == shape) .OrderBy(h => h.Expr == "(base)" ? 0 : 1).ThenBy(h => h.Expr) .Select(h => new PartItem { FileName = h.FileName, FilePath = h.FilePath, Shape = h.Shape, Expr = h.Expr, Display = h.Expr }) .ToList(); CmbExpr.ItemsSource = frames; } // ---------- accessory checklist ---------- private void BuildAccessoryChecks() { AccPanel.Children.Clear(); if (_accessories.Count == 0) { AccPanel.Children.Add(new TextBlock { Text = "(악세서리 이미지 없음)", Foreground = (Brush)FindResource("Mute"), FontSize = 12 }); return; } foreach (var a in _accessories) { var cb = new CheckBox { Content = a.Display, Tag = a }; cb.Checked += Acc_Toggled; cb.Unchecked += Acc_Toggled; AccPanel.Children.Add(cb); } } private void Acc_Toggled(object sender, RoutedEventArgs e) { if (_suppress) return; if (sender is not CheckBox cb || cb.Tag is not PartItem pi) return; if (cb.IsChecked == true) { if (!_build.Accessories.Any(a => a.FileName == pi.FileName)) { var layer = new AccessoryLayer { FileName = pi.FileName }; var low = pi.FileName.ToLowerInvariant(); // head-worn items start anchored to the head; wrist/foot items keep defaults. // Accessory art is framed standalone (roughly centred in its own canvas), so it must // start well below the head's own scale or it dwarfs the head — the user then fine-tunes. if (low.Contains("headphone") || low.Contains("catear") || low.Contains("clubband") || low.Contains("cap") || low.Contains("glasses") || low.Contains("hat") || low.Contains("ear")) { layer.OffsetX = _build.HeadOffsetX; layer.OffsetY = _build.HeadOffsetY; layer.Scale = _build.HeadScale * 0.62; } else { // props / wrist / foot items: sit near the body centre at a modest size layer.OffsetX = _build.BodyOffsetX; layer.OffsetY = _build.BodyOffsetY; layer.Scale = Math.Max(0.18, _build.HeadScale * 0.5); } _build.Accessories.Add(layer); } } else { _build.Accessories.RemoveAll(a => a.FileName == pi.FileName); } PopulateLayerCombo(); RebuildStage(); } // ---------- swatches ---------- private void BuildSwatches() { SwatchPanel.Children.Clear(); foreach (var (label, hex) in Swatches) { var btn = new Button { Width = 38, Height = 38, Margin = new Thickness(0, 0, 10, 10), Padding = new Thickness(0), Content = label, Tag = hex, Background = hex == null ? (Brush)FindResource("Panel2") : new SolidColorBrush((Color)ColorConverter.ConvertFromString(hex)), Foreground = (Brush)FindResource("Text"), }; btn.Click += Swatch_Click; SwatchPanel.Children.Add(btn); } } private void Swatch_Click(object sender, RoutedEventArgs e) { if (sender is not Button b) return; _build.HairColor = b.Tag as string; HighlightSwatch(_build.HairColor); RebuildStage(); } private void HighlightSwatch(string? hex) { foreach (var b in SwatchPanel.Children.OfType