diff --git a/Assets/Scenes/ARPG.unity b/Assets/Scenes/ARPG.unity index 370f1914b..9c650e2f0 100644 --- a/Assets/Scenes/ARPG.unity +++ b/Assets/Scenes/ARPG.unity @@ -8037,7 +8037,7 @@ Camera: m_Depth: 0 m_CullingMask: serializedVersion: 2 - m_Bits: 5632 + m_Bits: 5120 m_RenderingPath: 1 m_TargetTexture: {fileID: 8400000, guid: 06ec5ef8a5830fc44b1ac1a6e123add5, type: 2} m_TargetDisplay: 0 diff --git a/Assets/Scenes/Main Menu.unity b/Assets/Scenes/Main Menu.unity index b770f3db7..c67fdb7cc 100644 --- a/Assets/Scenes/Main Menu.unity +++ b/Assets/Scenes/Main Menu.unity @@ -982,7 +982,7 @@ Camera: m_Depth: 0 m_CullingMask: serializedVersion: 2 - m_Bits: 5632 + m_Bits: 5120 m_RenderingPath: 1 m_TargetTexture: {fileID: 8400000, guid: 06ec5ef8a5830fc44b1ac1a6e123add5, type: 2} m_TargetDisplay: 0 diff --git a/Assets/Scenes/TestScene.unity b/Assets/Scenes/TestScene.unity index 06ee5fa70..45c73ee85 100644 --- a/Assets/Scenes/TestScene.unity +++ b/Assets/Scenes/TestScene.unity @@ -3769,7 +3769,7 @@ Camera: m_Depth: 0 m_CullingMask: serializedVersion: 2 - m_Bits: 5632 + m_Bits: 5120 m_RenderingPath: 1 m_TargetTexture: {fileID: 8400000, guid: 06ec5ef8a5830fc44b1ac1a6e123add5, type: 2} m_TargetDisplay: 0 diff --git a/Assets/Scripts/Editor/GravityFieldParityVerify.cs b/Assets/Scripts/Editor/GravityFieldParityVerify.cs index c6de02c4a..20b116189 100644 --- a/Assets/Scripts/Editor/GravityFieldParityVerify.cs +++ b/Assets/Scripts/Editor/GravityFieldParityVerify.cs @@ -255,6 +255,19 @@ namespace Aetheria.EditorTools // menu's own brush objects (Boundary on layer 9, the Waves on layer 10) and are presentation by spec (follow-up // menu-grid-brushes), not a reader of the potential. This pins that: a ZoneRenderer in the menu would make its // cameras readers of the field, and they would have to cull layer 9 alone like ARPG's. + static void CheckTestSceneWiring() + { + EditorSceneManager.OpenScene("Assets/Scenes/TestScene.unity", OpenSceneMode.Single); + try + { + if (CheckPatchCameras("TestScene.unity") < 1) Fail("wiring", "no Fog Patch Displacement camera in TestScene.unity"); + } + finally + { + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + } + } + static void CheckMenuWiring() { var scene = EditorSceneManager.OpenScene("Assets/Scenes/Main Menu.unity", OpenSceneMode.Single); @@ -272,6 +285,7 @@ namespace Aetheria.EditorTools Fail("wiring", $"Main Menu camera {camera.name} culls mask {camera.cullingMask}, not the menu brush layers 9 and 10"); } if (seen != 1) Fail("wiring", $"{seen} Fog Surface Displacement cameras in Main Menu.unity, expected 1"); + if (CheckPatchCameras("Main Menu.unity") < 1) Fail("wiring", "no Fog Patch Displacement camera in Main Menu.unity"); } finally { @@ -279,6 +293,21 @@ namespace Aetheria.EditorTools } } + // Every scene's Fog Patch Displacement camera: cloudDensity adds the R32 gravity height to the patch texture, so + // a patch camera that culls the gravity layer counts the field twice. Returns how many it found in the open scene. + static int CheckPatchCameras(string sceneName) + { + var found = 0; + foreach (var camera in UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None)) + { + if (camera.targetTexture == null || camera.targetTexture.name != "Fog Patch Displacement") continue; + found++; + if ((camera.cullingMask & 1 << GravityLowering.FieldLayer) != 0) + Fail("wiring", $"camera {camera.name} (target Fog Patch Displacement) in {sceneName} culls layer {GravityLowering.FieldLayer}, the gravity layer"); + } + return found; + } + static void CheckWiring(GameSettings settings) { var scene = EditorSceneManager.OpenScene("Assets/Scenes/ARPG.unity", OpenSceneMode.Single); @@ -295,6 +324,9 @@ namespace Aetheria.EditorTools } if (seen < gravityTargets.Length) Fail("wiring", $"only {seen} gravity cameras found in ARPG.unity"); + // The patch texture is half-float: it holds the patch layers only, the nebula reads gravity from the R32 surface. + if (CheckPatchCameras("ARPG.unity") != 1) Fail("wiring", "ARPG.unity does not have exactly 1 Fog Patch Displacement camera"); + // The map screen's gravity camera has no target until MapRenderer gives it one at run time. var map = UnityEngine.Object.FindFirstObjectByType(FindObjectsInactive.Include); if (map == null || map.GravityCamera == null) Fail("wiring", "no MapRenderer.GravityCamera in ARPG.unity"); @@ -316,6 +348,7 @@ namespace Aetheria.EditorTools } CheckMenuWiring(); + CheckTestSceneWiring(); foreach (var path in new[] { diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index 5169969ec..3e143eb52 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -109,9 +109,8 @@ public class CombatState : BaseState // Cut 3 (docs/fire-control-cut.md): the one prediction function, shared with FireControl.Fire's // own snapshot and the roll's deviation measurement -- two separate predictions here and in the // roll would let an AI aim at one point and get judged for missing a different one. - var targetHullData = _agent.ItemManager.GetData(target.Hull) as HullData; var predictedPosition = FireControl.PredictedIntercept(testWeapon, _agent.Ship, target); - predictedPosition.y = _agent.Ship.Zone.GetHeight(predictedPosition.xz) + targetHullData.GridOffset; + predictedPosition.y = target.RideHeight(predictedPosition.xz); toTarget = normalize(predictedPosition - _agent.Ship.Position); } diff --git a/Assets/Scripts/ServerShared/Behaviors/TurretController.cs b/Assets/Scripts/ServerShared/Behaviors/TurretController.cs index 654ee5ec1..88578c637 100644 --- a/Assets/Scripts/ServerShared/Behaviors/TurretController.cs +++ b/Assets/Scripts/ServerShared/Behaviors/TurretController.cs @@ -64,13 +64,12 @@ public class TurretController : Behavior, IInitializableBehavior var diff = target.Position - Entity.Position; if (_predictShots) { - var targetHullData = Entity.ItemManager.GetData(target.Hull) as HullData; var targetVelocity = float3(target.Velocity.x, 0, target.Velocity.y); var predictedPosition = first_order_intercept( Entity.Position, float3.zero, _shotSpeed, target.Position, targetVelocity ); - predictedPosition.y = Entity.Zone.GetHeight(predictedPosition.xz) + targetHullData.GridOffset; + predictedPosition.y = target.RideHeight(predictedPosition.xz); Entity.Aim = normalize(predictedPosition - Entity.Position); } else diff --git a/Assets/Scripts/ServerShared/DrawAhead.cs b/Assets/Scripts/ServerShared/DrawAhead.cs index d4203d639..88fa08861 100644 --- a/Assets/Scripts/ServerShared/DrawAhead.cs +++ b/Assets/Scripts/ServerShared/DrawAhead.cs @@ -7,17 +7,22 @@ using static CultMath.math; // Pure presentation statics over sim state, never called by ServerShared, with no state of their own: the sim owns every // pose and no presenter stores one, a previous state or a velocity of its own. Ships are drawn where the frame falls Lead -// sim seconds after the last step, by their last step's velocity and turn. A ballistic round's flight is presentation +// sim seconds after the last step, by their last step's velocity and turn, and ride the field at that drawn xz (a ship's height is the field's there, not +// the last step's). A ballistic round's flight is presentation // (ruling projectile-flight-is-presentation), drawn from the sim's facts: its frozen line, the published outcome, the // commit tick, the impact cell on the target's drawn pose, the burst point. public static class DrawAhead { // A ship in a wormhole animation has its pose scripted by the step, not integrated from Velocity and TurnRate, // so it is drawn where the step put it: extrapolating a scripted pose draws a streak that resets every step. - public static float3 Position(Entity entity, float lead) => - entity is Ship { WormholeAnimationInProgress: true } - ? entity.Position - : entity.Position + float3(entity.Velocity.x, 0, entity.Velocity.y) * lead; + // A child is drawn where its parent is drawn, as the step copies the parent's pose onto it. + public static float3 Position(Entity entity, float lead) + { + if (entity is Ship { WormholeAnimationInProgress: true }) return entity.Position; + if (entity.Parent != null) return Position(entity.Parent, lead); + var planar = entity.Position.xz + entity.Velocity * lead; + return float3(planar.x, entity.RideHeight(planar), planar.y); + } // 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. diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 3738fe896..4a57fbe6e 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -1849,7 +1849,6 @@ public abstract class Entity var velocityBefore = Velocity; LimitClamp = float2(0, 0); - var hullData = ItemManager.GetData(Hull) as HullData; // Mining Cut 3: a chunk target is lost the moment this entity can no longer see it -- the lost-track rule // through the chunk path. One check of the one slot per tick, not a detection loop over chunks. @@ -1969,12 +1968,16 @@ public abstract class Entity Position = Parent.Position; Velocity = Parent.Velocity; } - else Position.y = Zone.GetHeight(Position.xz) + hullData.GridOffset; + else Position.y = RideHeight(Position.xz); Acceleration = delta > 0f ? (Velocity + LimitClamp - velocityBefore) / delta : float2(0, 0); Manoeuvre.Observe(Acceleration, delta, ItemManager.GameplaySettings.SolutionWindow); } + // Where an unparented entity sits on the field at a planar point: the zone's height there plus the hull's grid offset. + // The step puts the entity here at its own xz, and the presenter at its drawn xz (DrawAhead). + public float RideHeight(float2 planar) => Zone.GetHeight(planar) + (ItemManager.GetData(Hull) as HullData).GridOffset; + private void UpdateTemperature(float delta) { var hullData = ItemManager.GetData(Hull) as HullData; diff --git a/Assets/Shaders/Brushes/GravityField.shader b/Assets/Shaders/Brushes/GravityField.shader index b3987aab1..857d7c80e 100644 --- a/Assets/Shaders/Brushes/GravityField.shader +++ b/Assets/Shaders/Brushes/GravityField.shader @@ -1,5 +1,5 @@ // Draws the zone's gravity: depth = -Zone.GetHeight at the world xz of every texel, into whichever gravity camera culls -// layer 9 (Surface Displacement, Patch Displacement, Slime Gravity, both Map Gravity). One quad covers the zone; the +// layer 9 (Surface Displacement, Slime Gravity, both Map Gravity). One quad covers the zone; the // law lives in Gravity.cginc, which mirrors ServerShared's GravityField. Shader "Brushes/Gravity Field" { Category { diff --git a/Assets/Shaders/Volumetric.cginc b/Assets/Shaders/Volumetric.cginc index ec95aedc3..2ceb66314 100644 --- a/Assets/Shaders/Volumetric.cginc +++ b/Assets/Shaders/Volumetric.cginc @@ -97,7 +97,9 @@ float cloudDensity(float3 pos, float surfaceDisp) float2 uv = getUV(pos.xz); const float dist = pos.y + surfaceDisp - _NebulaFloorOffset; const float patch = tex2Dlod(_NebulaPatch, half4(uv, 0, 0)).r; - const float patchDisp = tex2Dlod(_NebulaPatchHeight, half4(uv, 0, 0)).r; + // Gravity comes from the R32 surface height the caller sampled at this uv; the patch texture carries only the patch + // layers (sector waves, patch brushes), small enough for half floats. + const float patchDisp = surfaceDisp + tex2Dlod(_NebulaPatchHeight, half4(uv, 0, 0)).r; const float patchDensity = saturate((-abs(pos.y+patchDisp - _NebulaFloorOffset)+patch)/_NebulaPatchBlend)*_NebulaPatchDensity; const float floorDist = -dist; diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 21566f81a..4e9a23268 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -84,12 +84,12 @@ public sealed class FireControlPerWeaponTests : IDisposable (ItemRotation Rotation, float Arc)[] mounts = null, bool beam = false, WeaponFuse? fuse = null, float blast = 0f, float range = 100f, float targetRange = 60f, float accuracy = 1f, float minHit = .2f, bool lockWeapon = false, bool turret = false, float energy = 0f, float heat = 0f, float visibility = 0f, int magazine = 0, bool singleAmmoBurst = false, bool decoy = false, bool charged = false, bool auto = false, - float effective = float.PositiveInfinity, float damageAtMax = 0f) + float effective = float.PositiveInfinity, float damageAtMax = 0f, float velocity = 0f, float targetGridOffset = 0f) { mounts ??= new[] { (ItemRotation.None, 0f) }; var hullData = new HullData { - Name = "Hull", HullType = HullType.Ship, Shape = SolidShape(5, 4), Durability = 1000000, Mass = 1000, Armor = 0, + Name = "Hull", HullType = HullType.Ship, Shape = SolidShape(5, 4), Durability = 1000000, Mass = 1000, Armor = 0, GridOffset = targetGridOffset, Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } }; var shooterHullData = new HullData @@ -135,7 +135,7 @@ public sealed class FireControlPerWeaponTests : IDisposable behavior.MinRange = Constant(0); behavior.EffectiveRange = Constant(effective); behavior.DamageAtMaxRange = damageAtMax; - behavior.Velocity = Constant(0); + behavior.Velocity = Constant(velocity); behavior.Spread = Constant(0); behavior.DamageSpread = Constant(0); behavior.Penetration = Constant(0); @@ -308,6 +308,39 @@ public sealed class FireControlPerWeaponTests : IDisposable Assert.True(r.Gun.Weapon.Firing, "the turret must fire a fused weapon at a designated target out of arc"); } + // The AI aims at the height a target rides at (Entity.RideHeight: field plus the hull's GridOffset), not at the + // plane. A target hull raised 40 u, a shot fast enough to be predicted: the turret's and Combat's aim point at the + // target's stepped pose. Kills: either aim site spelling its own offset (dropped, added to, or scaled). + [Fact] + public void ATurretAimsAtTheHeightATargetRidesAt() + { + var r = Build(turret: true, velocity: 500f, targetGridOffset: 40f); + r.Zone.Update(1f); + + AssertAimsAtTarget(r); + } + + [Fact] + public void CombatAimsAtTheHeightATargetRidesAt() + { + var r = Build(velocity: 500f, targetGridOffset: 40f); + r.Zone.Agents.Add(new Minion(r.Shooter)); + r.Zone.Update(1f); + r.Zone.Update(1f); + + AssertAimsAtTarget(r); + } + + private static void AssertAimsAtTarget(Rig r) + { + var toTarget = r.Target.Position - r.Shooter.Position; + Assert.True(abs(toTarget.y) > 30f, "fixture: the raised hull is well above the shooter"); + var expected = normalize(toTarget); + Assert.Equal(expected.x, r.Shooter.Aim.x, 3); + Assert.Equal(expected.y, r.Shooter.Aim.y, 3); + Assert.Equal(expected.z, r.Shooter.Aim.z, 3); + } + public enum Withheld { NoTarget, OutOfRange, NotVisible, Unlocked } // The AI never fires a fused weapon at nothing: a missing target, one out of range, one not visible and one diff --git a/tests/Aetheria.Shared.Tests/FireControlTests.cs b/tests/Aetheria.Shared.Tests/FireControlTests.cs index 93e056a9b..95b85df46 100644 --- a/tests/Aetheria.Shared.Tests/FireControlTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlTests.cs @@ -45,13 +45,13 @@ public sealed class FireControlTests : IDisposable // `well` digs the zone one well at the origin (WellFixture), so heights differ across the zone. private (ItemManager items, Ship ship, EquippedItem[] weapons) BuildShipWithHardpoints( - bool well, (ItemRotation rotation, float firingArc)[] hardpoints) + bool well, (ItemRotation rotation, float firingArc)[] hardpoints, float gridOffset = 0f) { var hullShape = new Shape(Math.Max(hardpoints.Length, 3), 3); foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; // Mass defaults to 0 (ItemData.cs:285), which zeroes ThermalMass (Entity.cs MapEntity) and NaNs // UpdateTemperature -- author a real mass so the entity's own temperature stays a real number. - var hullData = new HullData { Name = "TestHull", HullType = HullType.Ship, Shape = hullShape, Durability = 1, Mass = 1000 }; + var hullData = new HullData { Name = "TestHull", HullType = HullType.Ship, Shape = hullShape, Durability = 1, Mass = 1000, GridOffset = gridOffset }; for (var i = 0; i < hardpoints.Length; i++) hullData.Hardpoints.Add(new HardpointData { @@ -380,6 +380,39 @@ public sealed class FireControlTests : IDisposable // height, so its distance in space is longer than its planar distance. A claim whose Range lies between the two, full // damage out to the planar distance, is in range on the plane and out of range in space: it (against Slug's 5) is the // chosen claim. Mutation: Combat measures the three-dimensional distance (the claim then reads 0). + // B32: a ship drawn ahead rides the field at its drawn xz, not at the height of its last step (the per-step height + // staircase that read as jitter on a steep slope). Mutation: DrawAhead's y back to entity.Position.y fails the first + // assertion; reading the height at the last step's xz fails it too. + [Fact] + public void ADrawnShipRidesTheFieldAtItsDrawnPosition() + { + var (items, ship, _) = BuildShipWithHardpoints(true, new[] { (ItemRotation.None, 0f) }, gridOffset: 2.5f); + var zone = ship.Zone; + ship.Position = float3(200, 0, 0); + zone.Update(1f); + ship.Velocity = float2(100, 0); // up the slope, away from the well + const float lead = .1f; + + var drawn = DrawAhead.Position(ship, lead); + + Assert.Equal(ship.Position.x + 100 * lead, drawn.x, 4); + // The hull rides 2.5 u above the field (authored above): dropping or doubling the offset moves the ship by 2.5. + Assert.Equal(zone.GetHeight(float2(drawn.x, drawn.z)) + 2.5f, drawn.y, 4); + Assert.Equal(zone.GetHeight(float2(ship.Position.x, ship.Position.z)) + 2.5f, ship.Position.y, 4); + Assert.True(abs(drawn.y - ship.Position.y) > .1f, "the slope is real: the drawn height differs from the last step's"); + var still = DrawAhead.Position(ship, 0f); + Assert.Equal(ship.Position.x, still.x); + Assert.Equal(ship.Position.y, still.y); + Assert.Equal(ship.Position.z, still.z); + + // A child (a docked ship) is drawn where its parent is drawn, whatever its own pose and velocity. + var child = new Ship(items, zone, ship.Hull, new EntitySettings()) { Position = float3(-500, 7, 300), Velocity = float2(-30, 40), Parent = ship }; + var carried = DrawAhead.Position(child, lead); + Assert.Equal(drawn.x, carried.x, 4); + Assert.Equal(drawn.y, carried.y, 4); + Assert.Equal(drawn.z, carried.z, 4); + } + [Fact] public void TheAIMeasuresTheTargetOnTheGroundPlane() { diff --git a/tests/Aetheria.Shared.Tests/SimClockTests.cs b/tests/Aetheria.Shared.Tests/SimClockTests.cs index a87a39702..db30beb94 100644 --- a/tests/Aetheria.Shared.Tests/SimClockTests.cs +++ b/tests/Aetheria.Shared.Tests/SimClockTests.cs @@ -263,7 +263,7 @@ public sealed partial class RunStartTests const float lead = .007f; var ahead = DrawAhead.Position(player, lead); Assert.Equal(player.Position.x + player.Velocity.x * lead, ahead.x, 6); - Assert.Equal(player.Position.y, ahead.y); + Assert.Equal(player.RideHeight(float2(ahead.x, ahead.z)), ahead.y, 4); Assert.Equal(player.Position.z + player.Velocity.y * lead, ahead.z, 6); Assert.Equal(before, Snapshot(arena, new List())); }