Files
2026-07-04 10:34:46 +09:00

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