using System.Numerics; namespace AcDream.App.Rendering; /// /// Presentation-free port of gmCGAppearancePage::ZoomIn/ZoomOut /// (0x0047CF00/0x0047D050) and DoZoomAnimation /// (0x0047C960): a linear 0.6s tween of the preview camera's eye /// between (zoomed IN) /// and (zoomed OUT), /// driving the SAME zoom-state swap the /// button presses trigger in retail — immediately, not once the tween /// finishes (see 's own doc comment). /// /// /// One owner of the zoom state (fix round F2): retail's /// m_bZoomedIn is a SINGLE field on gmCGAppearancePage that /// gates both the camera target AND the animation swap — there is no way /// for retail's own camera and animation to disagree about which zoom state /// they're in. The first cut of this port kept two independent bools (one /// here, one on ) synced only by /// / calling a NULLABLE animator /// parameter — a null pass, or any direct /// call bypassing this /// controller, would desync the camera's target from the animation's pose. /// This class now takes its as a /// REQUIRED constructor dependency and reads /// straight through to — the /// animator is the sole state owner, matching retail's own single-field /// design, and there is no longer a second bool that could disagree with it. /// /// /// /// Retail drives once per frame from a global-message-3 /// tick while m_bShouldZoomAnimate is set /// (gmCGAppearancePage::ListenToGlobalMessage @ 0x0047CED0); the /// CC6b page-mount half will bind the Zoom In/Out buttons to /// / and the render loop to /// . Direction is always (0,0,0) for this camera /// (see 's own remarks), so only the eye /// position tweens — retail's own m_vectCurDirection lerp is a no-op /// here and is not reproduced. /// /// internal sealed class ChargenPreviewZoomController { /// /// ZoomIn/ZoomOut's explicit invalidation write /// (this->m_dAnimDuration = -0.1, pseudo-C ~0x0047cff1/0x0047cffb /// and ~0x0047d12c/0x0047d136 — the exact IEEE-754 bit pattern for /// -0.1) so the very next resets the duration /// to and the /// start time to "now", matching DoZoomAnimation's own /// reset-if-invalid guard exactly. /// private const double InvalidDurationSentinel = -0.1; private readonly uint _heritageId; private readonly ChargenPreviewAnimator _animator; private Vector3 _startEye; private Vector3 _targetEye; private double _animStartTime; private double _animDuration; private bool _shouldAnimate; public ChargenPreviewZoomController(uint heritageId, ChargenPreviewCamera camera, ChargenPreviewAnimator animator) { ArgumentNullException.ThrowIfNull(camera); ArgumentNullException.ThrowIfNull(animator); _heritageId = heritageId; Camera = camera; _animator = animator; } public ChargenPreviewCamera Camera { get; } /// /// Mirrors retail's m_bZoomedIn — a straight read-through to /// (see this class's own /// "one owner" doc above), which itself defaults false per /// gmCGAppearancePage::InitializePage @ 0x0047FDD0's explicit /// this->m_bZoomedIn = 0; at 0x004802C3 — written right /// after that same function points the camera at the zoomed-IN /// per-heritage eye (0x00480286-0x0048029E). One retail quirk /// this produces: the character starts framed close-up while /// NOT-zoomed-in, so the first Zoom In click (once mounted) tweens /// close-eye→close-eye — visually null — while still freezing the /// animation; this port reproduces it faithfully. /// public bool IsZoomedIn => _animator.IsZoomedIn; /// /// gmCGAppearancePage::ZoomIn @ 0x0047CF00: no-op if already /// zoomed in (retail's own early-return guard). Otherwise starts a tween /// from the camera's CURRENT eye to the default (zoomed-IN) per-heritage /// profile and swaps the animator to the frozen rest pose IMMEDIATELY /// (gmCG3DView::StopAnimation's call site, pseudo-C ~0x0047d024, /// precedes the tween's own completion by definition — it runs once, /// synchronously, inside ZoomIn itself). /// public void ZoomIn() { if (IsZoomedIn) return; StartTween(ChargenPreviewCamera.ResolveDefaultEye(_heritageId)); _animator.SetZoomedIn(true); } /// /// gmCGAppearancePage::ZoomOut @ 0x0047D050: no-op if not /// currently zoomed in. Otherwise starts a tween toward the zoomed-OUT /// per-heritage profile and swaps the animator back to the playing idle /// loop immediately, mirroring . /// public void ZoomOut() { if (!IsZoomedIn) return; StartTween(ChargenPreviewCamera.ResolveZoomedOutEye(_heritageId)); _animator.SetZoomedIn(false); } private void StartTween(Vector3 targetEye) { _startEye = Camera.Eye; _targetEye = targetEye; _shouldAnimate = true; _animDuration = InvalidDurationSentinel; } /// /// gmCGAppearancePage::DoZoomAnimation @ 0x0047C960: a LINEAR /// (not eased) lerp of the eye position from m_vectStartPosition /// to m_vectTargPosition over /// , clamping /// t to exactly 1.0 (and clearing m_bShouldZoomAnimate) the /// tick that reaches or passes the duration — the decomp shows a /// straight (targ - start) * t + start per axis with no easing /// curve applied anywhere in this function. /// public void Tick(double now) { if (!_shouldAnimate) return; if (_animDuration <= 0d) { _animDuration = ChargenPreviewCamera.ZoomTweenDurationSeconds; _animStartTime = now; } double elapsed = now - _animStartTime; if (elapsed >= _animDuration) { _shouldAnimate = false; elapsed = _animDuration; } float t = (float)(elapsed / _animDuration); Camera.Eye = Vector3.Lerp(_startEye, _targetEye, t); } }