Ship contact is a simulation rule: Zone steps Contacts, Unity physics writes no Entity

Contacts.Step exchanges normal velocity between touching hull discs (PostCollisionVelocity moved verbatim
from ShieldManager) and publishes Zone.Contacted; ShieldManager presents it; HullCollider.OnCollisionEnter
and ShieldManager's ship-on-ship branch are deleted. Not yet built or tested (verification follows).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-12 05:46:32 +02:00
commit 7392bc8fe1
6 changed files with 363 additions and 26 deletions

View file

@ -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;
}
}

View file

@ -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)

View file

@ -0,0 +1,83 @@
/* 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 exchange their normal velocity.
// 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;
}
// The elastic exchange of one body's normal velocity: v1 and m1 the body's own, v2 and m2 the other's.
private static float PostCollisionVelocity(float v1, float m1, float v2, float m2)
{
return (v1 * (m1 - m2) + m2 * v2) / (m1 + m2);
}
// For each unordered pair of the zone's entities, in list order: if their discs touch and are closing along
// the normal, each one's normal velocity is replaced (both from the velocities the pair had on entering) and
// its tangent component kept, and the contact is published. 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 va = dot(n, a.Velocity);
var vb = dot(n, b.Velocity);
if (vb - va >= 0f) continue;
var tangent = float2(-n.y, n.x);
var vaNew = PostCollisionVelocity(va, a.Mass, vb, b.Mass);
var vbNew = PostCollisionVelocity(vb, b.Mass, va, a.Mass);
a.Velocity = n * vaNew + tangent * dot(tangent, a.Velocity);
b.Velocity = n * vbNew + tangent * dot(tangent, b.Velocity);
zone.Contacted.OnNext(new ContactEvent(a, b, a.Position.xz + n * radii[i], n));
}
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7c1e5a3b9d4f4e2a8b6c0d1f2e3a4b5c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -54,6 +54,8 @@ public class Zone
public List<PendingShot> PendingShots = new List<PendingShot>();
public Subject<ShotOutcome> ShotCommitted = new Subject<ShotOutcome>();
public Subject<ShotOutcome> ShotResolved = new Subject<ShotOutcome>();
// Contacts.Step publishes each hull-on-hull contact it resolves; presentations are the only readers.
public Subject<ContactEvent> Contacted = new Subject<ContactEvent>();
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.

View file

@ -0,0 +1,236 @@
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; ruling unity-presents-only-r3): Zone.Update steps
// Contacts, which exchanges the normal velocity of two touching 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<CultCache> _caches = new List<CultCache>();
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<HullData>("Longinus").Shape; }
finally { cache.Dispose(); }
}
private sealed class Lab
{
public ItemManager Items;
public Zone Zone;
public readonly List<ContactEvent> Events = new List<ContactEvent>();
public int Lot = 1;
public Ship Add(string hull, float3 at, float2 velocity)
{
var hullRef = Items.ItemData.RefOf<ItemData>(Items.ItemData.GetByName<HullData>(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()
{
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
};
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("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);
// The formula is the deleted ShieldManager.PostCollisionVelocity verbatim (cut sim-contact moves it, it does
// not change it): with equal masses a body ends at half the other's incoming normal velocity.
[Fact]
public void HeadOnEqualMassesTakeHalfTheOthersNormalVelocity()
{
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));
lab.Step();
var n = NormalOf(a, b);
Assert.Equal(-5f, dot(n, a.Velocity), 4);
Assert.Equal(5f, dot(n, b.Velocity), 4);
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);
}
[Fact]
public void GlancingContactKeepsTangent()
{
var lab = Build();
var a = lab.Add("Solid", float3(0, 0, 0), float2(10, 0));
var b = lab.Add("Solid", float3(7.07f, 0, 7.07f), float2(0, 0));
lab.Step();
var n = NormalOf(a, b);
var t = float2(-n.y, n.x);
// A's incoming velocity (10, 0) has after the step the tangent component it had before, and so does B's (zero).
Assert.Equal(10f * t.x, dot(t, a.Velocity), 4);
Assert.Equal(0f, dot(t, b.Velocity), 4);
// Only the normal components changed: A handed half its incoming normal component 10 * n.x to B.
Assert.Equal(0f, dot(n, a.Velocity), 4);
Assert.Equal(5f * n.x, dot(n, 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);
}
[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 exchange left the pair opening: the next step publishes nothing.
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(14.2f, 0, 0), float2(-10, 0));
lab.Step();
Assert.Empty(lab.Events);
}
// Today's formula for a ship against a 1000x heavier body: the ship's normal velocity becomes
// (v1 (m1 - m2) + m2 v2) / (m1 + m2) = 10 * -999000 / 1001000, the heavy body's 10 * 1000 / 1001000.
[Fact]
public void MassRatioIsTodaysFormula()
{
var lab = Build();
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);
Assert.Equal(10f * -999000f / 1001000f, dot(n, ship.Velocity), 3);
Assert.Equal(10f * 1000f / 1001000f, dot(n, station.Velocity), 4);
}
[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);
lab.Step();
Assert.Empty(lab.Events);
}
[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);
}
}