feat(chargen): Campaign CC slice CC5 — Summary page, Finish flow, RandomizeCharacter port
Fills TS-82's Summary placeholder with a faithful port of gmCGSummaryPage (name field with NameInputFilter + the retail commit-on-focus-lost/submit dispatch + the >32-char ID_CharGen_NameTooLong reject-and-revert path, the REAL three-row-template listbox confirmed against the installed EoR dat before writing any page code, and Summary's own independent gmCG3DView preview instance wired through a second ChargenPreviewController pair mirroring the Appearance page's exact composition shape). Ports CharGenState::RandomizeCharacter and its six sub-primitives into RuntimeCharacterCreationState — not approximated: the RandInt/RollDice semantics are independently confirmed from both the decompiled RNG bodies and the CharGenStateVtbl union struct in acclient.h. Three consumers: the chargen screen's open-roll (retiring AP-214's honest-blank deviation and reproducing the Appearance page's gender-flip-on-init quirk), the Summary page's Random button (behind the retail randomize-warning confirm), and the Appearance page's Random button (narrowing AP-212 to just Heritage/Profession/Town's still-approximated rolls and Skills' still-unported RandomizeSkills). Wires the Finish button (previously ghosted) with retail's NoName/ CreditWarning dialog pair, adds the F12 amendment's HeritageOrGenderUnset local refusal to TryBeginFinish (register AP-223) as a defensive backstop now that the screen-open roll normally makes it unreachable, and wires the four ID_Character_Err_* rejection dialogs for the 0xF643 response codes CC3 already parsed but nothing displayed. Register: TS-82 retired, AP-214 retired, AP-212 narrowed, AP-223/224/225 filed (heritage/gender Finish refusal, Summary's two-bucket skill-list narrowing, the 32-vs-33 name-length threshold reconciliation). Runtime 1722/0 (was 1713), App 5240/3 skips (was 5223/3), Headless 166/0 unchanged, full solution Release build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
6114b2dda2
commit
34e3a534be
22 changed files with 2217 additions and 79 deletions
|
|
@ -282,6 +282,26 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
|
|||
RebuildPreview(view, snapshot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CC slice CC5: ports the Appearance case of
|
||||
/// <c>gmCharGenMainUI::DoRandom @ 0x004e7d70</c> (case 3) —
|
||||
/// <c>m_eCurType == ECG_CHOICE_CLOTHES ->
|
||||
/// CharGenState::RandomizeClothing(state, 1)</c>, else
|
||||
/// <c>CharGenState::RandomizeAppearance(state, 0)</c>. Retires the
|
||||
/// Appearance half of register AP-212 (the primitives are now real,
|
||||
/// faithful ports — see <c>RuntimeCharacterCreationState</c>'s own
|
||||
/// Randomize section — not a uniform-pick approximation).
|
||||
/// </summary>
|
||||
internal void Randomize()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
if (_currentChoice == Choice.Clothes)
|
||||
_bindings.RandomizeClothing?.Invoke();
|
||||
else
|
||||
_bindings.RandomizeAppearance?.Invoke();
|
||||
}
|
||||
|
||||
// ── Gender / Face-Clothes sub-tab ──────────────────────────────────
|
||||
|
||||
private void SelectChoice(Choice choice)
|
||||
|
|
|
|||
372
src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs
Normal file
372
src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
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 Summary page (<c>gmCGSummaryPage</c>, root <c>0x100003d6</c>) —
|
||||
/// Campaign CC slice CC5, retiring the TS-82 content-inert placeholder.
|
||||
/// Decomp anchors: <c>gmCGSummaryPage::InitializePage @ 0x0047bbf0</c>
|
||||
/// (widget ids, its OWN <c>gmCG3DView</c> instance, camera set + 180°
|
||||
/// heading + <c>StartAnimation</c> — a live idle-animated preview, not a
|
||||
/// static frozen frame), <c>::SetSummaryText @ 0x0047b1d0</c> (the listbox's
|
||||
/// three-template row content: template 0 = a single <c>UiText</c> line,
|
||||
/// template 1 = a category-header <c>UiText</c>, template 2 = a two-column
|
||||
/// key/value <c>UiText</c> pair — live-DAT-probe-confirmed against the
|
||||
/// installed EoR dat, resolving DID <c>0x2100004C</c> elements
|
||||
/// <c>0x100002F8/FA/FB</c>), <c>::ListenToElementMessage @ 0x0047bf40</c>
|
||||
/// (the name field's commit-on-idMessage-0x12-or-0x44 dispatch, the
|
||||
/// >32-char <c>ID_CharGen_NameTooLong</c> reject-and-revert path — see
|
||||
/// <see cref="CommitNameFromField"/>'s own doc comment for the 32-vs-33
|
||||
/// reconciliation), <c>::DoNameLimitDialog @ 0x0047bd80</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Listbox content scope cut (register-worthy, AP-213's own precedent):</b>
|
||||
/// retail's skills section walks FOUR buckets (Specialized/Trained/
|
||||
/// UseableUntrained/UnuseableUntrained) and lists every skill name in each.
|
||||
/// This port lists Specialized and Trained only — the two buckets a player
|
||||
/// actually spent credits on and would review before Finishing — and skips
|
||||
/// the two Untrained buckets (which would otherwise list the ~50 skills the
|
||||
/// player did NOT touch, adding volume without decision-relevant
|
||||
/// information). Health/Stamina/Mana reuse
|
||||
/// <see cref="CharacterCreationProfessionPage"/>'s own already-cited
|
||||
/// <c>UpdateAttributeValues @ 0x00482450</c> formulas (Health=Endurance/2,
|
||||
/// Stamina=Endurance, Mana=Self) rather than this page's OWN
|
||||
/// <c>SetSummaryText</c> call site, whose two GetAttribute calls for
|
||||
/// Health/Stamina are decompiler-ambiguous (both show a literal attribute
|
||||
/// index of 2 — ProfessionPage's site is the cleaner citation).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class CharacterCreationSummaryPage : IDisposable
|
||||
{
|
||||
internal const uint ListBoxId = 0x10000400u;
|
||||
internal const uint ScrollId = 0x10000401u;
|
||||
internal const uint NameTextId = 0x10000402u;
|
||||
internal const uint HowToTextId = 0x10000404u;
|
||||
internal const uint ViewportId = 0x10000406u;
|
||||
|
||||
/// <summary>Row-template child ids, live-DAT-probe-confirmed:
|
||||
/// template 0's single line, template 1's header line, template 2's
|
||||
/// key/value pair.</summary>
|
||||
private const uint SingleLineTextId = 0x100002F9u;
|
||||
private const uint HeaderTextId = 0x100000FEu;
|
||||
private const uint KeyTextId = 0x100002FCu;
|
||||
private const uint ValueTextId = 0x100002FDu;
|
||||
|
||||
/// <summary>Retail's <c>name[33]</c> buffer (32 usable chars + null
|
||||
/// terminator — <c>RuntimeCharacterCreationState.TrySetName</c>'s own
|
||||
/// already-established storage cap). The decompiled UI-side check at
|
||||
/// <c>ListenToElementMessage @ 0x0047bfd1</c> compares the raw input
|
||||
/// length against the literal <c>0x21</c> (33) — one more than this —
|
||||
/// but that comparison's exact base (visible character count vs. an
|
||||
/// internal length-prefix accounting the decompiler didn't resolve
|
||||
/// cleanly) is not fully certain from the pseudo-C. Using 32 here keeps
|
||||
/// the UI-level reject-and-revert threshold CONSISTENT with the
|
||||
/// already-reviewed storage cap rather than trusting an ambiguous
|
||||
/// 1-off decomp literal over that established contract.</summary>
|
||||
private const int MaxNameLength = 32;
|
||||
|
||||
private readonly CharacterCreationRuntimeBindings _bindings;
|
||||
private readonly RetailDialogFactory _dialogs;
|
||||
private readonly string _nameTooLongMessage;
|
||||
private readonly UiTemplateListBox? _list;
|
||||
private readonly UiField? _nameField;
|
||||
private string _lastCommittedName = string.Empty;
|
||||
private bool _suppressNextFieldEvent;
|
||||
private uint _nameTooLongDialogContext;
|
||||
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>0x10000406</c>) — Summary's OWN
|
||||
/// <c>gmCG3DView</c> instance, distinct from the Appearance page's.</summary>
|
||||
internal UiViewport? Viewport { get; }
|
||||
|
||||
internal CharacterCreationSummaryPage(
|
||||
UiElement pageRoot,
|
||||
CharacterCreationRuntimeBindings bindings,
|
||||
RetailDialogFactory dialogs,
|
||||
string nameTooLongMessage)
|
||||
{
|
||||
_bindings = bindings;
|
||||
_dialogs = dialogs;
|
||||
_nameTooLongMessage = nameTooLongMessage;
|
||||
|
||||
_list = UiElement.FindDescendant(pageRoot, ListBoxId) as UiTemplateListBox;
|
||||
|
||||
_nameField = UiElement.FindDescendant(pageRoot, NameTextId) as UiField;
|
||||
if (_nameField is not null)
|
||||
{
|
||||
// NameInputFilter @ 0x004663b0: ASCII letters, space, apostrophe,
|
||||
// hyphen — everything else is rejected per keystroke.
|
||||
_nameField.CharacterFilter = NameInputFilter;
|
||||
// Deliberately NOT capping UiField.MaxCharacters at MaxNameLength:
|
||||
// retail's own >32-char check (ListenToElementMessage's own
|
||||
// GetText().m_charbuffer length compare) only fires at COMMIT
|
||||
// time (idMessage 0x12/0x44), which means the textbox itself
|
||||
// accepts MORE than 32 characters while typing — the
|
||||
// DoNameLimitDialog reject-and-revert path exists specifically
|
||||
// to catch that post-typing case. A per-keystroke cap here would
|
||||
// make that whole retail code path structurally unreachable.
|
||||
// ListenToElementMessage @ 0x0047bf50: the name field commits on
|
||||
// idMessage 0x12 OR 0x44 — acdream's UiField exposes those two
|
||||
// triggers as OnFocusLost (clicking/tabbing away) and OnSubmit
|
||||
// (Enter). Both route through the same commit path.
|
||||
_nameField.OnFocusLost = CommitNameFromField;
|
||||
_nameField.OnSubmit = CommitNameFromField;
|
||||
_nameField.ClearOnSubmit = false;
|
||||
_nameField.RecordHistory = false;
|
||||
}
|
||||
|
||||
Viewport = UiElement.FindDescendant(pageRoot, ViewportId) as UiViewport;
|
||||
}
|
||||
|
||||
internal void Refresh(
|
||||
IRuntimeCharacterCreationView view,
|
||||
RuntimeCharacterCreationSnapshot snapshot)
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
// Keep the field's displayed text in sync with the committed name
|
||||
// unless the player is actively typing (a mid-edit Refresh — driven
|
||||
// by an unrelated selection change elsewhere on the screen — must
|
||||
// not clobber their in-progress keystrokes).
|
||||
if (_nameField is { IsFocused: false } field && field.Text != snapshot.Name)
|
||||
{
|
||||
_suppressNextFieldEvent = true;
|
||||
field.SetText(snapshot.Name);
|
||||
_lastCommittedName = snapshot.Name;
|
||||
}
|
||||
|
||||
RebuildListbox(view, snapshot);
|
||||
RebuildPreview(view, snapshot);
|
||||
}
|
||||
|
||||
// ── Name field (ListenToElementMessage @ 0x0047bf40) ────────────────
|
||||
|
||||
private void CommitNameFromField(string text)
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
if (_suppressNextFieldEvent)
|
||||
{
|
||||
_suppressNextFieldEvent = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (text.Length > MaxNameLength)
|
||||
{
|
||||
// DoNameLimitDialog @ 0x0047bd80 (ID_CharGen_NameTooLong):
|
||||
// revert the field to the last COMMITTED name rather than the
|
||||
// rejected input.
|
||||
_nameField?.SetText(_lastCommittedName);
|
||||
ShowNameTooLongDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
_lastCommittedName = text;
|
||||
_bindings.SetName?.Invoke(text);
|
||||
}
|
||||
|
||||
private void ShowNameTooLongDialog()
|
||||
{
|
||||
// DoNameLimitDialog's own guard: a context already open is a no-op.
|
||||
if (_nameTooLongDialogContext != 0u)
|
||||
return;
|
||||
_nameTooLongDialogContext = _dialogs.MakeMessage(
|
||||
_nameTooLongMessage,
|
||||
data =>
|
||||
{
|
||||
_ = data;
|
||||
_nameTooLongDialogContext = 0u;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Ports <c>NameInputFilter @ 0x004663b0</c> exactly: ASCII
|
||||
/// letters (<c>isalpha</c>), space (<c>0x20</c>), apostrophe
|
||||
/// (<c>0x27</c>), or hyphen (<c>0x2d</c>).</summary>
|
||||
private static bool NameInputFilter(char c) =>
|
||||
(c < 0x100 && char.IsAsciiLetter(c)) || c is ' ' or '\'' or '-';
|
||||
|
||||
// ── Listbox (SetSummaryText @ 0x0047b1d0) ───────────────────────────
|
||||
|
||||
private void RebuildListbox(
|
||||
IRuntimeCharacterCreationView view,
|
||||
RuntimeCharacterCreationSnapshot snapshot)
|
||||
{
|
||||
if (_list is null || _list.Templates.Count < 3)
|
||||
return;
|
||||
|
||||
_list.Flush();
|
||||
|
||||
if (!view.Options.TryGetHeritage(snapshot.HeritageId, out ChargenHeritageOptions? heritage))
|
||||
return;
|
||||
|
||||
UiTemplateListEntry lineTemplate = _list.Templates[0];
|
||||
UiTemplateListEntry headerTemplate = _list.Templates[1];
|
||||
UiTemplateListEntry pairTemplate = _list.Templates[2];
|
||||
|
||||
AddLine(lineTemplate, "Profession: " + ProfessionName(heritage, snapshot.Template));
|
||||
AddLine(lineTemplate, "Gender: " + GenderName(heritage, snapshot.GenderKey));
|
||||
AddLine(lineTemplate, "Heritage: " + heritage.Name);
|
||||
AddLine(lineTemplate, "Starting Town: " + StarterAreaName(view.Options, snapshot.StartArea));
|
||||
|
||||
AddHeader(headerTemplate, "Attributes");
|
||||
ChargenAttributeValues a = snapshot.Attributes;
|
||||
AddPair(pairTemplate, "Strength", a.Strength);
|
||||
AddPair(pairTemplate, "Endurance", a.Endurance);
|
||||
AddPair(pairTemplate, "Coordination", a.Coordination);
|
||||
AddPair(pairTemplate, "Quickness", a.Quickness);
|
||||
AddPair(pairTemplate, "Focus", a.Focus);
|
||||
AddPair(pairTemplate, "Self", a.Self);
|
||||
// CharacterCreationProfessionPage::Refresh's own already-cited
|
||||
// UpdateAttributeValues formulas (Health=Endurance/2, Stamina=
|
||||
// Endurance, Mana=Self) — see this class's own doc comment on why
|
||||
// that citation is used here instead of this page's own
|
||||
// decompiler-ambiguous GetAttribute(2)/GetAttribute(2) pair.
|
||||
AddPair(pairTemplate, "Health", a.Endurance / 2);
|
||||
AddPair(pairTemplate, "Stamina", a.Endurance);
|
||||
AddPair(pairTemplate, "Mana", a.Self);
|
||||
AddPair(pairTemplate, "Skill Credits", snapshot.RemainingSkillCredits);
|
||||
|
||||
AddSkillBucket(headerTemplate, lineTemplate, view, "Specialized Skills", ChargenSkillAdvancementClass.Specialized);
|
||||
AddSkillBucket(headerTemplate, lineTemplate, view, "Trained Skills", ChargenSkillAdvancementClass.Trained);
|
||||
}
|
||||
|
||||
private void AddLine(UiTemplateListEntry template, string text)
|
||||
{
|
||||
if (ResolveTemplateChild(template, SingleLineTextId) is { } child)
|
||||
SetLine(child, text);
|
||||
}
|
||||
|
||||
private void AddHeader(UiTemplateListEntry template, string text)
|
||||
{
|
||||
if (ResolveTemplateChild(template, HeaderTextId) is { } child)
|
||||
SetLine(child, text);
|
||||
}
|
||||
|
||||
private void AddPair(UiTemplateListEntry template, string key, int value)
|
||||
{
|
||||
UiElement? row = ResolveTemplateRow(template);
|
||||
if (row is null)
|
||||
return;
|
||||
if (UiElement.FindDescendant(row, KeyTextId) is UiText keyText)
|
||||
SetLine(keyText, key);
|
||||
if (UiElement.FindDescendant(row, ValueTextId) is UiText valueText)
|
||||
SetLine(valueText, value.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
private static void SetLine(UiText text, string content) =>
|
||||
text.LinesProvider = () => [new UiText.Line(content, text.DefaultColor)];
|
||||
|
||||
private UiElement? ResolveTemplateRow(UiTemplateListEntry template)
|
||||
{
|
||||
if (_list is null || _list.TemplateResolver is null)
|
||||
return null;
|
||||
UiElement? row = _list.TemplateResolver(template.TemplateLayoutId, template.TemplateElementId);
|
||||
if (row is null)
|
||||
return null;
|
||||
_list.AddPrebuiltRow(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
private UiText? ResolveTemplateChild(UiTemplateListEntry template, uint childId)
|
||||
{
|
||||
UiElement? row = ResolveTemplateRow(template);
|
||||
return row is null ? null : UiElement.FindDescendant(row, childId) as UiText;
|
||||
}
|
||||
|
||||
private void AddSkillBucket(
|
||||
UiTemplateListEntry headerTemplate,
|
||||
UiTemplateListEntry lineTemplate,
|
||||
IRuntimeCharacterCreationView view,
|
||||
string header,
|
||||
ChargenSkillAdvancementClass targetClass)
|
||||
{
|
||||
bool any = false;
|
||||
for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++)
|
||||
{
|
||||
if (view.GetSkillLevel(skillId) != targetClass)
|
||||
continue;
|
||||
if (!any)
|
||||
{
|
||||
AddHeader(headerTemplate, header);
|
||||
any = true;
|
||||
}
|
||||
AddLine(lineTemplate, ItemAppraisalTextFormatter.SkillName((int)skillId));
|
||||
}
|
||||
}
|
||||
|
||||
private static string ProfessionName(ChargenHeritageOptions heritage, uint template) =>
|
||||
template != RuntimeCharacterCreationSnapshot.TemplateUnset
|
||||
&& template < (uint)heritage.Templates.Count
|
||||
? heritage.Templates[(int)template].Name
|
||||
: "None";
|
||||
|
||||
private static string GenderName(ChargenHeritageOptions heritage, uint genderKey) =>
|
||||
heritage.GendersByKey.TryGetValue((int)genderKey, out ChargenGenderOptions? gender)
|
||||
? gender.Name
|
||||
: "None";
|
||||
|
||||
private static string StarterAreaName(ChargenOptions options, int startArea) =>
|
||||
startArea >= 0 && startArea < options.StarterAreas.Count
|
||||
? options.StarterAreas[startArea].Name
|
||||
: "None";
|
||||
|
||||
// ── Preview (own gmCG3DView — InitializePage @0x0047bbf0, camera set +
|
||||
// ── SetPlayerHeading(180) + StartAnimation, an idle-animated view) ───
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
if (_nameField is not null)
|
||||
{
|
||||
_nameField.OnFocusLost = null;
|
||||
_nameField.OnSubmit = null;
|
||||
}
|
||||
if (_nameTooLongDialogContext != 0u)
|
||||
{
|
||||
uint closing = _nameTooLongDialogContext;
|
||||
_nameTooLongDialogContext = 0u;
|
||||
_dialogs.CloseDialog(closing);
|
||||
}
|
||||
_list?.Flush();
|
||||
// PreviewControl is owned by the composition root (disposed with
|
||||
// the leased ChargenPreviewRenderer) — just drop the reference.
|
||||
PreviewControl = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.CharGen;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
|
|
@ -43,6 +44,23 @@ public sealed record CharacterCreationRuntimeBindings(
|
|||
/// <see langword="null"/> degrades to the heritage's own DAT
|
||||
/// <c>Name</c> field instead of the full composed copy.</summary>
|
||||
Func<string, string?>? ResolveText = null,
|
||||
/// <summary>Campaign CC slice CC5: the Summary page's name field
|
||||
/// commit (<c>gmCGSummaryPage::ListenToElementMessage</c>'s
|
||||
/// <c>CharGenState::SetName</c> call).</summary>
|
||||
Func<string, RuntimeCommandResult>? SetName = null,
|
||||
/// <summary>CC5: dismisses a surfaced <c>0xF643</c> rejection after its
|
||||
/// dialog closes (<c>RuntimeCharacterCreationState.TryAcknowledgeRejection</c>).</summary>
|
||||
Func<RuntimeCommandResult>? AcknowledgeRejection = null,
|
||||
/// <summary>CC5: the screen-open roll
|
||||
/// (<c>gmCharGenMainUI</c>'s ctor-time <c>RandomizeCharacter</c> call)
|
||||
/// and the Summary page's Random button.</summary>
|
||||
Func<RuntimeCommandResult>? RandomizeCharacter = null,
|
||||
/// <summary>CC5: the Appearance page's Random button on its Face
|
||||
/// sub-tab.</summary>
|
||||
Func<RuntimeCommandResult>? RandomizeAppearance = null,
|
||||
/// <summary>CC5: the Appearance page's Random button on its Clothes
|
||||
/// sub-tab.</summary>
|
||||
Func<RuntimeCommandResult>? RandomizeClothing = null,
|
||||
bool OpenOnStart = false);
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -106,7 +124,23 @@ internal sealed class CharacterCreationUiController : IDisposable
|
|||
Summary = 6,
|
||||
}
|
||||
|
||||
internal sealed record DialogStrings(string ExitWarning);
|
||||
internal sealed record DialogStrings(
|
||||
string ExitWarning,
|
||||
/// <summary>Campaign CC slice CC5: <c>ID_CharGen_NoNameWarning</c> —
|
||||
/// <c>DoFinish</c>'s empty-name refusal dialog.</summary>
|
||||
string NoNameWarning,
|
||||
/// <summary>CC5: <c>ID_CharGen_CreditWarning</c> —
|
||||
/// <c>MakeCreditWarningDialog</c>'s unspent-attribute-credits
|
||||
/// confirmation.</summary>
|
||||
string CreditWarning,
|
||||
/// <summary>CC5: <c>ID_CharGen_RandomizeWarning</c> —
|
||||
/// <c>MakeRandomizeWarningDialog</c>'s Summary-page Random
|
||||
/// confirmation.</summary>
|
||||
string RandomizeWarning,
|
||||
/// <summary>CC5: <c>ID_CharGen_NameTooLong</c> —
|
||||
/// <c>gmCGSummaryPage::DoNameLimitDialog</c>'s name-field-too-long
|
||||
/// notice.</summary>
|
||||
string NameTooLong);
|
||||
|
||||
private readonly UiRoot _host;
|
||||
private readonly ImportedLayout _layout;
|
||||
|
|
@ -138,6 +172,7 @@ internal sealed class CharacterCreationUiController : IDisposable
|
|||
private readonly CharacterCreationSkillsPage _skillsPage;
|
||||
private readonly CharacterCreationTownPage _townPage;
|
||||
private readonly CharacterCreationAppearancePage _appearancePage;
|
||||
private readonly CharacterCreationSummaryPage _summaryPage;
|
||||
|
||||
private Vector2 _authoredCanvas;
|
||||
private RuntimeGenerationToken _lastGeneration;
|
||||
|
|
@ -147,6 +182,13 @@ internal sealed class CharacterCreationUiController : IDisposable
|
|||
private bool _isOpen;
|
||||
private bool _openOnStartConsumed;
|
||||
private uint _exitDialogContext;
|
||||
// Campaign CC slice CC5: gmCharGenMainUI's own m_uiCreditWarningContext/
|
||||
// m_uiRandomizeWarningContext (0x004e8870/0x004e8a90) — same
|
||||
// one-outstanding-dialog-at-a-time guard shape as _exitDialogContext.
|
||||
private uint _creditWarningDialogContext;
|
||||
private uint _randomizeWarningDialogContext;
|
||||
private uint _noNameWarningDialogContext;
|
||||
private RuntimeCharacterCreationRejection? _lastShownRejection;
|
||||
private bool _suppressDialogCallbacks;
|
||||
private bool _disposed;
|
||||
|
||||
|
|
@ -227,18 +269,18 @@ internal sealed class CharacterCreationUiController : IDisposable
|
|||
_skillsPage = new CharacterCreationSkillsPage(skillsPageRoot, bindings, templateResolver);
|
||||
_townPage = new CharacterCreationTownPage(townPageRoot, bindings);
|
||||
_appearancePage = new CharacterCreationAppearancePage(appearancePageRoot, bindings);
|
||||
_summaryPage = new CharacterCreationSummaryPage(
|
||||
summaryPageRoot, bindings, dialogs, strings.NameTooLong);
|
||||
|
||||
// gmCharGenMainUI::ListenToElementMessage @ 0x004e9450.
|
||||
_back.OnClick = OnBack;
|
||||
_next.OnClick = OnNext;
|
||||
// Finish (0x100003c8) stays ghosted this round: Summary
|
||||
// (0x100003d6) is CC5's placeholder, and DoFinish's real gate
|
||||
// sequence lives in RuntimeCharacterCreationState.TryBeginFinish —
|
||||
// wiring the button here without a Summary page to confirm/collect
|
||||
// the name would let a click reach the wire with an empty name and
|
||||
// silently refuse. No OnClick handler; _finish.Enabled stays false
|
||||
// (see ApplyProgressState).
|
||||
_finish.OnClick = null;
|
||||
// Finish (0x100003c8): retail enables it on Summary only
|
||||
// (ListenToElementMessage's case 0x100003c8 no-ops unless
|
||||
// m_eProgressState == ECG_SUMMARY @ 0x004e956f) — ApplyProgressState
|
||||
// gates _finish.Enabled the same way. OnFinish itself re-checks the
|
||||
// current page defensively (mirroring that same retail guard).
|
||||
_finish.OnClick = OnFinish;
|
||||
// Help (0x100003c9) is not handled in gmCharGenMainUI's own
|
||||
// ListenToElementMessage switch (case 0x100003c9 falls straight
|
||||
// through to the base UIFramework handler) — retail has no custom
|
||||
|
|
@ -280,6 +322,26 @@ internal sealed class CharacterCreationUiController : IDisposable
|
|||
/// paperdoll's own outer-inventory-frame gate.</summary>
|
||||
internal bool IsAppearancePageVisible => Root.Visible && _appearancePageRoot.Visible;
|
||||
|
||||
/// <summary>Campaign CC slice CC5: the authored Summary-page viewport
|
||||
/// (<c>0x10000406</c>) — its OWN <c>gmCG3DView</c> instance, distinct
|
||||
/// from the Appearance page's (see this class's own class doc on the
|
||||
/// decomp citation).</summary>
|
||||
internal UiViewport? SummaryViewport => _summaryPage.Viewport;
|
||||
|
||||
/// <summary>CC5: the Summary preview's late-bound control surface. No
|
||||
/// zoom/rotate buttons bind against it — see
|
||||
/// <see cref="AcDream.App.Rendering.RetailSummaryPreviewPageVisibility"/>'s
|
||||
/// doc comment.</summary>
|
||||
internal AcDream.App.Rendering.IChargenPreviewControl? SummaryPreviewControl
|
||||
{
|
||||
get => _summaryPage.PreviewControl;
|
||||
set => _summaryPage.PreviewControl = value;
|
||||
}
|
||||
|
||||
/// <summary>CC5: same shape as <see cref="IsAppearancePageVisible"/>,
|
||||
/// for the Summary page.</summary>
|
||||
internal bool IsSummaryPageVisible => Root.Visible && _summaryPageRoot.Visible;
|
||||
|
||||
internal static CharacterCreationUiController? CreateDetached(
|
||||
UiRoot host,
|
||||
ImportedLayout layout,
|
||||
|
|
@ -404,6 +466,7 @@ internal sealed class CharacterCreationUiController : IDisposable
|
|||
_skillsPage.Refresh(view, snapshot);
|
||||
_townPage.Refresh(view, snapshot);
|
||||
_appearancePage.Refresh(view, snapshot);
|
||||
_summaryPage.Refresh(view, snapshot);
|
||||
_lastGeneration = snapshot.Generation;
|
||||
_lastRevision = snapshot.Revision;
|
||||
}
|
||||
|
|
@ -426,9 +489,40 @@ internal sealed class CharacterCreationUiController : IDisposable
|
|||
return;
|
||||
_isOpen = true;
|
||||
_host.DeclareFixedCanvas(this, _authoredCanvas);
|
||||
RollOpeningCharacter();
|
||||
ApplyProgressState(Page.Heritage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CC slice CC5: ports <c>gmCharGenMainUI</c>'s ctor-time roll
|
||||
/// (<c>~0x004e81f5-0x004e8218</c>) — <c>CharGenState::RandomizeCharacter
|
||||
/// (state, hasToD) @ 0x005c6d80</c> runs BEFORE any page constructs,
|
||||
/// retiring AP-214's honest-blank deviation (retail's chargen screen is
|
||||
/// never actually blank on open). Then reproduces
|
||||
/// <c>gmCGAppearancePage::InitializePage</c>'s own gender-read-then-FLIP
|
||||
/// (<c>~0x004802da-0x00480303</c>, decomp-confirmed:
|
||||
/// <c>mGender==1 -> SetGender(2)</c>, <c>mGender==2 -> SetGender(1)</c>) —
|
||||
/// a genuine, always-firing retail quirk that runs immediately AFTER
|
||||
/// <c>RandomizeCharacter</c> already assigned a real (non-zero) gender.
|
||||
/// Retail's whole UI tree (every page, including Appearance) is
|
||||
/// reconstructed fresh each time the chargen screen opens, so the flip
|
||||
/// fires once per visit there; acdream's pages are built once at mount
|
||||
/// time and only toggle visibility, so <see cref="Open"/> — the closest
|
||||
/// analogue to "runs once per screen-open" this architecture has — is
|
||||
/// where both the roll and the flip belong.
|
||||
/// </summary>
|
||||
private void RollOpeningCharacter()
|
||||
{
|
||||
if (_bindings.RandomizeCharacter?.Invoke().Status != RuntimeCommandStatus.Accepted)
|
||||
return;
|
||||
|
||||
uint gender = _bindings.View()?.Snapshot.GenderKey ?? 0u;
|
||||
if (gender == 1u)
|
||||
_bindings.SelectGender(2u);
|
||||
else if (gender == 2u)
|
||||
_bindings.SelectGender(1u);
|
||||
}
|
||||
|
||||
private void Close()
|
||||
{
|
||||
if (!_isOpen)
|
||||
|
|
@ -472,6 +566,7 @@ internal sealed class CharacterCreationUiController : IDisposable
|
|||
_skillsPage.Dispose();
|
||||
_townPage.Dispose();
|
||||
_appearancePage.Dispose();
|
||||
_summaryPage.Dispose();
|
||||
_host.RemoveChild(Root);
|
||||
}
|
||||
}
|
||||
|
|
@ -535,10 +630,16 @@ internal sealed class CharacterCreationUiController : IDisposable
|
|||
return;
|
||||
|
||||
// gmCharGenMainUI::DoRandom @ 0x004e7d70. Heritage/Profession/Town
|
||||
// are ported below; Skills' CharGenState::RandomizeSkills and the
|
||||
// Summary randomize-warning dialog have no CC3 primitive/page yet
|
||||
// this round — register AP-212 covers both gaps, and _random.Enabled
|
||||
// already keeps the control ghosted on those pages (ApplyProgressState).
|
||||
// still use the AP-212 uniform-pick approximation (unchanged this
|
||||
// slice); Appearance now delegates to the page's own real
|
||||
// RandomizeAppearance/RandomizeClothing primitives (CC5); Skills'
|
||||
// CharGenState::RandomizeSkills remains unported (AP-212, narrowed) —
|
||||
// _random.Enabled already keeps the control ghosted there
|
||||
// (ApplyProgressState). Summary goes through
|
||||
// gmCharGenMainUI::MakeRandomizeWarningDialog @ 0x004e8a90 first —
|
||||
// that dialog + its confirm-triggered RandomizeCharacter call are
|
||||
// gmCharGenMainUI's OWN methods in retail (not gmCGSummaryPage's),
|
||||
// so they live here on the master controller.
|
||||
IRuntimeCharacterCreationView? view = _bindings.View();
|
||||
if (view is null)
|
||||
return;
|
||||
|
|
@ -552,12 +653,44 @@ internal sealed class CharacterCreationUiController : IDisposable
|
|||
case Page.Profession:
|
||||
_professionPage.Randomize(snapshot);
|
||||
break;
|
||||
case Page.Appearance:
|
||||
_appearancePage.Randomize();
|
||||
break;
|
||||
case Page.Town:
|
||||
_townPage.Randomize(view);
|
||||
break;
|
||||
case Page.Summary:
|
||||
ShowRandomizeWarningDialog();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ports <c>gmCharGenMainUI::MakeRandomizeWarningDialog @
|
||||
/// 0x004e8a90</c> (<c>ID_CharGen_RandomizeWarning</c>) +
|
||||
/// <c>CloseRandomizeWarningDialog @ 0x004e8400</c>'s own confirm arm
|
||||
/// (<c>arg2 != 0 -> DoRandom(this)</c>, which on THIS second call
|
||||
/// takes <c>DoRandom</c>'s Summary case directly — no re-entrant
|
||||
/// warning, since the gate lives in the button-click dispatcher above,
|
||||
/// not inside <c>DoRandom</c> itself).</summary>
|
||||
private void ShowRandomizeWarningDialog()
|
||||
{
|
||||
// MakeRandomizeWarningDialog's own guard: a second click while the
|
||||
// dialog is already open is a no-op.
|
||||
if (_randomizeWarningDialogContext != 0u)
|
||||
return;
|
||||
|
||||
_randomizeWarningDialogContext = _dialogs.MakeConfirmation(
|
||||
_strings.RandomizeWarning,
|
||||
data =>
|
||||
{
|
||||
_randomizeWarningDialogContext = 0u;
|
||||
if (_disposed || _suppressDialogCallbacks)
|
||||
return;
|
||||
if (data.GetBoolean(RetailDialogProperty.ConfirmationResult))
|
||||
_bindings.RandomizeCharacter?.Invoke();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Page switching (gmCharGenMainUI::SetProgressState @ 0x004e7a10) ────
|
||||
|
||||
private void ApplyProgressState(Page target)
|
||||
|
|
@ -641,19 +774,14 @@ internal sealed class CharacterCreationUiController : IDisposable
|
|||
break;
|
||||
}
|
||||
|
||||
// Random (0x100003cb): fix round F5 — retail's DoRandom @0x004e7d70
|
||||
// case 3 fully ENABLES Random on Appearance (RandomizeClothing when
|
||||
// m_eCurType == ECG_CHOICE_CLOTHES, else RandomizeAppearance); this
|
||||
// is NOT a placeholder gap the way the old comment claimed. The
|
||||
// disable here rests on the SAME unported-primitive gap AP-212
|
||||
// tracks for Skills (no RandomizeSkills) and Summary (no
|
||||
// RandomizeCharacter) — RandomizeAppearance/RandomizeClothing are
|
||||
// two more of AP-212's six named-but-unported primitives.
|
||||
_random.Enabled = _currentPage
|
||||
is not (Page.Skills or Page.Appearance or Page.Summary);
|
||||
// Finish stays ghosted regardless of page — Summary is a
|
||||
// placeholder this round (see the ctor comment on _finish.OnClick).
|
||||
_finish.Enabled = false;
|
||||
// Random (0x100003cb): CC5 ports RandomizeAppearance/RandomizeClothing
|
||||
// (Appearance) and RandomizeCharacter (Summary), retiring both gaps
|
||||
// AP-212 used to track for those two pages — only Skills'
|
||||
// RandomizeSkills remains unported (AP-212, narrowed).
|
||||
_random.Enabled = _currentPage is not Page.Skills;
|
||||
// Finish (0x100003c8): retail enables it on Summary only
|
||||
// (ListenToElementMessage's case 0x100003c8 no-ops off Summary).
|
||||
_finish.Enabled = _currentPage == Page.Summary;
|
||||
|
||||
_lastRevision = long.MinValue;
|
||||
Tick();
|
||||
|
|
@ -719,12 +847,130 @@ internal sealed class CharacterCreationUiController : IDisposable
|
|||
// Else (including Lugian, 0x100005f1): no-op, matching retail.
|
||||
}
|
||||
|
||||
// ── Finish (gmCharGenMainUI::DoFinish @ 0x004E9170) ─────────────────
|
||||
|
||||
/// <summary>The Finish button's ordinary click — retail's <c>arg2 = 1</c>
|
||||
/// call site (<c>0x004E9579</c>). Re-checks the current page defensively,
|
||||
/// mirroring <c>ListenToElementMessage</c>'s own
|
||||
/// <c>m_eProgressState != ECG_SUMMARY</c> no-op guard.</summary>
|
||||
private void OnFinish()
|
||||
{
|
||||
if (_disposed || _currentPage != Page.Summary)
|
||||
return;
|
||||
TryFinish(confirmedUnspentCredits: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends via <see cref="CharacterCreationRuntimeBindings.Finish"/>
|
||||
/// (which itself calls <c>RuntimeCharacterCreationState.TryBeginFinish</c>);
|
||||
/// on a LOCAL refusal, surfaces retail's own dialog for the two refusal
|
||||
/// reasons retail dialogs at all (<c>NoName</c> ->
|
||||
/// <c>ID_CharGen_NoNameWarning</c>; <c>AttributeCreditsUnspent</c> ->
|
||||
/// the credit-warning confirm, whose OWN confirm re-invokes this method
|
||||
/// with <paramref name="confirmedUnspentCredits"/> — retail's
|
||||
/// <c>arg2 == 0</c> call site, <c>0x004E98BB</c>). The remaining local
|
||||
/// refusals (<c>HeritageOrGenderUnset</c>, <c>AlreadyPending</c>,
|
||||
/// <c>RosterFull</c>) have no retail dialog citation — retail's own
|
||||
/// <c>DoFinish</c> silently falls through to its final <c>return 0</c>
|
||||
/// for an already-Pending double-click, and the other two are
|
||||
/// acdream-only additions (register AP-223, AP-211) with the same
|
||||
/// silent-refusal shape.
|
||||
/// </summary>
|
||||
private void TryFinish(bool confirmedUnspentCredits)
|
||||
{
|
||||
if (_bindings.Finish(confirmedUnspentCredits).Status != RuntimeCommandStatus.Rejected)
|
||||
return;
|
||||
|
||||
RuntimeCharacterCreationLocalRefusal refusal =
|
||||
_bindings.View()?.Snapshot.LastLocalRefusal ?? default;
|
||||
if (refusal.NoName)
|
||||
ShowNoNameWarningDialog();
|
||||
else if (refusal.AttributeCreditsUnspent)
|
||||
ShowCreditWarningDialog();
|
||||
}
|
||||
|
||||
/// <summary>Ports the empty-name half of <c>DoFinish</c>
|
||||
/// (<c>ID_CharGen_NoNameWarning</c>, <c>@0x004e91dd</c>) — a plain
|
||||
/// informational dialog, no confirm/cancel semantics.</summary>
|
||||
private void ShowNoNameWarningDialog()
|
||||
{
|
||||
if (_noNameWarningDialogContext != 0u)
|
||||
return;
|
||||
_noNameWarningDialogContext = _dialogs.MakeMessage(
|
||||
_strings.NoNameWarning,
|
||||
data =>
|
||||
{
|
||||
_ = data;
|
||||
_noNameWarningDialogContext = 0u;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Ports <c>gmCharGenMainUI::MakeCreditWarningDialog @
|
||||
/// 0x004e8870</c> (<c>ID_CharGen_CreditWarning</c>) — on confirm,
|
||||
/// re-invokes <see cref="TryFinish"/> with
|
||||
/// <c>confirmedUnspentCredits: true</c>, retail's <c>DoFinish(this, 0)</c>
|
||||
/// call at <c>RecvNotice_CloseDialog @0x004e98bb</c>.</summary>
|
||||
private void ShowCreditWarningDialog()
|
||||
{
|
||||
if (_creditWarningDialogContext != 0u)
|
||||
return;
|
||||
_creditWarningDialogContext = _dialogs.MakeConfirmation(
|
||||
_strings.CreditWarning,
|
||||
data =>
|
||||
{
|
||||
_creditWarningDialogContext = 0u;
|
||||
if (_disposed || _suppressDialogCallbacks)
|
||||
return;
|
||||
if (data.GetBoolean(RetailDialogProperty.ConfirmationResult))
|
||||
TryFinish(confirmedUnspentCredits: true);
|
||||
});
|
||||
}
|
||||
|
||||
// ── 0xF643 rejection dialogs (Handle_CharGenVerificationResponse @ ──
|
||||
// ── 0x0055E8B0) ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Ports the four rejection-dialog mappings from
|
||||
/// <c>Handle_CharGenVerificationResponse</c>'s per-case switch (restated
|
||||
/// on <see cref="RuntimeCharacterCreationRejection"/>'s own doc
|
||||
/// comment); Pending/Undef never reach this method (CC3's
|
||||
/// <c>ApplyCreationResponse</c> treats them as a silent state reset with
|
||||
/// no <see cref="RuntimeCharacterCreationRejection"/> produced at all).
|
||||
/// Dedups against the LAST rejection instance already shown so a
|
||||
/// same-value re-check on a later <see cref="Tick"/> (this method runs
|
||||
/// every tick, not just on revision change) doesn't reopen the dialog
|
||||
/// the player already dismissed.</summary>
|
||||
private void ReconcileDialogs(RuntimeCharacterCreationSnapshot snapshot)
|
||||
{
|
||||
// Local-refusal / rejection surfacing is CC5's Summary-page job
|
||||
// (the Finish gate only fires from that page). This round only
|
||||
// needs the exit-confirmation dialog reconciled against disposal.
|
||||
_ = snapshot;
|
||||
RuntimeCharacterCreationRejection? rejection = snapshot.LastRejection;
|
||||
if (rejection is null)
|
||||
{
|
||||
_lastShownRejection = null;
|
||||
return;
|
||||
}
|
||||
if (_lastShownRejection == rejection)
|
||||
return;
|
||||
_lastShownRejection = rejection;
|
||||
|
||||
string? key = rejection.Value.Code switch
|
||||
{
|
||||
CharGenVerificationResponse.Code.NameInUse => "ID_Character_Err_NameReserved",
|
||||
CharGenVerificationResponse.Code.NameBanned => "ID_Character_Err_NameBanned",
|
||||
CharGenVerificationResponse.Code.Corrupt
|
||||
or CharGenVerificationResponse.Code.DatabaseDown => "ID_Character_Err_NameDBDown",
|
||||
CharGenVerificationResponse.Code.AdminPrivilegeDenied => "ID_Character_Err_NameAdminDenied",
|
||||
_ => null,
|
||||
};
|
||||
if (key is null)
|
||||
return;
|
||||
string? message = _bindings.ResolveText?.Invoke(key);
|
||||
if (message is null)
|
||||
return;
|
||||
|
||||
_dialogs.MakeMessage(message, data =>
|
||||
{
|
||||
_ = data;
|
||||
_bindings.AcknowledgeRejection?.Invoke();
|
||||
});
|
||||
}
|
||||
|
||||
private void Deactivate()
|
||||
|
|
@ -750,6 +996,24 @@ internal sealed class CharacterCreationUiController : IDisposable
|
|||
_exitDialogContext = 0u;
|
||||
_dialogs.CloseDialog(closing);
|
||||
}
|
||||
if (_creditWarningDialogContext != 0u)
|
||||
{
|
||||
uint closing = _creditWarningDialogContext;
|
||||
_creditWarningDialogContext = 0u;
|
||||
_dialogs.CloseDialog(closing);
|
||||
}
|
||||
if (_randomizeWarningDialogContext != 0u)
|
||||
{
|
||||
uint closing = _randomizeWarningDialogContext;
|
||||
_randomizeWarningDialogContext = 0u;
|
||||
_dialogs.CloseDialog(closing);
|
||||
}
|
||||
if (_noNameWarningDialogContext != 0u)
|
||||
{
|
||||
uint closing = _noNameWarningDialogContext;
|
||||
_noNameWarningDialogContext = 0u;
|
||||
_dialogs.CloseDialog(closing);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
|
|||
|
|
@ -669,6 +669,34 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
internal bool IsChargenPreviewPageVisible =>
|
||||
CharacterCreationController?.IsAppearancePageVisible ?? false;
|
||||
|
||||
/// <summary>Campaign CC slice CC5: the Summary page's OWN authored
|
||||
/// viewport (<c>0x10000406</c>) — same one-shot GPU-composition
|
||||
/// disposition as <see cref="ChargenPreviewViewportWidget"/> (see that
|
||||
/// property's own doc comment; AP-221 covers both).</summary>
|
||||
internal UiViewport? SummaryPreviewViewportWidget =>
|
||||
CharacterCreationController?.SummaryViewport;
|
||||
|
||||
/// <summary>Campaign CC slice CC5: the Summary preview's late-bound
|
||||
/// control surface. No zoom/rotate buttons bind against it (retail's
|
||||
/// Summary page has none) — the composition root assigns it purely so
|
||||
/// <see cref="AcDream.App.Rendering.ChargenPreviewController.Rebuild"/>
|
||||
/// gets driven per-selection-change the same way the Appearance
|
||||
/// preview's is.</summary>
|
||||
internal AcDream.App.Rendering.IChargenPreviewControl? SummaryPreviewControl
|
||||
{
|
||||
get => CharacterCreationController?.SummaryPreviewControl;
|
||||
set
|
||||
{
|
||||
if (CharacterCreationController is { } controller)
|
||||
controller.SummaryPreviewControl = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Campaign CC slice CC5: whether the Summary page
|
||||
/// (specifically) is the one currently showing.</summary>
|
||||
internal bool IsSummaryPreviewPageVisible =>
|
||||
CharacterCreationController?.IsSummaryPageVisible ?? false;
|
||||
|
||||
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bindings);
|
||||
|
|
@ -3977,14 +4005,38 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
}
|
||||
|
||||
string? exitWarning;
|
||||
string? noNameWarning;
|
||||
string? creditWarning;
|
||||
string? randomizeWarning;
|
||||
string? nameTooLong;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
exitWarning = ResolveCharacterManagementString(
|
||||
strings,
|
||||
stringTableId,
|
||||
"ID_CharGen_ExitWarning");
|
||||
noNameWarning = ResolveCharacterManagementString(
|
||||
strings,
|
||||
stringTableId,
|
||||
"ID_CharGen_NoNameWarning");
|
||||
creditWarning = ResolveCharacterManagementString(
|
||||
strings,
|
||||
stringTableId,
|
||||
"ID_CharGen_CreditWarning");
|
||||
randomizeWarning = ResolveCharacterManagementString(
|
||||
strings,
|
||||
stringTableId,
|
||||
"ID_CharGen_RandomizeWarning");
|
||||
nameTooLong = ResolveCharacterManagementString(
|
||||
strings,
|
||||
stringTableId,
|
||||
"ID_CharGen_NameTooLong");
|
||||
}
|
||||
if (exitWarning is null)
|
||||
if (exitWarning is null
|
||||
|| noNameWarning is null
|
||||
|| creditWarning is null
|
||||
|| randomizeWarning is null
|
||||
|| nameTooLong is null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
"[UI] character creation: required retail strings are unavailable.");
|
||||
|
|
@ -4009,7 +4061,8 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
layoutId,
|
||||
layout,
|
||||
ResolveTemplate,
|
||||
new CharacterCreationUiController.DialogStrings(exitWarning));
|
||||
new CharacterCreationUiController.DialogStrings(
|
||||
exitWarning, noNameWarning, creditWarning, randomizeWarning, nameTooLong));
|
||||
}
|
||||
|
||||
private void MountItemCooldowns()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue