using System.Collections.Generic; using System.Numerics; using AcDream.Core.Physics; using AcDream.Core.World; namespace AcDream.App.Rendering; /// /// Owns the chargen preview's per-frame idle-loop ↔ rest-pose playback, /// mirroring gmCG3DView::StartAnimation/StopAnimation's swap /// (0x004EE600/0x004EE640) and /// gmCGAppearancePage::ZoomIn/ZoomOut's immediate call into it /// (0x0047D024/0x0047D160 — the swap happens the instant the /// button is pressed, NOT once the camera's own 0.6s tween finishes). /// /// /// Retail default is idle-PLAYING, not frozen — see /// 's class doc for the decomp /// citations. This class's own default ( starts /// false) reproduces that: its constructor immediately plays the /// idle animation's frame 0 when one resolved, matching /// gmCGAppearancePage::Update's own trailing /// if (m_bZoomedIn == 0) StartAnimation() gate /// (~0x0047EF01-0x0047EF12), which re-fires on every heritage/gender/ /// appearance change too — restarts the idle loop /// at frame 0 on every transition INTO the playing state for the same /// reason: set_sequence_animation's arg3=1 clears the sequence /// before appending, so every StartAnimation call restarts the clip. /// The DEFAULT-false claim itself rests on gmCGAppearancePage::InitializePage /// @ 0x0047FDD0's explicit this->m_bZoomedIn = 0; at /// 0x004802C3 — written immediately after that same function sets the /// camera to the zoomed-IN per-heritage eye (0x00480286-0x0048029E), /// not from the ctor simply never touching the field (heap operator new /// memory is indeterminate, not zero — that argument doesn't hold on its /// own). One retail quirk this implies: the character starts framed close-up /// AND not-zoomed-in at the same time, so the FIRST Zoom In click (once /// mounted) tweens close-eye→close-eye — visually null — while still /// freezing the animation; the port reproduces this faithfully rather than /// treating it as a bug. /// /// /// /// The page-mount half (CC6b, after CC4 merges) wires the Zoom In/Out /// buttons to and the render loop to /// ; nothing in this repository calls either yet. /// /// internal sealed class ChargenPreviewAnimator { /// /// gmCG3DView::StartAnimation's literal framerate argument /// (set_sequence_animation(this->m_pPlayerObject, /// this->m_didAnimation.id, 1, 0, 30f), pseudo-C ~0x004ee61b). /// public const float IdleFramerate = 30f; private readonly ChargenPreviewAnimatedBuild _build; private float _currFrame; private bool _zoomedIn; // Double-buffered so a 30fps Tick doesn't allocate a fresh List // every frame: one buffer is whatever Entity.MeshRefs currently points // at (potentially still being read by the renderer's own Render() call // for this frame), the other is safe to Clear()+refill for the NEXT // tick and only gets published once fully populated. private readonly List _meshRefsBufferA = []; private readonly List _meshRefsBufferB = []; private bool _nextBufferIsA = true; public ChargenPreviewAnimator(ChargenPreviewAnimatedBuild build) { _build = build ?? throw new ArgumentNullException(nameof(build)); _currFrame = build.IdleLowFrame; if (build.IdleAnimation is not null) ApplyIdleFrame(); // retail's true default: idle playing, frame 0. // Else: Entity.MeshRefs already holds RestMeshRefs (set by // TryBuildAnimated) as the best available fallback. } /// The live preview entity — mutated in place by /// and ; the renderer never needs to re-call /// SetPreview after the first assignment (WorldEntity.MeshRefs /// is read fresh every draw — see its own doc comment). public WorldEntity Entity => _build.Entity; public bool IsZoomedIn => _zoomedIn; /// /// gmCGAppearancePage::ZoomIn/ZoomOut's /// StopAnimation/StartAnimation call, applied immediately /// (retail does not wait for the camera tween to finish before swapping /// animation state — see this class's own doc comment). No-op if /// already in the requested state, matching retail's own early-return /// guards (ZoomIn's if (m_bZoomedIn != 0) return, /// ZoomOut's mirror). /// public void SetZoomedIn(bool zoomedIn) { if (_zoomedIn == zoomedIn) return; _zoomedIn = zoomedIn; if (zoomedIn) { _build.Entity.MeshRefs = _build.RestMeshRefs; } else { _currFrame = _build.IdleLowFrame; if (_build.IdleAnimation is not null) ApplyIdleFrame(); } } /// /// Advances the idle loop by . No-op /// while zoomed in (the rest pose is frozen — retail's framerate-0 /// set_sequence_animation call never advances) or when no idle /// Animation resolved (heritage/DID gap; the entity keeps whatever pose /// the constructor seeded). /// public void Tick(float elapsedSeconds) { if (_zoomedIn || _build.IdleAnimation is null || elapsedSeconds <= 0f) return; _currFrame = RetailAnimationCyclePlayback.Advance( _currFrame, _build.IdleLowFrame, _build.IdleHighFrame, IdleFramerate, elapsedSeconds); ApplyIdleFrame(); } private void ApplyIdleFrame() { DatReaderWriter.DBObjs.Animation animation = _build.IdleAnimation!; IReadOnlyList parts = _build.DrawableParts; List meshRefs = _nextBufferIsA ? _meshRefsBufferA : _meshRefsBufferB; _nextBufferIsA = !_nextBufferIsA; meshRefs.Clear(); foreach (ChargenPreviewDrawablePart part in parts) { bool resolved = RetailAnimationCyclePlayback.TryInterpolatePart( animation, _currFrame, _build.IdleLowFrame, _build.IdleHighFrame, part.SetupPartIndex, out Vector3 origin, out Quaternion orientation); // Same defensive default as ApplyHeldPoseTransforms: a part // index the bracketing frame doesn't cover (a Setup/Animation // part-count mismatch, never expected in practice) keeps // identity rather than a degenerate zero quaternion. if (!resolved) { origin = Vector3.Zero; orientation = Quaternion.Identity; } Matrix4x4 transform = RetailHeldPose.ComposePartTransform(part.DefaultScale, origin, orientation); meshRefs.Add(new MeshRef(part.GfxObjId, transform) { SurfaceOverrides = part.SurfaceOverrides }); } _build.Entity.MeshRefs = meshRefs; } }