feat(chargen): Campaign CC slice CC6b-MOUNT — Appearance page + preview mount
The page-mount half CC6b-PRE deferred: CharacterCreationAppearancePage (gender buttons, Face/Clothes sub-tabs, nine spin controls with retail's decrement/increment/select-as-current-part OnClickAt zones, nine color swatches, shade scrollbar, zoom/rotate wiring) plus ChargenPreviewController, which bridges the ChargenPreviewRenderer/ChargenPreviewZoomController camera-injection gap CC6a/CC6b-PRE left open and mounts as the third private creature viewport beside paperdoll/creature-appraisal. Color-wheel scouting (campaign risk item 4): live-DAT probe found every color-wheel-family id resolves through existing DatWidgetFactory mappings (Button/Scrollbar/generic fallback) — no new widget type needed. The @140355 gender-flip-on-init oddity (risk item 5): resolved via decomp alone — gmCharGenMainUI's own ctor calls CharGenState::RandomizeCharacter before any page constructs, so retail's chargen screen is never actually blank on open; the Appearance page's gender-flip code always fires against a real, randomly-rolled gender. Filed AP-214 (acdream doesn't port RandomizeCharacter this round, so it opens honestly blank instead) and AP-215 (two narrow visual substitutions: swatch .Selected highlight vs retail's separate overlay, ordinal labels vs retail's icon-only spins). AD-101 retired: the Heritage page's auto-gender-select interim default is deleted now that the Appearance page's real gender buttons exist. TS-82 narrowed to Summary-only. Scope addendum: ChargenPreviewRotationController's parameterless-constructor default changes from 0f to a new RetailDefaultHeadingDegrees=180f constant (retail's InitializePage override, not the ctor's raw 0) — every real gmCG3DView owner converges on 180 before its first frame, so a controller defaulting to 0 was a trap for future consumers. Runtime 1713/0, Core 4786/1 skip, Content 147/0, App 5220/3 skips (Release, ACDREAM_PROBE_LIVE_MOUNT=1) — zero failures across two clean full-solution runs; the one Core.Net.Tests NakEmissionTests flake observed on a third run is the same pre-existing, previously-documented timing flake (zero files under src/AcDream.Core.Net/ touched, passes 100% in isolation). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
11374484dc
commit
34c6fceab0
20 changed files with 2109 additions and 57 deletions
692
src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs
Normal file
692
src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs
Normal file
|
|
@ -0,0 +1,692 @@
|
|||
using System.Globalization;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.CharGen;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// The Appearance page (<c>gmCGAppearancePage</c>, root <c>0x100003d4</c>) —
|
||||
/// Campaign CC slice CC6b-MOUNT, the final piece of CC6. Decomp anchors:
|
||||
/// <c>gmCGAppearancePage::InitializePage @ 0x0047FDD0</c> (widget ids, the
|
||||
/// 180° initial heading), <c>::ListenToElementMessage @ 0x0047EF30</c> (EVERY
|
||||
/// dispatch this page implements — gender buttons at cases <c>0x9d</c>/
|
||||
/// <c>0x9e</c>; Face/Clothes sub-tabs at <c>0x9f</c>/<c>0xa0</c>; the nine
|
||||
/// spin controls' decrement/increment arrow children, keyed by PARENT id, at
|
||||
/// cases <c>0</c>/<c>1</c>; the same nine spins' own BODY click (select-as-
|
||||
/// current-part, no index change) at cases <c>0xa5</c>-<c>0xa9</c> and the
|
||||
/// mirrored headgear/shirt/trousers/footwear cases; the nine color swatches
|
||||
/// at cases <c>5</c>-<c>0xd</c> -> <c>SetColor(0..8)</c>; the shade
|
||||
/// scrollbar at case <c>0x17</c> -> <c>SetShade</c>; rotate at
|
||||
/// <c>0x19</c>/<c>0x1a</c>; zoom at <c>0x1b</c>/<c>0x1c</c>),
|
||||
/// <c>::SetColor @ 0x0047DD50</c> and <c>::SetShade @ 0x0047C860</c> (the
|
||||
/// per-part color/shade routing table this page's <see cref="Part"/> switch
|
||||
/// mirrors, including the NOSE/MOUTH/SKIN-all-route-to-skin-shade quirk and
|
||||
/// EYES having no shade at all), <c>::Update @ 0x0047E8F0</c> (the heritage
|
||||
/// 6/0xc/0xd Clothes-button + Nose/Mouth-spin hide, Eyes-arrows-disable
|
||||
/// gate).
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Spin arrow geometry (live-DAT probe, CC6b-MOUNT):</b> every one of the
|
||||
/// nine spin elements (<c>0x100003af-b3</c>, <c>b5-b8</c>) is uniformly
|
||||
/// 200px wide with two LOCALLY-reused arrow child ids (<c>0x1000030a</c>
|
||||
/// decrement at local x=[80,127), <c>0x1000030b</c> increment at
|
||||
/// x=[127,174)) that <c>DatWidgetFactory</c>'s <c>UiButton</c> 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 <see cref="UiButton.OnClickAt"/>'s local x coordinate
|
||||
/// (no new <c>DatWidgetFactory</c> widget type needed — see this campaign's
|
||||
/// color-wheel scouting finding below, which reached the identical "existing
|
||||
/// types suffice" conclusion for the whole page).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Color-wheel family scouting (campaign plan risk item 4, RESOLVED):</b>
|
||||
/// a live-DAT probe (<c>CharacterCreationLiveDatTests</c>) found every
|
||||
/// color-wheel-family id resolves through EXISTING <c>DatWidgetFactory</c>
|
||||
/// mappings: the nine swatch buttons (<c>0x1000030f-0x10000317</c>, retail's
|
||||
/// <c>SetColor(0..8)</c> targets) author Type 1 -> <c>UiButton</c>; their
|
||||
/// nine Type-3 companion "selected" overlays (<c>0x10000318-0x10000320</c>,
|
||||
/// retail's <c>m_tColorWheel[...][0x10][iCurColor*7]->SetVisible</c>
|
||||
/// highlight ring) and the GradCircle (<c>0x1000030e</c>) author Type 3 ->
|
||||
/// <c>UiDatElement</c>; the shade scrollbar (<c>0x10000321</c>) authors
|
||||
/// Type 0xB -> <c>UiScrollbar</c>, matching the decomp's own
|
||||
/// <c>DynamicCast(0xb)</c>. NO new widget type was added. This page uses the
|
||||
/// swatch buttons' own <see cref="UiButton.Selected"/> 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.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Icon-only style lists have no name string (register-worthy scope
|
||||
/// cut):</b> CC1's <see cref="ChargenHairStyle"/>/<see cref="ChargenEyeStrip"/>/
|
||||
/// <see cref="ChargenFaceStrip"/> carry an <c>IconId</c>, 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 <see cref="ChargenGearOption.Name"/>
|
||||
/// and show it directly.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>Retail's nine <c>SetColor(0..8)</c> swatch buttons, in
|
||||
/// index order — verbatim off <c>ListenToElementMessage</c>'s cases
|
||||
/// <c>5</c>-<c>0xd</c> (<c>elementId - 0x1000030a</c>).</summary>
|
||||
internal static readonly uint[] SwatchIds =
|
||||
[
|
||||
0x1000030Fu, 0x10000310u, 0x10000311u, 0x10000312u, 0x10000313u,
|
||||
0x10000314u, 0x10000315u, 0x10000316u, 0x10000317u,
|
||||
];
|
||||
|
||||
/// <summary>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 <c>0xa5</c>-<c>0xa9</c>
|
||||
/// and their headgear/shirt/trousers/footwear mirrors).</summary>
|
||||
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<Part, UiButton> _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;
|
||||
|
||||
/// <summary>Late-bound preview control seam — see
|
||||
/// <see cref="IChargenPreviewControl"/>'s own doc comment for why this
|
||||
/// page cannot receive the real renderer at construction time.</summary>
|
||||
internal IChargenPreviewControl? PreviewControl { get; set; }
|
||||
|
||||
/// <summary>The authored viewport (<c>0x100003bb</c>) — the composition
|
||||
/// root assigns its <c>Renderer</c> once the graphics backend exists,
|
||||
/// mirroring the paperdoll's own late <c>viewport.Renderer = ...</c>
|
||||
/// assignment.</summary>
|
||||
internal UiViewport? Viewport { get; }
|
||||
|
||||
internal CharacterCreationAppearancePage(
|
||||
UiElement pageRoot,
|
||||
CharacterCreationRuntimeBindings bindings)
|
||||
{
|
||||
_bindings = bindings;
|
||||
|
||||
_femaleButton = Find<UiButton>(pageRoot, FemaleButtonId);
|
||||
if (_femaleButton is not null)
|
||||
_femaleButton.OnClick = () => _bindings.SelectGender(2u);
|
||||
_maleButton = Find<UiButton>(pageRoot, MaleButtonId);
|
||||
if (_maleButton is not null)
|
||||
_maleButton.OnClick = () => _bindings.SelectGender(1u);
|
||||
|
||||
_faceButton = Find<UiButton>(pageRoot, FaceButtonId);
|
||||
if (_faceButton is not null)
|
||||
_faceButton.OnClick = () => SelectChoice(Choice.Face);
|
||||
_clothesButton = Find<UiButton>(pageRoot, ClothesButtonId);
|
||||
if (_clothesButton is not null)
|
||||
_clothesButton.OnClick = () => SelectChoice(Choice.Clothes);
|
||||
|
||||
_faceChoices = Find<UiElement>(pageRoot, FaceChoicesId);
|
||||
_clothesChoices = Find<UiElement>(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<UiButton>(pageRoot, SwatchIds[i]);
|
||||
if (swatch is null)
|
||||
continue;
|
||||
int index = i;
|
||||
swatch.OnClick = () => SelectColor(index);
|
||||
_swatches[i] = swatch;
|
||||
}
|
||||
|
||||
_shadeScroll = Find<UiScrollbar>(pageRoot, ShadeScrollId);
|
||||
if (_shadeScroll is not null)
|
||||
_shadeScroll.ScalarChanged = SetShadeFromScalar;
|
||||
|
||||
Viewport = Find<UiViewport>(pageRoot, ViewportId);
|
||||
|
||||
_rotateClockwise = Find<UiButton>(pageRoot, RotateClockwiseId);
|
||||
if (_rotateClockwise is not null)
|
||||
_rotateClockwise.OnClick = () => PreviewControl?.RotateClockwise();
|
||||
_rotateCounterClockwise = Find<UiButton>(pageRoot, RotateCounterClockwiseId);
|
||||
if (_rotateCounterClockwise is not null)
|
||||
_rotateCounterClockwise.OnClick = () => PreviewControl?.RotateCounterClockwise();
|
||||
_zoomIn = Find<UiButton>(pageRoot, ZoomInId);
|
||||
if (_zoomIn is not null)
|
||||
_zoomIn.OnClick = () => PreviewControl?.ZoomIn();
|
||||
_zoomOut = Find<UiButton>(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<UiButton>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's decomp-derived wrap: <paramref name="allowUnset"/> reproduces
|
||||
/// <c>CharGenState::SetHeadgearStyle</c>'s literal signed-int32 ring of
|
||||
/// <paramref name="count"/>+1 positions (every real index, plus
|
||||
/// <see cref="Unset"/> — decrementing from index 0 lands on Unset,
|
||||
/// incrementing from Unset lands on index 0, matching
|
||||
/// <c>ListenToElementMessage</c>'s cases <c>6</c> 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 <c>RandomizeCharacter</c>-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.
|
||||
/// </summary>
|
||||
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<ChargenGearOption> 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.
|
||||
};
|
||||
|
||||
/// <summary>Retail's SIX colorable parts (<c>SetColor</c>'s cases
|
||||
/// <c>0,1,5,6,7,8</c>) — Nose/Mouth/Skin have no color list at all.</summary>
|
||||
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,
|
||||
};
|
||||
|
||||
/// <summary>Retail's <c>SetShade</c> 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).</summary>
|
||||
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,
|
||||
};
|
||||
|
||||
/// <summary>Hair/Eyes have their own real per-gender color lists;
|
||||
/// the four clothing slots share the gender's single
|
||||
/// <see cref="ChargenGenderOptions.ClothingColors"/> list (register
|
||||
/// AP-208).</summary>
|
||||
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<T>(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;
|
||||
}
|
||||
}
|
||||
|
|
@ -137,30 +137,29 @@ internal sealed class CharacterCreationHeritagePage : IDisposable
|
|||
Select(chosen);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CC slice CC6b-MOUNT: AD-101 RETIRED. The Appearance page's
|
||||
/// real gender buttons (<c>0x100003a7</c>/<c>0x100003a8</c>) now exist,
|
||||
/// so this no longer needs to auto-select a gender to keep the
|
||||
/// Profession/Skills/Town pages usable — gender is a real player choice.
|
||||
/// Retail's own default here is genuinely NOT blank: <c>CharGenState::
|
||||
/// Reset @ 0x005C68A0</c> calls <c>SetGender(this, 0)</c> (unset), but
|
||||
/// <c>gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0</c> calls
|
||||
/// <c>CharGenState::RandomizeCharacter</c> (0x005c6d80) BEFORE any page
|
||||
/// constructs — retail's chargen screen always opens with a fully
|
||||
/// RANDOM heritage/gender/appearance/clothing/template/start-area
|
||||
/// already rolled (see the ~0x004e81f5-0x004e8218 ctor call, ahead of
|
||||
/// every page's own <c>InitializePage</c>). acdream does not port
|
||||
/// <c>RandomizeCharacter</c> this round (register AP-214, the same
|
||||
/// unported-primitive gap AP-212 already tracks for the Random button)
|
||||
/// — so acdream's screen opens honestly blank instead, and gender is now
|
||||
/// the player's first real choice on the Appearance page.
|
||||
/// </summary>
|
||||
private void Select(uint heritageId)
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
RuntimeCommandResult result = _bindings.SelectHeritage(heritageId);
|
||||
if (!result.Accepted)
|
||||
return;
|
||||
|
||||
// CC4 interim default (register AD-101): the Profession/Skills/Town
|
||||
// pages this slice builds need heritage+gender both selected
|
||||
// (RuntimeCharacterCreationState.TrySelectTemplate's gate), but
|
||||
// gender selection lives on the Appearance page (0x100003a7/a8),
|
||||
// which stays an inert placeholder until CC6b. Auto-select the
|
||||
// heritage's first available gender so those pages remain usable;
|
||||
// CC6b's real gender buttons supersede this and the row retires
|
||||
// then.
|
||||
IRuntimeCharacterCreationView? view = _bindings.View();
|
||||
if (view is not null
|
||||
&& view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage)
|
||||
&& heritage.GendersByKey.Count > 0)
|
||||
{
|
||||
int genderKey = heritage.GendersByKey.Keys.Min();
|
||||
_bindings.SelectGender((uint)genderKey);
|
||||
}
|
||||
_bindings.SelectHeritage(heritageId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,11 @@ public sealed record CharacterCreationRuntimeBindings(
|
|||
Func<int, RuntimeCommandResult> SelectStartArea,
|
||||
Func<bool, RuntimeCommandResult> Finish,
|
||||
Action RequestExit,
|
||||
/// <summary>Campaign CC slice CC6b-MOUNT: the Appearance page's nine
|
||||
/// spin controls and nine color swatches.</summary>
|
||||
Func<ChargenAppearanceSlot, uint, RuntimeCommandResult>? SetAppearanceIndex = null,
|
||||
/// <summary>CC6b-MOUNT: the Appearance page's shade scrollbar.</summary>
|
||||
Func<ChargenShadeSlot, double, RuntimeCommandResult>? SetShade = null,
|
||||
/// <summary>DAT string lookup (table <c>0x23000002</c>, the SAME table
|
||||
/// every other <c>ID_CharGen_*</c>/<c>ID_Character*</c> key resolves
|
||||
/// through) — used by the Heritage page's composed description text.
|
||||
|
|
@ -130,6 +135,7 @@ internal sealed class CharacterCreationUiController : IDisposable
|
|||
private readonly CharacterCreationProfessionPage _professionPage;
|
||||
private readonly CharacterCreationSkillsPage _skillsPage;
|
||||
private readonly CharacterCreationTownPage _townPage;
|
||||
private readonly CharacterCreationAppearancePage _appearancePage;
|
||||
|
||||
private Vector2 _authoredCanvas;
|
||||
private RuntimeGenerationToken _lastGeneration;
|
||||
|
|
@ -218,6 +224,7 @@ internal sealed class CharacterCreationUiController : IDisposable
|
|||
_professionPage = new CharacterCreationProfessionPage(professionPageRoot, bindings);
|
||||
_skillsPage = new CharacterCreationSkillsPage(skillsPageRoot, bindings, templateResolver);
|
||||
_townPage = new CharacterCreationTownPage(townPageRoot, bindings);
|
||||
_appearancePage = new CharacterCreationAppearancePage(appearancePageRoot, bindings);
|
||||
|
||||
// gmCharGenMainUI::ListenToElementMessage @ 0x004e9450.
|
||||
_back.OnClick = OnBack;
|
||||
|
|
@ -247,6 +254,30 @@ internal sealed class CharacterCreationUiController : IDisposable
|
|||
|
||||
internal UiElement Root => _layout.Root;
|
||||
|
||||
/// <summary>The authored Appearance-page viewport (<c>0x100003bb</c>) —
|
||||
/// CC6b-MOUNT's composition root assigns its <c>Renderer</c> once the
|
||||
/// graphics backend exists (mirrors the paperdoll's own late
|
||||
/// <c>viewport.Renderer = ...</c> assignment).</summary>
|
||||
internal UiViewport? AppearanceViewport => _appearancePage.Viewport;
|
||||
|
||||
/// <summary>CC6b-MOUNT: the late-bound zoom/rotate control surface —
|
||||
/// see <see cref="AcDream.App.Rendering.IChargenPreviewControl"/>'s own
|
||||
/// doc comment for why this is assigned after construction rather than
|
||||
/// threaded through the ctor.</summary>
|
||||
internal AcDream.App.Rendering.IChargenPreviewControl? AppearancePreviewControl
|
||||
{
|
||||
get => _appearancePage.PreviewControl;
|
||||
set => _appearancePage.PreviewControl = value;
|
||||
}
|
||||
|
||||
/// <summary>Gates the Appearance preview's per-frame work on whether
|
||||
/// that specific page — AND the whole chargen screen — is the one
|
||||
/// currently showing. <c>Close()</c> only ever hides <see cref="Root"/>,
|
||||
/// not the individual page roots, so a page-root-only check would stay
|
||||
/// true after the screen closes on the Appearance page. Mirrors the
|
||||
/// paperdoll's own outer-inventory-frame gate.</summary>
|
||||
internal bool IsAppearancePageVisible => Root.Visible && _appearancePageRoot.Visible;
|
||||
|
||||
internal static CharacterCreationUiController? CreateDetached(
|
||||
UiRoot host,
|
||||
ImportedLayout layout,
|
||||
|
|
@ -370,6 +401,7 @@ internal sealed class CharacterCreationUiController : IDisposable
|
|||
_professionPage.Refresh(view, snapshot);
|
||||
_skillsPage.Refresh(view, snapshot);
|
||||
_townPage.Refresh(view, snapshot);
|
||||
_appearancePage.Refresh(view, snapshot);
|
||||
_lastGeneration = snapshot.Generation;
|
||||
_lastRevision = snapshot.Revision;
|
||||
}
|
||||
|
|
@ -437,6 +469,7 @@ internal sealed class CharacterCreationUiController : IDisposable
|
|||
_professionPage.Dispose();
|
||||
_skillsPage.Dispose();
|
||||
_townPage.Dispose();
|
||||
_appearancePage.Dispose();
|
||||
_host.RemoveChild(Root);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -622,6 +622,31 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
internal CharacterCreationUiController? CharacterCreationController =>
|
||||
_characterCreationMount?.Controller;
|
||||
|
||||
/// <summary>Campaign CC slice CC6b-MOUNT: the Appearance page's authored
|
||||
/// viewport (<c>0x100003bb</c>) — null until the screen has mounted.
|
||||
/// Mirrors <see cref="PaperdollViewportWidget"/>'s own computed-through
|
||||
/// shape.</summary>
|
||||
internal UiViewport? ChargenPreviewViewportWidget =>
|
||||
CharacterCreationController?.AppearanceViewport;
|
||||
|
||||
/// <summary>CC6b-MOUNT: the late-bound zoom/rotate control surface the
|
||||
/// composition root assigns once the graphics backend exists.</summary>
|
||||
internal AcDream.App.Rendering.IChargenPreviewControl? ChargenPreviewControl
|
||||
{
|
||||
get => CharacterCreationController?.AppearancePreviewControl;
|
||||
set
|
||||
{
|
||||
if (CharacterCreationController is { } controller)
|
||||
controller.AppearancePreviewControl = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>CC6b-MOUNT: whether the Appearance page (specifically) is
|
||||
/// the one currently showing — false, safely, before the screen mounts.
|
||||
/// </summary>
|
||||
internal bool IsChargenPreviewPageVisible =>
|
||||
CharacterCreationController?.IsAppearancePageVisible ?? false;
|
||||
|
||||
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bindings);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue