using System.Globalization;
using AcDream.App.Rendering;
using AcDream.Core.CharGen;
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.UI.Layout;
///
/// The Appearance page (gmCGAppearancePage, root 0x100003d4) —
/// Campaign CC slice CC6b-MOUNT, the final piece of CC6. Decomp anchors:
/// gmCGAppearancePage::InitializePage @ 0x0047FDD0 (widget ids, the
/// 180° initial heading), ::ListenToElementMessage @ 0x0047EF30 (EVERY
/// dispatch this page implements — gender buttons at cases 0x9d/
/// 0x9e; Face/Clothes sub-tabs at 0x9f/0xa0; the nine
/// spin controls' decrement/increment arrow children, keyed by PARENT id, at
/// cases 0/1; the same nine spins' own BODY click (select-as-
/// current-part, no index change) at cases 0xa5-0xa9 and the
/// mirrored headgear/shirt/trousers/footwear cases; the nine color swatches
/// at cases 5-0xd -> SetColor(0..8); the shade
/// scrollbar at case 0x17 -> SetShade; rotate at
/// 0x19/0x1a; zoom at 0x1b/0x1c),
/// ::SetColor @ 0x0047DD50 and ::SetShade @ 0x0047C860 (the
/// per-part color/shade routing table this page's switch
/// mirrors, including the NOSE/MOUTH/SKIN-all-route-to-skin-shade quirk and
/// EYES having no shade at all), ::Update @ 0x0047E8F0 (the heritage
/// 6/0xc/0xd Clothes-button + Nose/Mouth-spin hide, Eyes-arrows-disable
/// gate).
///
///
/// Spin arrow geometry (live-DAT probe, CC6b-MOUNT): every one of the
/// nine spin elements (0x100003af-b3, b5-b8) is uniformly
/// 200px wide with two LOCALLY-reused arrow child ids (0x1000030a
/// decrement at local x=[80,127), 0x1000030b increment at
/// x=[127,174)) that DatWidgetFactory's UiButton consumes into
/// one flat clickable leaf — there is no separate addressable arrow widget
/// to bind. This page reproduces retail's two-arrow-plus-body-click shape
/// entirely through 's local x coordinate
/// (no new DatWidgetFactory widget type needed — see this campaign's
/// color-wheel scouting finding below, which reached the identical "existing
/// types suffice" conclusion for the whole page).
///
///
///
/// Color-wheel family scouting (campaign plan risk item 4, RESOLVED):
/// a live-DAT probe (CharacterCreationLiveDatTests) found every
/// color-wheel-family id resolves through EXISTING DatWidgetFactory
/// mappings: the nine swatch buttons (0x1000030f-0x10000317, retail's
/// SetColor(0..8) targets) author Type 1 -> UiButton; their
/// nine Type-3 companion "selected" overlays (0x10000318-0x10000320,
/// retail's m_tColorWheel[...][0x10][iCurColor*7]->SetVisible
/// highlight ring) and the GradCircle (0x1000030e) author Type 3 ->
/// UiDatElement; the shade scrollbar (0x10000321) authors
/// Type 0xB -> UiScrollbar, matching the decomp's own
/// DynamicCast(0xb). NO new widget type was added. This page uses the
/// swatch buttons' own state for the
/// highlight instead of toggling the separate companion overlay elements —
/// a documented substitution (same class as AD-103's swallowed-child
/// pattern), not a pixel-identical port of retail's own two-widget
/// mechanism.
///
///
///
/// Icon-only style lists have no name string (register-worthy scope
/// cut): CC1's //
/// carry an IconId, not a name — retail
/// shows an actual icon thumbnail in these four spins (hair/eyes/nose/
/// mouth). Icon rendering is out of this round's scope; the spin shows a
/// 1-based ordinal instead. The four clothing spins (headgear/shirt/
/// trousers/footwear) DO carry a real
/// and show it directly.
///
///
internal sealed class CharacterCreationAppearancePage : IDisposable
{
private const uint Unset = RuntimeCharacterCreationAppearance.Unset;
internal enum Part
{
Hair = 1,
Eyes = 2,
Nose = 3,
Mouth = 4,
Skin = 5,
Headgear = 6,
Shirt = 7,
Trousers = 8,
Footwear = 9,
}
private enum Choice
{
Face,
Clothes,
}
internal const uint FemaleButtonId = 0x100003A7u;
internal const uint MaleButtonId = 0x100003A8u;
internal const uint FaceButtonId = 0x100003A9u;
internal const uint ClothesButtonId = 0x100003AAu;
internal const uint FaceChoicesId = 0x100003AEu;
internal const uint ClothesChoicesId = 0x100003B4u;
internal const uint HairSpinId = 0x100003AFu;
internal const uint EyesSpinId = 0x100003B0u;
internal const uint NoseSpinId = 0x100003B1u;
internal const uint MouthSpinId = 0x100003B2u;
internal const uint SkinSpinId = 0x100003B3u;
internal const uint HeadgearSpinId = 0x100003B5u;
internal const uint ShirtSpinId = 0x100003B6u;
internal const uint TrousersSpinId = 0x100003B7u;
internal const uint FootwearSpinId = 0x100003B8u;
internal const uint RotateClockwiseId = 0x10000323u;
internal const uint RotateCounterClockwiseId = 0x10000324u;
internal const uint ZoomInId = 0x10000325u;
internal const uint ZoomOutId = 0x10000326u;
internal const uint GradCircleId = 0x1000030Eu;
internal const uint ShadeScrollId = 0x10000321u;
internal const uint ViewportId = 0x100003BBu;
/// Retail's nine SetColor(0..8) swatch buttons, in
/// index order — verbatim off ListenToElementMessage's cases
/// 5-0xd (elementId - 0x1000030a).
internal static readonly uint[] SwatchIds =
[
0x1000030Fu, 0x10000310u, 0x10000311u, 0x10000312u, 0x10000313u,
0x10000314u, 0x10000315u, 0x10000316u, 0x10000317u,
];
/// Live-DAT-measured arrow geometry, uniform across all nine
/// spins (every one is 200px wide): decrement child at local
/// x=[80,127), increment child at x=[127,174). Anything outside both
/// zones is the spin's own BODY click (retail cases 0xa5-0xa9
/// and their headgear/shirt/trousers/footwear mirrors).
private const float DecrementZoneStart = 80f;
private const float IncrementZoneStart = 127f;
private const float IncrementZoneEnd = 174f;
private readonly CharacterCreationRuntimeBindings _bindings;
private readonly UiButton? _femaleButton;
private readonly UiButton? _maleButton;
private readonly UiButton? _faceButton;
private readonly UiButton? _clothesButton;
private readonly UiElement? _faceChoices;
private readonly UiElement? _clothesChoices;
private readonly Dictionary _spins = [];
private readonly UiButton?[] _swatches = new UiButton?[SwatchIds.Length];
private readonly UiScrollbar? _shadeScroll;
private readonly UiButton? _rotateClockwise;
private readonly UiButton? _rotateCounterClockwise;
private readonly UiButton? _zoomIn;
private readonly UiButton? _zoomOut;
private Choice _currentChoice = Choice.Face;
private Part _currentPart = Part.Hair;
private bool _eyesArrowsDisabled;
private bool _disposed;
/// Late-bound preview control seam — see
/// 's own doc comment for why this
/// page cannot receive the real renderer at construction time.
internal IChargenPreviewControl? PreviewControl { get; set; }
/// The authored viewport (0x100003bb) — the composition
/// root assigns its Renderer once the graphics backend exists,
/// mirroring the paperdoll's own late viewport.Renderer = ...
/// assignment.
internal UiViewport? Viewport { get; }
internal CharacterCreationAppearancePage(
UiElement pageRoot,
CharacterCreationRuntimeBindings bindings)
{
_bindings = bindings;
_femaleButton = Find(pageRoot, FemaleButtonId);
if (_femaleButton is not null)
_femaleButton.OnClick = () => _bindings.SelectGender(2u);
_maleButton = Find(pageRoot, MaleButtonId);
if (_maleButton is not null)
_maleButton.OnClick = () => _bindings.SelectGender(1u);
_faceButton = Find(pageRoot, FaceButtonId);
if (_faceButton is not null)
_faceButton.OnClick = () => SelectChoice(Choice.Face);
_clothesButton = Find(pageRoot, ClothesButtonId);
if (_clothesButton is not null)
_clothesButton.OnClick = () => SelectChoice(Choice.Clothes);
_faceChoices = Find(pageRoot, FaceChoicesId);
_clothesChoices = Find(pageRoot, ClothesChoicesId);
BindSpin(pageRoot, HairSpinId, Part.Hair);
BindSpin(pageRoot, EyesSpinId, Part.Eyes);
BindSpin(pageRoot, NoseSpinId, Part.Nose);
BindSpin(pageRoot, MouthSpinId, Part.Mouth);
BindSpin(pageRoot, SkinSpinId, Part.Skin);
BindSpin(pageRoot, HeadgearSpinId, Part.Headgear);
BindSpin(pageRoot, ShirtSpinId, Part.Shirt);
BindSpin(pageRoot, TrousersSpinId, Part.Trousers);
BindSpin(pageRoot, FootwearSpinId, Part.Footwear);
for (int i = 0; i < SwatchIds.Length; i++)
{
UiButton? swatch = Find(pageRoot, SwatchIds[i]);
if (swatch is null)
continue;
int index = i;
swatch.OnClick = () => SelectColor(index);
_swatches[i] = swatch;
}
_shadeScroll = Find(pageRoot, ShadeScrollId);
if (_shadeScroll is not null)
_shadeScroll.ScalarChanged = SetShadeFromScalar;
Viewport = Find(pageRoot, ViewportId);
_rotateClockwise = Find(pageRoot, RotateClockwiseId);
if (_rotateClockwise is not null)
_rotateClockwise.OnClick = () => PreviewControl?.RotateClockwise();
_rotateCounterClockwise = Find(pageRoot, RotateCounterClockwiseId);
if (_rotateCounterClockwise is not null)
_rotateCounterClockwise.OnClick = () => PreviewControl?.RotateCounterClockwise();
_zoomIn = Find(pageRoot, ZoomInId);
if (_zoomIn is not null)
_zoomIn.OnClick = () => PreviewControl?.ZoomIn();
_zoomOut = Find(pageRoot, ZoomOutId);
if (_zoomOut is not null)
_zoomOut.OnClick = () => PreviewControl?.ZoomOut();
ApplyChoiceVisibility();
}
internal void Refresh(
IRuntimeCharacterCreationView view,
RuntimeCharacterCreationSnapshot snapshot)
{
if (_disposed)
return;
if (_femaleButton is not null)
_femaleButton.Selected = snapshot.GenderKey == 2u;
if (_maleButton is not null)
_maleButton.Selected = snapshot.GenderKey == 1u;
// gmCGAppearancePage::Update @ ~0x0047EB46-0x0047EE95: heritage
// 6 (Gearknight) / 0xc (Olthoi) / 0xd (OlthoiAcid) hide the Clothes
// sub-tab (and, with it, every clothing spin behind it), hide the
// Nose/Mouth spins directly, and disable the Eyes spin's arrows —
// none of these three heritages have separate clothing, nose, or
// mouth strip choices.
bool clothesHidden = IsClothesHiddenHeritage(snapshot.HeritageId);
if (_clothesButton is not null)
_clothesButton.Visible = !clothesHidden;
if (_spins.TryGetValue(Part.Nose, out UiButton? noseSpin))
noseSpin.Visible = !clothesHidden;
if (_spins.TryGetValue(Part.Mouth, out UiButton? mouthSpin))
mouthSpin.Visible = !clothesHidden;
_eyesArrowsDisabled = clothesHidden;
if (clothesHidden && _currentChoice == Choice.Clothes)
{
// Update forces SetChoice(ECG_CHOICE_FACE) when Clothes becomes
// unreachable so the page never gets stuck showing a hidden tab.
_currentChoice = Choice.Face;
_currentPart = Part.Hair;
}
ApplyChoiceVisibility();
if (TryGetGender(view, snapshot, out ChargenGenderOptions? gender))
RefreshSpins(gender, snapshot.Appearance);
RefreshColorAndShadeControls(view, snapshot);
RebuildPreview(view, snapshot);
}
// ── Gender / Face-Clothes sub-tab ──────────────────────────────────
private void SelectChoice(Choice choice)
{
if (_disposed)
return;
_currentChoice = choice;
// gmCGAppearancePage::ListenToElementMessage cases 0x9f/0xa0:
// Face -> SetSelection(ECG_PARTS_HAIR); Clothes ->
// SetSelection(ECG_PARTS_HEADGEAR).
_currentPart = choice == Choice.Face ? Part.Hair : Part.Headgear;
ApplyChoiceVisibility();
RefreshColorAndShadeControlsFromLatestSnapshot();
}
private void ApplyChoiceVisibility()
{
if (_faceChoices is not null)
_faceChoices.Visible = _currentChoice == Choice.Face;
if (_clothesChoices is not null)
_clothesChoices.Visible = _currentChoice == Choice.Clothes;
if (_faceButton is not null)
_faceButton.Selected = _currentChoice == Choice.Face;
if (_clothesButton is not null)
_clothesButton.Selected = _currentChoice == Choice.Clothes;
}
// ── Spins (style cycling + select-as-current-part) ─────────────────
private void BindSpin(UiElement pageRoot, uint id, Part part)
{
UiButton? spin = Find(pageRoot, id);
if (spin is null)
return;
_spins[part] = spin;
if (part == Part.Skin)
{
// Skin has no style index at all — retail disables its arrow
// children outright (SetAttribute_Bool(...,0xd,1) in
// InitializePage/Update's heritage branches). Every click just
// selects Skin as the current part for the color/shade controls.
spin.OnClickAt = (_, _) => SelectPart(Part.Skin);
return;
}
spin.OnClickAt = (x, _) =>
{
if (x >= DecrementZoneStart && x < IncrementZoneStart)
CycleStyle(part, -1);
else if (x >= IncrementZoneStart && x < IncrementZoneEnd)
CycleStyle(part, +1);
else
SelectPart(part);
};
}
private void SelectPart(Part part)
{
if (_disposed)
return;
_currentPart = part;
RefreshColorAndShadeControlsFromLatestSnapshot();
}
private void CycleStyle(Part part, int delta)
{
if (_disposed)
return;
if (part == Part.Eyes && _eyesArrowsDisabled)
{
SelectPart(part);
return;
}
IRuntimeCharacterCreationView? view = _bindings.View();
if (view is null)
return;
RuntimeCharacterCreationSnapshot snapshot = view.Snapshot;
if (!TryGetGender(view, snapshot, out ChargenGenderOptions? gender))
return;
ChargenAppearanceSlot? slot = StyleSlotFor(part);
if (slot is null)
{
SelectPart(part);
return;
}
int count = StyleCount(part, gender);
uint current = StyleCurrent(part, snapshot.Appearance);
// Headgear alone allows the Unset ("no headgear") ring position —
// CharGenState::SetHeadgearStyle's decomp-derived (count+1)-position
// ring (0..count-1, Unset); every other style spin cycles [0,count).
uint next = CycleIndex(current, delta, count, allowUnset: part == Part.Headgear);
_bindings.SetAppearanceIndex?.Invoke(slot.Value, next);
SelectPart(part);
}
///
/// Retail's decomp-derived wrap: reproduces
/// CharGenState::SetHeadgearStyle's literal signed-int32 ring of
/// +1 positions (every real index, plus
/// — decrementing from index 0 lands on Unset,
/// incrementing from Unset lands on index 0, matching
/// ListenToElementMessage's cases 6 exactly). Every other
/// style spin has no decomp-observable Unset-cycling case (retail always
/// has a real 0-based index by the time the user can click — see
/// AP-214's RandomizeCharacter-at-open finding, which acdream
/// does not port this round) — an Unset start there is an edge case
/// retail itself never reaches, so the first click either direction just
/// starts cycling from index 0 rather than reconstructing an unfounded
/// wrap direction.
///
internal static uint CycleIndex(uint current, int delta, int count, bool allowUnset)
{
if (count <= 0)
return Unset;
if (allowUnset)
{
int cur = current == Unset ? count : (int)current;
int size = count + 1;
int next = Mod(cur + delta, size);
return next == count ? Unset : (uint)next;
}
if (current == Unset)
return 0u;
return (uint)Mod((int)current + delta, count);
}
private static int Mod(int value, int modulus) =>
((value % modulus) + modulus) % modulus;
// ── Color swatches + shade scroll ───────────────────────────────────
private void SelectColor(int index)
{
if (_disposed)
return;
IRuntimeCharacterCreationView? view = _bindings.View();
if (view is null)
return;
RuntimeCharacterCreationSnapshot snapshot = view.Snapshot;
if (!TryGetGender(view, snapshot, out ChargenGenderOptions? gender))
return;
ChargenAppearanceSlot? slot = ColorSlotFor(_currentPart);
if (slot is null)
return;
// gmCGAppearancePage::ListenToElementMessage's swatch cases each
// gate on the current part's own color-list length before calling
// SetColor — a swatch beyond the list clicks through to nothing.
int count = ColorCount(_currentPart, gender);
if (index >= count)
return;
_bindings.SetAppearanceIndex?.Invoke(slot.Value, (uint)index);
}
private void SetShadeFromScalar(float scalar)
{
if (_disposed)
return;
ChargenShadeSlot? slot = ShadeSlotFor(_currentPart);
if (slot is null)
return;
_bindings.SetShade?.Invoke(slot.Value, scalar);
}
private void RefreshColorAndShadeControlsFromLatestSnapshot()
{
IRuntimeCharacterCreationView? view = _bindings.View();
if (view is not null)
RefreshColorAndShadeControls(view, view.Snapshot);
}
private void RefreshColorAndShadeControls(
IRuntimeCharacterCreationView view,
RuntimeCharacterCreationSnapshot snapshot)
{
ChargenAppearanceSlot? colorSlot = ColorSlotFor(_currentPart);
uint currentColor = colorSlot is null ? Unset : ColorCurrent(_currentPart, snapshot.Appearance);
for (int i = 0; i < _swatches.Length; i++)
{
if (_swatches[i] is { } swatch)
swatch.Selected = colorSlot is not null && currentColor == (uint)i;
}
ChargenShadeSlot? shadeSlot = ShadeSlotFor(_currentPart);
if (_shadeScroll is null)
return;
_shadeScroll.Enabled = shadeSlot is not null;
if (shadeSlot is { } slot)
{
double shade = ShadeCurrent(slot, snapshot.Appearance);
float scalar = shade < 0.0 ? 0f : (float)Math.Clamp(shade, 0.0, 1.0);
_shadeScroll.SetScalarPosition(scalar);
}
}
// ── Spin labels ──────────────────────────────────────────────────
private void RefreshSpins(ChargenGenderOptions gender, RuntimeCharacterCreationAppearance a)
{
SetStyleSpinLabel(Part.Hair, gender.HairStyles.Count, a.HairStyle);
SetStyleSpinLabel(Part.Eyes, gender.EyeStrips.Count, a.EyesStrip);
SetStyleSpinLabel(Part.Nose, gender.NoseStrips.Count, a.NoseStrip);
SetStyleSpinLabel(Part.Mouth, gender.MouthStrips.Count, a.MouthStrip);
SetGearSpinLabel(Part.Headgear, gender.Headgears, a.HeadgearStyle);
SetGearSpinLabel(Part.Shirt, gender.Shirts, a.ShirtStyle);
SetGearSpinLabel(Part.Trousers, gender.Pants, a.TrousersStyle);
SetGearSpinLabel(Part.Footwear, gender.Footwear, a.FootwearStyle);
}
private void SetStyleSpinLabel(Part part, int count, uint index)
{
if (!_spins.TryGetValue(part, out UiButton? spin))
return;
spin.Label = index != Unset && index < (uint)count
? (index + 1).ToString(CultureInfo.InvariantCulture)
: "-";
}
private void SetGearSpinLabel(Part part, IReadOnlyList options, uint index)
{
if (!_spins.TryGetValue(part, out UiButton? spin))
return;
spin.Label = index != Unset && index < (uint)options.Count
? options[(int)index].Name
: "None";
}
// ── Preview rebuild ──────────────────────────────────────────────
private void RebuildPreview(
IRuntimeCharacterCreationView view,
RuntimeCharacterCreationSnapshot snapshot)
{
if (PreviewControl is null
|| snapshot.HeritageId == 0u
|| snapshot.GenderKey == 0u)
{
return;
}
RuntimeCharacterCreationAppearance a = snapshot.Appearance;
var selection = new ChargenAppearanceSelection(
a.EyesStrip, a.NoseStrip, a.MouthStrip,
a.HairStyle, a.HairColor, a.EyeColor,
a.HeadgearStyle, a.HeadgearColor,
a.ShirtStyle, a.ShirtColor,
a.TrousersStyle, a.TrousersColor,
a.FootwearStyle, a.FootwearColor,
a.SkinShade, a.HairShade, a.HeadgearShade,
a.ShirtShade, a.TrousersShade, a.FootwearShade);
PreviewControl.Rebuild(view.Options, snapshot.HeritageId, (int)snapshot.GenderKey, selection);
}
// ── Per-part routing tables (retail SetColor @0x0047DD50 / SetShade @0x0047C860) ──
private static ChargenAppearanceSlot? StyleSlotFor(Part part) => part switch
{
Part.Hair => ChargenAppearanceSlot.HairStyle,
Part.Eyes => ChargenAppearanceSlot.EyesStrip,
Part.Nose => ChargenAppearanceSlot.NoseStrip,
Part.Mouth => ChargenAppearanceSlot.MouthStrip,
Part.Headgear => ChargenAppearanceSlot.HeadgearStyle,
Part.Shirt => ChargenAppearanceSlot.ShirtStyle,
Part.Trousers => ChargenAppearanceSlot.TrousersStyle,
Part.Footwear => ChargenAppearanceSlot.FootwearStyle,
_ => null, // Skin.
};
/// Retail's SIX colorable parts (SetColor's cases
/// 0,1,5,6,7,8) — Nose/Mouth/Skin have no color list at all.
private static ChargenAppearanceSlot? ColorSlotFor(Part part) => part switch
{
Part.Hair => ChargenAppearanceSlot.HairColor,
Part.Eyes => ChargenAppearanceSlot.EyeColor,
Part.Headgear => ChargenAppearanceSlot.HeadgearColor,
Part.Shirt => ChargenAppearanceSlot.ShirtColor,
Part.Trousers => ChargenAppearanceSlot.TrousersColor,
Part.Footwear => ChargenAppearanceSlot.FootwearColor,
_ => null,
};
/// Retail's SetShade switch: Hair has its own shade;
/// Nose/Mouth/Skin ALL route to skin shade (cases 2/3/4 share one body
/// in the decompiled switch — a genuine retail quirk, not a porting
/// shortcut); Eyes has NO case at all (eye color has no shade
/// indirection anywhere in this campaign's model).
private static ChargenShadeSlot? ShadeSlotFor(Part part) => part switch
{
Part.Hair => ChargenShadeSlot.Hair,
Part.Nose => ChargenShadeSlot.Skin,
Part.Mouth => ChargenShadeSlot.Skin,
Part.Skin => ChargenShadeSlot.Skin,
Part.Headgear => ChargenShadeSlot.Headgear,
Part.Shirt => ChargenShadeSlot.Shirt,
Part.Trousers => ChargenShadeSlot.Trousers,
Part.Footwear => ChargenShadeSlot.Footwear,
_ => null, // Eyes.
};
private static int StyleCount(Part part, ChargenGenderOptions gender) => part switch
{
Part.Hair => gender.HairStyles.Count,
Part.Eyes => gender.EyeStrips.Count,
Part.Nose => gender.NoseStrips.Count,
Part.Mouth => gender.MouthStrips.Count,
Part.Headgear => gender.Headgears.Count,
Part.Shirt => gender.Shirts.Count,
Part.Trousers => gender.Pants.Count,
Part.Footwear => gender.Footwear.Count,
_ => 0,
};
/// Hair/Eyes have their own real per-gender color lists;
/// the four clothing slots share the gender's single
/// list (register
/// AP-208).
private static int ColorCount(Part part, ChargenGenderOptions gender) => part switch
{
Part.Hair => gender.HairColors.Count,
Part.Eyes => gender.EyeColors.Count,
Part.Headgear or Part.Shirt or Part.Trousers or Part.Footwear =>
gender.ClothingColors.Count,
_ => 0,
};
private static uint StyleCurrent(Part part, RuntimeCharacterCreationAppearance a) => part switch
{
Part.Hair => a.HairStyle,
Part.Eyes => a.EyesStrip,
Part.Nose => a.NoseStrip,
Part.Mouth => a.MouthStrip,
Part.Headgear => a.HeadgearStyle,
Part.Shirt => a.ShirtStyle,
Part.Trousers => a.TrousersStyle,
Part.Footwear => a.FootwearStyle,
_ => Unset,
};
private static uint ColorCurrent(Part part, RuntimeCharacterCreationAppearance a) => part switch
{
Part.Hair => a.HairColor,
Part.Eyes => a.EyeColor,
Part.Headgear => a.HeadgearColor,
Part.Shirt => a.ShirtColor,
Part.Trousers => a.TrousersColor,
Part.Footwear => a.FootwearColor,
_ => Unset,
};
private static double ShadeCurrent(ChargenShadeSlot slot, RuntimeCharacterCreationAppearance a) => slot switch
{
ChargenShadeSlot.Skin => a.SkinShade,
ChargenShadeSlot.Hair => a.HairShade,
ChargenShadeSlot.Headgear => a.HeadgearShade,
ChargenShadeSlot.Shirt => a.ShirtShade,
ChargenShadeSlot.Trousers => a.TrousersShade,
ChargenShadeSlot.Footwear => a.FootwearShade,
_ => 0.0,
};
private static bool IsClothesHiddenHeritage(uint heritageId) =>
heritageId == (uint)ChargenHeritageGroup.Gearknight
|| heritageId == (uint)ChargenHeritageGroup.Olthoi
|| heritageId == (uint)ChargenHeritageGroup.OlthoiAcid;
private static bool TryGetGender(
IRuntimeCharacterCreationView view,
RuntimeCharacterCreationSnapshot snapshot,
[System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out ChargenGenderOptions? gender)
{
gender = null;
if (snapshot.HeritageId == 0u || snapshot.GenderKey == 0u)
return false;
if (!view.Options.TryGetHeritage(snapshot.HeritageId, out ChargenHeritageOptions? heritage))
return false;
return heritage.GendersByKey.TryGetValue((int)snapshot.GenderKey, out gender);
}
private static T? Find(UiElement root, uint id) where T : UiElement =>
UiElement.FindDescendant(root, id) as T;
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
if (_femaleButton is not null) _femaleButton.OnClick = null;
if (_maleButton is not null) _maleButton.OnClick = null;
if (_faceButton is not null) _faceButton.OnClick = null;
if (_clothesButton is not null) _clothesButton.OnClick = null;
foreach (UiButton spin in _spins.Values)
spin.OnClickAt = null;
_spins.Clear();
foreach (UiButton? swatch in _swatches)
{
if (swatch is not null)
swatch.OnClick = null;
}
if (_shadeScroll is not null)
_shadeScroll.ScalarChanged = null;
if (_rotateClockwise is not null) _rotateClockwise.OnClick = null;
if (_rotateCounterClockwise is not null) _rotateCounterClockwise.OnClick = null;
if (_zoomIn is not null) _zoomIn.OnClick = null;
if (_zoomOut is not null) _zoomOut.OnClick = null;
// PreviewControl is owned by the composition root (disposed with
// the leased ChargenPreviewRenderer) — just drop the reference.
PreviewControl = null;
}
}