694 lines
28 KiB
C#
694 lines
28 KiB
C#
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<CharacterInfo> _characters = new();
|
|
private CharacterInfo? _char;
|
|
private BuildDefinition _build = new();
|
|
|
|
private List<PartItem> _bodies = new();
|
|
private List<PartItem> _headFrames = new(); // base + expressions
|
|
private List<PartItem> _accessories = new();
|
|
private List<string> _shapes = new();
|
|
private Dictionary<string, PartItem> _hairmaskByShape = new();
|
|
private Dictionary<string, string> _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}";
|
|
}
|
|
|
|
/// <summary>Component pieces (arms/legs/torso) aren't standalone bodies — hide them from the list.</summary>
|
|
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<CheckBox>())
|
|
{
|
|
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<Button>())
|
|
{
|
|
bool sel = string.Equals((b.Tag as string) ?? "", hex ?? "", StringComparison.OrdinalIgnoreCase);
|
|
b.BorderBrush = sel ? (Brush)FindResource("Accent") : (Brush)FindResource("Stroke");
|
|
b.BorderThickness = new Thickness(sel ? 2 : 1);
|
|
}
|
|
}
|
|
|
|
// ---------- compose events ----------
|
|
private void Body_Changed(object sender, SelectionChangedEventArgs e)
|
|
{
|
|
if (_suppress) return;
|
|
if (CmbBody.SelectedItem is PartItem p) { _build.BodyFile = p.FileName; AutoAlignHead(); }
|
|
}
|
|
|
|
private void Shape_Changed(object sender, SelectionChangedEventArgs e)
|
|
{
|
|
if (_suppress) return;
|
|
if (CmbShape.SelectedItem is not string shape) return;
|
|
_build.HeadFile = _headFrames.FirstOrDefault(h => h.Shape == shape && h.Expr == "(base)")?.FileName
|
|
?? _headFrames.FirstOrDefault(h => h.Shape == shape)?.FileName;
|
|
_build.HairmaskFile = _hairmaskByShape.TryGetValue(shape, out var hm) ? hm.FileName : null;
|
|
_suppress = true;
|
|
PopulateExpr(shape);
|
|
var neutral = (CmbExpr.ItemsSource as IEnumerable<PartItem>)?.FirstOrDefault(x => x.Expr == "neutral")
|
|
?? (CmbExpr.ItemsSource as IEnumerable<PartItem>)?.FirstOrDefault();
|
|
CmbExpr.SelectedItem = neutral;
|
|
_build.ExpressionFile = neutral?.FileName ?? _build.HeadFile;
|
|
_suppress = false;
|
|
AutoAlignHead();
|
|
}
|
|
|
|
private void Expr_Changed(object sender, SelectionChangedEventArgs e)
|
|
{
|
|
if (_suppress) return;
|
|
if (CmbExpr.SelectedItem is PartItem p) { _build.ExpressionFile = p.FileName; RebuildStage(); }
|
|
}
|
|
|
|
// ---------- layer transform ----------
|
|
// CmbLayer index: 0 = head, 1 = body, 2.. = accessories. Every layer is adjustable.
|
|
private void PopulateLayerCombo()
|
|
{
|
|
_suppress = true;
|
|
CmbLayer.Items.Clear();
|
|
CmbLayer.Items.Add("머리 (Head)");
|
|
CmbLayer.Items.Add("바디 (Body)");
|
|
foreach (var a in _build.Accessories) CmbLayer.Items.Add(a.FileName);
|
|
CmbLayer.SelectedIndex = 0;
|
|
_suppress = false;
|
|
LoadLayerToSliders();
|
|
}
|
|
|
|
private void Layer_Changed(object sender, SelectionChangedEventArgs e)
|
|
{
|
|
if (_suppress) return;
|
|
LoadLayerToSliders();
|
|
}
|
|
|
|
private (double x, double y, double s) GetLayer(int idx)
|
|
{
|
|
if (idx == 1) return (_build.BodyOffsetX, _build.BodyOffsetY, _build.BodyScale);
|
|
if (idx >= 2 && idx - 2 < _build.Accessories.Count)
|
|
{
|
|
var a = _build.Accessories[idx - 2];
|
|
return (a.OffsetX, a.OffsetY, a.Scale);
|
|
}
|
|
return (_build.HeadOffsetX, _build.HeadOffsetY, _build.HeadScale);
|
|
}
|
|
|
|
private void SetLayer(int idx, double x, double y, double s)
|
|
{
|
|
if (idx == 1) { _build.BodyOffsetX = x; _build.BodyOffsetY = y; _build.BodyScale = s; }
|
|
else if (idx >= 2 && idx - 2 < _build.Accessories.Count)
|
|
{
|
|
var a = _build.Accessories[idx - 2];
|
|
a.OffsetX = x; a.OffsetY = y; a.Scale = s;
|
|
}
|
|
else { _build.HeadOffsetX = x; _build.HeadOffsetY = y; _build.HeadScale = s; }
|
|
}
|
|
|
|
private void LoadLayerToSliders()
|
|
{
|
|
_suppress = true;
|
|
var (x, y, s) = GetLayer(CmbLayer.SelectedIndex);
|
|
SldX.Value = Math.Max(SldX.Minimum, Math.Min(SldX.Maximum, x));
|
|
SldY.Value = Math.Max(SldY.Minimum, Math.Min(SldY.Maximum, y));
|
|
SldScale.Value = Math.Max(SldScale.Minimum, Math.Min(SldScale.Maximum, s));
|
|
_suppress = false;
|
|
}
|
|
|
|
private void LayerXform_Changed(object sender, RoutedPropertyChangedEventArgs<double> e)
|
|
{
|
|
if (_suppress) return;
|
|
SetLayer(CmbLayer.SelectedIndex, SldX.Value, SldY.Value, SldScale.Value);
|
|
RebuildStage();
|
|
}
|
|
|
|
// ---------- stage compositing ----------
|
|
private string? ResolvePath(string? fn)
|
|
=> (fn != null && _pathByName.TryGetValue(fn, out var p)) ? p : null;
|
|
|
|
private void RebuildStage()
|
|
{
|
|
if (Stage == null) return;
|
|
Stage.Children.Clear();
|
|
_headImage = null; _headNormal = null; _headBlink = null;
|
|
if (_char == null) return;
|
|
|
|
// body
|
|
var bodyPath = ResolvePath(_build.BodyFile);
|
|
if (bodyPath != null) AddLayer(bodyPath, _build.BodyScale, _build.BodyOffsetX, _build.BodyOffsetY);
|
|
|
|
// head (expression frame preferred)
|
|
var headPath = ResolvePath(_build.ExpressionFile) ?? ResolvePath(_build.HeadFile);
|
|
if (headPath != null)
|
|
{
|
|
_headImage = AddLayer(headPath, _build.HeadScale, _build.HeadOffsetX, _build.HeadOffsetY);
|
|
_headNormal = _headImage.Source;
|
|
var shape = ShapeOf(_build.ExpressionFile) ?? ShapeOf(_build.HeadFile);
|
|
var blink = _headFrames.FirstOrDefault(h => h.Shape == shape && h.Expr == "blink");
|
|
if (blink != null) _headBlink = AssetScanner.LoadPart(blink.FilePath);
|
|
|
|
// hair tint
|
|
var maskPath = ResolvePath(_build.HairmaskFile);
|
|
if (maskPath != null && !string.IsNullOrEmpty(_build.HairColor))
|
|
AddTint(maskPath, _build.HairColor!, _build.HeadScale, _build.HeadOffsetX, _build.HeadOffsetY);
|
|
}
|
|
|
|
// accessories
|
|
foreach (var a in _build.Accessories)
|
|
{
|
|
var ap = ResolvePath(a.FileName);
|
|
if (ap != null) AddLayer(ap, a.Scale, a.OffsetX, a.OffsetY);
|
|
}
|
|
}
|
|
|
|
private Image AddLayer(string path, double scale, double offX, double offY)
|
|
{
|
|
var src = AssetScanner.LoadPart(path);
|
|
double aspect = (src != null && src.PixelHeight > 0) ? (double)src.PixelWidth / src.PixelHeight : 1.0;
|
|
// Bake scale AND the contain-fit into the element's SIZE (not a RenderTransform). A full-size
|
|
// element (680*aspect) overflows the stage and the Viewbox clips it — cutting the character —
|
|
// *before* any RenderTransform can shrink it. A pre-sized element only overflows by its
|
|
// transparent margins, so the character is never clipped.
|
|
double s = scale * _fit;
|
|
var img = new Image
|
|
{
|
|
Source = src,
|
|
Stretch = Stretch.Fill,
|
|
Width = StageH * aspect * s,
|
|
Height = StageH * s,
|
|
HorizontalAlignment = HorizontalAlignment.Center,
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|
RenderTransform = new TranslateTransform((offX - _fitCX) * _fit, (offY - _fitCY) * _fit),
|
|
IsHitTestVisible = false,
|
|
};
|
|
Stage.Children.Add(img);
|
|
return img;
|
|
}
|
|
|
|
private void AddTint(string maskPath, string hex, double scale, double offX, double offY)
|
|
{
|
|
try
|
|
{
|
|
var mask = AssetScanner.LoadBitmap(maskPath);
|
|
if (mask == null) return;
|
|
double aspect = mask.PixelHeight > 0 ? (double)mask.PixelWidth / mask.PixelHeight : 1;
|
|
double s = scale * _fit;
|
|
var rect = new Rectangle
|
|
{
|
|
Height = StageH * s,
|
|
Width = StageH * aspect * s,
|
|
Fill = new SolidColorBrush((Color)ColorConverter.ConvertFromString(hex)) { Opacity = 0.8 },
|
|
OpacityMask = new ImageBrush(mask) { Stretch = Stretch.Uniform },
|
|
HorizontalAlignment = HorizontalAlignment.Center,
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|
RenderTransform = new TranslateTransform((offX - _fitCX) * _fit, (offY - _fitCY) * _fit),
|
|
IsHitTestVisible = false,
|
|
};
|
|
Stage.Children.Add(rect);
|
|
}
|
|
catch { /* tint is best-effort */ }
|
|
}
|
|
|
|
// ---------- drag to move the selected layer ----------
|
|
private bool _dragging;
|
|
private Point _dragStart;
|
|
private double _dragBaseX, _dragBaseY;
|
|
|
|
private void Stage_MouseDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
|
{
|
|
if (_char == null) return;
|
|
(_dragBaseX, _dragBaseY) = GetSelectedOffset();
|
|
_dragStart = e.GetPosition(Stage);
|
|
_dragging = true;
|
|
StageHost.CaptureMouse();
|
|
}
|
|
|
|
private void Stage_MouseMove(object sender, System.Windows.Input.MouseEventArgs e)
|
|
{
|
|
if (!_dragging) return;
|
|
var p = e.GetPosition(Stage);
|
|
double f = _fit == 0 ? 1 : _fit; // layers are drawn at offset*_fit, so undo it for the delta
|
|
SetSelectedOffset(_dragBaseX + (p.X - _dragStart.X) / f, _dragBaseY + (p.Y - _dragStart.Y) / f);
|
|
}
|
|
|
|
private void Stage_MouseUp(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
|
{
|
|
if (!_dragging) return;
|
|
_dragging = false;
|
|
StageHost.ReleaseMouseCapture();
|
|
}
|
|
|
|
/// <summary>Offset of the layer currently chosen in CmbLayer (head/body/accessory).</summary>
|
|
private (double x, double y) GetSelectedOffset()
|
|
{
|
|
var (x, y, _) = GetLayer(CmbLayer.SelectedIndex);
|
|
return (x, y);
|
|
}
|
|
|
|
private void SetSelectedOffset(double x, double y)
|
|
{
|
|
var (_, _, s) = GetLayer(CmbLayer.SelectedIndex);
|
|
SetLayer(CmbLayer.SelectedIndex, Math.Round(x), Math.Round(y), s);
|
|
_suppress = true;
|
|
SldX.Value = Math.Max(SldX.Minimum, Math.Min(SldX.Maximum, Math.Round(x)));
|
|
SldY.Value = Math.Max(SldY.Minimum, Math.Min(SldY.Maximum, Math.Round(y)));
|
|
_suppress = false;
|
|
RebuildStage();
|
|
}
|
|
|
|
// ---------- auto-align (alpha neck detection) ----------
|
|
private void AutoAlign_Click(object sender, RoutedEventArgs e) => AutoAlignHead();
|
|
|
|
/// <summary>
|
|
/// Fit the whole figure: a headless body drawn full-height leaves no room for the head, so
|
|
/// scale the body down and drop it, then sit the head on top with a visible neck.
|
|
/// </summary>
|
|
private void AutoAlignHead()
|
|
{
|
|
var bodyPath = ResolvePath(_build.BodyFile);
|
|
var headPath = ResolvePath(_build.ExpressionFile) ?? ResolvePath(_build.HeadFile);
|
|
if (bodyPath == null || headPath == null) { RebuildStage(); return; }
|
|
|
|
var ab = AlphaTools.Analyze(bodyPath);
|
|
var ah = AlphaTools.Analyze(headPath);
|
|
if (ab == null || ah == null) { RebuildStage(); return; }
|
|
|
|
const double chin = 0.58;
|
|
|
|
// Head:body height ratio — waist-up poses (landscape images) carry a larger head, full-body
|
|
// poses (portrait images) a smaller one. These are nominal scales; UpdateFit() then scales
|
|
// the whole composition to fill the preview, so absolute values here only set the ratio.
|
|
bool fullBody = (double)ab.W / ab.H < 0.9;
|
|
double r = fullBody ? 0.24 : 0.60; // full-body figures need a smaller (natural) head
|
|
// Size the head against the body *below the neck* (torso→feet), not the full bbox: poses with
|
|
// raised arms make the bbox taller, which would otherwise inflate the head ("big-head" look).
|
|
double bodyBFrac = (double)(ab.MaxY - ab.NeckTop) / ab.H;
|
|
double M = bodyBFrac * ah.H / (double)ah.BH;
|
|
double bodyScale = 1.0;
|
|
double hs = Math.Max(0.15, Math.Min(1.3, r * M));
|
|
|
|
// Attach the head at the body's neck: neck top (robust to raised arms) for Y, torso axis for X.
|
|
double bodyNeckTopFrac = (double)ab.NeckTop / ab.H;
|
|
double bodyOffY = -bodyScale * (bodyNeckTopFrac - 0.5) * StageH; // neck line at stage centre
|
|
double lwB = StageH * ((double)ab.W / ab.H), lwH = StageH * ((double)ah.W / ah.H);
|
|
double bodyNeckX = bodyScale * ((double)ab.AxisX / ab.W - 0.5) * lwB;
|
|
double headCenterXf = (ah.MinX + ah.MaxX) / 2.0 / ah.W;
|
|
double headOffX = bodyNeckX - hs * (headCenterXf - 0.5) * lwH;
|
|
double headNeckFrac = (ah.MinY + chin * ah.BH) / (double)ah.H;
|
|
double headOffY = -hs * (headNeckFrac - 0.5) * StageH;
|
|
|
|
_build.BodyScale = Math.Round(bodyScale, 3);
|
|
_build.BodyOffsetX = 0;
|
|
_build.BodyOffsetY = Math.Round(bodyOffY);
|
|
_build.HeadScale = Math.Round(hs, 3);
|
|
_build.HeadOffsetX = Math.Round(headOffX);
|
|
_build.HeadOffsetY = Math.Round(headOffY);
|
|
|
|
UpdateFit();
|
|
LoadLayerToSliders();
|
|
RebuildStage();
|
|
}
|
|
|
|
/// <summary>Scale/centre the whole composition (head + body) to fill the preview stage.</summary>
|
|
private void UpdateFit()
|
|
{
|
|
var ab = ResolvePath(_build.BodyFile) is { } bp ? AlphaTools.Analyze(bp) : null;
|
|
var ah = ResolvePath(_build.ExpressionFile) is { } ep ? AlphaTools.Analyze(ep)
|
|
: ResolvePath(_build.HeadFile) is { } hp2 ? AlphaTools.Analyze(hp2) : null;
|
|
|
|
double x0 = double.MaxValue, x1 = double.MinValue, y0 = double.MaxValue, y1 = double.MinValue;
|
|
void Acc(AlphaTools.Analysis? a, double s, double ox, double oy)
|
|
{
|
|
if (a == null) return;
|
|
double lw = StageH * ((double)a.W / a.H);
|
|
foreach (var fx in new[] { (double)a.MinX / a.W, (double)a.MaxX / a.W })
|
|
{ double x = s * (fx - 0.5) * lw + ox; x0 = Math.Min(x0, x); x1 = Math.Max(x1, x); }
|
|
foreach (var fy in new[] { (double)a.MinY / a.H, (double)a.MaxY / a.H })
|
|
{ double y = s * (fy - 0.5) * StageH + oy; y0 = Math.Min(y0, y); y1 = Math.Max(y1, y); }
|
|
}
|
|
Acc(ab, _build.BodyScale, _build.BodyOffsetX, _build.BodyOffsetY);
|
|
Acc(ah, _build.HeadScale, _build.HeadOffsetX, _build.HeadOffsetY);
|
|
|
|
if (x1 <= x0 || y1 <= y0) { _fit = 1; _fitCX = _fitCY = 0; return; }
|
|
const double m = 0.03;
|
|
_fit = Math.Min(StageW * (1 - 2 * m) / (x1 - x0), StageH * (1 - 2 * m) / (y1 - y0));
|
|
_fitCX = (x0 + x1) / 2;
|
|
_fitCY = (y0 + y1) / 2;
|
|
}
|
|
|
|
// ---------- gesture preview ----------
|
|
private void Play_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (_char == null) return;
|
|
if (_playing) StopPlay(); else StartPlay();
|
|
}
|
|
|
|
private void StartPlay()
|
|
{
|
|
_playing = true; _phase = 0; _blinkRestoreAt = -1;
|
|
BtnPlay.Content = "⏸ 정지";
|
|
_timer.Start();
|
|
}
|
|
|
|
private void StopPlay()
|
|
{
|
|
_playing = false;
|
|
_timer.Stop();
|
|
_floatT.Y = 0;
|
|
BtnPlay.Content = "▶ 제스처 미리보기";
|
|
if (_headImage != null && _headNormal != null) _headImage.Source = _headNormal;
|
|
}
|
|
|
|
private void Timer_Tick(object? sender, EventArgs e)
|
|
{
|
|
_phase++;
|
|
_floatT.Y = Math.Sin(_phase * 0.09) * 7;
|
|
|
|
// blink
|
|
if (_headImage != null && _headBlink != null)
|
|
{
|
|
if (_phase % 55 == 0) { _headImage.Source = _headBlink; _blinkRestoreAt = _phase + 8; }
|
|
else if (_phase == _blinkRestoreAt && _headNormal != null) { _headImage.Source = _headNormal; }
|
|
}
|
|
|
|
// cycle gestures (bodies)
|
|
if (CmbBody.Items.Count > 1 && _phase % 45 == 0)
|
|
CmbBody.SelectedIndex = (CmbBody.SelectedIndex + 1) % CmbBody.Items.Count;
|
|
}
|
|
|
|
// ---------- save / load / reset ----------
|
|
private void Save_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (_char == null) { TxtStatus.Text = "먼저 캐릭터를 선택하세요."; return; }
|
|
var dlg = new Microsoft.Win32.SaveFileDialog
|
|
{
|
|
Title = "캐릭터 조합 저장",
|
|
InitialDirectory = _char.FolderPath,
|
|
Filter = "Markdown (*.md)|*.md",
|
|
FileName = _char.Name + "_build.md",
|
|
};
|
|
if (dlg.ShowDialog() != true) return;
|
|
_build.Name = System.IO.Path.GetFileNameWithoutExtension(dlg.FileName);
|
|
_build.Character = _char.Name;
|
|
BuildMd.Save(dlg.FileName, _build);
|
|
TxtCharName.Text = _char.Name + " · " + _build.Name;
|
|
TxtStatus.Text = "저장됨: " + dlg.FileName;
|
|
}
|
|
|
|
private void Load_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
var dlg = new Microsoft.Win32.OpenFileDialog
|
|
{
|
|
Title = "캐릭터 조합 불러오기",
|
|
InitialDirectory = _char?.FolderPath ?? _root ?? "",
|
|
Filter = "Markdown (*.md)|*.md",
|
|
};
|
|
if (dlg.ShowDialog() != true) return;
|
|
|
|
BuildDefinition b;
|
|
try { b = BuildMd.Load(dlg.FileName); }
|
|
catch (Exception ex) { TxtStatus.Text = "불러오기 실패: " + ex.Message; return; }
|
|
|
|
var ci = _characters.FirstOrDefault(c => string.Equals(c.Name, b.Character, StringComparison.OrdinalIgnoreCase));
|
|
if (ci == null) { TxtStatus.Text = "캐릭터 폴더를 찾을 수 없습니다: " + b.Character; return; }
|
|
|
|
_suppress = true;
|
|
CharList.SelectedItem = ci;
|
|
_suppress = false;
|
|
SelectCharacter(ci, b);
|
|
TxtStatus.Text = "불러옴: " + dlg.FileName;
|
|
}
|
|
|
|
private void Reset_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (_char != null) SelectCharacter(_char, null);
|
|
}
|
|
}
|