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
298
src/AcDream.App/Rendering/ChargenPreviewController.cs
Normal file
298
src/AcDream.App/Rendering/ChargenPreviewController.cs
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
using System.Diagnostics;
|
||||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Content;
|
||||
using AcDream.Core.CharGen;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using DatReaderWriter;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CC slice CC6b-MOUNT: the page-mount half's control surface over
|
||||
/// the CC6a/CC6b-PRE preview foundation. <see cref="CharacterCreationAppearancePage"/>
|
||||
/// is constructed BEFORE the graphical presentation pipeline exists (early
|
||||
/// retained-UI composition — see <see cref="AcDream.App.UI.Layout.CharacterCreationRuntimeBindings"/>'s
|
||||
/// own late-bound-Func doc comment), so its zoom/rotate buttons bind against
|
||||
/// this interface's default no-op-until-assigned shape rather than a
|
||||
/// concrete renderer reference. <see cref="AcDream.App.Composition.LivePresentationComposition"/>
|
||||
/// constructs the real <see cref="ChargenPreviewController"/> once the
|
||||
/// graphics backend exists and assigns it onto the page — mirroring exactly
|
||||
/// how the paperdoll's <c>viewport.Renderer = paperdollLease.Resource</c>
|
||||
/// late-assignment already works for a DIFFERENT screen's viewport.
|
||||
/// </summary>
|
||||
internal interface IChargenPreviewControl
|
||||
{
|
||||
/// <summary>
|
||||
/// Recomposes and rebuilds the preview entity when the heritage/gender/
|
||||
/// appearance selection actually changed since the last call (a cheap
|
||||
/// no-op otherwise). Returns false when the selection cannot be
|
||||
/// resolved/built (heritage or gender not yet chosen, or a missing dat
|
||||
/// resource) — the caller (the page) simply leaves the previous frame on
|
||||
/// screen, matching <c>PaperdollFramePresenter</c>'s own
|
||||
/// "keep the successful doll, retry next visible frame" precedent.
|
||||
/// </summary>
|
||||
bool Rebuild(
|
||||
ChargenOptions options,
|
||||
uint heritageId,
|
||||
int genderKey,
|
||||
ChargenAppearanceSelection selection);
|
||||
|
||||
void ZoomIn();
|
||||
void ZoomOut();
|
||||
void RotateClockwise();
|
||||
void RotateCounterClockwise();
|
||||
}
|
||||
|
||||
/// <summary>Gates the preview's per-frame work on whether the Appearance
|
||||
/// PAGE (not just the leaf viewport widget) is the currently visible page —
|
||||
/// mirrors <c>IPaperdollInventoryVisibility</c>'s outer-frame gate.</summary>
|
||||
internal interface IChargenPreviewPageVisibility
|
||||
{
|
||||
bool IsVisible { get; }
|
||||
}
|
||||
|
||||
/// <summary>CC6b-MOUNT: narrow seam mirroring <c>IPaperdollFrameView</c> so
|
||||
/// <see cref="ChargenPreviewController"/> can be exercised with a fake view
|
||||
/// in tests.</summary>
|
||||
internal interface IChargenPreviewFrameView
|
||||
{
|
||||
bool TryGetVisibleSize(out int width, out int height);
|
||||
|
||||
void SetTextureHandle(uint textureHandle);
|
||||
}
|
||||
|
||||
/// <summary>Thin adapter over <c>RetailUiRuntime.IsChargenPreviewPageVisible</c>
|
||||
/// — narrowed to <see cref="IChargenPreviewPageVisibility"/> so this
|
||||
/// Rendering-namespace class doesn't need a direct dependency on the
|
||||
/// UI/Layout-namespace <c>RetailUiRuntime</c> type beyond the one property
|
||||
/// read.</summary>
|
||||
internal sealed class RetailChargenPreviewPageVisibility : IChargenPreviewPageVisibility
|
||||
{
|
||||
private readonly AcDream.App.UI.RetailUiRuntime _runtime;
|
||||
|
||||
public RetailChargenPreviewPageVisibility(AcDream.App.UI.RetailUiRuntime runtime) =>
|
||||
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||
|
||||
public bool IsVisible => _runtime.IsChargenPreviewPageVisible;
|
||||
}
|
||||
|
||||
/// <summary>Retained-UI visibility + texture publication, mirroring
|
||||
/// <c>RetailPaperdollFrameView</c>.</summary>
|
||||
internal sealed class RetailChargenPreviewFrameView : IChargenPreviewFrameView
|
||||
{
|
||||
private readonly UiViewport _viewport;
|
||||
private readonly IChargenPreviewPageVisibility _page;
|
||||
|
||||
public RetailChargenPreviewFrameView(
|
||||
UiViewport viewport,
|
||||
IChargenPreviewPageVisibility page)
|
||||
{
|
||||
_viewport = viewport ?? throw new ArgumentNullException(nameof(viewport));
|
||||
_page = page ?? throw new ArgumentNullException(nameof(page));
|
||||
}
|
||||
|
||||
public bool TryGetVisibleSize(out int width, out int height)
|
||||
{
|
||||
width = 0;
|
||||
height = 0;
|
||||
if (!_viewport.Visible || !_page.IsVisible)
|
||||
return false;
|
||||
|
||||
width = (int)_viewport.Width;
|
||||
height = (int)_viewport.Height;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void SetTextureHandle(uint textureHandle) =>
|
||||
_viewport.TextureSlot = UiTextureTableHandle.ToSlot(textureHandle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The real, dat-touching implementation of <see cref="IChargenPreviewControl"/>
|
||||
/// plus the per-frame <see cref="IPrivateEntityViewportFrame"/> owner —
|
||||
/// constructed once in <see cref="AcDream.App.Composition.LivePresentationComposition"/>
|
||||
/// (same composition scope <c>RetailPaperdollPoseApplicator</c> is built in,
|
||||
/// which has the real <c>content.Dats</c>/<c>content.AnimationLoader</c>/
|
||||
/// <c>d.DatLock</c>) and assigned onto the already-mounted Appearance page.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Camera/zoom/rotation ownership (CC6b-MOUNT bridges a CC6a/CC6b-PRE gap):</b>
|
||||
/// <see cref="ChargenPreviewRenderer"/> only ever built its OWN private
|
||||
/// <see cref="ChargenPreviewCamera"/> with no injection seam, but
|
||||
/// <see cref="ChargenPreviewZoomController"/> needs a SETTABLE camera to
|
||||
/// tween. This class owns the ONE <see cref="ChargenPreviewCamera"/>
|
||||
/// instance and hands it to the renderer via the new
|
||||
/// <see cref="ChargenPreviewViewportCamera(ChargenPreviewCamera)"/> overload,
|
||||
/// so both the renderer's draw and the zoom controller's tween read/write
|
||||
/// the exact same eye position.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Rebuild vs per-frame ownership split, decomp-cited (retail
|
||||
/// <c>gmCGAppearancePage::Update @ 0x0047E8F0</c>):</b> the camera SNAPS to
|
||||
/// the heritage's default (zoomed-in) eye only on a HERITAGE or GENDER
|
||||
/// change (the two confirmed direct call sites of the outer <c>Update</c> —
|
||||
/// <c>InitializePage</c> and the two gender-button handlers,
|
||||
/// <c>ListenToElementMessage</c> cases <c>0x9d</c>/<c>0x9e</c>) — spin/color/
|
||||
/// shade changes call the narrower <c>SetSelection</c>/<c>SetColor</c>/
|
||||
/// <c>SetShade</c> instead, none of which touch <c>m_vectCurPosition</c>.
|
||||
/// <see cref="Rebuild"/> reproduces that split: it always recomposes the
|
||||
/// ObjDesc/mesh (every appearance field feeds <c>gmCG3DView::Update</c>'s
|
||||
/// rebuild eventually), but only resets the camera when heritage or gender
|
||||
/// actually changed. <c>m_fCurHeading</c> (this class's
|
||||
/// <see cref="ChargenPreviewRotationController"/>) and <c>m_bZoomedIn</c>
|
||||
/// (read through <see cref="ChargenPreviewAnimator.IsZoomedIn"/>) both live
|
||||
/// on the PAGE in retail and are NEVER reset by <c>Update</c> — so a fresh
|
||||
/// <see cref="ChargenPreviewAnimator"/> (unavoidable: it owns the resolved
|
||||
/// drawable-part list, which changes with the mesh) is immediately restored
|
||||
/// to the PREVIOUS zoom state, and the current accumulated heading is passed
|
||||
/// into the rebuild rather than resetting to the retail default.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class ChargenPreviewController :
|
||||
IChargenPreviewControl,
|
||||
IPrivateEntityViewportFrame,
|
||||
IDisposable
|
||||
{
|
||||
private readonly IChargenPreviewRenderer _renderer;
|
||||
private readonly IChargenPreviewFrameView _view;
|
||||
private readonly ChargenPreviewCamera _camera;
|
||||
private readonly ChargenPreviewRotationController _rotation;
|
||||
private readonly IDatReaderWriter _dats;
|
||||
private readonly IAnimationLoader _animations;
|
||||
private readonly IChargenPalSetSource _palSets;
|
||||
private readonly IChargenClothingTableSource _clothingTables;
|
||||
private readonly object _datLock;
|
||||
private readonly Stopwatch _clock = Stopwatch.StartNew();
|
||||
|
||||
private ChargenPreviewAnimator? _animator;
|
||||
private ChargenPreviewZoomController? _zoom;
|
||||
private double _lastElapsedSeconds;
|
||||
private bool _hasComposed;
|
||||
private uint _lastHeritageId;
|
||||
private int _lastGenderKey = -1;
|
||||
private ChargenAppearanceSelection _lastSelection;
|
||||
private bool _disposed;
|
||||
|
||||
/// <param name="camera">The SAME instance passed to the
|
||||
/// <see cref="ChargenPreviewRenderer"/>'s own <c>camera</c> constructor
|
||||
/// parameter — see this class's own doc comment on why the renderer and
|
||||
/// the zoom controller must share one mutable camera.</param>
|
||||
public ChargenPreviewController(
|
||||
IChargenPreviewRenderer renderer,
|
||||
ChargenPreviewCamera camera,
|
||||
IChargenPreviewFrameView view,
|
||||
IDatReaderWriter dats,
|
||||
IAnimationLoader animations,
|
||||
IChargenPalSetSource palSets,
|
||||
IChargenClothingTableSource clothingTables,
|
||||
object datLock)
|
||||
{
|
||||
_renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
|
||||
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
|
||||
_view = view ?? throw new ArgumentNullException(nameof(view));
|
||||
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
|
||||
_animations = animations ?? throw new ArgumentNullException(nameof(animations));
|
||||
_palSets = palSets ?? throw new ArgumentNullException(nameof(palSets));
|
||||
_clothingTables = clothingTables ?? throw new ArgumentNullException(nameof(clothingTables));
|
||||
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
|
||||
_rotation = new ChargenPreviewRotationController();
|
||||
}
|
||||
|
||||
/// <summary>Test-observability seam only — production callers use
|
||||
/// <see cref="ZoomIn"/>/<see cref="ZoomOut"/>.</summary>
|
||||
internal bool IsZoomedIn => _zoom?.IsZoomedIn ?? false;
|
||||
|
||||
/// <summary>Test-observability seam only.</summary>
|
||||
internal Vector3 CameraEye => _camera.Eye;
|
||||
|
||||
public bool Rebuild(
|
||||
ChargenOptions options,
|
||||
uint heritageId,
|
||||
int genderKey,
|
||||
ChargenAppearanceSelection selection)
|
||||
{
|
||||
if (_disposed)
|
||||
return false;
|
||||
|
||||
if (_hasComposed
|
||||
&& heritageId == _lastHeritageId
|
||||
&& genderKey == _lastGenderKey
|
||||
&& selection.Equals(_lastSelection))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!ChargenAppearanceFactory.TryCompose(
|
||||
options, heritageId, genderKey, selection,
|
||||
_palSets, _clothingTables, out ChargenAppearanceResult result))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Quaternion heading = MoveToMath.SetHeading(
|
||||
Quaternion.Identity, _rotation.HeadingDegrees);
|
||||
ChargenPreviewAnimatedBuild? build = ChargenPreviewEntityBuilder.TryBuildAnimated(
|
||||
_dats, _animations, result, heritageId, heading, _datLock);
|
||||
if (build is null)
|
||||
return false;
|
||||
|
||||
bool wasZoomedIn = _animator?.IsZoomedIn ?? false;
|
||||
_animator = new ChargenPreviewAnimator(build);
|
||||
if (wasZoomedIn)
|
||||
_animator.SetZoomedIn(true);
|
||||
|
||||
bool heritageOrGenderChanged =
|
||||
!_hasComposed || heritageId != _lastHeritageId || genderKey != _lastGenderKey;
|
||||
if (heritageOrGenderChanged)
|
||||
_camera.SetHeritage(heritageId);
|
||||
|
||||
// ChargenPreviewZoomController's animator dependency is required at
|
||||
// construction (fix round F2) — a fresh animator means a fresh
|
||||
// controller, but it reads IsZoomedIn straight through the animator
|
||||
// we just restored above, so zoom state itself survives the swap.
|
||||
_zoom = new ChargenPreviewZoomController(heritageId, _camera, _animator);
|
||||
|
||||
_renderer.SetPreview(_animator.Entity);
|
||||
_hasComposed = true;
|
||||
_lastHeritageId = heritageId;
|
||||
_lastGenderKey = genderKey;
|
||||
_lastSelection = selection;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void ZoomIn() => _zoom?.ZoomIn();
|
||||
public void ZoomOut() => _zoom?.ZoomOut();
|
||||
public void RotateClockwise() => _rotation.Toggle(ChargenRotateDirection.Clockwise);
|
||||
public void RotateCounterClockwise() => _rotation.Toggle(ChargenRotateDirection.CounterClockwise);
|
||||
|
||||
public void Render()
|
||||
{
|
||||
if (_disposed || !_view.TryGetVisibleSize(out int width, out int height))
|
||||
return;
|
||||
|
||||
double now = _clock.Elapsed.TotalSeconds;
|
||||
float deltaSeconds = (float)Math.Max(0.0, now - _lastElapsedSeconds);
|
||||
_lastElapsedSeconds = now;
|
||||
|
||||
_animator?.Tick(deltaSeconds);
|
||||
_rotation.Tick(now);
|
||||
_zoom?.Tick(now);
|
||||
if (_animator is not null)
|
||||
_animator.Entity.Rotation = _rotation.ToOrientation();
|
||||
|
||||
_view.SetTextureHandle(_renderer.Render(width, height));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
// The renderer itself is a leased composition resource disposed by
|
||||
// the composition root (mirrors PaperdollViewportRenderer — this
|
||||
// class does not own its lifetime, only its per-frame drive).
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue