feat(chargen): Campaign CC slice CC6b-PRE — idle loop, rotation, zoom (mount-independent half)
Idle animation loop: decomp re-read of gmCGAppearancePage::Update's trailing StartAnimation/StopAnimation gate (~0x0047EF01-0x0047EF12) plus the ctor evidence that m_bZoomedIn is a decompiler-elided bool (never explicitly set away from its zero default, unlike its two sibling bools) establishes that retail's chargen preview defaults to the idle loop PLAYING, not the frozen rest pose CC6a shipped as a deliberate simplification (TS-83) — the rest pose only appears once Zoom In fires. New Core primitive RetailAnimationCyclePlayback ports CPhysicsObj::set_sequence_animation's advance-with-wrap + lerp/slerp effect (the same algorithm LiveEntityAnimationPresenter's legacy NPC-idle branch already carries inline; not consolidated this round — out of blast radius for a preview-only feature, noted in the new type's own doc). New ChargenPreviewAnimator drives the per-tick swap; ChargenPreviewEntityBuilder gained TryBuildAnimated alongside the byte-behavior-unchanged TryBuild. Olthoi/OlthoiAcid use the SAME enum key for idle and rest DIDs (decomp-confirmed quirk). TS-83 retired in the register (§4 count 50->49). Rotation controller: ChargenPreviewRotationController ports Rotate/DoRotation (0x0047CB50/0x0047CA80) verbatim — toggle-to-stop, deltaDegrees = ((now-last)/RotationSecondsPerRevolution)*360, single-pass +-360 clamp (not a full modulo, matching retail's own tail), the -1.0 invalidation sentinel. Applies to the entity's heading via the existing MoveToMath.SetHeading port, not the camera, confirming CC6a's own note. Zoom tween: ChargenPreviewZoomController ports ZoomIn/ZoomOut/ DoZoomAnimation (0x0047CF00/0x0047D050/0x0047C960) — a LINEAR 0.6s tween (no easing curve in the decomp) between the already-recorded camera eye profiles, calling into the animator's zoom swap IMMEDIATELY at button-press time, matching retail's call order exactly. m_alternateSetupID (research correction): re-reading the decomp function-by-function found all five m_alternateSetupID write sites — including the two the CC6a review cited — belong to gmBarberUI (the post-creation barber shop), not gmCGAppearancePage, which has no m_pOption1Checkbox-equivalent field and never writes the field. For character creation the field is always INVALID_DID in retail. TryCompose still gained a real, decomp-cited alternateSetupIdOverride parameter (default no-op) implementing gmCG3DView::Update's generic override precedence, for a future non-chargen consumer. RetailHeldPose extraction: shared ResolvePoseDid/ComposePartTransform between RetailPaperdollPoseApplicator and ChargenPreviewEntityBuilder — a clean mechanical extraction, behavior-identical on the paperdoll side. Bookkeeping: CC6a ledger row now cites its real commit SHAs (55bfd9ca,1774d8b2); new CC6b-PRE ledger row records scope done + the page-mount half still owed. Tests: RetailAnimationCyclePlaybackTests (10, Core), ChargenAppearanceFactoryTests (+4), ChargenPreviewRotationControllerTests (9), ChargenPreviewZoomControllerTests (7), ChargenPreviewAnimatorTests (7, hand-built fixtures), ChargenPreviewEntityBuilderTests (+5, installed-DAT). Core.Tests 4786/1 skip, Content.Tests 147/0, App.Tests 5149/6 skips — zero failures, full solution Release build green. One pre-existing, unrelated flake noted: Core.Net.Tests' NakEmissionTests loss soak failed once in the full-suite run, passed 1/1 isolated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
1774d8b298
commit
8dfee1118f
18 changed files with 1657 additions and 108 deletions
154
tests/AcDream.App.Tests/Rendering/ChargenPreviewAnimatorTests.cs
Normal file
154
tests/AcDream.App.Tests/Rendering/ChargenPreviewAnimatorTests.cs
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Hand-built-fixture tests for <see cref="ChargenPreviewAnimator"/> — no dat
|
||||
/// access needed, since a <see cref="ChargenPreviewAnimatedBuild"/> can be
|
||||
/// constructed entirely in memory. Installed-DAT coverage for the RESOLUTION
|
||||
/// half (<c>ChargenPreviewEntityBuilder.TryBuildAnimated</c> actually finding
|
||||
/// the idle DID against real dat data) lives in
|
||||
/// <c>ChargenPreviewEntityBuilderTests</c>.
|
||||
/// </summary>
|
||||
public sealed class ChargenPreviewAnimatorTests
|
||||
{
|
||||
private static Animation MakeTwoFrameAnim(Vector3 frame0Origin, Vector3 frame1Origin)
|
||||
{
|
||||
var anim = new Animation();
|
||||
var pf0 = new AnimationFrame(1);
|
||||
pf0.Frames.Add(new Frame { Origin = frame0Origin, Orientation = Quaternion.Identity });
|
||||
var pf1 = new AnimationFrame(1);
|
||||
pf1.Frames.Add(new Frame { Origin = frame1Origin, Orientation = Quaternion.Identity });
|
||||
anim.PartFrames.Add(pf0);
|
||||
anim.PartFrames.Add(pf1);
|
||||
return anim;
|
||||
}
|
||||
|
||||
private static ChargenPreviewAnimatedBuild MakeBuild(Animation? idleAnimation, int idleLow = 0, int idleHigh = 1)
|
||||
{
|
||||
const uint gfxObjId = 0x0100_0001u;
|
||||
var restMeshRefs = new List<MeshRef>
|
||||
{
|
||||
new(gfxObjId, Matrix4x4.CreateTranslation(new Vector3(99f, 99f, 99f))), // distinct from any idle frame, so tests can tell them apart.
|
||||
};
|
||||
var drawableParts = new List<ChargenPreviewDrawablePart>
|
||||
{
|
||||
new(SetupPartIndex: 0, GfxObjId: gfxObjId, DefaultScale: Vector3.One, SurfaceOverrides: null),
|
||||
};
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = ChargenPreviewEntityBuilder.PreviewRenderId,
|
||||
ServerGuid = ChargenPreviewEntityBuilder.PreviewServerGuid,
|
||||
SourceGfxObjOrSetupId = 0x0200_0001u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = restMeshRefs,
|
||||
};
|
||||
return new ChargenPreviewAnimatedBuild
|
||||
{
|
||||
Entity = entity,
|
||||
DrawableParts = drawableParts,
|
||||
RestMeshRefs = restMeshRefs,
|
||||
IdleAnimation = idleAnimation,
|
||||
IdleLowFrame = idleLow,
|
||||
IdleHighFrame = idleHigh,
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithIdleAnimation_SeedsFrameZeroPose_NotTheRestPose()
|
||||
{
|
||||
// Retail's true default is the idle loop PLAYING, not the rest pose
|
||||
// — see ChargenPreviewEntityBuilder's class doc.
|
||||
var origin0 = new Vector3(1f, 0f, 0f);
|
||||
var origin1 = new Vector3(5f, 0f, 0f);
|
||||
var build = MakeBuild(MakeTwoFrameAnim(origin0, origin1));
|
||||
|
||||
var animator = new ChargenPreviewAnimator(build);
|
||||
|
||||
Assert.False(animator.IsZoomedIn);
|
||||
Assert.Equal(origin0, animator.Entity.MeshRefs[0].PartTransform.Translation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithNoIdleAnimation_KeepsTheRestPoseFallback()
|
||||
{
|
||||
var build = MakeBuild(idleAnimation: null);
|
||||
|
||||
var animator = new ChargenPreviewAnimator(build);
|
||||
|
||||
Assert.Equal(new Vector3(99f, 99f, 99f), animator.Entity.MeshRefs[0].PartTransform.Translation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_AdvancesTheIdleFrame_InterpolatingBetweenFrames()
|
||||
{
|
||||
var origin0 = new Vector3(0f, 0f, 0f);
|
||||
var origin1 = new Vector3(10f, 0f, 0f);
|
||||
var build = MakeBuild(MakeTwoFrameAnim(origin0, origin1));
|
||||
var animator = new ChargenPreviewAnimator(build);
|
||||
|
||||
// 30fps, half a frame's worth of elapsed time -> currFrame 0.5, lerp halfway.
|
||||
animator.Tick(1f / 60f);
|
||||
|
||||
Assert.Equal(5f, animator.Entity.MeshRefs[0].PartTransform.Translation.X, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetZoomedIn_True_SwapsToTheFrozenRestPoseImmediately()
|
||||
{
|
||||
var build = MakeBuild(MakeTwoFrameAnim(new Vector3(1f, 0f, 0f), new Vector3(5f, 0f, 0f)));
|
||||
var animator = new ChargenPreviewAnimator(build);
|
||||
|
||||
animator.SetZoomedIn(true);
|
||||
|
||||
Assert.True(animator.IsZoomedIn);
|
||||
Assert.Equal(new Vector3(99f, 99f, 99f), animator.Entity.MeshRefs[0].PartTransform.Translation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_WhileZoomedIn_DoesNotAdvanceTheFrozenPose()
|
||||
{
|
||||
var build = MakeBuild(MakeTwoFrameAnim(new Vector3(1f, 0f, 0f), new Vector3(5f, 0f, 0f)));
|
||||
var animator = new ChargenPreviewAnimator(build);
|
||||
animator.SetZoomedIn(true);
|
||||
|
||||
animator.Tick(10f); // large elapsed time — must still be a no-op while zoomed in.
|
||||
|
||||
Assert.Equal(new Vector3(99f, 99f, 99f), animator.Entity.MeshRefs[0].PartTransform.Translation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetZoomedIn_False_RestartsTheIdleLoopAtFrameZero()
|
||||
{
|
||||
var origin0 = new Vector3(1f, 0f, 0f);
|
||||
var origin1 = new Vector3(5f, 0f, 0f);
|
||||
var build = MakeBuild(MakeTwoFrameAnim(origin0, origin1));
|
||||
var animator = new ChargenPreviewAnimator(build);
|
||||
|
||||
animator.Tick(1f / 30f); // advance to frame 1.
|
||||
animator.SetZoomedIn(true);
|
||||
animator.SetZoomedIn(false); // gmCG3DView::StartAnimation restarts the clip (clear-then-append).
|
||||
|
||||
Assert.Equal(origin0, animator.Entity.MeshRefs[0].PartTransform.Translation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetZoomedIn_SameStateTwice_IsANoOp()
|
||||
{
|
||||
var build = MakeBuild(MakeTwoFrameAnim(new Vector3(1f, 0f, 0f), new Vector3(5f, 0f, 0f)));
|
||||
var animator = new ChargenPreviewAnimator(build);
|
||||
|
||||
animator.Tick(1f / 60f); // partway through frame 0->1.
|
||||
Vector3 beforeX = animator.Entity.MeshRefs[0].PartTransform.Translation;
|
||||
animator.SetZoomedIn(false); // already not zoomed in — must not restart the loop.
|
||||
|
||||
Assert.Equal(beforeX, animator.Entity.MeshRefs[0].PartTransform.Translation);
|
||||
}
|
||||
}
|
||||
|
|
@ -124,4 +124,155 @@ public sealed class ChargenPreviewEntityBuilderTests
|
|||
Assert.NotNull(entity);
|
||||
Assert.NotEmpty(entity!.MeshRefs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CC6b: <c>TryBuildAnimated</c> resolves a real idle Animation (retail's
|
||||
/// <c>m_didAnimation</c>) against the installed EoR dat, with a usable
|
||||
/// frame range and a non-empty drawable-part list a
|
||||
/// <c>ChargenPreviewAnimator</c> can drive.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryBuildAnimated_AluvianMaleDefaultSelection_ResolvesARealIdleCycle()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
using var adapter = new DatCollectionAdapter(dats);
|
||||
|
||||
ChargenOptions options = ChargenTableReader.Load(adapter);
|
||||
Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? aluvian));
|
||||
Assert.True(aluvian!.GendersByKey.TryGetValue(1, out ChargenGenderOptions? male));
|
||||
|
||||
var catalog = new ChargenAppearanceCatalog(adapter);
|
||||
ChargenAppearanceSelection selection = ChargenAppearanceSelection.Default with
|
||||
{
|
||||
HairStyle = male!.HairStyles.Count > 0 ? 0u : ChargenAppearanceSelection.Unset,
|
||||
SkinShade = 0.5,
|
||||
};
|
||||
|
||||
bool composed = ChargenAppearanceFactory.TryCompose(
|
||||
options, 1u, 1, selection, catalog, catalog, out ChargenAppearanceResult appearance);
|
||||
Assert.True(composed);
|
||||
|
||||
var animations = new RetailAnimationLoader(adapter);
|
||||
ChargenPreviewAnimatedBuild? build = ChargenPreviewEntityBuilder.TryBuildAnimated(
|
||||
adapter, animations, appearance, heritageId: 1u, Quaternion.Identity, new object());
|
||||
|
||||
Assert.NotNull(build);
|
||||
Assert.NotEmpty(build!.DrawableParts);
|
||||
Assert.NotEmpty(build.RestMeshRefs);
|
||||
Assert.NotNull(build.IdleAnimation);
|
||||
Assert.True(build.IdleHighFrame >= build.IdleLowFrame);
|
||||
Assert.True(build.IdleAnimation!.PartFrames.Count > build.IdleHighFrame);
|
||||
|
||||
// Live end-to-end: an Animator built from this resolves a non-empty,
|
||||
// playable preview — retail's true default (idle playing), not the
|
||||
// frozen rest pose TryBuild alone still returns.
|
||||
var animator = new ChargenPreviewAnimator(build);
|
||||
Assert.False(animator.IsZoomedIn);
|
||||
Assert.NotEmpty(animator.Entity.MeshRefs);
|
||||
|
||||
animator.Tick(1f / 30f); // one frame's worth — must not throw or empty the mesh.
|
||||
Assert.NotEmpty(animator.Entity.MeshRefs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryBuildAnimated_UnknownSetupId_ReturnsNull()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
using var adapter = new DatCollectionAdapter(dats);
|
||||
var animations = new RetailAnimationLoader(adapter);
|
||||
|
||||
var bogusAppearance = new ChargenAppearanceResult(
|
||||
SetupId: 0x0200_FFFFu,
|
||||
BasePaletteId: 0u,
|
||||
ObjDesc: ChargenObjDesc.Empty,
|
||||
MissingPalSetIds: [],
|
||||
MissingClothingTableIds: [],
|
||||
ClothingTablesMissingBaseEffectForSetup: []);
|
||||
|
||||
ChargenPreviewAnimatedBuild? build = ChargenPreviewEntityBuilder.TryBuildAnimated(
|
||||
adapter, animations, bogusAppearance, heritageId: 1u, Quaternion.Identity, new object());
|
||||
|
||||
Assert.Null(build);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decomp-verified quirk (<c>gmCG3DView</c>'s ctor / <c>::Update</c>,
|
||||
/// pseudo-C ~0x004ee7e9/0x004ee7ff and ~0x004ee892/0x004ee8a8): Olthoi
|
||||
/// and OlthoiAcid use the SAME enum key (0x10000011 / 0x10000013) for
|
||||
/// BOTH the live idle DID (<c>m_didAnimation</c>) and the rest DID
|
||||
/// (<c>m_didAnimationRest</c>) — every standard heritage uses two
|
||||
/// DIFFERENT keys (0x10000006 idle vs 0x10000005 rest). This proves the
|
||||
/// SHARED enum key resolves to a real installed Animation DID (the same
|
||||
/// <c>RetailHeldPose.ResolvePoseDid</c> call
|
||||
/// <c>ChargenPreviewEntityBuilder</c>'s <c>ResolveIdleAnimEnum</c> AND
|
||||
/// <c>ResolveRestPoseEnum</c> both return for these two heritages) — the
|
||||
/// enum-key identity itself is source-verified (both private methods
|
||||
/// literally return the SAME numeric constant for Olthoi/OlthoiAcid, see
|
||||
/// their own doc comments), so a single resolution here is enough to
|
||||
/// confirm the shared key is not a dead/unresolvable id.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(0x10000011u)] // Olthoi's shared idle/rest enum key.
|
||||
[InlineData(0x10000013u)] // OlthoiAcid's shared idle/rest enum key.
|
||||
public void OlthoiFamily_SharedIdleRestEnumKey_ResolvesToARealInstalledDid(uint sharedEnumKey)
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
using var adapter = new DatCollectionAdapter(dats);
|
||||
|
||||
uint did = RetailHeldPose.ResolvePoseDid(adapter, sharedEnumKey);
|
||||
|
||||
Assert.NotEqual(0u, did);
|
||||
Assert.Equal(0x03u, did >> 24); // resolves to a real Animation DID.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extends <see cref="TryBuild_OlthoiHeritage_ResolvesADifferentRestPoseDidThanStandardHeritages"/>
|
||||
/// to the idle side: <c>TryBuildAnimated</c> resolves a real idle
|
||||
/// Animation for Olthoi too (not just the rest pose the older
|
||||
/// <c>TryBuild</c>-only test covers), so an Olthoi
|
||||
/// <c>ChargenPreviewAnimator</c> actually plays instead of silently
|
||||
/// falling back to the rest-only pose.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryBuildAnimated_OlthoiHeritage_ResolvesARealIdleAnimationToo()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
using var adapter = new DatCollectionAdapter(dats);
|
||||
|
||||
ChargenOptions options = ChargenTableReader.Load(adapter);
|
||||
Assert.True(options.TryGetHeritage(12u, out ChargenHeritageOptions? olthoi));
|
||||
Assert.True(olthoi!.GendersByKey.TryGetValue(1, out ChargenGenderOptions? male)
|
||||
|| olthoi.GendersByKey.TryGetValue(2, out male));
|
||||
Assert.NotNull(male);
|
||||
int genderKey = olthoi.GendersByKey.First(kv => ReferenceEquals(kv.Value, male)).Key;
|
||||
|
||||
var catalog = new ChargenAppearanceCatalog(adapter);
|
||||
var animations = new RetailAnimationLoader(adapter);
|
||||
bool composed = ChargenAppearanceFactory.TryCompose(
|
||||
options, 12u, genderKey, ChargenAppearanceSelection.Default with { SkinShade = 0.5 },
|
||||
catalog, catalog, out ChargenAppearanceResult appearance);
|
||||
Assert.True(composed);
|
||||
|
||||
ChargenPreviewAnimatedBuild? build = ChargenPreviewEntityBuilder.TryBuildAnimated(
|
||||
adapter, animations, appearance, heritageId: 12u, Quaternion.Identity, new object());
|
||||
|
||||
Assert.NotNull(build);
|
||||
Assert.NotNull(build!.IdleAnimation);
|
||||
|
||||
var animator = new ChargenPreviewAnimator(build);
|
||||
Assert.False(animator.IsZoomedIn);
|
||||
Assert.NotEmpty(animator.Entity.MeshRefs);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Pure (no dat access) tests for <see cref="ChargenPreviewRotationController"/>
|
||||
/// — the port of <c>gmCGAppearancePage::Rotate</c>/<c>DoRotation</c>
|
||||
/// (<c>0x0047CB50</c>/<c>0x0047CA80</c>).
|
||||
/// </summary>
|
||||
public sealed class ChargenPreviewRotationControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Toggle_StartsRotatingInTheGivenDirection()
|
||||
{
|
||||
var controller = new ChargenPreviewRotationController();
|
||||
controller.Toggle(ChargenRotateDirection.Clockwise);
|
||||
|
||||
Assert.True(controller.IsRotating);
|
||||
Assert.Equal(ChargenRotateDirection.Clockwise, controller.Direction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Toggle_SameDirectionWhileRotating_Stops()
|
||||
{
|
||||
var controller = new ChargenPreviewRotationController();
|
||||
controller.Toggle(ChargenRotateDirection.Clockwise);
|
||||
controller.Toggle(ChargenRotateDirection.Clockwise);
|
||||
|
||||
Assert.False(controller.IsRotating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Toggle_OppositeDirectionWhileRotating_SwitchesDirectionAndKeepsRotating()
|
||||
{
|
||||
var controller = new ChargenPreviewRotationController();
|
||||
controller.Toggle(ChargenRotateDirection.Clockwise);
|
||||
controller.Toggle(ChargenRotateDirection.CounterClockwise);
|
||||
|
||||
Assert.True(controller.IsRotating);
|
||||
Assert.Equal(ChargenRotateDirection.CounterClockwise, controller.Direction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_WhileNotRotating_IsANoOp()
|
||||
{
|
||||
var controller = new ChargenPreviewRotationController();
|
||||
controller.Tick(100.0);
|
||||
|
||||
Assert.Equal(0f, controller.HeadingDegrees);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_FirstCallAfterToggle_ContributesZeroDelta()
|
||||
{
|
||||
// Rotate() invalidates m_dLastRotateTime so the very first DoRotation
|
||||
// tick resets it to "now" rather than computing a huge jump from a
|
||||
// stale/never-set timestamp.
|
||||
var controller = new ChargenPreviewRotationController();
|
||||
controller.Toggle(ChargenRotateDirection.Clockwise);
|
||||
controller.Tick(1000.0);
|
||||
|
||||
Assert.Equal(0f, controller.HeadingDegrees);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_ClockwiseAdvance_AddsTheExactPerTickFormula()
|
||||
{
|
||||
// deltaDegrees = ((now - last) / RotationSecondsPerRevolution) * 360.
|
||||
// Seed "now" nonzero (0.0 collides with the <= 0 reset-if-invalid
|
||||
// guard, same as retail's own sentinel check would if Timer::cur_time
|
||||
// could ever read exactly zero — never in practice, so tests avoid
|
||||
// it too).
|
||||
var controller = new ChargenPreviewRotationController();
|
||||
controller.Toggle(ChargenRotateDirection.Clockwise);
|
||||
controller.Tick(10.0); // seeds lastRotateTime = 10, zero delta.
|
||||
controller.Tick(11.5); // half a revolution at 3 s/rev.
|
||||
|
||||
Assert.Equal(180f, controller.HeadingDegrees, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_CounterClockwiseAdvance_SubtractsAndWrapsPositive()
|
||||
{
|
||||
var controller = new ChargenPreviewRotationController();
|
||||
controller.Toggle(ChargenRotateDirection.CounterClockwise);
|
||||
controller.Tick(10.0);
|
||||
controller.Tick(11.5); // would go to -180, wraps to +180.
|
||||
|
||||
Assert.Equal(180f, controller.HeadingDegrees, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_AccumulatesAcrossMultipleTicks()
|
||||
{
|
||||
var controller = new ChargenPreviewRotationController();
|
||||
controller.Toggle(ChargenRotateDirection.Clockwise);
|
||||
controller.Tick(10.0);
|
||||
controller.Tick(10.5); // +60 deg.
|
||||
controller.Tick(11.0); // +60 deg more.
|
||||
|
||||
Assert.Equal(120f, controller.HeadingDegrees, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToOrientation_AtZeroHeading_IsIdentity()
|
||||
{
|
||||
var controller = new ChargenPreviewRotationController();
|
||||
Quaternion orientation = controller.ToOrientation();
|
||||
|
||||
Assert.Equal(Quaternion.Identity.X, orientation.X, 4);
|
||||
Assert.Equal(Quaternion.Identity.Y, orientation.Y, 4);
|
||||
Assert.Equal(Quaternion.Identity.Z, orientation.Z, 4);
|
||||
Assert.Equal(Quaternion.Identity.W, orientation.W, 4);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.CharGen;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Pure (no dat access) tests for <see cref="ChargenPreviewZoomController"/>
|
||||
/// — the port of <c>gmCGAppearancePage::ZoomIn</c>/<c>ZoomOut</c>/
|
||||
/// <c>DoZoomAnimation</c> (<c>0x0047CF00</c>/<c>0x0047D050</c>/<c>0x0047C960</c>)
|
||||
/// including its immediate wiring into <see cref="ChargenPreviewAnimator"/>'s
|
||||
/// idle-loop ↔ rest-pose swap.
|
||||
/// </summary>
|
||||
public sealed class ChargenPreviewZoomControllerTests
|
||||
{
|
||||
private static ChargenPreviewAnimator MakeAnimator()
|
||||
{
|
||||
const uint gfxObjId = 0x0100_0001u;
|
||||
var restMeshRefs = new System.Collections.Generic.List<MeshRef>
|
||||
{
|
||||
new(gfxObjId, Matrix4x4.CreateTranslation(new Vector3(99f, 99f, 99f))),
|
||||
};
|
||||
var drawableParts = new System.Collections.Generic.List<ChargenPreviewDrawablePart>
|
||||
{
|
||||
new(SetupPartIndex: 0, GfxObjId: gfxObjId, DefaultScale: Vector3.One, SurfaceOverrides: null),
|
||||
};
|
||||
var anim = new Animation();
|
||||
var pf0 = new AnimationFrame(1);
|
||||
pf0.Frames.Add(new Frame { Origin = new Vector3(1f, 0f, 0f), Orientation = Quaternion.Identity });
|
||||
anim.PartFrames.Add(pf0);
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = ChargenPreviewEntityBuilder.PreviewRenderId,
|
||||
ServerGuid = ChargenPreviewEntityBuilder.PreviewServerGuid,
|
||||
SourceGfxObjOrSetupId = 0x0200_0001u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = restMeshRefs,
|
||||
};
|
||||
var build = new ChargenPreviewAnimatedBuild
|
||||
{
|
||||
Entity = entity,
|
||||
DrawableParts = drawableParts,
|
||||
RestMeshRefs = restMeshRefs,
|
||||
IdleAnimation = anim,
|
||||
IdleLowFrame = 0,
|
||||
IdleHighFrame = 0,
|
||||
};
|
||||
return new ChargenPreviewAnimator(build);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZoomIn_StartsATweenTowardTheDefaultEye_AndMarksZoomedIn()
|
||||
{
|
||||
var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian);
|
||||
camera.Eye = ChargenPreviewCamera.ResolveZoomedOutEye((uint)ChargenHeritageGroup.Aluvian);
|
||||
var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera);
|
||||
|
||||
controller.ZoomIn(animator: null);
|
||||
|
||||
Assert.True(controller.IsZoomedIn);
|
||||
// Tween in progress — eye hasn't jumped yet (Tick hasn't run).
|
||||
Assert.Equal(ChargenPreviewCamera.ResolveZoomedOutEye((uint)ChargenHeritageGroup.Aluvian), camera.Eye);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZoomIn_WhileAlreadyZoomedIn_IsANoOp()
|
||||
{
|
||||
var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian);
|
||||
camera.Eye = ChargenPreviewCamera.ResolveZoomedOutEye((uint)ChargenHeritageGroup.Aluvian);
|
||||
var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera);
|
||||
controller.ZoomIn(animator: null); // real tween: zoomed-out eye -> default eye.
|
||||
controller.Tick(10.0);
|
||||
controller.Tick(10.0 + ChargenPreviewCamera.ZoomTweenDurationSeconds + 1.0); // fully complete it.
|
||||
Vector3 eyeAfterCompletion = camera.Eye;
|
||||
|
||||
controller.ZoomIn(animator: null); // second call — retail's own early-return guard.
|
||||
controller.Tick(9999.0); // if ZoomIn wrongly armed a tween, this would move the eye.
|
||||
|
||||
Assert.Equal(eyeAfterCompletion, camera.Eye);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZoomOut_WhileNotZoomedIn_IsANoOp()
|
||||
{
|
||||
var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian);
|
||||
Vector3 startEye = camera.Eye;
|
||||
var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera);
|
||||
|
||||
controller.ZoomOut(animator: null);
|
||||
|
||||
Assert.False(controller.IsZoomedIn);
|
||||
Assert.Equal(startEye, camera.Eye);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_LinearlyInterpolatesTheEye_HalfwayAtHalfTheDuration()
|
||||
{
|
||||
var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian);
|
||||
Vector3 startEye = camera.Eye; // ctor default == the zoomed-IN eye.
|
||||
var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera);
|
||||
controller.ZoomIn(animator: null); // reach the "zoomed in" state (zero-distance tween — Eye already there).
|
||||
Vector3 targetEye = ChargenPreviewCamera.ResolveZoomedOutEye((uint)ChargenHeritageGroup.Aluvian);
|
||||
controller.ZoomOut(animator: null); // NOW arms a real tween: default eye -> zoomed-out eye.
|
||||
|
||||
controller.Tick(100.0); // seeds the tween's own start time (first tick of a fresh -0.1 sentinel).
|
||||
controller.Tick(100.0 + ChargenPreviewCamera.ZoomTweenDurationSeconds / 2.0);
|
||||
|
||||
Vector3 expectedHalfway = Vector3.Lerp(startEye, targetEye, 0.5f);
|
||||
Assert.Equal(expectedHalfway.X, camera.Eye.X, 3);
|
||||
Assert.Equal(expectedHalfway.Y, camera.Eye.Y, 3);
|
||||
Assert.Equal(expectedHalfway.Z, camera.Eye.Z, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_PastTheFullDuration_ClampsExactlyToTheTargetEye_AndStopsAnimating()
|
||||
{
|
||||
var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian);
|
||||
var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera);
|
||||
controller.ZoomIn(animator: null); // reach "zoomed in" (zero-distance).
|
||||
Vector3 targetEye = ChargenPreviewCamera.ResolveZoomedOutEye((uint)ChargenHeritageGroup.Aluvian);
|
||||
controller.ZoomOut(animator: null); // arms the real tween toward targetEye.
|
||||
|
||||
controller.Tick(0.0);
|
||||
controller.Tick(100.0); // way past the 0.6s duration.
|
||||
|
||||
Assert.Equal(targetEye, camera.Eye);
|
||||
|
||||
Vector3 eyeAfterCompletion = camera.Eye;
|
||||
controller.Tick(200.0); // tween finished — further ticks must not move the eye.
|
||||
Assert.Equal(eyeAfterCompletion, camera.Eye);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZoomIn_ImmediatelyFreezesTheAnimatorToTheRestPose_BeforeTheTweenCompletes()
|
||||
{
|
||||
var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian);
|
||||
var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera);
|
||||
var animator = MakeAnimator();
|
||||
|
||||
controller.ZoomIn(animator);
|
||||
|
||||
// No Tick() call at all — retail's ZoomIn calls StopAnimation
|
||||
// synchronously, before the camera tween has advanced a single frame.
|
||||
Assert.True(animator.IsZoomedIn);
|
||||
Assert.Equal(new Vector3(99f, 99f, 99f), animator.Entity.MeshRefs[0].PartTransform.Translation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZoomOut_ImmediatelyResumesTheAnimatorsIdleLoop()
|
||||
{
|
||||
var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian);
|
||||
var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera);
|
||||
var animator = MakeAnimator();
|
||||
controller.ZoomIn(animator);
|
||||
|
||||
controller.ZoomOut(animator);
|
||||
|
||||
Assert.False(animator.IsZoomedIn);
|
||||
Assert.Equal(new Vector3(1f, 0f, 0f), animator.Entity.MeshRefs[0].PartTransform.Translation);
|
||||
}
|
||||
}
|
||||
|
|
@ -302,6 +302,86 @@ public sealed class ChargenAppearanceFactoryTests
|
|||
Assert.Equal(ChargenAppearanceFactory.HumanSetupId, result.SetupId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CC6b MUST-COVER item 4 — <c>alternateSetupIdOverride</c> (retail's
|
||||
/// <c>m_alternateSetupID</c>) must WIN outright over the hairstyle's own
|
||||
/// <c>AlternateSetup</c> when both are supplied, matching
|
||||
/// <c>gmCG3DView::Update</c>'s replace-not-combine precedence
|
||||
/// (~0x004EEA46-0x004EEA53).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryCompose_AlternateSetupIdOverride_WinsOverHairStyleAlternateSetup()
|
||||
{
|
||||
const uint hairStyleSetup = 0x0200_00AAu;
|
||||
const uint pageLevelOverride = 0x0200_00BBu;
|
||||
ChargenOptions options = MakeOptions(MakeGender(alternateHairSetup: hairStyleSetup));
|
||||
var (pal, clothing) = MakeSources(bodySetupId: pageLevelOverride);
|
||||
var selection = ChargenAppearanceSelection.Default with { HairStyle = 0u };
|
||||
|
||||
ChargenAppearanceFactory.TryCompose(
|
||||
options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result,
|
||||
alternateSetupIdOverride: pageLevelOverride);
|
||||
|
||||
Assert.Equal(pageLevelOverride, result.SetupId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Companion: with NO hair style selected at all (so there is nothing for
|
||||
/// the page-level override to out-rank), the override still replaces the
|
||||
/// plain gender.SetupId.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryCompose_AlternateSetupIdOverride_WinsOverPlainGenderSetupId()
|
||||
{
|
||||
const uint pageLevelOverride = 0x0200_00CCu;
|
||||
ChargenOptions options = MakeOptions(MakeGender());
|
||||
var (pal, clothing) = MakeSources(bodySetupId: pageLevelOverride);
|
||||
|
||||
ChargenAppearanceFactory.TryCompose(
|
||||
options, HeritageId, GenderKey, ChargenAppearanceSelection.Default, pal, clothing,
|
||||
out ChargenAppearanceResult result,
|
||||
alternateSetupIdOverride: pageLevelOverride);
|
||||
|
||||
Assert.Equal(pageLevelOverride, result.SetupId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The default (no override supplied) call shape is unaffected — proves
|
||||
/// the new trailing parameter is additive, not a behavior change for
|
||||
/// every existing caller.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryCompose_NoAlternateSetupIdOverrideSupplied_ResolvesAsBefore()
|
||||
{
|
||||
ChargenOptions options = MakeOptions(MakeGender());
|
||||
var (pal, clothing) = MakeSources();
|
||||
|
||||
ChargenAppearanceFactory.TryCompose(
|
||||
options, HeritageId, GenderKey, ChargenAppearanceSelection.Default, pal, clothing,
|
||||
out ChargenAppearanceResult result);
|
||||
|
||||
Assert.Equal(BodySetupId, result.SetupId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An override equal to retail's <c>INVALID_DID</c> sentinel means "no
|
||||
/// override" (the field's own default), not "adopt 0xFFFFFFFF as the
|
||||
/// Setup id" — same sentinel discipline as the hairstyle source (F1).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryCompose_AlternateSetupIdOverrideIsInvalidDid_IsTreatedAsNoOverride()
|
||||
{
|
||||
ChargenOptions options = MakeOptions(MakeGender());
|
||||
var (pal, clothing) = MakeSources();
|
||||
|
||||
ChargenAppearanceFactory.TryCompose(
|
||||
options, HeritageId, GenderKey, ChargenAppearanceSelection.Default, pal, clothing,
|
||||
out ChargenAppearanceResult result,
|
||||
alternateSetupIdOverride: 0xFFFFFFFFu);
|
||||
|
||||
Assert.Equal(BodySetupId, result.SetupId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCompose_EyeStripSelected_UsesNonBaldObjDesc_WhenHairStyleIsNotBald()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,153 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="RetailAnimationCyclePlayback"/> is the shared advance-with-wrap
|
||||
/// + lerp/slerp primitive behind the chargen preview's idle loop
|
||||
/// (<c>ChargenPreviewAnimator</c>) — the SAME arithmetic
|
||||
/// <c>LiveEntityAnimationPresenter.Present</c>'s legacy (no-
|
||||
/// <see cref="AnimationSequencer"/>) branch already carries for NPC idle
|
||||
/// cycles, extracted here so a second, live-entity-free consumer (the
|
||||
/// chargen preview, which has no <c>LiveEntityRuntime</c> membership to hang
|
||||
/// a sequencer off of) doesn't retype the formula.
|
||||
/// </summary>
|
||||
public sealed class RetailAnimationCyclePlaybackTests
|
||||
{
|
||||
private static Animation MakeAnim(int numFrames, int numParts, Vector3 origin, Quaternion orientation)
|
||||
{
|
||||
var anim = new Animation();
|
||||
for (int f = 0; f < numFrames; f++)
|
||||
{
|
||||
var pf = new AnimationFrame((uint)numParts);
|
||||
for (int p = 0; p < numParts; p++)
|
||||
pf.Frames.Add(new Frame { Origin = origin, Orientation = orientation });
|
||||
anim.PartFrames.Add(pf);
|
||||
}
|
||||
return anim;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Advance_WithinSpan_AddsElapsedTimesFramerate()
|
||||
{
|
||||
float result = RetailAnimationCyclePlayback.Advance(
|
||||
currFrame: 5f, lowFrame: 0, highFrame: 29, framerate: 30f, elapsedSeconds: 0.1f);
|
||||
|
||||
Assert.Equal(8f, result, precision: 4); // 5 + 0.1*30 = 8.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Advance_PastHighFrame_WrapsBackToLowFrame()
|
||||
{
|
||||
// 29-frame span (0..29 inclusive = 30 frames), advancing from frame
|
||||
// 28 by one second at 30fps overshoots by (28+30)-29 = 29, wrapping
|
||||
// to lowFrame + (29 % 30) = 29... use a case with a clean wrap.
|
||||
float result = RetailAnimationCyclePlayback.Advance(
|
||||
currFrame: 25f, lowFrame: 0, highFrame: 29, framerate: 30f, elapsedSeconds: 0.2f);
|
||||
|
||||
// 25 + 6 = 31, over highFrame(29) by span+1=30: over = 31-0 = 31,
|
||||
// wrapped = 0 + (31 % 30) = 1.
|
||||
Assert.Equal(1f, result, precision: 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Advance_BelowLowFrame_ClampsToLowFrame()
|
||||
{
|
||||
float result = RetailAnimationCyclePlayback.Advance(
|
||||
currFrame: -5f, lowFrame: 0, highFrame: 29, framerate: 30f, elapsedSeconds: 0.05f);
|
||||
|
||||
// -5 + 1.5 = -3.5, still below lowFrame(0) -> clamp.
|
||||
Assert.Equal(0f, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 0)] // degenerate span (highFrame == lowFrame).
|
||||
[InlineData(0, -1)] // inverted span.
|
||||
public void Advance_DegenerateSpan_ReturnsCurrFrameUnchanged(int lowFrame, int highFrame)
|
||||
{
|
||||
float result = RetailAnimationCyclePlayback.Advance(
|
||||
currFrame: 3f, lowFrame, highFrame, framerate: 30f, elapsedSeconds: 1f);
|
||||
|
||||
Assert.Equal(3f, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Advance_NonPositiveFramerateOrElapsed_ReturnsCurrFrameUnchanged()
|
||||
{
|
||||
Assert.Equal(3f, RetailAnimationCyclePlayback.Advance(3f, 0, 29, framerate: 0f, elapsedSeconds: 1f));
|
||||
Assert.Equal(3f, RetailAnimationCyclePlayback.Advance(3f, 0, 29, framerate: 30f, elapsedSeconds: 0f));
|
||||
Assert.Equal(3f, RetailAnimationCyclePlayback.Advance(3f, 0, 29, framerate: 30f, elapsedSeconds: -1f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryInterpolatePart_ExactFrame_ReturnsThatFramesPose()
|
||||
{
|
||||
Animation anim = MakeAnim(3, 2, new Vector3(1f, 2f, 3f), Quaternion.Identity);
|
||||
|
||||
bool ok = RetailAnimationCyclePlayback.TryInterpolatePart(
|
||||
anim, currFrame: 1f, lowFrame: 0, highFrame: 2, partIndex: 0,
|
||||
out Vector3 origin, out Quaternion orientation);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(new Vector3(1f, 2f, 3f), origin);
|
||||
Assert.Equal(Quaternion.Identity, orientation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryInterpolatePart_BetweenFrames_LerpsOriginHalfway()
|
||||
{
|
||||
var anim = new Animation();
|
||||
var pf0 = new AnimationFrame(1);
|
||||
pf0.Frames.Add(new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity });
|
||||
var pf1 = new AnimationFrame(1);
|
||||
pf1.Frames.Add(new Frame { Origin = new Vector3(10f, 0f, 0f), Orientation = Quaternion.Identity });
|
||||
anim.PartFrames.Add(pf0);
|
||||
anim.PartFrames.Add(pf1);
|
||||
|
||||
bool ok = RetailAnimationCyclePlayback.TryInterpolatePart(
|
||||
anim, currFrame: 0.5f, lowFrame: 0, highFrame: 1, partIndex: 0,
|
||||
out Vector3 origin, out _);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(new Vector3(5f, 0f, 0f), origin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryInterpolatePart_AtHighFrame_WrapsNextFrameToLowFrame()
|
||||
{
|
||||
var anim = new Animation();
|
||||
var pf0 = new AnimationFrame(1);
|
||||
pf0.Frames.Add(new Frame { Origin = new Vector3(1f, 0f, 0f), Orientation = Quaternion.Identity });
|
||||
var pf1 = new AnimationFrame(1);
|
||||
pf1.Frames.Add(new Frame { Origin = new Vector3(2f, 0f, 0f), Orientation = Quaternion.Identity });
|
||||
anim.PartFrames.Add(pf0);
|
||||
anim.PartFrames.Add(pf1);
|
||||
|
||||
// currFrame exactly at highFrame(1): frameIndex=1, nextIndex would be
|
||||
// 2 which is > highFrame -> wraps to lowFrame(0). t=0 so origin==frame[1].
|
||||
bool ok = RetailAnimationCyclePlayback.TryInterpolatePart(
|
||||
anim, currFrame: 1f, lowFrame: 0, highFrame: 1, partIndex: 0,
|
||||
out Vector3 origin, out _);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(new Vector3(2f, 0f, 0f), origin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryInterpolatePart_PartIndexOutOfRange_ReturnsFalse()
|
||||
{
|
||||
Animation anim = MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity);
|
||||
|
||||
bool ok = RetailAnimationCyclePlayback.TryInterpolatePart(
|
||||
anim, currFrame: 0f, lowFrame: 0, highFrame: 1, partIndex: 5,
|
||||
out Vector3 origin, out Quaternion orientation);
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.Equal(default(Vector3), origin);
|
||||
Assert.Equal(default(Quaternion), orientation);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue