diff --git a/Assets/Scripts/Gameplay/HullCollider.cs b/Assets/Scripts/Gameplay/HullCollider.cs index 8d9275eb4..795de4755 100644 --- a/Assets/Scripts/Gameplay/HullCollider.cs +++ b/Assets/Scripts/Gameplay/HullCollider.cs @@ -1,17 +1,12 @@ using UnityEngine; -using CultMath.UnityBridge; // Cut 4 (docs/fire-control-cut.md): Hit, Splash, SendHit and SendSplash are deleted -- their last callers // (ConstantLaser, ConstantLightning, ConstantParticleWeapon, HitscanEffect) are gone too (see those // files and FireControl.Detonate, 12.4(b)'s renamed Splash). Nothing decides or applies damage through a Unity -// collider any more (R8): -// this component is now purely a physics surface for ship-on-ship collision impulse, not hit detection. +// collider any more (R8), and no collision callback writes an Entity (cut sim-contact): ship contact is +// Zone's, through Contacts.Step. This component holds the hull's collider for authoring, smokes and click +// picking, and writes nothing. public class HullCollider : MonoBehaviour { public Entity Entity { get; set; } - - private void OnCollisionEnter(Collision other) - { - Entity.Velocity += other.impulse.ToCultMath().xz / Time.deltaTime; - } } diff --git a/Assets/Scripts/Gameplay/ShieldManager.cs b/Assets/Scripts/Gameplay/ShieldManager.cs index 05a303e6d..46d0b36a5 100644 --- a/Assets/Scripts/Gameplay/ShieldManager.cs +++ b/Assets/Scripts/Gameplay/ShieldManager.cs @@ -5,6 +5,7 @@ using System.Linq; using CultMath; using CultMath.UnityBridge; using static CultMath.math; +using UniRx; using UnityEngine; public class ShieldManager : MonoBehaviour @@ -12,7 +13,29 @@ public class ShieldManager : MonoBehaviour public Prototype ShieldPrototype; public float CollisionHitDuration = 3; - public Entity Entity { get; set; } + private Entity _entity; + private IDisposable _contacts; + + // Ship-on-ship contact is Zone's (Contacts.Step); this shield only presents it. + public Entity Entity + { + get => _entity; + set + { + _contacts?.Dispose(); + _entity = value; + _contacts = value?.Zone.Contacted.Subscribe(Present); + } + } + + private void OnDestroy() => _contacts?.Dispose(); + + private void Present(ContactEvent contact) + { + 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); + } private ShieldEnvelope _envelope; private bool _loggedMissingEnvelope; @@ -60,24 +83,7 @@ public class ShieldManager : MonoBehaviour } else Debug.Log("Shield collision occurred, but other collider isn't a shield or a grid object!"); - return; } - var contact = other.GetContact(0); - var normal = normalize(float2(contact.normal.x, contact.normal.z)); - var tangent = normal.Rotate(ItemRotation.CounterClockwise); - var v1n = dot(normal, Entity.Velocity); - var v1t = dot(tangent, Entity.Velocity); - var v2n = dot(normal, otherShield.Entity.Velocity); - //var v2t = dot(tangent, otherShield.Entity.Velocity); - - var v1np = PostCollisionVelocity(v1n, Entity.Mass, v2n, otherShield.Entity.Mass); - Entity.Velocity = tangent * v1t + normal * v1np; - if(Entity.Shield != null && Entity.Shield.Item.Active.Value) ShowHit(contact.point, CollisionHitDuration); - } - - private float PostCollisionVelocity(float v1, float m1, float v2, float m2) - { - return (v1 * (m1 - m2) + m2 * v2) / (m1 + m2); } public void ShowHit(Vector3 point, float duration) diff --git a/Assets/Scripts/ServerShared/Contacts.cs b/Assets/Scripts/ServerShared/Contacts.cs new file mode 100644 index 000000000..f680faa5a --- /dev/null +++ b/Assets/Scripts/ServerShared/Contacts.cs @@ -0,0 +1,79 @@ +/* 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.Linq; +using CultMath; +using static CultMath.math; +using float2 = CultMath.float2; + +// A hull touching a hull (aetheria-release cut sim-contact; ruling unity-presents-only-r3): the simulation owns +// contact between entities. Each hull is a disc about its centre of mass that bounds every occupied cell, and +// two discs that touch while closing along the line between their centres take the normal impulse +// j = -(1 + e)(v_rel . n) / (1/m1 + 1/m2) (ruling ship-bounce-restitution-setting): momentum is conserved, the +// result is frame-independent, and a pair that has separated is not bounced again. e is +// GameplaySettings.ContactRestitution. +// Presentations read Zone.Contacted; no Unity collision callback writes an Entity. +public readonly struct ContactEvent +{ + public readonly Entity A; + public readonly Entity B; + public readonly float2 Point; + // Unit vector from A to B. + public readonly float2 Normal; + + public ContactEvent(Entity a, Entity b, float2 point, float2 normal) + { + A = a; + B = b; + Point = point; + Normal = normal; + } +} + +public static class Contacts +{ + // The farthest corner of any occupied hull cell from the centre of mass the schematic frame uses, in world + // units: a true bound on how far the hull reaches from Position (the half-diagonal of the bounding box is not, + // as FireControl.Detonate's comment records). A cell at (x, y) spans x +/- .5 and y +/- .5. + public static float Radius(Entity e) + { + var shape = e.HullData.Shape; + var com = shape.CenterOfMass; + var far = 0f; + foreach (var c in shape.Coordinates) + foreach (var (dx, dy) in new[] { (-.5f, -.5f), (.5f, -.5f), (-.5f, .5f), (.5f, .5f) }) + far = max(far, length(float2(c.x + dx, c.y + dy) - com)); + return far * e.ItemManager.GameplaySettings.SchematicCellSize; + } + + // For each unordered pair of the zone's entities, in list order: if their discs touch and are closing along + // the normal, the impulse moves each one's velocity along the normal only (the tangent components are + // untouched), and the contact is published. Pairs resolve one after another in list order, each from the + // velocities the earlier pairs left, so a body touching two others in one tick depends on that order; the + // total momentum does not. A docked entity (it has a Parent and takes its parent's pose) and an inactive one + // are not hulls in the zone. No positional push-out. + public static void Step(Zone zone) + { + var entities = zone.Entities.Where(e => e.Active && e.Parent == null).ToArray(); + var radii = entities.Select(Radius).ToArray(); + for (var i = 0; i < entities.Length; i++) + for (var j = i + 1; j < entities.Length; j++) + { + var a = entities[i]; + var b = entities[j]; + var offset = b.Position.xz - a.Position.xz; + var distance = length(offset); + // Coincident centres have no normal. + if (distance <= 0f || distance >= radii[i] + radii[j]) continue; + var n = offset / distance; + var closing = dot(n, b.Velocity - a.Velocity); + if (closing >= 0f) continue; + + var impulse = -(1f + a.ItemManager.GameplaySettings.ContactRestitution) * closing / (1f / a.Mass + 1f / b.Mass); + a.Velocity -= n * (impulse / a.Mass); + b.Velocity += n * (impulse / b.Mass); + zone.Contacted.OnNext(new ContactEvent(a, b, a.Position.xz + n * radii[i], n)); + } + } +} diff --git a/Assets/Scripts/ServerShared/Contacts.cs.meta b/Assets/Scripts/ServerShared/Contacts.cs.meta new file mode 100644 index 000000000..1cba80257 --- /dev/null +++ b/Assets/Scripts/ServerShared/Contacts.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7c1e5a3b9d4f4e2a8b6c0d1f2e3a4b5c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index a983b97f3..fcc9858a2 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -268,6 +268,10 @@ public class GameplaySettings // (pSpread) to turn a hull's cell footprint into a real-world silhouette size at range. public float SchematicCellSize = 2f; + // Restitution of a hull striking a hull (Contacts.Step): the speed at which a pair separates along the contact + // normal is this times the speed at which it closed. 0 is perfectly inelastic, 1 perfectly elastic. + public float ContactRestitution = .5f; + // 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/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 97554a9ad..cdea43a9a 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -54,6 +54,8 @@ public class Zone public List PendingShots = new List(); public Subject ShotCommitted = new Subject(); public Subject ShotResolved = new Subject(); + // Contacts.Step publishes each hull-on-hull contact it resolves; presentations are the only readers. + public Subject Contacted = new Subject(); private int _nextShotId; public int NextShotId() => ++_nextShotId; @@ -241,6 +243,10 @@ public class Zone StepMines(deltaTime); StepClouds(deltaTime); + // Hull contact (cut sim-contact): after every entity has moved and the mines and clouds have taken + // their toll, so only hulls still in the zone touch, and before shots resolve against the result. + Contacts.Step(this); + // Cut 3: after every entity has had its chance to fire this tick, age and resolve the shots that // firing queued. A shot fired this tick with a flight time shorter than CommitHorizon commits and // resolves in this same call. diff --git a/tests/Aetheria.Shared.Tests/ContactTests.cs b/tests/Aetheria.Shared.Tests/ContactTests.cs new file mode 100644 index 000000000..b139edea7 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ContactTests.cs @@ -0,0 +1,434 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using UniRx; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; + +// Hull contact in the sim (aetheria-release cut sim-contact; rulings unity-presents-only-r3 and +// ship-bounce-restitution-setting): Zone.Update steps Contacts, which applies the restitution impulse along the +// normal of two touching, closing hull discs and publishes the contact. Every test +// drives Zone.Update, the path the game takes. SchematicCellSize is 2, so a solid 5x5 hull has a radius of +// 2.5 * sqrt(2) * 2 = 7.07 and two such hulls ten apart touch. +public sealed class ContactTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-contacts-" + Guid.NewGuid().ToString("N")); + private readonly List _caches = new List(); + + public ContactTests() => Directory.CreateDirectory(_root); + + public void Dispose() + { + foreach (var c in _caches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static Shape Solid(int w, int h) + { + var shape = new Shape(w, h); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + return shape; + } + + private static Shape Ell() + { + var shape = new Shape(5, 5); + for (var i = 0; i < 5; i++) { shape[new int2(i, 0)] = true; shape[new int2(0, i)] = true; } + return shape; + } + + private static Shape ShippedLonginus() + { + var cache = new CultCache(TestCatalog.Registry()); + var store = new SingleFileMessagePackBackingStore(TestCatalog.Repo, true); + cache.AddBackingStore(store, AetheriaStores.CatalogTypes); + try { return cache.GetByName("Longinus").Shape; } + finally { cache.Dispose(); } + } + + private sealed class Lab + { + public ItemManager Items; + public Zone Zone; + public readonly List Events = new List(); + public int Lot = 1; + + public Ship Add(string hull, float3 at, float2 velocity) + { + var hullRef = Items.ItemData.RefOf(Items.ItemData.GetByName(hull)); + var ship = new Ship(Items, Zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = Lot++ }, new EntitySettings()); + Zone.Entities.Add(ship); + ship.Activate(); + ship.Position = at; + ship.Velocity = velocity; + return ship; + } + + public void Step() => Zone.Update(.01f); + } + + private Lab Build(float restitution = .5f) + { + var 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(), + TargetDetectionInfoThreshold = .1f, TargetArmorInfoThreshold = .2f, TargetGearInfoThreshold = .8f, + FiringArc = 170, CommitHorizon = .5f, SchematicCellSize = 2f, ContactRestitution = restitution + }; + var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true); + _caches.Add(cache); + cache.Upsert(new VerseGrammar { Revision = 1 }); + HullData Hull(string name, Shape shape, float mass) => new HullData + { + Name = name, HullType = HullType.Ship, Shape = shape, Durability = 1000000, Mass = mass, Armor = 0, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + cache.Upsert(Hull("Solid", Solid(5, 5), 1000)); + cache.Upsert(Hull("Heavy", Solid(5, 5), 1000000)); + cache.Upsert(Hull("Big", Solid(7, 7), 1000)); + cache.Upsert(Hull("Mid", Solid(5, 5), 3000)); + cache.Upsert(Hull("Ell", Ell(), 1000)); + cache.Upsert(Hull("Longinus", ShippedLonginus(), 1000)); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 50; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack { Radius = 0 }, new GalaxyZone { Name = "Contacts", Owner = null }, null); + var lab = new Lab { Items = items, Zone = zone }; + zone.Contacted.Subscribe(lab.Events.Add); + return lab; + } + + // The unit normal as the step saw it: from the first body's final position to the second's. + private static float2 NormalOf(Entity a, Entity b) => normalize(b.Position.xz - a.Position.xz); + + private static float2 Momentum(params Entity[] es) + { + var p = float2(0, 0); + foreach (var e in es) p += e.Mass * e.Velocity; + return p; + } + + // An oblique contact between unequal masses with both ships moving; `shift` is added to both incoming + // velocities (a moving frame). + private (Ship a, Ship b, float2 closingBefore, Lab lab) Oblique(float e, float2 shift) + { + var lab = Build(e); + var va = float2(8, 3) + shift; + var vb = float2(-4, -2) + shift; + var a = lab.Add("Solid", float3(0, 0, 0), va); + var b = lab.Add("Mid", float3(7, 0, 7), vb); + lab.Step(); + return (a, b, vb - va, lab); + } + + // Restitution: the speed at which a pair separates along the normal is e times the speed at which it closed. + [Theory] + [InlineData(0f)] + [InlineData(.5f)] + [InlineData(1f)] + public void SeparatingNormalSpeedIsRestitutionTimesClosingSpeed(float e) + { + var (a, b, relBefore, lab) = Oblique(e, float2(0, 0)); + var n = NormalOf(a, b); + var closing = -dot(n, relBefore); + Assert.True(closing > 0f); + Assert.Equal(e * closing, dot(n, b.Velocity - a.Velocity), 3); + Assert.Single(lab.Events); + } + + // The same bounce at a closing speed far below and far above the others: restitution is the setting at any speed. + [Theory] + [InlineData(.5f, 2f)] + [InlineData(.5f, 40f)] + [InlineData(1f, 2f)] + [InlineData(1f, 40f)] + [InlineData(0f, 2f)] + public void RestitutionHoldsAtEveryClosingSpeed(float e, float speedEach) + { + var lab = Build(e); + var a = lab.Add("Solid", float3(0, 0, 0), float2(speedEach, 0)); + var b = lab.Add("Heavy", float3(10, 0, 0), float2(-speedEach, 0)); + lab.Step(); + var n = NormalOf(a, b); + Assert.Equal(e * 2f * speedEach * n.x, dot(n, b.Velocity - a.Velocity), 3); + Assert.Single(lab.Events); + } + + // Momentum is conserved for unequal masses, an oblique contact and both ships moving. + [Theory] + [InlineData(0f)] + [InlineData(.5f)] + [InlineData(1f)] + public void ContactConservesMomentum(float e) + { + var lab = Build(e); + var a = lab.Add("Solid", float3(0, 0, 0), float2(8, 3)); + var b = lab.Add("Mid", float3(7, 0, 7), float2(-4, -2)); + var before = Momentum(a, b); + lab.Step(); + Assert.Single(lab.Events); + var after = Momentum(a, b); + Assert.Equal(before.x, after.x, 1); + Assert.Equal(before.y, after.y, 1); + Assert.True(length(a.Velocity - float2(8, 3)) > 1f, "the contact moved the first ship"); + } + + // A frame moving with the pair changes nothing: add the same velocity to both and each outcome shifts by exactly it. + [Theory] + [InlineData(.5f)] + [InlineData(1f)] + public void OutcomeShiftsByTheFrameVelocity(float e) + { + var shift = float2(25, -40); + var (a0, b0, _, _) = Oblique(e, float2(0, 0)); + var (a1, b1, _, lab1) = Oblique(e, shift); + Assert.Single(lab1.Events); + Assert.Equal(a0.Velocity.x + shift.x, a1.Velocity.x, 2); + Assert.Equal(a0.Velocity.y + shift.y, a1.Velocity.y, 2); + Assert.Equal(b0.Velocity.x + shift.x, b1.Velocity.x, 2); + Assert.Equal(b0.Velocity.y + shift.y, b1.Velocity.y, 2); + } + + // Two equal ships in a formation at 100 drift together at a closing speed of 1: they bounce apart at e, and + // the convoy keeps its momentum. + [Fact] + public void FormationBumpKeepsTheConvoyMoving() + { + var lab = Build(.5f); + var a = lab.Add("Solid", float3(0, 0, 0), float2(101, 0)); + var b = lab.Add("Solid", float3(10, 0, 0), float2(100, 0)); + var before = Momentum(a, b); + lab.Step(); + Assert.Single(lab.Events); + Assert.Equal(before.x, Momentum(a, b).x, 1); + Assert.True(a.Velocity.x > 99f && b.Velocity.x > 99f); + } + + [Fact] + public void HeadOnEqualMassesAtFullRestitutionSwapNormalVelocity() + { + var lab = Build(1f); + var a = lab.Add("Solid", float3(0, 0, 0), float2(10, 0)); + var b = lab.Add("Solid", float3(10, 0, 0), float2(-10, 0)); + lab.Step(); + + var n = NormalOf(a, b); + Assert.Equal(-10f, dot(n, a.Velocity), 3); + Assert.Equal(10f, dot(n, b.Velocity), 3); + var contact = Assert.Single(lab.Events); + Assert.Equal(new[] { a, b }, new[] { contact.A, contact.B }); + Assert.Equal(1f, length(contact.Normal), 5); + Assert.Equal(n.x, contact.Normal.x, 5); + // The contact point sits on A's disc, towards B. + Assert.Equal(7.0711f, length(contact.Point - a.Position.xz), 2); + } + + // The point is on A's disc, at A's radius, not B's: a small hull A against a larger hull B. + [Fact] + public void ContactPointLiesOnTheFirstHullsDisc() + { + var lab = Build(); + var a = lab.Add("Solid", float3(0, 0, 0), float2(10, 0)); + var b = lab.Add("Big", float3(12, 0, 0), float2(0, 0)); + lab.Step(); + Assert.True(Contacts.Radius(b) > Contacts.Radius(a) + 1f); + var contact = Assert.Single(lab.Events); + Assert.Equal(Contacts.Radius(a), length(contact.Point - a.Position.xz), 3); + } + + // Only the normal component changes: both bodies keep the tangent component they entered with. + [Fact] + public void BothBodiesKeepTheirTangentVelocity() + { + var lab = Build(); + var a = lab.Add("Solid", float3(0, 0, 0), float2(10, 0)); + var b = lab.Add("Mid", float3(7.07f, 0, 7.07f), float2(0, 3)); + lab.Step(); + + var n = NormalOf(a, b); + var t = float2(-n.y, n.x); + Assert.Equal(dot(t, float2(10, 0)), dot(t, a.Velocity), 4); + Assert.Equal(dot(t, float2(0, 3)), dot(t, b.Velocity), 4); + Assert.Single(lab.Events); + } + + [Fact] + public void OpeningPairsAreUntouched() + { + var lab = Build(); + var a = lab.Add("Solid", float3(0, 0, 0), float2(-5, 0)); + var b = lab.Add("Solid", float3(10, 0, 0), float2(5, 0)); + lab.Step(); + Assert.Equal(-5f, a.Velocity.x, 4); + Assert.Equal(5f, b.Velocity.x, 4); + Assert.Empty(lab.Events); + } + + // A pair that has separated is not bounced again: a heavy ship catching a light one is thrown clear by one + // contact, and the next ticks (the hulls still overlap, now opening) change and publish nothing. + [Fact] + public void NoImpulseWhileSeparating() + { + var lab = Build(.5f); + var a = lab.Add("Heavy", float3(0, 0, 0), float2(110, 0)); + var b = lab.Add("Solid", float3(10, 0, 0), float2(100, 0)); + lab.Step(); + var n = NormalOf(a, b); + Assert.True(dot(n, b.Velocity - a.Velocity) > 0f, "the bounce left the pair opening"); + var (va, vb) = (a.Velocity, b.Velocity); + for (var i = 0; i < 5; i++) lab.Step(); + Assert.Single(lab.Events); + Assert.Equal(va.x, a.Velocity.x, 3); + Assert.Equal(vb.x, b.Velocity.x, 3); + } + + [Fact] + public void OneEventPerContact() + { + var lab = Build(); + lab.Add("Solid", float3(0, 0, 0), float2(10, 0)); + lab.Add("Solid", float3(10, 0, 0), float2(-10, 0)); + lab.Step(); + Assert.Single(lab.Events); + // The bounce left the pair opening: the next step publishes nothing. + lab.Step(); + Assert.Single(lab.Events); + } + + // A pair closing slowly still touches: there is no speed below which hulls pass through each other. + [Fact] + public void SlowClosingStillBounces() + { + var lab = Build(.5f); + var a = lab.Add("Solid", float3(0, 0, 0), float2(2, 0)); + var b = lab.Add("Solid", float3(10, 0, 0), float2(-2, 0)); + lab.Step(); + var n = NormalOf(a, b); + Assert.Single(lab.Events); + Assert.Equal(.5f * 4f * n.x, dot(n, b.Velocity - a.Velocity), 3); + } + + // The tiniest closing speed still bounces too. + [Fact] + public void BarelyClosingStillBounces() + { + var lab = Build(1f); + lab.Add("Solid", float3(0, 0, 0), float2(.1f, 0)); + lab.Add("Solid", float3(10, 0, 0), float2(0, 0)); + lab.Step(); + Assert.Single(lab.Events); + } + + [Fact] + public void ADistantPairNeverTouches() + { + var lab = Build(); + lab.Add("Solid", float3(0, 0, 0), float2(10, 0)); + lab.Add("Solid", float3(15f, 0, 0), float2(-10, 0)); + lab.Step(); + Assert.Empty(lab.Events); + } + + // A ship against a 1000x heavier body: the heavy body takes (1 + e) m / (m + M) of the ship's closing speed + // and the ship rebounds with the rest of its momentum. + [Fact] + public void AShipAgainstAStationRebounds() + { + var lab = Build(.5f); + var ship = lab.Add("Solid", float3(0, 0, 0), float2(10, 0)); + var station = lab.Add("Heavy", float3(10, 0, 0), float2(0, 0)); + Assert.Equal(1000f, ship.Mass); + Assert.Equal(1000000f, station.Mass); + lab.Step(); + + var n = NormalOf(ship, station); + var closing = 10f * n.x; + Assert.Equal(1.5f * closing * 1000f / 1001000f, dot(n, station.Velocity), 4); + Assert.Equal(closing - 1.5f * closing * 1000000f / 1001000f, dot(n, ship.Velocity), 3); + } + + // An inactive hull is not in the zone: a ship flying through it is untouched. + [Fact] + public void AnInactiveHullTakesNoPartInContact() + { + var lab = Build(); + var a = lab.Add("Solid", float3(0, 0, 0), float2(10, 0)); + var b = lab.Add("Solid", float3(10, 0, 0), float2(-10, 0)); + b.Deactivate(); + lab.Step(); + Assert.Empty(lab.Events); + Assert.Equal(10f, a.Velocity.x, 4); + Assert.Equal(-10f, b.Velocity.x, 4); + } + + // Pairs resolve one after another in list order, so a body touching two others in one tick can leave the pile + // differently when the list is reversed (order independence is not claimed); what the order does not change is + // the momentum of the pile and the two contacts it publishes. + [Fact] + public void ThreeBodyContactConservesMomentumInEitherListOrder() + { + foreach (var reversed in new[] { false, true }) + { + var lab = Build(.5f); + var specs = new[] { ("Solid", float3(0, 0, 0), float2(10, 0)), ("Mid", float3(10, 0, 0), float2(0, 0)), ("Solid", float3(20, 0, 0), float2(-10, 0)) }; + var ships = new Ship[3]; + foreach (var i in reversed ? new[] { 2, 1, 0 } : new[] { 0, 1, 2 }) + ships[i] = lab.Add(specs[i].Item1, specs[i].Item2, specs[i].Item3); + lab.Step(); + Assert.Equal(2, lab.Events.Count); + var p = Momentum(ships); + Assert.Equal(0f, p.x, 1); + Assert.Equal(0f, p.y, 1); + } + } + + // A docked hull rides its carrier's pose and velocity, so it is never a second body at the carrier's place: a + // ship closing on the carrier makes one contact, with the carrier. (The visitor is the heavy hull: it keeps + // closing after the first exchange, so a second contact with the docked hull would show.) + [Fact] + public void ADockedHullTakesNoPartInContact() + { + var lab = Build(); + var carrier = lab.Add("Solid", float3(0, 0, 0), float2(0, 0)); + var docked = lab.Add("Solid", float3(0, 0, 0), float2(0, 0)); + docked.SetParent(carrier); + var visitor = lab.Add("Heavy", float3(10, 0, 0), float2(-10, 0)); + lab.Step(); + + var contact = Assert.Single(lab.Events); + Assert.Equal(new[] { carrier, visitor }, new[] { contact.A, contact.B }); + } + + [Theory] + [InlineData("Ell")] + [InlineData("Longinus")] + public void RadiusBoundsEveryCell(string hull) + { + var lab = Build(); + var ship = lab.Add(hull, float3(0, 0, 0), float2(0, 0)); + var shape = ship.HullData.Shape; + var com = shape.CenterOfMass; + var radius = Contacts.Radius(ship); + + var far = 0f; + foreach (var c in shape.Coordinates) + foreach (var (dx, dy) in new[] { (-.5f, -.5f), (.5f, -.5f), (-.5f, .5f), (.5f, .5f) }) + { + var d = length(float2(c.x + dx, c.y + dy) - com) * 2f; + Assert.True(d <= radius + 1e-4f, $"a corner of {hull} lies {d} from the centre of mass, past Radius {radius}"); + far = max(far, d); + } + Assert.Equal(radius, far, 3); + } +}