From 2cb1e83353d7b86024423921f144abf358e26141 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 15 Oct 2026 23:41:49 +0200 Subject: [PATCH] Presenters read the drawn pose: HUD markers, wells, rounds, ping, shield hit, compass, loot view, guided aim HUD target, lock, incoming-lock, bearing and sweep markers anchor on DrawAhead.Position / AtBearing at Clock.Lead. GravityLowering's record fill is DrawAhead.Well (pure, pinned at non-zero lead). Rounds take DrawAhead.Time instead of float Zone.Time + lead. Sensor ping, shield hit, compass icons, wormhole bearing, loot ViewOrigin and the guided-projectile aim read the drawn pose. Not built in Unity yet. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 15 +++--- Assets/Scripts/Gameplay/EntityInstance.cs | 2 +- Assets/Scripts/Gameplay/ShieldManager.cs | 2 +- .../Weapons/GuidedProjectileManager.cs | 6 ++- Assets/Scripts/Gameplay/Weapons/Projectile.cs | 5 +- Assets/Scripts/ServerShared/DrawAhead.cs | 36 +++++++++++-- .../Scripts/Zone Display/GravityLowering.cs | 10 ++-- Assets/Scripts/Zone Display/ZoneRenderer.cs | 6 +-- .../BallisticFlightTests.cs | 20 +++---- tests/Aetheria.Shared.Tests/DrawnPoseTests.cs | 54 +++++++++++++++++++ 10 files changed, 121 insertions(+), 35 deletions(-) diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 99f2a3af2..e821e96ac 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -1460,13 +1460,15 @@ public class ActionGameManager : MonoBehaviour { if (CurrentEntity == null || CurrentEntity.Parent != null) return; + var lead = Clock.Lead; + var ownDrawn = DrawAhead.Position(CurrentEntity, lead); ViewDot.Target = ZoneRenderer.EntityInstances[CurrentEntity].LookAtPoint.position; var target = CurrentEntity.Target.Value; if (target.Chunk is ChunkId chunk) TargetIndicator.Target = ChunkWorldPosition(chunk).ToUnity(); else if (target.Entity != null) - TargetIndicator.Target = target.Entity.Position.ToUnity(); - var distance = length(ViewDot.Target.ToCultMath() - CurrentEntity.Position); + TargetIndicator.Target = DrawAhead.Position(target.Entity, lead).ToUnity(); + var distance = length(ViewDot.Target.ToCultMath() - ownDrawn); foreach (var (_, barrels, crosshair) in _articulationGroups) { var averagePosition = Vector3.zero; @@ -1484,7 +1486,7 @@ public class ActionGameManager : MonoBehaviour indicator.gameObject.SetActive(showIndicator); if(showIndicator) { - indicator.Target = targetEntity.Position.ToUnity(); + indicator.Target = DrawAhead.Position(targetEntity, lead).ToUnity(); ApplyLockProgress(indicator, spin, value); } } @@ -1501,8 +1503,7 @@ public class ActionGameManager : MonoBehaviour if (strongest >= 0) { var warning = warnings[strongest]; - incomingIndicator.Target = CurrentEntity.Position.ToUnity() + - new Vector3(warning.Bearing.x, 0, warning.Bearing.y) * IncomingLockIndicatorDistance; + incomingIndicator.Target = DrawAhead.AtBearing(CurrentEntity, warning.Bearing, IncomingLockIndicatorDistance, lead).ToUnity(); ApplyLockProgress(incomingIndicator, incomingSpin, warning.Strength); } @@ -1533,7 +1534,7 @@ public class ActionGameManager : MonoBehaviour } marker.gameObject.SetActive(true); var bearing = CurrentEntity.ContactOf(subject).Bearing; - marker.Place.Target = CurrentEntity.Position.ToUnity() + new Vector3(bearing.x, 0, bearing.y) * BearingMarkerDistance; + marker.Place.Target = DrawAhead.AtBearing(CurrentEntity, bearing, BearingMarkerDistance, Clock.Lead).ToUnity(); marker.ShowLabel(string.Join(", ", CurrentEntity.ClaimedClasses(subject).Select(c => c.Name))); } } @@ -1572,7 +1573,7 @@ public class ActionGameManager : MonoBehaviour _sweepFlashes.RemoveAt(i); continue; } - indicator.Target = CurrentEntity.Position.ToUnity() + new Vector3(bearing.x, 0, bearing.y) * IncomingLockIndicatorDistance; + indicator.Target = DrawAhead.AtBearing(CurrentEntity, bearing, IncomingLockIndicatorDistance, Clock.Lead).ToUnity(); if (image != null) image.color = new Color(image.color.r, image.color.g, image.color.b, 1 - age / SearchFlashSeconds); } } diff --git a/Assets/Scripts/Gameplay/EntityInstance.cs b/Assets/Scripts/Gameplay/EntityInstance.cs index 63bcfe48e..e263bac27 100644 --- a/Assets/Scripts/Gameplay/EntityInstance.cs +++ b/Assets/Scripts/Gameplay/EntityInstance.cs @@ -187,7 +187,7 @@ public class EntityInstance : MonoBehaviour { var pingInstance = Instantiate(PingPrefab); var pingMesh = pingInstance.GetComponent(); - pingInstance.position = entity.Position.ToUnity(); + pingInstance.position = DrawAhead.Position(entity, ActionGameManager.Clock.Lead).ToUnity(); _pingBrightness = pingMesh.material.GetFloat("_Depth"); _currentPing = (pingInstance, pingMesh); }; diff --git a/Assets/Scripts/Gameplay/ShieldManager.cs b/Assets/Scripts/Gameplay/ShieldManager.cs index d0b5a05e4..0514cf1e7 100644 --- a/Assets/Scripts/Gameplay/ShieldManager.cs +++ b/Assets/Scripts/Gameplay/ShieldManager.cs @@ -34,7 +34,7 @@ public class ShieldManager : MonoBehaviour, IAbsorbPresenter { if (contact.A != Entity && contact.B != Entity) return; if (Entity.Shield != null && Entity.Shield.Item.Active.Value) - ShowHit(new Vector3(contact.Point.x, Entity.Position.y, contact.Point.y), CollisionHitDuration); + ShowHit(DrawAhead.Carried(Entity, float3(contact.Point.x, Entity.Position.y, contact.Point.y), ActionGameManager.Clock.Lead).ToUnity(), CollisionHitDuration); } private ShieldEnvelope _envelope; diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs index 98ce5594c..1b5290640 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs @@ -77,7 +77,11 @@ public class GuidedProjectileManager : InstantWeaponEffectManager p.Velocity = barrel.forward * weapon.Velocity; p.Thrust = item.Evaluate(MunitionParts.Of(item).Acceleration); p.TopSpeed = item.Evaluate(MunitionParts.Of(item).MaxSpeed); - p.TargetPosition = () => (source.Entity.Position + length(source.LookAtPoint.position.ToCultMath() - source.Entity.Position) * source.Entity.Aim).ToUnity(); + p.TargetPosition = () => + { + var drawn = DrawAhead.Position(source.Entity, ActionGameManager.Clock.Lead); + return (drawn + length(source.LookAtPoint.position.ToCultMath() - drawn) * source.Entity.Aim).ToUnity(); + }; Bind(p, source.Entity.Zone, shotId); } else Debug.LogError($"Weapon {item.Data.Name} linked to {name} effect, but is not a Launcher!"); diff --git a/Assets/Scripts/Gameplay/Weapons/Projectile.cs b/Assets/Scripts/Gameplay/Weapons/Projectile.cs index 86f9a46e5..aa43051c1 100644 --- a/Assets/Scripts/Gameplay/Weapons/Projectile.cs +++ b/Assets/Scripts/Gameplay/Weapons/Projectile.cs @@ -50,8 +50,9 @@ public class Projectile : MonoBehaviour private void Draw() { var lead = ActionGameManager.Clock.Lead; - transform.position = V(DrawAhead.Round(_shot, _known, _barrel, BlendTime, _zone.Time, lead)); - if (DrawAhead.RoundOver(_shot, _known, _zone.Time, lead)) Finish(); + var drawn = DrawAhead.Time(_zone, lead); + transform.position = V(DrawAhead.Round(_shot, _known, _barrel, BlendTime, drawn, lead)); + if (DrawAhead.RoundOver(_shot, _known, drawn)) Finish(); } // The sim's commit: the record it holds now (a contact burst's arrival is shortened here) and what it decided. diff --git a/Assets/Scripts/ServerShared/DrawAhead.cs b/Assets/Scripts/ServerShared/DrawAhead.cs index 75d43cb65..c6d5b8309 100644 --- a/Assets/Scripts/ServerShared/DrawAhead.cs +++ b/Assets/Scripts/ServerShared/DrawAhead.cs @@ -40,6 +40,32 @@ public static class DrawAhead return float3(planar.x, entity.RideHeight(planar, Time(entity.Zone, lead)), planar.y); } + // The point `distance` out along a planar bearing from the ship's drawn position: where the HUD anchors a marker to the + // ship the camera follows (an incoming lock, a remembered contact, a ping's flash). + public static float3 AtBearing(Entity origin, float2 bearing, float distance, float lead) + { + var at = Position(origin, lead); + return float3(at.x + bearing.x * distance, at.y, at.z + bearing.y * distance); + } + + // One well as the shader takes it: its body's rail at the drawn time, with the strength, core and reference the sim's + // GetHeight sums with. GravityLowering copies it into the shader's record and decides nothing. + public readonly struct DrawnWell + { + public readonly float2 Position; + public readonly float Strength, CoreRadius, Reference; + public DrawnWell(float2 position, float strength, float coreRadius, float reference) + { + Position = position; + Strength = strength; + CoreRadius = coreRadius; + Reference = reference; + } + } + + public static DrawnWell Well(Zone zone, Planet body, float lead) => + new DrawnWell(Orbit(zone, body.BodyData.Orbit.Key, lead), body.WellStrength, body.RocheLimit, zone.WellReference(body)); + // A free body (a vapour cloud) drawn where the frame falls lead sim seconds after the last step, by the velocity // that step moved it with: the launch velocity and the drift together. public static float2 Position(KinematicBody body, float lead) => body.Position + body.TotalVelocity * lead; @@ -65,13 +91,13 @@ public static class DrawAhead t.w * q.w - t.x * q.x - t.y * q.y - t.z * q.z)); } - // A ballistic round at a sim time (the clock's Zone.Time + Lead): on its frozen line while the outcome is unknown or a + // A ballistic round at the drawn time (DrawAhead.Time, passed in by the caller, as lead is): on its frozen line while the outcome is unknown or a // miss, with the barrel's offset from that line decaying to nothing over blendTime sim seconds from the moment of firing. // A round the sim published as a hit or burst steers from its line, from the commit tick, onto where it ends by the // time damage lands: a burst's frozen point, else the impact cell on the target's drawn pose; and is held there after. - public static float3 Round(in PendingShot shot, ShotOutcome known, float3 barrel, float blendTime, float simTime, float lead) + public static float3 Round(in PendingShot shot, ShotOutcome known, float3 barrel, float blendTime, double drawnTime, float lead) { - var time = simTime + lead; + var time = (float) drawnTime; var drawn = Lifted(shot, Line(shot, known?.Result, time), barrel, blendTime, time); if (known == null || known.Result == ShotResult.Miss) return drawn; var end = known.HasBurstPoint @@ -101,8 +127,8 @@ public static class DrawAhead } // Whether the drawn flight is over by time: only a published miss ends by the clock. A hit or burst ends on ShotResolved. - public static bool RoundOver(in PendingShot shot, ShotOutcome known, float simTime, float lead) => - known != null && known.Result == ShotResult.Miss && simTime + lead >= RoundEnd(shot, ShotResult.Miss); + public static bool RoundOver(in PendingShot shot, ShotOutcome known, double drawnTime) => + known != null && known.Result == ShotResult.Miss && (float) drawnTime >= RoundEnd(shot, ShotResult.Miss); // The sim time a round's drawn line ends, for what is known of its outcome. A round that hit or burst (or whose outcome // is not yet published) ends at its ArrivalTime; a round the sim has published as a Miss flies on to the weapon's frozen diff --git a/Assets/Scripts/Zone Display/GravityLowering.cs b/Assets/Scripts/Zone Display/GravityLowering.cs index db9a5a14d..b4ea1e34c 100644 --- a/Assets/Scripts/Zone Display/GravityLowering.cs +++ b/Assets/Scripts/Zone Display/GravityLowering.cs @@ -50,13 +50,13 @@ public static class GravityLowering if (zone != null) foreach (var body in zone.PlanetInstances.Values) { - var at = DrawAhead.Orbit(zone, body.BodyData.Orbit.Key, lead); + var well = DrawAhead.Well(zone, body, lead); _records[i++] = new WellRecord { - Position = new Vector2(at.x, at.y), - Strength = body.WellStrength, - CoreRadius = body.RocheLimit, - Reference = zone.WellReference(body) + Position = new Vector2(well.Position.x, well.Position.y), + Strength = well.Strength, + CoreRadius = well.CoreRadius, + Reference = well.Reference }; } wells.SetData(_records); diff --git a/Assets/Scripts/Zone Display/ZoneRenderer.cs b/Assets/Scripts/Zone Display/ZoneRenderer.cs index b06f8eeff..e92c78c27 100644 --- a/Assets/Scripts/Zone Display/ZoneRenderer.cs +++ b/Assets/Scripts/Zone Display/ZoneRenderer.cs @@ -524,7 +524,7 @@ public class ZoneRenderer : MonoBehaviour GravityLowering.Lower(Zone, lead, ref _gravityWells); foreach (var loot in _floatingItems.Values) { - loot.ViewOrigin = PerspectiveEntity.Position; + loot.ViewOrigin = DrawAhead.Position(PerspectiveEntity, lead); loot.ViewDirection = PerspectiveEntity.Aim; } @@ -601,7 +601,7 @@ public class ZoneRenderer : MonoBehaviour { if(entityInstance.CompassIcon) { - var difference = entityInstance.Entity.Position.xz - PerspectiveEntity.Position.xz; + var difference = DrawAhead.Position(entityInstance.Entity, lead).xz - DrawAhead.Position(PerspectiveEntity, lead).xz; var distance = length(difference); entityInstance.CompassIcon.gameObject.SetActive( @@ -613,7 +613,7 @@ public class ZoneRenderer : MonoBehaviour foreach (var wormhole in WormholeInstances.Values) { - var difference = wormhole.gravity.transform.position.Flatland() - PerspectiveEntity.Position.xz.ToUnity(); + var difference = wormhole.gravity.transform.position.Flatland() - DrawAhead.Position(PerspectiveEntity, lead).xz.ToUnity(); var distance = difference.magnitude; wormhole.icon.gameObject.SetActive(distance > _minimapDistance); wormhole.icon.transform.rotation = Quaternion.Euler(0, 0, Mathf.Atan2(difference.y, difference.x) * Mathf.Rad2Deg - 90); diff --git a/tests/Aetheria.Shared.Tests/BallisticFlightTests.cs b/tests/Aetheria.Shared.Tests/BallisticFlightTests.cs index 79c787a74..eb878718c 100644 --- a/tests/Aetheria.Shared.Tests/BallisticFlightTests.cs +++ b/tests/Aetheria.Shared.Tests/BallisticFlightTests.cs @@ -165,7 +165,7 @@ public sealed partial class FireControlCut124Tests { var at = shot.FireTime + i * .37f; DrawAhead.Round(shot, known, barrel, .1f, at, .01f); - DrawAhead.RoundOver(shot, known, at, .01f); + DrawAhead.RoundOver(shot, known, at); DrawAhead.RoundEnd(shot, known?.Result); DrawAhead.Impact(e.Target, cell, .01f * i); } @@ -232,12 +232,12 @@ public sealed partial class FireControlCut124Tests Assert.Equal(direct.FireTime + 100f / speed, DrawAhead.RoundEnd(direct, ShotResult.Burst), 4); Assert.Equal(direct.FireTime + 1000f / speed, DrawAhead.RoundEnd(direct, ShotResult.Miss), 4); - Assert.False(DrawAhead.RoundOver(direct, null, direct.FireTime + 10000f, 0f)); + Assert.False(DrawAhead.RoundOver(direct, null, direct.FireTime + 10000f)); foreach (var result in new[] { ShotResult.Hit, ShotResult.Burst }) - Assert.False(DrawAhead.RoundOver(direct, Known(direct, result), direct.FireTime + 10000f, 0f)); + Assert.False(DrawAhead.RoundOver(direct, Known(direct, result), direct.FireTime + 10000f)); var missEnd = direct.FireTime + 1000f / speed; - Assert.False(DrawAhead.RoundOver(direct, Known(direct, ShotResult.Miss), missEnd - .01f, 0f)); - Assert.True(DrawAhead.RoundOver(direct, Known(direct, ShotResult.Miss), missEnd, 0f)); + Assert.False(DrawAhead.RoundOver(direct, Known(direct, ShotResult.Miss), missEnd - .01f)); + Assert.True(DrawAhead.RoundOver(direct, Known(direct, ShotResult.Miss), missEnd)); // A target beyond the weapon's range: a miss never ends short of its own arrival. var far = FireDirectRound(speed, 100f, targetRange: 150f); @@ -269,10 +269,10 @@ public sealed partial class FireControlCut124Tests foreach (var lead in new[] { 0f, step / 2f, .999f * step }) { - var waiting = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, shot.ArrivalTime, lead); + var waiting = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, shot.ArrivalTime + lead, lead); Assert.Equal(origin.x, waiting.x, 3); Assert.Equal(origin.z + 100f, waiting.z, 3); - var missed = DrawAhead.Round(shot, Known(shot, ShotResult.Miss), shot.FireOrigin, 0f, shot.ArrivalTime, lead); + var missed = DrawAhead.Round(shot, Known(shot, ShotResult.Miss), shot.FireOrigin, 0f, shot.ArrivalTime + lead, lead); Assert.Equal(origin.x, missed.x, 3); Assert.Equal(origin.z + 100f + lead * speed, missed.z, 3); } @@ -386,7 +386,7 @@ public sealed partial class FireControlCut124Tests // A frame that falls `lead` after the step draws what the sim time lead later would. foreach (var lead in new[] { 1f / 60f, 1f / 90f }) { - var ahead = DrawAhead.Round(shot, miss, shot.FireOrigin, .1f, shot.FireTime + checkpoint - lead, lead); + var ahead = DrawAhead.Round(shot, miss, shot.FireOrigin, .1f, shot.FireTime + checkpoint, lead); Assert.Equal(fresh.x, ahead.x, 3); Assert.Equal(fresh.y, ahead.y, 3); Assert.Equal(fresh.z, ahead.z, 3); @@ -441,7 +441,7 @@ public sealed partial class FireControlCut124Tests Assert.Equal(expected.z, impact.z, 3); foreach (var simTime in new[] { shot.ArrivalTime, shot.ArrivalTime + .5f }) { - var drawn = DrawAhead.Round(shot, hit, shot.FireOrigin, .1f, simTime, lead); + var drawn = DrawAhead.Round(shot, hit, shot.FireOrigin, .1f, simTime + lead, lead); Assert.Equal(expected.x, drawn.x, 3); Assert.Equal(expected.y, drawn.y, 3); Assert.Equal(expected.z, drawn.z, 3); @@ -480,7 +480,7 @@ public sealed partial class FireControlCut124Tests Assert.Equal(onTheLine.y + (end.y - onTheLine.y) * .5f, halfway.y, 3); Assert.Equal(onTheLine.z + (end.z - onTheLine.z) * .5f, halfway.z, 3); - var ahead = DrawAhead.Round(shot, hit, shot.FireOrigin, 0f, m - .02f, .02f); + var ahead = DrawAhead.Round(shot, hit, shot.FireOrigin, 0f, m, .02f); Assert.Equal(halfway.x, ahead.x, 3); Assert.Equal(halfway.y, ahead.y, 3); Assert.Equal(halfway.z, ahead.z, 3); diff --git a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs index 349a44d9e..82b0f379e 100644 --- a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs +++ b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs @@ -340,6 +340,60 @@ public sealed class DrawnPoseTests : IDisposable } } + // The well the shader takes is its body's rail at the drawn time: the lowering copies DrawAhead.Well and decides + // nothing, so the drawn well and the drawn planet share one pose at a non-zero lead, and the strength, core and + // reference are the field law's (spelled here from the masses, the core 2 and the zone's edge), not the step's. + [Fact] + public void AWellIsLoweredAtTheDrawnTime() + { + var w = Build(); + Step(w, 1f / 60f, 803); + foreach (var lead in new[] { 0f, .004f, .0166f }) + { + var time = w.Zone.ExactTime + lead; + foreach (var body in w.Zone.PlanetInstances.Values) + { + var key = body.BodyData.Orbit.Key; + var (at, strength) = key == w.PlanetOrbit ? (PlanetAt(time), PlanetMass) + : key == w.MoonOrbit ? (MoonAt(time), MoonMass) + : (SunCentre, SunMass); + var well = DrawAhead.Well(w.Zone, body, lead); + AssertNear(at, well.Position, 1e-3f, $"well of the {strength} body at lead {lead}"); + Assert.Equal(strength, well.Strength, 3); + Assert.Equal(2f, well.CoreRadius, 3); + Assert.Equal(GravityField.Potential(strength, 2f, w.Zone.Pack.Radius), well.Reference, 3); + } + } + + var planet = w.Zone.PlanetInstances.Values.Single(b => b.BodyData.Orbit.Key == w.PlanetOrbit); + Assert.True(length(DrawAhead.Well(w.Zone, planet, .0166f).Position - DrawAhead.Well(w.Zone, planet, 0f).Position) > .1f, "the well moves over a lead"); + AssertNear(w.Zone.Orbits[w.PlanetOrbit].Position, DrawAhead.Well(w.Zone, planet, 0f).Position, 0f, "at lead 0 the well is the step's memo"); + } + + // A HUD marker round the ship the camera follows (an incoming lock, a contact's bearing, a ping's flash) is anchored on + // that ship's drawn position, so it holds still against the drawn hull at every lead. A marker anchored on the step's + // position slides v * lead off it and snaps back each step. + [Fact] + public void AMarkerAtABearingIsAnchoredOnTheDrawnShip() + { + var w = Build(); + Step(w, 1f / 60f, 30); + var ship = new OrbitalEntity(w.Items, w.Zone, w.Hull, default, new EntitySettings()) + { + Position = float3(520f, 0, -140f), + Velocity = float2(12f, -5f) + }; + var bearing = normalize(float2(.6f, .8f)); + foreach (var lead in new[] { 0f, .004f, .0166f }) + { + var drawn = DrawAhead.Position(ship, lead); + var marker = DrawAhead.AtBearing(ship, bearing, 7f, lead); + AssertNear(float2(520f, -140f) + float2(12f, -5f) * lead + bearing * 7f, marker.xz, 1e-4f, $"marker at lead {lead}"); + Assert.Equal(drawn.y, marker.y, 4); + AssertNear(drawn.xz, marker.xz - bearing * 7f, 1e-4f, $"the marker's anchor is the drawn ship at lead {lead}"); + } + } + private static void AssertNear(float2 expected, float2 actual, float eps, string what) => Assert.True(length(expected - actual) <= eps, $"{what}: expected {expected}, got {actual} (eps {eps})"); }