fix(physics): #265 landing-bounce family - retail check_contact seed + velocity-free landing commit
Retail jump landings BOUNCE: the floor touch records both a contact plane (grounding) AND a collision normal (collided_with_environment), and handle_all_collisions reflects the unmodified impact velocity off it at 5% elasticity (v += -(v.n)(elasticity+1).n, DEFAULT_ELASTICITY 0.05 @0x007c6a7c). Our transition already recorded both facts; the bounce was suppressed by the AD-25 adaptation stack in the per-tick commit: a Velocity.Z<=0 landing gate (needed because the resolver glued ascending movers to the ground) plus a landing Velocity.Z=0 hand-zero whose stated purpose was making the reflect a no-op. Downhill glided instead of bouncing, flat-ground landings had no pop, and uphill jumps flapped between grounded/airborne against the animation machine. Three retail mechanisms replace the stack: - check_contact (0x0050f5b0) seeding in ResolveWithTransition: a body in CONTACT seeds the transition's contact only while v.contactPlane.N <= 0.0002; moving away seeds the last-known plane alone (get_object_info 0x00511cc0). Ascending jumps therefore run contact-free (ballistic, no glue) - the gate's reason-for-being is gone. The plane requirement is strict: Contact-without-plane is unrepresentable in retail. - SetPositionInternal-shaped commit (0x00515330, byte-read end-to-end, velocity-sign-FREE): contact purely from the transition's contact plane, HitGround on the airborne->walkable edge, HandleAllCollisions with unmodified impact velocity. Whole commit gated on Ok && candidateMoved (retail pc:283657 skips SetPositionInternal entirely when the candidate did not move) - a standing body's contact state is never re-derived, which is what keeps rest bit-stable (AD-41 updated). - Byte decodes: gate override state&0x800000=Sledding, zero branch state&0x20000=Inelastic, reflect strictly dot<0 - our port already had all three correct. Settle: real landings (>=0.25 m/s) bounce and decay geometrically; smaller impacts are consumed by retail's unconditional small-velocity zero, so standing never micro-bounces. Re-baselines documented in place: landing-survival pin measures decay post-settle; LiveCompare_Tick0/376 pin the new IsOnGround=false on zero-move ticks (captured true was the retired seed echo; tick 376's captured body carries an 11.8 m/s grounded velocity from the deleted get_state_velocity-overwrite era); de-overlap fixture now carries the plane real grounded bodies always have. New pins: LandingBounceSeedingTests (ascent no-seed, rest keeps contact, strict plane, slope 5% reversal + tangential preservation, Sledding override). Investigation + implementation record: docs/research/2026-07-30-landing-bounce-family.md. Complete Release suite: 10,031 passed / 5 skips / 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
7fcc7db1d1
commit
2d611b2b01
10 changed files with 620 additions and 84 deletions
77
tests/AcDream.App.Tests/UI/Layout/VitaeColorDumpProbe.cs
Normal file
77
tests/AcDream.App.Tests/UI/Layout/VitaeColorDumpProbe.cs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using AcDream.App.UI.Layout;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
// THROWAWAY probe (#268): dump the authored 0x1B font-color arrays for the
|
||||
// character-panel footer labels so we can read retail's vitae parenthetical
|
||||
// color (AppendTextWithFont color index 3, gmSkillUI 0x0049b972). Delete
|
||||
// after the color constant is captured.
|
||||
public sealed class VitaeColorDumpProbe
|
||||
{
|
||||
[Fact]
|
||||
public void Dump_footer_font_color_arrays()
|
||||
{
|
||||
var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
if (!Directory.Exists(datDir)) return;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
foreach (uint layoutId in new[] { 0x2100002Eu, 0x2100002Cu })
|
||||
{
|
||||
var root = LayoutImporter.ImportInfos(dats, layoutId);
|
||||
if (root is null) { sb.AppendLine($"layout {layoutId:X8}: NOT FOUND"); continue; }
|
||||
sb.AppendLine($"=== layout {layoutId:X8} ===");
|
||||
Walk(root, sb);
|
||||
}
|
||||
|
||||
var outPath = Path.Combine(AppContext.BaseDirectory, "vitae-color-dump.txt");
|
||||
File.WriteAllText(outPath, sb.ToString());
|
||||
// Also drop a copy next to the repo artifacts when resolvable.
|
||||
var repoCopy = Environment.GetEnvironmentVariable("ACDREAM_PROBE_OUT");
|
||||
if (!string.IsNullOrEmpty(repoCopy))
|
||||
File.WriteAllText(repoCopy, sb.ToString());
|
||||
}
|
||||
|
||||
private static void Walk(ElementInfo e, StringBuilder sb)
|
||||
{
|
||||
if (e.TryGetEffectiveProperty(0x1Bu, out var color))
|
||||
{
|
||||
if (color.Kind == UiPropertyKind.Array && color.ArrayValue.Count > 1)
|
||||
{
|
||||
sb.Append($"element {e.Id:X8} 0x1B array[{color.ArrayValue.Count}]:");
|
||||
for (int i = 0; i < color.ArrayValue.Count; i++)
|
||||
{
|
||||
var v = color.ArrayValue[i];
|
||||
if (v.Kind == UiPropertyKind.Color)
|
||||
{
|
||||
var c = v.ColorValue;
|
||||
sb.Append($" [{i}]=A{c.Alpha:D3},R{c.Red:D3},G{c.Green:D3},B{c.Blue:D3}");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append($" [{i}]=kind:{v.Kind}");
|
||||
}
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
else if (color.Kind == UiPropertyKind.Color)
|
||||
{
|
||||
var c = color.ColorValue;
|
||||
sb.AppendLine(
|
||||
$"element {e.Id:X8} 0x1B single: A{c.Alpha:D3},R{c.Red:D3},G{c.Green:D3},B{c.Blue:D3}");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var child in e.Children)
|
||||
Walk(child, sb);
|
||||
}
|
||||
}
|
||||
|
|
@ -606,12 +606,25 @@ public class CellarUpTrajectoryReplayTests : IDisposable
|
|||
/// motion). The simplest test case: replay the call and verify the
|
||||
/// harness produces the same ResolveResult + bodyAfter state.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// #265 bounce rework (2026-07-30,
|
||||
/// docs/research/2026-07-30-landing-bounce-family.md): the capture's
|
||||
/// <c>Result.IsOnGround=true</c> on this ZERO-MOVE tick was the old
|
||||
/// seed's echo — the caller's isOnGround came straight back through the
|
||||
/// seeded oi flags. The retail check_contact seed (get_object_info
|
||||
/// 0x00511cc0) requires a stored contact plane, and this captured body
|
||||
/// has <c>cpV=false</c>, so the modern resolve reports IsOnGround=false
|
||||
/// here. Production no longer consumes IsOnGround on no-move frames
|
||||
/// (the candidateMoved commit gate — retail pc:283657 skips
|
||||
/// SetPositionInternal entirely), so the echo is intentionally gone;
|
||||
/// the comparison pins the NEW value instead.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void LiveCompare_Tick0_Spawn()
|
||||
{
|
||||
var (engine, cache) = BuildEngineWithCellarFixtures();
|
||||
var captured = LoadCapturedRecord(record => record.Tick == 0);
|
||||
AssertCallMatchesCapture(engine, captured);
|
||||
AssertCallMatchesCapture(engine, captured, expectStaleIsOnGroundEcho: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -621,12 +634,24 @@ public class CellarUpTrajectoryReplayTests : IDisposable
|
|||
/// the same walkable polygon + ResolveResult, the ramp geometry is
|
||||
/// loaded correctly.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// #265 bounce rework (2026-07-30): same stale IsOnGround echo as
|
||||
/// <see cref="LiveCompare_Tick0_Spawn"/>, with a second stale-era quirk:
|
||||
/// the captured bodyBefore carries Velocity=(0.64, 11.83, 0) while
|
||||
/// GROUNDED on the ramp — a value from the deleted
|
||||
/// get_state_velocity-overwrite era (root motion owns walking now;
|
||||
/// grounded Velocity stays ~0). dot(v, rampNormal)=+8.5 rightly fails
|
||||
/// retail check_contact (0x0050f5b0: contact holds only while
|
||||
/// v·n ≤ 0.0002), so the modern resolve refuses the stale contact on
|
||||
/// this zero-move tick. Geometry checks (the ramp polygon) still
|
||||
/// compare exactly.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void LiveCompare_Tick376_OnRamp()
|
||||
{
|
||||
var (engine, _) = BuildEngineWithCellarFixtures();
|
||||
var captured = LoadCapturedRecord(record => record.Tick == 376);
|
||||
AssertCallMatchesCapture(engine, captured);
|
||||
AssertCallMatchesCapture(engine, captured, expectStaleIsOnGroundEcho: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -838,7 +863,8 @@ public class CellarUpTrajectoryReplayTests : IDisposable
|
|||
/// </summary>
|
||||
private static void AssertCallMatchesCapture(
|
||||
PhysicsEngine engine,
|
||||
ResolveCaptureRecord captured)
|
||||
ResolveCaptureRecord captured,
|
||||
bool expectStaleIsOnGroundEcho = false)
|
||||
{
|
||||
Assert.NotNull(captured.BodyBefore);
|
||||
Assert.NotNull(captured.BodyAfter);
|
||||
|
|
@ -867,8 +893,19 @@ public class CellarUpTrajectoryReplayTests : IDisposable
|
|||
AddIfDifferent(divergences, "Result.CellId",
|
||||
$"0x{captured.Result.CellId:X8}",
|
||||
$"0x{harnessResult.CellId:X8}");
|
||||
AddIfDifferent(divergences, "Result.IsOnGround",
|
||||
captured.Result.IsOnGround, harnessResult.IsOnGround);
|
||||
if (expectStaleIsOnGroundEcho)
|
||||
{
|
||||
// #265 bounce rework: the capture's grounded echo on this tick is
|
||||
// the retired pre-check_contact seed. Pin the NEW deterministic
|
||||
// value (false) so any further drift still fails loudly.
|
||||
AddIfDifferent(divergences, "Result.IsOnGround(post-#265)",
|
||||
false, harnessResult.IsOnGround);
|
||||
}
|
||||
else
|
||||
{
|
||||
AddIfDifferent(divergences, "Result.IsOnGround",
|
||||
captured.Result.IsOnGround, harnessResult.IsOnGround);
|
||||
}
|
||||
AddIfDifferent(divergences, "Result.CollisionNormalValid",
|
||||
captured.Result.CollisionNormalValid,
|
||||
harnessResult.CollisionNormalValid);
|
||||
|
|
|
|||
153
tests/AcDream.Core.Tests/Physics/LandingBounceSeedingTests.cs
Normal file
153
tests/AcDream.Core.Tests/Physics/LandingBounceSeedingTests.cs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Landing-bounce family (#265, 2026-07-30,
|
||||
/// docs/research/2026-07-30-landing-bounce-family.md): the retail
|
||||
/// check_contact transition seed (CPhysicsObj::get_object_info 0x00511cc0 →
|
||||
/// check_contact 0x0050f5b0) plus the byte-decoded handle_all_collisions
|
||||
/// gate flags. Complements HandleAllCollisionsTests (landing reflect,
|
||||
/// walking no-reflect, Inelastic, fsf ladder — already pinned there).
|
||||
/// </summary>
|
||||
public class LandingBounceSeedingTests
|
||||
{
|
||||
private const uint Lb = 0x00010000u;
|
||||
private const uint Cell = 0x0001u;
|
||||
|
||||
private static PhysicsEngine BuildFlatEngine()
|
||||
{
|
||||
var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() };
|
||||
engine.AddLandblock(Lb, new TerrainSurface(new byte[81], new float[256]),
|
||||
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(), 0f, 0f);
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static PhysicsBody GroundedBody(Vector3 pos, Vector3 velocity) => new()
|
||||
{
|
||||
Position = pos,
|
||||
Orientation = Quaternion.Identity,
|
||||
State = PhysicsStateFlags.Gravity,
|
||||
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
|
||||
Velocity = velocity,
|
||||
ContactPlaneValid = true,
|
||||
ContactPlane = new Plane(Vector3.UnitZ, 0f),
|
||||
GroundNormal = Vector3.UnitZ,
|
||||
};
|
||||
|
||||
private static ResolveResult ZeroMoveResolve(PhysicsEngine engine, PhysicsBody body)
|
||||
=> engine.ResolveWithTransition(
|
||||
body.Position, body.Position, Cell,
|
||||
sphereRadius: 0.48f, sphereHeight: 1.835f,
|
||||
stepUpHeight: 0.55f, stepDownHeight: 0.55f,
|
||||
isOnGround: body.OnWalkable,
|
||||
body: body);
|
||||
|
||||
[Fact]
|
||||
public void CheckContact_AtRestGroundedBody_KeepsContactOnZeroMoveResolve()
|
||||
{
|
||||
// v·n = 0 ≤ ε → check_contact holds → the seed carries the contact
|
||||
// plane through a zero-move resolve (retail standing still).
|
||||
var engine = BuildFlatEngine();
|
||||
var body = GroundedBody(new Vector3(96f, 96f, 0.48f), Vector3.Zero);
|
||||
|
||||
var result = ZeroMoveResolve(engine, body);
|
||||
|
||||
Assert.True(result.IsOnGround);
|
||||
Assert.True(result.InContact);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckContact_AscendingJumper_SeedsNoContact()
|
||||
{
|
||||
// Jump launch: v·n = +5.4 > ε (0.0002) → retail check_contact fails →
|
||||
// the transition runs contact-free (no glue, ballistic ascent). The
|
||||
// zero-move probe therefore reports airborne even though the body's
|
||||
// transient flags still say grounded from the previous tick.
|
||||
var engine = BuildFlatEngine();
|
||||
var body = GroundedBody(new Vector3(96f, 96f, 0.48f), new Vector3(0f, 0f, 5.4f));
|
||||
|
||||
var result = ZeroMoveResolve(engine, body);
|
||||
|
||||
Assert.False(result.IsOnGround);
|
||||
Assert.False(result.InContact);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckContact_ContactWithoutStoredPlane_SeedsNothing()
|
||||
{
|
||||
// A Contact body with NO stored plane is unrepresentable in retail
|
||||
// (init_contact_plane always accompanies the CONTACT seed) — the
|
||||
// strict seed refuses it rather than echoing the caller's flags.
|
||||
var engine = BuildFlatEngine();
|
||||
var body = GroundedBody(new Vector3(96f, 96f, 0.48f), Vector3.Zero);
|
||||
body.ContactPlaneValid = false;
|
||||
|
||||
var result = ZeroMoveResolve(engine, body);
|
||||
|
||||
Assert.False(result.IsOnGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reflect_DownhillSlopeLanding_FivePercentNormalReversal_TangentialKept()
|
||||
{
|
||||
// Slope normal 30° from vertical; impact velocity carries components
|
||||
// both along and into the slope. Retail 0x0051490c-0x00514959:
|
||||
// v' = v - (v·n)(elasticity+1)·n with DEFAULT_ELASTICITY 0.05
|
||||
// (byte constant @0x007c6a7c) — the normal component REVERSES at 5%
|
||||
// (the bounce) and the tangential component is untouched (the carry).
|
||||
var n = Vector3.Normalize(new Vector3(0f, 0.5f, 0.8660254f));
|
||||
var v = new Vector3(0f, 4f, -6f);
|
||||
var body = new PhysicsBody
|
||||
{
|
||||
Velocity = v,
|
||||
Elasticity = 0.05f,
|
||||
FramesStationaryFall = 0,
|
||||
};
|
||||
|
||||
PhysicsObjUpdate.HandleAllCollisions(
|
||||
body,
|
||||
collisionNormalValid: true, collisionNormal: n,
|
||||
prevContact: false, prevOnWalkable: false, nowOnWalkable: true);
|
||||
|
||||
float dotBefore = Vector3.Dot(v, n);
|
||||
float dotAfter = Vector3.Dot(body.Velocity, n);
|
||||
Assert.True(dotBefore < 0f);
|
||||
// Normal component reversed and scaled by elasticity.
|
||||
Assert.True(MathF.Abs(dotAfter - (-dotBefore * 0.05f)) < 1e-5f,
|
||||
$"normal component: before={dotBefore}, after={dotAfter}");
|
||||
// Tangential component preserved bit-for-bit (the reflect only adds
|
||||
// along n).
|
||||
Vector3 tangBefore = v - n * dotBefore;
|
||||
Vector3 tangAfter = body.Velocity - n * dotAfter;
|
||||
Assert.True((tangAfter - tangBefore).Length() < 1e-5f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reflect_SleddingOverridesGroundedSuppression()
|
||||
{
|
||||
// Byte decode 0x0051479c: `test dword [esi+0xa8], 0x800000` —
|
||||
// PhysicsState.Sledding forces the reflect even while grounded-to-
|
||||
// grounded (the downhill sled keeps bouncing).
|
||||
var n = Vector3.UnitZ;
|
||||
var body = new PhysicsBody
|
||||
{
|
||||
Velocity = new Vector3(3f, 0f, -2f),
|
||||
Elasticity = 0.05f,
|
||||
State = PhysicsStateFlags.Sledding,
|
||||
FramesStationaryFall = 0,
|
||||
};
|
||||
|
||||
PhysicsObjUpdate.HandleAllCollisions(
|
||||
body,
|
||||
collisionNormalValid: true, collisionNormal: n,
|
||||
prevContact: true, prevOnWalkable: true, nowOnWalkable: true);
|
||||
|
||||
Assert.True(MathF.Abs(body.Velocity.Z - 0.1f) < 1e-5f,
|
||||
$"expected reflected +0.1 (=2·0.05), got {body.Velocity.Z}");
|
||||
Assert.Equal(3f, body.Velocity.X, precision: 5);
|
||||
}
|
||||
}
|
||||
|
|
@ -82,6 +82,14 @@ public class RemoteDeOverlapMechanismTests
|
|||
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable
|
||||
| TransientStateFlags.Active,
|
||||
Velocity = Vector3.Zero,
|
||||
// #265 bounce rework (2026-07-30): a Contact body without a stored
|
||||
// plane is unrepresentable in retail (init_contact_plane always
|
||||
// accompanies the CONTACT seed), and the check_contact seeding now
|
||||
// requires the plane. Give the fixture the state a real grounded body
|
||||
// carries: the flat Z=0 terrain plane under its feet.
|
||||
ContactPlaneValid = true,
|
||||
ContactPlane = new System.Numerics.Plane(Vector3.UnitZ, 0f),
|
||||
GroundNormal = Vector3.UnitZ,
|
||||
};
|
||||
|
||||
/// <summary>One catch-up-step + sweep for one creature (human dims); returns the resolved position.</summary>
|
||||
|
|
@ -308,7 +316,14 @@ public class RemoteDeOverlapMechanismTests
|
|||
|
||||
float sep = Vector2.Distance(new(a.Position.X, a.Position.Y), new(b.Position.X, b.Position.Y));
|
||||
_out.WriteLine($"large-creature sep={sep:F3} m (big contact {bigContact:F2}, human contact {ContactDist:F2})");
|
||||
Assert.True(sep >= bigContact - 0.20f,
|
||||
// #265 bounce rework (2026-07-30): the old -0.20 slack was calibrated
|
||||
// against a fixture body carrying Contact WITHOUT a stored plane — a
|
||||
// state production never generates (the engine always commits flags +
|
||||
// plane together, and the check_contact seed now requires the pair).
|
||||
// With the production-representative fixture (plane seeded) the pair
|
||||
// settles at 1.58 m — the value production always produced for this
|
||||
// radius; the bound now brackets that with the same ~2-step spirit.
|
||||
Assert.True(sep >= bigContact - 0.30f,
|
||||
$"large creatures must de-overlap near their 2R contact ({bigContact:F2} m); got {sep:F3} m");
|
||||
Assert.True(sep > ContactDist + 0.4f,
|
||||
$"large creatures must spread materially WIDER than the human contact ({ContactDist:F2} m); got {sep:F3} m");
|
||||
|
|
|
|||
|
|
@ -1144,20 +1144,32 @@ public class PlayerMovementControllerTests
|
|||
Assert.False(controller.IsAirborne, "Should have landed");
|
||||
|
||||
// THE #265/#166 ACCEPTANCE BAR: the tick immediately after landing
|
||||
// must NOT be hand-zeroed to exactly (0,0,0) -- the old bug. On flat
|
||||
// ground dot(velocity, GroundNormal=(0,0,1)) = velocity.Z ~ 0 after
|
||||
// the landing hand-zero, which is below calc_friction's 0.25f
|
||||
// threshold, so friction DOES measurably engage here (unlike the
|
||||
// specific captured roof geometry in
|
||||
// Issue265SteepSlopeCaptureBisectTests, where it happens not to) --
|
||||
// this is "survives, then decays," not "coasts forever."
|
||||
// must NOT be hand-zeroed to exactly (0,0,0) -- the old bug. The
|
||||
// landing-bounce rework (2026-07-30,
|
||||
// docs/research/2026-07-30-landing-bounce-family.md) restores retail
|
||||
// handle_all_collisions' reflect (v += -(v·n)(elasticity+1)·n,
|
||||
// elasticity 0.05), so the first post-landing ticks are a micro-hop
|
||||
// CHAIN: each ground contact reverses 5% of the impact's normal
|
||||
// component and keeps the tangential -- the residual speed survives,
|
||||
// and calc_friction engages once a hop's contact dot falls under the
|
||||
// 0.25 AP-7 gate. "Survives, then decays" therefore measures a few
|
||||
// ticks after touchdown, not the very first one (friction cannot run
|
||||
// during the reflected rise -- that IS retail's landing bounce).
|
||||
controller.Update(ObjectTick, new MovementInput());
|
||||
float horizSpeedAfterLanding =
|
||||
new Vector2(controller.BodyVelocity.X, controller.BodyVelocity.Y).Length();
|
||||
Assert.True(horizSpeedAfterLanding > 0.01f,
|
||||
$"Expected residual horizontal speed to survive the first post-landing tick; " +
|
||||
$"got {horizSpeedAfterLanding} (launch speed was {horizSpeedAtLaunch})");
|
||||
Assert.True(horizSpeedAfterLanding < horizSpeedAtLaunch,
|
||||
"Expected friction to have begun decaying the residual speed, not leave it unchanged");
|
||||
|
||||
// Let the 5%-elasticity hop chain settle (v_z decays geometrically;
|
||||
// a handful of ObjectTicks covers several hops), then require decay.
|
||||
for (int i = 0; i < 12; i++)
|
||||
controller.Update(ObjectTick, new MovementInput());
|
||||
float horizSpeedSettled =
|
||||
new Vector2(controller.BodyVelocity.X, controller.BodyVelocity.Y).Length();
|
||||
Assert.True(horizSpeedSettled < horizSpeedAtLaunch,
|
||||
$"Expected friction to decay the residual speed once the bounce chain settles; " +
|
||||
$"got {horizSpeedSettled} vs launch {horizSpeedAtLaunch}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue