Initial Dansori character workspace

This commit is contained in:
eKeerar
2026-07-04 10:34:46 +09:00
commit 5a419816ff
2480 changed files with 38692 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(python -c ' *)",
"PowerShell(dotnet build *)"
]
}
}
+173
View File
@@ -0,0 +1,173 @@
using System;
using System.Collections.Generic;
using System.Windows.Media;
using System.Windows.Media.Imaging;
namespace Character_Builder;
/// <summary>
/// Alpha-based analysis for auto-alignment: opaque bounding box + neck anchors
/// (top-edge center for a headless body, bottom-edge center for a head).
/// </summary>
public static class AlphaTools
{
public class Analysis
{
public int W, H;
public int MinX, MinY, MaxX, MaxY;
public double TopCenterX; // center X of opaque pixels along the top band
public double BottomCenterX; // center X of opaque pixels along the bottom band
public int AxisX; // torso central axis (robust neck X; ignores raised arms)
public int NeckTop; // topmost row of the central column (robust neck Y)
public int BH => MaxY - MinY + 1;
public int BW => MaxX - MinX + 1;
}
private static readonly Dictionary<string, Analysis?> _cache = new(StringComparer.OrdinalIgnoreCase);
private const byte AlphaThreshold = 24;
public static Analysis? Analyze(string path)
{
if (_cache.TryGetValue(path, out var cached)) return cached;
Analysis? a = null;
try { a = Compute(path); } catch { a = null; }
_cache[path] = a;
return a;
}
private static Analysis? Compute(string path)
{
var src = AssetScanner.LoadPart(path);
if (src == null) return null;
BitmapSource conv = src.Format == PixelFormats.Bgra32 ? src : new FormatConvertedBitmap(src, PixelFormats.Bgra32, null, 0);
int w = conv.PixelWidth, h = conv.PixelHeight;
if (w <= 0 || h <= 0) return null;
int stride = w * 4;
var px = new byte[h * stride];
conv.CopyPixels(px, stride, 0);
int minX = int.MaxValue, minY = int.MaxValue, maxX = -1, maxY = -1;
for (int y = 0; y < h; y++)
{
int row = y * stride;
for (int x = 0; x < w; x++)
{
if (px[row + x * 4 + 3] >= AlphaThreshold)
{
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
if (maxX < 0) return null; // fully transparent
int bh = maxY - minY + 1;
int band = Math.Max(1, (int)(bh * 0.06));
double topSum = 0; long topCount = 0;
for (int y = minY; y < Math.Min(h, minY + band); y++)
{
int row = y * stride;
for (int x = minX; x <= maxX; x++)
if (px[row + x * 4 + 3] >= AlphaThreshold) { topSum += x; topCount++; }
}
double bottomSum = 0; long bottomCount = 0;
for (int y = Math.Max(0, maxY - band + 1); y <= maxY; y++)
{
int row = y * stride;
for (int x = minX; x <= maxX; x++)
if (px[row + x * 4 + 3] >= AlphaThreshold) { bottomSum += x; bottomCount++; }
}
// Torso central axis: median opaque column in the mid-lower body (waist/legs are centered
// and free of raised arms), giving a neck-X that is stable across gesture poses.
int ta0 = minY + (int)(bh * 0.40), ta1 = Math.Min(maxY, minY + (int)(bh * 0.85));
var colCount = new int[w];
long axisTotal = 0;
for (int y = ta0; y <= ta1; y++)
{
int row = y * stride;
for (int x = minX; x <= maxX; x++)
if (px[row + x * 4 + 3] >= AlphaThreshold) { colCount[x]++; axisTotal++; }
}
int axisX = (minX + maxX) / 2;
if (axisTotal > 0)
{
long half = axisTotal / 2, acc = 0;
for (int x = 0; x < w; x++) { acc += colCount[x]; if (acc >= half) { axisX = x; break; } }
}
// Neck top: first row (from the top) whose central column — the opaque run around the
// axis — is solid (>=15px). Skips raised hands/arms that sit above the real neck.
int neckTop = minY;
for (int y = minY; y < minY + (int)(bh * 0.5); y++)
{
if (CentralRunWidth(px, stride, y, w, axisX) >= 15) { neckTop = y; break; }
}
// Precise neck via the bare-skin neck stump: the head's neck and the body's neck stump are
// the same anatomical part, so matching this locks the assembly. The stump is the central
// skin column at the top of a headless body; detecting it here gives a neck centre/top that
// is robust to raised arms, open jackets and asymmetric poses. Falls back to the alpha
// estimate above when no skin stump is present.
int bboxW = maxX - minX + 1, seedX = (minX + maxX) / 2;
for (int y = minY; y < minY + (int)(bh * 0.5); y++)
{
var (lo, hi, rw) = CentralSkinRun(px, stride, y, w, seedX);
if (rw >= 30 && rw <= bboxW / 2) { axisX = (lo + hi) / 2; neckTop = y; break; }
}
double cx = (minX + maxX) / 2.0;
return new Analysis
{
W = w,
H = h,
MinX = minX,
MinY = minY,
MaxX = maxX,
MaxY = maxY,
AxisX = axisX,
NeckTop = neckTop,
TopCenterX = topCount > 0 ? topSum / topCount : cx,
BottomCenterX = bottomCount > 0 ? bottomSum / bottomCount : cx,
};
}
/// <summary>Width of the opaque run that straddles <paramref name="axisX"/> on row y (0 if none).</summary>
private static int CentralRunWidth(byte[] px, int stride, int y, int w, int axisX)
{
int row = y * stride, seed = -1;
for (int d = 0; d <= 8 && seed < 0; d++)
{
if (axisX + d < w && px[row + (axisX + d) * 4 + 3] >= AlphaThreshold) seed = axisX + d;
else if (axisX - d >= 0 && px[row + (axisX - d) * 4 + 3] >= AlphaThreshold) seed = axisX - d;
}
if (seed < 0) return 0;
int lo = seed, hi = seed;
while (lo - 1 >= 0 && px[row + (lo - 1) * 4 + 3] >= AlphaThreshold) lo--;
while (hi + 1 < w && px[row + (hi + 1) * 4 + 3] >= AlphaThreshold) hi++;
return hi - lo + 1;
}
private static bool IsSkin(byte b, byte g, byte r)
=> r > 150 && r >= g && g >= b - 8 && (r - b) >= 15 && (r - b) <= 130 && (g - b) <= 70;
/// <summary>The bare-skin run straddling <paramref name="cx"/> on row y (searches ±60px for the seed).</summary>
private static (int lo, int hi, int w) CentralSkinRun(byte[] px, int stride, int y, int w, int cx)
{
int row = y * stride, seed = -1;
for (int d = 0; d <= 60 && seed < 0; d++)
{
int xr = cx + d, xl = cx - d;
if (xr < w && IsSkin(px[row + xr * 4], px[row + xr * 4 + 1], px[row + xr * 4 + 2])) seed = xr;
else if (xl >= 0 && IsSkin(px[row + xl * 4], px[row + xl * 4 + 1], px[row + xl * 4 + 2])) seed = xl;
}
if (seed < 0) return (0, 0, 0);
int lo = seed, hi = seed;
while (lo - 1 >= 0 && IsSkin(px[row + (lo - 1) * 4], px[row + (lo - 1) * 4 + 1], px[row + (lo - 1) * 4 + 2])) lo--;
while (hi + 1 < w && IsSkin(px[row + (hi + 1) * 4], px[row + (hi + 1) * 4 + 1], px[row + (hi + 1) * 4 + 2])) hi++;
return (lo, hi, hi - lo + 1);
}
}
+221
View File
@@ -0,0 +1,221 @@
<Application x:Class="Character_Builder.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml">
<Application.Resources>
<ResourceDictionary>
<SolidColorBrush x:Key="Bg" Color="#14151A"/>
<SolidColorBrush x:Key="Panel" Color="#1E2028"/>
<SolidColorBrush x:Key="Panel2" Color="#262933"/>
<SolidColorBrush x:Key="Stroke" Color="#343845"/>
<SolidColorBrush x:Key="Accent" Color="#4CC2FF"/>
<SolidColorBrush x:Key="AccentDk" Color="#04222F"/>
<SolidColorBrush x:Key="Text" Color="#EDEFF2"/>
<SolidColorBrush x:Key="Dim" Color="#A8ADB8"/>
<SolidColorBrush x:Key="Mute" Color="#6E7480"/>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource Text}"/>
<Setter Property="FontFamily" Value="Segoe UI Variable, Segoe UI"/>
<Setter Property="FontSize" Value="18"/>
</Style>
<Style x:Key="Label" TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
<Setter Property="FontSize" Value="17"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Margin" Value="0,16,0,6"/>
</Style>
<Style x:Key="Card" TargetType="Border">
<Setter Property="Background" Value="{StaticResource Panel}"/>
<Setter Property="BorderBrush" Value="{StaticResource Stroke}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="12"/>
</Style>
<!-- Button (subtle) -->
<Style TargetType="Button">
<Setter Property="Foreground" Value="{StaticResource Text}"/>
<Setter Property="Background" Value="{StaticResource Panel2}"/>
<Setter Property="BorderBrush" Value="{StaticResource Stroke}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Height" Value="46"/>
<Setter Property="Padding" Value="18,0"/>
<Setter Property="FontSize" Value="19"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="FontFamily" Value="Segoe UI Variable, Segoe UI"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="b" Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="8" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="b" Property="BorderBrush" Value="{StaticResource Accent}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="b" Property="Opacity" Value="0.75"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="b" Property="Opacity" Value="0.4"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Window caption (title bar) buttons -->
<Style x:Key="CaptionButton" TargetType="Button">
<Setter Property="Width" Value="50"/>
<Setter Property="Height" Value="36"/>
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="FontFamily" Value="Segoe MDL2 Assets"/>
<Setter Property="FontSize" Value="10"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="cb" Background="{TemplateBinding Background}" CornerRadius="6">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="cb" Property="Background" Value="{StaticResource Panel2}"/>
<Setter Property="Foreground" Value="{StaticResource Text}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="CaptionClose" TargetType="Button" BasedOn="{StaticResource CaptionButton}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="cb" Background="{TemplateBinding Background}" CornerRadius="6">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="cb" Property="Background" Value="#E23B3B"/>
<Setter Property="Foreground" Value="White"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="AccentButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Background" Value="{StaticResource Accent}"/>
<Setter Property="Foreground" Value="{StaticResource AccentDk}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="FontWeight" Value="Bold"/>
</Style>
<!-- Dark ComboBox: readable light text on both the closed box and the dropdown -->
<Style TargetType="ComboBoxItem">
<Setter Property="Foreground" Value="{StaticResource Text}"/>
<Setter Property="FontFamily" Value="Segoe UI Variable, Segoe UI"/>
<Setter Property="FontSize" Value="19"/>
<Setter Property="Padding" Value="12,8"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBoxItem">
<Border x:Name="ib" Background="Transparent" CornerRadius="6"
Padding="{TemplateBinding Padding}">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="ib" Property="Background" Value="#12344A"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="ib" Property="Background" Value="#173B52"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="ComboToggle" TargetType="ToggleButton">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Border Background="Transparent"/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ComboBox">
<Setter Property="Foreground" Value="{StaticResource Text}"/>
<Setter Property="Background" Value="{StaticResource Panel2}"/>
<Setter Property="BorderBrush" Value="{StaticResource Stroke}"/>
<Setter Property="Height" Value="44"/>
<Setter Property="Padding" Value="12,0"/>
<Setter Property="FontSize" Value="19"/>
<Setter Property="FontFamily" Value="Segoe UI Variable, Segoe UI"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<Border x:Name="box" Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="1"
CornerRadius="8"/>
<ToggleButton Style="{StaticResource ComboToggle}"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"/>
<ContentPresenter x:Name="sel" Margin="10,0,30,0"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
IsHitTestVisible="False"
HorizontalAlignment="Left" VerticalAlignment="Center"
TextElement.Foreground="{StaticResource Text}"/>
<Path x:Name="arrow" Data="M0,0 L8,0 L4,5 Z" Fill="{StaticResource Dim}"
HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0,0,12,0"
IsHitTestVisible="False"/>
<Popup x:Name="PART_Popup" Placement="Bottom" AllowsTransparency="True"
IsOpen="{TemplateBinding IsDropDownOpen}" Focusable="False"
PopupAnimation="Slide">
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Stroke}"
BorderThickness="1" CornerRadius="8" Margin="0,4,0,0"
MinWidth="{Binding ActualWidth, RelativeSource={RelativeSource TemplatedParent}}"
Padding="4">
<ScrollViewer MaxHeight="320" VerticalScrollBarVisibility="Auto">
<ItemsPresenter/>
</ScrollViewer>
</Border>
</Popup>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="box" Property="BorderBrush" Value="{StaticResource Accent}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="box" Property="Opacity" Value="0.5"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="CheckBox">
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
<Setter Property="FontSize" Value="19"/>
<Setter Property="Margin" Value="0,5"/>
<Setter Property="FontFamily" Value="Segoe UI Variable, Segoe UI"/>
</Style>
</ResourceDictionary>
</Application.Resources>
</Application>
+16
View File
@@ -0,0 +1,16 @@
using System.Windows;
using System.Windows.Threading;
namespace Character_Builder;
public partial class App : Application
{
public App()
{
DispatcherUnhandledException += (_, e) =>
{
MessageBox.Show(e.Exception.Message, "오류", MessageBoxButton.OK, MessageBoxImage.Warning);
e.Handled = true;
};
}
}
+138
View File
@@ -0,0 +1,138 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Windows.Media.Imaging;
namespace Character_Builder;
/// <summary>Finds the Characters_Build_Docs root, lists characters, and scans their part PNGs.</summary>
public static class AssetScanner
{
/// <summary>Walk up from the exe to find the "Characters_Build_Docs" folder.</summary>
public static string? FindRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
for (int i = 0; i < 10 && dir != null; i++)
{
if (string.Equals(dir.Name, "Characters_Build_Docs", StringComparison.OrdinalIgnoreCase))
return dir.FullName;
// also: a child named Characters_Build_Docs
var child = Path.Combine(dir.FullName, "Characters_Build_Docs");
if (Directory.Exists(child)) return child;
dir = dir.Parent;
}
return null;
}
/// <summary>Character = a subfolder that contains a Reference/ folder.</summary>
public static List<CharacterInfo> GetCharacters(string root)
{
var list = new List<CharacterInfo>();
if (!Directory.Exists(root)) return list;
foreach (var d in Directory.GetDirectories(root))
{
var refDir = Path.Combine(d, "Reference");
if (!Directory.Exists(refDir)) continue;
var name = Path.GetFileName(d);
var sheet = PickSheet(refDir, name);
list.Add(new CharacterInfo
{
Name = name,
FolderPath = d,
SheetPath = sheet,
Thumb = LoadBitmap(sheet)
});
}
return list.OrderBy(c => c.Name).ToList();
}
private static string? PickSheet(string refDir, string charName)
{
var sheets = Directory.GetFiles(refDir, "*_sheet.png");
if (sheets.Length == 0) return Directory.GetFiles(refDir, "*.png").FirstOrDefault();
bool duo = charName.Contains("and", StringComparison.OrdinalIgnoreCase)
|| charName.Contains("Duo", StringComparison.OrdinalIgnoreCase)
|| charName.Contains("&");
// duo -> prefer combined; single -> prefer non-combined
var ordered = sheets.OrderBy(s =>
{
bool combined = Path.GetFileName(s).Contains("combined", StringComparison.OrdinalIgnoreCase);
return duo ? (combined ? 0 : 1) : (combined ? 1 : 0);
}).ThenBy(s => s);
return ordered.First();
}
/// <summary>Scan every PNG under the character's Images/ folders.</summary>
public static List<PartItem> GetParts(string charFolder)
{
var result = new List<PartItem>();
if (!Directory.Exists(charFolder)) return result;
var pngs = Directory.GetFiles(charFolder, "*.png", SearchOption.AllDirectories)
.Where(p => p.Replace('\\', '/').Contains("/Images/"))
.OrderBy(p => p);
foreach (var p in pngs)
{
var fn = Path.GetFileName(p);
var stem = Path.GetFileNameWithoutExtension(fn);
var item = new PartItem { FileName = fn, FilePath = p, Display = stem };
var low = stem.ToLowerInvariant();
if (low.Contains("_hairmask_"))
{
item.Shape = AfterToken(stem, "hairmask");
}
else if (low.Contains("_head_"))
{
var parts = stem.Split('_');
int hi = Array.FindIndex(parts, t => t.Equals("head", StringComparison.OrdinalIgnoreCase));
if (hi >= 0 && hi + 1 < parts.Length)
{
item.Shape = parts[hi + 1];
item.Expr = (hi + 2 < parts.Length) ? string.Join("_", parts.Skip(hi + 2)) : "(base)";
}
}
result.Add(item);
}
return result;
}
private static string AfterToken(string stem, string token)
{
var parts = stem.Split('_');
int i = Array.FindIndex(parts, t => t.Equals(token, StringComparison.OrdinalIgnoreCase));
return (i >= 0 && i + 1 < parts.Length) ? string.Join("_", parts.Skip(i + 1)) : "";
}
public static BitmapImage? LoadBitmap(string? path)
{
if (string.IsNullOrEmpty(path) || !File.Exists(path)) return null;
try
{
var b = new BitmapImage();
b.BeginInit();
b.CacheOption = BitmapCacheOption.OnLoad;
b.CreateOptions = BitmapCreateOptions.IgnoreImageCache;
b.UriSource = new Uri(path);
b.EndInit();
b.Freeze();
return b;
}
catch { return null; }
}
private static readonly Dictionary<string, BitmapSource?> _partCache = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Load a part PNG with its baked-in background keyed out to transparency (cached).</summary>
public static BitmapSource? LoadPart(string? path)
{
if (string.IsNullOrEmpty(path)) return null;
if (_partCache.TryGetValue(path, out var cached)) return cached;
var raw = LoadBitmap(path);
BitmapSource? keyed = raw;
bool isHead = Path.GetFileName(path).ToLowerInvariant().Contains("_head_");
try { if (raw != null) keyed = BgKey.KeyOut(raw, isHead); } catch { keyed = raw; }
_partCache[path] = keyed;
return keyed;
}
}
+220
View File
@@ -0,0 +1,220 @@
using System;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
namespace Character_Builder;
/// <summary>
/// Many part PNGs are exported as opaque RGB with a "fake transparency" checkerboard
/// (two light grays ~#FEFEFE / ~#F1F1F1) baked into the pixels. That opaque background
/// hides layers below it and breaks alpha-based neck detection.
///
/// KeyOut() removes the background by flood-filling from the image borders, so only the
/// background that is *connected to the edge* is cleared — interior white clothing (crop
/// top, hoodie) is kept. If the source already has real transparency, it is returned as-is.
/// </summary>
public static class BgKey
{
// A pixel counts as background when it is light and near-neutral (low saturation).
private const int BgMinBrightness = 208; // min channel value
private const int BgMaxSpread = 26; // max(channel) - min(channel)
// Boundary feather: soften light fringe pixels that touch cleared background.
private const int FeatherLo = 200; // fully opaque below this brightness
private const int FeatherHi = 250; // fully transparent at/above this brightness
public static BitmapSource KeyOut(BitmapSource src, bool isHead = false)
{
BitmapSource conv = src.Format == PixelFormats.Bgra32
? src
: new FormatConvertedBitmap(src, PixelFormats.Bgra32, null, 0);
int w = conv.PixelWidth, h = conv.PixelHeight;
if (w <= 0 || h <= 0) return src;
int stride = w * 4;
var px = new byte[h * stride];
conv.CopyPixels(px, stride, 0);
// Already has real transparency? Leave it alone.
long transparent = 0;
for (int i = 3; i < px.Length; i += 4)
if (px[i] < 16) { if (++transparent > (long)w * h / 50) break; }
if (transparent > (long)w * h / 50) return src;
var seen = new bool[w * h];
var stack = new int[w * h];
int sp = 0;
// Seed the flood fill from every border pixel that looks like background.
for (int x = 0; x < w; x++)
{
TryPush(px, stride, seen, stack, ref sp, x, 0, w);
TryPush(px, stride, seen, stack, ref sp, x, h - 1, w);
}
for (int y = 0; y < h; y++)
{
TryPush(px, stride, seen, stack, ref sp, 0, y, w);
TryPush(px, stride, seen, stack, ref sp, w - 1, y, w);
}
// Flood fill (4-connected) over border-connected background; clear its alpha.
while (sp > 0)
{
int idx = stack[--sp];
int x = idx % w, y = idx / w;
px[idx * 4 + 3] = 0; // transparent
TryPush(px, stride, seen, stack, ref sp, x - 1, y, w);
TryPush(px, stride, seen, stack, ref sp, x + 1, y, w);
TryPush(px, stride, seen, stack, ref sp, x, y - 1, w);
TryPush(px, stride, seen, stack, ref sp, x, y + 1, w);
}
// The baked "transparency" is a two-tone light-gray checkerboard. Loops/rings (necklace,
// bracelet, headphone band) enclose background pockets the border fill can't reach; remove
// them by detecting the checker pattern (both tones present, no dark object pixels nearby).
RemoveCheckerPockets(px, w, h, stride);
// Head portraits include a white shirt/shoulders below the neck; that interior white
// is not border-connected, so it survives the flood fill and looks like an un-keyed
// white patch once the head is layered on a body. Drop light interior pixels in the
// lower part of the head silhouette (below the face, so eyes/teeth are untouched).
if (isHead) DropHeadShirt(px, w, h, stride);
Feather(px, w, h, stride);
var wb = new WriteableBitmap(w, h, 96, 96, PixelFormats.Bgra32, null);
wb.WritePixels(new Int32Rect(0, 0, w, h), px, stride, 0);
wb.Freeze();
return wb;
}
private static void TryPush(byte[] px, int stride, bool[] seen, int[] stack, ref int sp, int x, int y, int w)
{
int h = px.Length / stride;
if (x < 0 || y < 0 || x >= w || y >= h) return;
int idx = y * w + x;
if (seen[idx]) return;
int p = y * stride + x * 4;
if (!IsBg(px[p], px[p + 1], px[p + 2])) return;
seen[idx] = true;
stack[sp++] = idx;
}
private static bool IsBg(byte b, byte g, byte r)
{
int mn = Math.Min(r, Math.Min(g, b));
int mx = Math.Max(r, Math.Max(g, b));
return mn >= BgMinBrightness && (mx - mn) <= BgMaxSpread;
}
/// <summary>Remove enclosed checkerboard-background pockets while keeping solid light objects.</summary>
private static void RemoveCheckerPockets(byte[] px, int w, int h, int stride)
{
int W1 = w + 1;
var li = new int[(h + 1) * W1]; // integral of "light" checker tone (~254)
var mi = new int[(h + 1) * W1]; // integral of "mid" checker tone (~241)
var di = new int[(h + 1) * W1]; // integral of dark (object) pixels
var neutral = new bool[w * h];
for (int y = 0; y < h; y++)
{
int row = y * stride, cur = (y + 1) * W1, prev = y * W1;
int la = 0, ma = 0, da = 0;
for (int x = 0; x < w; x++)
{
int p = row + x * 4; int b = px[p], g = px[p + 1], r = px[p + 2];
int mn = Math.Min(r, Math.Min(g, b)), mx = Math.Max(r, Math.Max(g, b));
double v = (r + g + b) / 3.0;
bool nt = mn >= 224 && (mx - mn) <= 14;
neutral[y * w + x] = nt;
if (nt && v >= 249) la++;
if (nt && v >= 234 && v <= 246) ma++;
if (v < 205) da++;
li[cur + x + 1] = li[prev + x + 1] + la;
mi[cur + x + 1] = mi[prev + x + 1] + ma;
di[cur + x + 1] = di[prev + x + 1] + da;
}
}
const int R = 18;
int area = (2 * R + 1) * (2 * R + 1);
int need = (int)(0.06 * area);
for (int y = 0; y < h; y++)
{
int row = y * stride;
for (int x = 0; x < w; x++)
{
int p = row + x * 4;
if (px[p + 3] == 0 || !neutral[y * w + x]) continue;
int y0 = Math.Max(0, y - R), y1 = Math.Min(h, y + R + 1);
int x0 = Math.Max(0, x - R), x1 = Math.Min(w, x + R + 1);
if (Win(di, W1, y0, y1, x0, x1) > 2) continue; // an object edge is near → keep
if (Win(li, W1, y0, y1, x0, x1) >= need && Win(mi, W1, y0, y1, x0, x1) >= need)
px[p + 3] = 0; // both checker tones present → background
}
}
}
private static int Win(int[] I, int W1, int y0, int y1, int x0, int x1)
=> I[y1 * W1 + x1] - I[y0 * W1 + x1] - I[y1 * W1 + x0] + I[y0 * W1 + x0];
/// <summary>Clear light, low-saturation interior pixels (the shirt) in the lower head region.</summary>
private static void DropHeadShirt(byte[] px, int w, int h, int stride)
{
int minY = int.MaxValue, maxY = -1;
for (int y = 0; y < h; y++)
{
int row = y * stride;
for (int x = 0; x < w; x++)
if (px[row + x * 4 + 3] >= 24) { if (y < minY) minY = y; if (y > maxY) maxY = y; break; }
}
if (maxY < 0) return;
int yThr = minY + (int)((maxY - minY) * 0.58); // below the face
for (int y = yThr; y < h; y++)
{
int row = y * stride;
for (int x = 0; x < w; x++)
{
int p = row + x * 4;
if (px[p + 3] == 0) continue;
int b = px[p], g = px[p + 1], r = px[p + 2];
int mn = Math.Min(r, Math.Min(g, b));
int mx = Math.Max(r, Math.Max(g, b));
if (mn >= 222 && (mx - mn) <= 16) px[p + 3] = 0;
}
}
}
/// <summary>Soften light, low-saturation pixels that border a cleared area (anti-halo).</summary>
private static void Feather(byte[] px, int w, int h, int stride)
{
for (int y = 0; y < h; y++)
{
int row = y * stride;
for (int x = 0; x < w; x++)
{
int p = row + x * 4;
if (px[p + 3] == 0) continue;
bool touchesCleared =
(x > 0 && px[p - 4 + 3] == 0) ||
(x < w - 1 && px[p + 4 + 3] == 0) ||
(y > 0 && px[p - stride + 3] == 0) ||
(y < h - 1 && px[p + stride + 3] == 0);
if (!touchesCleared) continue;
int b = px[p], g = px[p + 1], r = px[p + 2];
int mn = Math.Min(r, Math.Min(g, b));
int mx = Math.Max(r, Math.Max(g, b));
if (mn < FeatherLo || (mx - mn) > BgMaxSpread) continue;
int t = Math.Min(255, Math.Max(0, (mn - FeatherLo) * 255 / (FeatherHi - FeatherLo)));
px[p + 3] = (byte)(255 - t);
}
}
}
}
+100
View File
@@ -0,0 +1,100 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
namespace Character_Builder;
/// <summary>Reads/writes a composition as a human-readable .md file.</summary>
public static class BuildMd
{
public static void Save(string path, BuildDefinition b)
{
var sb = new StringBuilder();
sb.AppendLine($"# Character Build — {b.Name}");
sb.AppendLine();
sb.AppendLine("> Saved by Character_Builder. Layers are composited: body + head(expression) + hair tint + accessories.");
sb.AppendLine("> Coordinate convention (for the Dansori app): stage 520x680. Each layer image is drawn Height=680 (Stretch=Uniform),");
sb.AppendLine("> centered in the stage, scaled about its center by 'scale', then translated by (x, y) pixels. transform = x, y, scale.");
sb.AppendLine("> The app can either reuse these transforms directly (same convention) or re-run alpha neck-alignment and treat these as deltas.");
sb.AppendLine();
sb.AppendLine($"name: {b.Name}");
sb.AppendLine($"character: {b.Character}");
sb.AppendLine($"body: {b.BodyFile}");
sb.AppendLine($"body.transform: {F(b.BodyOffsetX)}, {F(b.BodyOffsetY)}, {F(b.BodyScale)}");
sb.AppendLine($"head: {b.HeadFile}");
sb.AppendLine($"expression: {b.ExpressionFile}");
sb.AppendLine($"hairmask: {b.HairmaskFile}");
sb.AppendLine($"hairColor: {b.HairColor}");
sb.AppendLine($"head.transform: {F(b.HeadOffsetX)}, {F(b.HeadOffsetY)}, {F(b.HeadScale)}");
sb.AppendLine();
sb.AppendLine("## accessories");
foreach (var a in b.Accessories)
sb.AppendLine($"- {a.FileName} | {F(a.OffsetX)}, {F(a.OffsetY)}, {F(a.Scale)}");
File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
}
public static BuildDefinition Load(string path)
{
var b = new BuildDefinition();
bool inAcc = false;
foreach (var raw in File.ReadAllLines(path))
{
var line = raw.Trim();
if (line.Length == 0) continue;
if (line.StartsWith("## accessories", StringComparison.OrdinalIgnoreCase)) { inAcc = true; continue; }
if (line.StartsWith("#")) continue;
if (line.StartsWith(">")) continue;
if (inAcc && line.StartsWith("-"))
{
var body = line.TrimStart('-', ' ');
var parts = body.Split('|');
var a = new AccessoryLayer { FileName = parts[0].Trim() };
if (parts.Length > 1) ApplyTransform(parts[1], (x, y, s) => { a.OffsetX = x; a.OffsetY = y; a.Scale = s; });
if (!string.IsNullOrEmpty(a.FileName)) b.Accessories.Add(a);
continue;
}
int c = line.IndexOf(':');
if (c < 0) continue;
var key = line.Substring(0, c).Trim().ToLowerInvariant();
var val = line.Substring(c + 1).Trim();
switch (key)
{
case "name": b.Name = val; break;
case "character": b.Character = val; break;
case "body": b.BodyFile = Empty(val); break;
case "body.transform":
ApplyTransform(val, (x, y, s) => { b.BodyOffsetX = x; b.BodyOffsetY = y; b.BodyScale = s; });
break;
case "head": b.HeadFile = Empty(val); break;
case "expression": b.ExpressionFile = Empty(val); break;
case "hairmask": b.HairmaskFile = Empty(val); break;
case "haircolor": b.HairColor = Empty(val); break;
case "head.transform":
ApplyTransform(val, (x, y, s) => { b.HeadOffsetX = x; b.HeadOffsetY = y; b.HeadScale = s; });
break;
}
}
return b;
}
private static string? Empty(string v) => string.IsNullOrWhiteSpace(v) ? null : v;
private static void ApplyTransform(string s, Action<double, double, double> set)
{
var t = s.Split(',');
double x = t.Length > 0 ? D(t[0]) : 0;
double y = t.Length > 1 ? D(t[1]) : 0;
double sc = t.Length > 2 ? D(t[2]) : 1;
set(x, y, sc);
}
private static double D(string s) =>
double.TryParse(s.Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out var v) ? v : 0;
private static string F(double v) => v.ToString("0.###", CultureInfo.InvariantCulture);
}
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<RootNamespace>Character_Builder</RootNamespace>
<AssemblyName>Character_Builder</AssemblyName>
</PropertyGroup>
</Project>
+194
View File
@@ -0,0 +1,194 @@
<Window x:Class="Character_Builder.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
Title="Dansori · Character Builder" Height="1040" Width="1560"
MinHeight="760" MinWidth="1320"
WindowStyle="None" WindowState="Maximized" WindowStartupLocation="CenterScreen"
Background="Transparent"
FontFamily="Segoe UI Variable, Segoe UI">
<shell:WindowChrome.WindowChrome>
<shell:WindowChrome CaptionHeight="52" ResizeBorderThickness="6"
GlassFrameThickness="0" CornerRadius="0" UseAeroCaptionButtons="False"/>
</shell:WindowChrome.WindowChrome>
<Border x:Name="RootBorder" Background="{StaticResource Bg}"
BorderBrush="{StaticResource Stroke}" BorderThickness="1">
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- ===== TOP BAR ===== -->
<Grid Grid.Row="0" Margin="4,0,4,14">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Border Width="12" Height="12" CornerRadius="6" Background="{StaticResource Accent}" Margin="0,0,12,0"/>
<TextBlock Text="Character Builder" FontSize="28" FontWeight="Bold"/>
<TextBlock Text="Dansori 캐릭터 조합기" Foreground="{StaticResource Mute}"
FontSize="18" VerticalAlignment="Bottom" Margin="12,0,0,4"/>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right"
shell:WindowChrome.IsHitTestVisibleInChrome="True">
<Button x:Name="BtnRoot" Content="📁 루트" Click="Root_Click" Margin="0,0,8,0"/>
<Button x:Name="BtnLoad" Content="↥ 불러오기" Click="Load_Click" Margin="0,0,8,0"/>
<Button x:Name="BtnReset" Content="⟲ 초기화" Click="Reset_Click" Margin="0,0,8,0"/>
<Button x:Name="BtnSave" Content="💾 저장" Click="Save_Click" Style="{StaticResource AccentButton}"/>
<Border Width="1" Background="{StaticResource Stroke}" Margin="12,4,10,4"/>
<Button x:Name="BtnMin" Style="{StaticResource CaptionButton}" Click="Min_Click">
<Path Data="M0,5 H11" Stroke="{StaticResource Dim}" StrokeThickness="1.3"/>
</Button>
<Button x:Name="BtnMax" Style="{StaticResource CaptionButton}" Click="Max_Click" Margin="2,0">
<Path Data="M0,0 H10 V10 H0 Z" Stroke="{StaticResource Dim}" StrokeThickness="1.3" Fill="Transparent"/>
</Button>
<Button x:Name="BtnClose" Style="{StaticResource CaptionClose}" Click="Close_Click">
<Path Data="M0,0 L10,10 M0,10 L10,0" Stroke="{StaticResource Dim}" StrokeThickness="1.3"/>
</Button>
</StackPanel>
</Grid>
<!-- ===== MAIN ===== -->
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="300"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="440"/>
</Grid.ColumnDefinitions>
<!-- Character list -->
<Border Grid.Column="0" Style="{StaticResource Card}" Margin="0,0,14,0">
<DockPanel Margin="12">
<TextBlock DockPanel.Dock="Top" Text="캐릭터" FontWeight="Bold" FontSize="21" Margin="2,2,0,12"/>
<ListBox x:Name="CharList" Background="Transparent" BorderThickness="0"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
SelectionChanged="CharList_SelectionChanged">
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Margin" Value="0,0,0,8"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="bd" CornerRadius="10" Background="{StaticResource Panel2}"
BorderBrush="{StaticResource Stroke}" BorderThickness="1" Padding="8">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="bd" Property="BorderBrush" Value="{StaticResource Accent}"/>
<Setter TargetName="bd" Property="Background" Value="#12344A"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="BorderBrush" Value="{StaticResource Accent}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel>
<Border CornerRadius="8" Height="150" ClipToBounds="True" Background="#0E0F14">
<Image Source="{Binding Thumb}" Stretch="Uniform" Margin="4"/>
</Border>
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="19"
Margin="2,9,0,2" TextTrimming="CharacterEllipsis"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Border>
<!-- Preview -->
<Border Grid.Column="1" Style="{StaticResource Card}" Margin="0,0,14,0">
<DockPanel Margin="14">
<Grid DockPanel.Dock="Top" Margin="2,0,2,10">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock x:Name="TxtCharName" Text="캐릭터를 선택하세요" FontSize="22" FontWeight="Bold"/>
</StackPanel>
<Button x:Name="BtnPlay" Content="▶ 제스처 미리보기" Click="Play_Click"
HorizontalAlignment="Right" Width="210"/>
</Grid>
<Border Background="#0E0F14" CornerRadius="10" BorderBrush="{StaticResource Stroke}" BorderThickness="1">
<Grid>
<TextBlock x:Name="PreviewHint" Text="왼쪽에서 캐릭터를 고른 뒤, 오른쪽에서 파츠를 조합하세요."
Foreground="{StaticResource Mute}" HorizontalAlignment="Center"
VerticalAlignment="Center" TextWrapping="Wrap" TextAlignment="Center" MaxWidth="320"/>
<Viewbox Stretch="Uniform" Margin="16">
<Grid x:Name="StageHost" Width="540" Height="680"
Background="Transparent" Cursor="SizeAll"
MouseLeftButtonDown="Stage_MouseDown"
MouseMove="Stage_MouseMove"
MouseLeftButtonUp="Stage_MouseUp">
<Grid x:Name="Stage" Width="540" Height="680"/>
</Grid>
</Viewbox>
</Grid>
</Border>
</DockPanel>
</Border>
<!-- Controls -->
<Border Grid.Column="2" Style="{StaticResource Card}">
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="14,10,14,14">
<StackPanel>
<TextBlock Text="조합" FontWeight="Bold" FontSize="21" Margin="2,2,0,4"/>
<TextBlock Text="바디 / 제스처" Style="{StaticResource Label}"/>
<ComboBox x:Name="CmbBody" SelectionChanged="Body_Changed"/>
<TextBlock Text="헤어 모양" Style="{StaticResource Label}"/>
<ComboBox x:Name="CmbShape" SelectionChanged="Shape_Changed"/>
<TextBlock Text="표정" Style="{StaticResource Label}"/>
<ComboBox x:Name="CmbExpr" SelectionChanged="Expr_Changed"/>
<TextBlock Text="헤어 색상 (코드 틴트)" Style="{StaticResource Label}"/>
<WrapPanel x:Name="SwatchPanel"/>
<TextBlock Text="악세서리" Style="{StaticResource Label}"/>
<Border Background="{StaticResource Panel2}" BorderBrush="{StaticResource Stroke}"
BorderThickness="1" CornerRadius="8" Padding="10,6" MinHeight="40" MaxHeight="230">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="AccPanel"/>
</ScrollViewer>
</Border>
<TextBlock Text="레이어 위치/크기 조정" Style="{StaticResource Label}"/>
<ComboBox x:Name="CmbLayer" SelectionChanged="Layer_Changed"/>
<Grid Margin="0,8,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="34"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="X" Foreground="{StaticResource Dim}" VerticalAlignment="Center"/>
<Slider x:Name="SldX" Grid.Row="0" Grid.Column="1" Minimum="-400" Maximum="400" ValueChanged="LayerXform_Changed"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Y" Foreground="{StaticResource Dim}" VerticalAlignment="Center"/>
<Slider x:Name="SldY" Grid.Row="1" Grid.Column="1" Minimum="-400" Maximum="400" ValueChanged="LayerXform_Changed"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="⤢" Foreground="{StaticResource Dim}" VerticalAlignment="Center"/>
<Slider x:Name="SldScale" Grid.Row="2" Grid.Column="1" Minimum="0.1" Maximum="1.6" ValueChanged="LayerXform_Changed"/>
</Grid>
<Button Content="🎯 머리 자동 정렬" Click="AutoAlign_Click" Margin="0,10,0,0"/>
<TextBlock Text="미리보기에서 드래그하면 위 목록에서 선택한 레이어가 이동합니다."
Foreground="{StaticResource Mute}" FontSize="16" TextWrapping="Wrap" Margin="2,10,0,0"/>
</StackPanel>
</ScrollViewer>
</Border>
</Grid>
<!-- ===== STATUS ===== -->
<TextBlock x:Name="TxtStatus" Grid.Row="2" Margin="4,10,4,0"
Foreground="{StaticResource Mute}" FontSize="17" Text="준비"/>
</Grid>
</Border>
</Window>
+693
View File
@@ -0,0 +1,693 @@
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);
}
}
+53
View File
@@ -0,0 +1,53 @@
using System.Collections.Generic;
using System.Windows.Media;
namespace Character_Builder;
/// <summary>A character folder (has a Reference/ sheet).</summary>
public class CharacterInfo
{
public string Name { get; set; } = "";
public string FolderPath { get; set; } = "";
public string? SheetPath { get; set; }
public ImageSource? Thumb { get; set; }
public override string ToString() => Name;
}
/// <summary>One scanned PNG asset (body/head/expression/hairmask/accessory).</summary>
public class PartItem
{
public string Display { get; set; } = "";
public string FileName { get; set; } = "";
public string FilePath { get; set; } = "";
public string Shape { get; set; } = ""; // for heads/expressions
public string Expr { get; set; } = ""; // for expression frames
public override string ToString() => Display;
}
/// <summary>A composited accessory layer with its own transform.</summary>
public class AccessoryLayer
{
public string FileName { get; set; } = "";
public double OffsetX { get; set; }
public double OffsetY { get; set; } = -190;
public double Scale { get; set; } = 0.42;
}
/// <summary>The saveable/loadable composition.</summary>
public class BuildDefinition
{
public string Name { get; set; } = "";
public string Character { get; set; } = "";
public string? BodyFile { get; set; }
public double BodyOffsetX { get; set; }
public double BodyOffsetY { get; set; }
public double BodyScale { get; set; } = 1.0;
public string? HeadFile { get; set; }
public string? ExpressionFile { get; set; }
public string? HairmaskFile { get; set; }
public string? HairColor { get; set; }
public double HeadOffsetX { get; set; }
public double HeadOffsetY { get; set; } = -190;
public double HeadScale { get; set; } = 0.42;
public List<AccessoryLayer> Accessories { get; set; } = new();
}
@@ -0,0 +1,23 @@
{
"runtimeTarget": {
"name": ".NETCoreApp,Version=v8.0",
"signature": ""
},
"compilationOptions": {},
"targets": {
".NETCoreApp,Version=v8.0": {
"Character_Builder/1.0.0": {
"runtime": {
"Character_Builder.dll": {}
}
}
}
},
"libraries": {
"Character_Builder/1.0.0": {
"type": "project",
"serviceable": false,
"sha512": ""
}
}
}
@@ -0,0 +1,18 @@
{
"runtimeOptions": {
"tfm": "net8.0",
"frameworks": [
{
"name": "Microsoft.NETCore.App",
"version": "8.0.0"
},
{
"name": "Microsoft.WindowsDesktop.App",
"version": "8.0.0"
}
],
"configProperties": {
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": true
}
}
}
@@ -0,0 +1,69 @@
{
"format": 1,
"restore": {
"D:\\Work_AI\\Dansori\\Characters_Build_Docs\\Character_Builder\\Character_Builder.csproj": {}
},
"projects": {
"D:\\Work_AI\\Dansori\\Characters_Build_Docs\\Character_Builder\\Character_Builder.csproj": {
"version": "1.0.0",
"restore": {
"projectUniqueName": "D:\\Work_AI\\Dansori\\Characters_Build_Docs\\Character_Builder\\Character_Builder.csproj",
"projectName": "Character_Builder",
"projectPath": "D:\\Work_AI\\Dansori\\Characters_Build_Docs\\Character_Builder\\Character_Builder.csproj",
"packagesPath": "C:\\Users\\eKeerar\\.nuget\\packages\\",
"outputPath": "D:\\Work_AI\\Dansori\\Characters_Build_Docs\\Character_Builder\\obj\\",
"projectStyle": "PackageReference",
"configFilePaths": [
"C:\\Users\\eKeerar\\AppData\\Roaming\\NuGet\\NuGet.Config"
],
"originalTargetFrameworks": [
"net8.0-windows"
],
"sources": {
"https://api.nuget.org/v3/index.json": {}
},
"frameworks": {
"net8.0-windows7.0": {
"targetAlias": "net8.0-windows",
"projectReferences": {}
}
},
"warningProperties": {
"warnAsError": [
"NU1605"
]
},
"restoreAuditProperties": {
"enableAudit": "true",
"auditLevel": "low",
"auditMode": "direct"
}
},
"frameworks": {
"net8.0-windows7.0": {
"targetAlias": "net8.0-windows",
"imports": [
"net461",
"net462",
"net47",
"net471",
"net472",
"net48",
"net481"
],
"assetTargetFallback": true,
"warn": true,
"frameworkReferences": {
"Microsoft.NETCore.App": {
"privateAssets": "all"
},
"Microsoft.WindowsDesktop.App.WPF": {
"privateAssets": "none"
}
},
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\8.0.422/PortableRuntimeIdentifierGraph.json"
}
}
}
}
}
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<RestoreSuccess Condition=" '$(RestoreSuccess)' == '' ">True</RestoreSuccess>
<RestoreTool Condition=" '$(RestoreTool)' == '' ">NuGet</RestoreTool>
<ProjectAssetsFile Condition=" '$(ProjectAssetsFile)' == '' ">$(MSBuildThisFileDirectory)project.assets.json</ProjectAssetsFile>
<NuGetPackageRoot Condition=" '$(NuGetPackageRoot)' == '' ">$(UserProfile)\.nuget\packages\</NuGetPackageRoot>
<NuGetPackageFolders Condition=" '$(NuGetPackageFolders)' == '' ">C:\Users\eKeerar\.nuget\packages\</NuGetPackageFolders>
<NuGetProjectStyle Condition=" '$(NuGetProjectStyle)' == '' ">PackageReference</NuGetProjectStyle>
<NuGetToolVersion Condition=" '$(NuGetToolVersion)' == '' ">6.11.2</NuGetToolVersion>
</PropertyGroup>
<ItemGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<SourceRoot Include="C:\Users\eKeerar\.nuget\packages\" />
</ItemGroup>
</Project>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" />
@@ -0,0 +1,4 @@
// <autogenerated />
using System;
using System.Reflection;
[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v8.0", FrameworkDisplayName = ".NET 8.0")]
Binary file not shown.
@@ -0,0 +1,82 @@
#pragma checksum "..\..\..\App.xaml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "93E40EC7B36C3FCE2164127217EDE90B9E9D0788"
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.Diagnostics;
using System.Windows;
using System.Windows.Automation;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Controls.Ribbon;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Ink;
using System.Windows.Input;
using System.Windows.Markup;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Effects;
using System.Windows.Media.Imaging;
using System.Windows.Media.Media3D;
using System.Windows.Media.TextFormatting;
using System.Windows.Navigation;
using System.Windows.Shapes;
using System.Windows.Shell;
namespace Character_Builder {
/// <summary>
/// App
/// </summary>
public partial class App : System.Windows.Application {
private bool _contentLoaded;
/// <summary>
/// InitializeComponent
/// </summary>
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "8.0.28.0")]
public void InitializeComponent() {
if (_contentLoaded) {
return;
}
_contentLoaded = true;
#line 4 "..\..\..\App.xaml"
this.StartupUri = new System.Uri("MainWindow.xaml", System.UriKind.Relative);
#line default
#line hidden
System.Uri resourceLocater = new System.Uri("/Character_Builder;component/app.xaml", System.UriKind.Relative);
#line 1 "..\..\..\App.xaml"
System.Windows.Application.LoadComponent(this, resourceLocater);
#line default
#line hidden
}
/// <summary>
/// Application Entry Point.
/// </summary>
[System.STAThreadAttribute()]
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "8.0.28.0")]
public static void Main() {
Character_Builder.App app = new Character_Builder.App();
app.InitializeComponent();
app.Run();
}
}
}
@@ -0,0 +1,24 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Character_Builder")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0")]
[assembly: System.Reflection.AssemblyProductAttribute("Character_Builder")]
[assembly: System.Reflection.AssemblyTitleAttribute("Character_Builder")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
[assembly: System.Runtime.Versioning.TargetPlatformAttribute("Windows7.0")]
[assembly: System.Runtime.Versioning.SupportedOSPlatformAttribute("Windows7.0")]
// MSBuild WriteCodeFragment 클래스에서 생성되었습니다.
@@ -0,0 +1 @@
1c258de41a7d7aa346051148913e5c061ad06ddde6059e732bbb9876cdf09ec2
@@ -0,0 +1,13 @@
is_global = true
build_property.TargetFramework = net8.0-windows
build_property.TargetPlatformMinVersion = 7.0
build_property.UsingMicrosoftNETSdkWeb =
build_property.ProjectTypeGuids =
build_property.InvariantGlobalization =
build_property.PlatformNeutralAssembly =
build_property.EnforceExtendedAnalyzerRules =
build_property._SupportedPlatformList = Linux,macOS,Windows
build_property.RootNamespace = Character_Builder
build_property.ProjectDir = D:\Work_AI\Dansori\Characters_Build_Docs\Character_Builder\
build_property.EnableComHosting =
build_property.EnableGeneratedComInterfaceComImportInterop =
@@ -0,0 +1,6 @@
// <auto-generated/>
global using global::System;
global using global::System.Collections.Generic;
global using global::System.Linq;
global using global::System.Threading;
global using global::System.Threading.Tasks;
@@ -0,0 +1 @@
07a0985beef6e5eb47d3a46d05ca778b8054347852e2b1cae45c9a00e86007f5
@@ -0,0 +1,40 @@
D:\개인자료\Work_AI\DansoriEQ\Characters_Build_Docs\Character_Builder\obj\Debug\net8.0-windows\MainWindow.baml
D:\개인자료\Work_AI\DansoriEQ\Characters_Build_Docs\Character_Builder\obj\Debug\net8.0-windows\App.baml
D:\개인자료\Work_AI\DansoriEQ\Characters_Build_Docs\Character_Builder\obj\Debug\net8.0-windows\MainWindow.g.cs
D:\개인자료\Work_AI\DansoriEQ\Characters_Build_Docs\Character_Builder\obj\Debug\net8.0-windows\App.g.cs
D:\개인자료\Work_AI\DansoriEQ\Characters_Build_Docs\Character_Builder\obj\Debug\net8.0-windows\Character_Builder_MarkupCompile.cache
D:\개인자료\Work_AI\DansoriEQ\Characters_Build_Docs\Character_Builder\obj\Debug\net8.0-windows\Character_Builder.g.resources
D:\개인자료\Work_AI\DansoriEQ\Characters_Build_Docs\Character_Builder\obj\Debug\net8.0-windows\Character_Builder.GeneratedMSBuildEditorConfig.editorconfig
D:\개인자료\Work_AI\DansoriEQ\Characters_Build_Docs\Character_Builder\obj\Debug\net8.0-windows\Character_Builder.AssemblyInfoInputs.cache
D:\개인자료\Work_AI\DansoriEQ\Characters_Build_Docs\Character_Builder\obj\Debug\net8.0-windows\Character_Builder.AssemblyInfo.cs
D:\개인자료\Work_AI\DansoriEQ\Characters_Build_Docs\Character_Builder\obj\Debug\net8.0-windows\Character_Builder.csproj.CoreCompileInputs.cache
D:\개인자료\Work_AI\DansoriEQ\Characters_Build_Docs\Character_Builder\bin\Debug\net8.0-windows\Character_Builder.exe
D:\개인자료\Work_AI\DansoriEQ\Characters_Build_Docs\Character_Builder\bin\Debug\net8.0-windows\Character_Builder.deps.json
D:\개인자료\Work_AI\DansoriEQ\Characters_Build_Docs\Character_Builder\bin\Debug\net8.0-windows\Character_Builder.runtimeconfig.json
D:\개인자료\Work_AI\DansoriEQ\Characters_Build_Docs\Character_Builder\bin\Debug\net8.0-windows\Character_Builder.dll
D:\개인자료\Work_AI\DansoriEQ\Characters_Build_Docs\Character_Builder\bin\Debug\net8.0-windows\Character_Builder.pdb
D:\개인자료\Work_AI\DansoriEQ\Characters_Build_Docs\Character_Builder\obj\Debug\net8.0-windows\Character_Builder.dll
D:\개인자료\Work_AI\DansoriEQ\Characters_Build_Docs\Character_Builder\obj\Debug\net8.0-windows\refint\Character_Builder.dll
D:\개인자료\Work_AI\DansoriEQ\Characters_Build_Docs\Character_Builder\obj\Debug\net8.0-windows\Character_Builder.pdb
D:\개인자료\Work_AI\DansoriEQ\Characters_Build_Docs\Character_Builder\obj\Debug\net8.0-windows\Character_Builder.genruntimeconfig.cache
D:\개인자료\Work_AI\DansoriEQ\Characters_Build_Docs\Character_Builder\obj\Debug\net8.0-windows\ref\Character_Builder.dll
D:\Work_AI\Dansori\Characters_Build_Docs\Character_Builder\bin\Debug\net8.0-windows\Character_Builder.exe
D:\Work_AI\Dansori\Characters_Build_Docs\Character_Builder\bin\Debug\net8.0-windows\Character_Builder.deps.json
D:\Work_AI\Dansori\Characters_Build_Docs\Character_Builder\bin\Debug\net8.0-windows\Character_Builder.runtimeconfig.json
D:\Work_AI\Dansori\Characters_Build_Docs\Character_Builder\bin\Debug\net8.0-windows\Character_Builder.dll
D:\Work_AI\Dansori\Characters_Build_Docs\Character_Builder\bin\Debug\net8.0-windows\Character_Builder.pdb
D:\Work_AI\Dansori\Characters_Build_Docs\Character_Builder\obj\Debug\net8.0-windows\MainWindow.baml
D:\Work_AI\Dansori\Characters_Build_Docs\Character_Builder\obj\Debug\net8.0-windows\App.baml
D:\Work_AI\Dansori\Characters_Build_Docs\Character_Builder\obj\Debug\net8.0-windows\MainWindow.g.cs
D:\Work_AI\Dansori\Characters_Build_Docs\Character_Builder\obj\Debug\net8.0-windows\App.g.cs
D:\Work_AI\Dansori\Characters_Build_Docs\Character_Builder\obj\Debug\net8.0-windows\Character_Builder_MarkupCompile.cache
D:\Work_AI\Dansori\Characters_Build_Docs\Character_Builder\obj\Debug\net8.0-windows\Character_Builder.g.resources
D:\Work_AI\Dansori\Characters_Build_Docs\Character_Builder\obj\Debug\net8.0-windows\Character_Builder.GeneratedMSBuildEditorConfig.editorconfig
D:\Work_AI\Dansori\Characters_Build_Docs\Character_Builder\obj\Debug\net8.0-windows\Character_Builder.AssemblyInfoInputs.cache
D:\Work_AI\Dansori\Characters_Build_Docs\Character_Builder\obj\Debug\net8.0-windows\Character_Builder.AssemblyInfo.cs
D:\Work_AI\Dansori\Characters_Build_Docs\Character_Builder\obj\Debug\net8.0-windows\Character_Builder.csproj.CoreCompileInputs.cache
D:\Work_AI\Dansori\Characters_Build_Docs\Character_Builder\obj\Debug\net8.0-windows\Character_Builder.dll
D:\Work_AI\Dansori\Characters_Build_Docs\Character_Builder\obj\Debug\net8.0-windows\refint\Character_Builder.dll
D:\Work_AI\Dansori\Characters_Build_Docs\Character_Builder\obj\Debug\net8.0-windows\Character_Builder.pdb
D:\Work_AI\Dansori\Characters_Build_Docs\Character_Builder\obj\Debug\net8.0-windows\Character_Builder.genruntimeconfig.cache
D:\Work_AI\Dansori\Characters_Build_Docs\Character_Builder\obj\Debug\net8.0-windows\ref\Character_Builder.dll
@@ -0,0 +1 @@
e0da6d5848fb3c8f7177291c1a3794bcd05e16ca5e19ea615d9ef22a5fe104df
@@ -0,0 +1,20 @@
Character_Builder
winexe
C#
.cs
D:\Work_AI\Dansori\Characters_Build_Docs\Character_Builder\obj\Debug\net8.0-windows\
Character_Builder
none
false
TRACE;DEBUG;NET;NET8_0;NETCOREAPP
D:\Work_AI\Dansori\Characters_Build_Docs\Character_Builder\App.xaml
11407045341
827567737
1981026658812
MainWindow.xaml;
False
@@ -0,0 +1,463 @@
#pragma checksum "..\..\..\MainWindow.xaml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "8A60234C00FE3CBC6D4956AD0F7CF945C604F6A6"
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.Diagnostics;
using System.Windows;
using System.Windows.Automation;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Controls.Ribbon;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Ink;
using System.Windows.Input;
using System.Windows.Markup;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Effects;
using System.Windows.Media.Imaging;
using System.Windows.Media.Media3D;
using System.Windows.Media.TextFormatting;
using System.Windows.Navigation;
using System.Windows.Shapes;
using System.Windows.Shell;
namespace Character_Builder {
/// <summary>
/// MainWindow
/// </summary>
public partial class MainWindow : System.Windows.Window, System.Windows.Markup.IComponentConnector {
#line 16 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border RootBorder;
#line default
#line hidden
#line 35 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Button BtnRoot;
#line default
#line hidden
#line 36 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Button BtnLoad;
#line default
#line hidden
#line 37 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Button BtnReset;
#line default
#line hidden
#line 38 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Button BtnSave;
#line default
#line hidden
#line 40 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Button BtnMin;
#line default
#line hidden
#line 43 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Button BtnMax;
#line default
#line hidden
#line 46 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Button BtnClose;
#line default
#line hidden
#line 64 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.ListBox CharList;
#line default
#line hidden
#line 112 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock TxtCharName;
#line default
#line hidden
#line 114 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Button BtnPlay;
#line default
#line hidden
#line 119 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock PreviewHint;
#line default
#line hidden
#line 123 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Grid StageHost;
#line default
#line hidden
#line 128 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Grid Stage;
#line default
#line hidden
#line 143 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.ComboBox CmbBody;
#line default
#line hidden
#line 146 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.ComboBox CmbShape;
#line default
#line hidden
#line 149 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.ComboBox CmbExpr;
#line default
#line hidden
#line 152 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.WrapPanel SwatchPanel;
#line default
#line hidden
#line 158 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.StackPanel AccPanel;
#line default
#line hidden
#line 163 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.ComboBox CmbLayer;
#line default
#line hidden
#line 175 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Slider SldX;
#line default
#line hidden
#line 177 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Slider SldY;
#line default
#line hidden
#line 179 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Slider SldScale;
#line default
#line hidden
#line 190 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock TxtStatus;
#line default
#line hidden
private bool _contentLoaded;
/// <summary>
/// InitializeComponent
/// </summary>
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "8.0.28.0")]
public void InitializeComponent() {
if (_contentLoaded) {
return;
}
_contentLoaded = true;
System.Uri resourceLocater = new System.Uri("/Character_Builder;component/mainwindow.xaml", System.UriKind.Relative);
#line 1 "..\..\..\MainWindow.xaml"
System.Windows.Application.LoadComponent(this, resourceLocater);
#line default
#line hidden
}
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "8.0.28.0")]
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Never)]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Design", "CA1033:InterfaceMethodsShouldBeCallableByChildTypes")]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Maintainability", "CA1502:AvoidExcessiveComplexity")]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1800:DoNotCastUnnecessarily")]
void System.Windows.Markup.IComponentConnector.Connect(int connectionId, object target) {
switch (connectionId)
{
case 1:
this.RootBorder = ((System.Windows.Controls.Border)(target));
return;
case 2:
this.BtnRoot = ((System.Windows.Controls.Button)(target));
#line 35 "..\..\..\MainWindow.xaml"
this.BtnRoot.Click += new System.Windows.RoutedEventHandler(this.Root_Click);
#line default
#line hidden
return;
case 3:
this.BtnLoad = ((System.Windows.Controls.Button)(target));
#line 36 "..\..\..\MainWindow.xaml"
this.BtnLoad.Click += new System.Windows.RoutedEventHandler(this.Load_Click);
#line default
#line hidden
return;
case 4:
this.BtnReset = ((System.Windows.Controls.Button)(target));
#line 37 "..\..\..\MainWindow.xaml"
this.BtnReset.Click += new System.Windows.RoutedEventHandler(this.Reset_Click);
#line default
#line hidden
return;
case 5:
this.BtnSave = ((System.Windows.Controls.Button)(target));
#line 38 "..\..\..\MainWindow.xaml"
this.BtnSave.Click += new System.Windows.RoutedEventHandler(this.Save_Click);
#line default
#line hidden
return;
case 6:
this.BtnMin = ((System.Windows.Controls.Button)(target));
#line 40 "..\..\..\MainWindow.xaml"
this.BtnMin.Click += new System.Windows.RoutedEventHandler(this.Min_Click);
#line default
#line hidden
return;
case 7:
this.BtnMax = ((System.Windows.Controls.Button)(target));
#line 43 "..\..\..\MainWindow.xaml"
this.BtnMax.Click += new System.Windows.RoutedEventHandler(this.Max_Click);
#line default
#line hidden
return;
case 8:
this.BtnClose = ((System.Windows.Controls.Button)(target));
#line 46 "..\..\..\MainWindow.xaml"
this.BtnClose.Click += new System.Windows.RoutedEventHandler(this.Close_Click);
#line default
#line hidden
return;
case 9:
this.CharList = ((System.Windows.Controls.ListBox)(target));
#line 66 "..\..\..\MainWindow.xaml"
this.CharList.SelectionChanged += new System.Windows.Controls.SelectionChangedEventHandler(this.CharList_SelectionChanged);
#line default
#line hidden
return;
case 10:
this.TxtCharName = ((System.Windows.Controls.TextBlock)(target));
return;
case 11:
this.BtnPlay = ((System.Windows.Controls.Button)(target));
#line 114 "..\..\..\MainWindow.xaml"
this.BtnPlay.Click += new System.Windows.RoutedEventHandler(this.Play_Click);
#line default
#line hidden
return;
case 12:
this.PreviewHint = ((System.Windows.Controls.TextBlock)(target));
return;
case 13:
this.StageHost = ((System.Windows.Controls.Grid)(target));
#line 125 "..\..\..\MainWindow.xaml"
this.StageHost.MouseLeftButtonDown += new System.Windows.Input.MouseButtonEventHandler(this.Stage_MouseDown);
#line default
#line hidden
#line 126 "..\..\..\MainWindow.xaml"
this.StageHost.MouseMove += new System.Windows.Input.MouseEventHandler(this.Stage_MouseMove);
#line default
#line hidden
#line 127 "..\..\..\MainWindow.xaml"
this.StageHost.MouseLeftButtonUp += new System.Windows.Input.MouseButtonEventHandler(this.Stage_MouseUp);
#line default
#line hidden
return;
case 14:
this.Stage = ((System.Windows.Controls.Grid)(target));
return;
case 15:
this.CmbBody = ((System.Windows.Controls.ComboBox)(target));
#line 143 "..\..\..\MainWindow.xaml"
this.CmbBody.SelectionChanged += new System.Windows.Controls.SelectionChangedEventHandler(this.Body_Changed);
#line default
#line hidden
return;
case 16:
this.CmbShape = ((System.Windows.Controls.ComboBox)(target));
#line 146 "..\..\..\MainWindow.xaml"
this.CmbShape.SelectionChanged += new System.Windows.Controls.SelectionChangedEventHandler(this.Shape_Changed);
#line default
#line hidden
return;
case 17:
this.CmbExpr = ((System.Windows.Controls.ComboBox)(target));
#line 149 "..\..\..\MainWindow.xaml"
this.CmbExpr.SelectionChanged += new System.Windows.Controls.SelectionChangedEventHandler(this.Expr_Changed);
#line default
#line hidden
return;
case 18:
this.SwatchPanel = ((System.Windows.Controls.WrapPanel)(target));
return;
case 19:
this.AccPanel = ((System.Windows.Controls.StackPanel)(target));
return;
case 20:
this.CmbLayer = ((System.Windows.Controls.ComboBox)(target));
#line 163 "..\..\..\MainWindow.xaml"
this.CmbLayer.SelectionChanged += new System.Windows.Controls.SelectionChangedEventHandler(this.Layer_Changed);
#line default
#line hidden
return;
case 21:
this.SldX = ((System.Windows.Controls.Slider)(target));
#line 175 "..\..\..\MainWindow.xaml"
this.SldX.ValueChanged += new System.Windows.RoutedPropertyChangedEventHandler<double>(this.LayerXform_Changed);
#line default
#line hidden
return;
case 22:
this.SldY = ((System.Windows.Controls.Slider)(target));
#line 177 "..\..\..\MainWindow.xaml"
this.SldY.ValueChanged += new System.Windows.RoutedPropertyChangedEventHandler<double>(this.LayerXform_Changed);
#line default
#line hidden
return;
case 23:
this.SldScale = ((System.Windows.Controls.Slider)(target));
#line 179 "..\..\..\MainWindow.xaml"
this.SldScale.ValueChanged += new System.Windows.RoutedPropertyChangedEventHandler<double>(this.LayerXform_Changed);
#line default
#line hidden
return;
case 24:
#line 181 "..\..\..\MainWindow.xaml"
((System.Windows.Controls.Button)(target)).Click += new System.Windows.RoutedEventHandler(this.AutoAlign_Click);
#line default
#line hidden
return;
case 25:
this.TxtStatus = ((System.Windows.Controls.TextBlock)(target));
return;
}
this._contentLoaded = true;
}
}
}
+74
View File
@@ -0,0 +1,74 @@
{
"version": 3,
"targets": {
"net8.0-windows7.0": {}
},
"libraries": {},
"projectFileDependencyGroups": {
"net8.0-windows7.0": []
},
"packageFolders": {
"C:\\Users\\eKeerar\\.nuget\\packages\\": {}
},
"project": {
"version": "1.0.0",
"restore": {
"projectUniqueName": "D:\\Work_AI\\Dansori\\Characters_Build_Docs\\Character_Builder\\Character_Builder.csproj",
"projectName": "Character_Builder",
"projectPath": "D:\\Work_AI\\Dansori\\Characters_Build_Docs\\Character_Builder\\Character_Builder.csproj",
"packagesPath": "C:\\Users\\eKeerar\\.nuget\\packages\\",
"outputPath": "D:\\Work_AI\\Dansori\\Characters_Build_Docs\\Character_Builder\\obj\\",
"projectStyle": "PackageReference",
"configFilePaths": [
"C:\\Users\\eKeerar\\AppData\\Roaming\\NuGet\\NuGet.Config"
],
"originalTargetFrameworks": [
"net8.0-windows"
],
"sources": {
"https://api.nuget.org/v3/index.json": {}
},
"frameworks": {
"net8.0-windows7.0": {
"targetAlias": "net8.0-windows",
"projectReferences": {}
}
},
"warningProperties": {
"warnAsError": [
"NU1605"
]
},
"restoreAuditProperties": {
"enableAudit": "true",
"auditLevel": "low",
"auditMode": "direct"
}
},
"frameworks": {
"net8.0-windows7.0": {
"targetAlias": "net8.0-windows",
"imports": [
"net461",
"net462",
"net47",
"net471",
"net472",
"net48",
"net481"
],
"assetTargetFallback": true,
"warn": true,
"frameworkReferences": {
"Microsoft.NETCore.App": {
"privateAssets": "all"
},
"Microsoft.WindowsDesktop.App.WPF": {
"privateAssets": "none"
}
},
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\8.0.422/PortableRuntimeIdentifierGraph.json"
}
}
}
}
@@ -0,0 +1,8 @@
{
"version": 2,
"dgSpecHash": "zLTNiLqmafM=",
"success": true,
"projectFilePath": "D:\\Work_AI\\Dansori\\Characters_Build_Docs\\Character_Builder\\Character_Builder.csproj",
"expectedPackageFiles": [],
"logs": []
}
+87
View File
@@ -0,0 +1,87 @@
> ⚠️ **아카이브** (소스 자산 생성 시기 기록, 이동된 소스 폴더 참조 포함). **현재 베이스 = `INTERACTIVE_RIG_HANDOFF.md` + `*_Profile/` + `README.md`.**
# HANDOFF — Dansori 캐릭터 자산 & Character Builder (세션 인수인계)
> 작성: 2026-07-02 (같은 날 저녁 갱신). 다른 세션에서 이어서 진행하기 위한 요약.
> **최신**: NewImage 88장을 각 `Images/`로 **이동 완료** · `Characters`/`Characters.Old` **삭제 예정** · 이미지 생성 계속 후 **내일(2026-07-03) 이어서**.
> 이 문서 = **캐릭터 자산 시스템(`Characters_Build_Docs`) + 조합 앱(`Character_Builder`)** 진행/계획.
> DansoriEQ **본체 앱 UI** 작업(메인화면 캐릭터 배치·전 창 네온·부유 동기화 등)은 `../docs/HANDOFF.md §8` 참조.
---
## 0. 한 줄 요약
Dansori 브랜드 마스코트(이소리·이단·듀오)를 **페이퍼돌 조립 방식**으로 관리하는 자산 체계와,
이를 **직접 보고 조합/저장(.md)** 하는 WPF 앱을 구축했다. **이미지 대량 생성은 진행 중**이며,
빌더 테스트는 **이미지가 충분히 생성된 뒤** 진행 예정.
## 1. 자산 시스템 구조 (`Characters_Build_Docs/`)
```
README.md # 브랜드 자산 규칙 + "새 캐릭터 추가 Playbook"(STEP 1~7) ← 단일 진실원
HANDOFF.md # (이 문서)
LeeSori/ # 이소리(민트/여)
LeeSori.md # 토대(정체성·조립규칙·네이밍·§8 새 베리에이션 절차·23 표준 바디 목록)
USAGE_FOR_DEV.md # 앱 통합 가이드(캐릭터 공통) — 조립/앵커/포터블 규칙
Reference/sori_sheet.png
Base/Base.md (+Images/) # 트랙슈트 헤드리스 바디: 파츠5 + 제스처18 = 23블록
Hair/Hair.md (+Images/) # 4모양(short/long/waveS/waveL) 개별블록 88 (머리+표정20+hairmask ×4)
Hair/Hair_{Long,Short,WaveS,WaveL}Neat.md # 단정·윤기 추가본 각 22 → 헤어 토큰 총 8종(원본4 + neat4)
Accessories/Accessories.md (+Images/) # 오버레이 11(착용7+소품4)
Variations/{DressShort,DressLong,Jeans,Tshirt,CeoPantsuit}/*.md (+Images/) # 각 23블록
LeeDan/ # 이단(시안/남) — LeeSori와 동일 구조
LeeDan.md · Reference/dan_sheet.png
Base/Base.md(23) · Hair/Hair.md(88: short/buzz/messy/slick ×22) · Accessories(3: headphones/smartwatch/cap)
Variations/{Suit,Casual,Club}/*.md(각 23)
LeeSori_and_LeeDan/ # 듀오(베이크드 장면, 리그 아님)
Duo.md · Reference/{combined_master_sheet,sori_sheet,dan_sheet}.png
Scenes/Scenes.md(+Images/) # 7장: backtoback·sidebyside·fistbump·present·wave·success·earbud_share
Character_Builder/ # WPF 조합 앱 (아래 §4)
```
- 구버전 레이어식 요청서는 `../docs/IMAGE_GEN_*`(및 `*.old`)에 **보존**(다른 세션 작업중, 건드리지 않음). 이 폴더는 그 내용을 캐릭터 중심으로 재정리한 독립본.
## 2. 페이퍼돌 조립 규칙 (모든 캐릭터 공통) — 상세 `README.md`
- 완성 캐릭터 = **헤드리스 바디(복장)** + **머리(헤어모양)** + **표정 프레임** + **악세서리(오버레이)** + **색상(코드 틴트)**.
- **정합 앵커**: 목(바디↔머리)·어깨·정수리/귀·목선·손목·발.
- **동작은 코드**(끄덕/갸웃/호흡/blink/talk = 트랜스폼+프레임 교체) — 이미지 아님.
- **색상은 코드**(hairmask로 hue-shift) — 색 변형 이미지 생성 안 함.
- 생성용 md = **영어 프롬프트 위주**, `Reference/<char>_sheet.png` 첨부 명시, 결과는 **각 md 옆 `Images/`**에 저장.
## 3. 이미지 생성 현황 (갱신 2026-07-02 저녁)
- **목표 총량 ≈ 516장**(md 이미지 블록 기준): 이소리 ~326 · 이단 183 · 듀오 7.
- **완료·배치됨: 88장(이소리)** — `Characters/NewImage`에서 각 `Images/`**이동 완료**(미일치 0):
- `LeeSori/Base/Images` **23**(트랙 바디 전부) · `LeeSori/Accessories/Images` **11**(전부) · `LeeSori/Hair/Images` **54**(short·long 완성 + waveS/waveL 일부)
- `Characters/NewImage` **잔여 0**.
- **미생성 ≈430**: 이소리 헤어 나머지(waveS/waveL 잔여 + **neat 4종 88**) + 복장변형 116 · **이단 183 전부** · **듀오 7**.
- **앞으로**: 생성물은 임시폴더 말고 **각 md 옆 `Images/`에 바로 저장**(분류 불필요). 순서 = 각 캐릭터 `_RUN_ORDER.md`.
## 4. Character Builder (WPF 앱) — `Character_Builder/`
- **상태**: **빌드 성공(오류0)**. `Character_Builder/bin/Debug/net8.0-windows/Character_Builder.exe`.
- 빌드: `dotnet build Characters_Build_Docs/Character_Builder/Character_Builder.csproj` (외부 패키지 없음, 오프라인 OK).
- **기능**: 캐릭터 리스트(시트 썸네일) → 선택 → 파츠 조합(바디/헤어모양/표정/헤어색/악세서리) →
**미리보기 합성****.md 저장(파일명 지정)/불러오기** → **제스처 미리보기**(포즈순환+깜빡임+부유).
- **자동 정렬**: `AlphaTools.cs` 가 알파(불투명) 분석으로 목 위치 검출 → 머리를 바디 목에 스냅 + 비율 스케일.
캐릭터/바디/헤어 변경 시 자동, [🎯 머리 자동 정렬] 버튼으로 재실행, 슬라이더로 미세조정.
- **.md(빌드파일) = Dansori 앱 개발용**: 좌표 규약(stage 520×680, 각 레이어 Height=680 Uniform·중심·center scale·(x,y)px)을
.md 헤더에 명시. 앱은 트랜스폼 직접 사용 또는 동일 정렬 알고리즘 재사용(`AlphaTools` 재활용).
- **테스트 보류**: 사용자가 **이미지가 충분히 생성된 뒤 테스트** 예정(내일).
- 현재 `LeeSori/{Base,Accessories,Hair}/Images`에 88장 배치됨 → **이소리 + short/long 헤어 + 트랙 바디 + 악세서리** 조합은 지금도 미리보기 가능.
## 5. DansoriEQ 본체 앱 (요약 — 상세 `../docs/HANDOFF.md §8`)
- 메인화면 EQ 그래프 좌측 배경에 **이소리(단독)** 크게 배치, 그래프 좌측이동/축소, 창 1600×980.
- 배경 이소리 **부유/말하기**를 아바타와 동기(`MainWindow.SetBgMascotTalking`).
- **전 팝업 창 모던 타이틀(FluentWindow+ui:TitleBar) + 파란 네온 프레임**, MainWindow 네온.
- 다음 코드작업: 배경 이소리 **통짜→헤드리스 리그 교체**(파츠 합성+표정프레임+고개회전+hairmask 틴트+악세 오버레이).
## 6. 앞으로 할 일 (우선순위)
1. **이미지 생성 계속** (사용자, 외부 이미지 AI): 각 `_RUN_ORDER.md` 순서로 **이소리 나머지 → 이단 전체 → 듀오**. 결과는 **각 md 옆 `Images/`에 바로 저장**(분류 불필요).
2.**NewImage → 각 `Images/` 분류 완료** (88장 이동, 미일치 0).
3. **구 자료 삭제**(사용자 직접, 예정): `../Characters`, `../Characters.Old`.
- 안전성 확인됨: 앱은 자체 `src/DansoriEQ.App/Assets/Characters`(13장) 사용, 시트는 Build_Docs에 백업.
- 유일본 손실 주의: Characters.Old의 구 파츠·흰배경 백업·추가포즈(새 파이프라인이 대체 예정).
4. **Character Builder 테스트**(이미지 후) → 자동정렬 정확도 튜닝(머리 스케일 비율 현재 0.32, 목밴드 6%),
필요 시 **드래그 이동**·색상 커스텀 추가.
5. **앱 통합**: `LeeSori/USAGE_FOR_DEV.md` 규칙대로 DansoriEQ(및 향후 Dansori 앱)에 리그 통합.
6. **폴더 이동**: `Characters_Build_Docs`를 Dansori 브랜드 루트로 이동(포터블 규칙 — 절대경로 없음, 통째 이동 가능).
## 7. 유지할 결정/제약
- 색상=코드(이미지X) · 절대경로 하드코딩 금지 · 헤드리스 바디+목 앵커 · 듀오=베이크드 장면 ·
생성 md=영어 프롬프트+시트 첨부 · 빌드 .md=앱 개발용(좌표규약 명시) · 모든 신규 이소리 베리에이션은 기본 리깅 상속.
+47
View File
@@ -0,0 +1,47 @@
> ⚠️ **아카이브** (소스 이미지 생성 기록). 하루카는 완성됨 — 현재 베이스 = `INTERACTIVE_RIG_HANDOFF.md` + `Haruka_Profile/`.
# Haruka Image Generation Progress Handoff
Updated: 2026-07-03 16:32 KST
## Scope
- This session handled **Haruka only**.
- `Isabel/` was intentionally excluded because it is being processed in another parallel session.
## Result
- Haruka `_RUN_ORDER.md` target set is complete.
- Total Haruka targets: **100**
- Verified OK: **100**
- Missing: **0**
- Bad alpha / bad format: **0**
## Completed Groups
- `Haruka/Reference`: 1 / 1
- `Haruka/Hair/Images`: 22 / 22
- `Haruka/Base/Images`: 23 / 23
- `Haruka/Accessories/Images`: 7 / 7
- `Haruka/Variations/Idol/Images`: 23 / 23
- `Haruka/Variations/Witch/Images`: 24 / 24
## Alpha Verification
All Haruka target PNGs were checked with `System.Drawing.Bitmap`.
Required condition:
- `PixelFormat == Format32bppArgb`
- Four corner pixels have `alpha == 0`
Verification summary:
```text
Haruka target total=100 ok=100 missing=0 bad=0
Haruka\Accessories\Images: total=7 ok=7 missing=0 bad=0
Haruka\Base\Images: total=23 ok=23 missing=0 bad=0
Haruka\Hair\Images: total=22 ok=22 missing=0 bad=0
Haruka\Reference: total=1 ok=1 missing=0 bad=0
Haruka\Variations\Idol\Images: total=23 ok=23 missing=0 bad=0
Haruka\Variations\Witch\Images: total=24 ok=24 missing=0 bad=0
```
## Notes
- Generated sources were produced with a flat chroma-key background, then converted locally with the installed `remove_chroma_key.py` helper.
- Existing images were not AI-edited for transparency; alpha was produced by local pixel-based chroma-key removal.
- The final assets are saved beside their source md files under each `Images/` folder, following `_RUN_ORDER.md`.
+50
View File
@@ -0,0 +1,50 @@
# 확정 결정 로그 (Decisions)
> 논의를 통해 확정된 결정과 근거. 뒤집을 땐 여기에 사유와 함께 갱신.
## D1. 표현 방식 = 하이브리드 (확정)
리그 + 베이크드 포즈 + 표정 프레임 스왑을 상황별로 조합.
- **근거**: 리그는 앰비언트/열린 제스처에 강하고, 팔짱·하트 같은 자기-가림 포즈는 베이크드 이미지가 자연스럽다. 각자의 강점만 사용.
## D2. 구현 레벨 = 코드 네이티브 경량 리그 (확정, Live2D/Spine 배제)
- **근거**: Live2D/Spine의 리깅은 **독점 GUI 에디터에서 사람이** 하는 작업 → **AI 자동화 목적과 상충**. 우리 코드로 리그/모션/반응을 소유하면 데이터(JSON)만으로 자동화·반복이 가능.
## D3. 분절 = 완전 해부학 16파츠 (확정)
head·neck·chest·pelvis + (상완·전완·손)×2 + (허벅지·종아리·발)×2.
- **근거**: 팔꿈치·무릎·손목·목·허리가 실제로 접혀야 제스처/춤이 자연스럽다.
## D4. 얼굴 = 표정 프레임 스왑 (확정)
20종 표정 이미지 교체 + 말하기 = talk 프레임 순환(유사 립싱크).
- **한계 인지**: 눈+입이 세트로 고정 → "감정+정밀 립싱크 동시"는 불가. 필요 시 D7로 승급.
## D5. 자기-가림 포즈 = 베이크드 이미지 (확정)
팔짱(armscross)·하트(heart) 등은 리그 보간 대신 **통짜 포즈 이미지**로. (기존 표준 18제스처 자산 재사용.)
## D6. 투명 알파 필수 (확정)
모든 파츠/프레임 = 32-bit RGBA(`Format32bppArgb`), 배경 alpha=0. 24-bit·매트 배경 금지.
## D7. mesh-warp(그리드 변형) = 옵션·후속 (보류)
목/얼굴 국소 mesh-warp(WebGL)로 목 이음새·정밀 립싱크·중간 각도 고개돌림을 승급.
- **승급 조건**: 강체 리그로 목/얼굴이 실제로 부족할 때, 그 부위에만 국소 도입. 전신 적용 안 함.
## D8. 이미지 = ChatGPT 자동생성 (확정)
사람이 안 그림. 생성용 `.md` 스펙을 우리가 제공.
## D9. 색상·모션 = 코드/데이터 (확정)
색 변형 = hairmask hue-shift. 모션 = 리그 클립. 반응 = 시퀀서 데이터.
## D10. 프로필 구조 (확정)
`Haruka_Profile` 구조를 복제해 **`Haruka_Profile`** 로 운용(캐릭터별 자료 구조 표준). 시트 표준 위치 = `03_Assets/Reference/haruka_sheet.png`.
## D11. 리그 파츠 생성 = 마스터-슬라이스 우선, 개별생성 폴백/attachment (확정)
- 핵심 16파츠는 **마스터 1장 → 로컬 슬라이스**(같은 좌표계 → 관절 자동 정합, 접합 오차↓)가 **1순위**. 파츠 개별 생성은 그 **폴백**(같은 16파츠를 만드는 대체 방법 — 둘 다 만들 필요 없음).
- **슬라이스 출력 = 풀캔버스**: 각 파츠는 **크롭 없이 마스터와 동일한 520×900 캔버스에 제자리 배치**(그 외 투명). 16장 스택 시 마스터 복원 → 위치정보 보존, 앵커 튜닝 불필요. (타이트 크롭하면 위치정보가 사라져 정합이 깨짐.)
- **단 마스터에 없는 변형 파츠**(핑거하트·주먹·가리킴 등 대체 손 attachment)는 **개별 생성으로만** 가능 → 그 용도엔 개별 생성이 별도로 필요.
- **근거**: 슬라이스는 좌표 정합에 강함(반복 수정 원인 제거). 생성 AI는 픽셀 좌표를 못 맞추므로 접합 좌표는 **생성 후 이미지에서 측정**(정규화 앵커 `imgAnchor`)해 `rig.json`에 저장.
## 열린 결정 (미확정)
- **O1. 최종 런타임 호스트**: 프로토타입=웹(Canvas). 본체=WPF. WPF에 동일 리그/시퀀서를 이식(C#) 할지, 아니면 WebView2로 웹 런타임을 임베드할지 → `../08_Roadmap/App_Integration.md` 에서 결정 예정.
- **O2. 대사 표시**: 말풍선 캡션 vs TTS 음성 vs 둘 다.
@@ -0,0 +1,29 @@
# 목적과 방향 (Purpose & Direction)
## 최종 목적
하루카를 **앱에 탑재된 인터랙티브 마스코트**로 만든다. 사용자의 행동·앱 상태(상황)에 따라 캐릭터가 **적절한 제스처·표정·대사로 반응**해 살아있는 느낌을 준다.
## 대표 사용 시나리오 (상황 → 반응)
| 상황(트리거) | 반응 |
|---|---|
| 오류/금지된 동작 | 팔짱 끼고 인상 쓰며 고개 저으며 **"안돼요"** |
| 성공/완료/칭찬 | 손 하트 그리며 밝게 **"잘됐어요"** |
| 대기/유휴(배경) | 가볍게 **춤추는** 루프(앰비언트) |
| (확장) 인사 | 손 흔들며 "안녕하세요" |
| (확장) 안내/설명 | 한 손 제시(present) + 말하기 |
| (확장) 생각중/로딩 | 갸웃 + thinking 표정 |
> 확장 반응은 같은 프레임워크로 계속 추가한다(`../06_Reactions/Reactions.md`).
## 방향성 (핵심 원칙)
1. **AI 자동화**: 모든 캐릭터 이미지는 **ChatGPT로 생성**(각 생성용 `.md` 스펙 제공). 사람이 그리지 않는다.
2. **동작·색상은 코드/데이터**: 모션(리그 클립)·반응 시퀀스·색 변형(hairmask hue-shift)은 이미지가 아니라 코드/데이터. → 재사용·자동화·경량.
3. **하이브리드 표현**: 상황에 맞춰 **리그(앰비언트/열린 제스처)** + **베이크드 포즈(자기-가림 포즈)** + **표정 프레임 스왑(감정/말하기)** 을 조합.
4. **경량·포터블**: 에디터/외주 없이 **우리 코드**로 리그·모션·반응을 소유. 데이터(JSON)는 뷰어(웹)와 WPF 앱이 동일하게 사용.
5. **투명 알파 필수**: 모든 파츠/프레임은 32-bit RGBA(`Format32bppArgb`), 배경 alpha=0.
6. **점진적 품질 상향**: 강체 리그로 시작 → 필요한 곳(목/얼굴)만 **mesh-warp** 국소 승급(옵션).
## 범위 밖(당분간 안 함)
- Live2D/Spine 도입(GUI 리깅=자동화와 상충). 전신 mesh-warp. 3D. 정밀 음소 립싱크(얼굴 mesh-warp 승급 시 재검토).
@@ -0,0 +1,53 @@
# 아키텍처 (Architecture)
## 레이어 모델
```
[상황 이벤트] 예: "error" / "success" / "idle"
[트리거 매퍼] reactions.json 상황키 → 반응 클립 이름
[반응 시퀀서] clips/<name>.json 타임라인으로 아래 레이어들을 조율
├─ Body 레이어 ── 리그 클립(rig.json+track) │ 또는 │ 베이크드 포즈 이미지
├─ Face 레이어 ── 표정 프레임 스왑(neutral/negative/love/…)
├─ Mouth 레이어 ── 말하기(talk 프레임 순환, 유사 립싱크)
├─ Transform 레이어 ── 리그 위에 덧입히는 잔모션(고개젓기·바운스)
└─ FX/Caption 레이어 ── 말풍선·효과음(옵션)
[컴포지터] 파츠 합성 + 표정 오버레이 + 앵커 정렬(AlphaTools 재활용)
[렌더러] Canvas(웹 프로토타입) / WPF(본체) — 60fps
```
## 레이어 책임
- **Body**: 캐릭터 몸의 자세/모션. 두 모드.
- `rig` 모드 = 16파츠 리그를 클립으로 구동(앰비언트·열린 제스처·전환).
- `baked` 모드 = 통짜 포즈 이미지 1장(자기-가림 포즈: 팔짱·하트).
- **Face**: 감정 = 표정 프레임 교체(머리 이미지 스왑).
- **Mouth**: 말하기 = talk/neutral 프레임 순환(유사 립싱크). *정밀 립싱크는 mesh-warp 승급 시.*
- **Transform**: 리그 본에 delta를 더하는 잔모션(고개 좌우·호흡·바운스). Body가 baked여도 전체 트랜스폼은 적용 가능.
- **FX/Caption**: 말풍선 텍스트·효과음(옵션).
## 하이브리드 선택 규칙 (언제 무엇을)
| 상황 | Body 모드 | 근거 |
|---|---|---|
| 배경춤·유휴·호흡 | **rig** | 부드러운 앰비언트, 자산 최소 |
| 손 흔들기·가리키기·제시·박수 | **rig** | 열린 자세 → 리그로 자연스러움 |
| 고개 끄덕/젓기 | **rig**(Transform) | 작은 각도 + 가림 |
| 팔짱·핑거하트·볼하트 | **baked** | 손이 몸에 겹침 → 리그는 뚫림/어색 |
| 큰 감정 포즈(만세·좌절) | **baked** 또는 rig(joy/cheer) | 필요 정밀도에 따라 |
## 전환 (transition)
- rig→rig: 트랜스폼 보간(부드럽게).
- rig↔baked: 짧은 **크로스페이드**(150~250ms) 또는 리그로 근사 진입 후 스냅.
- 반응 종료 후 `return` 지정 클립(보통 `idle`/`dance_idle`)으로 복귀.
## 데이터 재사용
- `rig.json`·클립·`reactions.json`·표정/포즈 이미지는 **웹 뷰어와 WPF가 동일하게** 사용(플랫폼 독립 데이터).
- 앵커 정렬은 기존 `Character_Builder/AlphaTools.cs`(알파 기반 목/어깨 검출) 로직을 재활용.
@@ -0,0 +1,29 @@
# 한계와 완화 (Limits & Mitigations)
강체 컷아웃 + 프레임 스왑의 본질적 한계와, 우리가 쓰는 완화책. 그리고 mesh-warp 승급 기준.
## L1. 관절 이음새 (강체 회전)
- **문제**: 파츠를 크게 회전하면 관절 경계가 벌어지거나 겹침(특히 목). 분절을 늘려도 **근육 연속성은 해결 안 됨**(강체의 본질).
- **완화**: ① 회전 각도 작게 ② 피벗 낮게 ③ **근위단 오버랩 스텁**(부모가 틈을 덮음) ④ 옷깃/머리/초커로 **가림** ⑤ z-순서.
- **잔여 위험**: 큰 각도 고개돌림·큰 팔동작은 여전히 티남 → baked 포즈로 우회 또는 L4 승급.
## L2. 립싱크 (프레임 스왑 얼굴)
- **문제**: 표정이 눈+입 세트 고정 → "특정 감정 + 정밀 입모양" 동시 불가.
- **완화**: 감정 표정 + 고개짓 + talk/neutral 프레임 순환으로 **뉘앙스 전달**. 필요하면 **감정+talk 조합 머리**를 소수 추가 생성(`../03_Assets/Expressions_and_Poses.md`).
- **잔여 위험**: 음소 단위 정밀 립싱크는 불가 → L4 승급.
## L3. 자기-가림 포즈
- **문제**: 팔짱·하트 등 손이 몸/반대팔에 겹치는 포즈는 리그로 뚫림.
- **완화**: **baked 포즈 이미지**로 대체(기존 18제스처 자산). 진입은 크로스페이드.
## L4. mesh-warp 승급 (옵션·후속)
- **무엇**: 목/얼굴에 그리드 메시를 씌워 **피부 늘어남·입/눈썹 독립 변형·중간 각도 고개돌림**을 구현(WebGL). 우리 런타임 내 구현 → 자동화 유지.
- **승급 조건(하나라도 강하게 필요할 때)**: (a) 목 이음새가 baked/가림으로도 부족 (b) 감정+정밀 립싱크 동시 필요 (c) 중간 각도 고개돌림 필요.
- **한계(승급해도)**: 큰 각도(대략 30°↑) 고개돌림은 **가려진 반대면이 없어** 여전히 각도별 머리 아트 추가가 필요. 자동 워프가 없는 면을 만들지는 못함.
- **원칙**: 전신 아님, **국소(neck/head)만**. 품질 튜닝은 스크린샷 피드백 루프.
## 요약
> 강체+프레임스왑으로 **대부분의 상황 반응은 충분히 자연스럽게** 만든다(가림·baked·잔모션 조합). 정밀 립싱크/큰 고개돌림만 별도 승급(L4/각도 아트) 대상.
@@ -0,0 +1,30 @@
# 자산 전체 맵 (Assets Overview) — Haruka
> ✅ **완성** — 리그·소스 이미지·Library·반응 런타임 모두 완비. 프로필 자립(소스 원본은 별도 아카이브).
## 자산 (전부 프로필 내)
| 종류 | 개수 | 위치 |
|---|---|---|
| 시트 | 1 | `Reference/haruka_sheet.png` |
| 리그 파츠(마스터+16 풀캔버스) | 17 | `Parts/Images/` — 피벗 산출·배경춤 검증 완료 |
| 베이크드 포즈 | 54 | `Library/BakedPoses/` (Sailor·Idol·Witch 각 18) |
| 레거시 파츠 | 15 | `Library/CoarseParts/` (의상별 5) |
| 표정 프레임 | 20 (twin) | `Library/Heads/` (반응 head base `haruka_head_twin`) |
| hairmask / 악세서리 | 1 / 8 | `Library/Hairmasks/` · `Accessories/`(마녀모자 포함) |
| `_layout.json` | — | `06_Reactions/` (반응 목 정합) |
## 뷰어 (더블클릭 재생)
- 배경춤: `07_Viewer/index.html`
- 반응: `07_Viewer/reactions.html` — 트리거 idle / error / success
## dance 튜닝 (occlusion-aware)
세일러 **블라우스↔스커트가 별개 의상**`dance_idle`에서 **chest 리지드**(허리 봉인). 블라우스가 얇아 어깨 소켓 틈이 나기 쉬워 **팔 진폭 축소**(upperarm ±4), 생동감은 소매 안 forearm/hand로.
## 반응 매핑
| 상황 | Body(baked) | Face |
|---|---|---|
| error | `haruka_body_sailor_armscross` | `haruka_head_twin_negative` |
| success | `haruka_body_sailor_heart` | `haruka_head_twin_love` |
@@ -0,0 +1,39 @@
# 표정 & 베이크드 포즈 (Expressions & Poses)
반응 시퀀서의 **Face 레이어**(표정)와 **Body baked 모드**(포즈)가 쓰는 기존 자산 목록. 출처는 기존 Haruka 생성 md.
## 표정 프레임 20종 (Face 레이어)
출처: `(소스 아카이브)`(및 neat 변형). 헤어모양별로 동일 세트.
```
neutral · blink · talk · talk_wide · smile · positive · negative · confused · wink ·
surprised · laugh · thinking · cool · love · shy · sad · pout · sleepy · proud · playful
```
- **말하기**: `talk``talk_wide` ↔ (해당 감정 프레임) 순환으로 입 움직임 근사.
- **감정 표현**: 위 목록에서 상황에 맞는 프레임 선택.
## 베이크드 포즈 18종 (Body baked 모드)
출처: `(소스 아카이브)` (표준 제스처 바디, 헤드리스). 파일 `haruka_body_sailor_<pose>`.
```
idle_full · idle_upper · wave · handwave · listen · present · dj · piano · control ·
thumbsup · heart · clap · peace · armscross · shrug · point · cheer · joy
```
+ 파츠 5(apose·torso·arm_r·arm_l·legs) = 표준 23.
## 반응 3종이 쓰는 조합
| 반응 | Body | Face(감정) | Mouth |
|---|---|---|---|
| gesture_no | baked `armscross` | `negative` (또는 `pout`) | "안돼요" (negative↔talk 순환) |
| gesture_heart | baked `heart` | `love`/`positive`/`smile` | "잘됐어요" (love↔talk 순환) |
| dance_idle | rig 16파츠 | `smile`/`neutral` | — |
## (옵션) 감정+talk 조합 머리 — 립싱크 강화용
프레임 스왑 한계(L2)로 "감정 유지 + 입만 움직임"이 안 될 때만 소수 신규 생성.
- 후보: `haruka_head_<shape>_negative_talk`, `haruka_head_<shape>_love_talk` (+ positive_talk)
- **판단**: 먼저 표정↔talk 순환으로 충분한지 확인 후 결정(불충분하면 생성 또는 mesh-warp L4).
## 주의
- Face 프레임은 **선택한 헤어모양 세트**와 일치해야 함(예: short 바디엔 short 표정).
- 모든 프레임/포즈는 투명 알파 32-bit(`Format32bppArgb`).
Binary file not shown.

After

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 748 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 817 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 666 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 966 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 909 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 914 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 864 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 988 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 926 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 867 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 926 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 852 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 876 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Some files were not shown because too many files have changed in this diff Show More