diff --git a/Aetheria.State.Daemon.Smoke/Aetheria.State.Daemon.Smoke.csproj b/Aetheria.State.Daemon.Smoke/Aetheria.State.Daemon.Smoke.csproj
index 8c34ca0a4..1dfbf7d59 100644
--- a/Aetheria.State.Daemon.Smoke/Aetheria.State.Daemon.Smoke.csproj
+++ b/Aetheria.State.Daemon.Smoke/Aetheria.State.Daemon.Smoke.csproj
@@ -9,5 +9,6 @@
+
diff --git a/Aetheria.State.Daemon.Smoke/Program.cs b/Aetheria.State.Daemon.Smoke/Program.cs
index 572a4c585..0679cdc60 100644
--- a/Aetheria.State.Daemon.Smoke/Program.cs
+++ b/Aetheria.State.Daemon.Smoke/Program.cs
@@ -1,194 +1,125 @@
+using Aetheria.State.Daemon;
using GameCult.Aetheria.State.Verse;
-var checks = new AetheriaDaemonCombatKernelSmokeChecks();
+var checks = new AetheriaDaemonYmirSmokeChecks();
checks.Run();
-Console.WriteLine("Daemon combat kernel smoke passed.");
+Console.WriteLine("Daemon Ymir projectile smoke passed.");
-internal sealed class AetheriaDaemonCombatKernelSmokeChecks
+internal sealed class AetheriaDaemonYmirSmokeChecks
{
public void Run()
{
- DeterministicTicksProduceSameSnapshot();
- ActionableUnresolvedTrackCanFireBeforeVisibility();
- KernelWritesNativeSnapshotRows();
+ YmirMovesProjectileAndReportsStableContact();
+ DaemonSimulationAppliesYmirHit();
+ MissingPhysicsOwnerCannotAdvanceProjectiles();
}
- private static void DeterministicTicksProduceSameSnapshot()
+ private static void YmirMovesProjectileAndReportsStableContact()
{
- var left = Scenario();
- var right = Scenario();
- var settings = AetheriaDaemonCombatKernelSettings.Default;
+ var (run, zone, target) = Scenario();
+ var step = new AetheriaYmirProjectilePhysics().Step(zone, zone.Entities, 0.1);
- for (var i = 0; i < 8; i++)
+ RequireEqual("ymir.core", new AetheriaYmirProjectilePhysics().AuthorityId, "adapter must identify its owner");
+ RequireEqual(0, step.Projectiles.Count, "contacted projectile must not survive");
+ RequireEqual(1, step.Hits.Count, "Ymir must report one projectile contact");
+ RequireEqual(target.EntityIndex, step.Hits[0].TargetEntityIndex, "contact must resolve the daemon entity");
+ RequireEqual("aetheria.projectile.smoke-projectile", step.Hits[0].ProjectileBodyId, "projectile body id must be stable");
+ RequireEqual("aetheria.daemon.entity.2", step.Hits[0].TargetBodyId, "entity body id must be stable");
+ }
+
+ private static void DaemonSimulationAppliesYmirHit()
+ {
+ var (run, _, target) = Scenario();
+
+ AetheriaRuntimeDaemonSimulation.Step(
+ run,
+ new AetheriaRuntimeDaemonIntentState(),
+ 0.1,
+ AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault,
+ new AetheriaYmirProjectilePhysics());
+
+ RequireEqual(88.0, Stat(target, "hull"), "Aetheria must interpret the Ymir contact as damage");
+ RequireEqual(0, run.Zones[0].Projectiles.Count, "spent projectile must leave daemon state");
+ }
+
+ private static void MissingPhysicsOwnerCannotAdvanceProjectiles()
+ {
+ var (run, _, _) = Scenario();
+ try
{
- AetheriaDaemonCombatKernel.Step(left, null, 0.25, catalog: null, settings);
- AetheriaDaemonCombatKernel.Step(right, null, 0.25, catalog: null, settings);
+ AetheriaRuntimeDaemonSimulation.Step(
+ run,
+ new AetheriaRuntimeDaemonIntentState(),
+ 0.1,
+ AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault,
+ AetheriaRuntimeProjectilePhysicsUnavailable.Instance);
+ }
+ catch (InvalidOperationException)
+ {
+ return;
}
- RequireEqual(Snapshot(left), Snapshot(right), "combat kernel should be deterministic for the same native state");
+ throw new InvalidOperationException("daemon advanced a projectile without an authoritative physics owner");
}
- private static void ActionableUnresolvedTrackCanFireBeforeVisibility()
+ private static (AetheriaRuntimeRunCheckpointCommit Run, AetheriaRuntimeZoneSnapshotCommit Zone, AetheriaRuntimeEntitySnapshotCommit Target) Scenario()
{
- var run = Scenario(
- attackerContactConfidence: 0.35,
- targetShield: 0.0);
- var settings = new AetheriaDaemonCombatKernelSettings
+ var source = Entity(1, 0, "player");
+ var target = Entity(2, 30, "raider");
+ var zone = new AetheriaRuntimeZoneSnapshotCommit
{
- TrackResolutionPerSecond = 0.0,
- LaunchTrackThreshold = 0.3,
- VisibleTrackThreshold = 0.8,
- AbstractWeaponRange = 500.0,
- DefaultWeaponDamage = 10.0,
- WeaponCooldownSeconds = 1.0
- };
-
- var report = AetheriaDaemonCombatKernel.Step(run, null, 0.2, catalog: null, settings);
- var attacker = Entity(run, 1);
- var target = Entity(run, 2);
- var contact = attacker.Contacts.Single(contact => contact.TargetEntityIndex == 2);
-
- RequireEqual(1, report.ShotCount, "actionable unresolved track should permit a committed shot");
- Require(!contact.Visible, "track should remain unresolved below visibility threshold");
- Require(Stat(target, "hull") < 100.0, "shot should apply native hull damage");
- }
-
- private static void KernelWritesNativeSnapshotRows()
- {
- var run = Scenario();
- var report = AetheriaDaemonCombatKernel.Step(run, null, 0.5, catalog: null);
- var attacker = Entity(run, 1);
-
- Require(report.ResolvedContactCount > 0, "kernel should resolve contacts into native contact rows");
- Require(attacker.Contacts.Count > 0, "contacts should live on AetheriaRuntimeEntitySnapshotCommit");
- Require(attacker.WeaponStates.Any(state => state.OwnerKind == "daemon-combat-kernel"), "weapon state should be a native weapon state row");
- Require(attacker.StatGrids.Any(grid => grid.Name == "cognitive-load"), "cognition pressure should be a native stat grid row");
- Require(attacker.StatGrids.Any(grid => grid.Name == "heat-capacity"), "thermal capacity should be a native stat grid row");
- }
-
- private static AetheriaRuntimeRunCheckpointCommit Scenario(
- double attackerContactConfidence = 0.7,
- double targetShield = 15.0)
- {
- return new AetheriaRuntimeRunCheckpointCommit
- {
- RunId = "daemon-combat-smoke",
- CurrentZoneIndex = 0,
- CurrentEntityKey = "zone.0.entity.1",
- Zones =
+ ZoneIndex = 0,
+ Entities = [source, target],
+ Projectiles =
[
- new AetheriaRuntimeZoneSnapshotCommit
+ new AetheriaRuntimeProjectileCommit
{
- ZoneIndex = 0,
- SimulationTimeSeconds = 0,
- Entities =
- [
- new AetheriaRuntimeEntitySnapshotCommit
- {
- EntityIndex = 1,
- Name = "Cold hunter",
- Kind = "ship",
- FactionKey = "player",
- PositionX = 0,
- PositionZ = 0,
- DirectionX = 1,
- DirectionY = 0,
- TargetEntityIndex = 2,
- IsActive = true,
- Contacts =
- [
- new AetheriaRuntimeEntityContactCommit
- {
- TargetEntityIndex = 2,
- InfoGathered = attackerContactConfidence,
- Hostile = true,
- Visible = attackerContactConfidence >= 0.62
- }
- ],
- StatGrids =
- [
- Grid("hull", 100.0),
- Grid("shield", 20.0),
- Grid("heat", 4.0),
- Grid("sensor-sensitivity", 1.3),
- Grid("cognition", 1.4),
- Grid("fire-control", 1.2),
- Grid("signature-masking", 0.3)
- ]
- },
- new AetheriaRuntimeEntitySnapshotCommit
- {
- EntityIndex = 2,
- Name = "Hot brute",
- Kind = "ship",
- FactionKey = "raider",
- PositionX = 180,
- PositionZ = 0,
- DirectionX = -1,
- DirectionY = 0,
- TargetEntityIndex = 1,
- IsActive = true,
- StatGrids =
- [
- Grid("hull", 100.0),
- Grid("shield", targetShield),
- Grid("heat", 18.0),
- Grid("heat-capacity", 180.0),
- Grid("signature", 1.4),
- Grid("signature-masking", 0.05),
- Grid("sensor-sensitivity", 0.9),
- Grid("cognition", 0.85),
- Grid("fire-control", 1.05)
- ]
- }
- ]
+ ProjectileId = "smoke-projectile",
+ SourceEntityIndex = 1,
+ TargetEntityIndex = 2,
+ PositionX = 0,
+ PositionZ = 0,
+ VelocityX = 100,
+ VelocityY = 0,
+ Radius = 1,
+ Damage = 12,
+ LifetimeSeconds = 5,
+ Active = true
}
]
};
- }
-
- private static AetheriaRuntimeEntitySnapshotCommit Entity(AetheriaRuntimeRunCheckpointCommit run, int index)
- {
- return run.Zones
- .SelectMany(zone => zone.Entities)
- .Single(entity => entity.EntityIndex == index);
- }
-
- private static AetheriaRuntimeEntityStatGridCommit Grid(string name, double value)
- {
- return new AetheriaRuntimeEntityStatGridCommit
+ var run = new AetheriaRuntimeRunCheckpointCommit
{
- Name = name,
- Width = 1,
- Height = 1,
- Values = [value]
+ RunId = "ymir-projectile-smoke",
+ CurrentZoneIndex = 0,
+ CurrentEntityKey = "zone.0.entity.1",
+ Zones = [zone]
};
+ return (run, zone, target);
}
- private static double Stat(AetheriaRuntimeEntitySnapshotCommit entity, string name)
+ private static AetheriaRuntimeEntitySnapshotCommit Entity(int index, double x, string faction) => new()
{
- return entity.StatGrids
- .First(grid => string.Equals(grid.Name, name, StringComparison.OrdinalIgnoreCase))
- .Values
- .First();
- }
+ EntityIndex = index,
+ Kind = "ship",
+ FactionKey = faction,
+ PositionX = x,
+ PositionZ = 0,
+ TargetEntityIndex = -1,
+ IsActive = true,
+ StatGrids = [Grid("hull", 100), Grid("shield", 0), Grid("heat", 0)]
+ };
- private static string Snapshot(AetheriaRuntimeRunCheckpointCommit run)
+ private static AetheriaRuntimeEntityStatGridCommit Grid(string name, double value) => new()
{
- return string.Join(
- "|",
- run.Zones
- .SelectMany(zone => zone.Entities)
- .OrderBy(entity => entity.EntityIndex)
- .Select(entity =>
- $"{entity.EntityIndex}:{Stat(entity, "hull"):0.000}:{Stat(entity, "shield"):0.000}:{Stat(entity, "heat"):0.000}:{Stat(entity, "cognitive-load"):0.000}:{entity.Contacts.Count}:{entity.WeaponStates.Count}"));
- }
+ Name = name,
+ Width = 1,
+ Height = 1,
+ Values = [value]
+ };
- private static void Require(bool condition, string message)
- {
- if (!condition)
- throw new InvalidOperationException(message);
- }
+ private static double Stat(AetheriaRuntimeEntitySnapshotCommit entity, string name) =>
+ entity.StatGrids.Single(grid => string.Equals(grid.Name, name, StringComparison.OrdinalIgnoreCase)).Values[0];
private static void RequireEqual(T expected, T actual, string message)
{
diff --git a/Aetheria.State.Daemon/Aetheria.State.Daemon.csproj b/Aetheria.State.Daemon/Aetheria.State.Daemon.csproj
index ca788873d..b7350c9be 100644
--- a/Aetheria.State.Daemon/Aetheria.State.Daemon.csproj
+++ b/Aetheria.State.Daemon/Aetheria.State.Daemon.csproj
@@ -9,5 +9,6 @@
+
diff --git a/Aetheria.State.Daemon/AetheriaYmirProjectilePhysics.cs b/Aetheria.State.Daemon/AetheriaYmirProjectilePhysics.cs
new file mode 100644
index 000000000..969612fbc
--- /dev/null
+++ b/Aetheria.State.Daemon/AetheriaYmirProjectilePhysics.cs
@@ -0,0 +1,120 @@
+using GameCult.Aetheria.State.Verse;
+using Ymir.Core;
+
+namespace Aetheria.State.Daemon;
+
+public sealed class AetheriaYmirProjectilePhysics : IAetheriaRuntimeProjectilePhysics
+{
+ private const string ProjectileBodyPrefix = "aetheria.projectile.";
+ private const string EntityBodyPrefix = "aetheria.daemon.entity.";
+ private readonly YmirSimulator _simulator;
+
+ public AetheriaYmirProjectilePhysics(YmirSimulator? simulator = null)
+ {
+ _simulator = simulator ?? new YmirSimulator();
+ }
+
+ public string AuthorityId => "ymir.core";
+
+ public AetheriaRuntimeProjectileStep Step(
+ AetheriaRuntimeZoneSnapshotCommit zone,
+ IReadOnlyList entities,
+ double deltaSeconds)
+ {
+ ArgumentNullException.ThrowIfNull(zone);
+ if (deltaSeconds <= 0)
+ throw new ArgumentOutOfRangeException(nameof(deltaSeconds));
+
+ var entityByBodyId = (entities ?? Array.Empty())
+ .Where(entity => entity is { IsActive: true })
+ .ToDictionary(entity => EntityBodyId(entity.EntityIndex), StringComparer.Ordinal);
+ var survivors = new List();
+ var hits = new List();
+
+ foreach (var projectile in zone.Projectiles ?? Array.Empty())
+ {
+ if (projectile is not { Active: true })
+ continue;
+
+ var projectileBodyId = ProjectileBodyId(projectile.ProjectileId);
+ var bodies = new List
+ {
+ new(
+ projectileBodyId,
+ new Vec2((float)projectile.PositionX, (float)projectile.PositionZ),
+ new Vec2((float)projectile.VelocityX, (float)projectile.VelocityY),
+ (float)Math.Max(0.001, projectile.Radius),
+ 1.0f,
+ Restitution: 0.0f)
+ };
+ foreach (var pair in entityByBodyId)
+ {
+ var entity = pair.Value;
+ if (entity.EntityIndex == projectile.SourceEntityIndex)
+ continue;
+ bodies.Add(new PhysicsBody(
+ pair.Key,
+ new Vec2((float)entity.PositionX, (float)entity.PositionZ),
+ Vec2.Zero,
+ ResolveEntityRadius(entity),
+ 1.0f,
+ IsStatic: true,
+ Restitution: 0.0f));
+ }
+
+ var result = _simulator.Step(new SimulationStepRequest(
+ (float)deltaSeconds,
+ new YmirWorld((float)zone.SimulationTimeSeconds, bodies, Array.Empty())));
+ var projectileBody = result.World.Bodies.Single(body =>
+ string.Equals(body.Id, projectileBodyId, StringComparison.Ordinal));
+ projectile.PositionX = projectileBody.Position.X;
+ projectile.PositionZ = projectileBody.Position.Y;
+ projectile.VelocityX = projectileBody.Velocity.X;
+ projectile.VelocityY = projectileBody.Velocity.Y;
+
+ var contact = result.Contacts.FirstOrDefault(candidate =>
+ string.Equals(candidate.BodyA, projectileBodyId, StringComparison.Ordinal) ||
+ string.Equals(candidate.BodyB, projectileBodyId, StringComparison.Ordinal));
+ if (contact == null)
+ {
+ survivors.Add(projectile);
+ continue;
+ }
+
+ var targetBodyId = string.Equals(contact.BodyA, projectileBodyId, StringComparison.Ordinal)
+ ? contact.BodyB
+ : contact.BodyA;
+ if (!entityByBodyId.TryGetValue(targetBodyId, out var target))
+ {
+ survivors.Add(projectile);
+ continue;
+ }
+
+ var normal = string.Equals(contact.BodyA, projectileBodyId, StringComparison.Ordinal)
+ ? contact.Normal
+ : new Vec2(-contact.Normal.X, -contact.Normal.Y);
+ hits.Add(new AetheriaRuntimeProjectileHit
+ {
+ Projectile = projectile,
+ ProjectileBodyId = projectileBodyId,
+ TargetEntityIndex = target.EntityIndex,
+ TargetBodyId = targetBodyId,
+ PointX = contact.Point.X,
+ PointZ = contact.Point.Y,
+ NormalX = normal.X,
+ NormalZ = normal.Y
+ });
+ }
+
+ return new AetheriaRuntimeProjectileStep(survivors, hits);
+ }
+
+ private static string ProjectileBodyId(string? projectileId) =>
+ ProjectileBodyPrefix + (projectileId ?? "");
+
+ private static string EntityBodyId(int entityIndex) =>
+ EntityBodyPrefix + entityIndex;
+
+ private static float ResolveEntityRadius(AetheriaRuntimeEntitySnapshotCommit entity) =>
+ string.Equals(entity.Kind, "station", StringComparison.OrdinalIgnoreCase) ? 48.0f : 20.0f;
+}
diff --git a/Aetheria.State.Daemon/Program.cs b/Aetheria.State.Daemon/Program.cs
index 4552224e6..07dc47c54 100644
--- a/Aetheria.State.Daemon/Program.cs
+++ b/Aetheria.State.Daemon/Program.cs
@@ -1,5 +1,6 @@
using Aetheria.State;
using Aetheria.State.Documents;
+using Aetheria.State.Daemon;
using GameCult.Aetheria.State.Verse;
using GameCult.Caching;
using GameCult.Eve.Surface;
@@ -12,6 +13,7 @@ using System.Net.Sockets;
var options = AetheriaDaemonHostOptions.Parse(args);
var startedAtUtc = DateTimeOffset.UtcNow.ToString("O");
+var projectilePhysics = new AetheriaYmirProjectilePhysics();
Console.WriteLine($"Aetheria Verse daemon starting: {options.StatePath}");
Console.WriteLine(options.EnableOdinAnnouncements
@@ -42,7 +44,7 @@ using var cultMeshRudpHost = StartClientCultMeshHost(node, options, () => latest
using var clientPumpCancellation = new CancellationTokenSource();
var clientPump = RunClientCultMeshPumpAsync(cultMeshRudpHost, clientPumpCancellation.Token);
var nextApiPublicationUtc = DateTimeOffset.UtcNow;
-var firstTick = await TickAsync(node, options, latestFrame, buildPublications: true).ConfigureAwait(false);
+var firstTick = await TickAsync(node, options, projectilePhysics, latestFrame, buildPublications: true).ConfigureAwait(false);
ThrowIfClientPumpFaulted(clientPump);
latestFrame = firstTick.Frame;
nextApiPublicationUtc = DateTimeOffset.UtcNow.Add(options.ApiPublicationInterval);
@@ -79,7 +81,7 @@ while (!stopped.Task.IsCompleted)
}
var buildPublications = DateTimeOffset.UtcNow >= nextApiPublicationUtc;
- var tick = await TickAsync(node, options, latestFrame, buildPublications).ConfigureAwait(false);
+ var tick = await TickAsync(node, options, projectilePhysics, latestFrame, buildPublications).ConfigureAwait(false);
ThrowIfClientPumpFaulted(clientPump);
latestFrame = tick.Frame;
nextTickUtc += options.TickInterval;
@@ -114,6 +116,7 @@ static void ThrowIfClientPumpFaulted(Task clientPump)
static async Task TickAsync(
AetheriaStateNode node,
AetheriaDaemonHostOptions options,
+ IAetheriaRuntimeProjectilePhysics projectilePhysics,
AetheriaRuntimeDaemonFrameDocument? currentFrame,
bool buildPublications)
{
@@ -181,6 +184,7 @@ static async Task TickAsync(
Catalog = node.RuntimeCatalog().Latest(),
RenderSettings = options.RenderSettings,
SimulationSettings = options.SimulationSettings,
+ ProjectilePhysics = projectilePhysics,
StarbridgeScenario = starbridgeScenario,
StarbridgeSession = starbridgeSession,
BuildPublications = buildPublications,
@@ -201,19 +205,6 @@ static async Task TickAsync(
$"velocity={entity.VelocityX},{entity.VelocityY}");
}
}
- if (options.UseAbstractCombatKernel)
- {
- AetheriaDaemonCombatKernel.Step(
- result.Frame.Run,
- result.Intents,
- fixedDeltaSeconds,
- node.RuntimeCatalog().Latest(),
- options.CombatKernelSettings);
- result.Frame.Capabilities = (result.Frame.Capabilities ?? Array.Empty())
- .Concat(new[] { "aetheria.daemon.abstract_combat_kernel.v1" })
- .Distinct(StringComparer.Ordinal)
- .ToArray();
- }
result.Frame.CumulativeImportedFactIds =
currentFrame?.CumulativeImportedFactIds ?? Array.Empty();
result.Frame.CumulativeRejectedImportedFactIds =
@@ -1907,7 +1898,11 @@ static EveAssetCatalogDocument BuildCoreAssetCatalog(
bundle.Uri,
bundle.Hash,
bundle.Size,
- $"Assets/Resources/{resourcesPath}.prefab"))
+ $"Assets/Resources/{resourcesPath}.prefab",
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["view.pilot.excludeUnityLayers"] = "14"
+ }))
.ToArray(),
new Dictionary(StringComparer.Ordinal)
{
@@ -2893,9 +2888,6 @@ internal sealed class AetheriaDaemonHostOptions
AetheriaRuntimeDaemonRenderSettings.AetheriaDefault;
public AetheriaRuntimeDaemonSimulationSettings SimulationSettings { get; init; } =
AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault;
- public AetheriaDaemonCombatKernelSettings CombatKernelSettings { get; init; } =
- AetheriaDaemonCombatKernelSettings.Default;
- public bool UseAbstractCombatKernel { get; init; }
public bool Once { get; init; }
public static AetheriaDaemonHostOptions Parse(IReadOnlyList args)
@@ -2956,7 +2948,6 @@ internal sealed class AetheriaDaemonHostOptions
TickInterval = TimeSpan.FromMilliseconds(intervalMs),
ApiPublicationInterval = TimeSpan.FromMilliseconds(apiPublicationIntervalMs),
FixedDeltaSeconds = fixedDeltaMs / 1000.0,
- UseAbstractCombatKernel = HasFlag(args, "--abstract-combat-kernel"),
Once = HasFlag(args, "--once")
};
}
diff --git a/Aetheria.State.Verify/Program.cs b/Aetheria.State.Verify/Program.cs
index 8fba59222..52678cf8a 100644
--- a/Aetheria.State.Verify/Program.cs
+++ b/Aetheria.State.Verify/Program.cs
@@ -16446,12 +16446,14 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root)
"org.gamecult.aetheria.state",
"Runtime",
"AetheriaRuntimeDaemonSimulationSettings.cs");
- var ymirProjectilePhysicsPath = Path.Combine(
+ var projectilePhysicsContractPath = Path.Combine(
root,
"Packages",
"org.gamecult.aetheria.state",
"Runtime",
- "AetheriaRuntimeYmirProjectilePhysics.cs");
+ "AetheriaRuntimeProjectilePhysics.cs");
+ var ymirProjectilePhysicsPath = Path.Combine(root, "Aetheria.State.Daemon", "AetheriaYmirProjectilePhysics.cs");
+ var daemonProjectPath = Path.Combine(root, "Aetheria.State.Daemon", "Aetheria.State.Daemon.csproj");
var gameDocumentsPath = Path.Combine(
root,
"Packages",
@@ -16504,9 +16506,13 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root)
var daemonSimulationSettings = File.Exists(daemonSimulationSettingsPath)
? File.ReadAllText(daemonSimulationSettingsPath)
: throw new InvalidOperationException("Cannot verify daemon simulation authority; daemon simulation settings are missing.");
+ var projectilePhysicsContract = File.Exists(projectilePhysicsContractPath)
+ ? File.ReadAllText(projectilePhysicsContractPath)
+ : throw new InvalidOperationException("Cannot verify daemon projectile authority; shared physics port is missing.");
var ymirProjectilePhysics = File.Exists(ymirProjectilePhysicsPath)
? File.ReadAllText(ymirProjectilePhysicsPath)
- : throw new InvalidOperationException("Cannot verify daemon projectile authority; Ymir projectile physics boundary is missing.");
+ : throw new InvalidOperationException("Cannot verify daemon projectile authority; daemon Ymir adapter is missing.");
+ var daemonProject = File.ReadAllText(daemonProjectPath);
var gameDocuments = File.Exists(gameDocumentsPath)
? File.ReadAllText(gameDocumentsPath)
: throw new InvalidOperationException("Cannot verify daemon projectile authority; game viewport documents are missing.");
@@ -16574,10 +16580,10 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root)
{
"public static class AetheriaRuntimeDaemonSimulation",
"AetheriaRuntimeDaemonSimulationSettings settings",
- "StepCombat(zone, entities, deltaSeconds, settings)",
+ "StepCombat(run, zone, entities, intents, deltaSeconds, settings, projectilePhysics)",
"EnsureDaemonWeaponState(",
"SpawnProjectile(zone, attacker, target, settings)",
- "AetheriaRuntimeYmirProjectilePhysics.Step(zone, entities, deltaSeconds)",
+ "projectilePhysics.Step(zone, entities, deltaSeconds)",
"Damage(target, hit.Projectile.Damage)"
},
[daemonSimulationSettingsPath] = new[]
@@ -16587,14 +16593,29 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root)
"public double ProjectileSpeed { get; }",
"public double AttackRange { get; }"
},
+ [projectilePhysicsContractPath] = new[]
+ {
+ "public interface IAetheriaRuntimeProjectilePhysics",
+ "public sealed class AetheriaRuntimeProjectilePhysicsUnavailable",
+ "public sealed class AetheriaRuntimeProjectileStep",
+ "public sealed class AetheriaRuntimeProjectileHit"
+ },
[ymirProjectilePhysicsPath] = new[]
{
- "public static class AetheriaRuntimeYmirProjectilePhysics",
- "public static AetheriaRuntimeYmirProjectileStep Step(",
+ "public sealed class AetheriaYmirProjectilePhysics : IAetheriaRuntimeProjectilePhysics",
+ "private readonly YmirSimulator _simulator",
"ProjectileBodyPrefix",
- "DaemonEntityBodyPrefix",
- "TryResolveProjectileContact(",
- "public sealed class AetheriaRuntimeYmirProjectileHit"
+ "EntityBodyPrefix",
+ "_simulator.Step(new SimulationStepRequest("
+ },
+ [daemonProjectPath] = new[]
+ {
+ "Ymir.Core.csproj"
+ },
+ [daemonProgramPath] = new[]
+ {
+ "var projectilePhysics = new AetheriaYmirProjectilePhysics();",
+ "ProjectilePhysics = projectilePhysics"
},
[gameDocumentsPath] = new[]
{
@@ -16614,7 +16635,10 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root)
var text = pair.Key == snapshotDocumentsPath ? snapshotDocuments :
pair.Key == daemonSimulationPath ? daemonSimulation :
pair.Key == daemonSimulationSettingsPath ? daemonSimulationSettings :
+ pair.Key == projectilePhysicsContractPath ? projectilePhysicsContract :
pair.Key == ymirProjectilePhysicsPath ? ymirProjectilePhysics :
+ pair.Key == daemonProjectPath ? daemonProject :
+ pair.Key == daemonProgramPath ? daemonProgram :
pair.Key == gameDocumentsPath ? gameDocuments :
gameViewportDocuments;
return pair.Value
diff --git a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs
index e18b1d391..0a642066b 100644
--- a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs
+++ b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs
@@ -522,6 +522,7 @@ public class DaemonRuntimeDocumentTests
FrameId = 42,
SimulationTimeSeconds = 12.5,
FixedDeltaSeconds = 0.02,
+ ProjectilePhysics = new PassthroughProjectilePhysics(),
ObservedCommands = new[] { targetCommand, movementCommand }
});
@@ -942,6 +943,7 @@ public class DaemonRuntimeDocumentTests
FrameId = 43,
SimulationTimeSeconds = 12.52,
FixedDeltaSeconds = 0.02,
+ ProjectilePhysics = new PassthroughProjectilePhysics(),
ObservedCommands = new[] { targetCommand },
AccountedCommandIds = new[] { targetCommand.CommandId }
});
@@ -2030,7 +2032,8 @@ public class DaemonRuntimeDocumentTests
run,
new AetheriaRuntimeDaemonIntentState(),
0.1,
- AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault);
+ AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault,
+ new PassthroughProjectilePhysics());
var zone = FindZone(run, 0);
Assert.IsNotEmpty(zone.Projectiles);
@@ -2131,7 +2134,8 @@ public class DaemonRuntimeDocumentTests
run,
new AetheriaRuntimeDaemonIntentState(),
0.1,
- settings);
+ settings,
+ new PassthroughProjectilePhysics());
var zone = FindZone(run, 0);
var projectile = zone.Projectiles.Single(projectile => projectile.SourceEntityIndex == 0);
@@ -2211,7 +2215,8 @@ public class DaemonRuntimeDocumentTests
run,
new AetheriaRuntimeDaemonIntentState(),
0.1,
- AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault);
+ AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault,
+ new PassthroughProjectilePhysics());
Assert.IsTrue(pilot.CargoContents.SelectMany(bay => bay.Items).Any(slot => slot.Item.ItemKey == "raider-salvage"));
Assert.IsEmpty(wreck.CargoContents);
@@ -3021,7 +3026,8 @@ public class DaemonRuntimeDocumentTests
CultMeshAddress = "cultmesh://aetheria.local/eve/providers/aetheria.daemon",
FrameId = frame.FrameId,
SimulationTimeSeconds = frame.SimulationTimeSeconds,
- FixedDeltaSeconds = frame.FixedDeltaSeconds
+ FixedDeltaSeconds = frame.FixedDeltaSeconds,
+ ProjectilePhysics = new PassthroughProjectilePhysics()
});
PublishLatestFrameThroughVerseClient(statePath, tickResult.Frame);
PublishDaemonSurfacesThroughVerseClient(statePath, tickResult);
@@ -3176,7 +3182,8 @@ public class DaemonRuntimeDocumentTests
CultMeshAddress = "cultmesh://aetheria.local/eve/providers/aetheria.daemon",
FrameId = frame.FrameId,
SimulationTimeSeconds = frame.SimulationTimeSeconds,
- FixedDeltaSeconds = frame.FixedDeltaSeconds
+ FixedDeltaSeconds = frame.FixedDeltaSeconds,
+ ProjectilePhysics = new PassthroughProjectilePhysics()
});
PublishLatestFrameThroughVerseClient(statePath, tickResult.Frame);
PublishDaemonSurfacesThroughVerseClient(statePath, tickResult);
@@ -5553,4 +5560,19 @@ public class DaemonRuntimeDocumentTests
{
return document.LatestAsync().ConfigureAwait(false).GetAwaiter().GetResult();
}
+
+ private sealed class PassthroughProjectilePhysics : IAetheriaRuntimeProjectilePhysics
+ {
+ public string AuthorityId => "test.passthrough";
+
+ public AetheriaRuntimeProjectileStep Step(
+ AetheriaRuntimeZoneSnapshotCommit zone,
+ IReadOnlyList entities,
+ double deltaSeconds)
+ {
+ return new AetheriaRuntimeProjectileStep(
+ zone.Projectiles,
+ Array.Empty());
+ }
+ }
}
diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs
index adf0607e5..eb229b63d 100644
--- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs
+++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs
@@ -16,10 +16,13 @@ namespace GameCult.Aetheria.State.Verse
AetheriaRuntimeRunCheckpointCommit run,
AetheriaRuntimeDaemonIntentState intents,
double deltaSeconds,
- AetheriaRuntimeDaemonSimulationSettings settings)
+ AetheriaRuntimeDaemonSimulationSettings settings,
+ IAetheriaRuntimeProjectilePhysics projectilePhysics)
{
if (run == null || deltaSeconds <= 0)
return;
+ if (projectilePhysics == null)
+ throw new ArgumentNullException(nameof(projectilePhysics));
foreach (var zone in run.Zones ?? Array.Empty())
{
@@ -37,7 +40,7 @@ namespace GameCult.Aetheria.State.Verse
StepRaiderAi(entities);
StepTargetPursuit(entities, settings);
StepMovement(entities, deltaSeconds);
- StepCombat(run, zone, entities, intents, deltaSeconds, settings);
+ StepCombat(run, zone, entities, intents, deltaSeconds, settings, projectilePhysics);
StepTractorSalvage(run, zone, entities, settings);
RefreshContacts(entities, settings);
}
@@ -175,7 +178,8 @@ namespace GameCult.Aetheria.State.Verse
IReadOnlyList entities,
AetheriaRuntimeDaemonIntentState intents,
double deltaSeconds,
- AetheriaRuntimeDaemonSimulationSettings settings)
+ AetheriaRuntimeDaemonSimulationSettings settings,
+ IAetheriaRuntimeProjectilePhysics projectilePhysics)
{
var byIndex = entities.ToDictionary(entity => entity.EntityIndex);
foreach (var attacker in entities)
@@ -214,7 +218,12 @@ namespace GameCult.Aetheria.State.Verse
SetStat(attacker, Heat, Math.Min(100, GetStat(attacker, Heat) + ResolveProjectileDamage(attacker, settings) * settings.ProjectileHeatScale));
}
- var projectileStep = AetheriaRuntimeYmirProjectilePhysics.Step(zone, entities, deltaSeconds);
+ PrepareProjectiles(zone, byIndex, deltaSeconds);
+ var projectileStep = zone.Projectiles.Count == 0
+ ? new AetheriaRuntimeProjectileStep(
+ Array.Empty(),
+ Array.Empty())
+ : projectilePhysics.Step(zone, entities, deltaSeconds);
zone.Projectiles = projectileStep.Projectiles;
foreach (var hit in projectileStep.Hits)
{
@@ -235,6 +244,56 @@ namespace GameCult.Aetheria.State.Verse
}
}
+ private static void PrepareProjectiles(
+ AetheriaRuntimeZoneSnapshotCommit zone,
+ IReadOnlyDictionary entities,
+ double deltaSeconds)
+ {
+ var active = new List();
+ foreach (var projectile in zone.Projectiles ?? Array.Empty())
+ {
+ if (projectile == null || !projectile.Active)
+ continue;
+
+ projectile.AgeSeconds += deltaSeconds;
+ if (projectile.AgeSeconds >= projectile.LifetimeSeconds)
+ continue;
+
+ if (projectile.Guided &&
+ projectile.TargetEntityIndex >= 0 &&
+ entities.TryGetValue(projectile.TargetEntityIndex, out var target) &&
+ IsAlive(target))
+ {
+ GuideProjectile(projectile, target);
+ }
+
+ active.Add(projectile);
+ }
+ zone.Projectiles = active;
+ }
+
+ private static void GuideProjectile(
+ AetheriaRuntimeProjectileCommit projectile,
+ AetheriaRuntimeEntitySnapshotCommit target)
+ {
+ var speed = Math.Sqrt(
+ projectile.VelocityX * projectile.VelocityX +
+ projectile.VelocityY * projectile.VelocityY);
+ if (speed <= 0.0001)
+ return;
+
+ var x = target.PositionX - projectile.PositionX;
+ var y = target.PositionZ - projectile.PositionZ;
+ var magnitude = Math.Sqrt(x * x + y * y);
+ if (magnitude <= 0.0001)
+ return;
+
+ projectile.DirectionX = x / magnitude;
+ projectile.DirectionY = y / magnitude;
+ projectile.VelocityX = projectile.DirectionX * speed;
+ projectile.VelocityY = projectile.DirectionY * speed;
+ }
+
private static bool WantsFire(
AetheriaRuntimeRunCheckpointCommit run,
AetheriaRuntimeZoneSnapshotCommit zone,
diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonTickRunner.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonTickRunner.cs
index d1c50eb29..66d24d04c 100644
--- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonTickRunner.cs
+++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonTickRunner.cs
@@ -34,6 +34,8 @@ namespace GameCult.Aetheria.State.Verse
AetheriaRuntimeDaemonRenderSettings.AetheriaDefault;
public AetheriaRuntimeDaemonSimulationSettings SimulationSettings { get; set; } =
AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault;
+ public IAetheriaRuntimeProjectilePhysics ProjectilePhysics { get; set; } =
+ AetheriaRuntimeProjectilePhysicsUnavailable.Instance;
public bool BuildPublications { get; set; } = true;
}
@@ -135,7 +137,8 @@ namespace GameCult.Aetheria.State.Verse
operationResult.Run,
operationResult.Intents,
options.FixedDeltaSeconds,
- options.SimulationSettings);
+ options.SimulationSettings,
+ options.ProjectilePhysics);
StampZoneSimulationTime(operationResult.Run, options.SimulationTimeSeconds);
var frame = AetheriaRuntimeDaemonFrameDocument.Create(
diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeProjectilePhysics.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeProjectilePhysics.cs
new file mode 100644
index 000000000..c0443faea
--- /dev/null
+++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeProjectilePhysics.cs
@@ -0,0 +1,73 @@
+using System;
+using System.Collections.Generic;
+
+#nullable enable
+
+namespace GameCult.Aetheria.State.Verse
+{
+ public interface IAetheriaRuntimeProjectilePhysics
+ {
+ string AuthorityId { get; }
+
+ AetheriaRuntimeProjectileStep Step(
+ AetheriaRuntimeZoneSnapshotCommit zone,
+ IReadOnlyList entities,
+ double deltaSeconds);
+ }
+
+ public sealed class AetheriaRuntimeProjectilePhysicsUnavailable : IAetheriaRuntimeProjectilePhysics
+ {
+ public static AetheriaRuntimeProjectilePhysicsUnavailable Instance { get; } =
+ new AetheriaRuntimeProjectilePhysicsUnavailable();
+
+ private AetheriaRuntimeProjectilePhysicsUnavailable()
+ {
+ }
+
+ public string AuthorityId => "unavailable";
+
+ public AetheriaRuntimeProjectileStep Step(
+ AetheriaRuntimeZoneSnapshotCommit zone,
+ IReadOnlyList entities,
+ double deltaSeconds)
+ {
+ throw new InvalidOperationException(
+ "Aetheria projectile simulation requires an injected authoritative physics owner.");
+ }
+ }
+
+ public sealed class AetheriaRuntimeProjectileStep
+ {
+ public AetheriaRuntimeProjectileStep(
+ IReadOnlyList projectiles,
+ IReadOnlyList hits)
+ {
+ Projectiles = projectiles ?? Array.Empty();
+ Hits = hits ?? Array.Empty();
+ }
+
+ public IReadOnlyList Projectiles { get; }
+
+ public IReadOnlyList Hits { get; }
+ }
+
+ public sealed class AetheriaRuntimeProjectileHit
+ {
+ public AetheriaRuntimeProjectileCommit Projectile { get; set; } =
+ new AetheriaRuntimeProjectileCommit();
+
+ public string ProjectileBodyId { get; set; } = "";
+
+ public int TargetEntityIndex { get; set; }
+
+ public string TargetBodyId { get; set; } = "";
+
+ public double PointX { get; set; }
+
+ public double PointZ { get; set; }
+
+ public double NormalX { get; set; }
+
+ public double NormalZ { get; set; }
+ }
+}
diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeProjectilePhysics.cs.meta b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeProjectilePhysics.cs.meta
new file mode 100644
index 000000000..f756a4e89
--- /dev/null
+++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeProjectilePhysics.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 5437add74c5d2fb45b81299b41aa999d
diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeYmirProjectilePhysics.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeYmirProjectilePhysics.cs
deleted file mode 100644
index 27e4fc336..000000000
--- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeYmirProjectilePhysics.cs
+++ /dev/null
@@ -1,207 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-
-#nullable enable
-
-namespace GameCult.Aetheria.State.Verse
-{
- public static class AetheriaRuntimeYmirProjectilePhysics
- {
- private const string ProjectileBodyPrefix = "aetheria.projectile.";
- private const string DaemonEntityBodyPrefix = "aetheria.daemon.entity.";
- private const double DefaultEntityPhysicsRadius = 20.0;
-
- public static AetheriaRuntimeYmirProjectileStep Step(
- AetheriaRuntimeZoneSnapshotCommit zone,
- IReadOnlyList entities,
- double deltaSeconds)
- {
- if (zone == null || deltaSeconds <= 0)
- {
- return new AetheriaRuntimeYmirProjectileStep(
- zone?.Projectiles ?? Array.Empty(),
- Array.Empty());
- }
-
- var entityByIndex = (entities ?? Array.Empty())
- .Where(entity => entity != null)
- .ToDictionary(entity => entity.EntityIndex);
- var activeProjectiles = new List();
- var hits = new List();
-
- foreach (var projectile in zone.Projectiles ?? Array.Empty())
- {
- if (projectile == null || !projectile.Active)
- continue;
-
- projectile.AgeSeconds += deltaSeconds;
- if (projectile.AgeSeconds >= projectile.LifetimeSeconds)
- continue;
-
- AetheriaRuntimeEntitySnapshotCommit? target = null;
- if (projectile.TargetEntityIndex >= 0 &&
- entityByIndex.TryGetValue(projectile.TargetEntityIndex, out var resolvedTarget) &&
- IsActiveBody(resolvedTarget))
- {
- target = resolvedTarget;
- }
-
- if (projectile.Guided && target != null)
- GuideProjectile(projectile, target);
-
- var previousX = projectile.PositionX;
- var previousZ = projectile.PositionZ;
- projectile.PositionX += projectile.VelocityX * deltaSeconds;
- projectile.PositionZ += projectile.VelocityY * deltaSeconds;
-
- if (target != null &&
- TryResolveProjectileContact(projectile, previousX, previousZ, target, out var contact))
- {
- hits.Add(contact);
- continue;
- }
-
- activeProjectiles.Add(projectile);
- }
-
- return new AetheriaRuntimeYmirProjectileStep(
- activeProjectiles.ToArray(),
- hits.ToArray());
- }
-
- private static void GuideProjectile(
- AetheriaRuntimeProjectileCommit projectile,
- AetheriaRuntimeEntitySnapshotCommit target)
- {
- var speed = Math.Sqrt(projectile.VelocityX * projectile.VelocityX + projectile.VelocityY * projectile.VelocityY);
- if (speed <= 0.0001)
- return;
-
- var direction = Normalize(target.PositionX - projectile.PositionX, target.PositionZ - projectile.PositionZ);
- if (Math.Abs(direction.X) + Math.Abs(direction.Y) <= 0.0001)
- return;
-
- projectile.DirectionX = direction.X;
- projectile.DirectionY = direction.Y;
- projectile.VelocityX = direction.X * speed;
- projectile.VelocityY = direction.Y * speed;
- }
-
- private static bool TryResolveProjectileContact(
- AetheriaRuntimeProjectileCommit projectile,
- double previousX,
- double previousZ,
- AetheriaRuntimeEntitySnapshotCommit target,
- out AetheriaRuntimeYmirProjectileHit contact)
- {
- var targetRadius = ResolveEntityPhysicsRadius(target);
- var combinedRadius = Math.Max(0.001, projectile.Radius) + targetRadius;
- var currentDistanceSq = Sqr(target.PositionX - projectile.PositionX) + Sqr(target.PositionZ - projectile.PositionZ);
- if (currentDistanceSq <= combinedRadius * combinedRadius)
- {
- contact = Contact(projectile, target, projectile.PositionX, projectile.PositionZ);
- return true;
- }
-
- var segmentX = projectile.PositionX - previousX;
- var segmentZ = projectile.PositionZ - previousZ;
- var segmentLengthSq = segmentX * segmentX + segmentZ * segmentZ;
- if (segmentLengthSq <= 0.000001)
- {
- contact = default!;
- return false;
- }
-
- var targetX = target.PositionX - previousX;
- var targetZ = target.PositionZ - previousZ;
- var t = Math.Max(0, Math.Min(1, (targetX * segmentX + targetZ * segmentZ) / segmentLengthSq));
- var closestX = previousX + segmentX * t;
- var closestZ = previousZ + segmentZ * t;
- var closestDistanceSq = Sqr(target.PositionX - closestX) + Sqr(target.PositionZ - closestZ);
- if (closestDistanceSq > combinedRadius * combinedRadius)
- {
- contact = default!;
- return false;
- }
-
- contact = Contact(projectile, target, closestX, closestZ);
- return true;
- }
-
- private static AetheriaRuntimeYmirProjectileHit Contact(
- AetheriaRuntimeProjectileCommit projectile,
- AetheriaRuntimeEntitySnapshotCommit target,
- double pointX,
- double pointZ)
- {
- var normal = Normalize(pointX - target.PositionX, pointZ - target.PositionZ);
- return new AetheriaRuntimeYmirProjectileHit
- {
- Projectile = projectile,
- ProjectileBodyId = ProjectileBodyPrefix + (projectile.ProjectileId ?? ""),
- TargetEntityIndex = target.EntityIndex,
- TargetBodyId = DaemonEntityBodyPrefix + target.EntityIndex,
- PointX = pointX,
- PointZ = pointZ,
- NormalX = normal.X,
- NormalZ = normal.Y
- };
- }
-
- private static double ResolveEntityPhysicsRadius(AetheriaRuntimeEntitySnapshotCommit entity)
- {
- if (string.Equals(entity.Kind, "station", StringComparison.OrdinalIgnoreCase))
- return 48.0;
-
- return DefaultEntityPhysicsRadius;
- }
-
- private static bool IsActiveBody(AetheriaRuntimeEntitySnapshotCommit entity)
- {
- return entity.IsActive;
- }
-
- private static double Sqr(double value) => value * value;
-
- private static (double X, double Y) Normalize(double x, double y)
- {
- var magnitude = Math.Sqrt(x * x + y * y);
- return magnitude <= 0.0001 ? (0, 0) : (x / magnitude, y / magnitude);
- }
- }
-
- public sealed class AetheriaRuntimeYmirProjectileStep
- {
- public AetheriaRuntimeYmirProjectileStep(
- IReadOnlyList projectiles,
- IReadOnlyList hits)
- {
- Projectiles = projectiles ?? Array.Empty();
- Hits = hits ?? Array.Empty();
- }
-
- public IReadOnlyList Projectiles { get; }
-
- public IReadOnlyList Hits { get; }
- }
-
- public sealed class AetheriaRuntimeYmirProjectileHit
- {
- public AetheriaRuntimeProjectileCommit Projectile { get; set; } = new AetheriaRuntimeProjectileCommit();
-
- public string ProjectileBodyId { get; set; } = "";
-
- public int TargetEntityIndex { get; set; }
-
- public string TargetBodyId { get; set; } = "";
-
- public double PointX { get; set; }
-
- public double PointZ { get; set; }
-
- public double NormalX { get; set; }
-
- public double NormalZ { get; set; }
- }
-}