Cut 1: belts are evaluated when asked, not simulated every tick
Zone.Update no longer threads a per-belt Task.Run over a double-buffered Transforms/OrbitPosition pair. AsteroidBelt.Pose/Size are the one place a chunk pose is computed, a pure function of zone time; Zone.ChunkPose and Zone.EvaluateBelt read them at the zone's own time. MiningTool, ResourceScanner and ZoneRenderer (belt matrices and AsteroidBeltUI) all read through Zone instead of the deleted per-tick buffers; the renderer owns its own float4[] buffer, filled fresh every frame. Deleted: System.Threading/.Tasks usings, BeltUpdates, the wait loop, the per-belt copy+Task.Run, NearestAsteroid (dead/broken reader), the private UpdateAsteroidTransforms, and AsteroidBelt's four pose fields plus their allocations. ~65 lines, matching the cut map's estimate. miningprobe, densest zone of the operator's save (EAC-2265, 809 asteroids, 8 belts), rebuilt against this cut and rerun 10x each side: before (branch point, 8bd25f6f/e729e6d6): Zone.Update wall 541-3654 us/tick, CPU 120-391 us/tick (7 clean runs) 3/10 runs crashed: InvalidOperationException from Zone.GetOrbitPosition, the documented _updatedOrbits/Orbits data race between the main thread's Clear() and a belt Task.Run after (this commit): Zone.Update wall 95-185 us/tick, CPU 99-152 us/tick, in line with the 31-39 us "no belt work" baseline the map recorded -- belts are free per tick 0/10 races ChunkPose has no buffer to go stale: ~0.12 u of parent+asteroid motion is visible across one 17 ms tick instead of being one tick behind tests/Aetheria.Shared.Tests/MiningCut1Tests.cs (new, 260/260 suite green): ChunkPoseIsTheOrbitAtZoneTime, ChunkPoseDoesNotDependOnUpdateCadence, DamagedChunkShrinksAndABrokenOneHasNoSize -- each expected value is the closed-form orbit/size rule reimplemented in the test, never a call to AsteroidBelt.Pose/Size. Negative grep (Zone.cs, "Zone Display", ServerShared/Behaviors) for Task\.Run|System\.Threading|NewTransforms|\.Transforms\b|BeltUpdates| NearestAsteroid|\.OrbitPosition\b|NewOrbitPosition: one hit, the commented line in MineAsteroid that Cut 2 deletes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e729e6d6d5
commit
22a54ccbaf
5 changed files with 266 additions and 77 deletions
|
|
@ -56,10 +56,11 @@ public class MiningTool : Behavior
|
|||
public override bool Execute(float dt)
|
||||
{
|
||||
Range = Evaluate(_data.Range);
|
||||
var belt = Entity.Zone.AsteroidBelts[AsteroidBelt];
|
||||
if (AsteroidBelt.IsSet() &&
|
||||
Entity.Zone.AsteroidExists(AsteroidBelt, Asteroid) &&
|
||||
length(Entity.Position.xz - belt.Transforms[Asteroid].xy) - belt.Transforms[Asteroid].w < Range)
|
||||
if (!AsteroidBelt.IsSet() || !Entity.Zone.AsteroidExists(AsteroidBelt, Asteroid))
|
||||
return false;
|
||||
|
||||
var pose = Entity.Zone.ChunkPose(AsteroidBelt, Asteroid);
|
||||
if (length(Entity.Position.xz - pose.xy) - pose.w < Range)
|
||||
{
|
||||
Entity.Zone.MineAsteroid(
|
||||
Entity,
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ public class ResourceScanner : Behavior, IAlwaysUpdatedBehavior
|
|||
{
|
||||
if(Asteroid > -1 &&
|
||||
Asteroid < beltData.Asteroids.Length &&
|
||||
length(Entity.Position.xz - Entity.Zone.AsteroidBelts[ScanTarget].Transforms[Asteroid].xy) < Range)
|
||||
length(Entity.Position.xz - Entity.Zone.ChunkPose(ScanTarget, Asteroid).xy) < Range)
|
||||
{
|
||||
_scanTime += dt;
|
||||
if (_scanTime > ScanDuration)
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ using System;
|
|||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GameCult.Caching;
|
||||
using UniRx;
|
||||
using CultMath;
|
||||
|
|
@ -43,8 +41,6 @@ public class Zone
|
|||
private int _nextShotId;
|
||||
public int NextShotId() => ++_nextShotId;
|
||||
|
||||
private List<Task> BeltUpdates = new List<Task>();
|
||||
|
||||
public float Time
|
||||
{
|
||||
get => (float) _time;
|
||||
|
|
@ -150,9 +146,6 @@ public class Zone
|
|||
{
|
||||
_time += deltaTime;
|
||||
_updatedOrbits.Clear();
|
||||
foreach (var t in BeltUpdates)
|
||||
t.Wait();
|
||||
BeltUpdates.Clear();
|
||||
foreach (var orbit in Orbits)
|
||||
{
|
||||
orbit.Value.PreviousPosition = orbit.Value.Position;
|
||||
|
|
@ -160,13 +153,6 @@ public class Zone
|
|||
orbit.Value.Velocity = (orbit.Value.Position - orbit.Value.PreviousPosition) / deltaTime;
|
||||
}
|
||||
|
||||
foreach (var belt in AsteroidBelts)
|
||||
{
|
||||
Array.Copy(belt.Value.NewTransforms, belt.Value.Transforms, belt.Value.Transforms.Length);
|
||||
belt.Value.OrbitPosition = belt.Value.NewOrbitPosition;
|
||||
BeltUpdates.Add(Task.Run(() => UpdateAsteroidTransforms(belt.Key)));
|
||||
}
|
||||
|
||||
foreach(var agent in Agents)
|
||||
agent.Update(deltaTime);
|
||||
|
||||
|
|
@ -223,55 +209,22 @@ public class Zone
|
|||
return float2.zero;
|
||||
}
|
||||
|
||||
public int NearestAsteroid(CultRecordKey planetDataID, float2 position)
|
||||
{
|
||||
var beltData = Planets[planetDataID] as AsteroidBeltData;
|
||||
|
||||
var asteroidPositions = AsteroidBelts[planetDataID].Transforms;
|
||||
|
||||
int nearest = 0;
|
||||
float nearestDistance = Single.MaxValue;
|
||||
for (int i = 0; i < beltData.Asteroids.Length; i++)
|
||||
{
|
||||
var dist = lengthsq(asteroidPositions[i].xz - position);
|
||||
if (AsteroidExists(planetDataID, i) && dist < nearestDistance)
|
||||
{
|
||||
nearest = i;
|
||||
nearestDistance = dist;
|
||||
}
|
||||
}
|
||||
|
||||
return nearest;
|
||||
}
|
||||
|
||||
public bool AsteroidExists(CultRecordKey planetDataID, int asteroid) => ((AsteroidBeltData) Planets[planetDataID]).Asteroids.Length > asteroid && asteroid >= 0;
|
||||
|
||||
private void UpdateAsteroidTransforms(CultRecordKey planetDataID)
|
||||
// Cut 1 (docs/mining-cut.md): a chunk's pose is a pure function of zone time, computed when asked. No stored
|
||||
// pose survives a tick; the sim and the renderer both read it fresh through here.
|
||||
public float4 ChunkPose(CultRecordKey belt, int index)
|
||||
{
|
||||
var beltData = Planets[planetDataID] as AsteroidBeltData;
|
||||
var beltData = Planets[belt] as AsteroidBeltData;
|
||||
var parentPosition = GetOrbitPosition(Orbits[beltData.Orbit.Key].Data.Parent.Key);
|
||||
return AsteroidBelts[belt].Pose(index, _time, parentPosition, Settings);
|
||||
}
|
||||
|
||||
var belt = AsteroidBelts[planetDataID];
|
||||
|
||||
var orbitData = Orbits[beltData.Orbit.Key].Data;
|
||||
belt.NewOrbitPosition = GetOrbitPosition(orbitData.Parent.Key);
|
||||
for (var i = 0; i < beltData.Asteroids.Length; i++)
|
||||
{
|
||||
float size;
|
||||
if(belt.RespawnTimers.ContainsKey(i)) size = 0;
|
||||
else if (belt.Damage.ContainsKey(i))
|
||||
{
|
||||
var asteroidHitpoints = Settings.AsteroidHitpoints.Evaluate(beltData.Asteroids[i].Size);
|
||||
var damage = (asteroidHitpoints - belt.Damage[i]) / asteroidHitpoints;
|
||||
size = Settings.AsteroidSize.Evaluate(damage * beltData.Asteroids[i].Size);
|
||||
}
|
||||
else size = Settings.AsteroidSize.Evaluate(beltData.Asteroids[i].Size);
|
||||
|
||||
var rot = (float) (_time * beltData.Asteroids[i].RotationSpeed % (PI * 2));
|
||||
var pos = OrbitData.Evaluate((float) frac(_time / Settings.OrbitPeriod.Evaluate(beltData.Asteroids[i].Distance) +
|
||||
beltData.Asteroids[i].Phase)) * beltData.Asteroids[i].Distance + belt.NewOrbitPosition;
|
||||
//belt.NewPositions[i] = float3(pos.x, GetHeight(pos) + Settings.AsteroidVerticalOffset, pos.y);
|
||||
belt.NewTransforms[i] = float4(pos.x, pos.y, rot, size);
|
||||
}
|
||||
public void EvaluateBelt(CultRecordKey belt, Span<float4> into)
|
||||
{
|
||||
var beltData = Planets[belt] as AsteroidBeltData;
|
||||
var parentPosition = GetOrbitPosition(Orbits[beltData.Orbit.Key].Data.Parent.Key);
|
||||
AsteroidBelts[belt].Evaluate(into, _time, parentPosition, Settings);
|
||||
}
|
||||
|
||||
public OrbitData CreateOrbit(CultRecordKey parent, float2 position)
|
||||
|
|
@ -502,11 +455,7 @@ public class Sun : GasGiant
|
|||
public class AsteroidBelt
|
||||
{
|
||||
public AsteroidBeltData Data;
|
||||
public float4[] Transforms; // x, y, rotation, scale
|
||||
public float4[] NewTransforms; // x, y, rotation, scale
|
||||
public float Radius { get; }
|
||||
public float2 OrbitPosition;
|
||||
public float2 NewOrbitPosition;
|
||||
public Dictionary<int, float> RespawnTimers = new Dictionary<int, float>();
|
||||
public Dictionary<int, float> Damage = new Dictionary<int, float>();
|
||||
public Dictionary<(Entity, int), float> MiningAccumulator = new Dictionary<(Entity, int), float>();
|
||||
|
|
@ -514,10 +463,38 @@ public class AsteroidBelt
|
|||
public AsteroidBelt(AsteroidBeltData data)
|
||||
{
|
||||
Data = data;
|
||||
Transforms = new float4[data.Asteroids.Length];
|
||||
NewTransforms = new float4[data.Asteroids.Length];
|
||||
Radius = data.Asteroids.Max(a => a.Distance);
|
||||
}
|
||||
|
||||
// Cut 1 (docs/mining-cut.md): the formula of Zone.cs:269-271 verbatim, `time` in double as `_time` was used.
|
||||
// The only place a chunk pose is computed; no per-tick copy survives it.
|
||||
public float4 Pose(int index, double time, float2 parentPosition, PlanetSettings settings)
|
||||
{
|
||||
var asteroid = Data.Asteroids[index];
|
||||
var rot = (float) (time * asteroid.RotationSpeed % (PI * 2));
|
||||
var pos = OrbitData.Evaluate((float) frac(time / settings.OrbitPeriod.Evaluate(asteroid.Distance) +
|
||||
asteroid.Phase)) * asteroid.Distance + parentPosition;
|
||||
return float4(pos.x, pos.y, rot, Size(index, settings));
|
||||
}
|
||||
|
||||
// Cut 1 (docs/mining-cut.md): the size rule of Zone.cs:259-267, moved unchanged.
|
||||
public float Size(int index, PlanetSettings settings)
|
||||
{
|
||||
if (RespawnTimers.ContainsKey(index)) return 0;
|
||||
if (Damage.ContainsKey(index))
|
||||
{
|
||||
var asteroidHitpoints = settings.AsteroidHitpoints.Evaluate(Data.Asteroids[index].Size);
|
||||
var damage = (asteroidHitpoints - Damage[index]) / asteroidHitpoints;
|
||||
return settings.AsteroidSize.Evaluate(damage * Data.Asteroids[index].Size);
|
||||
}
|
||||
return settings.AsteroidSize.Evaluate(Data.Asteroids[index].Size);
|
||||
}
|
||||
|
||||
public void Evaluate(Span<float4> into, double time, float2 parentPosition, PlanetSettings settings)
|
||||
{
|
||||
for (var i = 0; i < Data.Asteroids.Length; i++)
|
||||
into[i] = Pose(i, time, parentPosition, settings);
|
||||
}
|
||||
}
|
||||
|
||||
public class Orbit
|
||||
|
|
|
|||
|
|
@ -72,6 +72,9 @@ public class ZoneRenderer : MonoBehaviour
|
|||
private Dictionary<CultRecordKey, AsteroidBeltUI> _beltObjects = new Dictionary<CultRecordKey, AsteroidBeltUI>();
|
||||
private Dictionary<CultRecordKey, InstancedMesh[]> _beltMeshes = new Dictionary<CultRecordKey, InstancedMesh[]>();
|
||||
private Dictionary<CultRecordKey, Matrix4x4[][]> _beltMatrices = new Dictionary<CultRecordKey, Matrix4x4[][]>();
|
||||
// Cut 1 (docs/mining-cut.md): the renderer's own buffer, filled from Zone.EvaluateBelt each frame. No pose
|
||||
// is stored on AsteroidBelt any more.
|
||||
private Dictionary<CultRecordKey, float4[]> _beltTransforms = new Dictionary<CultRecordKey, float4[]>();
|
||||
private float _viewDistance;
|
||||
//private float _maxDepth;
|
||||
private float _minimapDistance;
|
||||
|
|
@ -282,6 +285,7 @@ public class ZoneRenderer : MonoBehaviour
|
|||
_beltObjects.Clear();
|
||||
_beltMeshes.Clear();
|
||||
_beltMatrices.Clear();
|
||||
_beltTransforms.Clear();
|
||||
_tourPlanets.Clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -343,6 +347,7 @@ public class ZoneRenderer : MonoBehaviour
|
|||
meshes.RemoveAt(Random.Range(0, meshes.Count));
|
||||
_beltMeshes[key] = meshes.ToArray();
|
||||
_beltMatrices[key] = new Matrix4x4[meshes.Count][];
|
||||
_beltTransforms[key] = new float4[beltData.Asteroids.Length];
|
||||
var count = beltData.Asteroids.Length / meshes.Count;
|
||||
var remainder = beltData.Asteroids.Length - count * meshes.Count;
|
||||
for (int i = 0; i < meshes.Count; i++)
|
||||
|
|
@ -446,25 +451,30 @@ public class ZoneRenderer : MonoBehaviour
|
|||
|
||||
foreach (var (key, belt) in Zone.AsteroidBelts)
|
||||
{
|
||||
var height = Zone.GetHeight(belt.OrbitPosition);
|
||||
// Cut 1 (docs/mining-cut.md): belts have no stored pose any more. The renderer fills its own buffer
|
||||
// from Zone.EvaluateBelt, at the current zone time, every frame.
|
||||
var orbitPosition = Zone.GetOrbitPosition(Zone.Orbits[belt.Data.Orbit.Key].Data.Parent.Key);
|
||||
var height = Zone.GetHeight(orbitPosition);
|
||||
var transforms = _beltTransforms[key];
|
||||
Zone.EvaluateBelt(key, transforms);
|
||||
if(isVisible(new Bounds(
|
||||
new Vector3(belt.OrbitPosition.x,height,belt.OrbitPosition.y),
|
||||
new Vector3(orbitPosition.x,height,orbitPosition.y),
|
||||
new Vector3(belt.Radius * 2,100,belt.Radius * 2))))
|
||||
{
|
||||
var meshes = _beltMeshes[key];
|
||||
var matrices = _beltMatrices[key];
|
||||
var count = belt.Transforms.Length / meshes.Length;
|
||||
var count = transforms.Length / meshes.Length;
|
||||
for (int i = 0; i < meshes.Length; i++)
|
||||
{
|
||||
for (int t = 0; t < matrices[i].Length; t++)
|
||||
{
|
||||
var tx = t + i * count;
|
||||
var transform = belt.Transforms[tx];
|
||||
var transform = transforms[tx];
|
||||
matrices[i][t] = Matrix4x4.TRS(new Vector3(transform.x,0,transform.y),
|
||||
Quaternion.Euler(
|
||||
cos(transform.z + (float)i / meshes.Length) * 100,
|
||||
sin(transform.z + (float)i / meshes.Length) * 100,
|
||||
(float)tx / belt.Transforms.Length * 360),
|
||||
(float)tx / transforms.Length * 360),
|
||||
Vector3.one * transform.w);
|
||||
}
|
||||
|
||||
|
|
@ -473,7 +483,7 @@ public class ZoneRenderer : MonoBehaviour
|
|||
}
|
||||
|
||||
if(_showAsteroidUI)
|
||||
_beltObjects[key].Update(belt.Transforms, height);
|
||||
_beltObjects[key].Update(transforms, height);
|
||||
}
|
||||
|
||||
foreach (var planet in Planets)
|
||||
|
|
@ -659,7 +669,7 @@ public class AsteroidBeltUI
|
|||
|
||||
public void Update(float4[] transforms, float height)
|
||||
{
|
||||
var parentPosition = _belt.OrbitPosition;
|
||||
var parentPosition = _zone.GetOrbitPosition(_orbitParent);
|
||||
for (var i = 0; i < transforms.Length; i++)
|
||||
{
|
||||
var transform = transforms[i];
|
||||
|
|
|
|||
201
tests/Aetheria.Shared.Tests/MiningCut1Tests.cs
Normal file
201
tests/Aetheria.Shared.Tests/MiningCut1Tests.cs
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using CultMath;
|
||||
using GameCult.Caching;
|
||||
using Xunit;
|
||||
using static CultMath.math;
|
||||
using float2 = CultMath.float2;
|
||||
|
||||
// Cut 1 (docs/mining-cut.md): belts stop being simulated every tick on a threaded double buffer and become a
|
||||
// pure function of zone time, evaluated on request. These tests pin that: a chunk's pose is exactly the
|
||||
// closed-form orbit at the zone's current time (no staleness), it does not depend on how many ticks it took to
|
||||
// get there (no stored state), and the size rule moved off the deleted per-tick pass unchanged. Every expected
|
||||
// value below is computed independently, by the closed form written in this file -- never by calling
|
||||
// AsteroidBelt.Pose/Size, the code under test.
|
||||
public sealed class MiningCut1Tests : IDisposable
|
||||
{
|
||||
private int _fixtureCount;
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-miningcut1-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public MiningCut1Tests() => Directory.CreateDirectory(_root);
|
||||
public void Dispose() => Directory.Delete(_root, true);
|
||||
|
||||
private static GameplaySettings GameSettings() => new GameplaySettings
|
||||
{
|
||||
DefaultEntitySettings = new EntitySettings(),
|
||||
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
|
||||
QualityPriceModifier = new ExponentialLerp()
|
||||
};
|
||||
|
||||
// Authored-shape curves, not defaults: a default ExponentialLerp/ExponentialCurve evaluates to a flat
|
||||
// constant everywhere, which would hide a broken formula (every asteroid or every damage state would read
|
||||
// alike). These give period, size and hitpoints real, distinct curvature over the fixture's distance and
|
||||
// damage range.
|
||||
private static PlanetSettings Settings() => new PlanetSettings
|
||||
{
|
||||
OrbitPeriod = new ExponentialCurve { Multiplier = 0.6f, Exponent = 1.3f, Constant = 40f },
|
||||
AsteroidSize = new ExponentialLerp { Minimum = 2f, Maximum = 9f, Exponent = 1.7f },
|
||||
AsteroidHitpoints = new ExponentialLerp { Minimum = 10f, Maximum = 300f, Exponent = 2f },
|
||||
AsteroidRespawnTime = new ExponentialLerp { Minimum = 5f, Maximum = 60f, Exponent = 1f }
|
||||
};
|
||||
|
||||
private const int AsteroidCount = 24;
|
||||
private const float RootDistance = 4000f;
|
||||
private const float RootPhase = 0.17f;
|
||||
|
||||
// A belt of AsteroidCount asteroids at distinct distances, phases, size parameters and rotation speeds,
|
||||
// orbiting a parent (RootDistance/RootPhase) that is itself moving -- so a test that reads the parent's
|
||||
// motion through ChunkPose is exercising real recursion, not a fixed point. Each call opens its own
|
||||
// catalog, so two zones built from this fixture never share cache state.
|
||||
private (Zone zone, CultRecordKey beltKey, Asteroid[] asteroids, PlanetSettings settings) BuildFixture()
|
||||
{
|
||||
var dir = Path.Combine(_root, $"cat{_fixtureCount++}");
|
||||
Directory.CreateDirectory(dir);
|
||||
var catalog = Path.Combine(dir, "Aetheria.cc");
|
||||
var run = Path.Combine(dir, "run.cc");
|
||||
// OrbitData and BodyData (AsteroidBeltData) are run types (AetheriaStores.RunTypes), not catalog types:
|
||||
// they need a run store to write into.
|
||||
var cache = AetheriaStores.Open(catalog, run, catalogWritable: true);
|
||||
var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { });
|
||||
var settings = Settings();
|
||||
|
||||
var rootOrbit = new OrbitData { Distance = RootDistance, Phase = RootPhase };
|
||||
var rootKey = cache.Upsert(rootOrbit).Key;
|
||||
|
||||
var beltOrbit = new OrbitData { Parent = new CultRecordRef<OrbitData>(rootKey), Distance = 900f, Phase = 0.42f };
|
||||
var beltOrbitKey = cache.Upsert(beltOrbit).Key;
|
||||
|
||||
var asteroids = new Asteroid[AsteroidCount];
|
||||
for (var i = 0; i < AsteroidCount; i++)
|
||||
{
|
||||
asteroids[i] = new Asteroid
|
||||
{
|
||||
Distance = 120f + i * 11.3f,
|
||||
Phase = frac(i * 0.6180339887f),
|
||||
Size = frac(i * 0.37f + 0.1f),
|
||||
RotationSpeed = 0.15f + i * 0.037f
|
||||
};
|
||||
}
|
||||
|
||||
var beltData = new AsteroidBeltData { Orbit = new CultRecordRef<OrbitData>(beltOrbitKey), Asteroids = asteroids };
|
||||
var beltKey = cache.Upsert(beltData).Key;
|
||||
|
||||
var pack = new ZonePack
|
||||
{
|
||||
Orbits = { new CultRecordRef<OrbitData>(rootKey), new CultRecordRef<OrbitData>(beltOrbitKey) },
|
||||
Planets = { new CultRecordRef<BodyData>(beltKey) },
|
||||
Radius = 5000f,
|
||||
Mass = 10000f,
|
||||
Time = 0
|
||||
};
|
||||
|
||||
var zone = new Zone(items, settings, pack, new GalaxyZone { Name = "MiningCut1", Owner = null }, null);
|
||||
return (zone, beltKey, asteroids, settings);
|
||||
}
|
||||
|
||||
// The formula of Zone.GetOrbitPosition (unchanged by this cut), reimplemented here so the expected value
|
||||
// never calls production orbit code.
|
||||
private static float2 OrbitPosition(double time, float distance, float phase, PlanetSettings settings, float2 fixedPosition = default)
|
||||
{
|
||||
var period = settings.OrbitPeriod.Evaluate(distance);
|
||||
if (period <= .01f) return fixedPosition;
|
||||
var wrapped = (float) frac(time / period);
|
||||
return OrbitData.Evaluate(frac(wrapped + phase)) * distance + fixedPosition;
|
||||
}
|
||||
|
||||
// The formula of AsteroidBelt.Pose (Zone.cs:269-271 before this cut), reimplemented independently.
|
||||
private static float4 ExpectedPose(double time, Asteroid asteroid, float2 parentPosition, PlanetSettings settings, float size)
|
||||
{
|
||||
var rot = (float) (time * asteroid.RotationSpeed % (PI * 2));
|
||||
var pos = OrbitData.Evaluate((float) frac(time / settings.OrbitPeriod.Evaluate(asteroid.Distance) + asteroid.Phase))
|
||||
* asteroid.Distance + parentPosition;
|
||||
return float4(pos.x, pos.y, rot, size);
|
||||
}
|
||||
|
||||
private static void AssertClose(float expected, float actual, float eps, string what)
|
||||
{
|
||||
Assert.True(Math.Abs(expected - actual) <= eps, $"{what}: expected {expected}, got {actual} (eps {eps})");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChunkPoseIsTheOrbitAtZoneTime()
|
||||
{
|
||||
var (zone, beltKey, asteroids, settings) = BuildFixture();
|
||||
|
||||
// Uneven dt, so a tick-count-based or double-buffered implementation would disagree with a pure
|
||||
// function of the accumulated time.
|
||||
var dts = new[] { 0.13f, 0.07f, 0.31f, 0.02f, 0.5f, 0.11f, 0.19f, 0.08f, 0.44f, 0.03f, 0.27f, 0.09f };
|
||||
double time = 0;
|
||||
foreach (var dt in dts)
|
||||
{
|
||||
zone.Update(dt);
|
||||
time += dt;
|
||||
}
|
||||
|
||||
var parentPosition = OrbitPosition(time, RootDistance, RootPhase, settings);
|
||||
|
||||
for (var i = 0; i < asteroids.Length; i++)
|
||||
{
|
||||
var expected = ExpectedPose(time, asteroids[i], parentPosition, settings, settings.AsteroidSize.Evaluate(asteroids[i].Size));
|
||||
var actual = zone.ChunkPose(beltKey, i);
|
||||
AssertClose(expected.x, actual.x, 0.01f, $"asteroid {i} x");
|
||||
AssertClose(expected.y, actual.y, 0.01f, $"asteroid {i} y");
|
||||
AssertClose(expected.z, actual.z, 0.001f, $"asteroid {i} rotation");
|
||||
AssertClose(expected.w, actual.w, 0.001f, $"asteroid {i} size");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChunkPoseDoesNotDependOnUpdateCadence()
|
||||
{
|
||||
var (zoneOneStep, beltKeyA, asteroids, settings) = BuildFixture();
|
||||
zoneOneStep.Update(1.0f);
|
||||
|
||||
var (zoneManySteps, beltKeyB, _, _) = BuildFixture();
|
||||
for (var i = 0; i < 60; i++)
|
||||
zoneManySteps.Update(1f / 60f);
|
||||
|
||||
for (var i = 0; i < asteroids.Length; i++)
|
||||
{
|
||||
var a = zoneOneStep.ChunkPose(beltKeyA, i);
|
||||
var b = zoneManySteps.ChunkPose(beltKeyB, i);
|
||||
// Float summation of 60 steps of 1/60 drifts from 1.0 by a few ULPs; the pose is a pure function
|
||||
// of that (nearly identical) time, not of how many ticks produced it, so a loose position epsilon
|
||||
// catches a cadence-dependent implementation without chasing float noise.
|
||||
AssertClose(a.x, b.x, 0.05f, $"asteroid {i} x");
|
||||
AssertClose(a.y, b.y, 0.05f, $"asteroid {i} y");
|
||||
AssertClose(a.z, b.z, 0.01f, $"asteroid {i} rotation");
|
||||
AssertClose(a.w, b.w, 0.0001f, $"asteroid {i} size");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DamagedChunkShrinksAndABrokenOneHasNoSize()
|
||||
{
|
||||
var (zone, beltKey, asteroids, settings) = BuildFixture();
|
||||
var belt = zone.AsteroidBelts[beltKey];
|
||||
|
||||
const int undamagedIndex = 5;
|
||||
const int damagedIndex = 9;
|
||||
const int brokenIndex = 14;
|
||||
|
||||
var undamagedExpected = settings.AsteroidSize.Evaluate(asteroids[undamagedIndex].Size);
|
||||
Assert.Equal(undamagedExpected, belt.Size(undamagedIndex, settings), 4);
|
||||
|
||||
var hp = settings.AsteroidHitpoints.Evaluate(asteroids[damagedIndex].Size);
|
||||
var damage = hp * 0.6f;
|
||||
belt.Damage[damagedIndex] = damage;
|
||||
var damagedExpected = settings.AsteroidSize.Evaluate((hp - damage) / hp * asteroids[damagedIndex].Size);
|
||||
var damagedActual = belt.Size(damagedIndex, settings);
|
||||
Assert.Equal(damagedExpected, damagedActual, 4);
|
||||
Assert.True(damagedActual < settings.AsteroidSize.Evaluate(asteroids[damagedIndex].Size),
|
||||
"a damaged asteroid must be smaller than its undamaged size");
|
||||
|
||||
belt.RespawnTimers[brokenIndex] = settings.AsteroidRespawnTime.Evaluate(asteroids[brokenIndex].Size);
|
||||
Assert.Equal(0f, belt.Size(brokenIndex, settings));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue