diff --git a/Assets/Content/Prefabs/Mine.prefab b/Assets/Content/Prefabs/Mine.prefab index dbc1e0915..912986ee7 100644 --- a/Assets/Content/Prefabs/Mine.prefab +++ b/Assets/Content/Prefabs/Mine.prefab @@ -146,6 +146,5 @@ MonoBehaviour: ActiveEmission: 100 ArmedEmission: 1000 RotationSpeed: 1 - GridOffset: 0 HitEffect: {fileID: 1121192042590650, guid: e1b08032bec16ba4eb1ae23cbd572664, type: 3} HitEffectScale: 10 diff --git a/Assets/Prefabs/RPG/Pickups/Dodecahedron.prefab b/Assets/Prefabs/RPG/Pickups/Dodecahedron.prefab index 5d4f837d1..7a65a0864 100644 --- a/Assets/Prefabs/RPG/Pickups/Dodecahedron.prefab +++ b/Assets/Prefabs/RPG/Pickups/Dodecahedron.prefab @@ -189,7 +189,6 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: RotationSpeed: 1 - GridOffset: 0 LabelFadeDuration: 0.5 LabelPersistDuration: 3 LabelDisplayAngle: 15 diff --git a/Assets/Prefabs/RPG/Pickups/Hexahedron.prefab b/Assets/Prefabs/RPG/Pickups/Hexahedron.prefab index 91f1c2ad3..f9a477cda 100644 --- a/Assets/Prefabs/RPG/Pickups/Hexahedron.prefab +++ b/Assets/Prefabs/RPG/Pickups/Hexahedron.prefab @@ -162,7 +162,6 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: RotationSpeed: 1 - GridOffset: 0 LabelFadeDuration: 0.5 LabelPersistDuration: 3 LabelDisplayAngle: 15 diff --git a/Assets/Prefabs/RPG/Pickups/Icosahedron.prefab b/Assets/Prefabs/RPG/Pickups/Icosahedron.prefab index 95973ba56..4252e74cc 100644 --- a/Assets/Prefabs/RPG/Pickups/Icosahedron.prefab +++ b/Assets/Prefabs/RPG/Pickups/Icosahedron.prefab @@ -162,7 +162,6 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: RotationSpeed: 1 - GridOffset: 0 LabelFadeDuration: 0.5 LabelPersistDuration: 3 LabelDisplayAngle: 15 diff --git a/Assets/Prefabs/RPG/Pickups/Octahedron.prefab b/Assets/Prefabs/RPG/Pickups/Octahedron.prefab index 5957a7295..099083195 100644 --- a/Assets/Prefabs/RPG/Pickups/Octahedron.prefab +++ b/Assets/Prefabs/RPG/Pickups/Octahedron.prefab @@ -162,7 +162,6 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: RotationSpeed: 1 - GridOffset: 0 LabelFadeDuration: 0.5 LabelPersistDuration: 3 LabelDisplayAngle: 15 diff --git a/Assets/Prefabs/RPG/Pickups/Tetrahedron.prefab b/Assets/Prefabs/RPG/Pickups/Tetrahedron.prefab index 8688b6a3b..6dad19967 100644 --- a/Assets/Prefabs/RPG/Pickups/Tetrahedron.prefab +++ b/Assets/Prefabs/RPG/Pickups/Tetrahedron.prefab @@ -162,7 +162,6 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: RotationSpeed: 1 - GridOffset: 0 LabelFadeDuration: 0.5 LabelPersistDuration: 3 LabelDisplayAngle: 15 diff --git a/Assets/Resources/Settings.asset b/Assets/Resources/Settings.asset index b007e4a33..4b17fe993 100644 --- a/Assets/Resources/Settings.asset +++ b/Assets/Resources/Settings.asset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f5fda2fa736e02d879a317152738e0a4c44c10b356a6dbd41cf31d2d7e7e6fd8 -size 12571 +oid sha256:248373994a9d0caf17b8dcddbedb7b8c1d36001c31ae0f1e695385e6589734e6 +size 12647 diff --git a/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs b/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs index 54e2b39fb..eb4a75558 100644 --- a/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs +++ b/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs @@ -90,6 +90,8 @@ public static class FloatingItemsDrawnSmoke { var settings = AssetDatabase.LoadAssetAtPath("Assets/Resources/Settings.asset")?.GameplaySettings ?? throw new InvalidOperationException("No Settings.asset."); + // A floater rides its own layer: a nonzero one (never saved back to the asset), so a presenter that drops it fails. + settings.FloaterLayerHeight = -1.5f; // The wiring: the scene's ZoneRenderer names an ItemPickup prefab in each of the four fields, and none of those // prefabs keeps a script that no longer exists. @@ -159,7 +161,7 @@ public static class FloatingItemsDrawnSmoke { var lead = ActionGameManager.Clock.Lead; var at = DrawAhead.Position(floating.Body, lead); - var want = new Vector3(at.x, DrawAhead.Height(zone, at, lead), at.y); + var want = new Vector3(at.x, DrawAhead.FloaterHeight(zone, settings, at, lead), at.y); Require(Vector3.Distance(pickup.transform.position, want) < 1e-3f, $"{what}: the pickup is at {pickup.transform.position}, the body at {want}."); } diff --git a/Assets/Scripts/Gameplay/ItemPickup.cs b/Assets/Scripts/Gameplay/ItemPickup.cs index 4f78dc848..1378d6e28 100644 --- a/Assets/Scripts/Gameplay/ItemPickup.cs +++ b/Assets/Scripts/Gameplay/ItemPickup.cs @@ -12,7 +12,6 @@ using Random = UnityEngine.Random; public class ItemPickup : MonoBehaviour { public float RotationSpeed = 1f; - public float GridOffset; public float LabelFadeDuration = .5f; public float LabelPersistDuration = 3; public float LabelDisplayAngle = 15; @@ -27,11 +26,13 @@ public class ItemPickup : MonoBehaviour private float _displayTime; private float _timeOffset; private Zone _zone; + private GameplaySettings _settings; private FloatingItem _floating; - public void Bind(Zone zone, FloatingItem floating) + public void Bind(Zone zone, FloatingItem floating, GameplaySettings settings) { _zone = zone; + _settings = settings; _floating = floating; Item = floating.Item; _timeOffset = Random.value * 100; @@ -42,7 +43,7 @@ public class ItemPickup : MonoBehaviour { var lead = ActionGameManager.Clock.Lead; var p = DrawAhead.Position(_floating.Body, lead); - transform.position = new Vector3(p.x, DrawAhead.Height(_zone, p, lead) + GridOffset, p.y); + transform.position = new Vector3(p.x, DrawAhead.FloaterHeight(_zone, _settings, p, lead), p.y); } private void Update() diff --git a/Assets/Scripts/Gameplay/MineInstance.cs b/Assets/Scripts/Gameplay/MineInstance.cs index c09de1fdf..006f7d631 100644 --- a/Assets/Scripts/Gameplay/MineInstance.cs +++ b/Assets/Scripts/Gameplay/MineInstance.cs @@ -17,21 +17,22 @@ public class MineInstance : MonoBehaviour public float ActiveEmission = 100f; public float ArmedEmission = 1000f; public float RotationSpeed = 1f; - public float GridOffset; // The blast's particles; the effect prefab destroys itself when its particle system stops. public GameObject HitEffect; public float HitEffectScale = 10f; private Zone _zone; + private GameplaySettings _settings; private Mine _mine; private Material _material; private float _timeOffset; private float _pulse; private float _emission; - public void Bind(Zone zone, Mine mine) + public void Bind(Zone zone, Mine mine, GameplaySettings settings) { _zone = zone; + _settings = settings; _mine = mine; _material = MeshRenderer.materials[EmissionSubmesh]; _timeOffset = Random.value * 100; @@ -57,7 +58,7 @@ public class MineInstance : MonoBehaviour { var lead = ActionGameManager.Clock.Lead; var p = DrawAhead.Position(_mine.Body, lead); - transform.position = new Vector3(p.x, DrawAhead.Height(_zone, p, lead) + GridOffset, p.y); + transform.position = new Vector3(p.x, DrawAhead.FloaterHeight(_zone, _settings, p, lead), p.y); } // The mine left Zone.Mines, which only its blast does: show it where it was drawn last. diff --git a/Assets/Scripts/ServerShared/DrawAhead.cs b/Assets/Scripts/ServerShared/DrawAhead.cs index 92c5c8d9f..49ca0a3d0 100644 --- a/Assets/Scripts/ServerShared/DrawAhead.cs +++ b/Assets/Scripts/ServerShared/DrawAhead.cs @@ -26,6 +26,9 @@ public static class DrawAhead // The field's height at a point at the drawn time, its wells where their rails put them at T. public static float Height(Zone zone, float2 planar, float lead) => zone.GetHeight(planar, Time(zone, lead)); + // The drawn height of a floater (loot, mines): the field at the drawn time plus the floater layer. + public static float FloaterHeight(Zone zone, GameplaySettings settings, float2 planar, float lead) => Height(zone, planar, lead) + settings.FloaterLayerHeight; + // 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. // A child is drawn where its parent is drawn, as the step copies the parent's pose onto it. diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index edfe29a67..4d9470963 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -1974,9 +1974,13 @@ public abstract class Entity 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 rides at its own time and xz, the presenter at the drawn time and its drawn xz (DrawAhead). - public float RideHeight(float2 planar, double time) => Zone.GetHeight(planar, time) + (ItemManager.GetData(Hull) as HullData).GridOffset; + // The height above the field of the layer this kind of entity rides on (GameplaySettings): ships the ship plane, + // orbital entities the orbital layer above it. + public abstract float LayerHeight { get; } + + // Where an unparented entity sits on the field at a planar point: the zone's height there plus the layer this kind of + // entity rides on. The step rides at its own time and xz, the presenter at the drawn time and its drawn xz (DrawAhead). + public float RideHeight(float2 planar, double time) => Zone.GetHeight(planar, time) + LayerHeight; public float RideHeight(float2 planar) => RideHeight(planar, Zone.ExactTime); diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 9b9c057e7..8f3b8c4ea 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -519,8 +519,7 @@ public class HullData : EquippableItemData [Inspectable, JsonProperty("hullType"), Key(25)] public HullType HullType; - [Inspectable, JsonProperty("gridOffset"), Key(26)] - public float GridOffset; + // Key(26) retired 2026-10-16 (per-hull GridOffset; the layer is the kind's, GameplaySettings). Do not reuse. [Inspectable, JsonProperty("armor"), Key(27)] public float Armor; diff --git a/Assets/Scripts/ServerShared/OrbitalEntity.cs b/Assets/Scripts/ServerShared/OrbitalEntity.cs index 93f055804..a2c511ba7 100644 --- a/Assets/Scripts/ServerShared/OrbitalEntity.cs +++ b/Assets/Scripts/ServerShared/OrbitalEntity.cs @@ -23,6 +23,8 @@ public class OrbitalEntity : Entity OrbitData = orbit; } + public override float LayerHeight => ItemManager.GameplaySettings.OrbitalLayerHeight; + public override void Update(float delta) { if (OrbitData.IsSet()) diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index f09a6b187..cda5e1623 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -278,6 +278,13 @@ public class GameplaySettings // normal is this times the speed at which it closed. 0 is perfectly inelastic, 1 perfectly elastic. public float ContactRestitution = .5f; + // Heights above the field of the ship plane, the orbital layer (stations, turrets: every OrbitalEntity) and the floater + // layer (loot, mines): orbitals above ships and floaters below them, so no ship ever touches either (ruling + // aetheria-gravity:orbitals-above-floaters-below). + public float ShipLayerHeight = 3f; + public float OrbitalLayerHeight = 15f; + public float FloaterLayerHeight = 0f; + // The floor an AI's own predicted hit probability (FireControl.HitProbability) must clear before it counts // a firing solution worth taking (Combat.cs, TurretController.cs). Player fire is gated on arc alone // (Weapon.ArcAllowsFire, Q2) -- this threshold is an AI fire-discipline heuristic, not part of the roll. diff --git a/Assets/Scripts/ServerShared/Ship.cs b/Assets/Scripts/ServerShared/Ship.cs index 560afce17..4d621e2f9 100644 --- a/Assets/Scripts/ServerShared/Ship.cs +++ b/Assets/Scripts/ServerShared/Ship.cs @@ -16,6 +16,8 @@ using quaternion = CultMath.quaternion; [MessagePackObject] public class Ship : Entity { + public override float LayerHeight => ItemManager.GameplaySettings.ShipLayerHeight; + // [Key("bindings")] public Dictionary Bindings = new Dictionary(); //[IgnoreMember] public int HullHardpointCount; diff --git a/Assets/Scripts/Zone Display/ZoneRenderer.cs b/Assets/Scripts/Zone Display/ZoneRenderer.cs index e92c78c27..6bc9b45e3 100644 --- a/Assets/Scripts/Zone Display/ZoneRenderer.cs +++ b/Assets/Scripts/Zone Display/ZoneRenderer.cs @@ -282,7 +282,7 @@ public class ZoneRenderer : MonoBehaviour private void LoadMine(Zone zone, Mine mine) { var instance = Instantiate(MinePrefab); - instance.Bind(zone, mine); + instance.Bind(zone, mine, ItemManager.GameplaySettings); _mines.Add(mine, instance); } @@ -297,7 +297,7 @@ public class ZoneRenderer : MonoBehaviour _ => throw new NotImplementedException() }; var pickup = Instantiate(prefab, ZoneRoot); - pickup.Bind(zone, floating); + pickup.Bind(zone, floating, ItemManager.GameplaySettings); pickup.ScanLabel.text = ItemManager.Title(floating.Item); if (floating.Item is CraftedItemInstance craftedItemInstance) { diff --git a/tests/Aetheria.Shared.Tests/ContactTests.cs b/tests/Aetheria.Shared.Tests/ContactTests.cs index 47cf05bb2..2efc5431c 100644 --- a/tests/Aetheria.Shared.Tests/ContactTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactTests.cs @@ -391,12 +391,13 @@ public sealed class ContactTests : IDisposable var lab = Build(); var ship = lab.Add("Solid", float3(0, 0, 0), float2(10, 0)); var station = lab.AddOrbital("Heavy", float3(10, 0, 0), float2(0, 0)); - var turret = lab.AddOrbital("Solid", float3(-10, 0, 0), float2(10, 0)); + // Behind the ship and faster than it, so it closes on the ship as the station does. + var turret = lab.AddOrbital("Solid", float3(-10, 0, 0), float2(30, 0)); lab.Step(); Assert.Empty(lab.Events); Assert.Equal(float2(10, 0), ship.Velocity); Assert.Equal(float2(0, 0), station.Velocity); - Assert.Equal(float2(10, 0), turret.Velocity); + Assert.Equal(float2(30, 0), turret.Velocity); } // An orbital overlapping a colliding pair changes nothing about the pair (ruling ship-bounce-restitution-setting): diff --git a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs index e30f2869b..be622e298 100644 --- a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs +++ b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs @@ -26,7 +26,8 @@ public sealed class DrawnPoseTests : IDisposable private const float PlanetDistance = 1000f, PlanetPhase = .3f; private const float MoonDistance = 60f, MoonPhase = .1f; private const float StationDistance = 3f, StationPhase = .4f; - private const float GridOffset = 2.5f; + private const float ShipLayerHeight = 2.5f; + private const float OrbitalLayerHeight = 7.5f; // The sun's rail is parentless and off-centre, the shape ZoneGenerator gives a cluster root (FixedPosition = cluster centre). private static readonly float2 SunCentre = new float2(700f, -300f); @@ -73,7 +74,7 @@ public sealed class DrawnPoseTests : IDisposable using (var build = AetheriaStores.Open(catalog, catalogWritable: true)) { build.Upsert(new VerseGrammar { Revision = 1 }); - build.Upsert(new HullData { Name = "TestHull", HullType = HullType.Ship, Shape = shape, Durability = 1, Mass = 1000, GridOffset = GridOffset }); + build.Upsert(new HullData { Name = "TestHull", HullType = HullType.Ship, Shape = shape, Durability = 1, Mass = 1000 }); build.FlushAsync().Wait(); } @@ -85,7 +86,8 @@ public sealed class DrawnPoseTests : IDisposable { DefaultEntitySettings = new EntitySettings(), Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, - QualityPriceModifier = new ExponentialLerp() + QualityPriceModifier = new ExponentialLerp(), + ShipLayerHeight = ShipLayerHeight, OrbitalLayerHeight = OrbitalLayerHeight }, _ => { }); CultRecordKey Orbit(CultRecordKey parent, float distance, float phase, float2 fixedPosition = default) @@ -270,9 +272,9 @@ public sealed class DrawnPoseTests : IDisposable // A ship that stands still 40 u from the planet rides the field of the drawn well, plus its hull's offset. var ship = new Ship(w.Items, w.Zone, w.Hull, new EntitySettings()) { Position = float3(PlanetAt(w.Zone.ExactTime).x + 40f, 0, PlanetAt(w.Zone.ExactTime).y) }; var drawnShip = DrawAhead.Position(ship, lead); - Assert.Equal(FieldAt(w, drawnShip.xz, time) + GridOffset, drawnShip.y, 3); + Assert.Equal(FieldAt(w, drawnShip.xz, time) + ShipLayerHeight, drawnShip.y, 3); Assert.Equal(ship.RideHeight(drawnShip.xz, time), drawnShip.y, 4); - Assert.Equal(w.Zone.GetHeight(ship.Position.xz) + GridOffset, ship.RideHeight(ship.Position.xz), 4); + Assert.Equal(w.Zone.GetHeight(ship.Position.xz) + ShipLayerHeight, ship.RideHeight(ship.Position.xz), 4); } [Fact] @@ -292,7 +294,7 @@ public sealed class DrawnPoseTests : IDisposable AssertNear(StationAt(w.Zone.ExactTime + lead), drawn.xz, 2e-3f, "the station on its rail at the drawn time"); AssertNear(DrawAhead.Orbit(w.Zone, w.StationOrbit, lead), drawn.xz, 1e-6f, "the same point DrawAhead.Orbit gives"); Assert.True(length(station.Position.xz + station.Velocity * lead - drawn.xz) > .01f, "a tight rail curves away from its straight extrapolation"); - Assert.Equal(w.Zone.GetHeight(drawn.xz, w.Zone.ExactTime + lead) + GridOffset, drawn.y, 4); + Assert.Equal(w.Zone.GetHeight(drawn.xz, w.Zone.ExactTime + lead) + OrbitalLayerHeight, drawn.y, 4); } // The generators make orbital entities with no orbit set (Loadout, LoadoutGenerator): a rail-less one is an ordinary diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 4e9a23268..1d8f9f6be 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -35,8 +35,9 @@ public sealed class FireControlPerWeaponTests : IDisposable Directory.Delete(_root, true); } - private static GameplaySettings TestSettings(float minHit) => new GameplaySettings + private static GameplaySettings TestSettings(float minHit, float orbitalLayerHeight) => new GameplaySettings { + OrbitalLayerHeight = orbitalLayerHeight, DefaultEntitySettings = new EntitySettings(), Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, QualityPriceModifier = new ExponentialLerp(), @@ -71,7 +72,7 @@ public sealed class FireControlPerWeaponTests : IDisposable { public Zone Zone; public Ship Shooter; - public Ship Target; + public Entity Target; public List Guns = new List(); public Gun Gun => Guns[0]; public Gun Decoy; @@ -84,12 +85,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 velocity = 0f, float targetGridOffset = 0f) + float effective = float.PositiveInfinity, float damageAtMax = 0f, float velocity = 0f, float targetLayerHeight = 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, GridOffset = targetGridOffset, + Name = "Hull", HullType = HullType.Ship, Shape = SolidShape(5, 4), Durability = 1000000, Mass = 1000, Armor = 0, Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } }; var shooterHullData = new HullData @@ -192,7 +193,7 @@ public sealed class FireControlPerWeaponTests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 100; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, TestSettings(minHit), _ => { }); + var items = new ItemManager(cache, ledger, TestSettings(minHit, targetLayerHeight), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "PerWeapon", Owner = null }, null); EquippableItem Make(string name, int lot, float durability) => new EquippableItem @@ -235,7 +236,12 @@ public sealed class FireControlPerWeaponTests : IDisposable Assert.True(shooter.TryEquip(t, upos)); } - var target = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); + // A target layer height rides the target on the orbital layer at that height (the shooter is a ship, on the ship layer), + // so the two sit at different heights. + var targetItem = new EquippableItem { Data = hullRef, Durability = 1000000, Lot = lot++ }; + Entity target = targetLayerHeight != 0f + ? new OrbitalEntity(items, zone, targetItem, default, new EntitySettings()) + : new Ship(items, zone, targetItem, new EntitySettings()); Assert.True(target.TryEquip(Make("Gun", lot++, 1), new int2(0, 0))); zone.Admit(shooter, piloted: false); @@ -308,13 +314,14 @@ 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). + // The AI aims at the height a target rides at (Entity.RideHeight: field plus the layer the target's kind rides on), + // not at the plane and not at the shooter's own layer. A target on a layer 40 u up against a shooter on the ship + // layer, 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, scaled, or the shooter's layer). [Fact] public void ATurretAimsAtTheHeightATargetRidesAt() { - var r = Build(turret: true, velocity: 500f, targetGridOffset: 40f); + var r = Build(turret: true, velocity: 500f, targetLayerHeight: 40f); r.Zone.Update(1f); AssertAimsAtTarget(r); @@ -323,7 +330,7 @@ public sealed class FireControlPerWeaponTests : IDisposable [Fact] public void CombatAimsAtTheHeightATargetRidesAt() { - var r = Build(velocity: 500f, targetGridOffset: 40f); + var r = Build(velocity: 500f, targetLayerHeight: 40f); r.Zone.Agents.Add(new Minion(r.Shooter)); r.Zone.Update(1f); r.Zone.Update(1f); diff --git a/tests/Aetheria.Shared.Tests/FireControlTests.cs b/tests/Aetheria.Shared.Tests/FireControlTests.cs index 95b85df46..2d9a91d6b 100644 --- a/tests/Aetheria.Shared.Tests/FireControlTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlTests.cs @@ -29,8 +29,9 @@ public sealed class FireControlTests : IDisposable Directory.Delete(_root, true); } - private static GameplaySettings TestSettings() => new GameplaySettings + private static GameplaySettings TestSettings(float shipLayerHeight = 3f) => new GameplaySettings { + ShipLayerHeight = shipLayerHeight, DefaultEntitySettings = new EntitySettings(), Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, QualityPriceModifier = new ExponentialLerp(), @@ -45,13 +46,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, float gridOffset = 0f) + bool well, (ItemRotation rotation, float firingArc)[] hardpoints, float shipLayerHeight = 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, GridOffset = gridOffset }; + var hullData = new HullData { Name = "TestHull", HullType = HullType.Ship, Shape = hullShape, Durability = 1, Mass = 1000 }; for (var i = 0; i < hardpoints.Length; i++) hullData.Hardpoints.Add(new HardpointData { @@ -87,7 +88,7 @@ public sealed class FireControlTests : IDisposable ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; // hull for (var i = 0; i < hardpoints.Length; i++) ledger.Lots[2 + i] = new Lot { Origin = new Attributed(), Quality = 1 }; // guns - var items = new ItemManager(_openCache, ledger, TestSettings(), _ => { }); + var items = new ItemManager(_openCache, ledger, TestSettings(shipLayerHeight), _ => { }); var planets = new PlanetSettings(); var pack = new ZonePack(); if (well) WellFixture.Dig(_openCache, pack, planets, 300f, 100f); @@ -386,7 +387,7 @@ public sealed class FireControlTests : IDisposable [Fact] public void ADrawnShipRidesTheFieldAtItsDrawnPosition() { - var (items, ship, _) = BuildShipWithHardpoints(true, new[] { (ItemRotation.None, 0f) }, gridOffset: 2.5f); + var (items, ship, _) = BuildShipWithHardpoints(true, new[] { (ItemRotation.None, 0f) }, shipLayerHeight: 2.5f); var zone = ship.Zone; ship.Position = float3(200, 0, 0); zone.Update(1f); diff --git a/tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs b/tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs index d735b68a8..f95300ae0 100644 --- a/tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs +++ b/tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs @@ -354,8 +354,9 @@ public sealed partial class FireControlCut124Tests { foreach (var shield in new[] { false, true }) { - var e = Build(TestSettings(), SolidShape(5, 4), equipShield: shield, shieldCapacity: 100000f); - e.HullData.GridOffset = 5f; + var raised = TestSettings(); + raised.ShipLayerHeight = 5f; + var e = Build(raised, SolidShape(5, 4), equipShield: shield, shieldCapacity: 100000f); for (var i = 0; i < 30; i++) e.Zone.Update(.1f); Assert.Equal(5f, e.Target.Position.y, 1e-4); var subs = new List(); @@ -434,9 +435,10 @@ public sealed partial class FireControlCut124Tests [Fact] public void TheImpactHeightIsTheTargetsHeightAndCarriedKeepsRelativeHeight() { - // Production y is the zone height plus the hull's GridOffset, so a raised hull is reachable headless. - var e = Build(TestSettings(), SolidShape(21, 3), velocity: 0, damage: 100f, custom: new[] { ("Marker", new Shape(), new int2(2, 1), 1f) }); - e.HullData.GridOffset = 5f; + // Production y is the zone height plus the ship layer, so a raised ship plane is reachable headless. + var raised = TestSettings(); + raised.ShipLayerHeight = 5f; + var e = Build(raised, SolidShape(21, 3), velocity: 0, damage: 100f, custom: new[] { ("Marker", new Shape(), new int2(2, 1), 1f) }); e.Zone.Update(.01f); Assert.Equal(5f, e.Target.Position.y, 1e-4); var subs = new List(); diff --git a/tests/Aetheria.Shared.Tests/LayerTests.cs b/tests/Aetheria.Shared.Tests/LayerTests.cs new file mode 100644 index 000000000..171a170a1 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/LayerTests.cs @@ -0,0 +1,126 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using CultMath; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using Xunit; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; + +// Layers (aetheria-gravity cut orbital-layering; ruling orbitals-above-floaters-below): GameplaySettings' three +// heights decide where each kind of thing sits above the field. A ship rides the ship plane, every orbital entity +// (station, turret) the orbital layer above it, a floater (loot, mine) the floater layer below. No hull carries a +// height of its own. +public sealed class LayerTests : IDisposable +{ + private const float ShipHeight = 2.5f, OrbitalHeight = 9f, FloaterHeight = -1.25f; + + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-layers-" + Guid.NewGuid().ToString("N")); + private readonly CultCache _cache; + + public LayerTests() + { + Directory.CreateDirectory(_root); + _cache = AetheriaStores.Open(Path.Combine(_root, "layers.cc"), catalogWritable: true); + } + + public void Dispose() + { + _cache.Dispose(); + Directory.Delete(_root, true); + } + + private static GameplaySettings Settings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + ShipLayerHeight = ShipHeight, OrbitalLayerHeight = OrbitalHeight, FloaterLayerHeight = FloaterHeight + }; + + private (ItemManager Items, Zone Zone) Build() + { + // Hull sizes and masses are spread so no kind coincides with a size or a mass: a layer chosen by either fails. + Shape Solid(int side) + { + var shape = new Shape(side, side); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + return shape; + } + _cache.Upsert(new VerseGrammar { Revision = 1 }); + _cache.Upsert(new HullData { Name = "Ship", HullType = HullType.Ship, Shape = Solid(5), Durability = 1, Mass = 1000 }); + _cache.Upsert(new HullData { Name = "Station", HullType = HullType.Station, Shape = Solid(3), Durability = 1, Mass = 1000000 }); + _cache.Upsert(new HullData { Name = "Turret", HullType = HullType.Turret, Shape = Solid(7), Durability = 1, Mass = 50 }); + _cache.FlushAsync().Wait(); + var items = new ItemManager(_cache, new ProvenanceLedger(), Settings(), _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack { Radius = 0 }, new GalaxyZone { Name = "Layers", Owner = null }, null); + return (items, zone); + } + + private static EquippableItem Hull(ItemManager items, string name, int lot) => new EquippableItem + { + Data = items.ItemData.RefOf(items.ItemData.GetByName(name)), Durability = 1, Lot = lot + }; + + [Fact] + public void EachKindRidesItsLayer() + { + var (items, zone) = Build(); + var ship = new Ship(items, zone, Hull(items, "Ship", 1), new EntitySettings()); + var station = new OrbitalEntity(items, zone, Hull(items, "Station", 2), default, new EntitySettings()); + var turret = new OrbitalEntity(items, zone, Hull(items, "Turret", 3), default, new EntitySettings()); + foreach (var e in new Entity[] { ship, station, turret }) zone.Admit(e, piloted: false); + ship.Position = float3(40, 0, 10); + station.Position = float3(-30, 0, 20); + turret.Position = float3(5, 0, -60); + + zone.Update(.1f); + + Assert.Equal(zone.GetHeight(ship.Position.xz) + ShipHeight, ship.Position.y); + Assert.Equal(zone.GetHeight(station.Position.xz) + OrbitalHeight, station.Position.y); + Assert.Equal(zone.GetHeight(turret.Position.xz) + OrbitalHeight, turret.Position.y); + const float lead = .05f; + foreach (var (e, layer) in new (Entity, float)[] { (ship, ShipHeight), (station, OrbitalHeight), (turret, OrbitalHeight) }) + { + var drawn = DrawAhead.Position(e, lead); + Assert.Equal(zone.GetHeight(drawn.xz, DrawAhead.Time(zone, lead)) + layer, drawn.y); + } + } + + [Fact] + public void OrbitalsAboveShipsAboveFloaters() + { + var authored = AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read("GameplaySettings"); + Assert.True(authored.OrbitalLayerHeight > authored.ShipLayerHeight); + Assert.True(authored.ShipLayerHeight > authored.FloaterLayerHeight); + } + + [Fact] + public void AFloaterSitsOnTheFloaterLayer() + { + var (items, zone) = Build(); + var settings = items.GameplaySettings; + foreach (var at in new[] { float2(0, 0), float2(37, -12) }) + foreach (var lead in new[] { 0f, .05f }) + Assert.Equal(DrawAhead.Height(zone, at, lead) + FloaterHeight, DrawAhead.FloaterHeight(zone, settings, at, lead)); + } + + [Fact] + public void HullKey26IsRetired() + { + var keys = new System.Collections.Generic.HashSet(); + for (var t = typeof(HullData); t != null; t = t.BaseType) + foreach (var m in t.GetMembers(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + foreach (var k in m.GetCustomAttributes(typeof(MessagePack.KeyAttribute), false).Cast()) + if (k.IntKey.HasValue) keys.Add(k.IntKey.Value); + Assert.NotEmpty(keys); + Assert.DoesNotContain(26, keys); + + using var shipped = new CultCache(TestCatalog.Registry()); + shipped.AddBackingStore(new SingleFileMessagePackBackingStore(TestCatalog.Repo, true), AetheriaStores.CatalogTypes); + Assert.NotEmpty(shipped.GetAll()); + } +}