Move projectile physics behind daemon Ymir authority
This commit is contained in:
parent
88e77eecf0
commit
fe0c193724
12 changed files with 424 additions and 404 deletions
|
|
@ -9,5 +9,6 @@
|
|||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Aetheria.State.Daemon\Aetheria.State.Daemon.csproj" />
|
||||
<ProjectReference Include="..\..\Ymir\src\Ymir.Core\Ymir.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -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>(T expected, T actual, string message)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -9,5 +9,6 @@
|
|||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Aetheria.State\Aetheria.State.csproj" />
|
||||
<ProjectReference Include="..\..\Ymir\src\Ymir.Core\Ymir.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
120
Aetheria.State.Daemon/AetheriaYmirProjectilePhysics.cs
Normal file
120
Aetheria.State.Daemon/AetheriaYmirProjectilePhysics.cs
Normal file
|
|
@ -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<AetheriaRuntimeEntitySnapshotCommit> entities,
|
||||
double deltaSeconds)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zone);
|
||||
if (deltaSeconds <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(deltaSeconds));
|
||||
|
||||
var entityByBodyId = (entities ?? Array.Empty<AetheriaRuntimeEntitySnapshotCommit>())
|
||||
.Where(entity => entity is { IsActive: true })
|
||||
.ToDictionary(entity => EntityBodyId(entity.EntityIndex), StringComparer.Ordinal);
|
||||
var survivors = new List<AetheriaRuntimeProjectileCommit>();
|
||||
var hits = new List<AetheriaRuntimeProjectileHit>();
|
||||
|
||||
foreach (var projectile in zone.Projectiles ?? Array.Empty<AetheriaRuntimeProjectileCommit>())
|
||||
{
|
||||
if (projectile is not { Active: true })
|
||||
continue;
|
||||
|
||||
var projectileBodyId = ProjectileBodyId(projectile.ProjectileId);
|
||||
var bodies = new List<PhysicsBody>
|
||||
{
|
||||
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<RadialField>())));
|
||||
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;
|
||||
}
|
||||
|
|
@ -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<AetheriaRuntimeDaemonTickResult> TickAsync(
|
||||
AetheriaStateNode node,
|
||||
AetheriaDaemonHostOptions options,
|
||||
IAetheriaRuntimeProjectilePhysics projectilePhysics,
|
||||
AetheriaRuntimeDaemonFrameDocument? currentFrame,
|
||||
bool buildPublications)
|
||||
{
|
||||
|
|
@ -181,6 +184,7 @@ static async Task<AetheriaRuntimeDaemonTickResult> 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<AetheriaRuntimeDaemonTickResult> 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<string>())
|
||||
.Concat(new[] { "aetheria.daemon.abstract_combat_kernel.v1" })
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
result.Frame.CumulativeImportedFactIds =
|
||||
currentFrame?.CumulativeImportedFactIds ?? Array.Empty<string>();
|
||||
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<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["view.pilot.excludeUnityLayers"] = "14"
|
||||
}))
|
||||
.ToArray(),
|
||||
new Dictionary<string, string>(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<string> 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")
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<AetheriaRuntimeEntitySnapshotCommit> entities,
|
||||
double deltaSeconds)
|
||||
{
|
||||
return new AetheriaRuntimeProjectileStep(
|
||||
zone.Projectiles,
|
||||
Array.Empty<AetheriaRuntimeProjectileHit>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AetheriaRuntimeZoneSnapshotCommit>())
|
||||
{
|
||||
|
|
@ -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<AetheriaRuntimeEntitySnapshotCommit> 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<AetheriaRuntimeProjectileCommit>(),
|
||||
Array.Empty<AetheriaRuntimeProjectileHit>())
|
||||
: 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<int, AetheriaRuntimeEntitySnapshotCommit> entities,
|
||||
double deltaSeconds)
|
||||
{
|
||||
var active = new List<AetheriaRuntimeProjectileCommit>();
|
||||
foreach (var projectile in zone.Projectiles ?? Array.Empty<AetheriaRuntimeProjectileCommit>())
|
||||
{
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<AetheriaRuntimeEntitySnapshotCommit> 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<AetheriaRuntimeEntitySnapshotCommit> entities,
|
||||
double deltaSeconds)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Aetheria projectile simulation requires an injected authoritative physics owner.");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AetheriaRuntimeProjectileStep
|
||||
{
|
||||
public AetheriaRuntimeProjectileStep(
|
||||
IReadOnlyList<AetheriaRuntimeProjectileCommit> projectiles,
|
||||
IReadOnlyList<AetheriaRuntimeProjectileHit> hits)
|
||||
{
|
||||
Projectiles = projectiles ?? Array.Empty<AetheriaRuntimeProjectileCommit>();
|
||||
Hits = hits ?? Array.Empty<AetheriaRuntimeProjectileHit>();
|
||||
}
|
||||
|
||||
public IReadOnlyList<AetheriaRuntimeProjectileCommit> Projectiles { get; }
|
||||
|
||||
public IReadOnlyList<AetheriaRuntimeProjectileHit> 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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 5437add74c5d2fb45b81299b41aa999d
|
||||
|
|
@ -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<AetheriaRuntimeEntitySnapshotCommit> entities,
|
||||
double deltaSeconds)
|
||||
{
|
||||
if (zone == null || deltaSeconds <= 0)
|
||||
{
|
||||
return new AetheriaRuntimeYmirProjectileStep(
|
||||
zone?.Projectiles ?? Array.Empty<AetheriaRuntimeProjectileCommit>(),
|
||||
Array.Empty<AetheriaRuntimeYmirProjectileHit>());
|
||||
}
|
||||
|
||||
var entityByIndex = (entities ?? Array.Empty<AetheriaRuntimeEntitySnapshotCommit>())
|
||||
.Where(entity => entity != null)
|
||||
.ToDictionary(entity => entity.EntityIndex);
|
||||
var activeProjectiles = new List<AetheriaRuntimeProjectileCommit>();
|
||||
var hits = new List<AetheriaRuntimeYmirProjectileHit>();
|
||||
|
||||
foreach (var projectile in zone.Projectiles ?? Array.Empty<AetheriaRuntimeProjectileCommit>())
|
||||
{
|
||||
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<AetheriaRuntimeProjectileCommit> projectiles,
|
||||
IReadOnlyList<AetheriaRuntimeYmirProjectileHit> hits)
|
||||
{
|
||||
Projectiles = projectiles ?? Array.Empty<AetheriaRuntimeProjectileCommit>();
|
||||
Hits = hits ?? Array.Empty<AetheriaRuntimeYmirProjectileHit>();
|
||||
}
|
||||
|
||||
public IReadOnlyList<AetheriaRuntimeProjectileCommit> Projectiles { get; }
|
||||
|
||||
public IReadOnlyList<AetheriaRuntimeYmirProjectileHit> 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; }
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue